@theholocron/cli 5.0.0-alpha.9 → 5.0.0-alpha.91

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";
@@ -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,6 +1222,7 @@ 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",
1219
1228
  "config show": "repo-aware",
@@ -1233,6 +1242,40 @@ function commandsInContext(context) {
1233
1242
  }
1234
1243
  //#endregion
1235
1244
  //#region src/commands/deploy.ts
1245
+ /**
1246
+ * `holocron deploy` — trigger a deployment via the configured
1247
+ * `deployment` capability.
1248
+ *
1249
+ * Two modes, one command: `runDeploy` (the default — a Git branch,
1250
+ * `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
1251
+ * a local directory of source files, `deployFunction()`, no linked
1252
+ * repo required — for a consumer with no Git history to deploy from,
1253
+ * e.g. Sentinel, shipped as an npm package). Separate functions, not
1254
+ * one branching `runDeploy`, because their inputs genuinely differ
1255
+ * (`branch` vs `dir`) and their result shapes differ too
1256
+ * (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
1257
+ * is deliberately minimal, same as `DeployScriptResult` for Workers).
1258
+ *
1259
+ * Errors clearly when `deployment` isn't loaded (no provider declared
1260
+ * in `holocron.config.json`), or when the configured provider doesn't
1261
+ * implement `deployFunction` (it's optional on `Deployment` — not
1262
+ * every provider has a files-based deploy API).
1263
+ *
1264
+ * Dry-run skips the actual deploy call but still verifies the
1265
+ * deployment capability (and, for `--files`, `deployFunction`) is
1266
+ * present, so operators can sanity-check the wiring without spinning
1267
+ * up a build.
1268
+ */
1269
+ /**
1270
+ * Provider `url` fields (Vercel's REST API, at least) come back as bare
1271
+ * hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
1272
+ * linkify and browsers won't navigate to unmodified. Printed output
1273
+ * should be a URL a human can click/paste; structured log fields keep
1274
+ * the raw provider value as-is.
1275
+ */
1276
+ function withScheme(url) {
1277
+ return /^https?:\/\//.test(url) ? url : `https://${url}`;
1278
+ }
1236
1279
  async function runDeploy(input) {
1237
1280
  const print = input.print ?? ((line) => console.log(line));
1238
1281
  const logger = input.logger ?? getLogger();
@@ -1264,7 +1307,7 @@ async function runDeploy(input) {
1264
1307
  branch: input.branch,
1265
1308
  ...input.target ? { target: input.target } : {}
1266
1309
  }));
1267
- print(` ${style.success(`${record.status} — ${record.url}`)}`);
1310
+ print(` ${style.success(`${record.status} — ${withScheme(record.url)}`)}`);
1268
1311
  logger.info({
1269
1312
  status: record.status,
1270
1313
  url: record.url,
@@ -1288,6 +1331,87 @@ async function runDeploy(input) {
1288
1331
  };
1289
1332
  }
1290
1333
  }
1334
+ const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
1335
+ ".git",
1336
+ "node_modules",
1337
+ ".turbo"
1338
+ ]);
1339
+ /** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
1340
+ function defaultWalkFiles$2(dir) {
1341
+ const results = [];
1342
+ for (const entry of readdirSync(dir)) {
1343
+ if (SKIP_DIRS$2.has(entry)) continue;
1344
+ const full = join(dir, entry);
1345
+ const stat = statSync(full);
1346
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
1347
+ else if (stat.isFile()) results.push(full);
1348
+ }
1349
+ return results;
1350
+ }
1351
+ /** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
1352
+ async function runDeployFromFiles(input) {
1353
+ const print = input.print ?? ((line) => console.log(line));
1354
+ const logger = input.logger ?? getLogger();
1355
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1356
+ await loader.load();
1357
+ assertPluginsResolvable(loader, "deploy");
1358
+ const dryRun = input.context.dryRun ?? false;
1359
+ const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
1360
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1361
+ const files = {};
1362
+ for (const abs of walkFiles(input.dir)) {
1363
+ const rel = relative(input.dir, abs).split(sep).join("/");
1364
+ files[rel] = readFile(abs);
1365
+ }
1366
+ const fileCount = Object.keys(files).length;
1367
+ logger.info({
1368
+ dir: input.dir,
1369
+ fileCount,
1370
+ target: input.target ?? "preview",
1371
+ projectId: input.projectId,
1372
+ dryRun: dryRun || void 0
1373
+ }, "deploy: start (files)");
1374
+ print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
1375
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
1376
+ const deploy = loader.get("deployment");
1377
+ if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
1378
+ if (dryRun) {
1379
+ const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
1380
+ print(` ${style.dim(`… ${message}`)}`);
1381
+ return {
1382
+ deployment: null,
1383
+ status: "dry-run",
1384
+ message
1385
+ };
1386
+ }
1387
+ try {
1388
+ const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
1389
+ files,
1390
+ ...input.target ? { target: input.target } : {}
1391
+ }));
1392
+ print(` ${style.success(withScheme(result.url))}`);
1393
+ logger.info({
1394
+ url: result.url,
1395
+ id: result.deploymentId
1396
+ }, "deploy: triggered (files)");
1397
+ return {
1398
+ deployment: result,
1399
+ status: "ok"
1400
+ };
1401
+ } catch (err) {
1402
+ const message = err instanceof Error ? err.message : String(err);
1403
+ print(` ${style.fail(message)}`);
1404
+ logger.warn({
1405
+ dir: input.dir,
1406
+ reason: message
1407
+ }, "deploy: failed (files)");
1408
+ return {
1409
+ deployment: null,
1410
+ status: "fail",
1411
+ message
1412
+ };
1413
+ }
1414
+ }
1291
1415
  //#endregion
1292
1416
  //#region src/commands/doctor.ts
1293
1417
  async function runDoctor(input) {
@@ -3696,10 +3820,13 @@ async function runSecretsSync(input) {
3696
3820
  const rows = [];
3697
3821
  if (loader.has("secrets")) {
3698
3822
  const secrets = loader.get("secrets");
3699
- print(style.step("secrets (repo scope)"));
3823
+ const scope = input.githubSecretScope ?? { kind: "repo" };
3824
+ const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
3825
+ print(style.step("secrets"));
3700
3826
  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]);
3827
+ if (allowedKeys && !allowedKeys.has(key)) continue;
3828
+ rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
3829
+ await secrets.setSecret(scope, key, envVars[key]);
3703
3830
  }));
3704
3831
  print(formatRow(rows[rows.length - 1]));
3705
3832
  }
@@ -3791,11 +3918,114 @@ function formatRow(row) {
3791
3918
  if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3792
3919
  return ` ${style.dim(`· ${label}`)}`;
3793
3920
  }
3921
+ function scopeLabel(scope) {
3922
+ if (scope.kind === "repo") return "scope=repo";
3923
+ if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
3924
+ return `scope=environment,env=${scope.name}`;
3925
+ }
3794
3926
  function vaultProviderName(loader) {
3795
3927
  if (!loader.has("vault")) return "<missing>";
3796
3928
  return loader.get("vault").providerName;
3797
3929
  }
3798
3930
  //#endregion
3931
+ //#region src/commands/setup/derived-properties.ts
3932
+ /**
3933
+ * Repo archetype, in priority order:
3934
+ * 1. `*-template` repo name → `template` (independent of package shape).
3935
+ * 2. Monorepo whose primary published artifact is a CLI (some workspace
3936
+ * package has `bin`) → `platform` — matches `holocron` itself: the CLI is
3937
+ * the deliverable, the plugin packages are supporting infrastructure for
3938
+ * it, not separate products.
3939
+ * 3. Monorepo with no CLI but at least one non-private workspace package →
3940
+ * `library` — matches `clients`/`configs`/`utils`/`themes`/
3941
+ * `observability`: a family of published packages, none of them a CLI.
3942
+ * Checked *before* falling into the root-package branch below, because
3943
+ * these repos' own root `package.json` is private and often carries
3944
+ * `@theholocron/astro-config` for a docs site built from the monorepo
3945
+ * root — without this check they'd wrongly resolve to `docs` off that
3946
+ * root-level signal alone, when the docs site is a secondary concern of
3947
+ * a library collection, not what the repo fundamentally is.
3948
+ * 4. Single-package repo with `package.json#bin` → `cli`.
3949
+ * 5. Docs-only site (astro/starlight present, root package private, no
3950
+ * publishable exports) → `docs`.
3951
+ * 6. Private, non-publishable, non-docs → `app`.
3952
+ * 7. Otherwise, a single publishable package → `library`.
3953
+ */
3954
+ function deriveProfile(input) {
3955
+ if (/-template$/.test(input.repoName)) return "template";
3956
+ if (input.isMonorepo) {
3957
+ if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
3958
+ if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
3959
+ }
3960
+ const pkg = input.rootPackageJson;
3961
+ if (pkg?.bin) return "cli";
3962
+ const deps = {
3963
+ ...pkg?.dependencies,
3964
+ ...pkg?.devDependencies
3965
+ };
3966
+ const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
3967
+ if (pkg?.private && isDocsSite) return "docs";
3968
+ if (pkg?.private) return "app";
3969
+ return "library";
3970
+ }
3971
+ const KNOWN_STACK_DEPS = [
3972
+ "next",
3973
+ "vite",
3974
+ "astro",
3975
+ "tsdown",
3976
+ "rollup",
3977
+ "webpack",
3978
+ "storybook",
3979
+ "vitest",
3980
+ "playwright",
3981
+ "turbo"
3982
+ ];
3983
+ /**
3984
+ * Detected build/framework tooling — the literal "does this repo need
3985
+ * next/vite/tsdown" signal `holocron.config.ts` has no field for today
3986
+ * (framework choice isn't a config concept, unlike `providers`/`tasks`).
3987
+ * Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
3988
+ * frameworks show up rather than trying to detect anything on npm.
3989
+ */
3990
+ function deriveStack(pkg) {
3991
+ const deps = {
3992
+ ...pkg?.dependencies,
3993
+ ...pkg?.devDependencies
3994
+ };
3995
+ return KNOWN_STACK_DEPS.filter((name) => name in deps);
3996
+ }
3997
+ /** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
3998
+ function deriveCapabilities(providers) {
3999
+ return Object.keys(providers ?? {}).sort();
4000
+ }
4001
+ /**
4002
+ * `source` + `ci` are the minimal baseline every repo `holocron setup`
4003
+ * touches wires — every `holocron.config.ts` in this org already satisfies
4004
+ * this, so `non-compliant` here means drift (a provider manually removed
4005
+ * after setup), not an unmet aspirational policy. Deliberately not a richer
4006
+ * per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
4007
+ * a Phase B (GitHub App) refinement once it can post *why* on a check run.
4008
+ */
4009
+ const REQUIRED_BASELINE = ["source", "ci"];
4010
+ function deriveCompliance(capabilities) {
4011
+ return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
4012
+ }
4013
+ /**
4014
+ * Reads each workspace package's actual `package.json` content (not just
4015
+ * `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
4016
+ * `platform` detection needs `bin`, which that helper doesn't carry).
4017
+ * Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
4018
+ * own soft-fail behavior.
4019
+ */
4020
+ async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
4021
+ const results = [];
4022
+ for (const { slug, dir = "packages" } of workspacePackages) try {
4023
+ const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
4024
+ results.push(JSON.parse(raw));
4025
+ } catch {}
4026
+ return results;
4027
+ }
4028
+ //#endregion
3799
4029
  //#region src/commands/setup/labels.ts
3800
4030
  const CANONICAL_LABELS = [
3801
4031
  {
@@ -3935,6 +4165,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
3935
4165
  //#region src/templates/configs/alexjs/alexrc.json
3936
4166
  var alexrc_default = { allow: [
3937
4167
  "dead",
4168
+ "execute",
4169
+ "execution",
4170
+ "executes",
3938
4171
  "failure",
3939
4172
  "failures",
3940
4173
  "hook",
@@ -3944,12 +4177,30 @@ var alexrc_default = { allow: [
3944
4177
  ] };
3945
4178
  //#endregion
3946
4179
  //#region src/templates/configs/alexjs/create-config.ts
3947
- const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4180
+ const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
3948
4181
  function createRcConfig() {
3949
4182
  return JSON.stringify(alexrc_default, null, 2) + "\n";
3950
4183
  }
3951
4184
  function createIgnoreConfig$1() {
3952
- return `${workflowHeader$8()}${alexignore_default}`;
4185
+ return `${workflowHeader$9()}${alexignore_default}`;
4186
+ }
4187
+ //#endregion
4188
+ //#region src/templates/configs/commit-msg/commit-msg
4189
+ var commit_msg_default = "__HOLOCRON_SCRIPT__ lint commit-msg \"$1\"\n";
4190
+ //#endregion
4191
+ //#region src/templates/configs/commit-msg/create-config.ts
4192
+ const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4193
+ /**
4194
+ * `.husky/commit-msg` — lints the commit message via `holocron lint
4195
+ * commit-msg` (holocron#789), the real programmatic replacement for
4196
+ * shelling out to `commitlint --edit`. Installed by `holocron setup` when
4197
+ * hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
4198
+ *
4199
+ * @param holocronScript - the command that invokes the CLI. Defaults to
4200
+ * `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
4201
+ */
4202
+ function createConfig$7(holocronScript = "pnpm exec holocron") {
4203
+ return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
3953
4204
  }
3954
4205
  //#endregion
3955
4206
  //#region src/templates/configs/devmoji/create-config.ts
@@ -4945,7 +5196,10 @@ async function runSetup(input) {
4945
5196
  print(formatStep(steps[steps.length - 1]));
4946
5197
  if (effectivePreset && effectivePreset !== "none") {
4947
5198
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
4948
- await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
5199
+ await source.updateRepoSettings({
5200
+ ...BALANCED_REPO_SETTINGS,
5201
+ ...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
5202
+ });
4949
5203
  }));
4950
5204
  print(formatStep(steps[steps.length - 1]));
4951
5205
  const tasksConfig = { tasks: config.tasks };
@@ -5051,6 +5305,10 @@ async function runSetup(input) {
5051
5305
  await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
5052
5306
  }));
5053
5307
  print(formatStep(steps[steps.length - 1]));
5308
+ steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5309
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
5310
+ }));
5311
+ print(formatStep(steps[steps.length - 1]));
5054
5312
  if (hooksEnabled) {
5055
5313
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
5056
5314
  await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
@@ -5105,20 +5363,38 @@ async function runSetup(input) {
5105
5363
  print(formatStep(steps[steps.length - 1]));
5106
5364
  }
5107
5365
  {
5108
- const content = createAstromech({
5366
+ const astromechTurbo = createAstromech({
5109
5367
  cwd: input.context.repoRoot,
5110
5368
  config: { tasks: config.tasks },
5111
5369
  logger
5112
- }).turboConfig();
5370
+ });
5371
+ const content = astromechTurbo.turboConfig();
5113
5372
  if (content === null) steps.push({
5114
5373
  capability: "source",
5115
5374
  step: "write turbo.json",
5116
5375
  status: "skip",
5117
5376
  message: "no fan-out-eligible tasks configured"
5118
5377
  });
5119
- else steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5120
- await source.writeRepoFile("turbo.json", content);
5121
- }));
5378
+ else {
5379
+ steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5380
+ await source.writeRepoFile("turbo.json", content);
5381
+ }));
5382
+ print(formatStep(steps[steps.length - 1]));
5383
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5384
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5385
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5386
+ const rootScripts = rootPkg?.scripts ?? {};
5387
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5388
+ if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5389
+ await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5390
+ }));
5391
+ if (rootPkgRaw) {
5392
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5393
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5394
+ await source.writeRepoFile("package.json", depFix.content);
5395
+ }));
5396
+ }
5397
+ }
5122
5398
  print(formatStep(steps[steps.length - 1]));
5123
5399
  }
5124
5400
  if (source.syncLabels) {
@@ -5128,7 +5404,7 @@ async function runSetup(input) {
5128
5404
  print(formatStep(steps[steps.length - 1]));
5129
5405
  }
5130
5406
  const properties = {};
5131
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
5407
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
5132
5408
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
5133
5409
  properties["monorepo"] = String(isMonorepo);
5134
5410
  const manual = repo?.properties ?? {};
@@ -5136,6 +5412,21 @@ async function runSetup(input) {
5136
5412
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5137
5413
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5138
5414
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5415
+ {
5416
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5417
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5418
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5419
+ properties["holocron_profile"] = deriveProfile({
5420
+ rootPackageJson,
5421
+ repoName: basename(input.context.repoRoot),
5422
+ isMonorepo,
5423
+ workspacePackageJsons
5424
+ });
5425
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
5426
+ const capabilities = deriveCapabilities(config.providers);
5427
+ properties["holocron_capabilities"] = capabilities;
5428
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5429
+ }
5139
5430
  if (source.syncProperties) {
5140
5431
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5141
5432
  print(formatStep(steps[steps.length - 1]));
@@ -5154,34 +5445,36 @@ async function runSetup(input) {
5154
5445
  print(formatStep(steps[steps.length - 1]));
5155
5446
  }
5156
5447
  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
- }));
5448
+ if (teams.length > 0) {
5449
+ if (source.syncTeams) {
5450
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
5451
+ print(formatStep(steps[steps.length - 1]));
5452
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
5453
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
5454
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
5455
+ slug: t,
5456
+ permission: "push"
5457
+ } : t).filter((t) => [
5458
+ "push",
5459
+ "maintain",
5460
+ "admin"
5461
+ ].includes(t.permission));
5462
+ if (org && writeableTeams.length > 0) {
5463
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
5464
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
5465
+ await source.writeRepoFile(".github/CODEOWNERS", content);
5466
+ }));
5467
+ print(formatStep(steps[steps.length - 1]));
5468
+ }
5469
+ } else {
5470
+ steps.push({
5471
+ capability: "source",
5472
+ step: "sync teams",
5473
+ status: "skip",
5474
+ message: "provider does not implement syncTeams"
5475
+ });
5175
5476
  print(formatStep(steps[steps.length - 1]));
5176
5477
  }
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
5478
  }
5186
5479
  }
5187
5480
  if (loader.has("environments")) {
@@ -5237,11 +5530,9 @@ async function runSetup(input) {
5237
5530
  const wikiProxy = wiki.proxyConfig?.();
5238
5531
  if (wikiDns && loader.has("dns")) {
5239
5532
  const dns = loader.get("dns");
5240
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5533
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5241
5534
  await dns.upsertRecord(wikiDns.zone, {
5242
- type: "CNAME",
5243
- name: wikiDns.cname,
5244
- content: wikiDns.target,
5535
+ ...wikiDns.record,
5245
5536
  ttl: 1,
5246
5537
  ...wikiProxy ? { proxied: true } : {}
5247
5538
  });
@@ -5250,8 +5541,8 @@ async function runSetup(input) {
5250
5541
  }
5251
5542
  if (wikiProxy && wikiDns && loader.has("workers")) {
5252
5543
  const workers = loader.get("workers");
5253
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5254
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5544
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5545
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5255
5546
  }));
5256
5547
  print(formatStep(steps[steps.length - 1]));
5257
5548
  }
@@ -5264,11 +5555,31 @@ async function runSetup(input) {
5264
5555
  org: config.org,
5265
5556
  domain: config.domain
5266
5557
  }) : null;
5558
+ const ensureCustomDomain = async (projectId, hostname) => {
5559
+ if (!deploy.ensureCustomDomain) return;
5560
+ const customDomainDns = { value: null };
5561
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5562
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5563
+ }));
5564
+ print(formatStep(steps[steps.length - 1]));
5565
+ if (customDomainDns.value && loader.has("dns")) {
5566
+ const dns = loader.get("dns");
5567
+ const dnsRecord = customDomainDns.value;
5568
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5569
+ await dns.upsertRecord(dnsRecord.zone, {
5570
+ ...dnsRecord.record,
5571
+ ttl: 1
5572
+ });
5573
+ }));
5574
+ print(formatStep(steps[steps.length - 1]));
5575
+ }
5576
+ };
5267
5577
  if (!previewCfg) {
5268
5578
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5269
5579
  await deploy.ensureProject({ name: config.name });
5270
5580
  }));
5271
5581
  print(formatStep(steps[steps.length - 1]));
5582
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5272
5583
  }
5273
5584
  if (previewCfg) {
5274
5585
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5276,13 +5587,8 @@ async function runSetup(input) {
5276
5587
  }));
5277
5588
  print(formatStep(steps[steps.length - 1]));
5278
5589
  }
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")) {
5590
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5591
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5286
5592
  const dns = loader.get("dns");
5287
5593
  const wildcardDomain = `*.${previewCfg.domain}`;
5288
5594
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5730,11 +6036,13 @@ async function runSyncReadme(input) {
5730
6036
  }
5731
6037
  const description = loaded.resolved.description ?? "";
5732
6038
  const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
6039
+ const scripts = pkg.scripts ?? {};
6040
+ const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
5733
6041
  const sections = generateReadme({
5734
6042
  description,
5735
6043
  homepage,
5736
- scripts: pkg.scripts ?? {},
5737
- packages: pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0
6044
+ scripts,
6045
+ packages
5738
6046
  });
5739
6047
  const markerSections = {
5740
6048
  ...sections.packages !== void 0 && { packages: sections.packages },
@@ -5976,7 +6284,7 @@ async function runSyncWiki(input) {
5976
6284
  capability: "local",
5977
6285
  step: "sync wiki",
5978
6286
  status: "fail",
5979
- message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
6287
+ message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
5980
6288
  };
5981
6289
  }
5982
6290
  }
@@ -6038,40 +6346,59 @@ async function runSync(input) {
6038
6346
  for (const stepName of SYNC_STEPS) {
6039
6347
  if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
6040
6348
  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]));
6349
+ if (stepName === "labels") {
6350
+ if (source.syncLabels) {
6351
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
6352
+ print(formatSyncStep(steps[steps.length - 1]));
6353
+ } else {
6354
+ steps.push({
6355
+ capability: "source",
6356
+ step: "sync labels",
6357
+ status: "skip",
6358
+ message: "provider does not implement syncLabels"
6359
+ });
6360
+ print(formatSyncStep(steps[steps.length - 1]));
6361
+ }
6052
6362
  }
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]));
6363
+ if (stepName === "properties") {
6364
+ if (source.syncProperties) {
6365
+ const repo = config.repo;
6366
+ const properties = {};
6367
+ const effectivePreset = repo?.protection;
6368
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
6369
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
6370
+ properties["monorepo"] = String(isMonorepo);
6371
+ const manual = repo?.properties ?? {};
6372
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
6373
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
6374
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
6375
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6376
+ {
6377
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6378
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6379
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6380
+ properties["holocron_profile"] = deriveProfile({
6381
+ rootPackageJson,
6382
+ repoName: basename(input.context.repoRoot),
6383
+ isMonorepo,
6384
+ workspacePackageJsons
6385
+ });
6386
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6387
+ const capabilities = deriveCapabilities(config.providers);
6388
+ properties["holocron_capabilities"] = capabilities;
6389
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6390
+ }
6391
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
6392
+ print(formatSyncStep(steps[steps.length - 1]));
6393
+ } else {
6394
+ steps.push({
6395
+ capability: "source",
6396
+ step: "sync properties",
6397
+ status: "skip",
6398
+ message: "provider does not implement syncProperties"
6399
+ });
6400
+ print(formatSyncStep(steps[steps.length - 1]));
6401
+ }
6075
6402
  }
6076
6403
  if (stepName === "teams") {
6077
6404
  const teams = config.repo?.teams ?? [];
@@ -6283,36 +6610,42 @@ async function runSync(input) {
6283
6610
  }
6284
6611
  }
6285
6612
  }
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]));
6613
+ if (stepName === "scripts") {
6614
+ if (config.syncScripts === false) {
6615
+ steps.push({
6616
+ capability: "local",
6617
+ step: "sync scripts",
6618
+ status: "skip",
6619
+ message: "syncScripts: false"
6620
+ });
6621
+ print(formatSyncStep(steps[steps.length - 1]));
6622
+ } else {
6623
+ const tasksConfig = { tasks: config.tasks };
6624
+ if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6625
+ const astromechScripts = createAstromech({
6626
+ cwd: input.context.repoRoot,
6627
+ config: tasksConfig,
6628
+ logger
6629
+ });
6630
+ const desired = astromechScripts.packageScripts();
6631
+ const turboContent = astromechScripts.turboConfig();
6632
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
6633
+ steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6634
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
6635
+ if (changed === null) return "no package.json";
6636
+ if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6637
+ return `${changed.join(", ")} set`;
6638
+ }));
6639
+ print(formatSyncStep(steps[steps.length - 1]));
6640
+ }
6309
6641
  }
6310
6642
  if (stepName === "turbo") {
6311
- const content = createAstromech({
6643
+ const astromechTurbo = createAstromech({
6312
6644
  cwd: input.context.repoRoot,
6313
6645
  config: { tasks: config.tasks },
6314
6646
  logger
6315
- }).turboConfig();
6647
+ });
6648
+ const content = astromechTurbo.turboConfig();
6316
6649
  if (content === null) {
6317
6650
  steps.push({
6318
6651
  capability: "local",
@@ -6326,6 +6659,26 @@ async function runSync(input) {
6326
6659
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6327
6660
  }));
6328
6661
  print(formatSyncStep(steps[steps.length - 1]));
6662
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
6663
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6664
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6665
+ const rootScripts = rootPkg?.scripts ?? {};
6666
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6667
+ if (workspaceFix.changed) {
6668
+ steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
6669
+ await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
6670
+ }));
6671
+ print(formatSyncStep(steps[steps.length - 1]));
6672
+ }
6673
+ if (rootPkgRaw) {
6674
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
6675
+ if (depFix.changed) {
6676
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
6677
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
6678
+ }));
6679
+ print(formatSyncStep(steps[steps.length - 1]));
6680
+ }
6681
+ }
6329
6682
  }
6330
6683
  }
6331
6684
  if (stepName === "wiki") {
@@ -6409,8 +6762,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6409
6762
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6410
6763
  * listed keys, never touches the rest. Returns the sorted list of keys that
6411
6764
  * drifted (empty when all current), or `null` when there is no `package.json`.
6765
+ *
6766
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
6767
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
6768
+ * only, an *existing* script that isn't the generated `holocron run <task>
6769
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
6770
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
6771
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
6772
+ * real command and, once `turbo.json` exists and root is a real workspace
6773
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
6774
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
6775
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
6776
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
6777
+ * legitimate direct-command alternative and stays fully generator-owned.
6412
6778
  */
6413
- async function mergePackageJsonScripts(repoRoot, desired) {
6779
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6414
6780
  const pkgPath = join(repoRoot, "package.json");
6415
6781
  let content;
6416
6782
  try {
@@ -6421,15 +6787,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6421
6787
  const pkg = JSON.parse(content);
6422
6788
  const scripts = pkg.scripts ?? {};
6423
6789
  const changed = [];
6424
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6425
- scripts[name] = command;
6426
- changed.push(name);
6790
+ for (const [name, command] of Object.entries(desired)) {
6791
+ const existing = scripts[name];
6792
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
6793
+ if (existing !== command) {
6794
+ scripts[name] = command;
6795
+ changed.push(name);
6796
+ }
6427
6797
  }
6428
6798
  if (changed.length === 0) return [];
6429
6799
  pkg.scripts = scripts;
6430
6800
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6431
6801
  return changed.sort();
6432
6802
  }
6803
+ /**
6804
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
6805
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
6806
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
6807
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
6808
+ * rather than the whole string means an old wrapper survives a
6809
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
6810
+ * to update to the new one), not mistaken for a real command.
6811
+ */
6812
+ function isGeneratedRunWrapper(command, taskName) {
6813
+ return command.endsWith(` run ${taskName} --`);
6814
+ }
6433
6815
  const README_DESC_START = "<!-- holocron:description -->";
6434
6816
  const README_DESC_END = "<!-- /holocron:description -->";
6435
6817
  async function updateReadmeDescription(repoRoot, description) {
@@ -7296,6 +7678,63 @@ async function runUpgradeNode(input) {
7296
7678
  };
7297
7679
  }
7298
7680
  //#endregion
7681
+ //#region src/commit-lint/lint-message.ts
7682
+ /**
7683
+ * Lints one commit message string against an already-loaded commitlint
7684
+ * config. The genuinely environment-independent half of commit-message
7685
+ * linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
7686
+ * caller (Sentinel isolates itself from any ambient config since it runs
7687
+ * standalone, away from a real repo checkout; `lintCommitMsgFile` below
7688
+ * just lets cosmiconfig discover the invoking repo's own
7689
+ * `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
7690
+ * but linting one message against a loaded config is identical either way.
7691
+ *
7692
+ * Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
7693
+ * itself uses internally — never a reimplementation of commitlint's rules.
7694
+ */
7695
+ /** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
7696
+ function selectParserOpts(parserPreset) {
7697
+ return parserPreset?.parserOpts;
7698
+ }
7699
+ async function lintCommitMessage(message, loaded) {
7700
+ const opts = {
7701
+ /* istanbul ignore next -- see comment above */
7702
+ parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
7703
+ plugins: loaded.plugins,
7704
+ /* istanbul ignore next -- see comment above */
7705
+ ignores: loaded.ignores ?? [],
7706
+ defaultIgnores: loaded.defaultIgnores !== false
7707
+ };
7708
+ const outcome = await lint(message, loaded.rules, opts);
7709
+ if (outcome.valid) return [];
7710
+ return outcome.errors.map((error) => ({
7711
+ rule: error.name,
7712
+ message: error.message
7713
+ }));
7714
+ }
7715
+ //#endregion
7716
+ //#region src/commit-lint/lint-commit-msg-file.ts
7717
+ /**
7718
+ * `holocron lint commit-msg <file>` — a real programmatic replacement for
7719
+ * shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
7720
+ * (holocron#789). Matches that CLI's own contract exactly: `file` is a
7721
+ * path to the commit message being written (the arg git's `commit-msg`
7722
+ * hook passes as `$1`), no `--config` override needed — `@commitlint/load`
7723
+ * runs from `cwd` with no seed, so cosmiconfig's own normal upward search
7724
+ * discovers the invoking repo's real `commitlint.config.*` file, exactly
7725
+ * as `commitlint --edit` (no `--config` flag) already does today. Unlike
7726
+ * Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
7727
+ * runs inside a real repo checkout with a real `node_modules`, not
7728
+ * standalone on a server.
7729
+ */
7730
+ async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
7731
+ const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
7732
+ return {
7733
+ valid: violations.length === 0,
7734
+ violations
7735
+ };
7736
+ }
7737
+ //#endregion
7299
7738
  //#region src/config/load-config.ts
7300
7739
  /**
7301
7740
  * `holocron.config.{ts,js,mjs,cjs,json}` loader.
@@ -7406,7 +7845,9 @@ const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
7406
7845
  * The full command surface, hand-maintained in parallel with `cli.ts`'s
7407
7846
  * yargs registrations (this module does not introspect yargs at runtime).
7408
7847
  * `run <task>` is deliberately excluded — it's CI/scripting-oriented, and an
7409
- * interactive prompt in front of it would work against that.
7848
+ * interactive prompt in front of it would work against that. `lint
7849
+ * commit-msg <file>` is excluded for the same reason: it's invoked
7850
+ * exclusively from `.husky/commit-msg "$1"`, never typed by a human.
7410
7851
  */
7411
7852
  const COMMAND_REGISTRY = [
7412
7853
  {
@@ -7717,7 +8158,7 @@ async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessag
7717
8158
  //#endregion
7718
8159
  //#region src/update-notifier.ts
7719
8160
  const PACKAGE_NAME = "@theholocron/cli";
7720
- const CACHE_TTL_MS = 1440 * 60 * 1e3;
8161
+ const CACHE_TTL_MS = 864e5;
7721
8162
  const FETCH_TIMEOUT_MS = 3e3;
7722
8163
  function getCacheDir() {
7723
8164
  return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
@@ -8083,9 +8524,17 @@ try {
8083
8524
  type: "array",
8084
8525
  default: ["production", "preview"],
8085
8526
  describe: "Deployment targets to sync to. Defaults to production + preview."
8527
+ }).option("github-secret", {
8528
+ type: "array",
8529
+ describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
8530
+ }).option("github-secret-scope", {
8531
+ type: "string",
8532
+ default: "repo",
8533
+ describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
8086
8534
  }), async (argv) => {
8087
8535
  const tokens = tokenContext(argv.token);
8088
8536
  if (!tokens) return;
8537
+ const githubSecretScope = parseScope(argv.githubSecretScope);
8089
8538
  const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
8090
8539
  const loaded = await loadConfig(argv.cwd);
8091
8540
  applyResolvedConfig(argv, loaded.resolved);
@@ -8099,11 +8548,13 @@ try {
8099
8548
  },
8100
8549
  environmentId,
8101
8550
  ...argv.projectId ? { projectId: argv.projectId } : {},
8551
+ ...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
8552
+ githubSecretScope,
8102
8553
  targets: argv.target
8103
8554
  })).summary.fail > 0) process.exitCode = 1;
8104
8555
  }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
8105
8556
  type: "string",
8106
- describe: "Git branch to deploy"
8557
+ describe: "Git branch to deploy (omit when using --files)"
8107
8558
  }).option("project-id", {
8108
8559
  type: "string",
8109
8560
  demandOption: true,
@@ -8112,20 +8563,34 @@ try {
8112
8563
  type: "string",
8113
8564
  choices: ["production", "staging"],
8114
8565
  describe: "Named environment to deploy into. Omit for a branch preview."
8115
- }), async (argv) => {
8566
+ }).option("files", {
8567
+ type: "string",
8568
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
8569
+ }).conflicts("files", "branch"), async (argv) => {
8116
8570
  const tokens = tokenContext(argv.token);
8117
8571
  if (!tokens) return;
8118
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8119
8572
  const loaded = await loadConfig(argv.cwd);
8120
8573
  applyResolvedConfig(argv, loaded.resolved);
8574
+ const context = {
8575
+ repoRoot: argv.cwd,
8576
+ dryRun: argv.dryRun,
8577
+ ...tokens,
8578
+ org: resolveOrg(argv, loaded.resolved)
8579
+ };
8580
+ if (argv.files) {
8581
+ if ((await runDeployFromFiles({
8582
+ loaded,
8583
+ context,
8584
+ projectId: argv.projectId,
8585
+ dir: argv.files,
8586
+ ...argv.target ? { target: argv.target } : {}
8587
+ })).status === "fail") process.exitCode = 1;
8588
+ return;
8589
+ }
8590
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
8121
8591
  if ((await runDeploy({
8122
8592
  loaded,
8123
- context: {
8124
- repoRoot: argv.cwd,
8125
- dryRun: argv.dryRun,
8126
- ...tokens,
8127
- org: resolveOrg(argv, loaded.resolved)
8128
- },
8593
+ context,
8129
8594
  projectId: argv.projectId,
8130
8595
  branch,
8131
8596
  ...argv.target ? { target: argv.target } : {}
@@ -8301,7 +8766,17 @@ try {
8301
8766
  scope: argv.all ? "all" : "required",
8302
8767
  ...argv.filter ? { filter: argv.filter } : {}
8303
8768
  }).status === "fail") process.exitCode = 1;
8304
- }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8769
+ }).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", {
8770
+ type: "string",
8771
+ demandOption: true,
8772
+ describe: "Path to the commit message file (git's commit-msg hook passes this as $1)."
8773
+ }), async (argv) => {
8774
+ const result = await lintCommitMsgFile(argv.file, argv.cwd);
8775
+ if (!result.valid) {
8776
+ for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
8777
+ process.exitCode = 1;
8778
+ }
8779
+ }), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
8305
8780
  type: "string",
8306
8781
  default: "theholocron/.github",
8307
8782
  describe: "Target org/repo (default: theholocron/.github)"