@catalyst-cloud/cli 0.14.4 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  };