@catalyst-cloud/cli 0.14.0 → 0.14.2

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/dist/legacy.js CHANGED
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // The list is fixed on purpose. A prefix match would one day catch a current job (the housekeeping
8
8
  // job, the sandbox credit guard, jobs a person made); this matches names the old runtime installed,
9
- // read at the source commit below, and nothing else. Data folders are a separate yes (--data).
10
- import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs";
9
+ // read at the source commit below, and nothing else. Shared data folders are always kept. The historical --data flag cannot remove them.
10
+ import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { join } from "node:path";
13
13
  import { flagBool, positionals } from "./args.js";
@@ -47,8 +47,8 @@ const OLD_ROLE_PREFIX = "com.catalyst.role.";
47
47
  export const OLD_USER_UNITS = ["catalyst.service", "catalyst-monitor.service"];
48
48
  /** Command files the old runtime put on the person's PATH. `~/.local/bin/catalyst` is NOT one: that
49
49
  * name belongs to the current CLI wherever npm put it. */
50
- export const OLD_BINS = [".catalyst/bin", ".local/bin/catalyst-events", ".local/bin/catalyst-filter", ".local/bin/catalyst-hud", ".local/bin/catalyst-monitor", ".local/bin/catalyst-myown"];
51
- /** State the old runtime kept. `~/.config/catalyst-cloud` is the current CLI's and never touched. */
50
+ export const OLD_BINS = [".local/bin/catalyst-events", ".local/bin/catalyst-filter", ".local/bin/catalyst-hud", ".local/bin/catalyst-monitor", ".local/bin/catalyst-myown"];
51
+ /** Shared roots also hold current skills, logs, hosts and seats. Never remove these directories. */
52
52
  export const OLD_DATA_DIRS = [".catalyst", ".config/catalyst", ".local/state/catalyst", ".local/state/catalyst-fleet-runner", ".local/state/catalyst-ledger"];
53
53
  const RECOMMEND = "Remove them: the cloud runtime replaced them, and left in place they can start old jobs or shadow current commands.";
54
54
  function readJson(path) {
@@ -138,11 +138,10 @@ function remove(item, run, platform, uid) {
138
138
  unlinkSync(item.path);
139
139
  return null;
140
140
  }
141
- // a bin or a data folder: a file goes with unlink, a directory (~/.catalyst/bin, the data) with rm -rf
142
- if (lstatSync(item.path).isDirectory())
143
- rmSync(item.path, { recursive: true, force: true });
144
- else
145
- unlinkSync(item.path);
141
+ // CTC-4496: even explicit --data cannot delete shared roots. Unknown directories stay too.
142
+ if (item.data || lstatSync(item.path).isDirectory())
143
+ return "directory kept; shared data is protected";
144
+ unlinkSync(item.path);
146
145
  return null;
147
146
  }
148
147
  catch (err) {
@@ -171,10 +170,10 @@ export async function cmdLegacy(args, ctx, deps = {}) {
171
170
  }
172
171
  const listLines = [
173
172
  `Leftovers of the old local Catalyst runtime on this machine (${found.length}):`,
174
- ...found.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : f.data ? " (kept unless --data)" : ""}`),
173
+ ...found.map((f) => ` ${f.kind}: ${f.name}${f.kind === "job" ? ` — ${f.path}` : f.data ? " (kept: shared current state)" : ""}`),
175
174
  ];
176
175
  if (!wantRemove) {
177
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found }, [...listLines, RECOMMEND, "Run: catalyst legacy --remove (add --data to delete its data folders too)"]);
176
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found }, [...listLines, RECOMMEND, "Run: catalyst legacy --remove (all data folders are kept)"]);
178
177
  return 1;
179
178
  }
180
179
  // the one question, only on a terminal; --yes answers it without asking
@@ -182,13 +181,13 @@ export async function cmdLegacy(args, ctx, deps = {}) {
182
181
  if (!go) {
183
182
  const tty = (deps.isTty ?? stdinIsTty)();
184
183
  if (!tty) {
185
- emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: false }, [...listLines, RECOMMEND, "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (add --data for the data folders)"]);
184
+ emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: false }, [...listLines, RECOMMEND, "nothing removed: no terminal to ask on; run catalyst legacy --remove --yes to remove these without a question (all data folders are kept)"]);
186
185
  return 1;
187
186
  }
188
187
  for (const l of listLines)
189
188
  ctx.stdout(l);
190
189
  ctx.stdout(RECOMMEND);
191
- const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${found.length} item${found.length === 1 ? "" : "s"}${withData ? ", data folders included" : ", keeping the data folders"}? [y/N] `)).trim().toLowerCase();
190
+ const answer = (await (deps.prompt ?? ((q) => promptSecret(q)))(`Remove ${found.length} item${found.length === 1 ? "" : "s"}, keeping all data folders? [y/N] `)).trim().toLowerCase();
192
191
  go = answer === "y" || answer === "yes";
193
192
  if (!go) {
194
193
  emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed: [], remaining: found, asked: true }, ["nothing removed; run catalyst legacy --remove again when ready"]);
@@ -200,9 +199,9 @@ export async function cmdLegacy(args, ctx, deps = {}) {
200
199
  const kept = [];
201
200
  const lines = [];
202
201
  for (const item of found) {
203
- if (item.data && !withData) {
202
+ if (item.data) {
204
203
  kept.push(item);
205
- lines.push(`kept: ${item.kind} ${item.name} (run with --data to delete it)`);
204
+ lines.push(`kept: ${item.kind} ${item.name} (shared current state; always kept)`);
206
205
  continue;
207
206
  }
208
207
  const why = remove(item, run, platform, uid);
@@ -216,8 +215,8 @@ export async function cmdLegacy(args, ctx, deps = {}) {
216
215
  }
217
216
  }
218
217
  // re-check from the same list, so the report says what the machine holds now, not what was attempted
219
- const remaining = findLegacy(ctx.home, platform).filter((f) => !(f.data && !withData));
220
- const keptNote = kept.length > 0 ? " except the data folders you kept" : "";
218
+ const remaining = findLegacy(ctx.home, platform).filter((f) => !f.data);
219
+ const keptNote = kept.length > 0 ? "; all shared data folders were kept" : "";
221
220
  lines.push(remaining.length === 0 ? `re-checked: nothing of the old runtime remains${keptNote}` : `re-checked: ${remaining.length} item${remaining.length === 1 ? "" : "s"} of the old runtime remain${remaining.length === 1 ? "s" : ""}${keptNote}: ${remaining.map((r) => `${r.kind} ${r.name}`).join(", ")}`);
222
221
  emit({ sourceCommit: LEGACY_SOURCE_COMMIT, found, removed, remaining: [...remaining, ...kept], kept, failed }, lines);
223
222
  return remaining.length === 0 ? 0 : 1;
package/dist/oauth.js CHANGED
@@ -35,6 +35,9 @@ function isDiscovery(v) {
35
35
  return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
36
36
  }
37
37
  export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
38
+ const cancelled = () => { if (opts.signal?.aborted)
39
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
40
+ cancelled();
38
41
  const base = normalizeBaseUrl(baseUrl);
39
42
  const nowMs = ctx.now().getTime();
40
43
  const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
@@ -53,6 +56,7 @@ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
53
56
  throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
54
57
  }
55
58
  const doc = (await parseJson(res, url));
59
+ cancelled();
56
60
  if (!isDiscovery(doc))
57
61
  throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
58
62
  const entry = { baseUrl: base, fetchedAt: nowMs, doc };
@@ -79,12 +83,24 @@ export const MAX_DEVICE_CODES = 3;
79
83
  * expires, a fresh one is minted and printed, up to MAX_DEVICE_CODES in all.
80
84
  */
81
85
  export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
82
- const sleep = deps.sleep ?? defaultSleep;
83
- const discovery = await fetchDiscovery(ctx, baseUrl);
86
+ const cancelled = () => { if (deps.signal?.aborted)
87
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
88
+ cancelled();
89
+ if (deps.signal) {
90
+ const fetchImpl = ctx.fetch;
91
+ const signal = deps.signal;
92
+ ctx = { ...ctx, fetch: ((input, init) => fetchImpl(input, {
93
+ ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
94
+ })) };
95
+ }
96
+ const discovery = await fetchDiscovery(ctx, baseUrl, { signal: deps.signal });
97
+ cancelled();
84
98
  // The browser rule is decided on the first code; a later code re-opens it only when the first did.
85
99
  let browserOpened = false;
86
100
  for (let round = 1; round <= MAX_DEVICE_CODES; round++) {
101
+ cancelled();
87
102
  const auth = await deviceAuthorize(ctx, discovery);
103
+ cancelled();
88
104
  if (round > 1)
89
105
  ctx.stdout(`That code expired. Here is a new one (${round} of ${MAX_DEVICE_CODES}):`);
90
106
  ctx.stdout("");
@@ -103,7 +119,9 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
103
119
  }
104
120
  }
105
121
  ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
106
- const tokens = await pollDeviceCode(ctx, discovery, auth, sleep);
122
+ const poll = () => pollDeviceCode(ctx, discovery, auth, deps.sleep, deps.signal);
123
+ const tokens = deps.waitForApproval ? await deps.waitForApproval(poll) : await poll();
124
+ cancelled();
107
125
  if (tokens !== "expired")
108
126
  return tokensToAuth(ctx, tokens.access_token, tokens.refresh_token);
109
127
  }
@@ -116,20 +134,35 @@ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
116
134
  * a non-transient error) throw; expiry, by the server's `expired_token` or the code's own
117
135
  * `expires_in` lapsing, returns "expired" so the caller can mint the next code.
118
136
  */
119
- async function pollDeviceCode(ctx, discovery, auth, sleep) {
137
+ async function pollDeviceCode(ctx, discovery, auth, sleep, external) {
120
138
  const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
139
+ const signal = external ? AbortSignal.any([external, deadline]) : deadline;
140
+ const cancelled = () => { if (external?.aborted)
141
+ throw new CliError("Sign-in paused. Run the same command to resume.", "login-cancelled", 11); };
121
142
  let intervalMs = Math.max(1, auth.interval) * 1_000;
122
143
  for (;;) {
123
- await sleep(intervalMs);
144
+ cancelled();
145
+ try {
146
+ await devicePollDelay(intervalMs, signal, sleep);
147
+ }
148
+ catch (error) {
149
+ cancelled();
150
+ if (deadline.aborted)
151
+ return "expired";
152
+ throw error;
153
+ }
154
+ cancelled();
124
155
  // The device code's own lifetime bounds the loop: once it lapses, treat it as expired rather than
125
156
  // letting the next fetch's aborted signal surface as a generic network error (Codex P2).
126
157
  if (deadline.aborted)
127
158
  return "expired";
128
159
  let res;
129
160
  try {
130
- res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, deadline);
161
+ res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, signal);
162
+ cancelled();
131
163
  }
132
164
  catch (err) {
165
+ cancelled();
133
166
  // The deadline firing mid-fetch is an expiry, not a failure; anything else is a transient
134
167
  // network blip — retry with backoff until the deadline, never abandon the login (CTC-2112 P1).
135
168
  if (deadline.aborted)
@@ -165,6 +198,24 @@ async function pollDeviceCode(ctx, discovery, auth, sleep) {
165
198
  throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
166
199
  }
167
200
  }
201
+ /** Clear the real timer on cancellation; an injected slow callback is bounded by the same signal. */
202
+ function devicePollDelay(ms, signal, sleep) {
203
+ return new Promise((resolve, reject) => {
204
+ let timer;
205
+ const clean = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); };
206
+ const abort = () => { clean(); reject(new Error("device_poll_stopped")); };
207
+ if (signal.aborted) {
208
+ abort();
209
+ return;
210
+ }
211
+ signal.addEventListener("abort", abort, { once: true });
212
+ if (sleep) {
213
+ Promise.resolve().then(() => sleep(ms)).then(() => { clean(); resolve(); }, error => { clean(); reject(error); });
214
+ }
215
+ else
216
+ timer = setTimeout(() => { clean(); resolve(); }, ms);
217
+ });
218
+ }
168
219
  /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
169
220
  function backoff(intervalMs) {
170
221
  return Math.min(intervalMs * 2, MAX_POLL_BACKOFF_MS);
@@ -0,0 +1,169 @@
1
+ import { loadConfig, normalizeBaseUrl } from "./config.js";
2
+ import { CliError } from "./errors.js";
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;
6
+ const label = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
7
+ /** A receipt keeps an explicit selection, never the team used to inspect the tenant grant. */
8
+ export function selectedOnboardTeam(journal) {
9
+ const step = journal?.steps.find(row => row.id === "linear.team");
10
+ const id = step?.state === "done" ? step.evidence?.team : undefined;
11
+ return typeof id === "string" && idPattern.test(id) ? id : undefined;
12
+ }
13
+ /** Read-only requests bound headers and body together; raw provider errors never reach receipts. */
14
+ export async function readExistingOnboardJson(ctx, path, external) {
15
+ const cfg = loadConfig(ctx.home);
16
+ if (!cfg?.user)
17
+ return { reason: "personal_login_required" };
18
+ // Identity verification refreshes before adapters run. These bounded reads never rotate a grant
19
+ // 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);
22
+ if (!bearer)
23
+ return { reason: "team_read_login_refresh_required" };
24
+ const deadline = new AbortController();
25
+ const timer = setTimeout(() => deadline.abort(), 30_000);
26
+ const signal = external ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
27
+ let remove = () => { };
28
+ try {
29
+ return await new Promise((resolve, reject) => {
30
+ const stopped = () => external?.aborted
31
+ ? reject(new CliError("Setup paused. Run the same command to resume.", "interrupted", 11))
32
+ : resolve({ reason: "team_read_unavailable" });
33
+ if (signal.aborted) {
34
+ stopped();
35
+ return;
36
+ }
37
+ signal.addEventListener("abort", stopped, { once: true });
38
+ remove = () => signal.removeEventListener("abort", stopped);
39
+ Promise.resolve().then(async () => {
40
+ if (signal.aborted)
41
+ return { reason: "team_read_unavailable" };
42
+ const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
43
+ headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
44
+ });
45
+ if (response.status !== 200)
46
+ return { reason: "team_read_unavailable" };
47
+ const body = await response.json();
48
+ return { body };
49
+ }).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "team_read_unavailable" }));
50
+ });
51
+ }
52
+ finally {
53
+ clearTimeout(timer);
54
+ remove();
55
+ }
56
+ }
57
+ async function inventory(ctx, signal) {
58
+ const read = await readExistingOnboardJson(ctx, "/api/v1/agent/teams", signal);
59
+ if ("reason" in read)
60
+ return read;
61
+ const body = object(read.body);
62
+ if (!body || !Array.isArray(body.teams) || body.teams.length > 1_000)
63
+ return { reason: "team_inventory_shape" };
64
+ const live = object(body.liveTeamRead);
65
+ if (live?.error !== undefined && live.error !== null)
66
+ return { reason: "team_read_unavailable" };
67
+ const teams = [];
68
+ const seen = new Set();
69
+ for (const value of body.teams) {
70
+ 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")
73
+ return { reason: "team_inventory_shape" };
74
+ seen.add(row.teamId);
75
+ teams.push({ id: row.teamId, key: label(row.teamKey), name: label(row.teamName) });
76
+ }
77
+ return { teams };
78
+ }
79
+ async function observe(ctx, id, signal) {
80
+ const read = await readExistingOnboardJson(ctx, `/api/v1/agent/tenant/readiness?team=${encodeURIComponent(id)}`, signal);
81
+ if ("reason" in read)
82
+ return null;
83
+ const row = object(object(read.body)?.readiness);
84
+ // 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))
88
+ return null;
89
+ const checks = new Map();
90
+ for (const value of row.checks) {
91
+ 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))
93
+ return null;
94
+ checks.set(check.id, check.state);
95
+ }
96
+ return { checkedAt: row.checkedAt, revision: row.workflowRev, checks };
97
+ }
98
+ export function existingLinearAdapters(args, choose) {
99
+ let chosen;
100
+ const requested = (teams, journal) => {
101
+ const explicit = args.flags.team;
102
+ if (typeof explicit === "string") {
103
+ const ids = teams.filter(team => team.id === explicit);
104
+ if (ids.length === 1)
105
+ return ids[0];
106
+ const keys = teams.filter(team => team.key === explicit);
107
+ return keys.length === 1 ? keys[0] : undefined;
108
+ }
109
+ const saved = chosen ?? selectedOnboardTeam(journal);
110
+ return saved ? teams.find(team => team.id === saved) : undefined;
111
+ };
112
+ const teamCheck = async (ctx, journal, signal) => {
113
+ const list = await inventory(ctx, signal);
114
+ if ("reason" in list)
115
+ return waiting(list.reason);
116
+ if (!list.teams.length)
117
+ return waiting("team_inventory_empty");
118
+ const team = requested(list.teams, journal);
119
+ if (!team) {
120
+ if (typeof args.flags.team === "string")
121
+ return waiting("team_selection_unverified");
122
+ return choose ? { state: "pending" } : waiting("team_choice_required");
123
+ }
124
+ const result = await observe(ctx, team.id, signal);
125
+ if (!result || result.checks.get("team_visible") !== "pass")
126
+ return waiting("team_selection_unverified");
127
+ return { state: "done", evidence: { team: team.id, teamKey: team.key, checkedAt: result.checkedAt, revision: result.revision } };
128
+ };
129
+ return {
130
+ "linear.workspace": { check: async (ctx, journal, signal) => {
131
+ const list = await inventory(ctx, signal);
132
+ if ("reason" in list)
133
+ return waiting(list.reason);
134
+ const candidate = requested(list.teams, journal) ?? list.teams[0];
135
+ if (!candidate)
136
+ return waiting("linear_workspace_unverified");
137
+ 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 } }
140
+ : 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);
144
+ if ("reason" in list)
145
+ return waiting(list.reason);
146
+ if (!list.teams.length)
147
+ return waiting("team_inventory_empty");
148
+ if (!choose)
149
+ return waiting("team_choice_required");
150
+ let remove = () => { };
151
+ const answer = await new Promise((resolve, reject) => {
152
+ const stopped = () => resolve(null);
153
+ if (signal?.aborted) {
154
+ stopped();
155
+ return;
156
+ }
157
+ signal?.addEventListener("abort", stopped, { once: true });
158
+ remove = () => signal?.removeEventListener("abort", stopped);
159
+ Promise.resolve().then(() => signal?.aborted ? null : choose(list.teams.map(team => ({ ...team })))).then(resolve, reject);
160
+ }).finally(() => remove());
161
+ if (!answer || signal?.aborted)
162
+ return waiting("interrupted");
163
+ if (!list.teams.some(team => team.id === answer))
164
+ return waiting("team_selection_unverified");
165
+ chosen = answer;
166
+ return teamCheck(ctx, journal, signal);
167
+ } },
168
+ };
169
+ }
@@ -0,0 +1,70 @@
1
+ /** Progress never owns stdin, raw mode or process exit; cancellation belongs to onboarding. */
2
+ export function createOnboardProgress(output, signal, onError) {
3
+ let timer;
4
+ let active = false;
5
+ let disposed = false;
6
+ const clearTimer = () => { if (timer) {
7
+ clearInterval(timer);
8
+ timer = undefined;
9
+ } };
10
+ const failed = () => {
11
+ if (disposed)
12
+ return;
13
+ clearTimer();
14
+ active = false;
15
+ disposed = true;
16
+ signal.removeEventListener("abort", stop);
17
+ try {
18
+ output.write("\x1b[?25h");
19
+ }
20
+ catch { /* terminal is no longer writable */ }
21
+ onError?.();
22
+ };
23
+ const write = (text) => {
24
+ try {
25
+ output.write(text);
26
+ return true;
27
+ }
28
+ catch {
29
+ failed();
30
+ return false;
31
+ }
32
+ };
33
+ const stop = () => {
34
+ clearTimer();
35
+ if (active) {
36
+ active = false;
37
+ write("\r\x1b[2K\x1b[?25h");
38
+ }
39
+ };
40
+ signal.addEventListener("abort", stop);
41
+ output.on("error", failed);
42
+ return {
43
+ start(message) {
44
+ stop();
45
+ if (disposed || signal.aborted)
46
+ return;
47
+ const frames = ["◒", "◐", "◓", "◑"];
48
+ let frame = 0;
49
+ const render = () => {
50
+ const columns = output.columns ?? 80;
51
+ const text = Array.from(message.replace(/[\r\n\x1b]/g, " ")).slice(0, Math.max(0, columns - 5)).join("");
52
+ return write(`\r\x1b[2K${frames[frame++ % frames.length]} ${text}`);
53
+ };
54
+ active = true;
55
+ if (!write("\x1b[?25l") || !render())
56
+ return;
57
+ timer = setInterval(render, 80);
58
+ timer.unref();
59
+ },
60
+ stop,
61
+ dispose() { try {
62
+ stop();
63
+ }
64
+ finally {
65
+ disposed = true;
66
+ signal.removeEventListener("abort", stop);
67
+ output.off("error", failed);
68
+ } },
69
+ };
70
+ }
@@ -0,0 +1,67 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { loadContract } from "./contract.js";
3
+ const evidenceNames = new Set(["account", "membershipId", "project", "team", "repository", "count", "checkedAt", "expiresAt", "revision", "lag", "cursor", "heartbeatAgeMs", "supervised", "available", "validated", "phaseStarted", "linearComment", "fleetActivity"]);
4
+ const slug = (value) => value && /^[a-z][a-z0-9_]{1,63}$/.test(value) ? value : undefined;
5
+ function cleanCheck(check) {
6
+ const evidence = Object.fromEntries(Object.entries(check.evidence ?? {}).filter(([key, value]) => evidenceNames.has(key) &&
7
+ (value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)))));
8
+ return { id: check.id, state: check.state, required: check.required, ...(slug(check.reason) ? { reason: slug(check.reason) } : {}),
9
+ ...(Object.keys(evidence).length ? { evidence } : {}) };
10
+ }
11
+ /** Receipts describe previous changes. Completion requires current observations. */
12
+ export async function onboardingReadyReport(ctx, deps) {
13
+ const unavailable = {
14
+ checks: [{ id: "cloud.setup", state: "unknown", required: true, reason: "cloud_checks_unavailable" }],
15
+ work: { state: "unknown" },
16
+ };
17
+ let observation;
18
+ try {
19
+ observation = deps.observe ? await deps.observe(ctx, { teamIds: deps.teamIds }) : unavailable;
20
+ }
21
+ catch {
22
+ observation = unavailable;
23
+ }
24
+ const checks = observation.checks.map(cleanCheck);
25
+ if (deps.localSync && !checks.some(check => check.required && check.id.startsWith("local.")))
26
+ checks.push({ id: "local.sync", state: "unknown", required: true, reason: "local_sync_unverified" });
27
+ const work = { state: observation.work.state,
28
+ ...(observation.work.ticket && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(observation.work.ticket) ? { ticket: observation.work.ticket } : {}),
29
+ ...(observation.work.phaseStartedAt && Number.isFinite(Date.parse(observation.work.phaseStartedAt)) ? { phaseStartedAt: observation.work.phaseStartedAt } : {}) };
30
+ const required = checks.filter(check => check.required);
31
+ const state = required.some(check => check.state === "fail") || work.state === "not_observed" ? "incomplete"
32
+ : required.length === 0 || required.some(check => check.state === "unknown") || work.state === "unknown" ? "unknown" : "complete";
33
+ return { schema: 1, readMode: deps.localSync ? "local" : "cloud", state, checks, work };
34
+ }
35
+ /** The current contract exposes checked project setup, but not fresh starter-ticket proof. */
36
+ export async function observeCloudOnboarding(ctx, scope = {}) {
37
+ const cfg = loadConfig(ctx.home);
38
+ if (!cfg?.user)
39
+ return { checks: [{ id: "signin", state: "fail", required: true, reason: "personal_login_required" }], work: { state: "unknown" } };
40
+ const { doc, source } = await loadContract(ctx, cfg, { refresh: true });
41
+ if (source === "cache")
42
+ return { checks: [{ id: "cloud.setup", state: "unknown", required: true, reason: "cloud_observation_unavailable" }], work: { state: "unknown" } };
43
+ const checks = [];
44
+ if (doc.account.id !== cfg.account)
45
+ return { checks: [{ id: "workspace", state: "fail", required: true, reason: "workspace_mismatch" }], work: { state: "unknown" } };
46
+ if (!scope.teamIds?.length)
47
+ return { checks: [{ id: "projects", state: "unknown", required: true, reason: "project_selection_unverified" }], work: { state: "unknown" } };
48
+ for (const id of new Set(scope.teamIds)) {
49
+ const team = doc.teams.find(row => row.id === id);
50
+ if (!team) {
51
+ checks.push({ id: `team.${id}`, state: "unknown", required: true, reason: "team_selection_unverified", evidence: { team: id } });
52
+ continue;
53
+ }
54
+ const fresh = team.readiness.checkedAt !== null && team.readiness.expiresAt !== null && team.readiness.expiresAt > ctx.now().getTime();
55
+ if (!fresh || team.readiness.checks.length === 0) {
56
+ checks.push({ id: `team.${team.id}`, state: "unknown", required: true, reason: "team_check_pending", evidence: { team: team.id } });
57
+ continue;
58
+ }
59
+ for (const check of team.readiness.checks)
60
+ checks.push({ id: `team.${team.id}.${check.id}`, state: check.state, required: true,
61
+ reason: slug(check.reason), evidence: { team: team.id, checkedAt: team.readiness.checkedAt, ...(check.count !== undefined ? { count: check.count } : {}) } });
62
+ }
63
+ if (!checks.length)
64
+ checks.push({ id: "projects", state: "unknown", required: true, reason: "project_selection_unverified" });
65
+ // A project may already be working even with an unknown setup check. This endpoint cannot prove either work state.
66
+ return { checks, work: { state: "unknown" } };
67
+ }