@catalyst-cloud/catalyst-skills 0.3.1 → 0.5.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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +28 -15
  3. package/dist/args.js +19 -1
  4. package/dist/ask.js +1 -1
  5. package/dist/browser.js +20 -0
  6. package/dist/cli.js +56 -22
  7. package/dist/config.js +48 -6
  8. package/dist/contract.js +1 -1
  9. package/dist/events.js +106 -0
  10. package/dist/execution.js +55 -9
  11. package/dist/oauth.js +300 -0
  12. package/dist/query.js +1 -1
  13. package/dist/ready.js +1 -1
  14. package/dist/release.js +142 -0
  15. package/dist/replica.js +10 -3
  16. package/dist/{http.js → transport.js} +21 -10
  17. package/dist/watch/consumer.js +2 -2
  18. package/dist/watch.js +4 -2
  19. package/dist/write.js +1 -1
  20. package/package.json +5 -4
  21. package/skills/catalyst-github/scripts/lib/cli.mjs +3 -2
  22. package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
  23. package/skills/catalyst-linear/scripts/lib/cli.mjs +4 -3
  24. package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
  25. package/skills/catalyst-setup/SKILL.md +1 -1
  26. package/skills/catalyst-setup/references/what-each-check-means.md +5 -1
  27. package/skills/catalyst-setup/scripts/lib/cli.mjs +3 -2
  28. package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
  29. package/skills/catalyst-setup/scripts/replica-status.mjs +1 -1
  30. package/skills/connect-me/SKILL.md +16 -8
  31. package/skills/connect-me/scripts/lib/cli.mjs +3 -2
  32. package/skills/connect-me/scripts/lib/credential.mjs +29 -0
  33. package/skills/how-catalyst-works/references/stages-and-mapping.md +1 -1
  34. package/skills/how-catalyst-works/references/what-runs-next.md +1 -1
  35. package/skills/how-catalyst-works/references/when-a-phase-fails.md +4 -4
  36. package/skills/how-catalyst-works/scripts/lib/cli.mjs +4 -3
  37. package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
  38. package/skills/run-this-project/SKILL.md +1 -1
  39. package/skills/run-this-project/references/making-work-ready.md +1 -1
  40. package/skills/run-this-project/references/stalls-and-escalation.md +2 -2
  41. package/skills/run-this-project/scripts/lib/cli.mjs +3 -2
  42. package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
  43. package/skills/unstick/SKILL.md +41 -0
  44. package/skills/unstick/agents/openai.yaml +6 -0
  45. package/skills/unstick/agents/portability.yaml +5 -0
  46. package/skills/unstick/references/playbook.md +51 -0
  47. package/skills/unstick/scripts/lib/cli.mjs +135 -0
  48. package/skills/unstick/scripts/lib/credential.mjs +29 -0
  49. package/skills/unstick/scripts/unstick.mjs +57 -0
  50. package/skills/what-needs-me/scripts/lib/cli.mjs +3 -2
  51. package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
  52. package/skills/whats-happening/SKILL.md +1 -1
  53. package/skills/whats-happening/references/why-is-it-stuck.md +9 -4
  54. package/skills/whats-happening/scripts/lib/cli.mjs +3 -2
  55. package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
package/dist/events.js ADDED
@@ -0,0 +1,106 @@
1
+ import { flagInt, flagString, positionals } from "./args.js";
2
+ import { apiBase, requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { authStrategyFor } from "./oauth.js";
5
+ const eventsModule = "@catalyst-cloud/sdk/events";
6
+ export const loadEventsSdk = () => import(eventsModule);
7
+ export async function createEventSync(ctx, deps = {}) {
8
+ const cfg = requireConfig(ctx);
9
+ const sdk = await (deps.loadSdk ?? loadEventsSdk)();
10
+ const auth = authStrategyFor(ctx, cfg);
11
+ if (auth.kind === "cookie")
12
+ throw new CliError("event sync requires a token or OAuth session", "events-auth");
13
+ return new sdk.CatalystEventSync({
14
+ baseUrl: apiBase(cfg),
15
+ auth,
16
+ tenantId: cfg.account,
17
+ fetch: ctx.fetch,
18
+ });
19
+ }
20
+ export async function cmdEvents(args, ctx, deps = {}) {
21
+ const [sub] = positionals(args);
22
+ if (!sub)
23
+ throw new UsageError("events needs a subcommand: tail | wait-for | query");
24
+ if (!["tail", "wait-for", "query"].includes(sub))
25
+ throw new UsageError(`unknown events subcommand: ${sub}`);
26
+ const cfg = requireConfig(ctx);
27
+ const sdk = await (deps.loadSdk ?? loadEventsSdk)();
28
+ const directory = flagString(args, "directory") ??
29
+ sdk.defaultEventCacheDirectory(cfg.account);
30
+ const after = startingCursor(args, sub === "query");
31
+ const matches = matcher(args);
32
+ if (sub === "query") {
33
+ const events = (await sdk.readCachedEvents({ tenantId: cfg.account, directory, after }))
34
+ .filter(matches)
35
+ .slice(-flagInt(args, "limit", 50));
36
+ for (const event of events)
37
+ ctx.stdout(JSON.stringify(event));
38
+ return 0;
39
+ }
40
+ const controller = new AbortController();
41
+ const abort = () => controller.abort(deps.signal?.reason);
42
+ if (deps.signal?.aborted)
43
+ abort();
44
+ else
45
+ deps.signal?.addEventListener("abort", abort, { once: true });
46
+ const timer = sub === "wait-for"
47
+ ? setTimeout(() => controller.abort(new Error("event wait timed out")), flagInt(args, "timeout", 300) * 1_000)
48
+ : undefined;
49
+ try {
50
+ for await (const event of sdk.tailCachedEvents({
51
+ tenantId: cfg.account,
52
+ directory,
53
+ after,
54
+ signal: controller.signal,
55
+ })) {
56
+ if (!matches(event))
57
+ continue;
58
+ ctx.stdout(JSON.stringify(event));
59
+ if (sub === "wait-for")
60
+ return 0;
61
+ }
62
+ return sub === "wait-for" ? 1 : 0;
63
+ }
64
+ catch (error) {
65
+ if (controller.signal.aborted)
66
+ return sub === "wait-for" ? 1 : 0;
67
+ if (error.code === "ENOENT")
68
+ throw new CliError(`event cache is absent at ${directory} — start it with: catalyst-skills replica start --detach`, "events-absent", 3);
69
+ throw error;
70
+ }
71
+ finally {
72
+ if (timer)
73
+ clearTimeout(timer);
74
+ deps.signal?.removeEventListener("abort", abort);
75
+ }
76
+ }
77
+ function startingCursor(args, history) {
78
+ const requested = flagString(args, "after");
79
+ if (requested !== undefined) {
80
+ const parsed = Number(requested);
81
+ if (!Number.isSafeInteger(parsed) || parsed < 0)
82
+ throw new UsageError("--after must be a non-negative event sequence");
83
+ return parsed;
84
+ }
85
+ if (history)
86
+ return undefined;
87
+ return undefined;
88
+ }
89
+ function matcher(args) {
90
+ const type = flagString(args, "type");
91
+ const ticket = flagString(args, "ticket")?.toUpperCase();
92
+ return (event) => (!type || event.type === type) &&
93
+ (!ticket || payloadReferences(event.payload, ticket));
94
+ }
95
+ function payloadReferences(value, ticket) {
96
+ if (typeof value === "string")
97
+ return value.toUpperCase() === ticket;
98
+ if (Array.isArray(value))
99
+ return value.some((item) => payloadReferences(item, ticket));
100
+ if (!value || typeof value !== "object")
101
+ return false;
102
+ return Object.entries(value).some(([key, item]) => ["ticket", "identifier", "workItemKey", "issueIdentifier"].includes(key) &&
103
+ typeof item === "string"
104
+ ? item.toUpperCase() === ticket
105
+ : payloadReferences(item, ticket));
106
+ }
package/dist/execution.js CHANGED
@@ -5,7 +5,7 @@ import { flagBool, flagString, positionals } from "./args.js";
5
5
  import { normalizeBaseUrl, requireConfig } from "./config.js";
6
6
  import { loadContract } from "./contract.js";
7
7
  import { CliError, UsageError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  /** Every exclusion reason the eligibility evaluator names, in plain English. */
10
10
  export const EXCLUSION_REASONS = {
11
11
  ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
@@ -37,13 +37,20 @@ export const EXCLUSION_REASONS = {
37
37
  retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
38
38
  routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
39
39
  repo_paused: "an operator paused the repository",
40
- remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired",
40
+ remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst-skills release <ticket>` releases the park once its cause is fixed",
41
+ phase_parked: "the offered phase is parked after repeated failures or the repair-round cap; `catalyst-skills release <ticket>` releases it once its cause is fixed",
42
+ later_phase_lease_held: "an earlier phase is offered while a live container still holds a later phase of this ticket",
43
+ human_owned_pr: "a person's own pull request holds this ticket; it releases itself when that PR closes or merges",
44
+ review_not_converging: "review and repair kept finding new problems without converging; a person reads the findings and comments on the ticket to resume",
45
+ round_threshold: "the ticket spent its lifetime repair budget; answering its ask or pushing a fix buys one more cycle",
46
+ claim_storm: "this unit was claimed too many times in the last hour, so it waits out the hour; nothing to release",
47
+ repo_at_capacity: "the repository's runner seats are all in use; it starts when one frees",
41
48
  };
42
49
  /** Every fail-closed unknown the evaluator names. */
43
50
  export const UNKNOWN_REASONS = {
44
- ordering_never_published: "the dispatch order has never been published for this team",
51
+ ordering_never_published: "the dispatch order has never been published for this team — usually because the team has no saved stage mapping, which does not clear by itself",
45
52
  ordering_stale: "the dispatch order is stale",
46
- workflow_mapping_unknown: "the team's workflow mapping could not be read",
53
+ workflow_mapping_unknown: "the team has no saved stage mapping for dispatch, pr, done and canceled (or a mapped stage was deleted); it does not clear by itself — a tenant owner or admin maps the team in Settings → Linear teams",
47
54
  ticket_unknown: "the ticket is not in the mirror",
48
55
  dependency_snapshot_unknown: "the dependency snapshot has never completed",
49
56
  blocker_unknown: "a blocking relation could not be resolved",
@@ -61,7 +68,20 @@ export function describeReason(reason) {
61
68
  return "no reason was given";
62
69
  return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
63
70
  }
64
- export function renderExplain(ticket, row, team, knownState) {
71
+ export function renderDispatchGate(ticket, team, gate) {
72
+ const slots = (gate.missingSlots ?? []).join(", ");
73
+ const why = gate.cause === "mapping_state_unresolved"
74
+ ? `the stage team ${team} mapped for ${slots} no longer exists in Linear.`
75
+ : gate.cause === "mapping_missing"
76
+ ? `team ${team} has no saved stage mapping for ${slots}.`
77
+ : `team ${team} cannot be dispatched (${gate.cause}).`;
78
+ return [`${ticket} cannot start: ${why}`, gate.remedy].filter(Boolean).join(" ");
79
+ }
80
+ export function renderExplain(ticket, row, team, knownState, gate) {
81
+ // An unmapped team has no queue and so no rows; its gate is the whole answer, and it outranks the
82
+ // Backlog probe below, which would otherwise call the team's own Todo "not a dispatch state".
83
+ if (gate)
84
+ return renderDispatchGate(ticket, team, gate);
65
85
  if (!row) {
66
86
  // A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
67
87
  // `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
@@ -130,11 +150,15 @@ export async function cmdExplain(args, ctx) {
130
150
  if (probe.status !== 404)
131
151
  knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
132
152
  }
153
+ // The gate is TEAM-wide: it can say why a real ticket in that team cannot start, never that this id
154
+ // exists. Applied only when the mirror probe above found the ticket; a 404 keeps the unknown wording.
155
+ const gate = !row && knownState ? (res.body.eligibility?.dispatchGate ?? null) : null;
156
+ const explanation = renderExplain(ticket, row, team, knownState, gate);
133
157
  if (args.json) {
134
- ctx.stdout(JSON.stringify({ ticket, row, explanation: renderExplain(ticket, row, team, knownState) }));
158
+ ctx.stdout(JSON.stringify({ ticket, row, ...(gate ? { dispatchGate: gate } : {}), explanation }));
135
159
  }
136
160
  else {
137
- ctx.stdout(renderExplain(ticket, row, team, knownState));
161
+ ctx.stdout(explanation);
138
162
  }
139
163
  return 0;
140
164
  }
@@ -237,7 +261,7 @@ export function renderAccount(a) {
237
261
  * ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
238
262
  * which is a different fact from "you have no coding accounts" and must never print as the latter.
239
263
  */
240
- function needsNewerCloud(what, cfg) {
264
+ export function needsNewerCloud(what, cfg) {
241
265
  return new CliError(`${what} needs a newer Catalyst Cloud than ${normalizeBaseUrl(cfg.baseUrl)} is running — the route answered 404. Nothing is wrong with your tenant; ask your operator when the mirror last deployed.`, "route-not-deployed", 3, 404);
242
266
  }
243
267
  export async function cmdAccounts(args, ctx) {
@@ -310,7 +334,29 @@ export function renderHistory(ticket, doc) {
310
334
  lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
311
335
  }
312
336
  if (doc.park) {
313
- lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "needs an operator"} — ${doc.park.releasedBy}`);
337
+ lines.push(`Parked at ${doc.park.phase ?? "?"} (${doc.park.sentinel}): ${doc.park.selfReleases ? "releases itself" : "does not release itself"} — ${doc.park.releasedBy}`);
338
+ }
339
+ // Absent (an older cloud) prints nothing; `null` is a table that could not be read.
340
+ if (doc.governors === null) {
341
+ lines.push("Held by: could not be read (unreadable, not empty)");
342
+ }
343
+ else if (doc.governors !== undefined && doc.governors.length > 0) {
344
+ lines.push("Held by:");
345
+ for (const g of doc.governors) {
346
+ lines.push(` ${g.kind}${g.phase ? ` at ${g.phase}` : ""}${g.sentinel ? ` (${g.sentinel})` : ""} — ${g.release ?? "no release named"}`);
347
+ }
348
+ }
349
+ if (doc.releases === null) {
350
+ lines.push("Releases: could not be read (unreadable, not empty)");
351
+ }
352
+ else if (doc.releases !== undefined && doc.releases.length > 0) {
353
+ lines.push("Releases (newest first):");
354
+ for (const r of doc.releases) {
355
+ const what = r.outcome === "released"
356
+ ? `released: ${(r.released ?? []).map((a) => `${a.op}${a.phase ? ` ${a.phase}` : ""}`).join(", ")}`
357
+ : `refused: ${(r.refused ?? []).map((x) => x.code).join(", ")}`;
358
+ lines.push(` ${new Date(r.atMs).toISOString()} ${r.outcome} by ${r.actor.kind}:${r.actor.id} — "${r.because}" — ${what}`);
359
+ }
314
360
  }
315
361
  for (const l of doc.lease ?? [])
316
362
  lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
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
+ }
package/dist/query.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { flagInt, flagString, positionals } from "./args.js";
6
6
  import { requireConfig, replicaDbPath } from "./config.js";
7
7
  import { CliError, UsageError } from "./errors.js";
8
- import { apiClient } from "./http.js";
8
+ import { apiClient } from "./transport.js";
9
9
  import { engineFor, replicaStatus } from "./replica.js";
10
10
  import { loadSdk } from "./sdk.js";
11
11
  const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
package/dist/ready.js CHANGED
@@ -48,7 +48,7 @@ export async function readyReport(ctx, deps) {
48
48
  ? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
49
49
  : `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
50
50
  }
51
- : { id: "config", ok: false, line: "config: not connected", fix: "CATALYST_CLOUD_TOKEN=<your personal key> npx @catalyst-cloud/catalyst-skills login", who: "you (mint the key at Settings → API keys)" });
51
+ : { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst-skills login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
52
52
  }
53
53
  catch (err) {
54
54
  checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });