@theholocron/cli 5.0.0-alpha.13 → 5.0.0-alpha.133
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/README.md +197 -50
- package/dist/cli.mjs +2181 -877
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +245 -22
- package/dist/index.mjs +271 -6
- package/dist/plugin/capabilities.d.mts +153 -22
- package/dist/plugin/capabilities.mjs +1 -0
- package/package.json +17 -11
package/dist/cli.mjs
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
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
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
5
|
+
import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
7
|
+
import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
7
8
|
import { loadTasksConfig } from "@theholocron/astromech/config";
|
|
9
|
+
import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
|
|
8
10
|
import yargs from "yargs";
|
|
9
11
|
import { hideBin } from "yargs/helpers";
|
|
10
12
|
import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
|
|
11
13
|
import { createEnvLookup } from "@theholocron/env-utils";
|
|
12
14
|
import { Entry, findCredentials } from "@napi-rs/keyring";
|
|
13
15
|
import { pathToFileURL } from "node:url";
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
+
import { errorMessage } from "@theholocron/misc-utils";
|
|
17
|
+
import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
|
|
16
18
|
import chalk from "chalk";
|
|
19
|
+
import ora from "ora";
|
|
17
20
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
18
|
-
import { homedir, hostname, userInfo } from "node:os";
|
|
19
21
|
import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
20
22
|
import { createHash } from "node:crypto";
|
|
21
23
|
import { generateReadme } from "@theholocron/components-doc/markdown";
|
|
22
24
|
import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
|
|
23
25
|
import { createGitHubClient } from "@theholocron/github-client";
|
|
24
26
|
import { PostHogSink } from "@theholocron/observability/analytics";
|
|
25
|
-
import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
|
|
26
27
|
import { SentrySink } from "@theholocron/observability/errors";
|
|
28
|
+
import load from "@commitlint/load";
|
|
29
|
+
import lint from "@commitlint/lint";
|
|
27
30
|
import { promisify } from "node:util";
|
|
28
31
|
import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
|
|
29
32
|
import search from "@inquirer/search";
|
|
@@ -181,6 +184,7 @@ const CARDINALITY = {
|
|
|
181
184
|
analytics: "many",
|
|
182
185
|
errors: "single",
|
|
183
186
|
logs: "single",
|
|
187
|
+
monitoring: "single",
|
|
184
188
|
wiki: "single",
|
|
185
189
|
workers: "single"
|
|
186
190
|
};
|
|
@@ -295,46 +299,157 @@ function resolveConfig(raw) {
|
|
|
295
299
|
docs,
|
|
296
300
|
env: raw.env,
|
|
297
301
|
log: raw.log,
|
|
298
|
-
telemetry: raw.telemetry
|
|
302
|
+
telemetry: raw.telemetry,
|
|
303
|
+
eslint: raw.eslint
|
|
299
304
|
};
|
|
300
305
|
}
|
|
301
306
|
//#endregion
|
|
307
|
+
//#region src/commands/contexts.ts
|
|
308
|
+
/**
|
|
309
|
+
* Command → context. Keys are the full command path as it lands in
|
|
310
|
+
* yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
|
|
311
|
+
* otherwise; a longer, more specific key wins over its parent.
|
|
312
|
+
*
|
|
313
|
+
* - **`global`** — needs nothing but the CLI binary. Works anywhere.
|
|
314
|
+
* - **`repo-aware`** — reads `./holocron.config` + `./package.json`
|
|
315
|
+
* relative to cwd, but never touches the plugin loader.
|
|
316
|
+
* - **`workspace`** — needs the configured providers' plugin packages to
|
|
317
|
+
* resolve (devDeps in a repo, or `pnpm exec`).
|
|
318
|
+
*/
|
|
319
|
+
const COMMAND_CONTEXTS = {
|
|
320
|
+
clone: "global",
|
|
321
|
+
"new repo": "global",
|
|
322
|
+
"new plugin": "global",
|
|
323
|
+
"repo upgrade node": "global",
|
|
324
|
+
"repo upgrade deps": "global",
|
|
325
|
+
"auth set": "global",
|
|
326
|
+
"auth unset": "global",
|
|
327
|
+
"auth list": "global",
|
|
328
|
+
"auth check": "global",
|
|
329
|
+
"package bump-versions": "global",
|
|
330
|
+
"package publish": "global",
|
|
331
|
+
"skills install": "global",
|
|
332
|
+
"skills remove": "global",
|
|
333
|
+
"skills update": "global",
|
|
334
|
+
"run lint commit-msg": "global",
|
|
335
|
+
run: "repo-aware",
|
|
336
|
+
"run ci": "repo-aware",
|
|
337
|
+
"deploy on-release": "repo-aware",
|
|
338
|
+
"config show": "repo-aware",
|
|
339
|
+
"repo sync readme": "repo-aware",
|
|
340
|
+
doctor: "workspace",
|
|
341
|
+
"repo setup": "workspace",
|
|
342
|
+
"secrets set": "workspace",
|
|
343
|
+
"secrets sync": "workspace",
|
|
344
|
+
deploy: "workspace",
|
|
345
|
+
"deploy cleanup-preview": "workspace",
|
|
346
|
+
"repo sync": "workspace",
|
|
347
|
+
"sync github": "workspace"
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* The context for a command name from `argv._`. Falls back to the leading
|
|
351
|
+
* verb (`"auth foo"` → `"auth"`) and finally `undefined` for a name the
|
|
352
|
+
* map doesn't know — callers treat unknown as "don't guard".
|
|
353
|
+
*/
|
|
354
|
+
function contextForCommand(name) {
|
|
355
|
+
const map = COMMAND_CONTEXTS;
|
|
356
|
+
const tokens = name.split(" ").filter(Boolean);
|
|
357
|
+
for (let n = tokens.length; n > 0; n--) {
|
|
358
|
+
const ctx = map[tokens.slice(0, n).join(" ")];
|
|
359
|
+
if (ctx) return ctx;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Command names in a given context, in registration order. */
|
|
363
|
+
function commandsInContext(context) {
|
|
364
|
+
return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
|
|
365
|
+
}
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/ui/style.ts
|
|
368
|
+
const style = {
|
|
369
|
+
success: (msg) => `${chalk.green("✓")} ${msg}`,
|
|
370
|
+
warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
|
|
371
|
+
fail: (msg) => `${chalk.red("✗")} ${msg}`,
|
|
372
|
+
step: (msg) => `${chalk.cyan("→")} ${msg}`,
|
|
373
|
+
/** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
|
|
374
|
+
skip: (msg) => chalk.dim(`· ${msg}`),
|
|
375
|
+
hint: (msg) => chalk.dim(msg),
|
|
376
|
+
dim: (msg) => chalk.dim(msg),
|
|
377
|
+
header: (msg) => chalk.bold(msg)
|
|
378
|
+
};
|
|
379
|
+
//#endregion
|
|
302
380
|
//#region src/logger.ts
|
|
303
381
|
/**
|
|
382
|
+
* Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
|
|
383
|
+
* node`, …) — ADR-0012. These commands aren't "about" any repo, so their
|
|
384
|
+
* logs must never depend on `$PWD` or which repo's `.envrc` happens to be
|
|
385
|
+
* active; they always ship to holocron's own dataset instead of whatever
|
|
386
|
+
* `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
|
|
387
|
+
* `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
|
|
388
|
+
* per-repo override.
|
|
389
|
+
*/
|
|
390
|
+
const GLOBAL_AXIOM_DATASET = "holocron-global";
|
|
391
|
+
/**
|
|
304
392
|
* Resolve the explicit level to hand to `createLogger`, in priority order:
|
|
305
393
|
*
|
|
306
|
-
* 1. `--verbose` → `"debug"`
|
|
307
|
-
*
|
|
308
|
-
*
|
|
394
|
+
* 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
|
|
395
|
+
* 4. `HOLOCRON_LOG_LEVEL` env var
|
|
396
|
+
* 5. `holocron.config` `log.level` (`configLevel`)
|
|
309
397
|
*
|
|
310
398
|
* Returns `undefined` when nothing applies — `createLogger` then defaults
|
|
311
399
|
* to `"info"`. Resolving the full chain here (rather than passing
|
|
312
400
|
* `configLevel` straight through) keeps config below the env var.
|
|
313
401
|
*/
|
|
314
402
|
function resolveLogLevel(argv, configLevel) {
|
|
403
|
+
if (argv.log) return argv.log;
|
|
315
404
|
if (argv.verbose) return "debug";
|
|
316
405
|
if (argv.quiet) return "error";
|
|
317
406
|
return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
|
|
318
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Resolve whether human-visible console output (pretty or NDJSON) should run
|
|
410
|
+
* at all, independent of level — the `@theholocron/observability`
|
|
411
|
+
* `consoleOutput` override.
|
|
412
|
+
*
|
|
413
|
+
* Local/interactive runs are quiet by default: any raw structured logger
|
|
414
|
+
* output (colorized or not) stays off regardless of the resolved level,
|
|
415
|
+
* unless the user explicitly asked to see it via `--log <level>`,
|
|
416
|
+
* `--verbose`, or `--quiet`. CI is untouched either way — returning
|
|
417
|
+
* `undefined` there preserves `createLogger`'s own `ci`/`tty`
|
|
418
|
+
* auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
|
|
419
|
+
* unaffected by this flag.
|
|
420
|
+
*/
|
|
421
|
+
function resolveConsoleOutput(argv) {
|
|
422
|
+
if (argv.log || argv.verbose || argv.quiet) return true;
|
|
423
|
+
return isCI() ? void 0 : false;
|
|
424
|
+
}
|
|
319
425
|
let root;
|
|
320
426
|
let rootLevel;
|
|
321
427
|
let rootCommand;
|
|
322
428
|
let rootAxiomKey;
|
|
323
429
|
/**
|
|
324
|
-
* Resolve Axiom credentials for the CLI.
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* `holocron.config` `log.axiom.dataset
|
|
329
|
-
*
|
|
430
|
+
* Resolve Axiom credentials for the CLI.
|
|
431
|
+
*
|
|
432
|
+
* A `global`-context command (ADR-0012) always resolves to
|
|
433
|
+
* {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
|
|
434
|
+
* `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
|
|
435
|
+
* isn't "about" any repo, so the active directory's dataset is never the
|
|
436
|
+
* right answer. Everything else keeps the existing chain: env vars win
|
|
437
|
+
* (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
|
|
438
|
+
* falling back to the CLI-only bridge that pairs the OS-keyring token
|
|
439
|
+
* (`axiom.<org>` then bare `axiom`) with a dataset from
|
|
440
|
+
* `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
|
|
441
|
+
* Returns `undefined` unless both a token and a dataset are found.
|
|
330
442
|
*/
|
|
331
443
|
function resolveCliAxiom(opts) {
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
|
|
444
|
+
const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
|
|
445
|
+
if (!isGlobal) {
|
|
446
|
+
const fromEnv = resolveAxiomFromEnv();
|
|
447
|
+
if (fromEnv) return fromEnv;
|
|
448
|
+
}
|
|
449
|
+
const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : env.get("HOLOCRON_AXIOM_DATASET") || env.get("AXIOM_DATASET") || opts.configAxiomDataset;
|
|
335
450
|
if (!dataset) return void 0;
|
|
336
451
|
const org = opts.org ?? env.get("HOLOCRON_ORG");
|
|
337
|
-
const token = (org ? getToken(`axiom.${org}`) : null)
|
|
452
|
+
const token = env.get("HOLOCRON_AXIOM_TOKEN") || env.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
|
|
338
453
|
return token ? {
|
|
339
454
|
dataset,
|
|
340
455
|
token
|
|
@@ -354,13 +469,15 @@ function buildCliLogger(argv, opts = {}) {
|
|
|
354
469
|
const { command, configLevel } = opts;
|
|
355
470
|
if (command) rootCommand = command;
|
|
356
471
|
const level = resolveLogLevel(argv, configLevel);
|
|
472
|
+
const consoleOutput = resolveConsoleOutput(argv);
|
|
357
473
|
const axiom = resolveCliAxiom(opts);
|
|
358
474
|
const axiomKey = axiom?.dataset;
|
|
359
|
-
const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet;
|
|
475
|
+
const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
|
|
360
476
|
if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
|
|
361
477
|
const built = createLogger({
|
|
362
478
|
...level ? { level } : {},
|
|
363
|
-
...axiom ? { axiom } : {}
|
|
479
|
+
...axiom ? { axiom } : {},
|
|
480
|
+
...consoleOutput !== void 0 ? { consoleOutput } : {}
|
|
364
481
|
});
|
|
365
482
|
root = {
|
|
366
483
|
logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
|
|
@@ -371,9 +488,35 @@ function buildCliLogger(argv, opts = {}) {
|
|
|
371
488
|
}
|
|
372
489
|
return root;
|
|
373
490
|
}
|
|
374
|
-
/**
|
|
491
|
+
/**
|
|
492
|
+
* Lazily-memoized `Logger` for module-level call sites with no `argv` in
|
|
493
|
+
* scope — notably yargs' own `.fail()` handler, which fires on a
|
|
494
|
+
* pre-middleware validation failure (a missing required argument, an
|
|
495
|
+
* unknown command) and therefore runs before `buildCliLogger` has ever
|
|
496
|
+
* built the real root. Without an explicit `consoleOutput` here, that
|
|
497
|
+
* fallback would use `createLogger`'s bare auto-detection (pretty on any
|
|
498
|
+
* local TTY) and leak a structured block even though no flag asked for one
|
|
499
|
+
* — so it resolves the same "quiet unless CI" default `buildCliLogger`
|
|
500
|
+
* would, as if no flags were passed.
|
|
501
|
+
*/
|
|
375
502
|
function getLogger() {
|
|
376
|
-
return (root ??= createLogger()).logger;
|
|
503
|
+
return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Report a fatal, command-ending CLI error: always printed to the console
|
|
507
|
+
* (regardless of `consoleOutput` suppression) via `style.fail`, and also
|
|
508
|
+
* sent through the structured logger for Axiom/telemetry visibility.
|
|
509
|
+
*
|
|
510
|
+
* Use this when a handler is returning or exiting right here with nothing
|
|
511
|
+
* else for the user to see — a resolved `--token` failure, a yargs
|
|
512
|
+
* validation failure, a thrown domain error. Don't use it for a per-step
|
|
513
|
+
* warning inside a multi-step orchestrator that already prints its own
|
|
514
|
+
* `ok / fail / skip` summary — that summary is the user-facing signal;
|
|
515
|
+
* the per-step reason belongs in the structured log alone.
|
|
516
|
+
*/
|
|
517
|
+
function reportError(message, log = getLogger()) {
|
|
518
|
+
console.error(style.fail(message));
|
|
519
|
+
log.error(message);
|
|
377
520
|
}
|
|
378
521
|
/** The current root logger's correlation id, if a root has been built. */
|
|
379
522
|
function getRunId() {
|
|
@@ -491,7 +634,7 @@ var PluginLoader = class {
|
|
|
491
634
|
/** Internal — invoke a plugin's capability factory and return the impl. */
|
|
492
635
|
async loadOne(key, tuple) {
|
|
493
636
|
const mod = await this.importer(tuple.packageName).catch((err) => {
|
|
494
|
-
throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${
|
|
637
|
+
throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${errorMessage(err)}`);
|
|
495
638
|
});
|
|
496
639
|
if (isPluginModule(mod)) {
|
|
497
640
|
const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
|
|
@@ -532,7 +675,8 @@ var PluginLoader = class {
|
|
|
532
675
|
/** Default importer — resolves from cwd first so the global CLI finds project plugins. */
|
|
533
676
|
const defaultImporter$1 = async (pkg) => {
|
|
534
677
|
try {
|
|
535
|
-
|
|
678
|
+
const resolved = createRequire(process.cwd() + "/").resolve(pkg);
|
|
679
|
+
return import(pathToFileURL(resolved).href);
|
|
536
680
|
} catch {
|
|
537
681
|
return import(pkg);
|
|
538
682
|
}
|
|
@@ -632,17 +776,6 @@ async function withSpinner(label, fn) {
|
|
|
632
776
|
}
|
|
633
777
|
}
|
|
634
778
|
//#endregion
|
|
635
|
-
//#region src/ui/style.ts
|
|
636
|
-
const style = {
|
|
637
|
-
success: (msg) => `${chalk.green("✓")} ${msg}`,
|
|
638
|
-
warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
|
|
639
|
-
fail: (msg) => `${chalk.red("✗")} ${msg}`,
|
|
640
|
-
step: (msg) => `${chalk.cyan("→")} ${msg}`,
|
|
641
|
-
hint: (msg) => chalk.dim(msg),
|
|
642
|
-
dim: (msg) => chalk.dim(msg),
|
|
643
|
-
header: (msg) => chalk.bold(msg)
|
|
644
|
-
};
|
|
645
|
-
//#endregion
|
|
646
779
|
//#region src/commands/auth.ts
|
|
647
780
|
/**
|
|
648
781
|
* `holocron auth <subcommand>` — manage bootstrap credentials in the
|
|
@@ -664,7 +797,8 @@ const style = {
|
|
|
664
797
|
*/
|
|
665
798
|
const defaultImporter = async (pkg) => {
|
|
666
799
|
try {
|
|
667
|
-
|
|
800
|
+
const resolved = createRequire(process.cwd() + "/").resolve(pkg);
|
|
801
|
+
return await import(pathToFileURL(resolved).href);
|
|
668
802
|
} catch {
|
|
669
803
|
return await import(pkg);
|
|
670
804
|
}
|
|
@@ -733,7 +867,7 @@ async function runAuthSet(input) {
|
|
|
733
867
|
subject = verified.subject;
|
|
734
868
|
} else print(style.warn(`${provider} plugin has no verifyToken; storing without verification`));
|
|
735
869
|
} catch (err) {
|
|
736
|
-
const msg =
|
|
870
|
+
const msg = errorMessage(err);
|
|
737
871
|
print(style.warn(`cannot verify token — failed to load ${packageName}: ${msg}`));
|
|
738
872
|
print(style.hint(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`));
|
|
739
873
|
}
|
|
@@ -850,7 +984,7 @@ async function runAuthCheck(input) {
|
|
|
850
984
|
message: verified.message
|
|
851
985
|
};
|
|
852
986
|
} catch (err) {
|
|
853
|
-
const msg =
|
|
987
|
+
const msg = errorMessage(err);
|
|
854
988
|
if (isModuleNotFound(err)) {
|
|
855
989
|
print(style.warn(`${keyringKey}: token present — verification skipped (plugin not available here)`));
|
|
856
990
|
logger.info({
|
|
@@ -937,7 +1071,7 @@ async function runCleanupPreview(input) {
|
|
|
937
1071
|
// c8 ignore next -- real PluginLoader construction is integration-level; unit tests always supply loader
|
|
938
1072
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
939
1073
|
await loader.load();
|
|
940
|
-
assertPluginsResolvable(loader, "cleanup-preview");
|
|
1074
|
+
assertPluginsResolvable(loader, "deploy cleanup-preview");
|
|
941
1075
|
logger.info({
|
|
942
1076
|
pr: input.prNumber,
|
|
943
1077
|
project: input.project
|
|
@@ -949,7 +1083,7 @@ async function runCleanupPreview(input) {
|
|
|
949
1083
|
try {
|
|
950
1084
|
pr = await source.getPullRequest(input.prNumber, input.repo);
|
|
951
1085
|
} catch (err) {
|
|
952
|
-
const message =
|
|
1086
|
+
const message = errorMessage(err);
|
|
953
1087
|
throw new Error(`Failed to fetch PR #${input.prNumber}: ${message}`, { cause: err });
|
|
954
1088
|
}
|
|
955
1089
|
const branch = `${(input.repo ?? input.loaded.resolved.repo?.name ?? "").split("/").pop()}-pr-${pr.number}`;
|
|
@@ -966,7 +1100,7 @@ async function runCleanupPreview(input) {
|
|
|
966
1100
|
try {
|
|
967
1101
|
deployments = await deploy.listPreviewDeployments(input.project, branch);
|
|
968
1102
|
} catch (err) {
|
|
969
|
-
const message =
|
|
1103
|
+
const message = errorMessage(err);
|
|
970
1104
|
throw new Error(`Failed to list deployments: ${message}`, { cause: err });
|
|
971
1105
|
}
|
|
972
1106
|
if (deployments.length === 0) {
|
|
@@ -1045,7 +1179,7 @@ async function runCleanupPreview(input) {
|
|
|
1045
1179
|
status: "ok"
|
|
1046
1180
|
};
|
|
1047
1181
|
} catch (err) {
|
|
1048
|
-
const message =
|
|
1182
|
+
const message = errorMessage(err);
|
|
1049
1183
|
print(style.fail(message));
|
|
1050
1184
|
logger.warn({
|
|
1051
1185
|
pr: pr.number,
|
|
@@ -1065,13 +1199,56 @@ async function runCleanupPreview(input) {
|
|
|
1065
1199
|
}
|
|
1066
1200
|
}
|
|
1067
1201
|
//#endregion
|
|
1202
|
+
//#region src/ui/status-row.ts
|
|
1203
|
+
/** Right-pads `s` to `width` with spaces — never truncates. */
|
|
1204
|
+
function pad(s, width) {
|
|
1205
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
|
|
1209
|
+
* then `columns` joined with a two-space gap (blank columns dropped, so a
|
|
1210
|
+
* row that has nothing for its last column doesn't trail whitespace).
|
|
1211
|
+
* Column padding (`pad`) is the caller's job — `doctor` pads capability and
|
|
1212
|
+
* provider to a fixed width; `clone`'s repo names and paths don't, since
|
|
1213
|
+
* they vary too widely for fixed-width alignment to read cleanly.
|
|
1214
|
+
*
|
|
1215
|
+
* Shared by `doctor` (capability / provider / message) and `clone` (repo /
|
|
1216
|
+
* destination / reason) so every status table in the CLI reads the same way.
|
|
1217
|
+
*/
|
|
1218
|
+
function statusRow(status, columns) {
|
|
1219
|
+
const joined = columns.filter((c) => c.length > 0).join(" ");
|
|
1220
|
+
return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
|
|
1221
|
+
}
|
|
1222
|
+
//#endregion
|
|
1068
1223
|
//#region src/commands/clone.ts
|
|
1224
|
+
/**
|
|
1225
|
+
* Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
|
|
1226
|
+
* `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
|
|
1227
|
+
* repo. The repo half is never silently discarded: if it's there, it's used.
|
|
1228
|
+
* A trailing slash with nothing after it (`"theholocron/"`) is treated the
|
|
1229
|
+
* same as a bare org.
|
|
1230
|
+
*/
|
|
1231
|
+
function parseCloneTarget(raw) {
|
|
1232
|
+
const trimmed = raw.trim();
|
|
1233
|
+
const slash = trimmed.indexOf("/");
|
|
1234
|
+
if (slash === -1) return { org: trimmed };
|
|
1235
|
+
const org = trimmed.slice(0, slash);
|
|
1236
|
+
const repo = trimmed.slice(slash + 1);
|
|
1237
|
+
return repo ? {
|
|
1238
|
+
org,
|
|
1239
|
+
repo
|
|
1240
|
+
} : { org };
|
|
1241
|
+
}
|
|
1069
1242
|
function encodeTokenForGitHttpAuth(token) {
|
|
1070
1243
|
const trimmed = token.trim();
|
|
1071
1244
|
if (!trimmed) throw new Error("empty token");
|
|
1072
1245
|
if (/[\u0000-\u001F\u007F\s]/.test(trimmed)) throw new Error("token contains whitespace or control characters");
|
|
1073
1246
|
return encodeURIComponent(trimmed);
|
|
1074
1247
|
}
|
|
1248
|
+
/** Strip a leading dot so a hidden repo (e.g. `.github`) is still visible in Finder once cloned. */
|
|
1249
|
+
function repoDirName(repo) {
|
|
1250
|
+
return repo.name.startsWith(".") ? repo.name.slice(1) : repo.name;
|
|
1251
|
+
}
|
|
1075
1252
|
async function listOrgRepos(org, token, fetchFn) {
|
|
1076
1253
|
const repos = [];
|
|
1077
1254
|
let url = `https://api.github.com/orgs/${org}/repos?per_page=100&type=all`;
|
|
@@ -1088,6 +1265,16 @@ async function listOrgRepos(org, token, fetchFn) {
|
|
|
1088
1265
|
}
|
|
1089
1266
|
return repos;
|
|
1090
1267
|
}
|
|
1268
|
+
async function getRepo(org, repo, token, fetchFn) {
|
|
1269
|
+
const res = await fetchFn(`https://api.github.com/repos/${org}/${repo}`, { headers: {
|
|
1270
|
+
Authorization: `Bearer ${token}`,
|
|
1271
|
+
Accept: "application/vnd.github+json",
|
|
1272
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1273
|
+
} });
|
|
1274
|
+
if (res.status === 404) throw new Error(`${org}/${repo} not found — check the repo name and that the token can see it`);
|
|
1275
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${res.statusText} — check that the token has repo:read scope`);
|
|
1276
|
+
return await res.json();
|
|
1277
|
+
}
|
|
1091
1278
|
async function runClone(input) {
|
|
1092
1279
|
const print = input.print ?? ((line) => console.log(line));
|
|
1093
1280
|
const logger = input.logger ?? getLogger();
|
|
@@ -1096,6 +1283,7 @@ async function runClone(input) {
|
|
|
1096
1283
|
const targetDir = resolve(input.dir ?? join(homedir(), "Code", input.org));
|
|
1097
1284
|
logger.info({
|
|
1098
1285
|
org: input.org,
|
|
1286
|
+
repo: input.repo,
|
|
1099
1287
|
targetDir,
|
|
1100
1288
|
dryRun: dryRun || void 0
|
|
1101
1289
|
}, "clone: start");
|
|
@@ -1105,18 +1293,21 @@ async function runClone(input) {
|
|
|
1105
1293
|
stdio: "inherit"
|
|
1106
1294
|
}).status };
|
|
1107
1295
|
});
|
|
1108
|
-
print(
|
|
1109
|
-
if (!existsSync(targetDir))
|
|
1110
|
-
|
|
1296
|
+
print("");
|
|
1297
|
+
if (!existsSync(targetDir)) {
|
|
1298
|
+
if (dryRun) print(style.dim(` would create ${targetDir}`));
|
|
1299
|
+
else mkdirSync(targetDir, { recursive: true });
|
|
1300
|
+
}
|
|
1111
1301
|
let repos;
|
|
1112
1302
|
try {
|
|
1113
|
-
repos = await listOrgRepos(input.org, input.token, fetchFn);
|
|
1303
|
+
repos = input.repo ? [await getRepo(input.org, input.repo, input.token, fetchFn)] : await listOrgRepos(input.org, input.token, fetchFn);
|
|
1114
1304
|
} catch (err) {
|
|
1115
|
-
const message =
|
|
1305
|
+
const message = errorMessage(err);
|
|
1116
1306
|
logger.warn({
|
|
1117
1307
|
org: input.org,
|
|
1308
|
+
repo: input.repo,
|
|
1118
1309
|
reason: message
|
|
1119
|
-
}, "clone: failed to list
|
|
1310
|
+
}, "clone: failed to list repos");
|
|
1120
1311
|
return {
|
|
1121
1312
|
status: "fail",
|
|
1122
1313
|
cloned: 0,
|
|
@@ -1125,13 +1316,39 @@ async function runClone(input) {
|
|
|
1125
1316
|
message
|
|
1126
1317
|
};
|
|
1127
1318
|
}
|
|
1319
|
+
if (!input.repo && !dryRun && input.confirmWholeOrg) {
|
|
1320
|
+
const alreadyCloned = repos.filter((r) => existsSync(join(targetDir, repoDirName(r)))).length;
|
|
1321
|
+
const scope = {
|
|
1322
|
+
total: repos.length,
|
|
1323
|
+
alreadyCloned,
|
|
1324
|
+
toClone: repos.length - alreadyCloned
|
|
1325
|
+
};
|
|
1326
|
+
if (!await input.confirmWholeOrg(scope)) {
|
|
1327
|
+
print(style.dim(" aborted — nothing cloned"));
|
|
1328
|
+
logger.info({
|
|
1329
|
+
org: input.org,
|
|
1330
|
+
...scope
|
|
1331
|
+
}, "clone: aborted by user");
|
|
1332
|
+
return {
|
|
1333
|
+
status: "aborted",
|
|
1334
|
+
cloned: 0,
|
|
1335
|
+
skipped: 0,
|
|
1336
|
+
failed: 0,
|
|
1337
|
+
message: "aborted by user"
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1128
1341
|
let cloned = 0;
|
|
1129
1342
|
let skipped = 0;
|
|
1130
1343
|
let failed = 0;
|
|
1131
1344
|
for (const repo of repos) {
|
|
1132
|
-
const dest = join(targetDir,
|
|
1345
|
+
const dest = join(targetDir, repoDirName(repo));
|
|
1133
1346
|
if (existsSync(dest)) {
|
|
1134
|
-
print(
|
|
1347
|
+
print(statusRow("skip", [
|
|
1348
|
+
repo.full_name,
|
|
1349
|
+
dest,
|
|
1350
|
+
"already exists"
|
|
1351
|
+
]));
|
|
1135
1352
|
skipped++;
|
|
1136
1353
|
continue;
|
|
1137
1354
|
}
|
|
@@ -1140,10 +1357,14 @@ async function runClone(input) {
|
|
|
1140
1357
|
cloned++;
|
|
1141
1358
|
continue;
|
|
1142
1359
|
}
|
|
1143
|
-
print(style.step(` clone ${repo.full_name}`));
|
|
1360
|
+
print(style.step(` clone ${repo.full_name} → ${dest}`));
|
|
1144
1361
|
const { clone_url } = repo;
|
|
1145
1362
|
if (!clone_url.startsWith("https://github.com/")) {
|
|
1146
|
-
print(
|
|
1363
|
+
print(statusRow("fail", [
|
|
1364
|
+
repo.full_name,
|
|
1365
|
+
dest,
|
|
1366
|
+
`unexpected clone URL: ${clone_url}`
|
|
1367
|
+
]));
|
|
1147
1368
|
failed++;
|
|
1148
1369
|
continue;
|
|
1149
1370
|
}
|
|
@@ -1151,7 +1372,11 @@ async function runClone(input) {
|
|
|
1151
1372
|
try {
|
|
1152
1373
|
encodedToken = encodeTokenForGitHttpAuth(input.token);
|
|
1153
1374
|
} catch (err) {
|
|
1154
|
-
print(
|
|
1375
|
+
print(statusRow("fail", [
|
|
1376
|
+
repo.full_name,
|
|
1377
|
+
dest,
|
|
1378
|
+
`invalid token format: ${errorMessage(err)}`
|
|
1379
|
+
]));
|
|
1155
1380
|
failed++;
|
|
1156
1381
|
continue;
|
|
1157
1382
|
}
|
|
@@ -1161,23 +1386,29 @@ async function runClone(input) {
|
|
|
1161
1386
|
`https://x-access-token:${encodedToken}@github.com/${clone_url.slice(19)}`,
|
|
1162
1387
|
dest
|
|
1163
1388
|
], { cwd: targetDir }).status !== 0) {
|
|
1164
|
-
print(
|
|
1389
|
+
print(statusRow("fail", [repo.full_name, dest]));
|
|
1165
1390
|
failed++;
|
|
1166
1391
|
} else {
|
|
1167
|
-
print(
|
|
1392
|
+
print(statusRow("ok", [repo.full_name, dest]));
|
|
1168
1393
|
cloned++;
|
|
1169
1394
|
}
|
|
1170
1395
|
}
|
|
1171
|
-
const summary = `${cloned} cloned, ${
|
|
1172
|
-
print(
|
|
1396
|
+
const summary = `${cloned} cloned, ${failed} failed, ${skipped} skipped`;
|
|
1397
|
+
print("");
|
|
1398
|
+
print(dryRun ? style.dim(` dry-run: ${summary}`) : failed > 0 ? style.fail(` ${summary}`) : style.success(` ${summary}`));
|
|
1173
1399
|
const status = dryRun ? "dry-run" : failed > 0 ? "fail" : "ok";
|
|
1174
1400
|
logger[failed > 0 ? "warn" : "info"]({
|
|
1175
1401
|
org: input.org,
|
|
1402
|
+
repo: input.repo,
|
|
1176
1403
|
status,
|
|
1177
1404
|
cloned,
|
|
1178
1405
|
skipped,
|
|
1179
1406
|
failed
|
|
1180
1407
|
}, "clone: done");
|
|
1408
|
+
if (input.confirmOpen) {
|
|
1409
|
+
const openTarget = input.repo ? join(targetDir, repoDirName(repos[0])) : targetDir;
|
|
1410
|
+
if (existsSync(openTarget)) await input.confirmOpen(openTarget, dryRun && cloned > 0);
|
|
1411
|
+
}
|
|
1181
1412
|
return {
|
|
1182
1413
|
status,
|
|
1183
1414
|
cloned,
|
|
@@ -1186,53 +1417,81 @@ async function runClone(input) {
|
|
|
1186
1417
|
};
|
|
1187
1418
|
}
|
|
1188
1419
|
//#endregion
|
|
1189
|
-
//#region src/commands/
|
|
1420
|
+
//#region src/commands/deploy.ts
|
|
1190
1421
|
/**
|
|
1191
|
-
*
|
|
1192
|
-
* `
|
|
1193
|
-
* node"`), the bare verb otherwise.
|
|
1422
|
+
* `holocron deploy` — trigger a deployment via the configured
|
|
1423
|
+
* `deployment` capability.
|
|
1194
1424
|
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1425
|
+
* Two modes, one command: `runDeploy` (the default — a Git branch,
|
|
1426
|
+
* `triggerDeployment()`) and `runDeployFromFiles` (`--files <dir>` —
|
|
1427
|
+
* a local directory of source files, `deployFunction()`, no linked
|
|
1428
|
+
* repo required — for a consumer with no Git history to deploy from,
|
|
1429
|
+
* e.g. Sentinel, shipped as an npm package). Separate functions, not
|
|
1430
|
+
* one branching `runDeploy`, because their inputs genuinely differ
|
|
1431
|
+
* (`branch` vs `dir`) and their result shapes differ too
|
|
1432
|
+
* (`DeploymentRecord` carries `status`/`branch`; `DeployFunctionResult`
|
|
1433
|
+
* is deliberately minimal, same as `DeployScriptResult` for Workers).
|
|
1434
|
+
*
|
|
1435
|
+
* Errors clearly when `deployment` isn't loaded (no provider declared
|
|
1436
|
+
* in `holocron.config.json`), or when the configured provider doesn't
|
|
1437
|
+
* implement `deployFunction` (it's optional on `Deployment` — not
|
|
1438
|
+
* every provider has a files-based deploy API).
|
|
1439
|
+
*
|
|
1440
|
+
* Dry-run skips the actual deploy call but still verifies the
|
|
1441
|
+
* deployment capability (and, for `--files`, `deployFunction`) is
|
|
1442
|
+
* present, so operators can sanity-check the wiring without spinning
|
|
1443
|
+
* up a build.
|
|
1444
|
+
*
|
|
1445
|
+
* **Both modes wait for the deployment to finish** (holocron#911): a
|
|
1446
|
+
* provider accepting the upload/trigger is not the deployment succeeding.
|
|
1447
|
+
* Vercel builds asynchronously — an `npm install` failure there leaves
|
|
1448
|
+
* the deployment in `ERROR` while production keeps serving the previous
|
|
1449
|
+
* one, and this command used to report `ok` regardless (found live:
|
|
1450
|
+
* Sentinel's holocron#910 deploy "succeeded" in CI, never went live). So
|
|
1451
|
+
* after triggering, {@link waitForDeployment} polls
|
|
1452
|
+
* `Deployment.getDeployment()` until a terminal state; only `ready` is
|
|
1453
|
+
* `ok`, while `error` / `cancelled` / a timeout are `fail` (non-zero exit
|
|
1454
|
+
* via `cli.ts`).
|
|
1200
1455
|
*/
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
"
|
|
1206
|
-
"
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
}
|
|
1230
|
-
/**
|
|
1231
|
-
|
|
1232
|
-
|
|
1456
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 6e5;
|
|
1457
|
+
const DEFAULT_WAIT_INTERVAL_MS = 5e3;
|
|
1458
|
+
const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
1459
|
+
"ready",
|
|
1460
|
+
"error",
|
|
1461
|
+
"cancelled"
|
|
1462
|
+
]);
|
|
1463
|
+
/**
|
|
1464
|
+
* Polls `getDeployment(id)` until the deployment reaches a terminal status
|
|
1465
|
+
* (`ready` / `error` / `cancelled`). Throws on timeout — the caller turns
|
|
1466
|
+
* that into a `fail` report, same as a provider throwing mid-trigger.
|
|
1467
|
+
*/
|
|
1468
|
+
async function waitForDeployment(deploy, deploymentId, options = {}) {
|
|
1469
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
1470
|
+
const intervalMs = options.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
|
|
1471
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1472
|
+
const now = options.now ?? Date.now;
|
|
1473
|
+
const deadline = now() + timeoutMs;
|
|
1474
|
+
for (;;) {
|
|
1475
|
+
const record = await deploy.getDeployment(deploymentId);
|
|
1476
|
+
if (TERMINAL_STATUSES.has(record.status)) return record;
|
|
1477
|
+
if (now() >= deadline) throw new Error(`deployment ${deploymentId} still ${record.status} after ${Math.round(timeoutMs / 1e3)}s — check the provider's dashboard`);
|
|
1478
|
+
await sleep(intervalMs);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/** One terminal, non-ready record as a single human-readable failure line. */
|
|
1482
|
+
function terminalFailure(record) {
|
|
1483
|
+
return `deployment ${record.id} ended ${record.status}${record.errorMessage ? `: ${record.errorMessage}` : ""}`;
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Provider `url` fields (Vercel's REST API, at least) come back as bare
|
|
1487
|
+
* hostnames — `"my-app.vercel.app"`, no scheme — which terminals don't
|
|
1488
|
+
* linkify and browsers won't navigate to unmodified. Printed output
|
|
1489
|
+
* should be a URL a human can click/paste; structured log fields keep
|
|
1490
|
+
* the raw provider value as-is.
|
|
1491
|
+
*/
|
|
1492
|
+
function withScheme(url) {
|
|
1493
|
+
return /^https?:\/\//.test(url) ? url : `https://${url}`;
|
|
1233
1494
|
}
|
|
1234
|
-
//#endregion
|
|
1235
|
-
//#region src/commands/deploy.ts
|
|
1236
1495
|
async function runDeploy(input) {
|
|
1237
1496
|
const print = input.print ?? ((line) => console.log(line));
|
|
1238
1497
|
const logger = input.logger ?? getLogger();
|
|
@@ -1259,23 +1518,43 @@ async function runDeploy(input) {
|
|
|
1259
1518
|
};
|
|
1260
1519
|
}
|
|
1261
1520
|
try {
|
|
1262
|
-
const
|
|
1521
|
+
const triggered = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
|
|
1263
1522
|
projectId: input.projectId,
|
|
1264
1523
|
branch: input.branch,
|
|
1265
1524
|
...input.target ? { target: input.target } : {}
|
|
1266
1525
|
}));
|
|
1267
|
-
|
|
1526
|
+
logger.info({
|
|
1527
|
+
status: triggered.status,
|
|
1528
|
+
url: triggered.url,
|
|
1529
|
+
id: triggered.id
|
|
1530
|
+
}, "deploy: triggered");
|
|
1531
|
+
const record = TERMINAL_STATUSES.has(triggered.status) ? triggered : await withSpinner(`Waiting for ${withScheme(triggered.url)} to finish…`, () => waitForDeployment(deploy, triggered.id, input.wait));
|
|
1532
|
+
if (record.status !== "ready") {
|
|
1533
|
+
const message = terminalFailure(record);
|
|
1534
|
+
print(` ${style.fail(`${message} — ${withScheme(triggered.url)}`)}`);
|
|
1535
|
+
logger.warn({
|
|
1536
|
+
id: record.id,
|
|
1537
|
+
status: record.status,
|
|
1538
|
+
reason: record.errorMessage
|
|
1539
|
+
}, "deploy: failed");
|
|
1540
|
+
return {
|
|
1541
|
+
deployment: record,
|
|
1542
|
+
status: "fail",
|
|
1543
|
+
message
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
print(` ${style.success(`${record.status} — ${withScheme(triggered.url)}`)}`);
|
|
1268
1547
|
logger.info({
|
|
1269
1548
|
status: record.status,
|
|
1270
|
-
url:
|
|
1549
|
+
url: triggered.url,
|
|
1271
1550
|
id: record.id
|
|
1272
|
-
}, "deploy:
|
|
1551
|
+
}, "deploy: ready");
|
|
1273
1552
|
return {
|
|
1274
1553
|
deployment: record,
|
|
1275
1554
|
status: "ok"
|
|
1276
1555
|
};
|
|
1277
1556
|
} catch (err) {
|
|
1278
|
-
const message =
|
|
1557
|
+
const message = errorMessage(err);
|
|
1279
1558
|
print(` ${style.fail(message)}`);
|
|
1280
1559
|
logger.warn({
|
|
1281
1560
|
branch: input.branch,
|
|
@@ -1288,8 +1567,124 @@ async function runDeploy(input) {
|
|
|
1288
1567
|
};
|
|
1289
1568
|
}
|
|
1290
1569
|
}
|
|
1570
|
+
const SKIP_DIRS$2 = /* @__PURE__ */ new Set([
|
|
1571
|
+
".git",
|
|
1572
|
+
"node_modules",
|
|
1573
|
+
".turbo"
|
|
1574
|
+
]);
|
|
1575
|
+
/** Recursively lists absolute file paths under `dir`, skipping VCS/tooling noise. */
|
|
1576
|
+
function defaultWalkFiles$2(dir) {
|
|
1577
|
+
const results = [];
|
|
1578
|
+
for (const entry of readdirSync(dir)) {
|
|
1579
|
+
if (SKIP_DIRS$2.has(entry)) continue;
|
|
1580
|
+
const full = join(dir, entry);
|
|
1581
|
+
const stat = statSync(full);
|
|
1582
|
+
if (stat.isDirectory()) results.push(...defaultWalkFiles$2(full));
|
|
1583
|
+
else if (stat.isFile()) results.push(full);
|
|
1584
|
+
}
|
|
1585
|
+
return results;
|
|
1586
|
+
}
|
|
1587
|
+
/** `holocron deploy --files <dir>` — the non-git counterpart to `runDeploy`. */
|
|
1588
|
+
async function runDeployFromFiles(input) {
|
|
1589
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
1590
|
+
const logger = input.logger ?? getLogger();
|
|
1591
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
1592
|
+
await loader.load();
|
|
1593
|
+
assertPluginsResolvable(loader, "deploy");
|
|
1594
|
+
const dryRun = input.context.dryRun ?? false;
|
|
1595
|
+
const walkFiles = input.walkFiles ?? defaultWalkFiles$2;
|
|
1596
|
+
const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
1597
|
+
const files = {};
|
|
1598
|
+
for (const abs of walkFiles(input.dir)) {
|
|
1599
|
+
const rel = relative(input.dir, abs).split(sep).join("/");
|
|
1600
|
+
files[rel] = readFile(abs);
|
|
1601
|
+
}
|
|
1602
|
+
const fileCount = Object.keys(files).length;
|
|
1603
|
+
logger.info({
|
|
1604
|
+
dir: input.dir,
|
|
1605
|
+
fileCount,
|
|
1606
|
+
target: input.target ?? "preview",
|
|
1607
|
+
projectId: input.projectId,
|
|
1608
|
+
dryRun: dryRun || void 0
|
|
1609
|
+
}, "deploy: start (files)");
|
|
1610
|
+
print(style.header(`Holocron deploy — files=${input.dir} (${fileCount} file${fileCount === 1 ? "" : "s"})${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
|
|
1611
|
+
if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
|
|
1612
|
+
const deploy = loader.get("deployment");
|
|
1613
|
+
if (!deploy.deployFunction) throw new Error(`deployment provider "${deploy.providerName}" does not support deploying from files — deployFunction() is not implemented`);
|
|
1614
|
+
if (dryRun) {
|
|
1615
|
+
const message = `would: ${deploy.providerName}.deployFunction(projectId=${input.projectId}, files=${fileCount}${input.target ? `, target=${input.target}` : ""})`;
|
|
1616
|
+
print(` ${style.dim(`… ${message}`)}`);
|
|
1617
|
+
return {
|
|
1618
|
+
deployment: null,
|
|
1619
|
+
status: "dry-run",
|
|
1620
|
+
message
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
const result = await withSpinner(`Deploying ${fileCount} file${fileCount === 1 ? "" : "s"} from ${input.dir}…`, () => deploy.deployFunction(input.projectId, {
|
|
1625
|
+
files,
|
|
1626
|
+
...input.target ? { target: input.target } : {}
|
|
1627
|
+
}));
|
|
1628
|
+
logger.info({
|
|
1629
|
+
url: result.url,
|
|
1630
|
+
id: result.deploymentId
|
|
1631
|
+
}, "deploy: triggered (files)");
|
|
1632
|
+
const record = await withSpinner(`Waiting for ${withScheme(result.url)} to finish…`, () => waitForDeployment(deploy, result.deploymentId, input.wait));
|
|
1633
|
+
if (record.status !== "ready") {
|
|
1634
|
+
const message = terminalFailure(record);
|
|
1635
|
+
print(` ${style.fail(`${message} — ${withScheme(result.url)}`)}`);
|
|
1636
|
+
logger.warn({
|
|
1637
|
+
id: record.id,
|
|
1638
|
+
status: record.status,
|
|
1639
|
+
reason: record.errorMessage
|
|
1640
|
+
}, "deploy: failed (files)");
|
|
1641
|
+
return {
|
|
1642
|
+
deployment: result,
|
|
1643
|
+
status: "fail",
|
|
1644
|
+
message
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
print(` ${style.success(withScheme(result.url))}`);
|
|
1648
|
+
logger.info({
|
|
1649
|
+
url: result.url,
|
|
1650
|
+
id: result.deploymentId
|
|
1651
|
+
}, "deploy: ready (files)");
|
|
1652
|
+
return {
|
|
1653
|
+
deployment: result,
|
|
1654
|
+
status: "ok"
|
|
1655
|
+
};
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
const message = errorMessage(err);
|
|
1658
|
+
print(` ${style.fail(message)}`);
|
|
1659
|
+
logger.warn({
|
|
1660
|
+
dir: input.dir,
|
|
1661
|
+
reason: message
|
|
1662
|
+
}, "deploy: failed (files)");
|
|
1663
|
+
return {
|
|
1664
|
+
deployment: null,
|
|
1665
|
+
status: "fail",
|
|
1666
|
+
message
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1291
1670
|
//#endregion
|
|
1292
1671
|
//#region src/commands/doctor.ts
|
|
1672
|
+
/**
|
|
1673
|
+
* `holocron doctor` — load the config, instantiate every capability,
|
|
1674
|
+
* print a per-capability readiness report.
|
|
1675
|
+
*
|
|
1676
|
+
* Each capability gets a smoke check appropriate to its surface:
|
|
1677
|
+
* source → `whoami()`
|
|
1678
|
+
* issues → `doctor()` (rich report)
|
|
1679
|
+
* secrets → `listSecrets({ kind: 'repo' })`
|
|
1680
|
+
* ci → `listRuns({ limit: 1 })`
|
|
1681
|
+
* errors → `describe()`
|
|
1682
|
+
* logs → `whoami()` (dataset reachability) or `describe()`
|
|
1683
|
+
* others → just "loaded" (no smoke endpoint defined yet)
|
|
1684
|
+
*
|
|
1685
|
+
* Output is plain text via a passed-in `print` function so tests can
|
|
1686
|
+
* capture lines without ANSI / spinner noise.
|
|
1687
|
+
*/
|
|
1293
1688
|
async function runDoctor(input) {
|
|
1294
1689
|
const print = input.print ?? ((line) => console.log(line));
|
|
1295
1690
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
@@ -1297,7 +1692,7 @@ async function runDoctor(input) {
|
|
|
1297
1692
|
assertPluginsResolvable(loader, "doctor");
|
|
1298
1693
|
const rows = [];
|
|
1299
1694
|
const config = input.loaded.resolved;
|
|
1300
|
-
print(
|
|
1695
|
+
print("");
|
|
1301
1696
|
print(style.dim(` config: ${input.loaded.filepath}`));
|
|
1302
1697
|
print("");
|
|
1303
1698
|
for (const failure of loader.loadFailures()) rows.push({
|
|
@@ -1332,10 +1727,7 @@ async function runDoctor(input) {
|
|
|
1332
1727
|
status: row.status,
|
|
1333
1728
|
detail: row.message
|
|
1334
1729
|
}, `doctor: ${row.capability}`);
|
|
1335
|
-
|
|
1336
|
-
if (row.status === "ok") print(` ${style.success(label)}`);
|
|
1337
|
-
else if (row.status === "fail") print(` ${style.fail(label)}`);
|
|
1338
|
-
else print(` ${style.dim(`· ${label}`)}`);
|
|
1730
|
+
print(statusRow(row.status, [`${pad(row.capability, 14)} via ${pad(row.provider, 14)}`, row.message]));
|
|
1339
1731
|
}
|
|
1340
1732
|
const summary = rows.reduce((acc, r) => {
|
|
1341
1733
|
acc[r.status] += 1;
|
|
@@ -1347,7 +1739,7 @@ async function runDoctor(input) {
|
|
|
1347
1739
|
});
|
|
1348
1740
|
const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`;
|
|
1349
1741
|
print("");
|
|
1350
|
-
print(
|
|
1742
|
+
print(style.header(summaryLine));
|
|
1351
1743
|
return {
|
|
1352
1744
|
rows,
|
|
1353
1745
|
summary
|
|
@@ -1367,6 +1759,24 @@ async function smokeCheck(key, provider, impl) {
|
|
|
1367
1759
|
return mk(key, provider, "ok", runs.length === 0 ? "no recent runs" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
|
|
1368
1760
|
}
|
|
1369
1761
|
case "vault": return mk(key, provider, "ok", `${(await impl.list()).length} keys available`);
|
|
1762
|
+
case "deployment": return mk(key, provider, "ok", `${(await impl.listProjects()).length} project(s) visible`);
|
|
1763
|
+
case "environments": return mk(key, provider, "ok", `${(await impl.listEnvironments()).length} environment(s) configured`);
|
|
1764
|
+
case "dns": {
|
|
1765
|
+
const dns = impl;
|
|
1766
|
+
if (dns.whoami) {
|
|
1767
|
+
const who = await dns.whoami();
|
|
1768
|
+
return mk(key, provider, who.ok ? "ok" : "fail", `${who.zones} zone(s) visible`);
|
|
1769
|
+
}
|
|
1770
|
+
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
1771
|
+
}
|
|
1772
|
+
case "workers": {
|
|
1773
|
+
const workers = impl;
|
|
1774
|
+
if (workers.whoami) {
|
|
1775
|
+
const who = await workers.whoami();
|
|
1776
|
+
return mk(key, provider, who.ok ? "ok" : "fail", `${who.scripts} script(s) visible`);
|
|
1777
|
+
}
|
|
1778
|
+
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
1779
|
+
}
|
|
1370
1780
|
case "auth": {
|
|
1371
1781
|
const desc = await impl.describe();
|
|
1372
1782
|
return mk(key, provider, "ok", `provider: ${desc.provider}; env keys: ${desc.envKeys.join(", ")}`);
|
|
@@ -1384,10 +1794,18 @@ async function smokeCheck(key, provider, impl) {
|
|
|
1384
1794
|
}
|
|
1385
1795
|
return mk(key, provider, "ok", `provider: ${desc.provider}; env keys: ${desc.envKeys.join(", ")}`);
|
|
1386
1796
|
}
|
|
1797
|
+
case "monitoring": {
|
|
1798
|
+
const monitoring = impl;
|
|
1799
|
+
if (monitoring.whoami) {
|
|
1800
|
+
const who = await monitoring.whoami();
|
|
1801
|
+
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token verified" : "token check failed");
|
|
1802
|
+
}
|
|
1803
|
+
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
1804
|
+
}
|
|
1387
1805
|
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
1388
1806
|
}
|
|
1389
1807
|
} catch (err) {
|
|
1390
|
-
return mk(key, provider, "fail",
|
|
1808
|
+
return mk(key, provider, "fail", errorMessage(err));
|
|
1391
1809
|
}
|
|
1392
1810
|
}
|
|
1393
1811
|
function countResolvedLifecycle(report) {
|
|
@@ -1401,13 +1819,10 @@ function mk(capability, provider, status, message) {
|
|
|
1401
1819
|
message
|
|
1402
1820
|
};
|
|
1403
1821
|
}
|
|
1404
|
-
function pad(s, width) {
|
|
1405
|
-
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
1406
|
-
}
|
|
1407
1822
|
//#endregion
|
|
1408
1823
|
//#region src/commands/new.ts
|
|
1409
1824
|
/**
|
|
1410
|
-
* `holocron new <type> <name>` — create a GitHub repo from a template and
|
|
1825
|
+
* `holocron new repo <type> <name>` — create a GitHub repo from a template and
|
|
1411
1826
|
* bootstrap it by replacing all template-slug casing variants with the new
|
|
1412
1827
|
* project name, then generate a `holocron.config.ts` from the answers given
|
|
1413
1828
|
* during the interactive wizard.
|
|
@@ -1657,7 +2072,7 @@ async function runNew(input) {
|
|
|
1657
2072
|
stdio: "inherit"
|
|
1658
2073
|
});
|
|
1659
2074
|
} catch (err) {
|
|
1660
|
-
throw new NewError(`gh repo create failed: ${
|
|
2075
|
+
throw new NewError(`gh repo create failed: ${errorMessage(err)}`);
|
|
1661
2076
|
}
|
|
1662
2077
|
if (input.isTemplate) try {
|
|
1663
2078
|
execFn("gh", [
|
|
@@ -1752,7 +2167,7 @@ async function runNew(input) {
|
|
|
1752
2167
|
stdio: "inherit"
|
|
1753
2168
|
});
|
|
1754
2169
|
} catch (err) {
|
|
1755
|
-
print(` ✗ pnpm install failed — ${
|
|
2170
|
+
print(` ✗ pnpm install failed — ${errorMessage(err)}`);
|
|
1756
2171
|
return {
|
|
1757
2172
|
status: "fail",
|
|
1758
2173
|
repoDir,
|
|
@@ -1761,7 +2176,7 @@ async function runNew(input) {
|
|
|
1761
2176
|
};
|
|
1762
2177
|
}
|
|
1763
2178
|
print("");
|
|
1764
|
-
print(" Running holocron setup…");
|
|
2179
|
+
print(" Running holocron repo setup…");
|
|
1765
2180
|
try {
|
|
1766
2181
|
const setupArgs = ["setup"];
|
|
1767
2182
|
const setupEnv = {};
|
|
@@ -1781,7 +2196,7 @@ async function runNew(input) {
|
|
|
1781
2196
|
env: setupEnv
|
|
1782
2197
|
});
|
|
1783
2198
|
} catch {
|
|
1784
|
-
print(" ✗ holocron setup failed — run it manually after checking your config");
|
|
2199
|
+
print(" ✗ holocron repo setup failed — run it manually after checking your config");
|
|
1785
2200
|
}
|
|
1786
2201
|
}
|
|
1787
2202
|
print("");
|
|
@@ -1791,7 +2206,7 @@ async function runNew(input) {
|
|
|
1791
2206
|
print(` 1. cd ${repoDir}`);
|
|
1792
2207
|
if (input.noVerify) {
|
|
1793
2208
|
print(` 2. pnpm install`);
|
|
1794
|
-
print(` 3. holocron setup # wire up secrets, teams, labels, etc.`);
|
|
2209
|
+
print(` 3. holocron repo setup # wire up secrets, teams, labels, etc.`);
|
|
1795
2210
|
print(` 4. git push -u origin HEAD`);
|
|
1796
2211
|
} else print(` 2. git push -u origin HEAD`);
|
|
1797
2212
|
logger.info({
|
|
@@ -1963,14 +2378,16 @@ describe("resolveToken", () => {
|
|
|
1963
2378
|
});
|
|
1964
2379
|
|
|
1965
2380
|
it("throws AuthError with a helpful message when nothing is set", () => {
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2381
|
+
const err = (() => {
|
|
2382
|
+
try {
|
|
2383
|
+
resolveToken({ env: {}, keyring: noKeyring });
|
|
2384
|
+
} catch (e) {
|
|
2385
|
+
return e;
|
|
2386
|
+
}
|
|
2387
|
+
})();
|
|
2388
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
2389
|
+
expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
|
|
2390
|
+
expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
|
|
1974
2391
|
});
|
|
1975
2392
|
});
|
|
1976
2393
|
`;
|
|
@@ -2014,24 +2431,24 @@ export class ${inputs.capabilityClass} {
|
|
|
2014
2431
|
//#endregion
|
|
2015
2432
|
//#region src/commands/plugin-create/templates/capability-test.ts
|
|
2016
2433
|
function render$14(inputs) {
|
|
2017
|
-
const
|
|
2018
|
-
return `import { describe, it } from "vitest";
|
|
2434
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2435
|
+
return `import { describe, expect, it } from "vitest";
|
|
2019
2436
|
|
|
2020
2437
|
import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
|
|
2021
|
-
import { ${
|
|
2438
|
+
import { ${factoryName} } from "../rest.js";
|
|
2022
2439
|
import { stubFetch } from "./helpers.js";
|
|
2023
2440
|
|
|
2024
2441
|
// Stub client used by the constructor smoke test. Real capability
|
|
2025
2442
|
// tests replace this with per-method stubs once implementations land.
|
|
2026
2443
|
function makeCapability() {
|
|
2027
2444
|
const stub = stubFetch([]);
|
|
2028
|
-
const rest =
|
|
2445
|
+
const rest = ${factoryName}({ token: "t", fetch: stub.fetch });
|
|
2029
2446
|
return new ${inputs.capabilityClass}(() => rest);
|
|
2030
2447
|
}
|
|
2031
2448
|
|
|
2032
2449
|
describe("${inputs.capabilityClass}", () => {
|
|
2033
2450
|
it("constructs with a REST client", () => {
|
|
2034
|
-
makeCapability();
|
|
2451
|
+
expect(makeCapability().key).toBe("${inputs.capability}");
|
|
2035
2452
|
});
|
|
2036
2453
|
|
|
2037
2454
|
// TODO: implement one test per ${inputs.capability} capability method
|
|
@@ -2047,14 +2464,11 @@ describe("${inputs.capabilityClass}", () => {
|
|
|
2047
2464
|
//#endregion
|
|
2048
2465
|
//#region src/commands/plugin-create/templates/eslint-config.ts
|
|
2049
2466
|
function render$13(_inputs) {
|
|
2050
|
-
return `import
|
|
2467
|
+
return `import type { Linter } from "eslint";
|
|
2051
2468
|
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
ignores: ["dist/**", "coverage/**"],
|
|
2056
|
-
},
|
|
2057
|
-
];
|
|
2469
|
+
import root from "../../eslint.config.js";
|
|
2470
|
+
|
|
2471
|
+
export default [...root, { ignores: ["dist/**", "coverage/**"] }] satisfies Linter.Config[];
|
|
2058
2472
|
`;
|
|
2059
2473
|
}
|
|
2060
2474
|
//#endregion
|
|
@@ -2137,22 +2551,33 @@ describe("AUTH_HINT", () => {
|
|
|
2137
2551
|
//#endregion
|
|
2138
2552
|
//#region src/commands/plugin-create/templates/package-json.ts
|
|
2139
2553
|
function render$10(inputs) {
|
|
2554
|
+
const keywords = [
|
|
2555
|
+
inputs.capability,
|
|
2556
|
+
"holocron",
|
|
2557
|
+
"plugin",
|
|
2558
|
+
inputs.slug
|
|
2559
|
+
].sort((a, b) => a.localeCompare(b));
|
|
2140
2560
|
return JSON.stringify({
|
|
2141
2561
|
name: `@theholocron/holocron-plugin-${inputs.slug}`,
|
|
2142
2562
|
version: "2.0.0-alpha.1",
|
|
2143
2563
|
description: `Holocron plugin for ${inputs.vendorName}. Implements the ${inputs.capability} capability against ${inputs.vendorName}'s REST API, plus exports verifyToken + AUTH_HINT for \`holocron auth\`.`,
|
|
2144
|
-
|
|
2564
|
+
keywords,
|
|
2565
|
+
homepage: `https://github.com/theholocron/holocron/tree/main/plugins/holocron-plugin-${inputs.slug}#readme`,
|
|
2145
2566
|
bugs: "https://github.com/theholocron/holocron/issues",
|
|
2146
2567
|
repository: {
|
|
2147
2568
|
type: "git",
|
|
2148
2569
|
url: "git+https://github.com/theholocron/holocron.git",
|
|
2149
|
-
directory: `
|
|
2570
|
+
directory: `plugins/holocron-plugin-${inputs.slug}`
|
|
2150
2571
|
},
|
|
2151
2572
|
license: "MIT",
|
|
2152
2573
|
author: "Newton Koumantzelis",
|
|
2574
|
+
sideEffects: false,
|
|
2153
2575
|
type: "module",
|
|
2154
|
-
|
|
2155
|
-
|
|
2576
|
+
exports: { ".": {
|
|
2577
|
+
types: "./dist/index.d.mts",
|
|
2578
|
+
import: "./dist/index.mjs",
|
|
2579
|
+
default: "./dist/index.mjs"
|
|
2580
|
+
} },
|
|
2156
2581
|
scripts: {
|
|
2157
2582
|
"delivery.build": "tsdown",
|
|
2158
2583
|
"sourceQuality.staticAnalysis": "holocron run sourceQuality.staticAnalysis --",
|
|
@@ -2161,36 +2586,33 @@ function render$10(inputs) {
|
|
|
2161
2586
|
"test:watch": "vitest",
|
|
2162
2587
|
validate: "tsx scripts/validate.mjs"
|
|
2163
2588
|
},
|
|
2164
|
-
peerDependencies: {
|
|
2589
|
+
peerDependencies: {
|
|
2590
|
+
"@theholocron/cli": "workspace:*",
|
|
2591
|
+
"@theholocron/misc-utils": "catalog:utils"
|
|
2592
|
+
},
|
|
2165
2593
|
devDependencies: {
|
|
2166
2594
|
"@theholocron/cli": "workspace:*",
|
|
2167
|
-
"@theholocron/
|
|
2168
|
-
"@tsconfig
|
|
2595
|
+
"@theholocron/misc-utils": "catalog:utils",
|
|
2596
|
+
"@theholocron/tsconfig": "catalog:configs",
|
|
2597
|
+
"@types/node": "catalog:",
|
|
2169
2598
|
"@vitest/coverage-v8": "catalog:",
|
|
2170
2599
|
eslint: "catalog:",
|
|
2171
2600
|
globals: "catalog:",
|
|
2172
|
-
typescript: "catalog:",
|
|
2173
|
-
vitest: "catalog:",
|
|
2174
2601
|
tsdown: "catalog:",
|
|
2175
|
-
tsx: "catalog:"
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
access: "public",
|
|
2179
|
-
main: "./dist/index.mjs",
|
|
2180
|
-
types: "./dist/index.d.mts",
|
|
2181
|
-
exports: { ".": {
|
|
2182
|
-
types: "./dist/index.d.mts",
|
|
2183
|
-
import: "./dist/index.mjs",
|
|
2184
|
-
default: "./dist/index.mjs"
|
|
2185
|
-
} }
|
|
2602
|
+
tsx: "catalog:",
|
|
2603
|
+
typescript: "catalog:",
|
|
2604
|
+
vitest: "catalog:"
|
|
2186
2605
|
},
|
|
2187
|
-
|
|
2188
|
-
|
|
2606
|
+
engines: { node: ">=22" },
|
|
2607
|
+
publishConfig: { access: "public" },
|
|
2608
|
+
files: ["dist"]
|
|
2609
|
+
}, null, 2) + "\n";
|
|
2189
2610
|
}
|
|
2190
2611
|
//#endregion
|
|
2191
2612
|
//#region src/commands/plugin-create/templates/plugin-index.ts
|
|
2192
2613
|
function render$9(inputs) {
|
|
2193
2614
|
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2615
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2194
2616
|
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
2195
2617
|
return `/**
|
|
2196
2618
|
* \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
|
|
@@ -2207,7 +2629,7 @@ function render$9(inputs) {
|
|
|
2207
2629
|
|
|
2208
2630
|
import { resolveToken, type ResolveTokenInput } from "./auth.js";
|
|
2209
2631
|
import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2210
|
-
import { ${clientClass} } from "./rest.js";
|
|
2632
|
+
import { ${factoryName}, type ${clientClass} } from "./rest.js";
|
|
2211
2633
|
|
|
2212
2634
|
export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
|
|
2213
2635
|
/** Override base URL for tests. */
|
|
@@ -2228,10 +2650,12 @@ export function createContext(options: ${inputs.vendorName}PluginOptions): Plugi
|
|
|
2228
2650
|
options,
|
|
2229
2651
|
rest: () => {
|
|
2230
2652
|
if (rest) return rest;
|
|
2231
|
-
const restOpts:
|
|
2653
|
+
const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = {
|
|
2654
|
+
token: resolveToken(options),
|
|
2655
|
+
};
|
|
2232
2656
|
if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
|
|
2233
2657
|
if (options.fetch !== undefined) restOpts.fetch = options.fetch;
|
|
2234
|
-
return (rest =
|
|
2658
|
+
return (rest = ${factoryName}(restOpts));
|
|
2235
2659
|
},
|
|
2236
2660
|
};
|
|
2237
2661
|
}
|
|
@@ -2265,10 +2689,10 @@ export const AUTH_HINT =
|
|
|
2265
2689
|
// ── Public re-exports ────────────────────────────────────────────────
|
|
2266
2690
|
|
|
2267
2691
|
export * from "./auth.js";
|
|
2268
|
-
export { ${clientClass} } from "./rest.js";
|
|
2692
|
+
export { type ${clientClass}, ${factoryName} } from "./rest.js";
|
|
2269
2693
|
export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2270
2694
|
export { verifyToken } from "./verify-token.js";
|
|
2271
|
-
export type { VerifyTokenResult, VerifyTokenSuccess
|
|
2695
|
+
export type { VerifyTokenFailure, VerifyTokenResult, VerifyTokenSuccess } from "@theholocron/cli";
|
|
2272
2696
|
`;
|
|
2273
2697
|
}
|
|
2274
2698
|
//#endregion
|
|
@@ -2335,22 +2759,33 @@ TODO: fill in as capability methods land.
|
|
|
2335
2759
|
|
|
2336
2760
|
## Status
|
|
2337
2761
|
|
|
2338
|
-
**\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin
|
|
2762
|
+
**\`v2.0.0-alpha.1\`** — scaffolded via \`holocron new plugin\`.
|
|
2339
2763
|
Not yet published; capability methods are stubs.
|
|
2340
2764
|
`;
|
|
2341
2765
|
}
|
|
2342
2766
|
//#endregion
|
|
2343
2767
|
//#region src/commands/plugin-create/templates/rest.ts
|
|
2344
2768
|
function render$7(inputs) {
|
|
2345
|
-
|
|
2769
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2770
|
+
const clientType = `${inputs.vendorName}RestClient`;
|
|
2771
|
+
return `import { createRestClient, type RestClient } from "@theholocron/cli";
|
|
2772
|
+
|
|
2773
|
+
export type { RestClient };
|
|
2346
2774
|
|
|
2347
|
-
|
|
2775
|
+
/**
|
|
2776
|
+
* TODO: narrow this to the specific ${inputs.vendorName} endpoints this
|
|
2777
|
+
* plugin's capability needs (see \`@theholocron/holocron-plugin-axiom\`'s
|
|
2778
|
+
* \`rest.ts\` for the pattern: an interface with one method per endpoint,
|
|
2779
|
+
* built internally via \`createRestClient\`). Starts as an alias for the
|
|
2780
|
+
* generic client so the scaffold compiles end to end immediately.
|
|
2781
|
+
*/
|
|
2782
|
+
export type ${clientType} = RestClient;
|
|
2348
2783
|
|
|
2349
|
-
export function ${
|
|
2784
|
+
export function ${factoryName}(opts: {
|
|
2350
2785
|
\ttoken: string;
|
|
2351
2786
|
\tbaseUrl?: string;
|
|
2352
2787
|
\tfetch?: typeof fetch;
|
|
2353
|
-
}):
|
|
2788
|
+
}): ${clientType} {
|
|
2354
2789
|
\treturn createRestClient({
|
|
2355
2790
|
\t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
|
|
2356
2791
|
\t\ttoken: opts.token,
|
|
@@ -2423,7 +2858,7 @@ describe("${factoryName}", () => {
|
|
|
2423
2858
|
function render$5(inputs) {
|
|
2424
2859
|
return JSON.stringify({
|
|
2425
2860
|
display: `Holocron Plugin: ${inputs.vendorName}`,
|
|
2426
|
-
extends: "@tsconfig/node-lts
|
|
2861
|
+
extends: "@theholocron/tsconfig/node-lts",
|
|
2427
2862
|
compilerOptions: {
|
|
2428
2863
|
baseUrl: "./",
|
|
2429
2864
|
outDir: "./dist",
|
|
@@ -2431,7 +2866,7 @@ function render$5(inputs) {
|
|
|
2431
2866
|
},
|
|
2432
2867
|
include: ["src/**/*.ts"],
|
|
2433
2868
|
exclude: ["node_modules", "dist"]
|
|
2434
|
-
}, null,
|
|
2869
|
+
}, null, 2) + "\n";
|
|
2435
2870
|
}
|
|
2436
2871
|
//#endregion
|
|
2437
2872
|
//#region src/commands/plugin-create/templates/tsdown-config.ts
|
|
@@ -2487,11 +2922,12 @@ function render$3(inputs) {
|
|
|
2487
2922
|
* TODO: replace the positional args below with whatever your
|
|
2488
2923
|
* capability's methods need (e.g., project id, environment slug,
|
|
2489
2924
|
* secret name). Adapt the test steps to your capability's method
|
|
2490
|
-
* surface. Model on \`
|
|
2925
|
+
* surface. Model on \`plugins/holocron-plugin-infisical/scripts/validate.mjs\`.
|
|
2491
2926
|
*/
|
|
2492
2927
|
|
|
2493
2928
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
|
|
2494
2929
|
import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
|
|
2930
|
+
import { errorMessage } from "@theholocron/misc-utils";
|
|
2495
2931
|
|
|
2496
2932
|
const args = process.argv.slice(2);
|
|
2497
2933
|
|
|
@@ -2511,7 +2947,7 @@ try {
|
|
|
2511
2947
|
} catch (err) {
|
|
2512
2948
|
if (err instanceof AuthError) {
|
|
2513
2949
|
console.error(err.message);
|
|
2514
|
-
console.error(" see:
|
|
2950
|
+
console.error(" see: plugins/holocron-plugin-${inputs.slug}/README.md#setup");
|
|
2515
2951
|
process.exit(2);
|
|
2516
2952
|
}
|
|
2517
2953
|
throw err;
|
|
@@ -2547,11 +2983,12 @@ console.log("Done. Fill in capability method calls above before shipping.");
|
|
|
2547
2983
|
// ── helpers ────────────────────────────────────────────────────────
|
|
2548
2984
|
|
|
2549
2985
|
/** Wrap a capability call, print ERROR + hint on failure. */
|
|
2986
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- called once a real capability check replaces the TODO example above
|
|
2550
2987
|
async function runStep(body) {
|
|
2551
2988
|
try {
|
|
2552
2989
|
await body();
|
|
2553
2990
|
} catch (err) {
|
|
2554
|
-
const message =
|
|
2991
|
+
const message = errorMessage(err);
|
|
2555
2992
|
console.log(\` ✗ ERROR: \${message}\`);
|
|
2556
2993
|
const hint = hintFor(message);
|
|
2557
2994
|
if (hint) console.log(\` hint: \${hint}\`);
|
|
@@ -2586,7 +3023,7 @@ function hintFor(message) {
|
|
|
2586
3023
|
//#endregion
|
|
2587
3024
|
//#region src/commands/plugin-create/templates/verify-token.ts
|
|
2588
3025
|
function render$2(inputs) {
|
|
2589
|
-
const
|
|
3026
|
+
const factoryName = `create${inputs.vendorName}RestClient`;
|
|
2590
3027
|
return `/**
|
|
2591
3028
|
* \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
|
|
2592
3029
|
* \`holocron auth check\`. Hits a lightweight whoami-style endpoint
|
|
@@ -2603,19 +3040,10 @@ function render$2(inputs) {
|
|
|
2603
3040
|
* \`/me\`, \`/account\`.
|
|
2604
3041
|
*/
|
|
2605
3042
|
|
|
2606
|
-
import
|
|
2607
|
-
|
|
2608
|
-
export interface VerifyTokenSuccess {
|
|
2609
|
-
ok: true;
|
|
2610
|
-
subject: string;
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
export interface VerifyTokenFailure {
|
|
2614
|
-
ok: false;
|
|
2615
|
-
message: string;
|
|
2616
|
-
}
|
|
3043
|
+
import type { VerifyTokenResult } from "@theholocron/cli";
|
|
3044
|
+
import { errorMessage } from "@theholocron/misc-utils";
|
|
2617
3045
|
|
|
2618
|
-
|
|
3046
|
+
import { ${factoryName} } from "./rest.js";
|
|
2619
3047
|
|
|
2620
3048
|
interface MeResponse {
|
|
2621
3049
|
/** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
|
|
@@ -2630,18 +3058,17 @@ export interface VerifyTokenOptions {
|
|
|
2630
3058
|
}
|
|
2631
3059
|
|
|
2632
3060
|
export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
|
|
2633
|
-
const restOpts:
|
|
3061
|
+
const restOpts: { token: string; baseUrl?: string; fetch?: typeof fetch } = { token };
|
|
2634
3062
|
if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
|
|
2635
3063
|
if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
|
|
2636
|
-
const rest =
|
|
3064
|
+
const rest = ${factoryName}(restOpts);
|
|
2637
3065
|
try {
|
|
2638
3066
|
const me = await rest.request<MeResponse>("/me");
|
|
2639
3067
|
// Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
|
|
2640
3068
|
const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
|
|
2641
3069
|
return { ok: true, subject: \`user @ \${subject}\` };
|
|
2642
3070
|
} catch (err) {
|
|
2643
|
-
|
|
2644
|
-
return { ok: false, message };
|
|
3071
|
+
return { ok: false, message: errorMessage(err) };
|
|
2645
3072
|
}
|
|
2646
3073
|
}
|
|
2647
3074
|
`;
|
|
@@ -2659,18 +3086,14 @@ describe("verifyToken", () => {
|
|
|
2659
3086
|
const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
|
|
2660
3087
|
const result = await verifyToken("token", { fetch: stub.fetch });
|
|
2661
3088
|
expect(result.ok).toBe(true);
|
|
2662
|
-
|
|
2663
|
-
expect(result.subject).toMatch(/user@example.com/);
|
|
2664
|
-
}
|
|
3089
|
+
expect((result as { ok: boolean; subject?: string }).subject).toMatch(/user@example.com/);
|
|
2665
3090
|
});
|
|
2666
3091
|
|
|
2667
3092
|
it("returns ok:false with the error message on 401", async () => {
|
|
2668
3093
|
const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
|
|
2669
3094
|
const result = await verifyToken("bad", { fetch: stub.fetch });
|
|
2670
3095
|
expect(result.ok).toBe(false);
|
|
2671
|
-
|
|
2672
|
-
expect(result.message).toMatch(/→ 401/);
|
|
2673
|
-
}
|
|
3096
|
+
expect((result as { ok: boolean; message?: string }).message).toMatch(/→ 401/);
|
|
2674
3097
|
});
|
|
2675
3098
|
|
|
2676
3099
|
it("returns ok:false when the network layer throws", async () => {
|
|
@@ -2679,9 +3102,7 @@ describe("verifyToken", () => {
|
|
|
2679
3102
|
};
|
|
2680
3103
|
const result = await verifyToken("t", { fetch: throwing });
|
|
2681
3104
|
expect(result.ok).toBe(false);
|
|
2682
|
-
|
|
2683
|
-
expect(result.message).toMatch(/network down/);
|
|
2684
|
-
}
|
|
3105
|
+
expect((result as { ok: boolean; message?: string }).message).toMatch(/network down/);
|
|
2685
3106
|
});
|
|
2686
3107
|
});
|
|
2687
3108
|
`;
|
|
@@ -2709,7 +3130,7 @@ export default defineConfig({
|
|
|
2709
3130
|
//#endregion
|
|
2710
3131
|
//#region src/commands/plugin-create/index.ts
|
|
2711
3132
|
/**
|
|
2712
|
-
* `holocron plugin
|
|
3133
|
+
* `holocron new plugin <slug> <vendor>` — scaffold a new plugin
|
|
2713
3134
|
* package matching the proven template.
|
|
2714
3135
|
*
|
|
2715
3136
|
* Design: see `docs/wiki/specifications/tool-plugin-create.spec.md`.
|
|
@@ -2717,11 +3138,11 @@ export default defineConfig({
|
|
|
2717
3138
|
* Flow:
|
|
2718
3139
|
* 1. Preflight — verify CWD is a workspace root (pnpm-workspace.yaml
|
|
2719
3140
|
* + packages/ dir present).
|
|
2720
|
-
* 2. Slug collision —
|
|
3141
|
+
* 2. Slug collision — plugins/holocron-plugin-<slug>/ must not exist.
|
|
2721
3142
|
* 3. Capability sanity — must be one of the 14 known keys; warn for
|
|
2722
3143
|
* many-cardinality caps.
|
|
2723
3144
|
* 4. Generate — for each template, write to
|
|
2724
|
-
*
|
|
3145
|
+
* plugins/holocron-plugin-<slug>/<path>.
|
|
2725
3146
|
* 5. Verify (unless --no-verify) — runs pnpm install +
|
|
2726
3147
|
* pnpm --filter <pkg> typecheck lint test.
|
|
2727
3148
|
* 6. Print next steps.
|
|
@@ -2755,7 +3176,7 @@ const TEMPLATES = [
|
|
|
2755
3176
|
render
|
|
2756
3177
|
},
|
|
2757
3178
|
{
|
|
2758
|
-
path: "eslint.config.
|
|
3179
|
+
path: "eslint.config.ts",
|
|
2759
3180
|
render: render$13
|
|
2760
3181
|
},
|
|
2761
3182
|
{
|
|
@@ -2827,7 +3248,7 @@ function runPluginCreate(input) {
|
|
|
2827
3248
|
preflight(cwd);
|
|
2828
3249
|
validateSlug(input.slug);
|
|
2829
3250
|
validateVendorName(input.vendorName);
|
|
2830
|
-
const packageDir = path.join(cwd, "
|
|
3251
|
+
const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
|
|
2831
3252
|
if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
|
|
2832
3253
|
validateCapability(input.capability, print);
|
|
2833
3254
|
const derived = deriveDefaults$1({
|
|
@@ -2901,7 +3322,7 @@ function runPluginCreate(input) {
|
|
|
2901
3322
|
});
|
|
2902
3323
|
print(" ✓ scaffold verified");
|
|
2903
3324
|
} catch (err) {
|
|
2904
|
-
const message =
|
|
3325
|
+
const message = errorMessage(err);
|
|
2905
3326
|
print(` ✗ verify failed — ${message}`);
|
|
2906
3327
|
logger.warn({
|
|
2907
3328
|
slug: inputs.slug,
|
|
@@ -2931,7 +3352,7 @@ function runPluginCreate(input) {
|
|
|
2931
3352
|
};
|
|
2932
3353
|
}
|
|
2933
3354
|
function preflight(cwd) {
|
|
2934
|
-
if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron plugin
|
|
3355
|
+
if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron new plugin\` from the monorepo root.`);
|
|
2935
3356
|
if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
|
|
2936
3357
|
}
|
|
2937
3358
|
/**
|
|
@@ -2988,7 +3409,7 @@ function printNextSteps$1(print, inputs) {
|
|
|
2988
3409
|
//#endregion
|
|
2989
3410
|
//#region src/commands/publish.ts
|
|
2990
3411
|
/**
|
|
2991
|
-
* `holocron publish --initial` — bottles up the chicken-and-egg bootstrap
|
|
3412
|
+
* `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
|
|
2992
3413
|
* that every new npm-published holocron monorepo hits.
|
|
2993
3414
|
*
|
|
2994
3415
|
* npm requires a package to exist before Trusted Publishing can be
|
|
@@ -3002,14 +3423,14 @@ function printNextSteps$1(print, inputs) {
|
|
|
3002
3423
|
*
|
|
3003
3424
|
* $ pnpm install --frozen-lockfile
|
|
3004
3425
|
* $ pnpm build
|
|
3005
|
-
* $ pnpm exec tsx packages/cli/src/cli.ts publish --initial
|
|
3426
|
+
* $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
|
|
3006
3427
|
*
|
|
3007
3428
|
* `npm login --auth-type=web` runs automatically the first time — no
|
|
3008
3429
|
* separate manual step. `pnpm install` + `pnpm build` stay outside the
|
|
3009
3430
|
* command (no pnpm-inside-pnpm).
|
|
3010
3431
|
*
|
|
3011
3432
|
* `--initial` is required today — this command only implements the
|
|
3012
|
-
* bootstrap publish. A non-initial `holocron publish` (for a manual publish
|
|
3433
|
+
* bootstrap publish. A non-initial `holocron package publish` (for a manual publish
|
|
3013
3434
|
* outside the semantic-release/OIDC steady state) isn't built yet; the flag
|
|
3014
3435
|
* exists so the surface doesn't need another rename when it is.
|
|
3015
3436
|
*
|
|
@@ -3018,7 +3439,7 @@ function printNextSteps$1(print, inputs) {
|
|
|
3018
3439
|
* setup` for the ephemeral GH admin PAT.
|
|
3019
3440
|
*/
|
|
3020
3441
|
/**
|
|
3021
|
-
* `holocron publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
|
|
3442
|
+
* `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
|
|
3022
3443
|
* Trusted Publisher (OIDC) config to a new workflow filename across every
|
|
3023
3444
|
* public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
|
|
3024
3445
|
* shell-script prototype this supersedes) for the full "why": npm ties
|
|
@@ -3300,7 +3721,7 @@ async function runPublish(input = {}) {
|
|
|
3300
3721
|
if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
|
|
3301
3722
|
print("");
|
|
3302
3723
|
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
3303
|
-
print(` pnpm exec tsx packages/cli/src/cli.ts publish --initial --otp <6-digit-code>`);
|
|
3724
|
+
print(` pnpm exec tsx packages/cli/src/cli.ts package publish --initial --otp <6-digit-code>`);
|
|
3304
3725
|
}
|
|
3305
3726
|
logger.warn({
|
|
3306
3727
|
tag,
|
|
@@ -3325,7 +3746,7 @@ async function runPublish(input = {}) {
|
|
|
3325
3746
|
};
|
|
3326
3747
|
}
|
|
3327
3748
|
/**
|
|
3328
|
-
* `holocron publish` (no `--initial`) — the steady-state publish
|
|
3749
|
+
* `holocron package publish` (no `--initial`) — the steady-state publish
|
|
3329
3750
|
* `exec.publishCmd` in `release.config.ts` should call, replacing each
|
|
3330
3751
|
* repo's hand-typed `pnpm -r --filter=./packages/* publish ...` shell (which
|
|
3331
3752
|
* differs, today, only in monorepo-vs-single-package, provenance on/off, and
|
|
@@ -3465,7 +3886,7 @@ async function runSteadyPublish(input = {}) {
|
|
|
3465
3886
|
if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
|
|
3466
3887
|
print("");
|
|
3467
3888
|
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
3468
|
-
print(` pnpm exec holocron publish --otp <6-digit-code>`);
|
|
3889
|
+
print(` pnpm exec holocron package publish --otp <6-digit-code>`);
|
|
3469
3890
|
}
|
|
3470
3891
|
logger.warn({
|
|
3471
3892
|
tag,
|
|
@@ -3598,12 +4019,30 @@ const defaultLogin = async (cwd) => {
|
|
|
3598
4019
|
};
|
|
3599
4020
|
//#endregion
|
|
3600
4021
|
//#region src/commands/secret-set.ts
|
|
4022
|
+
/**
|
|
4023
|
+
* `holocron secrets set` — one-shot single-secret push.
|
|
4024
|
+
*
|
|
4025
|
+
* Complements `holocron secrets sync` (bulk vault → destinations) for
|
|
4026
|
+
* ad-hoc scenarios:
|
|
4027
|
+
* - Bootstrap secrets (e.g., NPM_TOKEN on theholocron/holocron itself
|
|
4028
|
+
* before the rest of the setup exists)
|
|
4029
|
+
* - One-off rotations that don't warrant a vault sync
|
|
4030
|
+
* - Setting secrets the vault doesn't track
|
|
4031
|
+
*
|
|
4032
|
+
* Value source resolution (in priority order):
|
|
4033
|
+
* 1. Positional `value` argument
|
|
4034
|
+
* 2. `--from-stdin` reads from stdin
|
|
4035
|
+
* 3. `--from-env <NAME>` reads from the named env var
|
|
4036
|
+
* 4. Implicit: env var matching the secret name (e.g., `NPM_TOKEN`)
|
|
4037
|
+
*
|
|
4038
|
+
* Errors clearly when no value can be sourced.
|
|
4039
|
+
*/
|
|
3601
4040
|
async function runSecretSet(input) {
|
|
3602
4041
|
const print = input.print ?? ((line) => console.log(line));
|
|
3603
4042
|
const logger = input.logger ?? getLogger();
|
|
3604
4043
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
3605
4044
|
await loader.load();
|
|
3606
|
-
assertPluginsResolvable(loader, "
|
|
4045
|
+
assertPluginsResolvable(loader, "secrets set");
|
|
3607
4046
|
const dryRun = input.context.dryRun ?? false;
|
|
3608
4047
|
const scope = input.scope ?? { kind: "repo" };
|
|
3609
4048
|
if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
|
|
@@ -3636,7 +4075,7 @@ async function runSecretSet(input) {
|
|
|
3636
4075
|
scope
|
|
3637
4076
|
};
|
|
3638
4077
|
} catch (err) {
|
|
3639
|
-
const message =
|
|
4078
|
+
const message = errorMessage(err);
|
|
3640
4079
|
print(` ✗ ${message}`);
|
|
3641
4080
|
logger.warn({
|
|
3642
4081
|
key: input.name,
|
|
@@ -3672,8 +4111,40 @@ function describeScope(scope) {
|
|
|
3672
4111
|
}
|
|
3673
4112
|
//#endregion
|
|
3674
4113
|
//#region src/commands/secrets-sync.ts
|
|
3675
|
-
|
|
3676
|
-
|
|
4114
|
+
/**
|
|
4115
|
+
* `holocron secrets sync` — vault → destinations.
|
|
4116
|
+
*
|
|
4117
|
+
* Reads a named environment from the configured `vault` (1P
|
|
4118
|
+
* Environment / similar) as KEY=VALUE pairs, then fans those values
|
|
4119
|
+
* out to the configured destinations:
|
|
4120
|
+
*
|
|
4121
|
+
* - `secrets` capability (e.g., GH Actions secrets) — repo-scope
|
|
4122
|
+
* - `deployment` capability (e.g., Vercel env vars) — per-target
|
|
4123
|
+
* - local `.env` file (optional, planned for v5.1 — currently skipped
|
|
4124
|
+
* with a soft "not yet" note)
|
|
4125
|
+
*
|
|
4126
|
+
* Per-secret routing rules:
|
|
4127
|
+
*
|
|
4128
|
+
* - All keys go to `deployment` for each target the caller asks for
|
|
4129
|
+
* (defaults: production + preview)
|
|
4130
|
+
* - `secrets` (GH Actions) defaults to every key, repo-scoped — but a
|
|
4131
|
+
* caller can pass `githubSecretKeys` (an allowlist, from repeatable
|
|
4132
|
+
* `--github-secret KEY` CLI values) to restrict `secrets` to only
|
|
4133
|
+
* those keys, and `githubSecretScope` (same `{ kind }` shape `secret
|
|
4134
|
+
* set`'s own `--scope repo|env=NAME|org=NAME` parses to) to apply one
|
|
4135
|
+
* scope to all of them. A key not in the allowlist is skipped for
|
|
4136
|
+
* `secrets` entirely — still synced to `deployment` if configured.
|
|
4137
|
+
* Needed for a vault holding a mix of deploy-only secrets and one
|
|
4138
|
+
* CI-consumed, org-wide secret (holocron#781's Sentinel case: 4
|
|
4139
|
+
* Vercel-only keys + one org-scoped Axiom ingest token
|
|
4140
|
+
* `theholocron/.github`'s dispatched-check workflow also reads).
|
|
4141
|
+
*
|
|
4142
|
+
* The vault MUST implement `readEnvironment(envId)` (optional method).
|
|
4143
|
+
* If it doesn't, the command errors with a hint to populate the keys
|
|
4144
|
+
* individually via `holocron secrets set` (also planned for v5.1).
|
|
4145
|
+
*/
|
|
4146
|
+
async function runSecretsSync(input) {
|
|
4147
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
3677
4148
|
const logger = input.logger ?? getLogger();
|
|
3678
4149
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
3679
4150
|
await withSpinner("Loading plugins…", () => loader.load());
|
|
@@ -3696,10 +4167,13 @@ async function runSecretsSync(input) {
|
|
|
3696
4167
|
const rows = [];
|
|
3697
4168
|
if (loader.has("secrets")) {
|
|
3698
4169
|
const secrets = loader.get("secrets");
|
|
3699
|
-
|
|
4170
|
+
const scope = input.githubSecretScope ?? { kind: "repo" };
|
|
4171
|
+
const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
|
|
4172
|
+
print(style.step("secrets"));
|
|
3700
4173
|
for (const key of keys) {
|
|
3701
|
-
|
|
3702
|
-
|
|
4174
|
+
if (allowedKeys && !allowedKeys.has(key)) continue;
|
|
4175
|
+
rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
|
|
4176
|
+
await secrets.setSecret(scope, key, envVars[key]);
|
|
3703
4177
|
}));
|
|
3704
4178
|
print(formatRow(rows[rows.length - 1]));
|
|
3705
4179
|
}
|
|
@@ -3779,7 +4253,7 @@ async function runRow(destination, scope, key, dryRun, body) {
|
|
|
3779
4253
|
scope,
|
|
3780
4254
|
key,
|
|
3781
4255
|
status: "fail",
|
|
3782
|
-
message:
|
|
4256
|
+
message: errorMessage(err)
|
|
3783
4257
|
};
|
|
3784
4258
|
}
|
|
3785
4259
|
}
|
|
@@ -3791,6 +4265,11 @@ function formatRow(row) {
|
|
|
3791
4265
|
if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
|
|
3792
4266
|
return ` ${style.dim(`· ${label}`)}`;
|
|
3793
4267
|
}
|
|
4268
|
+
function scopeLabel(scope) {
|
|
4269
|
+
if (scope.kind === "repo") return "scope=repo";
|
|
4270
|
+
if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
|
|
4271
|
+
return `scope=environment,env=${scope.name}`;
|
|
4272
|
+
}
|
|
3794
4273
|
function vaultProviderName(loader) {
|
|
3795
4274
|
if (!loader.has("vault")) return "<missing>";
|
|
3796
4275
|
return loader.get("vault").providerName;
|
|
@@ -3867,7 +4346,7 @@ function deriveCapabilities(providers) {
|
|
|
3867
4346
|
return Object.keys(providers ?? {}).sort();
|
|
3868
4347
|
}
|
|
3869
4348
|
/**
|
|
3870
|
-
* `source` + `ci` are the minimal baseline every repo `holocron setup`
|
|
4349
|
+
* `source` + `ci` are the minimal baseline every repo `holocron repo setup`
|
|
3871
4350
|
* touches wires — every `holocron.config.ts` in this org already satisfies
|
|
3872
4351
|
* this, so `non-compliant` here means drift (a provider manually removed
|
|
3873
4352
|
* after setup), not an unmet aspirational policy. Deliberately not a richer
|
|
@@ -3995,7 +4474,7 @@ var config_default = "# Configuration for sentiment-bot - https://github.com/beh
|
|
|
3995
4474
|
//#endregion
|
|
3996
4475
|
//#region src/utils/create-header.ts
|
|
3997
4476
|
function createHeader(options) {
|
|
3998
|
-
const { source, tool = "holocron setup", forPrimary = false } = options;
|
|
4477
|
+
const { source, tool = "holocron repo setup", forPrimary = false } = options;
|
|
3999
4478
|
const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
|
|
4000
4479
|
return {
|
|
4001
4480
|
workflowHeader(format = "yaml") {
|
|
@@ -4019,7 +4498,7 @@ function createHeader(options) {
|
|
|
4019
4498
|
},
|
|
4020
4499
|
scaffoldHeader() {
|
|
4021
4500
|
return [
|
|
4022
|
-
`# Scaffolded by holocron setup — edit this file freely.`,
|
|
4501
|
+
`# Scaffolded by holocron repo setup — edit this file freely.`,
|
|
4023
4502
|
`# Source: theholocron/holocron · ${source}`,
|
|
4024
4503
|
``
|
|
4025
4504
|
].join("\n");
|
|
@@ -4033,6 +4512,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
|
4033
4512
|
//#region src/templates/configs/alexjs/alexrc.json
|
|
4034
4513
|
var alexrc_default = { allow: [
|
|
4035
4514
|
"dead",
|
|
4515
|
+
"execute",
|
|
4516
|
+
"execution",
|
|
4517
|
+
"executes",
|
|
4036
4518
|
"failure",
|
|
4037
4519
|
"failures",
|
|
4038
4520
|
"hook",
|
|
@@ -4051,12 +4533,21 @@ function createIgnoreConfig$1() {
|
|
|
4051
4533
|
}
|
|
4052
4534
|
//#endregion
|
|
4053
4535
|
//#region src/templates/configs/commit-msg/commit-msg
|
|
4054
|
-
var commit_msg_default = "
|
|
4536
|
+
var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
|
|
4055
4537
|
//#endregion
|
|
4056
4538
|
//#region src/templates/configs/commit-msg/create-config.ts
|
|
4057
4539
|
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
4058
|
-
|
|
4059
|
-
|
|
4540
|
+
/**
|
|
4541
|
+
* `.husky/commit-msg` — lints the commit message via `holocron run lint
|
|
4542
|
+
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4543
|
+
* shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
|
|
4544
|
+
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4545
|
+
*
|
|
4546
|
+
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4547
|
+
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4548
|
+
*/
|
|
4549
|
+
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4550
|
+
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4060
4551
|
}
|
|
4061
4552
|
//#endregion
|
|
4062
4553
|
//#region src/templates/configs/devmoji/create-config.ts
|
|
@@ -4072,7 +4563,7 @@ function createConfig$6() {
|
|
|
4072
4563
|
}
|
|
4073
4564
|
//#endregion
|
|
4074
4565
|
//#region src/templates/configs/editorconfig/editorconfig
|
|
4075
|
-
var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
|
|
4566
|
+
var editorconfig_default = "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = tab\nindent_size = 4\n\n[.gitattributes]\nindent_style = space\nindent_size = 2\n\n[*.{json,yml,yaml}]\nindent_style = space\nindent_size = 2\n\n[*.{md,mdx}]\ntrim_trailing_whitespace = false\nindent_style = unset\n\n[.*{rc,ignore}]\nindent_style = space\nindent_size = 2\n";
|
|
4076
4567
|
//#endregion
|
|
4077
4568
|
//#region src/templates/configs/editorconfig/create-config.ts
|
|
4078
4569
|
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
@@ -4129,7 +4620,7 @@ var pre_commit_default = "# Run GitLeaks against staged changes only — a full
|
|
|
4129
4620
|
const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
|
|
4130
4621
|
/**
|
|
4131
4622
|
* `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
|
|
4132
|
-
* lint-staged before every commit. Installed by `holocron setup` when hooks
|
|
4623
|
+
* lint-staged before every commit. Installed by `holocron repo setup` when hooks
|
|
4133
4624
|
* are enabled (default for `protection: "strict"`). `git commit --no-verify`
|
|
4134
4625
|
* bypasses it.
|
|
4135
4626
|
*
|
|
@@ -4144,13 +4635,13 @@ function createConfig$3() {
|
|
|
4144
4635
|
}
|
|
4145
4636
|
//#endregion
|
|
4146
4637
|
//#region src/templates/configs/pre-push/pre-push
|
|
4147
|
-
var pre_push_default = "# Run the merge-gating checks locally before pushing — the same set CI runs.\n# Bypass once with: git push --no-verify\n\n# CI runners (semantic-release, super-linter auto-commit, bots) push without a\n# built dev environment — the hook has nothing to add there.\n[ -n \"$CI\" ] && exit 0\n\n__HOLOCRON_SCRIPT__ ci\n";
|
|
4638
|
+
var pre_push_default = "# Run the merge-gating checks locally before pushing — the same set CI runs.\n# Bypass once with: git push --no-verify\n\n# CI runners (semantic-release, super-linter auto-commit, bots) push without a\n# built dev environment — the hook has nothing to add there.\n[ -n \"$CI\" ] && exit 0\n\n__HOLOCRON_SCRIPT__ run ci\n";
|
|
4148
4639
|
//#endregion
|
|
4149
4640
|
//#region src/templates/configs/pre-push/create-config.ts
|
|
4150
4641
|
const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
|
|
4151
4642
|
/**
|
|
4152
|
-
* `.husky/pre-push` — runs `holocron ci` (the merge-gating checks, in CI order)
|
|
4153
|
-
* before every push. Installed by `holocron setup` when hooks are enabled
|
|
4643
|
+
* `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
|
|
4644
|
+
* before every push. Installed by `holocron repo setup` when hooks are enabled
|
|
4154
4645
|
* (default for `protection: "strict"`). `git push --no-verify` bypasses it.
|
|
4155
4646
|
*
|
|
4156
4647
|
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
@@ -4197,7 +4688,7 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
|
|
|
4197
4688
|
/**
|
|
4198
4689
|
* Canonical AI engineering workflow role prompts.
|
|
4199
4690
|
*
|
|
4200
|
-
* Written to `.agents/prompts/<role>.md` by `holocron setup` when `agent` is
|
|
4691
|
+
* Written to `.agents/prompts/<role>.md` by `holocron repo setup` when `agent` is
|
|
4201
4692
|
* configured. Paths are gitignored and regenerated on every setup run so the
|
|
4202
4693
|
* content always reflects the current CLI version.
|
|
4203
4694
|
*
|
|
@@ -4695,7 +5186,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
|
4695
5186
|
capability: "source",
|
|
4696
5187
|
step,
|
|
4697
5188
|
status: "fail",
|
|
4698
|
-
message:
|
|
5189
|
+
message: errorMessage(err)
|
|
4699
5190
|
};
|
|
4700
5191
|
}
|
|
4701
5192
|
try {
|
|
@@ -4718,7 +5209,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
|
4718
5209
|
capability: "source",
|
|
4719
5210
|
step,
|
|
4720
5211
|
status: "fail",
|
|
4721
|
-
message:
|
|
5212
|
+
message: errorMessage(err)
|
|
4722
5213
|
};
|
|
4723
5214
|
}
|
|
4724
5215
|
}
|
|
@@ -4811,7 +5302,7 @@ async function execStep(capability, step, dryRun, body, opts = {}) {
|
|
|
4811
5302
|
capability,
|
|
4812
5303
|
step,
|
|
4813
5304
|
status: "fail",
|
|
4814
|
-
message:
|
|
5305
|
+
message: errorMessage(err)
|
|
4815
5306
|
};
|
|
4816
5307
|
}
|
|
4817
5308
|
}
|
|
@@ -4984,7 +5475,7 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
|
|
|
4984
5475
|
//#endregion
|
|
4985
5476
|
//#region src/commands/setup/run-setup.ts
|
|
4986
5477
|
/**
|
|
4987
|
-
* `holocron setup` — orchestrates per-capability setup actions across
|
|
5478
|
+
* `holocron repo setup` — orchestrates per-capability setup actions across
|
|
4988
5479
|
* every plugin loaded from `holocron.config.json`.
|
|
4989
5480
|
*
|
|
4990
5481
|
* Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
|
|
@@ -5004,18 +5495,18 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
|
|
|
5004
5495
|
*/
|
|
5005
5496
|
const { workflowHeader: workflowHeader$1 } = createHeader({
|
|
5006
5497
|
source: "packages/cli/src/commands/setup/run-setup.ts",
|
|
5007
|
-
tool: "holocron setup"
|
|
5498
|
+
tool: "holocron repo setup"
|
|
5008
5499
|
});
|
|
5009
5500
|
const { workflowHeader: thinCallerHeader } = createHeader({
|
|
5010
5501
|
source: "packages/astromech/src/thin-callers.ts",
|
|
5011
|
-
tool: "holocron setup"
|
|
5502
|
+
tool: "holocron repo setup"
|
|
5012
5503
|
});
|
|
5013
5504
|
async function runSetup(input) {
|
|
5014
5505
|
const print = input.print ?? ((line) => console.log(line));
|
|
5015
5506
|
const logger = getLogger();
|
|
5016
5507
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
5017
5508
|
await withSpinner("Loading plugins…", () => loader.load());
|
|
5018
|
-
assertPluginsResolvable(loader, "setup");
|
|
5509
|
+
assertPluginsResolvable(loader, "repo setup");
|
|
5019
5510
|
const config = input.loaded.resolved;
|
|
5020
5511
|
const dryRun = input.context.dryRun ?? false;
|
|
5021
5512
|
const steps = [];
|
|
@@ -5052,7 +5543,10 @@ async function runSetup(input) {
|
|
|
5052
5543
|
print(formatStep(steps[steps.length - 1]));
|
|
5053
5544
|
if (effectivePreset && effectivePreset !== "none") {
|
|
5054
5545
|
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
5055
|
-
await source.updateRepoSettings(
|
|
5546
|
+
await source.updateRepoSettings({
|
|
5547
|
+
...BALANCED_REPO_SETTINGS,
|
|
5548
|
+
...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
|
|
5549
|
+
});
|
|
5056
5550
|
}));
|
|
5057
5551
|
print(formatStep(steps[steps.length - 1]));
|
|
5058
5552
|
const tasksConfig = { tasks: config.tasks };
|
|
@@ -5159,13 +5653,13 @@ async function runSetup(input) {
|
|
|
5159
5653
|
}));
|
|
5160
5654
|
print(formatStep(steps[steps.length - 1]));
|
|
5161
5655
|
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5162
|
-
await source.writeRepoFile(".husky/commit-msg", createConfig$7());
|
|
5656
|
+
await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
|
|
5163
5657
|
}));
|
|
5164
5658
|
print(formatStep(steps[steps.length - 1]));
|
|
5165
5659
|
if (hooksEnabled) {
|
|
5166
5660
|
steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
|
|
5167
5661
|
await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
|
|
5168
|
-
return "runs `holocron ci` before push — bypass with `git push --no-verify`";
|
|
5662
|
+
return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
|
|
5169
5663
|
}));
|
|
5170
5664
|
print(formatStep(steps[steps.length - 1]));
|
|
5171
5665
|
steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
|
|
@@ -5233,13 +5727,20 @@ async function runSetup(input) {
|
|
|
5233
5727
|
await source.writeRepoFile("turbo.json", content);
|
|
5234
5728
|
}));
|
|
5235
5729
|
print(formatStep(steps[steps.length - 1]));
|
|
5236
|
-
const
|
|
5730
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
5731
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
5237
5732
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
5238
|
-
const rootScripts =
|
|
5733
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
5239
5734
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
5240
5735
|
if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
5241
5736
|
await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
|
|
5242
5737
|
}));
|
|
5738
|
+
if (rootPkgRaw) {
|
|
5739
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
5740
|
+
if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
|
|
5741
|
+
await source.writeRepoFile("package.json", depFix.content);
|
|
5742
|
+
}));
|
|
5743
|
+
}
|
|
5243
5744
|
}
|
|
5244
5745
|
print(formatStep(steps[steps.length - 1]));
|
|
5245
5746
|
}
|
|
@@ -5291,34 +5792,36 @@ async function runSetup(input) {
|
|
|
5291
5792
|
print(formatStep(steps[steps.length - 1]));
|
|
5292
5793
|
}
|
|
5293
5794
|
const teams = repo?.teams ?? [];
|
|
5294
|
-
if (teams.length > 0)
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5795
|
+
if (teams.length > 0) {
|
|
5796
|
+
if (source.syncTeams) {
|
|
5797
|
+
steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
|
|
5798
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5799
|
+
const repoCoord = input.context.repo ?? repo?.name ?? "";
|
|
5800
|
+
const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
|
|
5801
|
+
const writeableTeams = teams.map((t) => typeof t === "string" ? {
|
|
5802
|
+
slug: t,
|
|
5803
|
+
permission: "push"
|
|
5804
|
+
} : t).filter((t) => [
|
|
5805
|
+
"push",
|
|
5806
|
+
"maintain",
|
|
5807
|
+
"admin"
|
|
5808
|
+
].includes(t.permission));
|
|
5809
|
+
if (org && writeableTeams.length > 0) {
|
|
5810
|
+
steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
|
|
5811
|
+
const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
|
|
5812
|
+
await source.writeRepoFile(".github/CODEOWNERS", content);
|
|
5813
|
+
}));
|
|
5814
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5815
|
+
}
|
|
5816
|
+
} else {
|
|
5817
|
+
steps.push({
|
|
5818
|
+
capability: "source",
|
|
5819
|
+
step: "sync teams",
|
|
5820
|
+
status: "skip",
|
|
5821
|
+
message: "provider does not implement syncTeams"
|
|
5822
|
+
});
|
|
5312
5823
|
print(formatStep(steps[steps.length - 1]));
|
|
5313
5824
|
}
|
|
5314
|
-
} else {
|
|
5315
|
-
steps.push({
|
|
5316
|
-
capability: "source",
|
|
5317
|
-
step: "sync teams",
|
|
5318
|
-
status: "skip",
|
|
5319
|
-
message: "provider does not implement syncTeams"
|
|
5320
|
-
});
|
|
5321
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5322
5825
|
}
|
|
5323
5826
|
}
|
|
5324
5827
|
if (loader.has("environments")) {
|
|
@@ -5374,11 +5877,9 @@ async function runSetup(input) {
|
|
|
5374
5877
|
const wikiProxy = wiki.proxyConfig?.();
|
|
5375
5878
|
if (wikiDns && loader.has("dns")) {
|
|
5376
5879
|
const dns = loader.get("dns");
|
|
5377
|
-
steps.push(await runStep("dns", `upsertRecord ${wikiDns.
|
|
5880
|
+
steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
|
|
5378
5881
|
await dns.upsertRecord(wikiDns.zone, {
|
|
5379
|
-
|
|
5380
|
-
name: wikiDns.cname,
|
|
5381
|
-
content: wikiDns.target,
|
|
5882
|
+
...wikiDns.record,
|
|
5382
5883
|
ttl: 1,
|
|
5383
5884
|
...wikiProxy ? { proxied: true } : {}
|
|
5384
5885
|
});
|
|
@@ -5387,8 +5888,8 @@ async function runSetup(input) {
|
|
|
5387
5888
|
}
|
|
5388
5889
|
if (wikiProxy && wikiDns && loader.has("workers")) {
|
|
5389
5890
|
const workers = loader.get("workers");
|
|
5390
|
-
steps.push(await runStep("workers", `upsertProxy ${wikiDns.
|
|
5391
|
-
await workers.upsertProxy(wikiDns.
|
|
5891
|
+
steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
|
|
5892
|
+
await workers.upsertProxy(wikiDns.record.name, wikiProxy);
|
|
5392
5893
|
}));
|
|
5393
5894
|
print(formatStep(steps[steps.length - 1]));
|
|
5394
5895
|
}
|
|
@@ -5401,11 +5902,31 @@ async function runSetup(input) {
|
|
|
5401
5902
|
org: config.org,
|
|
5402
5903
|
domain: config.domain
|
|
5403
5904
|
}) : null;
|
|
5905
|
+
const ensureCustomDomain = async (projectId, hostname) => {
|
|
5906
|
+
if (!deploy.ensureCustomDomain) return;
|
|
5907
|
+
const customDomainDns = { value: null };
|
|
5908
|
+
steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
|
|
5909
|
+
customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
|
|
5910
|
+
}));
|
|
5911
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5912
|
+
if (customDomainDns.value && loader.has("dns")) {
|
|
5913
|
+
const dns = loader.get("dns");
|
|
5914
|
+
const dnsRecord = customDomainDns.value;
|
|
5915
|
+
steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
|
|
5916
|
+
await dns.upsertRecord(dnsRecord.zone, {
|
|
5917
|
+
...dnsRecord.record,
|
|
5918
|
+
ttl: 1
|
|
5919
|
+
});
|
|
5920
|
+
}));
|
|
5921
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5922
|
+
}
|
|
5923
|
+
};
|
|
5404
5924
|
if (!previewCfg) {
|
|
5405
5925
|
steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
|
|
5406
5926
|
await deploy.ensureProject({ name: config.name });
|
|
5407
5927
|
}));
|
|
5408
5928
|
print(formatStep(steps[steps.length - 1]));
|
|
5929
|
+
if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
|
|
5409
5930
|
}
|
|
5410
5931
|
if (previewCfg) {
|
|
5411
5932
|
steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
|
|
@@ -5413,13 +5934,8 @@ async function runSetup(input) {
|
|
|
5413
5934
|
}));
|
|
5414
5935
|
print(formatStep(steps[steps.length - 1]));
|
|
5415
5936
|
}
|
|
5416
|
-
if (previewCfg?.domain
|
|
5417
|
-
|
|
5418
|
-
await deploy.ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5419
|
-
}));
|
|
5420
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5421
|
-
}
|
|
5422
|
-
if (previewCfg?.domain && loader.has("dns")) {
|
|
5937
|
+
if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5938
|
+
if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
|
|
5423
5939
|
const dns = loader.get("dns");
|
|
5424
5940
|
const wildcardDomain = `*.${previewCfg.domain}`;
|
|
5425
5941
|
steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
|
|
@@ -5493,7 +6009,7 @@ async function runSetup(input) {
|
|
|
5493
6009
|
capability: "vault",
|
|
5494
6010
|
step: "list",
|
|
5495
6011
|
status: "fail",
|
|
5496
|
-
message:
|
|
6012
|
+
message: errorMessage(err)
|
|
5497
6013
|
});
|
|
5498
6014
|
}
|
|
5499
6015
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -5532,6 +6048,17 @@ async function runSetup(input) {
|
|
|
5532
6048
|
print(formatStep(steps[steps.length - 1]));
|
|
5533
6049
|
}
|
|
5534
6050
|
}
|
|
6051
|
+
if (loader.has("monitoring")) {
|
|
6052
|
+
const monitoring = loader.get("monitoring");
|
|
6053
|
+
print(style.step("monitoring"));
|
|
6054
|
+
for (const checkInput of monitoring.checks) {
|
|
6055
|
+
steps.push(await runStep("monitoring", `ensureCheck ${checkInput.name}`, dryRun, async () => {
|
|
6056
|
+
const result = await monitoring.ensureCheck(checkInput);
|
|
6057
|
+
return `${checkInput.name} ${result.alreadyExists ? "exists" : "created"}`;
|
|
6058
|
+
}));
|
|
6059
|
+
print(formatStep(steps[steps.length - 1]));
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
5535
6062
|
if (loader.has("tooling")) {
|
|
5536
6063
|
const tools = loader.get("tooling");
|
|
5537
6064
|
print(style.step("tooling"));
|
|
@@ -5618,7 +6145,7 @@ async function runSetup(input) {
|
|
|
5618
6145
|
print(style.hint(" · Metadata — read (repository permission, auto-included)"));
|
|
5619
6146
|
print("");
|
|
5620
6147
|
print(style.hint(" Create tokens at: https://github.com/settings/personal-access-tokens/new"));
|
|
5621
|
-
print(style.hint(" Then re-run: holocron setup --token <your-admin-pat>"));
|
|
6148
|
+
print(style.hint(" Then re-run: holocron repo setup --token <your-admin-pat>"));
|
|
5622
6149
|
print(style.hint(" Store org token: HOLOCRON_ORG_TOKEN env var or keyring key github.org"));
|
|
5623
6150
|
}
|
|
5624
6151
|
return {
|
|
@@ -5656,7 +6183,7 @@ async function ensurePrepareScript(repoRoot) {
|
|
|
5656
6183
|
/**
|
|
5657
6184
|
* `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
|
|
5658
6185
|
*
|
|
5659
|
-
* Unlike `holocron setup`, this command is purely local (no GitHub token
|
|
6186
|
+
* Unlike `holocron repo setup`, this command is purely local (no GitHub token
|
|
5660
6187
|
* required). It reads `agent` and `skills` from the config and installs
|
|
5661
6188
|
* the listed skills into `.agents/skills/<name>/` with a symlink at the
|
|
5662
6189
|
* agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
|
|
@@ -5709,7 +6236,7 @@ async function runSkillsInstall(input) {
|
|
|
5709
6236
|
status: "ok"
|
|
5710
6237
|
}, "skills install: done");
|
|
5711
6238
|
} catch (err) {
|
|
5712
|
-
const message =
|
|
6239
|
+
const message = errorMessage(err);
|
|
5713
6240
|
print(` ✗ ${message}`);
|
|
5714
6241
|
logger.warn({
|
|
5715
6242
|
agent: config.agent,
|
|
@@ -5867,11 +6394,13 @@ async function runSyncReadme(input) {
|
|
|
5867
6394
|
}
|
|
5868
6395
|
const description = loaded.resolved.description ?? "";
|
|
5869
6396
|
const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
|
|
6397
|
+
const scripts = pkg.scripts ?? {};
|
|
6398
|
+
const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
|
|
5870
6399
|
const sections = generateReadme({
|
|
5871
6400
|
description,
|
|
5872
6401
|
homepage,
|
|
5873
|
-
scripts
|
|
5874
|
-
packages
|
|
6402
|
+
scripts,
|
|
6403
|
+
packages
|
|
5875
6404
|
});
|
|
5876
6405
|
const markerSections = {
|
|
5877
6406
|
...sections.packages !== void 0 && { packages: sections.packages },
|
|
@@ -6113,7 +6642,7 @@ async function runSyncWiki(input) {
|
|
|
6113
6642
|
capability: "local",
|
|
6114
6643
|
step: "sync wiki",
|
|
6115
6644
|
status: "fail",
|
|
6116
|
-
message: err instanceof Error ? err.message :
|
|
6645
|
+
message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
|
|
6117
6646
|
};
|
|
6118
6647
|
}
|
|
6119
6648
|
}
|
|
@@ -6121,7 +6650,7 @@ async function runSyncWiki(input) {
|
|
|
6121
6650
|
//#region src/commands/sync.ts
|
|
6122
6651
|
const { workflowHeader } = createHeader({
|
|
6123
6652
|
source: "packages/astromech/src/thin-callers.ts",
|
|
6124
|
-
tool: "holocron sync"
|
|
6653
|
+
tool: "holocron repo sync"
|
|
6125
6654
|
});
|
|
6126
6655
|
const SYNC_STEPS = [
|
|
6127
6656
|
"labels",
|
|
@@ -6175,55 +6704,59 @@ async function runSync(input) {
|
|
|
6175
6704
|
for (const stepName of SYNC_STEPS) {
|
|
6176
6705
|
if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
|
|
6177
6706
|
if (LOCAL_STEPS.has(stepName)) continue;
|
|
6178
|
-
if (stepName === "labels")
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6707
|
+
if (stepName === "labels") {
|
|
6708
|
+
if (source.syncLabels) {
|
|
6709
|
+
steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
|
|
6710
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6711
|
+
} else {
|
|
6712
|
+
steps.push({
|
|
6713
|
+
capability: "source",
|
|
6714
|
+
step: "sync labels",
|
|
6715
|
+
status: "skip",
|
|
6716
|
+
message: "provider does not implement syncLabels"
|
|
6717
|
+
});
|
|
6718
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6719
|
+
}
|
|
6189
6720
|
}
|
|
6190
|
-
if (stepName === "properties")
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6721
|
+
if (stepName === "properties") {
|
|
6722
|
+
if (source.syncProperties) {
|
|
6723
|
+
const repo = config.repo;
|
|
6724
|
+
const properties = {};
|
|
6725
|
+
const effectivePreset = repo?.protection;
|
|
6726
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
6727
|
+
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
6728
|
+
properties["monorepo"] = String(isMonorepo);
|
|
6729
|
+
const manual = repo?.properties ?? {};
|
|
6730
|
+
if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
|
|
6731
|
+
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
6732
|
+
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
6733
|
+
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
6734
|
+
{
|
|
6735
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
6736
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
6737
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
6738
|
+
properties["holocron_profile"] = deriveProfile({
|
|
6739
|
+
rootPackageJson,
|
|
6740
|
+
repoName: basename(input.context.repoRoot),
|
|
6741
|
+
isMonorepo,
|
|
6742
|
+
workspacePackageJsons
|
|
6743
|
+
});
|
|
6744
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
6745
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
6746
|
+
properties["holocron_capabilities"] = capabilities;
|
|
6747
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6748
|
+
}
|
|
6749
|
+
steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
6750
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6751
|
+
} else {
|
|
6752
|
+
steps.push({
|
|
6753
|
+
capability: "source",
|
|
6754
|
+
step: "sync properties",
|
|
6755
|
+
status: "skip",
|
|
6756
|
+
message: "provider does not implement syncProperties"
|
|
6211
6757
|
});
|
|
6212
|
-
|
|
6213
|
-
const capabilities = deriveCapabilities(config.providers);
|
|
6214
|
-
properties["holocron_capabilities"] = capabilities;
|
|
6215
|
-
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6758
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6216
6759
|
}
|
|
6217
|
-
steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
6218
|
-
print(formatSyncStep(steps[steps.length - 1]));
|
|
6219
|
-
} else {
|
|
6220
|
-
steps.push({
|
|
6221
|
-
capability: "source",
|
|
6222
|
-
step: "sync properties",
|
|
6223
|
-
status: "skip",
|
|
6224
|
-
message: "provider does not implement syncProperties"
|
|
6225
|
-
});
|
|
6226
|
-
print(formatSyncStep(steps[steps.length - 1]));
|
|
6227
6760
|
}
|
|
6228
6761
|
if (stepName === "teams") {
|
|
6229
6762
|
const teams = config.repo?.teams ?? [];
|
|
@@ -6435,29 +6968,34 @@ async function runSync(input) {
|
|
|
6435
6968
|
}
|
|
6436
6969
|
}
|
|
6437
6970
|
}
|
|
6438
|
-
if (stepName === "scripts")
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
const
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6971
|
+
if (stepName === "scripts") {
|
|
6972
|
+
if (config.syncScripts === false) {
|
|
6973
|
+
steps.push({
|
|
6974
|
+
capability: "local",
|
|
6975
|
+
step: "sync scripts",
|
|
6976
|
+
status: "skip",
|
|
6977
|
+
message: "syncScripts: false"
|
|
6978
|
+
});
|
|
6979
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6980
|
+
} else {
|
|
6981
|
+
const tasksConfig = { tasks: config.tasks };
|
|
6982
|
+
if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
|
|
6983
|
+
const astromechScripts = createAstromech({
|
|
6984
|
+
cwd: input.context.repoRoot,
|
|
6985
|
+
config: tasksConfig,
|
|
6986
|
+
logger
|
|
6987
|
+
});
|
|
6988
|
+
const desired = astromechScripts.packageScripts();
|
|
6989
|
+
const turboContent = astromechScripts.turboConfig();
|
|
6990
|
+
const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
|
|
6991
|
+
steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
|
|
6992
|
+
const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
|
|
6993
|
+
if (changed === null) return "no package.json";
|
|
6994
|
+
if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
|
|
6995
|
+
return `${changed.join(", ")} set`;
|
|
6996
|
+
}));
|
|
6997
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6998
|
+
}
|
|
6461
6999
|
}
|
|
6462
7000
|
if (stepName === "turbo") {
|
|
6463
7001
|
const astromechTurbo = createAstromech({
|
|
@@ -6479,9 +7017,10 @@ async function runSync(input) {
|
|
|
6479
7017
|
await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
|
|
6480
7018
|
}));
|
|
6481
7019
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6482
|
-
const
|
|
7020
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
7021
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
6483
7022
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
6484
|
-
const rootScripts =
|
|
7023
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
6485
7024
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
6486
7025
|
if (workspaceFix.changed) {
|
|
6487
7026
|
steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
@@ -6489,6 +7028,15 @@ async function runSync(input) {
|
|
|
6489
7028
|
}));
|
|
6490
7029
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6491
7030
|
}
|
|
7031
|
+
if (rootPkgRaw) {
|
|
7032
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
7033
|
+
if (depFix.changed) {
|
|
7034
|
+
steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
|
|
7035
|
+
await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
|
|
7036
|
+
}));
|
|
7037
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
7038
|
+
}
|
|
7039
|
+
}
|
|
6492
7040
|
}
|
|
6493
7041
|
}
|
|
6494
7042
|
if (stepName === "wiki") {
|
|
@@ -6546,7 +7094,7 @@ async function runSyncStep(capability, step, dryRun, body) {
|
|
|
6546
7094
|
capability,
|
|
6547
7095
|
step,
|
|
6548
7096
|
status: "fail",
|
|
6549
|
-
message:
|
|
7097
|
+
message: errorMessage(err)
|
|
6550
7098
|
};
|
|
6551
7099
|
}
|
|
6552
7100
|
}
|
|
@@ -6572,8 +7120,21 @@ async function writePackageJsonField(repoRoot, field, value) {
|
|
|
6572
7120
|
* Merge `desired` script entries into `package.json#scripts` — sets only the
|
|
6573
7121
|
* listed keys, never touches the rest. Returns the sorted list of keys that
|
|
6574
7122
|
* drifted (empty when all current), or `null` when there is no `package.json`.
|
|
7123
|
+
*
|
|
7124
|
+
* `turboEligible` (#747): task names `turbo.json` fans out — the same set
|
|
7125
|
+
* {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
|
|
7126
|
+
* only, an *existing* script that isn't the generated `holocron run <task>
|
|
7127
|
+
* --` wrapper is left untouched: root may genuinely be a directly-buildable
|
|
7128
|
+
* package (not an orchestrator) with its own real command (`tsc --noEmit`,
|
|
7129
|
+
* `vitest run`, …) for that task. Overwriting it would silently discard the
|
|
7130
|
+
* real command and, once `turbo.json` exists and root is a real workspace
|
|
7131
|
+
* member (#692), reintroduce infinite recursion — `turbo run <task>` →
|
|
7132
|
+
* root's own `<task>` script → the wrapper → `holocron run <task>` sees
|
|
7133
|
+
* `turbo.json` still defines the task → `turbo run <task>` again, forever.
|
|
7134
|
+
* Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
|
|
7135
|
+
* legitimate direct-command alternative and stays fully generator-owned.
|
|
6575
7136
|
*/
|
|
6576
|
-
async function mergePackageJsonScripts(repoRoot, desired) {
|
|
7137
|
+
async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
|
|
6577
7138
|
const pkgPath = join(repoRoot, "package.json");
|
|
6578
7139
|
let content;
|
|
6579
7140
|
try {
|
|
@@ -6584,15 +7145,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
|
|
|
6584
7145
|
const pkg = JSON.parse(content);
|
|
6585
7146
|
const scripts = pkg.scripts ?? {};
|
|
6586
7147
|
const changed = [];
|
|
6587
|
-
for (const [name, command] of Object.entries(desired))
|
|
6588
|
-
scripts[name]
|
|
6589
|
-
|
|
7148
|
+
for (const [name, command] of Object.entries(desired)) {
|
|
7149
|
+
const existing = scripts[name];
|
|
7150
|
+
if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
|
|
7151
|
+
if (existing !== command) {
|
|
7152
|
+
scripts[name] = command;
|
|
7153
|
+
changed.push(name);
|
|
7154
|
+
}
|
|
6590
7155
|
}
|
|
6591
7156
|
if (changed.length === 0) return [];
|
|
6592
7157
|
pkg.scripts = scripts;
|
|
6593
7158
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
6594
7159
|
return changed.sort();
|
|
6595
7160
|
}
|
|
7161
|
+
/**
|
|
7162
|
+
* Matches the exact shape `packageScripts()` generates for `taskName` —
|
|
7163
|
+
* `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
|
|
7164
|
+
* (`holocronScript`, itself allowed to be multi-word, e.g. `"node
|
|
7165
|
+
packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
|
|
7166
|
+
* rather than the whole string means an old wrapper survives a
|
|
7167
|
+
* `holocronScript` rename intact — it's still recognized as a wrapper (safe
|
|
7168
|
+
* to update to the new one), not mistaken for a real command.
|
|
7169
|
+
*/
|
|
7170
|
+
function isGeneratedRunWrapper(command, taskName) {
|
|
7171
|
+
return command.endsWith(` run ${taskName} --`);
|
|
7172
|
+
}
|
|
6596
7173
|
const README_DESC_START = "<!-- holocron:description -->";
|
|
6597
7174
|
const README_DESC_END = "<!-- /holocron:description -->";
|
|
6598
7175
|
async function updateReadmeDescription(repoRoot, description) {
|
|
@@ -6809,7 +7386,7 @@ const DEFAULT_REPO = "theholocron/.github";
|
|
|
6809
7386
|
* The file set `sync-github` pushes to `theholocron/.github` — the reusable
|
|
6810
7387
|
* `workflow_call` implementations, composite actions and starter-workflow
|
|
6811
7388
|
* metadata, generated by `@theholocron/astromech`. Secondary repos get nothing:
|
|
6812
|
-
* their thin callers are managed by `holocron sync --steps workflows`, which
|
|
7389
|
+
* their thin callers are managed by `holocron repo sync --steps workflows`, which
|
|
6813
7390
|
* executes the resolved config (regex parsing here can't resolve preset spreads
|
|
6814
7391
|
* like `...tasks` from `nodeDocs()` — see issue #464).
|
|
6815
7392
|
*/
|
|
@@ -6835,7 +7412,7 @@ async function runSyncGithub(input) {
|
|
|
6835
7412
|
token,
|
|
6836
7413
|
fetch: input.fetch
|
|
6837
7414
|
});
|
|
6838
|
-
print(`holocron sync
|
|
7415
|
+
print(`holocron sync github${dryRun ? " (dry-run)" : ""}`);
|
|
6839
7416
|
print(` repo: ${repo}`);
|
|
6840
7417
|
if (branch) print(` branch: ${branch}`);
|
|
6841
7418
|
print("");
|
|
@@ -6964,7 +7541,7 @@ async function runSyncGithub(input) {
|
|
|
6964
7541
|
sha: blob.sha
|
|
6965
7542
|
});
|
|
6966
7543
|
} catch (err) {
|
|
6967
|
-
const msg = `failed to create blob for ${file.path}: ${
|
|
7544
|
+
const msg = `failed to create blob for ${file.path}: ${errorMessage(err)}`;
|
|
6968
7545
|
print(` ✗ ${msg}`);
|
|
6969
7546
|
return done({
|
|
6970
7547
|
status: "fail",
|
|
@@ -6978,7 +7555,7 @@ async function runSyncGithub(input) {
|
|
|
6978
7555
|
try {
|
|
6979
7556
|
newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
|
|
6980
7557
|
} catch (err) {
|
|
6981
|
-
const msg = `failed to create tree: ${
|
|
7558
|
+
const msg = `failed to create tree: ${errorMessage(err)}`;
|
|
6982
7559
|
print(` ✗ ${msg}`);
|
|
6983
7560
|
return done({
|
|
6984
7561
|
status: "fail",
|
|
@@ -6992,7 +7569,7 @@ async function runSyncGithub(input) {
|
|
|
6992
7569
|
try {
|
|
6993
7570
|
newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
|
|
6994
7571
|
} catch (err) {
|
|
6995
|
-
const msg = `failed to create commit: ${
|
|
7572
|
+
const msg = `failed to create commit: ${errorMessage(err)}`;
|
|
6996
7573
|
print(` ✗ ${msg}`);
|
|
6997
7574
|
return done({
|
|
6998
7575
|
status: "fail",
|
|
@@ -7011,7 +7588,7 @@ async function runSyncGithub(input) {
|
|
|
7011
7588
|
}
|
|
7012
7589
|
else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
|
|
7013
7590
|
} catch (err) {
|
|
7014
|
-
const msg = `failed to update ref: ${
|
|
7591
|
+
const msg = `failed to update ref: ${errorMessage(err)}`;
|
|
7015
7592
|
print(` ✗ ${msg}`);
|
|
7016
7593
|
return done({
|
|
7017
7594
|
status: "fail",
|
|
@@ -7027,12 +7604,12 @@ async function runSyncGithub(input) {
|
|
|
7027
7604
|
title: message.split("\n")[0],
|
|
7028
7605
|
head: branch,
|
|
7029
7606
|
base: "main",
|
|
7030
|
-
body: "Auto-generated by `holocron sync
|
|
7607
|
+
body: "Auto-generated by `holocron sync github`. Review and merge to apply template updates."
|
|
7031
7608
|
})).html_url;
|
|
7032
7609
|
print(` → PR opened: ${prUrl}`);
|
|
7033
7610
|
} catch (err) {
|
|
7034
7611
|
if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
|
|
7035
|
-
else print(` ⚠ PR creation failed: ${
|
|
7612
|
+
else print(` ⚠ PR creation failed: ${errorMessage(err)}`);
|
|
7036
7613
|
}
|
|
7037
7614
|
return done({
|
|
7038
7615
|
status: "ok",
|
|
@@ -7045,7 +7622,7 @@ async function runSyncGithub(input) {
|
|
|
7045
7622
|
//#endregion
|
|
7046
7623
|
//#region src/commands/upgrade-deps.ts
|
|
7047
7624
|
/**
|
|
7048
|
-
* `holocron upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7625
|
+
* `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7049
7626
|
* its `holocron.config.ts` up to the current major.
|
|
7050
7627
|
*
|
|
7051
7628
|
* Two concerns:
|
|
@@ -7060,7 +7637,7 @@ async function runSyncGithub(input) {
|
|
|
7060
7637
|
* This applies the mechanical transforms and flags anything that needs a
|
|
7061
7638
|
* human eye.
|
|
7062
7639
|
*
|
|
7063
|
-
* After running, `pnpm install && holocron ci`.
|
|
7640
|
+
* After running, `pnpm install && holocron run ci`.
|
|
7064
7641
|
*/
|
|
7065
7642
|
const FETCH_TIMEOUT_MS$1 = 4e3;
|
|
7066
7643
|
/** Resolve a package's `latest` dist-tag from the npm registry. */
|
|
@@ -7127,7 +7704,7 @@ function isLegacyConfig(content) {
|
|
|
7127
7704
|
}
|
|
7128
7705
|
/**
|
|
7129
7706
|
* Apply the mechanical 7.x → 8.x transforms to a `holocron.config.ts`. Best
|
|
7130
|
-
* effort — the result should always be reviewed and run through `holocron ci`.
|
|
7707
|
+
* effort — the result should always be reviewed and run through `holocron run ci`.
|
|
7131
7708
|
*/
|
|
7132
7709
|
function migrateConfig(content) {
|
|
7133
7710
|
const transforms = [];
|
|
@@ -7253,7 +7830,7 @@ async function runUpgradeDeps(input = {}) {
|
|
|
7253
7830
|
print("");
|
|
7254
7831
|
if (bumps.length === 0 && !configMigrated) print("Nothing to do — already up to date.");
|
|
7255
7832
|
else {
|
|
7256
|
-
print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron ci\``);
|
|
7833
|
+
print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron run ci\``);
|
|
7257
7834
|
if (configMigrated) print(" review holocron.config.ts before committing.");
|
|
7258
7835
|
}
|
|
7259
7836
|
const status = dryRun ? "dry-run" : "ok";
|
|
@@ -7459,6 +8036,63 @@ async function runUpgradeNode(input) {
|
|
|
7459
8036
|
};
|
|
7460
8037
|
}
|
|
7461
8038
|
//#endregion
|
|
8039
|
+
//#region src/commit-lint/lint-message.ts
|
|
8040
|
+
/**
|
|
8041
|
+
* Lints one commit message string against an already-loaded commitlint
|
|
8042
|
+
* config. The genuinely environment-independent half of commit-message
|
|
8043
|
+
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
8044
|
+
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
8045
|
+
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
8046
|
+
* just lets cosmiconfig discover the invoking repo's own
|
|
8047
|
+
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
8048
|
+
* but linting one message against a loaded config is identical either way.
|
|
8049
|
+
*
|
|
8050
|
+
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
8051
|
+
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
8052
|
+
*/
|
|
8053
|
+
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
8054
|
+
function selectParserOpts(parserPreset) {
|
|
8055
|
+
return parserPreset?.parserOpts;
|
|
8056
|
+
}
|
|
8057
|
+
async function lintCommitMessage(message, loaded) {
|
|
8058
|
+
const opts = {
|
|
8059
|
+
/* istanbul ignore next -- see comment above */
|
|
8060
|
+
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
8061
|
+
plugins: loaded.plugins,
|
|
8062
|
+
/* istanbul ignore next -- see comment above */
|
|
8063
|
+
ignores: loaded.ignores ?? [],
|
|
8064
|
+
defaultIgnores: loaded.defaultIgnores !== false
|
|
8065
|
+
};
|
|
8066
|
+
const outcome = await lint(message, loaded.rules, opts);
|
|
8067
|
+
if (outcome.valid) return [];
|
|
8068
|
+
return outcome.errors.map((error) => ({
|
|
8069
|
+
rule: error.name,
|
|
8070
|
+
message: error.message
|
|
8071
|
+
}));
|
|
8072
|
+
}
|
|
8073
|
+
//#endregion
|
|
8074
|
+
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
8075
|
+
/**
|
|
8076
|
+
* `holocron run lint commit-msg <file>` — a real programmatic replacement for
|
|
8077
|
+
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
8078
|
+
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
8079
|
+
* path to the commit message being written (the arg git's `commit-msg`
|
|
8080
|
+
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
8081
|
+
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
8082
|
+
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
8083
|
+
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
8084
|
+
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
8085
|
+
* runs inside a real repo checkout with a real `node_modules`, not
|
|
8086
|
+
* standalone on a server.
|
|
8087
|
+
*/
|
|
8088
|
+
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
8089
|
+
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
8090
|
+
return {
|
|
8091
|
+
valid: violations.length === 0,
|
|
8092
|
+
violations
|
|
8093
|
+
};
|
|
8094
|
+
}
|
|
8095
|
+
//#endregion
|
|
7462
8096
|
//#region src/config/load-config.ts
|
|
7463
8097
|
/**
|
|
7464
8098
|
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
@@ -7473,14 +8107,19 @@ async function runUpgradeNode(input) {
|
|
|
7473
8107
|
*/
|
|
7474
8108
|
const execFileAsync = promisify(execFile);
|
|
7475
8109
|
/**
|
|
7476
|
-
* Read + parse + resolve `holocron.config.*` from the given
|
|
8110
|
+
* Read + parse + resolve `holocron.config.*` starting from the given
|
|
8111
|
+
* directory, walking up through ancestors to the nearest `.git` boundary
|
|
8112
|
+
* when `cwd` itself has no match — every command is usable from any
|
|
8113
|
+
* subdirectory of a repo, not only its exact root (see
|
|
8114
|
+
* `@theholocron/datapad`'s own doc comment for the stop conditions).
|
|
7477
8115
|
* Throws {@link ConfigFileError} when nothing is found or a file cannot
|
|
7478
|
-
* be loaded, or `ConfigError` when the config
|
|
8116
|
+
* be loaded, or `ConfigError` when the config fails schema checks.
|
|
7479
8117
|
*/
|
|
7480
8118
|
async function loadConfig(cwd) {
|
|
7481
8119
|
const found = await loadConfigFile({
|
|
7482
8120
|
cwd,
|
|
7483
|
-
name: "holocron"
|
|
8121
|
+
name: "holocron",
|
|
8122
|
+
walkUp: true
|
|
7484
8123
|
});
|
|
7485
8124
|
if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
|
|
7486
8125
|
return {
|
|
@@ -7553,6 +8192,15 @@ function parseGitRemoteUrl(url) {
|
|
|
7553
8192
|
* {@link promptForPositionals} for just that one command — no spawn needed,
|
|
7554
8193
|
* the handler already has everything else it needs.
|
|
7555
8194
|
*
|
|
8195
|
+
* For a command whose handler honours `--dry-run` ({@link
|
|
8196
|
+
* CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
|
|
8197
|
+
* dry run doesn't mutate anything, so there's nothing to ask permission
|
|
8198
|
+
* for — then, once that preview comes back clean and found something to
|
|
8199
|
+
* do, asks the one real decision: "proceed with the real run?". A preview
|
|
8200
|
+
* that finds nothing to do skips that question too. `--skip-dry-run` opts
|
|
8201
|
+
* a top-level invocation out of the preview entirely, straight to the
|
|
8202
|
+
* real run, for when you already know what you want.
|
|
8203
|
+
*
|
|
7556
8204
|
* Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
|
|
7557
8205
|
* (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
|
|
7558
8206
|
* hanging on `@inquirer/prompts` (which otherwise throws its own
|
|
@@ -7566,40 +8214,153 @@ var NonInteractiveError = class extends Error {
|
|
|
7566
8214
|
};
|
|
7567
8215
|
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
7568
8216
|
/**
|
|
8217
|
+
* This repo's own resolved task manifest, filtered to what's actually
|
|
8218
|
+
* runnable locally — `Astromech.runnableTasks()`, reused rather than
|
|
8219
|
+
* reimplemented (deliberately *not* `packageScripts()`, whose
|
|
8220
|
+
* `syncScripts: false` short-circuit returns `{}` for repos — this one
|
|
8221
|
+
* included — that opt out of generated `package.json` scripts but still
|
|
8222
|
+
* have real runnable tasks). Empty (never throws) when there's no config to
|
|
8223
|
+
* resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
|
|
8224
|
+
* as "run ›" simply having no children.
|
|
8225
|
+
*/
|
|
8226
|
+
async function listRunnableTasks(argv) {
|
|
8227
|
+
const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
|
|
8228
|
+
const config = await loadTasksConfig(cwd).catch(() => void 0);
|
|
8229
|
+
return createAstromech({
|
|
8230
|
+
cwd,
|
|
8231
|
+
config
|
|
8232
|
+
}).runnableTasks();
|
|
8233
|
+
}
|
|
8234
|
+
/**
|
|
8235
|
+
* Namespace display order for the `run` menu — deliberately its own thing,
|
|
8236
|
+
* not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
|
|
8237
|
+
* signal first" in an automated gate, a different goal from "how a person
|
|
8238
|
+
* would naturally work through a repo" (build it, clean up its style, check
|
|
8239
|
+
* it's secure, test it, validate it, document it). A namespace absent here
|
|
8240
|
+
* (a repo-specific one this list doesn't know about) sorts after every
|
|
8241
|
+
* named one, in whatever order it was first seen.
|
|
8242
|
+
*/
|
|
8243
|
+
const RUN_MENU_NAMESPACE_ORDER = [
|
|
8244
|
+
"delivery",
|
|
8245
|
+
"sourceQuality",
|
|
8246
|
+
"security",
|
|
8247
|
+
"verification",
|
|
8248
|
+
"platform",
|
|
8249
|
+
"knowledge"
|
|
8250
|
+
];
|
|
8251
|
+
/**
|
|
8252
|
+
* Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
|
|
8253
|
+
* groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
|
|
8254
|
+
* within each group is `taskNames`' own order (already declaration order,
|
|
8255
|
+
* from {@link listRunnableTasks}). A name with no `.` at all (defensive;
|
|
8256
|
+
* every real registry task has a namespace) is its own bucket, keyed by the
|
|
8257
|
+
* full name, sorted after every namespaced group.
|
|
8258
|
+
*/
|
|
8259
|
+
function groupByNamespace(taskNames) {
|
|
8260
|
+
const groups = /* @__PURE__ */ new Map();
|
|
8261
|
+
for (const name of taskNames) {
|
|
8262
|
+
const dot = name.indexOf(".");
|
|
8263
|
+
const key = dot === -1 ? name : name.slice(0, dot);
|
|
8264
|
+
const list = groups.get(key);
|
|
8265
|
+
if (list) list.push(name);
|
|
8266
|
+
else groups.set(key, [name]);
|
|
8267
|
+
}
|
|
8268
|
+
const ordered = /* @__PURE__ */ new Map();
|
|
8269
|
+
const byMenuOrder = (a, b) => {
|
|
8270
|
+
const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
|
|
8271
|
+
const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
|
|
8272
|
+
if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
|
8273
|
+
return 0;
|
|
8274
|
+
};
|
|
8275
|
+
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8276
|
+
return ordered;
|
|
8277
|
+
}
|
|
8278
|
+
/**
|
|
8279
|
+
* Dynamically-built `run` sub-entries for every locally-runnable task
|
|
8280
|
+
* ({@link listRunnableTasks}), grouped by namespace and merged into
|
|
8281
|
+
* `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
|
|
8282
|
+
* itself, since the task set is per-repo, not a static surface this module
|
|
8283
|
+
* can hand-enumerate.
|
|
8284
|
+
*
|
|
8285
|
+
* Each namespace becomes a group with a leading "<namespace> (run it)" item
|
|
8286
|
+
* (`holocron run <namespace>` — every task in it, via
|
|
8287
|
+
* `Astromech.runGroup()`) alongside each of its individual tasks, the exact
|
|
8288
|
+
* same "a group that's also runnable" shape `menuItems` already renders for
|
|
8289
|
+
* `repo sync` / `deploy` — reached here for free by using a space, not the
|
|
8290
|
+
* task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
|
|
8291
|
+
* the real dotted invocation `buildChildArgv` actually spawns).
|
|
8292
|
+
*/
|
|
8293
|
+
function buildRunMenuEntries(taskNames) {
|
|
8294
|
+
const entries = [];
|
|
8295
|
+
for (const [namespace, tasks] of groupByNamespace(taskNames)) {
|
|
8296
|
+
const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
|
|
8297
|
+
if (isRealNamespace) entries.push({
|
|
8298
|
+
name: `run ${namespace}`,
|
|
8299
|
+
description: `Run every ${namespace}.* task in sequence`,
|
|
8300
|
+
positionals: [],
|
|
8301
|
+
supportsDryRun: true,
|
|
8302
|
+
spawnArgs: ["run", namespace]
|
|
8303
|
+
});
|
|
8304
|
+
for (const task of tasks) {
|
|
8305
|
+
const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
|
|
8306
|
+
entries.push({
|
|
8307
|
+
name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
|
|
8308
|
+
description: `Run ${task}`,
|
|
8309
|
+
positionals: [],
|
|
8310
|
+
supportsDryRun: true,
|
|
8311
|
+
spawnArgs: ["run", task]
|
|
8312
|
+
});
|
|
8313
|
+
}
|
|
8314
|
+
}
|
|
8315
|
+
return entries;
|
|
8316
|
+
}
|
|
8317
|
+
/**
|
|
7569
8318
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
7570
8319
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
7571
|
-
* `run <task>`
|
|
7572
|
-
*
|
|
8320
|
+
* `run <task>` has no entry here at all — `cli.ts`'s `$0` handler merges in
|
|
8321
|
+
* {@link buildRunMenuEntries}'s dynamically-built ones instead, since the
|
|
8322
|
+
* task namespace is this repo's own resolved manifest, not a static set
|
|
8323
|
+
* this module can hand-enumerate the way every other entry's positionals
|
|
8324
|
+
* are. `job` and `passthrough` stay un-prompted either way; picking a task
|
|
8325
|
+
* and running it plain covers the common case, and typing
|
|
8326
|
+
* `holocron run <task> <job> -- <args>` directly is one step away for
|
|
8327
|
+
* everything else. `run lint commit-msg <file>` stays excluded entirely:
|
|
8328
|
+
* it's invoked exclusively from `.husky/commit-msg "$1"`, never typed by a
|
|
8329
|
+
* human.
|
|
7573
8330
|
*/
|
|
7574
8331
|
const COMMAND_REGISTRY = [
|
|
7575
|
-
{
|
|
7576
|
-
name: "version",
|
|
7577
|
-
description: "Print the CLI version",
|
|
7578
|
-
positionals: []
|
|
7579
|
-
},
|
|
7580
8332
|
{
|
|
7581
8333
|
name: "clone",
|
|
7582
|
-
description: "Clone
|
|
7583
|
-
positionals: [
|
|
8334
|
+
description: "Clone a single repo (org/repo) or every repo in an org (org)",
|
|
8335
|
+
positionals: [{
|
|
8336
|
+
key: "target",
|
|
8337
|
+
message: "org/repo to clone (or a bare org to clone every repo in it):",
|
|
8338
|
+
type: "input",
|
|
8339
|
+
validate: (v) => v.trim().length > 0 || "org/repo or org is required"
|
|
8340
|
+
}],
|
|
8341
|
+
supportsDryRun: true
|
|
7584
8342
|
},
|
|
7585
8343
|
{
|
|
7586
8344
|
name: "doctor",
|
|
7587
8345
|
description: "Load the config and run a smoke check against every provider",
|
|
7588
|
-
positionals: []
|
|
8346
|
+
positionals: [],
|
|
8347
|
+
supportsDryRun: true
|
|
7589
8348
|
},
|
|
7590
8349
|
{
|
|
7591
|
-
name: "setup",
|
|
8350
|
+
name: "repo setup",
|
|
7592
8351
|
description: "Apply infra setup actions across every configured capability",
|
|
7593
|
-
positionals: []
|
|
8352
|
+
positionals: [],
|
|
8353
|
+
supportsDryRun: true
|
|
7594
8354
|
},
|
|
7595
8355
|
{
|
|
7596
|
-
name: "
|
|
8356
|
+
name: "secrets set",
|
|
7597
8357
|
description: "Set a single secret via the configured `secrets` capability",
|
|
7598
8358
|
positionals: [{
|
|
7599
8359
|
key: "name",
|
|
7600
8360
|
message: "Secret name:",
|
|
7601
8361
|
type: "input"
|
|
7602
|
-
}]
|
|
8362
|
+
}],
|
|
8363
|
+
supportsDryRun: true
|
|
7603
8364
|
},
|
|
7604
8365
|
{
|
|
7605
8366
|
name: "secrets sync",
|
|
@@ -7608,7 +8369,8 @@ const COMMAND_REGISTRY = [
|
|
|
7608
8369
|
key: "environmentId",
|
|
7609
8370
|
message: "Vault environment id:",
|
|
7610
8371
|
type: "input"
|
|
7611
|
-
}]
|
|
8372
|
+
}],
|
|
8373
|
+
supportsDryRun: true
|
|
7612
8374
|
},
|
|
7613
8375
|
{
|
|
7614
8376
|
name: "deploy",
|
|
@@ -7617,52 +8379,66 @@ const COMMAND_REGISTRY = [
|
|
|
7617
8379
|
key: "branch",
|
|
7618
8380
|
message: "Branch to deploy:",
|
|
7619
8381
|
type: "input"
|
|
7620
|
-
}]
|
|
8382
|
+
}],
|
|
8383
|
+
supportsDryRun: true
|
|
7621
8384
|
},
|
|
7622
8385
|
{
|
|
7623
|
-
name: "cleanup-preview",
|
|
8386
|
+
name: "deploy cleanup-preview",
|
|
7624
8387
|
description: "List and delete Cloudflare Pages preview deployments for a GitHub PR",
|
|
7625
8388
|
positionals: [{
|
|
7626
8389
|
key: "pr",
|
|
7627
8390
|
message: "PR number:",
|
|
7628
8391
|
type: "input",
|
|
7629
8392
|
validate: numeric
|
|
7630
|
-
}]
|
|
8393
|
+
}],
|
|
8394
|
+
supportsDryRun: true
|
|
7631
8395
|
},
|
|
7632
8396
|
{
|
|
7633
|
-
name: "
|
|
8397
|
+
name: "deploy on-release",
|
|
8398
|
+
description: "Deploy every workspace package whose delivery.deploy task is `on: release`",
|
|
8399
|
+
positionals: [],
|
|
8400
|
+
supportsDryRun: true
|
|
8401
|
+
},
|
|
8402
|
+
{
|
|
8403
|
+
name: "package bump-versions",
|
|
7634
8404
|
description: "Bump all non-private package versions in lockstep (semantic-release prepareCmd)",
|
|
7635
8405
|
positionals: [{
|
|
7636
8406
|
key: "newVersion",
|
|
7637
8407
|
cliArg: "new-version",
|
|
7638
8408
|
message: "New version:",
|
|
7639
8409
|
type: "input"
|
|
7640
|
-
}]
|
|
8410
|
+
}],
|
|
8411
|
+
supportsDryRun: true
|
|
7641
8412
|
},
|
|
7642
8413
|
{
|
|
7643
|
-
name: "publish",
|
|
8414
|
+
name: "package publish",
|
|
7644
8415
|
description: "Publish @theholocron/* packages to npm",
|
|
7645
|
-
positionals: []
|
|
8416
|
+
positionals: [],
|
|
8417
|
+
supportsDryRun: true
|
|
7646
8418
|
},
|
|
7647
8419
|
{
|
|
7648
|
-
name: "sync",
|
|
8420
|
+
name: "repo sync",
|
|
7649
8421
|
description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
|
|
7650
|
-
positionals: []
|
|
8422
|
+
positionals: [],
|
|
8423
|
+
supportsDryRun: true
|
|
7651
8424
|
},
|
|
7652
8425
|
{
|
|
7653
|
-
name: "ci",
|
|
8426
|
+
name: "run ci",
|
|
7654
8427
|
description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
|
|
7655
|
-
positionals: []
|
|
8428
|
+
positionals: [],
|
|
8429
|
+
supportsDryRun: true
|
|
7656
8430
|
},
|
|
7657
8431
|
{
|
|
7658
|
-
name: "sync
|
|
8432
|
+
name: "sync github",
|
|
7659
8433
|
description: "Sync workflow templates and composite actions to theholocron/.github",
|
|
7660
|
-
positionals: []
|
|
8434
|
+
positionals: [],
|
|
8435
|
+
supportsDryRun: true
|
|
7661
8436
|
},
|
|
7662
8437
|
{
|
|
7663
|
-
name: "sync
|
|
8438
|
+
name: "repo sync readme",
|
|
7664
8439
|
description: "Sync the Installation + Usage block in README.md from package.json",
|
|
7665
|
-
positionals: []
|
|
8440
|
+
positionals: [],
|
|
8441
|
+
supportsDryRun: true
|
|
7666
8442
|
},
|
|
7667
8443
|
{
|
|
7668
8444
|
name: "config show",
|
|
@@ -7670,12 +8446,13 @@ const COMMAND_REGISTRY = [
|
|
|
7670
8446
|
positionals: []
|
|
7671
8447
|
},
|
|
7672
8448
|
{
|
|
7673
|
-
name: "new",
|
|
8449
|
+
name: "new repo",
|
|
7674
8450
|
description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
|
|
7675
|
-
positionals: []
|
|
8451
|
+
positionals: [],
|
|
8452
|
+
supportsDryRun: true
|
|
7676
8453
|
},
|
|
7677
8454
|
{
|
|
7678
|
-
name: "plugin
|
|
8455
|
+
name: "new plugin",
|
|
7679
8456
|
description: "Scaffold a new @theholocron/holocron-plugin-<slug> package",
|
|
7680
8457
|
positionals: [{
|
|
7681
8458
|
key: "slug",
|
|
@@ -7685,28 +8462,29 @@ const COMMAND_REGISTRY = [
|
|
|
7685
8462
|
key: "vendor",
|
|
7686
8463
|
message: "Vendor display name (PascalCase):",
|
|
7687
8464
|
type: "input"
|
|
7688
|
-
}]
|
|
8465
|
+
}],
|
|
8466
|
+
supportsDryRun: true
|
|
7689
8467
|
},
|
|
7690
8468
|
{
|
|
7691
8469
|
name: "skills install",
|
|
7692
8470
|
description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
|
|
7693
8471
|
positionals: [],
|
|
7694
|
-
|
|
8472
|
+
supportsDryRun: true
|
|
7695
8473
|
},
|
|
7696
8474
|
{
|
|
7697
8475
|
name: "skills remove",
|
|
7698
8476
|
description: "Remove installed skills via npx skills remove",
|
|
7699
8477
|
positionals: [],
|
|
7700
|
-
|
|
8478
|
+
supportsDryRun: true
|
|
7701
8479
|
},
|
|
7702
8480
|
{
|
|
7703
8481
|
name: "skills update",
|
|
7704
8482
|
description: "Update installed skills to their latest upstream versions via npx skills update",
|
|
7705
8483
|
positionals: [],
|
|
7706
|
-
|
|
8484
|
+
supportsDryRun: true
|
|
7707
8485
|
},
|
|
7708
8486
|
{
|
|
7709
|
-
name: "upgrade node",
|
|
8487
|
+
name: "repo upgrade node",
|
|
7710
8488
|
description: "Scan the repo and update every Node.js version pin to a new major",
|
|
7711
8489
|
positionals: [{
|
|
7712
8490
|
key: "to",
|
|
@@ -7714,13 +8492,13 @@ const COMMAND_REGISTRY = [
|
|
|
7714
8492
|
type: "input",
|
|
7715
8493
|
validate: numeric
|
|
7716
8494
|
}],
|
|
7717
|
-
|
|
8495
|
+
supportsDryRun: true
|
|
7718
8496
|
},
|
|
7719
8497
|
{
|
|
7720
|
-
name: "upgrade deps",
|
|
8498
|
+
name: "repo upgrade deps",
|
|
7721
8499
|
description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
|
|
7722
8500
|
positionals: [],
|
|
7723
|
-
|
|
8501
|
+
supportsDryRun: true
|
|
7724
8502
|
},
|
|
7725
8503
|
{
|
|
7726
8504
|
name: "auth set",
|
|
@@ -7729,8 +8507,7 @@ const COMMAND_REGISTRY = [
|
|
|
7729
8507
|
key: "provider",
|
|
7730
8508
|
message: "Provider name:",
|
|
7731
8509
|
type: "input"
|
|
7732
|
-
}]
|
|
7733
|
-
group: "auth"
|
|
8510
|
+
}]
|
|
7734
8511
|
},
|
|
7735
8512
|
{
|
|
7736
8513
|
name: "auth unset",
|
|
@@ -7740,8 +8517,7 @@ const COMMAND_REGISTRY = [
|
|
|
7740
8517
|
message: "Provider:",
|
|
7741
8518
|
type: "select",
|
|
7742
8519
|
choices: listStoredProviders
|
|
7743
|
-
}]
|
|
7744
|
-
group: "auth"
|
|
8520
|
+
}]
|
|
7745
8521
|
},
|
|
7746
8522
|
{
|
|
7747
8523
|
name: "auth check",
|
|
@@ -7751,14 +8527,12 @@ const COMMAND_REGISTRY = [
|
|
|
7751
8527
|
message: "Provider:",
|
|
7752
8528
|
type: "select",
|
|
7753
8529
|
choices: listStoredProviders
|
|
7754
|
-
}]
|
|
7755
|
-
group: "auth"
|
|
8530
|
+
}]
|
|
7756
8531
|
},
|
|
7757
8532
|
{
|
|
7758
8533
|
name: "auth list",
|
|
7759
8534
|
description: "List every provider with a stored bootstrap token",
|
|
7760
|
-
positionals: []
|
|
7761
|
-
group: "auth"
|
|
8535
|
+
positionals: []
|
|
7762
8536
|
}
|
|
7763
8537
|
];
|
|
7764
8538
|
/** Look up a {@link CommandEntry} by its full name — for Layer 3 call sites in `cli.ts`. */
|
|
@@ -7767,36 +8541,89 @@ function getEntry(name) {
|
|
|
7767
8541
|
if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
|
|
7768
8542
|
return entry;
|
|
7769
8543
|
}
|
|
8544
|
+
const GROUP = "group:";
|
|
8545
|
+
/** The "← back" item's value; only offered below the first level. */
|
|
8546
|
+
const BACK = "back";
|
|
8547
|
+
/**
|
|
8548
|
+
* One level of the menu, derived from the registered command names: the next word
|
|
8549
|
+
* of every entry under `path` (`[]` is the top). A word is
|
|
8550
|
+
*
|
|
8551
|
+
* - a **command** when nothing is nested under it (`doctor`);
|
|
8552
|
+
* - a **group** when commands are nested under it (`repo` → `repo setup`, `repo sync`, …),
|
|
8553
|
+
* which can nest as deep as the names go (`repo upgrade` → `repo upgrade node`);
|
|
8554
|
+
*
|
|
8555
|
+
* Every row is a single word, never a "<command> <subcommand>" path: `config show` is
|
|
8556
|
+
* `config ›` then `show`, even when a group holds just one command.
|
|
8557
|
+
*
|
|
8558
|
+
* A path that is itself runnable (`repo sync`, `deploy`) gets a leading "<word> (run it)" item.
|
|
8559
|
+
* Registration order is kept.
|
|
8560
|
+
*/
|
|
8561
|
+
function menuItems(entries, path = []) {
|
|
8562
|
+
const prefix = path.join(" ");
|
|
8563
|
+
const own = prefix ? entries.find((e) => e.name === prefix) : void 0;
|
|
8564
|
+
const under = prefix ? entries.filter((e) => e.name.startsWith(`${prefix} `)) : entries;
|
|
8565
|
+
const haystackOf = (list) => list.map((e) => `${e.name} ${e.description}`.toLowerCase()).join(" ");
|
|
8566
|
+
const items = [];
|
|
8567
|
+
if (own) items.push({
|
|
8568
|
+
name: `${path[path.length - 1]} (run it)`,
|
|
8569
|
+
value: own.name,
|
|
8570
|
+
description: own.description,
|
|
8571
|
+
haystack: haystackOf([own])
|
|
8572
|
+
});
|
|
8573
|
+
const words = [...new Set(under.map((e) => e.name.split(" ")[path.length]))];
|
|
8574
|
+
for (const word of words) {
|
|
8575
|
+
const full = [...path, word].join(" ");
|
|
8576
|
+
const family = under.filter((e) => e.name === full || e.name.startsWith(`${full} `));
|
|
8577
|
+
const self = family.find((e) => e.name === full);
|
|
8578
|
+
const nested = family.filter((e) => e.name !== full);
|
|
8579
|
+
if (nested.length === 0 && self) items.push({
|
|
8580
|
+
name: word,
|
|
8581
|
+
value: full,
|
|
8582
|
+
description: self.description,
|
|
8583
|
+
haystack: haystackOf([self])
|
|
8584
|
+
});
|
|
8585
|
+
else {
|
|
8586
|
+
const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
|
|
8587
|
+
items.push({
|
|
8588
|
+
name: `${word} ›`,
|
|
8589
|
+
value: `${GROUP}${full}`,
|
|
8590
|
+
description: children.join(", "),
|
|
8591
|
+
haystack: haystackOf(family)
|
|
8592
|
+
});
|
|
8593
|
+
}
|
|
8594
|
+
}
|
|
8595
|
+
return items;
|
|
8596
|
+
}
|
|
7770
8597
|
/**
|
|
7771
|
-
* Searchable autocomplete over
|
|
7772
|
-
*
|
|
7773
|
-
*
|
|
7774
|
-
* gracefully as the surface grows and costs nothing when it doesn't.
|
|
8598
|
+
* Searchable autocomplete over one menu level's `items`. `select()` would work too
|
|
8599
|
+
* at these list sizes, but `search()` degrades gracefully as the surface grows and
|
|
8600
|
+
* costs nothing when it doesn't. Resolves to the picked item's `value`.
|
|
7775
8601
|
*/
|
|
7776
|
-
async function
|
|
7777
|
-
|
|
7778
|
-
const picked = await search({
|
|
8602
|
+
async function pickMenuItem(items, message = "What would you like to do?") {
|
|
8603
|
+
return search({
|
|
7779
8604
|
message,
|
|
7780
|
-
source: (term) => searchChoices(
|
|
8605
|
+
source: (term) => searchChoices(items, term)
|
|
7781
8606
|
});
|
|
7782
|
-
return byName.get(picked);
|
|
7783
8607
|
}
|
|
7784
8608
|
/**
|
|
7785
8609
|
* `@inquirer/search`'s `source` callback, factored out as a plain function —
|
|
7786
8610
|
* unit-testable directly instead of only through a mocked `search()` call.
|
|
7787
|
-
* Empty/undefined `term` (nothing typed yet) returns every
|
|
8611
|
+
* Empty/undefined `term` (nothing typed yet) returns every item.
|
|
7788
8612
|
*/
|
|
7789
|
-
function searchChoices(
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
8613
|
+
function searchChoices(items, term) {
|
|
8614
|
+
const needle = term?.toLowerCase();
|
|
8615
|
+
const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
|
|
8616
|
+
const rank = (i) => {
|
|
8617
|
+
if (!needle) return 0;
|
|
8618
|
+
const name = i.name.toLowerCase();
|
|
8619
|
+
return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
|
|
8620
|
+
};
|
|
8621
|
+
return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
|
|
8622
|
+
name: i.name,
|
|
8623
|
+
value: i.value,
|
|
8624
|
+
description: i.description
|
|
7794
8625
|
}));
|
|
7795
8626
|
}
|
|
7796
|
-
function matches(entry, term) {
|
|
7797
|
-
const needle = term.toLowerCase();
|
|
7798
|
-
return entry.name.toLowerCase().includes(needle) || entry.description.toLowerCase().includes(needle);
|
|
7799
|
-
}
|
|
7800
8627
|
/**
|
|
7801
8628
|
* For each of `entry.positionals` not already present in `argv`, prompts for
|
|
7802
8629
|
* it (`input()` or `select()`, per the positional's `type`) and returns the
|
|
@@ -7835,78 +8662,426 @@ async function promptForPositionals(entry, argv) {
|
|
|
7835
8662
|
* menu-launched child should inherit from the parent invocation. Everything
|
|
7836
8663
|
* else (command-specific options) was never captured by the top-level `$0`
|
|
7837
8664
|
* handler in the first place, so there's nothing else to forward.
|
|
8665
|
+
*
|
|
8666
|
+
* `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
|
|
8667
|
+
* dry-run offer needs to force `--dry-run` on for the preview spawn and
|
|
8668
|
+
* force it back off for the real-run spawn that follows, regardless of
|
|
8669
|
+
* what was on the original invocation.
|
|
7838
8670
|
*/
|
|
7839
|
-
function forwardedFlags(argv) {
|
|
8671
|
+
function forwardedFlags(argv, dryRunOverride) {
|
|
7840
8672
|
const out = [];
|
|
7841
8673
|
const tokens = argv.token;
|
|
7842
8674
|
for (const t of tokens ?? []) out.push("--token", t);
|
|
7843
8675
|
if (typeof argv.org === "string") out.push("--org", argv.org);
|
|
7844
8676
|
if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
|
|
7845
|
-
if (argv.dryRun === true) out.push("--dry-run");
|
|
8677
|
+
if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
|
|
7846
8678
|
return out;
|
|
7847
8679
|
}
|
|
7848
|
-
/** `entry.name` split into tokens
|
|
7849
|
-
function buildChildArgv(entry, positionals, parentArgv) {
|
|
8680
|
+
/** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
|
|
8681
|
+
function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
|
|
7850
8682
|
return [
|
|
7851
|
-
...entry.name.split(" "),
|
|
8683
|
+
...entry.spawnArgs ?? entry.name.split(" "),
|
|
7852
8684
|
...positionals,
|
|
7853
|
-
...forwardedFlags(parentArgv)
|
|
8685
|
+
...forwardedFlags(parentArgv, dryRunOverride)
|
|
7854
8686
|
];
|
|
7855
8687
|
}
|
|
7856
|
-
/**
|
|
7857
|
-
|
|
8688
|
+
/**
|
|
8689
|
+
* Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
|
|
8690
|
+
* its handler knows this specific `--dry-run` came from `launchMenu`'s own
|
|
8691
|
+
* preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
|
|
8692
|
+
* A handler that supports it uses this — never bare `argv.dryRun` — to
|
|
8693
|
+
* decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
|
|
8694
|
+
* only has special meaning to `launchMenu` itself, so a direct/scripted
|
|
8695
|
+
* `--dry-run` call must never produce it — doing so unconditionally would
|
|
8696
|
+
* silently change the public exit-code contract for a successful dry run.
|
|
8697
|
+
*/
|
|
8698
|
+
const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
|
|
8699
|
+
/**
|
|
8700
|
+
* Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
|
|
8701
|
+
*
|
|
8702
|
+
* `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
|
|
8703
|
+
* update-notifier tail still runs after this resolves (see `launchMenu`'s
|
|
8704
|
+
* docstring), so without this the freshly-spawned child independently runs
|
|
8705
|
+
* its own `checkForUpdates()` too and the notice prints twice: once from
|
|
8706
|
+
* the child right after its command output, once more from the parent
|
|
8707
|
+
* once the child exits. `extraEnv` layers on top — used only for {@link
|
|
8708
|
+
* DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
|
|
8709
|
+
*/
|
|
8710
|
+
function spawnChild(args, extraEnv) {
|
|
7858
8711
|
return new Promise((resolve) => {
|
|
7859
|
-
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8712
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8713
|
+
stdio: "inherit",
|
|
8714
|
+
env: {
|
|
8715
|
+
...process.env,
|
|
8716
|
+
NO_UPDATE_NOTIFIER: "1",
|
|
8717
|
+
...extraEnv
|
|
8718
|
+
}
|
|
8719
|
+
});
|
|
7860
8720
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
7861
8721
|
child.on("error", () => resolve(1));
|
|
7862
8722
|
});
|
|
7863
8723
|
}
|
|
7864
8724
|
/**
|
|
7865
|
-
*
|
|
7866
|
-
*
|
|
7867
|
-
* `
|
|
7868
|
-
* `
|
|
7869
|
-
*
|
|
7870
|
-
*
|
|
7871
|
-
|
|
7872
|
-
|
|
8725
|
+
* Whether `launchMenu` should auto-preview a picked command before running
|
|
8726
|
+
* it for real — only when the command's handler actually honours
|
|
8727
|
+
* `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
|
|
8728
|
+
* invocation didn't pass `--skip-dry-run` to opt out of the preview
|
|
8729
|
+
* entirely (an escape hatch for "I already know what I want, skip
|
|
8730
|
+
* straight to the real run").
|
|
8731
|
+
*/
|
|
8732
|
+
function shouldPreviewDryRun(entry, parentArgv) {
|
|
8733
|
+
return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
|
|
8734
|
+
}
|
|
8735
|
+
/**
|
|
8736
|
+
* Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
|
|
8737
|
+
* `[]` for the top), prompt for the picked command's required positionals, spawn
|
|
8738
|
+
* it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
|
|
8739
|
+
* the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
|
|
8740
|
+
* parent's own telemetry flush / update-notifier tail still runs. The parent's
|
|
8741
|
+
* own `command_completed` event fires with command name "unknown" (the middleware
|
|
8742
|
+
* ran before any command was picked) — left as-is rather than suppressed; it's a
|
|
8743
|
+
* real, useful signal ("the menu got used").
|
|
8744
|
+
*
|
|
8745
|
+
* Picking a group opens its submenu, which has a "← back" item; a command ends
|
|
8746
|
+
* the walk. See {@link menuItems} for how a level is built.
|
|
8747
|
+
*
|
|
8748
|
+
* For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
|
|
8749
|
+
* and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
|
|
8750
|
+
* this always previews first — a dry run doesn't mutate anything, so
|
|
8751
|
+
* there's nothing to ask permission for. A preview that exits non-zero
|
|
8752
|
+
* stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
|
|
8753
|
+
* would change) stops here too, skipping the follow-up question since the
|
|
8754
|
+
* real run would have nothing to do either. Otherwise it asks "proceed
|
|
8755
|
+
* with the real run?" before the real spawn — the one actual decision
|
|
8756
|
+
* left to make, since this one does mutate something.
|
|
7873
8757
|
*/
|
|
7874
|
-
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
|
|
8758
|
+
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
|
|
7875
8759
|
if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
|
|
7876
|
-
const
|
|
7877
|
-
|
|
7878
|
-
|
|
8760
|
+
const stack = [startPath];
|
|
8761
|
+
let picked;
|
|
8762
|
+
while (!picked) {
|
|
8763
|
+
const path = stack[stack.length - 1];
|
|
8764
|
+
const items = menuItems(entries, path);
|
|
8765
|
+
if (stack.length > 1) items.push({
|
|
8766
|
+
name: "← back",
|
|
8767
|
+
value: BACK,
|
|
8768
|
+
description: "Return to the previous menu",
|
|
8769
|
+
haystack: "back"
|
|
8770
|
+
});
|
|
8771
|
+
const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
|
|
8772
|
+
if (value === "back") stack.pop();
|
|
8773
|
+
else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
|
|
8774
|
+
else picked = entries.find((e) => e.name === value);
|
|
8775
|
+
}
|
|
8776
|
+
const positionals = await promptForPositionals(picked, {});
|
|
8777
|
+
if (shouldPreviewDryRun(picked, parentArgv)) {
|
|
8778
|
+
const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
|
|
8779
|
+
if (dryExit === 2) {
|
|
8780
|
+
process.exitCode = 0;
|
|
8781
|
+
return;
|
|
8782
|
+
}
|
|
8783
|
+
if (dryExit !== 0) {
|
|
8784
|
+
process.exitCode = dryExit;
|
|
8785
|
+
return;
|
|
8786
|
+
}
|
|
8787
|
+
if (!await confirm({
|
|
8788
|
+
message: "Dry run complete — proceed with the real run?",
|
|
8789
|
+
default: false
|
|
8790
|
+
})) {
|
|
8791
|
+
process.exitCode = 0;
|
|
8792
|
+
return;
|
|
8793
|
+
}
|
|
8794
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
|
|
8795
|
+
return;
|
|
8796
|
+
}
|
|
8797
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
7879
8798
|
}
|
|
7880
8799
|
//#endregion
|
|
7881
|
-
//#region src/
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
const FETCH_TIMEOUT_MS = 3e3;
|
|
7885
|
-
function getCacheDir() {
|
|
7886
|
-
return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
|
|
8800
|
+
//#region src/ui/json-view.ts
|
|
8801
|
+
function isContainer(value) {
|
|
8802
|
+
return typeof value === "object" && value !== null;
|
|
7887
8803
|
}
|
|
7888
|
-
function
|
|
7889
|
-
return
|
|
8804
|
+
function childrenOf(value) {
|
|
8805
|
+
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
7890
8806
|
}
|
|
7891
|
-
function
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
}
|
|
8807
|
+
function scalar(value) {
|
|
8808
|
+
if (typeof value === "string") return chalk.green(JSON.stringify(value));
|
|
8809
|
+
if (typeof value === "number") return chalk.yellow(String(value));
|
|
8810
|
+
if (typeof value === "boolean") return chalk.magenta(String(value));
|
|
8811
|
+
return chalk.dim(String(value === void 0 ? null : value));
|
|
7897
8812
|
}
|
|
7898
|
-
function
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
} catch {}
|
|
8813
|
+
function summary(value) {
|
|
8814
|
+
const n = childrenOf(value).length;
|
|
8815
|
+
const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
|
|
8816
|
+
return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
|
|
7903
8817
|
}
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
8818
|
+
/**
|
|
8819
|
+
* Paths of every container deeper than `depth` levels are left out, so those
|
|
8820
|
+
* render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
|
|
8821
|
+
*/
|
|
8822
|
+
function expandToDepth(value, depth) {
|
|
8823
|
+
const out = /* @__PURE__ */ new Set();
|
|
8824
|
+
const walk = (v, path, level) => {
|
|
8825
|
+
if (!isContainer(v) || level >= depth) return;
|
|
8826
|
+
out.add(path);
|
|
8827
|
+
for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
|
|
8828
|
+
};
|
|
8829
|
+
walk(value, "", 0);
|
|
8830
|
+
return out;
|
|
8831
|
+
}
|
|
8832
|
+
/** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
|
|
8833
|
+
function renderTree(value, { expanded }) {
|
|
8834
|
+
const lines = [];
|
|
8835
|
+
const walk = (v, path, level, label, trailing) => {
|
|
8836
|
+
const indent = " ".repeat(level);
|
|
8837
|
+
const prefix = `${indent}${label}`;
|
|
8838
|
+
if (!isContainer(v)) {
|
|
8839
|
+
lines.push({
|
|
8840
|
+
path,
|
|
8841
|
+
text: `${prefix}${scalar(v)}${trailing}`,
|
|
8842
|
+
foldable: false,
|
|
8843
|
+
expanded: false,
|
|
8844
|
+
level
|
|
8845
|
+
});
|
|
8846
|
+
return;
|
|
8847
|
+
}
|
|
8848
|
+
const kids = childrenOf(v);
|
|
8849
|
+
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
8850
|
+
if (kids.length === 0) {
|
|
8851
|
+
lines.push({
|
|
8852
|
+
path,
|
|
8853
|
+
text: `${prefix}${open}${close}${trailing}`,
|
|
8854
|
+
foldable: false,
|
|
8855
|
+
expanded: false,
|
|
8856
|
+
level
|
|
8857
|
+
});
|
|
8858
|
+
return;
|
|
8859
|
+
}
|
|
8860
|
+
if (!expanded.has(path)) {
|
|
8861
|
+
lines.push({
|
|
8862
|
+
path,
|
|
8863
|
+
text: `${prefix}${summary(v)}${trailing}`,
|
|
8864
|
+
foldable: true,
|
|
8865
|
+
expanded: false,
|
|
8866
|
+
level
|
|
8867
|
+
});
|
|
8868
|
+
return;
|
|
8869
|
+
}
|
|
8870
|
+
lines.push({
|
|
8871
|
+
path,
|
|
8872
|
+
text: `${prefix}${open}`,
|
|
8873
|
+
foldable: true,
|
|
8874
|
+
expanded: true,
|
|
8875
|
+
level
|
|
8876
|
+
});
|
|
8877
|
+
kids.forEach(([k, child], i) => {
|
|
8878
|
+
const comma = i < kids.length - 1 ? "," : "";
|
|
8879
|
+
const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
|
|
8880
|
+
walk(child, `${path}/${k}`, level + 1, childLabel, comma);
|
|
8881
|
+
});
|
|
8882
|
+
lines.push({
|
|
8883
|
+
path: `${path}#close`,
|
|
8884
|
+
text: `${indent}${close}${trailing}`,
|
|
8885
|
+
foldable: false,
|
|
8886
|
+
expanded: false,
|
|
8887
|
+
level
|
|
8888
|
+
});
|
|
8889
|
+
};
|
|
8890
|
+
walk(value, "", 0, "", "");
|
|
8891
|
+
return lines;
|
|
8892
|
+
}
|
|
8893
|
+
/** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
|
|
8894
|
+
function reduceKey(state, key, value, rows) {
|
|
8895
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
8896
|
+
const cur = lines[state.cursor];
|
|
8897
|
+
let { cursor } = state;
|
|
8898
|
+
const expanded = new Set(state.expanded);
|
|
8899
|
+
switch (key) {
|
|
8900
|
+
case "up":
|
|
8901
|
+
cursor = Math.max(0, cursor - 1);
|
|
8902
|
+
break;
|
|
8903
|
+
case "down":
|
|
8904
|
+
cursor = Math.min(lines.length - 1, cursor + 1);
|
|
8905
|
+
break;
|
|
8906
|
+
case "right":
|
|
8907
|
+
if (cur?.foldable) expanded.add(cur.path);
|
|
8908
|
+
break;
|
|
8909
|
+
case "left":
|
|
8910
|
+
if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
|
|
8911
|
+
else if (cur) {
|
|
8912
|
+
const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
|
|
8913
|
+
if (parent >= 0) cursor = parent;
|
|
8914
|
+
}
|
|
8915
|
+
break;
|
|
8916
|
+
case "expandAll": return {
|
|
8917
|
+
cursor,
|
|
8918
|
+
top: state.top,
|
|
8919
|
+
expanded: expandToDepth(value, Infinity)
|
|
8920
|
+
};
|
|
8921
|
+
case "collapseAll": return {
|
|
8922
|
+
cursor: 0,
|
|
8923
|
+
top: 0,
|
|
8924
|
+
expanded: /* @__PURE__ */ new Set()
|
|
8925
|
+
};
|
|
8926
|
+
}
|
|
8927
|
+
const count = renderTree(value, { expanded }).length;
|
|
8928
|
+
cursor = Math.min(cursor, count - 1);
|
|
8929
|
+
let top = state.top;
|
|
8930
|
+
if (cursor < top) top = cursor;
|
|
8931
|
+
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
8932
|
+
return {
|
|
8933
|
+
cursor,
|
|
8934
|
+
top: Math.max(0, top),
|
|
8935
|
+
expanded
|
|
8936
|
+
};
|
|
8937
|
+
}
|
|
8938
|
+
const KEYMAP = {
|
|
8939
|
+
"\x1B[A": "up",
|
|
8940
|
+
k: "up",
|
|
8941
|
+
"\x1B[B": "down",
|
|
8942
|
+
j: "down",
|
|
8943
|
+
"\x1B[C": "right",
|
|
8944
|
+
l: "right",
|
|
8945
|
+
"\r": "right",
|
|
8946
|
+
" ": "right",
|
|
8947
|
+
"\x1B[D": "left",
|
|
8948
|
+
h: "left",
|
|
8949
|
+
E: "expandAll",
|
|
8950
|
+
C: "collapseAll",
|
|
8951
|
+
q: "quit",
|
|
8952
|
+
"\x1B": "quit",
|
|
8953
|
+
"": "quit"
|
|
8954
|
+
};
|
|
8955
|
+
function draw(out, value, state, rows) {
|
|
8956
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
8957
|
+
const view = lines.slice(state.top, state.top + rows).map((l, i) => {
|
|
8958
|
+
const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
|
|
8959
|
+
return state.top + i === state.cursor ? chalk.inverse(row) : row;
|
|
8960
|
+
});
|
|
8961
|
+
const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
|
|
8962
|
+
out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
|
|
8963
|
+
}
|
|
8964
|
+
async function runInteractive(value, depth) {
|
|
8965
|
+
const { stdin, stdout } = process;
|
|
8966
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
|
|
8967
|
+
const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
|
|
8968
|
+
let state = {
|
|
8969
|
+
cursor: 0,
|
|
8970
|
+
top: 0,
|
|
8971
|
+
expanded: expandToDepth(value, depth)
|
|
8972
|
+
};
|
|
8973
|
+
const wasRaw = stdin.isRaw;
|
|
8974
|
+
stdin.setRawMode(true);
|
|
8975
|
+
stdin.resume();
|
|
8976
|
+
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
8977
|
+
const redraw = () => draw(stdout, value, state, rows());
|
|
8978
|
+
stdout.on("resize", redraw);
|
|
8979
|
+
try {
|
|
8980
|
+
redraw();
|
|
8981
|
+
await new Promise((resolve) => {
|
|
8982
|
+
const onData = (buf) => {
|
|
8983
|
+
const key = KEYMAP[buf.toString()];
|
|
8984
|
+
if (key === "quit") {
|
|
8985
|
+
stdin.off("data", onData);
|
|
8986
|
+
resolve();
|
|
8987
|
+
return;
|
|
8988
|
+
}
|
|
8989
|
+
if (key) {
|
|
8990
|
+
state = reduceKey(state, key, value, rows());
|
|
8991
|
+
redraw();
|
|
8992
|
+
}
|
|
8993
|
+
};
|
|
8994
|
+
stdin.on("data", onData);
|
|
8995
|
+
});
|
|
8996
|
+
} finally {
|
|
8997
|
+
stdout.off("resize", redraw);
|
|
8998
|
+
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
8999
|
+
stdin.setRawMode(wasRaw);
|
|
9000
|
+
stdin.pause();
|
|
9001
|
+
}
|
|
9002
|
+
}
|
|
9003
|
+
/**
|
|
9004
|
+
* Print `value` as JSON. Non-TTY stdout always gets plain, valid
|
|
9005
|
+
* `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
|
|
9006
|
+
* coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
|
|
9007
|
+
*/
|
|
9008
|
+
async function viewJson(value, options = {}) {
|
|
9009
|
+
const depth = options.depth ?? Infinity;
|
|
9010
|
+
if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
|
|
9011
|
+
if (!process.stdout.isTTY) {
|
|
9012
|
+
console.log(JSON.stringify(value, null, 2));
|
|
9013
|
+
return;
|
|
9014
|
+
}
|
|
9015
|
+
const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
|
|
9016
|
+
console.log(lines.map((l) => l.text).join("\n"));
|
|
9017
|
+
}
|
|
9018
|
+
//#endregion
|
|
9019
|
+
//#region src/ui/open-path.ts
|
|
9020
|
+
/** Platform-appropriate command to open a path in the OS's file browser. */
|
|
9021
|
+
function openCommand(path) {
|
|
9022
|
+
if (process.platform === "darwin") return {
|
|
9023
|
+
cmd: "open",
|
|
9024
|
+
args: [path]
|
|
9025
|
+
};
|
|
9026
|
+
if (process.platform === "win32") return {
|
|
9027
|
+
cmd: "cmd",
|
|
9028
|
+
args: [
|
|
9029
|
+
"/c",
|
|
9030
|
+
"start",
|
|
9031
|
+
"",
|
|
9032
|
+
path
|
|
9033
|
+
]
|
|
9034
|
+
};
|
|
9035
|
+
return {
|
|
9036
|
+
cmd: "xdg-open",
|
|
9037
|
+
args: [path]
|
|
9038
|
+
};
|
|
9039
|
+
}
|
|
9040
|
+
/**
|
|
9041
|
+
* Open `path` in the OS's default file browser (Finder / Explorer /
|
|
9042
|
+
* whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
|
|
9043
|
+
* it outlives this process, and never throws when the opener binary isn't
|
|
9044
|
+
* on PATH (e.g. no `xdg-open` in a minimal container).
|
|
9045
|
+
*/
|
|
9046
|
+
function openPath(path) {
|
|
9047
|
+
const { cmd, args } = openCommand(path);
|
|
9048
|
+
const child = spawn(cmd, args, {
|
|
9049
|
+
stdio: "ignore",
|
|
9050
|
+
detached: true
|
|
9051
|
+
});
|
|
9052
|
+
child.on("error", () => {});
|
|
9053
|
+
child.unref();
|
|
9054
|
+
}
|
|
9055
|
+
//#endregion
|
|
9056
|
+
//#region src/update-notifier.ts
|
|
9057
|
+
const PACKAGE_NAME = "@theholocron/cli";
|
|
9058
|
+
const CACHE_TTL_MS = 864e5;
|
|
9059
|
+
const FETCH_TIMEOUT_MS = 3e3;
|
|
9060
|
+
function getCacheDir() {
|
|
9061
|
+
return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
|
|
9062
|
+
}
|
|
9063
|
+
function getCachePath() {
|
|
9064
|
+
return join(getCacheDir(), "update-check.json");
|
|
9065
|
+
}
|
|
9066
|
+
function readCache() {
|
|
9067
|
+
try {
|
|
9068
|
+
return JSON.parse(readFileSync(getCachePath(), "utf8"));
|
|
9069
|
+
} catch {
|
|
9070
|
+
return null;
|
|
9071
|
+
}
|
|
9072
|
+
}
|
|
9073
|
+
function writeCache(entry) {
|
|
9074
|
+
try {
|
|
9075
|
+
mkdirSync(getCacheDir(), { recursive: true });
|
|
9076
|
+
writeFileSync(getCachePath(), JSON.stringify(entry));
|
|
9077
|
+
} catch {}
|
|
9078
|
+
}
|
|
9079
|
+
async function fetchLatestVersion(channel) {
|
|
9080
|
+
try {
|
|
9081
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
9082
|
+
if (!res.ok) return null;
|
|
9083
|
+
const data = await res.json();
|
|
9084
|
+
return data["dist-tags"][channel] ?? data["dist-tags"]["latest"] ?? null;
|
|
7910
9085
|
} catch {
|
|
7911
9086
|
return null;
|
|
7912
9087
|
}
|
|
@@ -8021,6 +9196,19 @@ function resolveOrg(argv, config) {
|
|
|
8021
9196
|
return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
|
|
8022
9197
|
}
|
|
8023
9198
|
/**
|
|
9199
|
+
* Print the dim version line once per terminal session — keyed by the
|
|
9200
|
+
* parent shell's PID, since that's stable across repeated bare `holocron`
|
|
9201
|
+
* invocations in the same shell but changes in a new tab/window. A marker
|
|
9202
|
+
* file in the OS tmpdir tracks whether this shell has already seen it, so
|
|
9203
|
+
* re-running `holocron` doesn't repeat the line every time.
|
|
9204
|
+
*/
|
|
9205
|
+
function printVersionHeaderOnce() {
|
|
9206
|
+
const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
|
|
9207
|
+
if (existsSync(marker)) return;
|
|
9208
|
+
console.log(style.dim(`v${CLI_VERSION}`));
|
|
9209
|
+
writeFileSync(marker, "");
|
|
9210
|
+
}
|
|
9211
|
+
/**
|
|
8024
9212
|
* `buildCliLogger` options derived from a loaded `holocron.config` — the
|
|
8025
9213
|
* level, the Axiom dataset, and the resolved org for a keyring-backed
|
|
8026
9214
|
* Axiom token. Used by every handler that has already called `loadConfig`.
|
|
@@ -8050,23 +9238,31 @@ function tokenContext(rawTokens) {
|
|
|
8050
9238
|
return parseTokenArgs(rawTokens);
|
|
8051
9239
|
} catch (err) {
|
|
8052
9240
|
if (err instanceof TokenParseError) {
|
|
8053
|
-
|
|
9241
|
+
reportError(`--token: ${err.message}`);
|
|
8054
9242
|
process.exitCode = 1;
|
|
8055
9243
|
return null;
|
|
8056
9244
|
}
|
|
8057
9245
|
throw err;
|
|
8058
9246
|
}
|
|
8059
9247
|
}
|
|
9248
|
+
/** Layer 2: a bare group command (`holocron repo`, `holocron repo upgrade`, …) opens the menu at that level. */
|
|
9249
|
+
async function launchGroupMenu(parent, argv) {
|
|
9250
|
+
await launchMenu(COMMAND_REGISTRY, argv, `${parent} — choose a subcommand:`, `Run \`holocron ${parent} --help\` to see available ${parent} subcommands.`, parent.split(" "));
|
|
9251
|
+
}
|
|
8060
9252
|
init(CLI_VERSION);
|
|
8061
9253
|
const updateCheckPromise = checkForUpdates(CLI_VERSION);
|
|
8062
9254
|
let finishCommand = () => {};
|
|
8063
9255
|
/** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
|
|
8064
9256
|
let errorReported = false;
|
|
8065
9257
|
try {
|
|
8066
|
-
await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
|
|
9258
|
+
await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").version(CLI_VERSION).alias("version", "v").parserConfiguration({ "populate--": true }).option("dry-run", {
|
|
8067
9259
|
type: "boolean",
|
|
8068
9260
|
default: false,
|
|
8069
9261
|
describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
|
|
9262
|
+
}).option("skip-dry-run", {
|
|
9263
|
+
type: "boolean",
|
|
9264
|
+
default: false,
|
|
9265
|
+
describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
|
|
8070
9266
|
}).option("token", {
|
|
8071
9267
|
type: "string",
|
|
8072
9268
|
array: true,
|
|
@@ -8090,20 +9286,21 @@ try {
|
|
|
8090
9286
|
type: "boolean",
|
|
8091
9287
|
default: false,
|
|
8092
9288
|
describe: "Set the log level to error — suppress info and warn."
|
|
9289
|
+
}).option("log", {
|
|
9290
|
+
type: "string",
|
|
9291
|
+
choices: LOG_LEVELS,
|
|
9292
|
+
describe: "Show structured logger output at this level. Local/interactive runs are quiet by default (no console output regardless of level) — this opts back in."
|
|
8093
9293
|
}).middleware((argv) => {
|
|
8094
|
-
const name = argv._.slice(0,
|
|
9294
|
+
const name = argv._.slice(0, 3).join(" ") || "unknown";
|
|
8095
9295
|
printRunId = Boolean(argv.debug || argv.verbose);
|
|
8096
9296
|
buildCliLogger(argv, {
|
|
8097
9297
|
command: name,
|
|
8098
9298
|
org: argv.org
|
|
8099
9299
|
});
|
|
8100
9300
|
finishCommand = startCommand(name);
|
|
8101
|
-
}).command("
|
|
8102
|
-
console.log(`holocron ${CLI_VERSION}`);
|
|
8103
|
-
}).command("clone", "Clone all repos in a GitHub org as siblings under a single directory", (y) => y.option("org", {
|
|
9301
|
+
}).command("clone [target]", "Clone a single repo (org/repo) or every repo in an org (org)", (y) => y.positional("target", {
|
|
8104
9302
|
type: "string",
|
|
8105
|
-
|
|
8106
|
-
describe: "GitHub org to clone (e.g., theholocron)"
|
|
9303
|
+
describe: "org/repo to clone, or a bare org to clone every repo in it (confirms first)."
|
|
8107
9304
|
}).option("dir", {
|
|
8108
9305
|
type: "string",
|
|
8109
9306
|
describe: "Parent directory to clone into (default: ~/Code/<org>)"
|
|
@@ -8114,16 +9311,39 @@ try {
|
|
|
8114
9311
|
try {
|
|
8115
9312
|
token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
|
|
8116
9313
|
} catch (err) {
|
|
8117
|
-
|
|
9314
|
+
reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
|
|
8118
9315
|
process.exitCode = 1;
|
|
8119
9316
|
return;
|
|
8120
9317
|
}
|
|
8121
|
-
|
|
8122
|
-
|
|
9318
|
+
const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
|
|
9319
|
+
const { org, repo } = parseCloneTarget(targetInput);
|
|
9320
|
+
const report = await runClone({
|
|
9321
|
+
org,
|
|
9322
|
+
...repo ? { repo } : {},
|
|
8123
9323
|
token,
|
|
8124
9324
|
dryRun: argv.dryRun,
|
|
8125
|
-
...argv.dir ? { dir: argv.dir } : {}
|
|
8126
|
-
|
|
9325
|
+
...argv.dir ? { dir: argv.dir } : {},
|
|
9326
|
+
...!repo ? { confirmWholeOrg: async ({ total, alreadyCloned, toClone }) => {
|
|
9327
|
+
if (!process.stdin.isTTY) throw new NonInteractiveError(`clone: cloning every repo in "${org}" needs interactive confirmation — run this from a terminal, or pass a specific org/repo instead`);
|
|
9328
|
+
if (toClone === 0) return true;
|
|
9329
|
+
return confirm({
|
|
9330
|
+
message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
|
|
9331
|
+
default: false
|
|
9332
|
+
});
|
|
9333
|
+
} } : {},
|
|
9334
|
+
confirmOpen: async (openTarget, moreToCome) => {
|
|
9335
|
+
if (!process.stdin.isTTY) return;
|
|
9336
|
+
if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
|
|
9337
|
+
if (await confirm({
|
|
9338
|
+
message: `Open ${openTarget}?`,
|
|
9339
|
+
default: false
|
|
9340
|
+
})) openPath(openTarget);
|
|
9341
|
+
}
|
|
9342
|
+
});
|
|
9343
|
+
if (report.status === "fail") {
|
|
9344
|
+
if (report.message) reportError(`clone: ${report.message}`);
|
|
9345
|
+
process.exitCode = 1;
|
|
9346
|
+
} else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
|
|
8127
9347
|
}).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
|
|
8128
9348
|
type: "string",
|
|
8129
9349
|
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
@@ -8142,28 +9362,6 @@ try {
|
|
|
8142
9362
|
org: resolveOrg(argv, loaded.resolved)
|
|
8143
9363
|
}
|
|
8144
9364
|
})).summary.fail > 0) process.exitCode = 1;
|
|
8145
|
-
}).command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
|
|
8146
|
-
type: "string",
|
|
8147
|
-
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
8148
|
-
}).option("hooks", {
|
|
8149
|
-
type: "boolean",
|
|
8150
|
-
describe: "Install git hooks (.husky/pre-push → holocron ci). Default: on for protection:'strict'. --no-hooks to skip."
|
|
8151
|
-
}), async (argv) => {
|
|
8152
|
-
const tokens = tokenContext(argv.token);
|
|
8153
|
-
if (!tokens) return;
|
|
8154
|
-
const loaded = await loadConfig(argv.cwd);
|
|
8155
|
-
applyResolvedConfig(argv, loaded.resolved);
|
|
8156
|
-
if ((await runSetup({
|
|
8157
|
-
loaded,
|
|
8158
|
-
context: {
|
|
8159
|
-
repoRoot: argv.cwd,
|
|
8160
|
-
dryRun: argv.dryRun,
|
|
8161
|
-
...argv.repo ? { repo: argv.repo } : {},
|
|
8162
|
-
...tokens,
|
|
8163
|
-
org: resolveOrg(argv, loaded.resolved)
|
|
8164
|
-
},
|
|
8165
|
-
...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
|
|
8166
|
-
})).summary.fail > 0) process.exitCode = 1;
|
|
8167
9365
|
}).command("skills", "Manage agent skills from the @theholocron/skills registry", (y) => y.command("install", "Copy skills from @theholocron/skills into .agents/ with agent symlinks", () => {}, async (argv) => {
|
|
8168
9366
|
await runSkillsInstall({
|
|
8169
9367
|
loaded: await loadConfig(argv.cwd),
|
|
@@ -8196,331 +9394,214 @@ try {
|
|
|
8196
9394
|
...argv.name ? { name: argv.name } : {}
|
|
8197
9395
|
}).status === "fail") process.exitCode = 1;
|
|
8198
9396
|
}).command("$0", false, () => {}, async (argv) => {
|
|
8199
|
-
await
|
|
8200
|
-
}), () => {}).command("
|
|
9397
|
+
await launchGroupMenu("skills", argv);
|
|
9398
|
+
}), () => {}).command("run <task> [job] [passthrough..]", "Run a task locally (test, typecheck, lint, build, audit) — figures out turbo / the tool / the package manager. `run ci` runs every merge-gating check; `run lint commit-msg <file>` lints a commit message.", (y) => y.positional("task", {
|
|
8201
9399
|
type: "string",
|
|
8202
|
-
|
|
8203
|
-
|
|
9400
|
+
demandOption: true,
|
|
9401
|
+
describe: "Task name (test, typecheck, lint, build, audit, or `ci` for the merge-gating suite). See `holocron run --help`."
|
|
9402
|
+
}).positional("job", {
|
|
8204
9403
|
type: "string",
|
|
8205
|
-
describe: "
|
|
8206
|
-
}).
|
|
9404
|
+
describe: "Sub-job within the task (e.g. `holocron run audit performance`, `holocron run lint commit-msg <file>`). Omit to run every job."
|
|
9405
|
+
}).positional("passthrough", {
|
|
9406
|
+
type: "string",
|
|
9407
|
+
array: true,
|
|
9408
|
+
describe: "Args forwarded to the tool — put them after `--`."
|
|
9409
|
+
}).option("required", {
|
|
8207
9410
|
type: "boolean",
|
|
8208
9411
|
default: false,
|
|
8209
|
-
describe: "
|
|
8210
|
-
}).option("
|
|
9412
|
+
describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
|
|
9413
|
+
}).option("all", {
|
|
9414
|
+
type: "boolean",
|
|
9415
|
+
default: false,
|
|
9416
|
+
describe: "With `run ci`: run every `ci: true` task, not just the required ones."
|
|
9417
|
+
}).option("filter", {
|
|
8211
9418
|
type: "string",
|
|
8212
|
-
describe: "
|
|
8213
|
-
})
|
|
9419
|
+
describe: "turbo --filter=<pkg> passthrough (monorepo)."
|
|
9420
|
+
}), async (argv) => {
|
|
9421
|
+
const { logger } = buildCliLogger(argv, { command: argv.task === "ci" ? "run ci" : argv.task === "lint" && argv.job === "commit-msg" ? "run lint commit-msg" : "run" });
|
|
9422
|
+
const passthrough = [...argv.passthrough ?? [], ...argv["--"] ?? []];
|
|
9423
|
+
if (argv.task === "lint" && argv.job === "commit-msg") {
|
|
9424
|
+
const file = passthrough[0];
|
|
9425
|
+
if (!file) {
|
|
9426
|
+
reportError("run lint commit-msg: missing <file> (the commit message file git passes as $1).");
|
|
9427
|
+
process.exitCode = 1;
|
|
9428
|
+
return;
|
|
9429
|
+
}
|
|
9430
|
+
const result = await lintCommitMsgFile(file, argv.cwd);
|
|
9431
|
+
if (!result.valid) {
|
|
9432
|
+
for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
|
|
9433
|
+
process.exitCode = 1;
|
|
9434
|
+
}
|
|
9435
|
+
return;
|
|
9436
|
+
}
|
|
9437
|
+
if (argv.task === "ci") {
|
|
9438
|
+
const ciConfig = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
9439
|
+
if (createAstromech({
|
|
9440
|
+
cwd: argv.cwd,
|
|
9441
|
+
logger,
|
|
9442
|
+
config: ciConfig
|
|
9443
|
+
}).ci({
|
|
9444
|
+
dryRun: argv.dryRun,
|
|
9445
|
+
scope: argv.all ? "all" : "required",
|
|
9446
|
+
...argv.filter ? { filter: argv.filter } : {}
|
|
9447
|
+
}).status === "fail") process.exitCode = 1;
|
|
9448
|
+
return;
|
|
9449
|
+
}
|
|
9450
|
+
const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
9451
|
+
const report = createAstromech({
|
|
9452
|
+
cwd: argv.cwd,
|
|
9453
|
+
logger,
|
|
9454
|
+
config
|
|
9455
|
+
}).run(argv.task, {
|
|
9456
|
+
...argv.job !== void 0 ? { job: argv.job } : {},
|
|
9457
|
+
passthrough,
|
|
9458
|
+
dryRun: argv.dryRun,
|
|
9459
|
+
required: argv.required,
|
|
9460
|
+
...argv.filter ? { filter: argv.filter } : {}
|
|
9461
|
+
});
|
|
9462
|
+
if (report.status === "fail" || report.status === "unknown") process.exitCode = 1;
|
|
9463
|
+
}).command("config show", "Print the resolved holocron config", (y) => y.option("depth", {
|
|
9464
|
+
type: "number",
|
|
9465
|
+
describe: "Collapse objects/arrays nested deeper than N levels (TTY output only)"
|
|
9466
|
+
}).option("interactive", {
|
|
9467
|
+
type: "boolean",
|
|
9468
|
+
describe: "Browse the config in a foldable tree (default on a TTY; --no-interactive for static output)"
|
|
9469
|
+
}), async (argv) => {
|
|
9470
|
+
await viewJson((await loadConfig(argv.cwd)).resolved, {
|
|
9471
|
+
depth: argv.depth,
|
|
9472
|
+
interactive: argv.interactive
|
|
9473
|
+
});
|
|
9474
|
+
}).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set [provider] [value]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", { type: "string" }).positional("value", { type: "string" }), async (argv) => {
|
|
9475
|
+
const [provider] = await promptForPositionals(getEntry("auth set"), argv);
|
|
9476
|
+
if ((await runAuthSet({
|
|
9477
|
+
provider,
|
|
9478
|
+
...argv.value ? { positional: argv.value } : {},
|
|
9479
|
+
...argv.org ? { org: argv.org } : {}
|
|
9480
|
+
})).status === "fail") process.exitCode = 1;
|
|
9481
|
+
}).command("unset [provider]", "Remove a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
|
|
9482
|
+
const [provider] = await promptForPositionals(getEntry("auth unset"), argv);
|
|
9483
|
+
runAuthUnset({ provider });
|
|
9484
|
+
}).command("check [provider]", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
|
|
9485
|
+
const [provider] = await promptForPositionals(getEntry("auth check"), argv);
|
|
9486
|
+
if ((await runAuthCheck({
|
|
9487
|
+
provider,
|
|
9488
|
+
...argv.org ? { org: argv.org } : {}
|
|
9489
|
+
})).status === "fail") process.exitCode = 1;
|
|
9490
|
+
}).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
|
|
9491
|
+
await runAuthList();
|
|
9492
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9493
|
+
await launchGroupMenu("auth", argv);
|
|
9494
|
+
}), () => {}).command("$0", false, () => {}, async (argv) => {
|
|
9495
|
+
printVersionHeaderOnce();
|
|
9496
|
+
const runEntries = buildRunMenuEntries(await listRunnableTasks(argv));
|
|
9497
|
+
await launchMenu([...COMMAND_REGISTRY, ...runEntries], argv);
|
|
9498
|
+
}).command("repo", "Set up, sync and upgrade the current repo", (g) => g.command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
|
|
8214
9499
|
type: "string",
|
|
8215
|
-
|
|
8216
|
-
|
|
9500
|
+
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
9501
|
+
}).option("hooks", {
|
|
9502
|
+
type: "boolean",
|
|
9503
|
+
describe: "Install git hooks (.husky/pre-push → holocron run ci). Default: on for protection:'strict'. --no-hooks to skip."
|
|
8217
9504
|
}), async (argv) => {
|
|
8218
9505
|
const tokens = tokenContext(argv.token);
|
|
8219
9506
|
if (!tokens) return;
|
|
8220
|
-
const [name] = await promptForPositionals(getEntry("secret set"), argv);
|
|
8221
|
-
const scopeArg = argv.scope;
|
|
8222
|
-
const scope = parseScope(scopeArg);
|
|
8223
9507
|
const loaded = await loadConfig(argv.cwd);
|
|
8224
9508
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8225
|
-
if ((await
|
|
9509
|
+
if ((await runSetup({
|
|
8226
9510
|
loaded,
|
|
8227
9511
|
context: {
|
|
8228
9512
|
repoRoot: argv.cwd,
|
|
8229
9513
|
dryRun: argv.dryRun,
|
|
9514
|
+
...argv.repo ? { repo: argv.repo } : {},
|
|
8230
9515
|
...tokens,
|
|
8231
9516
|
org: resolveOrg(argv, loaded.resolved)
|
|
8232
9517
|
},
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
}).command("secrets sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
|
|
8240
|
-
type: "string",
|
|
8241
|
-
describe: "Vault environment id to read (1P Environment id, etc.)"
|
|
8242
|
-
}).option("project-id", {
|
|
8243
|
-
type: "string",
|
|
8244
|
-
describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
|
|
8245
|
-
}).option("target", {
|
|
8246
|
-
type: "array",
|
|
8247
|
-
default: ["production", "preview"],
|
|
8248
|
-
describe: "Deployment targets to sync to. Defaults to production + preview."
|
|
9518
|
+
...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
|
|
9519
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
9520
|
+
}).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.command("readme", "Sync the Installation + Usage block in README.md from package.json", (y) => y.option("dry-run", {
|
|
9521
|
+
type: "boolean",
|
|
9522
|
+
describe: "Print what would change without writing",
|
|
9523
|
+
default: false
|
|
8249
9524
|
}), async (argv) => {
|
|
8250
|
-
const tokens = tokenContext(argv.token);
|
|
8251
|
-
if (!tokens) return;
|
|
8252
|
-
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
8253
9525
|
const loaded = await loadConfig(argv.cwd);
|
|
8254
9526
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8255
|
-
|
|
9527
|
+
const report = await runSyncReadme({
|
|
8256
9528
|
loaded,
|
|
8257
9529
|
context: {
|
|
8258
9530
|
repoRoot: argv.cwd,
|
|
8259
|
-
dryRun: argv.dryRun
|
|
8260
|
-
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8267
|
-
}).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.positional("branch", {
|
|
8268
|
-
type: "string",
|
|
8269
|
-
describe: "Git branch to deploy"
|
|
8270
|
-
}).option("project-id", {
|
|
9531
|
+
dryRun: argv.dryRun
|
|
9532
|
+
}
|
|
9533
|
+
});
|
|
9534
|
+
if (report.status === "fail") {
|
|
9535
|
+
if (report.message) reportError(`repo sync readme: ${report.message}`);
|
|
9536
|
+
process.exitCode = 1;
|
|
9537
|
+
}
|
|
9538
|
+
}).positional("steps", {
|
|
8271
9539
|
type: "string",
|
|
8272
|
-
|
|
8273
|
-
describe: "
|
|
8274
|
-
}).option("
|
|
9540
|
+
array: true,
|
|
9541
|
+
describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
|
|
9542
|
+
}).option("repo", {
|
|
8275
9543
|
type: "string",
|
|
8276
|
-
|
|
8277
|
-
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
9544
|
+
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
8278
9545
|
}), async (argv) => {
|
|
8279
9546
|
const tokens = tokenContext(argv.token);
|
|
8280
9547
|
if (!tokens) return;
|
|
8281
|
-
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
8282
9548
|
const loaded = await loadConfig(argv.cwd);
|
|
8283
9549
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8284
|
-
if ((await
|
|
9550
|
+
if ((await runSync({
|
|
8285
9551
|
loaded,
|
|
8286
9552
|
context: {
|
|
8287
9553
|
repoRoot: argv.cwd,
|
|
8288
9554
|
dryRun: argv.dryRun,
|
|
9555
|
+
...argv.repo ? { repo: argv.repo } : {},
|
|
8289
9556
|
...tokens,
|
|
8290
9557
|
org: resolveOrg(argv, loaded.resolved)
|
|
8291
9558
|
},
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
})).status === "fail") process.exitCode = 1;
|
|
8296
|
-
}).command("cleanup-preview [pr]", "List and delete Cloudflare Pages preview deployments for a GitHub PR", (y) => y.positional("pr", {
|
|
9559
|
+
...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
|
|
9560
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
9561
|
+
}).command("upgrade", "Upgrade toolchain version pins across the repo", (y) => y.command("node [to]", "Scan the repo and update every Node.js version pin to a new major", (yy) => yy.positional("to", {
|
|
8297
9562
|
type: "number",
|
|
8298
|
-
describe: "
|
|
8299
|
-
}).option("
|
|
8300
|
-
type: "
|
|
8301
|
-
|
|
8302
|
-
describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
|
|
8303
|
-
}).option("repo", {
|
|
8304
|
-
type: "string",
|
|
8305
|
-
describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
|
|
8306
|
-
}), async (argv) => {
|
|
8307
|
-
const tokens = tokenContext(argv.token);
|
|
8308
|
-
if (!tokens) return;
|
|
8309
|
-
const [pr] = await promptForPositionals(getEntry("cleanup-preview"), argv);
|
|
8310
|
-
const loaded = await loadConfig(argv.cwd);
|
|
8311
|
-
applyResolvedConfig(argv, loaded.resolved);
|
|
8312
|
-
if ((await runCleanupPreview({
|
|
8313
|
-
loaded,
|
|
8314
|
-
context: {
|
|
8315
|
-
repoRoot: argv.cwd,
|
|
8316
|
-
dryRun: argv.dryRun,
|
|
8317
|
-
...tokens,
|
|
8318
|
-
org: resolveOrg(argv, loaded.resolved)
|
|
8319
|
-
},
|
|
8320
|
-
prNumber: Number(pr),
|
|
8321
|
-
project: argv.project,
|
|
8322
|
-
...argv.repo ? { repo: argv.repo } : {}
|
|
8323
|
-
})).status === "fail") process.exitCode = 1;
|
|
8324
|
-
}).command("bump-versions [new-version]", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (y) => y.positional("new-version", {
|
|
8325
|
-
type: "string",
|
|
8326
|
-
describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
|
|
8327
|
-
}), async (argv) => {
|
|
8328
|
-
const [newVersion] = await promptForPositionals(getEntry("bump-versions"), argv);
|
|
8329
|
-
if ((await runNpmBumpVersions({
|
|
8330
|
-
version: newVersion,
|
|
8331
|
-
cwd: argv.cwd,
|
|
8332
|
-
dryRun: argv.dryRun
|
|
8333
|
-
})).status === "fail") process.exitCode = 1;
|
|
8334
|
-
}).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
|
|
8335
|
-
type: "boolean",
|
|
8336
|
-
default: false,
|
|
8337
|
-
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."
|
|
8338
|
-
}).option("tag", {
|
|
8339
|
-
type: "string",
|
|
8340
|
-
describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
|
|
8341
|
-
}).option("otp", {
|
|
8342
|
-
type: "string",
|
|
8343
|
-
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
8344
|
-
}).option("sync-trust", {
|
|
8345
|
-
type: "array",
|
|
8346
|
-
describe: "Migrate npm Trusted Publisher config to a new workflow filename across every public package: --sync-trust <old-file> <new-file>. See issue #689 — npm requires fresh interactive 2FA per package; re-run after approving the printed authUrl if it stops early."
|
|
8347
|
-
}).option("repo", {
|
|
8348
|
-
type: "string",
|
|
8349
|
-
describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
|
|
8350
|
-
}).option("provenance", {
|
|
8351
|
-
type: "boolean",
|
|
8352
|
-
default: true,
|
|
8353
|
-
describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
|
|
8354
|
-
}).option("skip-already-published", {
|
|
8355
|
-
type: "boolean",
|
|
8356
|
-
default: false,
|
|
8357
|
-
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."
|
|
8358
|
-
}), async (argv) => {
|
|
8359
|
-
if (argv.syncTrust) {
|
|
8360
|
-
const [oldFile, newFile] = argv.syncTrust;
|
|
8361
|
-
if (!oldFile || !newFile) {
|
|
8362
|
-
getLogger().error("publish --sync-trust requires exactly two values: <old-file> <new-file>");
|
|
8363
|
-
process.exitCode = 1;
|
|
8364
|
-
return;
|
|
8365
|
-
}
|
|
8366
|
-
if ((await runSyncTrust({
|
|
8367
|
-
cwd: argv.cwd,
|
|
8368
|
-
oldFile,
|
|
8369
|
-
newFile,
|
|
8370
|
-
dryRun: argv.dryRun,
|
|
8371
|
-
...argv.repo ? { repo: argv.repo } : {}
|
|
8372
|
-
})).status === "fail") process.exitCode = 1;
|
|
8373
|
-
return;
|
|
8374
|
-
}
|
|
8375
|
-
if (argv.initial) {
|
|
8376
|
-
if ((await runPublish({
|
|
8377
|
-
cwd: argv.cwd,
|
|
8378
|
-
dryRun: argv.dryRun,
|
|
8379
|
-
...argv.tag ? { tag: argv.tag } : {},
|
|
8380
|
-
...argv.otp ? { otp: argv.otp } : {}
|
|
8381
|
-
})).status === "fail") process.exitCode = 1;
|
|
8382
|
-
return;
|
|
8383
|
-
}
|
|
8384
|
-
if ((await runSteadyPublish({
|
|
8385
|
-
cwd: argv.cwd,
|
|
8386
|
-
dryRun: argv.dryRun,
|
|
8387
|
-
provenance: argv.provenance,
|
|
8388
|
-
skipAlreadyPublished: argv.skipAlreadyPublished,
|
|
8389
|
-
...argv.tag ? { tag: argv.tag } : {},
|
|
8390
|
-
...argv.otp ? { otp: argv.otp } : {}
|
|
8391
|
-
})).status === "fail") process.exitCode = 1;
|
|
8392
|
-
}).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", {
|
|
8393
|
-
type: "string",
|
|
8394
|
-
array: true,
|
|
8395
|
-
describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
|
|
8396
|
-
}).option("repo", {
|
|
8397
|
-
type: "string",
|
|
8398
|
-
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
8399
|
-
}), async (argv) => {
|
|
8400
|
-
const tokens = tokenContext(argv.token);
|
|
8401
|
-
if (!tokens) return;
|
|
8402
|
-
const loaded = await loadConfig(argv.cwd);
|
|
8403
|
-
applyResolvedConfig(argv, loaded.resolved);
|
|
8404
|
-
if ((await runSync({
|
|
8405
|
-
loaded,
|
|
8406
|
-
context: {
|
|
8407
|
-
repoRoot: argv.cwd,
|
|
8408
|
-
dryRun: argv.dryRun,
|
|
8409
|
-
...argv.repo ? { repo: argv.repo } : {},
|
|
8410
|
-
...tokens,
|
|
8411
|
-
org: resolveOrg(argv, loaded.resolved)
|
|
8412
|
-
},
|
|
8413
|
-
...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
|
|
8414
|
-
})).summary.fail > 0) process.exitCode = 1;
|
|
8415
|
-
}).command("run <task> [job] [passthrough..]", "Run a task locally (test, typecheck, lint, build, audit) — figures out turbo / the tool / the package manager", (y) => y.positional("task", {
|
|
8416
|
-
type: "string",
|
|
8417
|
-
demandOption: true,
|
|
8418
|
-
describe: "Task name (test, typecheck, lint, build, audit). See `holocron run --help`."
|
|
8419
|
-
}).positional("job", {
|
|
8420
|
-
type: "string",
|
|
8421
|
-
describe: "Sub-job within the task (e.g. `holocron run audit performance`). Omit to run every job. Only `audit` has jobs today."
|
|
8422
|
-
}).positional("passthrough", {
|
|
8423
|
-
type: "string",
|
|
8424
|
-
array: true,
|
|
8425
|
-
describe: "Args forwarded to the tool — put them after `--`."
|
|
8426
|
-
}).option("required", {
|
|
8427
|
-
type: "boolean",
|
|
8428
|
-
default: false,
|
|
8429
|
-
describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
|
|
8430
|
-
}).option("filter", {
|
|
8431
|
-
type: "string",
|
|
8432
|
-
describe: "turbo --filter=<pkg> passthrough (monorepo)."
|
|
9563
|
+
describe: "Target Node.js major version (e.g., 22)"
|
|
9564
|
+
}).option("from", {
|
|
9565
|
+
type: "number",
|
|
9566
|
+
describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
|
|
8433
9567
|
}), async (argv) => {
|
|
8434
|
-
const
|
|
8435
|
-
|
|
8436
|
-
|
|
9568
|
+
const [to] = await promptForPositionals(getEntry("repo upgrade node"), argv);
|
|
9569
|
+
let extra = [];
|
|
9570
|
+
try {
|
|
9571
|
+
const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
|
|
9572
|
+
const extraRaw = JSON.parse(raw).upgrade?.node?.extra;
|
|
9573
|
+
if (Array.isArray(extraRaw)) extra = extraRaw;
|
|
9574
|
+
} catch {}
|
|
9575
|
+
const report = await runUpgradeNode({
|
|
9576
|
+
to: Number(to),
|
|
9577
|
+
...argv.from != null ? { from: argv.from } : {},
|
|
8437
9578
|
cwd: argv.cwd,
|
|
8438
|
-
logger,
|
|
8439
|
-
config
|
|
8440
|
-
}).run(argv.task, {
|
|
8441
|
-
...argv.job !== void 0 ? { job: argv.job } : {},
|
|
8442
|
-
passthrough: [...argv.passthrough ?? [], ...argv["--"] ?? []],
|
|
8443
9579
|
dryRun: argv.dryRun,
|
|
8444
|
-
|
|
8445
|
-
...argv.filter ? { filter: argv.filter } : {}
|
|
9580
|
+
extra
|
|
8446
9581
|
});
|
|
8447
|
-
if (report.status === "fail"
|
|
8448
|
-
|
|
9582
|
+
if (report.status === "fail") {
|
|
9583
|
+
if (report.message) reportError(`upgrade node: ${report.message}`);
|
|
9584
|
+
process.exitCode = 1;
|
|
9585
|
+
}
|
|
9586
|
+
}).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
|
|
8449
9587
|
type: "boolean",
|
|
8450
9588
|
default: false,
|
|
8451
|
-
describe: "
|
|
8452
|
-
}).option("filter", {
|
|
8453
|
-
type: "string",
|
|
8454
|
-
describe: "turbo --filter=<pkg> passthrough (monorepo)."
|
|
9589
|
+
describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
|
|
8455
9590
|
}), async (argv) => {
|
|
8456
|
-
const
|
|
8457
|
-
const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
8458
|
-
if (createAstromech({
|
|
9591
|
+
const report = await runUpgradeDeps({
|
|
8459
9592
|
cwd: argv.cwd,
|
|
8460
|
-
logger,
|
|
8461
|
-
config
|
|
8462
|
-
}).ci({
|
|
8463
9593
|
dryRun: argv.dryRun,
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
type: "string",
|
|
8469
|
-
default: "theholocron/.github",
|
|
8470
|
-
describe: "Target org/repo (default: theholocron/.github)"
|
|
8471
|
-
}).option("branch", {
|
|
8472
|
-
type: "string",
|
|
8473
|
-
describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
|
|
8474
|
-
}).option("pr", {
|
|
8475
|
-
type: "boolean",
|
|
8476
|
-
default: false,
|
|
8477
|
-
describe: "Open a PR after pushing to --branch (no-op without --branch)"
|
|
8478
|
-
}).option("message", {
|
|
8479
|
-
type: "string",
|
|
8480
|
-
describe: "Commit message (default: chore: sync from theholocron/holocron)"
|
|
8481
|
-
}).option("output-dir", {
|
|
8482
|
-
type: "string",
|
|
8483
|
-
describe: "Write generated files to this local directory instead of pushing (for validation)"
|
|
8484
|
-
}), async (argv) => {
|
|
8485
|
-
const outputDir = argv["output-dir"];
|
|
8486
|
-
const parsed = tokenContext(argv.token);
|
|
8487
|
-
if (!parsed) return;
|
|
8488
|
-
let token;
|
|
8489
|
-
if (outputDir) token = "no-token-needed";
|
|
8490
|
-
else try {
|
|
8491
|
-
token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
|
|
8492
|
-
} catch (err) {
|
|
8493
|
-
getLogger().error(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
|
|
9594
|
+
pinsOnly: argv.pinsOnly
|
|
9595
|
+
});
|
|
9596
|
+
if (report.status === "fail") {
|
|
9597
|
+
if (report.message) reportError(`upgrade deps: ${report.message}`);
|
|
8494
9598
|
process.exitCode = 1;
|
|
8495
|
-
return;
|
|
8496
9599
|
}
|
|
8497
|
-
|
|
8498
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
8502
|
-
...argv.pr ? { createPr: true } : {},
|
|
8503
|
-
...argv.message ? { message: argv.message } : {},
|
|
8504
|
-
...outputDir ? { outputDir } : {}
|
|
8505
|
-
})).status === "fail") process.exitCode = 1;
|
|
8506
|
-
}).command("sync-readme", "Sync the Installation + Usage block in README.md from package.json", (y) => y.option("dry-run", {
|
|
8507
|
-
type: "boolean",
|
|
8508
|
-
describe: "Print what would change without writing",
|
|
8509
|
-
default: false
|
|
8510
|
-
}), async (argv) => {
|
|
8511
|
-
const loaded = await loadConfig(argv.cwd);
|
|
8512
|
-
applyResolvedConfig(argv, loaded.resolved);
|
|
8513
|
-
if ((await runSyncReadme({
|
|
8514
|
-
loaded,
|
|
8515
|
-
context: {
|
|
8516
|
-
repoRoot: argv.cwd,
|
|
8517
|
-
dryRun: argv.dryRun
|
|
8518
|
-
}
|
|
8519
|
-
})).status === "fail") process.exitCode = 1;
|
|
8520
|
-
}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
|
|
8521
|
-
const loaded = await loadConfig(argv.cwd);
|
|
8522
|
-
console.log(JSON.stringify(loaded.resolved, null, 2));
|
|
8523
|
-
}).command("new [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
|
|
9600
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9601
|
+
await launchGroupMenu("repo upgrade", argv);
|
|
9602
|
+
}), () => {}).command("$0", false, () => {}, async (argv) => {
|
|
9603
|
+
await launchGroupMenu("repo", argv);
|
|
9604
|
+
}), () => {}).command("new", "Scaffold a new repo or plugin", (g) => g.command("repo [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
|
|
8524
9605
|
type: "string",
|
|
8525
9606
|
describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
|
|
8526
9607
|
}).positional("name", {
|
|
@@ -8569,7 +9650,7 @@ try {
|
|
|
8569
9650
|
}).option("verify", {
|
|
8570
9651
|
type: "boolean",
|
|
8571
9652
|
default: true,
|
|
8572
|
-
describe: "Run pnpm install + holocron setup after bootstrapping (--no-verify skips)"
|
|
9653
|
+
describe: "Run pnpm install + holocron repo setup after bootstrapping (--no-verify skips)"
|
|
8573
9654
|
}), async (argv) => {
|
|
8574
9655
|
try {
|
|
8575
9656
|
const tokens = tokenContext(argv.token);
|
|
@@ -8745,12 +9826,12 @@ try {
|
|
|
8745
9826
|
}) === "yes";
|
|
8746
9827
|
if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
|
|
8747
9828
|
if (!type) {
|
|
8748
|
-
|
|
9829
|
+
reportError("new: template type is required");
|
|
8749
9830
|
process.exitCode = 1;
|
|
8750
9831
|
return;
|
|
8751
9832
|
}
|
|
8752
9833
|
if (!name) {
|
|
8753
|
-
|
|
9834
|
+
reportError("new: repo name is required");
|
|
8754
9835
|
process.exitCode = 1;
|
|
8755
9836
|
return;
|
|
8756
9837
|
}
|
|
@@ -8779,13 +9860,13 @@ try {
|
|
|
8779
9860
|
})).status === "fail") process.exitCode = 1;
|
|
8780
9861
|
} catch (err) {
|
|
8781
9862
|
if (err instanceof NewError) {
|
|
8782
|
-
|
|
9863
|
+
reportError(`new: ${err.message}`);
|
|
8783
9864
|
process.exitCode = 1;
|
|
8784
9865
|
return;
|
|
8785
9866
|
}
|
|
8786
9867
|
throw err;
|
|
8787
9868
|
}
|
|
8788
|
-
}).command("plugin
|
|
9869
|
+
}).command("plugin [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
|
|
8789
9870
|
type: "string",
|
|
8790
9871
|
describe: "Package slug (kebab-case)"
|
|
8791
9872
|
}).positional("vendor", {
|
|
@@ -8809,7 +9890,7 @@ try {
|
|
|
8809
9890
|
describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
|
|
8810
9891
|
}), async (argv) => {
|
|
8811
9892
|
try {
|
|
8812
|
-
const [slug, vendor] = await promptForPositionals(getEntry("plugin
|
|
9893
|
+
const [slug, vendor] = await promptForPositionals(getEntry("new plugin"), argv);
|
|
8813
9894
|
const { capability, vendorEnv, baseUrl } = await resolvePluginCreateInputs({
|
|
8814
9895
|
capability: argv.capability,
|
|
8815
9896
|
vendorEnv: argv.vendorEnv,
|
|
@@ -8838,92 +9919,315 @@ try {
|
|
|
8838
9919
|
}).status === "fail") process.exitCode = 1;
|
|
8839
9920
|
} catch (err) {
|
|
8840
9921
|
if (err instanceof PluginCreateError) {
|
|
8841
|
-
|
|
9922
|
+
reportError(`plugin create: ${err.message}`);
|
|
8842
9923
|
process.exitCode = 1;
|
|
8843
9924
|
return;
|
|
8844
9925
|
}
|
|
8845
9926
|
throw err;
|
|
8846
9927
|
}
|
|
8847
|
-
}).command("
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
9928
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9929
|
+
await launchGroupMenu("new", argv);
|
|
9930
|
+
}), () => {}).command("secrets", "Set secrets and sync vault environments", (g) => g.command("set [name] [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
|
|
9931
|
+
type: "string",
|
|
9932
|
+
describe: "Secret name (e.g., NPM_TOKEN)"
|
|
9933
|
+
}).positional("value", {
|
|
9934
|
+
type: "string",
|
|
9935
|
+
describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
|
|
9936
|
+
}).option("from-stdin", {
|
|
9937
|
+
type: "boolean",
|
|
9938
|
+
default: false,
|
|
9939
|
+
describe: "Read the secret value from stdin"
|
|
9940
|
+
}).option("from-env", {
|
|
9941
|
+
type: "string",
|
|
9942
|
+
describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
|
|
9943
|
+
}).option("scope", {
|
|
9944
|
+
type: "string",
|
|
9945
|
+
default: "repo",
|
|
9946
|
+
describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
|
|
8853
9947
|
}), async (argv) => {
|
|
8854
|
-
const
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
9948
|
+
const tokens = tokenContext(argv.token);
|
|
9949
|
+
if (!tokens) return;
|
|
9950
|
+
const [name] = await promptForPositionals(getEntry("secrets set"), argv);
|
|
9951
|
+
const scopeArg = argv.scope;
|
|
9952
|
+
const scope = parseScope(scopeArg);
|
|
9953
|
+
const loaded = await loadConfig(argv.cwd);
|
|
9954
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
9955
|
+
if ((await runSecretSet({
|
|
9956
|
+
loaded,
|
|
9957
|
+
context: {
|
|
9958
|
+
repoRoot: argv.cwd,
|
|
9959
|
+
dryRun: argv.dryRun,
|
|
9960
|
+
...tokens,
|
|
9961
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
9962
|
+
},
|
|
9963
|
+
name,
|
|
9964
|
+
...argv.value ? { value: argv.value } : {},
|
|
9965
|
+
...argv.fromStdin ? { fromStdin: true } : {},
|
|
9966
|
+
...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
|
|
9967
|
+
scope
|
|
9968
|
+
})).status === "fail") process.exitCode = 1;
|
|
9969
|
+
}).command("sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
|
|
9970
|
+
type: "string",
|
|
9971
|
+
describe: "Vault environment id to read (1P Environment id, etc.)"
|
|
9972
|
+
}).option("project-id", {
|
|
9973
|
+
type: "string",
|
|
9974
|
+
describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
|
|
9975
|
+
}).option("target", {
|
|
9976
|
+
type: "array",
|
|
9977
|
+
default: ["production", "preview"],
|
|
9978
|
+
describe: "Deployment targets to sync to. Defaults to production + preview."
|
|
9979
|
+
}).option("github-secret", {
|
|
9980
|
+
type: "array",
|
|
9981
|
+
describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
|
|
9982
|
+
}).option("github-secret-scope", {
|
|
9983
|
+
type: "string",
|
|
9984
|
+
default: "repo",
|
|
9985
|
+
describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
|
|
9986
|
+
}), async (argv) => {
|
|
9987
|
+
const tokens = tokenContext(argv.token);
|
|
9988
|
+
if (!tokens) return;
|
|
9989
|
+
const githubSecretScope = parseScope(argv.githubSecretScope);
|
|
9990
|
+
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
9991
|
+
const loaded = await loadConfig(argv.cwd);
|
|
9992
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
9993
|
+
if ((await runSecretsSync({
|
|
9994
|
+
loaded,
|
|
9995
|
+
context: {
|
|
9996
|
+
repoRoot: argv.cwd,
|
|
9997
|
+
dryRun: argv.dryRun,
|
|
9998
|
+
...tokens,
|
|
9999
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10000
|
+
},
|
|
10001
|
+
environmentId,
|
|
10002
|
+
...argv.projectId ? { projectId: argv.projectId } : {},
|
|
10003
|
+
...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
|
|
10004
|
+
githubSecretScope,
|
|
10005
|
+
targets: argv.target
|
|
10006
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
10007
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
10008
|
+
await launchGroupMenu("secrets", argv);
|
|
10009
|
+
}), () => {}).command("sync", "Sync generated files to other repos", (g) => g.command("github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
|
|
10010
|
+
type: "string",
|
|
10011
|
+
default: "theholocron/.github",
|
|
10012
|
+
describe: "Target org/repo (default: theholocron/.github)"
|
|
10013
|
+
}).option("branch", {
|
|
10014
|
+
type: "string",
|
|
10015
|
+
describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
|
|
10016
|
+
}).option("pr", {
|
|
10017
|
+
type: "boolean",
|
|
10018
|
+
default: false,
|
|
10019
|
+
describe: "Open a PR after pushing to --branch (no-op without --branch)"
|
|
10020
|
+
}).option("message", {
|
|
10021
|
+
type: "string",
|
|
10022
|
+
describe: "Commit message (default: chore: sync from theholocron/holocron)"
|
|
10023
|
+
}).option("output-dir", {
|
|
10024
|
+
type: "string",
|
|
10025
|
+
describe: "Write generated files to this local directory instead of pushing (for validation)"
|
|
10026
|
+
}), async (argv) => {
|
|
10027
|
+
const outputDir = argv["output-dir"];
|
|
10028
|
+
const parsed = tokenContext(argv.token);
|
|
10029
|
+
if (!parsed) return;
|
|
10030
|
+
let token;
|
|
10031
|
+
if (outputDir) token = "no-token-needed";
|
|
10032
|
+
else try {
|
|
10033
|
+
token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
|
|
10034
|
+
} catch (err) {
|
|
10035
|
+
reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
|
|
8870
10036
|
process.exitCode = 1;
|
|
10037
|
+
return;
|
|
8871
10038
|
}
|
|
8872
|
-
|
|
10039
|
+
if ((await runSyncGithub({
|
|
10040
|
+
token,
|
|
10041
|
+
repo: argv.repo,
|
|
10042
|
+
dryRun: argv.dryRun,
|
|
10043
|
+
...argv.branch ? { branch: argv.branch } : {},
|
|
10044
|
+
...argv.pr ? { createPr: true } : {},
|
|
10045
|
+
...argv.message ? { message: argv.message } : {},
|
|
10046
|
+
...outputDir ? { outputDir } : {}
|
|
10047
|
+
})).status === "fail") process.exitCode = 1;
|
|
10048
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
10049
|
+
await launchGroupMenu("sync", argv);
|
|
10050
|
+
}), () => {}).command("package", "Version and publish workspace packages", (g) => g.command("bump-versions [new-version]", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (y) => y.positional("new-version", {
|
|
10051
|
+
type: "string",
|
|
10052
|
+
describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
|
|
10053
|
+
}), async (argv) => {
|
|
10054
|
+
const [newVersion] = await promptForPositionals(getEntry("package bump-versions"), argv);
|
|
10055
|
+
if ((await runNpmBumpVersions({
|
|
10056
|
+
version: newVersion,
|
|
10057
|
+
cwd: argv.cwd,
|
|
10058
|
+
dryRun: argv.dryRun
|
|
10059
|
+
})).status === "fail") process.exitCode = 1;
|
|
10060
|
+
}).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
|
|
8873
10061
|
type: "boolean",
|
|
8874
10062
|
default: false,
|
|
8875
|
-
describe: "
|
|
10063
|
+
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."
|
|
10064
|
+
}).option("tag", {
|
|
10065
|
+
type: "string",
|
|
10066
|
+
describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
|
|
10067
|
+
}).option("otp", {
|
|
10068
|
+
type: "string",
|
|
10069
|
+
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
10070
|
+
}).option("sync-trust", {
|
|
10071
|
+
type: "array",
|
|
10072
|
+
describe: "Migrate npm Trusted Publisher config to a new workflow filename across every public package: --sync-trust <old-file> <new-file>. See issue #689 — npm requires fresh interactive 2FA per package; re-run after approving the printed authUrl if it stops early."
|
|
10073
|
+
}).option("repo", {
|
|
10074
|
+
type: "string",
|
|
10075
|
+
describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
|
|
10076
|
+
}).option("provenance", {
|
|
10077
|
+
type: "boolean",
|
|
10078
|
+
default: true,
|
|
10079
|
+
describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
|
|
10080
|
+
}).option("skip-already-published", {
|
|
10081
|
+
type: "boolean",
|
|
10082
|
+
default: false,
|
|
10083
|
+
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."
|
|
8876
10084
|
}), async (argv) => {
|
|
8877
|
-
|
|
10085
|
+
if (argv.syncTrust) {
|
|
10086
|
+
const [oldFile, newFile] = argv.syncTrust;
|
|
10087
|
+
if (!oldFile || !newFile) {
|
|
10088
|
+
reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
|
|
10089
|
+
process.exitCode = 1;
|
|
10090
|
+
return;
|
|
10091
|
+
}
|
|
10092
|
+
if ((await runSyncTrust({
|
|
10093
|
+
cwd: argv.cwd,
|
|
10094
|
+
oldFile,
|
|
10095
|
+
newFile,
|
|
10096
|
+
dryRun: argv.dryRun,
|
|
10097
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
10098
|
+
})).status === "fail") process.exitCode = 1;
|
|
10099
|
+
return;
|
|
10100
|
+
}
|
|
10101
|
+
if (argv.initial) {
|
|
10102
|
+
if ((await runPublish({
|
|
10103
|
+
cwd: argv.cwd,
|
|
10104
|
+
dryRun: argv.dryRun,
|
|
10105
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
10106
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
10107
|
+
})).status === "fail") process.exitCode = 1;
|
|
10108
|
+
return;
|
|
10109
|
+
}
|
|
10110
|
+
if ((await runSteadyPublish({
|
|
8878
10111
|
cwd: argv.cwd,
|
|
8879
10112
|
dryRun: argv.dryRun,
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
}
|
|
10113
|
+
provenance: argv.provenance,
|
|
10114
|
+
skipAlreadyPublished: argv.skipAlreadyPublished,
|
|
10115
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
10116
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
10117
|
+
})).status === "fail") process.exitCode = 1;
|
|
8886
10118
|
}).command("$0", false, () => {}, async (argv) => {
|
|
8887
|
-
await
|
|
8888
|
-
}), () => {}).command("
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
10119
|
+
await launchGroupMenu("package", argv);
|
|
10120
|
+
}), () => {}).command("deploy [branch]", "Trigger a deployment via the configured `deployment` capability", (y) => y.command("cleanup-preview [pr]", "List and delete Cloudflare Pages preview deployments for a GitHub PR", (y) => y.positional("pr", {
|
|
10121
|
+
type: "number",
|
|
10122
|
+
describe: "PR number to clean up"
|
|
10123
|
+
}).option("project", {
|
|
10124
|
+
type: "string",
|
|
10125
|
+
demandOption: true,
|
|
10126
|
+
describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
|
|
10127
|
+
}).option("repo", {
|
|
10128
|
+
type: "string",
|
|
10129
|
+
describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
|
|
10130
|
+
}), async (argv) => {
|
|
10131
|
+
const tokens = tokenContext(argv.token);
|
|
10132
|
+
if (!tokens) return;
|
|
10133
|
+
const [pr] = await promptForPositionals(getEntry("deploy cleanup-preview"), argv);
|
|
10134
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10135
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10136
|
+
if ((await runCleanupPreview({
|
|
10137
|
+
loaded,
|
|
10138
|
+
context: {
|
|
10139
|
+
repoRoot: argv.cwd,
|
|
10140
|
+
dryRun: argv.dryRun,
|
|
10141
|
+
...tokens,
|
|
10142
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10143
|
+
},
|
|
10144
|
+
prNumber: Number(pr),
|
|
10145
|
+
project: argv.project,
|
|
10146
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
8894
10147
|
})).status === "fail") process.exitCode = 1;
|
|
8895
|
-
}).command("
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
|
|
10148
|
+
}).command("on-release", "Deploy every workspace package whose delivery.deploy task is `on: release` — run by the delivery.publish workflow's deploy job", (y) => y.option("channel", {
|
|
10149
|
+
type: "string",
|
|
10150
|
+
default: "",
|
|
10151
|
+
describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
|
|
10152
|
+
}).option("from", {
|
|
10153
|
+
type: "string",
|
|
10154
|
+
describe: "The previous release's commit, to diff against. Omit when there is none."
|
|
10155
|
+
}).option("to", {
|
|
10156
|
+
type: "string",
|
|
10157
|
+
describe: "The release commit. Default HEAD."
|
|
10158
|
+
}), async (argv) => {
|
|
10159
|
+
if ((await deployOnRelease({
|
|
10160
|
+
cwd: argv.cwd,
|
|
10161
|
+
channel: argv.channel,
|
|
10162
|
+
...argv.from ? { from: argv.from } : {},
|
|
10163
|
+
...argv.to ? { to: argv.to } : {},
|
|
10164
|
+
dryRun: argv.dryRun,
|
|
10165
|
+
print: (line) => console.log(line)
|
|
10166
|
+
})).status === "fail") process.exitCode = 1;
|
|
10167
|
+
}).positional("branch", {
|
|
10168
|
+
type: "string",
|
|
10169
|
+
describe: "Git branch to deploy (omit when using --files)"
|
|
10170
|
+
}).option("project-id", {
|
|
10171
|
+
type: "string",
|
|
10172
|
+
describe: "Deployment project id (required to trigger a deployment, e.g. Vercel prj_*)"
|
|
10173
|
+
}).option("target", {
|
|
10174
|
+
type: "string",
|
|
10175
|
+
choices: ["production", "staging"],
|
|
10176
|
+
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
10177
|
+
}).option("files", {
|
|
10178
|
+
type: "string",
|
|
10179
|
+
describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
|
|
10180
|
+
}).conflicts("files", "branch"), async (argv) => {
|
|
10181
|
+
if (!argv.projectId) {
|
|
10182
|
+
reportError("deploy: missing required argument --project-id");
|
|
10183
|
+
process.exitCode = 1;
|
|
10184
|
+
return;
|
|
10185
|
+
}
|
|
10186
|
+
const tokens = tokenContext(argv.token);
|
|
10187
|
+
if (!tokens) return;
|
|
10188
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10189
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10190
|
+
const context = {
|
|
10191
|
+
repoRoot: argv.cwd,
|
|
10192
|
+
dryRun: argv.dryRun,
|
|
10193
|
+
...tokens,
|
|
10194
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10195
|
+
};
|
|
10196
|
+
if (argv.files) {
|
|
10197
|
+
if ((await runDeployFromFiles({
|
|
10198
|
+
loaded,
|
|
10199
|
+
context,
|
|
10200
|
+
projectId: argv.projectId,
|
|
10201
|
+
dir: argv.files,
|
|
10202
|
+
...argv.target ? { target: argv.target } : {}
|
|
10203
|
+
})).status === "fail") process.exitCode = 1;
|
|
10204
|
+
return;
|
|
10205
|
+
}
|
|
10206
|
+
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
10207
|
+
if ((await runDeploy({
|
|
10208
|
+
loaded,
|
|
10209
|
+
context,
|
|
10210
|
+
projectId: argv.projectId,
|
|
10211
|
+
branch,
|
|
10212
|
+
...argv.target ? { target: argv.target } : {}
|
|
8903
10213
|
})).status === "fail") process.exitCode = 1;
|
|
8904
|
-
}).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
|
|
8905
|
-
await runAuthList();
|
|
8906
|
-
}).command("$0", false, () => {}, async (argv) => {
|
|
8907
|
-
await launchMenu(COMMAND_REGISTRY.filter((e) => e.group === "auth"), argv, "auth — choose a subcommand:", "Run `holocron auth --help` to see available auth subcommands.");
|
|
8908
|
-
}), () => {}).command("$0", false, () => {}, async (argv) => {
|
|
8909
|
-
await launchMenu(COMMAND_REGISTRY.filter((e) => !e.group), argv);
|
|
8910
10214
|
}).strict().help().epilogue(`Execution contexts:
|
|
8911
10215
|
global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
|
|
8912
10216
|
if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
|
|
8913
10217
|
captureException(err);
|
|
8914
|
-
|
|
10218
|
+
reportError(err.message);
|
|
8915
10219
|
errorReported = true;
|
|
8916
10220
|
process.exitCode = 1;
|
|
8917
10221
|
return;
|
|
8918
10222
|
}
|
|
8919
10223
|
if (err) throw err;
|
|
8920
|
-
|
|
10224
|
+
reportError(msg);
|
|
8921
10225
|
errorReported = true;
|
|
8922
10226
|
process.exitCode = 1;
|
|
8923
10227
|
}).parse();
|
|
8924
10228
|
} catch (err) {
|
|
8925
10229
|
captureException(err);
|
|
8926
|
-
if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name))
|
|
10230
|
+
if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
|
|
8927
10231
|
if (!process.exitCode) process.exitCode = 1;
|
|
8928
10232
|
}
|
|
8929
10233
|
finishCommand(!process.exitCode);
|