@catalyst-cloud/cli 0.14.10 → 0.15.0
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 +9 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +263 -101
- package/dist/oauth.js +48 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -212
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -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/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- package/vendor/self-host/provenance.json +11 -0
package/dist/onboard-workflow.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { configPathFor, loadConfig, normalizeBaseUrl } from "./config.js";
|
|
4
4
|
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
5
5
|
import { selectedOnboardTeam } from "./onboard-existing.js";
|
|
6
|
+
import { onboardTeamKey } from "./onboard-next.js";
|
|
6
7
|
import { ownedFetch } from "./owned-fetch.js";
|
|
7
8
|
import { loadHttpSdk } from "./sdk.js";
|
|
8
9
|
import { fetchMe } from "./transport.js";
|
|
@@ -36,6 +37,12 @@ const object = (value) => value !== null && typeof value === "object" && !Array.
|
|
|
36
37
|
: null;
|
|
37
38
|
const id = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
|
|
38
39
|
const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
40
|
+
const automationChecks = [
|
|
41
|
+
["open", "linear_automation_pr_open"],
|
|
42
|
+
["review", "linear_automation_pr_review"],
|
|
43
|
+
["ready", "linear_automation_pr_ready"],
|
|
44
|
+
["merge", "linear_automation_pr_merge"],
|
|
45
|
+
];
|
|
39
46
|
/** Existing mappings can satisfy this step by observation. This never means a preview was applied. */
|
|
40
47
|
export function observeOnboardWorkflow(value, team, now) {
|
|
41
48
|
const row = object(value), config = object(row?.config), readiness = object(row?.readiness);
|
|
@@ -120,191 +127,327 @@ export function observeOnboardWorkflow(value, team, now) {
|
|
|
120
127
|
"labels_present",
|
|
121
128
|
].some((check) => checks.get(check) !== "pass"))
|
|
122
129
|
return null;
|
|
130
|
+
const automations = automationChecks.map(([event, check]) => [
|
|
131
|
+
event,
|
|
132
|
+
checks.get(check),
|
|
133
|
+
]);
|
|
134
|
+
const conflicts = automations
|
|
135
|
+
.filter(([, state]) => state === "fail")
|
|
136
|
+
.map(([event]) => event);
|
|
123
137
|
return {
|
|
124
138
|
mappingHash: row.mappingHash,
|
|
125
139
|
mappingRevision: config.workflowRev,
|
|
126
140
|
readinessRevision: readiness.workflowRev,
|
|
127
141
|
checkedAt: readiness.checkedAt,
|
|
128
142
|
mappedSlots: [...mapping.values()].filter((value) => value !== null).length,
|
|
143
|
+
...(automations.every(([, state]) => state === "pass" || state === "fail")
|
|
144
|
+
? { automations: conflicts.length ? conflicts.join(",") : "none" }
|
|
145
|
+
: {}),
|
|
129
146
|
};
|
|
130
147
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
const label = (text) => text
|
|
149
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g, "")
|
|
150
|
+
.trim()
|
|
151
|
+
.slice(0, 80);
|
|
152
|
+
const listed = (names) => names.length > 10
|
|
153
|
+
? `${names.slice(0, 10).join(", ")}, and ${names.length - 10} more`
|
|
154
|
+
: names.join(", ");
|
|
155
|
+
/** The adopt preview in plain words: stages and labels it creates or keeps. Linear's names are data. */
|
|
156
|
+
export function adoptPlanLines(plan) {
|
|
157
|
+
const group = (rows, outcome) => rows.filter((row) => row.outcome === outcome).map((row) => label(row.name));
|
|
158
|
+
return [
|
|
159
|
+
["Create stages", group(plan.stages, "would-create")],
|
|
160
|
+
["Keep existing stages", group(plan.stages, "already-present")],
|
|
161
|
+
["Linear refused stages", group(plan.stages, "refused")],
|
|
162
|
+
["Create labels", group(plan.labels, "would-create")],
|
|
163
|
+
["Labels already present", group(plan.labels, "already-present")],
|
|
164
|
+
["Linear refused labels", group(plan.labels, "refused")],
|
|
165
|
+
]
|
|
166
|
+
.filter(([, names]) => names.length > 0)
|
|
167
|
+
.map(([title, names]) => `${title}: ${listed(names)}`)
|
|
168
|
+
.concat(plan.unfilledLoadBearing?.length ? [`Required work stages still missing: ${plan.unfilledLoadBearing.length}`] : [], (plan.provenanceGaps?.length ?? 0) + (plan.labelProvenanceGaps?.length ?? 0) ? ["Some existing states or labels have no recorded creator; setup keeps them"] : [], plan.labelsNotCreated?.length ? [`Labels that cannot be added: ${plan.labelsNotCreated.length}`] : []);
|
|
169
|
+
}
|
|
170
|
+
/** The only adopt bodies setup sends: this team, and the approved mode and hash. */
|
|
171
|
+
export function adoptRequestAllowed(body, team, allow) {
|
|
172
|
+
if (typeof body !== "string")
|
|
173
|
+
return false;
|
|
174
|
+
try {
|
|
175
|
+
const sent = object(JSON.parse(body));
|
|
176
|
+
return (!!sent &&
|
|
177
|
+
sent.team === team &&
|
|
178
|
+
sent.mode === allow.mode &&
|
|
179
|
+
Object.keys(sent).length === (allow.mode === "apply" ? 3 : 2) &&
|
|
180
|
+
(allow.mode === "preview" ||
|
|
181
|
+
(allow.team === team && sent.planHash === allow.planHash)));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** One bounded, identity-pinned exchange. Reads are GETs on the workflow, /me and the contract;
|
|
188
|
+
* `adopt` also allows the one POST it names to the adopt route, the same route `catalyst team adopt`
|
|
189
|
+
* uses. No credential rotation, no config write. Every entered fetch/body joins before return. */
|
|
190
|
+
async function workflowSession(ctx, journal, external, adopt, run, announce) {
|
|
191
|
+
let cfg;
|
|
192
|
+
try {
|
|
193
|
+
cfg = loadConfig(ctx.home);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return waiting("workflow_identity_unverified");
|
|
197
|
+
}
|
|
198
|
+
const team = selectedOnboardTeam(journal);
|
|
199
|
+
if (!team ||
|
|
200
|
+
!cfg?.user ||
|
|
201
|
+
(cfg.user.role !== "owner" && cfg.user.role !== "admin") ||
|
|
202
|
+
cfg.account !== (journal.account ?? journal.tenant) ||
|
|
203
|
+
cfg.user.id !== journal.membershipId ||
|
|
204
|
+
!journal.baseUrl ||
|
|
205
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
206
|
+
return waiting("workflow_identity_unverified");
|
|
207
|
+
if (adopt?.mode === "apply" && adopt.team !== team)
|
|
208
|
+
return waiting("workflow_identity_unverified");
|
|
209
|
+
const origin = normalizeBaseUrl(cfg.baseUrl), person = cfg.user.id, role = cfg.user.role;
|
|
210
|
+
let url;
|
|
211
|
+
try {
|
|
212
|
+
url = new URL(origin);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return waiting("workflow_identity_unverified");
|
|
216
|
+
}
|
|
217
|
+
if (url.protocol !== "https:" ||
|
|
218
|
+
url.origin !== origin ||
|
|
219
|
+
url.username ||
|
|
220
|
+
url.password)
|
|
221
|
+
return waiting("workflow_identity_unverified");
|
|
222
|
+
const credential = cfg.key ||
|
|
223
|
+
(cfg.auth && Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
|
|
224
|
+
? cfg.auth.accessToken
|
|
225
|
+
: undefined);
|
|
226
|
+
if (!credential)
|
|
227
|
+
return waiting("workflow_login_refresh_required");
|
|
228
|
+
const configWitness = () => createHash("sha256")
|
|
229
|
+
.update(readFileSync(configPathFor(ctx.home)))
|
|
230
|
+
.digest("hex");
|
|
231
|
+
let before;
|
|
232
|
+
try {
|
|
233
|
+
before = configWitness();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return waiting("workflow_identity_unverified");
|
|
237
|
+
}
|
|
238
|
+
const current = () => {
|
|
239
|
+
try {
|
|
240
|
+
const now = loadConfig(ctx.home);
|
|
241
|
+
return (now?.user?.id === person &&
|
|
242
|
+
now.user.role === role &&
|
|
243
|
+
now.account === cfg.account &&
|
|
244
|
+
normalizeBaseUrl(now.baseUrl) === origin &&
|
|
245
|
+
configWitness() === before &&
|
|
246
|
+
selectedOnboardTeam(journal) === team &&
|
|
247
|
+
(!!cfg.key ||
|
|
248
|
+
(!!cfg.auth && Date.parse(cfg.auth.expiresAt) > ctx.now().getTime())));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const deadline = new AbortController();
|
|
255
|
+
const timer = setTimeout(() => deadline.abort(), 30_000);
|
|
256
|
+
const signal = external
|
|
257
|
+
? AbortSignal.any([external, deadline.signal])
|
|
258
|
+
: deadline.signal;
|
|
259
|
+
const owned = ownedFetch(ctx.fetch, signal, { maxBodyBytes: 524_288 });
|
|
260
|
+
const read = (request, init) => {
|
|
261
|
+
if (!current() || signal.aborted)
|
|
262
|
+
return Promise.reject(new Error("workflow_read_stopped"));
|
|
263
|
+
const requested = new URL(request instanceof Request ? request.url : String(request));
|
|
264
|
+
const method = init?.method ?? (request instanceof Request ? request.method : "GET");
|
|
265
|
+
const write = !!adopt &&
|
|
266
|
+
method === "POST" &&
|
|
267
|
+
requested.pathname === `${path}/adopt` &&
|
|
268
|
+
requested.search === "" &&
|
|
269
|
+
!(request instanceof Request) &&
|
|
270
|
+
adoptRequestAllowed(init?.body, team, adopt);
|
|
271
|
+
if ((method !== "GET" && !write) ||
|
|
272
|
+
requested.origin !== origin ||
|
|
273
|
+
requested.username ||
|
|
274
|
+
requested.password ||
|
|
275
|
+
requested.hash ||
|
|
276
|
+
(!write &&
|
|
277
|
+
(!["/api/v1/me", "/api/v1/agent/contract", path].includes(requested.pathname) ||
|
|
278
|
+
(requested.pathname === path
|
|
279
|
+
? requested.search !== `?team=${encodeURIComponent(team)}`
|
|
280
|
+
: requested.search !== ""))))
|
|
281
|
+
return Promise.reject(new Error("workflow_read_scope"));
|
|
282
|
+
return owned.fetch(request, { ...init, signal, redirect: "error" });
|
|
283
|
+
};
|
|
284
|
+
const live = async () => {
|
|
285
|
+
const me = await fetchMe(origin, credential, read);
|
|
286
|
+
return (current() &&
|
|
287
|
+
!signal.aborted &&
|
|
288
|
+
me.account === cfg.account &&
|
|
289
|
+
me.user?.id === person &&
|
|
290
|
+
me.user.role === role);
|
|
291
|
+
};
|
|
292
|
+
const result = await (async () => {
|
|
293
|
+
try {
|
|
294
|
+
if (!(await live()))
|
|
150
295
|
return waiting("workflow_identity_unverified");
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
296
|
+
const support = await verifyOnboardRoutes({ ...ctx, fetch: read }, journal, [{ method: "GET", path }], signal);
|
|
297
|
+
if ("reason" in support)
|
|
298
|
+
return waiting(support.reason);
|
|
299
|
+
if (adopt) {
|
|
300
|
+
// Onboarding's own route list does not carry adopt; the tenant contract does, and it is the
|
|
301
|
+
// list `catalyst team adopt` requires before it posts.
|
|
302
|
+
const reply = await read(`${origin}/api/v1/agent/contract`, {
|
|
303
|
+
headers: {
|
|
304
|
+
authorization: `Bearer ${credential}`,
|
|
305
|
+
accept: "application/json",
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
const routes = reply.ok ? object(await reply.json())?.routes : null;
|
|
309
|
+
if (!Array.isArray(routes) ||
|
|
310
|
+
!routes.some((route) => object(route)?.method === "POST" &&
|
|
311
|
+
object(route)?.path === `${path}/adopt`))
|
|
312
|
+
return waiting("cloud_capability_unavailable");
|
|
155
313
|
}
|
|
156
|
-
|
|
314
|
+
if (!current() || signal.aborted)
|
|
157
315
|
return waiting("workflow_identity_unverified");
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
url.origin !== origin ||
|
|
161
|
-
url.username ||
|
|
162
|
-
url.password)
|
|
316
|
+
const sdk = await loadHttpSdk();
|
|
317
|
+
if (!current() || signal.aborted)
|
|
163
318
|
return waiting("workflow_identity_unverified");
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
319
|
+
const client = sdk.createTenantClient({
|
|
320
|
+
baseUrl: origin,
|
|
321
|
+
key: credential,
|
|
322
|
+
fetch: read,
|
|
323
|
+
timeoutMs: 30_000,
|
|
324
|
+
now: () => ctx.now().getTime(),
|
|
325
|
+
});
|
|
326
|
+
return await run({ team, client: client.teamWorkflow, live });
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
175
332
|
try {
|
|
176
|
-
|
|
333
|
+
owned.abort(new Error("workflow_read_complete"));
|
|
334
|
+
await owned.settle();
|
|
177
335
|
}
|
|
178
|
-
|
|
179
|
-
|
|
336
|
+
finally {
|
|
337
|
+
clearTimeout(timer);
|
|
180
338
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
},
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
|
|
287
|
-
}
|
|
288
|
-
finally {
|
|
289
|
-
try {
|
|
290
|
-
owned.abort(new Error("workflow_read_complete"));
|
|
291
|
-
await owned.settle();
|
|
292
|
-
}
|
|
293
|
-
finally {
|
|
294
|
-
clearTimeout(timer);
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
if (!current())
|
|
342
|
+
return waiting("workflow_identity_unverified");
|
|
343
|
+
if (signal.aborted)
|
|
344
|
+
return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
|
|
345
|
+
if (result.state === "done" && announce) {
|
|
346
|
+
// The display runs outside the bounded exchange, so identity is checked again after it.
|
|
347
|
+
announce();
|
|
348
|
+
if (!current() || signal.aborted)
|
|
349
|
+
return waiting("workflow_identity_unverified");
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
/** Verifies the selected team's mapping by observation. With a plan question or --yes, an unadopted
|
|
354
|
+
* team is `pending` and `act` previews, asks and applies the same adopt plan `catalyst team adopt`
|
|
355
|
+
* applies, then the engine checks again. Without either, setup never writes the workflow. */
|
|
356
|
+
export function onboardWorkflowVerificationAdapter(input = {}) {
|
|
357
|
+
const adopts = !!input.confirm || input.yes === true;
|
|
358
|
+
const check = (ctx, journal, external) => workflowSession(ctx, journal, external, null, async ({ team, client, live }) => {
|
|
359
|
+
const first = await client.get(team);
|
|
360
|
+
const observed = first.outcome === "ok"
|
|
361
|
+
? observeOnboardWorkflow(first, team, ctx.now().getTime())
|
|
362
|
+
: null;
|
|
363
|
+
if (!observed)
|
|
364
|
+
return first.outcome === "ok" && adopts && Array.isArray(first.rows) && first.rows.length === 0
|
|
365
|
+
? { state: "pending", reason: "workflow_mapping_unverified" }
|
|
366
|
+
: waiting("workflow_mapping_unverified");
|
|
367
|
+
if (!(await live()))
|
|
368
|
+
return waiting("workflow_identity_unverified");
|
|
369
|
+
const last = await client.get(team);
|
|
370
|
+
const verified = last.outcome === "ok"
|
|
371
|
+
? observeOnboardWorkflow(last, team, ctx.now().getTime())
|
|
372
|
+
: null;
|
|
373
|
+
if (!verified ||
|
|
374
|
+
verified.mappingHash !== observed.mappingHash ||
|
|
375
|
+
verified.mappingRevision !== observed.mappingRevision ||
|
|
376
|
+
verified.readinessRevision !== observed.readinessRevision)
|
|
377
|
+
return waiting("workflow_mapping_changed");
|
|
378
|
+
if (!(await live()))
|
|
379
|
+
return waiting("workflow_identity_unverified");
|
|
380
|
+
return {
|
|
381
|
+
state: "done",
|
|
382
|
+
evidence: {
|
|
383
|
+
team,
|
|
384
|
+
revision: verified.readinessRevision,
|
|
385
|
+
mappingRevision: verified.mappingRevision,
|
|
386
|
+
checkedAt: verified.checkedAt,
|
|
387
|
+
mappingHash: verified.mappingHash,
|
|
388
|
+
count: verified.mappedSlots,
|
|
389
|
+
...(verified.automations
|
|
390
|
+
? { automations: verified.automations }
|
|
391
|
+
: {}),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}, () => input.message?.(`${onboardTeamKey(journal) ?? "This team"} already has every state and label`));
|
|
395
|
+
if (!adopts)
|
|
396
|
+
return { check };
|
|
397
|
+
// A local non-admin never gets here; a 403 means the seat changed after login.
|
|
398
|
+
const refusal = (reply) => reply.outcome === "unauthorized"
|
|
399
|
+
? waiting("workflow_login_refresh_required")
|
|
400
|
+
: "status" in reply && reply.status === 403
|
|
401
|
+
? waiting("workflow_admin_required")
|
|
402
|
+
: waiting("workflow_unavailable");
|
|
403
|
+
return {
|
|
404
|
+
check,
|
|
405
|
+
act: async (ctx, journal, external) => {
|
|
406
|
+
// A plan that changed between preview and apply is planned and approved once more.
|
|
407
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
408
|
+
let plan, previewedTeam = "";
|
|
409
|
+
const previewed = await workflowSession(ctx, journal, external, { mode: "preview" }, async ({ team, client }) => {
|
|
410
|
+
const reply = await client.adoptPreview(team);
|
|
411
|
+
if (reply.outcome !== "ok")
|
|
412
|
+
return refusal(reply);
|
|
413
|
+
plan = reply;
|
|
414
|
+
previewedTeam = team;
|
|
415
|
+
return { state: "done" };
|
|
416
|
+
});
|
|
417
|
+
if (previewed.state !== "done" || !plan)
|
|
418
|
+
return previewed;
|
|
419
|
+
const key = onboardTeamKey(journal) ?? (label(plan.teamKey) || "this team");
|
|
420
|
+
const lines = adoptPlanLines(plan);
|
|
421
|
+
if (!input.confirm)
|
|
422
|
+
input.message?.([`Catalyst workflow plan for ${key}:`, ...lines].join("\n"));
|
|
423
|
+
const approved = input.confirm
|
|
424
|
+
? await input.confirm(key, lines)
|
|
425
|
+
: input.yes === true;
|
|
426
|
+
if (external?.aborted)
|
|
427
|
+
return waiting("interrupted");
|
|
428
|
+
if (!approved)
|
|
429
|
+
return waiting("workflow_adoption_declined");
|
|
430
|
+
const hash = plan.planHash;
|
|
431
|
+
let stale = false, summary = "";
|
|
432
|
+
const applied = await workflowSession(ctx, journal, external, { mode: "apply", team: previewedTeam, planHash: hash }, async ({ team, client }) => {
|
|
433
|
+
const reply = await client.adoptApply(team, hash);
|
|
434
|
+
if (reply.outcome !== "ok") {
|
|
435
|
+
stale =
|
|
436
|
+
"status" in reply &&
|
|
437
|
+
reply.status === 409 &&
|
|
438
|
+
reply.error === "plan-stale";
|
|
439
|
+
return stale ? waiting("workflow_plan_changed") : refusal(reply);
|
|
295
440
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (!current() || signal.aborted)
|
|
305
|
-
return waiting("workflow_identity_unverified");
|
|
441
|
+
const created = (rows) => rows.filter((row) => row.outcome === "created").length;
|
|
442
|
+
const stages = created(reply.stages), labels = created(reply.labels);
|
|
443
|
+
summary = `Applied the Catalyst workflow to ${key}: created ${stages} ${stages === 1 ? "stage" : "stages"} and ${labels} ${labels === 1 ? "label" : "labels"}.`;
|
|
444
|
+
return { state: "done" };
|
|
445
|
+
}, () => input.message?.(summary));
|
|
446
|
+
// Only the server's own plan-stale answer plans again; a later identity refusal does not.
|
|
447
|
+
if (!input.confirm || !(stale && applied.reason === "workflow_plan_changed"))
|
|
448
|
+
return applied;
|
|
306
449
|
}
|
|
307
|
-
return
|
|
450
|
+
return waiting("workflow_plan_changed");
|
|
308
451
|
},
|
|
309
452
|
};
|
|
310
453
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { finishOnTheWeb } from "./consent-browser.js";
|
|
2
2
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
3
3
|
import { pollConsent } from "./onboard-consent.js";
|
|
4
|
+
import { LINEAR_SCOPE, parsePermissions, savedOrigin, } from "./onboard-permissions.js";
|
|
4
5
|
const STATUS = "/api/v1/me/connections/linear/workspace";
|
|
5
6
|
const HANDOFF = "/connect/linear/workspace/handoff";
|
|
6
7
|
const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
@@ -106,7 +107,7 @@ function readFailure(value) {
|
|
|
106
107
|
return value.status === 200 ? null : waiting("workspace_status_unavailable");
|
|
107
108
|
}
|
|
108
109
|
/** Live liveness plus stored grant scopes is a connection verdict, not live workspace identity. */
|
|
109
|
-
function observation(body, now) {
|
|
110
|
+
function observation(body, now, origin) {
|
|
110
111
|
const row = object(body);
|
|
111
112
|
const workspace = object(row?.workspace);
|
|
112
113
|
const credential = object(row?.credential);
|
|
@@ -142,8 +143,42 @@ function observation(body, now) {
|
|
|
142
143
|
return waiting("workspace_status_shape");
|
|
143
144
|
if (verification.state === "unreachable")
|
|
144
145
|
return waiting("workspace_status_unavailable");
|
|
146
|
+
// CTC-4629: an existing grant short of this version's scopes is re-authorized at the one URL the
|
|
147
|
+
// cloud names, and only when that URL is this cloud's own OAuth start.
|
|
148
|
+
const permissions = parsePermissions(row.permissions, {
|
|
149
|
+
grant: "linear-workspace",
|
|
150
|
+
action: "reauthorize",
|
|
151
|
+
label: LINEAR_SCOPE,
|
|
152
|
+
actorFor: (url) => url.origin === origin &&
|
|
153
|
+
url.pathname === "/settings/connections" &&
|
|
154
|
+
url.search === "?reauthorize=linear"
|
|
155
|
+
? "workspace-admin"
|
|
156
|
+
: null,
|
|
157
|
+
});
|
|
158
|
+
// The verdict is derived from the verification: outdated only for missing-scope, current only
|
|
159
|
+
// for connected, and unknown may accompany either.
|
|
160
|
+
if (permissions === null ||
|
|
161
|
+
(permissions?.state === "outdated" &&
|
|
162
|
+
verification.state !== "missing-scope") ||
|
|
163
|
+
(permissions?.state === "current" && verification.state !== "connected"))
|
|
164
|
+
return waiting("workspace_status_shape");
|
|
165
|
+
if (permissions?.state === "outdated")
|
|
166
|
+
return {
|
|
167
|
+
state: "waiting",
|
|
168
|
+
reason: "linear_workspace_scope_outdated",
|
|
169
|
+
evidence: {
|
|
170
|
+
provider: "linear",
|
|
171
|
+
grant: "linear-workspace",
|
|
172
|
+
granted: permissions.granted.join(", "),
|
|
173
|
+
missing: permissions.missing.join(", "),
|
|
174
|
+
url: permissions.url,
|
|
175
|
+
actor: permissions.actor,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
145
178
|
if (!row.connected)
|
|
146
179
|
return { state: "pending" };
|
|
180
|
+
if (permissions?.state === "unknown")
|
|
181
|
+
return waiting("linear_workspace_permissions_unverified");
|
|
147
182
|
// A working credential alone cannot prove this account has a bound Linear workspace.
|
|
148
183
|
if (!workspace.bound ||
|
|
149
184
|
typeof workspace.workspaceId !== "string" ||
|
|
@@ -154,7 +189,13 @@ function observation(body, now) {
|
|
|
154
189
|
evidence: {
|
|
155
190
|
provider: "linear",
|
|
156
191
|
workspace: workspace.workspaceId,
|
|
192
|
+
...(typeof workspace.workspaceSlug === "string"
|
|
193
|
+
? { workspaceSlug: workspace.workspaceSlug }
|
|
194
|
+
: {}),
|
|
157
195
|
checkedAt: verification.checkedAt,
|
|
196
|
+
...(permissions?.state === "current"
|
|
197
|
+
? { granted: permissions.granted.join(", ") }
|
|
198
|
+
: {}),
|
|
158
199
|
},
|
|
159
200
|
};
|
|
160
201
|
}
|
|
@@ -216,7 +257,7 @@ export function linearWorkspaceAdapter(options) {
|
|
|
216
257
|
const failure = readFailure(result);
|
|
217
258
|
return {
|
|
218
259
|
result: failure ??
|
|
219
|
-
observation("body" in result ? result.body : undefined, ctx.now().getTime()),
|
|
260
|
+
observation("body" in result ? result.body : undefined, ctx.now().getTime(), savedOrigin(ctx)),
|
|
220
261
|
};
|
|
221
262
|
};
|
|
222
263
|
return {
|
|
@@ -232,6 +273,8 @@ export function linearWorkspaceAdapter(options) {
|
|
|
232
273
|
return before.result;
|
|
233
274
|
if (before.unsupported)
|
|
234
275
|
return waiting("cloud_capability_unavailable");
|
|
276
|
+
if (!options.wait)
|
|
277
|
+
return waiting("workspace_approval_required");
|
|
235
278
|
const start = await read(ctx, journal, `${STATUS}/start`, timeoutMs, signal);
|
|
236
279
|
if ("status" in start && [404, 405].includes(start.status))
|
|
237
280
|
return waiting("cloud_capability_unavailable");
|
|
@@ -251,7 +294,6 @@ export function linearWorkspaceAdapter(options) {
|
|
|
251
294
|
return { state: "refused", reason: "workspace_identity_refused" };
|
|
252
295
|
if (!["owner", "admin"].includes(current.user.role))
|
|
253
296
|
return { state: "refused", reason: "workspace_admin_required" };
|
|
254
|
-
ctx.stderr("Approve the organization's Linear connection in your browser. Your personal approval follows.");
|
|
255
297
|
// The signed URL is transient credential material: only the browser receives it.
|
|
256
298
|
let browserUnavailable = false;
|
|
257
299
|
try {
|
|
@@ -276,7 +318,10 @@ export function linearWorkspaceAdapter(options) {
|
|
|
276
318
|
outcome: "waiting",
|
|
277
319
|
reason: "cloud_capability_unavailable",
|
|
278
320
|
};
|
|
279
|
-
|
|
321
|
+
// An outdated grant is re-authorized at its own URL; polling longer cannot finish it.
|
|
322
|
+
if (latest.reason === "workspace_login_refresh_required" ||
|
|
323
|
+
latest.reason === "linear_workspace_scope_outdated" ||
|
|
324
|
+
latest.reason === "linear_workspace_permissions_unverified")
|
|
280
325
|
return { outcome: "waiting", reason: latest.reason };
|
|
281
326
|
if (latest.state === "done")
|
|
282
327
|
return { outcome: "connected" };
|
|
@@ -290,7 +335,9 @@ export function linearWorkspaceAdapter(options) {
|
|
|
290
335
|
const result = options.wait
|
|
291
336
|
? await options.wait("Waiting for workspace approval", run)
|
|
292
337
|
: await run();
|
|
293
|
-
if (result.state === "done"
|
|
338
|
+
if (result.state === "done" ||
|
|
339
|
+
latest.reason === "linear_workspace_scope_outdated" ||
|
|
340
|
+
latest.reason === "linear_workspace_permissions_unverified")
|
|
294
341
|
return latest;
|
|
295
342
|
return browserUnavailable && result.reason === "consent_timeout"
|
|
296
343
|
? waiting("workspace_browser_unavailable")
|