@catalyst-cloud/cli 0.14.9 → 0.15.0

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -0
  3. package/dist/args.js +34 -2
  4. package/dist/capabilities.js +2 -1
  5. package/dist/cli-update-notice.js +76 -0
  6. package/dist/cli.js +267 -94
  7. package/dist/oauth.js +80 -18
  8. package/dist/onboard-accounts.js +34 -5
  9. package/dist/onboard-automation-management.js +27 -9
  10. package/dist/onboard-capacity.js +10 -1
  11. package/dist/onboard-existing.js +132 -31
  12. package/dist/onboard-github.js +171 -6
  13. package/dist/onboard-headless.js +647 -0
  14. package/dist/onboard-interrupts.js +41 -0
  15. package/dist/onboard-next.js +215 -0
  16. package/dist/onboard-permissions.js +100 -0
  17. package/dist/onboard-personal.js +46 -10
  18. package/dist/onboard-runner-cloud.js +213 -0
  19. package/dist/onboard-runner-egress.js +67 -0
  20. package/dist/onboard-runner.js +780 -0
  21. package/dist/onboard-runtime.js +48 -8
  22. package/dist/onboard-standalone-copy.js +110 -0
  23. package/dist/onboard-team-create.js +267 -0
  24. package/dist/onboard-ui.js +623 -29
  25. package/dist/onboard-workflow.js +311 -168
  26. package/dist/onboard-workspace.js +52 -5
  27. package/dist/onboard.js +359 -207
  28. package/dist/setup-events.js +100 -0
  29. package/dist/setup-onboard-copy.js +438 -0
  30. package/dist/setup-prompt-frame.js +94 -0
  31. package/dist/setup-prompts.js +108 -0
  32. package/dist/setup-render.js +339 -0
  33. package/dist/setup-signin.js +187 -0
  34. package/dist/setup.js +426 -0
  35. package/package.json +7 -3
  36. package/skills/catalyst-github/SKILL.md +1 -1
  37. package/skills/catalyst-linear/SKILL.md +1 -1
  38. package/skills/catalyst-onboard/SKILL.md +3 -2
  39. package/skills/run-this-project/SKILL.md +1 -1
  40. package/skills/unstick/SKILL.md +1 -1
  41. package/skills/what-needs-me/SKILL.md +1 -1
  42. package/skills/what-this-repo-needs/SKILL.md +1 -1
  43. package/skills/whats-happening/SKILL.md +1 -1
  44. package/vendor/README.md +4 -0
  45. package/vendor/self-host/compose.yaml +110 -0
  46. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
  47. package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
  48. package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
  49. package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
  50. package/vendor/self-host/provenance.json +11 -0
@@ -1,15 +1,33 @@
1
- /** Rule management is unbuilt. This disposition does not assess the customer's actual rules.
2
- * Cloud readiness and first-ticket eligibility must still reject conflicting or unreadable rules.
3
- */
4
- export function onboardAutomationManagementAdapter(input = {}) {
1
+ import { selectedOnboardTeam } from "./onboard-existing.js";
2
+ /** Setup cannot change Linear's pull request automations. It reports what cloud readiness saw for
3
+ * the selected team, as the workflow step recorded it, and never reads the network itself. Only a
4
+ * fresh record for the same team proves "nothing to change"; anything else stays the plain skip,
5
+ * which keeps counting as satisfied because readiness and first-ticket eligibility check again. */
6
+ export function onboardAutomationManagementAdapter() {
5
7
  return {
6
- check: async (_ctx, _journal, signal) => {
8
+ check: async (ctx, journal, signal) => {
7
9
  if (signal?.aborted)
8
10
  return { state: "waiting", reason: "interrupted" };
9
- input.message?.("Linear automation management is unavailable. Setup made no automation change. Cloud readiness still checks your existing rules.");
10
- if (signal?.aborted)
11
- return { state: "waiting", reason: "interrupted" };
12
- return { state: "skipped", reason: "automation_management_unavailable" };
11
+ const skip = {
12
+ state: "skipped",
13
+ reason: "automation_management_unavailable",
14
+ };
15
+ const adopt = journal.steps.find((step) => step.id === "linear.adopt");
16
+ const team = selectedOnboardTeam(journal);
17
+ const evidence = adopt?.state === "done" ? adopt.evidence : undefined;
18
+ const checkedAt = evidence?.checkedAt;
19
+ const automations = evidence?.automations;
20
+ if (!team ||
21
+ evidence?.team !== team ||
22
+ typeof checkedAt !== "number" ||
23
+ ctx.now().getTime() - checkedAt >= 300_000 ||
24
+ ctx.now().getTime() < checkedAt ||
25
+ typeof automations !== "string" ||
26
+ !/^(?:none|(?:open|review|ready|merge)(?:,(?:open|review|ready|merge))*)$/.test(automations))
27
+ return skip;
28
+ return automations === "none"
29
+ ? { state: "done", reason: "automations_compatible" }
30
+ : { ...skip, evidence: { automations } };
13
31
  },
14
32
  };
15
33
  }
@@ -104,7 +104,7 @@ const selection = (journal) => JSON.stringify([
104
104
  selectedOnboardTeam(journal),
105
105
  selectedOnboardRepositories(journal),
106
106
  ]);
107
- async function liveTeamKey(ctx, teamId, signal) {
107
+ export async function liveTeamKey(ctx, teamId, signal) {
108
108
  const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
109
109
  if ("reason" in read)
110
110
  return null;
@@ -132,6 +132,15 @@ async function liveTeamKey(ctx, teamId, signal) {
132
132
  }
133
133
  return selected;
134
134
  }
135
+ /** Whether `teamKey`'s pool admits hosts, from a fresh capacity read. Null when the read is
136
+ * unavailable, stale or does not name the team. */
137
+ export async function onboardTeamAdmission(ctx, teamKey, signal) {
138
+ const read = await readExistingOnboardJson(ctx, route, signal);
139
+ if ("reason" in read)
140
+ return null;
141
+ const teams = snapshot(read.body, ctx.now().getTime())?.buckets.flatMap((b) => b.teams.filter((t) => t.key === teamKey));
142
+ return teams?.length ? teams.some((t) => t.admission) : null;
143
+ }
135
144
  async function liveIdentity(ctx, journal, signal) {
136
145
  const read = await readExistingOnboardJson(ctx, "/api/v1/me", signal);
137
146
  if ("reason" in read)
@@ -1,5 +1,7 @@
1
1
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
2
  import { CliError } from "./errors.js";
3
+ import { onboardReasonText } from "./onboard-next.js";
4
+ import { CREATE_TEAM_CHOICE, postNewTeam, teamCreateOffer, } from "./onboard-team-create.js";
3
5
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
4
6
  const waiting = (reason) => ({
5
7
  state: "waiting",
@@ -9,6 +11,25 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
9
11
  ? value
10
12
  : null;
11
13
  const label = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
14
+ /** A team this setup created whose workflow adoption has not been seen yet: kept so a resume can
15
+ * select it once it is adopted, and so its retry command stays on screen until then. */
16
+ export function createdOnboardTeam(journal) {
17
+ const step = journal?.steps.find((row) => row.id === "linear.team");
18
+ if (step?.state !== "waiting" || step.reason !== "team_created_not_adopted")
19
+ return undefined;
20
+ const id = step.evidence?.team, key = step.evidence?.teamKey;
21
+ return typeof id === "string" &&
22
+ idPattern.test(id) &&
23
+ typeof key === "string" &&
24
+ idPattern.test(key)
25
+ ? { id, key }
26
+ : undefined;
27
+ }
28
+ const notAdopted = (team) => ({
29
+ state: "waiting",
30
+ reason: "team_created_not_adopted",
31
+ evidence: { team: team.id, teamKey: team.key },
32
+ });
12
33
  /** A receipt keeps an explicit selection, never the team used to inspect the tenant grant. */
13
34
  export function selectedOnboardTeam(journal) {
14
35
  const step = journal?.steps.find((row) => row.id === "linear.team");
@@ -144,8 +165,17 @@ async function observe(ctx, id, signal) {
144
165
  }
145
166
  return { checkedAt: row.checkedAt, revision: row.workflowRev, checks };
146
167
  }
147
- export function existingLinearAdapters(args, choose) {
168
+ export function existingLinearAdapters(args, choose, nameNewTeam, message = () => { }) {
148
169
  let chosen;
170
+ let uncertain;
171
+ const uncertainResult = (state = "waiting") => uncertain?.key
172
+ ? {
173
+ state,
174
+ reason: "team_create_unverified",
175
+ evidence: { teamKey: uncertain.key },
176
+ }
177
+ : undefined;
178
+ const stopTeam = (reason) => uncertainResult() ?? waiting(reason);
149
179
  const requested = (teams, journal) => {
150
180
  const explicit = args.flags.team;
151
181
  if (typeof explicit === "string") {
@@ -155,24 +185,38 @@ export function existingLinearAdapters(args, choose) {
155
185
  const keys = teams.filter((team) => team.key === explicit);
156
186
  return keys.length === 1 ? keys[0] : undefined;
157
187
  }
158
- const saved = chosen ?? selectedOnboardTeam(journal);
188
+ const saved = chosen ?? selectedOnboardTeam(journal) ?? createdOnboardTeam(journal)?.id;
159
189
  return saved ? teams.find((team) => team.id === saved) : undefined;
160
190
  };
161
191
  const teamCheck = async (ctx, journal, signal) => {
192
+ const prior = journal.steps.find((step) => step.id === "linear.team");
193
+ const key = prior?.evidence?.teamKey;
194
+ if (prior?.reason === "team_create_unverified" &&
195
+ typeof key === "string" &&
196
+ /^[A-Z][A-Z0-9]{0,6}$/.test(key))
197
+ uncertain = { key, problem: onboardReasonText(prior) };
198
+ const pending = createdOnboardTeam(journal);
162
199
  const list = await readOnboardTeamInventory(ctx, signal);
163
200
  if ("reason" in list)
164
- return waiting(list.reason);
201
+ return pending ? notAdopted(pending) : stopTeam(list.reason);
165
202
  if (!list.teams.length)
166
- return waiting("team_inventory_empty");
203
+ return pending ? notAdopted(pending) : stopTeam("team_inventory_empty");
167
204
  const team = requested(list.teams, journal);
168
205
  if (!team) {
206
+ // The census lists a created team once its workflow is adopted (`catalyst team adopt`).
207
+ if (pending)
208
+ return notAdopted(pending);
169
209
  if (typeof args.flags.team === "string")
170
- return waiting("team_selection_unverified");
171
- return choose ? { state: "pending" } : waiting("team_choice_required");
210
+ return stopTeam("team_selection_unverified");
211
+ return choose
212
+ ? (uncertainResult("pending") ?? { state: "pending" })
213
+ : stopTeam("team_choice_required");
172
214
  }
173
215
  const result = await observe(ctx, team.id, signal);
174
216
  if (!result || result.checks.get("team_visible") !== "pass")
175
- return waiting("team_selection_unverified");
217
+ return pending
218
+ ? notAdopted(pending)
219
+ : stopTeam("team_selection_unverified");
176
220
  return {
177
221
  state: "done",
178
222
  evidence: {
@@ -211,32 +255,89 @@ export function existingLinearAdapters(args, choose) {
211
255
  act: async (ctx, journal, signal) => {
212
256
  const list = await readOnboardTeamInventory(ctx, signal);
213
257
  if ("reason" in list)
214
- return waiting(list.reason);
215
- if (!list.teams.length)
216
- return waiting("team_inventory_empty");
258
+ return stopTeam(list.reason);
217
259
  if (!choose)
218
- return waiting("team_choice_required");
219
- let remove = () => { };
220
- const answer = await new Promise((resolve, reject) => {
221
- const stopped = () => resolve(null);
222
- if (signal?.aborted) {
223
- stopped();
224
- return;
260
+ return stopTeam(list.teams.length ? "team_choice_required" : "team_inventory_empty");
261
+ let offer = nameNewTeam && typeof args.flags.team !== "string"
262
+ ? await teamCreateOffer(ctx, journal, signal)
263
+ : undefined;
264
+ if (signal?.aborted)
265
+ return stopTeam("interrupted");
266
+ if (!list.teams.length && !offer?.available)
267
+ return stopTeam("team_inventory_empty");
268
+ const ask = (question) => {
269
+ let remove = () => { };
270
+ return new Promise((resolve, reject) => {
271
+ const stopped = () => resolve(null);
272
+ if (signal?.aborted) {
273
+ stopped();
274
+ return;
275
+ }
276
+ signal?.addEventListener("abort", stopped, { once: true });
277
+ remove = () => signal?.removeEventListener("abort", stopped);
278
+ Promise.resolve()
279
+ .then(() => (signal?.aborted ? null : question()))
280
+ .then(resolve, reject);
281
+ }).finally(() => remove());
282
+ };
283
+ if (uncertain?.problem)
284
+ message(uncertain.problem);
285
+ // Going back from naming, or Linear refusing the person, returns here; each pass asks again.
286
+ for (;;) {
287
+ const answer = await ask(() => choose(list.teams.map((team) => ({ ...team })), offer ? { ...offer } : undefined));
288
+ if (!answer || signal?.aborted)
289
+ return stopTeam("interrupted");
290
+ if (answer !== CREATE_TEAM_CHOICE) {
291
+ if (!list.teams.some((team) => team.id === answer))
292
+ return stopTeam("team_selection_unverified");
293
+ chosen = answer;
294
+ return teamCheck(ctx, journal, signal);
295
+ }
296
+ if (!offer?.available || !nameNewTeam)
297
+ return stopTeam("team_selection_unverified");
298
+ let question = uncertain ? { ...uncertain } : {};
299
+ for (;;) {
300
+ const named = await ask(() => nameNewTeam({ ...question }));
301
+ if (signal?.aborted)
302
+ return stopTeam("interrupted");
303
+ if (!named)
304
+ break;
305
+ const reply = await postNewTeam(ctx, journal, named, signal, uncertain?.key);
306
+ if (reply.kind === "reask") {
307
+ question = { ...named, problem: reply.message };
308
+ continue;
309
+ }
310
+ if (reply.kind === "refused") {
311
+ message(reply.linearMessage
312
+ ? `${reply.message}\nLinear said: ${reply.linearMessage}`
313
+ : reply.message);
314
+ offer = { available: false, reason: reply.message };
315
+ if (!list.teams.length)
316
+ return stopTeam("team_inventory_empty");
317
+ break;
318
+ }
319
+ if (reply.kind === "failed") {
320
+ if (reply.message)
321
+ message(reply.message);
322
+ // The key rides along when the team may exist, so the next action can name it.
323
+ return reply.key
324
+ ? {
325
+ state: "waiting",
326
+ reason: reply.reason,
327
+ evidence: { teamKey: reply.key },
328
+ }
329
+ : stopTeam(reply.reason);
330
+ }
331
+ const team = reply.team;
332
+ if (!reply.adoption.adopted) {
333
+ message(`Created Linear team ${team.name} (${team.key}), but Catalyst's workflow is not set up on it yet: ${reply.adoption.reason}`);
334
+ return notAdopted(team);
335
+ }
336
+ message(`Created Linear team ${team.name} (${team.key}) and set up Catalyst's workflow on it.`);
337
+ chosen = team.id;
338
+ return teamCheck(ctx, journal, signal);
225
339
  }
226
- signal?.addEventListener("abort", stopped, { once: true });
227
- remove = () => signal?.removeEventListener("abort", stopped);
228
- Promise.resolve()
229
- .then(() => signal?.aborted
230
- ? null
231
- : choose(list.teams.map((team) => ({ ...team }))))
232
- .then(resolve, reject);
233
- }).finally(() => remove());
234
- if (!answer || signal?.aborted)
235
- return waiting("interrupted");
236
- if (!list.teams.some((team) => team.id === answer))
237
- return waiting("team_selection_unverified");
238
- chosen = answer;
239
- return teamCheck(ctx, journal, signal);
340
+ }
240
341
  },
241
342
  },
242
343
  };
@@ -1,6 +1,7 @@
1
1
  import { finishOnTheWeb } from "./consent-browser.js";
2
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
3
3
  import { pollConsent } from "./onboard-consent.js";
4
+ import { GITHUB_PERMISSION, githubInstallationPage, parsePermissions, } from "./onboard-permissions.js";
4
5
  const STATUS = "/api/v1/me/connections/github/workspace";
5
6
  const HANDOFF = "/connect/github/workspace/handoff";
6
7
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
@@ -19,6 +20,16 @@ const waiting = (reason) => ({
19
20
  reason,
20
21
  });
21
22
  const nullableText = (v) => v === null || typeof v === "string";
23
+ const orgPattern = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
24
+ const repositoryPattern = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/;
25
+ const coverageUnknown = new Set([
26
+ "registry-unavailable",
27
+ "over-bound",
28
+ "not-run",
29
+ "installation-unverified",
30
+ "unreachable",
31
+ "listing-truncated",
32
+ ]);
22
33
  /** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
23
34
  async function read(ctx, journal, path, timeoutMs, external) {
24
35
  const cfg = loadConfig(ctx.home);
@@ -116,6 +127,9 @@ function observation(body, now) {
116
127
  row.installations.length + row.pending.length > 100)
117
128
  return waiting("github_installation_status_shape");
118
129
  const ids = [];
130
+ const granted = [];
131
+ let outdated;
132
+ let permissionsUnverified = false;
119
133
  let unknown = false;
120
134
  let allConnected = row.installations.length > 0;
121
135
  let checkedAt = now + 30_000;
@@ -128,7 +142,7 @@ function observation(body, now) {
128
142
  ids.includes(item.installationId) ||
129
143
  !nullableText(item.githubOrg) ||
130
144
  (typeof item.githubOrg === "string" &&
131
- !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(item.githubOrg)) ||
145
+ !orgPattern.test(item.githubOrg)) ||
132
146
  !verification)
133
147
  return waiting("github_installation_status_shape");
134
148
  ids.push(item.installationId);
@@ -152,20 +166,41 @@ function observation(body, now) {
152
166
  verification.missing.length > 128 ||
153
167
  !verification.missing.every((name) => typeof name === "string" &&
154
168
  (/^[A-Za-z0-9_-]{1,128}$/.test(name) ||
155
- /^[A-Za-z0-9_-]{1,80} \((?:read|write|admin)\)$/.test(name)))))
169
+ GITHUB_PERMISSION.test(name)))))
156
170
  return waiting("github_installation_status_shape");
157
171
  checkedAt = Math.min(checkedAt, verification.checkedAt);
158
172
  if (verification.state === "unreachable")
159
173
  unknown = true;
160
174
  if (verification.state !== "connected")
161
175
  allConnected = false;
176
+ // CTC-4629: the typed verdict must agree with the verification it was derived from.
177
+ const org = typeof item.githubOrg === "string" ? item.githubOrg : null;
178
+ const permissions = parsePermissions(item.permissions, {
179
+ grant: "github-installation",
180
+ action: "review-permissions",
181
+ label: GITHUB_PERMISSION,
182
+ actorFor: (url) => githubInstallationPage(url, item.installationId, org, true)
183
+ ?.actor ?? null,
184
+ });
185
+ if (permissions === null ||
186
+ (permissions?.state === "current" &&
187
+ verification.state !== "connected") ||
188
+ (permissions?.state === "outdated" &&
189
+ verification.state !== "missing-scope"))
190
+ return waiting("github_installation_status_shape");
191
+ if (permissions?.state === "current")
192
+ granted.push(`${org ?? item.installationId}: ${permissions.granted.join(", ")}`);
193
+ if (permissions?.state === "unknown")
194
+ permissionsUnverified = true;
195
+ if (permissions?.state === "outdated" && !outdated)
196
+ outdated = permissionsOutdated(item.installationId, org, permissions);
162
197
  }
163
198
  for (const value of row.pending) {
164
199
  const pending = object(value);
165
200
  if (!pending ||
166
201
  !nullableText(pending.githubOrg) ||
167
202
  (typeof pending.githubOrg === "string" &&
168
- !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(pending.githubOrg)) ||
203
+ !orgPattern.test(pending.githubOrg)) ||
169
204
  typeof pending.requestedAt !== "number" ||
170
205
  !Number.isFinite(pending.requestedAt) ||
171
206
  pending.requestedAt < 0)
@@ -173,19 +208,146 @@ function observation(body, now) {
173
208
  }
174
209
  if (row.connected !== allConnected)
175
210
  return waiting("github_installation_status_shape");
211
+ const coverage = repositoryCoverage(row.repositories, ids);
212
+ if (coverage === null)
213
+ return waiting("github_installation_status_shape");
176
214
  if (unknown)
177
215
  return waiting("github_installation_status_unavailable");
216
+ if (outdated)
217
+ return outdated;
178
218
  if (!allConnected)
179
219
  return { state: "pending" };
220
+ if (permissionsUnverified)
221
+ return waiting("github_app_permissions_unverified");
222
+ if (coverage)
223
+ return coverage;
180
224
  return {
181
225
  state: "done",
182
226
  evidence: {
183
227
  provider: "github",
184
228
  installation: JSON.stringify(ids),
185
229
  checkedAt,
230
+ ...(granted.length ? { granted: granted.join("; ") } : {}),
186
231
  },
187
232
  };
188
233
  }
234
+ /** A wait whose fix is a page on github.com: polling longer cannot finish it. */
235
+ const githubSideAction = (result) => result.state === "waiting" &&
236
+ (result.reason === "github_app_permissions_outdated" ||
237
+ result.reason === "github_app_repository_missing" ||
238
+ result.reason === "github_app_permissions_unverified" ||
239
+ result.reason === "github_app_repository_access_unverified");
240
+ /** A pending permission request is the org's to accept on GitHub, never a new installation. */
241
+ function permissionsOutdated(installation, org, permissions) {
242
+ const login = githubInstallationPage(new URL(permissions.url), installation, org, true)?.login;
243
+ const name = org ?? login;
244
+ return {
245
+ state: "waiting",
246
+ reason: "github_app_permissions_outdated",
247
+ evidence: {
248
+ provider: "github",
249
+ grant: "github-installation",
250
+ installation,
251
+ ...(name ? { org: name } : {}),
252
+ granted: permissions.granted.join(", "),
253
+ missing: permissions.missing.join(", "),
254
+ url: permissions.url,
255
+ actor: permissions.actor,
256
+ },
257
+ };
258
+ }
259
+ /** CTC-4629: whether the installations reach every repository the account's projects register.
260
+ * `undefined` when covered or when an older cloud sent no verdict; `null` for a malformed one. */
261
+ function repositoryCoverage(value, installations) {
262
+ if (value === undefined)
263
+ return undefined;
264
+ const row = object(value);
265
+ if (!row)
266
+ return null;
267
+ if (row.state === "unknown")
268
+ return typeof row.reason === "string" && coverageUnknown.has(row.reason)
269
+ ? waiting("github_app_repository_access_unverified")
270
+ : null;
271
+ const names = (list) => Array.isArray(list) &&
272
+ list.length <= 50 &&
273
+ list.every((name) => typeof name === "string" && repositoryPattern.test(name));
274
+ if (!names(row.checked))
275
+ return null;
276
+ const checked = row.checked;
277
+ if (row.state === "covered")
278
+ return row.missing === undefined && row.unchecked === undefined ? undefined : null;
279
+ if (row.state !== "missing" ||
280
+ !Array.isArray(row.missing) ||
281
+ row.missing.length < 1 ||
282
+ row.missing.length > 50 ||
283
+ !Array.isArray(row.unchecked) ||
284
+ row.unchecked.length > 50 ||
285
+ !row.unchecked.every((value) => {
286
+ const item = object(value);
287
+ return (!!item &&
288
+ names([item.repository]) &&
289
+ !checked.includes(item.repository) &&
290
+ typeof item.reason === "string" &&
291
+ coverageUnknown.has(item.reason));
292
+ }))
293
+ return null;
294
+ const results = [];
295
+ for (const value of row.missing) {
296
+ const item = object(value);
297
+ if (!item ||
298
+ typeof item.repository !== "string" ||
299
+ !repositoryPattern.test(item.repository) ||
300
+ !checked.includes(item.repository) ||
301
+ !nullableText(item.githubOrg) ||
302
+ (typeof item.githubOrg === "string" && !orgPattern.test(item.githubOrg)))
303
+ return null;
304
+ const org = item.githubOrg;
305
+ if (item.installationId === null) {
306
+ if (item.settingsUrl !== null || item.actor !== null)
307
+ return null;
308
+ results.push({
309
+ state: "pending",
310
+ reason: "github_app_repository_not_installed",
311
+ evidence: {
312
+ provider: "github",
313
+ repository: item.repository,
314
+ ...(org ? { org } : {}),
315
+ },
316
+ });
317
+ continue;
318
+ }
319
+ if (typeof item.installationId !== "string" ||
320
+ !installations.includes(item.installationId) ||
321
+ typeof item.settingsUrl !== "string" ||
322
+ item.settingsUrl.length > 2048)
323
+ return null;
324
+ let url;
325
+ try {
326
+ url = new URL(item.settingsUrl);
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ const page = githubInstallationPage(url, item.installationId, org, false);
332
+ if (!page || url.protocol !== "https:" || url.username || url.password || url.search || url.hash || page.actor !== item.actor)
333
+ return null;
334
+ results.push({
335
+ state: "waiting",
336
+ reason: "github_app_repository_missing",
337
+ evidence: {
338
+ provider: "github",
339
+ installation: item.installationId,
340
+ ...(org ? { org } : {}),
341
+ repository: item.repository,
342
+ url: url.toString(),
343
+ actor: page.actor,
344
+ },
345
+ });
346
+ }
347
+ // A repository an existing installation lacks is fixed on GitHub; one with no installation at all
348
+ // is this step's own install action, which runs only once nothing else waits.
349
+ return results.find((result) => result.state === "waiting") ?? results[0];
350
+ }
189
351
  function safeHandoff(body, ctx) {
190
352
  const row = object(body);
191
353
  const cfg = loadConfig(ctx.home);
@@ -263,6 +425,8 @@ export function githubInstallationAdapter(options) {
263
425
  return before.result;
264
426
  if (before.unsupported)
265
427
  return waiting("cloud_capability_unavailable");
428
+ if (!options.wait)
429
+ return waiting("github_installation_approval_required");
266
430
  const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
267
431
  if ("status" in start && [404, 405].includes(start.status))
268
432
  return waiting("cloud_capability_unavailable");
@@ -291,7 +455,6 @@ export function githubInstallationAdapter(options) {
291
455
  state: "refused",
292
456
  reason: "github_installation_admin_required",
293
457
  };
294
- ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
295
458
  // The signed URL is transient credential material: only the browser receives it.
296
459
  let browserUnavailable = false;
297
460
  try {
@@ -316,7 +479,8 @@ export function githubInstallationAdapter(options) {
316
479
  outcome: "waiting",
317
480
  reason: "cloud_capability_unavailable",
318
481
  };
319
- if (latest.reason === "github_installation_login_refresh_required")
482
+ if (latest.reason === "github_installation_login_refresh_required" ||
483
+ githubSideAction(latest))
320
484
  return { outcome: "waiting", reason: latest.reason };
321
485
  if (latest.state === "done")
322
486
  return { outcome: "connected" };
@@ -330,7 +494,8 @@ export function githubInstallationAdapter(options) {
330
494
  const result = options.wait
331
495
  ? await options.wait("Waiting for GitHub App approval", run)
332
496
  : await run();
333
- if (result.state === "done")
497
+ // An installation that now exists but still needs a GitHub-side change keeps its action URL.
498
+ if (result.state === "done" || githubSideAction(latest))
334
499
  return latest;
335
500
  return browserUnavailable && result.reason === "consent_timeout"
336
501
  ? waiting("github_installation_browser_unavailable")