@theholocron/cli 5.0.0-alpha.151 → 5.0.0-alpha.153
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 +1363 -1609
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +82 -162
- package/dist/index.mjs +13 -163
- package/dist/plugin/capabilities.d.mts +32 -30
- package/dist/plugin/capabilities.mjs +1 -1
- package/package.json +13 -17
package/dist/cli.mjs
CHANGED
|
@@ -4,24 +4,27 @@ 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";
|
|
12
13
|
import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1, createRestClient } from "@theholocron/http-client";
|
|
13
|
-
import { createEnvLookup } from "@theholocron/env-utils";
|
|
14
|
+
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,14 +32,17 @@ 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
|
/**
|
|
39
|
+
* `get("KEY")` tries `HOLOCRON_KEY` first, then `KEY`. Use it for settings that
|
|
40
|
+
* have both a Holocron-namespaced override and a vendor-native name; do not use
|
|
41
|
+
* it for `HOLOCRON_*` variables that have no bare fallback (`HOLOCRON_ORG`,
|
|
42
|
+
* `HOLOCRON_LOG_LEVEL`, …), where the bare name would be wrong.
|
|
43
|
+
*/
|
|
44
|
+
const holocronEnv = withEnvPrefix(env, "HOLOCRON");
|
|
45
|
+
/**
|
|
40
46
|
* Create an injectable env for commands that accept a fake env in tests.
|
|
41
47
|
* Pass `input.env` when available, falls back to `process.env`.
|
|
42
48
|
*/
|
|
@@ -44,7 +50,7 @@ function makeEnv(source) {
|
|
|
44
50
|
return createEnvLookup(source);
|
|
45
51
|
}
|
|
46
52
|
//#endregion
|
|
47
|
-
//#region src/auth/keyring.ts
|
|
53
|
+
//#region src/auth/keyring/keyring.ts
|
|
48
54
|
/**
|
|
49
55
|
* Keyring-backed bootstrap credential store.
|
|
50
56
|
*
|
|
@@ -114,7 +120,7 @@ function listStoredProviders() {
|
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
//#endregion
|
|
117
|
-
//#region src/auth/auth-resolver.ts
|
|
123
|
+
//#region src/auth/auth-resolver/auth-resolver.ts
|
|
118
124
|
/**
|
|
119
125
|
* Build a strict, single-feature token resolver.
|
|
120
126
|
*
|
|
@@ -132,7 +138,7 @@ function createFeatureResolver(config) {
|
|
|
132
138
|
};
|
|
133
139
|
}
|
|
134
140
|
//#endregion
|
|
135
|
-
//#region src/auth/token-args.ts
|
|
141
|
+
//#region src/auth/token-args/token-args.ts
|
|
136
142
|
var TokenParseError = class extends Error {
|
|
137
143
|
name = "TokenParseError";
|
|
138
144
|
};
|
|
@@ -170,7 +176,7 @@ function parseTokenArgs(tokens) {
|
|
|
170
176
|
return result;
|
|
171
177
|
}
|
|
172
178
|
//#endregion
|
|
173
|
-
//#region src/plugin/capabilities.ts
|
|
179
|
+
//#region src/plugin/capabilities/capabilities.ts
|
|
174
180
|
const CARDINALITY = {
|
|
175
181
|
source: "single",
|
|
176
182
|
ci: "single",
|
|
@@ -198,7 +204,7 @@ const CARDINALITY = {
|
|
|
198
204
|
*/
|
|
199
205
|
const REQUIRED_CAPABILITIES = [];
|
|
200
206
|
//#endregion
|
|
201
|
-
//#region src/config/config.ts
|
|
207
|
+
//#region src/config/config/config.ts
|
|
202
208
|
var ConfigError = class extends Error {
|
|
203
209
|
name = "ConfigError";
|
|
204
210
|
};
|
|
@@ -307,7 +313,64 @@ function resolveConfig(raw) {
|
|
|
307
313
|
};
|
|
308
314
|
}
|
|
309
315
|
//#endregion
|
|
310
|
-
//#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
|
|
311
374
|
/**
|
|
312
375
|
* Command → context. Keys are the full command path as it lands in
|
|
313
376
|
* yargs' `argv._` (`"auth set"`, `"repo upgrade node"`), the bare verb
|
|
@@ -368,183 +431,556 @@ function commandsInContext(context) {
|
|
|
368
431
|
return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
|
|
369
432
|
}
|
|
370
433
|
//#endregion
|
|
371
|
-
//#region src/ui/
|
|
372
|
-
const style = {
|
|
373
|
-
success: (msg) => `${chalk.green("✓")} ${msg}`,
|
|
374
|
-
warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
|
|
375
|
-
fail: (msg) => `${chalk.red("✗")} ${msg}`,
|
|
376
|
-
step: (msg) => `${chalk.cyan("→")} ${msg}`,
|
|
377
|
-
/** A dim `·` marker for a row that's neither a pass nor a failure — loaded but inactive, or skipped. */
|
|
378
|
-
skip: (msg) => chalk.dim(`· ${msg}`),
|
|
379
|
-
hint: (msg) => chalk.dim(msg),
|
|
380
|
-
dim: (msg) => chalk.dim(msg),
|
|
381
|
-
header: (msg) => chalk.bold(msg)
|
|
382
|
-
};
|
|
383
|
-
//#endregion
|
|
384
|
-
//#region src/logger.ts
|
|
385
|
-
/**
|
|
386
|
-
* Dataset for `global`-context commands (`clone`, `auth set`, `upgrade
|
|
387
|
-
* node`, …) — ADR-0012. These commands aren't "about" any repo, so their
|
|
388
|
-
* logs must never depend on `$PWD` or which repo's `.envrc` happens to be
|
|
389
|
-
* active; they always ship to holocron's own dataset instead of whatever
|
|
390
|
-
* `HOLOCRON_AXIOM_DATASET` resolves to locally. Mirrors
|
|
391
|
-
* `telemetry/resolve.ts`'s `FALLBACK_DSN` — a holocron-owned default, not a
|
|
392
|
-
* per-repo override.
|
|
393
|
-
*/
|
|
394
|
-
const GLOBAL_AXIOM_DATASET = "holocron-global";
|
|
395
|
-
/**
|
|
396
|
-
* Resolve the explicit level to hand to `createLogger`, in priority order:
|
|
397
|
-
*
|
|
398
|
-
* 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
|
|
399
|
-
* 4. `HOLOCRON_LOG_LEVEL` env var
|
|
400
|
-
* 5. `holocron.config` `log.level` (`configLevel`)
|
|
401
|
-
*
|
|
402
|
-
* Returns `undefined` when nothing applies — `createLogger` then defaults
|
|
403
|
-
* to `"info"`. Resolving the full chain here (rather than passing
|
|
404
|
-
* `configLevel` straight through) keeps config below the env var.
|
|
405
|
-
*/
|
|
406
|
-
function resolveLogLevel(argv, configLevel) {
|
|
407
|
-
if (argv.log) return argv.log;
|
|
408
|
-
if (argv.verbose) return "debug";
|
|
409
|
-
if (argv.quiet) return "error";
|
|
410
|
-
return parseLogLevel(env.get("HOLOCRON_LOG_LEVEL")) ?? configLevel;
|
|
411
|
-
}
|
|
434
|
+
//#region src/ui/non-interactive-error/non-interactive-error.ts
|
|
412
435
|
/**
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
* `
|
|
416
|
-
*
|
|
417
|
-
* Local/interactive runs are quiet by default: any raw structured logger
|
|
418
|
-
* output (colorized or not) stays off regardless of the resolved level,
|
|
419
|
-
* unless the user explicitly asked to see it via `--log <level>`,
|
|
420
|
-
* `--verbose`, or `--quiet`. CI is untouched either way — returning
|
|
421
|
-
* `undefined` there preserves `createLogger`'s own `ci`/`tty`
|
|
422
|
-
* auto-detection exactly, so CI's existing NDJSON-to-stdout behavior is
|
|
423
|
-
* 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.
|
|
424
440
|
*/
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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;
|
|
428
448
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
let rootCommand;
|
|
432
|
-
let rootAxiomKey;
|
|
433
|
-
/**
|
|
434
|
-
* Resolve Axiom credentials for the CLI.
|
|
435
|
-
*
|
|
436
|
-
* A `global`-context command (ADR-0012) always resolves to
|
|
437
|
-
* {@link GLOBAL_AXIOM_DATASET}, ignoring `HOLOCRON_AXIOM_DATASET` /
|
|
438
|
-
* `AXIOM_DATASET` / `holocron.config` `log.axiom.dataset` entirely — it
|
|
439
|
-
* isn't "about" any repo, so the active directory's dataset is never the
|
|
440
|
-
* right answer. Everything else keeps the existing chain: env vars win
|
|
441
|
-
* (same contract as `@theholocron/observability`'s `resolveAxiomFromEnv`),
|
|
442
|
-
* falling back to the CLI-only bridge that pairs the OS-keyring token
|
|
443
|
-
* (`axiom.<org>` then bare `axiom`) with a dataset from
|
|
444
|
-
* `HOLOCRON_AXIOM_DATASET` / `AXIOM_DATASET` or `log.axiom.dataset`.
|
|
445
|
-
* Returns `undefined` unless both a token and a dataset are found.
|
|
446
|
-
*/
|
|
447
|
-
function resolveCliAxiom(opts) {
|
|
448
|
-
const isGlobal = contextForCommand(opts.command ?? rootCommand ?? "") === "global";
|
|
449
|
-
if (!isGlobal) {
|
|
450
|
-
const fromEnv = resolveAxiomFromEnv();
|
|
451
|
-
if (fromEnv) return fromEnv;
|
|
452
|
-
}
|
|
453
|
-
const dataset = isGlobal ? GLOBAL_AXIOM_DATASET : env.get("HOLOCRON_AXIOM_DATASET") || env.get("AXIOM_DATASET") || opts.configAxiomDataset;
|
|
454
|
-
if (!dataset) return void 0;
|
|
455
|
-
const org = opts.org ?? env.get("HOLOCRON_ORG");
|
|
456
|
-
const token = env.get("HOLOCRON_AXIOM_TOKEN") || env.get("AXIOM_TOKEN") || (org ? getToken(`axiom.${org}`) : null) || getToken("axiom");
|
|
457
|
-
return token ? {
|
|
458
|
-
dataset,
|
|
459
|
-
token
|
|
460
|
-
} : void 0;
|
|
449
|
+
function childrenOf(value) {
|
|
450
|
+
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
461
451
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
* `--quiet` already fixed it) or `log.axiom.dataset` + the resolved org
|
|
468
|
-
* for a keyring-backed Axiom transport. That rebuild generates a fresh
|
|
469
|
-
* `runId`, which is harmless: nothing logs between the middleware and the
|
|
470
|
-
* handler.
|
|
471
|
-
*/
|
|
472
|
-
function buildCliLogger(argv, opts = {}) {
|
|
473
|
-
const { command, configLevel } = opts;
|
|
474
|
-
if (command) rootCommand = command;
|
|
475
|
-
const level = resolveLogLevel(argv, configLevel);
|
|
476
|
-
const consoleOutput = resolveConsoleOutput(argv);
|
|
477
|
-
const axiom = resolveCliAxiom(opts);
|
|
478
|
-
const axiomKey = axiom?.dataset;
|
|
479
|
-
const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
|
|
480
|
-
if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
|
|
481
|
-
const built = createLogger({
|
|
482
|
-
...level ? { level } : {},
|
|
483
|
-
...axiom ? { axiom } : {},
|
|
484
|
-
...consoleOutput !== void 0 ? { consoleOutput } : {}
|
|
485
|
-
});
|
|
486
|
-
root = {
|
|
487
|
-
logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
|
|
488
|
-
runId: built.runId
|
|
489
|
-
};
|
|
490
|
-
rootLevel = level;
|
|
491
|
-
rootAxiomKey = axiomKey;
|
|
492
|
-
}
|
|
493
|
-
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));
|
|
494
457
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
* unknown command) and therefore runs before `buildCliLogger` has ever
|
|
500
|
-
* built the real root. Without an explicit `consoleOutput` here, that
|
|
501
|
-
* fallback would use `createLogger`'s bare auto-detection (pretty on any
|
|
502
|
-
* local TTY) and leak a structured block even though no flag asked for one
|
|
503
|
-
* — so it resolves the same "quiet unless CI" default `buildCliLogger`
|
|
504
|
-
* would, as if no flags were passed.
|
|
505
|
-
*/
|
|
506
|
-
function getLogger() {
|
|
507
|
-
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}}`);
|
|
508
462
|
}
|
|
509
463
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* sent through the structured logger for Axiom/telemetry visibility.
|
|
513
|
-
*
|
|
514
|
-
* Use this when a handler is returning or exiting right here with nothing
|
|
515
|
-
* else for the user to see — a resolved `--token` failure, a yargs
|
|
516
|
-
* validation failure, a thrown domain error. Don't use it for a per-step
|
|
517
|
-
* warning inside a multi-step orchestrator that already prints its own
|
|
518
|
-
* `ok / fail / skip` summary — that summary is the user-facing signal;
|
|
519
|
-
* 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.
|
|
520
466
|
*/
|
|
521
|
-
function
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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;
|
|
528
476
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
548
984
|
* preset options with any per-project overrides from the config file
|
|
549
985
|
* (project options win, mirroring ESLint's `extends` precedence).
|
|
550
986
|
*
|
|
@@ -692,7 +1128,111 @@ function isCapabilityConfigModule(mod) {
|
|
|
692
1128
|
return typeof mod.default?.provider === "string";
|
|
693
1129
|
}
|
|
694
1130
|
//#endregion
|
|
695
|
-
//#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
|
|
696
1236
|
/**
|
|
697
1237
|
* The `workspace`-context guard.
|
|
698
1238
|
*
|
|
@@ -762,25 +1302,7 @@ function assertPluginsResolvable(loader, command) {
|
|
|
762
1302
|
throw new WorkspaceContextError(command, [...new Set(failures.map((f) => f.packageName))]);
|
|
763
1303
|
}
|
|
764
1304
|
//#endregion
|
|
765
|
-
//#region src/
|
|
766
|
-
/**
|
|
767
|
-
* Runs `fn`, showing an ora spinner for its duration in TTY environments.
|
|
768
|
-
* In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
|
|
769
|
-
*/
|
|
770
|
-
async function withSpinner(label, fn) {
|
|
771
|
-
if (!process.stdout.isTTY) return fn();
|
|
772
|
-
const spinner = ora(label).start();
|
|
773
|
-
try {
|
|
774
|
-
const result = await fn();
|
|
775
|
-
spinner.succeed();
|
|
776
|
-
return result;
|
|
777
|
-
} catch (err) {
|
|
778
|
-
spinner.fail();
|
|
779
|
-
throw err;
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
//#endregion
|
|
783
|
-
//#region src/commands/auth.ts
|
|
1305
|
+
//#region src/commands/auth/auth.ts
|
|
784
1306
|
/**
|
|
785
1307
|
* `holocron auth <subcommand>` — manage bootstrap credentials in the
|
|
786
1308
|
* OS keyring.
|
|
@@ -813,10 +1335,8 @@ const defaultImporter = async (pkg) => {
|
|
|
813
1335
|
* pulling FROM it would just re-store the same value.
|
|
814
1336
|
*/
|
|
815
1337
|
function resolveAuthSetToken(input) {
|
|
816
|
-
const e = makeEnv(input.env);
|
|
817
|
-
|
|
818
|
-
const holocronKey = `HOLOCRON_${upper}_TOKEN`;
|
|
819
|
-
return input.positional || e.get(holocronKey) || e.get(`${upper}_TOKEN`) || null;
|
|
1338
|
+
const e = withEnvPrefix(makeEnv(input.env), "HOLOCRON");
|
|
1339
|
+
return input.positional || e.get(`${input.provider.toUpperCase()}_TOKEN`) || null;
|
|
820
1340
|
}
|
|
821
1341
|
async function runAuthSet(input) {
|
|
822
1342
|
const print = input.print ?? ((l) => console.log(l));
|
|
@@ -1063,7 +1583,7 @@ async function tryLoadHint(importer, packageName) {
|
|
|
1063
1583
|
}
|
|
1064
1584
|
}
|
|
1065
1585
|
//#endregion
|
|
1066
|
-
//#region src/commands/cleanup-preview.ts
|
|
1586
|
+
//#region src/commands/cleanup-preview/cleanup-preview.ts
|
|
1067
1587
|
function prStateLabel(pr) {
|
|
1068
1588
|
if (pr.merged) return style.success("merged");
|
|
1069
1589
|
if (pr.state === "closed") return style.dim("closed (not merged)");
|
|
@@ -1203,28 +1723,7 @@ async function runCleanupPreview(input) {
|
|
|
1203
1723
|
}
|
|
1204
1724
|
}
|
|
1205
1725
|
//#endregion
|
|
1206
|
-
//#region src/
|
|
1207
|
-
/** Right-pads `s` to `width` with spaces — never truncates. */
|
|
1208
|
-
function pad(s, width) {
|
|
1209
|
-
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
1210
|
-
}
|
|
1211
|
-
/**
|
|
1212
|
-
* One row of a status report: a 2-space indent, the status icon (✓ / ✗ / ·),
|
|
1213
|
-
* then `columns` joined with a two-space gap (blank columns dropped, so a
|
|
1214
|
-
* row that has nothing for its last column doesn't trail whitespace).
|
|
1215
|
-
* Column padding (`pad`) is the caller's job — `doctor` pads capability and
|
|
1216
|
-
* provider to a fixed width; `clone`'s repo names and paths don't, since
|
|
1217
|
-
* they vary too widely for fixed-width alignment to read cleanly.
|
|
1218
|
-
*
|
|
1219
|
-
* Shared by `doctor` (capability / provider / message) and `clone` (repo /
|
|
1220
|
-
* destination / reason) so every status table in the CLI reads the same way.
|
|
1221
|
-
*/
|
|
1222
|
-
function statusRow(status, columns) {
|
|
1223
|
-
const joined = columns.filter((c) => c.length > 0).join(" ");
|
|
1224
|
-
return ` ${status === "ok" ? style.success(joined) : status === "fail" ? style.fail(joined) : style.skip(joined)}`;
|
|
1225
|
-
}
|
|
1226
|
-
//#endregion
|
|
1227
|
-
//#region src/commands/clone.ts
|
|
1726
|
+
//#region src/commands/clone/clone.ts
|
|
1228
1727
|
/**
|
|
1229
1728
|
* Accepts a bare org (`"theholocron"`) — clones every repo in it — or an
|
|
1230
1729
|
* `org/repo` coordinate (`"theholocron/new-repo"`) — clones just that one
|
|
@@ -1421,7 +1920,7 @@ async function runClone(input) {
|
|
|
1421
1920
|
};
|
|
1422
1921
|
}
|
|
1423
1922
|
//#endregion
|
|
1424
|
-
//#region src/commands/deploy.ts
|
|
1923
|
+
//#region src/commands/deploy/deploy.ts
|
|
1425
1924
|
/**
|
|
1426
1925
|
* `holocron deploy` — trigger a deployment via the configured
|
|
1427
1926
|
* `deployment` capability.
|
|
@@ -1661,7 +2160,7 @@ async function runDeployFromFiles(input) {
|
|
|
1661
2160
|
}
|
|
1662
2161
|
}
|
|
1663
2162
|
//#endregion
|
|
1664
|
-
//#region src/commands/doctor.ts
|
|
2163
|
+
//#region src/commands/doctor/doctor.ts
|
|
1665
2164
|
/**
|
|
1666
2165
|
* `holocron doctor` — load the config, instantiate every capability,
|
|
1667
2166
|
* print a per-capability readiness report.
|
|
@@ -1807,117 +2306,13 @@ function countResolvedLifecycle(report) {
|
|
|
1807
2306
|
function mk(capability, provider, status, message) {
|
|
1808
2307
|
return {
|
|
1809
2308
|
capability,
|
|
1810
|
-
provider,
|
|
1811
|
-
status,
|
|
1812
|
-
message
|
|
1813
|
-
};
|
|
1814
|
-
}
|
|
1815
|
-
//#endregion
|
|
1816
|
-
//#region src/plugin/run-check.ts
|
|
1817
|
-
async function runCheckAgainstUrl(input, opts = {}) {
|
|
1818
|
-
const doFetch = opts.fetch ?? fetch;
|
|
1819
|
-
const url = resolveUrl(input.url, opts.url);
|
|
1820
|
-
const method = input.method ?? "GET";
|
|
1821
|
-
const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
|
|
1822
|
-
const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
|
|
1823
|
-
const start = Date.now();
|
|
1824
|
-
let response;
|
|
1825
|
-
let bodyText = "";
|
|
1826
|
-
try {
|
|
1827
|
-
response = await doFetch(url, {
|
|
1828
|
-
method,
|
|
1829
|
-
...hasBody ? { body: input.body } : {},
|
|
1830
|
-
redirect: "manual",
|
|
1831
|
-
signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
|
|
1832
|
-
});
|
|
1833
|
-
if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
|
|
1834
|
-
} catch (err) {
|
|
1835
|
-
return {
|
|
1836
|
-
passed: false,
|
|
1837
|
-
failures: [],
|
|
1838
|
-
requestError: requestErrorMessage(err)
|
|
1839
|
-
};
|
|
1840
|
-
}
|
|
1841
|
-
const responseTimeMs = Date.now() - start;
|
|
1842
|
-
const failures = [];
|
|
1843
|
-
if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
|
|
1844
|
-
for (const assertion of input.assertions ?? []) {
|
|
1845
|
-
const actual = extractActual(assertion, {
|
|
1846
|
-
response,
|
|
1847
|
-
bodyText,
|
|
1848
|
-
responseTimeMs
|
|
1849
|
-
});
|
|
1850
|
-
if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
|
|
1851
|
-
}
|
|
1852
|
-
return {
|
|
1853
|
-
passed: failures.length === 0,
|
|
1854
|
-
status: response.status,
|
|
1855
|
-
responseTimeMs,
|
|
1856
|
-
failures
|
|
1857
|
-
};
|
|
1858
|
-
}
|
|
1859
|
-
/** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
|
|
1860
|
-
function resolveUrl(originalUrl, overrideUrl) {
|
|
1861
|
-
if (!overrideUrl) return originalUrl;
|
|
1862
|
-
const original = new URL(originalUrl);
|
|
1863
|
-
const resolved = new URL(overrideUrl);
|
|
1864
|
-
resolved.pathname = original.pathname;
|
|
1865
|
-
resolved.search = original.search;
|
|
1866
|
-
return resolved.toString();
|
|
1867
|
-
}
|
|
1868
|
-
function requestErrorMessage(err) {
|
|
1869
|
-
if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
|
|
1870
|
-
return String(err);
|
|
1871
|
-
}
|
|
1872
|
-
/** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
|
|
1873
|
-
async function readBodyCapped(response, maxBytes) {
|
|
1874
|
-
if (!response.body) return "";
|
|
1875
|
-
const reader = response.body.getReader();
|
|
1876
|
-
const chunks = [];
|
|
1877
|
-
let total = 0;
|
|
1878
|
-
try {
|
|
1879
|
-
for (;;) {
|
|
1880
|
-
const { done, value } = await reader.read();
|
|
1881
|
-
if (done) break;
|
|
1882
|
-
const remaining = maxBytes - total;
|
|
1883
|
-
if (remaining <= 0) break;
|
|
1884
|
-
const stored = value.length > remaining ? value.slice(0, remaining) : value;
|
|
1885
|
-
chunks.push(stored);
|
|
1886
|
-
total += stored.length;
|
|
1887
|
-
}
|
|
1888
|
-
} finally {
|
|
1889
|
-
await reader.cancel().catch(() => {});
|
|
1890
|
-
}
|
|
1891
|
-
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
|
|
1892
|
-
}
|
|
1893
|
-
/** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
|
|
1894
|
-
function truncate(value, max = 200) {
|
|
1895
|
-
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
1896
|
-
}
|
|
1897
|
-
function extractActual(assertion, ctx) {
|
|
1898
|
-
switch (assertion.source) {
|
|
1899
|
-
case "status_code": return String(ctx.response.status);
|
|
1900
|
-
case "response_time": return String(ctx.responseTimeMs);
|
|
1901
|
-
case "body": return ctx.bodyText;
|
|
1902
|
-
case "header": {
|
|
1903
|
-
const name = assertion.property ?? "";
|
|
1904
|
-
return name ? ctx.response.headers.get(name) ?? "" : "";
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
function evaluate(comparison, actual, target) {
|
|
1909
|
-
const t = target ?? "";
|
|
1910
|
-
switch (comparison) {
|
|
1911
|
-
case "equals": return actual === t;
|
|
1912
|
-
case "not_equals": return actual !== t;
|
|
1913
|
-
case "contains": return actual.includes(t);
|
|
1914
|
-
case "not_contains": return !actual.includes(t);
|
|
1915
|
-
case "greater_than": return Number(actual) > Number(t);
|
|
1916
|
-
case "less_than": return Number(actual) < Number(t);
|
|
1917
|
-
}
|
|
2309
|
+
provider,
|
|
2310
|
+
status,
|
|
2311
|
+
message
|
|
2312
|
+
};
|
|
1918
2313
|
}
|
|
1919
2314
|
//#endregion
|
|
1920
|
-
//#region src/commands/monitoring-check.ts
|
|
2315
|
+
//#region src/commands/monitoring-check/monitoring-check.ts
|
|
1921
2316
|
async function runMonitoringCheck(input) {
|
|
1922
2317
|
const print = input.print ?? ((line) => console.log(line));
|
|
1923
2318
|
const logger = input.logger ?? getLogger();
|
|
@@ -1978,7 +2373,7 @@ async function runMonitoringCheck(input) {
|
|
|
1978
2373
|
};
|
|
1979
2374
|
}
|
|
1980
2375
|
//#endregion
|
|
1981
|
-
//#region src/commands/new.ts
|
|
2376
|
+
//#region src/commands/new/new.ts
|
|
1982
2377
|
/**
|
|
1983
2378
|
* `holocron new repo <type> <name>` — create a GitHub repo from a template and
|
|
1984
2379
|
* bootstrap it by replacing all template-slug casing variants with the new
|
|
@@ -2372,7 +2767,7 @@ async function runNew(input) {
|
|
|
2372
2767
|
};
|
|
2373
2768
|
}
|
|
2374
2769
|
//#endregion
|
|
2375
|
-
//#region src/commands/npm-bump-versions.ts
|
|
2770
|
+
//#region src/commands/npm-bump-versions/npm-bump-versions.ts
|
|
2376
2771
|
async function runNpmBumpVersions(input) {
|
|
2377
2772
|
const print = input.print ?? ((line) => console.log(line));
|
|
2378
2773
|
const logger = input.logger ?? getLogger();
|
|
@@ -2453,9 +2848,9 @@ async function runNpmBumpVersions(input) {
|
|
|
2453
2848
|
};
|
|
2454
2849
|
}
|
|
2455
2850
|
//#endregion
|
|
2456
|
-
//#region src/commands/plugin-create/template-inputs.ts
|
|
2851
|
+
//#region src/commands/plugin-create/template-inputs/template-inputs.ts
|
|
2457
2852
|
/** Derive the standard defaults from a slug + vendor name. */
|
|
2458
|
-
function deriveDefaults
|
|
2853
|
+
function deriveDefaults(input) {
|
|
2459
2854
|
const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
|
|
2460
2855
|
const capability = input.capability;
|
|
2461
2856
|
return {
|
|
@@ -3394,7 +3789,7 @@ function runPluginCreate(input) {
|
|
|
3394
3789
|
const packageDir = path.join(cwd, "plugins", `holocron-plugin-${input.slug}`);
|
|
3395
3790
|
if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
|
|
3396
3791
|
validateCapability(input.capability, print);
|
|
3397
|
-
const derived = deriveDefaults
|
|
3792
|
+
const derived = deriveDefaults({
|
|
3398
3793
|
slug: input.slug,
|
|
3399
3794
|
vendorName: input.vendorName,
|
|
3400
3795
|
capability: input.capability
|
|
@@ -3550,224 +3945,142 @@ function printNextSteps$1(print, inputs) {
|
|
|
3550
3945
|
print(" 7. Commit + push when capability is functionally complete.");
|
|
3551
3946
|
}
|
|
3552
3947
|
//#endregion
|
|
3553
|
-
//#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
|
|
3554
3967
|
/**
|
|
3555
|
-
* `
|
|
3556
|
-
*
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3559
|
-
* configured on it. So the first publish has to happen outside the OIDC
|
|
3560
|
-
* flow — a browser-auth session (`npm login --auth-type=web`) or an
|
|
3561
|
-
* ephemeral automation token. This command drives the login step itself
|
|
3562
|
-
* (when `npm whoami` shows you're not authenticated), runs the publish
|
|
3563
|
-
* step, and tells you exactly what to do next.
|
|
3564
|
-
*
|
|
3565
|
-
* Workflow:
|
|
3566
|
-
*
|
|
3567
|
-
* $ pnpm install --frozen-lockfile
|
|
3568
|
-
* $ pnpm build
|
|
3569
|
-
* $ pnpm exec tsx packages/cli/src/cli.ts package publish --initial
|
|
3570
|
-
*
|
|
3571
|
-
* `npm login --auth-type=web` runs automatically the first time — no
|
|
3572
|
-
* separate manual step. `pnpm install` + `pnpm build` stay outside the
|
|
3573
|
-
* command (no pnpm-inside-pnpm).
|
|
3574
|
-
*
|
|
3575
|
-
* `--initial` is required today — this command only implements the
|
|
3576
|
-
* bootstrap publish. A non-initial `holocron package publish` (for a manual publish
|
|
3577
|
-
* outside the semantic-release/OIDC steady state) isn't built yet; the flag
|
|
3578
|
-
* exists so the surface doesn't need another rename when it is.
|
|
3579
|
-
*
|
|
3580
|
-
* If `NPM_TOKEN` is detected in env, the command prints a final
|
|
3581
|
-
* "revoke this token at <url>" reminder — same pattern as `rando vc
|
|
3582
|
-
* 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).
|
|
3583
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
|
|
3584
3992
|
/**
|
|
3585
|
-
* `
|
|
3586
|
-
*
|
|
3587
|
-
*
|
|
3588
|
-
* shell-script prototype this supersedes) for the full "why": npm ties
|
|
3589
|
-
* Trusted Publisher to a workflow FILENAME per package, so renaming the
|
|
3590
|
-
* release workflow breaks OIDC publish (`ENEEDAUTH`) until every package's
|
|
3591
|
-
* config is updated to match.
|
|
3592
|
-
*
|
|
3593
|
-
* Requires npm@11.15.0+ (`npm trust`) — always shells out through
|
|
3594
|
-
* `npx -y npm@latest` regardless of the locally-installed npm version.
|
|
3595
|
-
*
|
|
3596
|
-
* npm's `trust` mutations — and reads, once a short-lived OTP grant lapses —
|
|
3597
|
-
* require a fresh interactive browser 2FA approval (an `EOTP` error with an
|
|
3598
|
-
* `authUrl`). No token bypasses this (npm's own docs: Granular Access Tokens
|
|
3599
|
-
* with the bypass-2FA option are explicitly unsupported for trust commands).
|
|
3600
|
-
* This command can't drive that browser step itself, so on the first `EOTP`
|
|
3601
|
-
* it stops immediately, prints the `authUrl`, and returns — the operator
|
|
3602
|
-
* approves it and re-runs; already-migrated packages are skipped on the next
|
|
3603
|
-
* 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.
|
|
3604
3996
|
*/
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
const packageNames = input.packages ?? discoverPublicPackages(cwd);
|
|
3613
|
-
const npm = "npx";
|
|
3614
|
-
const npmArgs = ["-y", "npm@latest"];
|
|
3615
|
-
print(`Holocron publish --sync-trust${dryRun ? " (dry-run)" : ""}`);
|
|
3616
|
-
print(` ${input.oldFile} -> ${input.newFile} (repo: ${repo})`);
|
|
3617
|
-
print(` ${packageNames.length} public packages`);
|
|
3618
|
-
print("");
|
|
3619
|
-
logger.info({
|
|
3620
|
-
oldFile: input.oldFile,
|
|
3621
|
-
newFile: input.newFile,
|
|
3622
|
-
packages: packageNames.length
|
|
3623
|
-
}, "sync-trust: start");
|
|
3624
|
-
if (packageNames.length === 0) {
|
|
3625
|
-
const message = "nothing to sync (no packages/*, plugins/*, or root package.json is private or unnamed)";
|
|
3626
|
-
print(` ✗ ${message}`);
|
|
3627
|
-
return {
|
|
3628
|
-
status: "fail",
|
|
3629
|
-
message,
|
|
3630
|
-
packages: []
|
|
3631
|
-
};
|
|
3632
|
-
}
|
|
3633
|
-
if (dryRun) {
|
|
3634
|
-
for (const name of packageNames) print(` … would sync ${name}`);
|
|
3635
|
-
return {
|
|
3636
|
-
status: "dry-run",
|
|
3637
|
-
message: "dry-run — no npm calls made",
|
|
3638
|
-
packages: []
|
|
3639
|
-
};
|
|
3640
|
-
}
|
|
3641
|
-
const results = [];
|
|
3642
|
-
for (const name of packageNames) {
|
|
3643
|
-
print(`=== ${name} ===`);
|
|
3644
|
-
const parsed = parseJson((await exec(npm, [
|
|
3645
|
-
...npmArgs,
|
|
3646
|
-
"trust",
|
|
3647
|
-
"list",
|
|
3648
|
-
name,
|
|
3649
|
-
"--json"
|
|
3650
|
-
], { cwd })).stdout);
|
|
3651
|
-
if (isEotp(parsed)) {
|
|
3652
|
-
const authUrl = parsed.error?.authUrl ?? "";
|
|
3653
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3654
|
-
print(" then re-run; already-migrated packages are skipped.");
|
|
3655
|
-
results.push({
|
|
3656
|
-
name,
|
|
3657
|
-
status: "needs-auth",
|
|
3658
|
-
message: authUrl
|
|
3659
|
-
});
|
|
3660
|
-
logger.warn({
|
|
3661
|
-
package: name,
|
|
3662
|
-
authUrl
|
|
3663
|
-
}, "sync-trust: needs auth, stopping");
|
|
3664
|
-
return {
|
|
3665
|
-
status: "fail",
|
|
3666
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3667
|
-
packages: results
|
|
3668
|
-
};
|
|
3669
|
-
}
|
|
3670
|
-
const entry = parsed;
|
|
3671
|
-
if (entry.file === input.newFile) {
|
|
3672
|
-
print(` already on ${input.newFile}, skipping`);
|
|
3673
|
-
results.push({
|
|
3674
|
-
name,
|
|
3675
|
-
status: "skipped"
|
|
3676
|
-
});
|
|
3677
|
-
continue;
|
|
3678
|
-
}
|
|
3679
|
-
if (entry.id) {
|
|
3680
|
-
print(` revoking existing trust (file=${entry.file ?? "?"}, id=${entry.id})`);
|
|
3681
|
-
const revoke = await exec(npm, [
|
|
3682
|
-
...npmArgs,
|
|
3683
|
-
"trust",
|
|
3684
|
-
"revoke",
|
|
3685
|
-
name,
|
|
3686
|
-
"--id",
|
|
3687
|
-
entry.id,
|
|
3688
|
-
"-y"
|
|
3689
|
-
], { cwd });
|
|
3690
|
-
if (revoke.exitCode !== 0 && isEotp(parseJson(revoke.stdout))) {
|
|
3691
|
-
const authUrl = parseJson(revoke.stdout).error?.authUrl ?? "";
|
|
3692
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3693
|
-
results.push({
|
|
3694
|
-
name,
|
|
3695
|
-
status: "needs-auth",
|
|
3696
|
-
message: authUrl
|
|
3697
|
-
});
|
|
3698
|
-
return {
|
|
3699
|
-
status: "fail",
|
|
3700
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3701
|
-
packages: results
|
|
3702
|
-
};
|
|
3703
|
-
}
|
|
3704
|
-
} else print(" no existing trust config found");
|
|
3705
|
-
print(` creating trust: ${input.newFile}`);
|
|
3706
|
-
const create = await exec(npm, [
|
|
3707
|
-
...npmArgs,
|
|
3708
|
-
"trust",
|
|
3709
|
-
"github",
|
|
3710
|
-
name,
|
|
3711
|
-
"--repo",
|
|
3712
|
-
repo,
|
|
3713
|
-
"--file",
|
|
3714
|
-
input.newFile,
|
|
3715
|
-
"--allow-publish",
|
|
3716
|
-
"--allow-stage-publish",
|
|
3717
|
-
"-y"
|
|
3718
|
-
], { cwd });
|
|
3719
|
-
if (create.exitCode === 0) {
|
|
3720
|
-
print(" ✓ ok");
|
|
3721
|
-
results.push({
|
|
3722
|
-
name,
|
|
3723
|
-
status: "ok"
|
|
3724
|
-
});
|
|
3725
|
-
} else {
|
|
3726
|
-
const createParsed = parseJson(create.stdout);
|
|
3727
|
-
if (isEotp(createParsed)) {
|
|
3728
|
-
const authUrl = createParsed.error?.authUrl ?? "";
|
|
3729
|
-
print(` needs fresh 2FA — open and approve: ${authUrl}`);
|
|
3730
|
-
results.push({
|
|
3731
|
-
name,
|
|
3732
|
-
status: "needs-auth",
|
|
3733
|
-
message: authUrl
|
|
3734
|
-
});
|
|
3735
|
-
return {
|
|
3736
|
-
status: "fail",
|
|
3737
|
-
message: "npm 2FA grant needed — see printed authUrl",
|
|
3738
|
-
packages: results
|
|
3739
|
-
};
|
|
3740
|
-
}
|
|
3741
|
-
const message = create.stderr.trim() || create.stdout.trim() || `exit ${create.exitCode}`;
|
|
3742
|
-
print(` ✗ FAILED: ${message}`);
|
|
3743
|
-
results.push({
|
|
3744
|
-
name,
|
|
3745
|
-
status: "fail",
|
|
3746
|
-
message
|
|
3747
|
-
});
|
|
3748
|
-
}
|
|
3997
|
+
function discoverManifests(cwd) {
|
|
3998
|
+
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
3999
|
+
const manifest = readPackageJsonSync(cwd);
|
|
4000
|
+
return manifest ? [{
|
|
4001
|
+
manifest,
|
|
4002
|
+
dir: "."
|
|
4003
|
+
}] : [];
|
|
3749
4004
|
}
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
}, "sync-trust: done");
|
|
3755
|
-
return {
|
|
3756
|
-
status: failed.length > 0 ? "fail" : "ok",
|
|
3757
|
-
...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
|
|
3758
|
-
packages: results
|
|
3759
|
-
};
|
|
4005
|
+
return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
|
|
4006
|
+
manifest,
|
|
4007
|
+
dir: `./${relativeDir}`
|
|
4008
|
+
}));
|
|
3760
4009
|
}
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
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");
|
|
3766
4027
|
}
|
|
3767
4028
|
}
|
|
3768
|
-
|
|
3769
|
-
|
|
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] : [];
|
|
3770
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
|
+
*/
|
|
3771
4084
|
async function runPublish(input = {}) {
|
|
3772
4085
|
const print = input.print ?? ((line) => console.log(line));
|
|
3773
4086
|
const logger = input.logger ?? getLogger();
|
|
@@ -3888,6 +4201,23 @@ async function runPublish(input = {}) {
|
|
|
3888
4201
|
packageNames
|
|
3889
4202
|
};
|
|
3890
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
|
|
3891
4221
|
/**
|
|
3892
4222
|
* `holocron package publish` (no `--initial`) — the steady-state publish
|
|
3893
4223
|
* `exec.publishCmd` in `release.config.ts` should call, replacing each
|
|
@@ -4031,129 +4361,222 @@ async function runSteadyPublish(input = {}) {
|
|
|
4031
4361
|
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
4032
4362
|
print(` pnpm exec holocron package publish --otp <6-digit-code>`);
|
|
4033
4363
|
}
|
|
4034
|
-
logger.warn({
|
|
4035
|
-
tag,
|
|
4036
|
-
reason: message
|
|
4037
|
-
}, "publish: done");
|
|
4038
|
-
return {
|
|
4039
|
-
status: "fail",
|
|
4040
|
-
message,
|
|
4041
|
-
packageNames
|
|
4042
|
-
};
|
|
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
|
+
}
|
|
4043
4566
|
}
|
|
4044
|
-
|
|
4567
|
+
const failed = results.filter((r) => r.status === "fail");
|
|
4045
4568
|
logger.info({
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
}, "publish: done");
|
|
4569
|
+
ok: results.filter((r) => r.status === "ok").length,
|
|
4570
|
+
failed: failed.length
|
|
4571
|
+
}, "sync-trust: done");
|
|
4050
4572
|
return {
|
|
4051
|
-
status: "ok",
|
|
4052
|
-
|
|
4573
|
+
status: failed.length > 0 ? "fail" : "ok",
|
|
4574
|
+
...failed.length > 0 ? { message: `${failed.length} package(s) failed` } : {},
|
|
4575
|
+
packages: results
|
|
4053
4576
|
};
|
|
4054
4577
|
}
|
|
4055
|
-
function hasPackagesDir(cwd) {
|
|
4056
|
-
return existsSync(join(cwd, "packages"));
|
|
4057
|
-
}
|
|
4058
|
-
/** `plugins/` — vendor capability providers, split from `packages/` as a sibling workspace root (#993). */
|
|
4059
|
-
function hasPluginsDir(cwd) {
|
|
4060
|
-
return existsSync(join(cwd, "plugins"));
|
|
4061
|
-
}
|
|
4062
|
-
/**
|
|
4063
|
-
* `-r --filter=./packages/* --filter=./plugins/*` — whichever workspace
|
|
4064
|
-
* roots exist. Empty (single-package repo, no `-r`) when neither does.
|
|
4065
|
-
* Shared by every `pnpm publish` invocation below so a repo with only
|
|
4066
|
-
* `plugins/*` (or both) is covered the same as `packages/*`-only.
|
|
4067
|
-
*/
|
|
4068
|
-
function workspaceFilterArgs(cwd) {
|
|
4069
|
-
const filters = [];
|
|
4070
|
-
if (hasPackagesDir(cwd)) filters.push("--filter=./packages/*");
|
|
4071
|
-
if (hasPluginsDir(cwd)) filters.push("--filter=./plugins/*");
|
|
4072
|
-
return filters.length > 0 ? ["-r", ...filters] : [];
|
|
4073
|
-
}
|
|
4074
|
-
/**
|
|
4075
|
-
* Monorepo (`packages/` and/or `plugins/` present): every workspace under
|
|
4076
|
-
* either. Single package (neither dir present): the root `package.json`.
|
|
4077
|
-
* Callers keep the public ones.
|
|
4078
|
-
*/
|
|
4079
|
-
function discoverManifests(cwd) {
|
|
4080
|
-
if (!hasPackagesDir(cwd) && !hasPluginsDir(cwd)) {
|
|
4081
|
-
const manifest = readPackageJsonSync(cwd);
|
|
4082
|
-
return manifest ? [{
|
|
4083
|
-
manifest,
|
|
4084
|
-
dir: "."
|
|
4085
|
-
}] : [];
|
|
4086
|
-
}
|
|
4087
|
-
return listWorkspacePackagesSync(cwd).map(({ manifest, relativeDir }) => ({
|
|
4088
|
-
manifest,
|
|
4089
|
-
dir: `./${relativeDir}`
|
|
4090
|
-
}));
|
|
4091
|
-
}
|
|
4092
|
-
/**
|
|
4093
|
-
* Every public package (`!private && name`) with its version and a
|
|
4094
|
-
* `pnpm --filter`-ready path — {@link runSteadyPublish}'s `skipAlreadyPublished`
|
|
4095
|
-
* mode needs both to check `npm view <pkg>@<version>` and publish one
|
|
4096
|
-
* workspace at a time.
|
|
4097
|
-
*/
|
|
4098
|
-
function discoverPublicPackageEntries(cwd) {
|
|
4099
|
-
return discoverManifests(cwd).flatMap(({ manifest, dir }) => !manifest.private && manifest.name && manifest.version ? [{
|
|
4100
|
-
name: manifest.name,
|
|
4101
|
-
version: manifest.version,
|
|
4102
|
-
dir
|
|
4103
|
-
}] : []);
|
|
4104
|
-
}
|
|
4105
|
-
/** The names of {@link discoverPublicPackageEntries}' packages, without requiring a version. */
|
|
4106
|
-
function discoverPublicPackages(cwd) {
|
|
4107
|
-
return discoverManifests(cwd).flatMap(({ manifest }) => !manifest.private && manifest.name ? [manifest.name] : []);
|
|
4108
|
-
}
|
|
4109
|
-
async function resolveRepoName(cwd, exec) {
|
|
4110
|
-
return (await readGitRemote(cwd, { exec: async (args, options) => {
|
|
4111
|
-
const result = await exec("git", args, options);
|
|
4112
|
-
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} exited ${result.exitCode}`);
|
|
4113
|
-
return result.stdout;
|
|
4114
|
-
} }))?.repo ?? "unknown";
|
|
4115
|
-
}
|
|
4116
|
-
function printNextSteps(print, env, packageNames, repoName) {
|
|
4117
|
-
print("");
|
|
4118
|
-
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
4119
|
-
for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
|
|
4120
|
-
print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: delivery.publish.yml`);
|
|
4121
|
-
if (env.get("NPM_TOKEN")) {
|
|
4122
|
-
print("");
|
|
4123
|
-
print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
|
|
4124
|
-
print(" https://www.npmjs.com/settings/~/tokens");
|
|
4125
|
-
}
|
|
4126
|
-
}
|
|
4127
|
-
const defaultExec$1 = async (cmd, args, opts) => {
|
|
4128
|
-
const result = spawnSync(cmd, args, {
|
|
4129
|
-
cwd: opts.cwd,
|
|
4130
|
-
encoding: "utf8",
|
|
4131
|
-
stdio: [
|
|
4132
|
-
"inherit",
|
|
4133
|
-
"pipe",
|
|
4134
|
-
"pipe"
|
|
4135
|
-
]
|
|
4136
|
-
});
|
|
4137
|
-
return {
|
|
4138
|
-
exitCode: result.status ?? -1,
|
|
4139
|
-
stdout: result.stdout ?? "",
|
|
4140
|
-
stderr: result.stderr ?? ""
|
|
4141
|
-
};
|
|
4142
|
-
};
|
|
4143
|
-
/**
|
|
4144
|
-
* Fully interactive — `npm login --auth-type=web` prints a URL and waits for
|
|
4145
|
-
* the browser flow to complete; the operator needs to see that live, so all
|
|
4146
|
-
* three stdio streams are inherited (unlike `defaultExec`, which pipes
|
|
4147
|
-
* stdout/stderr for capture).
|
|
4148
|
-
*/
|
|
4149
|
-
const defaultLogin = async (cwd) => {
|
|
4150
|
-
return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
|
|
4151
|
-
cwd,
|
|
4152
|
-
stdio: "inherit"
|
|
4153
|
-
}).status ?? -1 };
|
|
4154
|
-
};
|
|
4155
4578
|
//#endregion
|
|
4156
|
-
//#region src/commands/secret-set.ts
|
|
4579
|
+
//#region src/commands/secret-set/secret-set.ts
|
|
4157
4580
|
/**
|
|
4158
4581
|
* `holocron secrets set` — one-shot single-secret push.
|
|
4159
4582
|
*
|
|
@@ -4245,7 +4668,7 @@ function describeScope(scope) {
|
|
|
4245
4668
|
return `scope=organization:${scope.name}`;
|
|
4246
4669
|
}
|
|
4247
4670
|
//#endregion
|
|
4248
|
-
//#region src/commands/secrets-sync.ts
|
|
4671
|
+
//#region src/commands/secrets-sync/secrets-sync.ts
|
|
4249
4672
|
/**
|
|
4250
4673
|
* `holocron secrets sync` — vault → destinations.
|
|
4251
4674
|
*
|
|
@@ -4410,7 +4833,7 @@ function vaultProviderName(loader) {
|
|
|
4410
4833
|
return loader.get("vault").providerName;
|
|
4411
4834
|
}
|
|
4412
4835
|
//#endregion
|
|
4413
|
-
//#region src/commands/setup/derived-properties.ts
|
|
4836
|
+
//#region src/commands/setup/derived-properties/derived-properties.ts
|
|
4414
4837
|
/**
|
|
4415
4838
|
* Repo archetype, in priority order:
|
|
4416
4839
|
* 1. `*-template` repo name → `template` (independent of package shape).
|
|
@@ -4604,221 +5027,9 @@ const STALE_LABELS = [
|
|
|
4604
5027
|
"released on @alpha"
|
|
4605
5028
|
];
|
|
4606
5029
|
//#endregion
|
|
4607
|
-
//#region src/templates/config.yml
|
|
4608
|
-
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";
|
|
4609
|
-
//#endregion
|
|
4610
|
-
//#region src/utils/create-header.ts
|
|
4611
|
-
function createHeader(options) {
|
|
4612
|
-
const { source, tool = "holocron repo setup", forPrimary = false } = options;
|
|
4613
|
-
const doNotEdit = forPrimary ? `AUTO-GENERATED — do not edit in theholocron/.github directly.` : `AUTO-GENERATED — do not edit directly.`;
|
|
4614
|
-
return {
|
|
4615
|
-
workflowHeader(format = "yaml") {
|
|
4616
|
-
if (format === "cjs") return [
|
|
4617
|
-
`/* ${doNotEdit}`,
|
|
4618
|
-
` * Source: theholocron/holocron · ${source}`,
|
|
4619
|
-
` * Tool: ${tool}`,
|
|
4620
|
-
` * Changes: edit source in theholocron/holocron`,
|
|
4621
|
-
` */`,
|
|
4622
|
-
``
|
|
4623
|
-
].join("\n");
|
|
4624
|
-
const yamlLines = [
|
|
4625
|
-
`# ${doNotEdit}`,
|
|
4626
|
-
`# Source: theholocron/holocron · ${source}`,
|
|
4627
|
-
`# Tool: ${tool}`,
|
|
4628
|
-
`# Changes: edit source in theholocron/holocron`,
|
|
4629
|
-
``
|
|
4630
|
-
].join("\n");
|
|
4631
|
-
if (format === "shebang") return `#!/bin/sh\n\n${yamlLines}`;
|
|
4632
|
-
return yamlLines;
|
|
4633
|
-
},
|
|
4634
|
-
scaffoldHeader() {
|
|
4635
|
-
return [
|
|
4636
|
-
`# Scaffolded by holocron repo setup — edit this file freely.`,
|
|
4637
|
-
`# Source: theholocron/holocron · ${source}`,
|
|
4638
|
-
``
|
|
4639
|
-
].join("\n");
|
|
4640
|
-
}
|
|
4641
|
-
};
|
|
4642
|
-
}
|
|
4643
|
-
//#endregion
|
|
4644
|
-
//#region src/templates/configs/alexjs/alexignore
|
|
4645
|
-
var alexignore_default = ".github/*\nCHANGELOG.md\nLICENSE\n";
|
|
4646
|
-
//#endregion
|
|
4647
|
-
//#region src/templates/configs/alexjs/alexrc.json
|
|
4648
|
-
var alexrc_default = { allow: [
|
|
4649
|
-
"dead",
|
|
4650
|
-
"execute",
|
|
4651
|
-
"execution",
|
|
4652
|
-
"executes",
|
|
4653
|
-
"failure",
|
|
4654
|
-
"failures",
|
|
4655
|
-
"hook",
|
|
4656
|
-
"hooks",
|
|
4657
|
-
"husky",
|
|
4658
|
-
"period"
|
|
4659
|
-
] };
|
|
4660
|
-
//#endregion
|
|
4661
|
-
//#region src/templates/configs/alexjs/create-config.ts
|
|
4662
|
-
const { workflowHeader: workflowHeader$9 } = createHeader({ source: "packages/cli/src/templates/configs/alexjs/create-config.ts" });
|
|
4663
|
-
function createRcConfig() {
|
|
4664
|
-
return stringifyJson(alexrc_default);
|
|
4665
|
-
}
|
|
4666
|
-
function createIgnoreConfig$1() {
|
|
4667
|
-
return `${workflowHeader$9()}${alexignore_default}`;
|
|
4668
|
-
}
|
|
4669
|
-
//#endregion
|
|
4670
|
-
//#region src/templates/configs/commit-msg/commit-msg
|
|
4671
|
-
var commit_msg_default = "__HOLOCRON_SCRIPT__ run lint commit-msg \"$1\"\n";
|
|
4672
|
-
//#endregion
|
|
4673
|
-
//#region src/templates/configs/commit-msg/create-config.ts
|
|
4674
|
-
const { workflowHeader: workflowHeader$8 } = createHeader({ source: "packages/cli/src/templates/configs/commit-msg/create-config.ts" });
|
|
4675
|
-
/**
|
|
4676
|
-
* `.husky/commit-msg` — lints the commit message via `holocron run lint
|
|
4677
|
-
* commit-msg` (holocron#789), the real programmatic replacement for
|
|
4678
|
-
* shelling out to `commitlint --edit`. Installed by `holocron repo setup` when
|
|
4679
|
-
* hooks are enabled. Same `__HOLOCRON_SCRIPT__` substitution as `pre-push`.
|
|
4680
|
-
*
|
|
4681
|
-
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4682
|
-
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4683
|
-
*/
|
|
4684
|
-
function createConfig$7(holocronScript = "pnpm exec holocron") {
|
|
4685
|
-
return `${workflowHeader$8("shebang")}${commit_msg_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4686
|
-
}
|
|
4687
|
-
//#endregion
|
|
4688
|
-
//#region src/templates/configs/devmoji/create-config.ts
|
|
4689
|
-
const { workflowHeader: workflowHeader$7 } = createHeader({ source: "packages/cli/src/templates/configs/devmoji/create-config.ts" });
|
|
4690
|
-
function createConfig$6() {
|
|
4691
|
-
return [
|
|
4692
|
-
workflowHeader$7("cjs"),
|
|
4693
|
-
`/* eslint-disable */`,
|
|
4694
|
-
`const { defineConfig } = require("@theholocron/devmoji-config");`,
|
|
4695
|
-
`module.exports = defineConfig();`,
|
|
4696
|
-
``
|
|
4697
|
-
].join("\n");
|
|
4698
|
-
}
|
|
4699
|
-
//#endregion
|
|
4700
|
-
//#region src/templates/configs/editorconfig/editorconfig
|
|
4701
|
-
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";
|
|
4702
|
-
//#endregion
|
|
4703
|
-
//#region src/templates/configs/editorconfig/create-config.ts
|
|
4704
|
-
const { workflowHeader: workflowHeader$6 } = createHeader({ source: "packages/cli/src/templates/configs/editorconfig/create-config.ts" });
|
|
4705
|
-
function createConfig$5() {
|
|
4706
|
-
return `${workflowHeader$6()}${editorconfig_default}`;
|
|
4707
|
-
}
|
|
4708
|
-
//#endregion
|
|
4709
|
-
//#region src/templates/configs/editorconfig-checker/editorconfig-checker.json
|
|
4710
|
-
var editorconfig_checker_default = {
|
|
4711
|
-
Verbose: false,
|
|
4712
|
-
Format: "",
|
|
4713
|
-
Debug: false,
|
|
4714
|
-
IgnoreDefaults: false,
|
|
4715
|
-
SpacesAfterTabs: false,
|
|
4716
|
-
NoColor: false,
|
|
4717
|
-
Exclude: [
|
|
4718
|
-
"(^|.+/)LICENSE$",
|
|
4719
|
-
"^public/.*",
|
|
4720
|
-
"\\.md$",
|
|
4721
|
-
"\\.mdx$"
|
|
4722
|
-
],
|
|
4723
|
-
AllowedContentTypes: [],
|
|
4724
|
-
PassedFiles: [],
|
|
4725
|
-
Disable: {
|
|
4726
|
-
"EndOfLine": false,
|
|
4727
|
-
"Indentation": false,
|
|
4728
|
-
"InsertFinalNewline": false,
|
|
4729
|
-
"TrimTrailingWhitespace": false,
|
|
4730
|
-
"IndentSize": false,
|
|
4731
|
-
"MaxLineLength": false
|
|
4732
|
-
}
|
|
4733
|
-
};
|
|
4734
|
-
//#endregion
|
|
4735
|
-
//#region src/templates/configs/editorconfig-checker/create-config.ts
|
|
4736
|
-
/**
|
|
4737
|
-
* The generated `.editorconfig-checker.json`.
|
|
4738
|
-
*
|
|
4739
|
-
* Deliberately carries no `"Version"` key: editorconfig-checker treats it as a
|
|
4740
|
-
* hard gate and exits non-zero the moment the running binary's version differs
|
|
4741
|
-
* — which it does the instant a contributor's local install (Homebrew tracks
|
|
4742
|
-
* latest) drifts from the version super-linter bundles in CI, breaking
|
|
4743
|
-
* `holocron run lint` / the pre-push hook for everyone (#618). The config
|
|
4744
|
-
* options here (`Exclude`, `Disable`) are stable across releases, so pinning
|
|
4745
|
-
* buys nothing.
|
|
4746
|
-
*/
|
|
4747
|
-
function createConfig$4() {
|
|
4748
|
-
return stringifyJson(editorconfig_checker_default);
|
|
4749
|
-
}
|
|
4750
|
-
//#endregion
|
|
4751
|
-
//#region src/templates/configs/pre-commit/pre-commit
|
|
4752
|
-
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";
|
|
4753
|
-
//#endregion
|
|
4754
|
-
//#region src/templates/configs/pre-commit/create-config.ts
|
|
4755
|
-
const { workflowHeader: workflowHeader$5 } = createHeader({ source: "packages/cli/src/templates/configs/pre-commit/create-config.ts" });
|
|
4756
|
-
/**
|
|
4757
|
-
* `.husky/pre-commit` — runs GitLeaks (scoped to staged changes) and
|
|
4758
|
-
* lint-staged before every commit. Installed by `holocron repo setup` when hooks
|
|
4759
|
-
* are enabled (default for `protection: "strict"`). `git commit --no-verify`
|
|
4760
|
-
* bypasses it.
|
|
4761
|
-
*
|
|
4762
|
-
* `gitleaks protect --staged` (not `gitleaks git`) is deliberate: the latter
|
|
4763
|
-
* scans the entire commit history on every invocation, so once any false
|
|
4764
|
-
* positive lands in a past commit — a fake test token, an example key in a
|
|
4765
|
-
* doc — every future commit is permanently blocked, regardless of what's
|
|
4766
|
-
* actually staged.
|
|
4767
|
-
*/
|
|
4768
|
-
function createConfig$3() {
|
|
4769
|
-
return `${workflowHeader$5("shebang")}${pre_commit_default}`;
|
|
4770
|
-
}
|
|
4771
|
-
//#endregion
|
|
4772
|
-
//#region src/templates/configs/pre-push/pre-push
|
|
4773
|
-
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";
|
|
4774
|
-
//#endregion
|
|
4775
|
-
//#region src/templates/configs/pre-push/create-config.ts
|
|
4776
|
-
const { workflowHeader: workflowHeader$4 } = createHeader({ source: "packages/cli/src/templates/configs/pre-push/create-config.ts" });
|
|
4777
|
-
/**
|
|
4778
|
-
* `.husky/pre-push` — runs `holocron run ci` (the merge-gating checks, in CI order)
|
|
4779
|
-
* before every push. Installed by `holocron repo setup` when hooks are enabled
|
|
4780
|
-
* (default for `protection: "strict"`). `git push --no-verify` bypasses it.
|
|
4781
|
-
*
|
|
4782
|
-
* @param holocronScript - the command that invokes the CLI. Defaults to
|
|
4783
|
-
* `pnpm exec holocron`; the source repo passes `node packages/cli/dist/cli.mjs`.
|
|
4784
|
-
*/
|
|
4785
|
-
function createConfig$2(holocronScript = "pnpm exec holocron") {
|
|
4786
|
-
return `${workflowHeader$4("shebang")}${pre_push_default.replace("__HOLOCRON_SCRIPT__", holocronScript)}`;
|
|
4787
|
-
}
|
|
4788
|
-
//#endregion
|
|
4789
|
-
//#region src/templates/configs/prepare-commit-msg/prepare-commit-msg
|
|
4790
|
-
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";
|
|
4791
|
-
//#endregion
|
|
4792
|
-
//#region src/templates/configs/prepare-commit-msg/create-config.ts
|
|
4793
|
-
const { workflowHeader: workflowHeader$3 } = createHeader({ source: "packages/cli/src/templates/configs/prepare-commit-msg/create-config.ts" });
|
|
4794
|
-
function createConfig$1() {
|
|
4795
|
-
return `${workflowHeader$3("shebang")}${prepare_commit_msg_default}`;
|
|
4796
|
-
}
|
|
4797
|
-
//#endregion
|
|
4798
|
-
//#region src/templates/configs/yamllint/.yamlignore
|
|
4799
|
-
var _yamlignore_default = "pnpm-lock.yaml\n";
|
|
4800
|
-
//#endregion
|
|
4801
|
-
//#region src/templates/configs/yamllint/.yamllint.yml
|
|
4802
|
-
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";
|
|
4803
|
-
//#endregion
|
|
4804
|
-
//#region src/templates/configs/yamllint/create-config.ts
|
|
4805
|
-
const { workflowHeader: workflowHeader$2 } = createHeader({ source: "packages/cli/src/templates/configs/yamllint/create-config.ts" });
|
|
4806
|
-
function createConfig() {
|
|
4807
|
-
return `${workflowHeader$2()}${_yamllint_default}`;
|
|
4808
|
-
}
|
|
4809
|
-
function createIgnoreConfig() {
|
|
4810
|
-
return `${workflowHeader$2()}${_yamlignore_default}`;
|
|
4811
|
-
}
|
|
4812
|
-
//#endregion
|
|
4813
5030
|
//#region src/templates/dco.yml
|
|
4814
5031
|
var dco_default = "allowRemediationCommits:\n individual: true\n";
|
|
4815
5032
|
//#endregion
|
|
4816
|
-
//#region src/templates/dependabot.yml
|
|
4817
|
-
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";
|
|
4818
|
-
//#endregion
|
|
4819
|
-
//#region src/templates/labeler.yml
|
|
4820
|
-
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";
|
|
4821
|
-
//#endregion
|
|
4822
5033
|
//#region src/commands/setup/agent-prompts-data.ts
|
|
4823
5034
|
/**
|
|
4824
5035
|
* Canonical AI engineering workflow role prompts.
|
|
@@ -4829,7 +5040,6 @@ var labeler_default = "bug:\n - \"^fix\"\n\nchore:\n - '^chore(?!\\(deps)'\n\n
|
|
|
4829
5040
|
*
|
|
4830
5041
|
* Source: docs/wiki/specifications/ai-engineering-workflow.spec.md
|
|
4831
5042
|
*/
|
|
4832
|
-
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";
|
|
4833
5043
|
const AGENT_PROMPTS = {
|
|
4834
5044
|
"discovery.md": `# Discovery Agent
|
|
4835
5045
|
|
|
@@ -4958,10 +5168,14 @@ confidently, identify the missing information instead of inventing it.
|
|
|
4958
5168
|
|
|
4959
5169
|
## Format
|
|
4960
5170
|
|
|
4961
|
-
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.
|
|
4962
5176
|
|
|
4963
5177
|
\`\`\`sh
|
|
4964
|
-
cp
|
|
5178
|
+
cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
|
|
4965
5179
|
\`\`\`
|
|
4966
5180
|
|
|
4967
5181
|
Save to \`docs/wiki/decisions/NNNN-<slug>.md\`.
|
|
@@ -5168,10 +5382,13 @@ decision, the options that were considered, and the rationale for the choice mad
|
|
|
5168
5382
|
|
|
5169
5383
|
## Creating a new decision
|
|
5170
5384
|
|
|
5171
|
-
|
|
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:
|
|
5172
5389
|
|
|
5173
5390
|
\`\`\`sh
|
|
5174
|
-
cp
|
|
5391
|
+
cp .agents/skills/adr/assets/template.md docs/wiki/decisions/000N-short-title.md
|
|
5175
5392
|
\`\`\`
|
|
5176
5393
|
|
|
5177
5394
|
\`madr\` has no CLI — creation is manual. Frontmatter is validated in CI
|
|
@@ -5353,7 +5570,6 @@ async function writeIfAbsent(filePath, content) {
|
|
|
5353
5570
|
async function installEngineeringStructure({ repoRoot }) {
|
|
5354
5571
|
const results = [];
|
|
5355
5572
|
const writes = [
|
|
5356
|
-
[join(repoRoot, "docs/wiki/decisions/template.md"), DECISIONS_TEMPLATE],
|
|
5357
5573
|
[join(repoRoot, "docs/wiki/decisions/README.md"), DECISIONS_README],
|
|
5358
5574
|
[join(repoRoot, "docs/wiki/standards/README.md"), STANDARDS_README],
|
|
5359
5575
|
[join(repoRoot, "docs/wiki/specifications/README.md"), SPECIFICATIONS_README]
|
|
@@ -5376,7 +5592,7 @@ const BALANCED_REPO_SETTINGS = {
|
|
|
5376
5592
|
has_wiki: false
|
|
5377
5593
|
};
|
|
5378
5594
|
//#endregion
|
|
5379
|
-
//#region src/commands/setup/run-step.ts
|
|
5595
|
+
//#region src/commands/setup/run-step/run-step.ts
|
|
5380
5596
|
async function runStep(capability, step, dryRun, body, opts = {}) {
|
|
5381
5597
|
const result = await execStep(capability, step, dryRun, body, opts);
|
|
5382
5598
|
const { status, message, reason } = result;
|
|
@@ -5447,7 +5663,7 @@ function formatStep(step) {
|
|
|
5447
5663
|
return ` ${style.dim(`· ${label}`)}`;
|
|
5448
5664
|
}
|
|
5449
5665
|
//#endregion
|
|
5450
|
-
//#region src/commands/setup/skills.ts
|
|
5666
|
+
//#region src/commands/setup/skills/skills.ts
|
|
5451
5667
|
async function fetchExternalSkill(entry) {
|
|
5452
5668
|
if (entry.sourceType !== "github")
|
|
5453
5669
|
/* c8 ignore next */
|
|
@@ -5724,14 +5940,14 @@ async function runSetup(input) {
|
|
|
5724
5940
|
if (loader.has("source") && (config.tasks ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
|
|
5725
5941
|
const source = loader.get("source");
|
|
5726
5942
|
steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
|
|
5727
|
-
await source.writeRepoFile(".github/labeler.yml",
|
|
5943
|
+
await source.writeRepoFile(".github/labeler.yml", createLabeler());
|
|
5728
5944
|
}));
|
|
5729
5945
|
print(formatStep(steps[steps.length - 1]));
|
|
5730
5946
|
}
|
|
5731
5947
|
if (loader.has("source") && effectivePreset !== "none") {
|
|
5732
5948
|
const source = loader.get("source");
|
|
5733
5949
|
steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
|
|
5734
|
-
await source.writeRepoFile(".github/dependabot.yml",
|
|
5950
|
+
await source.writeRepoFile(".github/dependabot.yml", createDependabot());
|
|
5735
5951
|
}));
|
|
5736
5952
|
print(formatStep(steps[steps.length - 1]));
|
|
5737
5953
|
steps.push(await runStep("source", "write .github/dco.yml", dryRun, async () => {
|
|
@@ -5741,54 +5957,50 @@ async function runSetup(input) {
|
|
|
5741
5957
|
}
|
|
5742
5958
|
if (loader.has("source")) {
|
|
5743
5959
|
const source = loader.get("source");
|
|
5744
|
-
steps.push(await runStep("source", "write .github/config.yml", dryRun, async () => {
|
|
5745
|
-
await source.writeRepoFile(".github/config.yml", `${workflowHeader$1()}${config_default}`);
|
|
5746
|
-
}));
|
|
5747
|
-
print(formatStep(steps[steps.length - 1]));
|
|
5748
5960
|
steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
|
|
5749
|
-
await source.writeRepoFile(".alexrc.json",
|
|
5961
|
+
await source.writeRepoFile(".alexrc.json", createAlexrc());
|
|
5750
5962
|
}));
|
|
5751
5963
|
print(formatStep(steps[steps.length - 1]));
|
|
5752
5964
|
steps.push(await runStep("source", "write .alexignore", dryRun, async () => {
|
|
5753
|
-
await source.writeRepoFile(".alexignore",
|
|
5965
|
+
await source.writeRepoFile(".alexignore", createAlexignore());
|
|
5754
5966
|
}));
|
|
5755
5967
|
print(formatStep(steps[steps.length - 1]));
|
|
5756
5968
|
steps.push(await runStep("source", "write .yamllint.yml", dryRun, async () => {
|
|
5757
|
-
await source.writeRepoFile(".yamllint.yml",
|
|
5969
|
+
await source.writeRepoFile(".yamllint.yml", createYamllint());
|
|
5758
5970
|
}));
|
|
5759
5971
|
print(formatStep(steps[steps.length - 1]));
|
|
5760
5972
|
steps.push(await runStep("source", "write .yamlignore", dryRun, async () => {
|
|
5761
|
-
await source.writeRepoFile(".yamlignore",
|
|
5973
|
+
await source.writeRepoFile(".yamlignore", createYamlignore());
|
|
5762
5974
|
}));
|
|
5763
5975
|
print(formatStep(steps[steps.length - 1]));
|
|
5764
5976
|
steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
|
|
5765
|
-
await source.writeRepoFile(".editorconfig",
|
|
5977
|
+
await source.writeRepoFile(".editorconfig", createEditorconfig());
|
|
5766
5978
|
}));
|
|
5767
5979
|
print(formatStep(steps[steps.length - 1]));
|
|
5768
5980
|
steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
|
|
5769
|
-
await source.writeRepoFile(".editorconfig-checker.json",
|
|
5981
|
+
await source.writeRepoFile(".editorconfig-checker.json", createEditorconfigChecker());
|
|
5770
5982
|
}));
|
|
5771
5983
|
print(formatStep(steps[steps.length - 1]));
|
|
5772
5984
|
steps.push(await runStep("source", "write devmoji.config.cjs", dryRun, async () => {
|
|
5773
|
-
await source.writeRepoFile("devmoji.config.cjs",
|
|
5985
|
+
await source.writeRepoFile("devmoji.config.cjs", createDevmoji());
|
|
5774
5986
|
}));
|
|
5775
5987
|
print(formatStep(steps[steps.length - 1]));
|
|
5776
5988
|
steps.push(await runStep("source", "write .husky/prepare-commit-msg", dryRun, async () => {
|
|
5777
|
-
await source.writeRepoFile(".husky/prepare-commit-msg",
|
|
5989
|
+
await source.writeRepoFile(".husky/prepare-commit-msg", createPrepareCommitMsg());
|
|
5778
5990
|
}));
|
|
5779
5991
|
print(formatStep(steps[steps.length - 1]));
|
|
5780
5992
|
steps.push(await runStep("source", "write .husky/commit-msg", dryRun, async () => {
|
|
5781
|
-
await source.writeRepoFile(".husky/commit-msg",
|
|
5993
|
+
await source.writeRepoFile(".husky/commit-msg", createCommitMsg(config.holocronScript));
|
|
5782
5994
|
}));
|
|
5783
5995
|
print(formatStep(steps[steps.length - 1]));
|
|
5784
5996
|
if (hooksEnabled) {
|
|
5785
5997
|
steps.push(await runStep("source", "write .husky/pre-push", dryRun, async () => {
|
|
5786
|
-
await source.writeRepoFile(".husky/pre-push",
|
|
5998
|
+
await source.writeRepoFile(".husky/pre-push", createPrePush(config.holocronScript));
|
|
5787
5999
|
return "runs `holocron run ci` before push — bypass with `git push --no-verify`";
|
|
5788
6000
|
}));
|
|
5789
6001
|
print(formatStep(steps[steps.length - 1]));
|
|
5790
6002
|
steps.push(await runStep("source", "write .husky/pre-commit", dryRun, async () => {
|
|
5791
|
-
await source.writeRepoFile(".husky/pre-commit",
|
|
6003
|
+
await source.writeRepoFile(".husky/pre-commit", createPreCommit());
|
|
5792
6004
|
return "runs GitLeaks (staged-only) + lint-staged before commit";
|
|
5793
6005
|
}));
|
|
5794
6006
|
print(formatStep(steps[steps.length - 1]));
|
|
@@ -6304,7 +6516,7 @@ async function ensurePrepareScript(repoRoot) {
|
|
|
6304
6516
|
return true;
|
|
6305
6517
|
}
|
|
6306
6518
|
//#endregion
|
|
6307
|
-
//#region src/commands/skills.ts
|
|
6519
|
+
//#region src/commands/skills/skills.ts
|
|
6308
6520
|
/**
|
|
6309
6521
|
* `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
|
|
6310
6522
|
*
|
|
@@ -6403,7 +6615,7 @@ function runSkillsUpdate(input) {
|
|
|
6403
6615
|
return { status: exitCode === 0 ? "ok" : "fail" };
|
|
6404
6616
|
}
|
|
6405
6617
|
//#endregion
|
|
6406
|
-
//#region src/commands/sync-readme.ts
|
|
6618
|
+
//#region src/commands/sync-readme/sync-readme.ts
|
|
6407
6619
|
const MARKER_INSTALL_START = "<!-- holocron:installation -->";
|
|
6408
6620
|
const MARKER_INSTALL_END = "<!-- /holocron:installation -->";
|
|
6409
6621
|
const REGISTRY_MAP = {
|
|
@@ -6559,9 +6771,13 @@ async function runSyncReadme(input) {
|
|
|
6559
6771
|
};
|
|
6560
6772
|
}
|
|
6561
6773
|
//#endregion
|
|
6562
|
-
//#region src/commands/sync-wiki.ts
|
|
6774
|
+
//#region src/commands/sync-wiki/sync-wiki.ts
|
|
6563
6775
|
function resolveToken(input) {
|
|
6564
|
-
return input.token ?? input.context.cliToken ??
|
|
6776
|
+
return input.token ?? input.context.cliToken ?? firstEnv(env, [
|
|
6777
|
+
"HOLOCRON_READ_TOKEN",
|
|
6778
|
+
"GH_TOKEN",
|
|
6779
|
+
"GITHUB_TOKEN"
|
|
6780
|
+
]);
|
|
6565
6781
|
}
|
|
6566
6782
|
function deriveBasepath(domain, repoName) {
|
|
6567
6783
|
if (domain) {
|
|
@@ -6772,7 +6988,7 @@ async function runSyncWiki(input) {
|
|
|
6772
6988
|
}
|
|
6773
6989
|
}
|
|
6774
6990
|
//#endregion
|
|
6775
|
-
//#region src/commands/sync.ts
|
|
6991
|
+
//#region src/commands/sync/sync.ts
|
|
6776
6992
|
const { workflowHeader } = createHeader({
|
|
6777
6993
|
source: "packages/astromech/src/thin-callers.ts",
|
|
6778
6994
|
tool: "holocron repo sync"
|
|
@@ -7360,7 +7576,7 @@ async function writeWorkflowFile(repoRoot, filename, content) {
|
|
|
7360
7576
|
await rename(tmpPath, join(dir, filename));
|
|
7361
7577
|
}
|
|
7362
7578
|
//#endregion
|
|
7363
|
-
//#region src/telemetry/resolve.ts
|
|
7579
|
+
//#region src/observability/resolve-telemetry/resolve-telemetry.ts
|
|
7364
7580
|
/**
|
|
7365
7581
|
* Holocron-specific credential resolution for the telemetry sinks — the env
|
|
7366
7582
|
* chain plus Holocron's own shipped fallbacks. This is **CLI policy**, not part
|
|
@@ -7384,18 +7600,18 @@ const FALLBACK_POSTHOG_PROJECT_TOKEN = "phc_AC4vFCvYzwnzmG7Vg5nEc3PZKZztoPyfKp4L
|
|
|
7384
7600
|
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
7385
7601
|
/** Resolve the Sentry DSN. `""` → error telemetry disabled. */
|
|
7386
7602
|
function resolveDsn() {
|
|
7387
|
-
return
|
|
7603
|
+
return holocronEnv.get("SENTRY_DSN") ?? FALLBACK_DSN;
|
|
7388
7604
|
}
|
|
7389
7605
|
/** Resolve the PostHog project key. `""` → usage analytics disabled. */
|
|
7390
7606
|
function resolvePostHogKey() {
|
|
7391
|
-
return
|
|
7607
|
+
return holocronEnv.get("POSTHOG_PROJECT_TOKEN") ?? FALLBACK_POSTHOG_PROJECT_TOKEN;
|
|
7392
7608
|
}
|
|
7393
7609
|
/** Resolve the PostHog ingest host. */
|
|
7394
7610
|
function resolvePostHogHost() {
|
|
7395
|
-
return
|
|
7611
|
+
return holocronEnv.get("POSTHOG_HOST") ?? DEFAULT_POSTHOG_HOST;
|
|
7396
7612
|
}
|
|
7397
7613
|
//#endregion
|
|
7398
|
-
//#region src/telemetry.ts
|
|
7614
|
+
//#region src/observability/telemetry/telemetry.ts
|
|
7399
7615
|
/**
|
|
7400
7616
|
* CLI self-telemetry orchestration — errors (Sentry), usage analytics (PostHog).
|
|
7401
7617
|
*
|
|
@@ -7406,7 +7622,7 @@ function resolvePostHogHost() {
|
|
|
7406
7622
|
* installed when telemetry is off. Same seam the package's `Logger` uses for
|
|
7407
7623
|
* Pino.
|
|
7408
7624
|
*
|
|
7409
|
-
* Activation is env-var + shipped-fallback, resolved here (`telemetry
|
|
7625
|
+
* Activation is env-var + shipped-fallback, resolved here (`resolve-telemetry.ts`)
|
|
7410
7626
|
* and passed to the sink — the adapters read no environment. The single kill
|
|
7411
7627
|
* switch is `HOLOCRON_TELEMETRY=false` (legacy alias `NO_HOLOCRON_TELEMETRY`);
|
|
7412
7628
|
* see ADR-0007 / ADR-0008.
|
|
@@ -7539,7 +7755,7 @@ async function flush() {
|
|
|
7539
7755
|
analytics = new NoopAnalyticsSink();
|
|
7540
7756
|
}
|
|
7541
7757
|
//#endregion
|
|
7542
|
-
//#region src/commands/sync-github.ts
|
|
7758
|
+
//#region src/commands/sync-github/sync-github.ts
|
|
7543
7759
|
const DEFAULT_REPO = "theholocron/.github";
|
|
7544
7760
|
/**
|
|
7545
7761
|
* The file set `sync-github` pushes to `theholocron/.github` — the reusable
|
|
@@ -7779,7 +7995,7 @@ async function runSyncGithub(input) {
|
|
|
7779
7995
|
});
|
|
7780
7996
|
}
|
|
7781
7997
|
//#endregion
|
|
7782
|
-
//#region src/commands/upgrade-deps.ts
|
|
7998
|
+
//#region src/commands/upgrade-deps/upgrade-deps.ts
|
|
7783
7999
|
/**
|
|
7784
8000
|
* `holocron repo upgrade deps` — bring a repo's `@theholocron/*` dependency pins and
|
|
7785
8001
|
* its `holocron.config.ts` up to the current major.
|
|
@@ -8007,7 +8223,7 @@ async function runUpgradeDeps(input = {}) {
|
|
|
8007
8223
|
};
|
|
8008
8224
|
}
|
|
8009
8225
|
//#endregion
|
|
8010
|
-
//#region src/commands/upgrade-node.ts
|
|
8226
|
+
//#region src/commands/upgrade-node/upgrade-node.ts
|
|
8011
8227
|
const SKIP_DIRS = [
|
|
8012
8228
|
"node_modules",
|
|
8013
8229
|
".git",
|
|
@@ -8185,268 +8401,45 @@ async function runUpgradeNode(input) {
|
|
|
8185
8401
|
to,
|
|
8186
8402
|
updated: updated.length,
|
|
8187
8403
|
status
|
|
8188
|
-
}, "upgrade node: done");
|
|
8189
|
-
return {
|
|
8190
|
-
status,
|
|
8191
|
-
updated
|
|
8192
|
-
};
|
|
8193
|
-
}
|
|
8194
|
-
//#endregion
|
|
8195
|
-
//#region src/commit-lint/lint-message.ts
|
|
8196
|
-
/**
|
|
8197
|
-
* Lints one commit message string against an already-loaded commitlint
|
|
8198
|
-
* config. The genuinely environment-independent half of commit-message
|
|
8199
|
-
* linting (holocron#789) — *obtaining* a `QualifiedConfig` differs by
|
|
8200
|
-
* caller (Sentinel isolates itself from any ambient config since it runs
|
|
8201
|
-
* standalone, away from a real repo checkout; `lintCommitMsgFile` below
|
|
8202
|
-
* just lets cosmiconfig discover the invoking repo's own
|
|
8203
|
-
* `commitlint.config.*`, matching `commitlint --edit`'s own contract) —
|
|
8204
|
-
* but linting one message against a loaded config is identical either way.
|
|
8205
|
-
*
|
|
8206
|
-
* Real `@commitlint/lint` — the same programmatic API `@commitlint/cli`
|
|
8207
|
-
* itself uses internally — never a reimplementation of commitlint's rules.
|
|
8208
|
-
*/
|
|
8209
|
-
/** `parserPreset.parserOpts`, if the loaded config sets one — same lookup `@commitlint/cli`'s own `selectParserOpts()` does. */
|
|
8210
|
-
function selectParserOpts(parserPreset) {
|
|
8211
|
-
return parserPreset?.parserOpts;
|
|
8212
|
-
}
|
|
8213
|
-
async function lintCommitMessage(message, loaded) {
|
|
8214
|
-
const opts = {
|
|
8215
|
-
/* istanbul ignore next -- see comment above */
|
|
8216
|
-
parserOpts: selectParserOpts(loaded.parserPreset) ?? {},
|
|
8217
|
-
plugins: loaded.plugins,
|
|
8218
|
-
/* istanbul ignore next -- see comment above */
|
|
8219
|
-
ignores: loaded.ignores ?? [],
|
|
8220
|
-
defaultIgnores: loaded.defaultIgnores !== false
|
|
8221
|
-
};
|
|
8222
|
-
const outcome = await lint(message, loaded.rules, opts);
|
|
8223
|
-
if (outcome.valid) return [];
|
|
8224
|
-
return outcome.errors.map((error) => ({
|
|
8225
|
-
rule: error.name,
|
|
8226
|
-
message: error.message
|
|
8227
|
-
}));
|
|
8228
|
-
}
|
|
8229
|
-
//#endregion
|
|
8230
|
-
//#region src/commit-lint/lint-commit-msg-file.ts
|
|
8231
|
-
/**
|
|
8232
|
-
* `holocron run lint commit-msg <file>` — a real programmatic replacement for
|
|
8233
|
-
* shelling out to `commitlint --edit "$1"` from `.husky/commit-msg`
|
|
8234
|
-
* (holocron#789). Matches that CLI's own contract exactly: `file` is a
|
|
8235
|
-
* path to the commit message being written (the arg git's `commit-msg`
|
|
8236
|
-
* hook passes as `$1`), no `--config` override needed — `@commitlint/load`
|
|
8237
|
-
* runs from `cwd` with no seed, so cosmiconfig's own normal upward search
|
|
8238
|
-
* discovers the invoking repo's real `commitlint.config.*` file, exactly
|
|
8239
|
-
* as `commitlint --edit` (no `--config` flag) already does today. Unlike
|
|
8240
|
-
* Sentinel's `loadIsolatedConfig()`, no isolation is needed here — this
|
|
8241
|
-
* runs inside a real repo checkout with a real `node_modules`, not
|
|
8242
|
-
* standalone on a server.
|
|
8243
|
-
*/
|
|
8244
|
-
async function lintCommitMsgFile(filePath, cwd = process.cwd()) {
|
|
8245
|
-
const violations = await lintCommitMessage(await readFile(filePath, "utf8"), await load({}, { cwd }));
|
|
8246
|
-
return {
|
|
8247
|
-
valid: violations.length === 0,
|
|
8248
|
-
violations
|
|
8249
|
-
};
|
|
8250
|
-
}
|
|
8251
|
-
//#endregion
|
|
8252
|
-
//#region src/config/load-config.ts
|
|
8253
|
-
/**
|
|
8254
|
-
* `holocron.config.{ts,js,mjs,cjs,json}` loader.
|
|
8255
|
-
*
|
|
8256
|
-
* File discovery + module loading (typed configs via `tsx`, no build
|
|
8257
|
-
* step) is delegated to `@theholocron/datapad`. This module keeps the
|
|
8258
|
-
* holocron-specific parts: filling `name` / `repo.name` defaults and
|
|
8259
|
-
* running the config through `resolveConfig`.
|
|
8260
|
-
*
|
|
8261
|
-
* Probe order is TS-first (`.ts` → `.js` → `.mjs` → `.cjs` → `.json`) —
|
|
8262
|
-
* see ADR-0010 / issue #75 / #81.
|
|
8263
|
-
*/
|
|
8264
|
-
/**
|
|
8265
|
-
* Read + parse + resolve `holocron.config.*` starting from the given
|
|
8266
|
-
* directory, walking up through ancestors to the nearest `.git` boundary
|
|
8267
|
-
* when `cwd` itself has no match — every command is usable from any
|
|
8268
|
-
* subdirectory of a repo, not only its exact root (see
|
|
8269
|
-
* `@theholocron/datapad`'s own doc comment for the stop conditions).
|
|
8270
|
-
* Throws {@link ConfigFileError} when nothing is found or a file cannot
|
|
8271
|
-
* be loaded, or `ConfigError` when the config fails schema checks.
|
|
8272
|
-
*/
|
|
8273
|
-
async function loadConfig(cwd) {
|
|
8274
|
-
const found = await loadConfigFile({
|
|
8275
|
-
cwd,
|
|
8276
|
-
name: "holocron",
|
|
8277
|
-
walkUp: true
|
|
8278
|
-
});
|
|
8279
|
-
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");
|
|
8280
8405
|
return {
|
|
8281
|
-
|
|
8282
|
-
|
|
8406
|
+
status,
|
|
8407
|
+
updated
|
|
8283
8408
|
};
|
|
8284
8409
|
}
|
|
8285
|
-
async function deriveDefaults(found) {
|
|
8286
|
-
const configDir = dirname(found.filepath);
|
|
8287
|
-
const result = { ...found.config };
|
|
8288
|
-
if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
|
|
8289
|
-
if (result.repo && !result.repo.name) {
|
|
8290
|
-
const remote = await readGitRemote(configDir);
|
|
8291
|
-
if (remote?.host === "github.com") result.repo = {
|
|
8292
|
-
...result.repo,
|
|
8293
|
-
name: remote.slug
|
|
8294
|
-
};
|
|
8295
|
-
}
|
|
8296
|
-
const manifest = await loadTasksConfig(configDir, { holocron: found });
|
|
8297
|
-
if (manifest.tasks) result.tasks = manifest.tasks;
|
|
8298
|
-
if (manifest.syncScripts !== void 0) result.syncScripts = manifest.syncScripts;
|
|
8299
|
-
if (manifest.holocronScript !== void 0) result.holocronScript = manifest.holocronScript;
|
|
8300
|
-
if (manifest.hooks !== void 0) result.hooks = manifest.hooks;
|
|
8301
|
-
if (manifest.extraRequiredChecks) result.extraRequiredChecks = [...result.extraRequiredChecks ?? [], ...manifest.extraRequiredChecks];
|
|
8302
|
-
return result;
|
|
8303
|
-
}
|
|
8304
|
-
async function readPackageJsonName(dir) {
|
|
8305
|
-
const name = (await readPackageJson(dir))?.name;
|
|
8306
|
-
return name === void 0 ? void 0 : name.replace(/^@[^/]+\//, "");
|
|
8307
|
-
}
|
|
8308
8410
|
//#endregion
|
|
8309
|
-
//#region src/interactive-menu.ts
|
|
8411
|
+
//#region src/interactive-menu/child-argv/child-argv.ts
|
|
8310
8412
|
/**
|
|
8311
|
-
*
|
|
8312
|
-
*
|
|
8313
|
-
*
|
|
8314
|
-
*
|
|
8315
|
-
* 2. a parent command with no sub → `select()` over that group's commands
|
|
8316
|
-
* 3. a leaf command missing a → `input()` / `select()` per positional
|
|
8317
|
-
* required positional
|
|
8318
|
-
*
|
|
8319
|
-
* Layers 1–2 are driven from `cli.ts`'s `$0` handlers via {@link launchMenu}:
|
|
8320
|
-
* pick a command, prompt for its required positionals, then **spawn a fresh
|
|
8321
|
-
* `holocron <command> …` child process** (`stdio: "inherit"`) rather than
|
|
8322
|
-
* re-entering yargs' own parse pipeline — see the spec's "spawn vs re-parse"
|
|
8323
|
-
* section (theholocron/holocron#438) for why. Layer 3 is driven directly from
|
|
8324
|
-
* each leaf command's own handler in `cli.ts`, calling
|
|
8325
|
-
* {@link promptForPositionals} for just that one command — no spawn needed,
|
|
8326
|
-
* the handler already has everything else it needs.
|
|
8327
|
-
*
|
|
8328
|
-
* For a command whose handler honours `--dry-run` ({@link
|
|
8329
|
-
* CommandEntry.supportsDryRun}), `launchMenu` always previews it first — a
|
|
8330
|
-
* dry run doesn't mutate anything, so there's nothing to ask permission
|
|
8331
|
-
* for — then, once that preview comes back clean and found something to
|
|
8332
|
-
* do, asks the one real decision: "proceed with the real run?". A preview
|
|
8333
|
-
* that finds nothing to do skips that question too. `--skip-dry-run` opts
|
|
8334
|
-
* a top-level invocation out of the preview entirely, straight to the
|
|
8335
|
-
* 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.
|
|
8336
8417
|
*
|
|
8337
|
-
*
|
|
8338
|
-
*
|
|
8339
|
-
*
|
|
8340
|
-
*
|
|
8341
|
-
* catch prints its message cleanly, restoring the old demandCommand-style
|
|
8342
|
-
* hard failure for non-interactive callers.
|
|
8343
|
-
*/
|
|
8344
|
-
/** Raised when a prompt is needed but stdin isn't a TTY — see the module doc. */
|
|
8345
|
-
var NonInteractiveError = class extends Error {
|
|
8346
|
-
name = "NonInteractiveError";
|
|
8347
|
-
};
|
|
8348
|
-
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
8349
|
-
/**
|
|
8350
|
-
* This repo's own resolved task manifest, filtered to what's actually
|
|
8351
|
-
* runnable locally — `Astromech.runnableTasks()`, reused rather than
|
|
8352
|
-
* reimplemented (deliberately *not* `packageScripts()`, whose
|
|
8353
|
-
* `syncScripts: false` short-circuit returns `{}` for repos — this one
|
|
8354
|
-
* included — that opt out of generated `package.json` scripts but still
|
|
8355
|
-
* have real runnable tasks). Empty (never throws) when there's no config to
|
|
8356
|
-
* resolve — feeds {@link buildRunMenuEntries}, which renders an empty list
|
|
8357
|
-
* as "run ›" simply having no children.
|
|
8358
|
-
*/
|
|
8359
|
-
async function listRunnableTasks(argv) {
|
|
8360
|
-
const cwd = typeof argv.cwd === "string" ? argv.cwd : process.cwd();
|
|
8361
|
-
const config = await loadTasksConfig(cwd).catch(() => void 0);
|
|
8362
|
-
return createAstromech({
|
|
8363
|
-
cwd,
|
|
8364
|
-
config
|
|
8365
|
-
}).runnableTasks();
|
|
8366
|
-
}
|
|
8367
|
-
/**
|
|
8368
|
-
* Namespace display order for the `run` menu — deliberately its own thing,
|
|
8369
|
-
* not `@theholocron/astromech`'s `CI_ORDER`: that one is tuned for "cheapest
|
|
8370
|
-
* signal first" in an automated gate, a different goal from "how a person
|
|
8371
|
-
* would naturally work through a repo" (build it, clean up its style, check
|
|
8372
|
-
* it's secure, test it, validate it, document it). A namespace absent here
|
|
8373
|
-
* (a repo-specific one this list doesn't know about) sorts after every
|
|
8374
|
-
* named one, in whatever order it was first seen.
|
|
8375
|
-
*/
|
|
8376
|
-
const RUN_MENU_NAMESPACE_ORDER = [
|
|
8377
|
-
"delivery",
|
|
8378
|
-
"sourceQuality",
|
|
8379
|
-
"security",
|
|
8380
|
-
"verification",
|
|
8381
|
-
"platform",
|
|
8382
|
-
"knowledge"
|
|
8383
|
-
];
|
|
8384
|
-
/**
|
|
8385
|
-
* Splits `taskNames` (`"sourceQuality.staticAnalysis"`, …) into namespace
|
|
8386
|
-
* groups, ordered by {@link RUN_MENU_NAMESPACE_ORDER} — insertion order
|
|
8387
|
-
* within each group is `taskNames`' own order (already declaration order,
|
|
8388
|
-
* from {@link listRunnableTasks}). A name with no `.` at all (defensive;
|
|
8389
|
-
* every real registry task has a namespace) is its own bucket, keyed by the
|
|
8390
|
-
* 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.
|
|
8391
8422
|
*/
|
|
8392
|
-
function
|
|
8393
|
-
const
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
}
|
|
8401
|
-
const ordered = /* @__PURE__ */ new Map();
|
|
8402
|
-
const byMenuOrder = (a, b) => {
|
|
8403
|
-
const ia = RUN_MENU_NAMESPACE_ORDER.indexOf(a);
|
|
8404
|
-
const ib = RUN_MENU_NAMESPACE_ORDER.indexOf(b);
|
|
8405
|
-
if (ia !== -1 || ib !== -1) return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
|
8406
|
-
return 0;
|
|
8407
|
-
};
|
|
8408
|
-
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8409
|
-
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;
|
|
8410
8431
|
}
|
|
8411
|
-
/**
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
* Each namespace becomes a group with a leading "<namespace> (run it)" item
|
|
8419
|
-
* (`holocron run <namespace>` — every task in it, via
|
|
8420
|
-
* `Astromech.runGroup()`) alongside each of its individual tasks, the exact
|
|
8421
|
-
* same "a group that's also runnable" shape `menuItems` already renders for
|
|
8422
|
-
* `repo sync` / `deploy` — reached here for free by using a space, not the
|
|
8423
|
-
* task's own `.`, between namespace and leaf in `name` (`spawnArgs` carries
|
|
8424
|
-
* the real dotted invocation `buildChildArgv` actually spawns).
|
|
8425
|
-
*/
|
|
8426
|
-
function buildRunMenuEntries(taskNames) {
|
|
8427
|
-
const entries = [];
|
|
8428
|
-
for (const [namespace, tasks] of groupByNamespace(taskNames)) {
|
|
8429
|
-
const isRealNamespace = tasks[0] !== namespace || tasks.length > 1;
|
|
8430
|
-
if (isRealNamespace) entries.push({
|
|
8431
|
-
name: `run ${namespace}`,
|
|
8432
|
-
description: `Run every ${namespace}.* task in sequence`,
|
|
8433
|
-
positionals: [],
|
|
8434
|
-
supportsDryRun: true,
|
|
8435
|
-
spawnArgs: ["run", namespace]
|
|
8436
|
-
});
|
|
8437
|
-
for (const task of tasks) {
|
|
8438
|
-
const leaf = task === namespace ? namespace : task.slice(namespace.length + 1);
|
|
8439
|
-
entries.push({
|
|
8440
|
-
name: isRealNamespace ? `run ${namespace} ${leaf}` : `run ${leaf}`,
|
|
8441
|
-
description: `Run ${task}`,
|
|
8442
|
-
positionals: [],
|
|
8443
|
-
supportsDryRun: true,
|
|
8444
|
-
spawnArgs: ["run", task]
|
|
8445
|
-
});
|
|
8446
|
-
}
|
|
8447
|
-
}
|
|
8448
|
-
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
|
+
];
|
|
8449
8439
|
}
|
|
8440
|
+
//#endregion
|
|
8441
|
+
//#region src/interactive-menu/command-registry/command-registry.ts
|
|
8442
|
+
const numeric = (value) => /^\d+$/.test(value) ? true : "enter a number";
|
|
8450
8443
|
/**
|
|
8451
8444
|
* The full command surface, hand-maintained in parallel with `cli.ts`'s
|
|
8452
8445
|
* yargs registrations (this module does not introspect yargs at runtime).
|
|
@@ -8684,6 +8677,9 @@ function getEntry(name) {
|
|
|
8684
8677
|
if (!entry) throw new Error(`interactive-menu: no COMMAND_REGISTRY entry named "${name}"`);
|
|
8685
8678
|
return entry;
|
|
8686
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. */
|
|
8687
8683
|
const GROUP = "group:";
|
|
8688
8684
|
/** The "← back" item's value; only offered below the first level. */
|
|
8689
8685
|
const BACK = "back";
|
|
@@ -8729,105 +8725,16 @@ function menuItems(entries, path = []) {
|
|
|
8729
8725
|
const children = [...new Set(nested.map((e) => e.name.split(" ")[path.length + 1]))];
|
|
8730
8726
|
items.push({
|
|
8731
8727
|
name: `${word} ›`,
|
|
8732
|
-
value: `${GROUP}${full}`,
|
|
8733
|
-
description: children.join(", "),
|
|
8734
|
-
haystack: haystackOf(family)
|
|
8735
|
-
});
|
|
8736
|
-
}
|
|
8737
|
-
}
|
|
8738
|
-
return items;
|
|
8739
|
-
}
|
|
8740
|
-
/**
|
|
8741
|
-
* Searchable autocomplete over one menu level's `items`. `select()` would work too
|
|
8742
|
-
* at these list sizes, but `search()` degrades gracefully as the surface grows and
|
|
8743
|
-
* costs nothing when it doesn't. Resolves to the picked item's `value`.
|
|
8744
|
-
*/
|
|
8745
|
-
async function pickMenuItem(items, message = "What would you like to do?") {
|
|
8746
|
-
return search({
|
|
8747
|
-
message,
|
|
8748
|
-
source: (term) => searchChoices(items, term)
|
|
8749
|
-
});
|
|
8750
|
-
}
|
|
8751
|
-
/**
|
|
8752
|
-
* `@inquirer/search`'s `source` callback, factored out as a plain function —
|
|
8753
|
-
* unit-testable directly instead of only through a mocked `search()` call.
|
|
8754
|
-
* Empty/undefined `term` (nothing typed yet) returns every item.
|
|
8755
|
-
*/
|
|
8756
|
-
function searchChoices(items, term) {
|
|
8757
|
-
const needle = term?.toLowerCase();
|
|
8758
|
-
const pool = !needle ? items : items.filter((i) => i.haystack.includes(needle));
|
|
8759
|
-
const rank = (i) => {
|
|
8760
|
-
if (!needle) return 0;
|
|
8761
|
-
const name = i.name.toLowerCase();
|
|
8762
|
-
return name.startsWith(needle) ? 0 : name.includes(needle) ? 1 : 2;
|
|
8763
|
-
};
|
|
8764
|
-
return [...pool].sort((a, b) => rank(a) - rank(b)).map((i) => ({
|
|
8765
|
-
name: i.name,
|
|
8766
|
-
value: i.value,
|
|
8767
|
-
description: i.description
|
|
8768
|
-
}));
|
|
8769
|
-
}
|
|
8770
|
-
/**
|
|
8771
|
-
* For each of `entry.positionals` not already present in `argv`, prompts for
|
|
8772
|
-
* it (`input()` or `select()`, per the positional's `type`) and returns the
|
|
8773
|
-
* resolved values in positional order — ready to append to a spawned child's
|
|
8774
|
-
* argv, or to use directly in the current handler (Layer 3).
|
|
8775
|
-
*
|
|
8776
|
-
* Throws {@link NonInteractiveError} the first time it would need to prompt
|
|
8777
|
-
* on a non-TTY stdin, and again if a `select` positional's choices resolve
|
|
8778
|
-
* empty (nothing to pick from — e.g. `auth unset` with no stored tokens).
|
|
8779
|
-
*/
|
|
8780
|
-
async function promptForPositionals(entry, argv) {
|
|
8781
|
-
const out = [];
|
|
8782
|
-
for (const positional of entry.positionals) {
|
|
8783
|
-
const existing = argv[positional.key];
|
|
8784
|
-
if (existing !== void 0 && existing !== "") {
|
|
8785
|
-
out.push(String(existing));
|
|
8786
|
-
continue;
|
|
8787
|
-
}
|
|
8788
|
-
if (!process.stdin.isTTY) throw new NonInteractiveError(`\`${entry.name}\` needs "${positional.key}" — pass it directly: holocron ${entry.name} <${positional.cliArg ?? positional.key}>`);
|
|
8789
|
-
if (positional.type === "select") {
|
|
8790
|
-
const choices = typeof positional.choices === "function" ? positional.choices() : positional.choices ?? [];
|
|
8791
|
-
if (choices.length === 0) throw new NonInteractiveError(`\`${entry.name}\`: no ${positional.key} to choose from — nothing stored yet`);
|
|
8792
|
-
out.push(await select({
|
|
8793
|
-
message: positional.message,
|
|
8794
|
-
choices: choices.map((c) => ({ value: c }))
|
|
8795
|
-
}));
|
|
8796
|
-
} else out.push(await input({
|
|
8797
|
-
message: positional.message,
|
|
8798
|
-
...positional.validate ? { validate: positional.validate } : {}
|
|
8799
|
-
}));
|
|
8800
|
-
}
|
|
8801
|
-
return out;
|
|
8802
|
-
}
|
|
8803
|
-
/**
|
|
8804
|
-
* `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
|
|
8805
|
-
* menu-launched child should inherit from the parent invocation. Everything
|
|
8806
|
-
* else (command-specific options) was never captured by the top-level `$0`
|
|
8807
|
-
* handler in the first place, so there's nothing else to forward.
|
|
8808
|
-
*
|
|
8809
|
-
* `dryRunOverride`, when passed, wins over `argv.dryRun` — `launchMenu`'s
|
|
8810
|
-
* dry-run offer needs to force `--dry-run` on for the preview spawn and
|
|
8811
|
-
* force it back off for the real-run spawn that follows, regardless of
|
|
8812
|
-
* what was on the original invocation.
|
|
8813
|
-
*/
|
|
8814
|
-
function forwardedFlags(argv, dryRunOverride) {
|
|
8815
|
-
const out = [];
|
|
8816
|
-
const tokens = argv.token;
|
|
8817
|
-
for (const t of tokens ?? []) out.push("--token", t);
|
|
8818
|
-
if (typeof argv.org === "string") out.push("--org", argv.org);
|
|
8819
|
-
if (typeof argv.cwd === "string") out.push("--cwd", argv.cwd);
|
|
8820
|
-
if (dryRunOverride ?? argv.dryRun === true) out.push("--dry-run");
|
|
8821
|
-
return out;
|
|
8822
|
-
}
|
|
8823
|
-
/** `entry.spawnArgs` if set, else `entry.name` split into tokens — resolved positionals appended, then forwarded flags. */
|
|
8824
|
-
function buildChildArgv(entry, positionals, parentArgv, dryRunOverride) {
|
|
8825
|
-
return [
|
|
8826
|
-
...entry.spawnArgs ?? entry.name.split(" "),
|
|
8827
|
-
...positionals,
|
|
8828
|
-
...forwardedFlags(parentArgv, dryRunOverride)
|
|
8829
|
-
];
|
|
8728
|
+
value: `${GROUP}${full}`,
|
|
8729
|
+
description: children.join(", "),
|
|
8730
|
+
haystack: haystackOf(family)
|
|
8731
|
+
});
|
|
8732
|
+
}
|
|
8733
|
+
}
|
|
8734
|
+
return items;
|
|
8830
8735
|
}
|
|
8736
|
+
//#endregion
|
|
8737
|
+
//#region src/interactive-menu/launch-menu/launch-menu.ts
|
|
8831
8738
|
/**
|
|
8832
8739
|
* Set on a dry-run preview child's env ({@link spawnChild}'s `extraEnv`) so
|
|
8833
8740
|
* its handler knows this specific `--dry-run` came from `launchMenu`'s own
|
|
@@ -8850,353 +8757,200 @@ const DRY_RUN_PREVIEW_ENV_VAR = "HOLOCRON_DRY_RUN_PREVIEW";
|
|
|
8850
8757
|
* once the child exits. `extraEnv` layers on top — used only for {@link
|
|
8851
8758
|
* DRY_RUN_PREVIEW_ENV_VAR} on the dry-run preview spawn.
|
|
8852
8759
|
*/
|
|
8853
|
-
function spawnChild(args, extraEnv) {
|
|
8854
|
-
return new Promise((resolve) => {
|
|
8855
|
-
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
8856
|
-
stdio: "inherit",
|
|
8857
|
-
env: {
|
|
8858
|
-
...process.env,
|
|
8859
|
-
NO_UPDATE_NOTIFIER: "1",
|
|
8860
|
-
...extraEnv
|
|
8861
|
-
}
|
|
8862
|
-
});
|
|
8863
|
-
child.on("exit", (code) => resolve(code ?? 0));
|
|
8864
|
-
child.on("error", () => resolve(1));
|
|
8865
|
-
});
|
|
8866
|
-
}
|
|
8867
|
-
/**
|
|
8868
|
-
* Whether `launchMenu` should auto-preview a picked command before running
|
|
8869
|
-
* it for real — only when the command's handler actually honours
|
|
8870
|
-
* `--dry-run` ({@link CommandEntry.supportsDryRun}) and the top-level
|
|
8871
|
-
* invocation didn't pass `--skip-dry-run` to opt out of the preview
|
|
8872
|
-
* entirely (an escape hatch for "I already know what I want, skip
|
|
8873
|
-
* straight to the real run").
|
|
8874
|
-
*/
|
|
8875
|
-
function shouldPreviewDryRun(entry, parentArgv) {
|
|
8876
|
-
return Boolean(entry.supportsDryRun) && parentArgv.skipDryRun !== true;
|
|
8877
|
-
}
|
|
8878
|
-
/**
|
|
8879
|
-
* Layers 1 and 2: walk the nested menu over `entries` (starting at `startPath`,
|
|
8880
|
-
* `[]` for the top), prompt for the picked command's required positionals, spawn
|
|
8881
|
-
* it as a fresh `holocron` invocation, and set `process.exitCode` from the child —
|
|
8882
|
-
* the caller (a `$0` handler in `cli.ts`) returns normally afterward so the
|
|
8883
|
-
* parent's own telemetry flush / update-notifier tail still runs. The parent's
|
|
8884
|
-
* own `command_completed` event fires with command name "unknown" (the middleware
|
|
8885
|
-
* ran before any command was picked) — left as-is rather than suppressed; it's a
|
|
8886
|
-
* real, useful signal ("the menu got used").
|
|
8887
|
-
*
|
|
8888
|
-
* Picking a group opens its submenu, which has a "← back" item; a command ends
|
|
8889
|
-
* the walk. See {@link menuItems} for how a level is built.
|
|
8890
|
-
*
|
|
8891
|
-
* For a command that honours `--dry-run` ({@link CommandEntry.supportsDryRun})
|
|
8892
|
-
* and wasn't opted out via `--skip-dry-run` ({@link shouldPreviewDryRun}),
|
|
8893
|
-
* this always previews first — a dry run doesn't mutate anything, so
|
|
8894
|
-
* there's nothing to ask permission for. A preview that exits non-zero
|
|
8895
|
-
* stops here; one that reports {@link DRY_RUN_NOOP_EXIT_CODE} (nothing
|
|
8896
|
-
* would change) stops here too, skipping the follow-up question since the
|
|
8897
|
-
* real run would have nothing to do either. Otherwise it asks "proceed
|
|
8898
|
-
* with the real run?" before the real spawn — the one actual decision
|
|
8899
|
-
* left to make, since this one does mutate something.
|
|
8900
|
-
*/
|
|
8901
|
-
async function launchMenu(entries, parentArgv, pickMessage, nonInteractiveMessage = "Run `holocron --help` to see available commands.", startPath = []) {
|
|
8902
|
-
if (!process.stdin.isTTY) throw new NonInteractiveError(nonInteractiveMessage);
|
|
8903
|
-
const stack = [startPath];
|
|
8904
|
-
let picked;
|
|
8905
|
-
while (!picked) {
|
|
8906
|
-
const path = stack[stack.length - 1];
|
|
8907
|
-
const items = menuItems(entries, path);
|
|
8908
|
-
if (stack.length > 1) items.push({
|
|
8909
|
-
name: "← back",
|
|
8910
|
-
value: BACK,
|
|
8911
|
-
description: "Return to the previous menu",
|
|
8912
|
-
haystack: "back"
|
|
8913
|
-
});
|
|
8914
|
-
const value = await pickMenuItem(items, stack.length === 1 ? pickMessage : `${path.join(" ")} — choose a subcommand:`);
|
|
8915
|
-
if (value === "back") stack.pop();
|
|
8916
|
-
else if (value.startsWith(GROUP)) stack.push(value.slice(6).split(" "));
|
|
8917
|
-
else picked = entries.find((e) => e.name === value);
|
|
8918
|
-
}
|
|
8919
|
-
const positionals = await promptForPositionals(picked, {});
|
|
8920
|
-
if (shouldPreviewDryRun(picked, parentArgv)) {
|
|
8921
|
-
const dryExit = await spawnChild(buildChildArgv(picked, positionals, parentArgv, true), { [DRY_RUN_PREVIEW_ENV_VAR]: "1" });
|
|
8922
|
-
if (dryExit === 2) {
|
|
8923
|
-
process.exitCode = 0;
|
|
8924
|
-
return;
|
|
8925
|
-
}
|
|
8926
|
-
if (dryExit !== 0) {
|
|
8927
|
-
process.exitCode = dryExit;
|
|
8928
|
-
return;
|
|
8929
|
-
}
|
|
8930
|
-
if (!await confirm({
|
|
8931
|
-
message: "Dry run complete — proceed with the real run?",
|
|
8932
|
-
default: false
|
|
8933
|
-
})) {
|
|
8934
|
-
process.exitCode = 0;
|
|
8935
|
-
return;
|
|
8936
|
-
}
|
|
8937
|
-
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv, false));
|
|
8938
|
-
return;
|
|
8939
|
-
}
|
|
8940
|
-
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
8941
|
-
}
|
|
8942
|
-
//#endregion
|
|
8943
|
-
//#region src/ui/json-view.ts
|
|
8944
|
-
function isContainer(value) {
|
|
8945
|
-
return typeof value === "object" && value !== null;
|
|
8946
|
-
}
|
|
8947
|
-
function childrenOf(value) {
|
|
8948
|
-
return Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
|
|
8949
|
-
}
|
|
8950
|
-
function scalar(value) {
|
|
8951
|
-
if (typeof value === "string") return chalk.green(JSON.stringify(value));
|
|
8952
|
-
if (typeof value === "number") return chalk.yellow(String(value));
|
|
8953
|
-
if (typeof value === "boolean") return chalk.magenta(String(value));
|
|
8954
|
-
return chalk.dim(String(value === void 0 ? null : value));
|
|
8955
|
-
}
|
|
8956
|
-
function summary(value) {
|
|
8957
|
-
const n = childrenOf(value).length;
|
|
8958
|
-
const unit = Array.isArray(value) ? n === 1 ? "item" : "items" : n === 1 ? "key" : "keys";
|
|
8959
|
-
return chalk.dim(Array.isArray(value) ? `[… ${n} ${unit}]` : `{… ${n} ${unit}}`);
|
|
8960
|
-
}
|
|
8961
|
-
/**
|
|
8962
|
-
* Paths of every container deeper than `depth` levels are left out, so those
|
|
8963
|
-
* render collapsed. `depth: 0` collapses the root; `Infinity` expands all.
|
|
8964
|
-
*/
|
|
8965
|
-
function expandToDepth(value, depth) {
|
|
8966
|
-
const out = /* @__PURE__ */ new Set();
|
|
8967
|
-
const walk = (v, path, level) => {
|
|
8968
|
-
if (!isContainer(v) || level >= depth) return;
|
|
8969
|
-
out.add(path);
|
|
8970
|
-
for (const [k, child] of childrenOf(v)) walk(child, `${path}/${k}`, level + 1);
|
|
8971
|
-
};
|
|
8972
|
-
walk(value, "", 0);
|
|
8973
|
-
return out;
|
|
8974
|
-
}
|
|
8975
|
-
/** Flatten `value` into the lines visible under `expanded`. Pure — no I/O. */
|
|
8976
|
-
function renderTree(value, { expanded }) {
|
|
8977
|
-
const lines = [];
|
|
8978
|
-
const walk = (v, path, level, label, trailing) => {
|
|
8979
|
-
const indent = " ".repeat(level);
|
|
8980
|
-
const prefix = `${indent}${label}`;
|
|
8981
|
-
if (!isContainer(v)) {
|
|
8982
|
-
lines.push({
|
|
8983
|
-
path,
|
|
8984
|
-
text: `${prefix}${scalar(v)}${trailing}`,
|
|
8985
|
-
foldable: false,
|
|
8986
|
-
expanded: false,
|
|
8987
|
-
level
|
|
8988
|
-
});
|
|
8989
|
-
return;
|
|
8990
|
-
}
|
|
8991
|
-
const kids = childrenOf(v);
|
|
8992
|
-
const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
|
|
8993
|
-
if (kids.length === 0) {
|
|
8994
|
-
lines.push({
|
|
8995
|
-
path,
|
|
8996
|
-
text: `${prefix}${open}${close}${trailing}`,
|
|
8997
|
-
foldable: false,
|
|
8998
|
-
expanded: false,
|
|
8999
|
-
level
|
|
9000
|
-
});
|
|
9001
|
-
return;
|
|
9002
|
-
}
|
|
9003
|
-
if (!expanded.has(path)) {
|
|
9004
|
-
lines.push({
|
|
9005
|
-
path,
|
|
9006
|
-
text: `${prefix}${summary(v)}${trailing}`,
|
|
9007
|
-
foldable: true,
|
|
9008
|
-
expanded: false,
|
|
9009
|
-
level
|
|
9010
|
-
});
|
|
9011
|
-
return;
|
|
9012
|
-
}
|
|
9013
|
-
lines.push({
|
|
9014
|
-
path,
|
|
9015
|
-
text: `${prefix}${open}`,
|
|
9016
|
-
foldable: true,
|
|
9017
|
-
expanded: true,
|
|
9018
|
-
level
|
|
9019
|
-
});
|
|
9020
|
-
kids.forEach(([k, child], i) => {
|
|
9021
|
-
const comma = i < kids.length - 1 ? "," : "";
|
|
9022
|
-
const childLabel = Array.isArray(v) ? "" : `${chalk.cyan(JSON.stringify(k))}: `;
|
|
9023
|
-
walk(child, `${path}/${k}`, level + 1, childLabel, comma);
|
|
9024
|
-
});
|
|
9025
|
-
lines.push({
|
|
9026
|
-
path: `${path}#close`,
|
|
9027
|
-
text: `${indent}${close}${trailing}`,
|
|
9028
|
-
foldable: false,
|
|
9029
|
-
expanded: false,
|
|
9030
|
-
level
|
|
9031
|
-
});
|
|
9032
|
-
};
|
|
9033
|
-
walk(value, "", 0, "", "");
|
|
9034
|
-
return lines;
|
|
9035
|
-
}
|
|
9036
|
-
/** Pure key reducer: `(state, key) -> state`. `rows` is the viewport height. */
|
|
9037
|
-
function reduceKey(state, key, value, rows) {
|
|
9038
|
-
const lines = renderTree(value, { expanded: state.expanded });
|
|
9039
|
-
const cur = lines[state.cursor];
|
|
9040
|
-
let { cursor } = state;
|
|
9041
|
-
const expanded = new Set(state.expanded);
|
|
9042
|
-
switch (key) {
|
|
9043
|
-
case "up":
|
|
9044
|
-
cursor = Math.max(0, cursor - 1);
|
|
9045
|
-
break;
|
|
9046
|
-
case "down":
|
|
9047
|
-
cursor = Math.min(lines.length - 1, cursor + 1);
|
|
9048
|
-
break;
|
|
9049
|
-
case "right":
|
|
9050
|
-
if (cur?.foldable) expanded.add(cur.path);
|
|
9051
|
-
break;
|
|
9052
|
-
case "left":
|
|
9053
|
-
if (cur?.foldable && cur.expanded) expanded.delete(cur.path);
|
|
9054
|
-
else if (cur) {
|
|
9055
|
-
const parent = lines.findLastIndex((l, i) => i < cursor && l.foldable && l.expanded && l.level < cur.level);
|
|
9056
|
-
if (parent >= 0) cursor = parent;
|
|
9057
|
-
}
|
|
9058
|
-
break;
|
|
9059
|
-
case "expandAll": return {
|
|
9060
|
-
cursor,
|
|
9061
|
-
top: state.top,
|
|
9062
|
-
expanded: expandToDepth(value, Infinity)
|
|
9063
|
-
};
|
|
9064
|
-
case "collapseAll": return {
|
|
9065
|
-
cursor: 0,
|
|
9066
|
-
top: 0,
|
|
9067
|
-
expanded: /* @__PURE__ */ new Set()
|
|
9068
|
-
};
|
|
9069
|
-
}
|
|
9070
|
-
const count = renderTree(value, { expanded }).length;
|
|
9071
|
-
cursor = Math.min(cursor, count - 1);
|
|
9072
|
-
let top = state.top;
|
|
9073
|
-
if (cursor < top) top = cursor;
|
|
9074
|
-
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
9075
|
-
return {
|
|
9076
|
-
cursor,
|
|
9077
|
-
top: Math.max(0, top),
|
|
9078
|
-
expanded
|
|
9079
|
-
};
|
|
9080
|
-
}
|
|
9081
|
-
const KEYMAP = {
|
|
9082
|
-
"\x1B[A": "up",
|
|
9083
|
-
k: "up",
|
|
9084
|
-
"\x1B[B": "down",
|
|
9085
|
-
j: "down",
|
|
9086
|
-
"\x1B[C": "right",
|
|
9087
|
-
l: "right",
|
|
9088
|
-
"\r": "right",
|
|
9089
|
-
" ": "right",
|
|
9090
|
-
"\x1B[D": "left",
|
|
9091
|
-
h: "left",
|
|
9092
|
-
E: "expandAll",
|
|
9093
|
-
C: "collapseAll",
|
|
9094
|
-
q: "quit",
|
|
9095
|
-
"\x1B": "quit",
|
|
9096
|
-
"": "quit"
|
|
9097
|
-
};
|
|
9098
|
-
function draw(out, value, state, rows) {
|
|
9099
|
-
const lines = renderTree(value, { expanded: state.expanded });
|
|
9100
|
-
const view = lines.slice(state.top, state.top + rows).map((l, i) => {
|
|
9101
|
-
const row = `${l.foldable ? l.expanded ? "▾ " : "▸ " : " "}${l.text}`;
|
|
9102
|
-
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));
|
|
9103
8772
|
});
|
|
9104
|
-
const footer = chalk.dim(`${state.cursor + 1}/${lines.length} ↑↓ move →/⏎ expand ← collapse E/C all q quit`);
|
|
9105
|
-
out.write(`\u001b[H\u001b[J${view.join("\n")}\n${footer}`);
|
|
9106
8773
|
}
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
stdin.setRawMode(true);
|
|
9118
|
-
stdin.resume();
|
|
9119
|
-
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
9120
|
-
const redraw = () => draw(stdout, value, state, rows());
|
|
9121
|
-
stdout.on("resize", redraw);
|
|
9122
|
-
try {
|
|
9123
|
-
redraw();
|
|
9124
|
-
await new Promise((resolve) => {
|
|
9125
|
-
const onData = (buf) => {
|
|
9126
|
-
const key = KEYMAP[buf.toString()];
|
|
9127
|
-
if (key === "quit") {
|
|
9128
|
-
stdin.off("data", onData);
|
|
9129
|
-
resolve();
|
|
9130
|
-
return;
|
|
9131
|
-
}
|
|
9132
|
-
if (key) {
|
|
9133
|
-
state = reduceKey(state, key, value, rows());
|
|
9134
|
-
redraw();
|
|
9135
|
-
}
|
|
9136
|
-
};
|
|
9137
|
-
stdin.on("data", onData);
|
|
9138
|
-
});
|
|
9139
|
-
} finally {
|
|
9140
|
-
stdout.off("resize", redraw);
|
|
9141
|
-
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
9142
|
-
stdin.setRawMode(wasRaw);
|
|
9143
|
-
stdin.pause();
|
|
9144
|
-
}
|
|
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;
|
|
9145
8784
|
}
|
|
9146
8785
|
/**
|
|
9147
|
-
*
|
|
9148
|
-
* `
|
|
9149
|
-
*
|
|
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.
|
|
9150
8807
|
*/
|
|
9151
|
-
async function
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
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));
|
|
9156
8845
|
return;
|
|
9157
8846
|
}
|
|
9158
|
-
|
|
9159
|
-
console.log(lines.map((l) => l.text).join("\n"));
|
|
8847
|
+
process.exitCode = await spawnChild(buildChildArgv(picked, positionals, parentArgv));
|
|
9160
8848
|
}
|
|
9161
8849
|
//#endregion
|
|
9162
|
-
//#region src/
|
|
9163
|
-
/**
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
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;
|
|
9181
8909
|
};
|
|
8910
|
+
for (const key of [...groups.keys()].sort(byMenuOrder)) ordered.set(key, groups.get(key));
|
|
8911
|
+
return ordered;
|
|
9182
8912
|
}
|
|
9183
8913
|
/**
|
|
9184
|
-
*
|
|
9185
|
-
*
|
|
9186
|
-
*
|
|
9187
|
-
*
|
|
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).
|
|
9188
8927
|
*/
|
|
9189
|
-
function
|
|
9190
|
-
const
|
|
9191
|
-
const
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
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;
|
|
9197
8951
|
}
|
|
9198
8952
|
//#endregion
|
|
9199
|
-
//#region src/update-notifier.ts
|
|
8953
|
+
//#region src/update-notifier/update-notifier.ts
|
|
9200
8954
|
const PACKAGE_NAME = "@theholocron/cli";
|
|
9201
8955
|
const CACHE_TTL_MS = 864e5;
|
|
9202
8956
|
const FETCH_TIMEOUT_MS = 3e3;
|