@catalyst-cloud/cli 0.14.3 → 0.14.5
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 +11 -0
- 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 +231 -0
- package/dist/onboard-runtime.js +209 -113
- package/dist/onboard-ui.js +125 -47
- 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
package/dist/onboard-ui.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { createOnboardProgress } from "./onboard-progress.js";
|
|
2
|
-
import { ONBOARD_STEPS, ONBOARD_TITLES } from "./onboard.js";
|
|
1
|
+
import { createOnboardProgress, } from "./onboard-progress.js";
|
|
2
|
+
import { ONBOARD_STEPS, ONBOARD_TITLES, } from "./onboard.js";
|
|
3
3
|
export function shouldUseOnboardUi(args, stdoutTty) {
|
|
4
|
-
return stdoutTty &&
|
|
4
|
+
return (stdoutTty &&
|
|
5
|
+
!args.json &&
|
|
6
|
+
args.flags.yes !== true &&
|
|
7
|
+
args.flags["dry-run"] !== true);
|
|
5
8
|
}
|
|
6
9
|
export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
7
10
|
const abort = new AbortController();
|
|
8
11
|
const options = { ...streams, signal: abort.signal };
|
|
9
12
|
// Clack 1.8.1's spinner calls process.exit(0) on raw Ctrl-C, bypassing receipt/lock cleanup.
|
|
10
|
-
const spin = deps.progress ??
|
|
13
|
+
const spin = deps.progress ??
|
|
14
|
+
createOnboardProgress(streams.output, abort.signal, () => abort.abort());
|
|
11
15
|
const signals = deps.signals ?? process;
|
|
12
16
|
const interrupt = () => abort.abort();
|
|
13
17
|
for (const event of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
@@ -15,15 +19,23 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
15
19
|
let active = false;
|
|
16
20
|
let introduced = false;
|
|
17
21
|
let group;
|
|
18
|
-
const stop = () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const stop = () => {
|
|
23
|
+
if (active) {
|
|
24
|
+
spin.stop();
|
|
25
|
+
active = false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const message = (text) => {
|
|
29
|
+
stop();
|
|
30
|
+
prompts.log.message(text, { output: streams.output });
|
|
31
|
+
};
|
|
32
|
+
const start = (text) => {
|
|
33
|
+
stop();
|
|
34
|
+
if (!abort.signal.aborted) {
|
|
35
|
+
spin.start(text);
|
|
36
|
+
active = true;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
27
39
|
return {
|
|
28
40
|
signal: abort.signal,
|
|
29
41
|
plan(journal) {
|
|
@@ -32,52 +44,89 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
32
44
|
prompts.intro("Catalyst setup", { output: streams.output });
|
|
33
45
|
introduced = true;
|
|
34
46
|
}
|
|
35
|
-
const steps = new Map(journal.steps.map(step => [step.id, step]));
|
|
36
|
-
message(ONBOARD_STEPS.map(id => `· ${ONBOARD_TITLES[id]}${steps.get(id)?.state === "done" ? " (recheck)" : ""}`).join("\n"));
|
|
47
|
+
const steps = new Map(journal.steps.map((step) => [step.id, step]));
|
|
48
|
+
message(ONBOARD_STEPS.map((id) => `· ${ONBOARD_TITLES[id]}${steps.get(id)?.state === "done" ? " (recheck)" : ""}`).join("\n"));
|
|
37
49
|
message("Use cloud reads by default. Local sync is optional for SQL, offline work or sustained reads.");
|
|
38
50
|
},
|
|
39
51
|
async confirmPlan(localSync) {
|
|
40
52
|
stop();
|
|
41
|
-
const answer = await prompts.select({
|
|
42
|
-
|
|
53
|
+
const answer = await prompts.select({
|
|
54
|
+
...options,
|
|
55
|
+
message: "Continue with this plan?",
|
|
56
|
+
initialValue: localSync ? "local" : "cloud",
|
|
57
|
+
options: [
|
|
58
|
+
{
|
|
59
|
+
value: "cloud",
|
|
60
|
+
label: "Continue using cloud reads",
|
|
61
|
+
hint: "default",
|
|
62
|
+
},
|
|
43
63
|
{ value: "local", label: "Continue and set up local sync" },
|
|
44
64
|
{ value: "stop", label: "Stop without changes" },
|
|
45
|
-
]
|
|
65
|
+
],
|
|
66
|
+
});
|
|
46
67
|
if (prompts.isCancel(answer)) {
|
|
47
68
|
abort.abort();
|
|
48
69
|
return { proceed: false, localSync };
|
|
49
70
|
}
|
|
50
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
proceed: answer === "cloud" || answer === "local",
|
|
73
|
+
localSync: answer === "local",
|
|
74
|
+
};
|
|
51
75
|
},
|
|
52
76
|
async chooseTeam(teams) {
|
|
53
77
|
stop();
|
|
54
78
|
if (!teams.length || abort.signal.aborted)
|
|
55
79
|
return null;
|
|
56
|
-
const answer = await prompts.select({
|
|
57
|
-
options
|
|
80
|
+
const answer = await prompts.select({
|
|
81
|
+
...options,
|
|
82
|
+
message: "Which Linear team should this project use?",
|
|
83
|
+
initialValue: teams[0].id,
|
|
84
|
+
options: teams.map((team) => ({
|
|
85
|
+
value: team.id,
|
|
86
|
+
label: `${team.name || team.key || team.id}${team.key && team.name ? ` (${team.key})` : ""}`,
|
|
87
|
+
})),
|
|
88
|
+
});
|
|
58
89
|
if (prompts.isCancel(answer)) {
|
|
59
90
|
abort.abort();
|
|
60
91
|
return null;
|
|
61
92
|
}
|
|
62
93
|
return typeof answer === "string" ? answer : null;
|
|
63
94
|
},
|
|
64
|
-
...(prompts.multiselect
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
...(prompts.multiselect
|
|
96
|
+
? {
|
|
97
|
+
async chooseRepositories(repositories) {
|
|
98
|
+
stop();
|
|
99
|
+
if (!repositories.length || abort.signal.aborted)
|
|
100
|
+
return null;
|
|
101
|
+
const answer = await prompts.multiselect({
|
|
102
|
+
...options,
|
|
103
|
+
message: "Which repositories should use this Linear team?",
|
|
104
|
+
required: true,
|
|
105
|
+
options: repositories.map((row) => ({
|
|
106
|
+
value: `${row.owner}/${row.name}`,
|
|
107
|
+
label: `${row.owner}/${row.name}`,
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
if (prompts.isCancel(answer)) {
|
|
111
|
+
abort.abort();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return Array.isArray(answer) ? answer : null;
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
76
118
|
stepStart(id) {
|
|
77
|
-
const next = ["machine", "cli", "skills", "legacy"].includes(id)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
119
|
+
const next = ["machine", "cli", "skills", "legacy"].includes(id)
|
|
120
|
+
? "This computer"
|
|
121
|
+
: id === "signin"
|
|
122
|
+
? "Catalyst sign-in"
|
|
123
|
+
: id.startsWith("linear.") || id.startsWith("github.")
|
|
124
|
+
? "Connections"
|
|
125
|
+
: ["projects", "accounts", "settings", "values"].includes(id)
|
|
126
|
+
? "Project setup"
|
|
127
|
+
: ["capacity", "daemon", "housekeeping"].includes(id)
|
|
128
|
+
? "Runner and services"
|
|
129
|
+
: "Work and readiness";
|
|
81
130
|
if (next !== group) {
|
|
82
131
|
message(next);
|
|
83
132
|
group = next;
|
|
@@ -87,7 +136,24 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
87
136
|
stepEnd(step) {
|
|
88
137
|
stop();
|
|
89
138
|
const reasons = {
|
|
90
|
-
|
|
139
|
+
personal_consent_handoff: "The browser approval link could not be verified. Run catalyst onboard to try again.",
|
|
140
|
+
personal_identity_refused: "Your current login no longer matches this setup. Resume with the original workspace and person.",
|
|
141
|
+
personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
|
|
142
|
+
personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
|
|
143
|
+
personal_browser_unavailable: "The browser could not open. Run catalyst onboard to try again.",
|
|
144
|
+
cloud_capability_unavailable: "This setup step is not available on this server yet. Use the web app link shown above, then resume after the server update.",
|
|
145
|
+
onboarding_capability_unavailable: "The server's setup capabilities could not be checked. Run catalyst onboard to try again.",
|
|
146
|
+
onboarding_capability_identity_unverified: "The server's setup capabilities belong to another login or workspace. Sign in to the original workspace to resume.",
|
|
147
|
+
onboarding_capability_login_refresh_required: "Your login needs a refresh before setup capabilities can be checked. Run catalyst login, then catalyst onboard.",
|
|
148
|
+
workspace_consent_refused: "The server did not accept this login for Linear workspace setup. Sign in again or ask your workspace administrator.",
|
|
149
|
+
github_installation_status_unavailable: "Your GitHub App installation could not be checked. Run catalyst onboard to try again.",
|
|
150
|
+
github_installation_status_shape: "The server returned a GitHub installation result that could not be verified.",
|
|
151
|
+
github_installation_consent_handoff: "The GitHub installation approval link could not be verified. Run catalyst onboard to try again.",
|
|
152
|
+
github_installation_consent_refused: "The server did not accept this login for GitHub App setup. Sign in again or ask your workspace administrator.",
|
|
153
|
+
github_installation_identity_refused: "GitHub setup belongs to another login or workspace. Sign in to the original workspace to resume.",
|
|
154
|
+
github_installation_admin_required: "A workspace owner or administrator approves the GitHub App installation.",
|
|
155
|
+
github_installation_login_refresh_required: "Your login needs a refresh. Run catalyst onboard to resume.",
|
|
156
|
+
github_installation_browser_unavailable: "The GitHub App approval page could not be opened. Use your workspace's Connections page, then resume.",
|
|
91
157
|
step_not_available_in_this_release: "This setup step is not available yet.",
|
|
92
158
|
prerequisite_not_ready: "Waiting for an earlier setup step.",
|
|
93
159
|
local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
|
|
@@ -115,27 +181,39 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
115
181
|
repository_selection_unverified: "Your selected repositories could not be verified. Run catalyst onboard --repo <owner/name> to choose again.",
|
|
116
182
|
};
|
|
117
183
|
const text = `${ONBOARD_TITLES[step.id]}${step.reason ? `: ${reasons[step.reason] ?? step.reason.replaceAll("_", " ")}` : ""}`;
|
|
118
|
-
const kind = step.state === "done"
|
|
119
|
-
|
|
184
|
+
const kind = step.state === "done"
|
|
185
|
+
? "info"
|
|
186
|
+
: step.state === "failed"
|
|
187
|
+
? "error"
|
|
188
|
+
: step.state === "waiting" || step.state === "pending"
|
|
189
|
+
? "warn"
|
|
190
|
+
: "message";
|
|
191
|
+
prompts.log[kind](`${step.state === "done" ? "✓ " : ""}${text}`, {
|
|
192
|
+
output: streams.output,
|
|
193
|
+
});
|
|
120
194
|
},
|
|
121
195
|
message,
|
|
122
196
|
finish(journal, only) {
|
|
123
197
|
stop();
|
|
124
198
|
let text = "Setup still needs checks. Run catalyst onboard to resume.";
|
|
125
199
|
if (abort.signal.aborted)
|
|
126
|
-
text =
|
|
200
|
+
text =
|
|
201
|
+
"Setup paused. Your progress is saved.\nresume: catalyst onboard";
|
|
127
202
|
else if (journal.complete && journal.exit === 0 && !only)
|
|
128
203
|
text = "Onboarding complete.";
|
|
129
204
|
else if (only && journal.exit === 0)
|
|
130
205
|
text = `${ONBOARD_TITLES[only]} finished. Onboarding still has other steps.\nresume: catalyst onboard`;
|
|
131
206
|
prompts.outro(text, { output: streams.output });
|
|
132
207
|
},
|
|
133
|
-
async wait(text, run) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
208
|
+
async wait(text, run) {
|
|
209
|
+
start(text);
|
|
210
|
+
try {
|
|
211
|
+
return await run();
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
stop();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
139
217
|
dispose() {
|
|
140
218
|
try {
|
|
141
219
|
stop();
|
|
@@ -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
|
};
|