@theholocron/cli 5.0.0-alpha.11 → 5.0.0-alpha.110

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,9 +1,9 @@
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
- import { KNOWN_WORKFLOWS, createAstromech, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
6
+ import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
7
7
  import { loadTasksConfig } from "@theholocron/astromech/config";
8
8
  import yargs from "yargs";
9
9
  import { hideBin } from "yargs/helpers";
@@ -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";
@@ -295,7 +297,8 @@ function resolveConfig(raw) {
295
297
  docs,
296
298
  env: raw.env,
297
299
  log: raw.log,
298
- telemetry: raw.telemetry
300
+ telemetry: raw.telemetry,
301
+ eslint: raw.eslint
299
302
  };
300
303
  }
301
304
  //#endregion
@@ -532,7 +535,8 @@ var PluginLoader = class {
532
535
  /** Default importer — resolves from cwd first so the global CLI finds project plugins. */
533
536
  const defaultImporter$1 = async (pkg) => {
534
537
  try {
535
- return import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
538
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
539
+ return import(pathToFileURL(resolved).href);
536
540
  } catch {
537
541
  return import(pkg);
538
542
  }
@@ -664,7 +668,8 @@ const style = {
664
668
  */
665
669
  const defaultImporter = async (pkg) => {
666
670
  try {
667
- return await import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
671
+ const resolved = createRequire(process.cwd() + "/").resolve(pkg);
672
+ return await import(pathToFileURL(resolved).href);
668
673
  } catch {
669
674
  return await import(pkg);
670
675
  }
@@ -1106,8 +1111,10 @@ async function runClone(input) {
1106
1111
  }).status };
1107
1112
  });
1108
1113
  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 });
1114
+ if (!existsSync(targetDir)) {
1115
+ if (dryRun) print(style.dim(` would create ${targetDir}`));
1116
+ else mkdirSync(targetDir, { recursive: true });
1117
+ }
1111
1118
  let repos;
1112
1119
  try {
1113
1120
  repos = await listOrgRepos(input.org, input.token, fetchFn);
@@ -1129,7 +1136,8 @@ async function runClone(input) {
1129
1136
  let skipped = 0;
1130
1137
  let failed = 0;
1131
1138
  for (const repo of repos) {
1132
- const dest = join(targetDir, repo.name.startsWith(".") ? repo.name.slice(1) : repo.name);
1139
+ const dirName = repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
1140
+ const dest = join(targetDir, dirName);
1133
1141
  if (existsSync(dest)) {
1134
1142
  print(style.dim(` skip ${repo.full_name}`));
1135
1143
  skipped++;
@@ -1214,8 +1222,10 @@ const COMMAND_CONTEXTS = {
1214
1222
  "skills install": "global",
1215
1223
  "skills remove": "global",
1216
1224
  "skills update": "global",
1225
+ "lint commit-msg": "global",
1217
1226
  run: "repo-aware",
1218
1227
  ci: "repo-aware",
1228
+ "deploy-on-release": "repo-aware",
1219
1229
  "config show": "repo-aware",
1220
1230
  "sync-readme": "repo-aware",
1221
1231
  doctor: "workspace",
@@ -1233,6 +1243,80 @@ function commandsInContext(context) {
1233
1243
  }
1234
1244
  //#endregion
1235
1245
  //#region src/commands/deploy.ts
1246
+ /**
1247
+ * `holocron deploy` — trigger a deployment via the configured
1248
+ * `deployment` capability.
1249
+ *
1250
+ * Two modes, one command: `runDeploy` (the default — a Git branch,
1251
+ * `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
1252
+ * a local directory of source files, `deployFunction()`, no linked
1253
+ * repo required — for a consumer with no Git history to deploy from,
1254
+ * e.g. Sentinel, shipped as an npm package). Separate functions, not
1255
+ * one branching `runDeploy`, because their inputs genuinely differ
1256
+ * (`branch` vs `dir`) and their result shapes differ too
1257
+ * (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
1258
+ * is deliberately minimal, same as `DeployScriptResult` for Workers).
1259
+ *
1260
+ * Errors clearly when `deployment` isn't loaded (no provider declared
1261
+ * in `holocron.config.json`), or when the configured provider doesn't
1262
+ * implement `deployFunction` (it's optional on `Deployment` — not
1263
+ * every provider has a files-based deploy API).
1264
+ *
1265
+ * Dry-run skips the actual deploy call but still verifies the
1266
+ * deployment capability (and, for `--files`, `deployFunction`) is
1267
+ * present, so operators can sanity-check the wiring without spinning
1268
+ * up a build.
1269
+ *
1270
+ * **Both modes wait for the deployment to finish** (holocron#911): a
1271
+ * provider accepting the upload/trigger is not the deployment succeeding.
1272
+ * Vercel builds asynchronously — an `npm install` failure there leaves
1273
+ * the deployment in `ERROR` while production keeps serving the previous
1274
+ * one, and this command used to report `ok` regardless (found live:
1275
+ * Sentinel's holocron#910 deploy "succeeded" in CI, never went live). So
1276
+ * after triggering, {@link waitForDeployment} polls
1277
+ * `Deployment.getDeployment()` until a terminal state; only `ready` is
1278
+ * `ok`, while `error` / `cancelled` / a timeout are `fail` (non-zero exit
1279
+ * via `cli.ts`).
1280
+ */
1281
+ const DEFAULT_WAIT_TIMEOUT_MS = 6e5;
1282
+ const DEFAULT_WAIT_INTERVAL_MS = 5e3;
1283
+ const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
1284
+ "ready",
1285
+ "error",
1286
+ "cancelled"
1287
+ ]);
1288
+ /**
1289
+ * Polls `getDeployment(id)` until the deployment reaches a terminal status
1290
+ * (`ready` / `error` / `cancelled`). Throws on timeout — the caller turns
1291
+ * that into a `fail` report, same as a provider throwing mid-trigger.
1292
+ */
1293
+ async function waitForDeployment(deploy, deploymentId, options = {}) {
1294
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
1295
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
1296
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1297
+ const now = options.now ?? Date.now;
1298
+ const deadline = now() + timeoutMs;
1299
+ for (;;) {
1300
+ const record = await deploy.getDeployment(deploymentId);
1301
+ if (TERMINAL_STATUSES.has(record.status)) return record;
1302
+ if (now() >= deadline) throw new Error(`deployment ${deploymentId} still ${record.status} after ${Math.round(timeoutMs / 1e3)}s — check the provider's dashboard`);
1303
+ await sleep(intervalMs);
1304
+ }
1305
+ }
1306
+ /** One terminal, non-ready record as a single human-readable failure line. */
1307
+ function terminalFailure(record) {
1308
+ return `deployment ${record.id} ended ${record.status}${record.errorMessage ? `: ${record.errorMessage}` : ""}`;
1309
+ }
1310
+ /**
1311
+ * Provider `url` fields (Vercel's REST API, at least) come back as bare
1312
+ * hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
1313
+ * linkify and browsers won't navigate to unmodified. Printed output
1314
+ * should be a URL a human can click/paste; structured log fields keep
1315
+ * the raw provider value as-is.
1316
+ */
1317
+ function withScheme(url) {
1318
+ return /^https?:\/\//.test(url) ? url : `https://${url}`;
1319
+ }
1236
1320
  async function runDeploy(input) {
1237
1321
  const print = input.print ?? ((line) => console.log(line));
1238
1322
  const logger = input.logger ?? getLogger();
@@ -1259,17 +1343,37 @@ async function runDeploy(input) {
1259
1343
  };
1260
1344
  }
1261
1345
  try {
1262
- const record = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
1346
+ const triggered = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
1263
1347
  projectId: input.projectId,
1264
1348
  branch: input.branch,
1265
1349
  ...input.target ? { target: input.target } : {}
1266
1350
  }));
1267
- print(` ${style.success(`${record.status} — ${record.url}`)}`);
1351
+ logger.info({
1352
+ status: triggered.status,
1353
+ url: triggered.url,
1354
+ id: triggered.id
1355
+ }, "deploy: triggered");
1356
+ const record = TERMINAL_STATUSES.has(triggered.status) ? triggered : await withSpinner(`Waiting for ${withScheme(triggered.url)} to finish…`, () => waitForDeployment(deploy, triggered.id, input.wait));
1357
+ if (record.status !== "ready") {
1358
+ const message = terminalFailure(record);
1359
+ print(` ${style.fail(`${message} — ${withScheme(triggered.url)}`)}`);
1360
+ logger.warn({
1361
+ id: record.id,
1362
+ status: record.status,
1363
+ reason: record.errorMessage
1364
+ }, "deploy: failed");
1365
+ return {
1366
+ deployment: record,
1367
+ status: "fail",
1368
+ message
1369
+ };
1370
+ }
1371
+ print(` ${style.success(`${record.status} — ${withScheme(triggered.url)}`)}`);
1268
1372
  logger.info({
1269
1373
  status: record.status,
1270
- url: record.url,
1374
+ url: triggered.url,
1271
1375
  id: record.id
1272
- }, "deploy: triggered");
1376
+ }, "deploy: ready");
1273
1377
  return {
1274
1378
  deployment: record,
1275
1379
  status: "ok"
@@ -1288,6 +1392,106 @@ async function runDeploy(input) {
1288
1392
  };
1289
1393
  }
1290
1394
  }
1395
+ const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
1396
+ ".git",
1397
+ "node_modules",
1398
+ ".turbo"
1399
+ ]);
1400
+ /** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
1401
+ function defaultWalkFiles$2(dir) {
1402
+ const results = [];
1403
+ for (const entry of readdirSync(dir)) {
1404
+ if (SKIP_DIRS$2.has(entry)) continue;
1405
+ const full = join(dir, entry);
1406
+ const stat = statSync(full);
1407
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
1408
+ else if (stat.isFile()) results.push(full);
1409
+ }
1410
+ return results;
1411
+ }
1412
+ /** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
1413
+ async function runDeployFromFiles(input) {
1414
+ const print = input.print ?? ((line) => console.log(line));
1415
+ const logger = input.logger ?? getLogger();
1416
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1417
+ await loader.load();
1418
+ assertPluginsResolvable(loader, "deploy");
1419
+ const dryRun = input.context.dryRun ?? false;
1420
+ const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
1421
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1422
+ const files = {};
1423
+ for (const abs of walkFiles(input.dir)) {
1424
+ const rel = relative(input.dir, abs).split(sep).join("/");
1425
+ files[rel] = readFile(abs);
1426
+ }
1427
+ const fileCount = Object.keys(files).length;
1428
+ logger.info({
1429
+ dir: input.dir,
1430
+ fileCount,
1431
+ target: input.target ?? "preview",
1432
+ projectId: input.projectId,
1433
+ dryRun: dryRun || void 0
1434
+ }, "deploy: start (files)");
1435
+ print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
1436
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
1437
+ const deploy = loader.get("deployment");
1438
+ if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
1439
+ if (dryRun) {
1440
+ const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
1441
+ print(` ${style.dim(`… ${message}`)}`);
1442
+ return {
1443
+ deployment: null,
1444
+ status: "dry-run",
1445
+ message
1446
+ };
1447
+ }
1448
+ try {
1449
+ const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
1450
+ files,
1451
+ ...input.target ? { target: input.target } : {}
1452
+ }));
1453
+ logger.info({
1454
+ url: result.url,
1455
+ id: result.deploymentId
1456
+ }, "deploy: triggered (files)");
1457
+ const record = await withSpinner(`Waiting for ${withScheme(result.url)} to finish…`, () => waitForDeployment(deploy, result.deploymentId, input.wait));
1458
+ if (record.status !== "ready") {
1459
+ const message = terminalFailure(record);
1460
+ print(` ${style.fail(`${message} — ${withScheme(result.url)}`)}`);
1461
+ logger.warn({
1462
+ id: record.id,
1463
+ status: record.status,
1464
+ reason: record.errorMessage
1465
+ }, "deploy: failed (files)");
1466
+ return {
1467
+ deployment: result,
1468
+ status: "fail",
1469
+ message
1470
+ };
1471
+ }
1472
+ print(` ${style.success(withScheme(result.url))}`);
1473
+ logger.info({
1474
+ url: result.url,
1475
+ id: result.deploymentId
1476
+ }, "deploy: ready (files)");
1477
+ return {
1478
+ deployment: result,
1479
+ status: "ok"
1480
+ };
1481
+ } catch (err) {
1482
+ const message = err instanceof Error ? err.message : String(err);
1483
+ print(` ${style.fail(message)}`);
1484
+ logger.warn({
1485
+ dir: input.dir,
1486
+ reason: message
1487
+ }, "deploy: failed (files)");
1488
+ return {
1489
+ deployment: null,
1490
+ status: "fail",
1491
+ message
1492
+ };
1493
+ }
1494
+ }
1291
1495
  //#endregion
1292
1496
  //#region src/commands/doctor.ts
1293
1497
  async function runDoctor(input) {
@@ -3696,10 +3900,13 @@ async function runSecretsSync(input) {
3696
3900
  const rows = [];
3697
3901
  if (loader.has("secrets")) {
3698
3902
  const secrets = loader.get("secrets");
3699
- print(style.step("secrets (repo scope)"));
3903
+ const scope = input.githubSecretScope ?? { kind: "repo" };
3904
+ const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
3905
+ print(style.step("secrets"));
3700
3906
  for (const key of keys) {
3701
- rows.push(await runRow(`secrets:${secrets.providerName}`, "scope=repo", key, dryRun, async () => {
3702
- await secrets.setSecret({ kind: "repo" }, key, envVars[key]);
3907
+ if (allowedKeys && !allowedKeys.has(key)) continue;
3908
+ rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
3909
+ await secrets.setSecret(scope, key, envVars[key]);
3703
3910
  }));
3704
3911
  print(formatRow(rows[rows.length - 1]));
3705
3912
  }
@@ -3791,11 +3998,114 @@ function formatRow(row) {
3791
3998
  if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3792
3999
  return ` ${style.dim(`· ${label}`)}`;
3793
4000
  }
4001
+ function scopeLabel(scope) {
4002
+ if (scope.kind === "repo") return "scope=repo";
4003
+ if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
4004
+ return `scope=environment,env=${scope.name}`;
4005
+ }
3794
4006
  function vaultProviderName(loader) {
3795
4007
  if (!loader.has("vault")) return "<missing>";
3796
4008
  return loader.get("vault").providerName;
3797
4009
  }
3798
4010
  //#endregion
4011
+ //#region src/commands/setup/derived-properties.ts
4012
+ /**
4013
+ * Repo archetype, in priority order:
4014
+ * 1. `*-template` repo name → `template` (independent of package shape).
4015
+ * 2. Monorepo whose primary published artifact is a CLI (some workspace
4016
+ * package has `bin`) → `platform` — matches `holocron` itself: the CLI is
4017
+ * the deliverable, the plugin packages are supporting infrastructure for
4018
+ * it, not separate products.
4019
+ * 3. Monorepo with no CLI but at least one non-private workspace package →
4020
+ * `library` — matches `clients`/`configs`/`utils`/`themes`/
4021
+ * `observability`: a family of published packages, none of them a CLI.
4022
+ * Checked *before* falling into the root-package branch below, because
4023
+ * these repos' own root `package.json` is private and often carries
4024
+ * `@theholocron/astro-config` for a docs site built from the monorepo
4025
+ * root — without this check they'd wrongly resolve to `docs` off that
4026
+ * root-level signal alone, when the docs site is a secondary concern of
4027
+ * a library collection, not what the repo fundamentally is.
4028
+ * 4. Single-package repo with `package.json#bin` → `cli`.
4029
+ * 5. Docs-only site (astro/starlight present, root package private, no
4030
+ * publishable exports) → `docs`.
4031
+ * 6. Private, non-publishable, non-docs → `app`.
4032
+ * 7. Otherwise, a single publishable package → `library`.
4033
+ */
4034
+ function deriveProfile(input) {
4035
+ if (/-template$/.test(input.repoName)) return "template";
4036
+ if (input.isMonorepo) {
4037
+ if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
4038
+ if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
4039
+ }
4040
+ const pkg = input.rootPackageJson;
4041
+ if (pkg?.bin) return "cli";
4042
+ const deps = {
4043
+ ...pkg?.dependencies,
4044
+ ...pkg?.devDependencies
4045
+ };
4046
+ const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
4047
+ if (pkg?.private && isDocsSite) return "docs";
4048
+ if (pkg?.private) return "app";
4049
+ return "library";
4050
+ }
4051
+ const KNOWN_STACK_DEPS = [
4052
+ "next",
4053
+ "vite",
4054
+ "astro",
4055
+ "tsdown",
4056
+ "rollup",
4057
+ "webpack",
4058
+ "storybook",
4059
+ "vitest",
4060
+ "playwright",
4061
+ "turbo"
4062
+ ];
4063
+ /**
4064
+ * Detected build/framework tooling — the literal "does this repo need
4065
+ * next/vite/tsdown" signal `holocron.config.ts` has no field for today
4066
+ * (framework choice isn't a config concept, unlike `providers`/`tasks`).
4067
+ * Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
4068
+ * frameworks show up rather than trying to detect anything on npm.
4069
+ */
4070
+ function deriveStack(pkg) {
4071
+ const deps = {
4072
+ ...pkg?.dependencies,
4073
+ ...pkg?.devDependencies
4074
+ };
4075
+ return KNOWN_STACK_DEPS.filter((name) => name in deps);
4076
+ }
4077
+ /** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
4078
+ function deriveCapabilities(providers) {
4079
+ return Object.keys(providers ?? {}).sort();
4080
+ }
4081
+ /**
4082
+ * `source` + `ci` are the minimal baseline every repo `holocron setup`
4083
+ * touches wires — every `holocron.config.ts` in this org already satisfies
4084
+ * this, so `non-compliant` here means drift (a provider manually removed
4085
+ * after setup), not an unmet aspirational policy. Deliberately not a richer
4086
+ * per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
4087
+ * a Phase B (GitHub App) refinement once it can post *why* on a check run.
4088
+ */
4089
+ const REQUIRED_BASELINE = ["source", "ci"];
4090
+ function deriveCompliance(capabilities) {
4091
+ return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
4092
+ }
4093
+ /**
4094
+ * Reads each workspace package's actual `package.json` content (not just
4095
+ * `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
4096
+ * `platform` detection needs `bin`, which that helper doesn't carry).
4097
+ * Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
4098
+ * own soft-fail behavior.
4099
+ */
4100
+ async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
4101
+ const results = [];
4102
+ for (const { slug, dir = "packages" } of workspacePackages) try {
4103
+ const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
4104
+ results.push(JSON.parse(raw));
4105
+ } catch {}
4106
+ return results;
4107
+ }
4108
+ //#endregion
3799
4109
  //#region src/commands/setup/labels.ts
3800
4110
  const CANONICAL_LABELS = [
3801
4111
  {
@@ -3935,6 +4245,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
3935
4245
  //#region src/templates/configs/alexjs/alexrc.json
3936
4246
  var alexrc_default = { allow: [
3937
4247
  "dead",
4248
+ "execute",
4249
+ "execution",
4250
+ "executes",
3938
4251
  "failure",
3939
4252
  "failures",
3940
4253
  "hook",
@@ -3953,12 +4266,21 @@ function createIgnoreConfig$1() {
3953
4266
  }
3954
4267
  //#endregion
3955
4268
  //#region src/templates/configs/commit-msg/commit-msg
3956
- var commit_msg_default = "# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to commitlint's own auto-discovery of a local commitlint.config.ts when\n# the shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nCOMMITLINT_CONFIG=\"node_modules/@theholocron/commitlint-config/dist/index.js\"\nif [ -f \"$COMMITLINT_CONFIG\" ]; then\n npx --no -- commitlint --config \"$COMMITLINT_CONFIG\" --edit \"$1\"\nelse\n npx --no -- commitlint --edit \"$1\"\nfi\n";
4269
+ var commit_msg_default = "__HOLOCRON_SCRIPT__ lint commit-msg \"$1\"\n";
3957
4270
  //#endregion
3958
4271
  //#region src/templates/configs/commit-msg/create-config.ts
3959
4272
  const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
3960
- function createConfig$7() {
3961
- return `${workflowHeader$8("shebang")}${commit_msg_default}`;
4273
+ /**
4274
+ * `.husky/commit-msg` — lints the commit message via `holocron lint
4275
+ * commit-msg` (holocron#789), the real programmatic replacement for
4276
+ * shelling out to `commitlint --edit`. Installed by `holocron setup` when
4277
+ * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4278
+ *
4279
+ * @param holocronScript - the command that invokes the CLI. Defaults to
4280
+ * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4281
+ */
4282
+ function createConfig$7(holocronScript = "pnpm exec holocron") {
4283
+ return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
3962
4284
  }
3963
4285
  //#endregion
3964
4286
  //#region src/templates/configs/devmoji/create-config.ts
@@ -3974,7 +4296,7 @@ function createConfig$6() {
3974
4296
  }
3975
4297
  //#endregion
3976
4298
  //#region src/templates/configs/editorconfig/editorconfig
3977
- var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
4299
+ var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\nindent_style = unset\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
3978
4300
  //#endregion
3979
4301
  //#region src/templates/configs/editorconfig/create-config.ts
3980
4302
  const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
@@ -4954,7 +5276,10 @@ async function runSetup(input) {
4954
5276
  print(formatStep(steps[steps.length - 1]));
4955
5277
  if (effectivePreset && effectivePreset !== "none") {
4956
5278
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
4957
- await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
5279
+ await source.updateRepoSettings({
5280
+ ...BALANCED_REPO_SETTINGS,
5281
+ ...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
5282
+ });
4958
5283
  }));
4959
5284
  print(formatStep(steps[steps.length - 1]));
4960
5285
  const tasksConfig = { tasks: config.tasks };
@@ -5061,7 +5386,7 @@ async function runSetup(input) {
5061
5386
  }));
5062
5387
  print(formatStep(steps[steps.length - 1]));
5063
5388
  steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5064
- await source.writeRepoFile(".husky/commit-msg", createConfig$7());
5389
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5065
5390
  }));
5066
5391
  print(formatStep(steps[steps.length - 1]));
5067
5392
  if (hooksEnabled) {
@@ -5135,13 +5460,20 @@ async function runSetup(input) {
5135
5460
  await source.writeRepoFile("turbo.json", content);
5136
5461
  }));
5137
5462
  print(formatStep(steps[steps.length - 1]));
5138
- const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5463
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5464
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5139
5465
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5140
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5466
+ const rootScripts = rootPkg?.scripts ?? {};
5141
5467
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5142
5468
  if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5143
5469
  await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5144
5470
  }));
5471
+ if (rootPkgRaw) {
5472
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5473
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5474
+ await source.writeRepoFile("package.json", depFix.content);
5475
+ }));
5476
+ }
5145
5477
  }
5146
5478
  print(formatStep(steps[steps.length - 1]));
5147
5479
  }
@@ -5152,7 +5484,7 @@ async function runSetup(input) {
5152
5484
  print(formatStep(steps[steps.length - 1]));
5153
5485
  }
5154
5486
  const properties = {};
5155
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
5487
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
5156
5488
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
5157
5489
  properties["monorepo"] = String(isMonorepo);
5158
5490
  const manual = repo?.properties ?? {};
@@ -5160,6 +5492,21 @@ async function runSetup(input) {
5160
5492
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5161
5493
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5162
5494
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5495
+ {
5496
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5497
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5498
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5499
+ properties["holocron_profile"] = deriveProfile({
5500
+ rootPackageJson,
5501
+ repoName: basename(input.context.repoRoot),
5502
+ isMonorepo,
5503
+ workspacePackageJsons
5504
+ });
5505
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
5506
+ const capabilities = deriveCapabilities(config.providers);
5507
+ properties["holocron_capabilities"] = capabilities;
5508
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5509
+ }
5163
5510
  if (source.syncProperties) {
5164
5511
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5165
5512
  print(formatStep(steps[steps.length - 1]));
@@ -5178,34 +5525,36 @@ async function runSetup(input) {
5178
5525
  print(formatStep(steps[steps.length - 1]));
5179
5526
  }
5180
5527
  const teams = repo?.teams ?? [];
5181
- if (teams.length > 0) if (source.syncTeams) {
5182
- steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5183
- print(formatStep(steps[steps.length - 1]));
5184
- const repoCoord = input.context.repo ?? repo?.name ?? "";
5185
- const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5186
- const writeableTeams = teams.map((t) => typeof t === "string" ? {
5187
- slug: t,
5188
- permission: "push"
5189
- } : t).filter((t) => [
5190
- "push",
5191
- "maintain",
5192
- "admin"
5193
- ].includes(t.permission));
5194
- if (org && writeableTeams.length > 0) {
5195
- steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5196
- const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5197
- await source.writeRepoFile(".github/CODEOWNERS", content);
5198
- }));
5528
+ if (teams.length > 0) {
5529
+ if (source.syncTeams) {
5530
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5531
+ print(formatStep(steps[steps.length - 1]));
5532
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
5533
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5534
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
5535
+ slug: t,
5536
+ permission: "push"
5537
+ } : t).filter((t) => [
5538
+ "push",
5539
+ "maintain",
5540
+ "admin"
5541
+ ].includes(t.permission));
5542
+ if (org && writeableTeams.length > 0) {
5543
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5544
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5545
+ await source.writeRepoFile(".github/CODEOWNERS", content);
5546
+ }));
5547
+ print(formatStep(steps[steps.length - 1]));
5548
+ }
5549
+ } else {
5550
+ steps.push({
5551
+ capability: "source",
5552
+ step: "sync teams",
5553
+ status: "skip",
5554
+ message: "provider does not implement syncTeams"
5555
+ });
5199
5556
  print(formatStep(steps[steps.length - 1]));
5200
5557
  }
5201
- } else {
5202
- steps.push({
5203
- capability: "source",
5204
- step: "sync teams",
5205
- status: "skip",
5206
- message: "provider does not implement syncTeams"
5207
- });
5208
- print(formatStep(steps[steps.length - 1]));
5209
5558
  }
5210
5559
  }
5211
5560
  if (loader.has("environments")) {
@@ -5261,11 +5610,9 @@ async function runSetup(input) {
5261
5610
  const wikiProxy = wiki.proxyConfig?.();
5262
5611
  if (wikiDns && loader.has("dns")) {
5263
5612
  const dns = loader.get("dns");
5264
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5613
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5265
5614
  await dns.upsertRecord(wikiDns.zone, {
5266
- type: "CNAME",
5267
- name: wikiDns.cname,
5268
- content: wikiDns.target,
5615
+ ...wikiDns.record,
5269
5616
  ttl: 1,
5270
5617
  ...wikiProxy ? { proxied: true } : {}
5271
5618
  });
@@ -5274,8 +5621,8 @@ async function runSetup(input) {
5274
5621
  }
5275
5622
  if (wikiProxy && wikiDns && loader.has("workers")) {
5276
5623
  const workers = loader.get("workers");
5277
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5278
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5624
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5625
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5279
5626
  }));
5280
5627
  print(formatStep(steps[steps.length - 1]));
5281
5628
  }
@@ -5288,11 +5635,31 @@ async function runSetup(input) {
5288
5635
  org: config.org,
5289
5636
  domain: config.domain
5290
5637
  }) : null;
5638
+ const ensureCustomDomain = async (projectId, hostname) => {
5639
+ if (!deploy.ensureCustomDomain) return;
5640
+ const customDomainDns = { value: null };
5641
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5642
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5643
+ }));
5644
+ print(formatStep(steps[steps.length - 1]));
5645
+ if (customDomainDns.value && loader.has("dns")) {
5646
+ const dns = loader.get("dns");
5647
+ const dnsRecord = customDomainDns.value;
5648
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5649
+ await dns.upsertRecord(dnsRecord.zone, {
5650
+ ...dnsRecord.record,
5651
+ ttl: 1
5652
+ });
5653
+ }));
5654
+ print(formatStep(steps[steps.length - 1]));
5655
+ }
5656
+ };
5291
5657
  if (!previewCfg) {
5292
5658
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5293
5659
  await deploy.ensureProject({ name: config.name });
5294
5660
  }));
5295
5661
  print(formatStep(steps[steps.length - 1]));
5662
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5296
5663
  }
5297
5664
  if (previewCfg) {
5298
5665
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5300,13 +5667,8 @@ async function runSetup(input) {
5300
5667
  }));
5301
5668
  print(formatStep(steps[steps.length - 1]));
5302
5669
  }
5303
- if (previewCfg?.domain && deploy.ensureCustomDomain) {
5304
- steps.push(await runStep("deployment", `ensureCustomDomain ${previewCfg.domain}`, dryRun, async () => {
5305
- await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
5306
- }));
5307
- print(formatStep(steps[steps.length - 1]));
5308
- }
5309
- if (previewCfg?.domain && loader.has("dns")) {
5670
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5671
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5310
5672
  const dns = loader.get("dns");
5311
5673
  const wildcardDomain = `*.${previewCfg.domain}`;
5312
5674
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5754,11 +6116,13 @@ async function runSyncReadme(input) {
5754
6116
  }
5755
6117
  const description = loaded.resolved.description ?? "";
5756
6118
  const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
6119
+ const scripts = pkg.scripts ?? {};
6120
+ const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
5757
6121
  const sections = generateReadme({
5758
6122
  description,
5759
6123
  homepage,
5760
- scripts: pkg.scripts ?? {},
5761
- packages: pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0
6124
+ scripts,
6125
+ packages
5762
6126
  });
5763
6127
  const markerSections = {
5764
6128
  ...sections.packages !== void 0 && { packages: sections.packages },
@@ -6000,7 +6364,7 @@ async function runSyncWiki(input) {
6000
6364
  capability: "local",
6001
6365
  step: "sync wiki",
6002
6366
  status: "fail",
6003
- message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6367
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6004
6368
  };
6005
6369
  }
6006
6370
  }
@@ -6062,40 +6426,59 @@ async function runSync(input) {
6062
6426
  for (const stepName of SYNC_STEPS) {
6063
6427
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
6064
6428
  if (LOCAL_STEPS.has(stepName)) continue;
6065
- if (stepName === "labels") if (source.syncLabels) {
6066
- steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6067
- print(formatSyncStep(steps[steps.length - 1]));
6068
- } else {
6069
- steps.push({
6070
- capability: "source",
6071
- step: "sync labels",
6072
- status: "skip",
6073
- message: "provider does not implement syncLabels"
6074
- });
6075
- print(formatSyncStep(steps[steps.length - 1]));
6429
+ if (stepName === "labels") {
6430
+ if (source.syncLabels) {
6431
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6432
+ print(formatSyncStep(steps[steps.length - 1]));
6433
+ } else {
6434
+ steps.push({
6435
+ capability: "source",
6436
+ step: "sync labels",
6437
+ status: "skip",
6438
+ message: "provider does not implement syncLabels"
6439
+ });
6440
+ print(formatSyncStep(steps[steps.length - 1]));
6441
+ }
6076
6442
  }
6077
- if (stepName === "properties") if (source.syncProperties) {
6078
- const repo = config.repo;
6079
- const properties = {};
6080
- const effectivePreset = repo?.protection;
6081
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
6082
- const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6083
- properties["monorepo"] = String(isMonorepo);
6084
- const manual = repo?.properties ?? {};
6085
- if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6086
- if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6087
- if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6088
- if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6089
- steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6090
- print(formatSyncStep(steps[steps.length - 1]));
6091
- } else {
6092
- steps.push({
6093
- capability: "source",
6094
- step: "sync properties",
6095
- status: "skip",
6096
- message: "provider does not implement syncProperties"
6097
- });
6098
- print(formatSyncStep(steps[steps.length - 1]));
6443
+ if (stepName === "properties") {
6444
+ if (source.syncProperties) {
6445
+ const repo = config.repo;
6446
+ const properties = {};
6447
+ const effectivePreset = repo?.protection;
6448
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6449
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6450
+ properties["monorepo"] = String(isMonorepo);
6451
+ const manual = repo?.properties ?? {};
6452
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6453
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6454
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6455
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6456
+ {
6457
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6458
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6459
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6460
+ properties["holocron_profile"] = deriveProfile({
6461
+ rootPackageJson,
6462
+ repoName: basename(input.context.repoRoot),
6463
+ isMonorepo,
6464
+ workspacePackageJsons
6465
+ });
6466
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6467
+ const capabilities = deriveCapabilities(config.providers);
6468
+ properties["holocron_capabilities"] = capabilities;
6469
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6470
+ }
6471
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6472
+ print(formatSyncStep(steps[steps.length - 1]));
6473
+ } else {
6474
+ steps.push({
6475
+ capability: "source",
6476
+ step: "sync properties",
6477
+ status: "skip",
6478
+ message: "provider does not implement syncProperties"
6479
+ });
6480
+ print(formatSyncStep(steps[steps.length - 1]));
6481
+ }
6099
6482
  }
6100
6483
  if (stepName === "teams") {
6101
6484
  const teams = config.repo?.teams ?? [];
@@ -6307,29 +6690,34 @@ async function runSync(input) {
6307
6690
  }
6308
6691
  }
6309
6692
  }
6310
- if (stepName === "scripts") if (config.syncScripts === false) {
6311
- steps.push({
6312
- capability: "local",
6313
- step: "sync scripts",
6314
- status: "skip",
6315
- message: "syncScripts: false"
6316
- });
6317
- print(formatSyncStep(steps[steps.length - 1]));
6318
- } else {
6319
- const tasksConfig = { tasks: config.tasks };
6320
- if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6321
- const desired = createAstromech({
6322
- cwd: input.context.repoRoot,
6323
- config: tasksConfig,
6324
- logger
6325
- }).packageScripts();
6326
- steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6327
- const changed = await mergePackageJsonScripts(input.context.repoRoot, desired);
6328
- if (changed === null) return "no package.json";
6329
- if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6330
- return `${changed.join(", ")} set`;
6331
- }));
6332
- print(formatSyncStep(steps[steps.length - 1]));
6693
+ if (stepName === "scripts") {
6694
+ if (config.syncScripts === false) {
6695
+ steps.push({
6696
+ capability: "local",
6697
+ step: "sync scripts",
6698
+ status: "skip",
6699
+ message: "syncScripts: false"
6700
+ });
6701
+ print(formatSyncStep(steps[steps.length - 1]));
6702
+ } else {
6703
+ const tasksConfig = { tasks: config.tasks };
6704
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6705
+ const astromechScripts = createAstromech({
6706
+ cwd: input.context.repoRoot,
6707
+ config: tasksConfig,
6708
+ logger
6709
+ });
6710
+ const desired = astromechScripts.packageScripts();
6711
+ const turboContent = astromechScripts.turboConfig();
6712
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
6713
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6714
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
6715
+ if (changed === null) return "no package.json";
6716
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6717
+ return `${changed.join(", ")} set`;
6718
+ }));
6719
+ print(formatSyncStep(steps[steps.length - 1]));
6720
+ }
6333
6721
  }
6334
6722
  if (stepName === "turbo") {
6335
6723
  const astromechTurbo = createAstromech({
@@ -6351,9 +6739,10 @@ async function runSync(input) {
6351
6739
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6352
6740
  }));
6353
6741
  print(formatSyncStep(steps[steps.length - 1]));
6354
- const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6742
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
6743
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6355
6744
  const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6356
- const rootScripts = Object.keys(rootPkg?.scripts ?? {});
6745
+ const rootScripts = rootPkg?.scripts ?? {};
6357
6746
  const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6358
6747
  if (workspaceFix.changed) {
6359
6748
  steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
@@ -6361,6 +6750,15 @@ async function runSync(input) {
6361
6750
  }));
6362
6751
  print(formatSyncStep(steps[steps.length - 1]));
6363
6752
  }
6753
+ if (rootPkgRaw) {
6754
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
6755
+ if (depFix.changed) {
6756
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
6757
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
6758
+ }));
6759
+ print(formatSyncStep(steps[steps.length - 1]));
6760
+ }
6761
+ }
6364
6762
  }
6365
6763
  }
6366
6764
  if (stepName === "wiki") {
@@ -6444,8 +6842,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6444
6842
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6445
6843
  * listed keys, never touches the rest. Returns the sorted list of keys that
6446
6844
  * drifted (empty when all current), or `null` when there is no `package.json`.
6845
+ *
6846
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
6847
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
6848
+ * only, an *existing* script that isn't the generated `holocron run <task>
6849
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
6850
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
6851
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
6852
+ * real command and, once `turbo.json` exists and root is a real workspace
6853
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
6854
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
6855
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
6856
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
6857
+ * legitimate direct-command alternative and stays fully generator-owned.
6447
6858
  */
6448
- async function mergePackageJsonScripts(repoRoot, desired) {
6859
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6449
6860
  const pkgPath = join(repoRoot, "package.json");
6450
6861
  let content;
6451
6862
  try {
@@ -6456,15 +6867,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6456
6867
  const pkg = JSON.parse(content);
6457
6868
  const scripts = pkg.scripts ?? {};
6458
6869
  const changed = [];
6459
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6460
- scripts[name] = command;
6461
- changed.push(name);
6870
+ for (const [name, command] of Object.entries(desired)) {
6871
+ const existing = scripts[name];
6872
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
6873
+ if (existing !== command) {
6874
+ scripts[name] = command;
6875
+ changed.push(name);
6876
+ }
6462
6877
  }
6463
6878
  if (changed.length === 0) return [];
6464
6879
  pkg.scripts = scripts;
6465
6880
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6466
6881
  return changed.sort();
6467
6882
  }
6883
+ /**
6884
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
6885
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
6886
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
6887
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
6888
+ * rather than the whole string means an old wrapper survives a
6889
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
6890
+ * to update to the new one), not mistaken for a real command.
6891
+ */
6892
+ function isGeneratedRunWrapper(command, taskName) {
6893
+ return command.endsWith(` run ${taskName} --`);
6894
+ }
6468
6895
  const README_DESC_START = "<!-- holocron:description -->";
6469
6896
  const README_DESC_END = "<!-- /holocron:description -->";
6470
6897
  async function updateReadmeDescription(repoRoot, description) {
@@ -7331,6 +7758,63 @@ async function runUpgradeNode(input) {
7331
7758
  };
7332
7759
  }
7333
7760
  //#endregion
7761
+ //#region src/commit-lint/lint-message.ts
7762
+ /**
7763
+ * Lints one commit message string against an already-loaded commitlint
7764
+ * config. The genuinely environment-independent half of commit-message
7765
+ * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
7766
+ * caller (Sentinel isolates itself from any ambient config since it runs
7767
+ * standalone, away from a real repo checkout; `lintCommitMsgFile` below
7768
+ * just lets cosmiconfig discover the invoking repo's own
7769
+ * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
7770
+ * but linting one message against a loaded config is identical either way.
7771
+ *
7772
+ * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
7773
+ * itself uses internally — never a reimplementation of commitlint's rules.
7774
+ */
7775
+ /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
7776
+ function selectParserOpts(parserPreset) {
7777
+ return parserPreset?.parserOpts;
7778
+ }
7779
+ async function lintCommitMessage(message, loaded) {
7780
+ const opts = {
7781
+ /* istanbul ignore next -- see comment above */
7782
+ parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
7783
+ plugins: loaded.plugins,
7784
+ /* istanbul ignore next -- see comment above */
7785
+ ignores: loaded.ignores ?? [],
7786
+ defaultIgnores: loaded.defaultIgnores !== false
7787
+ };
7788
+ const outcome = await lint(message, loaded.rules, opts);
7789
+ if (outcome.valid) return [];
7790
+ return outcome.errors.map((error) => ({
7791
+ rule: error.name,
7792
+ message: error.message
7793
+ }));
7794
+ }
7795
+ //#endregion
7796
+ //#region src/commit-lint/lint-commit-msg-file.ts
7797
+ /**
7798
+ * `holocron lint commit-msg <file>` — a real programmatic replacement for
7799
+ * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
7800
+ * (holocron#789). Matches that CLI's own contract exactly: `file` is a
7801
+ * path to the commit message being written (the arg git's `commit-msg`
7802
+ * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
7803
+ * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
7804
+ * discovers the invoking repo's real `commitlint.config.*` file, exactly
7805
+ * as `commitlint --edit` (no `--config` flag) already does today. Unlike
7806
+ * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
7807
+ * runs inside a real repo checkout with a real `node_modules`, not
7808
+ * standalone on a server.
7809
+ */
7810
+ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
7811
+ const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
7812
+ return {
7813
+ valid: violations.length === 0,
7814
+ violations
7815
+ };
7816
+ }
7817
+ //#endregion
7334
7818
  //#region src/config/load-config.ts
7335
7819
  /**
7336
7820
  * `holocron.config.{ts,js,mjs,cjs,json}` loader.
@@ -7441,7 +7925,9 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
7441
7925
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
7442
7926
  * yargs registrations (this module does not introspect yargs at runtime).
7443
7927
  * `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
7444
- * interactive prompt in front of it would work against that.
7928
+ * interactive prompt in front of it would work against that. `lint
7929
+ * commit-msg <file>` is excluded for the same reason: it's invoked
7930
+ * exclusively from `.husky/commit-msg "$1"`, never typed by a human.
7445
7931
  */
7446
7932
  const COMMAND_REGISTRY = [
7447
7933
  {
@@ -7752,7 +8238,7 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
7752
8238
  //#endregion
7753
8239
  //#region src/update-notifier.ts
7754
8240
  const PACKAGE_NAME = "@theholocron/cli";
7755
- const CACHE_TTL_MS = 1440 * 60 * 1e3;
8241
+ const CACHE_TTL_MS = 864e5;
7756
8242
  const FETCH_TIMEOUT_MS = 3e3;
7757
8243
  function getCacheDir() {
7758
8244
  return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
@@ -8118,9 +8604,17 @@ try {
8118
8604
  type: "array",
8119
8605
  default: ["production", "preview"],
8120
8606
  describe: "Deployment targets to sync to. Defaults to production + preview."
8607
+ }).option("github-secret", {
8608
+ type: "array",
8609
+ describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
8610
+ }).option("github-secret-scope", {
8611
+ type: "string",
8612
+ default: "repo",
8613
+ describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
8121
8614
  }), async (argv) => {
8122
8615
  const tokens = tokenContext(argv.token);
8123
8616
  if (!tokens) return;
8617
+ const githubSecretScope = parseScope(argv.githubSecretScope);
8124
8618
  const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
8125
8619
  const loaded = await loadConfig(argv.cwd);
8126
8620
  applyResolvedConfig(argv, loaded.resolved);
@@ -8134,11 +8628,13 @@ try {
8134
8628
  },
8135
8629
  environmentId,
8136
8630
  ...argv.projectId ? { projectId: argv.projectId } : {},
8631
+ ...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
8632
+ githubSecretScope,
8137
8633
  targets: argv.target
8138
8634
  })).summary.fail > 0) process.exitCode = 1;
8139
8635
  }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
8140
8636
  type: "string",
8141
- describe: "Git branch to deploy"
8637
+ describe: "Git branch to deploy (omit when using --files)"
8142
8638
  }).option("project-id", {
8143
8639
  type: "string",
8144
8640
  demandOption: true,
@@ -8147,20 +8643,34 @@ try {
8147
8643
  type: "string",
8148
8644
  choices: ["production", "staging"],
8149
8645
  describe: "Named environment to deploy into. Omit for a branch preview."
8150
- }), async (argv) => {
8646
+ }).option("files", {
8647
+ type: "string",
8648
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
8649
+ }).conflicts("files", "branch"), async (argv) => {
8151
8650
  const tokens = tokenContext(argv.token);
8152
8651
  if (!tokens) return;
8153
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8154
8652
  const loaded = await loadConfig(argv.cwd);
8155
8653
  applyResolvedConfig(argv, loaded.resolved);
8654
+ const context = {
8655
+ repoRoot: argv.cwd,
8656
+ dryRun: argv.dryRun,
8657
+ ...tokens,
8658
+ org: resolveOrg(argv, loaded.resolved)
8659
+ };
8660
+ if (argv.files) {
8661
+ if ((await runDeployFromFiles({
8662
+ loaded,
8663
+ context,
8664
+ projectId: argv.projectId,
8665
+ dir: argv.files,
8666
+ ...argv.target ? { target: argv.target } : {}
8667
+ })).status === "fail") process.exitCode = 1;
8668
+ return;
8669
+ }
8670
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8156
8671
  if ((await runDeploy({
8157
8672
  loaded,
8158
- context: {
8159
- repoRoot: argv.cwd,
8160
- dryRun: argv.dryRun,
8161
- ...tokens,
8162
- org: resolveOrg(argv, loaded.resolved)
8163
- },
8673
+ context,
8164
8674
  projectId: argv.projectId,
8165
8675
  branch,
8166
8676
  ...argv.target ? { target: argv.target } : {}
@@ -8336,7 +8846,36 @@ try {
8336
8846
  scope: argv.all ? "all" : "required",
8337
8847
  ...argv.filter ? { filter: argv.filter } : {}
8338
8848
  }).status === "fail") process.exitCode = 1;
8339
- }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8849
+ }).command("deploy-on-release", "Deploy every workspace package whose delivery.deploy task is `on: release` — run by the delivery.publish workflow's deploy job", (y) => y.option("channel", {
8850
+ type: "string",
8851
+ default: "",
8852
+ describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
8853
+ }).option("from", {
8854
+ type: "string",
8855
+ describe: "The previous release's commit, to diff against. Omit when there is none."
8856
+ }).option("to", {
8857
+ type: "string",
8858
+ describe: "The release commit. Default HEAD."
8859
+ }), async (argv) => {
8860
+ if ((await deployOnRelease({
8861
+ cwd: argv.cwd,
8862
+ channel: argv.channel,
8863
+ ...argv.from ? { from: argv.from } : {},
8864
+ ...argv.to ? { to: argv.to } : {},
8865
+ dryRun: argv.dryRun,
8866
+ print: (line) => console.log(line)
8867
+ })).status === "fail") process.exitCode = 1;
8868
+ }).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", {
8869
+ type: "string",
8870
+ demandOption: true,
8871
+ describe: "Path to the commit message file (git's commit-msg hook passes this as $1)."
8872
+ }), async (argv) => {
8873
+ const result = await lintCommitMsgFile(argv.file, argv.cwd);
8874
+ if (!result.valid) {
8875
+ for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
8876
+ process.exitCode = 1;
8877
+ }
8878
+ }), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8340
8879
  type: "string",
8341
8880
  default: "theholocron/.github",
8342
8881
  describe: "Target org/repo (default: theholocron/.github)"