@catalyst-cloud/cli 0.14.4 → 0.14.6
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/dist/legacy.js +145 -32
- package/dist/onboard-capabilities.js +177 -0
- package/dist/onboard-consent.js +38 -11
- package/dist/onboard-github.js +329 -0
- package/dist/onboard-personal.js +3 -1
- package/dist/onboard-runtime.js +165 -95
- package/dist/onboard-ui.js +13 -1
- package/dist/onboard-workspace.js +150 -46
- package/dist/onboard.js +251 -65
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- 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
|
@@ -3,49 +3,90 @@ import { pollConsent } from "./onboard-consent.js";
|
|
|
3
3
|
const STATUS = "/api/v1/me/connections/linear/workspace";
|
|
4
4
|
const HANDOFF = "/connect/linear/workspace/handoff";
|
|
5
5
|
const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
-
const states = new Set([
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const states = new Set([
|
|
7
|
+
"connected",
|
|
8
|
+
"missing-scope",
|
|
9
|
+
"expired-or-revoked",
|
|
10
|
+
"unreachable",
|
|
11
|
+
"not-connected",
|
|
12
|
+
]);
|
|
13
|
+
const warmth = new Set([
|
|
14
|
+
"warm",
|
|
15
|
+
"absent",
|
|
16
|
+
"expired",
|
|
17
|
+
"renewal-due",
|
|
18
|
+
"no-expiry",
|
|
19
|
+
"unavailable",
|
|
20
|
+
]);
|
|
21
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
22
|
+
? v
|
|
23
|
+
: null;
|
|
24
|
+
const waiting = (reason) => ({
|
|
25
|
+
state: "waiting",
|
|
26
|
+
reason,
|
|
27
|
+
});
|
|
10
28
|
const nullableText = (v) => v === null || typeof v === "string";
|
|
11
|
-
const nullableTime = (v) => v === null || typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
29
|
+
const nullableTime = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
12
30
|
/** A bounded read never rotates credentials or writes a token after the receipt lock is released. */
|
|
13
31
|
async function read(ctx, journal, path, timeoutMs, external) {
|
|
14
32
|
const cfg = loadConfig(ctx.home);
|
|
15
33
|
if (!cfg?.user)
|
|
16
34
|
return { reason: "personal_login_required" };
|
|
17
|
-
if ((journal.account ?? journal.tenant) &&
|
|
18
|
-
journal.
|
|
19
|
-
journal.
|
|
35
|
+
if (((journal.account ?? journal.tenant) &&
|
|
36
|
+
(journal.account ?? journal.tenant) !== cfg.account) ||
|
|
37
|
+
(journal.membershipId && journal.membershipId !== cfg.user.id) ||
|
|
38
|
+
(journal.baseUrl &&
|
|
39
|
+
normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl)))
|
|
20
40
|
return { reason: "workspace_identity_refused" };
|
|
21
|
-
const expiry = cfg.auth
|
|
22
|
-
|
|
41
|
+
const expiry = cfg.auth
|
|
42
|
+
? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime()
|
|
43
|
+
: 0;
|
|
44
|
+
const bearer = cfg.key ||
|
|
45
|
+
(cfg.auth && Number.isFinite(expiry) && expiry > 30_000
|
|
46
|
+
? cfg.auth.accessToken
|
|
47
|
+
: undefined);
|
|
23
48
|
if (!bearer)
|
|
24
49
|
return { reason: "workspace_login_refresh_required" };
|
|
25
50
|
const deadline = new AbortController();
|
|
26
51
|
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
27
|
-
const signal = external
|
|
52
|
+
const signal = external
|
|
53
|
+
? AbortSignal.any([external, deadline.signal])
|
|
54
|
+
: deadline.signal;
|
|
28
55
|
let remove = () => { };
|
|
29
56
|
try {
|
|
30
|
-
return await new Promise(resolve => {
|
|
31
|
-
const stopped = () => resolve({
|
|
57
|
+
return await new Promise((resolve) => {
|
|
58
|
+
const stopped = () => resolve({
|
|
59
|
+
reason: external?.aborted
|
|
60
|
+
? "interrupted"
|
|
61
|
+
: "workspace_status_unavailable",
|
|
62
|
+
});
|
|
32
63
|
if (signal.aborted) {
|
|
33
64
|
stopped();
|
|
34
65
|
return;
|
|
35
66
|
}
|
|
36
67
|
signal.addEventListener("abort", stopped, { once: true });
|
|
37
68
|
remove = () => signal.removeEventListener("abort", stopped);
|
|
38
|
-
Promise.resolve()
|
|
69
|
+
Promise.resolve()
|
|
70
|
+
.then(async () => {
|
|
39
71
|
if (signal.aborted)
|
|
40
72
|
return { reason: "interrupted" };
|
|
41
73
|
const response = await ctx.fetch(`${normalizeBaseUrl(cfg.baseUrl)}${path}`, {
|
|
42
|
-
method: "GET",
|
|
74
|
+
method: "GET",
|
|
75
|
+
redirect: "error",
|
|
76
|
+
headers: {
|
|
77
|
+
authorization: `Bearer ${bearer}`,
|
|
78
|
+
accept: "application/json",
|
|
79
|
+
},
|
|
80
|
+
signal,
|
|
43
81
|
});
|
|
44
82
|
// Refusal bodies and provider diagnostics are never parsed, returned or printed.
|
|
45
83
|
if (response.status !== 200)
|
|
46
84
|
return { status: response.status };
|
|
47
|
-
return { status: 200, body: await response.json() };
|
|
48
|
-
})
|
|
85
|
+
return { status: 200, body: (await response.json()) };
|
|
86
|
+
})
|
|
87
|
+
.then((value) => (signal.aborted ? stopped() : resolve(value)), () => signal.aborted
|
|
88
|
+
? stopped()
|
|
89
|
+
: resolve({ reason: "workspace_status_unavailable" }));
|
|
49
90
|
});
|
|
50
91
|
}
|
|
51
92
|
finally {
|
|
@@ -55,8 +96,10 @@ async function read(ctx, journal, path, timeoutMs, external) {
|
|
|
55
96
|
}
|
|
56
97
|
function readFailure(value) {
|
|
57
98
|
if ("reason" in value)
|
|
58
|
-
return value.reason === "workspace_identity_refused" ||
|
|
59
|
-
|
|
99
|
+
return value.reason === "workspace_identity_refused" ||
|
|
100
|
+
value.reason === "personal_login_required"
|
|
101
|
+
? { state: "refused", reason: value.reason }
|
|
102
|
+
: waiting(value.reason);
|
|
60
103
|
if (value.status === 401 || value.status === 403)
|
|
61
104
|
return { state: "refused", reason: "workspace_consent_refused" };
|
|
62
105
|
return value.status === 200 ? null : waiting("workspace_status_unavailable");
|
|
@@ -67,16 +110,33 @@ function observation(body, now) {
|
|
|
67
110
|
const workspace = object(row?.workspace);
|
|
68
111
|
const credential = object(row?.credential);
|
|
69
112
|
const verification = object(row?.verification);
|
|
70
|
-
if (!row ||
|
|
71
|
-
|
|
72
|
-
!
|
|
73
|
-
|
|
113
|
+
if (!row ||
|
|
114
|
+
typeof row.connected !== "boolean" ||
|
|
115
|
+
!workspace ||
|
|
116
|
+
typeof workspace.bound !== "boolean" ||
|
|
117
|
+
!nullableText(workspace.workspaceId) ||
|
|
118
|
+
!nullableText(workspace.workspaceSlug) ||
|
|
119
|
+
!nullableText(workspace.workspaceName) ||
|
|
120
|
+
!credential ||
|
|
121
|
+
![true, false, null].includes(credential.stored) ||
|
|
122
|
+
!nullableTime(credential.updatedAt) ||
|
|
123
|
+
!nullableTime(credential.expiresAt) ||
|
|
124
|
+
typeof credential.warmth !== "string" ||
|
|
125
|
+
!warmth.has(credential.warmth) ||
|
|
126
|
+
!verification)
|
|
74
127
|
return waiting("workspace_status_shape");
|
|
75
|
-
if (verification.source === "none" &&
|
|
128
|
+
if (verification.source === "none" &&
|
|
129
|
+
verification.state === "not-run" &&
|
|
130
|
+
verification.checkedAt === null &&
|
|
131
|
+
row.connected === false)
|
|
76
132
|
return waiting("workspace_status_unavailable");
|
|
77
|
-
if (verification.source !== "live-probe" ||
|
|
78
|
-
typeof verification.
|
|
79
|
-
|
|
133
|
+
if (verification.source !== "live-probe" ||
|
|
134
|
+
typeof verification.state !== "string" ||
|
|
135
|
+
!states.has(verification.state) ||
|
|
136
|
+
typeof verification.checkedAt !== "number" ||
|
|
137
|
+
!Number.isFinite(verification.checkedAt) ||
|
|
138
|
+
verification.checkedAt < now - 120_000 ||
|
|
139
|
+
verification.checkedAt > now + 30_000 ||
|
|
80
140
|
row.connected !== (verification.state === "connected"))
|
|
81
141
|
return waiting("workspace_status_shape");
|
|
82
142
|
if (verification.state === "unreachable")
|
|
@@ -84,15 +144,29 @@ function observation(body, now) {
|
|
|
84
144
|
if (!row.connected)
|
|
85
145
|
return { state: "pending" };
|
|
86
146
|
// A working credential alone cannot prove this account has a bound Linear workspace.
|
|
87
|
-
if (!workspace.bound ||
|
|
147
|
+
if (!workspace.bound ||
|
|
148
|
+
typeof workspace.workspaceId !== "string" ||
|
|
149
|
+
!idPattern.test(workspace.workspaceId))
|
|
88
150
|
return waiting("linear_workspace_unverified");
|
|
89
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
state: "done",
|
|
153
|
+
evidence: {
|
|
154
|
+
provider: "linear",
|
|
155
|
+
workspace: workspace.workspaceId,
|
|
156
|
+
checkedAt: verification.checkedAt,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
90
159
|
}
|
|
91
160
|
function safeHandoff(body, ctx) {
|
|
92
161
|
const row = object(body);
|
|
93
162
|
const cfg = loadConfig(ctx.home);
|
|
94
|
-
if (!cfg ||
|
|
95
|
-
|
|
163
|
+
if (!cfg ||
|
|
164
|
+
!row ||
|
|
165
|
+
typeof row.authorizationUrl !== "string" ||
|
|
166
|
+
row.authorizationUrl.length > 8192 ||
|
|
167
|
+
/[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
|
|
168
|
+
typeof row.expiresAt !== "number" ||
|
|
169
|
+
!Number.isFinite(row.expiresAt))
|
|
96
170
|
return null;
|
|
97
171
|
const remaining = row.expiresAt - ctx.now().getTime();
|
|
98
172
|
// Thirty seconds accommodates clock skew; an expired link is never launched.
|
|
@@ -102,9 +176,17 @@ function safeHandoff(body, ctx) {
|
|
|
102
176
|
const url = new URL(row.authorizationUrl);
|
|
103
177
|
const keys = [...url.searchParams.keys()];
|
|
104
178
|
const handoff = url.searchParams.get("handoff");
|
|
105
|
-
if (url.protocol !== "https:" ||
|
|
106
|
-
url.
|
|
107
|
-
|
|
179
|
+
if (url.protocol !== "https:" ||
|
|
180
|
+
url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin ||
|
|
181
|
+
url.username ||
|
|
182
|
+
url.password ||
|
|
183
|
+
url.pathname !== HANDOFF ||
|
|
184
|
+
url.hash ||
|
|
185
|
+
keys.length !== 1 ||
|
|
186
|
+
keys[0] !== "handoff" ||
|
|
187
|
+
!handoff ||
|
|
188
|
+
handoff.length > 4096 ||
|
|
189
|
+
/[\s\u0000-\u001f\u007f]/.test(handoff))
|
|
108
190
|
return null;
|
|
109
191
|
return url.toString();
|
|
110
192
|
}
|
|
@@ -112,19 +194,29 @@ function safeHandoff(body, ctx) {
|
|
|
112
194
|
return null;
|
|
113
195
|
}
|
|
114
196
|
}
|
|
115
|
-
/** Uses
|
|
197
|
+
/** Uses personal bearer twins; missing routes wait without an ungated readiness fallback. */
|
|
116
198
|
export function linearWorkspaceAdapter(options) {
|
|
117
199
|
const timeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
118
200
|
const consentTimeoutMs = options.consentTimeoutMs ?? 600_000;
|
|
119
|
-
if (!Number.isSafeInteger(timeoutMs) ||
|
|
120
|
-
|
|
201
|
+
if (!Number.isSafeInteger(timeoutMs) ||
|
|
202
|
+
timeoutMs < 1 ||
|
|
203
|
+
timeoutMs > 30_000 ||
|
|
204
|
+
!Number.isSafeInteger(consentTimeoutMs) ||
|
|
205
|
+
consentTimeoutMs < 1 ||
|
|
206
|
+
consentTimeoutMs > 600_000)
|
|
121
207
|
throw new Error("workspace_timeout_invalid");
|
|
122
208
|
const inspect = async (ctx, journal, signal) => {
|
|
123
209
|
const result = await read(ctx, journal, STATUS, timeoutMs, signal);
|
|
124
|
-
if ("status" in result && result.status
|
|
125
|
-
return {
|
|
210
|
+
if ("status" in result && [404, 405].includes(result.status))
|
|
211
|
+
return {
|
|
212
|
+
result: waiting("cloud_capability_unavailable"),
|
|
213
|
+
unsupported: true,
|
|
214
|
+
};
|
|
126
215
|
const failure = readFailure(result);
|
|
127
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
result: failure ??
|
|
218
|
+
observation("body" in result ? result.body : undefined, ctx.now().getTime()),
|
|
219
|
+
};
|
|
128
220
|
};
|
|
129
221
|
return {
|
|
130
222
|
check: async (ctx, journal, signal) => (await inspect(ctx, journal, signal)).result,
|
|
@@ -140,7 +232,7 @@ export function linearWorkspaceAdapter(options) {
|
|
|
140
232
|
if (before.unsupported)
|
|
141
233
|
return waiting("cloud_capability_unavailable");
|
|
142
234
|
const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
|
|
143
|
-
if ("status" in start && start.status
|
|
235
|
+
if ("status" in start && [404, 405].includes(start.status))
|
|
144
236
|
return waiting("cloud_capability_unavailable");
|
|
145
237
|
const failure = readFailure(start);
|
|
146
238
|
if (failure)
|
|
@@ -151,7 +243,9 @@ export function linearWorkspaceAdapter(options) {
|
|
|
151
243
|
if (signal?.aborted)
|
|
152
244
|
return waiting("interrupted");
|
|
153
245
|
const current = loadConfig(ctx.home);
|
|
154
|
-
if (!current?.user ||
|
|
246
|
+
if (!current?.user ||
|
|
247
|
+
current.account !== cfg.account ||
|
|
248
|
+
current.user.id !== cfg.user.id ||
|
|
155
249
|
normalizeBaseUrl(current.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
|
|
156
250
|
return { state: "refused", reason: "workspace_identity_refused" };
|
|
157
251
|
if (!["owner", "admin"].includes(current.user.role))
|
|
@@ -165,20 +259,30 @@ export function linearWorkspaceAdapter(options) {
|
|
|
165
259
|
return waiting("workspace_browser_unavailable");
|
|
166
260
|
}
|
|
167
261
|
let latest = waiting("workspace_status_unavailable");
|
|
168
|
-
const run = () => pollConsent({
|
|
262
|
+
const run = () => pollConsent({
|
|
263
|
+
timeoutMs: consentTimeoutMs,
|
|
264
|
+
signal,
|
|
265
|
+
sleep: options.sleep,
|
|
169
266
|
readStatus: async (pollSignal) => {
|
|
170
267
|
const value = await inspect(ctx, journal, pollSignal);
|
|
171
268
|
latest = value.result;
|
|
172
269
|
if (value.unsupported)
|
|
173
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
outcome: "waiting",
|
|
272
|
+
reason: "cloud_capability_unavailable",
|
|
273
|
+
};
|
|
174
274
|
if (latest.state === "done")
|
|
175
275
|
return { outcome: "connected" };
|
|
176
276
|
if (latest.state === "refused" || latest.state === "failed")
|
|
177
277
|
return { outcome: latest.state, reason: latest.reason };
|
|
178
|
-
return {
|
|
278
|
+
return {
|
|
279
|
+
outcome: latest.state === "pending" ? "absent" : "unavailable",
|
|
280
|
+
};
|
|
179
281
|
},
|
|
180
282
|
});
|
|
181
|
-
const result = options.wait
|
|
283
|
+
const result = options.wait
|
|
284
|
+
? await options.wait("Waiting for workspace approval", run)
|
|
285
|
+
: await run();
|
|
182
286
|
return result.state === "done" ? latest : result;
|
|
183
287
|
},
|
|
184
288
|
};
|