@theholocron/cli 5.0.0-alpha.6 → 5.0.0-alpha.61

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/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
- import path, { basename, dirname, join, relative, resolve } from "node:path";
4
+ import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { checkbox, input, select } from "@inquirer/prompts";
6
6
  import { KNOWN_WORKFLOWS, createAstromech, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
7
  import { loadTasksConfig } from "@theholocron/astromech/config";
@@ -24,6 +24,8 @@ import { createGitHubClient } from "@theholocron/github-client";
24
24
  import { PostHogSink } from "@theholocron/observability/analytics";
25
25
  import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
26
26
  import { SentrySink } from "@theholocron/observability/errors";
27
+ import load from "@commitlint/load";
28
+ import lint from "@commitlint/lint";
27
29
  import { promisify } from "node:util";
28
30
  import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
29
31
  import search from "@inquirer/search";
@@ -532,7 +534,8 @@ var PluginLoader = class {
532
534
  /** Default importer — resolves from cwd first so the global CLI finds project plugins. */
533
535
  const defaultImporter$1 = async (pkg) => {
534
536
  try {
535
- return import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
537
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
538
+ return import(pathToFileURL(resolved).href);
536
539
  } catch {
537
540
  return import(pkg);
538
541
  }
@@ -664,7 +667,8 @@ const style = {
664
667
  */
665
668
  const defaultImporter = async (pkg) => {
666
669
  try {
667
- return await import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
670
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
671
+ return await import(pathToFileURL(resolved).href);
668
672
  } catch {
669
673
  return await import(pkg);
670
674
  }
@@ -1106,8 +1110,10 @@ async function runClone(input) {
1106
1110
  }).status };
1107
1111
  });
1108
1112
  print(style.header(`Holocron clone — org=${input.org} → ${targetDir}${dryRun ? " (dry-run)" : ""}`));
1109
- if (!existsSync(targetDir)) if (dryRun) print(style.dim(` would create ${targetDir}`));
1110
- else mkdirSync(targetDir, { recursive: true });
1113
+ if (!existsSync(targetDir)) {
1114
+ if (dryRun) print(style.dim(` would create ${targetDir}`));
1115
+ else mkdirSync(targetDir, { recursive: true });
1116
+ }
1111
1117
  let repos;
1112
1118
  try {
1113
1119
  repos = await listOrgRepos(input.org, input.token, fetchFn);
@@ -1129,7 +1135,8 @@ async function runClone(input) {
1129
1135
  let skipped = 0;
1130
1136
  let failed = 0;
1131
1137
  for (const repo of repos) {
1132
- const dest = join(targetDir, repo.name.startsWith(".") ? repo.name.slice(1) : repo.name);
1138
+ const dirName = repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
1139
+ const dest = join(targetDir, dirName);
1133
1140
  if (existsSync(dest)) {
1134
1141
  print(style.dim(` skip ${repo.full_name}`));
1135
1142
  skipped++;
@@ -1214,6 +1221,7 @@ const COMMAND_CONTEXTS = {
1214
1221
  "skills install": "global",
1215
1222
  "skills remove": "global",
1216
1223
  "skills update": "global",
1224
+ "lint commit-msg": "global",
1217
1225
  run: "repo-aware",
1218
1226
  ci: "repo-aware",
1219
1227
  "config show": "repo-aware",
@@ -1233,6 +1241,40 @@ function commandsInContext(context) {
1233
1241
  }
1234
1242
  //#endregion
1235
1243
  //#region src/commands/deploy.ts
1244
+ /**
1245
+ * `holocron deploy` — trigger a deployment via the configured
1246
+ * `deployment` capability.
1247
+ *
1248
+ * Two modes, one command: `runDeploy` (the default — a Git branch,
1249
+ * `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
1250
+ * a local directory of source files, `deployFunction()`, no linked
1251
+ * repo required — for a consumer with no Git history to deploy from,
1252
+ * e.g. Sentinel, shipped as an npm package). Separate functions, not
1253
+ * one branching `runDeploy`, because their inputs genuinely differ
1254
+ * (`branch` vs `dir`) and their result shapes differ too
1255
+ * (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
1256
+ * is deliberately minimal, same as `DeployScriptResult` for Workers).
1257
+ *
1258
+ * Errors clearly when `deployment` isn't loaded (no provider declared
1259
+ * in `holocron.config.json`), or when the configured provider doesn't
1260
+ * implement `deployFunction` (it's optional on `Deployment` — not
1261
+ * every provider has a files-based deploy API).
1262
+ *
1263
+ * Dry-run skips the actual deploy call but still verifies the
1264
+ * deployment capability (and, for `--files`, `deployFunction`) is
1265
+ * present, so operators can sanity-check the wiring without spinning
1266
+ * up a build.
1267
+ */
1268
+ /**
1269
+ * Provider `url` fields (Vercel's REST API, at least) come back as bare
1270
+ * hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
1271
+ * linkify and browsers won't navigate to unmodified. Printed output
1272
+ * should be a URL a human can click/paste; structured log fields keep
1273
+ * the raw provider value as-is.
1274
+ */
1275
+ function withScheme(url) {
1276
+ return /^https?:\/\//.test(url) ? url : `https://${url}`;
1277
+ }
1236
1278
  async function runDeploy(input) {
1237
1279
  const print = input.print ?? ((line) => console.log(line));
1238
1280
  const logger = input.logger ?? getLogger();
@@ -1264,7 +1306,7 @@ async function runDeploy(input) {
1264
1306
  branch: input.branch,
1265
1307
  ...input.target ? { target: input.target } : {}
1266
1308
  }));
1267
- print(` ${style.success(`${record.status} — ${record.url}`)}`);
1309
+ print(` ${style.success(`${record.status} — ${withScheme(record.url)}`)}`);
1268
1310
  logger.info({
1269
1311
  status: record.status,
1270
1312
  url: record.url,
@@ -1288,6 +1330,87 @@ async function runDeploy(input) {
1288
1330
  };
1289
1331
  }
1290
1332
  }
1333
+ const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
1334
+ ".git",
1335
+ "node_modules",
1336
+ ".turbo"
1337
+ ]);
1338
+ /** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
1339
+ function defaultWalkFiles$2(dir) {
1340
+ const results = [];
1341
+ for (const entry of readdirSync(dir)) {
1342
+ if (SKIP_DIRS$2.has(entry)) continue;
1343
+ const full = join(dir, entry);
1344
+ const stat = statSync(full);
1345
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
1346
+ else if (stat.isFile()) results.push(full);
1347
+ }
1348
+ return results;
1349
+ }
1350
+ /** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
1351
+ async function runDeployFromFiles(input) {
1352
+ const print = input.print ?? ((line) => console.log(line));
1353
+ const logger = input.logger ?? getLogger();
1354
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1355
+ await loader.load();
1356
+ assertPluginsResolvable(loader, "deploy");
1357
+ const dryRun = input.context.dryRun ?? false;
1358
+ const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
1359
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1360
+ const files = {};
1361
+ for (const abs of walkFiles(input.dir)) {
1362
+ const rel = relative(input.dir, abs).split(sep).join("/");
1363
+ files[rel] = readFile(abs);
1364
+ }
1365
+ const fileCount = Object.keys(files).length;
1366
+ logger.info({
1367
+ dir: input.dir,
1368
+ fileCount,
1369
+ target: input.target ?? "preview",
1370
+ projectId: input.projectId,
1371
+ dryRun: dryRun || void 0
1372
+ }, "deploy: start (files)");
1373
+ print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
1374
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
1375
+ const deploy = loader.get("deployment");
1376
+ if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
1377
+ if (dryRun) {
1378
+ const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
1379
+ print(` ${style.dim(`… ${message}`)}`);
1380
+ return {
1381
+ deployment: null,
1382
+ status: "dry-run",
1383
+ message
1384
+ };
1385
+ }
1386
+ try {
1387
+ const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
1388
+ files,
1389
+ ...input.target ? { target: input.target } : {}
1390
+ }));
1391
+ print(` ${style.success(withScheme(result.url))}`);
1392
+ logger.info({
1393
+ url: result.url,
1394
+ id: result.deploymentId
1395
+ }, "deploy: triggered (files)");
1396
+ return {
1397
+ deployment: result,
1398
+ status: "ok"
1399
+ };
1400
+ } catch (err) {
1401
+ const message = err instanceof Error ? err.message : String(err);
1402
+ print(` ${style.fail(message)}`);
1403
+ logger.warn({
1404
+ dir: input.dir,
1405
+ reason: message
1406
+ }, "deploy: failed (files)");
1407
+ return {
1408
+ deployment: null,
1409
+ status: "fail",
1410
+ message
1411
+ };
1412
+ }
1413
+ }
1291
1414
  //#endregion
1292
1415
  //#region src/commands/doctor.ts
1293
1416
  async function runDoctor(input) {
@@ -3796,6 +3919,104 @@ function vaultProviderName(loader) {
3796
3919
  return loader.get("vault").providerName;
3797
3920
  }
3798
3921
  //#endregion
3922
+ //#region src/commands/setup/derived-properties.ts
3923
+ /**
3924
+ * Repo archetype, in priority order:
3925
+ * 1. `*-template` repo name → `template` (independent of package shape).
3926
+ * 2. Monorepo whose primary published artifact is a CLI (some workspace
3927
+ * package has `bin`) → `platform` — matches `holocron` itself: the CLI is
3928
+ * the deliverable, the plugin packages are supporting infrastructure for
3929
+ * it, not separate products.
3930
+ * 3. Monorepo with no CLI but at least one non-private workspace package →
3931
+ * `library` — matches `clients`/`configs`/`utils`/`themes`/
3932
+ * `observability`: a family of published packages, none of them a CLI.
3933
+ * Checked *before* falling into the root-package branch below, because
3934
+ * these repos' own root `package.json` is private and often carries
3935
+ * `@theholocron/astro-config` for a docs site built from the monorepo
3936
+ * root — without this check they'd wrongly resolve to `docs` off that
3937
+ * root-level signal alone, when the docs site is a secondary concern of
3938
+ * a library collection, not what the repo fundamentally is.
3939
+ * 4. Single-package repo with `package.json#bin` → `cli`.
3940
+ * 5. Docs-only site (astro/starlight present, root package private, no
3941
+ * publishable exports) → `docs`.
3942
+ * 6. Private, non-publishable, non-docs → `app`.
3943
+ * 7. Otherwise, a single publishable package → `library`.
3944
+ */
3945
+ function deriveProfile(input) {
3946
+ if (/-template$/.test(input.repoName)) return "template";
3947
+ if (input.isMonorepo) {
3948
+ if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
3949
+ if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
3950
+ }
3951
+ const pkg = input.rootPackageJson;
3952
+ if (pkg?.bin) return "cli";
3953
+ const deps = {
3954
+ ...pkg?.dependencies,
3955
+ ...pkg?.devDependencies
3956
+ };
3957
+ const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
3958
+ if (pkg?.private && isDocsSite) return "docs";
3959
+ if (pkg?.private) return "app";
3960
+ return "library";
3961
+ }
3962
+ const KNOWN_STACK_DEPS = [
3963
+ "next",
3964
+ "vite",
3965
+ "astro",
3966
+ "tsdown",
3967
+ "rollup",
3968
+ "webpack",
3969
+ "storybook",
3970
+ "vitest",
3971
+ "playwright",
3972
+ "turbo"
3973
+ ];
3974
+ /**
3975
+ * Detected build/framework tooling — the literal "does this repo need
3976
+ * next/vite/tsdown" signal `holocron.config.ts` has no field for today
3977
+ * (framework choice isn't a config concept, unlike `providers`/`tasks`).
3978
+ * Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
3979
+ * frameworks show up rather than trying to detect anything on npm.
3980
+ */
3981
+ function deriveStack(pkg) {
3982
+ const deps = {
3983
+ ...pkg?.dependencies,
3984
+ ...pkg?.devDependencies
3985
+ };
3986
+ return KNOWN_STACK_DEPS.filter((name) => name in deps);
3987
+ }
3988
+ /** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
3989
+ function deriveCapabilities(providers) {
3990
+ return Object.keys(providers ?? {}).sort();
3991
+ }
3992
+ /**
3993
+ * `source` + `ci` are the minimal baseline every repo `holocron setup`
3994
+ * touches wires — every `holocron.config.ts` in this org already satisfies
3995
+ * this, so `non-compliant` here means drift (a provider manually removed
3996
+ * after setup), not an unmet aspirational policy. Deliberately not a richer
3997
+ * per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
3998
+ * a Phase B (GitHub App) refinement once it can post *why* on a check run.
3999
+ */
4000
+ const REQUIRED_BASELINE = ["source", "ci"];
4001
+ function deriveCompliance(capabilities) {
4002
+ return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
4003
+ }
4004
+ /**
4005
+ * Reads each workspace package's actual `package.json` content (not just
4006
+ * `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
4007
+ * `platform` detection needs `bin`, which that helper doesn't carry).
4008
+ * Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
4009
+ * own soft-fail behavior.
4010
+ */
4011
+ async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
4012
+ const results = [];
4013
+ for (const { slug, dir = "packages" } of workspacePackages) try {
4014
+ const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
4015
+ results.push(JSON.parse(raw));
4016
+ } catch {}
4017
+ return results;
4018
+ }
4019
+ //#endregion
3799
4020
  //#region src/commands/setup/labels.ts
3800
4021
  const CANONICAL_LABELS = [
3801
4022
  {
@@ -3944,12 +4165,30 @@ var alexrc_default = { allow: [
3944
4165
  ] };
3945
4166
  //#endregion
3946
4167
  //#region src/templates/configs/alexjs/create-config.ts
3947
- const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4168
+ const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
3948
4169
  function createRcConfig() {
3949
4170
  return JSON.stringify(alexrc_default, null, 2) + "\n";
3950
4171
  }
3951
4172
  function createIgnoreConfig$1() {
3952
- return `${workflowHeader$8()}${alexignore_default}`;
4173
+ return `${workflowHeader$9()}${alexignore_default}`;
4174
+ }
4175
+ //#endregion
4176
+ //#region src/templates/configs/commit-msg/commit-msg
4177
+ var commit_msg_default = "__HOLOCRON_SCRIPT__ lint commit-msg \"$1\"\n";
4178
+ //#endregion
4179
+ //#region src/templates/configs/commit-msg/create-config.ts
4180
+ const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4181
+ /**
4182
+ * `.husky/commit-msg` — lints the commit message via `holocron lint
4183
+ * commit-msg` (holocron#789), the real programmatic replacement for
4184
+ * shelling out to `commitlint --edit`. Installed by `holocron setup` when
4185
+ * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4186
+ *
4187
+ * @param holocronScript - the command that invokes the CLI. Defaults to
4188
+ * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4189
+ */
4190
+ function createConfig$7(holocronScript = "pnpm exec holocron") {
4191
+ return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
3953
4192
  }
3954
4193
  //#endregion
3955
4194
  //#region src/templates/configs/devmoji/create-config.ts
@@ -4054,7 +4293,7 @@ function createConfig$2(holocronScript = "pnpm exec holocron") {
4054
4293
  }
4055
4294
  //#endregion
4056
4295
  //#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
4057
- var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\nnpx devmoji -e\n";
4296
+ var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\n# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to devmoji's own auto-discovery of a local devmoji.config.cjs when the\n# shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nDEVMOJI_CONFIG=\"node_modules/@theholocron/devmoji-config/dist/index.cjs\"\nif [ -f \"$DEVMOJI_CONFIG\" ]; then\n npx devmoji --config \"$DEVMOJI_CONFIG\" -e\nelse\n npx devmoji -e\nfi\n";
4058
4297
  //#endregion
4059
4298
  //#region src/templates/configs/prepare-commit-msg/create-config.ts
4060
4299
  const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
@@ -5051,6 +5290,10 @@ async function runSetup(input) {
5051
5290
  await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
5052
5291
  }));
5053
5292
  print(formatStep(steps[steps.length - 1]));
5293
+ steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5294
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5295
+ }));
5296
+ print(formatStep(steps[steps.length - 1]));
5054
5297
  if (hooksEnabled) {
5055
5298
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5056
5299
  await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
@@ -5105,20 +5348,38 @@ async function runSetup(input) {
5105
5348
  print(formatStep(steps[steps.length - 1]));
5106
5349
  }
5107
5350
  {
5108
- const content = createAstromech({
5351
+ const astromechTurbo = createAstromech({
5109
5352
  cwd: input.context.repoRoot,
5110
5353
  config: { tasks: config.tasks },
5111
5354
  logger
5112
- }).turboConfig();
5355
+ });
5356
+ const content = astromechTurbo.turboConfig();
5113
5357
  if (content === null) steps.push({
5114
5358
  capability: "source",
5115
5359
  step: "write turbo.json",
5116
5360
  status: "skip",
5117
5361
  message: "no fan-out-eligible tasks configured"
5118
5362
  });
5119
- else steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5120
- await source.writeRepoFile("turbo.json", content);
5121
- }));
5363
+ else {
5364
+ steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5365
+ await source.writeRepoFile("turbo.json", content);
5366
+ }));
5367
+ print(formatStep(steps[steps.length - 1]));
5368
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5369
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5370
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5371
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5372
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5373
+ if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5374
+ await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5375
+ }));
5376
+ if (rootPkgRaw) {
5377
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5378
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5379
+ await source.writeRepoFile("package.json", depFix.content);
5380
+ }));
5381
+ }
5382
+ }
5122
5383
  print(formatStep(steps[steps.length - 1]));
5123
5384
  }
5124
5385
  if (source.syncLabels) {
@@ -5128,7 +5389,7 @@ async function runSetup(input) {
5128
5389
  print(formatStep(steps[steps.length - 1]));
5129
5390
  }
5130
5391
  const properties = {};
5131
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
5392
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
5132
5393
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
5133
5394
  properties["monorepo"] = String(isMonorepo);
5134
5395
  const manual = repo?.properties ?? {};
@@ -5136,6 +5397,21 @@ async function runSetup(input) {
5136
5397
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5137
5398
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5138
5399
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5400
+ {
5401
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5402
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5403
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5404
+ properties["holocron_profile"] = deriveProfile({
5405
+ rootPackageJson,
5406
+ repoName: basename(input.context.repoRoot),
5407
+ isMonorepo,
5408
+ workspacePackageJsons
5409
+ });
5410
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
5411
+ const capabilities = deriveCapabilities(config.providers);
5412
+ properties["holocron_capabilities"] = capabilities;
5413
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5414
+ }
5139
5415
  if (source.syncProperties) {
5140
5416
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5141
5417
  print(formatStep(steps[steps.length - 1]));
@@ -5154,34 +5430,36 @@ async function runSetup(input) {
5154
5430
  print(formatStep(steps[steps.length - 1]));
5155
5431
  }
5156
5432
  const teams = repo?.teams ?? [];
5157
- if (teams.length > 0) if (source.syncTeams) {
5158
- steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5159
- print(formatStep(steps[steps.length - 1]));
5160
- const repoCoord = input.context.repo ?? repo?.name ?? "";
5161
- const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5162
- const writeableTeams = teams.map((t) => typeof t === "string" ? {
5163
- slug: t,
5164
- permission: "push"
5165
- } : t).filter((t) => [
5166
- "push",
5167
- "maintain",
5168
- "admin"
5169
- ].includes(t.permission));
5170
- if (org && writeableTeams.length > 0) {
5171
- steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5172
- const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5173
- await source.writeRepoFile(".github/CODEOWNERS", content);
5174
- }));
5433
+ if (teams.length > 0) {
5434
+ if (source.syncTeams) {
5435
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5436
+ print(formatStep(steps[steps.length - 1]));
5437
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
5438
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5439
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
5440
+ slug: t,
5441
+ permission: "push"
5442
+ } : t).filter((t) => [
5443
+ "push",
5444
+ "maintain",
5445
+ "admin"
5446
+ ].includes(t.permission));
5447
+ if (org && writeableTeams.length > 0) {
5448
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5449
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5450
+ await source.writeRepoFile(".github/CODEOWNERS", content);
5451
+ }));
5452
+ print(formatStep(steps[steps.length - 1]));
5453
+ }
5454
+ } else {
5455
+ steps.push({
5456
+ capability: "source",
5457
+ step: "sync teams",
5458
+ status: "skip",
5459
+ message: "provider does not implement syncTeams"
5460
+ });
5175
5461
  print(formatStep(steps[steps.length - 1]));
5176
5462
  }
5177
- } else {
5178
- steps.push({
5179
- capability: "source",
5180
- step: "sync teams",
5181
- status: "skip",
5182
- message: "provider does not implement syncTeams"
5183
- });
5184
- print(formatStep(steps[steps.length - 1]));
5185
5463
  }
5186
5464
  }
5187
5465
  if (loader.has("environments")) {
@@ -5237,11 +5515,9 @@ async function runSetup(input) {
5237
5515
  const wikiProxy = wiki.proxyConfig?.();
5238
5516
  if (wikiDns && loader.has("dns")) {
5239
5517
  const dns = loader.get("dns");
5240
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5518
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5241
5519
  await dns.upsertRecord(wikiDns.zone, {
5242
- type: "CNAME",
5243
- name: wikiDns.cname,
5244
- content: wikiDns.target,
5520
+ ...wikiDns.record,
5245
5521
  ttl: 1,
5246
5522
  ...wikiProxy ? { proxied: true } : {}
5247
5523
  });
@@ -5250,8 +5526,8 @@ async function runSetup(input) {
5250
5526
  }
5251
5527
  if (wikiProxy && wikiDns && loader.has("workers")) {
5252
5528
  const workers = loader.get("workers");
5253
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5254
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5529
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5530
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5255
5531
  }));
5256
5532
  print(formatStep(steps[steps.length - 1]));
5257
5533
  }
@@ -5264,11 +5540,31 @@ async function runSetup(input) {
5264
5540
  org: config.org,
5265
5541
  domain: config.domain
5266
5542
  }) : null;
5543
+ const ensureCustomDomain = async (projectId, hostname) => {
5544
+ if (!deploy.ensureCustomDomain) return;
5545
+ const customDomainDns = { value: null };
5546
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5547
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5548
+ }));
5549
+ print(formatStep(steps[steps.length - 1]));
5550
+ if (customDomainDns.value && loader.has("dns")) {
5551
+ const dns = loader.get("dns");
5552
+ const dnsRecord = customDomainDns.value;
5553
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5554
+ await dns.upsertRecord(dnsRecord.zone, {
5555
+ ...dnsRecord.record,
5556
+ ttl: 1
5557
+ });
5558
+ }));
5559
+ print(formatStep(steps[steps.length - 1]));
5560
+ }
5561
+ };
5267
5562
  if (!previewCfg) {
5268
5563
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5269
5564
  await deploy.ensureProject({ name: config.name });
5270
5565
  }));
5271
5566
  print(formatStep(steps[steps.length - 1]));
5567
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5272
5568
  }
5273
5569
  if (previewCfg) {
5274
5570
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5276,13 +5572,8 @@ async function runSetup(input) {
5276
5572
  }));
5277
5573
  print(formatStep(steps[steps.length - 1]));
5278
5574
  }
5279
- if (previewCfg?.domain && deploy.ensureCustomDomain) {
5280
- steps.push(await runStep("deployment", `ensureCustomDomain ${previewCfg.domain}`, dryRun, async () => {
5281
- await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
5282
- }));
5283
- print(formatStep(steps[steps.length - 1]));
5284
- }
5285
- if (previewCfg?.domain && loader.has("dns")) {
5575
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5576
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5286
5577
  const dns = loader.get("dns");
5287
5578
  const wildcardDomain = `*.${previewCfg.domain}`;
5288
5579
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5730,11 +6021,13 @@ async function runSyncReadme(input) {
5730
6021
  }
5731
6022
  const description = loaded.resolved.description ?? "";
5732
6023
  const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
6024
+ const scripts = pkg.scripts ?? {};
6025
+ const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
5733
6026
  const sections = generateReadme({
5734
6027
  description,
5735
6028
  homepage,
5736
- scripts: pkg.scripts ?? {},
5737
- packages: pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0
6029
+ scripts,
6030
+ packages
5738
6031
  });
5739
6032
  const markerSections = {
5740
6033
  ...sections.packages !== void 0 && { packages: sections.packages },
@@ -5976,7 +6269,7 @@ async function runSyncWiki(input) {
5976
6269
  capability: "local",
5977
6270
  step: "sync wiki",
5978
6271
  status: "fail",
5979
- message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6272
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
5980
6273
  };
5981
6274
  }
5982
6275
  }
@@ -6038,40 +6331,59 @@ async function runSync(input) {
6038
6331
  for (const stepName of SYNC_STEPS) {
6039
6332
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
6040
6333
  if (LOCAL_STEPS.has(stepName)) continue;
6041
- if (stepName === "labels") if (source.syncLabels) {
6042
- steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6043
- print(formatSyncStep(steps[steps.length - 1]));
6044
- } else {
6045
- steps.push({
6046
- capability: "source",
6047
- step: "sync labels",
6048
- status: "skip",
6049
- message: "provider does not implement syncLabels"
6050
- });
6051
- print(formatSyncStep(steps[steps.length - 1]));
6334
+ if (stepName === "labels") {
6335
+ if (source.syncLabels) {
6336
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6337
+ print(formatSyncStep(steps[steps.length - 1]));
6338
+ } else {
6339
+ steps.push({
6340
+ capability: "source",
6341
+ step: "sync labels",
6342
+ status: "skip",
6343
+ message: "provider does not implement syncLabels"
6344
+ });
6345
+ print(formatSyncStep(steps[steps.length - 1]));
6346
+ }
6052
6347
  }
6053
- if (stepName === "properties") if (source.syncProperties) {
6054
- const repo = config.repo;
6055
- const properties = {};
6056
- const effectivePreset = repo?.protection;
6057
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
6058
- const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6059
- properties["monorepo"] = String(isMonorepo);
6060
- const manual = repo?.properties ?? {};
6061
- if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6062
- if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6063
- if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6064
- if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6065
- steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6066
- print(formatSyncStep(steps[steps.length - 1]));
6067
- } else {
6068
- steps.push({
6069
- capability: "source",
6070
- step: "sync properties",
6071
- status: "skip",
6072
- message: "provider does not implement syncProperties"
6073
- });
6074
- print(formatSyncStep(steps[steps.length - 1]));
6348
+ if (stepName === "properties") {
6349
+ if (source.syncProperties) {
6350
+ const repo = config.repo;
6351
+ const properties = {};
6352
+ const effectivePreset = repo?.protection;
6353
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6354
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6355
+ properties["monorepo"] = String(isMonorepo);
6356
+ const manual = repo?.properties ?? {};
6357
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6358
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6359
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6360
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6361
+ {
6362
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6363
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6364
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6365
+ properties["holocron_profile"] = deriveProfile({
6366
+ rootPackageJson,
6367
+ repoName: basename(input.context.repoRoot),
6368
+ isMonorepo,
6369
+ workspacePackageJsons
6370
+ });
6371
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6372
+ const capabilities = deriveCapabilities(config.providers);
6373
+ properties["holocron_capabilities"] = capabilities;
6374
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6375
+ }
6376
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6377
+ print(formatSyncStep(steps[steps.length - 1]));
6378
+ } else {
6379
+ steps.push({
6380
+ capability: "source",
6381
+ step: "sync properties",
6382
+ status: "skip",
6383
+ message: "provider does not implement syncProperties"
6384
+ });
6385
+ print(formatSyncStep(steps[steps.length - 1]));
6386
+ }
6075
6387
  }
6076
6388
  if (stepName === "teams") {
6077
6389
  const teams = config.repo?.teams ?? [];
@@ -6283,36 +6595,42 @@ async function runSync(input) {
6283
6595
  }
6284
6596
  }
6285
6597
  }
6286
- if (stepName === "scripts") if (config.syncScripts === false) {
6287
- steps.push({
6288
- capability: "local",
6289
- step: "sync scripts",
6290
- status: "skip",
6291
- message: "syncScripts: false"
6292
- });
6293
- print(formatSyncStep(steps[steps.length - 1]));
6294
- } else {
6295
- const tasksConfig = { tasks: config.tasks };
6296
- if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6297
- const desired = createAstromech({
6298
- cwd: input.context.repoRoot,
6299
- config: tasksConfig,
6300
- logger
6301
- }).packageScripts();
6302
- steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6303
- const changed = await mergePackageJsonScripts(input.context.repoRoot, desired);
6304
- if (changed === null) return "no package.json";
6305
- if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6306
- return `${changed.join(", ")} set`;
6307
- }));
6308
- print(formatSyncStep(steps[steps.length - 1]));
6598
+ if (stepName === "scripts") {
6599
+ if (config.syncScripts === false) {
6600
+ steps.push({
6601
+ capability: "local",
6602
+ step: "sync scripts",
6603
+ status: "skip",
6604
+ message: "syncScripts: false"
6605
+ });
6606
+ print(formatSyncStep(steps[steps.length - 1]));
6607
+ } else {
6608
+ const tasksConfig = { tasks: config.tasks };
6609
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6610
+ const astromechScripts = createAstromech({
6611
+ cwd: input.context.repoRoot,
6612
+ config: tasksConfig,
6613
+ logger
6614
+ });
6615
+ const desired = astromechScripts.packageScripts();
6616
+ const turboContent = astromechScripts.turboConfig();
6617
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
6618
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6619
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
6620
+ if (changed === null) return "no package.json";
6621
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6622
+ return `${changed.join(", ")} set`;
6623
+ }));
6624
+ print(formatSyncStep(steps[steps.length - 1]));
6625
+ }
6309
6626
  }
6310
6627
  if (stepName === "turbo") {
6311
- const content = createAstromech({
6628
+ const astromechTurbo = createAstromech({
6312
6629
  cwd: input.context.repoRoot,
6313
6630
  config: { tasks: config.tasks },
6314
6631
  logger
6315
- }).turboConfig();
6632
+ });
6633
+ const content = astromechTurbo.turboConfig();
6316
6634
  if (content === null) {
6317
6635
  steps.push({
6318
6636
  capability: "local",
@@ -6326,6 +6644,26 @@ async function runSync(input) {
6326
6644
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6327
6645
  }));
6328
6646
  print(formatSyncStep(steps[steps.length - 1]));
6647
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
6648
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6649
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6650
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
6651
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6652
+ if (workspaceFix.changed) {
6653
+ steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
6654
+ await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
6655
+ }));
6656
+ print(formatSyncStep(steps[steps.length - 1]));
6657
+ }
6658
+ if (rootPkgRaw) {
6659
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
6660
+ if (depFix.changed) {
6661
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
6662
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
6663
+ }));
6664
+ print(formatSyncStep(steps[steps.length - 1]));
6665
+ }
6666
+ }
6329
6667
  }
6330
6668
  }
6331
6669
  if (stepName === "wiki") {
@@ -6409,8 +6747,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6409
6747
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6410
6748
  * listed keys, never touches the rest. Returns the sorted list of keys that
6411
6749
  * drifted (empty when all current), or `null` when there is no `package.json`.
6750
+ *
6751
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
6752
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
6753
+ * only, an *existing* script that isn't the generated `holocron run <task>
6754
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
6755
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
6756
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
6757
+ * real command and, once `turbo.json` exists and root is a real workspace
6758
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
6759
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
6760
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
6761
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
6762
+ * legitimate direct-command alternative and stays fully generator-owned.
6412
6763
  */
6413
- async function mergePackageJsonScripts(repoRoot, desired) {
6764
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6414
6765
  const pkgPath = join(repoRoot, "package.json");
6415
6766
  let content;
6416
6767
  try {
@@ -6421,15 +6772,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6421
6772
  const pkg = JSON.parse(content);
6422
6773
  const scripts = pkg.scripts ?? {};
6423
6774
  const changed = [];
6424
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6425
- scripts[name] = command;
6426
- changed.push(name);
6775
+ for (const [name, command] of Object.entries(desired)) {
6776
+ const existing = scripts[name];
6777
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
6778
+ if (existing !== command) {
6779
+ scripts[name] = command;
6780
+ changed.push(name);
6781
+ }
6427
6782
  }
6428
6783
  if (changed.length === 0) return [];
6429
6784
  pkg.scripts = scripts;
6430
6785
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6431
6786
  return changed.sort();
6432
6787
  }
6788
+ /**
6789
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
6790
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
6791
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
6792
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
6793
+ * rather than the whole string means an old wrapper survives a
6794
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
6795
+ * to update to the new one), not mistaken for a real command.
6796
+ */
6797
+ function isGeneratedRunWrapper(command, taskName) {
6798
+ return command.endsWith(` run ${taskName} --`);
6799
+ }
6433
6800
  const README_DESC_START = "<!-- holocron:description -->";
6434
6801
  const README_DESC_END = "<!-- /holocron:description -->";
6435
6802
  async function updateReadmeDescription(repoRoot, description) {
@@ -7296,6 +7663,63 @@ async function runUpgradeNode(input) {
7296
7663
  };
7297
7664
  }
7298
7665
  //#endregion
7666
+ //#region src/commit-lint/lint-message.ts
7667
+ /**
7668
+ * Lints one commit message string against an already-loaded commitlint
7669
+ * config. The genuinely environment-independent half of commit-message
7670
+ * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
7671
+ * caller (Sentinel isolates itself from any ambient config since it runs
7672
+ * standalone, away from a real repo checkout; `lintCommitMsgFile` below
7673
+ * just lets cosmiconfig discover the invoking repo's own
7674
+ * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
7675
+ * but linting one message against a loaded config is identical either way.
7676
+ *
7677
+ * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
7678
+ * itself uses internally — never a reimplementation of commitlint's rules.
7679
+ */
7680
+ /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
7681
+ function selectParserOpts(parserPreset) {
7682
+ return parserPreset?.parserOpts;
7683
+ }
7684
+ async function lintCommitMessage(message, loaded) {
7685
+ const opts = {
7686
+ /* istanbul ignore next -- see comment above */
7687
+ parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
7688
+ plugins: loaded.plugins,
7689
+ /* istanbul ignore next -- see comment above */
7690
+ ignores: loaded.ignores ?? [],
7691
+ defaultIgnores: loaded.defaultIgnores !== false
7692
+ };
7693
+ const outcome = await lint(message, loaded.rules, opts);
7694
+ if (outcome.valid) return [];
7695
+ return outcome.errors.map((error) => ({
7696
+ rule: error.name,
7697
+ message: error.message
7698
+ }));
7699
+ }
7700
+ //#endregion
7701
+ //#region src/commit-lint/lint-commit-msg-file.ts
7702
+ /**
7703
+ * `holocron lint commit-msg <file>` — a real programmatic replacement for
7704
+ * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
7705
+ * (holocron#789). Matches that CLI's own contract exactly: `file` is a
7706
+ * path to the commit message being written (the arg git's `commit-msg`
7707
+ * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
7708
+ * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
7709
+ * discovers the invoking repo's real `commitlint.config.*` file, exactly
7710
+ * as `commitlint --edit` (no `--config` flag) already does today. Unlike
7711
+ * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
7712
+ * runs inside a real repo checkout with a real `node_modules`, not
7713
+ * standalone on a server.
7714
+ */
7715
+ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
7716
+ const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
7717
+ return {
7718
+ valid: violations.length === 0,
7719
+ violations
7720
+ };
7721
+ }
7722
+ //#endregion
7299
7723
  //#region src/config/load-config.ts
7300
7724
  /**
7301
7725
  * `holocron.config.{ts,js,mjs,cjs,json}` loader.
@@ -7406,7 +7830,9 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
7406
7830
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
7407
7831
  * yargs registrations (this module does not introspect yargs at runtime).
7408
7832
  * `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
7409
- * interactive prompt in front of it would work against that.
7833
+ * interactive prompt in front of it would work against that. `lint
7834
+ * commit-msg <file>` is excluded for the same reason: it's invoked
7835
+ * exclusively from `.husky/commit-msg "$1"`, never typed by a human.
7410
7836
  */
7411
7837
  const COMMAND_REGISTRY = [
7412
7838
  {
@@ -7717,7 +8143,7 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
7717
8143
  //#endregion
7718
8144
  //#region src/update-notifier.ts
7719
8145
  const PACKAGE_NAME = "@theholocron/cli";
7720
- const CACHE_TTL_MS = 1440 * 60 * 1e3;
8146
+ const CACHE_TTL_MS = 864e5;
7721
8147
  const FETCH_TIMEOUT_MS = 3e3;
7722
8148
  function getCacheDir() {
7723
8149
  return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
@@ -8103,7 +8529,7 @@ try {
8103
8529
  })).summary.fail > 0) process.exitCode = 1;
8104
8530
  }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
8105
8531
  type: "string",
8106
- describe: "Git branch to deploy"
8532
+ describe: "Git branch to deploy (omit when using --files)"
8107
8533
  }).option("project-id", {
8108
8534
  type: "string",
8109
8535
  demandOption: true,
@@ -8112,20 +8538,34 @@ try {
8112
8538
  type: "string",
8113
8539
  choices: ["production", "staging"],
8114
8540
  describe: "Named environment to deploy into. Omit for a branch preview."
8115
- }), async (argv) => {
8541
+ }).option("files", {
8542
+ type: "string",
8543
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
8544
+ }).conflicts("files", "branch"), async (argv) => {
8116
8545
  const tokens = tokenContext(argv.token);
8117
8546
  if (!tokens) return;
8118
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8119
8547
  const loaded = await loadConfig(argv.cwd);
8120
8548
  applyResolvedConfig(argv, loaded.resolved);
8549
+ const context = {
8550
+ repoRoot: argv.cwd,
8551
+ dryRun: argv.dryRun,
8552
+ ...tokens,
8553
+ org: resolveOrg(argv, loaded.resolved)
8554
+ };
8555
+ if (argv.files) {
8556
+ if ((await runDeployFromFiles({
8557
+ loaded,
8558
+ context,
8559
+ projectId: argv.projectId,
8560
+ dir: argv.files,
8561
+ ...argv.target ? { target: argv.target } : {}
8562
+ })).status === "fail") process.exitCode = 1;
8563
+ return;
8564
+ }
8565
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8121
8566
  if ((await runDeploy({
8122
8567
  loaded,
8123
- context: {
8124
- repoRoot: argv.cwd,
8125
- dryRun: argv.dryRun,
8126
- ...tokens,
8127
- org: resolveOrg(argv, loaded.resolved)
8128
- },
8568
+ context,
8129
8569
  projectId: argv.projectId,
8130
8570
  branch,
8131
8571
  ...argv.target ? { target: argv.target } : {}
@@ -8301,7 +8741,17 @@ try {
8301
8741
  scope: argv.all ? "all" : "required",
8302
8742
  ...argv.filter ? { filter: argv.filter } : {}
8303
8743
  }).status === "fail") process.exitCode = 1;
8304
- }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8744
+ }).command("lint", "Lint content locally against the org's shared rules", (y) => y.command("commit-msg <file>", "Lint a commit message file — matches `commitlint --edit`'s own contract, for use from .husky/commit-msg", (yy) => yy.positional("file", {
8745
+ type: "string",
8746
+ demandOption: true,
8747
+ describe: "Path to the commit message file (git's commit-msg hook passes this as $1)."
8748
+ }), async (argv) => {
8749
+ const result = await lintCommitMsgFile(argv.file, argv.cwd);
8750
+ if (!result.valid) {
8751
+ for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
8752
+ process.exitCode = 1;
8753
+ }
8754
+ }), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8305
8755
  type: "string",
8306
8756
  default: "theholocron/.github",
8307
8757
  describe: "Target org/repo (default: theholocron/.github)"