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

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