@catalyst-cloud/cli 0.14.6 → 0.14.8

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 (79) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -5
  3. package/dist/args.js +68 -18
  4. package/dist/browser.js +37 -3
  5. package/dist/cli.js +58 -12
  6. package/dist/config.js +17 -5
  7. package/dist/consent-browser.js +58 -0
  8. package/dist/daemon-authority-lifetime.js +171 -0
  9. package/dist/daemon-authority.js +216 -0
  10. package/dist/daemon-cache.js +348 -0
  11. package/dist/daemon-store.js +370 -0
  12. package/dist/daemon-supervisor.js +460 -0
  13. package/dist/env/onboard-draft-store.js +250 -0
  14. package/dist/env/onboard-draft.js +33 -0
  15. package/dist/env/selected-import-writer.js +163 -0
  16. package/dist/env/settings-authority.js +205 -0
  17. package/dist/events-cloud-tail.js +146 -0
  18. package/dist/events-cloud.js +619 -0
  19. package/dist/events.js +186 -31
  20. package/dist/oauth.js +129 -34
  21. package/dist/onboard-accounts.js +259 -0
  22. package/dist/onboard-automation-management.js +15 -0
  23. package/dist/onboard-bootstrap.js +162 -0
  24. package/dist/onboard-capabilities.js +15 -7
  25. package/dist/onboard-capacity.js +226 -0
  26. package/dist/onboard-existing.js +106 -32
  27. package/dist/onboard-file-snapshot.js +45 -0
  28. package/dist/onboard-github.js +14 -3
  29. package/dist/onboard-login-candidate.js +261 -0
  30. package/dist/onboard-next.js +139 -0
  31. package/dist/onboard-personal.js +14 -3
  32. package/dist/onboard-projects.js +350 -0
  33. package/dist/onboard-repositories.js +146 -37
  34. package/dist/onboard-runtime.js +108 -13
  35. package/dist/onboard-settings-context.js +141 -0
  36. package/dist/onboard-settings-import.js +127 -0
  37. package/dist/onboard-settings.js +234 -0
  38. package/dist/onboard-ui.js +85 -59
  39. package/dist/onboard-workflow.js +316 -0
  40. package/dist/onboard-workspace.js +14 -3
  41. package/dist/onboard.js +422 -39
  42. package/dist/owned-fetch.js +182 -0
  43. package/dist/pagination.js +13 -1
  44. package/dist/query.js +104 -31
  45. package/dist/ready.js +143 -35
  46. package/dist/replica.js +11 -4
  47. package/dist/repository-id.js +5 -0
  48. package/dist/sdk.js +33 -0
  49. package/dist/watch.js +2 -2
  50. package/package.json +3 -2
  51. package/skills/catalyst-github/SKILL.md +4 -2
  52. package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
  53. package/skills/catalyst-github/references/local-replica.md +78 -0
  54. package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
  55. package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
  56. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
  57. package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
  58. package/skills/catalyst-linear/SKILL.md +5 -3
  59. package/skills/catalyst-linear/references/local-replica.md +91 -0
  60. package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
  61. package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
  62. package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
  63. package/skills/catalyst-onboard/SKILL.md +6 -4
  64. package/skills/catalyst-onboard/references/local-replica.md +40 -0
  65. package/skills/catalyst-onboard/references/local-sync.md +8 -4
  66. package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
  67. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  68. package/skills/run-this-project/SKILL.md +1 -1
  69. package/skills/unstick/SKILL.md +1 -1
  70. package/skills/what-needs-me/SKILL.md +1 -1
  71. package/skills/what-this-repo-needs/SKILL.md +1 -1
  72. package/skills/whats-happening/SKILL.md +4 -2
  73. package/skills/whats-happening/references/local-replica.md +51 -0
  74. package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
  75. package/skills/whats-happening/references/status-reply.md +1 -1
  76. package/skills/whats-happening/scripts/snapshot.mjs +104 -26
  77. package/vendor/settings/index.d.ts +4 -0
  78. package/vendor/settings/index.js +2937 -0
  79. package/vendor/settings/provenance.json +148 -0
@@ -0,0 +1,226 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { verifyOnboardRoutes } from "./onboard-capabilities.js";
3
+ import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
+ import { readOnboardRepositoryInventory, selectedOnboardRepositories, } from "./onboard-repositories.js";
5
+ const route = "/api/v1/me/runner-capacity";
6
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value
8
+ : null;
9
+ const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
10
+ const text = (value) => typeof value === "string" &&
11
+ value.length > 0 &&
12
+ value.length <= 256 &&
13
+ !Array.from(value).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
14
+ const waiting = (reason) => ({
15
+ state: "waiting",
16
+ reason,
17
+ });
18
+ function snapshot(value, now) {
19
+ const body = object(value);
20
+ if (!body ||
21
+ body.status !== "ok" ||
22
+ body.scope !== "mapped-team-defaults" ||
23
+ !integer(body.observedAtMs) ||
24
+ body.observedAtMs < now - 30_000 ||
25
+ body.observedAtMs > now + 5_000 ||
26
+ !Array.isArray(body.buckets) ||
27
+ body.buckets.length > 500)
28
+ return null;
29
+ const buckets = [], seen = new Set(), seenTeam = new Set();
30
+ let totalTeams = 0;
31
+ for (const value of body.buckets) {
32
+ const b = object(value);
33
+ if (!b ||
34
+ !text(b.repoId) ||
35
+ seen.has(b.repoId) ||
36
+ !integer(b.effectiveLimit) ||
37
+ !integer(b.occupiedUnits) ||
38
+ !Array.isArray(b.teams) ||
39
+ !b.teams.length ||
40
+ b.teams.length > 500)
41
+ return null;
42
+ seen.add(b.repoId);
43
+ totalTeams += b.teams.length;
44
+ if (totalTeams > 1000)
45
+ return null;
46
+ const teams = [];
47
+ let minimum = Number.MAX_SAFE_INTEGER;
48
+ for (const value of b.teams) {
49
+ const t = object(value);
50
+ if (!t ||
51
+ !text(t.teamKey) ||
52
+ seenTeam.has(t.teamKey) ||
53
+ !integer(t.defaultLimit) ||
54
+ t.defaultLimit < 1 ||
55
+ !(t.configuredLimit === null ||
56
+ (integer(t.configuredLimit) && t.configuredLimit > 0)) ||
57
+ typeof t.paused !== "boolean" ||
58
+ typeof t.admissionEnabled !== "boolean" ||
59
+ !integer(t.effectiveTeamLimit))
60
+ return null;
61
+ const configured = t.configuredLimit === null ? t.defaultLimit : t.configuredLimit;
62
+ if (t.source !== (t.configuredLimit === null ? "default" : "configured") ||
63
+ t.effectiveTeamLimit !== (t.paused ? 0 : configured))
64
+ return null;
65
+ const remaining = t.admissionEnabled
66
+ ? Math.max(0, b.effectiveLimit - b.occupiedUnits)
67
+ : null;
68
+ if (t.remainingUnits !== remaining)
69
+ return null;
70
+ seenTeam.add(t.teamKey);
71
+ minimum = Math.min(minimum, t.effectiveTeamLimit);
72
+ teams.push({ key: t.teamKey, admission: t.admissionEnabled, remaining });
73
+ }
74
+ if (minimum !== b.effectiveLimit)
75
+ return null;
76
+ buckets.push({
77
+ repo: b.repoId,
78
+ limit: b.effectiveLimit,
79
+ occupied: b.occupiedUnits,
80
+ teams,
81
+ });
82
+ }
83
+ return { at: body.observedAtMs, buckets };
84
+ }
85
+ function binding(ctx, journal) {
86
+ const cfg = loadConfig(ctx.home);
87
+ if (!cfg?.user ||
88
+ !["owner", "admin"].includes(cfg.user.role) ||
89
+ cfg.account !== (journal.account ?? journal.tenant) ||
90
+ cfg.user.id !== journal.membershipId ||
91
+ !journal.baseUrl ||
92
+ normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
93
+ return null;
94
+ return JSON.stringify([
95
+ cfg.account,
96
+ cfg.user.id,
97
+ cfg.user.role,
98
+ normalizeBaseUrl(cfg.baseUrl),
99
+ cfg.permissions,
100
+ cfg.key ?? cfg.auth?.sessionId,
101
+ ]);
102
+ }
103
+ const selection = (journal) => JSON.stringify([
104
+ selectedOnboardTeam(journal),
105
+ selectedOnboardRepositories(journal),
106
+ ]);
107
+ async function liveTeamKey(ctx, teamId, signal) {
108
+ const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
109
+ if ("reason" in read)
110
+ return null;
111
+ const body = object(read.body), live = object(body?.liveTeamRead);
112
+ if (!body ||
113
+ !Array.isArray(body.teams) ||
114
+ body.teams.length > 1000 ||
115
+ (live?.error !== undefined && live.error !== null))
116
+ return null;
117
+ const ids = new Set(), keys = new Set();
118
+ let selected = null;
119
+ for (const value of body.teams) {
120
+ const team = object(value);
121
+ if (!team ||
122
+ !text(team.teamId) ||
123
+ !text(team.teamKey) ||
124
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(team.teamKey) ||
125
+ ids.has(team.teamId) ||
126
+ keys.has(team.teamKey))
127
+ return null;
128
+ ids.add(team.teamId);
129
+ keys.add(team.teamKey);
130
+ if (team.teamId === teamId)
131
+ selected = team.teamKey;
132
+ }
133
+ return selected;
134
+ }
135
+ async function liveIdentity(ctx, journal, signal) {
136
+ const read = await readExistingOnboardJson(ctx, "/api/v1/me", signal);
137
+ if ("reason" in read)
138
+ return false;
139
+ const me = object(read.body), user = object(me?.user), cfg = loadConfig(ctx.home);
140
+ return (!!cfg?.user &&
141
+ me?.account === (journal.account ?? journal.tenant) &&
142
+ user?.id === cfg.user.id &&
143
+ user.role === cfg.user.role &&
144
+ ["owner", "admin"].includes(cfg.user.role) &&
145
+ me?.principal === cfg.principal);
146
+ }
147
+ /** Fresh advisory for the selected team's DEFAULT mapped repo. It grants no lease and does not
148
+ * establish provider eligibility, host liveness or selected-repo overrides. No extra question. */
149
+ export function onboardCapacityAdapter(input = {}) {
150
+ return {
151
+ check: async (ctx, journal, external) => {
152
+ if (external?.aborted)
153
+ return waiting("interrupted");
154
+ const expected = binding(ctx, journal), selected = selection(journal), teamId = selectedOnboardTeam(journal), repos = selectedOnboardRepositories(journal);
155
+ if (!expected)
156
+ return waiting("capacity_identity_unverified");
157
+ if (!teamId || repos.length !== 1 || repos[0].teamId !== teamId)
158
+ return waiting("capacity_context_unverified");
159
+ const controller = new AbortController(), signal = external
160
+ ? AbortSignal.any([external, controller.signal])
161
+ : controller.signal;
162
+ const timer = setTimeout(() => controller.abort(), 30_000);
163
+ const current = () => !signal.aborted &&
164
+ binding(ctx, journal) === expected &&
165
+ selection(journal) === selected;
166
+ const pause = (reason) => waiting(external?.aborted
167
+ ? "interrupted"
168
+ : controller.signal.aborted
169
+ ? "capacity_unavailable"
170
+ : reason);
171
+ try {
172
+ const support = await verifyOnboardRoutes(ctx, journal, [{ method: "GET", path: route }], signal);
173
+ if ("reason" in support)
174
+ return pause(support.reason);
175
+ if (!current() ||
176
+ !(await liveIdentity(ctx, journal, signal)) ||
177
+ !current())
178
+ return pause("capacity_identity_unverified");
179
+ const teamKey = await liveTeamKey(ctx, teamId, signal);
180
+ if (!teamKey || !current())
181
+ return pause("capacity_context_unverified");
182
+ const inventory = await readOnboardRepositoryInventory(ctx, journal, signal);
183
+ if ("reason" in inventory ||
184
+ !current() ||
185
+ !inventory.repositories.some((r) => r.repoId === repos[0].repoId &&
186
+ r.teamId === teamId &&
187
+ r.owner.toLowerCase() === repos[0].owner.toLowerCase() &&
188
+ r.name.toLowerCase() === repos[0].name.toLowerCase()))
189
+ return pause("capacity_context_unverified");
190
+ const read = await readExistingOnboardJson(ctx, route, signal);
191
+ if ("reason" in read || !current())
192
+ return pause("capacity_unavailable");
193
+ if (!(await liveIdentity(ctx, journal, signal)) || !current())
194
+ return pause("capacity_identity_unverified");
195
+ const observed = snapshot(read.body, ctx.now().getTime());
196
+ const bucket = observed?.buckets.find((b) => b.repo === repos[0].repoId), mapped = bucket?.teams.find((t) => t.key === teamKey);
197
+ if (!observed || !bucket || !mapped)
198
+ return pause("capacity_context_unverified");
199
+ if (!mapped.admission || mapped.remaining === null)
200
+ return pause("capacity_admission_unverified");
201
+ if (mapped.remaining === 0)
202
+ return pause("capacity_currently_full");
203
+ input.message?.(`Runner capacity observed: ${bucket.occupied} of ${bucket.limit} units in use. Starting work will check admission again.`);
204
+ if (!current())
205
+ return pause("capacity_identity_unverified");
206
+ return {
207
+ state: "done",
208
+ evidence: {
209
+ repoId: bucket.repo,
210
+ effectiveLimit: bucket.limit,
211
+ occupiedUnits: bucket.occupied,
212
+ remainingUnits: mapped.remaining,
213
+ observedAtMs: observed.at,
214
+ advisory: true,
215
+ },
216
+ };
217
+ }
218
+ catch {
219
+ return pause(external?.aborted ? "interrupted" : "capacity_unavailable");
220
+ }
221
+ finally {
222
+ clearTimeout(timer);
223
+ }
224
+ },
225
+ };
226
+ }
@@ -1,12 +1,17 @@
1
1
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
2
  import { CliError } from "./errors.js";
3
3
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
4
- const waiting = (reason) => ({ state: "waiting", reason });
5
- const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
4
+ const waiting = (reason) => ({
5
+ state: "waiting",
6
+ reason,
7
+ });
8
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
9
+ ? value
10
+ : null;
6
11
  const label = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
7
12
  /** A receipt keeps an explicit selection, never the team used to inspect the tenant grant. */
8
13
  export function selectedOnboardTeam(journal) {
9
- const step = journal?.steps.find(row => row.id === "linear.team");
14
+ const step = journal?.steps.find((row) => row.id === "linear.team");
10
15
  const id = step?.state === "done" ? step.evidence?.team : undefined;
11
16
  return typeof id === "string" && idPattern.test(id) ? id : undefined;
12
17
  }
@@ -15,15 +20,23 @@ export async function readExistingOnboardJson(ctx, path, external) {
15
20
  const cfg = loadConfig(ctx.home);
16
21
  if (!cfg?.user)
17
22
  return { reason: "personal_login_required" };
23
+ const personId = cfg.user.id;
18
24
  // Identity verification refreshes before adapters run. These bounded reads never rotate a grant
19
25
  // that could finish after the engine has released its lock.
20
- const expiry = cfg.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
21
- const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
26
+ const expiry = cfg.auth
27
+ ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
28
+ : 0;
29
+ const bearer = cfg.key ||
30
+ (cfg.auth && Number.isFinite(expiry) && expiry > 30_000
31
+ ? cfg.auth.accessToken
32
+ : undefined);
22
33
  if (!bearer)
23
34
  return { reason: "team_read_login_refresh_required" };
24
35
  const deadline = new AbortController();
25
36
  const timer = setTimeout(() => deadline.abort(), 30_000);
26
- const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
37
+ const signal = external
38
+ ? AbortSignal.any([external, deadline.signal])
39
+ : deadline.signal;
27
40
  let remove = () => { };
28
41
  try {
29
42
  return await new Promise((resolve, reject) => {
@@ -36,17 +49,32 @@ export async function readExistingOnboardJson(ctx, path, external) {
36
49
  }
37
50
  signal.addEventListener("abort", stopped, { once: true });
38
51
  remove = () => signal.removeEventListener("abort", stopped);
39
- Promise.resolve().then(async () => {
52
+ Promise.resolve()
53
+ .then(async () => {
40
54
  if (signal.aborted)
41
55
  return { reason: "team_read_unavailable" };
42
56
  const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
43
- headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
57
+ redirect: "error",
58
+ headers: {
59
+ authorization: `Bearer ${bearer}`,
60
+ accept: "application/json",
61
+ },
62
+ signal,
44
63
  });
45
64
  if (response.status !== 200)
46
65
  return { reason: "team_read_unavailable" };
47
66
  const body = await response.json();
67
+ const current = loadConfig(ctx.home);
68
+ if (!current?.user ||
69
+ current.account !== cfg.account ||
70
+ current.user.id !== personId ||
71
+ normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
72
+ return { reason: "team_read_identity_unverified" };
48
73
  return { body };
49
- }).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "team_read_unavailable" }));
74
+ })
75
+ .then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
76
+ ? stopped()
77
+ : resolve({ reason: "team_read_unavailable" }));
50
78
  });
51
79
  }
52
80
  finally {
@@ -54,7 +82,7 @@ export async function readExistingOnboardJson(ctx, path, external) {
54
82
  remove();
55
83
  }
56
84
  }
57
- async function inventory(ctx, signal) {
85
+ export async function readOnboardTeamInventory(ctx, signal) {
58
86
  const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
59
87
  if ("reason" in read)
60
88
  return read;
@@ -68,11 +96,19 @@ async function inventory(ctx, signal) {
68
96
  const seen = new Set();
69
97
  for (const value of body.teams) {
70
98
  const row = object(value);
71
- if (!row || typeof row.teamId !== "string" || !idPattern.test(row.teamId) || seen.has(row.teamId) ||
72
- typeof row.teamKey !== "string" || typeof row.teamName !== "string")
99
+ if (!row ||
100
+ typeof row.teamId !== "string" ||
101
+ !idPattern.test(row.teamId) ||
102
+ seen.has(row.teamId) ||
103
+ typeof row.teamKey !== "string" ||
104
+ typeof row.teamName !== "string")
73
105
  return { reason: "team_inventory_shape" };
74
106
  seen.add(row.teamId);
75
- teams.push({ id: row.teamId, key: label(row.teamKey), name: label(row.teamName) });
107
+ teams.push({
108
+ id: row.teamId,
109
+ key: label(row.teamKey),
110
+ name: label(row.teamName),
111
+ });
76
112
  }
77
113
  return { teams };
78
114
  }
@@ -82,14 +118,27 @@ async function observe(ctx, id, signal) {
82
118
  return null;
83
119
  const row = object(object(read.body)?.readiness);
84
120
  // The GET owns expiry/revision freshness. Do not invent an expiresAt absent from its wire shape.
85
- if (!row || row.teamId !== id || typeof row.status !== "string" || !["ready", "degraded", "blocked"].includes(row.status) || typeof row.checkedAt !== "number" ||
86
- !Number.isFinite(row.checkedAt) || row.checkedAt < 0 || row.checkedAt > ctx.now().getTime() ||
87
- typeof row.workflowRev !== "number" || !Number.isSafeInteger(row.workflowRev) || row.workflowRev < 0 || !Array.isArray(row.checks))
121
+ if (!row ||
122
+ row.teamId !== id ||
123
+ typeof row.status !== "string" ||
124
+ !["ready", "degraded", "blocked"].includes(row.status) ||
125
+ typeof row.checkedAt !== "number" ||
126
+ !Number.isFinite(row.checkedAt) ||
127
+ row.checkedAt < 0 ||
128
+ row.checkedAt > ctx.now().getTime() ||
129
+ typeof row.workflowRev !== "number" ||
130
+ !Number.isSafeInteger(row.workflowRev) ||
131
+ row.workflowRev < 0 ||
132
+ !Array.isArray(row.checks))
88
133
  return null;
89
134
  const checks = new Map();
90
135
  for (const value of row.checks) {
91
136
  const check = object(value);
92
- if (!check || typeof check.id !== "string" || checks.has(check.id) || typeof check.state !== "string" || !["pass", "fail", "unknown"].includes(check.state))
137
+ if (!check ||
138
+ typeof check.id !== "string" ||
139
+ checks.has(check.id) ||
140
+ typeof check.state !== "string" ||
141
+ !["pass", "fail", "unknown"].includes(check.state))
93
142
  return null;
94
143
  checks.set(check.id, check.state);
95
144
  }
@@ -100,17 +149,17 @@ export function existingLinearAdapters(args, choose) {
100
149
  const requested = (teams, journal) => {
101
150
  const explicit = args.flags.team;
102
151
  if (typeof explicit === "string") {
103
- const ids = teams.filter(team => team.id === explicit);
152
+ const ids = teams.filter((team) => team.id === explicit);
104
153
  if (ids.length === 1)
105
154
  return ids[0];
106
- const keys = teams.filter(team => team.key === explicit);
155
+ const keys = teams.filter((team) => team.key === explicit);
107
156
  return keys.length === 1 ? keys[0] : undefined;
108
157
  }
109
158
  const saved = chosen ?? selectedOnboardTeam(journal);
110
- return saved ? teams.find(team => team.id === saved) : undefined;
159
+ return saved ? teams.find((team) => team.id === saved) : undefined;
111
160
  };
112
161
  const teamCheck = async (ctx, journal, signal) => {
113
- const list = await inventory(ctx, signal);
162
+ const list = await readOnboardTeamInventory(ctx, signal);
114
163
  if ("reason" in list)
115
164
  return waiting(list.reason);
116
165
  if (!list.teams.length)
@@ -124,23 +173,43 @@ export function existingLinearAdapters(args, choose) {
124
173
  const result = await observe(ctx, team.id, signal);
125
174
  if (!result || result.checks.get("team_visible") !== "pass")
126
175
  return waiting("team_selection_unverified");
127
- return { state: "done", evidence: { team: team.id, teamKey: team.key, checkedAt: result.checkedAt, revision: result.revision } };
176
+ return {
177
+ state: "done",
178
+ evidence: {
179
+ team: team.id,
180
+ teamKey: team.key,
181
+ checkedAt: result.checkedAt,
182
+ revision: result.revision,
183
+ },
184
+ };
128
185
  };
129
186
  return {
130
- "linear.workspace": { check: async (ctx, journal, signal) => {
131
- const list = await inventory(ctx, signal);
187
+ "linear.workspace": {
188
+ check: async (ctx, journal, signal) => {
189
+ const list = await readOnboardTeamInventory(ctx, signal);
132
190
  if ("reason" in list)
133
191
  return waiting(list.reason);
134
192
  const candidate = requested(list.teams, journal) ?? list.teams[0];
135
193
  if (!candidate)
136
194
  return waiting("linear_workspace_unverified");
137
195
  const result = await observe(ctx, candidate.id, signal);
138
- return result?.checks.get("oauth_scope") === "pass" && result.checks.get("token_live") === "pass"
139
- ? { state: "done", evidence: { checkedAt: result.checkedAt, revision: result.revision } }
196
+ return result?.checks.get("oauth_scope") === "pass" &&
197
+ result.checks.get("token_live") === "pass"
198
+ ? {
199
+ state: "done",
200
+ evidence: {
201
+ checkedAt: result.checkedAt,
202
+ revision: result.revision,
203
+ },
204
+ }
140
205
  : waiting("linear_workspace_unverified");
141
- }, act: async () => waiting("cloud_capability_unavailable") },
142
- "linear.team": { check: teamCheck, act: async (ctx, journal, signal) => {
143
- const list = await inventory(ctx, signal);
206
+ },
207
+ act: async () => waiting("cloud_capability_unavailable"),
208
+ },
209
+ "linear.team": {
210
+ check: teamCheck,
211
+ act: async (ctx, journal, signal) => {
212
+ const list = await readOnboardTeamInventory(ctx, signal);
144
213
  if ("reason" in list)
145
214
  return waiting(list.reason);
146
215
  if (!list.teams.length)
@@ -156,14 +225,19 @@ export function existingLinearAdapters(args, choose) {
156
225
  }
157
226
  signal?.addEventListener("abort", stopped, { once: true });
158
227
  remove = () => signal?.removeEventListener("abort", stopped);
159
- Promise.resolve().then(() => signal?.aborted ? null : choose(list.teams.map(team => ({ ...team })))).then(resolve, reject);
228
+ Promise.resolve()
229
+ .then(() => signal?.aborted
230
+ ? null
231
+ : choose(list.teams.map((team) => ({ ...team }))))
232
+ .then(resolve, reject);
160
233
  }).finally(() => remove());
161
234
  if (!answer || signal?.aborted)
162
235
  return waiting("interrupted");
163
- if (!list.teams.some(team => team.id === answer))
236
+ if (!list.teams.some((team) => team.id === answer))
164
237
  return waiting("team_selection_unverified");
165
238
  chosen = answer;
166
239
  return teamCheck(ctx, journal, signal);
167
- } },
240
+ },
241
+ },
168
242
  };
169
243
  }
@@ -0,0 +1,45 @@
1
+ import { constants, closeSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { CliError } from "./errors.js";
3
+ /** Bounded, regular-file snapshot for the review precheck. This is not a CAS primitive. */
4
+ export function onboardFileSnapshot(path) {
5
+ let fd;
6
+ try {
7
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
8
+ }
9
+ catch (error) {
10
+ if (error !== null &&
11
+ typeof error === "object" &&
12
+ "code" in error &&
13
+ error.code === "ENOENT")
14
+ return null;
15
+ throw new CliError("The saved setup files could not be verified. Keep them and try again.", "onboard-file-unverified", 12);
16
+ }
17
+ try {
18
+ const before = fstatSync(fd);
19
+ const limit = 1024 * 1024;
20
+ if (!before.isFile() || before.size > limit)
21
+ throw new Error("invalid file");
22
+ const bytes = Buffer.alloc(limit + 1);
23
+ let used = 0;
24
+ for (;;) {
25
+ const read = readSync(fd, bytes, used, bytes.length - used, null);
26
+ used += read;
27
+ if (used > limit)
28
+ throw new Error("oversized file");
29
+ if (read === 0)
30
+ break;
31
+ }
32
+ const after = fstatSync(fd);
33
+ if (before.size !== after.size ||
34
+ before.mtimeMs !== after.mtimeMs ||
35
+ used !== after.size)
36
+ throw new Error("changed file");
37
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used));
38
+ }
39
+ catch {
40
+ throw new CliError("The saved setup files could not be verified. Keep them and try again.", "onboard-file-unverified", 12);
41
+ }
42
+ finally {
43
+ closeSync(fd);
44
+ }
45
+ }
@@ -1,3 +1,4 @@
1
+ import { finishOnTheWeb } from "./consent-browser.js";
1
2
  import { loadConfig, normalizeBaseUrl } from "./config.js";
2
3
  import { pollConsent } from "./onboard-consent.js";
3
4
  const STATUS = "/api/v1/me/connections/github/workspace";
@@ -292,11 +293,15 @@ export function githubInstallationAdapter(options) {
292
293
  };
293
294
  ctx.stderr("Approve the GitHub App installation in your browser. Your personal GitHub connection is checked separately.");
294
295
  // The signed URL is transient credential material: only the browser receives it.
296
+ let browserUnavailable = false;
295
297
  try {
296
- options.openBrowser(url);
298
+ await options.openBrowser(url, signal);
297
299
  }
298
300
  catch {
299
- return waiting("github_installation_browser_unavailable");
301
+ if (signal?.aborted)
302
+ return waiting("interrupted");
303
+ browserUnavailable = true;
304
+ ctx.stderr(finishOnTheWeb(current.baseUrl, "connections", "install the GitHub App"));
300
305
  }
301
306
  let latest = waiting("github_installation_status_unavailable");
302
307
  const run = () => pollConsent({
@@ -311,6 +316,8 @@ export function githubInstallationAdapter(options) {
311
316
  outcome: "waiting",
312
317
  reason: "cloud_capability_unavailable",
313
318
  };
319
+ if (latest.reason === "github_installation_login_refresh_required")
320
+ return { outcome: "waiting", reason: latest.reason };
314
321
  if (latest.state === "done")
315
322
  return { outcome: "connected" };
316
323
  if (latest.state === "refused" || latest.state === "failed")
@@ -323,7 +330,11 @@ export function githubInstallationAdapter(options) {
323
330
  const result = options.wait
324
331
  ? await options.wait("Waiting for GitHub App approval", run)
325
332
  : await run();
326
- return result.state === "done" ? latest : result;
333
+ if (result.state === "done")
334
+ return latest;
335
+ return browserUnavailable && result.reason === "consent_timeout"
336
+ ? waiting("github_installation_browser_unavailable")
337
+ : result;
327
338
  },
328
339
  };
329
340
  }