@theholocron/cli 1.0.0 → 1.1.0

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.
Files changed (104) hide show
  1. package/LICENSE +21 -674
  2. package/README.md +19 -8
  3. package/dist/capabilities/index.d.mts +2 -0
  4. package/dist/capabilities/index.mjs +2 -0
  5. package/dist/capabilities-QjjhVlDd.mjs +43 -0
  6. package/dist/cli.d.mts +1 -0
  7. package/dist/cli.mjs +852 -0
  8. package/dist/config-DWlIfFZm.mjs +113 -0
  9. package/dist/index-CSxf0Yc7.d.mts +485 -0
  10. package/dist/index.d.mts +58 -0
  11. package/dist/index.mjs +3 -0
  12. package/package.json +46 -53
  13. package/.alexignore +0 -3
  14. package/.editorconfig +0 -24
  15. package/.editorconfig-checker.json +0 -20
  16. package/.env +0 -3
  17. package/.gitattributes +0 -12
  18. package/.github/CODEOWNERS +0 -1
  19. package/.github/dependabot.yml +0 -6
  20. package/.github/labeler.yml +0 -21
  21. package/.github/workflows/bookkeeping-pr.yml +0 -32
  22. package/.github/workflows/greetings.yml +0 -32
  23. package/.github/workflows/lint.yml +0 -96
  24. package/.github/workflows/publish.yml +0 -71
  25. package/.github/workflows/review.yml +0 -72
  26. package/.github/workflows/stale.yml +0 -25
  27. package/.husky/commit-msg +0 -1
  28. package/.husky/pre-commit +0 -13
  29. package/.husky/prepare-commit-msg +0 -18
  30. package/commitlint.config.js +0 -9
  31. package/eslint.config.js +0 -9
  32. package/media/README.md +0 -11
  33. package/media/error.mp3 +0 -0
  34. package/media/success.mp3 +0 -0
  35. package/media/warning.mp3 +0 -0
  36. package/prettier.config.js +0 -11
  37. package/src/cli.ts +0 -65
  38. package/src/commands/README.md +0 -13
  39. package/src/commands/bootstrap.ts +0 -102
  40. package/src/commands/conf/README.md +0 -45
  41. package/src/commands/conf/add.ts +0 -49
  42. package/src/commands/conf/edit.ts +0 -12
  43. package/src/commands/conf/view.ts +0 -40
  44. package/src/commands/conf.ts +0 -10
  45. package/src/commands/log.ts +0 -91
  46. package/src/const.ts +0 -19
  47. package/src/tasks/README.md +0 -33
  48. package/src/tasks/find/README.md +0 -49
  49. package/src/tasks/find/find-project.example.ts +0 -23
  50. package/src/tasks/find/find-project.ts +0 -76
  51. package/src/tasks/find/index.ts +0 -1
  52. package/src/tasks/index.ts +0 -2
  53. package/src/tasks/replace/README.md +0 -61
  54. package/src/tasks/replace/index.ts +0 -1
  55. package/src/tasks/replace/replace.example.ts +0 -31
  56. package/src/tasks/replace/replace.ts +0 -95
  57. package/src/ui/README.md +0 -8
  58. package/src/ui/index.ts +0 -2
  59. package/src/ui/open/README.md +0 -58
  60. package/src/ui/open/index.ts +0 -7
  61. package/src/ui/open/open-browser.ts +0 -25
  62. package/src/ui/open/open-editor.example.ts +0 -14
  63. package/src/ui/open/open-editor.ts +0 -30
  64. package/src/ui/prompts/README.md +0 -36
  65. package/src/ui/prompts/autocomplete.prompt.ts +0 -18
  66. package/src/ui/prompts/confirm.prompt.ts +0 -21
  67. package/src/ui/prompts/index.ts +0 -17
  68. package/src/ui/prompts/input.prompt.ts +0 -15
  69. package/src/ui/prompts/search.prompt.ts +0 -33
  70. package/src/ui/prompts/select.prompt.ts +0 -17
  71. package/src/ui/prompts/types.ts +0 -8
  72. package/src/ui/prompts/utils.ts +0 -11
  73. package/src/utils/$/README.md +0 -87
  74. package/src/utils/$/command.test.ts +0 -48
  75. package/src/utils/$/command.ts +0 -21
  76. package/src/utils/$/directory.ts +0 -108
  77. package/src/utils/$/file.ts +0 -98
  78. package/src/utils/$/index.example.ts +0 -37
  79. package/src/utils/$/index.ts +0 -22
  80. package/src/utils/$/path.ts +0 -13
  81. package/src/utils/$/remove.ts +0 -47
  82. package/src/utils/$/spawn.ts +0 -26
  83. package/src/utils/$/utils.ts +0 -21
  84. package/src/utils/README.md +0 -15
  85. package/src/utils/config/README.md +0 -41
  86. package/src/utils/config/config.ts +0 -26
  87. package/src/utils/config/index.ts +0 -1
  88. package/src/utils/config/preferences.conf.ts +0 -45
  89. package/src/utils/env/README.md +0 -52
  90. package/src/utils/env/env.example.ts +0 -21
  91. package/src/utils/env/env.ts +0 -57
  92. package/src/utils/env/index.ts +0 -1
  93. package/src/utils/index.ts +0 -5
  94. package/src/utils/log/README.md +0 -117
  95. package/src/utils/log/index.ts +0 -2
  96. package/src/utils/log/log.example.ts +0 -13
  97. package/src/utils/log/log.ts +0 -58
  98. package/src/utils/log/logger.ts +0 -28
  99. package/src/utils/log/sound.ts +0 -35
  100. package/src/utils/node/README.md +0 -23
  101. package/src/utils/node/index.ts +0 -5
  102. package/src/utils/node/package.ts +0 -12
  103. package/tsconfig.json +0 -15
  104. package/yamllint.config.yml +0 -20
package/dist/cli.mjs ADDED
@@ -0,0 +1,852 @@
1
+ #!/usr/bin/env -S tsx
2
+ import { t as CARDINALITY } from "./capabilities-QjjhVlDd.mjs";
3
+ import { n as resolveConfig, t as ConfigError } from "./config-DWlIfFZm.mjs";
4
+ import yargs from "yargs";
5
+ import { hideBin } from "yargs/helpers";
6
+ import { spawnSync } from "node:child_process";
7
+ import { readFile, stat } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ //#region src/loader.ts
10
+ var LoaderError = class extends Error {
11
+ name = "LoaderError";
12
+ };
13
+ var PluginLoader = class {
14
+ config;
15
+ context;
16
+ importer;
17
+ registry = /* @__PURE__ */ new Map();
18
+ constructor(config, context, importer = defaultImporter) {
19
+ this.config = config;
20
+ this.context = context;
21
+ this.importer = importer;
22
+ }
23
+ /** Imports every configured plugin and builds the capability registry. */
24
+ async load() {
25
+ const entries = Object.entries(this.config.providers);
26
+ for (const [key, entry] of entries) {
27
+ if (!entry) continue;
28
+ if (entry.cardinality === "single") this.registry.set(key, await this.loadOne(key, entry.tuple));
29
+ else {
30
+ const impls = [];
31
+ for (const tuple of entry.tuples) impls.push(await this.loadOne(key, tuple));
32
+ this.registry.set(key, impls);
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * Type-safe lookup. Single-cardinality keys return one impl;
38
+ * many-cardinality keys return an array. `ResolvedCapability<K>`
39
+ * encodes the split via the `CARDINALITY` map.
40
+ */
41
+ get(key) {
42
+ const impl = this.registry.get(key);
43
+ if (impl === void 0) throw new LoaderError(`capability \`${key}\` is not loaded — is it declared in holocron.config.json?`);
44
+ return impl;
45
+ }
46
+ /** Whether a capability has been loaded. */
47
+ has(key) {
48
+ return this.registry.has(key);
49
+ }
50
+ /** All capability keys currently loaded. Useful for the doctor command. */
51
+ loadedKeys() {
52
+ return Array.from(this.registry.keys());
53
+ }
54
+ /** Internal — invoke a plugin's capability factory and return the impl. */
55
+ async loadOne(key, tuple) {
56
+ const module = await this.importer(tuple.packageName).catch((err) => {
57
+ throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
58
+ });
59
+ if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
60
+ const factory = module.createPlugin({
61
+ ...this.context,
62
+ ...tuple.options
63
+ }).capabilities[key];
64
+ if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
65
+ return factory();
66
+ }
67
+ };
68
+ /** Default importer — native dynamic import. */
69
+ const defaultImporter = async (pkg) => {
70
+ return await import(pkg);
71
+ };
72
+ //#endregion
73
+ //#region src/commands/deploy.ts
74
+ async function runDeploy(input) {
75
+ const print = input.print ?? ((line) => console.log(line));
76
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
77
+ await loader.load();
78
+ const dryRun = input.context.dryRun ?? false;
79
+ print(`Holocron deploy — branch=${input.branch}${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`);
80
+ if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
81
+ const deploy = loader.get("deployment");
82
+ if (dryRun) {
83
+ const message = `would: ${deploy.providerName}.triggerDeployment(projectId=${input.projectId}, branch=${input.branch}${input.target ? `, target=${input.target}` : ""})`;
84
+ print(` … ${message}`);
85
+ return {
86
+ deployment: null,
87
+ status: "dry-run",
88
+ message
89
+ };
90
+ }
91
+ try {
92
+ const record = await deploy.triggerDeployment({
93
+ projectId: input.projectId,
94
+ branch: input.branch,
95
+ ...input.target ? { target: input.target } : {}
96
+ });
97
+ print(` ✓ ${record.status} — ${record.url}`);
98
+ return {
99
+ deployment: record,
100
+ status: "ok"
101
+ };
102
+ } catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ print(` ✗ ${message}`);
105
+ return {
106
+ deployment: null,
107
+ status: "fail",
108
+ message
109
+ };
110
+ }
111
+ }
112
+ //#endregion
113
+ //#region src/commands/doctor.ts
114
+ async function runDoctor(input) {
115
+ const print = input.print ?? ((line) => console.log(line));
116
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
117
+ await loader.load();
118
+ const rows = [];
119
+ const config = input.loaded.resolved;
120
+ print(`Holocron doctor — ${config.project.name}`);
121
+ print(` config: ${input.loaded.filepath}`);
122
+ print("");
123
+ for (const key of loader.loadedKeys()) {
124
+ const cardinality = CARDINALITY[key];
125
+ const entry = config.providers[key];
126
+ const providers = entry?.cardinality === "many" ? entry.tuples.map((t) => t.provider) : entry?.cardinality === "single" ? [entry.tuple.provider] : [];
127
+ if (cardinality === "many") loader.get(key).forEach((_impl, idx) => {
128
+ rows.push({
129
+ capability: key,
130
+ provider: providers[idx] ?? "?",
131
+ status: "skip",
132
+ message: "loaded (no smoke check defined for this capability)"
133
+ });
134
+ });
135
+ else {
136
+ const impl = loader.get(key);
137
+ const row = await smokeCheck(key, providers[0] ?? "?", impl);
138
+ rows.push(row);
139
+ }
140
+ }
141
+ for (const row of rows) print(` ${row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : "·"} ${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`);
142
+ const summary = rows.reduce((acc, r) => {
143
+ acc[r.status] += 1;
144
+ return acc;
145
+ }, {
146
+ ok: 0,
147
+ fail: 0,
148
+ skip: 0
149
+ });
150
+ print("");
151
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`);
152
+ return {
153
+ rows,
154
+ summary
155
+ };
156
+ }
157
+ async function smokeCheck(key, provider, impl) {
158
+ try {
159
+ switch (key) {
160
+ case "source": return mk(key, provider, "ok", `whoami: ${(await impl.whoami()).login}`);
161
+ case "issues": {
162
+ const report = await impl.doctor();
163
+ return mk(key, provider, "ok", `${report.authedAs} / ${report.projectLabel} (${countResolvedLifecycle(report)})`);
164
+ }
165
+ case "secrets": return mk(key, provider, "ok", `repo secrets: ${(await impl.listSecrets({ kind: "repo" })).length} configured`);
166
+ case "ci": {
167
+ const runs = await impl.listRuns({ limit: 1 });
168
+ return mk(key, provider, "ok", runs.length === 0 ? "no recent runs" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
169
+ }
170
+ case "vault": return mk(key, provider, "ok", `${(await impl.list()).length} keys available`);
171
+ case "auth": {
172
+ const desc = await impl.describe();
173
+ return mk(key, provider, "ok", `provider: ${desc.provider}; env keys: ${desc.envKeys.join(", ")}`);
174
+ }
175
+ default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
176
+ }
177
+ } catch (err) {
178
+ return mk(key, provider, "fail", err instanceof Error ? err.message : String(err));
179
+ }
180
+ }
181
+ function countResolvedLifecycle(report) {
182
+ return `${report.lifecycle.filter((l) => l.resolved).length}/${report.lifecycle.length} lifecycle slots ready`;
183
+ }
184
+ function mk(capability, provider, status, message) {
185
+ return {
186
+ capability,
187
+ provider,
188
+ status,
189
+ message
190
+ };
191
+ }
192
+ function pad(s, width) {
193
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
194
+ }
195
+ //#endregion
196
+ //#region src/commands/npm-publish-initial.ts
197
+ /**
198
+ * `holocron npm publish-initial` — bottles up the chicken-and-egg
199
+ * bootstrap that every new npm-published holocron monorepo hits.
200
+ *
201
+ * npm requires a package to exist before Trusted Publishing can be
202
+ * configured on it. So the first publish has to happen outside the
203
+ * OIDC flow — using either a browser-auth session (`npm login
204
+ * --auth-type=web`) or an ephemeral automation token. This command
205
+ * runs the publish step + tells you exactly what to do next.
206
+ *
207
+ * Workflow:
208
+ *
209
+ * $ npm login --auth-type=web # one-time, browser-based
210
+ * $ pnpm install --frozen-lockfile
211
+ * $ pnpm build
212
+ * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
213
+ *
214
+ * The command itself only handles the publish step + the post-publish
215
+ * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
216
+ * stay outside the command (no pnpm-inside-pnpm).
217
+ *
218
+ * If `NPM_TOKEN` is detected in env, the command prints a final
219
+ * "revoke this token at <url>" reminder — same pattern as `rando vc
220
+ * setup` for the ephemeral GH admin PAT.
221
+ */
222
+ const PUBLISHABLE_PACKAGES = [
223
+ "@theholocron/cli",
224
+ "@theholocron/holocron-plugin-github",
225
+ "@theholocron/holocron-plugin-vercel",
226
+ "@theholocron/holocron-plugin-neon",
227
+ "@theholocron/holocron-plugin-clerk",
228
+ "@theholocron/holocron-plugin-1password",
229
+ "@theholocron/holocron-plugin-postman"
230
+ ];
231
+ async function runNpmPublishInitial(input = {}) {
232
+ const print = input.print ?? ((line) => console.log(line));
233
+ const cwd = input.cwd ?? process.cwd();
234
+ const tag = input.tag ?? "alpha";
235
+ const dryRun = input.dryRun ?? false;
236
+ const otp = input.otp;
237
+ const env = input.env ?? process.env;
238
+ const exec = input.exec ?? defaultExec;
239
+ const publishArgs = [
240
+ "-r",
241
+ "--filter=./packages/*",
242
+ "--filter=!@theholocron/cli-utils",
243
+ "publish",
244
+ "--access",
245
+ "public",
246
+ "--no-git-checks",
247
+ "--tag",
248
+ tag,
249
+ ...otp ? ["--otp", otp] : []
250
+ ];
251
+ print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
252
+ print(` cwd: ${cwd}`);
253
+ print(` tag: ${tag}`);
254
+ if (otp) print(` otp: <${otp.length} chars>`);
255
+ print("");
256
+ print(" → verifying npm auth (`npm whoami`)…");
257
+ const whoami = await exec("npm", ["whoami"], { cwd });
258
+ if (whoami.exitCode !== 0) {
259
+ const message = "npm is not authenticated. Run `npm login --auth-type=web` (browser flow, no token stored) or `npm login`, then re-run this command.";
260
+ print(` ✗ ${message}`);
261
+ return {
262
+ status: "fail",
263
+ message,
264
+ packageNames: PUBLISHABLE_PACKAGES
265
+ };
266
+ }
267
+ print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
268
+ if (dryRun) {
269
+ print("");
270
+ print(" … (dry-run) skipping actual publish");
271
+ print(` would run: pnpm ${publishArgs.join(" ")}`);
272
+ printNextSteps(print, env);
273
+ return {
274
+ status: "dry-run",
275
+ message: "dry-run — no publish executed",
276
+ packageNames: PUBLISHABLE_PACKAGES
277
+ };
278
+ }
279
+ print("");
280
+ print(" → publishing all public @theholocron/* packages…");
281
+ const publish = await exec("pnpm", publishArgs, { cwd });
282
+ if (publish.exitCode !== 0) {
283
+ const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
284
+ print(` ✗ ${message}`);
285
+ if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
286
+ print("");
287
+ print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
288
+ print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
289
+ }
290
+ return {
291
+ status: "fail",
292
+ message,
293
+ packageNames: PUBLISHABLE_PACKAGES
294
+ };
295
+ }
296
+ print(" ✓ publish complete");
297
+ printNextSteps(print, env);
298
+ return {
299
+ status: "ok",
300
+ packageNames: PUBLISHABLE_PACKAGES
301
+ };
302
+ }
303
+ function printNextSteps(print, env) {
304
+ print("");
305
+ print(" → next: configure Trusted Publisher for each package on npm:");
306
+ for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
307
+ print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
308
+ if (env.NPM_TOKEN) {
309
+ print("");
310
+ print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
311
+ print(" https://www.npmjs.com/settings/~/tokens");
312
+ }
313
+ }
314
+ const defaultExec = async (cmd, args, opts) => {
315
+ const result = spawnSync(cmd, args, {
316
+ cwd: opts.cwd,
317
+ encoding: "utf8",
318
+ stdio: [
319
+ "inherit",
320
+ "pipe",
321
+ "pipe"
322
+ ]
323
+ });
324
+ return {
325
+ exitCode: result.status ?? -1,
326
+ stdout: result.stdout ?? "",
327
+ stderr: result.stderr ?? ""
328
+ };
329
+ };
330
+ //#endregion
331
+ //#region src/commands/secret-set.ts
332
+ async function runSecretSet(input) {
333
+ const print = input.print ?? ((line) => console.log(line));
334
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
335
+ await loader.load();
336
+ const dryRun = input.context.dryRun ?? false;
337
+ const scope = input.scope ?? { kind: "repo" };
338
+ if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
339
+ const value = await resolveValue(input);
340
+ if (!value) throw new Error(`no value for secret \`${input.name}\` — pass it positionally, via --from-stdin, --from-env <NAME>, or set $${input.name} in the environment`);
341
+ print(`Holocron secret set — ${input.name} (${describeScope(scope)})${dryRun ? " (dry-run)" : ""}`);
342
+ if (dryRun) {
343
+ const message = `would: secrets.setSecret(${describeScope(scope)}, ${input.name}, <${value.length} chars>)`;
344
+ print(` … ${message}`);
345
+ return {
346
+ status: "dry-run",
347
+ name: input.name,
348
+ scope,
349
+ message
350
+ };
351
+ }
352
+ try {
353
+ const secrets = loader.get("secrets");
354
+ await secrets.setSecret(scope, input.name, value);
355
+ print(` ✓ set ${input.name} via ${secrets.providerName}`);
356
+ return {
357
+ status: "ok",
358
+ name: input.name,
359
+ scope
360
+ };
361
+ } catch (err) {
362
+ const message = err instanceof Error ? err.message : String(err);
363
+ print(` ✗ ${message}`);
364
+ return {
365
+ status: "fail",
366
+ name: input.name,
367
+ scope,
368
+ message
369
+ };
370
+ }
371
+ }
372
+ async function resolveValue(input) {
373
+ if (input.value) return input.value;
374
+ if (input.fromStdin) return (await (input.readStdin ?? defaultReadStdin)()).replace(/\r?\n$/, "");
375
+ const env = process.env;
376
+ if (input.fromEnv) return env[input.fromEnv];
377
+ return env[input.name];
378
+ }
379
+ async function defaultReadStdin() {
380
+ const chunks = [];
381
+ return new Promise((resolve, reject) => {
382
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
383
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
384
+ process.stdin.on("error", reject);
385
+ });
386
+ }
387
+ function describeScope(scope) {
388
+ if (scope.kind === "repo") return "scope=repo";
389
+ if (scope.kind === "environment") return `scope=environment:${scope.name}`;
390
+ return `scope=organization:${scope.name}`;
391
+ }
392
+ //#endregion
393
+ //#region src/commands/secrets-sync.ts
394
+ async function runSecretsSync(input) {
395
+ const print = input.print ?? ((line) => console.log(line));
396
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
397
+ await loader.load();
398
+ const dryRun = input.context.dryRun ?? false;
399
+ const targets = input.targets ?? ["production", "preview"];
400
+ print(`Holocron secrets sync — environment ${input.environmentId}${dryRun ? " (dry-run)" : ""}`);
401
+ print(` vault: ${vaultProviderName(loader)}`);
402
+ print("");
403
+ const vault = loader.get("vault");
404
+ if (!vault.readEnvironment) throw new Error(`vault provider (${vault.providerName}) does not implement readEnvironment — sync needs bulk env reads`);
405
+ const envVars = await vault.readEnvironment(input.environmentId);
406
+ const keys = Object.keys(envVars).sort();
407
+ print(` → read ${keys.length} keys from vault`);
408
+ print("");
409
+ const rows = [];
410
+ if (loader.has("secrets")) {
411
+ const secrets = loader.get("secrets");
412
+ print(" → secrets (repo scope)");
413
+ for (const key of keys) {
414
+ rows.push(await runRow(`secrets:${secrets.providerName}`, "scope=repo", key, dryRun, async () => {
415
+ await secrets.setSecret({ kind: "repo" }, key, envVars[key]);
416
+ }));
417
+ print(formatRow(rows[rows.length - 1]));
418
+ }
419
+ }
420
+ if (loader.has("deployment")) {
421
+ const deploy = loader.get("deployment");
422
+ if (!input.projectId) {
423
+ print(" → deployment");
424
+ const row = {
425
+ destination: `deployment:${deploy.providerName}`,
426
+ scope: "no projectId",
427
+ key: "(all)",
428
+ status: "skip",
429
+ message: "projectId required for deployment env-var sync (pass --project-id)"
430
+ };
431
+ rows.push(row);
432
+ print(formatRow(row));
433
+ } else for (const target of targets) {
434
+ print(` → deployment (target=${target})`);
435
+ for (const key of keys) {
436
+ rows.push(await runRow(`deployment:${deploy.providerName}`, `target=${target}`, key, dryRun, async () => {
437
+ await deploy.setEnvVar(input.projectId, target, key, envVars[key]);
438
+ }));
439
+ print(formatRow(rows[rows.length - 1]));
440
+ }
441
+ }
442
+ }
443
+ const summary = rows.reduce((acc, r) => {
444
+ if (r.status === "ok") acc.ok += 1;
445
+ else if (r.status === "fail") acc.fail += 1;
446
+ else if (r.status === "skip") acc.skip += 1;
447
+ else if (r.status === "dry-run") acc.dryRun += 1;
448
+ return acc;
449
+ }, {
450
+ ok: 0,
451
+ fail: 0,
452
+ skip: 0,
453
+ dryRun: 0
454
+ });
455
+ print("");
456
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
457
+ return {
458
+ rows,
459
+ summary
460
+ };
461
+ }
462
+ async function runRow(destination, scope, key, dryRun, body) {
463
+ if (dryRun) return {
464
+ destination,
465
+ scope,
466
+ key,
467
+ status: "dry-run"
468
+ };
469
+ try {
470
+ await body();
471
+ return {
472
+ destination,
473
+ scope,
474
+ key,
475
+ status: "ok"
476
+ };
477
+ } catch (err) {
478
+ return {
479
+ destination,
480
+ scope,
481
+ key,
482
+ status: "fail",
483
+ message: err instanceof Error ? err.message : String(err)
484
+ };
485
+ }
486
+ }
487
+ function formatRow(row) {
488
+ const icon = row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : row.status === "dry-run" ? "…" : "·";
489
+ const detail = row.message ? ` (${row.message})` : "";
490
+ return ` ${icon} ${row.key}${detail}`;
491
+ }
492
+ function vaultProviderName(loader) {
493
+ if (!loader.has("vault")) return "<missing>";
494
+ return loader.get("vault").providerName;
495
+ }
496
+ //#endregion
497
+ //#region src/commands/setup.ts
498
+ async function runSetup(input) {
499
+ const print = input.print ?? ((line) => console.log(line));
500
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
501
+ await loader.load();
502
+ const config = input.loaded.resolved;
503
+ const dryRun = input.context.dryRun ?? false;
504
+ const steps = [];
505
+ print(`Holocron setup — ${config.project.name}${dryRun ? " (dry-run)" : ""}`);
506
+ print(` config: ${input.loaded.filepath}`);
507
+ print("");
508
+ if (loader.has("source")) {
509
+ const source = loader.get("source");
510
+ print(" → source");
511
+ for (const method of [
512
+ "enableVulnerabilityAlerts",
513
+ "enableAutomatedSecurityFixes",
514
+ "enableSecretScanning",
515
+ "enablePrivateVulnerabilityReporting"
516
+ ]) {
517
+ steps.push(await runStep("source", method, dryRun, async () => {
518
+ await source[method]();
519
+ }));
520
+ print(formatStep(steps[steps.length - 1]));
521
+ }
522
+ }
523
+ if (loader.has("environments")) {
524
+ const envs = loader.get("environments");
525
+ print(" → environments");
526
+ for (const envName of ["staging", "production"]) {
527
+ steps.push(await runStep("environments", `upsert ${envName}`, dryRun, async () => {
528
+ await envs.upsertEnvironment({ name: envName });
529
+ }));
530
+ print(formatStep(steps[steps.length - 1]));
531
+ }
532
+ }
533
+ if (loader.has("deployment")) {
534
+ const deploy = loader.get("deployment");
535
+ print(" → deployment");
536
+ steps.push(await runStep("deployment", `ensureProject ${config.project.name}`, dryRun, async () => {
537
+ await deploy.ensureProject({ name: config.project.name });
538
+ }));
539
+ print(formatStep(steps[steps.length - 1]));
540
+ }
541
+ if (loader.has("auth")) {
542
+ const auth = loader.get("auth");
543
+ print(" → auth");
544
+ if (auth.ensureWebhookApp) {
545
+ steps.push(await runStep("auth", "ensureWebhookApp", dryRun, async () => {
546
+ return `webhook ${(await auth.ensureWebhookApp()).alreadyExists ? "exists" : "created"}`;
547
+ }));
548
+ print(formatStep(steps[steps.length - 1]));
549
+ } else {
550
+ steps.push({
551
+ capability: "auth",
552
+ step: "ensureWebhookApp",
553
+ status: "skip",
554
+ message: "provider does not implement ensureWebhookApp"
555
+ });
556
+ print(formatStep(steps[steps.length - 1]));
557
+ }
558
+ }
559
+ if (loader.has("vault")) {
560
+ const vault = loader.get("vault");
561
+ print(" → vault");
562
+ try {
563
+ const keys = await vault.list();
564
+ steps.push({
565
+ capability: "vault",
566
+ step: "list",
567
+ status: "ok",
568
+ message: `${keys.length} keys available`
569
+ });
570
+ } catch (err) {
571
+ steps.push({
572
+ capability: "vault",
573
+ step: "list",
574
+ status: "fail",
575
+ message: err instanceof Error ? err.message : String(err)
576
+ });
577
+ }
578
+ print(formatStep(steps[steps.length - 1]));
579
+ }
580
+ if (loader.has("tooling")) {
581
+ const tools = loader.get("tooling");
582
+ print(" → tooling");
583
+ for (const tool of tools) {
584
+ steps.push(await runStep("tooling", `${tool.providerName}.sync`, dryRun, async () => {
585
+ await tool.sync();
586
+ }));
587
+ print(formatStep(steps[steps.length - 1]));
588
+ }
589
+ }
590
+ const summary = steps.reduce((acc, s) => {
591
+ if (s.status === "ok") acc.ok += 1;
592
+ else if (s.status === "fail") acc.fail += 1;
593
+ else if (s.status === "skip") acc.skip += 1;
594
+ else if (s.status === "dry-run") acc.dryRun += 1;
595
+ return acc;
596
+ }, {
597
+ ok: 0,
598
+ fail: 0,
599
+ skip: 0,
600
+ dryRun: 0
601
+ });
602
+ print("");
603
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
604
+ return {
605
+ steps,
606
+ summary
607
+ };
608
+ }
609
+ async function runStep(capability, step, dryRun, body) {
610
+ if (dryRun) return {
611
+ capability,
612
+ step,
613
+ status: "dry-run"
614
+ };
615
+ try {
616
+ const note = await body();
617
+ const result = {
618
+ capability,
619
+ step,
620
+ status: "ok"
621
+ };
622
+ if (typeof note === "string") result.message = note;
623
+ return result;
624
+ } catch (err) {
625
+ return {
626
+ capability,
627
+ step,
628
+ status: "fail",
629
+ message: err instanceof Error ? err.message : String(err)
630
+ };
631
+ }
632
+ }
633
+ function formatStep(step) {
634
+ const icon = step.status === "ok" ? "✓" : step.status === "fail" ? "✗" : step.status === "dry-run" ? "…" : "·";
635
+ const detail = step.message ? ` (${step.message})` : "";
636
+ return ` ${icon} ${step.step}${detail}`;
637
+ }
638
+ //#endregion
639
+ //#region src/load-config.ts
640
+ /**
641
+ * `holocron.config.{json,js,ts}` file loader.
642
+ *
643
+ * v2.0 only documents the JSON form, but the loader looks up all three
644
+ * extensions in priority order (json → js → ts) — that's the schema
645
+ * commitment captured in [`.notes/tech-architecture.spec.md` → Roadmap
646
+ * → Shareable configs] so the JS/TS preset story (issue #75) can land
647
+ * later without a breaking change.
648
+ *
649
+ * The JS/TS forms aren't actually parsed today; they trigger a clear
650
+ * error telling the operator to use JSON for v2.0. The contract is the
651
+ * lookup order, not the interpretation.
652
+ */
653
+ const CANDIDATE_FILENAMES = [
654
+ "holocron.config.json",
655
+ "holocron.config.js",
656
+ "holocron.config.ts"
657
+ ];
658
+ var ConfigFileError = class extends Error {
659
+ name = "ConfigFileError";
660
+ };
661
+ /**
662
+ * Read + parse + resolve `holocron.config.*` from the given directory.
663
+ * Search order: json → js → ts. Throws `ConfigFileError` if nothing
664
+ * found, or `ConfigError` if the JSON is malformed / invalid.
665
+ */
666
+ async function loadConfig(cwd) {
667
+ for (const filename of CANDIDATE_FILENAMES) {
668
+ const fullPath = join(cwd, filename);
669
+ if (await fileExists(fullPath)) {
670
+ if (filename.endsWith(".json")) return {
671
+ resolved: await loadJson(fullPath),
672
+ filepath: fullPath
673
+ };
674
+ throw new ConfigFileError(`${filename} found, but v2.0 only supports the JSON form. Rename to holocron.config.json. The JS/TS form lands with the preset feature (see issue #75).`);
675
+ }
676
+ }
677
+ throw new ConfigFileError(`no holocron.config.{json,js,ts} found in ${cwd}. Create one — see the README for the schema.`);
678
+ }
679
+ async function loadJson(filepath) {
680
+ const raw = await readFile(filepath, "utf8");
681
+ let parsed;
682
+ try {
683
+ parsed = JSON.parse(raw);
684
+ } catch (err) {
685
+ throw new ConfigError(`${filepath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
686
+ }
687
+ return resolveConfig(parsed);
688
+ }
689
+ async function fileExists(path) {
690
+ try {
691
+ return (await stat(path)).isFile();
692
+ } catch {
693
+ return false;
694
+ }
695
+ }
696
+ //#endregion
697
+ //#region src/cli.ts
698
+ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
699
+ type: "boolean",
700
+ default: false,
701
+ describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
702
+ }).option("token", {
703
+ type: "string",
704
+ describe: "Vendor token passed to plugins as `cliToken`, taking precedence over env vars. Unambiguous for single-plugin commands; for multi-plugin flows pass per-vendor env vars instead."
705
+ }).option("cwd", {
706
+ type: "string",
707
+ default: process.cwd(),
708
+ describe: "Directory to search for holocron.config.json"
709
+ }).command("version", "Print the CLI version", () => {}, () => {
710
+ console.log("holocron 2.0.0-alpha.0");
711
+ }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
712
+ type: "string",
713
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
714
+ }), async (argv) => {
715
+ if ((await runDoctor({
716
+ loaded: await loadConfig(argv.cwd),
717
+ context: {
718
+ repoRoot: argv.cwd,
719
+ dryRun: argv.dryRun,
720
+ ...argv.repo ? { repo: argv.repo } : {},
721
+ ...argv.token ? { cliToken: argv.token } : {}
722
+ }
723
+ })).summary.fail > 0) process.exitCode = 1;
724
+ }).command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
725
+ type: "string",
726
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
727
+ }), async (argv) => {
728
+ if ((await runSetup({
729
+ loaded: await loadConfig(argv.cwd),
730
+ context: {
731
+ repoRoot: argv.cwd,
732
+ dryRun: argv.dryRun,
733
+ ...argv.repo ? { repo: argv.repo } : {},
734
+ ...argv.token ? { cliToken: argv.token } : {}
735
+ }
736
+ })).summary.fail > 0) process.exitCode = 1;
737
+ }).command("secret set <name> [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
738
+ type: "string",
739
+ demandOption: true,
740
+ describe: "Secret name (e.g., NPM_TOKEN)"
741
+ }).positional("value", {
742
+ type: "string",
743
+ describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
744
+ }).option("from-stdin", {
745
+ type: "boolean",
746
+ default: false,
747
+ describe: "Read the secret value from stdin"
748
+ }).option("from-env", {
749
+ type: "string",
750
+ describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
751
+ }).option("scope", {
752
+ type: "string",
753
+ default: "repo",
754
+ describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
755
+ }), async (argv) => {
756
+ const scopeArg = argv.scope;
757
+ const scope = parseScope(scopeArg);
758
+ if ((await runSecretSet({
759
+ loaded: await loadConfig(argv.cwd),
760
+ context: {
761
+ repoRoot: argv.cwd,
762
+ dryRun: argv.dryRun,
763
+ ...argv.token ? { cliToken: argv.token } : {}
764
+ },
765
+ name: argv.name,
766
+ ...argv.value ? { value: argv.value } : {},
767
+ ...argv.fromStdin ? { fromStdin: true } : {},
768
+ ...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
769
+ scope
770
+ })).status === "fail") process.exitCode = 1;
771
+ }).command("secrets sync <environmentId>", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
772
+ type: "string",
773
+ demandOption: true,
774
+ describe: "Vault environment id to read (1P Environment id, etc.)"
775
+ }).option("project-id", {
776
+ type: "string",
777
+ describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
778
+ }).option("target", {
779
+ type: "array",
780
+ default: ["production", "preview"],
781
+ describe: "Deployment targets to sync to. Defaults to production + preview."
782
+ }), async (argv) => {
783
+ if ((await runSecretsSync({
784
+ loaded: await loadConfig(argv.cwd),
785
+ context: {
786
+ repoRoot: argv.cwd,
787
+ dryRun: argv.dryRun,
788
+ ...argv.token ? { cliToken: argv.token } : {}
789
+ },
790
+ environmentId: argv.environmentId,
791
+ ...argv.projectId ? { projectId: argv.projectId } : {},
792
+ targets: argv.target
793
+ })).summary.fail > 0) process.exitCode = 1;
794
+ }).command("deploy <branch>", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
795
+ type: "string",
796
+ demandOption: true,
797
+ describe: "Git branch to deploy"
798
+ }).option("project-id", {
799
+ type: "string",
800
+ demandOption: true,
801
+ describe: "Deployment project id (e.g., Vercel prj_*)"
802
+ }).option("target", {
803
+ type: "string",
804
+ choices: ["production", "staging"],
805
+ describe: "Named environment to deploy into. Omit for a branch preview."
806
+ }), async (argv) => {
807
+ if ((await runDeploy({
808
+ loaded: await loadConfig(argv.cwd),
809
+ context: {
810
+ repoRoot: argv.cwd,
811
+ dryRun: argv.dryRun,
812
+ ...argv.token ? { cliToken: argv.token } : {}
813
+ },
814
+ projectId: argv.projectId,
815
+ branch: argv.branch,
816
+ ...argv.target ? { target: argv.target } : {}
817
+ })).status === "fail") process.exitCode = 1;
818
+ }).command("npm publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (y) => y.option("tag", {
819
+ type: "string",
820
+ default: "alpha",
821
+ describe: "npm distribution tag (defaults to alpha)"
822
+ }).option("otp", {
823
+ type: "string",
824
+ describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
825
+ }), async (argv) => {
826
+ if ((await runNpmPublishInitial({
827
+ cwd: argv.cwd,
828
+ tag: argv.tag,
829
+ dryRun: argv.dryRun,
830
+ ...argv.otp ? { otp: argv.otp } : {}
831
+ })).status === "fail") process.exitCode = 1;
832
+ }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
833
+ const loaded = await loadConfig(argv.cwd);
834
+ console.log(JSON.stringify(loaded.resolved, null, 2));
835
+ }).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
836
+ /**
837
+ * Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
838
+ */
839
+ function parseScope(s) {
840
+ if (s === "repo") return { kind: "repo" };
841
+ if (s.startsWith("env=")) return {
842
+ kind: "environment",
843
+ name: s.slice(4)
844
+ };
845
+ if (s.startsWith("org=")) return {
846
+ kind: "organization",
847
+ name: s.slice(4)
848
+ };
849
+ throw new Error(`invalid --scope "${s}" — expected "repo", "env=<name>", or "org=<name>"`);
850
+ }
851
+ //#endregion
852
+ export {};