@catalyst-cloud/cli 0.9.4 → 0.13.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 +34 -0
- package/README.md +38 -32
- package/dist/args.js +16 -2
- package/dist/ask.js +2 -2
- package/dist/capabilities.js +70 -0
- package/dist/cli.js +42 -29
- package/dist/config.js +1 -1
- package/dist/connections.js +5 -5
- package/dist/env/declaration-rules.js +163 -0
- package/dist/env/group.js +51 -0
- package/dist/env/inventory.js +102 -0
- package/dist/env/render.js +39 -0
- package/dist/env/scan-env-example.js +62 -0
- package/dist/env/scan-source.js +43 -0
- package/dist/env/scan-workflow.js +123 -0
- package/dist/env/scan-wrangler.js +120 -0
- package/dist/env/types.js +1 -0
- package/dist/env/walk.js +87 -0
- package/dist/env.js +76 -0
- package/dist/environment.js +4 -4
- package/dist/events.js +1 -1
- package/dist/execution.js +3 -2
- package/dist/identity.js +3 -3
- package/dist/oauth.js +3 -3
- package/dist/query.js +27 -1
- package/dist/ready.js +3 -3
- package/dist/release.js +1 -1
- package/dist/replica.js +9 -7
- package/dist/runtime-store.js +2 -2
- package/dist/runtime.js +1 -1
- package/dist/secret.js +1 -1
- package/dist/skills.js +1 -1
- package/dist/team.js +329 -0
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +7 -7
- package/skills/catalyst-onboard/references/choosing-a-coding-account.md +2 -2
- package/skills/catalyst-onboard/references/declaring-a-repository.md +1 -1
- package/skills/catalyst-onboard/references/local-sync.md +3 -3
- package/skills/catalyst-onboard/references/skill-sources.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +12 -10
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +5 -5
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +6 -9
- package/skills/catalyst-onboard/references/who-fixes-what.md +35 -16
- package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +133 -54
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +3 -3
- package/skills/how-catalyst-works/SKILL.md +5 -3
- package/skills/how-catalyst-works/references/settings-and-where-they-live.md +20 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +19 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +4 -0
- package/skills/what-this-repo-needs/SKILL.md +40 -0
- package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
- package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
- package/skills/what-this-repo-needs/references/what-each-group-means.md +68 -0
- package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
- package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
- package/skills/what-this-repo-needs/scripts/lib/cli.mjs +114 -0
- package/skills/what-this-repo-needs/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reading-the-board.md +1 -1
- package/skills/whats-happening/references/reprioritising.md +1 -1
- package/skills/whats-happening/references/status-reply.md +2 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +41 -33
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const BINDING_KEY_BY_TABLE = {
|
|
2
|
+
kv_namespaces: "binding",
|
|
3
|
+
d1_databases: "binding",
|
|
4
|
+
r2_buckets: "binding",
|
|
5
|
+
"durable_objects.bindings": "name",
|
|
6
|
+
"queues.producers": "binding",
|
|
7
|
+
"queues.consumers": "binding",
|
|
8
|
+
};
|
|
9
|
+
// ⛔ TABLE_RE AND QUOTED_RE ARE END-ANCHORED, SO AN INLINE `#` COMMENT MUST BE STRIPPED FIRST
|
|
10
|
+
// (validate attempt 29, CR-1 / CR-4). `[vars] # public settings` never matched TABLE_RE, so
|
|
11
|
+
// `currentTable` stayed null and the scanner returned NOTHING for the whole file — zero bindings,
|
|
12
|
+
// zero vars, zero notes: a silent, total failure in a tool whose entire value is completeness. And
|
|
13
|
+
// `binding = "SESSIONS" # the session store` never matched QUOTED_RE, so the `: value` fallback
|
|
14
|
+
// below made the raw remainder — quotes, comment and all — the binding NAME the customer is asked to
|
|
15
|
+
// keep, drop or move. `stripInlineComment` is quote-aware, so a `#` inside a value (`id = "ab#cd"`)
|
|
16
|
+
// is still value material and is left alone.
|
|
17
|
+
const QUOTED_RE = /^"(.*)"$|^'(.*)'$/;
|
|
18
|
+
const KEY_VALUE_RE = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/;
|
|
19
|
+
const TABLE_RE = /^\[\[?([^\]]+)\]\]?$/;
|
|
20
|
+
const ENV_TABLE_RE = /^env\.([^.]+)\.(.+)$/;
|
|
21
|
+
export function scanWrangler(file, text) {
|
|
22
|
+
const sightings = [];
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
let currentTable = null;
|
|
25
|
+
let currentEnvironment;
|
|
26
|
+
// Tracks an open triple-quoted string, so a line INSIDE one (e.g. `a = not_a_name`) is never
|
|
27
|
+
// mined for a key — the key that opened the string (e.g. `MULTI = """`) already was.
|
|
28
|
+
let insideMultiline = null;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const trimmed = lines[i].trim();
|
|
31
|
+
if (insideMultiline) {
|
|
32
|
+
if (trimmed.includes(insideMultiline))
|
|
33
|
+
insideMultiline = null;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
37
|
+
continue;
|
|
38
|
+
const uncommented = stripInlineComment(trimmed).trim();
|
|
39
|
+
if (uncommented === "")
|
|
40
|
+
continue;
|
|
41
|
+
const tableMatch = TABLE_RE.exec(uncommented);
|
|
42
|
+
if (tableMatch) {
|
|
43
|
+
let name = tableMatch[1].trim();
|
|
44
|
+
currentEnvironment = undefined;
|
|
45
|
+
const envMatch = ENV_TABLE_RE.exec(name);
|
|
46
|
+
if (envMatch) {
|
|
47
|
+
currentEnvironment = envMatch[1];
|
|
48
|
+
name = envMatch[2];
|
|
49
|
+
}
|
|
50
|
+
currentTable = name;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (currentTable === null)
|
|
54
|
+
continue;
|
|
55
|
+
const kv = KEY_VALUE_RE.exec(uncommented);
|
|
56
|
+
if (!kv)
|
|
57
|
+
continue;
|
|
58
|
+
const key = kv[1];
|
|
59
|
+
const value = kv[2].trim();
|
|
60
|
+
if (isOpeningMultiline(value, '"""'))
|
|
61
|
+
insideMultiline = '"""';
|
|
62
|
+
else if (isOpeningMultiline(value, "'''"))
|
|
63
|
+
insideMultiline = "'''";
|
|
64
|
+
if (currentTable === "vars" || currentTable.endsWith(".vars")) {
|
|
65
|
+
sightings.push({
|
|
66
|
+
name: key,
|
|
67
|
+
finding: { file, line: i + 1, consumer: "wrangler.toml vars", consumerKind: "vars" },
|
|
68
|
+
isWranglerVar: true,
|
|
69
|
+
wranglerEnvironment: currentEnvironment,
|
|
70
|
+
localSource: "value committed in wrangler.toml — nothing to declare",
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bindingKey = BINDING_KEY_BY_TABLE[currentTable];
|
|
75
|
+
if (bindingKey && key === bindingKey) {
|
|
76
|
+
const qm = QUOTED_RE.exec(value);
|
|
77
|
+
const bindingName = qm ? (qm[1] ?? qm[2] ?? "") : value;
|
|
78
|
+
if (bindingName) {
|
|
79
|
+
sightings.push({
|
|
80
|
+
name: bindingName,
|
|
81
|
+
finding: { file, line: i + 1, consumer: `${currentTable} binding`, consumerKind: currentTable },
|
|
82
|
+
isBinding: true,
|
|
83
|
+
wranglerEnvironment: currentEnvironment,
|
|
84
|
+
localSource: "Cloudflare binding",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sightings;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The line with any TOML inline comment removed. A `#` only starts a comment outside a string, so
|
|
93
|
+
* the scan tracks the quote it is inside. A line whose quotes do not balance — a `"""` opener, most
|
|
94
|
+
* importantly — is returned untouched rather than guessed at.
|
|
95
|
+
*/
|
|
96
|
+
function stripInlineComment(line) {
|
|
97
|
+
let quote = null;
|
|
98
|
+
for (let i = 0; i < line.length; i++) {
|
|
99
|
+
const ch = line[i];
|
|
100
|
+
if (quote !== null) {
|
|
101
|
+
if (ch === "\\")
|
|
102
|
+
i++;
|
|
103
|
+
else if (ch === quote)
|
|
104
|
+
quote = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === '"' || ch === "'") {
|
|
108
|
+
quote = ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "#")
|
|
112
|
+
return line.slice(0, i);
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
}
|
|
116
|
+
function isOpeningMultiline(value, marker) {
|
|
117
|
+
if (!value.startsWith(marker))
|
|
118
|
+
return false;
|
|
119
|
+
return !(value.length > marker.length && value.endsWith(marker));
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/env/walk.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// walk.ts — the one definition of "the files this repository is made of", shared by every scanner
|
|
2
|
+
// that needs to enumerate a tree. Skips the directories no scan should ever descend into.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ A SYMLINK IS REPOSITORY CONTENT, AND REPOSITORY CONTENT IS UNTRUSTED (validate attempt 29,
|
|
5
|
+
// M-2 / CR-5). `statSync` FOLLOWS a link, so the previous walk had no way to tell a real directory
|
|
6
|
+
// from a link out of the tree, and no visited set to stop a cycle. Measured: a single `pkg/up -> ..`
|
|
7
|
+
// link reported one name 41 times with absurd file:line pairs, and a committed `elsewhere -> /tmp/x`
|
|
8
|
+
// walked a directory outside the repository entirely. So every entry is classified with a DIRENT
|
|
9
|
+
// (never a followed stat), and a link is followed only when its real path is inside the repository
|
|
10
|
+
// root and has not been walked before.
|
|
11
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
13
|
+
export const SKIP_DIRS = new Set(["node_modules", "dist", "coverage", ".git"]);
|
|
14
|
+
/** Every file under `root`, relative paths, POSIX-separated, skipping SKIP_DIRS. */
|
|
15
|
+
export function walkRepo(root) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let realRoot;
|
|
18
|
+
try {
|
|
19
|
+
realRoot = realpathSync(root);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
// Real paths already descended into. Seeded with the root so a link back to it is a cycle.
|
|
25
|
+
const walked = new Set([realRoot]);
|
|
26
|
+
const stack = [root];
|
|
27
|
+
while (stack.length > 0) {
|
|
28
|
+
const dir = stack.pop();
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const name = entry.name;
|
|
38
|
+
const full = join(dir, name);
|
|
39
|
+
let isDirectory = entry.isDirectory();
|
|
40
|
+
if (entry.isSymbolicLink()) {
|
|
41
|
+
let real;
|
|
42
|
+
try {
|
|
43
|
+
real = realpathSync(full);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!contains(realRoot, real))
|
|
49
|
+
continue; // points out of the repository
|
|
50
|
+
try {
|
|
51
|
+
isDirectory = statSync(full).isDirectory();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (isDirectory) {
|
|
58
|
+
if (SKIP_DIRS.has(name))
|
|
59
|
+
continue;
|
|
60
|
+
// Every directory is keyed by its REAL path, link or not: `pkg/up -> pkg` is a cycle only
|
|
61
|
+
// because the link and the directory resolve to the same place, and checking the link alone
|
|
62
|
+
// would still walk the tree one extra time under its second name.
|
|
63
|
+
let real;
|
|
64
|
+
try {
|
|
65
|
+
real = realpathSync(full);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (walked.has(real))
|
|
71
|
+
continue; // a cycle, or a second route to the same tree
|
|
72
|
+
walked.add(real);
|
|
73
|
+
stack.push(full);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
out.push(relative(root, full).split("\\").join("/"));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out.sort();
|
|
81
|
+
}
|
|
82
|
+
/** True when `child` is `root` itself or lies beneath it. Both must already be real paths. */
|
|
83
|
+
export function contains(root, child) {
|
|
84
|
+
const a = resolve(root);
|
|
85
|
+
const b = resolve(child);
|
|
86
|
+
return b === a || b.startsWith(a.endsWith(sep) ? a : a + sep);
|
|
87
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// env.ts — `catalyst env inventory` / `env check`: a LOCAL, OFFLINE, repo-scoped reporter and
|
|
2
|
+
// validator. Unlike every other verb in this file's siblings, `cmdEnv` never calls `requireConfig` or
|
|
3
|
+
// `apiClient` — it needs neither a login nor a network call, which is the whole point of the feature:
|
|
4
|
+
// a person reviews what a repository declares without connecting anything first.
|
|
5
|
+
import { readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { positionals } from "./args.js";
|
|
8
|
+
import { validateDeclaration } from "./env/declaration-rules.js";
|
|
9
|
+
import { inventoryRepo } from "./env/inventory.js";
|
|
10
|
+
import { inventoryToJson, renderCheck, renderInventory } from "./env/render.js";
|
|
11
|
+
import { UsageError } from "./errors.js";
|
|
12
|
+
export async function cmdEnv(args, ctx, deps = {}) {
|
|
13
|
+
const [sub, ...rest] = positionals(args);
|
|
14
|
+
if (sub === undefined)
|
|
15
|
+
throw new UsageError("env needs a subcommand: inventory | check");
|
|
16
|
+
if (sub === "inventory") {
|
|
17
|
+
if (rest.length > 1)
|
|
18
|
+
throw new UsageError(`env inventory takes at most one path (got an extra "${rest[1]}")`);
|
|
19
|
+
const root = resolve(rest[0] ?? ".");
|
|
20
|
+
// C-3: walkRepo swallows a readdir failure and returns [], so without this an unreadable or
|
|
21
|
+
// mistyped path printed three empty groups and exited 0 — a typo that reads as "declares nothing".
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
stat = statSync(root);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
ctx.stderr(`${root} does not exist — env inventory takes a path to a repository`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
if (!stat.isDirectory()) {
|
|
31
|
+
ctx.stderr(`${root} is not a directory — env inventory takes a path to a repository`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const inv = inventoryRepo(root, deps);
|
|
35
|
+
if (args.json)
|
|
36
|
+
ctx.stdout(JSON.stringify(inventoryToJson(inv)));
|
|
37
|
+
else
|
|
38
|
+
for (const line of renderInventory(inv))
|
|
39
|
+
ctx.stdout(line);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (sub === "check") {
|
|
43
|
+
const file = rest[0];
|
|
44
|
+
if (!file)
|
|
45
|
+
throw new UsageError("env check needs a file: catalyst env check <path to catalyst.env.json>");
|
|
46
|
+
if (rest.length > 1)
|
|
47
|
+
throw new UsageError(`env check takes exactly one file (got an extra "${rest[1]}")`);
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = readFileSync(file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
ctx.stderr(`could not read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
let doc;
|
|
57
|
+
try {
|
|
58
|
+
doc = JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// C-4/S-2: JSON.parse's own message embeds the first ten characters of the input, so
|
|
62
|
+
// forwarding it prints content from the file — on a verb documented as never printing a value.
|
|
63
|
+
ctx.stderr(`${file} is not valid JSON`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const errors = validateDeclaration(doc);
|
|
67
|
+
const state = errors.length === 0 ? "valid" : "invalid";
|
|
68
|
+
if (args.json)
|
|
69
|
+
ctx.stdout(JSON.stringify({ state, errors }));
|
|
70
|
+
else
|
|
71
|
+
for (const line of renderCheck({ state, errors }, file))
|
|
72
|
+
ctx.stdout(line);
|
|
73
|
+
return state === "valid" ? 0 : 1;
|
|
74
|
+
}
|
|
75
|
+
throw new UsageError(`unknown env subcommand "${sub}": inventory | check`);
|
|
76
|
+
}
|
package/dist/environment.js
CHANGED
|
@@ -131,7 +131,7 @@ export async function cmdEnvironment(args, ctx, deps = {}) {
|
|
|
131
131
|
if (body.reason)
|
|
132
132
|
ctx.stdout(` reason: ${body.reason}`);
|
|
133
133
|
if (body.currentRevision !== undefined) {
|
|
134
|
-
ctx.stdout(` the declaration is now at revision ${body.currentRevision} — run \`catalyst
|
|
134
|
+
ctx.stdout(` the declaration is now at revision ${body.currentRevision} — run \`catalyst environment read\` and propose again from that`);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
return 1;
|
|
@@ -144,14 +144,14 @@ export async function cmdEnvironment(args, ctx, deps = {}) {
|
|
|
144
144
|
if (args.json)
|
|
145
145
|
ctx.stdout(JSON.stringify(body));
|
|
146
146
|
else if (body.status !== "unchanged")
|
|
147
|
-
ctx.stdout("not delivered until it is approved: catalyst
|
|
147
|
+
ctx.stdout("not delivered until it is approved: catalyst environment approve");
|
|
148
148
|
return 0;
|
|
149
149
|
}
|
|
150
150
|
// ⭐ Approve exactly what propose just returned. The whole point of the CAS is that the approver
|
|
151
151
|
// saw the revision they are approving, and nothing here has to be copied by a human or a model.
|
|
152
152
|
const state = body.state;
|
|
153
153
|
if (!state) {
|
|
154
|
-
ctx.stderr("the cloud returned no state to approve — run: catalyst
|
|
154
|
+
ctx.stderr("the cloud returned no state to approve — run: catalyst environment read");
|
|
155
155
|
return 1;
|
|
156
156
|
}
|
|
157
157
|
const approved = await approveExact(api, routes, state.revision, state.canonicalHash);
|
|
@@ -172,7 +172,7 @@ export async function cmdEnvironment(args, ctx, deps = {}) {
|
|
|
172
172
|
if (revision === undefined || hash === undefined) {
|
|
173
173
|
const current = (await readState(api, routes)).current;
|
|
174
174
|
if (current === null || current === undefined) {
|
|
175
|
-
ctx.stdout("there is no declaration to approve — propose one first: catalyst
|
|
175
|
+
ctx.stdout("there is no declaration to approve — propose one first: catalyst environment propose --file <path>");
|
|
176
176
|
return 1;
|
|
177
177
|
}
|
|
178
178
|
revision = current.revision;
|
package/dist/events.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function cmdEvents(args, ctx, deps = {}) {
|
|
|
71
71
|
if (controller.signal.aborted)
|
|
72
72
|
return sub === "wait-for" ? 1 : 0;
|
|
73
73
|
if (error.code === "ENOENT")
|
|
74
|
-
throw new CliError(`event cache is absent at ${directory} — start it with: catalyst
|
|
74
|
+
throw new CliError(`event cache is absent at ${directory} — start it with: catalyst replica start --detach`, "events-absent", 3);
|
|
75
75
|
throw error;
|
|
76
76
|
}
|
|
77
77
|
finally {
|
package/dist/execution.js
CHANGED
|
@@ -37,14 +37,15 @@ 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; `catalyst
|
|
41
|
-
phase_parked: "the offered phase is parked after repeated failures or the repair-round cap; `catalyst
|
|
40
|
+
remediate_parked: "the remediate phase is parked, so the failing phase has nowhere to be repaired; `catalyst 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 release <ticket>` releases it once its cause is fixed",
|
|
42
42
|
later_phase_lease_held: "an earlier phase is offered while a live container still holds a later phase of this ticket",
|
|
43
43
|
human_owned_pr: "a person's own pull request holds this ticket; it releases itself when that PR closes or merges",
|
|
44
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
45
|
round_threshold: "the ticket spent its lifetime repair budget; answering its ask or pushing a fix buys one more cycle",
|
|
46
46
|
claim_storm: "this unit was claimed too many times in the last hour, so it waits out the hour; nothing to release",
|
|
47
47
|
repo_at_capacity: "the repository's runner seats are all in use; it starts when one frees",
|
|
48
|
+
wip_limit: "the project is at its WIP limit (tickets in progress, counting blocked, parked and waiting ones); this new start waits until one of them finishes, and the tickets in progress keep running",
|
|
48
49
|
};
|
|
49
50
|
/** Every fail-closed unknown the evaluator names. */
|
|
50
51
|
export const UNKNOWN_REASONS = {
|
package/dist/identity.js
CHANGED
|
@@ -14,7 +14,7 @@ function render(result, choices) {
|
|
|
14
14
|
return `${summary}\nNo choices were offered. An automatic match cannot be replaced; an unmatched roster may need a retry.`;
|
|
15
15
|
if (result.options.length === 0)
|
|
16
16
|
return `${summary}\nThe workspace roster has no eligible people.`;
|
|
17
|
-
return [summary, ...result.options.map((o) => `${o.id}\t${o.displayName ?? o.name ?? "(unnamed)"}`), "Select your own identity explicitly: catalyst
|
|
17
|
+
return [summary, ...result.options.map((o) => `${o.id}\t${o.displayName ?? o.name ?? "(unnamed)"}`), "Select your own identity explicitly: catalyst identity linear set <linearUserId>"].join("\n");
|
|
18
18
|
}
|
|
19
19
|
export async function cmdIdentity(args, ctx, deps = {}) {
|
|
20
20
|
const parts = positionals(args);
|
|
@@ -25,11 +25,11 @@ export async function cmdIdentity(args, ctx, deps = {}) {
|
|
|
25
25
|
}
|
|
26
26
|
const cfg = requireConfig(ctx);
|
|
27
27
|
if (!cfg.user)
|
|
28
|
-
throw new CliError("this machine uses a tenant account key; run catalyst
|
|
28
|
+
throw new CliError("this machine uses a tenant account key; run catalyst login as yourself", "member-required");
|
|
29
29
|
const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
|
|
30
30
|
const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
|
|
31
31
|
if (!client.linearIdentity)
|
|
32
|
-
throw new CliError("the installed SDK lacks identity recovery; upgrade catalyst
|
|
32
|
+
throw new CliError("the installed SDK lacks identity recovery; upgrade the catalyst CLI after the SDK release", "sdk-upgrade-required");
|
|
33
33
|
if (action === "set") {
|
|
34
34
|
// The arity check above requires a selection; never choose the first roster entry for a person.
|
|
35
35
|
if (selected === undefined)
|
package/dist/oauth.js
CHANGED
|
@@ -152,7 +152,7 @@ async function pollDeviceCode(ctx, discovery, auth, sleep) {
|
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
154
|
if (err === "access_denied")
|
|
155
|
-
throw new CliError("login was denied — run: catalyst
|
|
155
|
+
throw new CliError("login was denied — run: catalyst login to try again", "login-denied");
|
|
156
156
|
if (err === "expired_token")
|
|
157
157
|
return "expired";
|
|
158
158
|
// A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
|
|
@@ -220,7 +220,7 @@ export async function bearerFor(ctx, cfg, deps = {}) {
|
|
|
220
220
|
if (typeof cfg.key === "string" && cfg.key !== "")
|
|
221
221
|
return cfg.key;
|
|
222
222
|
if (!cfg.auth)
|
|
223
|
-
throw new CliError("this machine is not connected — run: catalyst
|
|
223
|
+
throw new CliError("this machine is not connected — run: catalyst login", "not-configured");
|
|
224
224
|
const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
|
|
225
225
|
if (Number.isFinite(msToExpiry) && msToExpiry > REFRESH_SKEW_MS)
|
|
226
226
|
return cfg.auth.accessToken;
|
|
@@ -256,7 +256,7 @@ async function refreshAndPersist(ctx, cfg, deps) {
|
|
|
256
256
|
const err = await oauthError(res, discovery.tokenUrl);
|
|
257
257
|
if (err === "invalid_grant") {
|
|
258
258
|
// revocation or > inactivity window — the ONLY session-expired case; NOT routine expiry.
|
|
259
|
-
throw new CliError("your login expired or was revoked — run: catalyst
|
|
259
|
+
throw new CliError("your login expired or was revoked — run: catalyst login", "session-expired");
|
|
260
260
|
}
|
|
261
261
|
const transient = res.status === 429 || res.status >= 500;
|
|
262
262
|
if (!transient)
|
package/dist/query.js
CHANGED
|
@@ -156,7 +156,7 @@ async function fromApi(ctx, cfg, sub, rest, f, args) {
|
|
|
156
156
|
if (!q)
|
|
157
157
|
throw new UsageError("search needs terms: query search <terms>");
|
|
158
158
|
const res = await api.getJson("/api/v1/search", { query: { q, limit: f.limit } });
|
|
159
|
-
return
|
|
159
|
+
return searchRows(res.body);
|
|
160
160
|
}
|
|
161
161
|
case "changes": {
|
|
162
162
|
const since = flagString(args, "since");
|
|
@@ -224,6 +224,21 @@ export function applyFilters(rows, f) {
|
|
|
224
224
|
return true;
|
|
225
225
|
});
|
|
226
226
|
}
|
|
227
|
+
/** The hub answers search in four groups (`issues`, `pulls`, `projects`, `initiatives`, the read
|
|
228
|
+
* model's SearchView). `rowsOf` returns only the first array it finds, so it dropped every match that
|
|
229
|
+
* was not a ticket. Every group comes back here, flattened, each row tagged with its `kind`. */
|
|
230
|
+
const SEARCH_GROUPS = [["issues", "issue"], ["pulls", "pull"], ["projects", "project"], ["initiatives", "initiative"]];
|
|
231
|
+
export function searchRows(body) {
|
|
232
|
+
if (Array.isArray(body))
|
|
233
|
+
return body;
|
|
234
|
+
if (!body || typeof body !== "object")
|
|
235
|
+
return [];
|
|
236
|
+
const b = body;
|
|
237
|
+
if (SEARCH_GROUPS.some(([group]) => Array.isArray(b[group]))) {
|
|
238
|
+
return SEARCH_GROUPS.flatMap(([group, kind]) => Array.isArray(b[group]) ? b[group].map((row) => ({ kind, ...row })) : []);
|
|
239
|
+
}
|
|
240
|
+
return rowsOf(body);
|
|
241
|
+
}
|
|
227
242
|
function printResult(ctx, args, sub, result) {
|
|
228
243
|
if (args.json || !Array.isArray(result)) {
|
|
229
244
|
ctx.stdout(JSON.stringify(result, null, args.json ? 0 : 2));
|
|
@@ -242,6 +257,17 @@ export function summaryLine(sub, r) {
|
|
|
242
257
|
return `${r.id} ${r.state ?? "?"} ${r.name ?? ""}`;
|
|
243
258
|
case "cycles":
|
|
244
259
|
return `cycle ${r.number ?? r.id} ${r.name ?? ""} ${r.starts_at ?? ""} → ${r.ends_at ?? ""}`;
|
|
260
|
+
case "search":
|
|
261
|
+
switch (r.kind) {
|
|
262
|
+
case "pull":
|
|
263
|
+
return `pull #${r.number ?? "?"} ${r.title ?? ""}${r.repo_id ? ` (${r.repo_id})` : ""}`;
|
|
264
|
+
case "project":
|
|
265
|
+
return `project ${r.name ?? r.id}`;
|
|
266
|
+
case "initiative":
|
|
267
|
+
return `initiative ${r.name ?? r.id}`;
|
|
268
|
+
default:
|
|
269
|
+
return `issue ${r.identifier ?? r.id} ${r.title ?? ""}`;
|
|
270
|
+
}
|
|
245
271
|
default:
|
|
246
272
|
return JSON.stringify(r);
|
|
247
273
|
}
|
package/dist/ready.js
CHANGED
|
@@ -184,7 +184,7 @@ export async function readyReport(ctx, deps) {
|
|
|
184
184
|
? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
|
|
185
185
|
: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
|
|
186
186
|
}
|
|
187
|
-
: { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst
|
|
187
|
+
: { id: "config", ok: false, line: "config: not connected", fix: "npx -p @catalyst-cloud/cli catalyst login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
|
|
188
188
|
}
|
|
189
189
|
catch (err) {
|
|
190
190
|
checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
|
|
@@ -192,7 +192,7 @@ export async function readyReport(ctx, deps) {
|
|
|
192
192
|
const cache = readContractCache(ctx.home);
|
|
193
193
|
const range = readManifest().tenantContractRange;
|
|
194
194
|
if (!cache) {
|
|
195
|
-
checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst
|
|
195
|
+
checks.push({ id: "contract", ok: false, line: "contract: not cached", fix: "catalyst contract --refresh", who: "you" });
|
|
196
196
|
}
|
|
197
197
|
else if (contractVersionInRange(cache.contractVersion, range) !== true) {
|
|
198
198
|
checks.push({ id: "contract", ok: false, line: `contract: version ${cache.contractVersion} is outside this bundle's range ${range}`, fix: upgradeCommand(), who: "you" });
|
|
@@ -290,7 +290,7 @@ export async function readyReport(ctx, deps) {
|
|
|
290
290
|
checks.push({ id: "skills", ok: true, line: `skills: all ${deps.skillNames.length} present in ${skillsDir}` });
|
|
291
291
|
}
|
|
292
292
|
else {
|
|
293
|
-
checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst
|
|
293
|
+
checks.push({ id: "skills", ok: false, line: `skills: ${present.length} of ${deps.skillNames.length} in ${skillsDir}, missing ${missing.join(", ")}`, fix: "catalyst install", who: "you" });
|
|
294
294
|
}
|
|
295
295
|
try {
|
|
296
296
|
await (deps.loadSdk ?? loadSdk)();
|
package/dist/release.js
CHANGED
|
@@ -37,7 +37,7 @@ export function renderTicketRelease(ticket, r) {
|
|
|
37
37
|
const lines = [];
|
|
38
38
|
const dry = r.dryRun === true;
|
|
39
39
|
if (r.outcome === "nothing-held") {
|
|
40
|
-
lines.push(`${ticket}: nothing holds this ticket — run \`catalyst
|
|
40
|
+
lines.push(`${ticket}: nothing holds this ticket — run \`catalyst explain ${ticket}\` for why it is not running`);
|
|
41
41
|
}
|
|
42
42
|
else if (r.outcome === "refused") {
|
|
43
43
|
lines.push(dry ? `${ticket} (dry run): would be refused — nothing would be released:` : `${ticket}: refused — nothing was released:`);
|
package/dist/replica.js
CHANGED
|
@@ -196,7 +196,7 @@ function baseStatusLine(s) {
|
|
|
196
196
|
case "not-configured":
|
|
197
197
|
return `replica: not configured (${s.reasons.join("; ")})`;
|
|
198
198
|
case "absent":
|
|
199
|
-
return `replica: absent at ${s.dbPath} — start it with: catalyst
|
|
199
|
+
return `replica: absent at ${s.dbPath} — start it with: catalyst replica start --detach`;
|
|
200
200
|
case "fresh":
|
|
201
201
|
return `replica: fresh at ${s.dbPath} (cursor ${s.cursor}, heartbeat ${s.heartbeatAgeMs}ms ago${s.lag !== undefined ? `, ${s.lag} behind head ${s.head}` : ""})`;
|
|
202
202
|
case "stale":
|
|
@@ -240,7 +240,9 @@ export async function engineFor(sdk, dbPath, ctx, deps = {}) {
|
|
|
240
240
|
}
|
|
241
241
|
catch (err) {
|
|
242
242
|
const why = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
243
|
-
|
|
243
|
+
// node:sqlite is the supported path (CTC-4272): say nothing when it works, except under CATALYST_DEBUG.
|
|
244
|
+
if (ctx.env?.CATALYST_DEBUG === "1")
|
|
245
|
+
ctx.stderr(`[catalyst] better-sqlite3 unavailable (${why}); using node:sqlite`);
|
|
244
246
|
return deps.readonly ? sdk.nodeSqliteReadonlyEngine(dbPath) : sdk.nodeSqliteEngine(dbPath);
|
|
245
247
|
}
|
|
246
248
|
}
|
|
@@ -252,7 +254,7 @@ export const SNAPSHOT_MAX_FAILURES = 5;
|
|
|
252
254
|
* count on; staying live is that signal. The failing warm loop never reaches it — its resync demand
|
|
253
255
|
* fails and tears the attempt down first (CTC-2499). */
|
|
254
256
|
export const SNAPSHOT_LIVE_STABLE_MS = 60_000;
|
|
255
|
-
export const REPLICA_RESTART_COMMAND = "catalyst
|
|
257
|
+
export const REPLICA_RESTART_COMMAND = "catalyst replica start --detach";
|
|
256
258
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
257
259
|
function defaultWaitForStop() {
|
|
258
260
|
return new Promise((resolve) => {
|
|
@@ -342,7 +344,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
342
344
|
const argv = (deps.argv ?? process.argv.slice(1)).filter((a) => a !== "--detach");
|
|
343
345
|
const { pid } = (deps.detach ?? detachSelf)(argv, ctx.env);
|
|
344
346
|
writeFileSync(pidfilePath(dbPath), `${pid}\n`);
|
|
345
|
-
ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst
|
|
347
|
+
ctx.stdout(`replica writer started in the background (pid ${pid}, db ${dbPath}); check with: catalyst replica status`);
|
|
346
348
|
return 0;
|
|
347
349
|
}
|
|
348
350
|
const sdk = await loadSdk();
|
|
@@ -503,7 +505,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
503
505
|
const lastError = attemptError ?? "the snapshot pull failed";
|
|
504
506
|
const nowMs = ctx.now().getTime();
|
|
505
507
|
const stopped = failures >= maxFailures
|
|
506
|
-
? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst
|
|
508
|
+
? { at: nowMs, reason: `${failures} consecutive snapshot failures`, restartWith: "catalyst replica start --detach" }
|
|
507
509
|
: null;
|
|
508
510
|
writeWriterState(dbPath, { pid: process.pid, consecutiveFailures: failures, lastError, lastFailureAt: nowMs, stopped }, nowMs);
|
|
509
511
|
if (stopped) {
|
|
@@ -562,7 +564,7 @@ export function assertSingleSelect(sql) {
|
|
|
562
564
|
async function cmdSql(args, ctx, dbPath, sql, deps) {
|
|
563
565
|
const query = assertSingleSelect(sql);
|
|
564
566
|
if (!existsSync(dbPath))
|
|
565
|
-
throw new CliError(`no replica at ${dbPath} — start it with: catalyst
|
|
567
|
+
throw new CliError(`no replica at ${dbPath} — start it with: catalyst replica start --detach`, "replica-absent", 3);
|
|
566
568
|
const sdk = await loadSdk();
|
|
567
569
|
const engine = await engineFor(sdk, dbPath, ctx, { ...deps.engineDeps, readonly: true });
|
|
568
570
|
const replica = await sdk.CatalystReplica.openReadOnly({ dbPath, engine, log: () => { } });
|
|
@@ -577,7 +579,7 @@ async function cmdSql(args, ctx, dbPath, sql, deps) {
|
|
|
577
579
|
}
|
|
578
580
|
async function cmdSchema(args, ctx, dbPath, table) {
|
|
579
581
|
if (!existsSync(dbPath))
|
|
580
|
-
throw new CliError(`no replica at ${dbPath} — the schema is what the file holds; start it with: catalyst
|
|
582
|
+
throw new CliError(`no replica at ${dbPath} — the schema is what the file holds; start it with: catalyst replica start --detach`, "replica-absent", 3);
|
|
581
583
|
const db = new (loadSqlite().DatabaseSync)(dbPath, { readOnly: true });
|
|
582
584
|
try {
|
|
583
585
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all().map((r) => r.name);
|
package/dist/runtime-store.js
CHANGED
|
@@ -21,11 +21,11 @@ const PLATFORM_ARCH = {
|
|
|
21
21
|
* needs `.zip` extraction, a separate mechanism this ticket does not build (D7). */
|
|
22
22
|
export function tarballFor(version, platform, arch) {
|
|
23
23
|
if (platform === "win32") {
|
|
24
|
-
throw new CliError("catalyst
|
|
24
|
+
throw new CliError("catalyst runtime install does not support Windows yet — supported: darwin (arm64, x64) and linux (x64, arm64). Use whatever Node 22.15+ or bun 1.4+ you already have on Windows.", "runtime-unsupported-platform");
|
|
25
25
|
}
|
|
26
26
|
const arches = PLATFORM_ARCH[platform];
|
|
27
27
|
if (!arches || !arches.includes(arch)) {
|
|
28
|
-
throw new CliError(`catalyst
|
|
28
|
+
throw new CliError(`catalyst runtime install does not support ${platform}/${arch} — supported: darwin (arm64, x64) and linux (x64, arm64)`, "runtime-unsupported-platform");
|
|
29
29
|
}
|
|
30
30
|
const file = `node-v${version}-${platform}-${arch}.tar.gz`;
|
|
31
31
|
return { file, url: `https://nodejs.org/dist/v${version}/${file}`, shasums: `https://nodejs.org/dist/v${version}/SHASUMS256.txt` };
|
package/dist/runtime.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
export const BUN_MIN = "1.4.0";
|
|
14
14
|
/** The ONE copy-pasteable command. Installs a pinned Node under the CLI's own cache and pins it;
|
|
15
15
|
* it never touches the machine's default Node and needs no admin rights. See src/runtime-store.ts. */
|
|
16
|
-
export const FIX_COMMAND = "npx -y @catalyst-cloud/catalyst
|
|
16
|
+
export const FIX_COMMAND = "npx -y -p @catalyst-cloud/cli catalyst runtime install";
|
|
17
17
|
/** Detect the runtime this process is running on. `process.versions.bun` is present only under bun. */
|
|
18
18
|
export function detectRuntime(versions = process.versions) {
|
|
19
19
|
if (versions.bun)
|
package/dist/secret.js
CHANGED
|
@@ -70,7 +70,7 @@ function requireRepo(args, verb) {
|
|
|
70
70
|
/** The cloud's refusal, in its own words, plus what to do for the two we can explain. */
|
|
71
71
|
function refusalLine(status, body, repo) {
|
|
72
72
|
if (status === 401) {
|
|
73
|
-
return "refused (401): the cloud did not accept your credential here. If `catalyst
|
|
73
|
+
return "refused (401): the cloud did not accept your credential here. If `catalyst me` works, this cloud predates secret writes from the CLI and needs a newer release; otherwise log in again.";
|
|
74
74
|
}
|
|
75
75
|
if (status === 404)
|
|
76
76
|
return `refused (404): ${repo} is not a repository of this tenant (register it in the app first)`;
|
package/dist/skills.js
CHANGED
|
@@ -137,5 +137,5 @@ export function updateNoticeLine(previous, current, entry) {
|
|
|
137
137
|
// minor — so it never crosses 0.2.x → 0.3.0. Pin @latest via `npm install`, THEN re-run login: the
|
|
138
138
|
// install alone does not rewrite customer.json.cliPath, so the skill helpers would keep spawning the
|
|
139
139
|
// stale recorded bundle and repeat this notice forever; the new global bin records its own path.
|
|
140
|
-
return `[catalyst
|
|
140
|
+
return `[catalyst] updated ${previous} → ${current}: ${summary} · update with: ${upgradeCommand()}`;
|
|
141
141
|
}
|