@theholocron/cli 5.0.0-alpha.4 → 5.0.0-alpha.40

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";
@@ -1233,6 +1233,40 @@ function commandsInContext(context) {
1233
1233
  }
1234
1234
  //#endregion
1235
1235
  //#region src/commands/deploy.ts
1236
+ /**
1237
+ * `holocron deploy` — trigger a deployment via the configured
1238
+ * `deployment` capability.
1239
+ *
1240
+ * Two modes, one command: `runDeploy` (the default — a Git branch,
1241
+ * `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
1242
+ * a local directory of source files, `deployFunction()`, no linked
1243
+ * repo required — for a consumer with no Git history to deploy from,
1244
+ * e.g. Sentinel, shipped as an npm package). Separate functions, not
1245
+ * one branching `runDeploy`, because their inputs genuinely differ
1246
+ * (`branch` vs `dir`) and their result shapes differ too
1247
+ * (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
1248
+ * is deliberately minimal, same as `DeployScriptResult` for Workers).
1249
+ *
1250
+ * Errors clearly when `deployment` isn't loaded (no provider declared
1251
+ * in `holocron.config.json`), or when the configured provider doesn't
1252
+ * implement `deployFunction` (it's optional on `Deployment` — not
1253
+ * every provider has a files-based deploy API).
1254
+ *
1255
+ * Dry-run skips the actual deploy call but still verifies the
1256
+ * deployment capability (and, for `--files`, `deployFunction`) is
1257
+ * present, so operators can sanity-check the wiring without spinning
1258
+ * up a build.
1259
+ */
1260
+ /**
1261
+ * Provider `url` fields (Vercel's REST API, at least) come back as bare
1262
+ * hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
1263
+ * linkify and browsers won't navigate to unmodified. Printed output
1264
+ * should be a URL a human can click/paste; structured log fields keep
1265
+ * the raw provider value as-is.
1266
+ */
1267
+ function withScheme(url) {
1268
+ return /^https?:\/\//.test(url) ? url : `https://${url}`;
1269
+ }
1236
1270
  async function runDeploy(input) {
1237
1271
  const print = input.print ?? ((line) => console.log(line));
1238
1272
  const logger = input.logger ?? getLogger();
@@ -1264,7 +1298,7 @@ async function runDeploy(input) {
1264
1298
  branch: input.branch,
1265
1299
  ...input.target ? { target: input.target } : {}
1266
1300
  }));
1267
- print(` ${style.success(`${record.status} — ${record.url}`)}`);
1301
+ print(` ${style.success(`${record.status} — ${withScheme(record.url)}`)}`);
1268
1302
  logger.info({
1269
1303
  status: record.status,
1270
1304
  url: record.url,
@@ -1288,6 +1322,87 @@ async function runDeploy(input) {
1288
1322
  };
1289
1323
  }
1290
1324
  }
1325
+ const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
1326
+ ".git",
1327
+ "node_modules",
1328
+ ".turbo"
1329
+ ]);
1330
+ /** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
1331
+ function defaultWalkFiles$2(dir) {
1332
+ const results = [];
1333
+ for (const entry of readdirSync(dir)) {
1334
+ if (SKIP_DIRS$2.has(entry)) continue;
1335
+ const full = join(dir, entry);
1336
+ const stat = statSync(full);
1337
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
1338
+ else if (stat.isFile()) results.push(full);
1339
+ }
1340
+ return results;
1341
+ }
1342
+ /** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
1343
+ async function runDeployFromFiles(input) {
1344
+ const print = input.print ?? ((line) => console.log(line));
1345
+ const logger = input.logger ?? getLogger();
1346
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1347
+ await loader.load();
1348
+ assertPluginsResolvable(loader, "deploy");
1349
+ const dryRun = input.context.dryRun ?? false;
1350
+ const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
1351
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1352
+ const files = {};
1353
+ for (const abs of walkFiles(input.dir)) {
1354
+ const rel = relative(input.dir, abs).split(sep).join("/");
1355
+ files[rel] = readFile(abs);
1356
+ }
1357
+ const fileCount = Object.keys(files).length;
1358
+ logger.info({
1359
+ dir: input.dir,
1360
+ fileCount,
1361
+ target: input.target ?? "preview",
1362
+ projectId: input.projectId,
1363
+ dryRun: dryRun || void 0
1364
+ }, "deploy: start (files)");
1365
+ print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
1366
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
1367
+ const deploy = loader.get("deployment");
1368
+ if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
1369
+ if (dryRun) {
1370
+ const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
1371
+ print(` ${style.dim(`… ${message}`)}`);
1372
+ return {
1373
+ deployment: null,
1374
+ status: "dry-run",
1375
+ message
1376
+ };
1377
+ }
1378
+ try {
1379
+ const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
1380
+ files,
1381
+ ...input.target ? { target: input.target } : {}
1382
+ }));
1383
+ print(` ${style.success(withScheme(result.url))}`);
1384
+ logger.info({
1385
+ url: result.url,
1386
+ id: result.deploymentId
1387
+ }, "deploy: triggered (files)");
1388
+ return {
1389
+ deployment: result,
1390
+ status: "ok"
1391
+ };
1392
+ } catch (err) {
1393
+ const message = err instanceof Error ? err.message : String(err);
1394
+ print(` ${style.fail(message)}`);
1395
+ logger.warn({
1396
+ dir: input.dir,
1397
+ reason: message
1398
+ }, "deploy: failed (files)");
1399
+ return {
1400
+ deployment: null,
1401
+ status: "fail",
1402
+ message
1403
+ };
1404
+ }
1405
+ }
1291
1406
  //#endregion
1292
1407
  //#region src/commands/doctor.ts
1293
1408
  async function runDoctor(input) {
@@ -3324,6 +3439,170 @@ async function runPublish(input = {}) {
3324
3439
  packageNames
3325
3440
  };
3326
3441
  }
3442
+ /**
3443
+ * `holocron publish` (no `--initial`) — the steady-state publish
3444
+ * `exec.publishCmd` in `release.config.ts` should call, replacing each
3445
+ * repo's hand-typed `pnpm -r --filter=./packages/* publish ...` shell (which
3446
+ * differs, today, only in monorepo-vs-single-package, provenance on/off, and
3447
+ * whether a repo needs to skip already-published packages) with one uniform
3448
+ * invocation. Part of the config-resolution workstream, #676 — see
3449
+ * `.notes/tech-config-resolution.spec.md`.
3450
+ *
3451
+ * Deliberately does not do the `--initial` bootstrap dance (npm
3452
+ * login-if-needed, Trusted Publisher next-steps printout) — a steady-state
3453
+ * publish runs in CI, authenticated via OIDC automatically, no interactive
3454
+ * step involved. Reuses `hasPackagesDir()`'s monorepo-vs-single-package
3455
+ * detection rather than duplicating it.
3456
+ */
3457
+ async function runSteadyPublish(input = {}) {
3458
+ const print = input.print ?? ((line) => console.log(line));
3459
+ const logger = input.logger ?? getLogger();
3460
+ const cwd = input.cwd ?? process.cwd();
3461
+ const tag = input.tag ?? "latest";
3462
+ const dryRun = input.dryRun ?? false;
3463
+ const otp = input.otp;
3464
+ const exec = input.exec ?? defaultExec$1;
3465
+ const provenance = input.provenance ?? true;
3466
+ const skipAlreadyPublished = input.skipAlreadyPublished ?? false;
3467
+ const entries = input.packages ?? discoverPublicPackageEntries(cwd);
3468
+ const packageNames = entries.map((e) => e.name);
3469
+ print(`Holocron publish${dryRun ? " (dry-run)" : ""}`);
3470
+ print(` cwd: ${cwd}`);
3471
+ print(` tag: ${tag}`);
3472
+ print(` provenance: ${provenance}`);
3473
+ if (skipAlreadyPublished) print(" skip-already-published: true");
3474
+ logger.info({
3475
+ tag,
3476
+ provenance,
3477
+ skipAlreadyPublished,
3478
+ dryRun: dryRun || void 0
3479
+ }, "publish: start");
3480
+ if (otp) print(` otp: <${otp.length} chars>`);
3481
+ print("");
3482
+ if (entries.length === 0) {
3483
+ const message = "nothing to publish (no packages/* and root package.json is private or unnamed)";
3484
+ print(` ✗ ${message}`);
3485
+ logger.warn({ reason: message }, "publish: done");
3486
+ return {
3487
+ status: "fail",
3488
+ message,
3489
+ packageNames
3490
+ };
3491
+ }
3492
+ const baseFlags = [
3493
+ "--access",
3494
+ "public",
3495
+ "--no-git-checks",
3496
+ "--tag",
3497
+ tag,
3498
+ ...provenance ? ["--provenance"] : []
3499
+ ];
3500
+ const otpFlags = otp ? ["--otp", otp] : [];
3501
+ if (dryRun) {
3502
+ print("");
3503
+ if (skipAlreadyPublished) for (const entry of entries) print(` … would check ${entry.name}@${entry.version}, publish if not already on npm`);
3504
+ else {
3505
+ const args = [
3506
+ ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3507
+ "publish",
3508
+ ...baseFlags,
3509
+ ...otpFlags
3510
+ ];
3511
+ print(` … (dry-run) skipping actual publish`);
3512
+ print(` would run: pnpm ${args.join(" ")}`);
3513
+ }
3514
+ logger.info({
3515
+ tag,
3516
+ status: "dry-run",
3517
+ packages: entries.length
3518
+ }, "publish: done");
3519
+ return {
3520
+ status: "dry-run",
3521
+ message: "dry-run — no publish executed",
3522
+ packageNames
3523
+ };
3524
+ }
3525
+ if (skipAlreadyPublished) {
3526
+ print(" → publishing packages not already on npm…");
3527
+ for (const entry of entries) {
3528
+ const view = await exec("npm", [
3529
+ "view",
3530
+ `${entry.name}@${entry.version}`,
3531
+ "version"
3532
+ ], { cwd });
3533
+ if (view.exitCode === 0 && view.stdout.trim()) {
3534
+ print(` skip ${entry.name}@${entry.version} (already published)`);
3535
+ continue;
3536
+ }
3537
+ const publish = await exec("pnpm", [
3538
+ "--filter",
3539
+ entry.dir,
3540
+ "publish",
3541
+ ...baseFlags,
3542
+ ...otpFlags
3543
+ ], { cwd });
3544
+ if (publish.exitCode !== 0) {
3545
+ const message = `publish failed for ${entry.name}@${entry.version} (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
3546
+ print(` ✗ ${message}`);
3547
+ logger.warn({
3548
+ tag,
3549
+ package: entry.name,
3550
+ reason: message
3551
+ }, "publish: done");
3552
+ return {
3553
+ status: "fail",
3554
+ message,
3555
+ packageNames
3556
+ };
3557
+ }
3558
+ print(` ✓ ${entry.name}@${entry.version}`);
3559
+ }
3560
+ logger.info({
3561
+ tag,
3562
+ status: "ok",
3563
+ packages: entries.length
3564
+ }, "publish: done");
3565
+ return {
3566
+ status: "ok",
3567
+ packageNames
3568
+ };
3569
+ }
3570
+ print(" → publishing all public @theholocron/* packages…");
3571
+ const publish = await exec("pnpm", [
3572
+ ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3573
+ "publish",
3574
+ ...baseFlags,
3575
+ ...otpFlags
3576
+ ], { cwd });
3577
+ if (publish.exitCode !== 0) {
3578
+ const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
3579
+ print(` ✗ ${message}`);
3580
+ if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
3581
+ print("");
3582
+ print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
3583
+ print(` pnpm exec holocron publish --otp <6-digit-code>`);
3584
+ }
3585
+ logger.warn({
3586
+ tag,
3587
+ reason: message
3588
+ }, "publish: done");
3589
+ return {
3590
+ status: "fail",
3591
+ message,
3592
+ packageNames
3593
+ };
3594
+ }
3595
+ print(" ✓ publish complete");
3596
+ logger.info({
3597
+ tag,
3598
+ status: "ok",
3599
+ packages: entries.length
3600
+ }, "publish: done");
3601
+ return {
3602
+ status: "ok",
3603
+ packageNames
3604
+ };
3605
+ }
3327
3606
  function hasPackagesDir(cwd) {
3328
3607
  return existsSync(join(cwd, "packages"));
3329
3608
  }
@@ -3337,6 +3616,37 @@ function publicPackageName(pkgPath) {
3337
3616
  return;
3338
3617
  }
3339
3618
  }
3619
+ /** Read a `package.json`'s `name` + `version`, when it's public (`!private && name`). */
3620
+ function publicPackageEntry(pkgPath, dir) {
3621
+ if (!existsSync(pkgPath)) return void 0;
3622
+ try {
3623
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
3624
+ return !pkg.private && pkg.name && pkg.version ? {
3625
+ name: pkg.name,
3626
+ version: pkg.version,
3627
+ dir
3628
+ } : void 0;
3629
+ } catch {
3630
+ return;
3631
+ }
3632
+ }
3633
+ /**
3634
+ * Same repo-layout detection as {@link discoverPublicPackages}, but carrying
3635
+ * version + a `pnpm --filter`-ready path — {@link runSteadyPublish}'s
3636
+ * `skipAlreadyPublished` mode needs both to check `npm view <pkg>@<version>`
3637
+ * and publish one workspace at a time.
3638
+ */
3639
+ function discoverPublicPackageEntries(cwd) {
3640
+ if (!hasPackagesDir(cwd)) {
3641
+ const entry = publicPackageEntry(join(cwd, "package.json"), ".");
3642
+ return entry ? [entry] : [];
3643
+ }
3644
+ const packagesDir = join(cwd, "packages");
3645
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3646
+ const entry = publicPackageEntry(join(packagesDir, e.name, "package.json"), `./packages/${e.name}`);
3647
+ return entry ? [entry] : [];
3648
+ });
3649
+ }
3340
3650
  /**
3341
3651
  * Monorepo (`packages/` present): every public `packages/*` workspace.
3342
3652
  * Single package (no `packages/`): the root `package.json`, if it's public —
@@ -3601,6 +3911,104 @@ function vaultProviderName(loader) {
3601
3911
  return loader.get("vault").providerName;
3602
3912
  }
3603
3913
  //#endregion
3914
+ //#region src/commands/setup/derived-properties.ts
3915
+ /**
3916
+ * Repo archetype, in priority order:
3917
+ * 1. `*-template` repo name → `template` (independent of package shape).
3918
+ * 2. Monorepo whose primary published artifact is a CLI (some workspace
3919
+ * package has `bin`) → `platform` — matches `holocron` itself: the CLI is
3920
+ * the deliverable, the plugin packages are supporting infrastructure for
3921
+ * it, not separate products.
3922
+ * 3. Monorepo with no CLI but at least one non-private workspace package →
3923
+ * `library` — matches `clients`/`configs`/`utils`/`themes`/
3924
+ * `observability`: a family of published packages, none of them a CLI.
3925
+ * Checked *before* falling into the root-package branch below, because
3926
+ * these repos' own root `package.json` is private and often carries
3927
+ * `@theholocron/astro-config` for a docs site built from the monorepo
3928
+ * root — without this check they'd wrongly resolve to `docs` off that
3929
+ * root-level signal alone, when the docs site is a secondary concern of
3930
+ * a library collection, not what the repo fundamentally is.
3931
+ * 4. Single-package repo with `package.json#bin` → `cli`.
3932
+ * 5. Docs-only site (astro/starlight present, root package private, no
3933
+ * publishable exports) → `docs`.
3934
+ * 6. Private, non-publishable, non-docs → `app`.
3935
+ * 7. Otherwise, a single publishable package → `library`.
3936
+ */
3937
+ function deriveProfile(input) {
3938
+ if (/-template$/.test(input.repoName)) return "template";
3939
+ if (input.isMonorepo) {
3940
+ if (input.workspacePackageJsons.some((pkg) => Boolean(pkg.bin))) return "platform";
3941
+ if (input.workspacePackageJsons.some((pkg) => pkg.private !== true)) return "library";
3942
+ }
3943
+ const pkg = input.rootPackageJson;
3944
+ if (pkg?.bin) return "cli";
3945
+ const deps = {
3946
+ ...pkg?.dependencies,
3947
+ ...pkg?.devDependencies
3948
+ };
3949
+ const isDocsSite = Boolean(deps["@theholocron/astro-config"] || deps["astro"]);
3950
+ if (pkg?.private && isDocsSite) return "docs";
3951
+ if (pkg?.private) return "app";
3952
+ return "library";
3953
+ }
3954
+ const KNOWN_STACK_DEPS = [
3955
+ "next",
3956
+ "vite",
3957
+ "astro",
3958
+ "tsdown",
3959
+ "rollup",
3960
+ "webpack",
3961
+ "storybook",
3962
+ "vitest",
3963
+ "playwright",
3964
+ "turbo"
3965
+ ];
3966
+ /**
3967
+ * Detected build/framework tooling — the literal "does this repo need
3968
+ * next/vite/tsdown" signal `holocron.config.ts` has no field for today
3969
+ * (framework choice isn't a config concept, unlike `providers`/`tasks`).
3970
+ * Curated table, not exhaustive by design — add to `KNOWN_STACK_DEPS` as new
3971
+ * frameworks show up rather than trying to detect anything on npm.
3972
+ */
3973
+ function deriveStack(pkg) {
3974
+ const deps = {
3975
+ ...pkg?.dependencies,
3976
+ ...pkg?.devDependencies
3977
+ };
3978
+ return KNOWN_STACK_DEPS.filter((name) => name in deps);
3979
+ }
3980
+ /** The provider capability keys actually wired in `providers: {}` — zero heuristics. */
3981
+ function deriveCapabilities(providers) {
3982
+ return Object.keys(providers ?? {}).sort();
3983
+ }
3984
+ /**
3985
+ * `source` + `ci` are the minimal baseline every repo `holocron setup`
3986
+ * touches wires — every `holocron.config.ts` in this org already satisfies
3987
+ * this, so `non-compliant` here means drift (a provider manually removed
3988
+ * after setup), not an unmet aspirational policy. Deliberately not a richer
3989
+ * per-profile policy yet (e.g. "a `library` must have `deployment`") — that's
3990
+ * a Phase B (GitHub App) refinement once it can post *why* on a check run.
3991
+ */
3992
+ const REQUIRED_BASELINE = ["source", "ci"];
3993
+ function deriveCompliance(capabilities) {
3994
+ return REQUIRED_BASELINE.every((key) => capabilities.includes(key)) ? "compliant" : "non-compliant";
3995
+ }
3996
+ /**
3997
+ * Reads each workspace package's actual `package.json` content (not just
3998
+ * `readWorkspacePackages()`'s `{ slug, name, dir }` — `deriveProfile()`'s
3999
+ * `platform` detection needs `bin`, which that helper doesn't carry).
4000
+ * Missing/invalid files are skipped, matching `readWorkspacePackages()`'s
4001
+ * own soft-fail behavior.
4002
+ */
4003
+ async function readWorkspacePackageJsons(repoRoot, workspacePackages) {
4004
+ const results = [];
4005
+ for (const { slug, dir = "packages" } of workspacePackages) try {
4006
+ const raw = await readFile(join(repoRoot, dir, slug, "package.json"), "utf8");
4007
+ results.push(JSON.parse(raw));
4008
+ } catch {}
4009
+ return results;
4010
+ }
4011
+ //#endregion
3604
4012
  //#region src/commands/setup/labels.ts
3605
4013
  const CANONICAL_LABELS = [
3606
4014
  {
@@ -3749,12 +4157,21 @@ var alexrc_default = { allow: [
3749
4157
  ] };
3750
4158
  //#endregion
3751
4159
  //#region src/templates/configs/alexjs/create-config.ts
3752
- const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
4160
+ const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
3753
4161
  function createRcConfig() {
3754
4162
  return JSON.stringify(alexrc_default, null, 2) + "\n";
3755
4163
  }
3756
4164
  function createIgnoreConfig$1() {
3757
- return `${workflowHeader$8()}${alexignore_default}`;
4165
+ return `${workflowHeader$9()}${alexignore_default}`;
4166
+ }
4167
+ //#endregion
4168
+ //#region src/templates/configs/commit-msg/commit-msg
4169
+ 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";
4170
+ //#endregion
4171
+ //#region src/templates/configs/commit-msg/create-config.ts
4172
+ const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
4173
+ function createConfig$7() {
4174
+ return `${workflowHeader$8("shebang")}${commit_msg_default}`;
3758
4175
  }
3759
4176
  //#endregion
3760
4177
  //#region src/templates/configs/devmoji/create-config.ts
@@ -3859,7 +4276,7 @@ function createConfig$2(holocronScript = "pnpm exec holocron") {
3859
4276
  }
3860
4277
  //#endregion
3861
4278
  //#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
3862
- var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\nnpx devmoji -e\n";
4279
+ var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\n# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to devmoji's own auto-discovery of a local devmoji.config.cjs when the\n# shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nDEVMOJI_CONFIG=\"node_modules/@theholocron/devmoji-config/dist/index.cjs\"\nif [ -f \"$DEVMOJI_CONFIG\" ]; then\n npx devmoji --config \"$DEVMOJI_CONFIG\" -e\nelse\n npx devmoji -e\nfi\n";
3863
4280
  //#endregion
3864
4281
  //#region src/templates/configs/prepare-commit-msg/create-config.ts
3865
4282
  const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
@@ -4856,6 +5273,10 @@ async function runSetup(input) {
4856
5273
  await source.writeRepoFile(".husky/prepare-commit-msg", createConfig$1());
4857
5274
  }));
4858
5275
  print(formatStep(steps[steps.length - 1]));
5276
+ steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
5277
+ await source.writeRepoFile(".husky/commit-msg", createConfig$7());
5278
+ }));
5279
+ print(formatStep(steps[steps.length - 1]));
4859
5280
  if (hooksEnabled) {
4860
5281
  steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
4861
5282
  await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
@@ -4910,20 +5331,38 @@ async function runSetup(input) {
4910
5331
  print(formatStep(steps[steps.length - 1]));
4911
5332
  }
4912
5333
  {
4913
- const content = createAstromech({
5334
+ const astromechTurbo = createAstromech({
4914
5335
  cwd: input.context.repoRoot,
4915
5336
  config: { tasks: config.tasks },
4916
5337
  logger
4917
- }).turboConfig();
5338
+ });
5339
+ const content = astromechTurbo.turboConfig();
4918
5340
  if (content === null) steps.push({
4919
5341
  capability: "source",
4920
5342
  step: "write turbo.json",
4921
5343
  status: "skip",
4922
5344
  message: "no fan-out-eligible tasks configured"
4923
5345
  });
4924
- else steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
4925
- await source.writeRepoFile("turbo.json", content);
4926
- }));
5346
+ else {
5347
+ steps.push(await runStep("source", "write turbo.json", dryRun, async () => {
5348
+ await source.writeRepoFile("turbo.json", content);
5349
+ }));
5350
+ print(formatStep(steps[steps.length - 1]));
5351
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
5352
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
5353
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5354
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5355
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5356
+ if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5357
+ await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5358
+ }));
5359
+ if (rootPkgRaw) {
5360
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
5361
+ if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
5362
+ await source.writeRepoFile("package.json", depFix.content);
5363
+ }));
5364
+ }
5365
+ }
4927
5366
  print(formatStep(steps[steps.length - 1]));
4928
5367
  }
4929
5368
  if (source.syncLabels) {
@@ -4933,7 +5372,7 @@ async function runSetup(input) {
4933
5372
  print(formatStep(steps[steps.length - 1]));
4934
5373
  }
4935
5374
  const properties = {};
4936
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
5375
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
4937
5376
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
4938
5377
  properties["monorepo"] = String(isMonorepo);
4939
5378
  const manual = repo?.properties ?? {};
@@ -4941,6 +5380,21 @@ async function runSetup(input) {
4941
5380
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
4942
5381
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
4943
5382
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5383
+ {
5384
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5385
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5386
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5387
+ properties["holocron_profile"] = deriveProfile({
5388
+ rootPackageJson,
5389
+ repoName: basename(input.context.repoRoot),
5390
+ isMonorepo,
5391
+ workspacePackageJsons
5392
+ });
5393
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
5394
+ const capabilities = deriveCapabilities(config.providers);
5395
+ properties["holocron_capabilities"] = capabilities;
5396
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5397
+ }
4944
5398
  if (source.syncProperties) {
4945
5399
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
4946
5400
  print(formatStep(steps[steps.length - 1]));
@@ -5042,11 +5496,9 @@ async function runSetup(input) {
5042
5496
  const wikiProxy = wiki.proxyConfig?.();
5043
5497
  if (wikiDns && loader.has("dns")) {
5044
5498
  const dns = loader.get("dns");
5045
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5499
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5046
5500
  await dns.upsertRecord(wikiDns.zone, {
5047
- type: "CNAME",
5048
- name: wikiDns.cname,
5049
- content: wikiDns.target,
5501
+ ...wikiDns.record,
5050
5502
  ttl: 1,
5051
5503
  ...wikiProxy ? { proxied: true } : {}
5052
5504
  });
@@ -5055,8 +5507,8 @@ async function runSetup(input) {
5055
5507
  }
5056
5508
  if (wikiProxy && wikiDns && loader.has("workers")) {
5057
5509
  const workers = loader.get("workers");
5058
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5059
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5510
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5511
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5060
5512
  }));
5061
5513
  print(formatStep(steps[steps.length - 1]));
5062
5514
  }
@@ -5069,11 +5521,31 @@ async function runSetup(input) {
5069
5521
  org: config.org,
5070
5522
  domain: config.domain
5071
5523
  }) : null;
5524
+ const ensureCustomDomain = async (projectId, hostname) => {
5525
+ if (!deploy.ensureCustomDomain) return;
5526
+ const customDomainDns = { value: null };
5527
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5528
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5529
+ }));
5530
+ print(formatStep(steps[steps.length - 1]));
5531
+ if (customDomainDns.value && loader.has("dns")) {
5532
+ const dns = loader.get("dns");
5533
+ const dnsRecord = customDomainDns.value;
5534
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5535
+ await dns.upsertRecord(dnsRecord.zone, {
5536
+ ...dnsRecord.record,
5537
+ ttl: 1
5538
+ });
5539
+ }));
5540
+ print(formatStep(steps[steps.length - 1]));
5541
+ }
5542
+ };
5072
5543
  if (!previewCfg) {
5073
5544
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5074
5545
  await deploy.ensureProject({ name: config.name });
5075
5546
  }));
5076
5547
  print(formatStep(steps[steps.length - 1]));
5548
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5077
5549
  }
5078
5550
  if (previewCfg) {
5079
5551
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5081,13 +5553,8 @@ async function runSetup(input) {
5081
5553
  }));
5082
5554
  print(formatStep(steps[steps.length - 1]));
5083
5555
  }
5084
- if (previewCfg?.domain && deploy.ensureCustomDomain) {
5085
- steps.push(await runStep("deployment", `ensureCustomDomain ${previewCfg.domain}`, dryRun, async () => {
5086
- await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
5087
- }));
5088
- print(formatStep(steps[steps.length - 1]));
5089
- }
5090
- if (previewCfg?.domain && loader.has("dns")) {
5556
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5557
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5091
5558
  const dns = loader.get("dns");
5092
5559
  const wildcardDomain = `*.${previewCfg.domain}`;
5093
5560
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5859,7 +6326,7 @@ async function runSync(input) {
5859
6326
  const repo = config.repo;
5860
6327
  const properties = {};
5861
6328
  const effectivePreset = repo?.protection;
5862
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
6329
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
5863
6330
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
5864
6331
  properties["monorepo"] = String(isMonorepo);
5865
6332
  const manual = repo?.properties ?? {};
@@ -5867,6 +6334,21 @@ async function runSync(input) {
5867
6334
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5868
6335
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5869
6336
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6337
+ {
6338
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6339
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6340
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6341
+ properties["holocron_profile"] = deriveProfile({
6342
+ rootPackageJson,
6343
+ repoName: basename(input.context.repoRoot),
6344
+ isMonorepo,
6345
+ workspacePackageJsons
6346
+ });
6347
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6348
+ const capabilities = deriveCapabilities(config.providers);
6349
+ properties["holocron_capabilities"] = capabilities;
6350
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6351
+ }
5870
6352
  steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5871
6353
  print(formatSyncStep(steps[steps.length - 1]));
5872
6354
  } else {
@@ -6099,13 +6581,16 @@ async function runSync(input) {
6099
6581
  } else {
6100
6582
  const tasksConfig = { tasks: config.tasks };
6101
6583
  if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
6102
- const desired = createAstromech({
6584
+ const astromechScripts = createAstromech({
6103
6585
  cwd: input.context.repoRoot,
6104
6586
  config: tasksConfig,
6105
6587
  logger
6106
- }).packageScripts();
6588
+ });
6589
+ const desired = astromechScripts.packageScripts();
6590
+ const turboContent = astromechScripts.turboConfig();
6591
+ const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
6107
6592
  steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
6108
- const changed = await mergePackageJsonScripts(input.context.repoRoot, desired);
6593
+ const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
6109
6594
  if (changed === null) return "no package.json";
6110
6595
  if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
6111
6596
  return `${changed.join(", ")} set`;
@@ -6113,11 +6598,12 @@ async function runSync(input) {
6113
6598
  print(formatSyncStep(steps[steps.length - 1]));
6114
6599
  }
6115
6600
  if (stepName === "turbo") {
6116
- const content = createAstromech({
6601
+ const astromechTurbo = createAstromech({
6117
6602
  cwd: input.context.repoRoot,
6118
6603
  config: { tasks: config.tasks },
6119
6604
  logger
6120
- }).turboConfig();
6605
+ });
6606
+ const content = astromechTurbo.turboConfig();
6121
6607
  if (content === null) {
6122
6608
  steps.push({
6123
6609
  capability: "local",
@@ -6131,6 +6617,26 @@ async function runSync(input) {
6131
6617
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6132
6618
  }));
6133
6619
  print(formatSyncStep(steps[steps.length - 1]));
6620
+ const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
6621
+ const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
6622
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6623
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
6624
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6625
+ if (workspaceFix.changed) {
6626
+ steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
6627
+ await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
6628
+ }));
6629
+ print(formatSyncStep(steps[steps.length - 1]));
6630
+ }
6631
+ if (rootPkgRaw) {
6632
+ const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
6633
+ if (depFix.changed) {
6634
+ steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
6635
+ await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
6636
+ }));
6637
+ print(formatSyncStep(steps[steps.length - 1]));
6638
+ }
6639
+ }
6134
6640
  }
6135
6641
  }
6136
6642
  if (stepName === "wiki") {
@@ -6214,8 +6720,21 @@ async function writePackageJsonField(repoRoot, field, value) {
6214
6720
  * Merge `desired` script entries into `package.json#scripts` — sets only the
6215
6721
  * listed keys, never touches the rest. Returns the sorted list of keys that
6216
6722
  * drifted (empty when all current), or `null` when there is no `package.json`.
6723
+ *
6724
+ * `turboEligible` (#747): task names `turbo.json` fans out — the same set
6725
+ * {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
6726
+ * only, an *existing* script that isn't the generated `holocron run <task>
6727
+ * --` wrapper is left untouched: root may genuinely be a directly-buildable
6728
+ * package (not an orchestrator) with its own real command (`tsc --noEmit`,
6729
+ * `vitest run`, …) for that task. Overwriting it would silently discard the
6730
+ * real command and, once `turbo.json` exists and root is a real workspace
6731
+ * member (#692), reintroduce infinite recursion — `turbo run <task>` →
6732
+ * root's own `<task>` script → the wrapper → `holocron run <task>` sees
6733
+ * `turbo.json` still defines the task → `turbo run <task>` again, forever.
6734
+ * Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
6735
+ * legitimate direct-command alternative and stays fully generator-owned.
6217
6736
  */
6218
- async function mergePackageJsonScripts(repoRoot, desired) {
6737
+ async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
6219
6738
  const pkgPath = join(repoRoot, "package.json");
6220
6739
  let content;
6221
6740
  try {
@@ -6226,15 +6745,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
6226
6745
  const pkg = JSON.parse(content);
6227
6746
  const scripts = pkg.scripts ?? {};
6228
6747
  const changed = [];
6229
- for (const [name, command] of Object.entries(desired)) if (scripts[name] !== command) {
6230
- scripts[name] = command;
6231
- changed.push(name);
6748
+ for (const [name, command] of Object.entries(desired)) {
6749
+ const existing = scripts[name];
6750
+ if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
6751
+ if (existing !== command) {
6752
+ scripts[name] = command;
6753
+ changed.push(name);
6754
+ }
6232
6755
  }
6233
6756
  if (changed.length === 0) return [];
6234
6757
  pkg.scripts = scripts;
6235
6758
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
6236
6759
  return changed.sort();
6237
6760
  }
6761
+ /**
6762
+ * Matches the exact shape `packageScripts()` generates for `taskName` —
6763
+ * `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
6764
+ * (`holocronScript`, itself allowed to be multi-word, e.g. `"node
6765
+ packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
6766
+ * rather than the whole string means an old wrapper survives a
6767
+ * `holocronScript` rename intact — it's still recognized as a wrapper (safe
6768
+ * to update to the new one), not mistaken for a real command.
6769
+ */
6770
+ function isGeneratedRunWrapper(command, taskName) {
6771
+ return command.endsWith(` run ${taskName} --`);
6772
+ }
6238
6773
  const README_DESC_START = "<!-- holocron:description -->";
6239
6774
  const README_DESC_END = "<!-- /holocron:description -->";
6240
6775
  async function updateReadmeDescription(repoRoot, description) {
@@ -7908,7 +8443,7 @@ try {
7908
8443
  })).summary.fail > 0) process.exitCode = 1;
7909
8444
  }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
7910
8445
  type: "string",
7911
- describe: "Git branch to deploy"
8446
+ describe: "Git branch to deploy (omit when using --files)"
7912
8447
  }).option("project-id", {
7913
8448
  type: "string",
7914
8449
  demandOption: true,
@@ -7917,20 +8452,34 @@ try {
7917
8452
  type: "string",
7918
8453
  choices: ["production", "staging"],
7919
8454
  describe: "Named environment to deploy into. Omit for a branch preview."
7920
- }), async (argv) => {
8455
+ }).option("files", {
8456
+ type: "string",
8457
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
8458
+ }).conflicts("files", "branch"), async (argv) => {
7921
8459
  const tokens = tokenContext(argv.token);
7922
8460
  if (!tokens) return;
7923
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
7924
8461
  const loaded = await loadConfig(argv.cwd);
7925
8462
  applyResolvedConfig(argv, loaded.resolved);
8463
+ const context = {
8464
+ repoRoot: argv.cwd,
8465
+ dryRun: argv.dryRun,
8466
+ ...tokens,
8467
+ org: resolveOrg(argv, loaded.resolved)
8468
+ };
8469
+ if (argv.files) {
8470
+ if ((await runDeployFromFiles({
8471
+ loaded,
8472
+ context,
8473
+ projectId: argv.projectId,
8474
+ dir: argv.files,
8475
+ ...argv.target ? { target: argv.target } : {}
8476
+ })).status === "fail") process.exitCode = 1;
8477
+ return;
8478
+ }
8479
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
7926
8480
  if ((await runDeploy({
7927
8481
  loaded,
7928
- context: {
7929
- repoRoot: argv.cwd,
7930
- dryRun: argv.dryRun,
7931
- ...tokens,
7932
- org: resolveOrg(argv, loaded.resolved)
7933
- },
8482
+ context,
7934
8483
  projectId: argv.projectId,
7935
8484
  branch,
7936
8485
  ...argv.target ? { target: argv.target } : {}
@@ -7976,11 +8525,10 @@ try {
7976
8525
  }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
7977
8526
  type: "boolean",
7978
8527
  default: false,
7979
- describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Required today — a non-initial `publish` isn't implemented yet."
8528
+ describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Skips the steady-state OIDC path below — use only for a package's very first publish."
7980
8529
  }).option("tag", {
7981
8530
  type: "string",
7982
- default: "alpha",
7983
- describe: "npm distribution tag (defaults to alpha)"
8531
+ describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
7984
8532
  }).option("otp", {
7985
8533
  type: "string",
7986
8534
  describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
@@ -7990,6 +8538,14 @@ try {
7990
8538
  }).option("repo", {
7991
8539
  type: "string",
7992
8540
  describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
8541
+ }).option("provenance", {
8542
+ type: "boolean",
8543
+ default: true,
8544
+ describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
8545
+ }).option("skip-already-published", {
8546
+ type: "boolean",
8547
+ default: false,
8548
+ describe: "Steady-state only: check `npm view <pkg>@<version>` before publishing each package and skip it if that exact version already exists, instead of one bulk publish. For repos that ship many independently-versioned packages where a release doesn't bump every one."
7993
8549
  }), async (argv) => {
7994
8550
  if (argv.syncTrust) {
7995
8551
  const [oldFile, newFile] = argv.syncTrust;
@@ -8007,15 +8563,21 @@ try {
8007
8563
  })).status === "fail") process.exitCode = 1;
8008
8564
  return;
8009
8565
  }
8010
- if (!argv.initial) {
8011
- getLogger().error("publish: only `--initial` and `--sync-trust` are supported today. Run `holocron publish --initial`.");
8012
- process.exitCode = 1;
8566
+ if (argv.initial) {
8567
+ if ((await runPublish({
8568
+ cwd: argv.cwd,
8569
+ dryRun: argv.dryRun,
8570
+ ...argv.tag ? { tag: argv.tag } : {},
8571
+ ...argv.otp ? { otp: argv.otp } : {}
8572
+ })).status === "fail") process.exitCode = 1;
8013
8573
  return;
8014
8574
  }
8015
- if ((await runPublish({
8575
+ if ((await runSteadyPublish({
8016
8576
  cwd: argv.cwd,
8017
- tag: argv.tag,
8018
8577
  dryRun: argv.dryRun,
8578
+ provenance: argv.provenance,
8579
+ skipAlreadyPublished: argv.skipAlreadyPublished,
8580
+ ...argv.tag ? { tag: argv.tag } : {},
8019
8581
  ...argv.otp ? { otp: argv.otp } : {}
8020
8582
  })).status === "fail") process.exitCode = 1;
8021
8583
  }).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {