pack

tcmts.cli.pack.pack · #xft26uo3 · term · runs on the server

uses node:path

import type { Codebase } from "#codebase/tcmts/cli/codebase";
import { exportFiles, fileContent, fileName, filesIn } from "#codebase/tcmts/cli/files";
import { exportsOf, forPackage, fromSiblings, provide } from "#codebase/tcmts/cli/pack";
import type { PackOptions } from "#codebase/tcmts/cli/pack";
import { exportId } from "#codebase/tcmts/core/hash";
import { createProgram } from "#codebase/tcmts/core/program";
import { SERVER_HOST_MODULE } from "#codebase/tcmts/core/types";
import { externalsOf, render } from "#codebase/tcmts/loader";
import type { DefSource } from "#codebase/tcmts/loader";
import { npmAlias, parseModule } from "#codebase/tcmts/loader/packages";
import fs from "node:fs";
import path from "node:path";
import { ScriptTarget as ts_ScriptTarget } from "typescript/ast";
import { ModuleKind as ts_ModuleKind, ModuleResolutionKind as ts_ModuleResolutionKind } from "typescript/sync";

export function pack(
  cb: Codebase,
  opts: PackOptions,
): { dir: string; modules: number; dependencies: Record<string, string> } {
  const subpaths = new Map<string, string[]>();
  for (const e of opts.entries)
    subpaths.set(e.subpath, [...(subpaths.get(e.subpath) ?? []), e.target]);
  const names = opts.names ?? cb.store.names();
  const entries = [...subpaths].map(([subpath, targets]) => ({
    subpath,
    exports: exportsOf(names, targets),
  }));
  const valueAt = (target: string) => {
    if (!opts.names) return cb.lookupOne(target, "value");
    const hs = names.filter((n) => n.path === target && n.kind !== "type").map((n) => n.hash);
    if (hs.length !== 1)
      throw new Error(hs.length ? `'${target}' is conflicted` : `'${target}' isn't a value`);
    return hs[0]!;
  };
  const bins = opts.bins.map((b) => ({ ...b, hash: valueAt(b.target) }));
  const roots = [
    ...entries.flatMap((e) => e.exports.map((x) => x.hash)),
    ...bins.map((b) => b.hash),
  ];
  // every component the code needs, to compile it; those it carries are the ones other packages don't provide
  const comps = [...cb.closureComponents(roots)];
  const { kept, provided, links } = provide(cb, roots, opts.siblings ?? []);
  // generated without source maps: the published modules are the code
  const base = cb.source();
  const src: DefSource = { get: base.get, members: base.members, nameOf: base.nameOf };

  // the package's dependencies: every package the code imports, at its locked version
  const modules = new Set<string>();
  for (const c of kept)
    for (const e of externalsOf(src.members(c)))
      if (e.module.startsWith("npm:")) modules.add(e.module);
  const byName = new Map<string, string[]>();
  for (const m of modules) {
    const { name } = parseModule(m);
    byName.set(name, [...(byName.get(name) ?? []), m]);
  }
  const dependencies: Record<string, string> = { ...links };
  const aliases = new Map<string, string>();
  for (const [name, ms] of byName) {
    for (const m of ms) {
      const { version: range } = parseModule(m);
      const version = cb.store.lockedVersion(name, range);
      if (!version)
        throw new Error(
          `${name} ${range} isn't locked; run the code once (tcm check or tcm run) so tcm installs it`,
        );
      // DefinitelyTyped types where the package has none: the declarations refer to them
      const typesName = `@types/${name.replace(/^@/, "").replace("/", "__")}`;
      const types = cb.store.lockedVersion(typesName, range);
      if (ms.length === 1) {
        dependencies[name] = version;
        if (types) dependencies[typesName] = types;
        aliases.set(npmAlias(m), name);
      } else {
        dependencies[npmAlias(m)] = `npm:${name}@${version}`;
        if (types) dependencies[`@types/${npmAlias(m)}`] = `npm:${typesName}@${types}`;
      }
    }
  }
  for (const w of opts.with) {
    const at = w.lastIndexOf("@");
    const [name, version] = at > 0 ? [w.slice(0, at), w.slice(at + 1)] : [w, undefined];
    const locked = cb.store
      .lockedPackages()
      .filter((l) => l.name === name)
      .at(-1)?.version;
    dependencies[name] = version ?? locked ?? "*";
  }

  // the closure as TypeScript, next to the cache so the pinned packages resolve; compiled to JavaScript
  // with declarations
  const dir = path.resolve(cb.lay.root, opts.out);
  // the output is replaced, so it must be empty or an earlier pack: never the codebase, or files of yours
  const earlier =
    fs.existsSync(path.join(dir, "modules")) && fs.existsSync(path.join(dir, "package.json"));
  if (
    fs.existsSync(dir) &&
    fs.readdirSync(dir).length &&
    (!earlier || !path.relative(dir, cb.lay.root).startsWith(".."))
  ) {
    throw new Error(
      `--out ${opts.out}: ${dir} isn't empty or an earlier tcm pack; pick another directory`,
    );
  }
  const work = fs.mkdtempSync(path.join(cb.lay.cacheDir, "_pack-"));
  try {
    const files: string[] = [];
    let server = false;
    for (const c of comps) {
      const members = src.members(c);
      const text = render(members, src, cb.lay, { style: "cache", docs: true }).text;
      // (by what it imports, not its text: code mentioning the file's name isn't code using it)
      if (
        kept.has(c) &&
        members.some((m) => m.spans.some((s) => s.ext?.module === SERVER_HOST_MODULE))
      )
        server = true;
      const file = path.join(work, `${c}${members.some((m) => m.jsx) ? ".tsx" : ".ts"}`);
      fs.writeFileSync(file, text);
      files.push(file);
    }
    fs.rmSync(dir, { recursive: true, force: true });
    const outModules = path.join(dir, "modules");
    const ownFiles = ["tcm-host.js", "tcm-host.d.ts"];
    if (server) {
      for (const f of ownFiles)
        fs.copyFileSync(path.join(cb.lay.cacheDir, f), path.join(work, f));
    }
    const { program } = createProgram(files, {
      ...cb.recheckOptions(undefined),
      noEmit: false,
      declaration: true,
      outDir: outModules,
      rootDir: work,
      rewriteRelativeImportExtensions: true,
      allowImportingTsExtensions: false,
      sourceMap: false,
      noEmitOnError: false,
      module: ts_ModuleKind.ESNext,
      moduleResolution: ts_ModuleResolutionKind.Bundler,
      target: ts_ScriptTarget.ES2022,
      skipLibCheck: true,
    });
    const emitted = program.emitToString();
    for (const [file, { text }] of emitted.outputFiles) {
      // what another package provides is imported from it, not shipped again
      if (!kept.has(path.basename(file).replace(/\.(d\.ts|js)$/, ""))) continue;
      fs.mkdirSync(path.dirname(file), { recursive: true });
      fs.writeFileSync(file, fromSiblings(forPackage(text, aliases), kept, provided));
    }
    const missing = files
      .filter((f) => kept.has(path.basename(f).replace(/\.tsx?$/, "")))
      .filter(
        (f) =>
          !fs.existsSync(path.join(outModules, path.basename(f).replace(/\.tsx?$/, ".js"))),
      );
    if (missing.length) {
      const why = emitted.diagnostics
        .map((d) => d.text)
        .slice(0, 3)
        .join("\n");
      throw new Error(`couldn't compile ${missing.length} module(s)${why ? `:\n${why}` : ""}`);
    }
    if (server)
      for (const f of ownFiles) fs.copyFileSync(path.join(work, f), path.join(outModules, f));
  } finally {
    fs.rmSync(work, { recursive: true, force: true });
  }

  // entry points: each definition exported under its name, from its module
  const exportsField: Record<string, { types: string; default: string }> = {};
  for (const e of entries) {
    const at = e.subpath === "." ? "" : e.subpath.replace(/^\.\//, "");
    const entryDir = path.join(dir, at);
    const rel = path.relative(entryDir, path.join(dir, "modules")).split(path.sep).join("/");
    const from = rel.startsWith(".") ? rel : `./${rel}`;
    const js: string[] = ["// generated by tcm pack"];
    const dts: string[] = ["// generated by tcm pack"];
    for (const x of e.exports) {
      const comp = cb.store.getDef(x.hash)!.componentHash;
      const line = (type: boolean) =>
        `export ${type ? "type " : ""}{ ${exportId(x.hash)} as ${x.name} } from "${from}/${comp}.js";`;
      if (x.kind !== "type") js.push(line(false));
      dts.push(line(x.kind === "type"));
    }
    fs.mkdirSync(entryDir, { recursive: true });
    fs.writeFileSync(path.join(entryDir, "index.js"), js.join("\n") + "\n");
    fs.writeFileSync(path.join(entryDir, "index.d.ts"), dts.join("\n") + "\n");
    exportsField[e.subpath === "." ? "." : `./${at}`] = {
      types: `./${at ? `${at}/` : ""}index.d.ts`,
      default: `./${at ? `${at}/` : ""}index.js`,
    };
  }
  // bins: the definition run as `tcm run` runs it (called with the arguments); a number it returns is the exit code
  const binField: Record<string, string> = {};
  for (const b of bins) {
    const comp = cb.store.getDef(b.hash)!.componentHash;
    fs.mkdirSync(path.join(dir, "bin"), { recursive: true });
    fs.writeFileSync(
      path.join(dir, "bin", `${b.command}.js`),
      `#!/usr/bin/env node\n// generated by tcm pack: runs ${b.target}\nimport { ${exportId(b.hash)} as main } from "../modules/${comp}.js";\n` +
        `const result = typeof main === "function" ? await main(...process.argv.slice(2)) : main;\n` +
        `if (typeof result === "number") process.exitCode = result;\nelse if (result !== undefined) console.log(result);\n`,
      { mode: 0o755 },
    );
    binField[b.command] = `bin/${b.command}.js`;
  }
  if (opts.changelog) fs.writeFileSync(path.join(dir, "CHANGELOG.md"), opts.changelog);
  for (const f of opts.files) {
    const from = path.resolve(cb.lay.root, f);
    // or one of the codebase's files, or a namespace of them (pages, images), written out under its last name
    if (!fs.existsSync(from) && filesIn(cb, f).size) {
      const leaf = f.slice(f.lastIndexOf(".") + 1);
      const file = cb.store
        .hashesAt(f)
        .map((h) => cb.store.getDef(h)!)
        .find((d) => d.kind === "file");
      if (file)
        fs.writeFileSync(
          path.join(dir, fileName(leaf, file)),
          fileContent(cb, file, f.slice(0, Math.max(0, f.lastIndexOf(".")))),
        );
      exportFiles(cb, f, path.join(dir, leaf), false);
      continue;
    }
    if (!fs.existsSync(from))
      throw new Error(`--files: ${f} doesn't exist, and isn't a namespace with files in it`);
    const rel = path.relative(cb.lay.root, from);
    fs.cpSync(from, path.join(dir, rel.startsWith("..") ? path.basename(from) : rel), {
      recursive: true,
    });
  }
  // what package.json starts from (a VS Code extension's contributes), without what tcm works out itself
  const {
    dependencies: givenDeps,
    devDependencies: _dd,
    peerDependencies: _p,
    scripts: _s,
    ...given
  } = opts.packageJson ?? {};
  // of the dependencies it gives, those shipped with the package (`file:./plugin`) stay: tcm can't see them
  const shipped = Object.entries((givenDeps ?? {}) as Record<string, string>).filter(([, v]) =>
    v.startsWith("file:"),
  );
  const entry = (exportsField["."] as { default?: string } | undefined)?.default;
  const pkg = {
    ...given,
    name: typeof given.name === "string" ? given.name : opts.name,
    version: opts.version,
    ...(opts.description && !given.description ? { description: opts.description } : {}),
    type: "module",
    ...(Object.keys(binField).length ? { bin: binField } : {}),
    ...(Object.keys(exportsField).length ? { exports: exportsField } : {}),
    // (an editor loads an extension from `main`)
    ...(given.main !== undefined && entry ? { main: entry } : {}),
    engines: { ...(given.engines as Record<string, string> | undefined), node: ">=22.18" },
    dependencies: Object.fromEntries([...Object.entries(dependencies), ...shipped].sort()),
  };
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
  return { dir, modules: kept.size, dependencies: pkg.dependencies };
}

uses

used by