@theholocron/cli 5.0.0-alpha.3 → 5.0.0-alpha.30

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,31 @@ 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 rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5352
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
5353
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
5354
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
5355
+ if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
5356
+ await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
5357
+ }));
5358
+ }
4927
5359
  print(formatStep(steps[steps.length - 1]));
4928
5360
  }
4929
5361
  if (source.syncLabels) {
@@ -4933,7 +5365,7 @@ async function runSetup(input) {
4933
5365
  print(formatStep(steps[steps.length - 1]));
4934
5366
  }
4935
5367
  const properties = {};
4936
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
5368
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
4937
5369
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
4938
5370
  properties["monorepo"] = String(isMonorepo);
4939
5371
  const manual = repo?.properties ?? {};
@@ -4941,6 +5373,21 @@ async function runSetup(input) {
4941
5373
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
4942
5374
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
4943
5375
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
5376
+ {
5377
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
5378
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
5379
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
5380
+ properties["holocron_profile"] = deriveProfile({
5381
+ rootPackageJson,
5382
+ repoName: basename(input.context.repoRoot),
5383
+ isMonorepo,
5384
+ workspacePackageJsons
5385
+ });
5386
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
5387
+ const capabilities = deriveCapabilities(config.providers);
5388
+ properties["holocron_capabilities"] = capabilities;
5389
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
5390
+ }
4944
5391
  if (source.syncProperties) {
4945
5392
  steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
4946
5393
  print(formatStep(steps[steps.length - 1]));
@@ -5042,11 +5489,9 @@ async function runSetup(input) {
5042
5489
  const wikiProxy = wiki.proxyConfig?.();
5043
5490
  if (wikiDns && loader.has("dns")) {
5044
5491
  const dns = loader.get("dns");
5045
- steps.push(await runStep("dns", `upsertRecord ${wikiDns.cname}`, dryRun, async () => {
5492
+ steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
5046
5493
  await dns.upsertRecord(wikiDns.zone, {
5047
- type: "CNAME",
5048
- name: wikiDns.cname,
5049
- content: wikiDns.target,
5494
+ ...wikiDns.record,
5050
5495
  ttl: 1,
5051
5496
  ...wikiProxy ? { proxied: true } : {}
5052
5497
  });
@@ -5055,8 +5500,8 @@ async function runSetup(input) {
5055
5500
  }
5056
5501
  if (wikiProxy && wikiDns && loader.has("workers")) {
5057
5502
  const workers = loader.get("workers");
5058
- steps.push(await runStep("workers", `upsertProxy ${wikiDns.cname}`, dryRun, async () => {
5059
- await workers.upsertProxy(wikiDns.cname, wikiProxy);
5503
+ steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
5504
+ await workers.upsertProxy(wikiDns.record.name, wikiProxy);
5060
5505
  }));
5061
5506
  print(formatStep(steps[steps.length - 1]));
5062
5507
  }
@@ -5069,11 +5514,31 @@ async function runSetup(input) {
5069
5514
  org: config.org,
5070
5515
  domain: config.domain
5071
5516
  }) : null;
5517
+ const ensureCustomDomain = async (projectId, hostname) => {
5518
+ if (!deploy.ensureCustomDomain) return;
5519
+ const customDomainDns = { value: null };
5520
+ steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
5521
+ customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
5522
+ }));
5523
+ print(formatStep(steps[steps.length - 1]));
5524
+ if (customDomainDns.value && loader.has("dns")) {
5525
+ const dns = loader.get("dns");
5526
+ const dnsRecord = customDomainDns.value;
5527
+ steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
5528
+ await dns.upsertRecord(dnsRecord.zone, {
5529
+ ...dnsRecord.record,
5530
+ ttl: 1
5531
+ });
5532
+ }));
5533
+ print(formatStep(steps[steps.length - 1]));
5534
+ }
5535
+ };
5072
5536
  if (!previewCfg) {
5073
5537
  steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
5074
5538
  await deploy.ensureProject({ name: config.name });
5075
5539
  }));
5076
5540
  print(formatStep(steps[steps.length - 1]));
5541
+ if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
5077
5542
  }
5078
5543
  if (previewCfg) {
5079
5544
  steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
@@ -5081,13 +5546,8 @@ async function runSetup(input) {
5081
5546
  }));
5082
5547
  print(formatStep(steps[steps.length - 1]));
5083
5548
  }
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")) {
5549
+ if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
5550
+ if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
5091
5551
  const dns = loader.get("dns");
5092
5552
  const wildcardDomain = `*.${previewCfg.domain}`;
5093
5553
  steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
@@ -5859,7 +6319,7 @@ async function runSync(input) {
5859
6319
  const repo = config.repo;
5860
6320
  const properties = {};
5861
6321
  const effectivePreset = repo?.protection;
5862
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
6322
+ if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
5863
6323
  const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
5864
6324
  properties["monorepo"] = String(isMonorepo);
5865
6325
  const manual = repo?.properties ?? {};
@@ -5867,6 +6327,21 @@ async function runSync(input) {
5867
6327
  if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
5868
6328
  if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
5869
6329
  if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
6330
+ {
6331
+ const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6332
+ const workspacePackages = readWorkspacePackages(input.context.repoRoot);
6333
+ const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
6334
+ properties["holocron_profile"] = deriveProfile({
6335
+ rootPackageJson,
6336
+ repoName: basename(input.context.repoRoot),
6337
+ isMonorepo,
6338
+ workspacePackageJsons
6339
+ });
6340
+ properties["holocron_stack"] = deriveStack(rootPackageJson);
6341
+ const capabilities = deriveCapabilities(config.providers);
6342
+ properties["holocron_capabilities"] = capabilities;
6343
+ properties["holocron_compliance"] = deriveCompliance(capabilities);
6344
+ }
5870
6345
  steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
5871
6346
  print(formatSyncStep(steps[steps.length - 1]));
5872
6347
  } else {
@@ -6113,11 +6588,12 @@ async function runSync(input) {
6113
6588
  print(formatSyncStep(steps[steps.length - 1]));
6114
6589
  }
6115
6590
  if (stepName === "turbo") {
6116
- const content = createAstromech({
6591
+ const astromechTurbo = createAstromech({
6117
6592
  cwd: input.context.repoRoot,
6118
6593
  config: { tasks: config.tasks },
6119
6594
  logger
6120
- }).turboConfig();
6595
+ });
6596
+ const content = astromechTurbo.turboConfig();
6121
6597
  if (content === null) {
6122
6598
  steps.push({
6123
6599
  capability: "local",
@@ -6131,6 +6607,16 @@ async function runSync(input) {
6131
6607
  await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
6132
6608
  }));
6133
6609
  print(formatSyncStep(steps[steps.length - 1]));
6610
+ const rootPkg = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
6611
+ const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
6612
+ const rootScripts = Object.keys(rootPkg?.scripts ?? {});
6613
+ const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
6614
+ if (workspaceFix.changed) {
6615
+ steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
6616
+ await writeFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), workspaceFix.content, "utf8");
6617
+ }));
6618
+ print(formatSyncStep(steps[steps.length - 1]));
6619
+ }
6134
6620
  }
6135
6621
  }
6136
6622
  if (stepName === "wiki") {
@@ -7908,7 +8394,7 @@ try {
7908
8394
  })).summary.fail > 0) process.exitCode = 1;
7909
8395
  }).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
7910
8396
  type: "string",
7911
- describe: "Git branch to deploy"
8397
+ describe: "Git branch to deploy (omit when using --files)"
7912
8398
  }).option("project-id", {
7913
8399
  type: "string",
7914
8400
  demandOption: true,
@@ -7917,20 +8403,34 @@ try {
7917
8403
  type: "string",
7918
8404
  choices: ["production", "staging"],
7919
8405
  describe: "Named environment to deploy into. Omit for a branch preview."
7920
- }), async (argv) => {
8406
+ }).option("files", {
8407
+ type: "string",
8408
+ describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
8409
+ }).conflicts("files", "branch"), async (argv) => {
7921
8410
  const tokens = tokenContext(argv.token);
7922
8411
  if (!tokens) return;
7923
- const [branch] = await promptForPositionals(getEntry("deploy"), argv);
7924
8412
  const loaded = await loadConfig(argv.cwd);
7925
8413
  applyResolvedConfig(argv, loaded.resolved);
8414
+ const context = {
8415
+ repoRoot: argv.cwd,
8416
+ dryRun: argv.dryRun,
8417
+ ...tokens,
8418
+ org: resolveOrg(argv, loaded.resolved)
8419
+ };
8420
+ if (argv.files) {
8421
+ if ((await runDeployFromFiles({
8422
+ loaded,
8423
+ context,
8424
+ projectId: argv.projectId,
8425
+ dir: argv.files,
8426
+ ...argv.target ? { target: argv.target } : {}
8427
+ })).status === "fail") process.exitCode = 1;
8428
+ return;
8429
+ }
8430
+ const [branch] = await promptForPositionals(getEntry("deploy"), argv);
7926
8431
  if ((await runDeploy({
7927
8432
  loaded,
7928
- context: {
7929
- repoRoot: argv.cwd,
7930
- dryRun: argv.dryRun,
7931
- ...tokens,
7932
- org: resolveOrg(argv, loaded.resolved)
7933
- },
8433
+ context,
7934
8434
  projectId: argv.projectId,
7935
8435
  branch,
7936
8436
  ...argv.target ? { target: argv.target } : {}
@@ -7976,11 +8476,10 @@ try {
7976
8476
  }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
7977
8477
  type: "boolean",
7978
8478
  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."
8479
+ 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
8480
  }).option("tag", {
7981
8481
  type: "string",
7982
- default: "alpha",
7983
- describe: "npm distribution tag (defaults to alpha)"
8482
+ describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
7984
8483
  }).option("otp", {
7985
8484
  type: "string",
7986
8485
  describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
@@ -7990,6 +8489,14 @@ try {
7990
8489
  }).option("repo", {
7991
8490
  type: "string",
7992
8491
  describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
8492
+ }).option("provenance", {
8493
+ type: "boolean",
8494
+ default: true,
8495
+ describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
8496
+ }).option("skip-already-published", {
8497
+ type: "boolean",
8498
+ default: false,
8499
+ 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
8500
  }), async (argv) => {
7994
8501
  if (argv.syncTrust) {
7995
8502
  const [oldFile, newFile] = argv.syncTrust;
@@ -8007,15 +8514,21 @@ try {
8007
8514
  })).status === "fail") process.exitCode = 1;
8008
8515
  return;
8009
8516
  }
8010
- if (!argv.initial) {
8011
- getLogger().error("publish: only `--initial` and `--sync-trust` are supported today. Run `holocron publish --initial`.");
8012
- process.exitCode = 1;
8517
+ if (argv.initial) {
8518
+ if ((await runPublish({
8519
+ cwd: argv.cwd,
8520
+ dryRun: argv.dryRun,
8521
+ ...argv.tag ? { tag: argv.tag } : {},
8522
+ ...argv.otp ? { otp: argv.otp } : {}
8523
+ })).status === "fail") process.exitCode = 1;
8013
8524
  return;
8014
8525
  }
8015
- if ((await runPublish({
8526
+ if ((await runSteadyPublish({
8016
8527
  cwd: argv.cwd,
8017
- tag: argv.tag,
8018
8528
  dryRun: argv.dryRun,
8529
+ provenance: argv.provenance,
8530
+ skipAlreadyPublished: argv.skipAlreadyPublished,
8531
+ ...argv.tag ? { tag: argv.tag } : {},
8019
8532
  ...argv.otp ? { otp: argv.otp } : {}
8020
8533
  })).status === "fail") process.exitCode = 1;
8021
8534
  }).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", {