@catalyst-cloud/catalyst-skills 0.5.0 → 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.
- package/README.md +11 -178
- package/bin/catalyst-skills.js +3 -49
- package/bin/catalyst.js +3 -0
- package/package.json +8 -29
- package/CHANGELOG.md +0 -41
- package/LICENSE +0 -21
- package/dist/args.js +0 -261
- package/dist/ask.js +0 -158
- package/dist/browser.js +0 -20
- package/dist/cli.js +0 -356
- package/dist/config.js +0 -148
- package/dist/contract-types.js +0 -4
- package/dist/contract.js +0 -178
- package/dist/detach.js +0 -10
- package/dist/errors.js +0 -27
- package/dist/events.js +0 -106
- package/dist/execution.js +0 -369
- package/dist/oauth.js +0 -300
- package/dist/prompt.js +0 -35
- package/dist/query.js +0 -225
- package/dist/ready.js +0 -163
- package/dist/release.js +0 -142
- package/dist/replica.js +0 -325
- package/dist/sdk.js +0 -26
- package/dist/skill-shape.js +0 -179
- package/dist/skills.js +0 -78
- package/dist/transport.js +0 -205
- package/dist/ts-deps-loader.js +0 -68
- package/dist/watch/consumer.js +0 -141
- package/dist/watch/cursor-file.js +0 -62
- package/dist/watch.js +0 -175
- package/dist/write.js +0 -224
- package/skills/catalyst-github/SKILL.md +0 -35
- package/skills/catalyst-github/agents/openai.yaml +0 -6
- package/skills/catalyst-github/agents/portability.yaml +0 -4
- package/skills/catalyst-github/references/is-it-mergeable.md +0 -57
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +0 -61
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +0 -124
- package/skills/catalyst-github/scripts/lib/cli.mjs +0 -103
- package/skills/catalyst-github/scripts/lib/credential.mjs +0 -29
- package/skills/catalyst-github/scripts/lib/pull.mjs +0 -82
- package/skills/catalyst-github/scripts/read-pr.mjs +0 -97
- package/skills/catalyst-linear/SKILL.md +0 -43
- package/skills/catalyst-linear/agents/openai.yaml +0 -6
- package/skills/catalyst-linear/agents/portability.yaml +0 -5
- package/skills/catalyst-linear/references/reading-a-ticket.md +0 -50
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +0 -53
- package/skills/catalyst-linear/references/writing-to-linear.md +0 -43
- package/skills/catalyst-linear/scripts/comment.mjs +0 -59
- package/skills/catalyst-linear/scripts/create-ticket.mjs +0 -44
- package/skills/catalyst-linear/scripts/label.mjs +0 -48
- package/skills/catalyst-linear/scripts/lib/cli.mjs +0 -164
- package/skills/catalyst-linear/scripts/lib/credential.mjs +0 -29
- package/skills/catalyst-linear/scripts/move.mjs +0 -41
- package/skills/catalyst-linear/scripts/read-ticket.mjs +0 -93
- package/skills/catalyst-linear/scripts/search.mjs +0 -49
- package/skills/catalyst-setup/SKILL.md +0 -32
- package/skills/catalyst-setup/agents/openai.yaml +0 -6
- package/skills/catalyst-setup/agents/portability.yaml +0 -4
- package/skills/catalyst-setup/references/what-each-check-means.md +0 -66
- package/skills/catalyst-setup/scripts/check.mjs +0 -75
- package/skills/catalyst-setup/scripts/lib/cli.mjs +0 -103
- package/skills/catalyst-setup/scripts/lib/credential.mjs +0 -29
- package/skills/catalyst-setup/scripts/replica-status.mjs +0 -46
- package/skills/connect-me/SKILL.md +0 -63
- package/skills/connect-me/agents/openai.yaml +0 -6
- package/skills/connect-me/agents/portability.yaml +0 -5
- package/skills/connect-me/references/keeping-the-replica-running.md +0 -88
- package/skills/connect-me/scripts/lib/cli.mjs +0 -185
- package/skills/connect-me/scripts/lib/credential.mjs +0 -29
- package/skills/connect-me/scripts/verify-connection.mjs +0 -68
- package/skills/how-catalyst-works/SKILL.md +0 -42
- package/skills/how-catalyst-works/agents/openai.yaml +0 -6
- package/skills/how-catalyst-works/agents/portability.yaml +0 -4
- package/skills/how-catalyst-works/references/coding-accounts.md +0 -51
- package/skills/how-catalyst-works/references/stages-and-mapping.md +0 -54
- package/skills/how-catalyst-works/references/the-ladder.md +0 -41
- package/skills/how-catalyst-works/references/what-runs-next.md +0 -77
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +0 -57
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +0 -41
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +0 -164
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +0 -29
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +0 -94
- package/skills/how-catalyst-works/scripts/whats-running.mjs +0 -65
- package/skills/run-this-project/SKILL.md +0 -45
- package/skills/run-this-project/agents/openai.yaml +0 -6
- package/skills/run-this-project/agents/portability.yaml +0 -5
- package/skills/run-this-project/assets/stall-policy.json +0 -15
- package/skills/run-this-project/references/making-work-ready.md +0 -60
- package/skills/run-this-project/references/reacting-to-events.md +0 -76
- package/skills/run-this-project/references/stalls-and-escalation.md +0 -63
- package/skills/run-this-project/scripts/lib/cli.mjs +0 -185
- package/skills/run-this-project/scripts/lib/credential.mjs +0 -29
- package/skills/run-this-project/scripts/make-ready.mjs +0 -64
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +0 -61
- package/skills/unstick/SKILL.md +0 -41
- package/skills/unstick/agents/openai.yaml +0 -6
- package/skills/unstick/agents/portability.yaml +0 -5
- package/skills/unstick/references/playbook.md +0 -51
- package/skills/unstick/scripts/lib/cli.mjs +0 -135
- package/skills/unstick/scripts/lib/credential.mjs +0 -29
- package/skills/unstick/scripts/unstick.mjs +0 -57
- package/skills/what-needs-me/SKILL.md +0 -41
- package/skills/what-needs-me/agents/openai.yaml +0 -6
- package/skills/what-needs-me/agents/portability.yaml +0 -5
- package/skills/what-needs-me/references/raising-a-decision.md +0 -41
- package/skills/what-needs-me/references/reading-the-inbox.md +0 -38
- package/skills/what-needs-me/references/settling-an-answer.md +0 -37
- package/skills/what-needs-me/scripts/inbox.mjs +0 -56
- package/skills/what-needs-me/scripts/lib/cli.mjs +0 -135
- package/skills/what-needs-me/scripts/lib/credential.mjs +0 -29
- package/skills/what-needs-me/scripts/raise.mjs +0 -53
- package/skills/what-needs-me/scripts/settle.mjs +0 -73
- package/skills/whats-happening/SKILL.md +0 -43
- package/skills/whats-happening/agents/openai.yaml +0 -6
- package/skills/whats-happening/agents/portability.yaml +0 -4
- package/skills/whats-happening/assets/status-reply.json +0 -77
- package/skills/whats-happening/references/reading-the-board.md +0 -43
- package/skills/whats-happening/references/reprioritising.md +0 -37
- package/skills/whats-happening/references/routing-work.md +0 -36
- package/skills/whats-happening/references/status-reply.md +0 -34
- package/skills/whats-happening/references/why-is-it-stuck.md +0 -62
- package/skills/whats-happening/scripts/explain.mjs +0 -28
- package/skills/whats-happening/scripts/lib/cli.mjs +0 -135
- package/skills/whats-happening/scripts/lib/credential.mjs +0 -29
- package/skills/whats-happening/scripts/snapshot.mjs +0 -145
package/dist/execution.js
DELETED
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
// execution.ts — what Catalyst is doing: `explain <ticket>`, `running`, `queue`, `accounts`.
|
|
2
|
-
// `explain` turns one work-eligibility row into a paragraph a human can act on, translating every
|
|
3
|
-
// exclusion reason and unknown the cloud names; an unlisted reason prints its raw string, never nothing.
|
|
4
|
-
import { flagBool, flagString, positionals } from "./args.js";
|
|
5
|
-
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
6
|
-
import { loadContract } from "./contract.js";
|
|
7
|
-
import { CliError, UsageError } from "./errors.js";
|
|
8
|
-
import { apiClient } from "./transport.js";
|
|
9
|
-
/** Every exclusion reason the eligibility evaluator names, in plain English. */
|
|
10
|
-
export const EXCLUSION_REASONS = {
|
|
11
|
-
ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
|
|
12
|
-
pipeline_complete: "every relay phase has already completed",
|
|
13
|
-
not_at_dispatch_stage: "the card is not in the team's dispatch column, so nothing is offered until it is moved there",
|
|
14
|
-
not_at_pr_stage: "merge is next but the card is not in the PR column",
|
|
15
|
-
blocked: "a live blocking relation holds it — the blocker has to close first",
|
|
16
|
-
cooling_down: "the offered phase is parked; a callback or an operator releases it, not a clock",
|
|
17
|
-
lease_held: "a live container already holds the lease for the offered phase",
|
|
18
|
-
intake_lease_held: "a later phase is offered while an intake container still holds this ticket",
|
|
19
|
-
ask_ticket: "it carries an ask label, and a question is never work",
|
|
20
|
-
ask_shape_suspected: "its own text reads as a decision request; a human releases it with the not-an-ask label",
|
|
21
|
-
externally_claimed: "a worker outside the cloud has claimed it",
|
|
22
|
-
environment_check_required: "the repository's environment check has not been run",
|
|
23
|
-
environment_check_running: "the repository's environment check is in flight",
|
|
24
|
-
environment_check_failed: "the repository's environment check failed",
|
|
25
|
-
environment_check_expired: "the repository's environment check verdict aged out",
|
|
26
|
-
environment_check_hash_mismatch: "the environment changed since its recorded verdict",
|
|
27
|
-
scope_overlap: "its declared file scope intersects a ticket already in flight",
|
|
28
|
-
waiting_on: "a merge-gate failure with no remediable cause holds the card at PR",
|
|
29
|
-
branch_missing: "the ticket branch has never been seen, so a branch-dependent phase has nothing to clone",
|
|
30
|
-
branch_gone: "the ticket branch was deleted after it existed",
|
|
31
|
-
pr_merged: "its PR has merged and no other PR is open — nothing is left to clone or re-merge",
|
|
32
|
-
no_change_hold: "a remediate round changed nothing; a human comment or a new push releases it",
|
|
33
|
-
validate_class_spent: "this validate failure already spent its one repair round in this episode",
|
|
34
|
-
stale_failure_episode: "the ladder advanced after the recorded failure, so the round would repair a phase already passed",
|
|
35
|
-
runner_image_breaker: "fleet-wide: the live runner image is failing every phase at startup; dispatch resumes when the pin moves",
|
|
36
|
-
no_branch_to_remediate: "a remediate round is queued on a ticket with no recorded branch",
|
|
37
|
-
retry_backoff: "the failed phase is retrying in place and waiting out its backoff",
|
|
38
|
-
routing_unavailable: "the unit was claimed and refused at kickoff (no route, no eligible coding-account slot, or the provider is unavailable)",
|
|
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; `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",
|
|
48
|
-
};
|
|
49
|
-
/** Every fail-closed unknown the evaluator names. */
|
|
50
|
-
export const UNKNOWN_REASONS = {
|
|
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",
|
|
52
|
-
ordering_stale: "the dispatch order is stale",
|
|
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",
|
|
54
|
-
ticket_unknown: "the ticket is not in the mirror",
|
|
55
|
-
dependency_snapshot_unknown: "the dependency snapshot has never completed",
|
|
56
|
-
blocker_unknown: "a blocking relation could not be resolved",
|
|
57
|
-
label_snapshot_unknown: "the labels poll has never completed, so ask labels cannot be read",
|
|
58
|
-
prior_artifact_unknown: "a prior phase artifact could not be read",
|
|
59
|
-
scope_unknown: "the ticket declared no scope, or the declaration was truncated",
|
|
60
|
-
scope_occupancy_unknown: "an in-flight ticket's state could not be resolved",
|
|
61
|
-
branch_snapshot_unknown: "the pull-request poll has never completed, so branch evidence cannot be read",
|
|
62
|
-
};
|
|
63
|
-
export const ADVISORIES = {
|
|
64
|
-
human_addressed_unlabeled_ask_suspect: "assigned to a human with no delegate: this may be an unlabelled ask",
|
|
65
|
-
};
|
|
66
|
-
export function describeReason(reason) {
|
|
67
|
-
if (!reason)
|
|
68
|
-
return "no reason was given";
|
|
69
|
-
return EXCLUSION_REASONS[reason] ?? UNKNOWN_REASONS[reason] ?? `reason "${reason}" (not in this bundle's table; read it as the cloud spelled it)`;
|
|
70
|
-
}
|
|
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);
|
|
85
|
-
if (!row) {
|
|
86
|
-
// A null dispatch row is not proof the ticket does not exist — the explainer is a dispatch scan. When
|
|
87
|
-
// `knownState` is set (the mirror answered GET /issues/:id) the ticket is real; it just sits outside a
|
|
88
|
-
// dispatch column. Only a 404 (knownState null/absent) is truly unknown to the mirror.
|
|
89
|
-
if (knownState)
|
|
90
|
-
return `${ticket}: known to the mirror; state ${knownState} is not a dispatch state.`;
|
|
91
|
-
return `${ticket}: not in the ${team} eligibility explainer — the ticket is unknown to the mirror, terminal, or on another team.`;
|
|
92
|
-
}
|
|
93
|
-
const parts = [];
|
|
94
|
-
const pos = typeof row.position === "number" ? `position ${row.position}` : "no queue position";
|
|
95
|
-
const status = row.status ?? "unknown";
|
|
96
|
-
if (status === "offered" || status === "eligible") {
|
|
97
|
-
parts.push(`${ticket} is ${status} (${pos})${row.phase ? ` for phase ${row.phase}` : ""}.`);
|
|
98
|
-
}
|
|
99
|
-
else if (status === "unknown") {
|
|
100
|
-
parts.push(`${ticket} cannot be judged (${pos}): ${describeReason(row.unknown ?? row.reason)}.`);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
parts.push(`${ticket} is ${status} (${pos}): ${describeReason(row.reason)}.`);
|
|
104
|
-
}
|
|
105
|
-
if (row.detail)
|
|
106
|
-
parts.push(`Detail: ${row.detail}.`);
|
|
107
|
-
if (row.marker)
|
|
108
|
-
parts.push(`Marker: ${row.marker}.`);
|
|
109
|
-
if (row.nextPhase)
|
|
110
|
-
parts.push(`Next phase would be ${row.nextPhase}.`);
|
|
111
|
-
if (row.release)
|
|
112
|
-
parts.push(`Release: ${row.release}.`);
|
|
113
|
-
if (row.failure && typeof row.failure === "object") {
|
|
114
|
-
const f = row.failure;
|
|
115
|
-
const bits = ["phase", "class", "attempts", "consecutive", "lastFailedAt", "message", "summary"]
|
|
116
|
-
.filter((k) => f[k] !== undefined && f[k] !== null)
|
|
117
|
-
.map((k) => `${k}=${typeof f[k] === "string" ? f[k] : JSON.stringify(f[k])}`);
|
|
118
|
-
parts.push(`Last failure: ${bits.length ? bits.join(", ") : JSON.stringify(f)}.`);
|
|
119
|
-
}
|
|
120
|
-
if (row.advisories && row.advisories.length > 0) {
|
|
121
|
-
parts.push(`Advisories: ${row.advisories.map((a) => ADVISORIES[a] ?? a).join("; ")}.`);
|
|
122
|
-
}
|
|
123
|
-
return parts.join(" ");
|
|
124
|
-
}
|
|
125
|
-
export async function cmdExplain(args, ctx) {
|
|
126
|
-
const [ticket] = positionals(args);
|
|
127
|
-
if (!ticket)
|
|
128
|
-
throw new UsageError("explain needs a ticket: explain <ticket>");
|
|
129
|
-
const cfg = requireConfig(ctx);
|
|
130
|
-
// `explain --history` is the same read as `history <ticket>`; both go to CTC-1954's route.
|
|
131
|
-
if (flagBool(args, "history"))
|
|
132
|
-
return await cmdHistory(args, ctx, ticket);
|
|
133
|
-
const { doc } = await loadContract(ctx, cfg);
|
|
134
|
-
const dash = ticket.indexOf("-");
|
|
135
|
-
if (dash <= 0)
|
|
136
|
-
throw new UsageError(`"${ticket}" is not a ticket identifier (expected KEY-123)`);
|
|
137
|
-
const team = ticket.slice(0, dash).toUpperCase();
|
|
138
|
-
const api = apiClient(cfg, ctx);
|
|
139
|
-
const res = await api.getJson("/api/v1/work-eligibility", {
|
|
140
|
-
query: { team, capabilities: doc.ladder.phases.join(",") },
|
|
141
|
-
});
|
|
142
|
-
const rows = res.body.eligibility?.rows ?? res.body.rows ?? [];
|
|
143
|
-
const row = rows.find((r) => String(r.ticket ?? "").toUpperCase() === ticket.toUpperCase()) ?? null;
|
|
144
|
-
// No dispatch row is not proof of non-existence: probe the mirror so a Backlog ticket reads as known.
|
|
145
|
-
// The mirror compares identifiers exactly, so normalize (uppercase) as the row lookup above does —
|
|
146
|
-
// otherwise `explain eng-7` probes a lowercase id, 404s, and reports an existing ENG-7 as unknown.
|
|
147
|
-
let knownState = null;
|
|
148
|
-
if (!row) {
|
|
149
|
-
const probe = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket.toUpperCase())}`, { accept: [404] });
|
|
150
|
-
if (probe.status !== 404)
|
|
151
|
-
knownState = typeof probe.body?.state === "string" ? probe.body.state : "unknown";
|
|
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);
|
|
157
|
-
if (args.json) {
|
|
158
|
-
ctx.stdout(JSON.stringify({ ticket, row, ...(gate ? { dispatchGate: gate } : {}), explanation }));
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
ctx.stdout(explanation);
|
|
162
|
-
}
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* `running` — the fleet-wide "what is Catalyst doing right now?".
|
|
167
|
-
*
|
|
168
|
-
* ⛔ `/api/v1/lease/attributions` IS NOT A FLEET-WIDE ROUTE. It answers "who wrote this transition,
|
|
169
|
-
* holding which lease?" for ONE (ticket, phase) pair and 400s `invalid_field` without both — 0.2.0
|
|
170
|
-
* called it bare on every `running`, so the headline verb (and `whats-running.mjs`, and
|
|
171
|
-
* `snapshot.mjs`) failed for every tenant. The tenant-wide question is answered by
|
|
172
|
-
* `/fleet-activity/current` (what is executing) and `/agent-roster/current` (who is coordinating),
|
|
173
|
-
* neither of which takes coordinates. Lease attributions stay available, behind the coordinates the
|
|
174
|
-
* route requires.
|
|
175
|
-
*/
|
|
176
|
-
export async function cmdRunning(args, ctx) {
|
|
177
|
-
const cfg = requireConfig(ctx);
|
|
178
|
-
const ticket = flagString(args, "ticket");
|
|
179
|
-
const phase = flagString(args, "phase");
|
|
180
|
-
if ((ticket === undefined) !== (phase === undefined)) {
|
|
181
|
-
throw new UsageError("running: --ticket and --phase go together — lease attributions are recorded per (ticket, phase), and the route refuses either one alone");
|
|
182
|
-
}
|
|
183
|
-
const api = apiClient(cfg, ctx);
|
|
184
|
-
const [activity, roster] = await Promise.all([
|
|
185
|
-
api.getJson("/api/v1/fleet-activity/current"),
|
|
186
|
-
api.getJson("/api/v1/agent-roster/current"),
|
|
187
|
-
]);
|
|
188
|
-
const out = { fleetActivity: activity.body, agentRoster: roster.body };
|
|
189
|
-
if (ticket !== undefined && phase !== undefined) {
|
|
190
|
-
const leases = await api.getJson("/api/v1/lease/attributions", { query: { ticket, phase } });
|
|
191
|
-
out.leaseAttributions = leases.body;
|
|
192
|
-
}
|
|
193
|
-
if (args.json) {
|
|
194
|
-
ctx.stdout(JSON.stringify(out));
|
|
195
|
-
return 0;
|
|
196
|
-
}
|
|
197
|
-
ctx.stdout(`fleet activity: ${JSON.stringify(out.fleetActivity)}`);
|
|
198
|
-
ctx.stdout(`agent roster: ${JSON.stringify(out.agentRoster)}`);
|
|
199
|
-
if (out.leaseAttributions !== undefined) {
|
|
200
|
-
ctx.stdout(`lease attributions (${ticket} ${phase}): ${JSON.stringify(out.leaseAttributions)}`);
|
|
201
|
-
}
|
|
202
|
-
return 0;
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* `queue` — the dispatch order. The route is per-team and REQUIRES `?team=` ("bad team", 400), but
|
|
206
|
-
* a customer asking "what is next?" rarely means one team, so an omitted `--team` reads every team
|
|
207
|
-
* the tenant contract names rather than sending a call the route will refuse.
|
|
208
|
-
*/
|
|
209
|
-
export async function cmdQueue(args, ctx) {
|
|
210
|
-
const cfg = requireConfig(ctx);
|
|
211
|
-
const api = apiClient(cfg, ctx);
|
|
212
|
-
const named = flagString(args, "team");
|
|
213
|
-
let teams;
|
|
214
|
-
if (named !== undefined) {
|
|
215
|
-
teams = [named];
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
const { doc } = await loadContract(ctx, cfg);
|
|
219
|
-
teams = doc.teams.map((t) => t.key).filter((k) => typeof k === "string" && k !== "");
|
|
220
|
-
if (teams.length === 0) {
|
|
221
|
-
throw new UsageError("queue needs --team: the tenant contract names no team to read a queue for");
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
const bodies = await Promise.all(teams.map(async (team) => [team, (await api.getJson("/api/v1/dispatch-queue/current", { query: { team } })).body]));
|
|
225
|
-
// One team asked for → its envelope, unwrapped, exactly as before. Several → keyed by team, so a
|
|
226
|
-
// caller can tell whose queue a row belongs to.
|
|
227
|
-
const out = bodies.length === 1 && named !== undefined ? bodies[0][1] : Object.fromEntries(bodies);
|
|
228
|
-
ctx.stdout(args.json ? JSON.stringify(out) : JSON.stringify(out, null, 2));
|
|
229
|
-
return 0;
|
|
230
|
-
}
|
|
231
|
-
/** The five status words `/api/v1/coding-accounts` reports, in the words a customer reads. */
|
|
232
|
-
export const ACCOUNT_STATUS = {
|
|
233
|
-
"expired-or-revoked": "expired or revoked — re-enrol it before it can take work",
|
|
234
|
-
walled: "walled — the provider's usage limit is spent for now",
|
|
235
|
-
active: "active — observed working",
|
|
236
|
-
attested: "attested — healthy at last check, no work observed since",
|
|
237
|
-
unobserved: "unobserved — enrolled, but nothing has been seen from it yet",
|
|
238
|
-
};
|
|
239
|
-
/** One slot as a line a human reads: who it is, what state it is in, and what it is spending on. */
|
|
240
|
-
export function renderAccount(a) {
|
|
241
|
-
const name = a.label ? `${a.accountSlot ?? "?"} (${a.label})` : (a.accountSlot ?? "?");
|
|
242
|
-
const harness = a.harness ? `/${a.harness}` : "";
|
|
243
|
-
const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
|
|
244
|
-
const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
|
|
245
|
-
if (typeof a.bindingUsedPercent === "number") {
|
|
246
|
-
const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
|
|
247
|
-
bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
|
|
248
|
-
}
|
|
249
|
-
if (a.quarantined)
|
|
250
|
-
bits.push(`quarantined${a.quarantineReason ? `: ${a.quarantineReason}` : ""}`);
|
|
251
|
-
const holds = a.liveHolds ?? [];
|
|
252
|
-
if (holds.length > 0)
|
|
253
|
-
bits.push(`holding ${holds.map((h) => `${h.ticket}/${h.phase}`).join(", ")}`);
|
|
254
|
-
else if (a.liveHoldsCount)
|
|
255
|
-
bits.push(`${a.liveHoldsCount} live hold(s)`);
|
|
256
|
-
return bits.join(" · ");
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* ⛔ A ROUTE THIS TENANT'S CLOUD DOES NOT SERVE IS SAID OUT LOUD, never rendered as an empty success.
|
|
260
|
-
* `GET /api/v1/coding-accounts` (CTC-1953) and `GET /api/v1/issues/:id/execution` (CTC-1954) ship
|
|
261
|
-
* ahead of some tenants' deployed mirror; a 404 there means "your cloud is older than this bundle",
|
|
262
|
-
* which is a different fact from "you have no coding accounts" and must never print as the latter.
|
|
263
|
-
*/
|
|
264
|
-
export function needsNewerCloud(what, cfg) {
|
|
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);
|
|
266
|
-
}
|
|
267
|
-
export async function cmdAccounts(args, ctx) {
|
|
268
|
-
const cfg = requireConfig(ctx);
|
|
269
|
-
const api = apiClient(cfg, ctx);
|
|
270
|
-
const res = await api.getJson("/api/v1/coding-accounts", {
|
|
271
|
-
accept: [404],
|
|
272
|
-
});
|
|
273
|
-
if (res.status === 404)
|
|
274
|
-
throw needsNewerCloud("coding-account status", cfg);
|
|
275
|
-
const accounts = Array.isArray(res.body) ? res.body : (res.body?.accounts ?? []);
|
|
276
|
-
if (args.json) {
|
|
277
|
-
ctx.stdout(JSON.stringify(res.body));
|
|
278
|
-
return 0;
|
|
279
|
-
}
|
|
280
|
-
if (accounts.length === 0) {
|
|
281
|
-
ctx.stdout(`No coding accounts are enrolled on this tenant — enrol one at ${normalizeBaseUrl(cfg.baseUrl)}/settings/coding-accounts`);
|
|
282
|
-
return 0;
|
|
283
|
-
}
|
|
284
|
-
for (const a of accounts)
|
|
285
|
-
ctx.stdout(renderAccount(a));
|
|
286
|
-
return 0;
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* `history <ticket>` (and `explain --history`) — the ticket's execution history: per-phase attempts
|
|
290
|
-
* and outcomes, remediate rounds against the cap, the park sentinel and what releases it, the live
|
|
291
|
-
* lease, the last advance. Reads `GET /api/v1/issues/:identifier/execution` (CTC-1954).
|
|
292
|
-
*/
|
|
293
|
-
export async function cmdHistory(args, ctx, ticketArg) {
|
|
294
|
-
const ticket = ticketArg ?? positionals(args)[0];
|
|
295
|
-
if (!ticket)
|
|
296
|
-
throw new UsageError("history needs a ticket: history <ticket>");
|
|
297
|
-
const cfg = requireConfig(ctx);
|
|
298
|
-
const api = apiClient(cfg, ctx);
|
|
299
|
-
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}/execution`, {
|
|
300
|
-
accept: [404],
|
|
301
|
-
});
|
|
302
|
-
if (res.status === 404)
|
|
303
|
-
throw needsNewerCloud(`execution history for ${ticket}`, cfg);
|
|
304
|
-
if (args.json) {
|
|
305
|
-
ctx.stdout(JSON.stringify(res.body));
|
|
306
|
-
return 0;
|
|
307
|
-
}
|
|
308
|
-
for (const line of renderHistory(ticket, res.body))
|
|
309
|
-
ctx.stdout(line);
|
|
310
|
-
return 0;
|
|
311
|
-
}
|
|
312
|
-
/** ⛔ `null` is UNREADABLE, never "nothing happened" — the report's own contract, kept in the prose. */
|
|
313
|
-
export function renderHistory(ticket, doc) {
|
|
314
|
-
const lines = [];
|
|
315
|
-
if (doc.hasLadderHistory === false) {
|
|
316
|
-
lines.push(`${ticket}: no ladder history recorded${doc.note ? ` — ${doc.note}` : ""}.`);
|
|
317
|
-
}
|
|
318
|
-
const phases = doc.phases;
|
|
319
|
-
if (phases === null || phases === undefined) {
|
|
320
|
-
lines.push(`${ticket}: the per-phase table could not be read (unreadable, not empty).`);
|
|
321
|
-
}
|
|
322
|
-
else {
|
|
323
|
-
lines.push(`${ticket} phases (${doc.attemptHistory ?? "latest-per-phase"}):`);
|
|
324
|
-
for (const p of phases) {
|
|
325
|
-
const fail = p.lastFailureClass ? `, last failure ${p.lastFailureClass}${p.consecutiveFailures ? ` ×${p.consecutiveFailures}` : ""}` : "";
|
|
326
|
-
lines.push(` ${p.phase}: ${p.status ?? "?"} (attempt ${p.attempt ?? "?"})${fail}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
if (doc.failure) {
|
|
330
|
-
const f = doc.failure;
|
|
331
|
-
lines.push(`Last failure: ${f.phase} — ${f.failureMode}${f.failureDetail ? ` (${f.failureDetail})` : ""}${f.summary ? `: ${f.summary}` : ""}`);
|
|
332
|
-
}
|
|
333
|
-
if (doc.remediate && typeof doc.remediate.roundsDispatched === "number") {
|
|
334
|
-
lines.push(`Remediate rounds dispatched: ${doc.remediate.roundsDispatched}${typeof doc.remediate.cap === "number" ? ` (cap ${doc.remediate.cap})` : ""}`);
|
|
335
|
-
}
|
|
336
|
-
if (doc.park) {
|
|
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
|
-
}
|
|
360
|
-
}
|
|
361
|
-
for (const l of doc.lease ?? [])
|
|
362
|
-
lines.push(`Live lease: ${l.phase} held by ${l.holder ?? "?"} until ${new Date(l.deadlineMs).toISOString()}`);
|
|
363
|
-
if (doc.lastAdvance) {
|
|
364
|
-
lines.push(`Last advance: ${doc.lastAdvance.phase} → ${doc.lastAdvance.toSlot ?? "?"} (${doc.lastAdvance.landed ? "landed" : "not landed"})`);
|
|
365
|
-
}
|
|
366
|
-
for (const u of doc.unreadable ?? [])
|
|
367
|
-
lines.push(`Unreadable: ${u.table} (${u.error}) — absent from this report, not absent from the ticket.`);
|
|
368
|
-
return lines;
|
|
369
|
-
}
|
package/dist/oauth.js
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
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
|
-
}
|