@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.
- package/CHANGELOG.md +13 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +267 -94
- package/dist/oauth.js +80 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -207
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- package/vendor/self-host/provenance.json +11 -0
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 (
|
|
8
|
+
check: async (ctx, journal, signal) => {
|
|
7
9
|
if (signal?.aborted)
|
|
8
10
|
return { state: "waiting", reason: "interrupted" };
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
package/dist/onboard-capacity.js
CHANGED
|
@@ -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)
|
package/dist/onboard-existing.js
CHANGED
|
@@ -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
|
|
201
|
+
return pending ? notAdopted(pending) : stopTeam(list.reason);
|
|
165
202
|
if (!list.teams.length)
|
|
166
|
-
return
|
|
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
|
|
171
|
-
return choose
|
|
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
|
|
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
|
|
215
|
-
if (!list.teams.length)
|
|
216
|
-
return waiting("team_inventory_empty");
|
|
258
|
+
return stopTeam(list.reason);
|
|
217
259
|
if (!choose)
|
|
218
|
-
return
|
|
219
|
-
let
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/onboard-github.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|