@theholocron/cli 5.0.0-alpha.13 → 5.0.0-alpha.134
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 +2218 -889
- 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
|
|
@@ -3058,7 +3479,7 @@ async function runSyncTrust(input) {
|
|
|
3058
3479
|
packages: packageNames.length
|
|
3059
3480
|
}, "sync-trust: start");
|
|
3060
3481
|
if (packageNames.length === 0) {
|
|
3061
|
-
const message = "nothing to sync (no packages
|
|
3482
|
+
const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
3062
3483
|
print(` ✗ ${message}`);
|
|
3063
3484
|
return {
|
|
3064
3485
|
status: "fail",
|
|
@@ -3215,7 +3636,7 @@ async function runPublish(input = {}) {
|
|
|
3215
3636
|
const exec = input.exec ?? defaultExec$1;
|
|
3216
3637
|
const login = input.login ?? defaultLogin;
|
|
3217
3638
|
const publishArgs = [
|
|
3218
|
-
...
|
|
3639
|
+
...workspaceFilterArgs(cwd),
|
|
3219
3640
|
"publish",
|
|
3220
3641
|
"--access",
|
|
3221
3642
|
"public",
|
|
@@ -3266,7 +3687,7 @@ async function runPublish(input = {}) {
|
|
|
3266
3687
|
const packageNames = input.packages ?? discoverPublicPackages(cwd);
|
|
3267
3688
|
const repoName = input.repoName ?? await resolveRepoName(cwd, exec);
|
|
3268
3689
|
if (packageNames.length === 0) {
|
|
3269
|
-
const message = "nothing to publish (no packages
|
|
3690
|
+
const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
3270
3691
|
print(` ✗ ${message}`);
|
|
3271
3692
|
logger.warn({ reason: message }, "publish --initial: done");
|
|
3272
3693
|
return {
|
|
@@ -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
|
|
@@ -3336,7 +3757,7 @@ async function runPublish(input = {}) {
|
|
|
3336
3757
|
* Deliberately does not do the `--initial` bootstrap dance (npm
|
|
3337
3758
|
* login-if-needed, Trusted Publisher next-steps printout) — a steady-state
|
|
3338
3759
|
* publish runs in CI, authenticated via OIDC automatically, no interactive
|
|
3339
|
-
* step involved. Reuses `
|
|
3760
|
+
* step involved. Reuses `workspaceFilterArgs()`'s monorepo-vs-single-package
|
|
3340
3761
|
* detection rather than duplicating it.
|
|
3341
3762
|
*/
|
|
3342
3763
|
async function runSteadyPublish(input = {}) {
|
|
@@ -3365,7 +3786,7 @@ async function runSteadyPublish(input = {}) {
|
|
|
3365
3786
|
if (otp) print(` otp: <${otp.length} chars>`);
|
|
3366
3787
|
print("");
|
|
3367
3788
|
if (entries.length === 0) {
|
|
3368
|
-
const message = "nothing to publish (no packages
|
|
3789
|
+
const message = "nothing to publish (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
3369
3790
|
print(` ✗ ${message}`);
|
|
3370
3791
|
logger.warn({ reason: message }, "publish: done");
|
|
3371
3792
|
return {
|
|
@@ -3388,7 +3809,7 @@ async function runSteadyPublish(input = {}) {
|
|
|
3388
3809
|
if (skipAlreadyPublished) for (const entry of entries) print(` … would check ${entry.name}@${entry.version}, publish if not already on npm`);
|
|
3389
3810
|
else {
|
|
3390
3811
|
const args = [
|
|
3391
|
-
...
|
|
3812
|
+
...workspaceFilterArgs(cwd),
|
|
3392
3813
|
"publish",
|
|
3393
3814
|
...baseFlags,
|
|
3394
3815
|
...otpFlags
|
|
@@ -3454,7 +3875,7 @@ async function runSteadyPublish(input = {}) {
|
|
|
3454
3875
|
}
|
|
3455
3876
|
print(" → publishing all public @theholocron/* packages…");
|
|
3456
3877
|
const publish = await exec("pnpm", [
|
|
3457
|
-
...
|
|
3878
|
+
...workspaceFilterArgs(cwd),
|
|
3458
3879
|
"publish",
|
|
3459
3880
|
...baseFlags,
|
|
3460
3881
|
...otpFlags
|
|
@@ -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,
|
|
@@ -3491,6 +3912,22 @@ async function runSteadyPublish(input = {}) {
|
|
|
3491
3912
|
function hasPackagesDir(cwd) {
|
|
3492
3913
|
return existsSync(join(cwd, "packages"));
|
|
3493
3914
|
}
|
|
3915
|
+
/** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
|
|
3916
|
+
function hasPluginsDir(cwd) {
|
|
3917
|
+
return existsSync(join(cwd, "plugins"));
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
|
|
3921
|
+
* roots exist. Empty (single-package repo, no `-r`) when neither does.
|
|
3922
|
+
* Shared by every `pnpm publish` invocation below so a repo with only
|
|
3923
|
+
* `plugins/*` (or both) is covered the same as `packages/*`-only.
|
|
3924
|
+
*/
|
|
3925
|
+
function workspaceFilterArgs(cwd) {
|
|
3926
|
+
const filters = [];
|
|
3927
|
+
if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
|
|
3928
|
+
if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
|
|
3929
|
+
return filters.length > 0 ? ["-r", ...filters] : [];
|
|
3930
|
+
}
|
|
3494
3931
|
/** Read a `package.json`'s `name`, when it's public (`!private && name`). */
|
|
3495
3932
|
function publicPackageName(pkgPath) {
|
|
3496
3933
|
if (!existsSync(pkgPath)) return void 0;
|
|
@@ -3522,29 +3959,38 @@ function publicPackageEntry(pkgPath, dir) {
|
|
|
3522
3959
|
* and publish one workspace at a time.
|
|
3523
3960
|
*/
|
|
3524
3961
|
function discoverPublicPackageEntries(cwd) {
|
|
3525
|
-
if (!hasPackagesDir(cwd)) {
|
|
3962
|
+
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
3526
3963
|
const entry = publicPackageEntry(join(cwd, "package.json"), ".");
|
|
3527
3964
|
return entry ? [entry] : [];
|
|
3528
3965
|
}
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3966
|
+
return [...entriesInWorkspaceDir(cwd, "packages"), ...entriesInWorkspaceDir(cwd, "plugins")];
|
|
3967
|
+
}
|
|
3968
|
+
function entriesInWorkspaceDir(cwd, dirName) {
|
|
3969
|
+
const dirPath = join(cwd, dirName);
|
|
3970
|
+
if (!existsSync(dirPath)) return [];
|
|
3971
|
+
return readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
|
|
3972
|
+
const entry = publicPackageEntry(join(dirPath, e.name, "package.json"), `./${dirName}/${e.name}`);
|
|
3532
3973
|
return entry ? [entry] : [];
|
|
3533
3974
|
});
|
|
3534
3975
|
}
|
|
3535
3976
|
/**
|
|
3536
|
-
* Monorepo (`packages/` present): every public
|
|
3537
|
-
* Single package (
|
|
3538
|
-
* most `node-template` scaffolds are one
|
|
3977
|
+
* Monorepo (`packages/` and/or `plugins/` present): every public workspace
|
|
3978
|
+
* under either. Single package (neither dir present): the root
|
|
3979
|
+
* `package.json`, if it's public — most `node-template` scaffolds are one
|
|
3980
|
+
* package at the repo root.
|
|
3539
3981
|
*/
|
|
3540
3982
|
function discoverPublicPackages(cwd) {
|
|
3541
|
-
if (!hasPackagesDir(cwd)) {
|
|
3983
|
+
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
3542
3984
|
const name = publicPackageName(join(cwd, "package.json"));
|
|
3543
3985
|
return name ? [name] : [];
|
|
3544
3986
|
}
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3987
|
+
return [...namesInWorkspaceDir(cwd, "packages"), ...namesInWorkspaceDir(cwd, "plugins")];
|
|
3988
|
+
}
|
|
3989
|
+
function namesInWorkspaceDir(cwd, dirName) {
|
|
3990
|
+
const dirPath = join(cwd, dirName);
|
|
3991
|
+
if (!existsSync(dirPath)) return [];
|
|
3992
|
+
return readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
|
|
3993
|
+
const name = publicPackageName(join(dirPath, e.name, "package.json"));
|
|
3548
3994
|
return name ? [name] : [];
|
|
3549
3995
|
});
|
|
3550
3996
|
}
|
|
@@ -3598,12 +4044,30 @@ const defaultLogin = async (cwd) => {
|
|
|
3598
4044
|
};
|
|
3599
4045
|
//#endregion
|
|
3600
4046
|
//#region src/commands/secret-set.ts
|
|
4047
|
+
/**
|
|
4048
|
+
* `holocron secrets set` — one-shot single-secret push.
|
|
4049
|
+
*
|
|
4050
|
+
* Complements `holocron secrets sync` (bulk vault → destinations) for
|
|
4051
|
+
* ad-hoc scenarios:
|
|
4052
|
+
* - Bootstrap secrets (e.g., NPM_TOKEN on theholocron/holocron itself
|
|
4053
|
+
* before the rest of the setup exists)
|
|
4054
|
+
* - One-off rotations that don't warrant a vault sync
|
|
4055
|
+
* - Setting secrets the vault doesn't track
|
|
4056
|
+
*
|
|
4057
|
+
* Value source resolution (in priority order):
|
|
4058
|
+
* 1. Positional `value` argument
|
|
4059
|
+
* 2. `--from-stdin` reads from stdin
|
|
4060
|
+
* 3. `--from-env <NAME>` reads from the named env var
|
|
4061
|
+
* 4. Implicit: env var matching the secret name (e.g., `NPM_TOKEN`)
|
|
4062
|
+
*
|
|
4063
|
+
* Errors clearly when no value can be sourced.
|
|
4064
|
+
*/
|
|
3601
4065
|
async function runSecretSet(input) {
|
|
3602
4066
|
const print = input.print ?? ((line) => console.log(line));
|
|
3603
4067
|
const logger = input.logger ?? getLogger();
|
|
3604
4068
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
3605
4069
|
await loader.load();
|
|
3606
|
-
assertPluginsResolvable(loader, "
|
|
4070
|
+
assertPluginsResolvable(loader, "secrets set");
|
|
3607
4071
|
const dryRun = input.context.dryRun ?? false;
|
|
3608
4072
|
const scope = input.scope ?? { kind: "repo" };
|
|
3609
4073
|
if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
|
|
@@ -3636,7 +4100,7 @@ async function runSecretSet(input) {
|
|
|
3636
4100
|
scope
|
|
3637
4101
|
};
|
|
3638
4102
|
} catch (err) {
|
|
3639
|
-
const message =
|
|
4103
|
+
const message = errorMessage(err);
|
|
3640
4104
|
print(` ✗ ${message}`);
|
|
3641
4105
|
logger.warn({
|
|
3642
4106
|
key: input.name,
|
|
@@ -3672,6 +4136,38 @@ function describeScope(scope) {
|
|
|
3672
4136
|
}
|
|
3673
4137
|
//#endregion
|
|
3674
4138
|
//#region src/commands/secrets-sync.ts
|
|
4139
|
+
/**
|
|
4140
|
+
* `holocron secrets sync` — vault → destinations.
|
|
4141
|
+
*
|
|
4142
|
+
* Reads a named environment from the configured `vault` (1P
|
|
4143
|
+
* Environment / similar) as KEY=VALUE pairs, then fans those values
|
|
4144
|
+
* out to the configured destinations:
|
|
4145
|
+
*
|
|
4146
|
+
* - `secrets` capability (e.g., GH Actions secrets) — repo-scope
|
|
4147
|
+
* - `deployment` capability (e.g., Vercel env vars) — per-target
|
|
4148
|
+
* - local `.env` file (optional, planned for v5.1 — currently skipped
|
|
4149
|
+
* with a soft "not yet" note)
|
|
4150
|
+
*
|
|
4151
|
+
* Per-secret routing rules:
|
|
4152
|
+
*
|
|
4153
|
+
* - All keys go to `deployment` for each target the caller asks for
|
|
4154
|
+
* (defaults: production + preview)
|
|
4155
|
+
* - `secrets` (GH Actions) defaults to every key, repo-scoped — but a
|
|
4156
|
+
* caller can pass `githubSecretKeys` (an allowlist, from repeatable
|
|
4157
|
+
* `--github-secret KEY` CLI values) to restrict `secrets` to only
|
|
4158
|
+
* those keys, and `githubSecretScope` (same `{ kind }` shape `secret
|
|
4159
|
+
* set`'s own `--scope repo|env=NAME|org=NAME` parses to) to apply one
|
|
4160
|
+
* scope to all of them. A key not in the allowlist is skipped for
|
|
4161
|
+
* `secrets` entirely — still synced to `deployment` if configured.
|
|
4162
|
+
* Needed for a vault holding a mix of deploy-only secrets and one
|
|
4163
|
+
* CI-consumed, org-wide secret (holocron#781's Sentinel case: 4
|
|
4164
|
+
* Vercel-only keys + one org-scoped Axiom ingest token
|
|
4165
|
+
* `theholocron/.github`'s dispatched-check workflow also reads).
|
|
4166
|
+
*
|
|
4167
|
+
* The vault MUST implement `readEnvironment(envId)` (optional method).
|
|
4168
|
+
* If it doesn't, the command errors with a hint to populate the keys
|
|
4169
|
+
* individually via `holocron secrets set` (also planned for v5.1).
|
|
4170
|
+
*/
|
|
3675
4171
|
async function runSecretsSync(input) {
|
|
3676
4172
|
const print = input.print ?? ((line) => console.log(line));
|
|
3677
4173
|
const logger = input.logger ?? getLogger();
|
|
@@ -3696,10 +4192,13 @@ async function runSecretsSync(input) {
|
|
|
3696
4192
|
const rows = [];
|
|
3697
4193
|
if (loader.has("secrets")) {
|
|
3698
4194
|
const secrets = loader.get("secrets");
|
|
3699
|
-
|
|
4195
|
+
const scope = input.githubSecretScope ?? { kind: "repo" };
|
|
4196
|
+
const allowedKeys = input.githubSecretKeys ? new Set(input.githubSecretKeys) : void 0;
|
|
4197
|
+
print(style.step("secrets"));
|
|
3700
4198
|
for (const key of keys) {
|
|
3701
|
-
|
|
3702
|
-
|
|
4199
|
+
if (allowedKeys && !allowedKeys.has(key)) continue;
|
|
4200
|
+
rows.push(await runRow(`secrets:${secrets.providerName}`, scopeLabel(scope), key, dryRun, async () => {
|
|
4201
|
+
await secrets.setSecret(scope, key, envVars[key]);
|
|
3703
4202
|
}));
|
|
3704
4203
|
print(formatRow(rows[rows.length - 1]));
|
|
3705
4204
|
}
|
|
@@ -3779,7 +4278,7 @@ async function runRow(destination, scope, key, dryRun, body) {
|
|
|
3779
4278
|
scope,
|
|
3780
4279
|
key,
|
|
3781
4280
|
status: "fail",
|
|
3782
|
-
message:
|
|
4281
|
+
message: errorMessage(err)
|
|
3783
4282
|
};
|
|
3784
4283
|
}
|
|
3785
4284
|
}
|
|
@@ -3791,6 +4290,11 @@ function formatRow(row) {
|
|
|
3791
4290
|
if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
|
|
3792
4291
|
return ` ${style.dim(`· ${label}`)}`;
|
|
3793
4292
|
}
|
|
4293
|
+
function scopeLabel(scope) {
|
|
4294
|
+
if (scope.kind === "repo") return "scope=repo";
|
|
4295
|
+
if (scope.kind === "organization") return `scope=organization,org=${scope.name}`;
|
|
4296
|
+
return `scope=environment,env=${scope.name}`;
|
|
4297
|
+
}
|
|
3794
4298
|
function vaultProviderName(loader) {
|
|
3795
4299
|
if (!loader.has("vault")) return "<missing>";
|
|
3796
4300
|
return loader.get("vault").providerName;
|
|
@@ -3867,7 +4371,7 @@ function deriveCapabilities(providers) {
|
|
|
3867
4371
|
return Object.keys(providers ?? {}).sort();
|
|
3868
4372
|
}
|
|
3869
4373
|
/**
|
|
3870
|
-
* `source` + `ci` are the minimal baseline every repo `holocron setup`
|
|
4374
|
+
* `source` + `ci` are the minimal baseline every repo `holocron repo setup`
|
|
3871
4375
|
* touches wires — every `holocron.config.ts` in this org already satisfies
|
|
3872
4376
|
* this, so `non-compliant` here means drift (a provider manually removed
|
|
3873
4377
|
* after setup), not an unmet aspirational policy. Deliberately not a richer
|
|
@@ -3995,7 +4499,7 @@ var config_default = "# Configuration for sentiment-bot - https://github.com/beh
|
|
|
3995
4499
|
//#endregion
|
|
3996
4500
|
//#region src/utils/create-header.ts
|
|
3997
4501
|
function createHeader(options) {
|
|
3998
|
-
const { source, tool = "holocron setup", forPrimary = false } = options;
|
|
4502
|
+
const { source, tool = "holocron repo setup", forPrimary = false } = options;
|
|
3999
4503
|
const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
|
|
4000
4504
|
return {
|
|
4001
4505
|
workflowHeader(format = "yaml") {
|
|
@@ -4019,7 +4523,7 @@ function createHeader(options) {
|
|
|
4019
4523
|
},
|
|
4020
4524
|
scaffoldHeader() {
|
|
4021
4525
|
return [
|
|
4022
|
-
`# Scaffolded by holocron setup — edit this file freely.`,
|
|
4526
|
+
`# Scaffolded by holocron repo setup — edit this file freely.`,
|
|
4023
4527
|
`# Source: theholocron/holocron · ${source}`,
|
|
4024
4528
|
``
|
|
4025
4529
|
].join("\n");
|
|
@@ -4033,6 +4537,9 @@ var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
|
4033
4537
|
//#region src/templates/configs/alexjs/alexrc.json
|
|
4034
4538
|
var alexrc_default = { allow: [
|
|
4035
4539
|
"dead",
|
|
4540
|
+
"execute",
|
|
4541
|
+
"execution",
|
|
4542
|
+
"executes",
|
|
4036
4543
|
"failure",
|
|
4037
4544
|
"failures",
|
|
4038
4545
|
"hook",
|
|
@@ -4051,12 +4558,21 @@ function createIgnoreConfig$1() {
|
|
|
4051
4558
|
}
|
|
4052
4559
|
//#endregion
|
|
4053
4560
|
//#region src/templates/configs/commit-msg/commit-msg
|
|
4054
|
-
var commit_msg_default = "
|
|
4561
|
+
var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
|
|
4055
4562
|
//#endregion
|
|
4056
4563
|
//#region src/templates/configs/commit-msg/create-config.ts
|
|
4057
4564
|
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
4058
|
-
|
|
4059
|
-
|
|
4565
|
+
/**
|
|
4566
|
+
* `.husky/commit-msg` — lints the commit message via `holocron run lint
|
|
4567
|
+
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4568
|
+
* shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
|
|
4569
|
+
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4570
|
+
*
|
|
4571
|
+
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4572
|
+
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4573
|
+
*/
|
|
4574
|
+
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4575
|
+
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4060
4576
|
}
|
|
4061
4577
|
//#endregion
|
|
4062
4578
|
//#region src/templates/configs/devmoji/create-config.ts
|
|
@@ -4072,7 +4588,7 @@ function createConfig$6() {
|
|
|
4072
4588
|
}
|
|
4073
4589
|
//#endregion
|
|
4074
4590
|
//#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";
|
|
4591
|
+
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
4592
|
//#endregion
|
|
4077
4593
|
//#region src/templates/configs/editorconfig/create-config.ts
|
|
4078
4594
|
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
@@ -4129,7 +4645,7 @@ var pre_commit_default = "# Run GitLeaks against staged changes only — a full
|
|
|
4129
4645
|
const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
|
|
4130
4646
|
/**
|
|
4131
4647
|
* `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
|
|
4132
|
-
* lint-staged before every commit. Installed by `holocron setup` when hooks
|
|
4648
|
+
* lint-staged before every commit. Installed by `holocron repo setup` when hooks
|
|
4133
4649
|
* are enabled (default for `protection: "strict"`). `git commit --no-verify`
|
|
4134
4650
|
* bypasses it.
|
|
4135
4651
|
*
|
|
@@ -4144,13 +4660,13 @@ function createConfig$3() {
|
|
|
4144
4660
|
}
|
|
4145
4661
|
//#endregion
|
|
4146
4662
|
//#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";
|
|
4663
|
+
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
4664
|
//#endregion
|
|
4149
4665
|
//#region src/templates/configs/pre-push/create-config.ts
|
|
4150
4666
|
const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
|
|
4151
4667
|
/**
|
|
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
|
|
4668
|
+
* `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
|
|
4669
|
+
* before every push. Installed by `holocron repo setup` when hooks are enabled
|
|
4154
4670
|
* (default for `protection: "strict"`). `git push --no-verify` bypasses it.
|
|
4155
4671
|
*
|
|
4156
4672
|
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
@@ -4197,7 +4713,7 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
|
|
|
4197
4713
|
/**
|
|
4198
4714
|
* Canonical AI engineering workflow role prompts.
|
|
4199
4715
|
*
|
|
4200
|
-
* Written to `.agents/prompts/<role>.md` by `holocron setup` when `agent` is
|
|
4716
|
+
* Written to `.agents/prompts/<role>.md` by `holocron repo setup` when `agent` is
|
|
4201
4717
|
* configured. Paths are gitignored and regenerated on every setup run so the
|
|
4202
4718
|
* content always reflects the current CLI version.
|
|
4203
4719
|
*
|
|
@@ -4695,7 +5211,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
|
4695
5211
|
capability: "source",
|
|
4696
5212
|
step,
|
|
4697
5213
|
status: "fail",
|
|
4698
|
-
message:
|
|
5214
|
+
message: errorMessage(err)
|
|
4699
5215
|
};
|
|
4700
5216
|
}
|
|
4701
5217
|
try {
|
|
@@ -4718,7 +5234,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
|
4718
5234
|
capability: "source",
|
|
4719
5235
|
step,
|
|
4720
5236
|
status: "fail",
|
|
4721
|
-
message:
|
|
5237
|
+
message: errorMessage(err)
|
|
4722
5238
|
};
|
|
4723
5239
|
}
|
|
4724
5240
|
}
|
|
@@ -4811,7 +5327,7 @@ async function execStep(capability, step, dryRun, body, opts = {}) {
|
|
|
4811
5327
|
capability,
|
|
4812
5328
|
step,
|
|
4813
5329
|
status: "fail",
|
|
4814
|
-
message:
|
|
5330
|
+
message: errorMessage(err)
|
|
4815
5331
|
};
|
|
4816
5332
|
}
|
|
4817
5333
|
}
|
|
@@ -4984,7 +5500,7 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
|
|
|
4984
5500
|
//#endregion
|
|
4985
5501
|
//#region src/commands/setup/run-setup.ts
|
|
4986
5502
|
/**
|
|
4987
|
-
* `holocron setup` — orchestrates per-capability setup actions across
|
|
5503
|
+
* `holocron repo setup` — orchestrates per-capability setup actions across
|
|
4988
5504
|
* every plugin loaded from `holocron.config.json`.
|
|
4989
5505
|
*
|
|
4990
5506
|
* Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
|
|
@@ -5004,18 +5520,18 @@ async function updateSkillsGitignore(gitignorePath, existingContent, skills, sym
|
|
|
5004
5520
|
*/
|
|
5005
5521
|
const { workflowHeader: workflowHeader$1 } = createHeader({
|
|
5006
5522
|
source: "packages/cli/src/commands/setup/run-setup.ts",
|
|
5007
|
-
tool: "holocron setup"
|
|
5523
|
+
tool: "holocron repo setup"
|
|
5008
5524
|
});
|
|
5009
5525
|
const { workflowHeader: thinCallerHeader } = createHeader({
|
|
5010
5526
|
source: "packages/astromech/src/thin-callers.ts",
|
|
5011
|
-
tool: "holocron setup"
|
|
5527
|
+
tool: "holocron repo setup"
|
|
5012
5528
|
});
|
|
5013
5529
|
async function runSetup(input) {
|
|
5014
5530
|
const print = input.print ?? ((line) => console.log(line));
|
|
5015
5531
|
const logger = getLogger();
|
|
5016
5532
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
5017
5533
|
await withSpinner("Loading plugins…", () => loader.load());
|
|
5018
|
-
assertPluginsResolvable(loader, "setup");
|
|
5534
|
+
assertPluginsResolvable(loader, "repo setup");
|
|
5019
5535
|
const config = input.loaded.resolved;
|
|
5020
5536
|
const dryRun = input.context.dryRun ?? false;
|
|
5021
5537
|
const steps = [];
|
|
@@ -5052,7 +5568,10 @@ async function runSetup(input) {
|
|
|
5052
5568
|
print(formatStep(steps[steps.length - 1]));
|
|
5053
5569
|
if (effectivePreset && effectivePreset !== "none") {
|
|
5054
5570
|
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
5055
|
-
await source.updateRepoSettings(
|
|
5571
|
+
await source.updateRepoSettings({
|
|
5572
|
+
...BALANCED_REPO_SETTINGS,
|
|
5573
|
+
...repo?.defaultBranch ? { default_branch: repo.defaultBranch } : {}
|
|
5574
|
+
});
|
|
5056
5575
|
}));
|
|
5057
5576
|
print(formatStep(steps[steps.length - 1]));
|
|
5058
5577
|
const tasksConfig = { tasks: config.tasks };
|
|
@@ -5159,13 +5678,13 @@ async function runSetup(input) {
|
|
|
5159
5678
|
}));
|
|
5160
5679
|
print(formatStep(steps[steps.length - 1]));
|
|
5161
5680
|
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5162
|
-
await source.writeRepoFile(".husky/commit-msg", createConfig$7());
|
|
5681
|
+
await source.writeRepoFile(".husky/commit-msg", createConfig$7(config.holocronScript));
|
|
5163
5682
|
}));
|
|
5164
5683
|
print(formatStep(steps[steps.length - 1]));
|
|
5165
5684
|
if (hooksEnabled) {
|
|
5166
5685
|
steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
|
|
5167
5686
|
await source.writeRepoFile(".husky/pre-push", createConfig$2(config.holocronScript));
|
|
5168
|
-
return "runs `holocron ci` before push — bypass with `git push --no-verify`";
|
|
5687
|
+
return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
|
|
5169
5688
|
}));
|
|
5170
5689
|
print(formatStep(steps[steps.length - 1]));
|
|
5171
5690
|
steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
|
|
@@ -5233,13 +5752,20 @@ async function runSetup(input) {
|
|
|
5233
5752
|
await source.writeRepoFile("turbo.json", content);
|
|
5234
5753
|
}));
|
|
5235
5754
|
print(formatStep(steps[steps.length - 1]));
|
|
5236
|
-
const
|
|
5755
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
5756
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
5237
5757
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
5238
|
-
const rootScripts =
|
|
5758
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
5239
5759
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
5240
5760
|
if (workspaceFix.changed) steps.push(await runStep("source", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
5241
5761
|
await source.writeRepoFile("pnpm-workspace.yaml", workspaceFix.content);
|
|
5242
5762
|
}));
|
|
5763
|
+
if (rootPkgRaw) {
|
|
5764
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
5765
|
+
if (depFix.changed) steps.push(await runStep("source", "add turbo devDependency", dryRun, async () => {
|
|
5766
|
+
await source.writeRepoFile("package.json", depFix.content);
|
|
5767
|
+
}));
|
|
5768
|
+
}
|
|
5243
5769
|
}
|
|
5244
5770
|
print(formatStep(steps[steps.length - 1]));
|
|
5245
5771
|
}
|
|
@@ -5291,34 +5817,36 @@ async function runSetup(input) {
|
|
|
5291
5817
|
print(formatStep(steps[steps.length - 1]));
|
|
5292
5818
|
}
|
|
5293
5819
|
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
|
-
|
|
5820
|
+
if (teams.length > 0) {
|
|
5821
|
+
if (source.syncTeams) {
|
|
5822
|
+
steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams), { skipCodes: [422] }));
|
|
5823
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5824
|
+
const repoCoord = input.context.repo ?? repo?.name ?? "";
|
|
5825
|
+
const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
|
|
5826
|
+
const writeableTeams = teams.map((t) => typeof t === "string" ? {
|
|
5827
|
+
slug: t,
|
|
5828
|
+
permission: "push"
|
|
5829
|
+
} : t).filter((t) => [
|
|
5830
|
+
"push",
|
|
5831
|
+
"maintain",
|
|
5832
|
+
"admin"
|
|
5833
|
+
].includes(t.permission));
|
|
5834
|
+
if (org && writeableTeams.length > 0) {
|
|
5835
|
+
steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
|
|
5836
|
+
const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
|
|
5837
|
+
await source.writeRepoFile(".github/CODEOWNERS", content);
|
|
5838
|
+
}));
|
|
5839
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5840
|
+
}
|
|
5841
|
+
} else {
|
|
5842
|
+
steps.push({
|
|
5843
|
+
capability: "source",
|
|
5844
|
+
step: "sync teams",
|
|
5845
|
+
status: "skip",
|
|
5846
|
+
message: "provider does not implement syncTeams"
|
|
5847
|
+
});
|
|
5312
5848
|
print(formatStep(steps[steps.length - 1]));
|
|
5313
5849
|
}
|
|
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
5850
|
}
|
|
5323
5851
|
}
|
|
5324
5852
|
if (loader.has("environments")) {
|
|
@@ -5374,11 +5902,9 @@ async function runSetup(input) {
|
|
|
5374
5902
|
const wikiProxy = wiki.proxyConfig?.();
|
|
5375
5903
|
if (wikiDns && loader.has("dns")) {
|
|
5376
5904
|
const dns = loader.get("dns");
|
|
5377
|
-
steps.push(await runStep("dns", `upsertRecord ${wikiDns.
|
|
5905
|
+
steps.push(await runStep("dns", `upsertRecord ${wikiDns.record.name}`, dryRun, async () => {
|
|
5378
5906
|
await dns.upsertRecord(wikiDns.zone, {
|
|
5379
|
-
|
|
5380
|
-
name: wikiDns.cname,
|
|
5381
|
-
content: wikiDns.target,
|
|
5907
|
+
...wikiDns.record,
|
|
5382
5908
|
ttl: 1,
|
|
5383
5909
|
...wikiProxy ? { proxied: true } : {}
|
|
5384
5910
|
});
|
|
@@ -5387,8 +5913,8 @@ async function runSetup(input) {
|
|
|
5387
5913
|
}
|
|
5388
5914
|
if (wikiProxy && wikiDns && loader.has("workers")) {
|
|
5389
5915
|
const workers = loader.get("workers");
|
|
5390
|
-
steps.push(await runStep("workers", `upsertProxy ${wikiDns.
|
|
5391
|
-
await workers.upsertProxy(wikiDns.
|
|
5916
|
+
steps.push(await runStep("workers", `upsertProxy ${wikiDns.record.name}`, dryRun, async () => {
|
|
5917
|
+
await workers.upsertProxy(wikiDns.record.name, wikiProxy);
|
|
5392
5918
|
}));
|
|
5393
5919
|
print(formatStep(steps[steps.length - 1]));
|
|
5394
5920
|
}
|
|
@@ -5401,11 +5927,31 @@ async function runSetup(input) {
|
|
|
5401
5927
|
org: config.org,
|
|
5402
5928
|
domain: config.domain
|
|
5403
5929
|
}) : null;
|
|
5930
|
+
const ensureCustomDomain = async (projectId, hostname) => {
|
|
5931
|
+
if (!deploy.ensureCustomDomain) return;
|
|
5932
|
+
const customDomainDns = { value: null };
|
|
5933
|
+
steps.push(await runStep("deployment", `ensureCustomDomain ${hostname}`, dryRun, async () => {
|
|
5934
|
+
customDomainDns.value = await deploy.ensureCustomDomain(projectId, hostname);
|
|
5935
|
+
}));
|
|
5936
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5937
|
+
if (customDomainDns.value && loader.has("dns")) {
|
|
5938
|
+
const dns = loader.get("dns");
|
|
5939
|
+
const dnsRecord = customDomainDns.value;
|
|
5940
|
+
steps.push(await runStep("dns", `upsertRecord ${dnsRecord.record.name}`, dryRun, async () => {
|
|
5941
|
+
await dns.upsertRecord(dnsRecord.zone, {
|
|
5942
|
+
...dnsRecord.record,
|
|
5943
|
+
ttl: 1
|
|
5944
|
+
});
|
|
5945
|
+
}));
|
|
5946
|
+
print(formatStep(steps[steps.length - 1]));
|
|
5947
|
+
}
|
|
5948
|
+
};
|
|
5404
5949
|
if (!previewCfg) {
|
|
5405
5950
|
steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
|
|
5406
5951
|
await deploy.ensureProject({ name: config.name });
|
|
5407
5952
|
}));
|
|
5408
5953
|
print(formatStep(steps[steps.length - 1]));
|
|
5954
|
+
if (deploy.domain) await ensureCustomDomain(config.name, deploy.domain);
|
|
5409
5955
|
}
|
|
5410
5956
|
if (previewCfg) {
|
|
5411
5957
|
steps.push(await runStep("deployment", `ensureProject ${previewCfg.project}`, dryRun, async () => {
|
|
@@ -5413,13 +5959,8 @@ async function runSetup(input) {
|
|
|
5413
5959
|
}));
|
|
5414
5960
|
print(formatStep(steps[steps.length - 1]));
|
|
5415
5961
|
}
|
|
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")) {
|
|
5962
|
+
if (previewCfg?.domain) await ensureCustomDomain(previewCfg.project, previewCfg.domain);
|
|
5963
|
+
if (previewCfg?.domain && deploy.providerName === "cloudflare" && loader.has("dns")) {
|
|
5423
5964
|
const dns = loader.get("dns");
|
|
5424
5965
|
const wildcardDomain = `*.${previewCfg.domain}`;
|
|
5425
5966
|
steps.push(await runStep("dns", `upsertRecord ${previewCfg.domain}`, dryRun, async () => {
|
|
@@ -5493,7 +6034,7 @@ async function runSetup(input) {
|
|
|
5493
6034
|
capability: "vault",
|
|
5494
6035
|
step: "list",
|
|
5495
6036
|
status: "fail",
|
|
5496
|
-
message:
|
|
6037
|
+
message: errorMessage(err)
|
|
5497
6038
|
});
|
|
5498
6039
|
}
|
|
5499
6040
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -5532,6 +6073,17 @@ async function runSetup(input) {
|
|
|
5532
6073
|
print(formatStep(steps[steps.length - 1]));
|
|
5533
6074
|
}
|
|
5534
6075
|
}
|
|
6076
|
+
if (loader.has("monitoring")) {
|
|
6077
|
+
const monitoring = loader.get("monitoring");
|
|
6078
|
+
print(style.step("monitoring"));
|
|
6079
|
+
for (const checkInput of monitoring.checks) {
|
|
6080
|
+
steps.push(await runStep("monitoring", `ensureCheck ${checkInput.name}`, dryRun, async () => {
|
|
6081
|
+
const result = await monitoring.ensureCheck(checkInput);
|
|
6082
|
+
return `${checkInput.name} ${result.alreadyExists ? "exists" : "created"}`;
|
|
6083
|
+
}));
|
|
6084
|
+
print(formatStep(steps[steps.length - 1]));
|
|
6085
|
+
}
|
|
6086
|
+
}
|
|
5535
6087
|
if (loader.has("tooling")) {
|
|
5536
6088
|
const tools = loader.get("tooling");
|
|
5537
6089
|
print(style.step("tooling"));
|
|
@@ -5618,7 +6170,7 @@ async function runSetup(input) {
|
|
|
5618
6170
|
print(style.hint(" · Metadata — read (repository permission, auto-included)"));
|
|
5619
6171
|
print("");
|
|
5620
6172
|
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>"));
|
|
6173
|
+
print(style.hint(" Then re-run: holocron repo setup --token <your-admin-pat>"));
|
|
5622
6174
|
print(style.hint(" Store org token: HOLOCRON_ORG_TOKEN env var or keyring key github.org"));
|
|
5623
6175
|
}
|
|
5624
6176
|
return {
|
|
@@ -5656,7 +6208,7 @@ async function ensurePrepareScript(repoRoot) {
|
|
|
5656
6208
|
/**
|
|
5657
6209
|
* `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
|
|
5658
6210
|
*
|
|
5659
|
-
* Unlike `holocron setup`, this command is purely local (no GitHub token
|
|
6211
|
+
* Unlike `holocron repo setup`, this command is purely local (no GitHub token
|
|
5660
6212
|
* required). It reads `agent` and `skills` from the config and installs
|
|
5661
6213
|
* the listed skills into `.agents/skills/<name>/` with a symlink at the
|
|
5662
6214
|
* agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
|
|
@@ -5709,7 +6261,7 @@ async function runSkillsInstall(input) {
|
|
|
5709
6261
|
status: "ok"
|
|
5710
6262
|
}, "skills install: done");
|
|
5711
6263
|
} catch (err) {
|
|
5712
|
-
const message =
|
|
6264
|
+
const message = errorMessage(err);
|
|
5713
6265
|
print(` ✗ ${message}`);
|
|
5714
6266
|
logger.warn({
|
|
5715
6267
|
agent: config.agent,
|
|
@@ -5867,11 +6419,13 @@ async function runSyncReadme(input) {
|
|
|
5867
6419
|
}
|
|
5868
6420
|
const description = loaded.resolved.description ?? "";
|
|
5869
6421
|
const homepage = loaded.resolved.homepage ?? pkg.homepage ?? "";
|
|
6422
|
+
const scripts = pkg.scripts ?? {};
|
|
6423
|
+
const packages = pkg.name ? REGISTRY_MAP[pkg.name]?.() : void 0;
|
|
5870
6424
|
const sections = generateReadme({
|
|
5871
6425
|
description,
|
|
5872
6426
|
homepage,
|
|
5873
|
-
scripts
|
|
5874
|
-
packages
|
|
6427
|
+
scripts,
|
|
6428
|
+
packages
|
|
5875
6429
|
});
|
|
5876
6430
|
const markerSections = {
|
|
5877
6431
|
...sections.packages !== void 0 && { packages: sections.packages },
|
|
@@ -6113,7 +6667,7 @@ async function runSyncWiki(input) {
|
|
|
6113
6667
|
capability: "local",
|
|
6114
6668
|
step: "sync wiki",
|
|
6115
6669
|
status: "fail",
|
|
6116
|
-
message: err instanceof Error ? err.message :
|
|
6670
|
+
message: err instanceof Error ? err.message : /* c8 ignore next */ String(err)
|
|
6117
6671
|
};
|
|
6118
6672
|
}
|
|
6119
6673
|
}
|
|
@@ -6121,7 +6675,7 @@ async function runSyncWiki(input) {
|
|
|
6121
6675
|
//#region src/commands/sync.ts
|
|
6122
6676
|
const { workflowHeader } = createHeader({
|
|
6123
6677
|
source: "packages/astromech/src/thin-callers.ts",
|
|
6124
|
-
tool: "holocron sync"
|
|
6678
|
+
tool: "holocron repo sync"
|
|
6125
6679
|
});
|
|
6126
6680
|
const SYNC_STEPS = [
|
|
6127
6681
|
"labels",
|
|
@@ -6175,55 +6729,59 @@ async function runSync(input) {
|
|
|
6175
6729
|
for (const stepName of SYNC_STEPS) {
|
|
6176
6730
|
if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
|
|
6177
6731
|
if (LOCAL_STEPS.has(stepName)) continue;
|
|
6178
|
-
if (stepName === "labels")
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6732
|
+
if (stepName === "labels") {
|
|
6733
|
+
if (source.syncLabels) {
|
|
6734
|
+
steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
|
|
6735
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6736
|
+
} else {
|
|
6737
|
+
steps.push({
|
|
6738
|
+
capability: "source",
|
|
6739
|
+
step: "sync labels",
|
|
6740
|
+
status: "skip",
|
|
6741
|
+
message: "provider does not implement syncLabels"
|
|
6742
|
+
});
|
|
6743
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6744
|
+
}
|
|
6189
6745
|
}
|
|
6190
|
-
if (stepName === "properties")
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6746
|
+
if (stepName === "properties") {
|
|
6747
|
+
if (source.syncProperties) {
|
|
6748
|
+
const repo = config.repo;
|
|
6749
|
+
const properties = {};
|
|
6750
|
+
const effectivePreset = repo?.protection;
|
|
6751
|
+
if (effectivePreset && effectivePreset !== "none") properties["holocron_branch_protection_level"] = effectivePreset;
|
|
6752
|
+
const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
|
|
6753
|
+
properties["monorepo"] = String(isMonorepo);
|
|
6754
|
+
const manual = repo?.properties ?? {};
|
|
6755
|
+
if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
|
|
6756
|
+
if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
|
|
6757
|
+
if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
|
|
6758
|
+
if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
|
|
6759
|
+
{
|
|
6760
|
+
const rootPackageJson = await readFile(join(input.context.repoRoot, "package.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
6761
|
+
const workspacePackages = readWorkspacePackages(input.context.repoRoot);
|
|
6762
|
+
const workspacePackageJsons = await readWorkspacePackageJsons(input.context.repoRoot, workspacePackages);
|
|
6763
|
+
properties["holocron_profile"] = deriveProfile({
|
|
6764
|
+
rootPackageJson,
|
|
6765
|
+
repoName: basename(input.context.repoRoot),
|
|
6766
|
+
isMonorepo,
|
|
6767
|
+
workspacePackageJsons
|
|
6768
|
+
});
|
|
6769
|
+
properties["holocron_stack"] = deriveStack(rootPackageJson);
|
|
6770
|
+
const capabilities = deriveCapabilities(config.providers);
|
|
6771
|
+
properties["holocron_capabilities"] = capabilities;
|
|
6772
|
+
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6773
|
+
}
|
|
6774
|
+
steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
|
|
6775
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6776
|
+
} else {
|
|
6777
|
+
steps.push({
|
|
6778
|
+
capability: "source",
|
|
6779
|
+
step: "sync properties",
|
|
6780
|
+
status: "skip",
|
|
6781
|
+
message: "provider does not implement syncProperties"
|
|
6211
6782
|
});
|
|
6212
|
-
|
|
6213
|
-
const capabilities = deriveCapabilities(config.providers);
|
|
6214
|
-
properties["holocron_capabilities"] = capabilities;
|
|
6215
|
-
properties["holocron_compliance"] = deriveCompliance(capabilities);
|
|
6783
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
6216
6784
|
}
|
|
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
6785
|
}
|
|
6228
6786
|
if (stepName === "teams") {
|
|
6229
6787
|
const teams = config.repo?.teams ?? [];
|
|
@@ -6435,29 +6993,34 @@ async function runSync(input) {
|
|
|
6435
6993
|
}
|
|
6436
6994
|
}
|
|
6437
6995
|
}
|
|
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
|
-
|
|
6996
|
+
if (stepName === "scripts") {
|
|
6997
|
+
if (config.syncScripts === false) {
|
|
6998
|
+
steps.push({
|
|
6999
|
+
capability: "local",
|
|
7000
|
+
step: "sync scripts",
|
|
7001
|
+
status: "skip",
|
|
7002
|
+
message: "syncScripts: false"
|
|
7003
|
+
});
|
|
7004
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
7005
|
+
} else {
|
|
7006
|
+
const tasksConfig = { tasks: config.tasks };
|
|
7007
|
+
if (config.holocronScript !== void 0) tasksConfig.holocronScript = config.holocronScript;
|
|
7008
|
+
const astromechScripts = createAstromech({
|
|
7009
|
+
cwd: input.context.repoRoot,
|
|
7010
|
+
config: tasksConfig,
|
|
7011
|
+
logger
|
|
7012
|
+
});
|
|
7013
|
+
const desired = astromechScripts.packageScripts();
|
|
7014
|
+
const turboContent = astromechScripts.turboConfig();
|
|
7015
|
+
const turboEligible = new Set(turboContent ? Object.keys(JSON.parse(turboContent).tasks) : []);
|
|
7016
|
+
steps.push(await runSyncStep("local", "sync scripts", dryRun, async () => {
|
|
7017
|
+
const changed = await mergePackageJsonScripts(input.context.repoRoot, desired, turboEligible);
|
|
7018
|
+
if (changed === null) return "no package.json";
|
|
7019
|
+
if (changed.length === 0) return `${Object.keys(desired).length} scripts already current`;
|
|
7020
|
+
return `${changed.join(", ")} set`;
|
|
7021
|
+
}));
|
|
7022
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
7023
|
+
}
|
|
6461
7024
|
}
|
|
6462
7025
|
if (stepName === "turbo") {
|
|
6463
7026
|
const astromechTurbo = createAstromech({
|
|
@@ -6479,9 +7042,10 @@ async function runSync(input) {
|
|
|
6479
7042
|
await writeFile(join(input.context.repoRoot, "turbo.json"), content, "utf8");
|
|
6480
7043
|
}));
|
|
6481
7044
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6482
|
-
const
|
|
7045
|
+
const rootPkgRaw = await readFile(join(input.context.repoRoot, "package.json"), "utf8").catch(() => null);
|
|
7046
|
+
const rootPkg = rootPkgRaw ? JSON.parse(rootPkgRaw) : null;
|
|
6483
7047
|
const workspaceYaml = await readFile(join(input.context.repoRoot, "pnpm-workspace.yaml"), "utf8").catch(() => "");
|
|
6484
|
-
const rootScripts =
|
|
7048
|
+
const rootScripts = rootPkg?.scripts ?? {};
|
|
6485
7049
|
const workspaceFix = astromechTurbo.ensureRootWorkspaceMember(workspaceYaml, rootScripts);
|
|
6486
7050
|
if (workspaceFix.changed) {
|
|
6487
7051
|
steps.push(await runSyncStep("local", "fix pnpm-workspace.yaml (root workspace member, #692)", dryRun, async () => {
|
|
@@ -6489,6 +7053,15 @@ async function runSync(input) {
|
|
|
6489
7053
|
}));
|
|
6490
7054
|
print(formatSyncStep(steps[steps.length - 1]));
|
|
6491
7055
|
}
|
|
7056
|
+
if (rootPkgRaw) {
|
|
7057
|
+
const depFix = astromechTurbo.ensureTurboDependency(rootPkgRaw, workspaceFix.content);
|
|
7058
|
+
if (depFix.changed) {
|
|
7059
|
+
steps.push(await runSyncStep("local", "add turbo devDependency", dryRun, async () => {
|
|
7060
|
+
await writeFile(join(input.context.repoRoot, "package.json"), depFix.content, "utf8");
|
|
7061
|
+
}));
|
|
7062
|
+
print(formatSyncStep(steps[steps.length - 1]));
|
|
7063
|
+
}
|
|
7064
|
+
}
|
|
6492
7065
|
}
|
|
6493
7066
|
}
|
|
6494
7067
|
if (stepName === "wiki") {
|
|
@@ -6546,7 +7119,7 @@ async function runSyncStep(capability, step, dryRun, body) {
|
|
|
6546
7119
|
capability,
|
|
6547
7120
|
step,
|
|
6548
7121
|
status: "fail",
|
|
6549
|
-
message:
|
|
7122
|
+
message: errorMessage(err)
|
|
6550
7123
|
};
|
|
6551
7124
|
}
|
|
6552
7125
|
}
|
|
@@ -6572,8 +7145,21 @@ async function writePackageJsonField(repoRoot, field, value) {
|
|
|
6572
7145
|
* Merge `desired` script entries into `package.json#scripts` — sets only the
|
|
6573
7146
|
* listed keys, never touches the rest. Returns the sorted list of keys that
|
|
6574
7147
|
* drifted (empty when all current), or `null` when there is no `package.json`.
|
|
7148
|
+
*
|
|
7149
|
+
* `turboEligible` (#747): task names `turbo.json` fans out — the same set
|
|
7150
|
+
* {@link EnsureRootWorkspaceMemberResult} above already keys off. For those
|
|
7151
|
+
* only, an *existing* script that isn't the generated `holocron run <task>
|
|
7152
|
+
* --` wrapper is left untouched: root may genuinely be a directly-buildable
|
|
7153
|
+
* package (not an orchestrator) with its own real command (`tsc --noEmit`,
|
|
7154
|
+
* `vitest run`, …) for that task. Overwriting it would silently discard the
|
|
7155
|
+
* real command and, once `turbo.json` exists and root is a real workspace
|
|
7156
|
+
* member (#692), reintroduce infinite recursion — `turbo run <task>` →
|
|
7157
|
+
* root's own `<task>` script → the wrapper → `holocron run <task>` sees
|
|
7158
|
+
* `turbo.json` still defines the task → `turbo run <task>` again, forever.
|
|
7159
|
+
* Every other key (non-turbo tasks, `holocron`, `prepare`, …) has no
|
|
7160
|
+
* legitimate direct-command alternative and stays fully generator-owned.
|
|
6575
7161
|
*/
|
|
6576
|
-
async function mergePackageJsonScripts(repoRoot, desired) {
|
|
7162
|
+
async function mergePackageJsonScripts(repoRoot, desired, turboEligible) {
|
|
6577
7163
|
const pkgPath = join(repoRoot, "package.json");
|
|
6578
7164
|
let content;
|
|
6579
7165
|
try {
|
|
@@ -6584,15 +7170,31 @@ async function mergePackageJsonScripts(repoRoot, desired) {
|
|
|
6584
7170
|
const pkg = JSON.parse(content);
|
|
6585
7171
|
const scripts = pkg.scripts ?? {};
|
|
6586
7172
|
const changed = [];
|
|
6587
|
-
for (const [name, command] of Object.entries(desired))
|
|
6588
|
-
scripts[name]
|
|
6589
|
-
|
|
7173
|
+
for (const [name, command] of Object.entries(desired)) {
|
|
7174
|
+
const existing = scripts[name];
|
|
7175
|
+
if (turboEligible.has(name) && existing !== void 0 && !isGeneratedRunWrapper(existing, name)) continue;
|
|
7176
|
+
if (existing !== command) {
|
|
7177
|
+
scripts[name] = command;
|
|
7178
|
+
changed.push(name);
|
|
7179
|
+
}
|
|
6590
7180
|
}
|
|
6591
7181
|
if (changed.length === 0) return [];
|
|
6592
7182
|
pkg.scripts = scripts;
|
|
6593
7183
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
6594
7184
|
return changed.sort();
|
|
6595
7185
|
}
|
|
7186
|
+
/**
|
|
7187
|
+
* Matches the exact shape `packageScripts()` generates for `taskName` —
|
|
7188
|
+
* `<bin> run <taskName> --`, `<bin>` normally `holocron` but substitutable
|
|
7189
|
+
* (`holocronScript`, itself allowed to be multi-word, e.g. `"node
|
|
7190
|
+
packages/cli/dist/cli.mjs"` — see its own test). Checking the *suffix*
|
|
7191
|
+
* rather than the whole string means an old wrapper survives a
|
|
7192
|
+
* `holocronScript` rename intact — it's still recognized as a wrapper (safe
|
|
7193
|
+
* to update to the new one), not mistaken for a real command.
|
|
7194
|
+
*/
|
|
7195
|
+
function isGeneratedRunWrapper(command, taskName) {
|
|
7196
|
+
return command.endsWith(` run ${taskName} --`);
|
|
7197
|
+
}
|
|
6596
7198
|
const README_DESC_START = "<!-- holocron:description -->";
|
|
6597
7199
|
const README_DESC_END = "<!-- /holocron:description -->";
|
|
6598
7200
|
async function updateReadmeDescription(repoRoot, description) {
|
|
@@ -6809,7 +7411,7 @@ const DEFAULT_REPO = "theholocron/.github";
|
|
|
6809
7411
|
* The file set `sync-github` pushes to `theholocron/.github` — the reusable
|
|
6810
7412
|
* `workflow_call` implementations, composite actions and starter-workflow
|
|
6811
7413
|
* metadata, generated by `@theholocron/astromech`. Secondary repos get nothing:
|
|
6812
|
-
* their thin callers are managed by `holocron sync --steps workflows`, which
|
|
7414
|
+
* their thin callers are managed by `holocron repo sync --steps workflows`, which
|
|
6813
7415
|
* executes the resolved config (regex parsing here can't resolve preset spreads
|
|
6814
7416
|
* like `...tasks` from `nodeDocs()` — see issue #464).
|
|
6815
7417
|
*/
|
|
@@ -6835,7 +7437,7 @@ async function runSyncGithub(input) {
|
|
|
6835
7437
|
token,
|
|
6836
7438
|
fetch: input.fetch
|
|
6837
7439
|
});
|
|
6838
|
-
print(`holocron sync
|
|
7440
|
+
print(`holocron sync github${dryRun ? " (dry-run)" : ""}`);
|
|
6839
7441
|
print(` repo: ${repo}`);
|
|
6840
7442
|
if (branch) print(` branch: ${branch}`);
|
|
6841
7443
|
print("");
|
|
@@ -6964,7 +7566,7 @@ async function runSyncGithub(input) {
|
|
|
6964
7566
|
sha: blob.sha
|
|
6965
7567
|
});
|
|
6966
7568
|
} catch (err) {
|
|
6967
|
-
const msg = `failed to create blob for ${file.path}: ${
|
|
7569
|
+
const msg = `failed to create blob for ${file.path}: ${errorMessage(err)}`;
|
|
6968
7570
|
print(` ✗ ${msg}`);
|
|
6969
7571
|
return done({
|
|
6970
7572
|
status: "fail",
|
|
@@ -6978,7 +7580,7 @@ async function runSyncGithub(input) {
|
|
|
6978
7580
|
try {
|
|
6979
7581
|
newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
|
|
6980
7582
|
} catch (err) {
|
|
6981
|
-
const msg = `failed to create tree: ${
|
|
7583
|
+
const msg = `failed to create tree: ${errorMessage(err)}`;
|
|
6982
7584
|
print(` ✗ ${msg}`);
|
|
6983
7585
|
return done({
|
|
6984
7586
|
status: "fail",
|
|
@@ -6992,7 +7594,7 @@ async function runSyncGithub(input) {
|
|
|
6992
7594
|
try {
|
|
6993
7595
|
newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
|
|
6994
7596
|
} catch (err) {
|
|
6995
|
-
const msg = `failed to create commit: ${
|
|
7597
|
+
const msg = `failed to create commit: ${errorMessage(err)}`;
|
|
6996
7598
|
print(` ✗ ${msg}`);
|
|
6997
7599
|
return done({
|
|
6998
7600
|
status: "fail",
|
|
@@ -7011,7 +7613,7 @@ async function runSyncGithub(input) {
|
|
|
7011
7613
|
}
|
|
7012
7614
|
else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
|
|
7013
7615
|
} catch (err) {
|
|
7014
|
-
const msg = `failed to update ref: ${
|
|
7616
|
+
const msg = `failed to update ref: ${errorMessage(err)}`;
|
|
7015
7617
|
print(` ✗ ${msg}`);
|
|
7016
7618
|
return done({
|
|
7017
7619
|
status: "fail",
|
|
@@ -7027,12 +7629,12 @@ async function runSyncGithub(input) {
|
|
|
7027
7629
|
title: message.split("\n")[0],
|
|
7028
7630
|
head: branch,
|
|
7029
7631
|
base: "main",
|
|
7030
|
-
body: "Auto-generated by `holocron sync
|
|
7632
|
+
body: "Auto-generated by `holocron sync github`. Review and merge to apply template updates."
|
|
7031
7633
|
})).html_url;
|
|
7032
7634
|
print(` → PR opened: ${prUrl}`);
|
|
7033
7635
|
} catch (err) {
|
|
7034
7636
|
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: ${
|
|
7637
|
+
else print(` ⚠ PR creation failed: ${errorMessage(err)}`);
|
|
7036
7638
|
}
|
|
7037
7639
|
return done({
|
|
7038
7640
|
status: "ok",
|
|
@@ -7045,7 +7647,7 @@ async function runSyncGithub(input) {
|
|
|
7045
7647
|
//#endregion
|
|
7046
7648
|
//#region src/commands/upgrade-deps.ts
|
|
7047
7649
|
/**
|
|
7048
|
-
* `holocron upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7650
|
+
* `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7049
7651
|
* its `holocron.config.ts` up to the current major.
|
|
7050
7652
|
*
|
|
7051
7653
|
* Two concerns:
|
|
@@ -7060,7 +7662,7 @@ async function runSyncGithub(input) {
|
|
|
7060
7662
|
* This applies the mechanical transforms and flags anything that needs a
|
|
7061
7663
|
* human eye.
|
|
7062
7664
|
*
|
|
7063
|
-
* After running, `pnpm install && holocron ci`.
|
|
7665
|
+
* After running, `pnpm install && holocron run ci`.
|
|
7064
7666
|
*/
|
|
7065
7667
|
const FETCH_TIMEOUT_MS$1 = 4e3;
|
|
7066
7668
|
/** Resolve a package's `latest` dist-tag from the npm registry. */
|
|
@@ -7127,7 +7729,7 @@ function isLegacyConfig(content) {
|
|
|
7127
7729
|
}
|
|
7128
7730
|
/**
|
|
7129
7731
|
* 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`.
|
|
7732
|
+
* effort — the result should always be reviewed and run through `holocron run ci`.
|
|
7131
7733
|
*/
|
|
7132
7734
|
function migrateConfig(content) {
|
|
7133
7735
|
const transforms = [];
|
|
@@ -7253,7 +7855,7 @@ async function runUpgradeDeps(input = {}) {
|
|
|
7253
7855
|
print("");
|
|
7254
7856
|
if (bumps.length === 0 && !configMigrated) print("Nothing to do — already up to date.");
|
|
7255
7857
|
else {
|
|
7256
|
-
print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron ci\``);
|
|
7858
|
+
print(`Next: ${dryRun ? "run without --dry-run, then " : ""}\`pnpm install && holocron run ci\``);
|
|
7257
7859
|
if (configMigrated) print(" review holocron.config.ts before committing.");
|
|
7258
7860
|
}
|
|
7259
7861
|
const status = dryRun ? "dry-run" : "ok";
|
|
@@ -7459,6 +8061,63 @@ async function runUpgradeNode(input) {
|
|
|
7459
8061
|
};
|
|
7460
8062
|
}
|
|
7461
8063
|
//#endregion
|
|
8064
|
+
//#region src/commit-lint/lint-message.ts
|
|
8065
|
+
/**
|
|
8066
|
+
* Lints one commit message string against an already-loaded commitlint
|
|
8067
|
+
* config. The genuinely environment-independent half of commit-message
|
|
8068
|
+
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
8069
|
+
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
8070
|
+
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
8071
|
+
* just lets cosmiconfig discover the invoking repo's own
|
|
8072
|
+
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
8073
|
+
* but linting one message against a loaded config is identical either way.
|
|
8074
|
+
*
|
|
8075
|
+
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
8076
|
+
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
8077
|
+
*/
|
|
8078
|
+
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
8079
|
+
function selectParserOpts(parserPreset) {
|
|
8080
|
+
return parserPreset?.parserOpts;
|
|
8081
|
+
}
|
|
8082
|
+
async function lintCommitMessage(message, loaded) {
|
|
8083
|
+
const opts = {
|
|
8084
|
+
/* istanbul ignore next -- see comment above */
|
|
8085
|
+
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
8086
|
+
plugins: loaded.plugins,
|
|
8087
|
+
/* istanbul ignore next -- see comment above */
|
|
8088
|
+
ignores: loaded.ignores ?? [],
|
|
8089
|
+
defaultIgnores: loaded.defaultIgnores !== false
|
|
8090
|
+
};
|
|
8091
|
+
const outcome = await lint(message, loaded.rules, opts);
|
|
8092
|
+
if (outcome.valid) return [];
|
|
8093
|
+
return outcome.errors.map((error) => ({
|
|
8094
|
+
rule: error.name,
|
|
8095
|
+
message: error.message
|
|
8096
|
+
}));
|
|
8097
|
+
}
|
|
8098
|
+
//#endregion
|
|
8099
|
+
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
8100
|
+
/**
|
|
8101
|
+
* `holocron run lint commit-msg <file>` — a real programmatic replacement for
|
|
8102
|
+
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
8103
|
+
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
8104
|
+
* path to the commit message being written (the arg git's `commit-msg`
|
|
8105
|
+
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
8106
|
+
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
8107
|
+
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
8108
|
+
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
8109
|
+
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
8110
|
+
* runs inside a real repo checkout with a real `node_modules`, not
|
|
8111
|
+
* standalone on a server.
|
|
8112
|
+
*/
|
|
8113
|
+
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
8114
|
+
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
8115
|
+
return {
|
|
8116
|
+
valid: violations.length === 0,
|
|
8117
|
+
violations
|
|
8118
|
+
};
|
|
8119
|
+
}
|
|
8120
|
+
//#endregion
|
|
7462
8121
|
//#region src/config/load-config.ts
|
|
7463
8122
|
/**
|
|
7464
8123
|
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
@@ -7473,14 +8132,19 @@ async function runUpgradeNode(input) {
|
|
|
7473
8132
|
*/
|
|
7474
8133
|
const execFileAsync = promisify(execFile);
|
|
7475
8134
|
/**
|
|
7476
|
-
* Read + parse + resolve `holocron.config.*` from the given
|
|
8135
|
+
* Read + parse + resolve `holocron.config.*` starting from the given
|
|
8136
|
+
* directory, walking up through ancestors to the nearest `.git` boundary
|
|
8137
|
+
* when `cwd` itself has no match — every command is usable from any
|
|
8138
|
+
* subdirectory of a repo, not only its exact root (see
|
|
8139
|
+
* `@theholocron/datapad`'s own doc comment for the stop conditions).
|
|
7477
8140
|
* Throws {@link ConfigFileError} when nothing is found or a file cannot
|
|
7478
|
-
* be loaded, or `ConfigError` when the config
|
|
8141
|
+
* be loaded, or `ConfigError` when the config fails schema checks.
|
|
7479
8142
|
*/
|
|
7480
8143
|
async function loadConfig(cwd) {
|
|
7481
8144
|
const found = await loadConfigFile({
|
|
7482
8145
|
cwd,
|
|
7483
|
-
name: "holocron"
|
|
8146
|
+
name: "holocron",
|
|
8147
|
+
walkUp: true
|
|
7484
8148
|
});
|
|
7485
8149
|
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
8150
|
return {
|
|
@@ -7553,6 +8217,15 @@ function parseGitRemoteUrl(url) {
|
|
|
7553
8217
|
* {@link promptForPositionals} for just that one command — no spawn needed,
|
|
7554
8218
|
* the handler already has everything else it needs.
|
|
7555
8219
|
*
|
|
8220
|
+
* For a command whose handler honours `--dry-run` ({@link
|
|
8221
|
+
* CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
|
|
8222
|
+
* dry run doesn't mutate anything, so there's nothing to ask permission
|
|
8223
|
+
* for — then, once that preview comes back clean and found something to
|
|
8224
|
+
* do, asks the one real decision: "proceed with the real run?". A preview
|
|
8225
|
+
* that finds nothing to do skips that question too. `--skip-dry-run` opts
|
|
8226
|
+
* a top-level invocation out of the preview entirely, straight to the
|
|
8227
|
+
* real run, for when you already know what you want.
|
|
8228
|
+
*
|
|
7556
8229
|
* Every prompt is preceded by a `process.stdin.isTTY` check. A non-TTY run
|
|
7557
8230
|
* (CI, a script, a pipe) throws {@link NonInteractiveError} instead of
|
|
7558
8231
|
* hanging on `@inquirer/prompts` (which otherwise throws its own
|
|
@@ -7566,40 +8239,153 @@ var NonInteractiveError = class extends Error {
|
|
|
7566
8239
|
};
|
|
7567
8240
|
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
7568
8241
|
/**
|
|
8242
|
+
* This repo's own resolved task manifest, filtered to what's actually
|
|
8243
|
+
* runnable locally — `Astromech.runnableTasks()`, reused rather than
|
|
8244
|
+
* reimplemented (deliberately *not* `packageScripts()`, whose
|
|
8245
|
+
* `syncScripts: false` short-circuit returns `{}` for repos — this one
|
|
8246
|
+
* included — that opt out of generated `package.json` scripts but still
|
|
8247
|
+
* have real runnable tasks). Empty (never throws) when there's no config to
|
|
8248
|
+
* resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
|
|
8249
|
+
* as "run ›" simply having no children.
|
|
8250
|
+
*/
|
|
8251
|
+
async function listRunnableTasks(argv) {
|
|
8252
|
+
const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
|
|
8253
|
+
const config = await loadTasksConfig(cwd).catch(() => void 0);
|
|
8254
|
+
return createAstromech({
|
|
8255
|
+
cwd,
|
|
8256
|
+
config
|
|
8257
|
+
}).runnableTasks();
|
|
8258
|
+
}
|
|
8259
|
+
/**
|
|
8260
|
+
* Namespace display order for the `run` menu — deliberately its own thing,
|
|
8261
|
+
* not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
|
|
8262
|
+
* signal first" in an automated gate, a different goal from "how a person
|
|
8263
|
+
* would naturally work through a repo" (build it, clean up its style, check
|
|
8264
|
+
* it's secure, test it, validate it, document it). A namespace absent here
|
|
8265
|
+
* (a repo-specific one this list doesn't know about) sorts after every
|
|
8266
|
+
* named one, in whatever order it was first seen.
|
|
8267
|
+
*/
|
|
8268
|
+
const RUN_MENU_NAMESPACE_ORDER = [
|
|
8269
|
+
"delivery",
|
|
8270
|
+
"sourceQuality",
|
|
8271
|
+
"security",
|
|
8272
|
+
"verification",
|
|
8273
|
+
"platform",
|
|
8274
|
+
"knowledge"
|
|
8275
|
+
];
|
|
8276
|
+
/**
|
|
8277
|
+
* Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
|
|
8278
|
+
* groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
|
|
8279
|
+
* within each group is `taskNames`' own order (already declaration order,
|
|
8280
|
+
* from {@link listRunnableTasks}). A name with no `.` at all (defensive;
|
|
8281
|
+
* every real registry task has a namespace) is its own bucket, keyed by the
|
|
8282
|
+
* full name, sorted after every namespaced group.
|
|
8283
|
+
*/
|
|
8284
|
+
function groupByNamespace(taskNames) {
|
|
8285
|
+
const groups = /* @__PURE__ */ new Map();
|
|
8286
|
+
for (const name of taskNames) {
|
|
8287
|
+
const dot = name.indexOf(".");
|
|
8288
|
+
const key = dot === -1 ? name : name.slice(0, dot);
|
|
8289
|
+
const list = groups.get(key);
|
|
8290
|
+
if (list) list.push(name);
|
|
8291
|
+
else groups.set(key, [name]);
|
|
8292
|
+
}
|
|
8293
|
+
const ordered = /* @__PURE__ */ new Map();
|
|
8294
|
+
const byMenuOrder = (a, b) => {
|
|
8295
|
+
const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
|
|
8296
|
+
const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
|
|
8297
|
+
if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
|
8298
|
+
return 0;
|
|
8299
|
+
};
|
|
8300
|
+
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8301
|
+
return ordered;
|
|
8302
|
+
}
|
|
8303
|
+
/**
|
|
8304
|
+
* Dynamically-built `run` sub-entries for every locally-runnable task
|
|
8305
|
+
* ({@link listRunnableTasks}), grouped by namespace and merged into
|
|
8306
|
+
* `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
|
|
8307
|
+
* itself, since the task set is per-repo, not a static surface this module
|
|
8308
|
+
* can hand-enumerate.
|
|
8309
|
+
*
|
|
8310
|
+
* Each namespace becomes a group with a leading "<namespace> (run it)" item
|
|
8311
|
+
* (`holocron run <namespace>` — every task in it, via
|
|
8312
|
+
* `Astromech.runGroup()`) alongside each of its individual tasks, the exact
|
|
8313
|
+
* same "a group that's also runnable" shape `menuItems` already renders for
|
|
8314
|
+
* `repo sync` / `deploy` — reached here for free by using a space, not the
|
|
8315
|
+
* task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
|
|
8316
|
+
* the real dotted invocation `buildChildArgv` actually spawns).
|
|
8317
|
+
*/
|
|
8318
|
+
function buildRunMenuEntries(taskNames) {
|
|
8319
|
+
const entries = [];
|
|
8320
|
+
for (const [namespace, tasks] of groupByNamespace(taskNames)) {
|
|
8321
|
+
const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
|
|
8322
|
+
if (isRealNamespace) entries.push({
|
|
8323
|
+
name: `run ${namespace}`,
|
|
8324
|
+
description: `Run every ${namespace}.* task in sequence`,
|
|
8325
|
+
positionals: [],
|
|
8326
|
+
supportsDryRun: true,
|
|
8327
|
+
spawnArgs: ["run", namespace]
|
|
8328
|
+
});
|
|
8329
|
+
for (const task of tasks) {
|
|
8330
|
+
const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
|
|
8331
|
+
entries.push({
|
|
8332
|
+
name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
|
|
8333
|
+
description: `Run ${task}`,
|
|
8334
|
+
positionals: [],
|
|
8335
|
+
supportsDryRun: true,
|
|
8336
|
+
spawnArgs: ["run", task]
|
|
8337
|
+
});
|
|
8338
|
+
}
|
|
8339
|
+
}
|
|
8340
|
+
return entries;
|
|
8341
|
+
}
|
|
8342
|
+
/**
|
|
7569
8343
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
7570
8344
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
7571
|
-
* `run <task>`
|
|
7572
|
-
*
|
|
8345
|
+
* `run <task>` has no entry here at all — `cli.ts`'s `$0` handler merges in
|
|
8346
|
+
* {@link buildRunMenuEntries}'s dynamically-built ones instead, since the
|
|
8347
|
+
* task namespace is this repo's own resolved manifest, not a static set
|
|
8348
|
+
* this module can hand-enumerate the way every other entry's positionals
|
|
8349
|
+
* are. `job` and `passthrough` stay un-prompted either way; picking a task
|
|
8350
|
+
* and running it plain covers the common case, and typing
|
|
8351
|
+
* `holocron run <task> <job> -- <args>` directly is one step away for
|
|
8352
|
+
* everything else. `run lint commit-msg <file>` stays excluded entirely:
|
|
8353
|
+
* it's invoked exclusively from `.husky/commit-msg "$1"`, never typed by a
|
|
8354
|
+
* human.
|
|
7573
8355
|
*/
|
|
7574
8356
|
const COMMAND_REGISTRY = [
|
|
7575
|
-
{
|
|
7576
|
-
name: "version",
|
|
7577
|
-
description: "Print the CLI version",
|
|
7578
|
-
positionals: []
|
|
7579
|
-
},
|
|
7580
8357
|
{
|
|
7581
8358
|
name: "clone",
|
|
7582
|
-
description: "Clone
|
|
7583
|
-
positionals: [
|
|
8359
|
+
description: "Clone a single repo (org/repo) or every repo in an org (org)",
|
|
8360
|
+
positionals: [{
|
|
8361
|
+
key: "target",
|
|
8362
|
+
message: "org/repo to clone (or a bare org to clone every repo in it):",
|
|
8363
|
+
type: "input",
|
|
8364
|
+
validate: (v) => v.trim().length > 0 || "org/repo or org is required"
|
|
8365
|
+
}],
|
|
8366
|
+
supportsDryRun: true
|
|
7584
8367
|
},
|
|
7585
8368
|
{
|
|
7586
8369
|
name: "doctor",
|
|
7587
8370
|
description: "Load the config and run a smoke check against every provider",
|
|
7588
|
-
positionals: []
|
|
8371
|
+
positionals: [],
|
|
8372
|
+
supportsDryRun: true
|
|
7589
8373
|
},
|
|
7590
8374
|
{
|
|
7591
|
-
name: "setup",
|
|
8375
|
+
name: "repo setup",
|
|
7592
8376
|
description: "Apply infra setup actions across every configured capability",
|
|
7593
|
-
positionals: []
|
|
8377
|
+
positionals: [],
|
|
8378
|
+
supportsDryRun: true
|
|
7594
8379
|
},
|
|
7595
8380
|
{
|
|
7596
|
-
name: "
|
|
8381
|
+
name: "secrets set",
|
|
7597
8382
|
description: "Set a single secret via the configured `secrets` capability",
|
|
7598
8383
|
positionals: [{
|
|
7599
8384
|
key: "name",
|
|
7600
8385
|
message: "Secret name:",
|
|
7601
8386
|
type: "input"
|
|
7602
|
-
}]
|
|
8387
|
+
}],
|
|
8388
|
+
supportsDryRun: true
|
|
7603
8389
|
},
|
|
7604
8390
|
{
|
|
7605
8391
|
name: "secrets sync",
|
|
@@ -7608,7 +8394,8 @@ const COMMAND_REGISTRY = [
|
|
|
7608
8394
|
key: "environmentId",
|
|
7609
8395
|
message: "Vault environment id:",
|
|
7610
8396
|
type: "input"
|
|
7611
|
-
}]
|
|
8397
|
+
}],
|
|
8398
|
+
supportsDryRun: true
|
|
7612
8399
|
},
|
|
7613
8400
|
{
|
|
7614
8401
|
name: "deploy",
|
|
@@ -7617,52 +8404,66 @@ const COMMAND_REGISTRY = [
|
|
|
7617
8404
|
key: "branch",
|
|
7618
8405
|
message: "Branch to deploy:",
|
|
7619
8406
|
type: "input"
|
|
7620
|
-
}]
|
|
8407
|
+
}],
|
|
8408
|
+
supportsDryRun: true
|
|
7621
8409
|
},
|
|
7622
8410
|
{
|
|
7623
|
-
name: "cleanup-preview",
|
|
8411
|
+
name: "deploy cleanup-preview",
|
|
7624
8412
|
description: "List and delete Cloudflare Pages preview deployments for a GitHub PR",
|
|
7625
8413
|
positionals: [{
|
|
7626
8414
|
key: "pr",
|
|
7627
8415
|
message: "PR number:",
|
|
7628
8416
|
type: "input",
|
|
7629
8417
|
validate: numeric
|
|
7630
|
-
}]
|
|
8418
|
+
}],
|
|
8419
|
+
supportsDryRun: true
|
|
7631
8420
|
},
|
|
7632
8421
|
{
|
|
7633
|
-
name: "
|
|
8422
|
+
name: "deploy on-release",
|
|
8423
|
+
description: "Deploy every workspace package whose delivery.deploy task is `on: release`",
|
|
8424
|
+
positionals: [],
|
|
8425
|
+
supportsDryRun: true
|
|
8426
|
+
},
|
|
8427
|
+
{
|
|
8428
|
+
name: "package bump-versions",
|
|
7634
8429
|
description: "Bump all non-private package versions in lockstep (semantic-release prepareCmd)",
|
|
7635
8430
|
positionals: [{
|
|
7636
8431
|
key: "newVersion",
|
|
7637
8432
|
cliArg: "new-version",
|
|
7638
8433
|
message: "New version:",
|
|
7639
8434
|
type: "input"
|
|
7640
|
-
}]
|
|
8435
|
+
}],
|
|
8436
|
+
supportsDryRun: true
|
|
7641
8437
|
},
|
|
7642
8438
|
{
|
|
7643
|
-
name: "publish",
|
|
8439
|
+
name: "package publish",
|
|
7644
8440
|
description: "Publish @theholocron/* packages to npm",
|
|
7645
|
-
positionals: []
|
|
8441
|
+
positionals: [],
|
|
8442
|
+
supportsDryRun: true
|
|
7646
8443
|
},
|
|
7647
8444
|
{
|
|
7648
|
-
name: "sync",
|
|
8445
|
+
name: "repo sync",
|
|
7649
8446
|
description: "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)",
|
|
7650
|
-
positionals: []
|
|
8447
|
+
positionals: [],
|
|
8448
|
+
supportsDryRun: true
|
|
7651
8449
|
},
|
|
7652
8450
|
{
|
|
7653
|
-
name: "ci",
|
|
8451
|
+
name: "run ci",
|
|
7654
8452
|
description: "Run the merge-gating checks locally, in CI order — 'will CI pass?'",
|
|
7655
|
-
positionals: []
|
|
8453
|
+
positionals: [],
|
|
8454
|
+
supportsDryRun: true
|
|
7656
8455
|
},
|
|
7657
8456
|
{
|
|
7658
|
-
name: "sync
|
|
8457
|
+
name: "sync github",
|
|
7659
8458
|
description: "Sync workflow templates and composite actions to theholocron/.github",
|
|
7660
|
-
positionals: []
|
|
8459
|
+
positionals: [],
|
|
8460
|
+
supportsDryRun: true
|
|
7661
8461
|
},
|
|
7662
8462
|
{
|
|
7663
|
-
name: "sync
|
|
8463
|
+
name: "repo sync readme",
|
|
7664
8464
|
description: "Sync the Installation + Usage block in README.md from package.json",
|
|
7665
|
-
positionals: []
|
|
8465
|
+
positionals: [],
|
|
8466
|
+
supportsDryRun: true
|
|
7666
8467
|
},
|
|
7667
8468
|
{
|
|
7668
8469
|
name: "config show",
|
|
@@ -7670,12 +8471,13 @@ const COMMAND_REGISTRY = [
|
|
|
7670
8471
|
positionals: []
|
|
7671
8472
|
},
|
|
7672
8473
|
{
|
|
7673
|
-
name: "new",
|
|
8474
|
+
name: "new repo",
|
|
7674
8475
|
description: "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)",
|
|
7675
|
-
positionals: []
|
|
8476
|
+
positionals: [],
|
|
8477
|
+
supportsDryRun: true
|
|
7676
8478
|
},
|
|
7677
8479
|
{
|
|
7678
|
-
name: "plugin
|
|
8480
|
+
name: "new plugin",
|
|
7679
8481
|
description: "Scaffold a new @theholocron/holocron-plugin-<slug> package",
|
|
7680
8482
|
positionals: [{
|
|
7681
8483
|
key: "slug",
|
|
@@ -7685,28 +8487,29 @@ const COMMAND_REGISTRY = [
|
|
|
7685
8487
|
key: "vendor",
|
|
7686
8488
|
message: "Vendor display name (PascalCase):",
|
|
7687
8489
|
type: "input"
|
|
7688
|
-
}]
|
|
8490
|
+
}],
|
|
8491
|
+
supportsDryRun: true
|
|
7689
8492
|
},
|
|
7690
8493
|
{
|
|
7691
8494
|
name: "skills install",
|
|
7692
8495
|
description: "Copy skills from @theholocron/skills into .agents/ with agent symlinks",
|
|
7693
8496
|
positionals: [],
|
|
7694
|
-
|
|
8497
|
+
supportsDryRun: true
|
|
7695
8498
|
},
|
|
7696
8499
|
{
|
|
7697
8500
|
name: "skills remove",
|
|
7698
8501
|
description: "Remove installed skills via npx skills remove",
|
|
7699
8502
|
positionals: [],
|
|
7700
|
-
|
|
8503
|
+
supportsDryRun: true
|
|
7701
8504
|
},
|
|
7702
8505
|
{
|
|
7703
8506
|
name: "skills update",
|
|
7704
8507
|
description: "Update installed skills to their latest upstream versions via npx skills update",
|
|
7705
8508
|
positionals: [],
|
|
7706
|
-
|
|
8509
|
+
supportsDryRun: true
|
|
7707
8510
|
},
|
|
7708
8511
|
{
|
|
7709
|
-
name: "upgrade node",
|
|
8512
|
+
name: "repo upgrade node",
|
|
7710
8513
|
description: "Scan the repo and update every Node.js version pin to a new major",
|
|
7711
8514
|
positionals: [{
|
|
7712
8515
|
key: "to",
|
|
@@ -7714,13 +8517,13 @@ const COMMAND_REGISTRY = [
|
|
|
7714
8517
|
type: "input",
|
|
7715
8518
|
validate: numeric
|
|
7716
8519
|
}],
|
|
7717
|
-
|
|
8520
|
+
supportsDryRun: true
|
|
7718
8521
|
},
|
|
7719
8522
|
{
|
|
7720
|
-
name: "upgrade deps",
|
|
8523
|
+
name: "repo upgrade deps",
|
|
7721
8524
|
description: "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API",
|
|
7722
8525
|
positionals: [],
|
|
7723
|
-
|
|
8526
|
+
supportsDryRun: true
|
|
7724
8527
|
},
|
|
7725
8528
|
{
|
|
7726
8529
|
name: "auth set",
|
|
@@ -7729,8 +8532,7 @@ const COMMAND_REGISTRY = [
|
|
|
7729
8532
|
key: "provider",
|
|
7730
8533
|
message: "Provider name:",
|
|
7731
8534
|
type: "input"
|
|
7732
|
-
}]
|
|
7733
|
-
group: "auth"
|
|
8535
|
+
}]
|
|
7734
8536
|
},
|
|
7735
8537
|
{
|
|
7736
8538
|
name: "auth unset",
|
|
@@ -7740,8 +8542,7 @@ const COMMAND_REGISTRY = [
|
|
|
7740
8542
|
message: "Provider:",
|
|
7741
8543
|
type: "select",
|
|
7742
8544
|
choices: listStoredProviders
|
|
7743
|
-
}]
|
|
7744
|
-
group: "auth"
|
|
8545
|
+
}]
|
|
7745
8546
|
},
|
|
7746
8547
|
{
|
|
7747
8548
|
name: "auth check",
|
|
@@ -7751,14 +8552,12 @@ const COMMAND_REGISTRY = [
|
|
|
7751
8552
|
message: "Provider:",
|
|
7752
8553
|
type: "select",
|
|
7753
8554
|
choices: listStoredProviders
|
|
7754
|
-
}]
|
|
7755
|
-
group: "auth"
|
|
8555
|
+
}]
|
|
7756
8556
|
},
|
|
7757
8557
|
{
|
|
7758
8558
|
name: "auth list",
|
|
7759
8559
|
description: "List every provider with a stored bootstrap token",
|
|
7760
|
-
positionals: []
|
|
7761
|
-
group: "auth"
|
|
8560
|
+
positionals: []
|
|
7762
8561
|
}
|
|
7763
8562
|
];
|
|
7764
8563
|
/** Look up a {@link CommandEntry} by its full name — for Layer 3 call sites in `cli.ts`. */
|
|
@@ -7767,36 +8566,89 @@ function getEntry(name) {
|
|
|
7767
8566
|
if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
|
|
7768
8567
|
return entry;
|
|
7769
8568
|
}
|
|
8569
|
+
const GROUP = "group:";
|
|
8570
|
+
/** The "← back" item's value; only offered below the first level. */
|
|
8571
|
+
const BACK = "back";
|
|
8572
|
+
/**
|
|
8573
|
+
* One level of the menu, derived from the registered command names: the next word
|
|
8574
|
+
* of every entry under `path` (`[]` is the top). A word is
|
|
8575
|
+
*
|
|
8576
|
+
* - a **command** when nothing is nested under it (`doctor`);
|
|
8577
|
+
* - a **group** when commands are nested under it (`repo` → `repo setup`, `repo sync`, …),
|
|
8578
|
+
* which can nest as deep as the names go (`repo upgrade` → `repo upgrade node`);
|
|
8579
|
+
*
|
|
8580
|
+
* Every row is a single word, never a "<command> <subcommand>" path: `config show` is
|
|
8581
|
+
* `config ›` then `show`, even when a group holds just one command.
|
|
8582
|
+
*
|
|
8583
|
+
* A path that is itself runnable (`repo sync`, `deploy`) gets a leading "<word> (run it)" item.
|
|
8584
|
+
* Registration order is kept.
|
|
8585
|
+
*/
|
|
8586
|
+
function menuItems(entries, path = []) {
|
|
8587
|
+
const prefix = path.join(" ");
|
|
8588
|
+
const own = prefix ? entries.find((e) => e.name === prefix) : void 0;
|
|
8589
|
+
const under = prefix ? entries.filter((e) => e.name.startsWith(`${prefix} `)) : entries;
|
|
8590
|
+
const haystackOf = (list) => list.map((e) => `${e.name} ${e.description}`.toLowerCase()).join(" ");
|
|
8591
|
+
const items = [];
|
|
8592
|
+
if (own) items.push({
|
|
8593
|
+
name: `${path[path.length - 1]} (run it)`,
|
|
8594
|
+
value: own.name,
|
|
8595
|
+
description: own.description,
|
|
8596
|
+
haystack: haystackOf([own])
|
|
8597
|
+
});
|
|
8598
|
+
const words = [...new Set(under.map((e) => e.name.split(" ")[path.length]))];
|
|
8599
|
+
for (const word of words) {
|
|
8600
|
+
const full = [...path, word].join(" ");
|
|
8601
|
+
const family = under.filter((e) => e.name === full || e.name.startsWith(`${full} `));
|
|
8602
|
+
const self = family.find((e) => e.name === full);
|
|
8603
|
+
const nested = family.filter((e) => e.name !== full);
|
|
8604
|
+
if (nested.length === 0 && self) items.push({
|
|
8605
|
+
name: word,
|
|
8606
|
+
value: full,
|
|
8607
|
+
description: self.description,
|
|
8608
|
+
haystack: haystackOf([self])
|
|
8609
|
+
});
|
|
8610
|
+
else {
|
|
8611
|
+
const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
|
|
8612
|
+
items.push({
|
|
8613
|
+
name: `${word} ›`,
|
|
8614
|
+
value: `${GROUP}${full}`,
|
|
8615
|
+
description: children.join(", "),
|
|
8616
|
+
haystack: haystackOf(family)
|
|
8617
|
+
});
|
|
8618
|
+
}
|
|
8619
|
+
}
|
|
8620
|
+
return items;
|
|
8621
|
+
}
|
|
7770
8622
|
/**
|
|
7771
|
-
* Searchable autocomplete over
|
|
7772
|
-
*
|
|
7773
|
-
*
|
|
7774
|
-
* gracefully as the surface grows and costs nothing when it doesn't.
|
|
8623
|
+
* Searchable autocomplete over one menu level's `items`. `select()` would work too
|
|
8624
|
+
* at these list sizes, but `search()` degrades gracefully as the surface grows and
|
|
8625
|
+
* costs nothing when it doesn't. Resolves to the picked item's `value`.
|
|
7775
8626
|
*/
|
|
7776
|
-
async function
|
|
7777
|
-
|
|
7778
|
-
const picked = await search({
|
|
8627
|
+
async function pickMenuItem(items, message = "What would you like to do?") {
|
|
8628
|
+
return search({
|
|
7779
8629
|
message,
|
|
7780
|
-
source: (term) => searchChoices(
|
|
8630
|
+
source: (term) => searchChoices(items, term)
|
|
7781
8631
|
});
|
|
7782
|
-
return byName.get(picked);
|
|
7783
8632
|
}
|
|
7784
8633
|
/**
|
|
7785
8634
|
* `@inquirer/search`'s `source` callback, factored out as a plain function —
|
|
7786
8635
|
* unit-testable directly instead of only through a mocked `search()` call.
|
|
7787
|
-
* Empty/undefined `term` (nothing typed yet) returns every
|
|
8636
|
+
* Empty/undefined `term` (nothing typed yet) returns every item.
|
|
7788
8637
|
*/
|
|
7789
|
-
function searchChoices(
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
8638
|
+
function searchChoices(items, term) {
|
|
8639
|
+
const needle = term?.toLowerCase();
|
|
8640
|
+
const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
|
|
8641
|
+
const rank = (i) => {
|
|
8642
|
+
if (!needle) return 0;
|
|
8643
|
+
const name = i.name.toLowerCase();
|
|
8644
|
+
return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
|
|
8645
|
+
};
|
|
8646
|
+
return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
|
|
8647
|
+
name: i.name,
|
|
8648
|
+
value: i.value,
|
|
8649
|
+
description: i.description
|
|
7794
8650
|
}));
|
|
7795
8651
|
}
|
|
7796
|
-
function matches(entry, term) {
|
|
7797
|
-
const needle = term.toLowerCase();
|
|
7798
|
-
return entry.name.toLowerCase().includes(needle) || entry.description.toLowerCase().includes(needle);
|
|
7799
|
-
}
|
|
7800
8652
|
/**
|
|
7801
8653
|
* For each of `entry.positionals` not already present in `argv`, prompts for
|
|
7802
8654
|
* it (`input()` or `select()`, per the positional's `type`) and returns the
|
|
@@ -7835,74 +8687,422 @@ async function promptForPositionals(entry, argv) {
|
|
|
7835
8687
|
* menu-launched child should inherit from the parent invocation. Everything
|
|
7836
8688
|
* else (command-specific options) was never captured by the top-level `$0`
|
|
7837
8689
|
* handler in the first place, so there's nothing else to forward.
|
|
8690
|
+
*
|
|
8691
|
+
* `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
|
|
8692
|
+
* dry-run offer needs to force `--dry-run` on for the preview spawn and
|
|
8693
|
+
* force it back off for the real-run spawn that follows, regardless of
|
|
8694
|
+
* what was on the original invocation.
|
|
7838
8695
|
*/
|
|
7839
|
-
function forwardedFlags(argv) {
|
|
8696
|
+
function forwardedFlags(argv, dryRunOverride) {
|
|
7840
8697
|
const out = [];
|
|
7841
8698
|
const tokens = argv.token;
|
|
7842
8699
|
for (const t of tokens ?? []) out.push("--token", t);
|
|
7843
8700
|
if (typeof argv.org === "string") out.push("--org", argv.org);
|
|
7844
8701
|
if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
|
|
7845
|
-
if (argv.dryRun === true) out.push("--dry-run");
|
|
8702
|
+
if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
|
|
7846
8703
|
return out;
|
|
7847
8704
|
}
|
|
7848
|
-
/** `entry.name` split into tokens
|
|
7849
|
-
function buildChildArgv(entry, positionals, parentArgv) {
|
|
8705
|
+
/** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
|
|
8706
|
+
function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
|
|
7850
8707
|
return [
|
|
7851
|
-
...entry.name.split(" "),
|
|
8708
|
+
...entry.spawnArgs ?? entry.name.split(" "),
|
|
7852
8709
|
...positionals,
|
|
7853
|
-
...forwardedFlags(parentArgv)
|
|
8710
|
+
...forwardedFlags(parentArgv, dryRunOverride)
|
|
7854
8711
|
];
|
|
7855
8712
|
}
|
|
7856
|
-
/**
|
|
7857
|
-
|
|
8713
|
+
/**
|
|
8714
|
+
* Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
|
|
8715
|
+
* its handler knows this specific `--dry-run` came from `launchMenu`'s own
|
|
8716
|
+
* preview-then-ask flow, not a direct `holocron <cmd> --dry-run` invocation.
|
|
8717
|
+
* A handler that supports it uses this — never bare `argv.dryRun` — to
|
|
8718
|
+
* decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code
|
|
8719
|
+
* only has special meaning to `launchMenu` itself, so a direct/scripted
|
|
8720
|
+
* `--dry-run` call must never produce it — doing so unconditionally would
|
|
8721
|
+
* silently change the public exit-code contract for a successful dry run.
|
|
8722
|
+
*/
|
|
8723
|
+
const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
|
|
8724
|
+
/**
|
|
8725
|
+
* Spawn `holocron <...args>` inheriting stdio; resolve with its exit code.
|
|
8726
|
+
*
|
|
8727
|
+
* `NO_UPDATE_NOTIFIER` is forced on in the child's env — the parent's own
|
|
8728
|
+
* update-notifier tail still runs after this resolves (see `launchMenu`'s
|
|
8729
|
+
* docstring), so without this the freshly-spawned child independently runs
|
|
8730
|
+
* its own `checkForUpdates()` too and the notice prints twice: once from
|
|
8731
|
+
* the child right after its command output, once more from the parent
|
|
8732
|
+
* once the child exits. `extraEnv` layers on top — used only for {@link
|
|
8733
|
+
* DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
|
|
8734
|
+
*/
|
|
8735
|
+
function spawnChild(args, extraEnv) {
|
|
7858
8736
|
return new Promise((resolve) => {
|
|
7859
|
-
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8737
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8738
|
+
stdio: "inherit",
|
|
8739
|
+
env: {
|
|
8740
|
+
...process.env,
|
|
8741
|
+
NO_UPDATE_NOTIFIER: "1",
|
|
8742
|
+
...extraEnv
|
|
8743
|
+
}
|
|
8744
|
+
});
|
|
7860
8745
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
7861
8746
|
child.on("error", () => resolve(1));
|
|
7862
8747
|
});
|
|
7863
8748
|
}
|
|
7864
8749
|
/**
|
|
7865
|
-
*
|
|
7866
|
-
*
|
|
7867
|
-
* `
|
|
7868
|
-
* `
|
|
7869
|
-
*
|
|
7870
|
-
*
|
|
7871
|
-
|
|
7872
|
-
|
|
8750
|
+
* Whether `launchMenu` should auto-preview a picked command before running
|
|
8751
|
+
* it for real — only when the command's handler actually honours
|
|
8752
|
+
* `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
|
|
8753
|
+
* invocation didn't pass `--skip-dry-run` to opt out of the preview
|
|
8754
|
+
* entirely (an escape hatch for "I already know what I want, skip
|
|
8755
|
+
* straight to the real run").
|
|
8756
|
+
*/
|
|
8757
|
+
function shouldPreviewDryRun(entry, parentArgv) {
|
|
8758
|
+
return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
|
|
8759
|
+
}
|
|
8760
|
+
/**
|
|
8761
|
+
* Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
|
|
8762
|
+
* `[]` for the top), prompt for the picked command's required positionals, spawn
|
|
8763
|
+
* it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
|
|
8764
|
+
* the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
|
|
8765
|
+
* parent's own telemetry flush / update-notifier tail still runs. The parent's
|
|
8766
|
+
* own `command_completed` event fires with command name "unknown" (the middleware
|
|
8767
|
+
* ran before any command was picked) — left as-is rather than suppressed; it's a
|
|
8768
|
+
* real, useful signal ("the menu got used").
|
|
8769
|
+
*
|
|
8770
|
+
* Picking a group opens its submenu, which has a "← back" item; a command ends
|
|
8771
|
+
* the walk. See {@link menuItems} for how a level is built.
|
|
8772
|
+
*
|
|
8773
|
+
* For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
|
|
8774
|
+
* and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
|
|
8775
|
+
* this always previews first — a dry run doesn't mutate anything, so
|
|
8776
|
+
* there's nothing to ask permission for. A preview that exits non-zero
|
|
8777
|
+
* stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
|
|
8778
|
+
* would change) stops here too, skipping the follow-up question since the
|
|
8779
|
+
* real run would have nothing to do either. Otherwise it asks "proceed
|
|
8780
|
+
* with the real run?" before the real spawn — the one actual decision
|
|
8781
|
+
* left to make, since this one does mutate something.
|
|
7873
8782
|
*/
|
|
7874
|
-
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.") {
|
|
8783
|
+
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
|
|
7875
8784
|
if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
|
|
7876
|
-
const
|
|
7877
|
-
|
|
7878
|
-
|
|
8785
|
+
const stack = [startPath];
|
|
8786
|
+
let picked;
|
|
8787
|
+
while (!picked) {
|
|
8788
|
+
const path = stack[stack.length - 1];
|
|
8789
|
+
const items = menuItems(entries, path);
|
|
8790
|
+
if (stack.length > 1) items.push({
|
|
8791
|
+
name: "← back",
|
|
8792
|
+
value: BACK,
|
|
8793
|
+
description: "Return to the previous menu",
|
|
8794
|
+
haystack: "back"
|
|
8795
|
+
});
|
|
8796
|
+
const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
|
|
8797
|
+
if (value === "back") stack.pop();
|
|
8798
|
+
else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
|
|
8799
|
+
else picked = entries.find((e) => e.name === value);
|
|
8800
|
+
}
|
|
8801
|
+
const positionals = await promptForPositionals(picked, {});
|
|
8802
|
+
if (shouldPreviewDryRun(picked, parentArgv)) {
|
|
8803
|
+
const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
|
|
8804
|
+
if (dryExit === 2) {
|
|
8805
|
+
process.exitCode = 0;
|
|
8806
|
+
return;
|
|
8807
|
+
}
|
|
8808
|
+
if (dryExit !== 0) {
|
|
8809
|
+
process.exitCode = dryExit;
|
|
8810
|
+
return;
|
|
8811
|
+
}
|
|
8812
|
+
if (!await confirm({
|
|
8813
|
+
message: "Dry run complete — proceed with the real run?",
|
|
8814
|
+
default: false
|
|
8815
|
+
})) {
|
|
8816
|
+
process.exitCode = 0;
|
|
8817
|
+
return;
|
|
8818
|
+
}
|
|
8819
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
|
|
8820
|
+
return;
|
|
8821
|
+
}
|
|
8822
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
7879
8823
|
}
|
|
7880
8824
|
//#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");
|
|
8825
|
+
//#region src/ui/json-view.ts
|
|
8826
|
+
function isContainer(value) {
|
|
8827
|
+
return typeof value === "object" && value !== null;
|
|
7887
8828
|
}
|
|
7888
|
-
function
|
|
7889
|
-
return
|
|
8829
|
+
function childrenOf(value) {
|
|
8830
|
+
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
7890
8831
|
}
|
|
7891
|
-
function
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
}
|
|
8832
|
+
function scalar(value) {
|
|
8833
|
+
if (typeof value === "string") return chalk.green(JSON.stringify(value));
|
|
8834
|
+
if (typeof value === "number") return chalk.yellow(String(value));
|
|
8835
|
+
if (typeof value === "boolean") return chalk.magenta(String(value));
|
|
8836
|
+
return chalk.dim(String(value === void 0 ? null : value));
|
|
7897
8837
|
}
|
|
7898
|
-
function
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
} catch {}
|
|
8838
|
+
function summary(value) {
|
|
8839
|
+
const n = childrenOf(value).length;
|
|
8840
|
+
const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
|
|
8841
|
+
return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
|
|
7903
8842
|
}
|
|
7904
|
-
|
|
7905
|
-
|
|
8843
|
+
/**
|
|
8844
|
+
* Paths of every container deeper than `depth` levels are left out, so those
|
|
8845
|
+
* render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
|
|
8846
|
+
*/
|
|
8847
|
+
function expandToDepth(value, depth) {
|
|
8848
|
+
const out = /* @__PURE__ */ new Set();
|
|
8849
|
+
const walk = (v, path, level) => {
|
|
8850
|
+
if (!isContainer(v) || level >= depth) return;
|
|
8851
|
+
out.add(path);
|
|
8852
|
+
for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
|
|
8853
|
+
};
|
|
8854
|
+
walk(value, "", 0);
|
|
8855
|
+
return out;
|
|
8856
|
+
}
|
|
8857
|
+
/** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
|
|
8858
|
+
function renderTree(value, { expanded }) {
|
|
8859
|
+
const lines = [];
|
|
8860
|
+
const walk = (v, path, level, label, trailing) => {
|
|
8861
|
+
const indent = " ".repeat(level);
|
|
8862
|
+
const prefix = `${indent}${label}`;
|
|
8863
|
+
if (!isContainer(v)) {
|
|
8864
|
+
lines.push({
|
|
8865
|
+
path,
|
|
8866
|
+
text: `${prefix}${scalar(v)}${trailing}`,
|
|
8867
|
+
foldable: false,
|
|
8868
|
+
expanded: false,
|
|
8869
|
+
level
|
|
8870
|
+
});
|
|
8871
|
+
return;
|
|
8872
|
+
}
|
|
8873
|
+
const kids = childrenOf(v);
|
|
8874
|
+
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
8875
|
+
if (kids.length === 0) {
|
|
8876
|
+
lines.push({
|
|
8877
|
+
path,
|
|
8878
|
+
text: `${prefix}${open}${close}${trailing}`,
|
|
8879
|
+
foldable: false,
|
|
8880
|
+
expanded: false,
|
|
8881
|
+
level
|
|
8882
|
+
});
|
|
8883
|
+
return;
|
|
8884
|
+
}
|
|
8885
|
+
if (!expanded.has(path)) {
|
|
8886
|
+
lines.push({
|
|
8887
|
+
path,
|
|
8888
|
+
text: `${prefix}${summary(v)}${trailing}`,
|
|
8889
|
+
foldable: true,
|
|
8890
|
+
expanded: false,
|
|
8891
|
+
level
|
|
8892
|
+
});
|
|
8893
|
+
return;
|
|
8894
|
+
}
|
|
8895
|
+
lines.push({
|
|
8896
|
+
path,
|
|
8897
|
+
text: `${prefix}${open}`,
|
|
8898
|
+
foldable: true,
|
|
8899
|
+
expanded: true,
|
|
8900
|
+
level
|
|
8901
|
+
});
|
|
8902
|
+
kids.forEach(([k, child], i) => {
|
|
8903
|
+
const comma = i < kids.length - 1 ? "," : "";
|
|
8904
|
+
const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
|
|
8905
|
+
walk(child, `${path}/${k}`, level + 1, childLabel, comma);
|
|
8906
|
+
});
|
|
8907
|
+
lines.push({
|
|
8908
|
+
path: `${path}#close`,
|
|
8909
|
+
text: `${indent}${close}${trailing}`,
|
|
8910
|
+
foldable: false,
|
|
8911
|
+
expanded: false,
|
|
8912
|
+
level
|
|
8913
|
+
});
|
|
8914
|
+
};
|
|
8915
|
+
walk(value, "", 0, "", "");
|
|
8916
|
+
return lines;
|
|
8917
|
+
}
|
|
8918
|
+
/** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
|
|
8919
|
+
function reduceKey(state, key, value, rows) {
|
|
8920
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
8921
|
+
const cur = lines[state.cursor];
|
|
8922
|
+
let { cursor } = state;
|
|
8923
|
+
const expanded = new Set(state.expanded);
|
|
8924
|
+
switch (key) {
|
|
8925
|
+
case "up":
|
|
8926
|
+
cursor = Math.max(0, cursor - 1);
|
|
8927
|
+
break;
|
|
8928
|
+
case "down":
|
|
8929
|
+
cursor = Math.min(lines.length - 1, cursor + 1);
|
|
8930
|
+
break;
|
|
8931
|
+
case "right":
|
|
8932
|
+
if (cur?.foldable) expanded.add(cur.path);
|
|
8933
|
+
break;
|
|
8934
|
+
case "left":
|
|
8935
|
+
if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
|
|
8936
|
+
else if (cur) {
|
|
8937
|
+
const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
|
|
8938
|
+
if (parent >= 0) cursor = parent;
|
|
8939
|
+
}
|
|
8940
|
+
break;
|
|
8941
|
+
case "expandAll": return {
|
|
8942
|
+
cursor,
|
|
8943
|
+
top: state.top,
|
|
8944
|
+
expanded: expandToDepth(value, Infinity)
|
|
8945
|
+
};
|
|
8946
|
+
case "collapseAll": return {
|
|
8947
|
+
cursor: 0,
|
|
8948
|
+
top: 0,
|
|
8949
|
+
expanded: /* @__PURE__ */ new Set()
|
|
8950
|
+
};
|
|
8951
|
+
}
|
|
8952
|
+
const count = renderTree(value, { expanded }).length;
|
|
8953
|
+
cursor = Math.min(cursor, count - 1);
|
|
8954
|
+
let top = state.top;
|
|
8955
|
+
if (cursor < top) top = cursor;
|
|
8956
|
+
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
8957
|
+
return {
|
|
8958
|
+
cursor,
|
|
8959
|
+
top: Math.max(0, top),
|
|
8960
|
+
expanded
|
|
8961
|
+
};
|
|
8962
|
+
}
|
|
8963
|
+
const KEYMAP = {
|
|
8964
|
+
"\x1B[A": "up",
|
|
8965
|
+
k: "up",
|
|
8966
|
+
"\x1B[B": "down",
|
|
8967
|
+
j: "down",
|
|
8968
|
+
"\x1B[C": "right",
|
|
8969
|
+
l: "right",
|
|
8970
|
+
"\r": "right",
|
|
8971
|
+
" ": "right",
|
|
8972
|
+
"\x1B[D": "left",
|
|
8973
|
+
h: "left",
|
|
8974
|
+
E: "expandAll",
|
|
8975
|
+
C: "collapseAll",
|
|
8976
|
+
q: "quit",
|
|
8977
|
+
"\x1B": "quit",
|
|
8978
|
+
"": "quit"
|
|
8979
|
+
};
|
|
8980
|
+
function draw(out, value, state, rows) {
|
|
8981
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
8982
|
+
const view = lines.slice(state.top, state.top + rows).map((l, i) => {
|
|
8983
|
+
const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
|
|
8984
|
+
return state.top + i === state.cursor ? chalk.inverse(row) : row;
|
|
8985
|
+
});
|
|
8986
|
+
const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
|
|
8987
|
+
out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
|
|
8988
|
+
}
|
|
8989
|
+
async function runInteractive(value, depth) {
|
|
8990
|
+
const { stdin, stdout } = process;
|
|
8991
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
|
|
8992
|
+
const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
|
|
8993
|
+
let state = {
|
|
8994
|
+
cursor: 0,
|
|
8995
|
+
top: 0,
|
|
8996
|
+
expanded: expandToDepth(value, depth)
|
|
8997
|
+
};
|
|
8998
|
+
const wasRaw = stdin.isRaw;
|
|
8999
|
+
stdin.setRawMode(true);
|
|
9000
|
+
stdin.resume();
|
|
9001
|
+
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
9002
|
+
const redraw = () => draw(stdout, value, state, rows());
|
|
9003
|
+
stdout.on("resize", redraw);
|
|
9004
|
+
try {
|
|
9005
|
+
redraw();
|
|
9006
|
+
await new Promise((resolve) => {
|
|
9007
|
+
const onData = (buf) => {
|
|
9008
|
+
const key = KEYMAP[buf.toString()];
|
|
9009
|
+
if (key === "quit") {
|
|
9010
|
+
stdin.off("data", onData);
|
|
9011
|
+
resolve();
|
|
9012
|
+
return;
|
|
9013
|
+
}
|
|
9014
|
+
if (key) {
|
|
9015
|
+
state = reduceKey(state, key, value, rows());
|
|
9016
|
+
redraw();
|
|
9017
|
+
}
|
|
9018
|
+
};
|
|
9019
|
+
stdin.on("data", onData);
|
|
9020
|
+
});
|
|
9021
|
+
} finally {
|
|
9022
|
+
stdout.off("resize", redraw);
|
|
9023
|
+
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
9024
|
+
stdin.setRawMode(wasRaw);
|
|
9025
|
+
stdin.pause();
|
|
9026
|
+
}
|
|
9027
|
+
}
|
|
9028
|
+
/**
|
|
9029
|
+
* Print `value` as JSON. Non-TTY stdout always gets plain, valid
|
|
9030
|
+
* `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
|
|
9031
|
+
* coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
|
|
9032
|
+
*/
|
|
9033
|
+
async function viewJson(value, options = {}) {
|
|
9034
|
+
const depth = options.depth ?? Infinity;
|
|
9035
|
+
if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
|
|
9036
|
+
if (!process.stdout.isTTY) {
|
|
9037
|
+
console.log(JSON.stringify(value, null, 2));
|
|
9038
|
+
return;
|
|
9039
|
+
}
|
|
9040
|
+
const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
|
|
9041
|
+
console.log(lines.map((l) => l.text).join("\n"));
|
|
9042
|
+
}
|
|
9043
|
+
//#endregion
|
|
9044
|
+
//#region src/ui/open-path.ts
|
|
9045
|
+
/** Platform-appropriate command to open a path in the OS's file browser. */
|
|
9046
|
+
function openCommand(path) {
|
|
9047
|
+
if (process.platform === "darwin") return {
|
|
9048
|
+
cmd: "open",
|
|
9049
|
+
args: [path]
|
|
9050
|
+
};
|
|
9051
|
+
if (process.platform === "win32") return {
|
|
9052
|
+
cmd: "cmd",
|
|
9053
|
+
args: [
|
|
9054
|
+
"/c",
|
|
9055
|
+
"start",
|
|
9056
|
+
"",
|
|
9057
|
+
path
|
|
9058
|
+
]
|
|
9059
|
+
};
|
|
9060
|
+
return {
|
|
9061
|
+
cmd: "xdg-open",
|
|
9062
|
+
args: [path]
|
|
9063
|
+
};
|
|
9064
|
+
}
|
|
9065
|
+
/**
|
|
9066
|
+
* Open `path` in the OS's default file browser (Finder / Explorer /
|
|
9067
|
+
* whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
|
|
9068
|
+
* it outlives this process, and never throws when the opener binary isn't
|
|
9069
|
+
* on PATH (e.g. no `xdg-open` in a minimal container).
|
|
9070
|
+
*/
|
|
9071
|
+
function openPath(path) {
|
|
9072
|
+
const { cmd, args } = openCommand(path);
|
|
9073
|
+
const child = spawn(cmd, args, {
|
|
9074
|
+
stdio: "ignore",
|
|
9075
|
+
detached: true
|
|
9076
|
+
});
|
|
9077
|
+
child.on("error", () => {});
|
|
9078
|
+
child.unref();
|
|
9079
|
+
}
|
|
9080
|
+
//#endregion
|
|
9081
|
+
//#region src/update-notifier.ts
|
|
9082
|
+
const PACKAGE_NAME = "@theholocron/cli";
|
|
9083
|
+
const CACHE_TTL_MS = 864e5;
|
|
9084
|
+
const FETCH_TIMEOUT_MS = 3e3;
|
|
9085
|
+
function getCacheDir() {
|
|
9086
|
+
return env.get("HOLOCRON_CACHE_DIR") ?? join(homedir(), ".cache", "holocron");
|
|
9087
|
+
}
|
|
9088
|
+
function getCachePath() {
|
|
9089
|
+
return join(getCacheDir(), "update-check.json");
|
|
9090
|
+
}
|
|
9091
|
+
function readCache() {
|
|
9092
|
+
try {
|
|
9093
|
+
return JSON.parse(readFileSync(getCachePath(), "utf8"));
|
|
9094
|
+
} catch {
|
|
9095
|
+
return null;
|
|
9096
|
+
}
|
|
9097
|
+
}
|
|
9098
|
+
function writeCache(entry) {
|
|
9099
|
+
try {
|
|
9100
|
+
mkdirSync(getCacheDir(), { recursive: true });
|
|
9101
|
+
writeFileSync(getCachePath(), JSON.stringify(entry));
|
|
9102
|
+
} catch {}
|
|
9103
|
+
}
|
|
9104
|
+
async function fetchLatestVersion(channel) {
|
|
9105
|
+
try {
|
|
7906
9106
|
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
7907
9107
|
if (!res.ok) return null;
|
|
7908
9108
|
const data = await res.json();
|
|
@@ -8021,6 +9221,19 @@ function resolveOrg(argv, config) {
|
|
|
8021
9221
|
return argv.org ?? env.get("HOLOCRON_ORG") ?? config.org;
|
|
8022
9222
|
}
|
|
8023
9223
|
/**
|
|
9224
|
+
* Print the dim version line once per terminal session — keyed by the
|
|
9225
|
+
* parent shell's PID, since that's stable across repeated bare `holocron`
|
|
9226
|
+
* invocations in the same shell but changes in a new tab/window. A marker
|
|
9227
|
+
* file in the OS tmpdir tracks whether this shell has already seen it, so
|
|
9228
|
+
* re-running `holocron` doesn't repeat the line every time.
|
|
9229
|
+
*/
|
|
9230
|
+
function printVersionHeaderOnce() {
|
|
9231
|
+
const marker = join(tmpdir(), `.holocron-version-shown-${process.ppid}`);
|
|
9232
|
+
if (existsSync(marker)) return;
|
|
9233
|
+
console.log(style.dim(`v${CLI_VERSION}`));
|
|
9234
|
+
writeFileSync(marker, "");
|
|
9235
|
+
}
|
|
9236
|
+
/**
|
|
8024
9237
|
* `buildCliLogger` options derived from a loaded `holocron.config` — the
|
|
8025
9238
|
* level, the Axiom dataset, and the resolved org for a keyring-backed
|
|
8026
9239
|
* Axiom token. Used by every handler that has already called `loadConfig`.
|
|
@@ -8050,23 +9263,31 @@ function tokenContext(rawTokens) {
|
|
|
8050
9263
|
return parseTokenArgs(rawTokens);
|
|
8051
9264
|
} catch (err) {
|
|
8052
9265
|
if (err instanceof TokenParseError) {
|
|
8053
|
-
|
|
9266
|
+
reportError(`--token: ${err.message}`);
|
|
8054
9267
|
process.exitCode = 1;
|
|
8055
9268
|
return null;
|
|
8056
9269
|
}
|
|
8057
9270
|
throw err;
|
|
8058
9271
|
}
|
|
8059
9272
|
}
|
|
9273
|
+
/** Layer 2: a bare group command (`holocron repo`, `holocron repo upgrade`, …) opens the menu at that level. */
|
|
9274
|
+
async function launchGroupMenu(parent, argv) {
|
|
9275
|
+
await launchMenu(COMMAND_REGISTRY, argv, `${parent} — choose a subcommand:`, `Run \`holocron ${parent} --help\` to see available ${parent} subcommands.`, parent.split(" "));
|
|
9276
|
+
}
|
|
8060
9277
|
init(CLI_VERSION);
|
|
8061
9278
|
const updateCheckPromise = checkForUpdates(CLI_VERSION);
|
|
8062
9279
|
let finishCommand = () => {};
|
|
8063
9280
|
/** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
|
|
8064
9281
|
let errorReported = false;
|
|
8065
9282
|
try {
|
|
8066
|
-
await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
|
|
9283
|
+
await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").version(CLI_VERSION).alias("version", "v").parserConfiguration({ "populate--": true }).option("dry-run", {
|
|
8067
9284
|
type: "boolean",
|
|
8068
9285
|
default: false,
|
|
8069
9286
|
describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
|
|
9287
|
+
}).option("skip-dry-run", {
|
|
9288
|
+
type: "boolean",
|
|
9289
|
+
default: false,
|
|
9290
|
+
describe: "Interactive mode only: skip the automatic dry-run preview for a dry-run-capable command and run for real immediately."
|
|
8070
9291
|
}).option("token", {
|
|
8071
9292
|
type: "string",
|
|
8072
9293
|
array: true,
|
|
@@ -8090,20 +9311,21 @@ try {
|
|
|
8090
9311
|
type: "boolean",
|
|
8091
9312
|
default: false,
|
|
8092
9313
|
describe: "Set the log level to error — suppress info and warn."
|
|
9314
|
+
}).option("log", {
|
|
9315
|
+
type: "string",
|
|
9316
|
+
choices: LOG_LEVELS,
|
|
9317
|
+
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
9318
|
}).middleware((argv) => {
|
|
8094
|
-
const name = argv._.slice(0,
|
|
9319
|
+
const name = argv._.slice(0, 3).join(" ") || "unknown";
|
|
8095
9320
|
printRunId = Boolean(argv.debug || argv.verbose);
|
|
8096
9321
|
buildCliLogger(argv, {
|
|
8097
9322
|
command: name,
|
|
8098
9323
|
org: argv.org
|
|
8099
9324
|
});
|
|
8100
9325
|
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", {
|
|
9326
|
+
}).command("clone [target]", "Clone a single repo (org/repo) or every repo in an org (org)", (y) => y.positional("target", {
|
|
8104
9327
|
type: "string",
|
|
8105
|
-
|
|
8106
|
-
describe: "GitHub org to clone (e.g., theholocron)"
|
|
9328
|
+
describe: "org/repo to clone, or a bare org to clone every repo in it (confirms first)."
|
|
8107
9329
|
}).option("dir", {
|
|
8108
9330
|
type: "string",
|
|
8109
9331
|
describe: "Parent directory to clone into (default: ~/Code/<org>)"
|
|
@@ -8114,16 +9336,39 @@ try {
|
|
|
8114
9336
|
try {
|
|
8115
9337
|
token = resolveCloneToken({ cliToken: tokens.cliTokens?.["github"] ?? tokens.cliToken });
|
|
8116
9338
|
} catch (err) {
|
|
8117
|
-
|
|
9339
|
+
reportError(`clone: ${err instanceof AuthError ? err.message : String(err)}`);
|
|
8118
9340
|
process.exitCode = 1;
|
|
8119
9341
|
return;
|
|
8120
9342
|
}
|
|
8121
|
-
|
|
8122
|
-
|
|
9343
|
+
const [targetInput] = await promptForPositionals(getEntry("clone"), argv);
|
|
9344
|
+
const { org, repo } = parseCloneTarget(targetInput);
|
|
9345
|
+
const report = await runClone({
|
|
9346
|
+
org,
|
|
9347
|
+
...repo ? { repo } : {},
|
|
8123
9348
|
token,
|
|
8124
9349
|
dryRun: argv.dryRun,
|
|
8125
|
-
...argv.dir ? { dir: argv.dir } : {}
|
|
8126
|
-
|
|
9350
|
+
...argv.dir ? { dir: argv.dir } : {},
|
|
9351
|
+
...!repo ? { confirmWholeOrg: async ({ total, alreadyCloned, toClone }) => {
|
|
9352
|
+
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`);
|
|
9353
|
+
if (toClone === 0) return true;
|
|
9354
|
+
return confirm({
|
|
9355
|
+
message: `${org} has ${total} repos — ${alreadyCloned} already cloned locally, ${toClone} new. Clone them all?`,
|
|
9356
|
+
default: false
|
|
9357
|
+
});
|
|
9358
|
+
} } : {},
|
|
9359
|
+
confirmOpen: async (openTarget, moreToCome) => {
|
|
9360
|
+
if (!process.stdin.isTTY) return;
|
|
9361
|
+
if (moreToCome && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") return;
|
|
9362
|
+
if (await confirm({
|
|
9363
|
+
message: `Open ${openTarget}?`,
|
|
9364
|
+
default: false
|
|
9365
|
+
})) openPath(openTarget);
|
|
9366
|
+
}
|
|
9367
|
+
});
|
|
9368
|
+
if (report.status === "fail") {
|
|
9369
|
+
if (report.message) reportError(`clone: ${report.message}`);
|
|
9370
|
+
process.exitCode = 1;
|
|
9371
|
+
} else if (report.status === "dry-run" && report.cloned === 0 && env.get("HOLOCRON_DRY_RUN_PREVIEW") === "1") process.exitCode = 2;
|
|
8127
9372
|
}).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
|
|
8128
9373
|
type: "string",
|
|
8129
9374
|
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
@@ -8142,28 +9387,6 @@ try {
|
|
|
8142
9387
|
org: resolveOrg(argv, loaded.resolved)
|
|
8143
9388
|
}
|
|
8144
9389
|
})).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
9390
|
}).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
9391
|
await runSkillsInstall({
|
|
8169
9392
|
loaded: await loadConfig(argv.cwd),
|
|
@@ -8196,331 +9419,214 @@ try {
|
|
|
8196
9419
|
...argv.name ? { name: argv.name } : {}
|
|
8197
9420
|
}).status === "fail") process.exitCode = 1;
|
|
8198
9421
|
}).command("$0", false, () => {}, async (argv) => {
|
|
8199
|
-
await
|
|
8200
|
-
}), () => {}).command("
|
|
9422
|
+
await launchGroupMenu("skills", argv);
|
|
9423
|
+
}), () => {}).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
9424
|
type: "string",
|
|
8202
|
-
|
|
8203
|
-
|
|
9425
|
+
demandOption: true,
|
|
9426
|
+
describe: "Task name (test, typecheck, lint, build, audit, or `ci` for the merge-gating suite). See `holocron run --help`."
|
|
9427
|
+
}).positional("job", {
|
|
8204
9428
|
type: "string",
|
|
8205
|
-
describe: "
|
|
8206
|
-
}).
|
|
9429
|
+
describe: "Sub-job within the task (e.g. `holocron run audit performance`, `holocron run lint commit-msg <file>`). Omit to run every job."
|
|
9430
|
+
}).positional("passthrough", {
|
|
9431
|
+
type: "string",
|
|
9432
|
+
array: true,
|
|
9433
|
+
describe: "Args forwarded to the tool — put them after `--`."
|
|
9434
|
+
}).option("required", {
|
|
8207
9435
|
type: "boolean",
|
|
8208
9436
|
default: false,
|
|
8209
|
-
describe: "
|
|
8210
|
-
}).option("
|
|
9437
|
+
describe: "Fail (exit 1) if this repo has no such task, instead of skipping."
|
|
9438
|
+
}).option("all", {
|
|
9439
|
+
type: "boolean",
|
|
9440
|
+
default: false,
|
|
9441
|
+
describe: "With `run ci`: run every `ci: true` task, not just the required ones."
|
|
9442
|
+
}).option("filter", {
|
|
8211
9443
|
type: "string",
|
|
8212
|
-
describe: "
|
|
8213
|
-
})
|
|
9444
|
+
describe: "turbo --filter=<pkg> passthrough (monorepo)."
|
|
9445
|
+
}), async (argv) => {
|
|
9446
|
+
const { logger } = buildCliLogger(argv, { command: argv.task === "ci" ? "run ci" : argv.task === "lint" && argv.job === "commit-msg" ? "run lint commit-msg" : "run" });
|
|
9447
|
+
const passthrough = [...argv.passthrough ?? [], ...argv["--"] ?? []];
|
|
9448
|
+
if (argv.task === "lint" && argv.job === "commit-msg") {
|
|
9449
|
+
const file = passthrough[0];
|
|
9450
|
+
if (!file) {
|
|
9451
|
+
reportError("run lint commit-msg: missing <file> (the commit message file git passes as $1).");
|
|
9452
|
+
process.exitCode = 1;
|
|
9453
|
+
return;
|
|
9454
|
+
}
|
|
9455
|
+
const result = await lintCommitMsgFile(file, argv.cwd);
|
|
9456
|
+
if (!result.valid) {
|
|
9457
|
+
for (const violation of result.violations) console.error(`✖ ${violation.rule}: ${violation.message}`);
|
|
9458
|
+
process.exitCode = 1;
|
|
9459
|
+
}
|
|
9460
|
+
return;
|
|
9461
|
+
}
|
|
9462
|
+
if (argv.task === "ci") {
|
|
9463
|
+
const ciConfig = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
9464
|
+
if (createAstromech({
|
|
9465
|
+
cwd: argv.cwd,
|
|
9466
|
+
logger,
|
|
9467
|
+
config: ciConfig
|
|
9468
|
+
}).ci({
|
|
9469
|
+
dryRun: argv.dryRun,
|
|
9470
|
+
scope: argv.all ? "all" : "required",
|
|
9471
|
+
...argv.filter ? { filter: argv.filter } : {}
|
|
9472
|
+
}).status === "fail") process.exitCode = 1;
|
|
9473
|
+
return;
|
|
9474
|
+
}
|
|
9475
|
+
const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
9476
|
+
const report = createAstromech({
|
|
9477
|
+
cwd: argv.cwd,
|
|
9478
|
+
logger,
|
|
9479
|
+
config
|
|
9480
|
+
}).run(argv.task, {
|
|
9481
|
+
...argv.job !== void 0 ? { job: argv.job } : {},
|
|
9482
|
+
passthrough,
|
|
9483
|
+
dryRun: argv.dryRun,
|
|
9484
|
+
required: argv.required,
|
|
9485
|
+
...argv.filter ? { filter: argv.filter } : {}
|
|
9486
|
+
});
|
|
9487
|
+
if (report.status === "fail" || report.status === "unknown") process.exitCode = 1;
|
|
9488
|
+
}).command("config show", "Print the resolved holocron config", (y) => y.option("depth", {
|
|
9489
|
+
type: "number",
|
|
9490
|
+
describe: "Collapse objects/arrays nested deeper than N levels (TTY output only)"
|
|
9491
|
+
}).option("interactive", {
|
|
9492
|
+
type: "boolean",
|
|
9493
|
+
describe: "Browse the config in a foldable tree (default on a TTY; --no-interactive for static output)"
|
|
9494
|
+
}), async (argv) => {
|
|
9495
|
+
await viewJson((await loadConfig(argv.cwd)).resolved, {
|
|
9496
|
+
depth: argv.depth,
|
|
9497
|
+
interactive: argv.interactive
|
|
9498
|
+
});
|
|
9499
|
+
}).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) => {
|
|
9500
|
+
const [provider] = await promptForPositionals(getEntry("auth set"), argv);
|
|
9501
|
+
if ((await runAuthSet({
|
|
9502
|
+
provider,
|
|
9503
|
+
...argv.value ? { positional: argv.value } : {},
|
|
9504
|
+
...argv.org ? { org: argv.org } : {}
|
|
9505
|
+
})).status === "fail") process.exitCode = 1;
|
|
9506
|
+
}).command("unset [provider]", "Remove a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
|
|
9507
|
+
const [provider] = await promptForPositionals(getEntry("auth unset"), argv);
|
|
9508
|
+
runAuthUnset({ provider });
|
|
9509
|
+
}).command("check [provider]", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", { type: "string" }), async (argv) => {
|
|
9510
|
+
const [provider] = await promptForPositionals(getEntry("auth check"), argv);
|
|
9511
|
+
if ((await runAuthCheck({
|
|
9512
|
+
provider,
|
|
9513
|
+
...argv.org ? { org: argv.org } : {}
|
|
9514
|
+
})).status === "fail") process.exitCode = 1;
|
|
9515
|
+
}).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
|
|
9516
|
+
await runAuthList();
|
|
9517
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9518
|
+
await launchGroupMenu("auth", argv);
|
|
9519
|
+
}), () => {}).command("$0", false, () => {}, async (argv) => {
|
|
9520
|
+
printVersionHeaderOnce();
|
|
9521
|
+
const runEntries = buildRunMenuEntries(await listRunnableTasks(argv));
|
|
9522
|
+
await launchMenu([...COMMAND_REGISTRY, ...runEntries], argv);
|
|
9523
|
+
}).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
9524
|
type: "string",
|
|
8215
|
-
|
|
8216
|
-
|
|
9525
|
+
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
9526
|
+
}).option("hooks", {
|
|
9527
|
+
type: "boolean",
|
|
9528
|
+
describe: "Install git hooks (.husky/pre-push → holocron run ci). Default: on for protection:'strict'. --no-hooks to skip."
|
|
8217
9529
|
}), async (argv) => {
|
|
8218
9530
|
const tokens = tokenContext(argv.token);
|
|
8219
9531
|
if (!tokens) return;
|
|
8220
|
-
const [name] = await promptForPositionals(getEntry("secret set"), argv);
|
|
8221
|
-
const scopeArg = argv.scope;
|
|
8222
|
-
const scope = parseScope(scopeArg);
|
|
8223
9532
|
const loaded = await loadConfig(argv.cwd);
|
|
8224
9533
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8225
|
-
if ((await
|
|
9534
|
+
if ((await runSetup({
|
|
8226
9535
|
loaded,
|
|
8227
9536
|
context: {
|
|
8228
9537
|
repoRoot: argv.cwd,
|
|
8229
9538
|
dryRun: argv.dryRun,
|
|
9539
|
+
...argv.repo ? { repo: argv.repo } : {},
|
|
8230
9540
|
...tokens,
|
|
8231
9541
|
org: resolveOrg(argv, loaded.resolved)
|
|
8232
9542
|
},
|
|
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."
|
|
9543
|
+
...argv.hooks !== void 0 ? { hooks: argv.hooks } : {}
|
|
9544
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
9545
|
+
}).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", {
|
|
9546
|
+
type: "boolean",
|
|
9547
|
+
describe: "Print what would change without writing",
|
|
9548
|
+
default: false
|
|
8249
9549
|
}), async (argv) => {
|
|
8250
|
-
const tokens = tokenContext(argv.token);
|
|
8251
|
-
if (!tokens) return;
|
|
8252
|
-
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
8253
9550
|
const loaded = await loadConfig(argv.cwd);
|
|
8254
9551
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8255
|
-
|
|
9552
|
+
const report = await runSyncReadme({
|
|
8256
9553
|
loaded,
|
|
8257
9554
|
context: {
|
|
8258
9555
|
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", {
|
|
9556
|
+
dryRun: argv.dryRun
|
|
9557
|
+
}
|
|
9558
|
+
});
|
|
9559
|
+
if (report.status === "fail") {
|
|
9560
|
+
if (report.message) reportError(`repo sync readme: ${report.message}`);
|
|
9561
|
+
process.exitCode = 1;
|
|
9562
|
+
}
|
|
9563
|
+
}).positional("steps", {
|
|
8271
9564
|
type: "string",
|
|
8272
|
-
|
|
8273
|
-
describe: "
|
|
8274
|
-
}).option("
|
|
9565
|
+
array: true,
|
|
9566
|
+
describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
|
|
9567
|
+
}).option("repo", {
|
|
8275
9568
|
type: "string",
|
|
8276
|
-
|
|
8277
|
-
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
9569
|
+
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
8278
9570
|
}), async (argv) => {
|
|
8279
9571
|
const tokens = tokenContext(argv.token);
|
|
8280
9572
|
if (!tokens) return;
|
|
8281
|
-
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
8282
9573
|
const loaded = await loadConfig(argv.cwd);
|
|
8283
9574
|
applyResolvedConfig(argv, loaded.resolved);
|
|
8284
|
-
if ((await
|
|
9575
|
+
if ((await runSync({
|
|
8285
9576
|
loaded,
|
|
8286
9577
|
context: {
|
|
8287
9578
|
repoRoot: argv.cwd,
|
|
8288
9579
|
dryRun: argv.dryRun,
|
|
9580
|
+
...argv.repo ? { repo: argv.repo } : {},
|
|
8289
9581
|
...tokens,
|
|
8290
9582
|
org: resolveOrg(argv, loaded.resolved)
|
|
8291
9583
|
},
|
|
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", {
|
|
9584
|
+
...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
|
|
9585
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
9586
|
+
}).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
9587
|
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)."
|
|
9588
|
+
describe: "Target Node.js major version (e.g., 22)"
|
|
9589
|
+
}).option("from", {
|
|
9590
|
+
type: "number",
|
|
9591
|
+
describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
|
|
8433
9592
|
}), async (argv) => {
|
|
8434
|
-
const
|
|
8435
|
-
|
|
8436
|
-
|
|
9593
|
+
const [to] = await promptForPositionals(getEntry("repo upgrade node"), argv);
|
|
9594
|
+
let extra = [];
|
|
9595
|
+
try {
|
|
9596
|
+
const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
|
|
9597
|
+
const extraRaw = JSON.parse(raw).upgrade?.node?.extra;
|
|
9598
|
+
if (Array.isArray(extraRaw)) extra = extraRaw;
|
|
9599
|
+
} catch {}
|
|
9600
|
+
const report = await runUpgradeNode({
|
|
9601
|
+
to: Number(to),
|
|
9602
|
+
...argv.from != null ? { from: argv.from } : {},
|
|
8437
9603
|
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
9604
|
dryRun: argv.dryRun,
|
|
8444
|
-
|
|
8445
|
-
...argv.filter ? { filter: argv.filter } : {}
|
|
9605
|
+
extra
|
|
8446
9606
|
});
|
|
8447
|
-
if (report.status === "fail"
|
|
8448
|
-
|
|
9607
|
+
if (report.status === "fail") {
|
|
9608
|
+
if (report.message) reportError(`upgrade node: ${report.message}`);
|
|
9609
|
+
process.exitCode = 1;
|
|
9610
|
+
}
|
|
9611
|
+
}).command("deps", "Bump every @theholocron/* pin to latest and migrate holocron.config.ts to the current preset API", (yy) => yy.option("pins-only", {
|
|
8449
9612
|
type: "boolean",
|
|
8450
9613
|
default: false,
|
|
8451
|
-
describe: "
|
|
8452
|
-
}).option("filter", {
|
|
8453
|
-
type: "string",
|
|
8454
|
-
describe: "turbo --filter=<pkg> passthrough (monorepo)."
|
|
9614
|
+
describe: "Only bump the catalog pins — skip the holocron.config.ts migration"
|
|
8455
9615
|
}), async (argv) => {
|
|
8456
|
-
const
|
|
8457
|
-
const config = await loadTasksConfig(argv.cwd).catch(() => void 0);
|
|
8458
|
-
if (createAstromech({
|
|
9616
|
+
const report = await runUpgradeDeps({
|
|
8459
9617
|
cwd: argv.cwd,
|
|
8460
|
-
logger,
|
|
8461
|
-
config
|
|
8462
|
-
}).ci({
|
|
8463
9618
|
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)}`);
|
|
9619
|
+
pinsOnly: argv.pinsOnly
|
|
9620
|
+
});
|
|
9621
|
+
if (report.status === "fail") {
|
|
9622
|
+
if (report.message) reportError(`upgrade deps: ${report.message}`);
|
|
8494
9623
|
process.exitCode = 1;
|
|
8495
|
-
return;
|
|
8496
9624
|
}
|
|
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", {
|
|
9625
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9626
|
+
await launchGroupMenu("repo upgrade", argv);
|
|
9627
|
+
}), () => {}).command("$0", false, () => {}, async (argv) => {
|
|
9628
|
+
await launchGroupMenu("repo", argv);
|
|
9629
|
+
}), () => {}).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
9630
|
type: "string",
|
|
8525
9631
|
describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
|
|
8526
9632
|
}).positional("name", {
|
|
@@ -8569,7 +9675,7 @@ try {
|
|
|
8569
9675
|
}).option("verify", {
|
|
8570
9676
|
type: "boolean",
|
|
8571
9677
|
default: true,
|
|
8572
|
-
describe: "Run pnpm install + holocron setup after bootstrapping (--no-verify skips)"
|
|
9678
|
+
describe: "Run pnpm install + holocron repo setup after bootstrapping (--no-verify skips)"
|
|
8573
9679
|
}), async (argv) => {
|
|
8574
9680
|
try {
|
|
8575
9681
|
const tokens = tokenContext(argv.token);
|
|
@@ -8745,12 +9851,12 @@ try {
|
|
|
8745
9851
|
}) === "yes";
|
|
8746
9852
|
if (skills.length === 0) skills = parseTopics(await input({ message: "Agent skills (comma-separated, optional):" }));
|
|
8747
9853
|
if (!type) {
|
|
8748
|
-
|
|
9854
|
+
reportError("new: template type is required");
|
|
8749
9855
|
process.exitCode = 1;
|
|
8750
9856
|
return;
|
|
8751
9857
|
}
|
|
8752
9858
|
if (!name) {
|
|
8753
|
-
|
|
9859
|
+
reportError("new: repo name is required");
|
|
8754
9860
|
process.exitCode = 1;
|
|
8755
9861
|
return;
|
|
8756
9862
|
}
|
|
@@ -8779,13 +9885,13 @@ try {
|
|
|
8779
9885
|
})).status === "fail") process.exitCode = 1;
|
|
8780
9886
|
} catch (err) {
|
|
8781
9887
|
if (err instanceof NewError) {
|
|
8782
|
-
|
|
9888
|
+
reportError(`new: ${err.message}`);
|
|
8783
9889
|
process.exitCode = 1;
|
|
8784
9890
|
return;
|
|
8785
9891
|
}
|
|
8786
9892
|
throw err;
|
|
8787
9893
|
}
|
|
8788
|
-
}).command("plugin
|
|
9894
|
+
}).command("plugin [slug] [vendor]", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
|
|
8789
9895
|
type: "string",
|
|
8790
9896
|
describe: "Package slug (kebab-case)"
|
|
8791
9897
|
}).positional("vendor", {
|
|
@@ -8809,7 +9915,7 @@ try {
|
|
|
8809
9915
|
describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
|
|
8810
9916
|
}), async (argv) => {
|
|
8811
9917
|
try {
|
|
8812
|
-
const [slug, vendor] = await promptForPositionals(getEntry("plugin
|
|
9918
|
+
const [slug, vendor] = await promptForPositionals(getEntry("new plugin"), argv);
|
|
8813
9919
|
const { capability, vendorEnv, baseUrl } = await resolvePluginCreateInputs({
|
|
8814
9920
|
capability: argv.capability,
|
|
8815
9921
|
vendorEnv: argv.vendorEnv,
|
|
@@ -8838,92 +9944,315 @@ try {
|
|
|
8838
9944
|
}).status === "fail") process.exitCode = 1;
|
|
8839
9945
|
} catch (err) {
|
|
8840
9946
|
if (err instanceof PluginCreateError) {
|
|
8841
|
-
|
|
9947
|
+
reportError(`plugin create: ${err.message}`);
|
|
8842
9948
|
process.exitCode = 1;
|
|
8843
9949
|
return;
|
|
8844
9950
|
}
|
|
8845
9951
|
throw err;
|
|
8846
9952
|
}
|
|
8847
|
-
}).command("
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
9953
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
9954
|
+
await launchGroupMenu("new", argv);
|
|
9955
|
+
}), () => {}).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", {
|
|
9956
|
+
type: "string",
|
|
9957
|
+
describe: "Secret name (e.g., NPM_TOKEN)"
|
|
9958
|
+
}).positional("value", {
|
|
9959
|
+
type: "string",
|
|
9960
|
+
describe: "Secret value (positional). If omitted, sources from --from-stdin, --from-env, or env var matching <name>."
|
|
9961
|
+
}).option("from-stdin", {
|
|
9962
|
+
type: "boolean",
|
|
9963
|
+
default: false,
|
|
9964
|
+
describe: "Read the secret value from stdin"
|
|
9965
|
+
}).option("from-env", {
|
|
9966
|
+
type: "string",
|
|
9967
|
+
describe: "Read the secret value from the named env var (otherwise: env var matching <name>)"
|
|
9968
|
+
}).option("scope", {
|
|
9969
|
+
type: "string",
|
|
9970
|
+
default: "repo",
|
|
9971
|
+
describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
|
|
8853
9972
|
}), async (argv) => {
|
|
8854
|
-
const
|
|
8855
|
-
|
|
8856
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
8860
|
-
|
|
8861
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
9973
|
+
const tokens = tokenContext(argv.token);
|
|
9974
|
+
if (!tokens) return;
|
|
9975
|
+
const [name] = await promptForPositionals(getEntry("secrets set"), argv);
|
|
9976
|
+
const scopeArg = argv.scope;
|
|
9977
|
+
const scope = parseScope(scopeArg);
|
|
9978
|
+
const loaded = await loadConfig(argv.cwd);
|
|
9979
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
9980
|
+
if ((await runSecretSet({
|
|
9981
|
+
loaded,
|
|
9982
|
+
context: {
|
|
9983
|
+
repoRoot: argv.cwd,
|
|
9984
|
+
dryRun: argv.dryRun,
|
|
9985
|
+
...tokens,
|
|
9986
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
9987
|
+
},
|
|
9988
|
+
name,
|
|
9989
|
+
...argv.value ? { value: argv.value } : {},
|
|
9990
|
+
...argv.fromStdin ? { fromStdin: true } : {},
|
|
9991
|
+
...argv.fromEnv ? { fromEnv: argv.fromEnv } : {},
|
|
9992
|
+
scope
|
|
9993
|
+
})).status === "fail") process.exitCode = 1;
|
|
9994
|
+
}).command("sync [environmentId]", "Read a vault environment + fan KEY=VALUEs out to secrets + deployment env vars", (y) => y.positional("environmentId", {
|
|
9995
|
+
type: "string",
|
|
9996
|
+
describe: "Vault environment id to read (1P Environment id, etc.)"
|
|
9997
|
+
}).option("project-id", {
|
|
9998
|
+
type: "string",
|
|
9999
|
+
describe: "Deployment project id (e.g., Vercel prj_*). Required when deployment is loaded."
|
|
10000
|
+
}).option("target", {
|
|
10001
|
+
type: "array",
|
|
10002
|
+
default: ["production", "preview"],
|
|
10003
|
+
describe: "Deployment targets to sync to. Defaults to production + preview."
|
|
10004
|
+
}).option("github-secret", {
|
|
10005
|
+
type: "array",
|
|
10006
|
+
describe: "Vault key(s) to push to GH Actions secrets (repeatable). Omit to push every key."
|
|
10007
|
+
}).option("github-secret-scope", {
|
|
10008
|
+
type: "string",
|
|
10009
|
+
default: "repo",
|
|
10010
|
+
describe: "Scope applied to every --github-secret key: \"repo\" (default), \"env=<name>\", or \"org=<name>\"."
|
|
10011
|
+
}), async (argv) => {
|
|
10012
|
+
const tokens = tokenContext(argv.token);
|
|
10013
|
+
if (!tokens) return;
|
|
10014
|
+
const githubSecretScope = parseScope(argv.githubSecretScope);
|
|
10015
|
+
const [environmentId] = await promptForPositionals(getEntry("secrets sync"), argv);
|
|
10016
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10017
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10018
|
+
if ((await runSecretsSync({
|
|
10019
|
+
loaded,
|
|
10020
|
+
context: {
|
|
10021
|
+
repoRoot: argv.cwd,
|
|
10022
|
+
dryRun: argv.dryRun,
|
|
10023
|
+
...tokens,
|
|
10024
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10025
|
+
},
|
|
10026
|
+
environmentId,
|
|
10027
|
+
...argv.projectId ? { projectId: argv.projectId } : {},
|
|
10028
|
+
...argv.githubSecret?.length ? { githubSecretKeys: argv.githubSecret } : {},
|
|
10029
|
+
githubSecretScope,
|
|
10030
|
+
targets: argv.target
|
|
10031
|
+
})).summary.fail > 0) process.exitCode = 1;
|
|
10032
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
10033
|
+
await launchGroupMenu("secrets", argv);
|
|
10034
|
+
}), () => {}).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", {
|
|
10035
|
+
type: "string",
|
|
10036
|
+
default: "theholocron/.github",
|
|
10037
|
+
describe: "Target org/repo (default: theholocron/.github)"
|
|
10038
|
+
}).option("branch", {
|
|
10039
|
+
type: "string",
|
|
10040
|
+
describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
|
|
10041
|
+
}).option("pr", {
|
|
10042
|
+
type: "boolean",
|
|
10043
|
+
default: false,
|
|
10044
|
+
describe: "Open a PR after pushing to --branch (no-op without --branch)"
|
|
10045
|
+
}).option("message", {
|
|
10046
|
+
type: "string",
|
|
10047
|
+
describe: "Commit message (default: chore: sync from theholocron/holocron)"
|
|
10048
|
+
}).option("output-dir", {
|
|
10049
|
+
type: "string",
|
|
10050
|
+
describe: "Write generated files to this local directory instead of pushing (for validation)"
|
|
10051
|
+
}), async (argv) => {
|
|
10052
|
+
const outputDir = argv["output-dir"];
|
|
10053
|
+
const parsed = tokenContext(argv.token);
|
|
10054
|
+
if (!parsed) return;
|
|
10055
|
+
let token;
|
|
10056
|
+
if (outputDir) token = "no-token-needed";
|
|
10057
|
+
else try {
|
|
10058
|
+
token = resolveSyncToken({ cliToken: parsed.cliTokens?.["github"] ?? parsed.cliToken });
|
|
10059
|
+
} catch (err) {
|
|
10060
|
+
reportError(`sync-github: ${err instanceof AuthError ? err.message : String(err)}`);
|
|
8870
10061
|
process.exitCode = 1;
|
|
10062
|
+
return;
|
|
8871
10063
|
}
|
|
8872
|
-
|
|
10064
|
+
if ((await runSyncGithub({
|
|
10065
|
+
token,
|
|
10066
|
+
repo: argv.repo,
|
|
10067
|
+
dryRun: argv.dryRun,
|
|
10068
|
+
...argv.branch ? { branch: argv.branch } : {},
|
|
10069
|
+
...argv.pr ? { createPr: true } : {},
|
|
10070
|
+
...argv.message ? { message: argv.message } : {},
|
|
10071
|
+
...outputDir ? { outputDir } : {}
|
|
10072
|
+
})).status === "fail") process.exitCode = 1;
|
|
10073
|
+
}).command("$0", false, () => {}, async (argv) => {
|
|
10074
|
+
await launchGroupMenu("sync", argv);
|
|
10075
|
+
}), () => {}).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", {
|
|
10076
|
+
type: "string",
|
|
10077
|
+
describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
|
|
10078
|
+
}), async (argv) => {
|
|
10079
|
+
const [newVersion] = await promptForPositionals(getEntry("package bump-versions"), argv);
|
|
10080
|
+
if ((await runNpmBumpVersions({
|
|
10081
|
+
version: newVersion,
|
|
10082
|
+
cwd: argv.cwd,
|
|
10083
|
+
dryRun: argv.dryRun
|
|
10084
|
+
})).status === "fail") process.exitCode = 1;
|
|
10085
|
+
}).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
|
|
8873
10086
|
type: "boolean",
|
|
8874
10087
|
default: false,
|
|
8875
|
-
describe: "
|
|
10088
|
+
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."
|
|
10089
|
+
}).option("tag", {
|
|
10090
|
+
type: "string",
|
|
10091
|
+
describe: "npm distribution tag. Defaults to alpha for --initial, latest for a steady-state publish."
|
|
10092
|
+
}).option("otp", {
|
|
10093
|
+
type: "string",
|
|
10094
|
+
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
10095
|
+
}).option("sync-trust", {
|
|
10096
|
+
type: "array",
|
|
10097
|
+
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."
|
|
10098
|
+
}).option("repo", {
|
|
10099
|
+
type: "string",
|
|
10100
|
+
describe: "owner/repo for Trusted Publisher config (--sync-trust). Defaults to theholocron/holocron."
|
|
10101
|
+
}).option("provenance", {
|
|
10102
|
+
type: "boolean",
|
|
10103
|
+
default: true,
|
|
10104
|
+
describe: "Publish with --provenance (steady-state only). Default on — free under CI/OIDC."
|
|
10105
|
+
}).option("skip-already-published", {
|
|
10106
|
+
type: "boolean",
|
|
10107
|
+
default: false,
|
|
10108
|
+
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
10109
|
}), async (argv) => {
|
|
8877
|
-
|
|
10110
|
+
if (argv.syncTrust) {
|
|
10111
|
+
const [oldFile, newFile] = argv.syncTrust;
|
|
10112
|
+
if (!oldFile || !newFile) {
|
|
10113
|
+
reportError("publish --sync-trust requires exactly two values: <old-file> <new-file>");
|
|
10114
|
+
process.exitCode = 1;
|
|
10115
|
+
return;
|
|
10116
|
+
}
|
|
10117
|
+
if ((await runSyncTrust({
|
|
10118
|
+
cwd: argv.cwd,
|
|
10119
|
+
oldFile,
|
|
10120
|
+
newFile,
|
|
10121
|
+
dryRun: argv.dryRun,
|
|
10122
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
10123
|
+
})).status === "fail") process.exitCode = 1;
|
|
10124
|
+
return;
|
|
10125
|
+
}
|
|
10126
|
+
if (argv.initial) {
|
|
10127
|
+
if ((await runPublish({
|
|
10128
|
+
cwd: argv.cwd,
|
|
10129
|
+
dryRun: argv.dryRun,
|
|
10130
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
10131
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
10132
|
+
})).status === "fail") process.exitCode = 1;
|
|
10133
|
+
return;
|
|
10134
|
+
}
|
|
10135
|
+
if ((await runSteadyPublish({
|
|
8878
10136
|
cwd: argv.cwd,
|
|
8879
10137
|
dryRun: argv.dryRun,
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
}
|
|
10138
|
+
provenance: argv.provenance,
|
|
10139
|
+
skipAlreadyPublished: argv.skipAlreadyPublished,
|
|
10140
|
+
...argv.tag ? { tag: argv.tag } : {},
|
|
10141
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
10142
|
+
})).status === "fail") process.exitCode = 1;
|
|
8886
10143
|
}).command("$0", false, () => {}, async (argv) => {
|
|
8887
|
-
await
|
|
8888
|
-
}), () => {}).command("
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
10144
|
+
await launchGroupMenu("package", argv);
|
|
10145
|
+
}), () => {}).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", {
|
|
10146
|
+
type: "number",
|
|
10147
|
+
describe: "PR number to clean up"
|
|
10148
|
+
}).option("project", {
|
|
10149
|
+
type: "string",
|
|
10150
|
+
demandOption: true,
|
|
10151
|
+
describe: "Cloudflare Pages project name (e.g. theholocron-preview)"
|
|
10152
|
+
}).option("repo", {
|
|
10153
|
+
type: "string",
|
|
10154
|
+
describe: "GitHub repo as owner/name — defaults to the repo in holocron.config"
|
|
10155
|
+
}), async (argv) => {
|
|
10156
|
+
const tokens = tokenContext(argv.token);
|
|
10157
|
+
if (!tokens) return;
|
|
10158
|
+
const [pr] = await promptForPositionals(getEntry("deploy cleanup-preview"), argv);
|
|
10159
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10160
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10161
|
+
if ((await runCleanupPreview({
|
|
10162
|
+
loaded,
|
|
10163
|
+
context: {
|
|
10164
|
+
repoRoot: argv.cwd,
|
|
10165
|
+
dryRun: argv.dryRun,
|
|
10166
|
+
...tokens,
|
|
10167
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10168
|
+
},
|
|
10169
|
+
prNumber: Number(pr),
|
|
10170
|
+
project: argv.project,
|
|
10171
|
+
...argv.repo ? { repo: argv.repo } : {}
|
|
8894
10172
|
})).status === "fail") process.exitCode = 1;
|
|
8895
|
-
}).command("
|
|
8896
|
-
|
|
8897
|
-
|
|
8898
|
-
|
|
8899
|
-
|
|
8900
|
-
|
|
8901
|
-
|
|
8902
|
-
|
|
10173
|
+
}).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", {
|
|
10174
|
+
type: "string",
|
|
10175
|
+
default: "",
|
|
10176
|
+
describe: "The release's channel: its prerelease identifier (`alpha`); empty or `main` for a stable release."
|
|
10177
|
+
}).option("from", {
|
|
10178
|
+
type: "string",
|
|
10179
|
+
describe: "The previous release's commit, to diff against. Omit when there is none."
|
|
10180
|
+
}).option("to", {
|
|
10181
|
+
type: "string",
|
|
10182
|
+
describe: "The release commit. Default HEAD."
|
|
10183
|
+
}), async (argv) => {
|
|
10184
|
+
if ((await deployOnRelease({
|
|
10185
|
+
cwd: argv.cwd,
|
|
10186
|
+
channel: argv.channel,
|
|
10187
|
+
...argv.from ? { from: argv.from } : {},
|
|
10188
|
+
...argv.to ? { to: argv.to } : {},
|
|
10189
|
+
dryRun: argv.dryRun,
|
|
10190
|
+
print: (line) => console.log(line)
|
|
10191
|
+
})).status === "fail") process.exitCode = 1;
|
|
10192
|
+
}).positional("branch", {
|
|
10193
|
+
type: "string",
|
|
10194
|
+
describe: "Git branch to deploy (omit when using --files)"
|
|
10195
|
+
}).option("project-id", {
|
|
10196
|
+
type: "string",
|
|
10197
|
+
describe: "Deployment project id (required to trigger a deployment, e.g. Vercel prj_*)"
|
|
10198
|
+
}).option("target", {
|
|
10199
|
+
type: "string",
|
|
10200
|
+
choices: ["production", "staging"],
|
|
10201
|
+
describe: "Named environment to deploy into. Omit for a branch preview."
|
|
10202
|
+
}).option("files", {
|
|
10203
|
+
type: "string",
|
|
10204
|
+
describe: "Deploy from a local directory of source files instead of a Git branch — calls deployFunction(), no linked repo required. Conflicts with `branch`."
|
|
10205
|
+
}).conflicts("files", "branch"), async (argv) => {
|
|
10206
|
+
if (!argv.projectId) {
|
|
10207
|
+
reportError("deploy: missing required argument --project-id");
|
|
10208
|
+
process.exitCode = 1;
|
|
10209
|
+
return;
|
|
10210
|
+
}
|
|
10211
|
+
const tokens = tokenContext(argv.token);
|
|
10212
|
+
if (!tokens) return;
|
|
10213
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10214
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10215
|
+
const context = {
|
|
10216
|
+
repoRoot: argv.cwd,
|
|
10217
|
+
dryRun: argv.dryRun,
|
|
10218
|
+
...tokens,
|
|
10219
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10220
|
+
};
|
|
10221
|
+
if (argv.files) {
|
|
10222
|
+
if ((await runDeployFromFiles({
|
|
10223
|
+
loaded,
|
|
10224
|
+
context,
|
|
10225
|
+
projectId: argv.projectId,
|
|
10226
|
+
dir: argv.files,
|
|
10227
|
+
...argv.target ? { target: argv.target } : {}
|
|
10228
|
+
})).status === "fail") process.exitCode = 1;
|
|
10229
|
+
return;
|
|
10230
|
+
}
|
|
10231
|
+
const [branch] = await promptForPositionals(getEntry("deploy"), argv);
|
|
10232
|
+
if ((await runDeploy({
|
|
10233
|
+
loaded,
|
|
10234
|
+
context,
|
|
10235
|
+
projectId: argv.projectId,
|
|
10236
|
+
branch,
|
|
10237
|
+
...argv.target ? { target: argv.target } : {}
|
|
8903
10238
|
})).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
10239
|
}).strict().help().epilogue(`Execution contexts:
|
|
8911
10240
|
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
10241
|
if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
|
|
8913
10242
|
captureException(err);
|
|
8914
|
-
|
|
10243
|
+
reportError(err.message);
|
|
8915
10244
|
errorReported = true;
|
|
8916
10245
|
process.exitCode = 1;
|
|
8917
10246
|
return;
|
|
8918
10247
|
}
|
|
8919
10248
|
if (err) throw err;
|
|
8920
|
-
|
|
10249
|
+
reportError(msg);
|
|
8921
10250
|
errorReported = true;
|
|
8922
10251
|
process.exitCode = 1;
|
|
8923
10252
|
}).parse();
|
|
8924
10253
|
} catch (err) {
|
|
8925
10254
|
captureException(err);
|
|
8926
|
-
if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name))
|
|
10255
|
+
if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) reportError(err.message);
|
|
8927
10256
|
if (!process.exitCode) process.exitCode = 1;
|
|
8928
10257
|
}
|
|
8929
10258
|
finishCommand(!process.exitCode);
|