@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.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bin/catalyst-skills.js +8 -0
- package/bin/catalyst.js +5 -0
- package/bin/launch.js +154 -0
- package/dist/args.js +280 -0
- package/dist/ask.js +161 -0
- package/dist/browser.js +20 -0
- package/dist/cli.js +397 -0
- package/dist/config.js +241 -0
- package/dist/contract-types.js +4 -0
- package/dist/contract.js +184 -0
- package/dist/detach.js +10 -0
- package/dist/environment.js +207 -0
- package/dist/errors.js +27 -0
- package/dist/events.js +106 -0
- package/dist/execution.js +451 -0
- package/dist/oauth.js +300 -0
- package/dist/pagination.js +76 -0
- package/dist/prompt.js +35 -0
- package/dist/published.js +79 -0
- package/dist/query.js +248 -0
- package/dist/ready.js +380 -0
- package/dist/release.js +142 -0
- package/dist/replica.js +614 -0
- package/dist/runtime-store.js +135 -0
- package/dist/runtime-verb.js +66 -0
- package/dist/runtime.js +87 -0
- package/dist/sdk.js +29 -0
- package/dist/secret.js +190 -0
- package/dist/semver.js +18 -0
- package/dist/skill-shape.js +189 -0
- package/dist/skills.js +129 -0
- package/dist/transport.js +205 -0
- package/dist/ts-deps-loader.js +113 -0
- package/dist/watch/consumer.js +141 -0
- package/dist/watch/cursor-file.js +62 -0
- package/dist/watch.js +175 -0
- package/dist/write.js +224 -0
- package/package.json +60 -0
- package/skills/catalyst-github/SKILL.md +35 -0
- package/skills/catalyst-github/agents/openai.yaml +6 -0
- package/skills/catalyst-github/agents/portability.yaml +4 -0
- package/skills/catalyst-github/references/is-it-mergeable.md +57 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +61 -0
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +124 -0
- package/skills/catalyst-github/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-github/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-github/scripts/lib/pull.mjs +82 -0
- package/skills/catalyst-github/scripts/read-pr.mjs +97 -0
- package/skills/catalyst-linear/SKILL.md +43 -0
- package/skills/catalyst-linear/agents/openai.yaml +6 -0
- package/skills/catalyst-linear/agents/portability.yaml +5 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +52 -0
- package/skills/catalyst-linear/references/what-a-ticket-accumulates.md +53 -0
- package/skills/catalyst-linear/references/writing-to-linear.md +43 -0
- package/skills/catalyst-linear/scripts/comment.mjs +59 -0
- package/skills/catalyst-linear/scripts/create-ticket.mjs +44 -0
- package/skills/catalyst-linear/scripts/label.mjs +48 -0
- package/skills/catalyst-linear/scripts/lib/cli.mjs +164 -0
- package/skills/catalyst-linear/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-linear/scripts/move.mjs +41 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +93 -0
- package/skills/catalyst-linear/scripts/search.mjs +49 -0
- package/skills/catalyst-onboard/SKILL.md +57 -0
- package/skills/catalyst-onboard/agents/openai.yaml +6 -0
- package/skills/catalyst-onboard/agents/portability.yaml +5 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +23 -0
- package/skills/catalyst-onboard/references/skill-sources.md +35 -0
- package/skills/catalyst-onboard/references/the-one-path.md +149 -0
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +46 -0
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +50 -0
- package/skills/catalyst-onboard/references/who-fixes-what.md +44 -0
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +117 -0
- package/skills/catalyst-onboard/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +345 -0
- package/skills/catalyst-setup/SKILL.md +36 -0
- package/skills/catalyst-setup/agents/openai.yaml +6 -0
- package/skills/catalyst-setup/agents/portability.yaml +4 -0
- package/skills/catalyst-setup/references/what-each-check-means.md +88 -0
- package/skills/catalyst-setup/scripts/check.mjs +75 -0
- package/skills/catalyst-setup/scripts/lib/cli.mjs +103 -0
- package/skills/catalyst-setup/scripts/lib/credential.mjs +29 -0
- package/skills/catalyst-setup/scripts/replica-status.mjs +46 -0
- package/skills/connect-me/SKILL.md +63 -0
- package/skills/connect-me/agents/openai.yaml +6 -0
- package/skills/connect-me/agents/portability.yaml +5 -0
- package/skills/connect-me/references/keeping-the-replica-running.md +88 -0
- package/skills/connect-me/scripts/lib/cli.mjs +185 -0
- package/skills/connect-me/scripts/lib/credential.mjs +29 -0
- package/skills/connect-me/scripts/verify-connection.mjs +68 -0
- package/skills/how-catalyst-works/SKILL.md +43 -0
- package/skills/how-catalyst-works/agents/openai.yaml +6 -0
- package/skills/how-catalyst-works/agents/portability.yaml +4 -0
- package/skills/how-catalyst-works/references/coding-accounts.md +51 -0
- package/skills/how-catalyst-works/references/stages-and-mapping.md +56 -0
- package/skills/how-catalyst-works/references/the-ladder.md +41 -0
- package/skills/how-catalyst-works/references/what-catalyst-is.md +30 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +77 -0
- package/skills/how-catalyst-works/references/when-a-phase-fails.md +57 -0
- package/skills/how-catalyst-works/scripts/explain-ticket.mjs +41 -0
- package/skills/how-catalyst-works/scripts/lib/cli.mjs +164 -0
- package/skills/how-catalyst-works/scripts/lib/credential.mjs +29 -0
- package/skills/how-catalyst-works/scripts/show-my-map.mjs +94 -0
- package/skills/how-catalyst-works/scripts/whats-running.mjs +65 -0
- package/skills/run-this-project/SKILL.md +45 -0
- package/skills/run-this-project/agents/openai.yaml +6 -0
- package/skills/run-this-project/agents/portability.yaml +5 -0
- package/skills/run-this-project/assets/stall-policy.json +15 -0
- package/skills/run-this-project/references/making-work-ready.md +60 -0
- package/skills/run-this-project/references/reacting-to-events.md +76 -0
- package/skills/run-this-project/references/stalls-and-escalation.md +63 -0
- package/skills/run-this-project/scripts/lib/cli.mjs +185 -0
- package/skills/run-this-project/scripts/lib/credential.mjs +29 -0
- package/skills/run-this-project/scripts/make-ready.mjs +64 -0
- package/skills/run-this-project/scripts/scope-status.mjs +0 -0
- package/skills/run-this-project/scripts/watch-scope.mjs +61 -0
- package/skills/unstick/SKILL.md +41 -0
- package/skills/unstick/agents/openai.yaml +6 -0
- package/skills/unstick/agents/portability.yaml +5 -0
- package/skills/unstick/references/playbook.md +51 -0
- package/skills/unstick/scripts/lib/cli.mjs +135 -0
- package/skills/unstick/scripts/lib/credential.mjs +29 -0
- package/skills/unstick/scripts/unstick.mjs +57 -0
- package/skills/what-needs-me/SKILL.md +41 -0
- package/skills/what-needs-me/agents/openai.yaml +6 -0
- package/skills/what-needs-me/agents/portability.yaml +5 -0
- package/skills/what-needs-me/references/raising-a-decision.md +41 -0
- package/skills/what-needs-me/references/reading-the-inbox.md +38 -0
- package/skills/what-needs-me/references/settling-an-answer.md +37 -0
- package/skills/what-needs-me/scripts/inbox.mjs +56 -0
- package/skills/what-needs-me/scripts/lib/cli.mjs +135 -0
- package/skills/what-needs-me/scripts/lib/credential.mjs +29 -0
- package/skills/what-needs-me/scripts/raise.mjs +53 -0
- package/skills/what-needs-me/scripts/settle.mjs +73 -0
- package/skills/whats-happening/SKILL.md +43 -0
- package/skills/whats-happening/agents/openai.yaml +6 -0
- package/skills/whats-happening/agents/portability.yaml +4 -0
- package/skills/whats-happening/assets/status-reply.json +77 -0
- package/skills/whats-happening/references/reading-the-board.md +43 -0
- package/skills/whats-happening/references/reprioritising.md +37 -0
- package/skills/whats-happening/references/routing-work.md +36 -0
- package/skills/whats-happening/references/status-reply.md +34 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +62 -0
- package/skills/whats-happening/scripts/explain.mjs +28 -0
- package/skills/whats-happening/scripts/lib/cli.mjs +135 -0
- package/skills/whats-happening/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/scripts/snapshot.mjs +149 -0
- package/vendor/README.md +9 -0
- package/vendor/paths/index.d.ts +85 -0
- package/vendor/paths/index.js +148 -0
- package/vendor/paths/legacy-installer.d.ts +36 -0
- package/vendor/paths/legacy-installer.js +154 -0
- package/vendor/paths/node.d.ts +18 -0
- package/vendor/paths/node.js +102 -0
- package/vendor/paths/provenance.json +17 -0
package/dist/contract.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// contract.ts — GET /api/v1/agent/contract, cached with its ETag at ~/.config/catalyst-cloud/contract.json.
|
|
2
|
+
// Every tenant fact a verb needs (stage ids, label ids, route paths, the bookkeeping marker, thresholds)
|
|
3
|
+
// is read from here; no verb carries a literal fallback for any of them.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { contractPathFor, readManifest, upgradeCommand } from "./config.js";
|
|
7
|
+
import { CliError, MeError } from "./errors.js";
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
|
+
export const CONTRACT_ROUTE = "/api/v1/agent/contract";
|
|
10
|
+
export function readContractCache(home) {
|
|
11
|
+
const path = contractPathFor(home);
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
16
|
+
if (typeof parsed.fetchedAt !== "string" || typeof parsed.contractVersion !== "string" || !parsed.doc)
|
|
17
|
+
return null;
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function writeContractCache(home, cache) {
|
|
25
|
+
const path = contractPathFor(home);
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
const tmp = `${path}.tmp`;
|
|
28
|
+
writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 });
|
|
29
|
+
renameSync(tmp, path);
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
/** `1.x` → major 1; `1.2.3` → exact; `unpinned` → anything. Returns null when the range is unparseable. */
|
|
33
|
+
export function contractVersionInRange(version, range) {
|
|
34
|
+
if (range === "unpinned")
|
|
35
|
+
return true;
|
|
36
|
+
const major = /^(\d+)\.x$/.exec(range);
|
|
37
|
+
if (major)
|
|
38
|
+
return version.split(".")[0] === major[1];
|
|
39
|
+
if (/^\d+\.\d+\.\d+$/.test(range))
|
|
40
|
+
return version === range;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function assertContractRange(version, range) {
|
|
44
|
+
const ok = contractVersionInRange(version, range);
|
|
45
|
+
if (ok === null)
|
|
46
|
+
throw new CliError(`tenantContractRange "${range}" in package.json is not a range this CLI understands`, "contract-range");
|
|
47
|
+
if (!ok) {
|
|
48
|
+
throw new CliError(`the tenant serves contract version ${version} but this bundle accepts ${range} — update the bundle (${upgradeCommand()}) or ask your tenant admin which version is live`, "contract-version");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the contract per the cloud's own cache policy: a cache younger than `doc.cache.maxAgeSeconds`
|
|
53
|
+
* is used as-is; otherwise a conditional GET revalidates it (304 refreshes `fetchedAt`, 200 replaces
|
|
54
|
+
* the document). A network failure on a cache older than `staleRefusalSeconds` refuses; a 403 (a
|
|
55
|
+
* personal key on an older cloud) refuses naming the cloud version; a major version outside the bundle's range refuses
|
|
56
|
+
* naming both versions.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadContract(ctx, cfg, opts = {}) {
|
|
59
|
+
const path = contractPathFor(ctx.home);
|
|
60
|
+
const cached = readContractCache(ctx.home);
|
|
61
|
+
const nowMs = ctx.now().getTime();
|
|
62
|
+
const ageSeconds = cached ? Math.max(0, Math.floor((nowMs - Date.parse(cached.fetchedAt)) / 1000)) : Infinity;
|
|
63
|
+
const range = readManifest().tenantContractRange;
|
|
64
|
+
if (cached && !opts.refresh && ageSeconds < cached.doc.cache.maxAgeSeconds) {
|
|
65
|
+
assertContractRange(cached.contractVersion, range);
|
|
66
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
67
|
+
}
|
|
68
|
+
if (opts.offline) {
|
|
69
|
+
if (cached) {
|
|
70
|
+
assertContractRange(cached.contractVersion, range);
|
|
71
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
72
|
+
}
|
|
73
|
+
throw new CliError("no cached contract and network reads are disabled", "contract-missing");
|
|
74
|
+
}
|
|
75
|
+
const api = apiClient(cfg, ctx);
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await api.getJson(CONTRACT_ROUTE, { etag: cached?.etag ?? null, accept: [304] });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof CliError && err.status === 403) {
|
|
82
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} refused (403): this cloud is older than the bundle and does not yet admit a personal key on the contract — update the cloud, or log in with the tenant's account key until it is`, "contract-forbidden", 2, 403);
|
|
83
|
+
}
|
|
84
|
+
if (err instanceof MeError && err.kind === "network" && cached) {
|
|
85
|
+
if (ageSeconds >= cached.doc.cache.staleRefusalSeconds) {
|
|
86
|
+
throw new CliError(`the cached contract is ${ageSeconds}s old (refusal after ${cached.doc.cache.staleRefusalSeconds}s) and the refresh failed: ${err.message}`, "contract-stale");
|
|
87
|
+
}
|
|
88
|
+
assertContractRange(cached.contractVersion, range);
|
|
89
|
+
return { doc: cached.doc, source: "cache", ageSeconds, path };
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 304) {
|
|
94
|
+
if (!cached)
|
|
95
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} answered 304 with no cache to revalidate`, "contract-shape");
|
|
96
|
+
assertContractRange(cached.contractVersion, range);
|
|
97
|
+
writeContractCache(ctx.home, { ...cached, fetchedAt: ctx.now().toISOString() });
|
|
98
|
+
return { doc: cached.doc, source: "revalidated", ageSeconds: 0, path };
|
|
99
|
+
}
|
|
100
|
+
const doc = res.body;
|
|
101
|
+
if (!doc || typeof doc !== "object" || typeof doc.contractVersion !== "string" || !Array.isArray(doc.teams) || !Array.isArray(doc.routes)) {
|
|
102
|
+
throw new CliError(`GET ${CONTRACT_ROUTE} returned an unexpected shape`, "contract-shape");
|
|
103
|
+
}
|
|
104
|
+
const version = res.headers.get("x-catalyst-contract-version") ?? doc.contractVersion;
|
|
105
|
+
assertContractRange(version, range);
|
|
106
|
+
writeContractCache(ctx.home, {
|
|
107
|
+
etag: res.headers.get("etag"),
|
|
108
|
+
fetchedAt: ctx.now().toISOString(),
|
|
109
|
+
contractVersion: version,
|
|
110
|
+
doc,
|
|
111
|
+
});
|
|
112
|
+
return { doc, source: "network", ageSeconds: 0, path };
|
|
113
|
+
}
|
|
114
|
+
// ── helpers every verb uses ──────────────────────────────────────────────────────────────────────
|
|
115
|
+
export function teamByKey(doc, key) {
|
|
116
|
+
const wanted = key.toUpperCase();
|
|
117
|
+
const team = doc.teams.find((t) => (t.key ?? "").toUpperCase() === wanted);
|
|
118
|
+
if (!team) {
|
|
119
|
+
const known = doc.teams.map((t) => t.key ?? t.id).join(", ") || "none";
|
|
120
|
+
throw new CliError(`team "${key}" is not on this tenant's contract (teams: ${known})`, "team-unknown");
|
|
121
|
+
}
|
|
122
|
+
return team;
|
|
123
|
+
}
|
|
124
|
+
/** The contract's team for a key, or null. `teamByKey` refuses; this one answers "is it there?" for
|
|
125
|
+
* readers (explain's cached dispatch gate) that must stay silent for a team the contract omits. */
|
|
126
|
+
export function findTeamByKey(doc, key) {
|
|
127
|
+
const wanted = key.toUpperCase();
|
|
128
|
+
return doc.teams.find((t) => (t.key ?? "").toUpperCase() === wanted) ?? null;
|
|
129
|
+
}
|
|
130
|
+
export function teamById(doc, id) {
|
|
131
|
+
return doc.teams.find((t) => t.id === id) ?? null;
|
|
132
|
+
}
|
|
133
|
+
/** The team a ticket identifier belongs to, by its `KEY-` prefix. */
|
|
134
|
+
export function teamForTicket(doc, ticket) {
|
|
135
|
+
const dash = ticket.indexOf("-");
|
|
136
|
+
if (dash <= 0)
|
|
137
|
+
throw new CliError(`"${ticket}" is not a ticket identifier (expected KEY-123)`, "ticket-shape");
|
|
138
|
+
return teamByKey(doc, ticket.slice(0, dash));
|
|
139
|
+
}
|
|
140
|
+
export function stageIdForSlot(team, slot) {
|
|
141
|
+
const stage = team.stages[slot];
|
|
142
|
+
if (!stage) {
|
|
143
|
+
const mapped = Object.keys(team.stages).join(", ") || "none";
|
|
144
|
+
throw new CliError(`slot "${slot}" is not mapped for team ${team.key ?? team.id} (mapped: ${mapped})`, "slot-unmapped");
|
|
145
|
+
}
|
|
146
|
+
if (!stage.stateStillExists) {
|
|
147
|
+
throw new CliError(`slot "${slot}" on team ${team.key ?? team.id} maps to state ${stage.stateId}, which no longer exists in Linear — fix the mapping in settings first`, "slot-stale");
|
|
148
|
+
}
|
|
149
|
+
return stage.stateId;
|
|
150
|
+
}
|
|
151
|
+
/** Resolve a label by name through the contract's ask/hold/release lists; an unknown name is returned
|
|
152
|
+
* as-is when it looks like an id (the contract only lists Catalyst's own labels). */
|
|
153
|
+
export function labelId(team, nameOrId) {
|
|
154
|
+
const all = [...team.labels.ask, ...team.labels.hold, ...team.labels.release];
|
|
155
|
+
const hit = all.find((l) => l.name === nameOrId);
|
|
156
|
+
if (hit) {
|
|
157
|
+
if (!hit.preferredId) {
|
|
158
|
+
throw new CliError(`label "${nameOrId}" is absent from this workspace (contract scope: ${hit.scope}) — create it in Linear first`, "label-absent");
|
|
159
|
+
}
|
|
160
|
+
return hit.preferredId;
|
|
161
|
+
}
|
|
162
|
+
return nameOrId;
|
|
163
|
+
}
|
|
164
|
+
/** The route whose last path segment is `name` (e.g. `issue-comment`). The path itself is never assumed. */
|
|
165
|
+
export function routePath(doc, name) {
|
|
166
|
+
const route = doc.routes.find((r) => r.path.split("/").filter(Boolean).at(-1) === name);
|
|
167
|
+
if (!route) {
|
|
168
|
+
throw new CliError(`the tenant contract serves no "${name}" route (routes: ${doc.routes.map((r) => r.path).join(", ")})`, "route-unknown");
|
|
169
|
+
}
|
|
170
|
+
return route.path;
|
|
171
|
+
}
|
|
172
|
+
export function bookkeepingPrefix(doc) {
|
|
173
|
+
return doc.vocabulary.bookkeeping.marker;
|
|
174
|
+
}
|
|
175
|
+
/** Walk a dotted path (`teams.0.stages`) into the document. */
|
|
176
|
+
export function pickPath(doc, path) {
|
|
177
|
+
let cur = doc;
|
|
178
|
+
for (const seg of path.split(".").filter(Boolean)) {
|
|
179
|
+
if (cur === null || typeof cur !== "object")
|
|
180
|
+
return undefined;
|
|
181
|
+
cur = cur[seg];
|
|
182
|
+
}
|
|
183
|
+
return cur;
|
|
184
|
+
}
|
package/dist/detach.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// detach.ts — re-spawn this CLI detached (replica start --detach). Isolated so a test can stub it.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
/** Spawn `process.execPath <argv>` detached from this process, stdio ignored, and let it go. */
|
|
4
|
+
export function detachSelf(argv, env = process.env) {
|
|
5
|
+
const child = spawn(process.execPath, argv, { detached: true, stdio: "ignore", env });
|
|
6
|
+
child.unref();
|
|
7
|
+
if (typeof child.pid !== "number")
|
|
8
|
+
throw new Error("detached spawn returned no pid");
|
|
9
|
+
return { pid: child.pid };
|
|
10
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// environment.ts — `catalyst-skills environment`: the tenant-scope environment declaration, through
|
|
2
|
+
// the contract's account-environment routes. This is the ONE setup write a person's own key can
|
|
3
|
+
// perform: the cloud has admitted it since the routes shipped, and until this verb existed nothing
|
|
4
|
+
// could call it, so every setup action was a settings page.
|
|
5
|
+
//
|
|
6
|
+
// ⛔ THE ROUTE PATHS ARE DISCOVERED, NEVER ASSUMED. `read` is the route the contract's table names;
|
|
7
|
+
// `propose` and `approve` are derived from it the way the Worker derives them (one prefix, three
|
|
8
|
+
// verbs) and then CHECKED against the same table. A tenant whose cloud predates the routes gets the
|
|
9
|
+
// "needs a newer cloud" refusal by name rather than a 404 from a hand-copied path.
|
|
10
|
+
//
|
|
11
|
+
// ⭐ APPROVE IS A COMPARE-AND-SET on `(revision, canonicalHash)`, and with no flags this verb reads
|
|
12
|
+
// the current state and approves exactly what it just read. Hand-copying a hash between two commands
|
|
13
|
+
// is the failure that shape exists to prevent; making the caller do it anyway would reintroduce it.
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
16
|
+
import { requireConfig } from "./config.js";
|
|
17
|
+
import { loadContract } from "./contract.js";
|
|
18
|
+
import { UsageError } from "./errors.js";
|
|
19
|
+
import { needsNewerCloud } from "./execution.js";
|
|
20
|
+
import { apiClient } from "./transport.js";
|
|
21
|
+
/**
|
|
22
|
+
* The three account-environment paths this tenant serves.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ `propose` and `approve` are NOT looked up by their own last path segment. `routePath`-style
|
|
25
|
+
* last-segment matching takes the FIRST route ending in that word, so the day a repo-scope
|
|
26
|
+
* `.../repo-environment/propose` joins the table, a lookup for "propose" could silently return it
|
|
27
|
+
* and this verb would write a repo's declaration into the account's name. Deriving both from the
|
|
28
|
+
* account prefix — and then asserting each derived path is really in the table — cannot do that.
|
|
29
|
+
*/
|
|
30
|
+
export function environmentRoutes(doc, cfg) {
|
|
31
|
+
const base = doc.routes.find((r) => r.method === "GET" && r.path.split("/").filter(Boolean).at(-1) === "account-environment");
|
|
32
|
+
if (!base) {
|
|
33
|
+
throw needsNewerCloud('the tenant environment declaration (the contract serves no "account-environment" route)', cfg);
|
|
34
|
+
}
|
|
35
|
+
const routes = {
|
|
36
|
+
read: base.path,
|
|
37
|
+
propose: `${base.path}/propose`,
|
|
38
|
+
approve: `${base.path}/approve`,
|
|
39
|
+
};
|
|
40
|
+
for (const verb of ["propose", "approve"]) {
|
|
41
|
+
const served = doc.routes.some((r) => r.method === "POST" && r.path === routes[verb]);
|
|
42
|
+
if (!served) {
|
|
43
|
+
throw needsNewerCloud(`the tenant environment declaration's ${verb} route (${routes[verb]} is absent from the contract)`, cfg);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return routes;
|
|
47
|
+
}
|
|
48
|
+
/** The declaration to send: a JSON file, or stdin. Never invented, and never partially parsed. */
|
|
49
|
+
async function readDeclaration(args, readStdin) {
|
|
50
|
+
const file = flagString(args, "file");
|
|
51
|
+
const useStdin = flagBool(args, "stdin");
|
|
52
|
+
if (file !== undefined && useStdin)
|
|
53
|
+
throw new UsageError("environment propose takes --file <path> or --stdin, not both");
|
|
54
|
+
if (file === undefined && !useStdin) {
|
|
55
|
+
throw new UsageError("environment propose needs --file <path> or --stdin (the declaration is JSON; this verb never invents one)");
|
|
56
|
+
}
|
|
57
|
+
const text = file === undefined ? await readStdin() : readFileSync(file, "utf8");
|
|
58
|
+
if (text.trim() === "")
|
|
59
|
+
throw new UsageError(`the declaration ${file === undefined ? "on stdin" : `in ${file}`} is empty`);
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
throw new UsageError(`the declaration ${file === undefined ? "on stdin" : `in ${file}`} is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function renderRead(r) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
if (r.current === null || r.current === undefined) {
|
|
70
|
+
lines.push("no tenant environment declaration yet — nothing is delivered to a phase's checkout");
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
lines.push(`revision ${r.current.revision} (${r.current.canonicalHash})${r.current.proposedBy ? `, proposed by ${r.current.proposedBy}` : ""}`);
|
|
74
|
+
lines.push(r.isApproved ? `approved${r.approvedBy ? ` by ${r.approvedBy}` : ""}` : "NOT approved — it is not delivered until someone approves it");
|
|
75
|
+
lines.push(r.delivered
|
|
76
|
+
? `delivered to phases: revision ${r.delivered.revision} (${r.delivered.canonicalHash})`
|
|
77
|
+
: "delivered to phases: nothing yet");
|
|
78
|
+
// A name the declaration references that no tenant secret or variable carries RIGHT NOW. A warning,
|
|
79
|
+
// never a refusal: the secret may be created next, and a repo may supply the name at checkout.
|
|
80
|
+
const unresolved = r.unresolvedReferences ?? [];
|
|
81
|
+
if (unresolved.length > 0) {
|
|
82
|
+
lines.push(`referenced but not set on this tenant yet: ${unresolved.join(", ")} — add them in the app, or a phase that needs one will fail on it`);
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
function renderState(state, verb) {
|
|
87
|
+
if (!state)
|
|
88
|
+
return [`${verb}, but the cloud returned no state`];
|
|
89
|
+
return [`${verb}: revision ${state.revision} (${state.canonicalHash})`];
|
|
90
|
+
}
|
|
91
|
+
async function readState(api, routes) {
|
|
92
|
+
const res = await api.getJson(routes.read);
|
|
93
|
+
return res.body;
|
|
94
|
+
}
|
|
95
|
+
async function approveExact(api, routes, revision, canonicalHash) {
|
|
96
|
+
const res = await api.postJson(routes.approve, { revision, canonicalHash }, { accept: [404, 409] });
|
|
97
|
+
return { body: res.body, status: res.status };
|
|
98
|
+
}
|
|
99
|
+
export async function cmdEnvironment(args, ctx, deps = {}) {
|
|
100
|
+
const [sub = "read", ...extra] = positionals(args);
|
|
101
|
+
if (extra.length > 0)
|
|
102
|
+
throw new UsageError(`environment takes one subcommand (got an extra "${extra[0]}")`);
|
|
103
|
+
if (sub !== "read" && sub !== "propose" && sub !== "approve") {
|
|
104
|
+
throw new UsageError(`unknown environment subcommand "${sub}": read | propose | approve`);
|
|
105
|
+
}
|
|
106
|
+
const cfg = requireConfig(ctx);
|
|
107
|
+
const { doc } = await loadContract(ctx, cfg);
|
|
108
|
+
const routes = environmentRoutes(doc, cfg);
|
|
109
|
+
const api = apiClient(cfg, ctx);
|
|
110
|
+
if (sub === "read") {
|
|
111
|
+
const body = await readState(api, routes);
|
|
112
|
+
if (args.json)
|
|
113
|
+
ctx.stdout(JSON.stringify(body));
|
|
114
|
+
else
|
|
115
|
+
for (const line of renderRead(body))
|
|
116
|
+
ctx.stdout(line);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (sub === "propose") {
|
|
120
|
+
const declaration = await readDeclaration(args, deps.readStdin ?? readStdin);
|
|
121
|
+
const expectedRevision = args.flags["expect-revision"] === undefined ? undefined : flagInt(args, "expect-revision", 0);
|
|
122
|
+
const res = await api.postJson(routes.propose, { declaration, ...(expectedRevision !== undefined ? { expectedRevision } : {}) }, { accept: [400, 404, 409] });
|
|
123
|
+
const body = res.body;
|
|
124
|
+
// A refusal is the cloud's own sentence, printed verbatim and exited 1 — never retried, and never
|
|
125
|
+
// smoothed into a success. `conflict` means someone else moved the declaration; re-read and redo.
|
|
126
|
+
if (res.status === 409 || res.status === 400 || res.status === 404) {
|
|
127
|
+
if (args.json)
|
|
128
|
+
ctx.stdout(JSON.stringify(body));
|
|
129
|
+
else {
|
|
130
|
+
ctx.stdout(`refused (${res.status}): ${body.message ?? body.error ?? "no reason given"}`);
|
|
131
|
+
if (body.reason)
|
|
132
|
+
ctx.stdout(` reason: ${body.reason}`);
|
|
133
|
+
if (body.currentRevision !== undefined) {
|
|
134
|
+
ctx.stdout(` the declaration is now at revision ${body.currentRevision} — run \`catalyst-skills environment read\` and propose again from that`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
const approveToo = flagBool(args, "approve");
|
|
140
|
+
if (!args.json)
|
|
141
|
+
for (const line of renderState(body.state, body.status === "unchanged" ? "unchanged" : `${body.status ?? "proposed"}`))
|
|
142
|
+
ctx.stdout(line);
|
|
143
|
+
if (!approveToo) {
|
|
144
|
+
if (args.json)
|
|
145
|
+
ctx.stdout(JSON.stringify(body));
|
|
146
|
+
else if (body.status !== "unchanged")
|
|
147
|
+
ctx.stdout("not delivered until it is approved: catalyst-skills environment approve");
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
// ⭐ Approve exactly what propose just returned. The whole point of the CAS is that the approver
|
|
151
|
+
// saw the revision they are approving, and nothing here has to be copied by a human or a model.
|
|
152
|
+
const state = body.state;
|
|
153
|
+
if (!state) {
|
|
154
|
+
ctx.stderr("the cloud returned no state to approve — run: catalyst-skills environment read");
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
const approved = await approveExact(api, routes, state.revision, state.canonicalHash);
|
|
158
|
+
if (args.json) {
|
|
159
|
+
ctx.stdout(JSON.stringify({ propose: body, approve: approved.body }));
|
|
160
|
+
return approved.status === 200 ? 0 : 1;
|
|
161
|
+
}
|
|
162
|
+
return renderApprove(ctx, approved);
|
|
163
|
+
}
|
|
164
|
+
// approve — with no flags, read the current state and approve exactly that.
|
|
165
|
+
const revisionFlag = args.flags.revision === undefined ? undefined : flagInt(args, "revision", 0);
|
|
166
|
+
const hashFlag = flagString(args, "hash");
|
|
167
|
+
if ((revisionFlag === undefined) !== (hashFlag === undefined)) {
|
|
168
|
+
throw new UsageError("environment approve takes --revision and --hash together, or neither (it reads the current one)");
|
|
169
|
+
}
|
|
170
|
+
let revision = revisionFlag;
|
|
171
|
+
let hash = hashFlag;
|
|
172
|
+
if (revision === undefined || hash === undefined) {
|
|
173
|
+
const current = (await readState(api, routes)).current;
|
|
174
|
+
if (current === null || current === undefined) {
|
|
175
|
+
ctx.stdout("there is no declaration to approve — propose one first: catalyst-skills environment propose --file <path>");
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
revision = current.revision;
|
|
179
|
+
hash = current.canonicalHash;
|
|
180
|
+
}
|
|
181
|
+
const approved = await approveExact(api, routes, revision, hash);
|
|
182
|
+
if (args.json) {
|
|
183
|
+
ctx.stdout(JSON.stringify(approved.body));
|
|
184
|
+
return approved.status === 200 ? 0 : 1;
|
|
185
|
+
}
|
|
186
|
+
return renderApprove(ctx, approved);
|
|
187
|
+
}
|
|
188
|
+
function renderApprove(ctx, approved) {
|
|
189
|
+
const { body, status } = approved;
|
|
190
|
+
if (status === 200 && body.approved) {
|
|
191
|
+
for (const line of renderState(body.state, "approved"))
|
|
192
|
+
ctx.stdout(line);
|
|
193
|
+
ctx.stdout("it is now what a phase's checkout carries");
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
ctx.stdout(`refused (${status}): ${body.message ?? body.error ?? "no reason given"}`);
|
|
197
|
+
if (body.currentRevision !== undefined) {
|
|
198
|
+
ctx.stdout(` it is now at revision ${body.currentRevision}${body.currentHash ? ` (${body.currentHash})` : ""} — read it again before approving`);
|
|
199
|
+
}
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
async function readStdin() {
|
|
203
|
+
const chunks = [];
|
|
204
|
+
for await (const chunk of process.stdin)
|
|
205
|
+
chunks.push(Buffer.from(chunk));
|
|
206
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
207
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// errors.ts — the three error classes the exit contract is built on: usage → 1, MeError/CliError → 2.
|
|
2
|
+
export class UsageError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export class CliError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
exitCode;
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, code,
|
|
9
|
+
/** Exit code the dispatcher returns for this error. Defaults to 2 (the CliError contract). */
|
|
10
|
+
exitCode = 2,
|
|
11
|
+
/** The HTTP status that produced it, when one did. */
|
|
12
|
+
status) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class MeError extends Error {
|
|
20
|
+
kind;
|
|
21
|
+
status;
|
|
22
|
+
constructor(message, kind, status) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.kind = kind;
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
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
|
+
}
|