@theholocron/astromech 5.0.0-alpha.147 → 5.0.0-alpha.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
- import { r as normalizeTaskEntry, t as loadTasksConfig } from "./load-ytspboBj.mjs";
2
- import { spawnSync } from "node:child_process";
1
+ import { r as normalizeTaskEntry, t as loadTasksConfig } from "./load-ToVjaKPr.mjs";
3
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
3
  import { join, relative } from "node:path";
5
4
  import { stringifyJson } from "@theholocron/json-utils";
5
+ import { dependencyNames, detectPackageManager, readPackageJsonSync } from "@theholocron/pkg-utils";
6
+ import { spawnSync } from "node:child_process";
7
+ import { findExecutableSync } from "@theholocron/fs-utils";
6
8
  //#region src/templates/codecov/codecov.yml
7
9
  var codecov_default = "codecov:\n require_ci_to_pass: true\n\ncoverage:\n precision: 2\n round: down\n status:\n project:\n default:\n target: auto\n threshold: 2%\n # Infra-only PRs (dep bumps, config, lockfile) touch no coverable code,\n # so codecov produces no report to compare. Pass instead of blocking\n # forever on a required check that will never post.\n if_not_found: success\n patch:\n default:\n target: 80%\n if_not_found: success\n\ncomment:\n layout: \"reach,diff,flags,components\"\n behavior: default\n require_changes: true\n\ncomponent_management:\n default_rules:\n statuses:\n - type: patch\n target: 80%\n if_not_found: success\n individual_components:\n";
8
10
  //#endregion
@@ -608,6 +610,27 @@ function reusableTemplates() {
608
610
  }
609
611
  //#endregion
610
612
  //#region src/registry.ts
613
+ /**
614
+ * The task registry — how each task runs *locally*, without GitHub
615
+ * Actions. `holocron run <task>` and `holocron run ci` resolve against this;
616
+ * adding a task here gives every repo that task.
617
+ *
618
+ * Keyed identically to the workflow templates — a task IS a workflow. This
619
+ * is the canonical vocabulary table (epic #672, D11): every other artifact
620
+ * (`KNOWN_TASKS`, `thin-callers.ts`'s `KNOWN_WORKFLOWS`/`WORKFLOW_CHECK_CONTEXTS`,
621
+ * `CI_ORDER`) derives from these keys rather than hand-duplicating them —
622
+ * including the future GitHub App (#679), which imports this same table for
623
+ * config-schema validation instead of reimplementing its own copy.
624
+ *
625
+ * Task names are an intent-facing vocabulary (`verification.*`,
626
+ * `sourceQuality.*`, `security.*`, `delivery.*`, `platform.*`,
627
+ * `knowledge.*`), not tool names — `eslint`/`vitest`/`tsdown`/… stay
628
+ * internal to this file and `theholocron/configs`. See
629
+ * `docs/wiki/specifications/tech-vocabulary-rename.spec.md` (#675) for the full mapping and
630
+ * the reasoning behind each namespace and decomposition.
631
+ *
632
+ * Spec: `docs/wiki/specifications/tech-astromech-task-runner.spec.md` (epic #581).
633
+ */
611
634
  const TASKS = {
612
635
  "verification.unitTests": {
613
636
  local: {
@@ -1022,6 +1045,15 @@ function ensureTurboDependency(packageJson, workspaceYaml) {
1022
1045
  //#endregion
1023
1046
  //#region src/tasks/run/linters.ts
1024
1047
  /**
1048
+ * The linter registry — one list of linter names (from `config.tasks`'
1049
+ * `{ name: "lint", linters: [...] }`, or auto-detected) maps to both the
1050
+ * CI super-linter `VALIDATE_*` env and the native local run. This table is
1051
+ * the single source of truth for that mapping; {@link superLinterConfig}
1052
+ * (CI) and the `holocron run lint` aggregate (local) both read it.
1053
+ *
1054
+ * Spec: `docs/wiki/specifications/tech-astromech-task-runner.spec.md` "Lint parity".
1055
+ */
1056
+ /**
1025
1057
  * Known linters, in execution order. `always` entries are the current
1026
1058
  * hard-coded super-linter baseline; `prettier` is always-on because the org
1027
1059
  * applies it universally (super-linter only lints files that exist).
@@ -1165,7 +1197,79 @@ function resolveLinters(opts) {
1165
1197
  }));
1166
1198
  }
1167
1199
  //#endregion
1168
- //#region src/tasks/run/resolver.ts
1200
+ //#region src/tasks/run/resolve-bin.ts
1201
+ /** `<cwd>/node_modules/.bin/<tool>` when it exists, else the bare `tool` name (left to `PATH`). */
1202
+ function resolveBin(cwd, tool, fileExists) {
1203
+ const local = join(cwd, "node_modules", ".bin", tool);
1204
+ return fileExists(local) ? local : tool;
1205
+ }
1206
+ //#endregion
1207
+ //#region src/tasks/run/resolve-runner.ts
1208
+ const mkRunner = (tool, args = []) => ({
1209
+ tool,
1210
+ args
1211
+ });
1212
+ /**
1213
+ * Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
1214
+ * files, falling back (#750) to each candidate's `tool` name against
1215
+ * `package.json`'s own declared dependencies when neither matches by
1216
+ * filename. Only called for `tool` / `detect` runners — the caller handles
1217
+ * `command` runners itself, so `local.detect` is present whenever
1218
+ * `local.tool` is not.
1219
+ *
1220
+ * The dependency fallback matters once a Bucket A migration (#680) deletes
1221
+ * a build tool's own config file (`tsdown.config.ts`, `vite.config.ts`, …):
1222
+ * that file was the *only* signal `detect[]` had to offer, so removing it
1223
+ * used to make `delivery.build` report "no task for this repo" and silently
1224
+ * skip the build, even though the tool is still very much in use — a
1225
+ * package can't actually build with `tsdown` without it in
1226
+ * `dependencies`/`devDependencies`, so that's a signal at least as reliable
1227
+ * as the config file, and one deleting the file doesn't remove.
1228
+ *
1229
+ * **Per-candidate, not two full passes** (#750 follow-up — the first
1230
+ * shipped version got this wrong): `delivery.build`'s own `detect[]` ends
1231
+ * with `{ when: /^tsconfig\.json$/, tool: "tsc" }` as its last-resort
1232
+ * fallback, and `tsconfig.json` exists in essentially every package
1233
+ * regardless of build tool. A "check every candidate's filename, *then*
1234
+ * check every candidate's dependency" two-pass design would let that
1235
+ * always-present fallback file win over a real `tsdown`/`vite`/`rollup`
1236
+ * dependency match, the moment the earlier candidates' own config files are
1237
+ * gone — exactly backwards. Checking `(file match) || (dependency match)`
1238
+ * together for each candidate before moving to the next preserves the
1239
+ * intended precedence: a real signal for an *earlier* candidate, by either
1240
+ * means, still wins over a later candidate's mere filename presence.
1241
+ */
1242
+ function resolveRunner(local, cwd, listDir, readFile) {
1243
+ if (local.tool) return mkRunner(local.tool, local.args);
1244
+ let files;
1245
+ try {
1246
+ files = listDir(cwd);
1247
+ } catch {
1248
+ files = [];
1249
+ }
1250
+ const deps = dependencyNames(readPackageJsonSync(cwd, { readFile }));
1251
+ for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f)) || deps.has(candidate.tool)) return mkRunner(candidate.tool, candidate.args);
1252
+ }
1253
+ //#endregion
1254
+ //#region src/tasks/run/resolve-export-entry.ts
1255
+ /**
1256
+ * Unwrap a `package.json#exports` subpath entry down to its file path —
1257
+ * either a bare string, or an object with condition keys (`import`,
1258
+ * `default`, `require`, …) that may themselves nest one level (`{ import: {
1259
+ * types, default } }`, the shape every `@theholocron/*-config` package
1260
+ * actually uses). Prefers `import` (this org ships ESM), then `default`,
1261
+ * then `require` as a last resort.
1262
+ */
1263
+ function resolveExportEntry(entry) {
1264
+ if (typeof entry === "string") return entry;
1265
+ if (entry === null || typeof entry !== "object") return void 0;
1266
+ const conditions = entry;
1267
+ const value = conditions.import ?? conditions.default ?? conditions.require;
1268
+ if (typeof value === "string") return value;
1269
+ if (typeof value === "object" && value !== null) return resolveExportEntry(value);
1270
+ }
1271
+ //#endregion
1272
+ //#region src/tasks/run/tool-configs.ts
1169
1273
  /**
1170
1274
  * `library` is every mapped tool's org-wide default variant today — every
1171
1275
  * currently-migrated repo (`holocron`, `clients`, `utils`, `configs`,
@@ -1242,7 +1346,42 @@ const LOCAL_CONFIG_FILES = {
1242
1346
  "commitlint.config.cjs"
1243
1347
  ]
1244
1348
  };
1245
- new Set(Object.keys(TOOL_CONFIGS));
1349
+ //#endregion
1350
+ //#region src/tasks/run/resolve-tool-config.ts
1351
+ /**
1352
+ * Resolves the absolute path to a Bucket A tool's shared `@theholocron/*-config`
1353
+ * entry point, installed in the consuming repo's own `node_modules` — and the
1354
+ * CLI flag(s) to hand it via (`--config`, `--extends`, …). `run.ts` splices the
1355
+ * result into the command it builds for a tool/detect runner or a linter-group
1356
+ * entry, right after the tool's own args.
1357
+ *
1358
+ * Every mapped package's resolved entry point already carries a ready-to-use
1359
+ * default export (a plain re-export for `prettier`/`commitlint`; an invoked,
1360
+ * zero-arg preset for `eslint`/`vitest`/`tsdown` — verified end-to-end against
1361
+ * each tool's real `--config`/`--extends` loader, not just the file's shape).
1362
+ * A repo doesn't need this package installed at all — every lookup degrades to
1363
+ * `[]` (no flag added, the tool falls back to its own auto-discovery of a
1364
+ * local file, unchanged from today) rather than throwing.
1365
+ *
1366
+ * **Local file wins.** A `<tool>.config.*` sitting in `cwd` (whether it's
1367
+ * pure boilerplate or has real per-package customization on top of the
1368
+ * shared bundle, e.g. `mergeConfig(base, { test: { coverage: { exclude }
1369
+ * } })`) always skips the splice, even when the shared package is also
1370
+ * installed — an explicit `--config <shared path>` flag beats a tool's own
1371
+ * auto-discovery every time, so splicing it unconditionally would silently
1372
+ * discard that customization the file is still sitting right there on disk.
1373
+ * The shared bundle is only ever the fallback for a package with *no* local
1374
+ * file at all — that's still the "delete the file to go fully shared" path
1375
+ * this was designed for (#676); it just isn't unconditional (#749).
1376
+ *
1377
+ * `semantic-release`, `devmoji`, `editorconfig-checker`, and `knip` are
1378
+ * deliberately absent — `semantic-release-config`'s `defineConfig()` needs
1379
+ * real per-repo data (branches, npm options) a static `--extends <path>` can't
1380
+ * carry; `devmoji` runs through a git hook template, not `holocron run
1381
+ * <task>`; `editorconfig-checker` has no shared-config package yet; `knip` is
1382
+ * repo-specific by nature, never a shared-config candidate. See
1383
+ * `.notes/tech-config-resolution.spec.md` (#676).
1384
+ */
1246
1385
  /**
1247
1386
  * `<flag> <absolute path>` for `tool`, resolved against `cwd`'s
1248
1387
  * `node_modules` — or `[]` when the tool isn't mapped, `cwd` already has its
@@ -1255,9 +1394,9 @@ new Set(Object.keys(TOOL_CONFIGS));
1255
1394
  function resolveToolConfig(tool, cwd, deps) {
1256
1395
  const spec = TOOL_CONFIGS[tool];
1257
1396
  if (!spec) return [];
1258
- if ((LOCAL_CONFIG_FILES[tool] ?? []).some((name) => deps.fileExists(joinPath(cwd, name)))) return [];
1259
- const pkgDir = joinPath(cwd, "node_modules", ...spec.package.split("/"));
1260
- const pkgJsonPath = joinPath(pkgDir, "package.json");
1397
+ if ((LOCAL_CONFIG_FILES[tool] ?? []).some((name) => deps.fileExists(join(cwd, name)))) return [];
1398
+ const pkgDir = join(cwd, "node_modules", ...spec.package.split("/"));
1399
+ const pkgJsonPath = join(pkgDir, "package.json");
1261
1400
  if (!deps.fileExists(pkgJsonPath)) return [];
1262
1401
  let pkg;
1263
1402
  try {
@@ -1267,36 +1406,21 @@ function resolveToolConfig(tool, cwd, deps) {
1267
1406
  }
1268
1407
  const relative = resolveExportEntry(pkg.exports?.[spec.exportPath]);
1269
1408
  if (!relative) return [];
1270
- const absolute = joinPath(pkgDir, relative.replace(/^\.\//, ""));
1409
+ const absolute = join(pkgDir, relative);
1271
1410
  if (!deps.fileExists(absolute)) return [];
1272
1411
  return [...spec.flag, absolute];
1273
1412
  }
1274
- /**
1275
- * Unwrap a `package.json#exports` subpath entry down to its file path —
1276
- * either a bare string, or an object with condition keys (`import`,
1277
- * `default`, `require`, …) that may themselves nest one level (`{ import: {
1278
- * types, default } }`, the shape every `@theholocron/*-config` package
1279
- * actually uses). Prefers `import` (this org ships ESM), then `default`,
1280
- * then `require` as a last resort.
1281
- */
1282
- function resolveExportEntry(entry) {
1283
- if (typeof entry === "string") return entry;
1284
- if (entry === null || typeof entry !== "object") return void 0;
1285
- const conditions = entry;
1286
- const value = conditions.import ?? conditions.default ?? conditions.require;
1287
- if (typeof value === "string") return value;
1288
- if (typeof value === "object" && value !== null) return resolveExportEntry(value);
1289
- }
1290
- /**
1291
- * A minimal, POSIX-safe `path.join` — avoids importing `node:path` just for
1292
- * this. Every path in play here is either already POSIX (a `package.json`
1293
- * `exports` value, always forward-slashed regardless of platform) or built
1294
- * from platform-neutral segments (`cwd`, `"node_modules"`, a scoped package
1295
- * name split on `/`), so a manual join is safe and keeps this module
1296
- * dependency-free like the rest of `run.ts`'s helpers.
1297
- */
1298
- function joinPath(...segments) {
1299
- return segments.map((s, i) => i === 0 ? s.replace(/\/+$/, "") : s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/");
1413
+ //#endregion
1414
+ //#region src/tasks/run/turbo-defines-task.ts
1415
+ /** `true` when `<cwd>/turbo.json` declares `task` (under `tasks`, or turbo 1.x's `pipeline`). */
1416
+ function turboDefinesTask(cwd, task, readFile, fileExists) {
1417
+ if (!fileExists(join(cwd, "turbo.json"))) return false;
1418
+ try {
1419
+ const turbo = JSON.parse(readFile(join(cwd, "turbo.json")));
1420
+ return Boolean(turbo.tasks?.[task] ?? turbo.pipeline?.[task]);
1421
+ } catch {
1422
+ return false;
1423
+ }
1300
1424
  }
1301
1425
  //#endregion
1302
1426
  //#region src/tasks/run/run.ts
@@ -1405,8 +1529,11 @@ function runTask(input) {
1405
1529
  ];
1406
1530
  return run(resolveBin(cwd, "turbo", fileExists), args);
1407
1531
  }
1408
- const script = packageJsonScript(cwd, task, readFile, fileExists);
1409
- if (script && !/^holocron run\b/.test(script.trim())) return run(packageManager(cwd, readFile, fileExists), [
1532
+ const script = readPackageJsonSync(cwd, { readFile })?.scripts?.[task];
1533
+ if (script && !/^holocron run\b/.test(script.trim())) return run(detectPackageManager(cwd, {
1534
+ readFile,
1535
+ fileExists
1536
+ }), [
1410
1537
  "run",
1411
1538
  task,
1412
1539
  ...passthrough.length ? ["--", ...passthrough] : []
@@ -1628,92 +1755,6 @@ function runLinterGroup(input, names, passthrough) {
1628
1755
  command
1629
1756
  };
1630
1757
  }
1631
- const mkRunner = (tool, args = []) => ({
1632
- tool,
1633
- args
1634
- });
1635
- /**
1636
- * Resolve `{ tool, args }` for a runner, applying `detect[]` against repo
1637
- * files, falling back (#750) to each candidate's `tool` name against
1638
- * `package.json`'s own declared dependencies when neither matches by
1639
- * filename. Only called for `tool` / `detect` runners — the caller handles
1640
- * `command` runners itself, so `local.detect` is present whenever
1641
- * `local.tool` is not.
1642
- *
1643
- * The dependency fallback matters once a Bucket A migration (#680) deletes
1644
- * a build tool's own config file (`tsdown.config.ts`, `vite.config.ts`, …):
1645
- * that file was the *only* signal `detect[]` had to offer, so removing it
1646
- * used to make `delivery.build` report "no task for this repo" and silently
1647
- * skip the build, even though the tool is still very much in use — a
1648
- * package can't actually build with `tsdown` without it in
1649
- * `dependencies`/`devDependencies`, so that's a signal at least as reliable
1650
- * as the config file, and one deleting the file doesn't remove.
1651
- *
1652
- * **Per-candidate, not two full passes** (#750 follow-up — the first
1653
- * shipped version got this wrong): `delivery.build`'s own `detect[]` ends
1654
- * with `{ when: /^tsconfig\.json$/, tool: "tsc" }` as its last-resort
1655
- * fallback, and `tsconfig.json` exists in essentially every package
1656
- * regardless of build tool. A "check every candidate's filename, *then*
1657
- * check every candidate's dependency" two-pass design would let that
1658
- * always-present fallback file win over a real `tsdown`/`vite`/`rollup`
1659
- * dependency match, the moment the earlier candidates' own config files are
1660
- * gone — exactly backwards. Checking `(file match) || (dependency match)`
1661
- * together for each candidate before moving to the next preserves the
1662
- * intended precedence: a real signal for an *earlier* candidate, by either
1663
- * means, still wins over a later candidate's mere filename presence.
1664
- */
1665
- function resolveRunner(local, cwd, listDir, readFile) {
1666
- if (local.tool) return mkRunner(local.tool, local.args);
1667
- let files;
1668
- try {
1669
- files = listDir(cwd);
1670
- } catch {
1671
- files = [];
1672
- }
1673
- const deps = packageJsonDependencies(cwd, readFile);
1674
- for (const candidate of local.detect) if (files.some((f) => candidate.when.test(f)) || deps.has(candidate.tool)) return mkRunner(candidate.tool, candidate.args);
1675
- }
1676
- /** Every key from `package.json`'s `dependencies` + `devDependencies`, or an empty set on any read/parse failure. */
1677
- function packageJsonDependencies(cwd, readFile) {
1678
- try {
1679
- const pkg = JSON.parse(readFile(join(cwd, "package.json")));
1680
- return /* @__PURE__ */ new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
1681
- } catch {
1682
- return /* @__PURE__ */ new Set();
1683
- }
1684
- }
1685
- function resolveBin(cwd, tool, fileExists) {
1686
- const local = join(cwd, "node_modules", ".bin", tool);
1687
- return fileExists(local) ? local : tool;
1688
- }
1689
- function packageManager(cwd, readFile, fileExists) {
1690
- try {
1691
- const pkg = JSON.parse(readFile(join(cwd, "package.json")));
1692
- if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
1693
- } catch {}
1694
- if (fileExists(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1695
- if (fileExists(join(cwd, "bun.lockb"))) return "bun";
1696
- if (fileExists(join(cwd, "yarn.lock"))) return "yarn";
1697
- if (fileExists(join(cwd, "package-lock.json"))) return "npm";
1698
- return "pnpm";
1699
- }
1700
- function turboDefinesTask(cwd, task, readFile, fileExists) {
1701
- if (!fileExists(join(cwd, "turbo.json"))) return false;
1702
- try {
1703
- const turbo = JSON.parse(readFile(join(cwd, "turbo.json")));
1704
- return Boolean(turbo.tasks?.[task] ?? turbo.pipeline?.[task]);
1705
- } catch {
1706
- return false;
1707
- }
1708
- }
1709
- function packageJsonScript(cwd, task, readFile, fileExists) {
1710
- if (!fileExists(join(cwd, "package.json"))) return void 0;
1711
- try {
1712
- return JSON.parse(readFile(join(cwd, "package.json"))).scripts?.[task];
1713
- } catch {
1714
- return;
1715
- }
1716
- }
1717
1758
  //#endregion
1718
1759
  //#region src/tasks/ci.ts
1719
1760
  /**
@@ -1785,19 +1826,7 @@ function taskName(item) {
1785
1826
  return typeof item === "string" ? item : item.name;
1786
1827
  }
1787
1828
  //#endregion
1788
- //#region src/tasks/group.ts
1789
- /**
1790
- * `holocron run <namespace>` — run every configured task under one
1791
- * namespace prefix (`sourceQuality.*`, `verification.*`, …) in sequence.
1792
- * `Astromech.run()` falls back here whenever the given name isn't an exact
1793
- * `TASKS` entry but matches the prefix of one or more configured tasks —
1794
- * same "aggregate several real tasks into one report" shape `runCi` already
1795
- * established, scoped to one namespace instead of the merge-gating set.
1796
- *
1797
- * Every task in the namespace always runs — one task's failure never skips
1798
- * the rest, same as `runCi`'s own loop — so a single invocation surfaces
1799
- * every problem in the namespace at once instead of stopping at the first.
1800
- */
1829
+ //#region src/tasks/match-namespace.ts
1801
1830
  /** Every configured task whose name starts with `"<namespace>."`, in manifest declaration order. */
1802
1831
  function matchNamespace(config, namespace) {
1803
1832
  const prefix = `${namespace}.`;
@@ -1806,6 +1835,8 @@ function matchNamespace(config, namespace) {
1806
1835
  return names;
1807
1836
  }, []);
1808
1837
  }
1838
+ //#endregion
1839
+ //#region src/tasks/group.ts
1809
1840
  function runGroup(input) {
1810
1841
  const { print, namespace } = input;
1811
1842
  const names = matchNamespace(input.config, namespace);
@@ -1844,29 +1875,47 @@ function runGroup(input) {
1844
1875
  };
1845
1876
  }
1846
1877
  //#endregion
1847
- //#region src/astromech.ts
1848
- /**
1849
- * `createAstromech(options)` — the self-contained task runner.
1850
- * `@theholocron/cli` instantiates it once and delegates the `run` /
1851
- * `ci` / workflow-generation commands to it (like `@theholocron/observability`).
1852
- */
1853
- const noopLogger = {
1854
- debug() {},
1855
- warn() {}
1856
- };
1878
+ //#region src/utils/default-list-dirs.ts
1879
+ const defaultListDirs = (path) => readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1880
+ //#endregion
1881
+ //#region src/utils/default-read-json.ts
1882
+ const defaultReadJson = (path) => JSON.parse(readFileSync(path, "utf8"));
1883
+ //#endregion
1884
+ //#region src/utils/default-run.ts
1885
+ const defaultRun = (command, args, opts) => spawnSync(command, args, {
1886
+ encoding: "utf8",
1887
+ ...opts
1888
+ });
1889
+ //#endregion
1890
+ //#region src/utils/default-sleep.ts
1891
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1892
+ //#endregion
1893
+ //#region src/utils/exec.ts
1894
+ /** Runs `cmd` with the parent's stdio; a signal-killed or unspawnable process reports `-1`. */
1857
1895
  const realExec = (cmd, args, opts) => {
1858
1896
  return { exitCode: spawnSync(cmd, args, {
1859
1897
  cwd: opts.cwd,
1860
1898
  stdio: "inherit"
1861
1899
  }).status ?? -1 };
1862
1900
  };
1901
+ //#endregion
1902
+ //#region src/utils/look-path.ts
1863
1903
  /** `node_modules/.bin/<bin>`, else the first `PATH` entry that has it, else `null`. */
1864
- const realLookPath = (cwd, bin) => {
1865
- const local = join(cwd, "node_modules", ".bin", bin);
1866
- if (existsSync(local)) return local;
1867
- for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir && existsSync(join(dir, bin))) return join(dir, bin);
1868
- return null;
1904
+ const realLookPath = (cwd, bin) => findExecutableSync(bin, { path: join(cwd, "node_modules", ".bin") }) ?? findExecutableSync(bin) ?? null;
1905
+ //#endregion
1906
+ //#region src/utils/noop-logger.ts
1907
+ /** The `RunLogger` used when the caller injects none: drops every record. */
1908
+ const noopLogger = {
1909
+ debug() {},
1910
+ warn() {}
1869
1911
  };
1912
+ //#endregion
1913
+ //#region src/astromech.ts
1914
+ /**
1915
+ * `createAstromech(options)` — the self-contained task runner.
1916
+ * `@theholocron/cli` instantiates it once and delegates the `run` /
1917
+ * `ci` / workflow-generation commands to it (like `@theholocron/observability`).
1918
+ */
1870
1919
  function createAstromech(options) {
1871
1920
  const deps = {
1872
1921
  print: options.print ?? ((line) => console.log(line)),
@@ -2047,78 +2096,22 @@ function createTsconfig(options) {
2047
2096
  return stringifyJson(config);
2048
2097
  }
2049
2098
  //#endregion
2050
- //#region src/tasks/deploy-on-release.ts
2051
- /**
2052
- * Deploy workspace packages once a release has published (holocron#930).
2053
- *
2054
- * A package opts in with a `delivery.deploy` task in its own `holocron.config`
2055
- * whose `with.on` is `"release"`:
2056
- *
2057
- * ```ts
2058
- * // packages/<app>/holocron.config.ts
2059
- * tasks: [{ name: "delivery.deploy", with: { on: "release", channel: "alpha" } }];
2060
- * ```
2061
- *
2062
- * `channel` defaults to `main` (a stable release); set a prerelease identifier
2063
- * such as `alpha` to deploy those releases instead.
2064
- *
2065
- * The shared `delivery.publish` workflow runs this (`holocron deploy on-release`)
2066
- * as its own `deploy` job after the release job, so a deploy is a separate,
2067
- * re-runnable check rather than a step buried in the release log. Each package
2068
- * with such a task deploys when the release is on its `channel` and changes a file under its paths: the package itself plus its
2069
- * `workspace:*` dependencies ({@link defaultPaths}), or an explicit
2070
- * `with.paths`. It first waits for npm to serve the release's new versions of
2071
- * those dependencies ({@link waitForPublished}) — the registry can lag a version
2072
- * it has just accepted, and the deploy installs them — then runs
2073
- * `pnpm --filter <package> delivery.deploy`.
2074
- *
2075
- * The release is already published by then, so a failed deploy can't undo it;
2076
- * it fails this job instead, loudly and re-runnable on its own. A task is
2077
- * `skip`ped (not failed) only when it doesn't apply to this release.
2078
- *
2079
- * A `delivery.deploy` task with `on: "release"` generates no CI workflow.
2080
- */
2099
+ //#region src/tasks/deploy-on-release/constants.ts
2081
2100
  /** A stable release (no prerelease identifier) is on `main`, the default channel for a task. */
2082
2101
  const MAIN_CHANNEL = "main";
2083
2102
  const DEPLOY_TASK = "delivery.deploy";
2084
2103
  /** How long to wait for npm to serve the release's versions, and how often to ask. */
2085
2104
  const WAIT_TIMEOUT_MS = 3e5;
2086
2105
  const WAIT_INTERVAL_MS = 1e4;
2087
- function scanWorkspaceDir(dirPath, { readJson, listDirs }) {
2088
- let folders;
2089
- try {
2090
- folders = listDirs(dirPath);
2091
- } catch {
2092
- return [];
2093
- }
2094
- return folders.flatMap((folder) => {
2095
- try {
2096
- const manifest = readJson(join(dirPath, folder, "package.json"));
2097
- if (!manifest.name) return [];
2098
- return [{
2099
- name: manifest.name,
2100
- dir: join(dirPath, folder),
2101
- version: manifest.version,
2102
- dependencies: manifest.dependencies ?? {}
2103
- }];
2104
- } catch {
2105
- return [];
2106
- }
2107
- });
2108
- }
2109
- /**
2110
- * Every package under `<repoRoot>/packages/*` and `<repoRoot>/plugins/*`
2111
- * with a readable, named `package.json` — `holocron`'s own layout splits
2112
- * vendor plugin packages into `plugins/*`, sibling to `packages/*`.
2113
- */
2114
- function workspacePackages(repoRoot, tree) {
2115
- return [...scanWorkspaceDir(join(repoRoot, "packages"), tree), ...scanWorkspaceDir(join(repoRoot, "plugins"), tree)];
2116
- }
2106
+ //#endregion
2107
+ //#region src/tasks/deploy-on-release/workspace-dependencies.ts
2117
2108
  /** The `workspace:*` dependencies of `pkg`, as workspace packages (with their current version). */
2118
2109
  function workspaceDependencies(pkg, all) {
2119
2110
  const names = new Set(Object.entries(pkg.dependencies).filter(([, spec]) => String(spec).startsWith("workspace:")).map(([name]) => name));
2120
2111
  return all.filter((candidate) => names.has(candidate.name));
2121
2112
  }
2113
+ //#endregion
2114
+ //#region src/tasks/deploy-on-release/default-paths.ts
2122
2115
  /**
2123
2116
  * The paths a release must touch to redeploy `pkg` when its manifest doesn't
2124
2117
  * list them: its own folder plus the folder of every
@@ -2130,6 +2123,8 @@ function workspaceDependencies(pkg, all) {
2130
2123
  function defaultPaths(repoRoot, pkg, all) {
2131
2124
  return [pkg, ...workspaceDependencies(pkg, all)].map(({ dir }) => `${relative(repoRoot, dir)}/`);
2132
2125
  }
2126
+ //#endregion
2127
+ //#region src/tasks/deploy-on-release/deploy-targets.ts
2133
2128
  /**
2134
2129
  * The packages whose manifest declares a release-time `delivery.deploy`
2135
2130
  * (`with.on === "release"`), with the task's options resolved.
@@ -2137,7 +2132,7 @@ function defaultPaths(repoRoot, pkg, all) {
2137
2132
  function deployTargets(packages, manifests) {
2138
2133
  return packages.flatMap((pkg) => (manifests.get(pkg.dir)?.tasks ?? []).flatMap((item) => {
2139
2134
  const entry = normalizeTaskEntry(item);
2140
- if (entry.name !== DEPLOY_TASK || entry.with?.["on"] !== "release") return [];
2135
+ if (entry.name !== "delivery.deploy" || entry.with?.["on"] !== "release") return [];
2141
2136
  const { channel, paths } = entry.with;
2142
2137
  return [{
2143
2138
  pkg,
@@ -2146,6 +2141,32 @@ function deployTargets(packages, manifests) {
2146
2141
  }];
2147
2142
  }));
2148
2143
  }
2144
+ //#endregion
2145
+ //#region src/tasks/deploy-on-release/should-deploy.ts
2146
+ /**
2147
+ * @param channel The release's channel: its prerelease identifier (`alpha`), or `main` for a stable release.
2148
+ * @param changedFiles `undefined` when there's no previous release to diff against.
2149
+ */
2150
+ function shouldDeploy({ channel, configuredChannel, changedFiles, paths }) {
2151
+ if (channel !== configuredChannel) return {
2152
+ deploy: false,
2153
+ reason: `channel "${channel}" isn't "${configuredChannel}"`
2154
+ };
2155
+ if (changedFiles === void 0) return {
2156
+ deploy: true,
2157
+ reason: "no previous release to diff against"
2158
+ };
2159
+ const touched = changedFiles.find((file) => paths.some((path) => file.startsWith(path)));
2160
+ return touched ? {
2161
+ deploy: true,
2162
+ reason: `the release changes ${touched}`
2163
+ } : {
2164
+ deploy: false,
2165
+ reason: `the release changes nothing under ${paths.join(", ")}`
2166
+ };
2167
+ }
2168
+ //#endregion
2169
+ //#region src/tasks/deploy-on-release/wait-for-published.ts
2149
2170
  /**
2150
2171
  * Waits until npm serves every `name@version` in `packages`, asking with
2151
2172
  * `npm view --prefer-online` (so npm's local cache can't answer for the
@@ -2171,35 +2192,70 @@ async function waitForPublished({ packages, run, sleep, now, cwd, timeoutMs = WA
2171
2192
  await sleep(intervalMs);
2172
2193
  }
2173
2194
  }
2195
+ //#endregion
2196
+ //#region src/tasks/deploy-on-release/workspace-packages.ts
2197
+ function scanWorkspaceDir(dirPath, { readJson, listDirs }) {
2198
+ let folders;
2199
+ try {
2200
+ folders = listDirs(dirPath);
2201
+ } catch {
2202
+ return [];
2203
+ }
2204
+ return folders.flatMap((folder) => {
2205
+ try {
2206
+ const manifest = readJson(join(dirPath, folder, "package.json"));
2207
+ if (!manifest.name) return [];
2208
+ return [{
2209
+ name: manifest.name,
2210
+ dir: join(dirPath, folder),
2211
+ version: manifest.version,
2212
+ dependencies: manifest.dependencies ?? {}
2213
+ }];
2214
+ } catch {
2215
+ return [];
2216
+ }
2217
+ });
2218
+ }
2174
2219
  /**
2175
- * @param channel The release's channel: its prerelease identifier (`alpha`), or `main` for a stable release.
2176
- * @param changedFiles `undefined` when there's no previous release to diff against.
2220
+ * Every package under `<repoRoot>/packages/*` and `<repoRoot>/plugins/*`
2221
+ * with a readable, named `package.json` — `holocron`'s own layout splits
2222
+ * vendor plugin packages into `plugins/*`, sibling to `packages/*`.
2177
2223
  */
2178
- function shouldDeploy({ channel, configuredChannel, changedFiles, paths }) {
2179
- if (channel !== configuredChannel) return {
2180
- deploy: false,
2181
- reason: `channel "${channel}" isn't "${configuredChannel}"`
2182
- };
2183
- if (changedFiles === void 0) return {
2184
- deploy: true,
2185
- reason: "no previous release to diff against"
2186
- };
2187
- const touched = changedFiles.find((file) => paths.some((path) => file.startsWith(path)));
2188
- return touched ? {
2189
- deploy: true,
2190
- reason: `the release changes ${touched}`
2191
- } : {
2192
- deploy: false,
2193
- reason: `the release changes nothing under ${paths.join(", ")}`
2194
- };
2224
+ function workspacePackages(repoRoot, tree) {
2225
+ return [...scanWorkspaceDir(join(repoRoot, "packages"), tree), ...scanWorkspaceDir(join(repoRoot, "plugins"), tree)];
2195
2226
  }
2196
- const defaultRun = (command, args, opts) => spawnSync(command, args, {
2197
- encoding: "utf8",
2198
- ...opts
2199
- });
2200
- const defaultReadJson = (path) => JSON.parse(readFileSync(path, "utf8"));
2201
- const defaultListDirs = (path) => readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2202
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2227
+ //#endregion
2228
+ //#region src/tasks/deploy-on-release/deploy-on-release.ts
2229
+ /**
2230
+ * Deploy workspace packages once a release has published (holocron#930).
2231
+ *
2232
+ * A package opts in with a `delivery.deploy` task in its own `holocron.config`
2233
+ * whose `with.on` is `"release"`:
2234
+ *
2235
+ * ```ts
2236
+ * // packages/<app>/holocron.config.ts
2237
+ * tasks: [{ name: "delivery.deploy", with: { on: "release", channel: "alpha" } }];
2238
+ * ```
2239
+ *
2240
+ * `channel` defaults to `main` (a stable release); set a prerelease identifier
2241
+ * such as `alpha` to deploy those releases instead.
2242
+ *
2243
+ * The shared `delivery.publish` workflow runs this (`holocron deploy on-release`)
2244
+ * as its own `deploy` job after the release job, so a deploy is a separate,
2245
+ * re-runnable check rather than a step buried in the release log. Each package
2246
+ * with such a task deploys when the release is on its `channel` and changes a file under its paths: the package itself plus its
2247
+ * `workspace:*` dependencies ({@link defaultPaths}), or an explicit
2248
+ * `with.paths`. It first waits for npm to serve the release's new versions of
2249
+ * those dependencies ({@link waitForPublished}) — the registry can lag a version
2250
+ * it has just accepted, and the deploy installs them — then runs
2251
+ * `pnpm --filter <package> delivery.deploy`.
2252
+ *
2253
+ * The release is already published by then, so a failed deploy can't undo it;
2254
+ * it fails this job instead, loudly and re-runnable on its own. A task is
2255
+ * `skip`ped (not failed) only when it doesn't apply to this release.
2256
+ *
2257
+ * A `delivery.deploy` task with `on: "release"` generates no CI workflow.
2258
+ */
2203
2259
  /**
2204
2260
  * Deploys every package whose manifest declares a release-time deploy that
2205
2261
  * applies to this release. Never throws for a deploy problem: each package's
@@ -2207,7 +2263,7 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2207
2263
  */
2208
2264
  async function deployOnRelease(options, { run = defaultRun, readJson = defaultReadJson, listDirs = defaultListDirs, sleep = defaultSleep, now = Date.now, loadTasks = loadTasksConfig } = {}) {
2209
2265
  const { cwd, from, print } = options;
2210
- const channel = options.channel || MAIN_CHANNEL;
2266
+ const channel = options.channel || "main";
2211
2267
  const to = options.to ?? "HEAD";
2212
2268
  const packages = workspacePackages(cwd, {
2213
2269
  readJson,