@catalyst-cloud/cli 0.14.4 → 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 +6 -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 +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
package/dist/onboard-runtime.js
CHANGED
|
@@ -11,6 +11,8 @@ import { boundedOnboardSignin } from "./onboard-signin.js";
|
|
|
11
11
|
import { existingLinearAdapters } from "./onboard-existing.js";
|
|
12
12
|
import { linearWorkspaceAdapter } from "./onboard-workspace.js";
|
|
13
13
|
import { existingRepositoryAdapter } from "./onboard-repositories.js";
|
|
14
|
+
import { githubInstallationAdapter } from "./onboard-github.js";
|
|
15
|
+
import { guardOnboardCapabilities, } from "./onboard-capabilities.js";
|
|
14
16
|
import { fetchMe } from "./transport.js";
|
|
15
17
|
const waiting = (reason) => ({
|
|
16
18
|
state: "waiting",
|
|
@@ -67,10 +69,6 @@ export function createOnboardRuntime(args, ctx, hooks) {
|
|
|
67
69
|
evidence: { found, remaining: found },
|
|
68
70
|
};
|
|
69
71
|
};
|
|
70
|
-
const unsupported = {
|
|
71
|
-
check: async () => waiting("cloud_capability_unavailable"),
|
|
72
|
-
act: async () => waiting("cloud_capability_unavailable"),
|
|
73
|
-
};
|
|
74
72
|
const personalAdapter = (provider) => personalConsentAdapter({
|
|
75
73
|
provider,
|
|
76
74
|
openBrowser: hooks.openBrowser ?? openBrowser,
|
|
@@ -82,102 +80,174 @@ export function createOnboardRuntime(args, ctx, hooks) {
|
|
|
82
80
|
const linear = existingLinearAdapters(args, hooks.ui?.chooseTeam
|
|
83
81
|
? (teams) => hooks.ui.chooseTeam(teams.map((team) => ({ ...team })))
|
|
84
82
|
: undefined);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
const adapters = {
|
|
84
|
+
machine: {
|
|
85
|
+
check: async () => process.platform === "darwin" || process.platform === "linux"
|
|
86
|
+
? { state: "done", evidence: { provider: process.platform } }
|
|
87
|
+
: waiting("machine_platform_unsupported"),
|
|
88
|
+
},
|
|
89
|
+
cli: {
|
|
90
|
+
check: async () => ({
|
|
91
|
+
state: "done",
|
|
92
|
+
evidence: { version: readManifest().version },
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
skills: {
|
|
96
|
+
check: async (stepCtx) => {
|
|
97
|
+
const cfg = loadConfig(stepCtx.home);
|
|
98
|
+
const dir = stepCtx.env.CATALYST_SKILLS_DIR ??
|
|
99
|
+
cfg?.skillsDir ??
|
|
100
|
+
defaultSkillsDirFor(stepCtx.home);
|
|
101
|
+
const names = hooks.skillNames ?? [];
|
|
102
|
+
const count = names.filter((name) => existsSync(join(dir, name, "SKILL.md"))).length;
|
|
103
|
+
return names.length > 0 && count === names.length
|
|
104
|
+
? { state: "done", evidence: { count, path: dir } }
|
|
105
|
+
: waiting("skills_install_unverified");
|
|
94
106
|
},
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
},
|
|
108
|
+
legacy: {
|
|
109
|
+
check: legacyCheck,
|
|
110
|
+
act: async (stepCtx) => {
|
|
111
|
+
const before = await legacyCheck(stepCtx);
|
|
112
|
+
if (before.state !== "pending")
|
|
113
|
+
return before;
|
|
114
|
+
// Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
|
|
115
|
+
const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
|
|
116
|
+
const code = await cmdLegacy({
|
|
117
|
+
...args,
|
|
118
|
+
command: "legacy",
|
|
119
|
+
subcommand: null,
|
|
120
|
+
rest: [],
|
|
121
|
+
flags: { remove: true, yes: true },
|
|
122
|
+
json: false,
|
|
123
|
+
}, quiet, hooks.legacy);
|
|
124
|
+
return code === 0
|
|
125
|
+
? legacyCheck(stepCtx)
|
|
126
|
+
: { state: "failed", reason: "legacy_cleanup_failed" };
|
|
100
127
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
},
|
|
129
|
+
signin: {
|
|
130
|
+
check: async (stepCtx, journal) => {
|
|
131
|
+
const who = await identity(stepCtx, journal);
|
|
132
|
+
return who
|
|
133
|
+
? {
|
|
134
|
+
state: "done",
|
|
135
|
+
evidence: {
|
|
136
|
+
account: who.account,
|
|
137
|
+
membershipId: who.membershipId,
|
|
138
|
+
role: who.role,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: { state: "pending" };
|
|
113
142
|
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
act: async (stepCtx) => {
|
|
117
|
-
const before = await legacyCheck(stepCtx);
|
|
118
|
-
if (before.state !== "pending")
|
|
119
|
-
return before;
|
|
120
|
-
// Provider/process output is not trusted to be secret-free; keep it out of the receipt/log.
|
|
121
|
-
const quiet = { ...stepCtx, stdout: () => { }, stderr: () => { } };
|
|
122
|
-
const code = await cmdLegacy({
|
|
123
|
-
...args,
|
|
124
|
-
command: "legacy",
|
|
125
|
-
subcommand: null,
|
|
126
|
-
rest: [],
|
|
127
|
-
flags: { remove: true, yes: true },
|
|
128
|
-
json: false,
|
|
129
|
-
}, quiet, hooks.legacy);
|
|
130
|
-
return code === 0
|
|
131
|
-
? legacyCheck(stepCtx)
|
|
132
|
-
: { state: "failed", reason: "legacy_cleanup_failed" };
|
|
133
|
-
},
|
|
143
|
+
act: async (stepCtx, _journal, signal) => {
|
|
144
|
+
return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
|
|
134
145
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
},
|
|
147
|
+
...linear,
|
|
148
|
+
"linear.workspace": linearWorkspaceAdapter({
|
|
149
|
+
fallback: linear["linear.workspace"],
|
|
150
|
+
openBrowser: hooks.openBrowser ?? openBrowser,
|
|
151
|
+
wait: hooks.ui
|
|
152
|
+
? (message, work) => hooks.ui.wait(message, work)
|
|
153
|
+
: undefined,
|
|
154
|
+
sleep: hooks.sleep,
|
|
155
|
+
}),
|
|
156
|
+
"github.install": githubInstallationAdapter({
|
|
157
|
+
openBrowser: hooks.openBrowser ?? openBrowser,
|
|
158
|
+
wait: hooks.ui
|
|
159
|
+
? (message, work) => hooks.ui.wait(message, work)
|
|
160
|
+
: undefined,
|
|
161
|
+
sleep: hooks.sleep,
|
|
162
|
+
}),
|
|
163
|
+
"github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
|
|
164
|
+
? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
|
|
165
|
+
: undefined),
|
|
166
|
+
"linear.personal": personalAdapter("linear"),
|
|
167
|
+
"github.personal": personalAdapter("github"),
|
|
168
|
+
daemon: {
|
|
169
|
+
check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
|
|
170
|
+
? waiting("local_sync_capability_unavailable")
|
|
171
|
+
: {
|
|
172
|
+
state: "skipped",
|
|
173
|
+
reason: "local_sync_not_selected",
|
|
174
|
+
evidence: { provider: "cloud" },
|
|
151
175
|
},
|
|
152
|
-
},
|
|
153
|
-
...linear,
|
|
154
|
-
"linear.workspace": linearWorkspaceAdapter({
|
|
155
|
-
fallback: linear["linear.workspace"],
|
|
156
|
-
openBrowser: hooks.openBrowser ?? openBrowser,
|
|
157
|
-
wait: hooks.ui
|
|
158
|
-
? (message, work) => hooks.ui.wait(message, work)
|
|
159
|
-
: undefined,
|
|
160
|
-
sleep: hooks.sleep,
|
|
161
|
-
}),
|
|
162
|
-
"github.install": unsupported,
|
|
163
|
-
"github.repos": existingRepositoryAdapter(args, hooks.ui?.chooseRepositories
|
|
164
|
-
? (repositories) => hooks.ui.chooseRepositories(repositories.map((row) => ({ ...row })))
|
|
165
|
-
: undefined),
|
|
166
|
-
"linear.personal": personalAdapter("linear"),
|
|
167
|
-
"github.personal": personalAdapter("github"),
|
|
168
|
-
daemon: {
|
|
169
|
-
check: async (_stepCtx, journal) => (journal.localSync ?? args.flags["local-sync"] === true)
|
|
170
|
-
? waiting("local_sync_capability_unavailable")
|
|
171
|
-
: {
|
|
172
|
-
state: "skipped",
|
|
173
|
-
reason: "local_sync_not_selected",
|
|
174
|
-
evidence: { provider: "cloud" },
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
housekeeping: {
|
|
178
|
-
check: async () => waiting("housekeeping_service_unverified"),
|
|
179
|
-
},
|
|
180
|
-
ready: { check: hooks.ready },
|
|
181
176
|
},
|
|
177
|
+
housekeeping: {
|
|
178
|
+
check: async () => waiting("housekeeping_service_unverified"),
|
|
179
|
+
},
|
|
180
|
+
ready: { check: hooks.ready },
|
|
181
|
+
};
|
|
182
|
+
const get = (path) => ({ method: "GET", path });
|
|
183
|
+
const capabilities = {
|
|
184
|
+
"linear.workspace": {
|
|
185
|
+
check: [get("/api/v1/me/connections/linear/workspace")],
|
|
186
|
+
act: [
|
|
187
|
+
get("/api/v1/me/connections/linear/workspace"),
|
|
188
|
+
get("/api/v1/me/connections/linear/workspace/start"),
|
|
189
|
+
],
|
|
190
|
+
fallback: "connections",
|
|
191
|
+
},
|
|
192
|
+
"linear.personal": {
|
|
193
|
+
check: [get("/api/v1/me/connections/linear/personal")],
|
|
194
|
+
act: [
|
|
195
|
+
get("/api/v1/me/connections/linear/personal"),
|
|
196
|
+
get("/connect/linear/personal/start"),
|
|
197
|
+
],
|
|
198
|
+
fallback: "personalConnections",
|
|
199
|
+
},
|
|
200
|
+
"github.install": {
|
|
201
|
+
check: [get("/api/v1/me/connections/github/workspace")],
|
|
202
|
+
act: [
|
|
203
|
+
get("/api/v1/me/connections/github/workspace"),
|
|
204
|
+
get("/api/v1/me/connections/github/workspace/start"),
|
|
205
|
+
],
|
|
206
|
+
fallback: "connections",
|
|
207
|
+
},
|
|
208
|
+
"github.personal": {
|
|
209
|
+
check: [get("/api/v1/me/connections/github/personal")],
|
|
210
|
+
act: [
|
|
211
|
+
get("/api/v1/me/connections/github/personal"),
|
|
212
|
+
get("/connect/github/personal/start"),
|
|
213
|
+
],
|
|
214
|
+
fallback: "personalConnections",
|
|
215
|
+
},
|
|
216
|
+
"linear.team": {
|
|
217
|
+
check: [
|
|
218
|
+
get("/api/v1/agent/teams"),
|
|
219
|
+
get("/api/v1/agent/tenant/readiness"),
|
|
220
|
+
],
|
|
221
|
+
fallback: "connections",
|
|
222
|
+
},
|
|
223
|
+
"linear.automations": {
|
|
224
|
+
check: [get("/api/v1/agent/tenant/readiness")],
|
|
225
|
+
fallback: "connections",
|
|
226
|
+
},
|
|
227
|
+
"github.repos": {
|
|
228
|
+
check: [get("/api/v1/repos"), get("/api/v1/agent/contract")],
|
|
229
|
+
fallback: "connections",
|
|
230
|
+
},
|
|
231
|
+
ready: {
|
|
232
|
+
check: [get("/api/v1/agent/contract")],
|
|
233
|
+
fallback: "connections",
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
for (const [id, options] of Object.entries(capabilities)) {
|
|
237
|
+
const step = id;
|
|
238
|
+
const adapter = adapters[step];
|
|
239
|
+
if (adapter)
|
|
240
|
+
adapters[step] = guardOnboardCapabilities(adapter, {
|
|
241
|
+
...options,
|
|
242
|
+
message: (text) => hooks.ui ? hooks.ui.message(text) : ctx.stderr(text),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Public auth discovery and the existing /me sign-in bootstrap establish identity before the
|
|
246
|
+
// tenant-bound step guards can run. Unsupported/local-only steps make no endpoint requests.
|
|
247
|
+
return {
|
|
248
|
+
identity: (journal) => identity(ctx, journal),
|
|
249
|
+
bindSignals: true,
|
|
250
|
+
ui: hooks.ui,
|
|
251
|
+
adapters,
|
|
182
252
|
};
|
|
183
253
|
}
|
package/dist/onboard-ui.js
CHANGED
|
@@ -141,7 +141,19 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
141
141
|
personal_login_refresh_required: "Renew your login with catalyst login, then run catalyst onboard to resume.",
|
|
142
142
|
personal_status_unavailable: "Your personal connection could not be checked. Run catalyst onboard to try again.",
|
|
143
143
|
personal_browser_unavailable: "The browser could not open. Run catalyst onboard to try again.",
|
|
144
|
-
cloud_capability_unavailable: "
|
|
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.",
|
|
145
157
|
step_not_available_in_this_release: "This setup step is not available yet.",
|
|
146
158
|
prerequisite_not_ready: "Waiting for an earlier setup step.",
|
|
147
159
|
local_sync_not_selected: "Using cloud reads. Local sync was not selected.",
|
|
@@ -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
|
};
|