@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.
@@ -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
- return {
86
- identity: (journal) => identity(ctx, journal),
87
- bindSignals: true,
88
- ui: hooks.ui,
89
- adapters: {
90
- machine: {
91
- check: async () => process.platform === "darwin" || process.platform === "linux"
92
- ? { state: "done", evidence: { provider: process.platform } }
93
- : waiting("machine_platform_unsupported"),
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
- cli: {
96
- check: async () => ({
97
- state: "done",
98
- evidence: { version: readManifest().version },
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
- skills: {
102
- check: async (stepCtx) => {
103
- const cfg = loadConfig(stepCtx.home);
104
- const dir = stepCtx.env.CATALYST_SKILLS_DIR ??
105
- cfg?.skillsDir ??
106
- defaultSkillsDirFor(stepCtx.home);
107
- const names = hooks.skillNames ?? [];
108
- const count = names.filter((name) => existsSync(join(dir, name, "SKILL.md"))).length;
109
- return names.length > 0 && count === names.length
110
- ? { state: "done", evidence: { count, path: dir } }
111
- : waiting("skills_install_unverified");
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
- legacy: {
115
- check: legacyCheck,
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
- signin: {
136
- check: async (stepCtx, journal) => {
137
- const who = await identity(stepCtx, journal);
138
- return who
139
- ? {
140
- state: "done",
141
- evidence: {
142
- account: who.account,
143
- membershipId: who.membershipId,
144
- role: who.role,
145
- },
146
- }
147
- : { state: "pending" };
148
- },
149
- act: async (stepCtx, _journal, signal) => {
150
- return boundedOnboardSignin({ ...stepCtx, stdout: stepCtx.stderr }, hooks.login, signal, hooks.signinTimeoutMs);
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
  }
@@ -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: "Your cloud does not support this setup step yet.",
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(["connected", "missing-scope", "expired-or-revoked", "unreachable", "not-connected"]);
7
- const warmth = new Set(["warm", "absent", "expired", "renewal-due", "no-expiry", "unavailable"]);
8
- const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
9
- const waiting = (reason) => ({ state: "waiting", reason });
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) && (journal.account ?? journal.tenant) !== cfg.account ||
18
- journal.membershipId && journal.membershipId !== cfg.user.id ||
19
- journal.baseUrl && normalizeBaseUrl(journal.baseUrl) !== normalizeBaseUrl(cfg.baseUrl))
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 ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
22
- const bearer = cfg.key || (cfg.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
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 ? AbortSignal.any([external, deadline.signal]) : deadline.signal;
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({ reason: external?.aborted ? "interrupted" : "workspace_status_unavailable" });
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().then(async () => {
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", redirect: "error", headers: { authorization: `Bearer ${bearer}`, accept: "application/json" }, signal,
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
- }).then(value => signal.aborted ? stopped() : resolve(value), () => signal.aborted ? stopped() : resolve({ reason: "workspace_status_unavailable" }));
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" || value.reason === "personal_login_required"
59
- ? { state: "refused", reason: value.reason } : waiting(value.reason);
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 || typeof row.connected !== "boolean" || !workspace || typeof workspace.bound !== "boolean" ||
71
- !nullableText(workspace.workspaceId) || !nullableText(workspace.workspaceSlug) || !nullableText(workspace.workspaceName) ||
72
- !credential || ![true, false, null].includes(credential.stored) ||
73
- !nullableTime(credential.updatedAt) || !nullableTime(credential.expiresAt) || (typeof credential.warmth !== "string" || !warmth.has(credential.warmth)) || !verification)
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" && verification.state === "not-run" && verification.checkedAt === null && row.connected === false)
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" || (typeof verification.state !== "string" || !states.has(verification.state)) ||
78
- typeof verification.checkedAt !== "number" || !Number.isFinite(verification.checkedAt) ||
79
- verification.checkedAt < now - 120_000 || verification.checkedAt > now + 30_000 ||
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 || typeof workspace.workspaceId !== "string" || !idPattern.test(workspace.workspaceId))
147
+ if (!workspace.bound ||
148
+ typeof workspace.workspaceId !== "string" ||
149
+ !idPattern.test(workspace.workspaceId))
88
150
  return waiting("linear_workspace_unverified");
89
- return { state: "done", evidence: { provider: "linear", workspace: workspace.workspaceId, checkedAt: verification.checkedAt } };
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 || !row || typeof row.authorizationUrl !== "string" || row.authorizationUrl.length > 8192 || /[\u0000-\u0020\u007f]/.test(row.authorizationUrl) ||
95
- typeof row.expiresAt !== "number" || !Number.isFinite(row.expiresAt))
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:" || url.origin !== new URL(normalizeBaseUrl(cfg.baseUrl)).origin || url.username || url.password ||
106
- url.pathname !== HANDOFF || url.hash || keys.length !== 1 || keys[0] !== "handoff" ||
107
- !handoff || handoff.length > 4096 || /[\s\u0000-\u001f\u007f]/.test(handoff))
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 the new personal bearer twins; old clouds retain their supported read-only readiness path. */
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) || timeoutMs < 1 || timeoutMs > 30_000 ||
120
- !Number.isSafeInteger(consentTimeoutMs) || consentTimeoutMs < 1 || consentTimeoutMs > 600_000)
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 === 404)
125
- return { result: await options.fallback.check(ctx, journal, signal), unsupported: true };
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 { result: failure ?? observation("body" in result ? result.body : undefined, ctx.now().getTime()) };
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 === 404)
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 || current.account !== cfg.account || current.user.id !== cfg.user.id ||
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({ timeoutMs: consentTimeoutMs, signal, sleep: options.sleep,
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 { outcome: "failed", reason: "cloud_capability_unavailable" };
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 { outcome: latest.state === "pending" ? "absent" : "unavailable" };
278
+ return {
279
+ outcome: latest.state === "pending" ? "absent" : "unavailable",
280
+ };
179
281
  },
180
282
  });
181
- const result = options.wait ? await options.wait("Waiting for workspace approval", run) : await run();
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
  };