@theholocron/cli 5.0.0-alpha.152 → 5.0.0-alpha.154
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 +3 -3
- package/dist/cli.mjs +1345 -1600
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +82 -162
- package/dist/index.mjs +9 -162
- package/dist/plugin/capabilities.d.mts +32 -30
- package/dist/plugin/capabilities.mjs +1 -1
- package/package.json +11 -16
package/dist/cli.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSyn
|
|
|
4
4
|
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
5
5
|
import path, { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
7
|
-
import { KNOWN_WORKFLOWS, createAstromech, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
7
|
+
import { KNOWN_WORKFLOWS, createAlexignore, createAlexrc, createAstromech, createCommitMsg, createDependabot, createDevmoji, createEditorconfig, createEditorconfigChecker, createHeader, createLabeler, createPreCommit, createPrePush, createPrepareCommitMsg, createYamlignore, createYamllint, deployOnRelease, extractPreviewConfig, readWorkspacePackages, reusableTemplates } from "@theholocron/astromech";
|
|
8
8
|
import { loadTasksConfig } from "@theholocron/astromech/config";
|
|
9
|
+
import { lintCommitMsgFile } from "@theholocron/astromech/lint";
|
|
9
10
|
import { LOG_LEVELS, NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
|
|
10
11
|
import yargs from "yargs";
|
|
11
12
|
import { hideBin } from "yargs/helpers";
|
|
@@ -14,14 +15,16 @@ import { createEnvLookup, firstEnv, withEnvPrefix } from "@theholocron/env-utils
|
|
|
14
15
|
import { Entry, findCredentials } from "@napi-rs/keyring";
|
|
15
16
|
import { pathToFileURL } from "node:url";
|
|
16
17
|
import { errorMessage } from "@theholocron/misc-utils";
|
|
18
|
+
import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
|
|
19
|
+
import { readGitRemote, updateGitignore } from "@theholocron/git-utils";
|
|
20
|
+
import { listWorkspacePackagesSync, readPackageJson, readPackageJsonSync } from "@theholocron/pkg-utils";
|
|
17
21
|
import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
|
|
18
22
|
import chalk from "chalk";
|
|
19
|
-
import
|
|
23
|
+
import search from "@inquirer/search";
|
|
20
24
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import ora from "ora";
|
|
21
26
|
import { findRootSync, walkFilesSync } from "@theholocron/fs-utils";
|
|
22
27
|
import { stringifyJson } from "@theholocron/json-utils";
|
|
23
|
-
import { listWorkspacePackagesSync, readPackageJson, readPackageJsonSync } from "@theholocron/pkg-utils";
|
|
24
|
-
import { readGitRemote, updateGitignore } from "@theholocron/git-utils";
|
|
25
28
|
import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
26
29
|
import { createHash, randomUUID } from "node:crypto";
|
|
27
30
|
import { generateReadme } from "@theholocron/components-doc/markdown";
|
|
@@ -29,11 +32,7 @@ import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getU
|
|
|
29
32
|
import { createGitHubClient } from "@theholocron/github-client";
|
|
30
33
|
import { PostHogSink } from "@theholocron/observability/analytics";
|
|
31
34
|
import { SentrySink } from "@theholocron/observability/errors";
|
|
32
|
-
|
|
33
|
-
import lint from "@commitlint/lint";
|
|
34
|
-
import { ConfigFileError, loadConfigFile } from "@theholocron/datapad";
|
|
35
|
-
import search from "@inquirer/search";
|
|
36
|
-
//#region src/env.ts
|
|
35
|
+
//#region src/env/env.ts
|
|
37
36
|
/** Singleton env for simple global lookups throughout the CLI. */
|
|
38
37
|
const env = createEnvLookup();
|
|
39
38
|
/**
|
|
@@ -51,7 +50,7 @@ function makeEnv(source) {
|
|
|
51
50
|
return createEnvLookup(source);
|
|
52
51
|
}
|
|
53
52
|
//#endregion
|
|
54
|
-
//#region src/auth/keyring.ts
|
|
53
|
+
//#region src/auth/keyring/keyring.ts
|
|
55
54
|
/**
|
|
56
55
|
* Keyring-backed bootstrap credential store.
|
|
57
56
|
*
|
|
@@ -121,7 +120,7 @@ function listStoredProviders() {
|
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
122
|
//#endregion
|
|
124
|
-
//#region src/auth/auth-resolver.ts
|
|
123
|
+
//#region src/auth/auth-resolver/auth-resolver.ts
|
|
125
124
|
/**
|
|
126
125
|
* Build a strict, single-feature token resolver.
|
|
127
126
|
*
|
|
@@ -139,7 +138,7 @@ function createFeatureResolver(config) {
|
|
|
139
138
|
};
|
|
140
139
|
}
|
|
141
140
|
//#endregion
|
|
142
|
-
//#region src/auth/token-args.ts
|
|
141
|
+
//#region src/auth/token-args/token-args.ts
|
|
143
142
|
var TokenParseError = class extends Error {
|
|
144
143
|
name = "TokenParseError";
|
|
145
144
|
};
|
|
@@ -177,7 +176,7 @@ function parseTokenArgs(tokens) {
|
|
|
177
176
|
return result;
|
|
178
177
|
}
|
|
179
178
|
//#endregion
|
|
180
|
-
//#region src/plugin/capabilities.ts
|
|
179
|
+
//#region src/plugin/capabilities/capabilities.ts
|
|
181
180
|
const CARDINALITY = {
|
|
182
181
|
source: "single",
|
|
183
182
|
ci: "single",
|
|
@@ -205,7 +204,7 @@ const CARDINALITY = {
|
|
|
205
204
|
*/
|
|
206
205
|
const REQUIRED_CAPABILITIES = [];
|
|
207
206
|
//#endregion
|
|
208
|
-
//#region src/config/config.ts
|
|
207
|
+
//#region src/config/config/config.ts
|
|
209
208
|
var ConfigError = class extends Error {
|
|
210
209
|
name = "ConfigError";
|
|
211
210
|
};
|
|
@@ -314,7 +313,64 @@ function resolveConfig(raw) {
|
|
|
314
313
|
};
|
|
315
314
|
}
|
|
316
315
|
//#endregion
|
|
317
|
-
//#region src/
|
|
316
|
+
//#region src/config/load-config/load-config.ts
|
|
317
|
+
/**
|
|
318
|
+
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
319
|
+
*
|
|
320
|
+
* File discovery + module loading (typed configs via `tsx`, no build
|
|
321
|
+
* step) is delegated to `@theholocron/datapad`. This module keeps the
|
|
322
|
+
* holocron-specific parts: filling `name` / `repo.name` defaults and
|
|
323
|
+
* running the config through `resolveConfig`.
|
|
324
|
+
*
|
|
325
|
+
* Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
|
|
326
|
+
* see ADR-0010 / issue #75 / #81.
|
|
327
|
+
*/
|
|
328
|
+
/**
|
|
329
|
+
* Read + parse + resolve `holocron.config.*` starting from the given
|
|
330
|
+
* directory, walking up through ancestors to the nearest `.git` boundary
|
|
331
|
+
* when `cwd` itself has no match — every command is usable from any
|
|
332
|
+
* subdirectory of a repo, not only its exact root (see
|
|
333
|
+
* `@theholocron/datapad`'s own doc comment for the stop conditions).
|
|
334
|
+
* Throws {@link ConfigFileError} when nothing is found or a file cannot
|
|
335
|
+
* be loaded, or `ConfigError` when the config fails schema checks.
|
|
336
|
+
*/
|
|
337
|
+
async function loadConfig(cwd) {
|
|
338
|
+
const found = await loadConfigFile({
|
|
339
|
+
cwd,
|
|
340
|
+
name: "holocron",
|
|
341
|
+
walkUp: true
|
|
342
|
+
});
|
|
343
|
+
if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
|
|
344
|
+
return {
|
|
345
|
+
resolved: resolveConfig(await deriveDefaults$1(found)),
|
|
346
|
+
filepath: found.filepath
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function deriveDefaults$1(found) {
|
|
350
|
+
const configDir = dirname(found.filepath);
|
|
351
|
+
const result = { ...found.config };
|
|
352
|
+
if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
|
|
353
|
+
if (result.repo && !result.repo.name) {
|
|
354
|
+
const remote = await readGitRemote(configDir);
|
|
355
|
+
if (remote?.host === "github.com") result.repo = {
|
|
356
|
+
...result.repo,
|
|
357
|
+
name: remote.slug
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const manifest = await loadTasksConfig(configDir, { holocron: found });
|
|
361
|
+
if (manifest.tasks) result.tasks = manifest.tasks;
|
|
362
|
+
if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
|
|
363
|
+
if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
|
|
364
|
+
if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
|
|
365
|
+
if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
async function readPackageJsonName(dir) {
|
|
369
|
+
const name = (await readPackageJson(dir))?.name;
|
|
370
|
+
return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
|
|
371
|
+
}
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/commands/contexts/contexts.ts
|
|
318
374
|
/**
|
|
319
375
|
* Command → context. Keys are the full command path as it lands in
|
|
320
376
|
* yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
|
|
@@ -375,183 +431,556 @@ function commandsInContext(context) {
|
|
|
375
431
|
return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
|
|
376
432
|
}
|
|
377
433
|
//#endregion
|
|
378
|
-
//#region src/ui/
|
|
379
|
-
const style = {
|
|
380
|
-
success: (msg) => `${chalk.green("✓")} ${msg}`,
|
|
381
|
-
warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
|
|
382
|
-
fail: (msg) => `${chalk.red("✗")} ${msg}`,
|
|
383
|
-
step: (msg) => `${chalk.cyan("→")} ${msg}`,
|
|
384
|
-
/** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
|
|
385
|
-
skip: (msg) => chalk.dim(`· ${msg}`),
|
|
386
|
-
hint: (msg) => chalk.dim(msg),
|
|
387
|
-
dim: (msg) => chalk.dim(msg),
|
|
388
|
-
header: (msg) => chalk.bold(msg)
|
|
389
|
-
};
|
|
390
|
-
//#endregion
|
|
391
|
-
//#region src/logger.ts
|
|
392
|
-
/**
|
|
393
|
-
* Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
|
|
394
|
-
* node`, …) — ADR-0012. These commands aren't "about" any repo, so their
|
|
395
|
-
* logs must never depend on `$PWD` or which repo's `.envrc` happens to be
|
|
396
|
-
* active; they always ship to holocron's own dataset instead of whatever
|
|
397
|
-
* `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
|
|
398
|
-
* `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
|
|
399
|
-
* per-repo override.
|
|
400
|
-
*/
|
|
401
|
-
const GLOBAL_AXIOM_DATASET = "holocron-global";
|
|
402
|
-
/**
|
|
403
|
-
* Resolve the explicit level to hand to `createLogger`, in priority order:
|
|
404
|
-
*
|
|
405
|
-
* 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
|
|
406
|
-
* 4. `HOLOCRON_LOG_LEVEL` env var
|
|
407
|
-
* 5. `holocron.config` `log.level` (`configLevel`)
|
|
408
|
-
*
|
|
409
|
-
* Returns `undefined` when nothing applies — `createLogger` then defaults
|
|
410
|
-
* to `"info"`. Resolving the full chain here (rather than passing
|
|
411
|
-
* `configLevel` straight through) keeps config below the env var.
|
|
412
|
-
*/
|
|
413
|
-
function resolveLogLevel(argv, configLevel) {
|
|
414
|
-
if (argv.log) return argv.log;
|
|
415
|
-
if (argv.verbose) return "debug";
|
|
416
|
-
if (argv.quiet) return "error";
|
|
417
|
-
return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
|
|
418
|
-
}
|
|
434
|
+
//#region src/ui/non-interactive-error/non-interactive-error.ts
|
|
419
435
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* `
|
|
423
|
-
*
|
|
424
|
-
* Local/interactive runs are quiet by default: any raw structured logger
|
|
425
|
-
* output (colorized or not) stays off regardless of the resolved level,
|
|
426
|
-
* unless the user explicitly asked to see it via `--log <level>`,
|
|
427
|
-
* `--verbose`, or `--quiet`. CI is untouched either way — returning
|
|
428
|
-
* `undefined` there preserves `createLogger`'s own `ci`/`tty`
|
|
429
|
-
* auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
|
|
430
|
-
* unaffected by this flag.
|
|
436
|
+
* Raised when a prompt is needed but stdin isn't a TTY. A non-TTY run (CI, a
|
|
437
|
+
* script, a pipe) throws this instead of hanging on `@inquirer/prompts`, which
|
|
438
|
+
* otherwise throws its own `ExitPromptError` the moment stdin closes. `cli.ts`'s
|
|
439
|
+
* `USER_FACING_ERRORS` catch prints the message cleanly.
|
|
431
440
|
*/
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
441
|
+
var NonInteractiveError = class extends Error {
|
|
442
|
+
name = "NonInteractiveError";
|
|
443
|
+
};
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/ui/json-view/json-view.ts
|
|
446
|
+
function isContainer(value) {
|
|
447
|
+
return typeof value === "object" && value !== null;
|
|
435
448
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
let rootCommand;
|
|
439
|
-
let rootAxiomKey;
|
|
440
|
-
/**
|
|
441
|
-
* Resolve Axiom credentials for the CLI.
|
|
442
|
-
*
|
|
443
|
-
* A `global`-context command (ADR-0012) always resolves to
|
|
444
|
-
* {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
|
|
445
|
-
* `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
|
|
446
|
-
* isn't "about" any repo, so the active directory's dataset is never the
|
|
447
|
-
* right answer. Everything else keeps the existing chain: env vars win
|
|
448
|
-
* (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
|
|
449
|
-
* falling back to the CLI-only bridge that pairs the OS-keyring token
|
|
450
|
-
* (`axiom.<org>` then bare `axiom`) with a dataset from
|
|
451
|
-
* `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
|
|
452
|
-
* Returns `undefined` unless both a token and a dataset are found.
|
|
453
|
-
*/
|
|
454
|
-
function resolveCliAxiom(opts) {
|
|
455
|
-
const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
|
|
456
|
-
if (!isGlobal) {
|
|
457
|
-
const fromEnv = resolveAxiomFromEnv();
|
|
458
|
-
if (fromEnv) return fromEnv;
|
|
459
|
-
}
|
|
460
|
-
const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : holocronEnv.get("AXIOM_DATASET") ?? opts.configAxiomDataset;
|
|
461
|
-
if (!dataset) return void 0;
|
|
462
|
-
const org = opts.org ?? env.get("HOLOCRON_ORG");
|
|
463
|
-
const token = holocronEnv.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
|
|
464
|
-
return token ? {
|
|
465
|
-
dataset,
|
|
466
|
-
token
|
|
467
|
-
} : void 0;
|
|
449
|
+
function childrenOf(value) {
|
|
450
|
+
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
468
451
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
* `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
|
|
475
|
-
* for a keyring-backed Axiom transport. That rebuild generates a fresh
|
|
476
|
-
* `runId`, which is harmless: nothing logs between the middleware and the
|
|
477
|
-
* handler.
|
|
478
|
-
*/
|
|
479
|
-
function buildCliLogger(argv, opts = {}) {
|
|
480
|
-
const { command, configLevel } = opts;
|
|
481
|
-
if (command) rootCommand = command;
|
|
482
|
-
const level = resolveLogLevel(argv, configLevel);
|
|
483
|
-
const consoleOutput = resolveConsoleOutput(argv);
|
|
484
|
-
const axiom = resolveCliAxiom(opts);
|
|
485
|
-
const axiomKey = axiom?.dataset;
|
|
486
|
-
const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
|
|
487
|
-
if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
|
|
488
|
-
const built = createLogger({
|
|
489
|
-
...level ? { level } : {},
|
|
490
|
-
...axiom ? { axiom } : {},
|
|
491
|
-
...consoleOutput !== void 0 ? { consoleOutput } : {}
|
|
492
|
-
});
|
|
493
|
-
root = {
|
|
494
|
-
logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
|
|
495
|
-
runId: built.runId
|
|
496
|
-
};
|
|
497
|
-
rootLevel = level;
|
|
498
|
-
rootAxiomKey = axiomKey;
|
|
499
|
-
}
|
|
500
|
-
return root;
|
|
452
|
+
function scalar(value) {
|
|
453
|
+
if (typeof value === "string") return chalk.green(JSON.stringify(value));
|
|
454
|
+
if (typeof value === "number") return chalk.yellow(String(value));
|
|
455
|
+
if (typeof value === "boolean") return chalk.magenta(String(value));
|
|
456
|
+
return chalk.dim(String(value === void 0 ? null : value));
|
|
501
457
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
* unknown command) and therefore runs before `buildCliLogger` has ever
|
|
507
|
-
* built the real root. Without an explicit `consoleOutput` here, that
|
|
508
|
-
* fallback would use `createLogger`'s bare auto-detection (pretty on any
|
|
509
|
-
* local TTY) and leak a structured block even though no flag asked for one
|
|
510
|
-
* — so it resolves the same "quiet unless CI" default `buildCliLogger`
|
|
511
|
-
* would, as if no flags were passed.
|
|
512
|
-
*/
|
|
513
|
-
function getLogger() {
|
|
514
|
-
return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
|
|
458
|
+
function summary(value) {
|
|
459
|
+
const n = childrenOf(value).length;
|
|
460
|
+
const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
|
|
461
|
+
return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
|
|
515
462
|
}
|
|
516
463
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
* sent through the structured logger for Axiom/telemetry visibility.
|
|
520
|
-
*
|
|
521
|
-
* Use this when a handler is returning or exiting right here with nothing
|
|
522
|
-
* else for the user to see — a resolved `--token` failure, a yargs
|
|
523
|
-
* validation failure, a thrown domain error. Don't use it for a per-step
|
|
524
|
-
* warning inside a multi-step orchestrator that already prints its own
|
|
525
|
-
* `ok / fail / skip` summary — that summary is the user-facing signal;
|
|
526
|
-
* the per-step reason belongs in the structured log alone.
|
|
464
|
+
* Paths of every container deeper than `depth` levels are left out, so those
|
|
465
|
+
* render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
|
|
527
466
|
*/
|
|
528
|
-
function
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
467
|
+
function expandToDepth(value, depth) {
|
|
468
|
+
const out = /* @__PURE__ */ new Set();
|
|
469
|
+
const walk = (v, path, level) => {
|
|
470
|
+
if (!isContainer(v) || level >= depth) return;
|
|
471
|
+
out.add(path);
|
|
472
|
+
for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
|
|
473
|
+
};
|
|
474
|
+
walk(value, "", 0);
|
|
475
|
+
return out;
|
|
535
476
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
477
|
+
/** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
|
|
478
|
+
function renderTree(value, { expanded }) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
const walk = (v, path, level, label, trailing) => {
|
|
481
|
+
const indent = " ".repeat(level);
|
|
482
|
+
const prefix = `${indent}${label}`;
|
|
483
|
+
if (!isContainer(v)) {
|
|
484
|
+
lines.push({
|
|
485
|
+
path,
|
|
486
|
+
text: `${prefix}${scalar(v)}${trailing}`,
|
|
487
|
+
foldable: false,
|
|
488
|
+
expanded: false,
|
|
489
|
+
level
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const kids = childrenOf(v);
|
|
494
|
+
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
495
|
+
if (kids.length === 0) {
|
|
496
|
+
lines.push({
|
|
497
|
+
path,
|
|
498
|
+
text: `${prefix}${open}${close}${trailing}`,
|
|
499
|
+
foldable: false,
|
|
500
|
+
expanded: false,
|
|
501
|
+
level
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!expanded.has(path)) {
|
|
506
|
+
lines.push({
|
|
507
|
+
path,
|
|
508
|
+
text: `${prefix}${summary(v)}${trailing}`,
|
|
509
|
+
foldable: true,
|
|
510
|
+
expanded: false,
|
|
511
|
+
level
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
lines.push({
|
|
516
|
+
path,
|
|
517
|
+
text: `${prefix}${open}`,
|
|
518
|
+
foldable: true,
|
|
519
|
+
expanded: true,
|
|
520
|
+
level
|
|
521
|
+
});
|
|
522
|
+
kids.forEach(([k, child], i) => {
|
|
523
|
+
const comma = i < kids.length - 1 ? "," : "";
|
|
524
|
+
const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
|
|
525
|
+
walk(child, `${path}/${k}`, level + 1, childLabel, comma);
|
|
526
|
+
});
|
|
527
|
+
lines.push({
|
|
528
|
+
path: `${path}#close`,
|
|
529
|
+
text: `${indent}${close}${trailing}`,
|
|
530
|
+
foldable: false,
|
|
531
|
+
expanded: false,
|
|
532
|
+
level
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
walk(value, "", 0, "", "");
|
|
536
|
+
return lines;
|
|
537
|
+
}
|
|
538
|
+
/** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
|
|
539
|
+
function reduceKey(state, key, value, rows) {
|
|
540
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
541
|
+
const cur = lines[state.cursor];
|
|
542
|
+
let { cursor } = state;
|
|
543
|
+
const expanded = new Set(state.expanded);
|
|
544
|
+
switch (key) {
|
|
545
|
+
case "up":
|
|
546
|
+
cursor = Math.max(0, cursor - 1);
|
|
547
|
+
break;
|
|
548
|
+
case "down":
|
|
549
|
+
cursor = Math.min(lines.length - 1, cursor + 1);
|
|
550
|
+
break;
|
|
551
|
+
case "right":
|
|
552
|
+
if (cur?.foldable) expanded.add(cur.path);
|
|
553
|
+
break;
|
|
554
|
+
case "left":
|
|
555
|
+
if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
|
|
556
|
+
else if (cur) {
|
|
557
|
+
const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
|
|
558
|
+
if (parent >= 0) cursor = parent;
|
|
559
|
+
}
|
|
560
|
+
break;
|
|
561
|
+
case "expandAll": return {
|
|
562
|
+
cursor,
|
|
563
|
+
top: state.top,
|
|
564
|
+
expanded: expandToDepth(value, Infinity)
|
|
565
|
+
};
|
|
566
|
+
case "collapseAll": return {
|
|
567
|
+
cursor: 0,
|
|
568
|
+
top: 0,
|
|
569
|
+
expanded: /* @__PURE__ */ new Set()
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const count = renderTree(value, { expanded }).length;
|
|
573
|
+
cursor = Math.min(cursor, count - 1);
|
|
574
|
+
let top = state.top;
|
|
575
|
+
if (cursor < top) top = cursor;
|
|
576
|
+
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
577
|
+
return {
|
|
578
|
+
cursor,
|
|
579
|
+
top: Math.max(0, top),
|
|
580
|
+
expanded
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const KEYMAP = {
|
|
584
|
+
"\x1B[A": "up",
|
|
585
|
+
k: "up",
|
|
586
|
+
"\x1B[B": "down",
|
|
587
|
+
j: "down",
|
|
588
|
+
"\x1B[C": "right",
|
|
589
|
+
l: "right",
|
|
590
|
+
"\r": "right",
|
|
591
|
+
" ": "right",
|
|
592
|
+
"\x1B[D": "left",
|
|
593
|
+
h: "left",
|
|
594
|
+
E: "expandAll",
|
|
595
|
+
C: "collapseAll",
|
|
596
|
+
q: "quit",
|
|
597
|
+
"\x1B": "quit",
|
|
598
|
+
"": "quit"
|
|
599
|
+
};
|
|
600
|
+
function draw(out, value, state, rows) {
|
|
601
|
+
const lines = renderTree(value, { expanded: state.expanded });
|
|
602
|
+
const view = lines.slice(state.top, state.top + rows).map((l, i) => {
|
|
603
|
+
const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
|
|
604
|
+
return state.top + i === state.cursor ? chalk.inverse(row) : row;
|
|
605
|
+
});
|
|
606
|
+
const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
|
|
607
|
+
out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
|
|
608
|
+
}
|
|
609
|
+
async function runInteractive(value, depth) {
|
|
610
|
+
const { stdin, stdout } = process;
|
|
611
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new NonInteractiveError("--interactive needs a TTY on stdin and stdout.");
|
|
612
|
+
const rows = () => Math.max(3, (stdout.rows ?? 24) - 1);
|
|
613
|
+
let state = {
|
|
614
|
+
cursor: 0,
|
|
615
|
+
top: 0,
|
|
616
|
+
expanded: expandToDepth(value, depth)
|
|
617
|
+
};
|
|
618
|
+
const wasRaw = stdin.isRaw;
|
|
619
|
+
stdin.setRawMode(true);
|
|
620
|
+
stdin.resume();
|
|
621
|
+
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
622
|
+
const redraw = () => draw(stdout, value, state, rows());
|
|
623
|
+
stdout.on("resize", redraw);
|
|
624
|
+
try {
|
|
625
|
+
redraw();
|
|
626
|
+
await new Promise((resolve) => {
|
|
627
|
+
const onData = (buf) => {
|
|
628
|
+
const key = KEYMAP[buf.toString()];
|
|
629
|
+
if (key === "quit") {
|
|
630
|
+
stdin.off("data", onData);
|
|
631
|
+
resolve();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (key) {
|
|
635
|
+
state = reduceKey(state, key, value, rows());
|
|
636
|
+
redraw();
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
stdin.on("data", onData);
|
|
640
|
+
});
|
|
641
|
+
} finally {
|
|
642
|
+
stdout.off("resize", redraw);
|
|
643
|
+
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
644
|
+
stdin.setRawMode(wasRaw);
|
|
645
|
+
stdin.pause();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Print `value` as JSON. Non-TTY stdout always gets plain, valid
|
|
650
|
+
* `JSON.stringify` output (so `| jq` and redirects keep working); a TTY gets a
|
|
651
|
+
* coloured tree, collapsed past `depth`. `interactive` opens a folding viewer (the default on a TTY).
|
|
652
|
+
*/
|
|
653
|
+
async function viewJson(value, options = {}) {
|
|
654
|
+
const depth = options.depth ?? Infinity;
|
|
655
|
+
if (options.interactive ?? (options.depth === void 0 && Boolean(process.stdin.isTTY && process.stdout.isTTY))) return runInteractive(value, options.depth ?? 1);
|
|
656
|
+
if (!process.stdout.isTTY) {
|
|
657
|
+
console.log(JSON.stringify(value, null, 2));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const lines = renderTree(value, { expanded: expandToDepth(value, depth) });
|
|
661
|
+
console.log(lines.map((l) => l.text).join("\n"));
|
|
662
|
+
}
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/ui/menu/menu.ts
|
|
665
|
+
/**
|
|
666
|
+
* Searchable autocomplete over one menu level's `items`. `select()` would work too
|
|
667
|
+
* at these list sizes, but `search()` degrades gracefully as the surface grows and
|
|
668
|
+
* costs nothing when it doesn't. Resolves to the picked item's `value`.
|
|
669
|
+
*/
|
|
670
|
+
async function pickMenuItem(items, message = "What would you like to do?") {
|
|
671
|
+
return search({
|
|
672
|
+
message,
|
|
673
|
+
source: (term) => searchChoices(items, term)
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* `@inquirer/search`'s `source` callback, factored out as a plain function —
|
|
678
|
+
* unit-testable directly instead of only through a mocked `search()` call.
|
|
679
|
+
* Empty/undefined `term` (nothing typed yet) returns every item.
|
|
680
|
+
*/
|
|
681
|
+
function searchChoices(items, term) {
|
|
682
|
+
const needle = term?.toLowerCase();
|
|
683
|
+
const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
|
|
684
|
+
const rank = (i) => {
|
|
685
|
+
if (!needle) return 0;
|
|
686
|
+
const name = i.name.toLowerCase();
|
|
687
|
+
return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
|
|
688
|
+
};
|
|
689
|
+
return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
|
|
690
|
+
name: i.name,
|
|
691
|
+
value: i.value,
|
|
692
|
+
description: i.description
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/ui/open-path/open-path.ts
|
|
697
|
+
/** Platform-appropriate command to open a path in the OS's file browser. */
|
|
698
|
+
function openCommand(path) {
|
|
699
|
+
if (process.platform === "darwin") return {
|
|
700
|
+
cmd: "open",
|
|
701
|
+
args: [path]
|
|
702
|
+
};
|
|
703
|
+
if (process.platform === "win32") return {
|
|
704
|
+
cmd: "cmd",
|
|
705
|
+
args: [
|
|
706
|
+
"/c",
|
|
707
|
+
"start",
|
|
708
|
+
"",
|
|
709
|
+
path
|
|
710
|
+
]
|
|
711
|
+
};
|
|
712
|
+
return {
|
|
713
|
+
cmd: "xdg-open",
|
|
714
|
+
args: [path]
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Open `path` in the OS's default file browser (Finder / Explorer /
|
|
719
|
+
* whatever `xdg-open` resolves to on Linux). Fire-and-forget — detached so
|
|
720
|
+
* it outlives this process, and never throws when the opener binary isn't
|
|
721
|
+
* on PATH (e.g. no `xdg-open` in a minimal container).
|
|
722
|
+
*/
|
|
723
|
+
function openPath(path) {
|
|
724
|
+
const { cmd, args } = openCommand(path);
|
|
725
|
+
const child = spawn(cmd, args, {
|
|
726
|
+
stdio: "ignore",
|
|
727
|
+
detached: true
|
|
728
|
+
});
|
|
729
|
+
child.on("error", () => {});
|
|
730
|
+
child.unref();
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/ui/progress/progress.ts
|
|
734
|
+
/**
|
|
735
|
+
* Runs `fn`, showing an ora spinner for its duration in TTY environments.
|
|
736
|
+
* In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
|
|
737
|
+
*/
|
|
738
|
+
async function withSpinner(label, fn) {
|
|
739
|
+
if (!process.stdout.isTTY) return fn();
|
|
740
|
+
const spinner = ora(label).start();
|
|
741
|
+
try {
|
|
742
|
+
const result = await fn();
|
|
743
|
+
spinner.succeed();
|
|
744
|
+
return result;
|
|
745
|
+
} catch (err) {
|
|
746
|
+
spinner.fail();
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/ui/prompt-positionals/prompt-positionals.ts
|
|
752
|
+
/**
|
|
753
|
+
* For each of `entry.positionals` not already present in `argv`, prompts for
|
|
754
|
+
* it (`input()` or `select()`, per the positional's `type`) and returns the
|
|
755
|
+
* resolved values in positional order — ready to append to a spawned child's
|
|
756
|
+
* argv, or to use directly in the current handler (Layer 3).
|
|
757
|
+
*
|
|
758
|
+
* Throws {@link NonInteractiveError} the first time it would need to prompt
|
|
759
|
+
* on a non-TTY stdin, and again if a `select` positional's choices resolve
|
|
760
|
+
* empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
|
|
761
|
+
*/
|
|
762
|
+
async function promptForPositionals(entry, argv) {
|
|
763
|
+
const out = [];
|
|
764
|
+
for (const positional of entry.positionals) {
|
|
765
|
+
const existing = argv[positional.key];
|
|
766
|
+
if (existing !== void 0 && existing !== "") {
|
|
767
|
+
out.push(String(existing));
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
|
|
771
|
+
if (positional.type === "select") {
|
|
772
|
+
const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
|
|
773
|
+
if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
|
|
774
|
+
out.push(await select({
|
|
775
|
+
message: positional.message,
|
|
776
|
+
choices: choices.map((c) => ({ value: c }))
|
|
777
|
+
}));
|
|
778
|
+
} else out.push(await input({
|
|
779
|
+
message: positional.message,
|
|
780
|
+
...positional.validate ? { validate: positional.validate } : {}
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
785
|
+
//#endregion
|
|
786
|
+
//#region src/ui/style/style.ts
|
|
787
|
+
const style = {
|
|
788
|
+
success: (msg) => `${chalk.green("✓")} ${msg}`,
|
|
789
|
+
warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
|
|
790
|
+
fail: (msg) => `${chalk.red("✗")} ${msg}`,
|
|
791
|
+
step: (msg) => `${chalk.cyan("→")} ${msg}`,
|
|
792
|
+
/** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
|
|
793
|
+
skip: (msg) => chalk.dim(`· ${msg}`),
|
|
794
|
+
hint: (msg) => chalk.dim(msg),
|
|
795
|
+
dim: (msg) => chalk.dim(msg),
|
|
796
|
+
header: (msg) => chalk.bold(msg)
|
|
797
|
+
};
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/ui/status-row/status-row.ts
|
|
800
|
+
/** Right-pads `s` to `width` with spaces — never truncates. */
|
|
801
|
+
function pad(s, width) {
|
|
802
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
|
|
806
|
+
* then `columns` joined with a two-space gap (blank columns dropped, so a
|
|
807
|
+
* row that has nothing for its last column doesn't trail whitespace).
|
|
808
|
+
* Column padding (`pad`) is the caller's job — `doctor` pads capability and
|
|
809
|
+
* provider to a fixed width; `clone`'s repo names and paths don't, since
|
|
810
|
+
* they vary too widely for fixed-width alignment to read cleanly.
|
|
811
|
+
*
|
|
812
|
+
* Shared by `doctor` (capability / provider / message) and `clone` (repo /
|
|
813
|
+
* destination / reason) so every status table in the CLI reads the same way.
|
|
814
|
+
*/
|
|
815
|
+
function statusRow(status, columns) {
|
|
816
|
+
const joined = columns.filter((c) => c.length > 0).join(" ");
|
|
817
|
+
return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
820
|
+
//#region src/observability/logger/logger.ts
|
|
821
|
+
/**
|
|
822
|
+
* Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
|
|
823
|
+
* node`, …) — ADR-0012. These commands aren't "about" any repo, so their
|
|
824
|
+
* logs must never depend on `$PWD` or which repo's `.envrc` happens to be
|
|
825
|
+
* active; they always ship to holocron's own dataset instead of whatever
|
|
826
|
+
* `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
|
|
827
|
+
* `resolve-telemetry.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
|
|
828
|
+
* per-repo override.
|
|
829
|
+
*/
|
|
830
|
+
const GLOBAL_AXIOM_DATASET = "holocron-global";
|
|
831
|
+
/**
|
|
832
|
+
* Resolve the explicit level to hand to `createLogger`, in priority order:
|
|
833
|
+
*
|
|
834
|
+
* 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
|
|
835
|
+
* 4. `HOLOCRON_LOG_LEVEL` env var
|
|
836
|
+
* 5. `holocron.config` `log.level` (`configLevel`)
|
|
837
|
+
*
|
|
838
|
+
* Returns `undefined` when nothing applies — `createLogger` then defaults
|
|
839
|
+
* to `"info"`. Resolving the full chain here (rather than passing
|
|
840
|
+
* `configLevel` straight through) keeps config below the env var.
|
|
841
|
+
*/
|
|
842
|
+
function resolveLogLevel(argv, configLevel) {
|
|
843
|
+
if (argv.log) return argv.log;
|
|
844
|
+
if (argv.verbose) return "debug";
|
|
845
|
+
if (argv.quiet) return "error";
|
|
846
|
+
return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Resolve whether human-visible console output (pretty or NDJSON) should run
|
|
850
|
+
* at all, independent of level — the `@theholocron/observability`
|
|
851
|
+
* `consoleOutput` override.
|
|
852
|
+
*
|
|
853
|
+
* Local/interactive runs are quiet by default: any raw structured logger
|
|
854
|
+
* output (colorized or not) stays off regardless of the resolved level,
|
|
855
|
+
* unless the user explicitly asked to see it via `--log <level>`,
|
|
856
|
+
* `--verbose`, or `--quiet`. CI is untouched either way — returning
|
|
857
|
+
* `undefined` there preserves `createLogger`'s own `ci`/`tty`
|
|
858
|
+
* auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
|
|
859
|
+
* unaffected by this flag.
|
|
860
|
+
*/
|
|
861
|
+
function resolveConsoleOutput(argv) {
|
|
862
|
+
if (argv.log || argv.verbose || argv.quiet) return true;
|
|
863
|
+
return isCI() ? void 0 : false;
|
|
864
|
+
}
|
|
865
|
+
let root;
|
|
866
|
+
let rootLevel;
|
|
867
|
+
let rootCommand;
|
|
868
|
+
let rootAxiomKey;
|
|
869
|
+
/**
|
|
870
|
+
* Resolve Axiom credentials for the CLI.
|
|
871
|
+
*
|
|
872
|
+
* A `global`-context command (ADR-0012) always resolves to
|
|
873
|
+
* {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
|
|
874
|
+
* `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
|
|
875
|
+
* isn't "about" any repo, so the active directory's dataset is never the
|
|
876
|
+
* right answer. Everything else keeps the existing chain: env vars win
|
|
877
|
+
* (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
|
|
878
|
+
* falling back to the CLI-only bridge that pairs the OS-keyring token
|
|
879
|
+
* (`axiom.<org>` then bare `axiom`) with a dataset from
|
|
880
|
+
* `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
|
|
881
|
+
* Returns `undefined` unless both a token and a dataset are found.
|
|
882
|
+
*/
|
|
883
|
+
function resolveCliAxiom(opts) {
|
|
884
|
+
const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
|
|
885
|
+
if (!isGlobal) {
|
|
886
|
+
const fromEnv = resolveAxiomFromEnv();
|
|
887
|
+
if (fromEnv) return fromEnv;
|
|
888
|
+
}
|
|
889
|
+
const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : holocronEnv.get("AXIOM_DATASET") ?? opts.configAxiomDataset;
|
|
890
|
+
if (!dataset) return void 0;
|
|
891
|
+
const org = opts.org ?? env.get("HOLOCRON_ORG");
|
|
892
|
+
const token = holocronEnv.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
|
|
893
|
+
return token ? {
|
|
894
|
+
dataset,
|
|
895
|
+
token
|
|
896
|
+
} : void 0;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The process-wide root logger. Built once (from `cli.ts`'s middleware,
|
|
900
|
+
* with the command name + flags + env). Rebuilt at most once more when a
|
|
901
|
+
* command's handler supplies `holocron.config` context the flag/env-only
|
|
902
|
+
* first pass could not have known — `log.level` (unless `--verbose` /
|
|
903
|
+
* `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
|
|
904
|
+
* for a keyring-backed Axiom transport. That rebuild generates a fresh
|
|
905
|
+
* `runId`, which is harmless: nothing logs between the middleware and the
|
|
906
|
+
* handler.
|
|
907
|
+
*/
|
|
908
|
+
function buildCliLogger(argv, opts = {}) {
|
|
909
|
+
const { command, configLevel } = opts;
|
|
910
|
+
if (command) rootCommand = command;
|
|
911
|
+
const level = resolveLogLevel(argv, configLevel);
|
|
912
|
+
const consoleOutput = resolveConsoleOutput(argv);
|
|
913
|
+
const axiom = resolveCliAxiom(opts);
|
|
914
|
+
const axiomKey = axiom?.dataset;
|
|
915
|
+
const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
|
|
916
|
+
if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
|
|
917
|
+
const built = createLogger({
|
|
918
|
+
...level ? { level } : {},
|
|
919
|
+
...axiom ? { axiom } : {},
|
|
920
|
+
...consoleOutput !== void 0 ? { consoleOutput } : {}
|
|
921
|
+
});
|
|
922
|
+
root = {
|
|
923
|
+
logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
|
|
924
|
+
runId: built.runId
|
|
925
|
+
};
|
|
926
|
+
rootLevel = level;
|
|
927
|
+
rootAxiomKey = axiomKey;
|
|
928
|
+
}
|
|
929
|
+
return root;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Lazily-memoized `Logger` for module-level call sites with no `argv` in
|
|
933
|
+
* scope — notably yargs' own `.fail()` handler, which fires on a
|
|
934
|
+
* pre-middleware validation failure (a missing required argument, an
|
|
935
|
+
* unknown command) and therefore runs before `buildCliLogger` has ever
|
|
936
|
+
* built the real root. Without an explicit `consoleOutput` here, that
|
|
937
|
+
* fallback would use `createLogger`'s bare auto-detection (pretty on any
|
|
938
|
+
* local TTY) and leak a structured block even though no flag asked for one
|
|
939
|
+
* — so it resolves the same "quiet unless CI" default `buildCliLogger`
|
|
940
|
+
* would, as if no flags were passed.
|
|
941
|
+
*/
|
|
942
|
+
function getLogger() {
|
|
943
|
+
return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Report a fatal, command-ending CLI error: always printed to the console
|
|
947
|
+
* (regardless of `consoleOutput` suppression) via `style.fail`, and also
|
|
948
|
+
* sent through the structured logger for Axiom/telemetry visibility.
|
|
949
|
+
*
|
|
950
|
+
* Use this when a handler is returning or exiting right here with nothing
|
|
951
|
+
* else for the user to see — a resolved `--token` failure, a yargs
|
|
952
|
+
* validation failure, a thrown domain error. Don't use it for a per-step
|
|
953
|
+
* warning inside a multi-step orchestrator that already prints its own
|
|
954
|
+
* `ok / fail / skip` summary — that summary is the user-facing signal;
|
|
955
|
+
* the per-step reason belongs in the structured log alone.
|
|
956
|
+
*/
|
|
957
|
+
function reportError(message, log = getLogger()) {
|
|
958
|
+
console.error(style.fail(message));
|
|
959
|
+
log.error(message);
|
|
960
|
+
}
|
|
961
|
+
/** The current root logger's correlation id, if a root has been built. */
|
|
962
|
+
function getRunId() {
|
|
963
|
+
return root?.runId;
|
|
964
|
+
}
|
|
965
|
+
//#endregion
|
|
966
|
+
//#region src/plugin/loader/loader.ts
|
|
967
|
+
/**
|
|
968
|
+
* `PluginLoader` — loads provider plugins per the resolved config and
|
|
969
|
+
* builds a typed capability registry the runtime can query.
|
|
970
|
+
*
|
|
971
|
+
* Flow:
|
|
972
|
+
* 1. Walk `config.providers[*]` from `resolveConfig()`
|
|
973
|
+
* 2. For each entry, dynamic-import the resolved package name
|
|
974
|
+
* (`@theholocron/holocron-plugin-<provider>` by default)
|
|
975
|
+
* 3. Call the package's exported `createPlugin(options)` to get a
|
|
976
|
+
* plugin object whose `capabilities` map holds factories
|
|
977
|
+
* 4. Invoke the matching capability factory and stash the impl in
|
|
978
|
+
* the registry — single-cardinality entries hold one impl,
|
|
979
|
+
* many-cardinality entries hold an array
|
|
980
|
+
*
|
|
981
|
+
* A package may instead export a capability config (see
|
|
982
|
+
* `CapabilityConfigPackage` in config.ts). The loader detects the shape
|
|
983
|
+
* at import time and re-resolves to the underlying plugin, merging the
|
|
555
984
|
* preset options with any per-project overrides from the config file
|
|
556
985
|
* (project options win, mirroring ESLint's `extends` precedence).
|
|
557
986
|
*
|
|
@@ -699,7 +1128,111 @@ function isCapabilityConfigModule(mod) {
|
|
|
699
1128
|
return typeof mod.default?.provider === "string";
|
|
700
1129
|
}
|
|
701
1130
|
//#endregion
|
|
702
|
-
//#region src/plugin/
|
|
1131
|
+
//#region src/plugin/run-check/run-check.ts
|
|
1132
|
+
async function runCheckAgainstUrl(input, opts = {}) {
|
|
1133
|
+
const doFetch = opts.fetch ?? fetch;
|
|
1134
|
+
const url = resolveUrl(input.url, opts.url);
|
|
1135
|
+
const method = input.method ?? "GET";
|
|
1136
|
+
const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
|
|
1137
|
+
const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
|
|
1138
|
+
const start = Date.now();
|
|
1139
|
+
let response;
|
|
1140
|
+
let bodyText = "";
|
|
1141
|
+
try {
|
|
1142
|
+
response = await doFetch(url, {
|
|
1143
|
+
method,
|
|
1144
|
+
...hasBody ? { body: input.body } : {},
|
|
1145
|
+
redirect: "manual",
|
|
1146
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
|
|
1147
|
+
});
|
|
1148
|
+
if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
return {
|
|
1151
|
+
passed: false,
|
|
1152
|
+
failures: [],
|
|
1153
|
+
requestError: requestErrorMessage(err)
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
const responseTimeMs = Date.now() - start;
|
|
1157
|
+
const failures = [];
|
|
1158
|
+
if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
|
|
1159
|
+
for (const assertion of input.assertions ?? []) {
|
|
1160
|
+
const actual = extractActual(assertion, {
|
|
1161
|
+
response,
|
|
1162
|
+
bodyText,
|
|
1163
|
+
responseTimeMs
|
|
1164
|
+
});
|
|
1165
|
+
if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
passed: failures.length === 0,
|
|
1169
|
+
status: response.status,
|
|
1170
|
+
responseTimeMs,
|
|
1171
|
+
failures
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
/** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
|
|
1175
|
+
function resolveUrl(originalUrl, overrideUrl) {
|
|
1176
|
+
if (!overrideUrl) return originalUrl;
|
|
1177
|
+
const original = new URL(originalUrl);
|
|
1178
|
+
const resolved = new URL(overrideUrl);
|
|
1179
|
+
resolved.pathname = original.pathname;
|
|
1180
|
+
resolved.search = original.search;
|
|
1181
|
+
return resolved.toString();
|
|
1182
|
+
}
|
|
1183
|
+
function requestErrorMessage(err) {
|
|
1184
|
+
if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
|
|
1185
|
+
return String(err);
|
|
1186
|
+
}
|
|
1187
|
+
/** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
|
|
1188
|
+
async function readBodyCapped(response, maxBytes) {
|
|
1189
|
+
if (!response.body) return "";
|
|
1190
|
+
const reader = response.body.getReader();
|
|
1191
|
+
const chunks = [];
|
|
1192
|
+
let total = 0;
|
|
1193
|
+
try {
|
|
1194
|
+
for (;;) {
|
|
1195
|
+
const { done, value } = await reader.read();
|
|
1196
|
+
if (done) break;
|
|
1197
|
+
const remaining = maxBytes - total;
|
|
1198
|
+
if (remaining <= 0) break;
|
|
1199
|
+
const stored = value.length > remaining ? value.slice(0, remaining) : value;
|
|
1200
|
+
chunks.push(stored);
|
|
1201
|
+
total += stored.length;
|
|
1202
|
+
}
|
|
1203
|
+
} finally {
|
|
1204
|
+
await reader.cancel().catch(() => {});
|
|
1205
|
+
}
|
|
1206
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
1207
|
+
}
|
|
1208
|
+
/** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
|
|
1209
|
+
function truncate(value, max = 200) {
|
|
1210
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
1211
|
+
}
|
|
1212
|
+
function extractActual(assertion, ctx) {
|
|
1213
|
+
switch (assertion.source) {
|
|
1214
|
+
case "status_code": return String(ctx.response.status);
|
|
1215
|
+
case "response_time": return String(ctx.responseTimeMs);
|
|
1216
|
+
case "body": return ctx.bodyText;
|
|
1217
|
+
case "header": {
|
|
1218
|
+
const name = assertion.property ?? "";
|
|
1219
|
+
return name ? ctx.response.headers.get(name) ?? "" : "";
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function evaluate(comparison, actual, target) {
|
|
1224
|
+
const t = target ?? "";
|
|
1225
|
+
switch (comparison) {
|
|
1226
|
+
case "equals": return actual === t;
|
|
1227
|
+
case "not_equals": return actual !== t;
|
|
1228
|
+
case "contains": return actual.includes(t);
|
|
1229
|
+
case "not_contains": return !actual.includes(t);
|
|
1230
|
+
case "greater_than": return Number(actual) > Number(t);
|
|
1231
|
+
case "less_than": return Number(actual) < Number(t);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region src/plugin/workspace/workspace.ts
|
|
703
1236
|
/**
|
|
704
1237
|
* The `workspace`-context guard.
|
|
705
1238
|
*
|
|
@@ -769,25 +1302,7 @@ function assertPluginsResolvable(loader, command) {
|
|
|
769
1302
|
throw new WorkspaceContextError(command, [...new Set(failures.map((f) => f.packageName))]);
|
|
770
1303
|
}
|
|
771
1304
|
//#endregion
|
|
772
|
-
//#region src/
|
|
773
|
-
/**
|
|
774
|
-
* Runs `fn`, showing an ora spinner for its duration in TTY environments.
|
|
775
|
-
* In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
|
|
776
|
-
*/
|
|
777
|
-
async function withSpinner(label, fn) {
|
|
778
|
-
if (!process.stdout.isTTY) return fn();
|
|
779
|
-
const spinner = ora(label).start();
|
|
780
|
-
try {
|
|
781
|
-
const result = await fn();
|
|
782
|
-
spinner.succeed();
|
|
783
|
-
return result;
|
|
784
|
-
} catch (err) {
|
|
785
|
-
spinner.fail();
|
|
786
|
-
throw err;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
//#endregion
|
|
790
|
-
//#region src/commands/auth.ts
|
|
1305
|
+
//#region src/commands/auth/auth.ts
|
|
791
1306
|
/**
|
|
792
1307
|
* `holocron auth <subcommand>` — manage bootstrap credentials in the
|
|
793
1308
|
* OS keyring.
|
|
@@ -1068,7 +1583,7 @@ async function tryLoadHint(importer, packageName) {
|
|
|
1068
1583
|
}
|
|
1069
1584
|
}
|
|
1070
1585
|
//#endregion
|
|
1071
|
-
//#region src/commands/cleanup-preview.ts
|
|
1586
|
+
//#region src/commands/cleanup-preview/cleanup-preview.ts
|
|
1072
1587
|
function prStateLabel(pr) {
|
|
1073
1588
|
if (pr.merged) return style.success("merged");
|
|
1074
1589
|
if (pr.state === "closed") return style.dim("closed (not merged)");
|
|
@@ -1208,28 +1723,7 @@ async function runCleanupPreview(input) {
|
|
|
1208
1723
|
}
|
|
1209
1724
|
}
|
|
1210
1725
|
//#endregion
|
|
1211
|
-
//#region src/
|
|
1212
|
-
/** Right-pads `s` to `width` with spaces — never truncates. */
|
|
1213
|
-
function pad(s, width) {
|
|
1214
|
-
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
1215
|
-
}
|
|
1216
|
-
/**
|
|
1217
|
-
* One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
|
|
1218
|
-
* then `columns` joined with a two-space gap (blank columns dropped, so a
|
|
1219
|
-
* row that has nothing for its last column doesn't trail whitespace).
|
|
1220
|
-
* Column padding (`pad`) is the caller's job — `doctor` pads capability and
|
|
1221
|
-
* provider to a fixed width; `clone`'s repo names and paths don't, since
|
|
1222
|
-
* they vary too widely for fixed-width alignment to read cleanly.
|
|
1223
|
-
*
|
|
1224
|
-
* Shared by `doctor` (capability / provider / message) and `clone` (repo /
|
|
1225
|
-
* destination / reason) so every status table in the CLI reads the same way.
|
|
1226
|
-
*/
|
|
1227
|
-
function statusRow(status, columns) {
|
|
1228
|
-
const joined = columns.filter((c) => c.length > 0).join(" ");
|
|
1229
|
-
return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
|
|
1230
|
-
}
|
|
1231
|
-
//#endregion
|
|
1232
|
-
//#region src/commands/clone.ts
|
|
1726
|
+
//#region src/commands/clone/clone.ts
|
|
1233
1727
|
/**
|
|
1234
1728
|
* Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
|
|
1235
1729
|
* `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
|
|
@@ -1426,7 +1920,7 @@ async function runClone(input) {
|
|
|
1426
1920
|
};
|
|
1427
1921
|
}
|
|
1428
1922
|
//#endregion
|
|
1429
|
-
//#region src/commands/deploy.ts
|
|
1923
|
+
//#region src/commands/deploy/deploy.ts
|
|
1430
1924
|
/**
|
|
1431
1925
|
* `holocron deploy` — trigger a deployment via the configured
|
|
1432
1926
|
* `deployment` capability.
|
|
@@ -1666,7 +2160,7 @@ async function runDeployFromFiles(input) {
|
|
|
1666
2160
|
}
|
|
1667
2161
|
}
|
|
1668
2162
|
//#endregion
|
|
1669
|
-
//#region src/commands/doctor.ts
|
|
2163
|
+
//#region src/commands/doctor/doctor.ts
|
|
1670
2164
|
/**
|
|
1671
2165
|
* `holocron doctor` — load the config, instantiate every capability,
|
|
1672
2166
|
* print a per-capability readiness report.
|
|
@@ -1812,117 +2306,13 @@ function countResolvedLifecycle(report) {
|
|
|
1812
2306
|
function mk(capability, provider, status, message) {
|
|
1813
2307
|
return {
|
|
1814
2308
|
capability,
|
|
1815
|
-
provider,
|
|
1816
|
-
status,
|
|
1817
|
-
message
|
|
1818
|
-
};
|
|
1819
|
-
}
|
|
1820
|
-
//#endregion
|
|
1821
|
-
//#region src/plugin/run-check.ts
|
|
1822
|
-
async function runCheckAgainstUrl(input, opts = {}) {
|
|
1823
|
-
const doFetch = opts.fetch ?? fetch;
|
|
1824
|
-
const url = resolveUrl(input.url, opts.url);
|
|
1825
|
-
const method = input.method ?? "GET";
|
|
1826
|
-
const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
|
|
1827
|
-
const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
|
|
1828
|
-
const start = Date.now();
|
|
1829
|
-
let response;
|
|
1830
|
-
let bodyText = "";
|
|
1831
|
-
try {
|
|
1832
|
-
response = await doFetch(url, {
|
|
1833
|
-
method,
|
|
1834
|
-
...hasBody ? { body: input.body } : {},
|
|
1835
|
-
redirect: "manual",
|
|
1836
|
-
signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
|
|
1837
|
-
});
|
|
1838
|
-
if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
|
|
1839
|
-
} catch (err) {
|
|
1840
|
-
return {
|
|
1841
|
-
passed: false,
|
|
1842
|
-
failures: [],
|
|
1843
|
-
requestError: requestErrorMessage(err)
|
|
1844
|
-
};
|
|
1845
|
-
}
|
|
1846
|
-
const responseTimeMs = Date.now() - start;
|
|
1847
|
-
const failures = [];
|
|
1848
|
-
if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
|
|
1849
|
-
for (const assertion of input.assertions ?? []) {
|
|
1850
|
-
const actual = extractActual(assertion, {
|
|
1851
|
-
response,
|
|
1852
|
-
bodyText,
|
|
1853
|
-
responseTimeMs
|
|
1854
|
-
});
|
|
1855
|
-
if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
|
|
1856
|
-
}
|
|
1857
|
-
return {
|
|
1858
|
-
passed: failures.length === 0,
|
|
1859
|
-
status: response.status,
|
|
1860
|
-
responseTimeMs,
|
|
1861
|
-
failures
|
|
1862
|
-
};
|
|
1863
|
-
}
|
|
1864
|
-
/** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
|
|
1865
|
-
function resolveUrl(originalUrl, overrideUrl) {
|
|
1866
|
-
if (!overrideUrl) return originalUrl;
|
|
1867
|
-
const original = new URL(originalUrl);
|
|
1868
|
-
const resolved = new URL(overrideUrl);
|
|
1869
|
-
resolved.pathname = original.pathname;
|
|
1870
|
-
resolved.search = original.search;
|
|
1871
|
-
return resolved.toString();
|
|
1872
|
-
}
|
|
1873
|
-
function requestErrorMessage(err) {
|
|
1874
|
-
if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
|
|
1875
|
-
return String(err);
|
|
1876
|
-
}
|
|
1877
|
-
/** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
|
|
1878
|
-
async function readBodyCapped(response, maxBytes) {
|
|
1879
|
-
if (!response.body) return "";
|
|
1880
|
-
const reader = response.body.getReader();
|
|
1881
|
-
const chunks = [];
|
|
1882
|
-
let total = 0;
|
|
1883
|
-
try {
|
|
1884
|
-
for (;;) {
|
|
1885
|
-
const { done, value } = await reader.read();
|
|
1886
|
-
if (done) break;
|
|
1887
|
-
const remaining = maxBytes - total;
|
|
1888
|
-
if (remaining <= 0) break;
|
|
1889
|
-
const stored = value.length > remaining ? value.slice(0, remaining) : value;
|
|
1890
|
-
chunks.push(stored);
|
|
1891
|
-
total += stored.length;
|
|
1892
|
-
}
|
|
1893
|
-
} finally {
|
|
1894
|
-
await reader.cancel().catch(() => {});
|
|
1895
|
-
}
|
|
1896
|
-
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
1897
|
-
}
|
|
1898
|
-
/** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
|
|
1899
|
-
function truncate(value, max = 200) {
|
|
1900
|
-
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
1901
|
-
}
|
|
1902
|
-
function extractActual(assertion, ctx) {
|
|
1903
|
-
switch (assertion.source) {
|
|
1904
|
-
case "status_code": return String(ctx.response.status);
|
|
1905
|
-
case "response_time": return String(ctx.responseTimeMs);
|
|
1906
|
-
case "body": return ctx.bodyText;
|
|
1907
|
-
case "header": {
|
|
1908
|
-
const name = assertion.property ?? "";
|
|
1909
|
-
return name ? ctx.response.headers.get(name) ?? "" : "";
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
}
|
|
1913
|
-
function evaluate(comparison, actual, target) {
|
|
1914
|
-
const t = target ?? "";
|
|
1915
|
-
switch (comparison) {
|
|
1916
|
-
case "equals": return actual === t;
|
|
1917
|
-
case "not_equals": return actual !== t;
|
|
1918
|
-
case "contains": return actual.includes(t);
|
|
1919
|
-
case "not_contains": return !actual.includes(t);
|
|
1920
|
-
case "greater_than": return Number(actual) > Number(t);
|
|
1921
|
-
case "less_than": return Number(actual) < Number(t);
|
|
1922
|
-
}
|
|
2309
|
+
provider,
|
|
2310
|
+
status,
|
|
2311
|
+
message
|
|
2312
|
+
};
|
|
1923
2313
|
}
|
|
1924
2314
|
//#endregion
|
|
1925
|
-
//#region src/commands/monitoring-check.ts
|
|
2315
|
+
//#region src/commands/monitoring-check/monitoring-check.ts
|
|
1926
2316
|
async function runMonitoringCheck(input) {
|
|
1927
2317
|
const print = input.print ?? ((line) => console.log(line));
|
|
1928
2318
|
const logger = input.logger ?? getLogger();
|
|
@@ -1983,7 +2373,7 @@ async function runMonitoringCheck(input) {
|
|
|
1983
2373
|
};
|
|
1984
2374
|
}
|
|
1985
2375
|
//#endregion
|
|
1986
|
-
//#region src/commands/new.ts
|
|
2376
|
+
//#region src/commands/new/new.ts
|
|
1987
2377
|
/**
|
|
1988
2378
|
* `holocron new repo <type> <name>` — create a GitHub repo from a template and
|
|
1989
2379
|
* bootstrap it by replacing all template-slug casing variants with the new
|
|
@@ -2377,7 +2767,7 @@ async function runNew(input) {
|
|
|
2377
2767
|
};
|
|
2378
2768
|
}
|
|
2379
2769
|
//#endregion
|
|
2380
|
-
//#region src/commands/npm-bump-versions.ts
|
|
2770
|
+
//#region src/commands/npm-bump-versions/npm-bump-versions.ts
|
|
2381
2771
|
async function runNpmBumpVersions(input) {
|
|
2382
2772
|
const print = input.print ?? ((line) => console.log(line));
|
|
2383
2773
|
const logger = input.logger ?? getLogger();
|
|
@@ -2458,9 +2848,9 @@ async function runNpmBumpVersions(input) {
|
|
|
2458
2848
|
};
|
|
2459
2849
|
}
|
|
2460
2850
|
//#endregion
|
|
2461
|
-
//#region src/commands/plugin-create/template-inputs.ts
|
|
2851
|
+
//#region src/commands/plugin-create/template-inputs/template-inputs.ts
|
|
2462
2852
|
/** Derive the standard defaults from a slug + vendor name. */
|
|
2463
|
-
function deriveDefaults
|
|
2853
|
+
function deriveDefaults(input) {
|
|
2464
2854
|
const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
|
|
2465
2855
|
const capability = input.capability;
|
|
2466
2856
|
return {
|
|
@@ -3399,7 +3789,7 @@ function runPluginCreate(input) {
|
|
|
3399
3789
|
const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
|
|
3400
3790
|
if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
|
|
3401
3791
|
validateCapability(input.capability, print);
|
|
3402
|
-
const derived = deriveDefaults
|
|
3792
|
+
const derived = deriveDefaults({
|
|
3403
3793
|
slug: input.slug,
|
|
3404
3794
|
vendorName: input.vendorName,
|
|
3405
3795
|
capability: input.capability
|
|
@@ -3555,224 +3945,142 @@ function printNextSteps$1(print, inputs) {
|
|
|
3555
3945
|
print(" 7. Commit + push when capability is functionally complete.");
|
|
3556
3946
|
}
|
|
3557
3947
|
//#endregion
|
|
3558
|
-
//#region src/commands/publish.ts
|
|
3948
|
+
//#region src/commands/publish/default-exec.ts
|
|
3949
|
+
const defaultExec$1 = async (cmd, args, opts) => {
|
|
3950
|
+
const result = spawnSync(cmd, args, {
|
|
3951
|
+
cwd: opts.cwd,
|
|
3952
|
+
encoding: "utf8",
|
|
3953
|
+
stdio: [
|
|
3954
|
+
"inherit",
|
|
3955
|
+
"pipe",
|
|
3956
|
+
"pipe"
|
|
3957
|
+
]
|
|
3958
|
+
});
|
|
3959
|
+
return {
|
|
3960
|
+
exitCode: result.status ?? -1,
|
|
3961
|
+
stdout: result.stdout ?? "",
|
|
3962
|
+
stderr: result.stderr ?? ""
|
|
3963
|
+
};
|
|
3964
|
+
};
|
|
3965
|
+
//#endregion
|
|
3966
|
+
//#region src/commands/publish/default-login.ts
|
|
3559
3967
|
/**
|
|
3560
|
-
* `
|
|
3561
|
-
*
|
|
3562
|
-
*
|
|
3563
|
-
*
|
|
3564
|
-
* configured on it. So the first publish has to happen outside the OIDC
|
|
3565
|
-
* flow — a browser-auth session (`npm login --auth-type=web`) or an
|
|
3566
|
-
* ephemeral automation token. This command drives the login step itself
|
|
3567
|
-
* (when `npm whoami` shows you're not authenticated), runs the publish
|
|
3568
|
-
* step, and tells you exactly what to do next.
|
|
3569
|
-
*
|
|
3570
|
-
* Workflow:
|
|
3571
|
-
*
|
|
3572
|
-
* $ pnpm install --frozen-lockfile
|
|
3573
|
-
* $ pnpm build
|
|
3574
|
-
* $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
|
|
3575
|
-
*
|
|
3576
|
-
* `npm login --auth-type=web` runs automatically the first time — no
|
|
3577
|
-
* separate manual step. `pnpm install` + `pnpm build` stay outside the
|
|
3578
|
-
* command (no pnpm-inside-pnpm).
|
|
3579
|
-
*
|
|
3580
|
-
* `--initial` is required today — this command only implements the
|
|
3581
|
-
* bootstrap publish. A non-initial `holocron package publish` (for a manual publish
|
|
3582
|
-
* outside the semantic-release/OIDC steady state) isn't built yet; the flag
|
|
3583
|
-
* exists so the surface doesn't need another rename when it is.
|
|
3584
|
-
*
|
|
3585
|
-
* If `NPM_TOKEN` is detected in env, the command prints a final
|
|
3586
|
-
* "revoke this token at <url>" reminder — same pattern as `rando vc
|
|
3587
|
-
* setup` for the ephemeral GH admin PAT.
|
|
3968
|
+
* Fully interactive — `npm login --auth-type=web` prints a URL and waits for
|
|
3969
|
+
* the browser flow to complete; the operator needs to see that live, so all
|
|
3970
|
+
* three stdio streams are inherited (unlike `defaultExec`, which pipes
|
|
3971
|
+
* stdout/stderr for capture).
|
|
3588
3972
|
*/
|
|
3973
|
+
const defaultLogin = async (cwd) => {
|
|
3974
|
+
return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
|
|
3975
|
+
cwd,
|
|
3976
|
+
stdio: "inherit"
|
|
3977
|
+
}).status ?? -1 };
|
|
3978
|
+
};
|
|
3979
|
+
//#endregion
|
|
3980
|
+
//#region src/commands/publish/has-packages-dir.ts
|
|
3981
|
+
function hasPackagesDir(cwd) {
|
|
3982
|
+
return existsSync(join(cwd, "packages"));
|
|
3983
|
+
}
|
|
3984
|
+
//#endregion
|
|
3985
|
+
//#region src/commands/publish/has-plugins-dir.ts
|
|
3986
|
+
/** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
|
|
3987
|
+
function hasPluginsDir(cwd) {
|
|
3988
|
+
return existsSync(join(cwd, "plugins"));
|
|
3989
|
+
}
|
|
3990
|
+
//#endregion
|
|
3991
|
+
//#region src/commands/publish/discover-manifests.ts
|
|
3589
3992
|
/**
|
|
3590
|
-
* `
|
|
3591
|
-
*
|
|
3592
|
-
*
|
|
3593
|
-
* shell-script prototype this supersedes) for the full "why": npm ties
|
|
3594
|
-
* Trusted Publisher to a workflow FILENAME per package, so renaming the
|
|
3595
|
-
* release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
|
|
3596
|
-
* config is updated to match.
|
|
3597
|
-
*
|
|
3598
|
-
* Requires npm@11.15.0+ (`npm trust`) — always shells out through
|
|
3599
|
-
* `npx -y npm@latest` regardless of the locally-installed npm version.
|
|
3600
|
-
*
|
|
3601
|
-
* npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
|
|
3602
|
-
* require a fresh interactive browser 2FA approval (an `EOTP` error with an
|
|
3603
|
-
* `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
|
|
3604
|
-
* with the bypass-2FA option are explicitly unsupported for trust commands).
|
|
3605
|
-
* This command can't drive that browser step itself, so on the first `EOTP`
|
|
3606
|
-
* it stops immediately, prints the `authUrl`, and returns — the operator
|
|
3607
|
-
* approves it and re-runs; already-migrated packages are skipped on the next
|
|
3608
|
-
* pass, so it's safe to resume from wherever it stopped.
|
|
3993
|
+
* Monorepo (`packages/` and/or `plugins/` present): every workspace under
|
|
3994
|
+
* either. Single package (neither dir present): the root `package.json`.
|
|
3995
|
+
* Callers keep the public ones.
|
|
3609
3996
|
*/
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
const packageNames = input.packages ?? discoverPublicPackages(cwd);
|
|
3618
|
-
const npm = "npx";
|
|
3619
|
-
const npmArgs = ["-y", "npm@latest"];
|
|
3620
|
-
print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
|
|
3621
|
-
print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
|
|
3622
|
-
print(` ${packageNames.length} public packages`);
|
|
3623
|
-
print("");
|
|
3624
|
-
logger.info({
|
|
3625
|
-
oldFile: input.oldFile,
|
|
3626
|
-
newFile: input.newFile,
|
|
3627
|
-
packages: packageNames.length
|
|
3628
|
-
}, "sync-trust: start");
|
|
3629
|
-
if (packageNames.length === 0) {
|
|
3630
|
-
const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
3631
|
-
print(` ✗ ${message}`);
|
|
3632
|
-
return {
|
|
3633
|
-
status: "fail",
|
|
3634
|
-
message,
|
|
3635
|
-
packages: []
|
|
3636
|
-
};
|
|
3637
|
-
}
|
|
3638
|
-
if (dryRun) {
|
|
3639
|
-
for (const name of packageNames) print(` … would sync ${name}`);
|
|
3640
|
-
return {
|
|
3641
|
-
status: "dry-run",
|
|
3642
|
-
message: "dry-run — no npm calls made",
|
|
3643
|
-
packages: []
|
|
3644
|
-
};
|
|
3645
|
-
}
|
|
3646
|
-
const results = [];
|
|
3647
|
-
for (const name of packageNames) {
|
|
3648
|
-
print(`=== ${name} ===`);
|
|
3649
|
-
const parsed = parseJson((await exec(npm, [
|
|
3650
|
-
...npmArgs,
|
|
3651
|
-
"trust",
|
|
3652
|
-
"list",
|
|
3653
|
-
name,
|
|
3654
|
-
"--json"
|
|
3655
|
-
], { cwd })).stdout);
|
|
3656
|
-
if (isEotp(parsed)) {
|
|
3657
|
-
const authUrl = parsed.error?.authUrl ?? "";
|
|
3658
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3659
|
-
print(" then re-run; already-migrated packages are skipped.");
|
|
3660
|
-
results.push({
|
|
3661
|
-
name,
|
|
3662
|
-
status: "needs-auth",
|
|
3663
|
-
message: authUrl
|
|
3664
|
-
});
|
|
3665
|
-
logger.warn({
|
|
3666
|
-
package: name,
|
|
3667
|
-
authUrl
|
|
3668
|
-
}, "sync-trust: needs auth, stopping");
|
|
3669
|
-
return {
|
|
3670
|
-
status: "fail",
|
|
3671
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3672
|
-
packages: results
|
|
3673
|
-
};
|
|
3674
|
-
}
|
|
3675
|
-
const entry = parsed;
|
|
3676
|
-
if (entry.file === input.newFile) {
|
|
3677
|
-
print(` already on ${input.newFile}, skipping`);
|
|
3678
|
-
results.push({
|
|
3679
|
-
name,
|
|
3680
|
-
status: "skipped"
|
|
3681
|
-
});
|
|
3682
|
-
continue;
|
|
3683
|
-
}
|
|
3684
|
-
if (entry.id) {
|
|
3685
|
-
print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
|
|
3686
|
-
const revoke = await exec(npm, [
|
|
3687
|
-
...npmArgs,
|
|
3688
|
-
"trust",
|
|
3689
|
-
"revoke",
|
|
3690
|
-
name,
|
|
3691
|
-
"--id",
|
|
3692
|
-
entry.id,
|
|
3693
|
-
"-y"
|
|
3694
|
-
], { cwd });
|
|
3695
|
-
if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
|
|
3696
|
-
const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
|
|
3697
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3698
|
-
results.push({
|
|
3699
|
-
name,
|
|
3700
|
-
status: "needs-auth",
|
|
3701
|
-
message: authUrl
|
|
3702
|
-
});
|
|
3703
|
-
return {
|
|
3704
|
-
status: "fail",
|
|
3705
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3706
|
-
packages: results
|
|
3707
|
-
};
|
|
3708
|
-
}
|
|
3709
|
-
} else print(" no existing trust config found");
|
|
3710
|
-
print(` creating trust: ${input.newFile}`);
|
|
3711
|
-
const create = await exec(npm, [
|
|
3712
|
-
...npmArgs,
|
|
3713
|
-
"trust",
|
|
3714
|
-
"github",
|
|
3715
|
-
name,
|
|
3716
|
-
"--repo",
|
|
3717
|
-
repo,
|
|
3718
|
-
"--file",
|
|
3719
|
-
input.newFile,
|
|
3720
|
-
"--allow-publish",
|
|
3721
|
-
"--allow-stage-publish",
|
|
3722
|
-
"-y"
|
|
3723
|
-
], { cwd });
|
|
3724
|
-
if (create.exitCode === 0) {
|
|
3725
|
-
print(" ✓ ok");
|
|
3726
|
-
results.push({
|
|
3727
|
-
name,
|
|
3728
|
-
status: "ok"
|
|
3729
|
-
});
|
|
3730
|
-
} else {
|
|
3731
|
-
const createParsed = parseJson(create.stdout);
|
|
3732
|
-
if (isEotp(createParsed)) {
|
|
3733
|
-
const authUrl = createParsed.error?.authUrl ?? "";
|
|
3734
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3735
|
-
results.push({
|
|
3736
|
-
name,
|
|
3737
|
-
status: "needs-auth",
|
|
3738
|
-
message: authUrl
|
|
3739
|
-
});
|
|
3740
|
-
return {
|
|
3741
|
-
status: "fail",
|
|
3742
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3743
|
-
packages: results
|
|
3744
|
-
};
|
|
3745
|
-
}
|
|
3746
|
-
const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
|
|
3747
|
-
print(` ✗ FAILED: ${message}`);
|
|
3748
|
-
results.push({
|
|
3749
|
-
name,
|
|
3750
|
-
status: "fail",
|
|
3751
|
-
message
|
|
3752
|
-
});
|
|
3753
|
-
}
|
|
3997
|
+
function discoverManifests(cwd) {
|
|
3998
|
+
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
3999
|
+
const manifest = readPackageJsonSync(cwd);
|
|
4000
|
+
return manifest ? [{
|
|
4001
|
+
manifest,
|
|
4002
|
+
dir: "."
|
|
4003
|
+
}] : [];
|
|
3754
4004
|
}
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
}, "sync-trust: done");
|
|
3760
|
-
return {
|
|
3761
|
-
status: failed.length > 0 ? "fail" : "ok",
|
|
3762
|
-
...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
|
|
3763
|
-
packages: results
|
|
3764
|
-
};
|
|
4005
|
+
return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
|
|
4006
|
+
manifest,
|
|
4007
|
+
dir: `./${relativeDir}`
|
|
4008
|
+
}));
|
|
3765
4009
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
4010
|
+
//#endregion
|
|
4011
|
+
//#region src/commands/publish/discover-public-packages.ts
|
|
4012
|
+
/** The names of `discoverPublicPackageEntries`' packages, without requiring a version. */
|
|
4013
|
+
function discoverPublicPackages(cwd) {
|
|
4014
|
+
return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
|
|
4015
|
+
}
|
|
4016
|
+
//#endregion
|
|
4017
|
+
//#region src/commands/publish/print-next-steps.ts
|
|
4018
|
+
function printNextSteps(print, env, packageNames, repoName) {
|
|
4019
|
+
print("");
|
|
4020
|
+
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
4021
|
+
for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
|
|
4022
|
+
print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
|
|
4023
|
+
if (env.get("NPM_TOKEN")) {
|
|
4024
|
+
print("");
|
|
4025
|
+
print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
|
|
4026
|
+
print(" https://www.npmjs.com/settings/~/tokens");
|
|
3771
4027
|
}
|
|
3772
4028
|
}
|
|
3773
|
-
|
|
3774
|
-
|
|
4029
|
+
//#endregion
|
|
4030
|
+
//#region src/commands/publish/resolve-repo-name.ts
|
|
4031
|
+
async function resolveRepoName(cwd, exec) {
|
|
4032
|
+
return (await readGitRemote(cwd, { exec: async (args, options) => {
|
|
4033
|
+
const result = await exec("git", args, options);
|
|
4034
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
|
|
4035
|
+
return result.stdout;
|
|
4036
|
+
} }))?.repo ?? "unknown";
|
|
4037
|
+
}
|
|
4038
|
+
//#endregion
|
|
4039
|
+
//#region src/commands/publish/workspace-filter-args.ts
|
|
4040
|
+
/**
|
|
4041
|
+
* `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
|
|
4042
|
+
* roots exist. Empty (single-package repo, no `-r`) when neither does.
|
|
4043
|
+
* Shared by every `pnpm publish` invocation below so a repo with only
|
|
4044
|
+
* `plugins/*` (or both) is covered the same as `packages/*`-only.
|
|
4045
|
+
*/
|
|
4046
|
+
function workspaceFilterArgs(cwd) {
|
|
4047
|
+
const filters = [];
|
|
4048
|
+
if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
|
|
4049
|
+
if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
|
|
4050
|
+
return filters.length > 0 ? ["-r", ...filters] : [];
|
|
3775
4051
|
}
|
|
4052
|
+
//#endregion
|
|
4053
|
+
//#region src/commands/publish/run-publish/run-publish.ts
|
|
4054
|
+
/**
|
|
4055
|
+
* `holocron package publish --initial` — bottles up the chicken-and-egg bootstrap
|
|
4056
|
+
* that every new npm-published holocron monorepo hits.
|
|
4057
|
+
*
|
|
4058
|
+
* npm requires a package to exist before Trusted Publishing can be
|
|
4059
|
+
* configured on it. So the first publish has to happen outside the OIDC
|
|
4060
|
+
* flow — a browser-auth session (`npm login --auth-type=web`) or an
|
|
4061
|
+
* ephemeral automation token. This command drives the login step itself
|
|
4062
|
+
* (when `npm whoami` shows you're not authenticated), runs the publish
|
|
4063
|
+
* step, and tells you exactly what to do next.
|
|
4064
|
+
*
|
|
4065
|
+
* Workflow:
|
|
4066
|
+
*
|
|
4067
|
+
* $ pnpm install --frozen-lockfile
|
|
4068
|
+
* $ pnpm build
|
|
4069
|
+
* $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
|
|
4070
|
+
*
|
|
4071
|
+
* `npm login --auth-type=web` runs automatically the first time — no
|
|
4072
|
+
* separate manual step. `pnpm install` + `pnpm build` stay outside the
|
|
4073
|
+
* command (no pnpm-inside-pnpm).
|
|
4074
|
+
*
|
|
4075
|
+
* `--initial` is required today — this command only implements the
|
|
4076
|
+
* bootstrap publish. A non-initial `holocron package publish` (for a manual publish
|
|
4077
|
+
* outside the semantic-release/OIDC steady state) isn't built yet; the flag
|
|
4078
|
+
* exists so the surface doesn't need another rename when it is.
|
|
4079
|
+
*
|
|
4080
|
+
* If `NPM_TOKEN` is detected in env, the command prints a final
|
|
4081
|
+
* "revoke this token at <url>" reminder — same pattern as `rando vc
|
|
4082
|
+
* setup` for the ephemeral GH admin PAT.
|
|
4083
|
+
*/
|
|
3776
4084
|
async function runPublish(input = {}) {
|
|
3777
4085
|
const print = input.print ?? ((line) => console.log(line));
|
|
3778
4086
|
const logger = input.logger ?? getLogger();
|
|
@@ -3893,6 +4201,23 @@ async function runPublish(input = {}) {
|
|
|
3893
4201
|
packageNames
|
|
3894
4202
|
};
|
|
3895
4203
|
}
|
|
4204
|
+
//#endregion
|
|
4205
|
+
//#region src/commands/publish/discover-public-package-entries.ts
|
|
4206
|
+
/**
|
|
4207
|
+
* Every public package (`!private && name`) with its version and a
|
|
4208
|
+
* `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
|
|
4209
|
+
* mode needs both to check `npm view <pkg>@<version>` and publish one
|
|
4210
|
+
* workspace at a time.
|
|
4211
|
+
*/
|
|
4212
|
+
function discoverPublicPackageEntries(cwd) {
|
|
4213
|
+
return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
|
|
4214
|
+
name: manifest.name,
|
|
4215
|
+
version: manifest.version,
|
|
4216
|
+
dir
|
|
4217
|
+
}] : []);
|
|
4218
|
+
}
|
|
4219
|
+
//#endregion
|
|
4220
|
+
//#region src/commands/publish/run-steady-publish/run-steady-publish.ts
|
|
3896
4221
|
/**
|
|
3897
4222
|
* `holocron package publish` (no `--initial`) — the steady-state publish
|
|
3898
4223
|
* `exec.publishCmd` in `release.config.ts` should call, replacing each
|
|
@@ -4036,129 +4361,222 @@ async function runSteadyPublish(input = {}) {
|
|
|
4036
4361
|
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
4037
4362
|
print(` pnpm exec holocron package publish --otp <6-digit-code>`);
|
|
4038
4363
|
}
|
|
4039
|
-
logger.warn({
|
|
4040
|
-
tag,
|
|
4041
|
-
reason: message
|
|
4042
|
-
}, "publish: done");
|
|
4043
|
-
return {
|
|
4044
|
-
status: "fail",
|
|
4045
|
-
message,
|
|
4046
|
-
packageNames
|
|
4047
|
-
};
|
|
4364
|
+
logger.warn({
|
|
4365
|
+
tag,
|
|
4366
|
+
reason: message
|
|
4367
|
+
}, "publish: done");
|
|
4368
|
+
return {
|
|
4369
|
+
status: "fail",
|
|
4370
|
+
message,
|
|
4371
|
+
packageNames
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
print(" ✓ publish complete");
|
|
4375
|
+
logger.info({
|
|
4376
|
+
tag,
|
|
4377
|
+
status: "ok",
|
|
4378
|
+
packages: entries.length
|
|
4379
|
+
}, "publish: done");
|
|
4380
|
+
return {
|
|
4381
|
+
status: "ok",
|
|
4382
|
+
packageNames
|
|
4383
|
+
};
|
|
4384
|
+
}
|
|
4385
|
+
//#endregion
|
|
4386
|
+
//#region src/commands/publish/is-eotp.ts
|
|
4387
|
+
function isEotp(parsed) {
|
|
4388
|
+
return typeof parsed === "object" && parsed !== null && "error" in parsed && parsed.error?.code === "EOTP";
|
|
4389
|
+
}
|
|
4390
|
+
//#endregion
|
|
4391
|
+
//#region src/commands/publish/parse-json.ts
|
|
4392
|
+
function parseJson(raw) {
|
|
4393
|
+
try {
|
|
4394
|
+
return JSON.parse(raw);
|
|
4395
|
+
} catch {
|
|
4396
|
+
return;
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
//#endregion
|
|
4400
|
+
//#region src/commands/publish/run-sync-trust/run-sync-trust.ts
|
|
4401
|
+
/**
|
|
4402
|
+
* `holocron package publish --sync-trust <old-file> <new-file>` — bulk-migrates npm
|
|
4403
|
+
* Trusted Publisher (OIDC) config to a new workflow filename across every
|
|
4404
|
+
* public package. See issue #689 / `scripts/npm-trust-migrate.sh` (the
|
|
4405
|
+
* shell-script prototype this supersedes) for the full "why": npm ties
|
|
4406
|
+
* Trusted Publisher to a workflow FILENAME per package, so renaming the
|
|
4407
|
+
* release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
|
|
4408
|
+
* config is updated to match.
|
|
4409
|
+
*
|
|
4410
|
+
* Requires npm@11.15.0+ (`npm trust`) — always shells out through
|
|
4411
|
+
* `npx -y npm@latest` regardless of the locally-installed npm version.
|
|
4412
|
+
*
|
|
4413
|
+
* npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
|
|
4414
|
+
* require a fresh interactive browser 2FA approval (an `EOTP` error with an
|
|
4415
|
+
* `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
|
|
4416
|
+
* with the bypass-2FA option are explicitly unsupported for trust commands).
|
|
4417
|
+
* This command can't drive that browser step itself, so on the first `EOTP`
|
|
4418
|
+
* it stops immediately, prints the `authUrl`, and returns — the operator
|
|
4419
|
+
* approves it and re-runs; already-migrated packages are skipped on the next
|
|
4420
|
+
* pass, so it's safe to resume from wherever it stopped.
|
|
4421
|
+
*/
|
|
4422
|
+
async function runSyncTrust(input) {
|
|
4423
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
4424
|
+
const logger = input.logger ?? getLogger();
|
|
4425
|
+
const cwd = input.cwd ?? process.cwd();
|
|
4426
|
+
const repo = input.repo ?? "theholocron/holocron";
|
|
4427
|
+
const dryRun = input.dryRun ?? false;
|
|
4428
|
+
const exec = input.exec ?? defaultExec$1;
|
|
4429
|
+
const packageNames = input.packages ?? discoverPublicPackages(cwd);
|
|
4430
|
+
const npm = "npx";
|
|
4431
|
+
const npmArgs = ["-y", "npm@latest"];
|
|
4432
|
+
print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
|
|
4433
|
+
print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
|
|
4434
|
+
print(` ${packageNames.length} public packages`);
|
|
4435
|
+
print("");
|
|
4436
|
+
logger.info({
|
|
4437
|
+
oldFile: input.oldFile,
|
|
4438
|
+
newFile: input.newFile,
|
|
4439
|
+
packages: packageNames.length
|
|
4440
|
+
}, "sync-trust: start");
|
|
4441
|
+
if (packageNames.length === 0) {
|
|
4442
|
+
const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
4443
|
+
print(` ✗ ${message}`);
|
|
4444
|
+
return {
|
|
4445
|
+
status: "fail",
|
|
4446
|
+
message,
|
|
4447
|
+
packages: []
|
|
4448
|
+
};
|
|
4449
|
+
}
|
|
4450
|
+
if (dryRun) {
|
|
4451
|
+
for (const name of packageNames) print(` … would sync ${name}`);
|
|
4452
|
+
return {
|
|
4453
|
+
status: "dry-run",
|
|
4454
|
+
message: "dry-run — no npm calls made",
|
|
4455
|
+
packages: []
|
|
4456
|
+
};
|
|
4457
|
+
}
|
|
4458
|
+
const results = [];
|
|
4459
|
+
for (const name of packageNames) {
|
|
4460
|
+
print(`=== ${name} ===`);
|
|
4461
|
+
const parsed = parseJson((await exec(npm, [
|
|
4462
|
+
...npmArgs,
|
|
4463
|
+
"trust",
|
|
4464
|
+
"list",
|
|
4465
|
+
name,
|
|
4466
|
+
"--json"
|
|
4467
|
+
], { cwd })).stdout);
|
|
4468
|
+
if (isEotp(parsed)) {
|
|
4469
|
+
const authUrl = parsed.error?.authUrl ?? "";
|
|
4470
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
4471
|
+
print(" then re-run; already-migrated packages are skipped.");
|
|
4472
|
+
results.push({
|
|
4473
|
+
name,
|
|
4474
|
+
status: "needs-auth",
|
|
4475
|
+
message: authUrl
|
|
4476
|
+
});
|
|
4477
|
+
logger.warn({
|
|
4478
|
+
package: name,
|
|
4479
|
+
authUrl
|
|
4480
|
+
}, "sync-trust: needs auth, stopping");
|
|
4481
|
+
return {
|
|
4482
|
+
status: "fail",
|
|
4483
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
4484
|
+
packages: results
|
|
4485
|
+
};
|
|
4486
|
+
}
|
|
4487
|
+
const entry = parsed;
|
|
4488
|
+
if (entry.file === input.newFile) {
|
|
4489
|
+
print(` already on ${input.newFile}, skipping`);
|
|
4490
|
+
results.push({
|
|
4491
|
+
name,
|
|
4492
|
+
status: "skipped"
|
|
4493
|
+
});
|
|
4494
|
+
continue;
|
|
4495
|
+
}
|
|
4496
|
+
if (entry.id) {
|
|
4497
|
+
print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
|
|
4498
|
+
const revoke = await exec(npm, [
|
|
4499
|
+
...npmArgs,
|
|
4500
|
+
"trust",
|
|
4501
|
+
"revoke",
|
|
4502
|
+
name,
|
|
4503
|
+
"--id",
|
|
4504
|
+
entry.id,
|
|
4505
|
+
"-y"
|
|
4506
|
+
], { cwd });
|
|
4507
|
+
if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
|
|
4508
|
+
const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
|
|
4509
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
4510
|
+
results.push({
|
|
4511
|
+
name,
|
|
4512
|
+
status: "needs-auth",
|
|
4513
|
+
message: authUrl
|
|
4514
|
+
});
|
|
4515
|
+
return {
|
|
4516
|
+
status: "fail",
|
|
4517
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
4518
|
+
packages: results
|
|
4519
|
+
};
|
|
4520
|
+
}
|
|
4521
|
+
} else print(" no existing trust config found");
|
|
4522
|
+
print(` creating trust: ${input.newFile}`);
|
|
4523
|
+
const create = await exec(npm, [
|
|
4524
|
+
...npmArgs,
|
|
4525
|
+
"trust",
|
|
4526
|
+
"github",
|
|
4527
|
+
name,
|
|
4528
|
+
"--repo",
|
|
4529
|
+
repo,
|
|
4530
|
+
"--file",
|
|
4531
|
+
input.newFile,
|
|
4532
|
+
"--allow-publish",
|
|
4533
|
+
"--allow-stage-publish",
|
|
4534
|
+
"-y"
|
|
4535
|
+
], { cwd });
|
|
4536
|
+
if (create.exitCode === 0) {
|
|
4537
|
+
print(" ✓ ok");
|
|
4538
|
+
results.push({
|
|
4539
|
+
name,
|
|
4540
|
+
status: "ok"
|
|
4541
|
+
});
|
|
4542
|
+
} else {
|
|
4543
|
+
const createParsed = parseJson(create.stdout);
|
|
4544
|
+
if (isEotp(createParsed)) {
|
|
4545
|
+
const authUrl = createParsed.error?.authUrl ?? "";
|
|
4546
|
+
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
4547
|
+
results.push({
|
|
4548
|
+
name,
|
|
4549
|
+
status: "needs-auth",
|
|
4550
|
+
message: authUrl
|
|
4551
|
+
});
|
|
4552
|
+
return {
|
|
4553
|
+
status: "fail",
|
|
4554
|
+
message: "npm 2FA grant needed — see printed authUrl",
|
|
4555
|
+
packages: results
|
|
4556
|
+
};
|
|
4557
|
+
}
|
|
4558
|
+
const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
|
|
4559
|
+
print(` ✗ FAILED: ${message}`);
|
|
4560
|
+
results.push({
|
|
4561
|
+
name,
|
|
4562
|
+
status: "fail",
|
|
4563
|
+
message
|
|
4564
|
+
});
|
|
4565
|
+
}
|
|
4048
4566
|
}
|
|
4049
|
-
|
|
4567
|
+
const failed = results.filter((r) => r.status === "fail");
|
|
4050
4568
|
logger.info({
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
}, "publish: done");
|
|
4569
|
+
ok: results.filter((r) => r.status === "ok").length,
|
|
4570
|
+
failed: failed.length
|
|
4571
|
+
}, "sync-trust: done");
|
|
4055
4572
|
return {
|
|
4056
|
-
status: "ok",
|
|
4057
|
-
|
|
4573
|
+
status: failed.length > 0 ? "fail" : "ok",
|
|
4574
|
+
...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
|
|
4575
|
+
packages: results
|
|
4058
4576
|
};
|
|
4059
4577
|
}
|
|
4060
|
-
function hasPackagesDir(cwd) {
|
|
4061
|
-
return existsSync(join(cwd, "packages"));
|
|
4062
|
-
}
|
|
4063
|
-
/** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
|
|
4064
|
-
function hasPluginsDir(cwd) {
|
|
4065
|
-
return existsSync(join(cwd, "plugins"));
|
|
4066
|
-
}
|
|
4067
|
-
/**
|
|
4068
|
-
* `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
|
|
4069
|
-
* roots exist. Empty (single-package repo, no `-r`) when neither does.
|
|
4070
|
-
* Shared by every `pnpm publish` invocation below so a repo with only
|
|
4071
|
-
* `plugins/*` (or both) is covered the same as `packages/*`-only.
|
|
4072
|
-
*/
|
|
4073
|
-
function workspaceFilterArgs(cwd) {
|
|
4074
|
-
const filters = [];
|
|
4075
|
-
if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
|
|
4076
|
-
if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
|
|
4077
|
-
return filters.length > 0 ? ["-r", ...filters] : [];
|
|
4078
|
-
}
|
|
4079
|
-
/**
|
|
4080
|
-
* Monorepo (`packages/` and/or `plugins/` present): every workspace under
|
|
4081
|
-
* either. Single package (neither dir present): the root `package.json`.
|
|
4082
|
-
* Callers keep the public ones.
|
|
4083
|
-
*/
|
|
4084
|
-
function discoverManifests(cwd) {
|
|
4085
|
-
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
4086
|
-
const manifest = readPackageJsonSync(cwd);
|
|
4087
|
-
return manifest ? [{
|
|
4088
|
-
manifest,
|
|
4089
|
-
dir: "."
|
|
4090
|
-
}] : [];
|
|
4091
|
-
}
|
|
4092
|
-
return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
|
|
4093
|
-
manifest,
|
|
4094
|
-
dir: `./${relativeDir}`
|
|
4095
|
-
}));
|
|
4096
|
-
}
|
|
4097
|
-
/**
|
|
4098
|
-
* Every public package (`!private && name`) with its version and a
|
|
4099
|
-
* `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
|
|
4100
|
-
* mode needs both to check `npm view <pkg>@<version>` and publish one
|
|
4101
|
-
* workspace at a time.
|
|
4102
|
-
*/
|
|
4103
|
-
function discoverPublicPackageEntries(cwd) {
|
|
4104
|
-
return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
|
|
4105
|
-
name: manifest.name,
|
|
4106
|
-
version: manifest.version,
|
|
4107
|
-
dir
|
|
4108
|
-
}] : []);
|
|
4109
|
-
}
|
|
4110
|
-
/** The names of {@link discoverPublicPackageEntries}' packages, without requiring a version. */
|
|
4111
|
-
function discoverPublicPackages(cwd) {
|
|
4112
|
-
return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
|
|
4113
|
-
}
|
|
4114
|
-
async function resolveRepoName(cwd, exec) {
|
|
4115
|
-
return (await readGitRemote(cwd, { exec: async (args, options) => {
|
|
4116
|
-
const result = await exec("git", args, options);
|
|
4117
|
-
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
|
|
4118
|
-
return result.stdout;
|
|
4119
|
-
} }))?.repo ?? "unknown";
|
|
4120
|
-
}
|
|
4121
|
-
function printNextSteps(print, env, packageNames, repoName) {
|
|
4122
|
-
print("");
|
|
4123
|
-
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
4124
|
-
for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
|
|
4125
|
-
print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
|
|
4126
|
-
if (env.get("NPM_TOKEN")) {
|
|
4127
|
-
print("");
|
|
4128
|
-
print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
|
|
4129
|
-
print(" https://www.npmjs.com/settings/~/tokens");
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
const defaultExec$1 = async (cmd, args, opts) => {
|
|
4133
|
-
const result = spawnSync(cmd, args, {
|
|
4134
|
-
cwd: opts.cwd,
|
|
4135
|
-
encoding: "utf8",
|
|
4136
|
-
stdio: [
|
|
4137
|
-
"inherit",
|
|
4138
|
-
"pipe",
|
|
4139
|
-
"pipe"
|
|
4140
|
-
]
|
|
4141
|
-
});
|
|
4142
|
-
return {
|
|
4143
|
-
exitCode: result.status ?? -1,
|
|
4144
|
-
stdout: result.stdout ?? "",
|
|
4145
|
-
stderr: result.stderr ?? ""
|
|
4146
|
-
};
|
|
4147
|
-
};
|
|
4148
|
-
/**
|
|
4149
|
-
* Fully interactive — `npm login --auth-type=web` prints a URL and waits for
|
|
4150
|
-
* the browser flow to complete; the operator needs to see that live, so all
|
|
4151
|
-
* three stdio streams are inherited (unlike `defaultExec`, which pipes
|
|
4152
|
-
* stdout/stderr for capture).
|
|
4153
|
-
*/
|
|
4154
|
-
const defaultLogin = async (cwd) => {
|
|
4155
|
-
return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
|
|
4156
|
-
cwd,
|
|
4157
|
-
stdio: "inherit"
|
|
4158
|
-
}).status ?? -1 };
|
|
4159
|
-
};
|
|
4160
4578
|
//#endregion
|
|
4161
|
-
//#region src/commands/secret-set.ts
|
|
4579
|
+
//#region src/commands/secret-set/secret-set.ts
|
|
4162
4580
|
/**
|
|
4163
4581
|
* `holocron secrets set` — one-shot single-secret push.
|
|
4164
4582
|
*
|
|
@@ -4250,7 +4668,7 @@ function describeScope(scope) {
|
|
|
4250
4668
|
return `scope=organization:${scope.name}`;
|
|
4251
4669
|
}
|
|
4252
4670
|
//#endregion
|
|
4253
|
-
//#region src/commands/secrets-sync.ts
|
|
4671
|
+
//#region src/commands/secrets-sync/secrets-sync.ts
|
|
4254
4672
|
/**
|
|
4255
4673
|
* `holocron secrets sync` — vault → destinations.
|
|
4256
4674
|
*
|
|
@@ -4415,7 +4833,7 @@ function vaultProviderName(loader) {
|
|
|
4415
4833
|
return loader.get("vault").providerName;
|
|
4416
4834
|
}
|
|
4417
4835
|
//#endregion
|
|
4418
|
-
//#region src/commands/setup/derived-properties.ts
|
|
4836
|
+
//#region src/commands/setup/derived-properties/derived-properties.ts
|
|
4419
4837
|
/**
|
|
4420
4838
|
* Repo archetype, in priority order:
|
|
4421
4839
|
* 1. `*-template` repo name → `template` (independent of package shape).
|
|
@@ -4609,221 +5027,9 @@ const STALE_LABELS = [
|
|
|
4609
5027
|
"released on @alpha"
|
|
4610
5028
|
];
|
|
4611
5029
|
//#endregion
|
|
4612
|
-
//#region src/templates/config.yml
|
|
4613
|
-
var config_default = "# Configuration for sentiment-bot - https://github.com/behaviorbot/sentiment-bot\n\n# *Required* toxicity threshold between 0 and .99 with the higher numbers being the most toxic\n# Anything higher than this threshold will be marked as toxic and commented on\nsentimentBotToxicityThreshold: .7\n\n# *Required* Comment to reply with\nsentimentBotReplyComment: >\n Please be sure to review the [Code of Conduct](https://docs.theholocron.dev/reference/code-of-conduct/) and be respectful of other users.\n";
|
|
4614
|
-
//#endregion
|
|
4615
|
-
//#region src/utils/create-header.ts
|
|
4616
|
-
function createHeader(options) {
|
|
4617
|
-
const { source, tool = "holocron repo setup", forPrimary = false } = options;
|
|
4618
|
-
const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
|
|
4619
|
-
return {
|
|
4620
|
-
workflowHeader(format = "yaml") {
|
|
4621
|
-
if (format === "cjs") return [
|
|
4622
|
-
`/* ${doNotEdit}`,
|
|
4623
|
-
` * Source: theholocron/holocron · ${source}`,
|
|
4624
|
-
` * Tool: ${tool}`,
|
|
4625
|
-
` * Changes: edit source in theholocron/holocron`,
|
|
4626
|
-
` */`,
|
|
4627
|
-
``
|
|
4628
|
-
].join("\n");
|
|
4629
|
-
const yamlLines = [
|
|
4630
|
-
`# ${doNotEdit}`,
|
|
4631
|
-
`# Source: theholocron/holocron · ${source}`,
|
|
4632
|
-
`# Tool: ${tool}`,
|
|
4633
|
-
`# Changes: edit source in theholocron/holocron`,
|
|
4634
|
-
``
|
|
4635
|
-
].join("\n");
|
|
4636
|
-
if (format === "shebang") return `#!/bin/sh\n\n${yamlLines}`;
|
|
4637
|
-
return yamlLines;
|
|
4638
|
-
},
|
|
4639
|
-
scaffoldHeader() {
|
|
4640
|
-
return [
|
|
4641
|
-
`# Scaffolded by holocron repo setup — edit this file freely.`,
|
|
4642
|
-
`# Source: theholocron/holocron · ${source}`,
|
|
4643
|
-
``
|
|
4644
|
-
].join("\n");
|
|
4645
|
-
}
|
|
4646
|
-
};
|
|
4647
|
-
}
|
|
4648
|
-
//#endregion
|
|
4649
|
-
//#region src/templates/configs/alexjs/alexignore
|
|
4650
|
-
var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
4651
|
-
//#endregion
|
|
4652
|
-
//#region src/templates/configs/alexjs/alexrc.json
|
|
4653
|
-
var alexrc_default = { allow: [
|
|
4654
|
-
"dead",
|
|
4655
|
-
"execute",
|
|
4656
|
-
"execution",
|
|
4657
|
-
"executes",
|
|
4658
|
-
"failure",
|
|
4659
|
-
"failures",
|
|
4660
|
-
"hook",
|
|
4661
|
-
"hooks",
|
|
4662
|
-
"husky",
|
|
4663
|
-
"period"
|
|
4664
|
-
] };
|
|
4665
|
-
//#endregion
|
|
4666
|
-
//#region src/templates/configs/alexjs/create-config.ts
|
|
4667
|
-
const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
|
|
4668
|
-
function createRcConfig() {
|
|
4669
|
-
return stringifyJson(alexrc_default);
|
|
4670
|
-
}
|
|
4671
|
-
function createIgnoreConfig$1() {
|
|
4672
|
-
return `${workflowHeader$9()}${alexignore_default}`;
|
|
4673
|
-
}
|
|
4674
|
-
//#endregion
|
|
4675
|
-
//#region src/templates/configs/commit-msg/commit-msg
|
|
4676
|
-
var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
|
|
4677
|
-
//#endregion
|
|
4678
|
-
//#region src/templates/configs/commit-msg/create-config.ts
|
|
4679
|
-
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
4680
|
-
/**
|
|
4681
|
-
* `.husky/commit-msg` — lints the commit message via `holocron run lint
|
|
4682
|
-
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4683
|
-
* shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
|
|
4684
|
-
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4685
|
-
*
|
|
4686
|
-
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4687
|
-
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4688
|
-
*/
|
|
4689
|
-
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4690
|
-
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4691
|
-
}
|
|
4692
|
-
//#endregion
|
|
4693
|
-
//#region src/templates/configs/devmoji/create-config.ts
|
|
4694
|
-
const { workflowHeader: workflowHeader$7 } = createHeader({ source: "packages/cli/src/templates/configs/devmoji/create-config.ts" });
|
|
4695
|
-
function createConfig$6() {
|
|
4696
|
-
return [
|
|
4697
|
-
workflowHeader$7("cjs"),
|
|
4698
|
-
`/* eslint-disable */`,
|
|
4699
|
-
`const { defineConfig } = require("@theholocron/devmoji-config");`,
|
|
4700
|
-
`module.exports = defineConfig();`,
|
|
4701
|
-
``
|
|
4702
|
-
].join("\n");
|
|
4703
|
-
}
|
|
4704
|
-
//#endregion
|
|
4705
|
-
//#region src/templates/configs/editorconfig/editorconfig
|
|
4706
|
-
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";
|
|
4707
|
-
//#endregion
|
|
4708
|
-
//#region src/templates/configs/editorconfig/create-config.ts
|
|
4709
|
-
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
4710
|
-
function createConfig$5() {
|
|
4711
|
-
return `${workflowHeader$6()}${editorconfig_default}`;
|
|
4712
|
-
}
|
|
4713
|
-
//#endregion
|
|
4714
|
-
//#region src/templates/configs/editorconfig-checker/editorconfig-checker.json
|
|
4715
|
-
var editorconfig_checker_default = {
|
|
4716
|
-
Verbose: false,
|
|
4717
|
-
Format: "",
|
|
4718
|
-
Debug: false,
|
|
4719
|
-
IgnoreDefaults: false,
|
|
4720
|
-
SpacesAfterTabs: false,
|
|
4721
|
-
NoColor: false,
|
|
4722
|
-
Exclude: [
|
|
4723
|
-
"(^|.+/)LICENSE$",
|
|
4724
|
-
"^public/.*",
|
|
4725
|
-
"\\.md$",
|
|
4726
|
-
"\\.mdx$"
|
|
4727
|
-
],
|
|
4728
|
-
AllowedContentTypes: [],
|
|
4729
|
-
PassedFiles: [],
|
|
4730
|
-
Disable: {
|
|
4731
|
-
"EndOfLine": false,
|
|
4732
|
-
"Indentation": false,
|
|
4733
|
-
"InsertFinalNewline": false,
|
|
4734
|
-
"TrimTrailingWhitespace": false,
|
|
4735
|
-
"IndentSize": false,
|
|
4736
|
-
"MaxLineLength": false
|
|
4737
|
-
}
|
|
4738
|
-
};
|
|
4739
|
-
//#endregion
|
|
4740
|
-
//#region src/templates/configs/editorconfig-checker/create-config.ts
|
|
4741
|
-
/**
|
|
4742
|
-
* The generated `.editorconfig-checker.json`.
|
|
4743
|
-
*
|
|
4744
|
-
* Deliberately carries no `"Version"` key: editorconfig-checker treats it as a
|
|
4745
|
-
* hard gate and exits non-zero the moment the running binary's version differs
|
|
4746
|
-
* — which it does the instant a contributor's local install (Homebrew tracks
|
|
4747
|
-
* latest) drifts from the version super-linter bundles in CI, breaking
|
|
4748
|
-
* `holocron run lint` / the pre-push hook for everyone (#618). The config
|
|
4749
|
-
* options here (`Exclude`, `Disable`) are stable across releases, so pinning
|
|
4750
|
-
* buys nothing.
|
|
4751
|
-
*/
|
|
4752
|
-
function createConfig$4() {
|
|
4753
|
-
return stringifyJson(editorconfig_checker_default);
|
|
4754
|
-
}
|
|
4755
|
-
//#endregion
|
|
4756
|
-
//#region src/templates/configs/pre-commit/pre-commit
|
|
4757
|
-
var pre_commit_default = "# Run GitLeaks against staged changes only — a full `gitleaks git` history\n# scan re-flags every pre-existing (already-merged) finding on every commit,\n# permanently blocking the repo once any false positive lands in history.\n# CI's super-linter GITLEAKS validator is likewise scoped to changed files.\ngitleaks protect --staged --no-banner\n\n# Exit with non-zero if secrets are detected by GitLeaks\nif [ $? -ne 0 ]; then\n echo \"Secrets detected by GitLeaks, aborting commit!\"\n exit 1\nfi\n\n# Run lint-staged with a custom config\nnpx lint-staged --config @theholocron/lint-staged-config\n";
|
|
4758
|
-
//#endregion
|
|
4759
|
-
//#region src/templates/configs/pre-commit/create-config.ts
|
|
4760
|
-
const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
|
|
4761
|
-
/**
|
|
4762
|
-
* `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
|
|
4763
|
-
* lint-staged before every commit. Installed by `holocron repo setup` when hooks
|
|
4764
|
-
* are enabled (default for `protection: "strict"`). `git commit --no-verify`
|
|
4765
|
-
* bypasses it.
|
|
4766
|
-
*
|
|
4767
|
-
* `gitleaks protect --staged` (not `gitleaks git`) is deliberate: the latter
|
|
4768
|
-
* scans the entire commit history on every invocation, so once any false
|
|
4769
|
-
* positive lands in a past commit — a fake test token, an example key in a
|
|
4770
|
-
* doc — every future commit is permanently blocked, regardless of what's
|
|
4771
|
-
* actually staged.
|
|
4772
|
-
*/
|
|
4773
|
-
function createConfig$3() {
|
|
4774
|
-
return `${workflowHeader$5("shebang")}${pre_commit_default}`;
|
|
4775
|
-
}
|
|
4776
|
-
//#endregion
|
|
4777
|
-
//#region src/templates/configs/pre-push/pre-push
|
|
4778
|
-
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";
|
|
4779
|
-
//#endregion
|
|
4780
|
-
//#region src/templates/configs/pre-push/create-config.ts
|
|
4781
|
-
const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
|
|
4782
|
-
/**
|
|
4783
|
-
* `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
|
|
4784
|
-
* before every push. Installed by `holocron repo setup` when hooks are enabled
|
|
4785
|
-
* (default for `protection: "strict"`). `git push --no-verify` bypasses it.
|
|
4786
|
-
*
|
|
4787
|
-
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4788
|
-
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4789
|
-
*/
|
|
4790
|
-
function createConfig$2(holocronScript = "pnpm exec holocron") {
|
|
4791
|
-
return `${workflowHeader$4("shebang")}${pre_push_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4792
|
-
}
|
|
4793
|
-
//#endregion
|
|
4794
|
-
//#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
|
|
4795
|
-
var prepare_commit_msg_default = "NAME=$(git config user.name)\nEMAIL=$(git config user.email)\n\nif [ -z \"$NAME\" ]; then\n echo \"empty git config user.name\"\n exit 1\nfi\n\nif [ -z \"$EMAIL\" ]; then\n echo \"empty git config user.email\"\n exit 1\nfi\n\ngit interpret-trailers --if-exists doNothing --trailer \\\n \"Signed-off-by: $NAME <$EMAIL>\" \\\n --in-place \"$1\"\n\n# --config <resolved shared path> (config-resolution workstream, #676) — falls\n# back to devmoji's own auto-discovery of a local devmoji.config.cjs when the\n# shared package's built dist/ isn't present (not installed, or a stale\n# install predating this file).\nDEVMOJI_CONFIG=\"node_modules/@theholocron/devmoji-config/dist/index.cjs\"\nif [ -f \"$DEVMOJI_CONFIG\" ]; then\n npx devmoji --config \"$DEVMOJI_CONFIG\" -e\nelse\n npx devmoji -e\nfi\n";
|
|
4796
|
-
//#endregion
|
|
4797
|
-
//#region src/templates/configs/prepare-commit-msg/create-config.ts
|
|
4798
|
-
const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
|
|
4799
|
-
function createConfig$1() {
|
|
4800
|
-
return `${workflowHeader$3("shebang")}${prepare_commit_msg_default}`;
|
|
4801
|
-
}
|
|
4802
|
-
//#endregion
|
|
4803
|
-
//#region src/templates/configs/yamllint/.yamlignore
|
|
4804
|
-
var _yamlignore_default = "pnpm-lock.yaml\n";
|
|
4805
|
-
//#endregion
|
|
4806
|
-
//#region src/templates/configs/yamllint/.yamllint.yml
|
|
4807
|
-
var _yamllint_default = "---\nextends: default\n\nignore-from-file:\n - .gitignore\n - .yamlignore\n\nrules:\n comments:\n min-spaces-from-content: 1\n require-starting-space: true\n\n comments-indentation: {}\n\n document-start:\n ignore: |\n .github/*.yml\n\n indentation:\n spaces: consistent\n indent-sequences: consistent\n\n line-length: disable\n";
|
|
4808
|
-
//#endregion
|
|
4809
|
-
//#region src/templates/configs/yamllint/create-config.ts
|
|
4810
|
-
const { workflowHeader: workflowHeader$2 } = createHeader({ source: "packages/cli/src/templates/configs/yamllint/create-config.ts" });
|
|
4811
|
-
function createConfig() {
|
|
4812
|
-
return `${workflowHeader$2()}${_yamllint_default}`;
|
|
4813
|
-
}
|
|
4814
|
-
function createIgnoreConfig() {
|
|
4815
|
-
return `${workflowHeader$2()}${_yamlignore_default}`;
|
|
4816
|
-
}
|
|
4817
|
-
//#endregion
|
|
4818
5030
|
//#region src/templates/dco.yml
|
|
4819
5031
|
var dco_default = "allowRemediationCommits:\n individual: true\n";
|
|
4820
5032
|
//#endregion
|
|
4821
|
-
//#region src/templates/dependabot.yml
|
|
4822
|
-
var dependabot_default = "version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n commit-message:\n prefix: \"chore(deps)\"\n prefix-development: \"chore(deps-dev)\"\n groups:\n security-patches:\n applies-to: security-updates\n patterns:\n - \"*\"\n all-dependencies:\n update-types:\n - minor\n - patch\n\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n commit-message:\n prefix: \"chore(deps)\"\n groups:\n all-actions:\n patterns:\n - \"*\"\n";
|
|
4823
|
-
//#endregion
|
|
4824
|
-
//#region src/templates/labeler.yml
|
|
4825
|
-
var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\nci:\n - \"^ci\"\n\ndependencies:\n - '^chore\\(deps'\n\ndocumentation:\n - \"^docs\"\n\nenhancement:\n - \"^feat\"\n\nperformance:\n - \"^perf\"\n\nrefactor:\n - \"^refactor\"\n\ntest:\n - \"^test\"\n";
|
|
4826
|
-
//#endregion
|
|
4827
5033
|
//#region src/commands/setup/agent-prompts-data.ts
|
|
4828
5034
|
/**
|
|
4829
5035
|
* Canonical AI engineering workflow role prompts.
|
|
@@ -4834,7 +5040,6 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
|
|
|
4834
5040
|
*
|
|
4835
5041
|
* Source: docs/wiki/specifications/ai-engineering-workflow.spec.md
|
|
4836
5042
|
*/
|
|
4837
|
-
const DECISIONS_TEMPLATE = "---\nid: ADR-XXXX\ntitle: \"\"\nstatus: proposed\ndate: YYYY-MM-DD\nowners: []\nspecs: []\ndiscussion:\n github:\nsupersedes: []\nsuperseded-by: []\ntags: []\n---\n\n# [Short title of the decision]\n\n- Status: [proposed | accepted | rejected | deprecated | superseded by ADR-XXXX]\n- Date: YYYY-MM-DD\n\n## Context and Problem Statement\n\n2–3 sentences describing the situation that forced this decision.\n\n## Decision Drivers\n\n- [driver 1 — a constraint, goal, or value]\n- [driver 2]\n\n## Considered Options\n\n- [Option A]\n- [Option B]\n- [Option C — do nothing]\n\n## Decision Outcome\n\nChosen option: **[Option A]**, because [one-sentence justification].\n\n### Positive Consequences\n\n- …\n\n### Negative Consequences\n\n- …\n\n## Pros and Cons of the Options\n\n### [Option A]\n\n- Good, because [argument]\n- Bad, because [argument]\n\n### [Option B]\n\n- Good, because [argument]\n- Bad, because [argument]\n";
|
|
4838
5043
|
const AGENT_PROMPTS = {
|
|
4839
5044
|
"discovery.md": `# Discovery Agent
|
|
4840
5045
|
|
|
@@ -4963,10 +5168,14 @@ confidently, identify the missing information instead of inventing it.
|
|
|
4963
5168
|
|
|
4964
5169
|
## Format
|
|
4965
5170
|
|
|
4966
|
-
Use MADR format.
|
|
5171
|
+
Use MADR format. Start from the \`adr\` skill's template,
|
|
5172
|
+
\`.agents/skills/adr/assets/template.md\`, and number the record sequentially.
|
|
5173
|
+
\`holocron repo setup\` installs that skill when \`adr\` is listed under
|
|
5174
|
+
\`skills\` in \`holocron.config\` — if the file is missing, add it there and
|
|
5175
|
+
re-run setup.
|
|
4967
5176
|
|
|
4968
5177
|
\`\`\`sh
|
|
4969
|
-
cp
|
|
5178
|
+
cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
|
|
4970
5179
|
\`\`\`
|
|
4971
5180
|
|
|
4972
5181
|
Save to \`docs/wiki/decisions/NNNN-<slug>.md\`.
|
|
@@ -5173,10 +5382,13 @@ decision, the options that were considered, and the rationale for the choice mad
|
|
|
5173
5382
|
|
|
5174
5383
|
## Creating a new decision
|
|
5175
5384
|
|
|
5176
|
-
|
|
5385
|
+
Start from the template that ships with the \`adr\` skill,
|
|
5386
|
+
\`.agents/skills/adr/assets/template.md\` (installed by \`holocron repo setup\`
|
|
5387
|
+
when \`adr\` is listed under \`skills\` in \`holocron.config\`), and number the
|
|
5388
|
+
record sequentially:
|
|
5177
5389
|
|
|
5178
5390
|
\`\`\`sh
|
|
5179
|
-
cp
|
|
5391
|
+
cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
|
|
5180
5392
|
\`\`\`
|
|
5181
5393
|
|
|
5182
5394
|
\`madr\` has no CLI — creation is manual. Frontmatter is validated in CI
|
|
@@ -5358,7 +5570,6 @@ async function writeIfAbsent(filePath, content) {
|
|
|
5358
5570
|
async function installEngineeringStructure({ repoRoot }) {
|
|
5359
5571
|
const results = [];
|
|
5360
5572
|
const writes = [
|
|
5361
|
-
[join(repoRoot, "docs/wiki/decisions/template.md"), DECISIONS_TEMPLATE],
|
|
5362
5573
|
[join(repoRoot, "docs/wiki/decisions/README.md"), DECISIONS_README],
|
|
5363
5574
|
[join(repoRoot, "docs/wiki/standards/README.md"), STANDARDS_README],
|
|
5364
5575
|
[join(repoRoot, "docs/wiki/specifications/README.md"), SPECIFICATIONS_README]
|
|
@@ -5381,7 +5592,7 @@ const BALANCED_REPO_SETTINGS = {
|
|
|
5381
5592
|
has_wiki: false
|
|
5382
5593
|
};
|
|
5383
5594
|
//#endregion
|
|
5384
|
-
//#region src/commands/setup/run-step.ts
|
|
5595
|
+
//#region src/commands/setup/run-step/run-step.ts
|
|
5385
5596
|
async function runStep(capability, step, dryRun, body, opts = {}) {
|
|
5386
5597
|
const result = await execStep(capability, step, dryRun, body, opts);
|
|
5387
5598
|
const { status, message, reason } = result;
|
|
@@ -5452,7 +5663,7 @@ function formatStep(step) {
|
|
|
5452
5663
|
return ` ${style.dim(`· ${label}`)}`;
|
|
5453
5664
|
}
|
|
5454
5665
|
//#endregion
|
|
5455
|
-
//#region src/commands/setup/skills.ts
|
|
5666
|
+
//#region src/commands/setup/skills/skills.ts
|
|
5456
5667
|
async function fetchExternalSkill(entry) {
|
|
5457
5668
|
if (entry.sourceType !== "github")
|
|
5458
5669
|
/* c8 ignore next */
|
|
@@ -5729,14 +5940,14 @@ async function runSetup(input) {
|
|
|
5729
5940
|
if (loader.has("source") && (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
|
|
5730
5941
|
const source = loader.get("source");
|
|
5731
5942
|
steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
|
|
5732
|
-
await source.writeRepoFile(".github/labeler.yml",
|
|
5943
|
+
await source.writeRepoFile(".github/labeler.yml", createLabeler());
|
|
5733
5944
|
}));
|
|
5734
5945
|
print(formatStep(steps[steps.length - 1]));
|
|
5735
5946
|
}
|
|
5736
5947
|
if (loader.has("source") && effectivePreset !== "none") {
|
|
5737
5948
|
const source = loader.get("source");
|
|
5738
5949
|
steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
|
|
5739
|
-
await source.writeRepoFile(".github/dependabot.yml",
|
|
5950
|
+
await source.writeRepoFile(".github/dependabot.yml", createDependabot());
|
|
5740
5951
|
}));
|
|
5741
5952
|
print(formatStep(steps[steps.length - 1]));
|
|
5742
5953
|
steps.push(await runStep("source", "write .github/dco.yml", dryRun, async () => {
|
|
@@ -5746,54 +5957,50 @@ async function runSetup(input) {
|
|
|
5746
5957
|
}
|
|
5747
5958
|
if (loader.has("source")) {
|
|
5748
5959
|
const source = loader.get("source");
|
|
5749
|
-
steps.push(await runStep("source", "write .github/config.yml", dryRun, async () => {
|
|
5750
|
-
await source.writeRepoFile(".github/config.yml", `${workflowHeader$1()}${config_default}`);
|
|
5751
|
-
}));
|
|
5752
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5753
5960
|
steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
|
|
5754
|
-
await source.writeRepoFile(".alexrc.json",
|
|
5961
|
+
await source.writeRepoFile(".alexrc.json", createAlexrc());
|
|
5755
5962
|
}));
|
|
5756
5963
|
print(formatStep(steps[steps.length - 1]));
|
|
5757
5964
|
steps.push(await runStep("source", "write .alexignore", dryRun, async () => {
|
|
5758
|
-
await source.writeRepoFile(".alexignore",
|
|
5965
|
+
await source.writeRepoFile(".alexignore", createAlexignore());
|
|
5759
5966
|
}));
|
|
5760
5967
|
print(formatStep(steps[steps.length - 1]));
|
|
5761
5968
|
steps.push(await runStep("source", "write .yamllint.yml", dryRun, async () => {
|
|
5762
|
-
await source.writeRepoFile(".yamllint.yml",
|
|
5969
|
+
await source.writeRepoFile(".yamllint.yml", createYamllint());
|
|
5763
5970
|
}));
|
|
5764
5971
|
print(formatStep(steps[steps.length - 1]));
|
|
5765
5972
|
steps.push(await runStep("source", "write .yamlignore", dryRun, async () => {
|
|
5766
|
-
await source.writeRepoFile(".yamlignore",
|
|
5973
|
+
await source.writeRepoFile(".yamlignore", createYamlignore());
|
|
5767
5974
|
}));
|
|
5768
5975
|
print(formatStep(steps[steps.length - 1]));
|
|
5769
5976
|
steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
|
|
5770
|
-
await source.writeRepoFile(".editorconfig",
|
|
5977
|
+
await source.writeRepoFile(".editorconfig", createEditorconfig());
|
|
5771
5978
|
}));
|
|
5772
5979
|
print(formatStep(steps[steps.length - 1]));
|
|
5773
5980
|
steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
|
|
5774
|
-
await source.writeRepoFile(".editorconfig-checker.json",
|
|
5981
|
+
await source.writeRepoFile(".editorconfig-checker.json", createEditorconfigChecker());
|
|
5775
5982
|
}));
|
|
5776
5983
|
print(formatStep(steps[steps.length - 1]));
|
|
5777
5984
|
steps.push(await runStep("source", "write devmoji.config.cjs", dryRun, async () => {
|
|
5778
|
-
await source.writeRepoFile("devmoji.config.cjs",
|
|
5985
|
+
await source.writeRepoFile("devmoji.config.cjs", createDevmoji());
|
|
5779
5986
|
}));
|
|
5780
5987
|
print(formatStep(steps[steps.length - 1]));
|
|
5781
5988
|
steps.push(await runStep("source", "write .husky/prepare-commit-msg", dryRun, async () => {
|
|
5782
|
-
await source.writeRepoFile(".husky/prepare-commit-msg",
|
|
5989
|
+
await source.writeRepoFile(".husky/prepare-commit-msg", createPrepareCommitMsg());
|
|
5783
5990
|
}));
|
|
5784
5991
|
print(formatStep(steps[steps.length - 1]));
|
|
5785
5992
|
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5786
|
-
await source.writeRepoFile(".husky/commit-msg",
|
|
5993
|
+
await source.writeRepoFile(".husky/commit-msg", createCommitMsg(config.holocronScript));
|
|
5787
5994
|
}));
|
|
5788
5995
|
print(formatStep(steps[steps.length - 1]));
|
|
5789
5996
|
if (hooksEnabled) {
|
|
5790
5997
|
steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
|
|
5791
|
-
await source.writeRepoFile(".husky/pre-push",
|
|
5998
|
+
await source.writeRepoFile(".husky/pre-push", createPrePush(config.holocronScript));
|
|
5792
5999
|
return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
|
|
5793
6000
|
}));
|
|
5794
6001
|
print(formatStep(steps[steps.length - 1]));
|
|
5795
6002
|
steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
|
|
5796
|
-
await source.writeRepoFile(".husky/pre-commit",
|
|
6003
|
+
await source.writeRepoFile(".husky/pre-commit", createPreCommit());
|
|
5797
6004
|
return "runs GitLeaks (staged-only) + lint-staged before commit";
|
|
5798
6005
|
}));
|
|
5799
6006
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -6309,7 +6516,7 @@ async function ensurePrepareScript(repoRoot) {
|
|
|
6309
6516
|
return true;
|
|
6310
6517
|
}
|
|
6311
6518
|
//#endregion
|
|
6312
|
-
//#region src/commands/skills.ts
|
|
6519
|
+
//#region src/commands/skills/skills.ts
|
|
6313
6520
|
/**
|
|
6314
6521
|
* `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
|
|
6315
6522
|
*
|
|
@@ -6408,7 +6615,7 @@ function runSkillsUpdate(input) {
|
|
|
6408
6615
|
return { status: exitCode === 0 ? "ok" : "fail" };
|
|
6409
6616
|
}
|
|
6410
6617
|
//#endregion
|
|
6411
|
-
//#region src/commands/sync-readme.ts
|
|
6618
|
+
//#region src/commands/sync-readme/sync-readme.ts
|
|
6412
6619
|
const MARKER_INSTALL_START = "<!-- holocron:installation -->";
|
|
6413
6620
|
const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
|
|
6414
6621
|
const REGISTRY_MAP = {
|
|
@@ -6564,7 +6771,7 @@ async function runSyncReadme(input) {
|
|
|
6564
6771
|
};
|
|
6565
6772
|
}
|
|
6566
6773
|
//#endregion
|
|
6567
|
-
//#region src/commands/sync-wiki.ts
|
|
6774
|
+
//#region src/commands/sync-wiki/sync-wiki.ts
|
|
6568
6775
|
function resolveToken(input) {
|
|
6569
6776
|
return input.token ?? input.context.cliToken ?? firstEnv(env, [
|
|
6570
6777
|
"HOLOCRON_READ_TOKEN",
|
|
@@ -6781,7 +6988,7 @@ async function runSyncWiki(input) {
|
|
|
6781
6988
|
}
|
|
6782
6989
|
}
|
|
6783
6990
|
//#endregion
|
|
6784
|
-
//#region src/commands/sync.ts
|
|
6991
|
+
//#region src/commands/sync/sync.ts
|
|
6785
6992
|
const { workflowHeader } = createHeader({
|
|
6786
6993
|
source: "packages/astromech/src/thin-callers.ts",
|
|
6787
6994
|
tool: "holocron repo sync"
|
|
@@ -7369,7 +7576,7 @@ async function writeWorkflowFile(repoRoot, filename, content) {
|
|
|
7369
7576
|
await rename(tmpPath, join(dir, filename));
|
|
7370
7577
|
}
|
|
7371
7578
|
//#endregion
|
|
7372
|
-
//#region src/telemetry/resolve.ts
|
|
7579
|
+
//#region src/observability/resolve-telemetry/resolve-telemetry.ts
|
|
7373
7580
|
/**
|
|
7374
7581
|
* Holocron-specific credential resolution for the telemetry sinks — the env
|
|
7375
7582
|
* chain plus Holocron's own shipped fallbacks. This is **CLI policy**, not part
|
|
@@ -7404,7 +7611,7 @@ function resolvePostHogHost() {
|
|
|
7404
7611
|
return holocronEnv.get("POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST;
|
|
7405
7612
|
}
|
|
7406
7613
|
//#endregion
|
|
7407
|
-
//#region src/telemetry.ts
|
|
7614
|
+
//#region src/observability/telemetry/telemetry.ts
|
|
7408
7615
|
/**
|
|
7409
7616
|
* CLI self-telemetry orchestration — errors (Sentry), usage analytics (PostHog).
|
|
7410
7617
|
*
|
|
@@ -7415,7 +7622,7 @@ function resolvePostHogHost() {
|
|
|
7415
7622
|
* installed when telemetry is off. Same seam the package's `Logger` uses for
|
|
7416
7623
|
* Pino.
|
|
7417
7624
|
*
|
|
7418
|
-
* Activation is env-var + shipped-fallback, resolved here (`telemetry
|
|
7625
|
+
* Activation is env-var + shipped-fallback, resolved here (`resolve-telemetry.ts`)
|
|
7419
7626
|
* and passed to the sink — the adapters read no environment. The single kill
|
|
7420
7627
|
* switch is `HOLOCRON_TELEMETRY=false` (legacy alias `NO_HOLOCRON_TELEMETRY`);
|
|
7421
7628
|
* see ADR-0007 / ADR-0008.
|
|
@@ -7548,7 +7755,7 @@ async function flush() {
|
|
|
7548
7755
|
analytics = new NoopAnalyticsSink();
|
|
7549
7756
|
}
|
|
7550
7757
|
//#endregion
|
|
7551
|
-
//#region src/commands/sync-github.ts
|
|
7758
|
+
//#region src/commands/sync-github/sync-github.ts
|
|
7552
7759
|
const DEFAULT_REPO = "theholocron/.github";
|
|
7553
7760
|
/**
|
|
7554
7761
|
* The file set `sync-github` pushes to `theholocron/.github` — the reusable
|
|
@@ -7788,7 +7995,7 @@ async function runSyncGithub(input) {
|
|
|
7788
7995
|
});
|
|
7789
7996
|
}
|
|
7790
7997
|
//#endregion
|
|
7791
|
-
//#region src/commands/upgrade-deps.ts
|
|
7998
|
+
//#region src/commands/upgrade-deps/upgrade-deps.ts
|
|
7792
7999
|
/**
|
|
7793
8000
|
* `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7794
8001
|
* its `holocron.config.ts` up to the current major.
|
|
@@ -8016,7 +8223,7 @@ async function runUpgradeDeps(input = {}) {
|
|
|
8016
8223
|
};
|
|
8017
8224
|
}
|
|
8018
8225
|
//#endregion
|
|
8019
|
-
//#region src/commands/upgrade-node.ts
|
|
8226
|
+
//#region src/commands/upgrade-node/upgrade-node.ts
|
|
8020
8227
|
const SKIP_DIRS = [
|
|
8021
8228
|
"node_modules",
|
|
8022
8229
|
".git",
|
|
@@ -8194,268 +8401,45 @@ async function runUpgradeNode(input) {
|
|
|
8194
8401
|
to,
|
|
8195
8402
|
updated: updated.length,
|
|
8196
8403
|
status
|
|
8197
|
-
}, "upgrade node: done");
|
|
8198
|
-
return {
|
|
8199
|
-
status,
|
|
8200
|
-
updated
|
|
8201
|
-
};
|
|
8202
|
-
}
|
|
8203
|
-
//#endregion
|
|
8204
|
-
//#region src/commit-lint/lint-message.ts
|
|
8205
|
-
/**
|
|
8206
|
-
* Lints one commit message string against an already-loaded commitlint
|
|
8207
|
-
* config. The genuinely environment-independent half of commit-message
|
|
8208
|
-
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
8209
|
-
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
8210
|
-
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
8211
|
-
* just lets cosmiconfig discover the invoking repo's own
|
|
8212
|
-
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
8213
|
-
* but linting one message against a loaded config is identical either way.
|
|
8214
|
-
*
|
|
8215
|
-
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
8216
|
-
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
8217
|
-
*/
|
|
8218
|
-
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
8219
|
-
function selectParserOpts(parserPreset) {
|
|
8220
|
-
return parserPreset?.parserOpts;
|
|
8221
|
-
}
|
|
8222
|
-
async function lintCommitMessage(message, loaded) {
|
|
8223
|
-
const opts = {
|
|
8224
|
-
/* istanbul ignore next -- see comment above */
|
|
8225
|
-
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
8226
|
-
plugins: loaded.plugins,
|
|
8227
|
-
/* istanbul ignore next -- see comment above */
|
|
8228
|
-
ignores: loaded.ignores ?? [],
|
|
8229
|
-
defaultIgnores: loaded.defaultIgnores !== false
|
|
8230
|
-
};
|
|
8231
|
-
const outcome = await lint(message, loaded.rules, opts);
|
|
8232
|
-
if (outcome.valid) return [];
|
|
8233
|
-
return outcome.errors.map((error) => ({
|
|
8234
|
-
rule: error.name,
|
|
8235
|
-
message: error.message
|
|
8236
|
-
}));
|
|
8237
|
-
}
|
|
8238
|
-
//#endregion
|
|
8239
|
-
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
8240
|
-
/**
|
|
8241
|
-
* `holocron run lint commit-msg <file>` — a real programmatic replacement for
|
|
8242
|
-
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
8243
|
-
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
8244
|
-
* path to the commit message being written (the arg git's `commit-msg`
|
|
8245
|
-
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
8246
|
-
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
8247
|
-
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
8248
|
-
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
8249
|
-
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
8250
|
-
* runs inside a real repo checkout with a real `node_modules`, not
|
|
8251
|
-
* standalone on a server.
|
|
8252
|
-
*/
|
|
8253
|
-
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
8254
|
-
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
8255
|
-
return {
|
|
8256
|
-
valid: violations.length === 0,
|
|
8257
|
-
violations
|
|
8258
|
-
};
|
|
8259
|
-
}
|
|
8260
|
-
//#endregion
|
|
8261
|
-
//#region src/config/load-config.ts
|
|
8262
|
-
/**
|
|
8263
|
-
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
8264
|
-
*
|
|
8265
|
-
* File discovery + module loading (typed configs via `tsx`, no build
|
|
8266
|
-
* step) is delegated to `@theholocron/datapad`. This module keeps the
|
|
8267
|
-
* holocron-specific parts: filling `name` / `repo.name` defaults and
|
|
8268
|
-
* running the config through `resolveConfig`.
|
|
8269
|
-
*
|
|
8270
|
-
* Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
|
|
8271
|
-
* see ADR-0010 / issue #75 / #81.
|
|
8272
|
-
*/
|
|
8273
|
-
/**
|
|
8274
|
-
* Read + parse + resolve `holocron.config.*` starting from the given
|
|
8275
|
-
* directory, walking up through ancestors to the nearest `.git` boundary
|
|
8276
|
-
* when `cwd` itself has no match — every command is usable from any
|
|
8277
|
-
* subdirectory of a repo, not only its exact root (see
|
|
8278
|
-
* `@theholocron/datapad`'s own doc comment for the stop conditions).
|
|
8279
|
-
* Throws {@link ConfigFileError} when nothing is found or a file cannot
|
|
8280
|
-
* be loaded, or `ConfigError` when the config fails schema checks.
|
|
8281
|
-
*/
|
|
8282
|
-
async function loadConfig(cwd) {
|
|
8283
|
-
const found = await loadConfigFile({
|
|
8284
|
-
cwd,
|
|
8285
|
-
name: "holocron",
|
|
8286
|
-
walkUp: true
|
|
8287
|
-
});
|
|
8288
|
-
if (!found) throw new ConfigFileError(`no holocron.config.{ts,js,mjs,cjs,json} found in ${cwd}. Create one — see the README for the schema.`);
|
|
8404
|
+
}, "upgrade node: done");
|
|
8289
8405
|
return {
|
|
8290
|
-
|
|
8291
|
-
|
|
8406
|
+
status,
|
|
8407
|
+
updated
|
|
8292
8408
|
};
|
|
8293
8409
|
}
|
|
8294
|
-
async function deriveDefaults(found) {
|
|
8295
|
-
const configDir = dirname(found.filepath);
|
|
8296
|
-
const result = { ...found.config };
|
|
8297
|
-
if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
|
|
8298
|
-
if (result.repo && !result.repo.name) {
|
|
8299
|
-
const remote = await readGitRemote(configDir);
|
|
8300
|
-
if (remote?.host === "github.com") result.repo = {
|
|
8301
|
-
...result.repo,
|
|
8302
|
-
name: remote.slug
|
|
8303
|
-
};
|
|
8304
|
-
}
|
|
8305
|
-
const manifest = await loadTasksConfig(configDir, { holocron: found });
|
|
8306
|
-
if (manifest.tasks) result.tasks = manifest.tasks;
|
|
8307
|
-
if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
|
|
8308
|
-
if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
|
|
8309
|
-
if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
|
|
8310
|
-
if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
|
|
8311
|
-
return result;
|
|
8312
|
-
}
|
|
8313
|
-
async function readPackageJsonName(dir) {
|
|
8314
|
-
const name = (await readPackageJson(dir))?.name;
|
|
8315
|
-
return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
|
|
8316
|
-
}
|
|
8317
8410
|
//#endregion
|
|
8318
|
-
//#region src/interactive-menu.ts
|
|
8411
|
+
//#region src/interactive-menu/child-argv/child-argv.ts
|
|
8319
8412
|
/**
|
|
8320
|
-
*
|
|
8321
|
-
*
|
|
8322
|
-
*
|
|
8323
|
-
*
|
|
8324
|
-
* 2. a parent command with no sub → `select()` over that group's commands
|
|
8325
|
-
* 3. a leaf command missing a → `input()` / `select()` per positional
|
|
8326
|
-
* required positional
|
|
8327
|
-
*
|
|
8328
|
-
* Layers 1–2 are driven from `cli.ts`'s `$0` handlers via {@link launchMenu}:
|
|
8329
|
-
* pick a command, prompt for its required positionals, then **spawn a fresh
|
|
8330
|
-
* `holocron <command> …` child process** (`stdio: "inherit"`) rather than
|
|
8331
|
-
* re-entering yargs' own parse pipeline — see the spec's "spawn vs re-parse"
|
|
8332
|
-
* section (theholocron/holocron#438) for why. Layer 3 is driven directly from
|
|
8333
|
-
* each leaf command's own handler in `cli.ts`, calling
|
|
8334
|
-
* {@link promptForPositionals} for just that one command — no spawn needed,
|
|
8335
|
-
* the handler already has everything else it needs.
|
|
8336
|
-
*
|
|
8337
|
-
* For a command whose handler honours `--dry-run` ({@link
|
|
8338
|
-
* CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
|
|
8339
|
-
* dry run doesn't mutate anything, so there's nothing to ask permission
|
|
8340
|
-
* for — then, once that preview comes back clean and found something to
|
|
8341
|
-
* do, asks the one real decision: "proceed with the real run?". A preview
|
|
8342
|
-
* that finds nothing to do skips that question too. `--skip-dry-run` opts
|
|
8343
|
-
* a top-level invocation out of the preview entirely, straight to the
|
|
8344
|
-
* real run, for when you already know what you want.
|
|
8413
|
+
* `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
|
|
8414
|
+
* menu-launched child should inherit from the parent invocation. Everything
|
|
8415
|
+
* else (command-specific options) was never captured by the top-level `$0`
|
|
8416
|
+
* handler in the first place, so there's nothing else to forward.
|
|
8345
8417
|
*
|
|
8346
|
-
*
|
|
8347
|
-
*
|
|
8348
|
-
*
|
|
8349
|
-
*
|
|
8350
|
-
* catch prints its message cleanly, restoring the old demandCommand-style
|
|
8351
|
-
* hard failure for non-interactive callers.
|
|
8352
|
-
*/
|
|
8353
|
-
/** Raised when a prompt is needed but stdin isn't a TTY — see the module doc. */
|
|
8354
|
-
var NonInteractiveError = class extends Error {
|
|
8355
|
-
name = "NonInteractiveError";
|
|
8356
|
-
};
|
|
8357
|
-
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
8358
|
-
/**
|
|
8359
|
-
* This repo's own resolved task manifest, filtered to what's actually
|
|
8360
|
-
* runnable locally — `Astromech.runnableTasks()`, reused rather than
|
|
8361
|
-
* reimplemented (deliberately *not* `packageScripts()`, whose
|
|
8362
|
-
* `syncScripts: false` short-circuit returns `{}` for repos — this one
|
|
8363
|
-
* included — that opt out of generated `package.json` scripts but still
|
|
8364
|
-
* have real runnable tasks). Empty (never throws) when there's no config to
|
|
8365
|
-
* resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
|
|
8366
|
-
* as "run ›" simply having no children.
|
|
8367
|
-
*/
|
|
8368
|
-
async function listRunnableTasks(argv) {
|
|
8369
|
-
const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
|
|
8370
|
-
const config = await loadTasksConfig(cwd).catch(() => void 0);
|
|
8371
|
-
return createAstromech({
|
|
8372
|
-
cwd,
|
|
8373
|
-
config
|
|
8374
|
-
}).runnableTasks();
|
|
8375
|
-
}
|
|
8376
|
-
/**
|
|
8377
|
-
* Namespace display order for the `run` menu — deliberately its own thing,
|
|
8378
|
-
* not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
|
|
8379
|
-
* signal first" in an automated gate, a different goal from "how a person
|
|
8380
|
-
* would naturally work through a repo" (build it, clean up its style, check
|
|
8381
|
-
* it's secure, test it, validate it, document it). A namespace absent here
|
|
8382
|
-
* (a repo-specific one this list doesn't know about) sorts after every
|
|
8383
|
-
* named one, in whatever order it was first seen.
|
|
8384
|
-
*/
|
|
8385
|
-
const RUN_MENU_NAMESPACE_ORDER = [
|
|
8386
|
-
"delivery",
|
|
8387
|
-
"sourceQuality",
|
|
8388
|
-
"security",
|
|
8389
|
-
"verification",
|
|
8390
|
-
"platform",
|
|
8391
|
-
"knowledge"
|
|
8392
|
-
];
|
|
8393
|
-
/**
|
|
8394
|
-
* Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
|
|
8395
|
-
* groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
|
|
8396
|
-
* within each group is `taskNames`' own order (already declaration order,
|
|
8397
|
-
* from {@link listRunnableTasks}). A name with no `.` at all (defensive;
|
|
8398
|
-
* every real registry task has a namespace) is its own bucket, keyed by the
|
|
8399
|
-
* full name, sorted after every namespaced group.
|
|
8418
|
+
* `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
|
|
8419
|
+
* dry-run offer needs to force `--dry-run` on for the preview spawn and
|
|
8420
|
+
* force it back off for the real-run spawn that follows, regardless of
|
|
8421
|
+
* what was on the original invocation.
|
|
8400
8422
|
*/
|
|
8401
|
-
function
|
|
8402
|
-
const
|
|
8403
|
-
|
|
8404
|
-
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
}
|
|
8410
|
-
const ordered = /* @__PURE__ */ new Map();
|
|
8411
|
-
const byMenuOrder = (a, b) => {
|
|
8412
|
-
const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
|
|
8413
|
-
const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
|
|
8414
|
-
if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
|
8415
|
-
return 0;
|
|
8416
|
-
};
|
|
8417
|
-
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8418
|
-
return ordered;
|
|
8423
|
+
function forwardedFlags(argv, dryRunOverride) {
|
|
8424
|
+
const out = [];
|
|
8425
|
+
const tokens = argv.token;
|
|
8426
|
+
for (const t of tokens ?? []) out.push("--token", t);
|
|
8427
|
+
if (typeof argv.org === "string") out.push("--org", argv.org);
|
|
8428
|
+
if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
|
|
8429
|
+
if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
|
|
8430
|
+
return out;
|
|
8419
8431
|
}
|
|
8420
|
-
/**
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
* Each namespace becomes a group with a leading "<namespace> (run it)" item
|
|
8428
|
-
* (`holocron run <namespace>` — every task in it, via
|
|
8429
|
-
* `Astromech.runGroup()`) alongside each of its individual tasks, the exact
|
|
8430
|
-
* same "a group that's also runnable" shape `menuItems` already renders for
|
|
8431
|
-
* `repo sync` / `deploy` — reached here for free by using a space, not the
|
|
8432
|
-
* task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
|
|
8433
|
-
* the real dotted invocation `buildChildArgv` actually spawns).
|
|
8434
|
-
*/
|
|
8435
|
-
function buildRunMenuEntries(taskNames) {
|
|
8436
|
-
const entries = [];
|
|
8437
|
-
for (const [namespace, tasks] of groupByNamespace(taskNames)) {
|
|
8438
|
-
const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
|
|
8439
|
-
if (isRealNamespace) entries.push({
|
|
8440
|
-
name: `run ${namespace}`,
|
|
8441
|
-
description: `Run every ${namespace}.* task in sequence`,
|
|
8442
|
-
positionals: [],
|
|
8443
|
-
supportsDryRun: true,
|
|
8444
|
-
spawnArgs: ["run", namespace]
|
|
8445
|
-
});
|
|
8446
|
-
for (const task of tasks) {
|
|
8447
|
-
const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
|
|
8448
|
-
entries.push({
|
|
8449
|
-
name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
|
|
8450
|
-
description: `Run ${task}`,
|
|
8451
|
-
positionals: [],
|
|
8452
|
-
supportsDryRun: true,
|
|
8453
|
-
spawnArgs: ["run", task]
|
|
8454
|
-
});
|
|
8455
|
-
}
|
|
8456
|
-
}
|
|
8457
|
-
return entries;
|
|
8432
|
+
/** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
|
|
8433
|
+
function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
|
|
8434
|
+
return [
|
|
8435
|
+
...entry.spawnArgs ?? entry.name.split(" "),
|
|
8436
|
+
...positionals,
|
|
8437
|
+
...forwardedFlags(parentArgv, dryRunOverride)
|
|
8438
|
+
];
|
|
8458
8439
|
}
|
|
8440
|
+
//#endregion
|
|
8441
|
+
//#region src/interactive-menu/command-registry/command-registry.ts
|
|
8442
|
+
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
8459
8443
|
/**
|
|
8460
8444
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
8461
8445
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
@@ -8693,6 +8677,9 @@ function getEntry(name) {
|
|
|
8693
8677
|
if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
|
|
8694
8678
|
return entry;
|
|
8695
8679
|
}
|
|
8680
|
+
//#endregion
|
|
8681
|
+
//#region src/interactive-menu/menu-items/menu-items.ts
|
|
8682
|
+
/** Prefix of a menu item's `value` that opens a submenu rather than running a command. */
|
|
8696
8683
|
const GROUP = "group:";
|
|
8697
8684
|
/** The "← back" item's value; only offered below the first level. */
|
|
8698
8685
|
const BACK = "back";
|
|
@@ -8738,105 +8725,16 @@ function menuItems(entries, path = []) {
|
|
|
8738
8725
|
const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
|
|
8739
8726
|
items.push({
|
|
8740
8727
|
name: `${word} ›`,
|
|
8741
|
-
value: `${GROUP}${full}`,
|
|
8742
|
-
description: children.join(", "),
|
|
8743
|
-
haystack: haystackOf(family)
|
|
8744
|
-
});
|
|
8745
|
-
}
|
|
8746
|
-
}
|
|
8747
|
-
return items;
|
|
8748
|
-
}
|
|
8749
|
-
/**
|
|
8750
|
-
* Searchable autocomplete over one menu level's `items`. `select()` would work too
|
|
8751
|
-
* at these list sizes, but `search()` degrades gracefully as the surface grows and
|
|
8752
|
-
* costs nothing when it doesn't. Resolves to the picked item's `value`.
|
|
8753
|
-
*/
|
|
8754
|
-
async function pickMenuItem(items, message = "What would you like to do?") {
|
|
8755
|
-
return search({
|
|
8756
|
-
message,
|
|
8757
|
-
source: (term) => searchChoices(items, term)
|
|
8758
|
-
});
|
|
8759
|
-
}
|
|
8760
|
-
/**
|
|
8761
|
-
* `@inquirer/search`'s `source` callback, factored out as a plain function —
|
|
8762
|
-
* unit-testable directly instead of only through a mocked `search()` call.
|
|
8763
|
-
* Empty/undefined `term` (nothing typed yet) returns every item.
|
|
8764
|
-
*/
|
|
8765
|
-
function searchChoices(items, term) {
|
|
8766
|
-
const needle = term?.toLowerCase();
|
|
8767
|
-
const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
|
|
8768
|
-
const rank = (i) => {
|
|
8769
|
-
if (!needle) return 0;
|
|
8770
|
-
const name = i.name.toLowerCase();
|
|
8771
|
-
return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
|
|
8772
|
-
};
|
|
8773
|
-
return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
|
|
8774
|
-
name: i.name,
|
|
8775
|
-
value: i.value,
|
|
8776
|
-
description: i.description
|
|
8777
|
-
}));
|
|
8778
|
-
}
|
|
8779
|
-
/**
|
|
8780
|
-
* For each of `entry.positionals` not already present in `argv`, prompts for
|
|
8781
|
-
* it (`input()` or `select()`, per the positional's `type`) and returns the
|
|
8782
|
-
* resolved values in positional order — ready to append to a spawned child's
|
|
8783
|
-
* argv, or to use directly in the current handler (Layer 3).
|
|
8784
|
-
*
|
|
8785
|
-
* Throws {@link NonInteractiveError} the first time it would need to prompt
|
|
8786
|
-
* on a non-TTY stdin, and again if a `select` positional's choices resolve
|
|
8787
|
-
* empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
|
|
8788
|
-
*/
|
|
8789
|
-
async function promptForPositionals(entry, argv) {
|
|
8790
|
-
const out = [];
|
|
8791
|
-
for (const positional of entry.positionals) {
|
|
8792
|
-
const existing = argv[positional.key];
|
|
8793
|
-
if (existing !== void 0 && existing !== "") {
|
|
8794
|
-
out.push(String(existing));
|
|
8795
|
-
continue;
|
|
8796
|
-
}
|
|
8797
|
-
if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
|
|
8798
|
-
if (positional.type === "select") {
|
|
8799
|
-
const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
|
|
8800
|
-
if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
|
|
8801
|
-
out.push(await select({
|
|
8802
|
-
message: positional.message,
|
|
8803
|
-
choices: choices.map((c) => ({ value: c }))
|
|
8804
|
-
}));
|
|
8805
|
-
} else out.push(await input({
|
|
8806
|
-
message: positional.message,
|
|
8807
|
-
...positional.validate ? { validate: positional.validate } : {}
|
|
8808
|
-
}));
|
|
8809
|
-
}
|
|
8810
|
-
return out;
|
|
8811
|
-
}
|
|
8812
|
-
/**
|
|
8813
|
-
* `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
|
|
8814
|
-
* menu-launched child should inherit from the parent invocation. Everything
|
|
8815
|
-
* else (command-specific options) was never captured by the top-level `$0`
|
|
8816
|
-
* handler in the first place, so there's nothing else to forward.
|
|
8817
|
-
*
|
|
8818
|
-
* `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
|
|
8819
|
-
* dry-run offer needs to force `--dry-run` on for the preview spawn and
|
|
8820
|
-
* force it back off for the real-run spawn that follows, regardless of
|
|
8821
|
-
* what was on the original invocation.
|
|
8822
|
-
*/
|
|
8823
|
-
function forwardedFlags(argv, dryRunOverride) {
|
|
8824
|
-
const out = [];
|
|
8825
|
-
const tokens = argv.token;
|
|
8826
|
-
for (const t of tokens ?? []) out.push("--token", t);
|
|
8827
|
-
if (typeof argv.org === "string") out.push("--org", argv.org);
|
|
8828
|
-
if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
|
|
8829
|
-
if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
|
|
8830
|
-
return out;
|
|
8831
|
-
}
|
|
8832
|
-
/** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
|
|
8833
|
-
function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
|
|
8834
|
-
return [
|
|
8835
|
-
...entry.spawnArgs ?? entry.name.split(" "),
|
|
8836
|
-
...positionals,
|
|
8837
|
-
...forwardedFlags(parentArgv, dryRunOverride)
|
|
8838
|
-
];
|
|
8728
|
+
value: `${GROUP}${full}`,
|
|
8729
|
+
description: children.join(", "),
|
|
8730
|
+
haystack: haystackOf(family)
|
|
8731
|
+
});
|
|
8732
|
+
}
|
|
8733
|
+
}
|
|
8734
|
+
return items;
|
|
8839
8735
|
}
|
|
8736
|
+
//#endregion
|
|
8737
|
+
//#region src/interactive-menu/launch-menu/launch-menu.ts
|
|
8840
8738
|
/**
|
|
8841
8739
|
* Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
|
|
8842
8740
|
* its handler knows this specific `--dry-run` came from `launchMenu`'s own
|
|
@@ -8859,353 +8757,200 @@ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
|
|
|
8859
8757
|
* once the child exits. `extraEnv` layers on top — used only for {@link
|
|
8860
8758
|
* DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
|
|
8861
8759
|
*/
|
|
8862
|
-
function spawnChild(args, extraEnv) {
|
|
8863
|
-
return new Promise((resolve) => {
|
|
8864
|
-
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8865
|
-
stdio: "inherit",
|
|
8866
|
-
env: {
|
|
8867
|
-
...process.env,
|
|
8868
|
-
NO_UPDATE_NOTIFIER: "1",
|
|
8869
|
-
...extraEnv
|
|
8870
|
-
}
|
|
8871
|
-
});
|
|
8872
|
-
child.on("exit", (code) => resolve(code ?? 0));
|
|
8873
|
-
child.on("error", () => resolve(1));
|
|
8874
|
-
});
|
|
8875
|
-
}
|
|
8876
|
-
/**
|
|
8877
|
-
* Whether `launchMenu` should auto-preview a picked command before running
|
|
8878
|
-
* it for real — only when the command's handler actually honours
|
|
8879
|
-
* `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
|
|
8880
|
-
* invocation didn't pass `--skip-dry-run` to opt out of the preview
|
|
8881
|
-
* entirely (an escape hatch for "I already know what I want, skip
|
|
8882
|
-
* straight to the real run").
|
|
8883
|
-
*/
|
|
8884
|
-
function shouldPreviewDryRun(entry, parentArgv) {
|
|
8885
|
-
return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
|
|
8886
|
-
}
|
|
8887
|
-
/**
|
|
8888
|
-
* Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
|
|
8889
|
-
* `[]` for the top), prompt for the picked command's required positionals, spawn
|
|
8890
|
-
* it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
|
|
8891
|
-
* the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
|
|
8892
|
-
* parent's own telemetry flush / update-notifier tail still runs. The parent's
|
|
8893
|
-
* own `command_completed` event fires with command name "unknown" (the middleware
|
|
8894
|
-
* ran before any command was picked) — left as-is rather than suppressed; it's a
|
|
8895
|
-
* real, useful signal ("the menu got used").
|
|
8896
|
-
*
|
|
8897
|
-
* Picking a group opens its submenu, which has a "← back" item; a command ends
|
|
8898
|
-
* the walk. See {@link menuItems} for how a level is built.
|
|
8899
|
-
*
|
|
8900
|
-
* For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
|
|
8901
|
-
* and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
|
|
8902
|
-
* this always previews first — a dry run doesn't mutate anything, so
|
|
8903
|
-
* there's nothing to ask permission for. A preview that exits non-zero
|
|
8904
|
-
* stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
|
|
8905
|
-
* would change) stops here too, skipping the follow-up question since the
|
|
8906
|
-
* real run would have nothing to do either. Otherwise it asks "proceed
|
|
8907
|
-
* with the real run?" before the real spawn — the one actual decision
|
|
8908
|
-
* left to make, since this one does mutate something.
|
|
8909
|
-
*/
|
|
8910
|
-
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
|
|
8911
|
-
if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
|
|
8912
|
-
const stack = [startPath];
|
|
8913
|
-
let picked;
|
|
8914
|
-
while (!picked) {
|
|
8915
|
-
const path = stack[stack.length - 1];
|
|
8916
|
-
const items = menuItems(entries, path);
|
|
8917
|
-
if (stack.length > 1) items.push({
|
|
8918
|
-
name: "← back",
|
|
8919
|
-
value: BACK,
|
|
8920
|
-
description: "Return to the previous menu",
|
|
8921
|
-
haystack: "back"
|
|
8922
|
-
});
|
|
8923
|
-
const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
|
|
8924
|
-
if (value === "back") stack.pop();
|
|
8925
|
-
else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
|
|
8926
|
-
else picked = entries.find((e) => e.name === value);
|
|
8927
|
-
}
|
|
8928
|
-
const positionals = await promptForPositionals(picked, {});
|
|
8929
|
-
if (shouldPreviewDryRun(picked, parentArgv)) {
|
|
8930
|
-
const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
|
|
8931
|
-
if (dryExit === 2) {
|
|
8932
|
-
process.exitCode = 0;
|
|
8933
|
-
return;
|
|
8934
|
-
}
|
|
8935
|
-
if (dryExit !== 0) {
|
|
8936
|
-
process.exitCode = dryExit;
|
|
8937
|
-
return;
|
|
8938
|
-
}
|
|
8939
|
-
if (!await confirm({
|
|
8940
|
-
message: "Dry run complete — proceed with the real run?",
|
|
8941
|
-
default: false
|
|
8942
|
-
})) {
|
|
8943
|
-
process.exitCode = 0;
|
|
8944
|
-
return;
|
|
8945
|
-
}
|
|
8946
|
-
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
|
|
8947
|
-
return;
|
|
8948
|
-
}
|
|
8949
|
-
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
8950
|
-
}
|
|
8951
|
-
//#endregion
|
|
8952
|
-
//#region src/ui/json-view.ts
|
|
8953
|
-
function isContainer(value) {
|
|
8954
|
-
return typeof value === "object" && value !== null;
|
|
8955
|
-
}
|
|
8956
|
-
function childrenOf(value) {
|
|
8957
|
-
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
8958
|
-
}
|
|
8959
|
-
function scalar(value) {
|
|
8960
|
-
if (typeof value === "string") return chalk.green(JSON.stringify(value));
|
|
8961
|
-
if (typeof value === "number") return chalk.yellow(String(value));
|
|
8962
|
-
if (typeof value === "boolean") return chalk.magenta(String(value));
|
|
8963
|
-
return chalk.dim(String(value === void 0 ? null : value));
|
|
8964
|
-
}
|
|
8965
|
-
function summary(value) {
|
|
8966
|
-
const n = childrenOf(value).length;
|
|
8967
|
-
const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
|
|
8968
|
-
return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
|
|
8969
|
-
}
|
|
8970
|
-
/**
|
|
8971
|
-
* Paths of every container deeper than `depth` levels are left out, so those
|
|
8972
|
-
* render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
|
|
8973
|
-
*/
|
|
8974
|
-
function expandToDepth(value, depth) {
|
|
8975
|
-
const out = /* @__PURE__ */ new Set();
|
|
8976
|
-
const walk = (v, path, level) => {
|
|
8977
|
-
if (!isContainer(v) || level >= depth) return;
|
|
8978
|
-
out.add(path);
|
|
8979
|
-
for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
|
|
8980
|
-
};
|
|
8981
|
-
walk(value, "", 0);
|
|
8982
|
-
return out;
|
|
8983
|
-
}
|
|
8984
|
-
/** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
|
|
8985
|
-
function renderTree(value, { expanded }) {
|
|
8986
|
-
const lines = [];
|
|
8987
|
-
const walk = (v, path, level, label, trailing) => {
|
|
8988
|
-
const indent = " ".repeat(level);
|
|
8989
|
-
const prefix = `${indent}${label}`;
|
|
8990
|
-
if (!isContainer(v)) {
|
|
8991
|
-
lines.push({
|
|
8992
|
-
path,
|
|
8993
|
-
text: `${prefix}${scalar(v)}${trailing}`,
|
|
8994
|
-
foldable: false,
|
|
8995
|
-
expanded: false,
|
|
8996
|
-
level
|
|
8997
|
-
});
|
|
8998
|
-
return;
|
|
8999
|
-
}
|
|
9000
|
-
const kids = childrenOf(v);
|
|
9001
|
-
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
9002
|
-
if (kids.length === 0) {
|
|
9003
|
-
lines.push({
|
|
9004
|
-
path,
|
|
9005
|
-
text: `${prefix}${open}${close}${trailing}`,
|
|
9006
|
-
foldable: false,
|
|
9007
|
-
expanded: false,
|
|
9008
|
-
level
|
|
9009
|
-
});
|
|
9010
|
-
return;
|
|
9011
|
-
}
|
|
9012
|
-
if (!expanded.has(path)) {
|
|
9013
|
-
lines.push({
|
|
9014
|
-
path,
|
|
9015
|
-
text: `${prefix}${summary(v)}${trailing}`,
|
|
9016
|
-
foldable: true,
|
|
9017
|
-
expanded: false,
|
|
9018
|
-
level
|
|
9019
|
-
});
|
|
9020
|
-
return;
|
|
9021
|
-
}
|
|
9022
|
-
lines.push({
|
|
9023
|
-
path,
|
|
9024
|
-
text: `${prefix}${open}`,
|
|
9025
|
-
foldable: true,
|
|
9026
|
-
expanded: true,
|
|
9027
|
-
level
|
|
9028
|
-
});
|
|
9029
|
-
kids.forEach(([k, child], i) => {
|
|
9030
|
-
const comma = i < kids.length - 1 ? "," : "";
|
|
9031
|
-
const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
|
|
9032
|
-
walk(child, `${path}/${k}`, level + 1, childLabel, comma);
|
|
9033
|
-
});
|
|
9034
|
-
lines.push({
|
|
9035
|
-
path: `${path}#close`,
|
|
9036
|
-
text: `${indent}${close}${trailing}`,
|
|
9037
|
-
foldable: false,
|
|
9038
|
-
expanded: false,
|
|
9039
|
-
level
|
|
9040
|
-
});
|
|
9041
|
-
};
|
|
9042
|
-
walk(value, "", 0, "", "");
|
|
9043
|
-
return lines;
|
|
9044
|
-
}
|
|
9045
|
-
/** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
|
|
9046
|
-
function reduceKey(state, key, value, rows) {
|
|
9047
|
-
const lines = renderTree(value, { expanded: state.expanded });
|
|
9048
|
-
const cur = lines[state.cursor];
|
|
9049
|
-
let { cursor } = state;
|
|
9050
|
-
const expanded = new Set(state.expanded);
|
|
9051
|
-
switch (key) {
|
|
9052
|
-
case "up":
|
|
9053
|
-
cursor = Math.max(0, cursor - 1);
|
|
9054
|
-
break;
|
|
9055
|
-
case "down":
|
|
9056
|
-
cursor = Math.min(lines.length - 1, cursor + 1);
|
|
9057
|
-
break;
|
|
9058
|
-
case "right":
|
|
9059
|
-
if (cur?.foldable) expanded.add(cur.path);
|
|
9060
|
-
break;
|
|
9061
|
-
case "left":
|
|
9062
|
-
if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
|
|
9063
|
-
else if (cur) {
|
|
9064
|
-
const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
|
|
9065
|
-
if (parent >= 0) cursor = parent;
|
|
9066
|
-
}
|
|
9067
|
-
break;
|
|
9068
|
-
case "expandAll": return {
|
|
9069
|
-
cursor,
|
|
9070
|
-
top: state.top,
|
|
9071
|
-
expanded: expandToDepth(value, Infinity)
|
|
9072
|
-
};
|
|
9073
|
-
case "collapseAll": return {
|
|
9074
|
-
cursor: 0,
|
|
9075
|
-
top: 0,
|
|
9076
|
-
expanded: /* @__PURE__ */ new Set()
|
|
9077
|
-
};
|
|
9078
|
-
}
|
|
9079
|
-
const count = renderTree(value, { expanded }).length;
|
|
9080
|
-
cursor = Math.min(cursor, count - 1);
|
|
9081
|
-
let top = state.top;
|
|
9082
|
-
if (cursor < top) top = cursor;
|
|
9083
|
-
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
9084
|
-
return {
|
|
9085
|
-
cursor,
|
|
9086
|
-
top: Math.max(0, top),
|
|
9087
|
-
expanded
|
|
9088
|
-
};
|
|
9089
|
-
}
|
|
9090
|
-
const KEYMAP = {
|
|
9091
|
-
"\x1B[A": "up",
|
|
9092
|
-
k: "up",
|
|
9093
|
-
"\x1B[B": "down",
|
|
9094
|
-
j: "down",
|
|
9095
|
-
"\x1B[C": "right",
|
|
9096
|
-
l: "right",
|
|
9097
|
-
"\r": "right",
|
|
9098
|
-
" ": "right",
|
|
9099
|
-
"\x1B[D": "left",
|
|
9100
|
-
h: "left",
|
|
9101
|
-
E: "expandAll",
|
|
9102
|
-
C: "collapseAll",
|
|
9103
|
-
q: "quit",
|
|
9104
|
-
"\x1B": "quit",
|
|
9105
|
-
"": "quit"
|
|
9106
|
-
};
|
|
9107
|
-
function draw(out, value, state, rows) {
|
|
9108
|
-
const lines = renderTree(value, { expanded: state.expanded });
|
|
9109
|
-
const view = lines.slice(state.top, state.top + rows).map((l, i) => {
|
|
9110
|
-
const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
|
|
9111
|
-
return state.top + i === state.cursor ? chalk.inverse(row) : row;
|
|
8760
|
+
function spawnChild(args, extraEnv) {
|
|
8761
|
+
return new Promise((resolve) => {
|
|
8762
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8763
|
+
stdio: "inherit",
|
|
8764
|
+
env: {
|
|
8765
|
+
...process.env,
|
|
8766
|
+
NO_UPDATE_NOTIFIER: "1",
|
|
8767
|
+
...extraEnv
|
|
8768
|
+
}
|
|
8769
|
+
});
|
|
8770
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
8771
|
+
child.on("error", () => resolve(1));
|
|
9112
8772
|
});
|
|
9113
|
-
const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
|
|
9114
|
-
out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
|
|
9115
8773
|
}
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
stdin.setRawMode(true);
|
|
9127
|
-
stdin.resume();
|
|
9128
|
-
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
9129
|
-
const redraw = () => draw(stdout, value, state, rows());
|
|
9130
|
-
stdout.on("resize", redraw);
|
|
9131
|
-
try {
|
|
9132
|
-
redraw();
|
|
9133
|
-
await new Promise((resolve) => {
|
|
9134
|
-
const onData = (buf) => {
|
|
9135
|
-
const key = KEYMAP[buf.toString()];
|
|
9136
|
-
if (key === "quit") {
|
|
9137
|
-
stdin.off("data", onData);
|
|
9138
|
-
resolve();
|
|
9139
|
-
return;
|
|
9140
|
-
}
|
|
9141
|
-
if (key) {
|
|
9142
|
-
state = reduceKey(state, key, value, rows());
|
|
9143
|
-
redraw();
|
|
9144
|
-
}
|
|
9145
|
-
};
|
|
9146
|
-
stdin.on("data", onData);
|
|
9147
|
-
});
|
|
9148
|
-
} finally {
|
|
9149
|
-
stdout.off("resize", redraw);
|
|
9150
|
-
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
9151
|
-
stdin.setRawMode(wasRaw);
|
|
9152
|
-
stdin.pause();
|
|
9153
|
-
}
|
|
8774
|
+
/**
|
|
8775
|
+
* Whether `launchMenu` should auto-preview a picked command before running
|
|
8776
|
+
* it for real — only when the command's handler actually honours
|
|
8777
|
+
* `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
|
|
8778
|
+
* invocation didn't pass `--skip-dry-run` to opt out of the preview
|
|
8779
|
+
* entirely (an escape hatch for "I already know what I want, skip
|
|
8780
|
+
* straight to the real run").
|
|
8781
|
+
*/
|
|
8782
|
+
function shouldPreviewDryRun(entry, parentArgv) {
|
|
8783
|
+
return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
|
|
9154
8784
|
}
|
|
9155
8785
|
/**
|
|
9156
|
-
*
|
|
9157
|
-
* `
|
|
9158
|
-
*
|
|
8786
|
+
* Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
|
|
8787
|
+
* `[]` for the top), prompt for the picked command's required positionals, spawn
|
|
8788
|
+
* it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
|
|
8789
|
+
* the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
|
|
8790
|
+
* parent's own telemetry flush / update-notifier tail still runs. The parent's
|
|
8791
|
+
* own `command_completed` event fires with command name "unknown" (the middleware
|
|
8792
|
+
* ran before any command was picked) — left as-is rather than suppressed; it's a
|
|
8793
|
+
* real, useful signal ("the menu got used").
|
|
8794
|
+
*
|
|
8795
|
+
* Picking a group opens its submenu, which has a "← back" item; a command ends
|
|
8796
|
+
* the walk. See {@link menuItems} for how a level is built.
|
|
8797
|
+
*
|
|
8798
|
+
* For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
|
|
8799
|
+
* and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
|
|
8800
|
+
* this always previews first — a dry run doesn't mutate anything, so
|
|
8801
|
+
* there's nothing to ask permission for. A preview that exits non-zero
|
|
8802
|
+
* stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
|
|
8803
|
+
* would change) stops here too, skipping the follow-up question since the
|
|
8804
|
+
* real run would have nothing to do either. Otherwise it asks "proceed
|
|
8805
|
+
* with the real run?" before the real spawn — the one actual decision
|
|
8806
|
+
* left to make, since this one does mutate something.
|
|
9159
8807
|
*/
|
|
9160
|
-
async function
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
8808
|
+
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
|
|
8809
|
+
if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
|
|
8810
|
+
const stack = [startPath];
|
|
8811
|
+
let picked;
|
|
8812
|
+
while (!picked) {
|
|
8813
|
+
const path = stack[stack.length - 1];
|
|
8814
|
+
const items = menuItems(entries, path);
|
|
8815
|
+
if (stack.length > 1) items.push({
|
|
8816
|
+
name: "← back",
|
|
8817
|
+
value: BACK,
|
|
8818
|
+
description: "Return to the previous menu",
|
|
8819
|
+
haystack: "back"
|
|
8820
|
+
});
|
|
8821
|
+
const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
|
|
8822
|
+
if (value === "back") stack.pop();
|
|
8823
|
+
else if (value.startsWith("group:")) stack.push(value.slice(6).split(" "));
|
|
8824
|
+
else picked = entries.find((e) => e.name === value);
|
|
8825
|
+
}
|
|
8826
|
+
const positionals = await promptForPositionals(picked, {});
|
|
8827
|
+
if (shouldPreviewDryRun(picked, parentArgv)) {
|
|
8828
|
+
const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
|
|
8829
|
+
if (dryExit === 2) {
|
|
8830
|
+
process.exitCode = 0;
|
|
8831
|
+
return;
|
|
8832
|
+
}
|
|
8833
|
+
if (dryExit !== 0) {
|
|
8834
|
+
process.exitCode = dryExit;
|
|
8835
|
+
return;
|
|
8836
|
+
}
|
|
8837
|
+
if (!await confirm({
|
|
8838
|
+
message: "Dry run complete — proceed with the real run?",
|
|
8839
|
+
default: false
|
|
8840
|
+
})) {
|
|
8841
|
+
process.exitCode = 0;
|
|
8842
|
+
return;
|
|
8843
|
+
}
|
|
8844
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
|
|
9165
8845
|
return;
|
|
9166
8846
|
}
|
|
9167
|
-
|
|
9168
|
-
console.log(lines.map((l) => l.text).join("\n"));
|
|
8847
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
9169
8848
|
}
|
|
9170
8849
|
//#endregion
|
|
9171
|
-
//#region src/
|
|
9172
|
-
/**
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
8850
|
+
//#region src/interactive-menu/run-menu-entries/run-menu-entries.ts
|
|
8851
|
+
/**
|
|
8852
|
+
* This repo's own resolved task manifest, filtered to what's actually
|
|
8853
|
+
* runnable locally — `Astromech.runnableTasks()`, reused rather than
|
|
8854
|
+
* reimplemented (deliberately *not* `packageScripts()`, whose
|
|
8855
|
+
* `syncScripts: false` short-circuit returns `{}` for repos — this one
|
|
8856
|
+
* included — that opt out of generated `package.json` scripts but still
|
|
8857
|
+
* have real runnable tasks). Empty (never throws) when there's no config to
|
|
8858
|
+
* resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
|
|
8859
|
+
* as "run ›" simply having no children.
|
|
8860
|
+
*/
|
|
8861
|
+
async function listRunnableTasks(argv) {
|
|
8862
|
+
const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
|
|
8863
|
+
const config = await loadTasksConfig(cwd).catch(() => void 0);
|
|
8864
|
+
return createAstromech({
|
|
8865
|
+
cwd,
|
|
8866
|
+
config
|
|
8867
|
+
}).runnableTasks();
|
|
8868
|
+
}
|
|
8869
|
+
/**
|
|
8870
|
+
* Namespace display order for the `run` menu — deliberately its own thing,
|
|
8871
|
+
* not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
|
|
8872
|
+
* signal first" in an automated gate, a different goal from "how a person
|
|
8873
|
+
* would naturally work through a repo" (build it, clean up its style, check
|
|
8874
|
+
* it's secure, test it, validate it, document it). A namespace absent here
|
|
8875
|
+
* (a repo-specific one this list doesn't know about) sorts after every
|
|
8876
|
+
* named one, in whatever order it was first seen.
|
|
8877
|
+
*/
|
|
8878
|
+
const RUN_MENU_NAMESPACE_ORDER = [
|
|
8879
|
+
"delivery",
|
|
8880
|
+
"sourceQuality",
|
|
8881
|
+
"security",
|
|
8882
|
+
"verification",
|
|
8883
|
+
"platform",
|
|
8884
|
+
"knowledge"
|
|
8885
|
+
];
|
|
8886
|
+
/**
|
|
8887
|
+
* Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
|
|
8888
|
+
* groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
|
|
8889
|
+
* within each group is `taskNames`' own order (already declaration order,
|
|
8890
|
+
* from {@link listRunnableTasks}). A name with no `.` at all (defensive;
|
|
8891
|
+
* every real registry task has a namespace) is its own bucket, keyed by the
|
|
8892
|
+
* full name, sorted after every namespaced group.
|
|
8893
|
+
*/
|
|
8894
|
+
function groupByNamespace(taskNames) {
|
|
8895
|
+
const groups = /* @__PURE__ */ new Map();
|
|
8896
|
+
for (const name of taskNames) {
|
|
8897
|
+
const dot = name.indexOf(".");
|
|
8898
|
+
const key = dot === -1 ? name : name.slice(0, dot);
|
|
8899
|
+
const list = groups.get(key);
|
|
8900
|
+
if (list) list.push(name);
|
|
8901
|
+
else groups.set(key, [name]);
|
|
8902
|
+
}
|
|
8903
|
+
const ordered = /* @__PURE__ */ new Map();
|
|
8904
|
+
const byMenuOrder = (a, b) => {
|
|
8905
|
+
const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
|
|
8906
|
+
const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
|
|
8907
|
+
if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
|
8908
|
+
return 0;
|
|
9190
8909
|
};
|
|
8910
|
+
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8911
|
+
return ordered;
|
|
9191
8912
|
}
|
|
9192
8913
|
/**
|
|
9193
|
-
*
|
|
9194
|
-
*
|
|
9195
|
-
*
|
|
9196
|
-
*
|
|
8914
|
+
* Dynamically-built `run` sub-entries for every locally-runnable task
|
|
8915
|
+
* ({@link listRunnableTasks}), grouped by namespace and merged into
|
|
8916
|
+
* `COMMAND_REGISTRY` at `cli.ts`'s `$0` call site — not a registry entry
|
|
8917
|
+
* itself, since the task set is per-repo, not a static surface this module
|
|
8918
|
+
* can hand-enumerate.
|
|
8919
|
+
*
|
|
8920
|
+
* Each namespace becomes a group with a leading "<namespace> (run it)" item
|
|
8921
|
+
* (`holocron run <namespace>` — every task in it, via
|
|
8922
|
+
* `Astromech.runGroup()`) alongside each of its individual tasks, the exact
|
|
8923
|
+
* same "a group that's also runnable" shape `menuItems` already renders for
|
|
8924
|
+
* `repo sync` / `deploy` — reached here for free by using a space, not the
|
|
8925
|
+
* task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
|
|
8926
|
+
* the real dotted invocation `buildChildArgv` actually spawns).
|
|
9197
8927
|
*/
|
|
9198
|
-
function
|
|
9199
|
-
const
|
|
9200
|
-
const
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
8928
|
+
function buildRunMenuEntries(taskNames) {
|
|
8929
|
+
const entries = [];
|
|
8930
|
+
for (const [namespace, tasks] of groupByNamespace(taskNames)) {
|
|
8931
|
+
const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
|
|
8932
|
+
if (isRealNamespace) entries.push({
|
|
8933
|
+
name: `run ${namespace}`,
|
|
8934
|
+
description: `Run every ${namespace}.* task in sequence`,
|
|
8935
|
+
positionals: [],
|
|
8936
|
+
supportsDryRun: true,
|
|
8937
|
+
spawnArgs: ["run", namespace]
|
|
8938
|
+
});
|
|
8939
|
+
for (const task of tasks) {
|
|
8940
|
+
const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
|
|
8941
|
+
entries.push({
|
|
8942
|
+
name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
|
|
8943
|
+
description: `Run ${task}`,
|
|
8944
|
+
positionals: [],
|
|
8945
|
+
supportsDryRun: true,
|
|
8946
|
+
spawnArgs: ["run", task]
|
|
8947
|
+
});
|
|
8948
|
+
}
|
|
8949
|
+
}
|
|
8950
|
+
return entries;
|
|
9206
8951
|
}
|
|
9207
8952
|
//#endregion
|
|
9208
|
-
//#region src/update-notifier.ts
|
|
8953
|
+
//#region src/update-notifier/update-notifier.ts
|
|
9209
8954
|
const PACKAGE_NAME = "@theholocron/cli";
|
|
9210
8955
|
const CACHE_TTL_MS = 864e5;
|
|
9211
8956
|
const FETCH_TIMEOUT_MS = 3e3;
|