@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.
- package/LICENSE +21 -674
- package/README.md +19 -8
- package/dist/capabilities/index.d.mts +2 -0
- package/dist/capabilities/index.mjs +2 -0
- package/dist/capabilities-QjjhVlDd.mjs +43 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +852 -0
- package/dist/config-DWlIfFZm.mjs +113 -0
- package/dist/index-CSxf0Yc7.d.mts +485 -0
- package/dist/index.d.mts +58 -0
- package/dist/index.mjs +3 -0
- package/package.json +46 -53
- package/.alexignore +0 -3
- package/.editorconfig +0 -24
- package/.editorconfig-checker.json +0 -20
- package/.env +0 -3
- package/.gitattributes +0 -12
- package/.github/CODEOWNERS +0 -1
- package/.github/dependabot.yml +0 -6
- package/.github/labeler.yml +0 -21
- package/.github/workflows/bookkeeping-pr.yml +0 -32
- package/.github/workflows/greetings.yml +0 -32
- package/.github/workflows/lint.yml +0 -96
- package/.github/workflows/publish.yml +0 -71
- package/.github/workflows/review.yml +0 -72
- package/.github/workflows/stale.yml +0 -25
- package/.husky/commit-msg +0 -1
- package/.husky/pre-commit +0 -13
- package/.husky/prepare-commit-msg +0 -18
- package/commitlint.config.js +0 -9
- package/eslint.config.js +0 -9
- package/media/README.md +0 -11
- package/media/error.mp3 +0 -0
- package/media/success.mp3 +0 -0
- package/media/warning.mp3 +0 -0
- package/prettier.config.js +0 -11
- package/src/cli.ts +0 -65
- package/src/commands/README.md +0 -13
- package/src/commands/bootstrap.ts +0 -102
- package/src/commands/conf/README.md +0 -45
- package/src/commands/conf/add.ts +0 -49
- package/src/commands/conf/edit.ts +0 -12
- package/src/commands/conf/view.ts +0 -40
- package/src/commands/conf.ts +0 -10
- package/src/commands/log.ts +0 -91
- package/src/const.ts +0 -19
- package/src/tasks/README.md +0 -33
- package/src/tasks/find/README.md +0 -49
- package/src/tasks/find/find-project.example.ts +0 -23
- package/src/tasks/find/find-project.ts +0 -76
- package/src/tasks/find/index.ts +0 -1
- package/src/tasks/index.ts +0 -2
- package/src/tasks/replace/README.md +0 -61
- package/src/tasks/replace/index.ts +0 -1
- package/src/tasks/replace/replace.example.ts +0 -31
- package/src/tasks/replace/replace.ts +0 -95
- package/src/ui/README.md +0 -8
- package/src/ui/index.ts +0 -2
- package/src/ui/open/README.md +0 -58
- package/src/ui/open/index.ts +0 -7
- package/src/ui/open/open-browser.ts +0 -25
- package/src/ui/open/open-editor.example.ts +0 -14
- package/src/ui/open/open-editor.ts +0 -30
- package/src/ui/prompts/README.md +0 -36
- package/src/ui/prompts/autocomplete.prompt.ts +0 -18
- package/src/ui/prompts/confirm.prompt.ts +0 -21
- package/src/ui/prompts/index.ts +0 -17
- package/src/ui/prompts/input.prompt.ts +0 -15
- package/src/ui/prompts/search.prompt.ts +0 -33
- package/src/ui/prompts/select.prompt.ts +0 -17
- package/src/ui/prompts/types.ts +0 -8
- package/src/ui/prompts/utils.ts +0 -11
- package/src/utils/$/README.md +0 -87
- package/src/utils/$/command.test.ts +0 -48
- package/src/utils/$/command.ts +0 -21
- package/src/utils/$/directory.ts +0 -108
- package/src/utils/$/file.ts +0 -98
- package/src/utils/$/index.example.ts +0 -37
- package/src/utils/$/index.ts +0 -22
- package/src/utils/$/path.ts +0 -13
- package/src/utils/$/remove.ts +0 -47
- package/src/utils/$/spawn.ts +0 -26
- package/src/utils/$/utils.ts +0 -21
- package/src/utils/README.md +0 -15
- package/src/utils/config/README.md +0 -41
- package/src/utils/config/config.ts +0 -26
- package/src/utils/config/index.ts +0 -1
- package/src/utils/config/preferences.conf.ts +0 -45
- package/src/utils/env/README.md +0 -52
- package/src/utils/env/env.example.ts +0 -21
- package/src/utils/env/env.ts +0 -57
- package/src/utils/env/index.ts +0 -1
- package/src/utils/index.ts +0 -5
- package/src/utils/log/README.md +0 -117
- package/src/utils/log/index.ts +0 -2
- package/src/utils/log/log.example.ts +0 -13
- package/src/utils/log/log.ts +0 -58
- package/src/utils/log/logger.ts +0 -28
- package/src/utils/log/sound.ts +0 -35
- package/src/utils/node/README.md +0 -23
- package/src/utils/node/index.ts +0 -5
- package/src/utils/node/package.ts +0 -12
- package/tsconfig.json +0 -15
- 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 {};
|