@theholocron/cli 5.0.0-alpha.180 → 5.0.0-alpha.182

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 CHANGED
@@ -407,6 +407,8 @@ const COMMAND_CONTEXTS = {
407
407
  "repo setup": "workspace",
408
408
  "secrets set": "workspace",
409
409
  "secrets sync": "workspace",
410
+ "notifications channel add": "workspace",
411
+ "notifications setup": "workspace",
410
412
  deploy: "workspace",
411
413
  "deploy cleanup-preview": "workspace",
412
414
  "monitoring check": "workspace",
@@ -2407,6 +2409,7 @@ async function runDeployFromFiles(input) {
2407
2409
  * auth → `whoami()` (required on every provider)
2408
2410
  * errors → `whoami()` (org reachability) or `describe()`
2409
2411
  * logs → `whoami()` (dataset reachability) or `describe()`
2412
+ * wiki → `whoami()` (token presence only — not a live check, see holocron#1079)
2410
2413
  * others → just "loaded" (no smoke endpoint defined yet)
2411
2414
  *
2412
2415
  * Output is plain text via a passed-in `print` function so tests can
@@ -2579,6 +2582,14 @@ async function runSmokeCheck(key, provider, impl) {
2579
2582
  }
2580
2583
  return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2581
2584
  }
2585
+ case "wiki": {
2586
+ const wiki = impl;
2587
+ if (wiki.whoami) {
2588
+ const who = await wiki.whoami();
2589
+ return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token configured" : "token not configured");
2590
+ }
2591
+ return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2592
+ }
2582
2593
  default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2583
2594
  }
2584
2595
  }
@@ -3198,6 +3209,321 @@ async function runNew(input) {
3198
3209
  };
3199
3210
  }
3200
3211
  //#endregion
3212
+ //#region src/commands/notifications-channel-add/notifications-channel-add.ts
3213
+ /**
3214
+ * `holocron notifications channel add` — create one channel (and, with
3215
+ * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
3216
+ *
3217
+ * Provider-neutral: it drives the `notifications` capability's `ensure*`
3218
+ * methods, so any provider that implements them works. Idempotent — an
3219
+ * existing channel or webhook is left alone, and a webhook URL is never
3220
+ * printed, only stored.
3221
+ */
3222
+ /**
3223
+ * `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
3224
+ * provider, so changing providers means re-pointing the same secrets at new
3225
+ * webhook URLs, with no workflow edits.
3226
+ */
3227
+ function defaultSecretName(channel) {
3228
+ return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
3229
+ }
3230
+ /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
3231
+ function whyMissing(loader, key, fallback) {
3232
+ const failure = loader.loadFailures().find((f) => f.key === key);
3233
+ return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${failure.error.message})` : fallback;
3234
+ }
3235
+ /** Pick the provider that can provision; with several, `--provider` decides. */
3236
+ function pickProvisioner(providers, wanted) {
3237
+ const capable = providers.filter((p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function");
3238
+ if (capable.length === 0) throw new Error("no `notifications` provider can provision — add one that implements it (for example discord) to holocron.config.ts");
3239
+ if (wanted) {
3240
+ const match = capable.find((p) => p.providerName === wanted);
3241
+ if (!match) throw new Error(`no provisioning \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
3242
+ return match;
3243
+ }
3244
+ if (capable.length > 1) throw new Error(`several \`notifications\` providers can provision (${names(capable)}) — pass --provider <name>`);
3245
+ return capable[0];
3246
+ }
3247
+ const names = (ps) => ps.map((p) => p.providerName).join(", ");
3248
+ /** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
3249
+ async function provisionChannel(deps, spec) {
3250
+ const { notifications, secrets, scope, dryRun, print } = deps;
3251
+ const webhookCfg = spec.webhook || void 0;
3252
+ const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
3253
+ const webhookName = webhookCfg?.name ?? spec.name;
3254
+ const where = spec.category ? ` in ${spec.category}` : "";
3255
+ if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
3256
+ if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
3257
+ if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
3258
+ if (dryRun) {
3259
+ print(` … would ensure channel ${spec.name}${where}`);
3260
+ if (webhookCfg) print(` … would ensure webhook ${webhookName} and store its URL in ${secretName}`);
3261
+ return {
3262
+ name: spec.name,
3263
+ ...spec.category ? { category: spec.category } : {},
3264
+ created: false
3265
+ };
3266
+ }
3267
+ const channel = await notifications.ensureChannel({
3268
+ name: spec.name,
3269
+ ...spec.category ? { category: spec.category } : {},
3270
+ ...spec.topic ? { topic: spec.topic } : {}
3271
+ });
3272
+ print(` ${channel.created ? "✓ created" : "· exists "} channel ${spec.name}${where}`);
3273
+ const outcome = {
3274
+ name: spec.name,
3275
+ ...spec.category ? { category: spec.category } : {},
3276
+ created: channel.created
3277
+ };
3278
+ if (!webhookCfg) return outcome;
3279
+ const hook = await notifications.ensureWebhook({
3280
+ channel: spec.name,
3281
+ name: webhookName,
3282
+ ...spec.category ? { category: spec.category } : {}
3283
+ });
3284
+ if (hook.url) {
3285
+ try {
3286
+ await secrets.setSecret(scope, secretName, hook.url);
3287
+ } catch (err) {
3288
+ throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${errorMessage(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
3289
+ }
3290
+ print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
3291
+ outcome.webhook = {
3292
+ name: webhookName,
3293
+ created: hook.created,
3294
+ secret: secretName,
3295
+ stored: true
3296
+ };
3297
+ } else {
3298
+ const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
3299
+ print(` ! ${note}`);
3300
+ outcome.webhook = {
3301
+ name: webhookName,
3302
+ created: hook.created,
3303
+ secret: secretName,
3304
+ stored: false,
3305
+ note
3306
+ };
3307
+ }
3308
+ return outcome;
3309
+ }
3310
+ async function runNotificationsChannelAdd(input) {
3311
+ const print = input.print ?? ((line) => console.log(line));
3312
+ const logger = input.logger ?? getLogger();
3313
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3314
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3315
+ await loader.load();
3316
+ assertPluginsResolvable(loader, "notifications channel add");
3317
+ const dryRun = input.context.dryRun ?? false;
3318
+ print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
3319
+ try {
3320
+ const notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3321
+ let secrets;
3322
+ if (input.withWebhook && !dryRun) {
3323
+ if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
3324
+ secrets = loader.get("secrets");
3325
+ }
3326
+ const org = input.context.org;
3327
+ const scope = input.scope ?? (org ? {
3328
+ kind: "organization",
3329
+ name: org
3330
+ } : void 0);
3331
+ 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");
3332
+ const channel = await provisionChannel({
3333
+ notifications,
3334
+ ...secrets ? { secrets } : {},
3335
+ scope: scope ?? { kind: "repo" },
3336
+ dryRun,
3337
+ print
3338
+ }, {
3339
+ name: input.name,
3340
+ ...input.category ? { category: input.category } : {},
3341
+ ...input.topic ? { topic: input.topic } : {},
3342
+ ...input.withWebhook ? { webhook: {
3343
+ ...input.webhookName ? { name: input.webhookName } : {},
3344
+ ...input.secret ? { secret: input.secret } : {}
3345
+ } } : {}
3346
+ });
3347
+ logger.info({
3348
+ channel: input.name,
3349
+ created: channel.created,
3350
+ provider: notifications.providerName,
3351
+ status: "ok"
3352
+ }, `notifications channel add: ${input.name}`);
3353
+ if (channel.webhook && !channel.webhook.stored && !dryRun) {
3354
+ const message = channel.webhook.note;
3355
+ print(` ✗ ${message}`);
3356
+ return {
3357
+ status: "fail",
3358
+ channel,
3359
+ message
3360
+ };
3361
+ }
3362
+ return {
3363
+ status: dryRun ? "dry-run" : "ok",
3364
+ channel
3365
+ };
3366
+ } catch (err) {
3367
+ const message = errorMessage(err);
3368
+ print(` ✗ ${message}`);
3369
+ logger.warn({
3370
+ channel: input.name,
3371
+ reason: message
3372
+ }, `notifications channel add: ${input.name}`);
3373
+ return {
3374
+ status: "fail",
3375
+ message
3376
+ };
3377
+ }
3378
+ }
3379
+ //#endregion
3380
+ //#region src/commands/notifications-setup/notifications-setup.ts
3381
+ /**
3382
+ * `holocron notifications setup` — apply the server layout a `notifications`
3383
+ * provider declares in `holocron.config.ts` (categories, channels, roles, and
3384
+ * a webhook secret per channel that asks for one).
3385
+ *
3386
+ * Idempotent: every step looks things up by name and creates only what is
3387
+ * missing, so it is safe to re-run and `--dry-run` previews it. A failing step
3388
+ * is reported and the rest still run.
3389
+ *
3390
+ * Run it from ONE repo per server — a layout is org-level, and putting it in a
3391
+ * shared preset would apply it from every repo.
3392
+ */
3393
+ async function runNotificationsSetup(input) {
3394
+ const print = input.print ?? ((line) => console.log(line));
3395
+ const logger = input.logger ?? getLogger();
3396
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3397
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3398
+ await loader.load();
3399
+ assertPluginsResolvable(loader, "notifications setup");
3400
+ const dryRun = input.context.dryRun ?? false;
3401
+ const report = {
3402
+ status: "ok",
3403
+ categories: [],
3404
+ channels: [],
3405
+ roles: [],
3406
+ failures: []
3407
+ };
3408
+ const refuse = (message) => {
3409
+ print(` ✗ ${message}`);
3410
+ logger.warn({ reason: message }, "notifications setup");
3411
+ return {
3412
+ ...report,
3413
+ status: "fail",
3414
+ message
3415
+ };
3416
+ };
3417
+ let notifications;
3418
+ try {
3419
+ notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3420
+ } catch (err) {
3421
+ return refuse(errorMessage(err));
3422
+ }
3423
+ const layout = notifications.layout;
3424
+ if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3425
+ const wantsWebhook = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook));
3426
+ let secrets;
3427
+ if (wantsWebhook && !dryRun) {
3428
+ if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout asks for webhooks, which need a `secrets` capability — add a `secrets` provider"));
3429
+ secrets = loader.get("secrets");
3430
+ }
3431
+ const org = input.context.org;
3432
+ const scope = input.scope ?? (org ? {
3433
+ kind: "organization",
3434
+ name: org
3435
+ } : void 0);
3436
+ if (wantsWebhook && !scope && !dryRun) return refuse("no org to store webhook secrets under — pass --scope or set `org` in holocron.config.ts");
3437
+ const taken = /* @__PURE__ */ new Map();
3438
+ for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
3439
+ if (!channel.webhook) continue;
3440
+ const secret = channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name);
3441
+ const other = taken.get(secret);
3442
+ if (other) return refuse(`channels \`${other}\` and \`${channel.name}\` would both store their webhook in ${secret} — give one a distinct \`webhook.secret\``);
3443
+ taken.set(secret, channel.name);
3444
+ }
3445
+ print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
3446
+ const attempt = async (step, fn) => {
3447
+ try {
3448
+ await fn();
3449
+ } catch (err) {
3450
+ const message = errorMessage(err);
3451
+ print(` ✗ ${step}: ${message}`);
3452
+ report.failures.push({
3453
+ step,
3454
+ message
3455
+ });
3456
+ }
3457
+ };
3458
+ for (const category of layout.categories ?? []) {
3459
+ print(`\n${category.name}`);
3460
+ report.categories.push(category.name);
3461
+ await attempt(`category ${category.name}`, async () => {
3462
+ if (!notifications.ensureCategory) return;
3463
+ if (dryRun) {
3464
+ print(` … would ensure category ${category.name}`);
3465
+ return;
3466
+ }
3467
+ const made = await notifications.ensureCategory(category.name);
3468
+ print(` ${made.created ? "✓ created" : "· exists "} category ${category.name}`);
3469
+ });
3470
+ for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
3471
+ const outcome = await provisionChannel({
3472
+ notifications,
3473
+ ...secrets ? { secrets } : {},
3474
+ scope: scope ?? { kind: "repo" },
3475
+ dryRun,
3476
+ print
3477
+ }, {
3478
+ name: channel.name,
3479
+ category: category.name,
3480
+ ...channel.topic ? { topic: channel.topic } : {},
3481
+ ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
3482
+ });
3483
+ report.channels.push(outcome);
3484
+ if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
3485
+ step: `webhook ${outcome.webhook.name}`,
3486
+ message: outcome.webhook.note
3487
+ });
3488
+ });
3489
+ }
3490
+ if ((layout.roles ?? []).length > 0) print("\nRoles");
3491
+ for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3492
+ if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3493
+ if (dryRun) {
3494
+ print(` … would ensure role ${role.name}`);
3495
+ report.roles.push({
3496
+ name: role.name,
3497
+ created: false
3498
+ });
3499
+ return;
3500
+ }
3501
+ const made = await notifications.ensureRole({
3502
+ name: role.name,
3503
+ ...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {}
3504
+ });
3505
+ const outcome = {
3506
+ name: role.name,
3507
+ id: made.id,
3508
+ created: made.created
3509
+ };
3510
+ report.roles.push(outcome);
3511
+ print(` ${made.created ? "✓ created" : "· exists "} role ${role.name} (id ${made.id})`);
3512
+ });
3513
+ if (report.failures.length > 0) {
3514
+ report.status = "fail";
3515
+ print(`\n${report.failures.length} step(s) need attention`);
3516
+ } else if (dryRun) report.status = "dry-run";
3517
+ logger.info({
3518
+ provider: notifications.providerName,
3519
+ channels: report.channels.length,
3520
+ roles: report.roles.length,
3521
+ failures: report.failures.length,
3522
+ status: report.status
3523
+ }, "notifications setup");
3524
+ return report;
3525
+ }
3526
+ //#endregion
3201
3527
  //#region src/commands/npm-bump-versions/npm-bump-versions.ts
3202
3528
  async function runNpmBumpVersions(input) {
3203
3529
  const print = input.print ?? ((line) => console.log(line));
@@ -9324,6 +9650,22 @@ const COMMAND_REGISTRY = [
9324
9650
  }],
9325
9651
  supportsDryRun: true
9326
9652
  },
9653
+ {
9654
+ name: "notifications channel add",
9655
+ description: "Create a notification channel, optionally with a webhook whose URL is stored as a secret",
9656
+ positionals: [{
9657
+ key: "name",
9658
+ message: "Channel name:",
9659
+ type: "input"
9660
+ }],
9661
+ supportsDryRun: true
9662
+ },
9663
+ {
9664
+ name: "notifications setup",
9665
+ description: "Apply the server layout a notifications provider declares in holocron.config.ts",
9666
+ positionals: [],
9667
+ supportsDryRun: true
9668
+ },
9327
9669
  {
9328
9670
  name: "deploy",
9329
9671
  description: "Trigger a deployment via the configured `deployment` capability",
@@ -10804,6 +11146,80 @@ try {
10804
11146
  })).summary.fail > 0) process.exitCode = 1;
10805
11147
  }).command("$0", false, () => {}, async (argv) => {
10806
11148
  await launchGroupMenu("secrets", argv);
11149
+ }), () => {}).command("notifications", "Provision notification channels, webhooks and roles", (g) => g.command("channel", "Manage notification channels", (c) => c.command("add [name]", "Create a channel, optionally with a webhook whose URL is stored as a secret", (y) => y.positional("name", {
11150
+ type: "string",
11151
+ describe: "Channel name (e.g. alerts)"
11152
+ }).option("category", {
11153
+ type: "string",
11154
+ describe: "Put the channel under this category (created if missing)"
11155
+ }).option("topic", {
11156
+ type: "string",
11157
+ describe: "Channel topic"
11158
+ }).option("with-webhook", {
11159
+ type: "boolean",
11160
+ default: false,
11161
+ describe: "Also create a webhook and store its URL as a secret (the URL is never printed)"
11162
+ }).option("webhook-name", {
11163
+ type: "string",
11164
+ describe: "Webhook display name (default: the channel name)"
11165
+ }).option("secret", {
11166
+ type: "string",
11167
+ describe: "Secret to store the URL in (default: HOLOCRON_NOTIFY_WEBHOOK_<CHANNEL>)"
11168
+ }).option("scope", {
11169
+ type: "string",
11170
+ describe: "Secret scope: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
11171
+ }).option("provider", {
11172
+ type: "string",
11173
+ describe: "Which notifications provider, when several can provision"
11174
+ }), async (argv) => {
11175
+ const tokens = tokenContext(argv.token);
11176
+ if (!tokens) return;
11177
+ const [name] = await promptForPositionals(getEntry("notifications channel add"), argv);
11178
+ const loaded = await loadConfig(argv.cwd);
11179
+ applyResolvedConfig(argv, loaded.resolved);
11180
+ if ((await runNotificationsChannelAdd({
11181
+ loaded,
11182
+ context: {
11183
+ repoRoot: argv.cwd,
11184
+ dryRun: argv.dryRun,
11185
+ ...tokens,
11186
+ org: resolveOrg(argv, loaded.resolved)
11187
+ },
11188
+ name,
11189
+ ...argv.category ? { category: argv.category } : {},
11190
+ ...argv.topic ? { topic: argv.topic } : {},
11191
+ ...argv.withWebhook ? { withWebhook: true } : {},
11192
+ ...argv.webhookName ? { webhookName: argv.webhookName } : {},
11193
+ ...argv.secret ? { secret: argv.secret } : {},
11194
+ ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11195
+ ...argv.provider ? { provider: argv.provider } : {}
11196
+ })).status === "fail") process.exitCode = 1;
11197
+ }).command("$0", false, () => {}, async (argv) => {
11198
+ await launchGroupMenu("notifications channel", argv);
11199
+ }), () => {}).command("setup", "Apply the server layout a notifications provider declares in holocron.config.ts", (y) => y.option("scope", {
11200
+ type: "string",
11201
+ describe: "Webhook secret scope: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
11202
+ }).option("provider", {
11203
+ type: "string",
11204
+ describe: "Which notifications provider, when several can provision"
11205
+ }), async (argv) => {
11206
+ const tokens = tokenContext(argv.token);
11207
+ if (!tokens) return;
11208
+ const loaded = await loadConfig(argv.cwd);
11209
+ applyResolvedConfig(argv, loaded.resolved);
11210
+ if ((await runNotificationsSetup({
11211
+ loaded,
11212
+ context: {
11213
+ repoRoot: argv.cwd,
11214
+ dryRun: argv.dryRun,
11215
+ ...tokens,
11216
+ org: resolveOrg(argv, loaded.resolved)
11217
+ },
11218
+ ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11219
+ ...argv.provider ? { provider: argv.provider } : {}
11220
+ })).status === "fail") process.exitCode = 1;
11221
+ }).command("$0", false, () => {}, async (argv) => {
11222
+ await launchGroupMenu("notifications", argv);
10807
11223
  }), () => {}).command("sync", "Sync generated files to other repos", (g) => g.command("github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {
10808
11224
  type: "string",
10809
11225
  default: "theholocron/.github",