@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/CHANGELOG.md +14 -0
- package/README.md +14 -0
- package/dist/args.js +8 -3
- package/dist/cli.js +94 -37
- package/dist/contract.js +5 -0
- package/dist/env/parse-env.js +40 -4
- package/dist/env/selected-import.js +249 -0
- package/dist/legacy.js +16 -17
- package/dist/oauth.js +57 -6
- package/dist/onboard-existing.js +169 -0
- package/dist/onboard-progress.js +70 -0
- package/dist/onboard-ready.js +67 -0
- package/dist/onboard-repositories.js +160 -0
- package/dist/onboard-runtime.js +153 -0
- package/dist/onboard-signin.js +43 -0
- package/dist/onboard-ui.js +155 -0
- package/dist/onboard.js +151 -240
- package/dist/ready.js +28 -1
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +18 -5
- package/skills/catalyst-onboard/references/the-one-path.md +1 -1
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +2 -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/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.
|
|
10
|
-
import { existsSync, lstatSync, readFileSync, readdirSync,
|
|
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 = [".
|
|
51
|
-
/**
|
|
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
|
-
//
|
|
142
|
-
if (lstatSync(item.path).isDirectory())
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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"}
|
|
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
|
|
202
|
+
if (item.data) {
|
|
204
203
|
kept.push(item);
|
|
205
|
-
lines.push(`kept: ${item.kind} ${item.name} (
|
|
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) => !
|
|
220
|
-
const keptNote = kept.length > 0 ? "
|
|
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
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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 }) },
|
|
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
|
+
}
|