@catalyst-cloud/cli 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/bin/catalyst-skills.js +8 -0
  5. package/bin/catalyst.js +5 -0
  6. package/bin/launch.js +154 -0
  7. package/dist/args.js +280 -0
  8. package/dist/ask.js +161 -0
  9. package/dist/browser.js +20 -0
  10. package/dist/cli.js +397 -0
  11. package/dist/config.js +241 -0
  12. package/dist/contract-types.js +4 -0
  13. package/dist/contract.js +184 -0
  14. package/dist/detach.js +10 -0
  15. package/dist/environment.js +207 -0
  16. package/dist/errors.js +27 -0
  17. package/dist/events.js +106 -0
  18. package/dist/execution.js +451 -0
  19. package/dist/oauth.js +300 -0
  20. package/dist/pagination.js +76 -0
  21. package/dist/prompt.js +35 -0
  22. package/dist/published.js +79 -0
  23. package/dist/query.js +248 -0
  24. package/dist/ready.js +380 -0
  25. package/dist/release.js +142 -0
  26. package/dist/replica.js +614 -0
  27. package/dist/runtime-store.js +135 -0
  28. package/dist/runtime-verb.js +66 -0
  29. package/dist/runtime.js +87 -0
  30. package/dist/sdk.js +29 -0
  31. package/dist/secret.js +190 -0
  32. package/dist/semver.js +18 -0
  33. package/dist/skill-shape.js +189 -0
  34. package/dist/skills.js +129 -0
  35. package/dist/transport.js +205 -0
  36. package/dist/ts-deps-loader.js +113 -0
  37. package/dist/watch/consumer.js +141 -0
  38. package/dist/watch/cursor-file.js +62 -0
  39. package/dist/watch.js +175 -0
  40. package/dist/write.js +224 -0
  41. package/package.json +60 -0
  42. package/skills/catalyst-github/SKILL.md +35 -0
  43. package/skills/catalyst-github/agents/openai.yaml +6 -0
  44. package/skills/catalyst-github/agents/portability.yaml +4 -0
  45. package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
  46. package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
  47. package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
  48. package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
  49. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  50. package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
  51. package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
  52. package/skills/catalyst-linear/SKILL.md +43 -0
  53. package/skills/catalyst-linear/agents/openai.yaml +6 -0
  54. package/skills/catalyst-linear/agents/portability.yaml +5 -0
  55. package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
  56. package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
  57. package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
  58. package/skills/catalyst-linear/scripts/comment.mjs +59 -0
  59. package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
  60. package/skills/catalyst-linear/scripts/label.mjs +48 -0
  61. package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
  62. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  63. package/skills/catalyst-linear/scripts/move.mjs +41 -0
  64. package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
  65. package/skills/catalyst-linear/scripts/search.mjs +49 -0
  66. package/skills/catalyst-onboard/SKILL.md +57 -0
  67. package/skills/catalyst-onboard/agents/openai.yaml +6 -0
  68. package/skills/catalyst-onboard/agents/portability.yaml +5 -0
  69. package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
  70. package/skills/catalyst-onboard/references/skill-sources.md +35 -0
  71. package/skills/catalyst-onboard/references/the-one-path.md +149 -0
  72. package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
  73. package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
  74. package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
  75. package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
  76. package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
  77. package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
  78. package/skills/catalyst-setup/SKILL.md +36 -0
  79. package/skills/catalyst-setup/agents/openai.yaml +6 -0
  80. package/skills/catalyst-setup/agents/portability.yaml +4 -0
  81. package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
  82. package/skills/catalyst-setup/scripts/check.mjs +75 -0
  83. package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
  84. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  85. package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
  86. package/skills/connect-me/SKILL.md +63 -0
  87. package/skills/connect-me/agents/openai.yaml +6 -0
  88. package/skills/connect-me/agents/portability.yaml +5 -0
  89. package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
  90. package/skills/connect-me/scripts/lib/cli.mjs +185 -0
  91. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  92. package/skills/connect-me/scripts/verify-connection.mjs +68 -0
  93. package/skills/how-catalyst-works/SKILL.md +43 -0
  94. package/skills/how-catalyst-works/agents/openai.yaml +6 -0
  95. package/skills/how-catalyst-works/agents/portability.yaml +4 -0
  96. package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
  97. package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
  98. package/skills/how-catalyst-works/references/the-ladder.md +41 -0
  99. package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
  100. package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
  101. package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
  102. package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
  103. package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
  104. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  105. package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
  106. package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
  107. package/skills/run-this-project/SKILL.md +45 -0
  108. package/skills/run-this-project/agents/openai.yaml +6 -0
  109. package/skills/run-this-project/agents/portability.yaml +5 -0
  110. package/skills/run-this-project/assets/stall-policy.json +15 -0
  111. package/skills/run-this-project/references/making-work-ready.md +60 -0
  112. package/skills/run-this-project/references/reacting-to-events.md +76 -0
  113. package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
  114. package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
  115. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  116. package/skills/run-this-project/scripts/make-ready.mjs +64 -0
  117. package/skills/run-this-project/scripts/scope-status.mjs +0 -0
  118. package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
  119. package/skills/unstick/SKILL.md +41 -0
  120. package/skills/unstick/agents/openai.yaml +6 -0
  121. package/skills/unstick/agents/portability.yaml +5 -0
  122. package/skills/unstick/references/playbook.md +51 -0
  123. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  124. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  125. package/skills/unstick/scripts/unstick.mjs +57 -0
  126. package/skills/what-needs-me/SKILL.md +41 -0
  127. package/skills/what-needs-me/agents/openai.yaml +6 -0
  128. package/skills/what-needs-me/agents/portability.yaml +5 -0
  129. package/skills/what-needs-me/references/raising-a-decision.md +41 -0
  130. package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
  131. package/skills/what-needs-me/references/settling-an-answer.md +37 -0
  132. package/skills/what-needs-me/scripts/inbox.mjs +56 -0
  133. package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
  134. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  135. package/skills/what-needs-me/scripts/raise.mjs +53 -0
  136. package/skills/what-needs-me/scripts/settle.mjs +73 -0
  137. package/skills/whats-happening/SKILL.md +43 -0
  138. package/skills/whats-happening/agents/openai.yaml +6 -0
  139. package/skills/whats-happening/agents/portability.yaml +4 -0
  140. package/skills/whats-happening/assets/status-reply.json +77 -0
  141. package/skills/whats-happening/references/reading-the-board.md +43 -0
  142. package/skills/whats-happening/references/reprioritising.md +37 -0
  143. package/skills/whats-happening/references/routing-work.md +36 -0
  144. package/skills/whats-happening/references/status-reply.md +34 -0
  145. package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
  146. package/skills/whats-happening/scripts/explain.mjs +28 -0
  147. package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
  148. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
  149. package/skills/whats-happening/scripts/snapshot.mjs +149 -0
  150. package/vendor/README.md +9 -0
  151. package/vendor/paths/index.d.ts +85 -0
  152. package/vendor/paths/index.js +148 -0
  153. package/vendor/paths/legacy-installer.d.ts +36 -0
  154. package/vendor/paths/legacy-installer.js +154 -0
  155. package/vendor/paths/node.d.ts +18 -0
  156. package/vendor/paths/node.js +102 -0
  157. package/vendor/paths/provenance.json +17 -0
package/dist/oauth.js ADDED
@@ -0,0 +1,300 @@
1
+ import { discoveryCachePathFor, loadConfig, normalizeBaseUrl, writeConfig } from "./config.js";
2
+ import { CliError } from "./errors.js";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ const DISCOVERY_TTL_MS = 60 * 60_000;
5
+ const REFRESH_SKEW_MS = 60_000;
6
+ const REQUEST_TIMEOUT_MS = 15_000;
7
+ const SLOW_DOWN_BUMP_MS = 5_000;
8
+ const MAX_POLL_BACKOFF_MS = 30_000;
9
+ const REFRESH_MAX_ATTEMPTS = 4;
10
+ const REFRESH_BASE_BACKOFF_MS = 500;
11
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ let memo = null;
13
+ /** Test seam: forget the cached discovery document. */
14
+ export function resetDiscoveryCache() {
15
+ memo = null;
16
+ }
17
+ function readDiscoveryDisk(home) {
18
+ const path = discoveryCachePathFor(home);
19
+ if (!existsSync(path))
20
+ return null;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
23
+ if (isDiscovery(parsed.doc) && typeof parsed.fetchedAt === "number" && typeof parsed.baseUrl === "string")
24
+ return parsed;
25
+ }
26
+ catch {
27
+ // a corrupt cache is just a cache miss
28
+ }
29
+ return null;
30
+ }
31
+ function isDiscovery(v) {
32
+ if (typeof v !== "object" || v === null)
33
+ return false;
34
+ const d = v;
35
+ return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
36
+ }
37
+ export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
38
+ const base = normalizeBaseUrl(baseUrl);
39
+ const nowMs = ctx.now().getTime();
40
+ const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
41
+ if (!opts.refresh) {
42
+ if (fresh(memo))
43
+ return memo.doc;
44
+ const disk = readDiscoveryDisk(ctx.home);
45
+ if (fresh(disk)) {
46
+ memo = disk;
47
+ return disk.doc;
48
+ }
49
+ }
50
+ const url = `${base}/api/v1/auth/cli`;
51
+ const res = await safeFetch(ctx, url, { headers: { accept: "application/json" } });
52
+ if (!res.ok) {
53
+ throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
54
+ }
55
+ const doc = (await parseJson(res, url));
56
+ if (!isDiscovery(doc))
57
+ throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
58
+ const entry = { baseUrl: base, fetchedAt: nowMs, doc };
59
+ memo = entry;
60
+ try {
61
+ writeFileSync(discoveryCachePathFor(ctx.home), JSON.stringify(entry, null, 2) + "\n");
62
+ }
63
+ catch {
64
+ // the disk cache is an optimisation; the in-memory memo is enough
65
+ }
66
+ return doc;
67
+ }
68
+ /**
69
+ * Run the device-authorization grant to completion and return the stored session block. Prints the
70
+ * user code and where to enter it; on a TTY it also opens the completion URL. Polls at the server's
71
+ * `interval`, honours `slow_down`, and is bounded by `AbortSignal.timeout(expires_in)` so it can
72
+ * never poll past the device code's own lifetime.
73
+ */
74
+ export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
75
+ const sleep = deps.sleep ?? defaultSleep;
76
+ const discovery = await fetchDiscovery(ctx, baseUrl);
77
+ const auth = await deviceAuthorize(ctx, discovery);
78
+ ctx.stdout("");
79
+ ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
80
+ ctx.stdout(`and enter the code: ${auth.user_code}`);
81
+ if ((deps.isTty ?? (() => false))()) {
82
+ try {
83
+ (deps.openBrowser ?? (() => { }))(auth.verification_uri_complete);
84
+ ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
85
+ }
86
+ catch {
87
+ // a browser that will not open is not a failure; the code and URL still work
88
+ }
89
+ }
90
+ ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
91
+ // Terminal states print ONE clear, actionable line and exit 2 (CliError's default). The duration
92
+ // comes from the device code's own `expires_in`, so it is right whatever the server set.
93
+ const mins = Math.round(auth.expires_in / 60);
94
+ const expired = () => new CliError(`the login code expired${mins >= 1 ? ` after ${mins} minute${mins === 1 ? "" : "s"}` : ""} — run: catalyst-skills login again`, "login-expired");
95
+ const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
96
+ let intervalMs = Math.max(1, auth.interval) * 1_000;
97
+ for (;;) {
98
+ await sleep(intervalMs);
99
+ // The device code's own lifetime bounds the loop: once it lapses, say so plainly rather than
100
+ // letting the next fetch's aborted signal surface as a generic network error (Codex P2).
101
+ if (deadline.aborted)
102
+ throw expired();
103
+ let res;
104
+ try {
105
+ res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, deadline);
106
+ }
107
+ catch (err) {
108
+ // The deadline firing mid-fetch is an expiry, not a failure; anything else is a transient
109
+ // network blip — retry with backoff until the deadline, never abandon the login (CTC-2112 P1).
110
+ if (deadline.aborted)
111
+ throw expired();
112
+ intervalMs = backoff(intervalMs);
113
+ continue;
114
+ }
115
+ if (res.ok) {
116
+ const pair = (await parseJson(res, discovery.tokenUrl));
117
+ if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
118
+ throw new CliError("the login token response was missing a token", "login-shape");
119
+ }
120
+ return tokensToAuth(ctx, pair.access_token, pair.refresh_token);
121
+ }
122
+ const err = await oauthError(res, discovery.tokenUrl);
123
+ if (err === "authorization_pending")
124
+ continue;
125
+ if (err === "slow_down") {
126
+ intervalMs += SLOW_DOWN_BUMP_MS;
127
+ continue;
128
+ }
129
+ if (err === "access_denied")
130
+ throw new CliError("login was denied — run: catalyst-skills login to try again", "login-denied");
131
+ if (err === "expired_token")
132
+ throw expired();
133
+ // A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
134
+ // Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
135
+ if (res.status === 408 || res.status === 429 || res.status >= 500) {
136
+ const retryAfter = Number(res.headers.get("retry-after"));
137
+ intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
138
+ continue;
139
+ }
140
+ throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
141
+ }
142
+ }
143
+ /** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
144
+ function backoff(intervalMs) {
145
+ return Math.min(intervalMs * 2, MAX_POLL_BACKOFF_MS);
146
+ }
147
+ /**
148
+ * Persist a rotated token pair WITHOUT clobbering a newer login (Codex P1). A long-running watch or
149
+ * detached replica holds the `cfg` it started with; if the person meanwhile re-ran `login` (a new
150
+ * session, a different tenant, or the key rail), that config is already on disk and must survive. So
151
+ * we reload disk and only write when it is still the same session we just refreshed — or when there
152
+ * is nothing on disk yet. The reloaded doc is written (not our in-memory `cfg`), so its tenant, user
153
+ * and CLI-path fields are preserved and only the auth block advances.
154
+ */
155
+ function persistRotation(ctx, cfg, sessionId, rotated) {
156
+ let disk;
157
+ try {
158
+ disk = loadConfig(ctx.home);
159
+ }
160
+ catch {
161
+ return; // a corrupt/unreadable disk config: don't overwrite what we can't understand
162
+ }
163
+ if (disk && disk.auth?.sessionId !== sessionId)
164
+ return; // a newer login (or a switch to a key) — leave it
165
+ const target = disk ?? cfg;
166
+ target.auth = rotated;
167
+ writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
168
+ }
169
+ async function deviceAuthorize(ctx, discovery) {
170
+ const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
171
+ if (!res.ok)
172
+ throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
173
+ const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
174
+ if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
175
+ throw new CliError("the login authorization response was not the expected shape", "login-shape");
176
+ }
177
+ return {
178
+ device_code: d.device_code,
179
+ user_code: d.user_code,
180
+ verification_uri: d.verification_uri,
181
+ verification_uri_complete: d.verification_uri_complete ?? d.verification_uri,
182
+ expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
183
+ interval: typeof d.interval === "number" ? d.interval : 5,
184
+ };
185
+ }
186
+ // ── the silent refresh ─────────────────────────────────────────────────────────────────────────
187
+ const inFlight = new Map();
188
+ /**
189
+ * The current bearer for `cfg`: the personal key verbatim, or (for an OAuth session) the access
190
+ * token, refreshed in place when it is within 60s of expiry. Concurrent callers that both hit the
191
+ * refresh window share one refresh (single-flight, keyed by the config's home) so the rotating
192
+ * refresh token is spent exactly once.
193
+ */
194
+ export async function bearerFor(ctx, cfg, deps = {}) {
195
+ if (typeof cfg.key === "string" && cfg.key !== "")
196
+ return cfg.key;
197
+ if (!cfg.auth)
198
+ throw new CliError("this machine is not connected — run: catalyst-skills login", "not-configured");
199
+ const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
200
+ if (Number.isFinite(msToExpiry) && msToExpiry > REFRESH_SKEW_MS)
201
+ return cfg.auth.accessToken;
202
+ const pending = inFlight.get(ctx.home);
203
+ if (pending) {
204
+ const rotated = await pending;
205
+ return rotated.accessToken;
206
+ }
207
+ const promise = refreshAndPersist(ctx, cfg, deps).finally(() => inFlight.delete(ctx.home));
208
+ inFlight.set(ctx.home, promise);
209
+ const rotated = await promise;
210
+ return rotated.accessToken;
211
+ }
212
+ async function refreshAndPersist(ctx, cfg, deps) {
213
+ const sleep = deps.sleep ?? defaultSleep;
214
+ const discovery = await fetchDiscovery(ctx, cfg.baseUrl);
215
+ const current = cfg.auth;
216
+ let lastStatus = 0;
217
+ for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
218
+ if (attempt > 0)
219
+ await sleep(REFRESH_BASE_BACKOFF_MS * 2 ** (attempt - 1));
220
+ const res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: discovery.clientId }) });
221
+ if (res.ok) {
222
+ const pair = (await parseJson(res, discovery.tokenUrl));
223
+ if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
224
+ throw new CliError("the refresh response was missing a token", "session-refresh-shape");
225
+ const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
226
+ cfg.auth = rotated; // this process keeps using its own refreshed token
227
+ persistRotation(ctx, cfg, current.sessionId, rotated);
228
+ return rotated;
229
+ }
230
+ lastStatus = res.status;
231
+ const err = await oauthError(res, discovery.tokenUrl);
232
+ if (err === "invalid_grant") {
233
+ // revocation or > inactivity window — the ONLY session-expired case; NOT routine expiry.
234
+ throw new CliError("your login expired or was revoked — run: catalyst-skills login", "session-expired");
235
+ }
236
+ const transient = res.status === 429 || res.status >= 500;
237
+ if (!transient)
238
+ throw new CliError(`could not refresh your login (${res.status}): ${err}`, "session-refresh-failed");
239
+ // transient: fall through to backoff + retry, keeping the stored tokens untouched
240
+ }
241
+ throw new CliError(`could not refresh your login after ${REFRESH_MAX_ATTEMPTS} attempts (last status ${lastStatus}) — your tokens are unchanged; try again`, "session-refresh-failed");
242
+ }
243
+ /** The SDK auth strategy for `cfg`: a static token for the key rail, a fresh-per-connect bearer for
244
+ * the OAuth rail (its `getToken` runs the same refresh path as every HTTP request). */
245
+ export function authStrategyFor(ctx, cfg) {
246
+ if (typeof cfg.key === "string" && cfg.key !== "")
247
+ return { kind: "token", token: cfg.key };
248
+ return { kind: "bearer", getToken: () => bearerFor(ctx, cfg) };
249
+ }
250
+ // ── helpers ──────────────────────────────────────────────────────────────────────────────────────
251
+ function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
252
+ const claims = decodeJwtClaims(accessToken);
253
+ const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
254
+ const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
255
+ return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
256
+ }
257
+ function decodeJwtClaims(token) {
258
+ const parts = token.split(".");
259
+ if (parts.length < 2)
260
+ return {};
261
+ try {
262
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
263
+ }
264
+ catch {
265
+ return {};
266
+ }
267
+ }
268
+ function formHeaders() {
269
+ return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
270
+ }
271
+ function form(fields) {
272
+ return new URLSearchParams(fields).toString();
273
+ }
274
+ async function safeFetch(ctx, url, init, signal) {
275
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
276
+ try {
277
+ return await ctx.fetch(url, { ...init, signal: sig });
278
+ }
279
+ catch (err) {
280
+ throw new CliError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
281
+ }
282
+ }
283
+ async function parseJson(res, url) {
284
+ try {
285
+ return await res.json();
286
+ }
287
+ catch {
288
+ throw new CliError(`${url} returned a non-JSON body`, "shape");
289
+ }
290
+ }
291
+ /** The OAuth `error` code from a 4xx body, or a short reason when there is none. */
292
+ async function oauthError(res, url) {
293
+ try {
294
+ const body = (await res.json());
295
+ return body.error ?? body.error_description ?? `HTTP ${res.status}`;
296
+ }
297
+ catch {
298
+ return `HTTP ${res.status}`;
299
+ }
300
+ }
@@ -0,0 +1,76 @@
1
+ // pagination.ts — the mirror's keyset contract, followed. `/issues` and `/pulls` answer a page plus
2
+ // three headers: X-Mirror-Cursor (the head seq the page is consistent with), X-Mirror-Total (the
3
+ // WHOLE scope's count, independent of the page) and X-Mirror-Next-Cursor — present ONLY while rows
4
+ // remain, ABSENT on the last page. A caller that ignores them gets a page and believes it is the
5
+ // answer, which is the whole of CTC-2010.
6
+ //
7
+ // The SDK's createTenantClient parses these same three headers into a typed KeysetPageMeta, and this
8
+ // module deliberately does not use it: that client takes a STATIC `key`, while this bundle resolves
9
+ // its bearer fresh per request through `bearerFor` (an OAuth session refreshes mid-walk), and `ask`
10
+ // must keep working on a runtime where the SDK cannot load (src/sdk.ts). Adopting it is the CTC-2004
11
+ // Tier 2 migration that also deletes transport.ts — not this change.
12
+ import { CliError } from "./errors.js";
13
+ export const NEXT_CURSOR_HEADER = "x-mirror-next-cursor";
14
+ export const TOTAL_HEADER = "x-mirror-total";
15
+ /** The server clamps every page to this regardless of the requested `limit` (MAX_VIEW_LIMIT). */
16
+ export const SERVER_PAGE_CAP = 500;
17
+ /** 100 pages × 500 rows = 50,000. Reaching it is a refusal, not an ending. */
18
+ export const MAX_PAGES = 100;
19
+ /** The API answers a list route with either a bare array or `{rows|items|issues|...: []}`. */
20
+ export function rowsOf(body) {
21
+ if (Array.isArray(body))
22
+ return body;
23
+ if (body && typeof body === "object") {
24
+ for (const key of ["rows", "items", "issues", "pulls", "projects", "cycles", "results", "changes", "data"]) {
25
+ const v = body[key];
26
+ if (Array.isArray(v))
27
+ return v;
28
+ }
29
+ }
30
+ return [];
31
+ }
32
+ function readTotal(headers) {
33
+ const raw = headers.get(TOTAL_HEADER);
34
+ if (raw === null)
35
+ return null;
36
+ const n = Number(raw);
37
+ return Number.isInteger(n) && n >= 0 ? n : null;
38
+ }
39
+ export async function fetchPage(api, path, query) {
40
+ const res = await api.getJson(path, { query });
41
+ const next = res.headers.get(NEXT_CURSOR_HEADER);
42
+ return {
43
+ rows: rowsOf(res.body),
44
+ nextCursor: next === null || next === "" ? null : next,
45
+ total: readTotal(res.headers),
46
+ };
47
+ }
48
+ export async function fetchAllPages(api, path, query) {
49
+ const rows = [];
50
+ const seen = new Set();
51
+ let after;
52
+ let total = null;
53
+ for (let pages = 1; pages <= MAX_PAGES; pages++) {
54
+ const page = await fetchPage(api, path, { ...query, limit: SERVER_PAGE_CAP, after });
55
+ rows.push(...page.rows);
56
+ if (page.total !== null)
57
+ total = page.total;
58
+ if (page.nextCursor === null)
59
+ return { rows, total, pages };
60
+ // ⛔ A cursor that does not advance is an infinite loop, not a long read.
61
+ if (seen.has(page.nextCursor)) {
62
+ throw new CliError(`GET ${path} repeated a page cursor after ${rows.length} rows — the cloud is not advancing the keyset, so --all cannot finish`, "pagination-stalled", 1);
63
+ }
64
+ seen.add(page.nextCursor);
65
+ after = page.nextCursor;
66
+ }
67
+ throw new CliError(`GET ${path} still had pages after ${MAX_PAGES} of them (${rows.length} rows) — narrow the scope with --team/--project/--state, or read this through the replica`, "pagination-overrun", 1);
68
+ }
69
+ /** ⛔ N IS WHAT THE SERVER SENT, not what a client-side filter left. `--team ZZZ` over a capped page
70
+ * would otherwise read "truncated at 0 of 120", which names the filter, not the truncation. */
71
+ export function truncationNotice(page) {
72
+ if (page.nextCursor === null)
73
+ return null;
74
+ const of = page.total === null ? `an unknown total (the cloud sent no ${TOTAL_HEADER})` : String(page.total);
75
+ return `truncated at ${page.rows.length} of ${of} — re-run with --all to read the whole scope`;
76
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,35 @@
1
+ // prompt.ts — the one interactive read in the CLI: asking for the personal key on a terminal without
2
+ // echoing it. The streams are parameters so the tests can drive it without a real TTY.
3
+ import { createInterface } from "node:readline";
4
+ /** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
5
+ export function stdinIsTty() {
6
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ }
8
+ /**
9
+ * Read one line without echoing it. Everything readline would write after the question itself goes
10
+ * to a muted stream, so the key never reaches the screen or a scrollback buffer; the caller still
11
+ * gets it back, trimmed.
12
+ */
13
+ export async function promptSecret(question, io) {
14
+ const input = io?.input ?? process.stdin;
15
+ const target = io?.output ?? process.stdout;
16
+ let muted = false;
17
+ const output = Object.create(target);
18
+ output.write = (chunk) => {
19
+ if (!muted)
20
+ target.write(chunk);
21
+ return true;
22
+ };
23
+ const rl = createInterface({ input, output, terminal: true });
24
+ try {
25
+ const answer = await new Promise((resolve) => {
26
+ rl.question(question, (value) => resolve(value));
27
+ muted = true;
28
+ });
29
+ target.write("\n");
30
+ return answer.trim();
31
+ }
32
+ finally {
33
+ rl.close();
34
+ }
35
+ }
@@ -0,0 +1,79 @@
1
+ // published.ts — "what is the newest release of this package?", read from the npm registry. This is
2
+ // the ONLY non-tenant host this package contacts. `ready` has always been an offline command, so the
3
+ // answer is cached for six hours, the request is capped at 1500ms, and EVERY failure returns a
4
+ // reason instead of throwing: a customer who cannot reach npm must still get a verdict (CTC-2160).
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { LEGACY_PACKAGE_NAME, publishedCachePathFor } from "./config.js";
8
+ /** CTC-3479 — asks about the forwarder package whichever package is installed. The release publishes
9
+ * @catalyst-cloud/cli first and the forwarder last, at one version, so the forwarder's `latest`
10
+ * never names a release that either package lacks. The upgrade hint names the installed package
11
+ * (config.ts updatePackageName). */
12
+ export const REGISTRY_DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(LEGACY_PACKAGE_NAME)}/dist-tags`;
13
+ export const PUBLISHED_TTL_SECONDS = 6 * 60 * 60;
14
+ export const PUBLISHED_TIMEOUT_MS = 1500;
15
+ function readPublishedCache(home) {
16
+ const path = publishedCachePathFor(home);
17
+ if (!existsSync(path))
18
+ return null;
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
21
+ if (typeof parsed.fetchedAt !== "string" || typeof parsed.latest !== "string")
22
+ return null;
23
+ return parsed;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** A write failure (read-only home) must not break the lookup — best-effort only. */
30
+ function writePublishedCache(home, cache) {
31
+ try {
32
+ const path = publishedCachePathFor(home);
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ const tmp = `${path}.tmp`;
35
+ writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
36
+ renameSync(tmp, path);
37
+ }
38
+ catch {
39
+ // best-effort: a stale or missing cache next time is not a new failure mode
40
+ }
41
+ }
42
+ function fallback(cached, reason) {
43
+ return cached ? { latest: cached.latest, reason: null, source: "stale-cache" } : { latest: null, reason, source: "none" };
44
+ }
45
+ export async function latestPublishedVersion(ctx, opts = {}) {
46
+ const cached = readPublishedCache(ctx.home);
47
+ const ageSeconds = cached ? Math.max(0, Math.floor((ctx.now().getTime() - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
48
+ if (cached && ageSeconds < PUBLISHED_TTL_SECONDS) {
49
+ return { latest: cached.latest, reason: null, source: "cache" };
50
+ }
51
+ if (opts.offline) {
52
+ return cached ? { latest: cached.latest, reason: null, source: "cache" } : { latest: null, reason: "offline", source: "none" };
53
+ }
54
+ let res;
55
+ try {
56
+ res = await ctx.fetch(REGISTRY_DIST_TAGS_URL, {
57
+ signal: AbortSignal.timeout(PUBLISHED_TIMEOUT_MS),
58
+ headers: { accept: "application/json" },
59
+ });
60
+ }
61
+ catch (err) {
62
+ const reason = err instanceof Error && err.name === "TimeoutError" ? "timed out" : err instanceof Error ? err.message : String(err);
63
+ return fallback(cached, reason);
64
+ }
65
+ if (!res.ok)
66
+ return fallback(cached, `registry answered ${res.status}`);
67
+ let body;
68
+ try {
69
+ body = await res.json();
70
+ }
71
+ catch {
72
+ return fallback(cached, "the registry sent an unreadable body");
73
+ }
74
+ const latest = body?.latest;
75
+ if (typeof latest !== "string")
76
+ return fallback(cached, "the registry sent no dist-tags.latest");
77
+ writePublishedCache(ctx.home, { fetchedAt: ctx.now().toISOString(), latest });
78
+ return { latest, reason: null, source: "network" };
79
+ }