@catalyst-cloud/cli 0.15.3 → 0.15.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.5
4
+
5
+ Setup explains what running work on this computer means and lists every repository registered to your Linear team.
6
+
7
+ ## 0.15.4
8
+
9
+ - Setup shows a colour mark and clearer progress, with a plain display when your terminal needs it.
10
+ - Setup separates your next steps from tasks waiting on an owner or admin, and reports when it stops waiting for an account connection.
11
+ - Setup wraps long text by words and keeps numbered steps in order.
12
+
3
13
  ## 0.15.3
4
14
 
5
15
  - Setup can finish once the steps needed to run work are ready. It lists optional next steps and keeps your first-ticket choice when you resume.
package/README.md CHANGED
@@ -133,6 +133,8 @@ catalyst onboard
133
133
  catalyst ready --onboarding --json
134
134
  ```
135
135
 
136
+ `catalyst setup` opens with the Catalyst Cloud mark in your terminal's colours. It asks the terminal whether its background is light or dark; set `CATALYST_THEME=light` or `CATALYST_THEME=dark` when it guesses wrong. `--no-color` or `NO_COLOR` prints plain text without the mark.
137
+
136
138
  The dry run writes no files. `--only <step>` runs one step and reports its scope separately from full onboarding. Exit 10 means a failed step, 11 means waiting, and 12 means a guard refused the action. The private receipt is `install/last-run.json` under your machine's Catalyst state directory; credentials stay in the login config. A fake HOME reports old services and keeps them intact.
137
139
 
138
140
  ### Headless setup (CI, image builds, agent VMs)
package/dist/args.js CHANGED
@@ -64,6 +64,10 @@ export const FLAG_TABLES = {
64
64
  value: false,
65
65
  help: "do not run Catalyst's work on this machine, and do not ask",
66
66
  },
67
+ "no-color": {
68
+ value: false,
69
+ help: "print setup in plain text, without colour or the Catalyst Cloud mark",
70
+ },
67
71
  "resume-from": {
68
72
  value: true,
69
73
  help: "resume at a verified onboarding step",
@@ -459,7 +463,7 @@ export const FLAG_TABLES = {
459
463
  export const VERB_USAGE = {
460
464
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
461
465
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
462
- onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
466
+ onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--json]",
463
467
  install: "install [--skills-dir <dir>] [--force]",
464
468
  status: "status",
465
469
  notice: "notice",
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
2
2
  // their own modules; this file keeps every export the 0.1 tests and the bin import.
3
- import { createSetupRenderer } from "./setup-render.js";
3
+ import { createSetupRenderer, setupBanner, } from "./setup-render.js";
4
4
  import { existsSync, openSync, closeSync } from "node:fs";
5
5
  import { ReadStream as TerminalInput } from "node:tty";
6
6
  import { Writable } from "node:stream";
@@ -210,6 +210,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
210
210
  // CTC-4625: setup passes the install options through to its engine untouched, so they never meet
211
211
  // this CLI's own flag tables. CTC-4680: without --engine, `setup` is the name a person types for
212
212
  // the onboard flow, so it runs exactly that.
213
+ const viaSetup = drawsMark(argv);
213
214
  if (argv[0] === "setup") {
214
215
  const rest = argv.slice(1);
215
216
  const own = rest.indexOf("--") === -1 ? rest : rest.slice(0, rest.indexOf("--"));
@@ -278,6 +279,9 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
278
279
  // Headless runs drop the terminal UI, browser sign-in and every browser action (CTC-4633).
279
280
  const runOnboard = async (args, ctx, headless) => {
280
281
  let ui = headless ? undefined : deps.setupUi;
282
+ // --no-color reaches the renderer as NO_COLOR, so the banner and every step row go plain.
283
+ const noColor = args.flags["no-color"] === true;
284
+ const rendererEnv = noColor ? { ...ctx.env, NO_COLOR: "1" } : ctx.env;
281
285
  let terminal;
282
286
  let input = process.stdin;
283
287
  const originalRaw = Boolean(input.isRaw);
@@ -300,10 +304,18 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
300
304
  }
301
305
  if (input.isTTY) {
302
306
  try {
307
+ const banner = await setupBanner({
308
+ setup: viaSetup,
309
+ env: rendererEnv,
310
+ input,
311
+ stdout: process.stdout,
312
+ stream: bannerStream(ctx),
313
+ noColor,
314
+ });
303
315
  ui = createClackOnboardUi(await import("@clack/prompts"), { input, output: process.stdout }, {
304
316
  baseUrl: () => savedOnboardBaseUrl(ctx.home),
305
317
  verbose: args.flags.verbose === true,
306
- renderer: createSetupRenderer(process.stdout, ctx.env),
318
+ renderer: createSetupRenderer(process.stdout, rendererEnv, banner),
307
319
  disposeRenderer: true,
308
320
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
309
321
  version: manifest.version,
@@ -326,7 +338,14 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
326
338
  });
327
339
  ui = createClackOnboardUi(await import("@clack/prompts"), { input: process.stdin, output }, {
328
340
  baseUrl: () => savedOnboardBaseUrl(ctx.home),
329
- renderer: createSetupRenderer(output, ctx.env),
341
+ renderer: createSetupRenderer(output, rendererEnv, await setupBanner({
342
+ setup: viaSetup,
343
+ env: rendererEnv,
344
+ input: process.stdin,
345
+ stdout: process.stdout,
346
+ stream: bannerStream(ctx),
347
+ noColor,
348
+ })),
330
349
  disposeRenderer: true,
331
350
  signinTimeoutMs: deps.onboardSigninTimeoutMs,
332
351
  version: manifest.version,
@@ -560,6 +579,23 @@ const VERB_HELP_KNOWN = Object.fromEntries([
560
579
  "legacy",
561
580
  "onboard",
562
581
  ].map((v) => [v, true]));
582
+ /** CTC-4680: the Pixel Nucleus mark belongs to `catalyst setup` alone. `setup` runs the onboard
583
+ * flow, so this is read from the verb typed, before setup becomes onboard. */
584
+ export function drawsMark(argv) {
585
+ return argv[0] === "setup";
586
+ }
587
+ /** CTC-4680: the banner goes to stderr, through ctx when stderr is not a terminal. */
588
+ function bannerStream(ctx) {
589
+ if (process.stderr.isTTY === true)
590
+ return process.stderr;
591
+ return {
592
+ write(chunk) {
593
+ for (const line of chunk.replace(/\n$/, "").split("\n"))
594
+ ctx.stderr(line);
595
+ return true;
596
+ },
597
+ };
598
+ }
563
599
  /** CTC-4625: `catalyst setup`, wired to this terminal, @clack/prompts and the keyless login. */
564
600
  async function runSetup(argv, ctx, deps) {
565
601
  if (argv[0] === "--help" || argv[0] === "-h") {
@@ -235,8 +235,9 @@ export function connectChecklistAdapter(options) {
235
235
  // The contract advertised the route for this read; the wait's polls read the checklist alone.
236
236
  const origin = new URL(url).origin;
237
237
  let instruction;
238
- if (options.page.openedBy === null ||
239
- options.page.openedBy === options.step) {
238
+ // One tab per run: a second wait (Keep waiting, or another step) points at the page already
239
+ // open, and the wait still shows its address.
240
+ if (options.page.openedBy === null) {
240
241
  try {
241
242
  await options.openBrowser(url, signal);
242
243
  options.page.openedBy = options.step;
@@ -242,6 +242,9 @@ export function planOnboardHeadless(args, ctx) {
242
242
  delete flags["key-file"];
243
243
  delete flags.runner;
244
244
  delete flags["no-runner"];
245
+ // CATALYST_ONBOARD_HEADLESS=1 alone leaves the flag unset; the plan names the headless input
246
+ // rather than a --no-runner nobody typed (CTC-4739).
247
+ flags.headless = true;
245
248
  if (runner === "yes")
246
249
  flags.runner = true;
247
250
  if (runner === "no")
@@ -97,6 +97,17 @@ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
97
97
  const teamId = selectedOnboardTeam(journal);
98
98
  if (!teamId)
99
99
  return { reason: "team_selection_unverified" };
100
+ // CTC-4742: the per-person repository list names every project a repository belongs to, so a
101
+ // team that uses several repositories shows all of them. A member's list is narrowed to their
102
+ // own access by the server. An older server without the route falls back to the one-per-team
103
+ // registry below.
104
+ const agent = await readExistingOnboardJson(ctx, "/api/v1/agent/repos", signal);
105
+ if (!sameIdentity())
106
+ return { reason: "repository_identity_unverified" };
107
+ if (!("reason" in agent))
108
+ return teamRepositories(ctx, journal, teamId, agent.body, sameIdentity, signal);
109
+ if (agent.status !== 404)
110
+ return { reason: "repository_read_unavailable" };
100
111
  const read = await readExistingOnboardJson(ctx, "/api/v1/repos", signal);
101
112
  if (!sameIdentity())
102
113
  return { reason: "repository_identity_unverified" };
@@ -171,6 +182,162 @@ export async function readOnboardRepositoryInventory(ctx, journal, signal) {
171
182
  }
172
183
  return { repositories };
173
184
  }
185
+ /** The saved login is still the person, account and origin this run started with (and, when
186
+ * `manager`, an owner or admin). Returns that config, or null. */
187
+ function journalIdentity(ctx, journal, manager = false) {
188
+ const cfg = loadConfig(ctx.home);
189
+ return cfg?.user &&
190
+ cfg.account === journal.account &&
191
+ cfg.user.id === journal.membershipId &&
192
+ !!journal.baseUrl &&
193
+ normalizeBaseUrl(cfg.baseUrl) === normalizeBaseUrl(journal.baseUrl) &&
194
+ (!manager || ["owner", "admin"].includes(cfg.user.role))
195
+ ? cfg
196
+ : null;
197
+ }
198
+ const REGISTER_PATH = "/api/v1/agent/project-repositories";
199
+ /** The fresh contract's repository IDs and the repositories registered to the chosen team (by its
200
+ * id), or why they can't be trusted. */
201
+ async function contractIds(ctx, journal, teamId, sameIdentity, signal) {
202
+ const read = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
203
+ if (!sameIdentity())
204
+ return { reason: "repository_identity_unverified" };
205
+ if ("reason" in read)
206
+ return { reason: "repository_contract_unavailable" };
207
+ const doc = object(read.body);
208
+ const account = object(doc?.account);
209
+ const merge = object(doc?.merge);
210
+ if (!doc ||
211
+ account?.id !== journal.account ||
212
+ typeof doc.contractVersion !== "string" ||
213
+ contractVersionInRange(doc.contractVersion, readManifest().tenantContractRange) !== true ||
214
+ !merge ||
215
+ !Array.isArray(merge.repositories) ||
216
+ merge.repositories.length > 10_000 ||
217
+ !Array.isArray(doc.teams) ||
218
+ doc.teams.length > 1_000)
219
+ return { reason: "repository_contract_unverified" };
220
+ // The team's repositories, keyed by the team's id (a team key can be renamed or missing).
221
+ const teams = doc.teams.filter((value) => object(value)?.id === teamId);
222
+ const names = object(object(teams[0])?.repositories)?.registered;
223
+ if (teams.length !== 1 ||
224
+ !Array.isArray(names) ||
225
+ names.length > 1_000 ||
226
+ !names.every((name) => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
227
+ return { reason: "team_selection_unverified" };
228
+ const registered = new Set(names.map((name) => name.toLowerCase()));
229
+ const ids = new Map();
230
+ const idNames = new Map();
231
+ for (const value of merge.repositories) {
232
+ const row = object(value);
233
+ if (!row ||
234
+ typeof row.owner !== "string" ||
235
+ !part.test(row.owner) ||
236
+ typeof row.name !== "string" ||
237
+ !part.test(row.name) ||
238
+ !isRepositoryId(row.repoId))
239
+ return { reason: "repository_contract_unverified" };
240
+ const name = key({ owner: row.owner, name: row.name });
241
+ if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
242
+ return { reason: "repository_contract_unverified" };
243
+ idNames.set(row.repoId, name);
244
+ ids.set(name, [...(ids.get(name) ?? []), row.repoId]);
245
+ }
246
+ // Registering is offered only when this account's contract lists the route (CTC-4742).
247
+ const canRegister = Array.isArray(doc.routes) &&
248
+ doc.routes.some((value) => {
249
+ const route = object(value);
250
+ return route?.method === "POST" && route.path === REGISTER_PATH;
251
+ });
252
+ return { registered, ids, canRegister };
253
+ }
254
+ /** CTC-4742: every repository an active project of the chosen team uses, with its contract ID. */
255
+ async function teamRepositories(ctx, journal, teamId, body, sameIdentity, signal) {
256
+ const list = object(body);
257
+ if (!list || !Array.isArray(list.repositories) || list.repositories.length > 10_000)
258
+ return { reason: "repository_inventory_shape" };
259
+ const contract = await contractIds(ctx, journal, teamId, sameIdentity, signal);
260
+ if ("reason" in contract)
261
+ return contract;
262
+ const repositories = [];
263
+ const seen = new Set();
264
+ for (const value of list.repositories) {
265
+ const row = object(value);
266
+ if (!row ||
267
+ typeof row.id !== "string" ||
268
+ typeof row.owner !== "string" ||
269
+ !part.test(row.owner) ||
270
+ typeof row.name !== "string" ||
271
+ !part.test(row.name))
272
+ return { reason: "repository_inventory_shape" };
273
+ // The contract says which repositories the team uses; this person's list says which of them
274
+ // they can see (the server narrows it for a member).
275
+ const name = key({ owner: row.owner, name: row.name });
276
+ if (!contract.registered.has(name))
277
+ continue;
278
+ if (seen.has(name))
279
+ return { reason: "repository_binding_ambiguous" };
280
+ seen.add(name);
281
+ const ids = contract.ids.get(name);
282
+ if (!ids || ids.length !== 1 || ids[0] !== row.id)
283
+ return { reason: "repository_id_unverified" };
284
+ repositories.push({ teamId, owner: row.owner, name: row.name, repoId: ids[0], registered: true });
285
+ }
286
+ return { repositories, canRegister: contract.canRegister };
287
+ }
288
+ /** CTC-4742: repositories the GitHub App can reach that the team does not use yet, for an owner or
289
+ * admin only (only they can register one). Any read problem means no offers, never a stopped step. */
290
+ async function offeredRepositories(ctx, journal, teamId, registered, signal) {
291
+ if (!journalIdentity(ctx, journal, true))
292
+ return [];
293
+ const read = await readExistingOnboardJson(ctx, "/api/v1/me/repositories/options", signal);
294
+ if (!journalIdentity(ctx, journal, true) || "reason" in read)
295
+ return [];
296
+ const github = object(object(read.body)?.github);
297
+ if (!github || github.connected !== true || !Array.isArray(github.repositories) || github.repositories.length > 10_000)
298
+ return [];
299
+ const used = new Set(registered.map(key));
300
+ const offers = [];
301
+ for (const value of github.repositories) {
302
+ const row = object(value);
303
+ if (!row || typeof row.owner !== "string" || !part.test(row.owner) || typeof row.name !== "string" || !part.test(row.name))
304
+ return [];
305
+ const name = key({ owner: row.owner, name: row.name });
306
+ if (used.has(name))
307
+ continue;
308
+ used.add(name);
309
+ offers.push({ teamId, owner: row.owner, name: row.name, repoId: null, registered: false });
310
+ }
311
+ return offers;
312
+ }
313
+ /** CTC-4742: register one repository to the team's project. Only adds; nothing here removes one. */
314
+ async function registerToTeam(ctx, journal, teamId, repository, signal) {
315
+ // The person who answered the question is the one who registers: checked right before the write.
316
+ const cfg = journalIdentity(ctx, journal, true);
317
+ if (!cfg)
318
+ return "repository_identity_unverified";
319
+ const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
320
+ const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
321
+ if (!bearer)
322
+ return "project_login_refresh_required";
323
+ // Bounded like every read here, so a server that never answers can't hold setup.
324
+ const deadline = AbortSignal.timeout(30_000);
325
+ const bounded = signal ? AbortSignal.any([signal, deadline]) : deadline;
326
+ try {
327
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${REGISTER_PATH}`, {
328
+ method: "POST",
329
+ redirect: "error",
330
+ signal: bounded,
331
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", accept: "application/json" },
332
+ body: JSON.stringify({ teamId, repository }),
333
+ });
334
+ // A response is not evidence: the next read must show the repository on the team.
335
+ return response.status === 200 ? null : "repository_register_unverified";
336
+ }
337
+ catch {
338
+ return signal?.aborted ? "interrupted" : "repository_register_unverified";
339
+ }
340
+ }
174
341
  export function existingRepositoryAdapter(args, choose) {
175
342
  let chosen;
176
343
  const requested = (repositories, journal) => {
@@ -218,7 +385,7 @@ export function existingRepositoryAdapter(args, choose) {
218
385
  return {
219
386
  state: "done",
220
387
  evidence: {
221
- repository: JSON.stringify(rows),
388
+ repository: JSON.stringify(rows.map(({ teamId, owner, name, repoId }) => ({ teamId, owner, name, repoId }))),
222
389
  count: rows.length,
223
390
  checkedAt: ctx.now().getTime(),
224
391
  },
@@ -234,6 +401,14 @@ export function existingRepositoryAdapter(args, choose) {
234
401
  return waiting("repository_inventory_empty");
235
402
  if (!choose)
236
403
  return waiting("repository_choice_required");
404
+ const teamId = list.repositories[0].teamId;
405
+ const offers = list.canRegister
406
+ ? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
407
+ : [];
408
+ const choices = [
409
+ ...list.repositories.map((row) => ({ ...row })),
410
+ ...offers.map((row) => ({ ...row })),
411
+ ];
237
412
  let remove = () => { };
238
413
  const answer = await new Promise((resolve, reject) => {
239
414
  const stopped = () => resolve(null);
@@ -244,9 +419,8 @@ export function existingRepositoryAdapter(args, choose) {
244
419
  signal?.addEventListener("abort", stopped, { once: true });
245
420
  remove = () => signal?.removeEventListener("abort", stopped);
246
421
  Promise.resolve()
247
- .then(() => signal?.aborted
248
- ? null
249
- : choose(list.repositories.map((row) => ({ ...row }))))
422
+ // The hook gets copies: changing one can never change what is matched below.
423
+ .then(() => signal?.aborted ? null : choose(choices.map((row) => ({ ...row }))))
250
424
  .then(resolve, reject);
251
425
  }).finally(() => remove());
252
426
  if (!answer || signal?.aborted)
@@ -259,9 +433,26 @@ export function existingRepositoryAdapter(args, choose) {
259
433
  const names = answer.map((name) => name.toLowerCase());
260
434
  if (new Set(names).size !== names.length)
261
435
  return waiting("repository_selection_unverified");
262
- const rows = names.map((name) => list.repositories.find((row) => key(row) === name));
263
- if (!rows.every((row) => !!row))
436
+ const picked = names.map((name) => choices.find((row) => key(row) === name));
437
+ if (!picked.every((row) => !!row))
264
438
  return waiting("repository_selection_unverified");
439
+ // CTC-4742: a selected offer is registered to the team. Leaving a registered repository
440
+ // unselected never unregisters it; nothing here removes a repository.
441
+ for (const row of picked) {
442
+ if (row.registered !== false)
443
+ continue;
444
+ const failed = await registerToTeam(ctx, journal, teamId, `${row.owner}/${row.name}`, signal);
445
+ if (failed)
446
+ return waiting(failed);
447
+ }
448
+ const fresh = picked.some((row) => row.registered === false)
449
+ ? await readOnboardRepositoryInventory(ctx, journal, signal)
450
+ : list;
451
+ if ("reason" in fresh)
452
+ return waiting(fresh.reason);
453
+ const rows = names.map((name) => fresh.repositories.find((row) => key(row) === name));
454
+ if (!rows.every((row) => !!row))
455
+ return waiting("repository_registration_visibility_pending");
265
456
  chosen = rows;
266
457
  return check(ctx, journal, signal);
267
458
  },
@@ -1,5 +1,8 @@
1
1
  import { setupPartHeading, setupStepView, SETUP_NUMBERS, SETUP_PART_NUMBERS, SETUP_PART_OF, } from "./setup-onboard-copy.js";
2
2
  export const COMPUTER_CHECKS = ["machine", "cli", "skills", "legacy"];
3
+ /** The runner row when no runner was chosen up front, naming where that choice came from, so the
4
+ * plan says why no question comes (CTC-4739). */
5
+ const runnerDeclined = (by) => `this computer will not take work, because ${by}`;
3
6
  const PLAN = [
4
7
  ["On this computer", "signin", "you approve once in your browser"],
5
8
  ["On this computer", "housekeeping", "schedule the daily update"],
@@ -22,7 +25,14 @@ const PLAN = [
22
25
  * part. `json` is the plain-text plan an agent reads beside `--json` (dry run, --json on stderr,
23
26
  * headless): the older groups and the numbers JSON `actions[].number` reports (CTC-4680).
24
27
  */
25
- export function standalonePlan(journal, scope, runnerSelected = false, numbering = "parts") {
28
+ export function standalonePlan(journal, scope,
29
+ /** `true` for --runner, `false` for --no-runner, absent when setup will ask (CTC-4739). */
30
+ runner, numbering = "parts",
31
+ /** Why `runner` is false: the flag on an interactive run, the headless input otherwise. */
32
+ declinedBy = "--no-runner was passed") {
33
+ const runnerSelected = runner === true;
34
+ const RUNNER_DECLINED = runnerDeclined(declinedBy);
35
+ const declined = runner === false;
26
36
  const json = numbering === "json";
27
37
  const includes = (id) => !scope || scope.includes(id);
28
38
  const steps = new Map(journal.steps.map((step) => [step.id, step]));
@@ -53,7 +63,9 @@ export function standalonePlan(journal, scope, runnerSelected = false, numbering
53
63
  if (includes("runner"))
54
64
  row("runner", runnerSelected
55
65
  ? "start a Catalyst runner here with Docker"
56
- : "optional; this computer takes work only if you choose it");
66
+ : declined
67
+ ? RUNNER_DECLINED
68
+ : "optional; this computer takes work only if you choose it");
57
69
  continue;
58
70
  }
59
71
  // The terminal gives repository values their own row; the plain-text plan keeps them folded
@@ -66,9 +78,13 @@ export function standalonePlan(journal, scope, runnerSelected = false, numbering
66
78
  continue;
67
79
  const reviewedDetail = id === "capacity" && includes("runner") && runnerSelected
68
80
  ? "start a Catalyst runner here with Docker"
69
- : id === "capacity" && !includes("capacity")
70
- ? "review the optional runner on this machine; start only if selected"
71
- : detail;
81
+ : id === "capacity" && includes("runner") && declined
82
+ ? includes("capacity")
83
+ ? `${detail}; ${RUNNER_DECLINED}`
84
+ : RUNNER_DECLINED
85
+ : id === "capacity" && !includes("capacity")
86
+ ? "review the optional runner on this machine; start only if selected"
87
+ : detail;
72
88
  rows.push({
73
89
  group: json ? group : setupPartHeading(SETUP_PART_OF[id], false),
74
90
  part: SETUP_PART_OF[id],