@catalyst-cloud/cli 0.14.4 → 0.14.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.5
4
+
5
+ - Onboarding checks the server's advertised support before provider and repository setup. An unavailable route waits with a public web link instead of reporting declined consent.
6
+ - GitHub App installation can open a verified browser handoff and checks every installation with fresh live probes. It remains separate from your personal GitHub connection and repository selection.
7
+ - A route removed during browser approval stops with a waiting result. Signed provider approval links remain out of logs and receipts.
8
+
3
9
  ## 0.14.4
4
10
 
5
11
  - Personal Linear and GitHub approval links stay out of logs and receipts. Setup validates the signed browser destination and its expiry before opening it.
@@ -0,0 +1,177 @@
1
+ import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
2
+ import { contractVersionInRange } from "./contract.js";
3
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ const safePages = {
7
+ connections: "/a/account/connections",
8
+ personalConnections: "/settings/connected-accounts",
9
+ };
10
+ const routeKey = (route) => `${route.method} ${route.path}`;
11
+ const waiting = (reason, path) => ({
12
+ state: "waiting",
13
+ reason,
14
+ ...(path ? { evidence: { path } } : {}),
15
+ });
16
+ /** Fresh contract advertisement proves route availability, never consent or resource readiness.
17
+ * No cache, token refresh or local write may create a stale positive capability. */
18
+ async function read(ctx, journal, timeoutMs, external) {
19
+ if (external?.aborted)
20
+ return { reason: "interrupted" };
21
+ const cfg = loadConfig(ctx.home);
22
+ if (!cfg?.user)
23
+ return { reason: "personal_login_required" };
24
+ const personId = cfg.user.id;
25
+ if ((journal.account ?? journal.tenant) !== cfg.account ||
26
+ journal.membershipId !== cfg.user.id ||
27
+ !journal.baseUrl ||
28
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
29
+ return { reason: "onboarding_capability_identity_unverified" };
30
+ let origin;
31
+ try {
32
+ const url = new URL(cfg.baseUrl);
33
+ if (url.protocol !== "https:" ||
34
+ url.username ||
35
+ url.password ||
36
+ url.search ||
37
+ url.hash ||
38
+ url.pathname !== "/")
39
+ throw new Error();
40
+ origin = url.origin;
41
+ }
42
+ catch {
43
+ return { reason: "onboarding_capability_identity_unverified" };
44
+ }
45
+ const expiry = cfg.auth
46
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
47
+ : 0;
48
+ const bearer = cfg.key ||
49
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
50
+ ? cfg.auth.accessToken
51
+ : undefined);
52
+ if (!bearer)
53
+ return { reason: "onboarding_capability_login_refresh_required", origin };
54
+ const owned = new AbortController();
55
+ const signal = external
56
+ ? AbortSignal.any([external, owned.signal])
57
+ : owned.signal;
58
+ const timer = setTimeout(() => owned.abort(), timeoutMs);
59
+ let remove = () => { };
60
+ try {
61
+ return await new Promise((resolve) => {
62
+ const stopped = () => resolve({
63
+ reason: external?.aborted
64
+ ? "interrupted"
65
+ : "onboarding_capability_unavailable",
66
+ origin,
67
+ });
68
+ if (signal.aborted) {
69
+ stopped();
70
+ return;
71
+ }
72
+ signal.addEventListener("abort", stopped, { once: true });
73
+ remove = () => signal.removeEventListener("abort", stopped);
74
+ Promise.resolve()
75
+ .then(async () => {
76
+ if (signal.aborted)
77
+ return { reason: "interrupted", origin };
78
+ const response = await ctx.fetch(`${origin}/api/v1/agent/contract`, {
79
+ method: "GET",
80
+ redirect: "error",
81
+ signal,
82
+ headers: {
83
+ authorization: `Bearer ${bearer}`,
84
+ accept: "application/json",
85
+ "cache-control": "no-cache",
86
+ },
87
+ });
88
+ if (response.status !== 200)
89
+ return { reason: "onboarding_capability_unavailable", origin };
90
+ const body = object(await response.json());
91
+ const current = loadConfig(ctx.home);
92
+ if (!current?.user ||
93
+ current.account !== cfg.account ||
94
+ current.user.id !== personId ||
95
+ normalizeBaseUrl(current.baseUrl) !== origin ||
96
+ object(body?.account)?.id !== cfg.account)
97
+ return {
98
+ reason: "onboarding_capability_identity_unverified",
99
+ origin,
100
+ };
101
+ if (!body ||
102
+ typeof body.contractVersion !== "string" ||
103
+ contractVersionInRange(body.contractVersion, readManifest().tenantContractRange) !== true)
104
+ return { reason: "onboarding_capability_unavailable", origin };
105
+ const advertised = object(body.onboarding), web = object(advertised?.web);
106
+ if (!advertised)
107
+ return { reason: "cloud_capability_unavailable", origin };
108
+ if (advertised.schema !== 1 ||
109
+ !Array.isArray(advertised.routes) ||
110
+ advertised.routes.length > 100 ||
111
+ !web ||
112
+ web.connections !== safePages.connections ||
113
+ web.personalConnections !== safePages.personalConnections)
114
+ return { reason: "onboarding_capability_unavailable", origin };
115
+ const routes = new Set();
116
+ for (const value of advertised.routes) {
117
+ const row = object(value);
118
+ if (!row ||
119
+ !["GET", "POST"].includes(row.method) ||
120
+ typeof row.path !== "string" ||
121
+ row.path.length > 240 ||
122
+ !/^\/(?:api\/v1\/|connect\/|me\/)[A-Za-z0-9_/-]+$/.test(row.path) ||
123
+ row.personalBearer !== true)
124
+ return { reason: "onboarding_capability_unavailable", origin };
125
+ const key = `${row.method} ${row.path}`;
126
+ if (routes.has(key))
127
+ return { reason: "onboarding_capability_unavailable", origin };
128
+ routes.add(key);
129
+ }
130
+ return { capabilities: { routes, web: { ...safePages } }, origin };
131
+ })
132
+ .then((result) => (signal.aborted ? stopped() : resolve(result)), () => signal.aborted
133
+ ? stopped()
134
+ : resolve({
135
+ reason: "onboarding_capability_unavailable",
136
+ origin,
137
+ }));
138
+ });
139
+ }
140
+ finally {
141
+ clearTimeout(timer);
142
+ remove();
143
+ }
144
+ }
145
+ /** Guard both the observation and action before their first endpoint request. A route's real
146
+ * refusal is preserved only after the same-account cloud advertised personal bearer support. */
147
+ export function guardOnboardCapabilities(adapter, options) {
148
+ const timeoutMs = options.requestTimeoutMs ?? 30_000;
149
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
150
+ throw new Error("onboarding_capability_timeout_invalid");
151
+ const run = async (ctx, journal, signal, requirements, work) => {
152
+ const advertised = await read(ctx, journal, timeoutMs, signal);
153
+ if ("reason" in advertised ||
154
+ requirements.some((route) => !advertised.capabilities.routes.has(routeKey(route)))) {
155
+ const reason = "reason" in advertised
156
+ ? advertised.reason
157
+ : "cloud_capability_unavailable";
158
+ const url = advertised.origin
159
+ ? `${advertised.origin}${safePages[options.fallback]}`
160
+ : undefined;
161
+ if (reason === "cloud_capability_unavailable" && url)
162
+ options.message?.(`This setup step is not available on this server yet. Continue in the web app: ${url}`);
163
+ return waiting(reason, reason === "cloud_capability_unavailable" ? url : undefined);
164
+ }
165
+ if (signal?.aborted)
166
+ return waiting("interrupted");
167
+ return work();
168
+ };
169
+ return {
170
+ check: (ctx, journal, signal) => run(ctx, journal, signal, options.check, () => adapter.check(ctx, journal, signal)),
171
+ ...(adapter.act
172
+ ? {
173
+ act: (ctx, journal, signal) => run(ctx, journal, signal, options.act ?? options.check, () => adapter.act(ctx, journal, signal)),
174
+ }
175
+ : {}),
176
+ };
177
+ }
@@ -9,7 +9,9 @@ function bounded(work, signal) {
9
9
  return;
10
10
  }
11
11
  signal.addEventListener("abort", abort, { once: true });
12
- work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
12
+ work
13
+ .then(resolve, reject)
14
+ .finally(() => signal.removeEventListener("abort", abort));
13
15
  });
14
16
  }
15
17
  function safeReason(reason, fallback) {
@@ -19,17 +21,27 @@ function safeReason(reason, fallback) {
19
21
  export async function pollConsent(options) {
20
22
  const timeoutMs = options.timeoutMs ?? 600_000;
21
23
  const intervalMs = options.intervalMs ?? 2_000;
22
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 600_000 ||
23
- !Number.isSafeInteger(intervalMs) || intervalMs <= 0)
24
+ if (!Number.isSafeInteger(timeoutMs) ||
25
+ timeoutMs <= 0 ||
26
+ timeoutMs > 600_000 ||
27
+ !Number.isSafeInteger(intervalMs) ||
28
+ intervalMs <= 0)
24
29
  throw new UsageError("consent timeout must be 1 to 600000 milliseconds, with a positive poll interval");
25
30
  const now = options.now ?? (() => performance.now());
26
31
  const started = now();
27
32
  const deadline = new AbortController();
28
33
  const timer = setTimeout(() => deadline.abort(), timeoutMs);
29
- const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
34
+ const signal = options.signal
35
+ ? AbortSignal.any([options.signal, deadline.signal])
36
+ : deadline.signal;
30
37
  const elapsedMs = () => Math.max(0, Math.round(now() - started));
31
- const waiting = () => ({ state: "waiting", reason: options.signal?.aborted ? "interrupted" : "consent_timeout", elapsedMs: elapsedMs() });
32
- const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
38
+ const waiting = () => ({
39
+ state: "waiting",
40
+ reason: options.signal?.aborted ? "interrupted" : "consent_timeout",
41
+ elapsedMs: elapsedMs(),
42
+ });
43
+ const sleep = options.sleep ??
44
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
33
45
  try {
34
46
  while (!signal.aborted && elapsedMs() < timeoutMs) {
35
47
  let status;
@@ -39,16 +51,31 @@ export async function pollConsent(options) {
39
51
  catch (error) {
40
52
  if (signal.aborted)
41
53
  return waiting();
42
- if (error instanceof CliError && (error.status === 401 || error.status === 403))
43
- return { state: "refused", reason: "consent_identity_refused", elapsedMs: elapsedMs() };
44
- return { state: "failed", reason: "consent_status_failed", elapsedMs: elapsedMs() };
54
+ if (error instanceof CliError &&
55
+ (error.status === 401 || error.status === 403))
56
+ return {
57
+ state: "refused",
58
+ reason: "consent_identity_refused",
59
+ elapsedMs: elapsedMs(),
60
+ };
61
+ return {
62
+ state: "failed",
63
+ reason: "consent_status_failed",
64
+ elapsedMs: elapsedMs(),
65
+ };
45
66
  }
46
67
  if (elapsedMs() >= timeoutMs || signal.aborted)
47
68
  return waiting();
48
69
  if (status.outcome === "connected")
49
70
  return { state: "done", elapsedMs: elapsedMs() };
50
- if (status.outcome === "refused" || status.outcome === "failed")
51
- return { state: status.outcome, reason: safeReason(status.reason, `consent_${status.outcome}`), elapsedMs: elapsedMs() };
71
+ if (status.outcome === "waiting" ||
72
+ status.outcome === "refused" ||
73
+ status.outcome === "failed")
74
+ return {
75
+ state: status.outcome,
76
+ reason: safeReason(status.reason, `consent_${status.outcome}`),
77
+ elapsedMs: elapsedMs(),
78
+ };
52
79
  const remaining = timeoutMs - elapsedMs();
53
80
  try {
54
81
  await bounded(sleep(Math.min(intervalMs, remaining)), signal);
@@ -0,0 +1,329 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { pollConsent } from "./onboard-consent.js";
3
+ const STATUS = "/api/v1/me/connections/github/workspace";
4
+ const HANDOFF = "/connect/github/workspace/handoff";
5
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ const states = new Set([
7
+ "connected",
8
+ "missing-scope",
9
+ "expired-or-revoked",
10
+ "unreachable",
11
+ "not-connected",
12
+ ]);
13
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
14
+ ? v
15
+ : null;
16
+ const waiting = (reason) => ({
17
+ state: "waiting",
18
+ reason,
19
+ });
20
+ const nullableText = (v) => v === null || typeof v === "string";
21
+ /** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
22
+ async function read(ctx, journal, path, timeoutMs, external) {
23
+ const cfg = loadConfig(ctx.home);
24
+ if (!cfg?.user)
25
+ return { reason: "personal_login_required" };
26
+ const personId = cfg.user.id;
27
+ if ((journal.account ?? journal.tenant) !== cfg.account ||
28
+ journal.membershipId !== cfg.user.id ||
29
+ !journal.baseUrl ||
30
+ normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
31
+ return { reason: "github_installation_identity_refused" };
32
+ const expiry = cfg.auth
33
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
34
+ : 0;
35
+ const bearer = cfg.key ||
36
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
37
+ ? cfg.auth.accessToken
38
+ : undefined);
39
+ if (!bearer)
40
+ return { reason: "github_installation_login_refresh_required" };
41
+ const deadline = new AbortController();
42
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
43
+ const signal = external
44
+ ? AbortSignal.any([external, deadline.signal])
45
+ : deadline.signal;
46
+ let remove = () => { };
47
+ try {
48
+ return await new Promise((resolve) => {
49
+ const stopped = () => resolve({
50
+ reason: external?.aborted
51
+ ? "interrupted"
52
+ : "github_installation_status_unavailable",
53
+ });
54
+ if (signal.aborted) {
55
+ stopped();
56
+ return;
57
+ }
58
+ signal.addEventListener("abort", stopped, { once: true });
59
+ remove = () => signal.removeEventListener("abort", stopped);
60
+ Promise.resolve()
61
+ .then(async () => {
62
+ if (signal.aborted)
63
+ return { reason: "interrupted" };
64
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
65
+ method: "GET",
66
+ redirect: "error",
67
+ headers: {
68
+ authorization: `Bearer ${bearer}`,
69
+ accept: "application/json",
70
+ },
71
+ signal,
72
+ });
73
+ // Refusal bodies and provider diagnostics are never parsed, returned or printed.
74
+ if (response.status !== 200)
75
+ return { status: response.status };
76
+ const body = await response.json();
77
+ const current = loadConfig(ctx.home);
78
+ if (!current?.user ||
79
+ current.account !== cfg.account ||
80
+ current.user.id !== personId ||
81
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
82
+ return { reason: "github_installation_identity_refused" };
83
+ return { status: 200, body };
84
+ })
85
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
86
+ ? stopped()
87
+ : resolve({ reason: "github_installation_status_unavailable" }));
88
+ });
89
+ }
90
+ finally {
91
+ clearTimeout(timer);
92
+ remove();
93
+ }
94
+ }
95
+ function readFailure(value) {
96
+ if ("reason" in value)
97
+ return value.reason === "github_installation_identity_refused" ||
98
+ value.reason === "personal_login_required"
99
+ ? { state: "refused", reason: value.reason }
100
+ : waiting(value.reason);
101
+ if (value.status === 401 || value.status === 403)
102
+ return { state: "refused", reason: "github_installation_consent_refused" };
103
+ return value.status === 200
104
+ ? null
105
+ : waiting("github_installation_status_unavailable");
106
+ }
107
+ /** Every reported installation must have a fresh live installation/App-permission probe.
108
+ * This proves installation connectivity, not access to repositories selected later in Q3. */
109
+ function observation(body, now) {
110
+ const row = object(body);
111
+ if (!row ||
112
+ typeof row.connected !== "boolean" ||
113
+ !Array.isArray(row.installations) ||
114
+ !Array.isArray(row.pending) ||
115
+ row.installations.length + row.pending.length > 100)
116
+ return waiting("github_installation_status_shape");
117
+ const ids = [];
118
+ let unknown = false;
119
+ let allConnected = row.installations.length > 0;
120
+ let checkedAt = now + 30_000;
121
+ for (const value of row.installations) {
122
+ const item = object(value);
123
+ const verification = object(item?.verification);
124
+ if (!item ||
125
+ typeof item.installationId !== "string" ||
126
+ !idPattern.test(item.installationId) ||
127
+ ids.includes(item.installationId) ||
128
+ !nullableText(item.githubOrg) ||
129
+ (typeof item.githubOrg === "string" &&
130
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(item.githubOrg)) ||
131
+ !verification)
132
+ return waiting("github_installation_status_shape");
133
+ ids.push(item.installationId);
134
+ if (verification.source === "none" &&
135
+ verification.state === "not-run" &&
136
+ verification.checkedAt === null) {
137
+ unknown = true;
138
+ allConnected = false;
139
+ continue;
140
+ }
141
+ if (verification.source !== "live-probe" ||
142
+ typeof verification.state !== "string" ||
143
+ !states.has(verification.state) ||
144
+ typeof verification.checkedAt !== "number" ||
145
+ !Number.isFinite(verification.checkedAt) ||
146
+ verification.checkedAt < now - 120_000 ||
147
+ verification.checkedAt > now + 30_000)
148
+ return waiting("github_installation_status_shape");
149
+ if (verification.missing !== undefined &&
150
+ (!Array.isArray(verification.missing) ||
151
+ verification.missing.length > 128 ||
152
+ !verification.missing.every((name) => typeof name === "string" &&
153
+ (/^[A-Za-z0-9_-]{1,128}$/.test(name) ||
154
+ /^[A-Za-z0-9_-]{1,80} \((?:read|write|admin)\)$/.test(name)))))
155
+ return waiting("github_installation_status_shape");
156
+ checkedAt = Math.min(checkedAt, verification.checkedAt);
157
+ if (verification.state === "unreachable")
158
+ unknown = true;
159
+ if (verification.state !== "connected")
160
+ allConnected = false;
161
+ }
162
+ for (const value of row.pending) {
163
+ const pending = object(value);
164
+ if (!pending ||
165
+ !nullableText(pending.githubOrg) ||
166
+ (typeof pending.githubOrg === "string" &&
167
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/.test(pending.githubOrg)) ||
168
+ typeof pending.requestedAt !== "number" ||
169
+ !Number.isFinite(pending.requestedAt) ||
170
+ pending.requestedAt < 0)
171
+ return waiting("github_installation_status_shape");
172
+ }
173
+ if (row.connected !== allConnected)
174
+ return waiting("github_installation_status_shape");
175
+ if (unknown)
176
+ return waiting("github_installation_status_unavailable");
177
+ if (!allConnected)
178
+ return { state: "pending" };
179
+ return {
180
+ state: "done",
181
+ evidence: {
182
+ provider: "github",
183
+ installation: JSON.stringify(ids),
184
+ checkedAt,
185
+ },
186
+ };
187
+ }
188
+ function safeHandoff(body, ctx) {
189
+ const row = object(body);
190
+ const cfg = loadConfig(ctx.home);
191
+ if (!cfg ||
192
+ !row ||
193
+ typeof row.authorizationUrl !== "string" ||
194
+ row.authorizationUrl.length > 8192 ||
195
+ /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
196
+ typeof row.expiresAt !== "number" ||
197
+ !Number.isFinite(row.expiresAt))
198
+ return null;
199
+ const remaining = row.expiresAt - ctx.now().getTime();
200
+ // Thirty seconds accommodates clock skew; an expired link is never launched.
201
+ if (remaining <= 0 || remaining > 630_000)
202
+ return null;
203
+ try {
204
+ const url = new URL(row.authorizationUrl);
205
+ const keys = [...url.searchParams.keys()];
206
+ const handoff = url.searchParams.get("handoff");
207
+ if (url.protocol !== "https:" ||
208
+ url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin ||
209
+ url.username ||
210
+ url.password ||
211
+ url.pathname !== HANDOFF ||
212
+ url.hash ||
213
+ keys.length !== 1 ||
214
+ keys[0] !== "handoff" ||
215
+ !handoff ||
216
+ handoff.length > 4096 ||
217
+ /[\s\u0000-\u001f\u007f]/.test(handoff))
218
+ return null;
219
+ return url.toString();
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ /** Uses personal bearer App-install routes. Older clouds wait without a browser/session fallback. */
226
+ export function githubInstallationAdapter(options) {
227
+ const timeoutMs = options.requestTimeoutMs ?? 30_000;
228
+ const consentTimeoutMs = options.consentTimeoutMs ?? 600_000;
229
+ if (!Number.isSafeInteger(timeoutMs) ||
230
+ timeoutMs < 1 ||
231
+ timeoutMs > 30_000 ||
232
+ !Number.isSafeInteger(consentTimeoutMs) ||
233
+ consentTimeoutMs < 1 ||
234
+ consentTimeoutMs > 600_000)
235
+ throw new Error("github_installation_timeout_invalid");
236
+ const inspect = async (ctx, journal, signal) => {
237
+ const result = await read(ctx, journal, STATUS, timeoutMs, signal);
238
+ if ("status" in result && [404, 405].includes(result.status))
239
+ return {
240
+ result: waiting("cloud_capability_unavailable"),
241
+ unsupported: true,
242
+ };
243
+ const failure = readFailure(result);
244
+ return {
245
+ result: failure ??
246
+ observation("body" in result ? result.body : undefined, ctx.now().getTime()),
247
+ };
248
+ };
249
+ return {
250
+ check: async (ctx, journal, signal) => (await inspect(ctx, journal, signal)).result,
251
+ act: async (ctx, journal, signal) => {
252
+ const cfg = loadConfig(ctx.home);
253
+ if (!cfg?.user || !["owner", "admin"].includes(cfg.user.role))
254
+ return {
255
+ state: "refused",
256
+ reason: "github_installation_admin_required",
257
+ };
258
+ if (signal?.aborted)
259
+ return waiting("interrupted");
260
+ const before = await inspect(ctx, journal, signal);
261
+ if (before.result.state !== "pending")
262
+ return before.result;
263
+ if (before.unsupported)
264
+ return waiting("cloud_capability_unavailable");
265
+ const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
266
+ if ("status" in start && [404, 405].includes(start.status))
267
+ return waiting("cloud_capability_unavailable");
268
+ const failure = readFailure(start);
269
+ if (failure)
270
+ return failure;
271
+ const url = safeHandoff("body" in start ? start.body : undefined, ctx);
272
+ if (!url)
273
+ return {
274
+ state: "refused",
275
+ reason: "github_installation_consent_handoff",
276
+ };
277
+ if (signal?.aborted)
278
+ return waiting("interrupted");
279
+ const current = loadConfig(ctx.home);
280
+ if (!current?.user ||
281
+ current.account !== cfg.account ||
282
+ current.user.id !== cfg.user.id ||
283
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
284
+ return {
285
+ state: "refused",
286
+ reason: "github_installation_identity_refused",
287
+ };
288
+ if (!["owner", "admin"].includes(current.user.role))
289
+ return {
290
+ state: "refused",
291
+ reason: "github_installation_admin_required",
292
+ };
293
+ ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
294
+ // The signed URL is transient credential material: only the browser receives it.
295
+ try {
296
+ options.openBrowser(url);
297
+ }
298
+ catch {
299
+ return waiting("github_installation_browser_unavailable");
300
+ }
301
+ let latest = waiting("github_installation_status_unavailable");
302
+ const run = () => pollConsent({
303
+ timeoutMs: consentTimeoutMs,
304
+ signal,
305
+ sleep: options.sleep,
306
+ readStatus: async (pollSignal) => {
307
+ const value = await inspect(ctx, journal, pollSignal);
308
+ latest = value.result;
309
+ if (value.unsupported)
310
+ return {
311
+ outcome: "waiting",
312
+ reason: "cloud_capability_unavailable",
313
+ };
314
+ if (latest.state === "done")
315
+ return { outcome: "connected" };
316
+ if (latest.state === "refused" || latest.state === "failed")
317
+ return { outcome: latest.state, reason: latest.reason };
318
+ return {
319
+ outcome: latest.state === "pending" ? "absent" : "unavailable",
320
+ };
321
+ },
322
+ });
323
+ const result = options.wait
324
+ ? await options.wait("Waiting for GitHub App approval", run)
325
+ : await run();
326
+ return result.state === "done" ? latest : result;
327
+ },
328
+ };
329
+ }
@@ -90,7 +90,7 @@ function failure(value, start = false) {
90
90
  return { state: "refused", reason: "personal_consent_refused" };
91
91
  if (value.status === 503)
92
92
  return waiting("personal_status_unavailable");
93
- if (value.status === 404)
93
+ if ([404, 405].includes(value.status))
94
94
  return waiting("cloud_capability_unavailable");
95
95
  return value.status === 200
96
96
  ? null
@@ -211,6 +211,8 @@ export function personalConsentAdapter(options) {
211
211
  sleep: options.sleep,
212
212
  readStatus: async (pollSignal) => {
213
213
  latest = await inspect(ctx, journal, pollSignal);
214
+ if (latest.reason === "cloud_capability_unavailable")
215
+ return { outcome: "waiting", reason: latest.reason };
214
216
  if (latest.state === "done")
215
217
  return { outcome: "connected" };
216
218
  if (latest.state === "refused" || latest.state === "failed")