@catalyst-cloud/cli 0.14.1 → 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 +10 -0
- package/README.md +14 -0
- package/dist/args.js +7 -2
- 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/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 +2 -1
- 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/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/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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { loadConfig, normalizeBaseUrl, readManifest } from "./config.js";
|
|
2
|
+
import { contractVersionInRange } from "./contract.js";
|
|
3
|
+
import { readExistingOnboardJson, selectedOnboardTeam } from "./onboard-existing.js";
|
|
4
|
+
const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
5
|
+
const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
7
|
+
const key = (row) => `${row.owner}/${row.name}`.toLowerCase();
|
|
8
|
+
const waiting = (reason) => ({ state: "waiting", reason });
|
|
9
|
+
const valid = (value) => {
|
|
10
|
+
const row = object(value);
|
|
11
|
+
return !!row && typeof row.teamId === "string" && id.test(row.teamId) && typeof row.repoId === "string" && id.test(row.repoId) &&
|
|
12
|
+
typeof row.owner === "string" && part.test(row.owner) && typeof row.name === "string" && part.test(row.name);
|
|
13
|
+
};
|
|
14
|
+
/** Names/IDs only. A saved selection never proves current access or repository readiness. */
|
|
15
|
+
export function selectedOnboardRepositories(journal) {
|
|
16
|
+
const step = journal?.steps.find(row => row.id === "github.repos" && row.state === "done");
|
|
17
|
+
const receipt = step?.evidence?.repository;
|
|
18
|
+
if (typeof receipt !== "string" || receipt.length > 64_000)
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
const rows = JSON.parse(receipt);
|
|
22
|
+
if (!Array.isArray(rows) || !rows.length || rows.length > 100 || !rows.every(valid))
|
|
23
|
+
return [];
|
|
24
|
+
if (new Set(rows.map(key)).size !== rows.length)
|
|
25
|
+
return [];
|
|
26
|
+
return rows.map(row => ({ teamId: row.teamId, owner: row.owner, name: row.name, repoId: row.repoId }));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The personal ACL inventory chooses bindings; a fresh contract supplies IDs only. No cache read. */
|
|
33
|
+
async function inventory(ctx, journal, signal) {
|
|
34
|
+
const cfg = loadConfig(ctx.home);
|
|
35
|
+
if (!cfg?.user || !journal.account || cfg.account !== journal.account || journal.membershipId !== cfg.user.id ||
|
|
36
|
+
!journal.baseUrl || normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
37
|
+
return { reason: "repository_identity_unverified" };
|
|
38
|
+
const teamId = selectedOnboardTeam(journal);
|
|
39
|
+
if (!teamId)
|
|
40
|
+
return { reason: "team_selection_unverified" };
|
|
41
|
+
const read = await readExistingOnboardJson(ctx, "/api/v1/repos", signal);
|
|
42
|
+
if ("reason" in read)
|
|
43
|
+
return { reason: "repository_read_unavailable" };
|
|
44
|
+
const body = object(read.body);
|
|
45
|
+
if (!body || !Array.isArray(body.repos) || body.repos.length > 1_000)
|
|
46
|
+
return { reason: "repository_inventory_shape" };
|
|
47
|
+
const bindings = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const value of body.repos) {
|
|
50
|
+
const row = object(value);
|
|
51
|
+
if (!row || typeof row.teamId !== "string" || !id.test(row.teamId) || typeof row.owner !== "string" || !part.test(row.owner) ||
|
|
52
|
+
typeof row.name !== "string" || !part.test(row.name))
|
|
53
|
+
return { reason: "repository_inventory_shape" };
|
|
54
|
+
if (row.teamId !== teamId)
|
|
55
|
+
continue;
|
|
56
|
+
const name = key({ owner: row.owner, name: row.name });
|
|
57
|
+
if (seen.has(name))
|
|
58
|
+
return { reason: "repository_binding_ambiguous" };
|
|
59
|
+
seen.add(name);
|
|
60
|
+
bindings.push({ owner: row.owner, name: row.name });
|
|
61
|
+
}
|
|
62
|
+
if (!bindings.length)
|
|
63
|
+
return { repositories: [] };
|
|
64
|
+
const contractRead = await readExistingOnboardJson(ctx, "/api/v1/agent/contract", signal);
|
|
65
|
+
if ("reason" in contractRead)
|
|
66
|
+
return { reason: "repository_contract_unavailable" };
|
|
67
|
+
const doc = object(contractRead.body);
|
|
68
|
+
const account = object(doc?.account);
|
|
69
|
+
const merge = object(doc?.merge);
|
|
70
|
+
if (!doc || account?.id !== journal.account || typeof doc.contractVersion !== "string" ||
|
|
71
|
+
contractVersionInRange(doc.contractVersion, readManifest().tenantContractRange) !== true ||
|
|
72
|
+
!merge || !Array.isArray(merge.repositories) || merge.repositories.length > 10_000)
|
|
73
|
+
return { reason: "repository_contract_unverified" };
|
|
74
|
+
const lookup = new Map();
|
|
75
|
+
const idNames = new Map();
|
|
76
|
+
for (const value of merge.repositories) {
|
|
77
|
+
const row = object(value);
|
|
78
|
+
if (!row || typeof row.owner !== "string" || !part.test(row.owner) || typeof row.name !== "string" || !part.test(row.name) ||
|
|
79
|
+
typeof row.repoId !== "string" || !id.test(row.repoId))
|
|
80
|
+
return { reason: "repository_contract_unverified" };
|
|
81
|
+
const name = key({ owner: row.owner, name: row.name });
|
|
82
|
+
if (idNames.has(row.repoId) && idNames.get(row.repoId) !== name)
|
|
83
|
+
return { reason: "repository_contract_unverified" };
|
|
84
|
+
idNames.set(row.repoId, name);
|
|
85
|
+
lookup.set(name, [...(lookup.get(name) ?? []), row.repoId]);
|
|
86
|
+
}
|
|
87
|
+
const repositories = [];
|
|
88
|
+
for (const binding of bindings) {
|
|
89
|
+
const ids = lookup.get(key(binding));
|
|
90
|
+
if (!ids || ids.length !== 1)
|
|
91
|
+
return { reason: "repository_id_unverified" };
|
|
92
|
+
repositories.push({ ...binding, teamId, repoId: ids[0] });
|
|
93
|
+
}
|
|
94
|
+
return { repositories };
|
|
95
|
+
}
|
|
96
|
+
export function existingRepositoryAdapter(args, choose) {
|
|
97
|
+
let chosen;
|
|
98
|
+
const requested = (repositories, journal) => {
|
|
99
|
+
const explicit = args.flags.repo;
|
|
100
|
+
if (explicit !== undefined) {
|
|
101
|
+
const names = Array.isArray(explicit) ? explicit : [explicit];
|
|
102
|
+
if (!names.length || names.length > 100 || !names.every(name => typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(name)))
|
|
103
|
+
return null;
|
|
104
|
+
const wanted = names.map(name => String(name).toLowerCase());
|
|
105
|
+
if (new Set(wanted).size !== wanted.length)
|
|
106
|
+
return null;
|
|
107
|
+
const selected = wanted.map(name => repositories.find(row => key(row) === name));
|
|
108
|
+
return selected.every((row) => !!row) ? selected : null;
|
|
109
|
+
}
|
|
110
|
+
const saved = chosen ?? selectedOnboardRepositories(journal);
|
|
111
|
+
if (!saved.length)
|
|
112
|
+
return null;
|
|
113
|
+
const selected = saved.map(row => repositories.find(live => key(live) === key(row) && live.teamId === row.teamId && live.repoId === row.repoId));
|
|
114
|
+
return selected.every((row) => !!row) ? selected : null;
|
|
115
|
+
};
|
|
116
|
+
const check = async (ctx, journal, signal) => {
|
|
117
|
+
const list = await inventory(ctx, journal, signal);
|
|
118
|
+
if ("reason" in list)
|
|
119
|
+
return waiting(list.reason);
|
|
120
|
+
if (!list.repositories.length)
|
|
121
|
+
return waiting("repository_inventory_empty");
|
|
122
|
+
const rows = requested(list.repositories, journal);
|
|
123
|
+
if (!rows)
|
|
124
|
+
return args.flags.repo !== undefined || chosen || selectedOnboardRepositories(journal).length
|
|
125
|
+
? waiting("repository_selection_unverified") : choose ? { state: "pending" } : waiting("repository_choice_required");
|
|
126
|
+
return { state: "done", evidence: { repository: JSON.stringify(rows), count: rows.length, checkedAt: ctx.now().getTime() } };
|
|
127
|
+
};
|
|
128
|
+
return { check, act: async (ctx, journal, signal) => {
|
|
129
|
+
const list = await inventory(ctx, journal, signal);
|
|
130
|
+
if ("reason" in list)
|
|
131
|
+
return waiting(list.reason);
|
|
132
|
+
if (!list.repositories.length)
|
|
133
|
+
return waiting("repository_inventory_empty");
|
|
134
|
+
if (!choose)
|
|
135
|
+
return waiting("repository_choice_required");
|
|
136
|
+
let remove = () => { };
|
|
137
|
+
const answer = await new Promise((resolve, reject) => {
|
|
138
|
+
const stopped = () => resolve(null);
|
|
139
|
+
if (signal?.aborted) {
|
|
140
|
+
stopped();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
signal?.addEventListener("abort", stopped, { once: true });
|
|
144
|
+
remove = () => signal?.removeEventListener("abort", stopped);
|
|
145
|
+
Promise.resolve().then(() => signal?.aborted ? null : choose(list.repositories.map(row => ({ ...row })))).then(resolve, reject);
|
|
146
|
+
}).finally(() => remove());
|
|
147
|
+
if (!answer || signal?.aborted)
|
|
148
|
+
return waiting("interrupted");
|
|
149
|
+
if (!Array.isArray(answer) || !answer.length || answer.length > 100 || !answer.every(name => typeof name === "string"))
|
|
150
|
+
return waiting("repository_selection_unverified");
|
|
151
|
+
const names = answer.map(name => name.toLowerCase());
|
|
152
|
+
if (new Set(names).size !== names.length)
|
|
153
|
+
return waiting("repository_selection_unverified");
|
|
154
|
+
const rows = names.map(name => list.repositories.find(row => key(row) === name));
|
|
155
|
+
if (!rows.every((row) => !!row))
|
|
156
|
+
return waiting("repository_selection_unverified");
|
|
157
|
+
chosen = rows;
|
|
158
|
+
return check(ctx, journal, signal);
|
|
159
|
+
} };
|
|
160
|
+
}
|