@theholocron/cli 5.0.0-alpha.184 → 5.0.0-alpha.186
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/dist/cli.mjs +120 -52
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/plugin/capabilities.d.mts +7 -0
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import ora from "ora";
|
|
|
26
26
|
import { findRootSync, isWorkspaceRoot, walkFilesSync } from "@theholocron/fs-utils";
|
|
27
27
|
import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
28
28
|
import { stringifyJson } from "@theholocron/json-utils";
|
|
29
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
29
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
30
30
|
import { generateReadme } from "@theholocron/components-doc/markdown";
|
|
31
31
|
import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
|
|
32
32
|
import { createGitHubClient } from "@theholocron/github-client";
|
|
@@ -2396,6 +2396,43 @@ async function runDeployFromFiles(input) {
|
|
|
2396
2396
|
}
|
|
2397
2397
|
}
|
|
2398
2398
|
//#endregion
|
|
2399
|
+
//#region src/ui/provider-error/provider-error.ts
|
|
2400
|
+
/** Vendor response bodies land in the terminal — cap their length so one
|
|
2401
|
+
* oversized error can't flood the report, and strip control characters
|
|
2402
|
+
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2403
|
+
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2404
|
+
const MAX_DETAIL_LENGTH = 200;
|
|
2405
|
+
function sanitizeDetail(text) {
|
|
2406
|
+
const stripped = text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2407
|
+
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* `errorMessage(err)` alone is just `"<Vendor> <METHOD> <path> → <status>"` —
|
|
2411
|
+
* the actual reason (what the vendor's response body said) lives in
|
|
2412
|
+
* `ProviderApiError.details`, a raw response-body string (not parsed JSON —
|
|
2413
|
+
* see AGENTS.md), and was being silently dropped. Appending it (sanitized
|
|
2414
|
+
* and length-capped — see `sanitizeDetail`) turns e.g. a bare "→ 403" into
|
|
2415
|
+
* something that actually explains itself, without the vendor text being
|
|
2416
|
+
* able to overwhelm or manipulate the terminal.
|
|
2417
|
+
*/
|
|
2418
|
+
function describeProviderError(err) {
|
|
2419
|
+
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2420
|
+
const detail = sanitizeDetail(String(err.details));
|
|
2421
|
+
if (detail) return `${errorMessage(err)}: ${detail}`;
|
|
2422
|
+
}
|
|
2423
|
+
return errorMessage(err);
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* What to put in structured logs: the plain message of the underlying error,
|
|
2427
|
+
* with no response body. The detail {@link describeProviderError} adds is for the
|
|
2428
|
+
* person at the terminal; a provider's raw body has no business in telemetry.
|
|
2429
|
+
* Wrapper errors carry the original as `cause`, so look through one level.
|
|
2430
|
+
*/
|
|
2431
|
+
function logReason(err) {
|
|
2432
|
+
const root = err instanceof Error && err.cause !== void 0 ? err.cause : err;
|
|
2433
|
+
return errorMessage(root);
|
|
2434
|
+
}
|
|
2435
|
+
//#endregion
|
|
2399
2436
|
//#region src/commands/doctor/doctor.ts
|
|
2400
2437
|
/**
|
|
2401
2438
|
* `holocron doctor` — load the config, instantiate every capability,
|
|
@@ -2493,45 +2530,17 @@ async function smokeCheck(key, provider, impl) {
|
|
|
2493
2530
|
try {
|
|
2494
2531
|
return await withTimeout(runSmokeCheck(key, provider, impl), SMOKE_CHECK_TIMEOUT_MS);
|
|
2495
2532
|
} catch (err) {
|
|
2496
|
-
return mk(key, provider, "fail",
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
/** Vendor response bodies land in the terminal — cap their length so one
|
|
2500
|
-
* oversized error can't flood the report, and strip control characters
|
|
2501
|
-
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2502
|
-
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2503
|
-
const MAX_DETAIL_LENGTH = 200;
|
|
2504
|
-
function sanitizeDetail(text) {
|
|
2505
|
-
const stripped = text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2506
|
-
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2507
|
-
}
|
|
2508
|
-
/**
|
|
2509
|
-
* `errorMessage(err)` alone is just `"<Vendor> <METHOD> <path> → <status>"` —
|
|
2510
|
-
* the actual reason (what the vendor's response body said) lives in
|
|
2511
|
-
* `ProviderApiError.details`, a raw response-body string (not parsed JSON —
|
|
2512
|
-
* see AGENTS.md), and was being silently dropped. Appending it (sanitized
|
|
2513
|
-
* and length-capped — see `sanitizeDetail`) turns e.g. a bare "→ 400" into
|
|
2514
|
-
* something that actually explains itself, without the vendor text being
|
|
2515
|
-
* able to overwhelm or manipulate the terminal report.
|
|
2516
|
-
*/
|
|
2517
|
-
function smokeCheckErrorMessage(err) {
|
|
2518
|
-
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2519
|
-
const detail = sanitizeDetail(String(err.details));
|
|
2520
|
-
if (detail) return `${errorMessage(err)}: ${detail}`;
|
|
2533
|
+
return mk(key, provider, "fail", describeProviderError(err), errorMessage(err));
|
|
2521
2534
|
}
|
|
2522
|
-
return errorMessage(err);
|
|
2523
2535
|
}
|
|
2524
2536
|
async function runSmokeCheck(key, provider, impl) {
|
|
2525
2537
|
switch (key) {
|
|
2526
2538
|
case "source": return mk(key, provider, "ok", `whoami: ${(await impl.whoami()).login}`);
|
|
2527
|
-
case "issues":
|
|
2528
|
-
const report = await impl.doctor();
|
|
2529
|
-
return mk(key, provider, "ok", `${report.authedAs} / ${report.projectLabel} (${countResolvedLifecycle(report)})`);
|
|
2530
|
-
}
|
|
2539
|
+
case "issues": return mk(key, provider, "ok", describeLifecycle(await impl.doctor()));
|
|
2531
2540
|
case "secrets": return mk(key, provider, "ok", `secrets: ${(await impl.listSecrets({ kind: "repo" })).length} configured`);
|
|
2532
2541
|
case "ci": {
|
|
2533
2542
|
const runs = await impl.listRuns({ limit: 1 });
|
|
2534
|
-
return mk(key, provider, "ok", runs.length === 0 ? "
|
|
2543
|
+
return mk(key, provider, "ok", runs.length === 0 ? "runs: none recent" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
|
|
2535
2544
|
}
|
|
2536
2545
|
case "vault": return mk(key, provider, "ok", `keys: ${(await impl.list()).length} visible`);
|
|
2537
2546
|
case "deployment": return mk(key, provider, "ok", `projects: ${(await impl.listProjects()).length} visible`);
|
|
@@ -2577,7 +2586,7 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2577
2586
|
const monitoring = impl;
|
|
2578
2587
|
if (monitoring.whoami) {
|
|
2579
2588
|
const who = await monitoring.whoami();
|
|
2580
|
-
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token verified" : "token check failed");
|
|
2589
|
+
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token: verified" : "token: check failed");
|
|
2581
2590
|
}
|
|
2582
2591
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2583
2592
|
}
|
|
@@ -2591,15 +2600,23 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2591
2600
|
const wiki = impl;
|
|
2592
2601
|
if (wiki.whoami) {
|
|
2593
2602
|
const who = await wiki.whoami();
|
|
2594
|
-
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token configured" : "token not configured");
|
|
2603
|
+
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token: configured" : "token: not configured");
|
|
2595
2604
|
}
|
|
2596
2605
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2597
2606
|
}
|
|
2598
2607
|
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2599
2608
|
}
|
|
2600
2609
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2610
|
+
/**
|
|
2611
|
+
* What the issues row says: how many lifecycle slots (inProgress, inReview, done)
|
|
2612
|
+
* map to something the tracker recognises, and which do not. The token's user and
|
|
2613
|
+
* the repo are not repeated -- `source` already names the account, and a slot
|
|
2614
|
+
* that is not configured is the part someone has to act on.
|
|
2615
|
+
*/
|
|
2616
|
+
function describeLifecycle(report) {
|
|
2617
|
+
const unresolved = report.lifecycle.filter((l) => !l.resolved).map((l) => l.slot);
|
|
2618
|
+
const base = `lifecycle: ${report.lifecycle.length - unresolved.length}/${report.lifecycle.length} slots ready`;
|
|
2619
|
+
return unresolved.length > 0 ? `${base} (unresolved: ${unresolved.join(", ")})` : base;
|
|
2603
2620
|
}
|
|
2604
2621
|
function mk(capability, provider, status, message, logMessage) {
|
|
2605
2622
|
return {
|
|
@@ -3232,6 +3249,33 @@ async function runNew(input) {
|
|
|
3232
3249
|
function defaultSecretName(channel) {
|
|
3233
3250
|
return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
3234
3251
|
}
|
|
3252
|
+
/**
|
|
3253
|
+
* Prefix of the throwaway secret the preflight writes and removes. Every run adds a
|
|
3254
|
+
* random suffix, so it can never overwrite (and then delete) a secret someone already
|
|
3255
|
+
* has; layouts may not use any name under this prefix for their own secrets.
|
|
3256
|
+
*/
|
|
3257
|
+
const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
3258
|
+
/** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
|
|
3259
|
+
function isReservedSecret(name) {
|
|
3260
|
+
return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Prove the secrets store can be WRITTEN before creating anything that needs it.
|
|
3264
|
+
* A webhook's URL is shown once; creating the webhook and then failing to store
|
|
3265
|
+
* it leaves an orphan the user has to delete by hand. Listing is not enough --
|
|
3266
|
+
* a token can read secrets it cannot write -- so write a throwaway secret and
|
|
3267
|
+
* remove it.
|
|
3268
|
+
*/
|
|
3269
|
+
async function assertSecretsReachable(secrets, scope) {
|
|
3270
|
+
const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
3271
|
+
try {
|
|
3272
|
+
await secrets.setSecret(scope, probe, "ok");
|
|
3273
|
+
await secrets.deleteSecret(scope, probe);
|
|
3274
|
+
} catch (err) {
|
|
3275
|
+
const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
|
|
3276
|
+
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3235
3279
|
/** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
|
|
3236
3280
|
function whyMissing(loader, key, fallback) {
|
|
3237
3281
|
const failure = loader.loadFailures().find((f) => f.key === key);
|
|
@@ -3259,6 +3303,7 @@ async function provisionChannel(deps, spec) {
|
|
|
3259
3303
|
const where = spec.category ? ` in ${spec.category}` : "";
|
|
3260
3304
|
if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
|
|
3261
3305
|
if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
|
|
3306
|
+
if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
|
|
3262
3307
|
if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
|
|
3263
3308
|
if (dryRun) {
|
|
3264
3309
|
print(` … would ensure channel ${spec.name}${where}`);
|
|
@@ -3290,7 +3335,7 @@ async function provisionChannel(deps, spec) {
|
|
|
3290
3335
|
try {
|
|
3291
3336
|
await secrets.setSecret(scope, secretName, hook.url);
|
|
3292
3337
|
} catch (err) {
|
|
3293
|
-
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${
|
|
3338
|
+
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${describeProviderError(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
|
|
3294
3339
|
}
|
|
3295
3340
|
print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
|
|
3296
3341
|
outcome.webhook = {
|
|
@@ -3334,6 +3379,7 @@ async function runNotificationsChannelAdd(input) {
|
|
|
3334
3379
|
name: org
|
|
3335
3380
|
} : void 0);
|
|
3336
3381
|
if (input.withWebhook && !scope && !dryRun) throw new Error("no org to store the webhook secret under — pass --scope or set `org` in holocron.config.ts");
|
|
3382
|
+
if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
|
|
3337
3383
|
const channel = await provisionChannel({
|
|
3338
3384
|
notifications,
|
|
3339
3385
|
...secrets ? { secrets } : {},
|
|
@@ -3369,11 +3415,11 @@ async function runNotificationsChannelAdd(input) {
|
|
|
3369
3415
|
channel
|
|
3370
3416
|
};
|
|
3371
3417
|
} catch (err) {
|
|
3372
|
-
const message =
|
|
3418
|
+
const message = describeProviderError(err);
|
|
3373
3419
|
print(` ✗ ${message}`);
|
|
3374
3420
|
logger.warn({
|
|
3375
3421
|
channel: input.name,
|
|
3376
|
-
reason:
|
|
3422
|
+
reason: logReason(err)
|
|
3377
3423
|
}, `notifications channel add: ${input.name}`);
|
|
3378
3424
|
return {
|
|
3379
3425
|
status: "fail",
|
|
@@ -3410,9 +3456,9 @@ async function runNotificationsSetup(input) {
|
|
|
3410
3456
|
roles: [],
|
|
3411
3457
|
failures: []
|
|
3412
3458
|
};
|
|
3413
|
-
const refuse = (message) => {
|
|
3459
|
+
const refuse = (message, reason = message) => {
|
|
3414
3460
|
print(` ✗ ${message}`);
|
|
3415
|
-
logger.warn({ reason
|
|
3461
|
+
logger.warn({ reason }, "notifications setup");
|
|
3416
3462
|
return {
|
|
3417
3463
|
...report,
|
|
3418
3464
|
status: "fail",
|
|
@@ -3427,10 +3473,10 @@ async function runNotificationsSetup(input) {
|
|
|
3427
3473
|
}
|
|
3428
3474
|
const layout = notifications.layout;
|
|
3429
3475
|
if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
|
|
3430
|
-
const
|
|
3476
|
+
const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
|
|
3431
3477
|
let secrets;
|
|
3432
|
-
if (
|
|
3433
|
-
if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout
|
|
3478
|
+
if (wantsSecrets && !dryRun) {
|
|
3479
|
+
if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout stores webhook URLs or role ids, which need a `secrets` capability — add a `secrets` provider"));
|
|
3434
3480
|
secrets = loader.get("secrets");
|
|
3435
3481
|
}
|
|
3436
3482
|
const org = input.context.org;
|
|
@@ -3438,21 +3484,36 @@ async function runNotificationsSetup(input) {
|
|
|
3438
3484
|
kind: "organization",
|
|
3439
3485
|
name: org
|
|
3440
3486
|
} : void 0);
|
|
3441
|
-
if (
|
|
3487
|
+
if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
|
|
3488
|
+
if (secrets && scope && !dryRun) try {
|
|
3489
|
+
await assertSecretsReachable(secrets, scope);
|
|
3490
|
+
} catch (err) {
|
|
3491
|
+
return refuse(errorMessage(err), logReason(err));
|
|
3492
|
+
}
|
|
3442
3493
|
const taken = /* @__PURE__ */ new Map();
|
|
3494
|
+
const claim = (secret, owner, hint) => {
|
|
3495
|
+
if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
|
|
3496
|
+
const key = secret.toUpperCase();
|
|
3497
|
+
const other = taken.get(key);
|
|
3498
|
+
if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
|
|
3499
|
+
taken.set(key, owner);
|
|
3500
|
+
};
|
|
3443
3501
|
for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
|
|
3444
3502
|
if (!channel.webhook) continue;
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3503
|
+
const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
|
|
3504
|
+
if (clash) return refuse(clash);
|
|
3505
|
+
}
|
|
3506
|
+
for (const role of layout.roles ?? []) {
|
|
3507
|
+
if (!role.secret) continue;
|
|
3508
|
+
const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
|
|
3509
|
+
if (clash) return refuse(clash);
|
|
3449
3510
|
}
|
|
3450
3511
|
print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
|
|
3451
3512
|
const attempt = async (step, fn) => {
|
|
3452
3513
|
try {
|
|
3453
3514
|
await fn();
|
|
3454
3515
|
} catch (err) {
|
|
3455
|
-
const message =
|
|
3516
|
+
const message = describeProviderError(err);
|
|
3456
3517
|
print(` ✗ ${step}: ${message}`);
|
|
3457
3518
|
report.failures.push({
|
|
3458
3519
|
step,
|
|
@@ -3496,10 +3557,11 @@ async function runNotificationsSetup(input) {
|
|
|
3496
3557
|
for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
|
|
3497
3558
|
if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
|
|
3498
3559
|
if (dryRun) {
|
|
3499
|
-
print(` … would ensure role ${role.name}`);
|
|
3560
|
+
print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
|
|
3500
3561
|
report.roles.push({
|
|
3501
3562
|
name: role.name,
|
|
3502
|
-
created: false
|
|
3563
|
+
created: false,
|
|
3564
|
+
...role.secret ? { secret: role.secret } : {}
|
|
3503
3565
|
});
|
|
3504
3566
|
return;
|
|
3505
3567
|
}
|
|
@@ -3514,6 +3576,12 @@ async function runNotificationsSetup(input) {
|
|
|
3514
3576
|
};
|
|
3515
3577
|
report.roles.push(outcome);
|
|
3516
3578
|
print(` ${made.created ? "✓ created" : "· exists "} role ${role.name} (id ${made.id})`);
|
|
3579
|
+
if (role.secret) {
|
|
3580
|
+
await secrets.setSecret(scope, role.secret, made.id);
|
|
3581
|
+
outcome.secret = role.secret;
|
|
3582
|
+
outcome.stored = true;
|
|
3583
|
+
print(` ✓ stored the id of ${role.name} in ${role.secret}`);
|
|
3584
|
+
}
|
|
3517
3585
|
});
|
|
3518
3586
|
if (report.failures.length > 0) {
|
|
3519
3587
|
report.status = "fail";
|