@catalyst-cloud/cli 0.9.3 → 0.9.5
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 +20 -0
- package/README.md +35 -31
- package/dist/args.js +13 -1
- package/dist/ask.js +2 -2
- package/dist/capabilities.js +70 -0
- package/dist/cli.js +37 -29
- package/dist/config.js +1 -1
- package/dist/connections.js +5 -5
- package/dist/environment.js +4 -4
- package/dist/events.js +1 -1
- package/dist/execution.js +5 -4
- 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 +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +51 -35
- package/skills/catalyst-onboard/references/choosing-a-coding-account.md +52 -0
- package/skills/catalyst-onboard/references/declaring-a-repository.md +4 -2
- package/skills/catalyst-onboard/references/local-sync.md +3 -3
- package/skills/catalyst-onboard/references/replacing-a-credential.md +2 -2
- package/skills/catalyst-onboard/references/skill-sources.md +1 -1
- package/skills/catalyst-onboard/references/the-one-path.md +54 -86
- package/skills/catalyst-onboard/references/what-a-phase-needs.md +6 -6
- package/skills/catalyst-onboard/references/what-the-browser-owns.md +8 -10
- package/skills/catalyst-onboard/references/who-fixes-what.md +38 -18
- package/skills/catalyst-onboard/scripts/lib/cli.mjs +15 -1
- package/skills/catalyst-onboard/scripts/local-sync.mjs +6 -6
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +294 -61
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +1 -1
- package/skills/how-catalyst-works/SKILL.md +2 -2
- package/skills/how-catalyst-works/references/what-runs-next.md +11 -0
- 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/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reading-the-board.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.md +1 -0
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 = {
|
|
@@ -332,9 +333,9 @@ export function renderAccount(a) {
|
|
|
332
333
|
const harness = a.harness ? `/${a.harness}` : "";
|
|
333
334
|
const status = a.status ? (ACCOUNT_STATUS[a.status] ?? a.status) : "status unknown";
|
|
334
335
|
const bits = [`${name} ${a.provider ?? "?"}${harness} ${status}`];
|
|
335
|
-
// CTC-4199: a cancelled subscription is
|
|
336
|
+
// CTC-4199: a cancelled subscription is kept for reporting and not used; never re-credential it (Ryan, 2026-09-29).
|
|
336
337
|
if (a.renewalStatus === "canceled")
|
|
337
|
-
bits.push("subscription canceled —
|
|
338
|
+
bits.push("subscription canceled — kept for reporting, not used");
|
|
338
339
|
if (typeof a.bindingUsedPercent === "number") {
|
|
339
340
|
const resets = typeof a.bindingResetsAtMs === "number" ? `, resets ${new Date(a.bindingResetsAtMs).toISOString()}` : "";
|
|
340
341
|
bits.push(`usage ${a.bindingUsedPercent}% of the ${a.bindingWindow ?? "binding"} window${resets}`);
|
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
|
}
|
package/dist/team.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Team setup uses the person's saved login and the SDK's typed personal-admin methods.
|
|
2
|
+
// A fresh SDK client per call refreshes a device-login token across a multi-chunk migration.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { flagBool, flagList, flagString, positionals } from "./args.js";
|
|
5
|
+
import { requireConfig } from "./config.js";
|
|
6
|
+
import { loadContract } from "./contract.js";
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { bearerFor } from "./oauth.js";
|
|
9
|
+
import { loadHttpSdk } from "./sdk.js";
|
|
10
|
+
const SLOTS = ["dispatch", "intake", "research", "plan", "implement", "remediate", "verify", "review", "pr", "done", "canceled"];
|
|
11
|
+
function isSlot(value) { return SLOTS.includes(value); }
|
|
12
|
+
const NAMES = {
|
|
13
|
+
dispatch: ["todo", "to do", "ready"], intake: ["intake", "triage"], research: ["research"],
|
|
14
|
+
plan: ["plan", "planning"], implement: ["implement", "in progress", "doing"],
|
|
15
|
+
remediate: ["remediate", "fix"], verify: ["verify", "validate", "testing"], review: ["review"],
|
|
16
|
+
pr: ["pr", "in review", "pull request"], done: ["done", "completed"], canceled: ["canceled", "cancelled"],
|
|
17
|
+
};
|
|
18
|
+
const MAX_CHUNKS = 100;
|
|
19
|
+
const MAX_RUN_MS = 5 * 60_000;
|
|
20
|
+
function routes(doc) {
|
|
21
|
+
const read = doc.routes.find((r) => r.method === "GET" && r.path.endsWith("/team-workflow"))?.path;
|
|
22
|
+
if (!read)
|
|
23
|
+
throw new UsageError("team setup needs a newer Catalyst Cloud (team-workflow route absent)");
|
|
24
|
+
const desired = { teams: read.replace(/\/team-workflow$/, "/teams"), read, check: `${read}/check`, save: `${read}/save`, adopt: `${read}/adopt`, undo: `${read}/adopt-undo`, migrate: `${read}/migrate` };
|
|
25
|
+
for (const [name, path] of Object.entries(desired)) {
|
|
26
|
+
const method = name === "teams" || name === "read" ? "GET" : "POST";
|
|
27
|
+
if (!doc.routes.some((r) => r.method === method && r.path === path))
|
|
28
|
+
throw new UsageError(`team setup needs a newer Catalyst Cloud (${method} ${path} absent)`);
|
|
29
|
+
}
|
|
30
|
+
return desired;
|
|
31
|
+
}
|
|
32
|
+
function key(args) {
|
|
33
|
+
const [sub, team, ...extra] = positionals(args);
|
|
34
|
+
if (extra.length > 0)
|
|
35
|
+
throw new UsageError(`team ${sub ?? ""} takes one team key`);
|
|
36
|
+
if (!team && !(sub === "list" || (sub === "check" && flagBool(args, "all"))))
|
|
37
|
+
throw new UsageError(`team ${sub ?? ""} needs a team key`);
|
|
38
|
+
if (team && flagBool(args, "all"))
|
|
39
|
+
throw new UsageError("team check takes a key or --all, not both");
|
|
40
|
+
return team ?? "";
|
|
41
|
+
}
|
|
42
|
+
function emit(ctx, args, value, lines) {
|
|
43
|
+
if (args.json)
|
|
44
|
+
ctx.stdout(JSON.stringify(value));
|
|
45
|
+
else
|
|
46
|
+
for (const line of lines)
|
|
47
|
+
ctx.stdout(line);
|
|
48
|
+
}
|
|
49
|
+
function refusal(ctx, args, reply) {
|
|
50
|
+
const { body, status } = reply;
|
|
51
|
+
emit(ctx, args, { status, ...body }, [`refused (${status}): ${String(body.error ?? "http")}${body.reason || body.message ? ` — ${String(body.reason ?? body.message)}` : ""}`]);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
function readinessLines(value) {
|
|
55
|
+
if (value === null || typeof value !== "object")
|
|
56
|
+
return ["the change landed, but readiness could not be checked; run team check"];
|
|
57
|
+
const r = value;
|
|
58
|
+
return [`readiness: ${String(r.status ?? "unknown")}; checked at ${String(r.checkedAt ?? "unknown")}`,
|
|
59
|
+
...(Array.isArray(r.checks) ? r.checks : []).filter((c) => c.state === "fail").map((c) => ` ${String(c.id)}: ${String(c.reason ?? "failed")} (${String(c.owner ?? c.whoCanFix ?? "team admin")})`)];
|
|
60
|
+
}
|
|
61
|
+
function fromSdk(result) {
|
|
62
|
+
if (result.outcome === "ok") {
|
|
63
|
+
const { outcome: _outcome, status, ...body } = result;
|
|
64
|
+
return { status, body: body };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
status: result.outcome === "shape" ? 502 : "status" in result ? result.status : 503,
|
|
68
|
+
body: { error: "error" in result ? result.error ?? result.outcome : result.outcome, reason: result.reason, ...(result.outcome === "shape" ? { upstreamStatus: result.status } : {}) },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function isRefusal(reply) { return typeof reply.body.error === "string" || !((reply.status >= 200 && reply.status < 300) || reply.status === 304); }
|
|
72
|
+
function assignments(values, label) {
|
|
73
|
+
return values.map((value) => {
|
|
74
|
+
const split = value.indexOf("=");
|
|
75
|
+
if (split <= 0 || split === value.length - 1)
|
|
76
|
+
throw new UsageError(`--${label} needs left=right (got ${value})`);
|
|
77
|
+
return [value.slice(0, split), value.slice(split + 1)];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function mapping(view, explicit) {
|
|
81
|
+
if (view.stageSource === "none" || !Array.isArray(view.stages))
|
|
82
|
+
throw new UsageError("live Linear stages are unreadable; retry team map after the connection is healthy");
|
|
83
|
+
const stages = view.stages;
|
|
84
|
+
const selected = new Map();
|
|
85
|
+
const unresolved = [];
|
|
86
|
+
if (explicit.length > 0) {
|
|
87
|
+
for (const [slot, stateName] of explicit) {
|
|
88
|
+
if (!isSlot(slot))
|
|
89
|
+
throw new UsageError(`unknown workflow role: ${slot}`);
|
|
90
|
+
if (selected.has(slot))
|
|
91
|
+
throw new UsageError(`duplicate workflow role: ${slot}`);
|
|
92
|
+
const found = stages.filter((s) => s.name.toLocaleLowerCase() === stateName.toLocaleLowerCase() || s.id === stateName);
|
|
93
|
+
if (found.length !== 1)
|
|
94
|
+
throw new UsageError(`${stateName} matches ${found.length} live Linear stages; choose an unambiguous name or id`);
|
|
95
|
+
selected.set(slot, found[0].id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
for (const slot of SLOTS) {
|
|
100
|
+
const names = NAMES[slot] ?? [];
|
|
101
|
+
const match = names.flatMap((name) => stages.filter((stage) => stage.name.toLocaleLowerCase() === name));
|
|
102
|
+
const unique = [...new Map(match.map((stage) => [stage.id, stage])).values()];
|
|
103
|
+
if (unique.length === 1)
|
|
104
|
+
selected.set(slot, unique[0].id);
|
|
105
|
+
else
|
|
106
|
+
unresolved.push(slot);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const previous = new Map((view.rows ?? []).map((r) => [r.slot, r.linearStateId]));
|
|
110
|
+
const diff = SLOTS.map((slot) => {
|
|
111
|
+
const before = previous.get(slot) ?? null;
|
|
112
|
+
const after = selected.get(slot) ?? null;
|
|
113
|
+
return { slot, before, after, change: before === after ? "unchanged" : before === null ? "added" : after === null ? "removed" : "changed" };
|
|
114
|
+
});
|
|
115
|
+
return { rows: SLOTS.filter((slot) => selected.has(slot)).map((slot) => ({ slot, linearStateId: selected.get(slot), source: "chosen" })), diff, unresolved };
|
|
116
|
+
}
|
|
117
|
+
function hashPlan(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
118
|
+
function planLines(title, rows, hash) {
|
|
119
|
+
return [title, ...rows.map((row) => ` ${JSON.stringify(row)}`), `Plan hash: ${hash}`, `Review this plan with the person, then rerun with --yes --plan-hash ${hash} to apply exactly it.`];
|
|
120
|
+
}
|
|
121
|
+
export async function cmdTeam(args, ctx, deps = {}) {
|
|
122
|
+
const [sub] = positionals(args);
|
|
123
|
+
if (!sub || !["list", "check", "map", "adopt", "migrate", "checklist"].includes(sub))
|
|
124
|
+
throw new UsageError("team needs list | check | map | adopt | migrate | checklist");
|
|
125
|
+
const team = key(args);
|
|
126
|
+
if (sub !== "check" && flagBool(args, "all"))
|
|
127
|
+
throw new UsageError("--all belongs to team check");
|
|
128
|
+
if (sub !== "adopt" && flagBool(args, "undo"))
|
|
129
|
+
throw new UsageError("--undo belongs to team adopt");
|
|
130
|
+
if (sub !== "migrate" && (flagBool(args, "retire") || flagList(args, "choice").length))
|
|
131
|
+
throw new UsageError("--retire and --choice belong to team migrate");
|
|
132
|
+
if (sub !== "map" && flagList(args, "stage").length)
|
|
133
|
+
throw new UsageError("--stage belongs to team map");
|
|
134
|
+
if (!["map", "adopt", "migrate"].includes(sub) && flagString(args, "plan-hash") !== undefined)
|
|
135
|
+
throw new UsageError("--plan-hash belongs to team map, adopt, or migrate");
|
|
136
|
+
if ((sub === "check" || sub === "checklist") && flagBool(args, "yes"))
|
|
137
|
+
throw new UsageError(`team ${sub} takes no --yes`);
|
|
138
|
+
const cfg = requireConfig(ctx);
|
|
139
|
+
let { doc } = await loadContract(ctx, cfg);
|
|
140
|
+
try {
|
|
141
|
+
routes(doc);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (!(error instanceof UsageError) || !error.message.includes("team-workflow"))
|
|
145
|
+
throw error;
|
|
146
|
+
// A fresh cached contract can predate the team routes. Revalidate once before telling the
|
|
147
|
+
// person the cloud is too old; the conditional GET is cheap when the server has not changed.
|
|
148
|
+
doc = (await loadContract(ctx, cfg, { refresh: true })).doc;
|
|
149
|
+
routes(doc);
|
|
150
|
+
}
|
|
151
|
+
// API hashes detect stale server state. CLI approval additionally binds the reviewed scope,
|
|
152
|
+
// operation and signed-in tenant so consent cannot transfer to a different command or account.
|
|
153
|
+
const approvalHash = (operation, plan) => hashPlan({ baseUrl: cfg.baseUrl, account: cfg.account, actor: cfg.user?.id ?? null, team, operation, plan });
|
|
154
|
+
const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
|
|
155
|
+
const client = async () => (await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch })).teamWorkflow;
|
|
156
|
+
if (sub === "list") {
|
|
157
|
+
const reply = fromSdk(await (await client()).teams());
|
|
158
|
+
if (isRefusal(reply))
|
|
159
|
+
return refusal(ctx, args, reply);
|
|
160
|
+
if (!Array.isArray(reply.body.teams))
|
|
161
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team list was missing" } });
|
|
162
|
+
const teams = reply.body.teams;
|
|
163
|
+
emit(ctx, args, { teams, liveTeamRead: reply.body.liveTeamRead }, teams.map((t) => `${String(t.teamKey ?? t.teamId ?? t.key ?? "unknown")}: ${String(t.name ?? t.teamName ?? "")}`));
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
if (sub === "check") {
|
|
167
|
+
const teamList = flagBool(args, "all") ? fromSdk(await (await client()).teams()) : null;
|
|
168
|
+
if (teamList && isRefusal(teamList))
|
|
169
|
+
return refusal(ctx, args, teamList);
|
|
170
|
+
const teamKeys = teamList ? (Array.isArray(teamList.body.teams) ? teamList.body.teams : []).map((t) => String(t.teamKey || t.teamId || t.key || "")).filter(Boolean) : [team];
|
|
171
|
+
if (teamList && !Array.isArray(teamList.body.teams))
|
|
172
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team list was missing" } });
|
|
173
|
+
if (teamList && teamKeys.length === 0)
|
|
174
|
+
return refusal(ctx, args, { status: 404, body: { error: "no-teams", reason: "No Linear teams are visible to this account; connect the tenant's Linear workspace and retry." } });
|
|
175
|
+
const results = [];
|
|
176
|
+
let failed = false;
|
|
177
|
+
for (const teamKey of teamKeys) {
|
|
178
|
+
const reply = fromSdk(await (await client()).check(teamKey));
|
|
179
|
+
if (isRefusal(reply)) {
|
|
180
|
+
results.push({ team: teamKey, status: reply.status, ...reply.body });
|
|
181
|
+
failed = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
results.push({ team: teamKey, ...reply.body });
|
|
185
|
+
if (reply.body.readiness?.status !== "ready")
|
|
186
|
+
failed = true;
|
|
187
|
+
}
|
|
188
|
+
emit(ctx, args, { results }, results.flatMap((result) => [`${String(result.team)}:`, ...(result.error ? [` refused: ${String(result.error)} — ${String(result.reason ?? result.message ?? "")}`] : readinessLines(result.readiness))]));
|
|
189
|
+
return failed ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
if (sub === "checklist") {
|
|
192
|
+
const reply = fromSdk(await (await client()).get(team));
|
|
193
|
+
if (isRefusal(reply))
|
|
194
|
+
return refusal(ctx, args, reply);
|
|
195
|
+
const checklist = reply.body.checklist;
|
|
196
|
+
if (!Array.isArray(checklist) || !checklist.every((line) => typeof line === "string"))
|
|
197
|
+
return refusal(ctx, args, { status: 503, body: { error: "stages-unreadable", reason: "Could not read live Linear stages; retry team checklist." } });
|
|
198
|
+
emit(ctx, args, { team, checklist }, checklist);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
if (sub === "map") {
|
|
202
|
+
const reply = fromSdk(await (await client()).get(team));
|
|
203
|
+
if (isRefusal(reply))
|
|
204
|
+
return refusal(ctx, args, reply);
|
|
205
|
+
if (typeof reply.body.mappingHash !== "string")
|
|
206
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "team workflow read omitted the mapping hash; update Catalyst Cloud before saving" } });
|
|
207
|
+
let proposal;
|
|
208
|
+
try {
|
|
209
|
+
proposal = mapping(reply.body, assignments(flagList(args, "stage"), "stage"));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return refusal(ctx, args, { status: 400, body: { error: "mapping-unresolved", reason: error instanceof Error ? error.message : String(error) } });
|
|
213
|
+
}
|
|
214
|
+
const config = reply.body.config && typeof reply.body.config === "object" ? reply.body.config : undefined;
|
|
215
|
+
const mode = config?.mode ?? "mapped-existing";
|
|
216
|
+
const gitAutomation = config?.gitAutomation ?? "off";
|
|
217
|
+
const planHash = approvalHash("map", { team, rows: proposal.rows, diff: proposal.diff, unresolved: proposal.unresolved, mappingHash: reply.body.mappingHash, mode, gitAutomation });
|
|
218
|
+
const preview = { team, rows: proposal.rows, diff: proposal.diff, unresolved: proposal.unresolved, mappingHash: reply.body.mappingHash, mode, gitAutomation, planHash, decision: "not-confirmed" };
|
|
219
|
+
if (!flagBool(args, "yes")) {
|
|
220
|
+
emit(ctx, args, preview, planLines(`Mapping plan for ${team}:`, proposal.diff, planHash));
|
|
221
|
+
return 3;
|
|
222
|
+
}
|
|
223
|
+
if (flagString(args, "plan-hash") !== planHash)
|
|
224
|
+
return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
|
|
225
|
+
if (!args.json)
|
|
226
|
+
for (const line of planLines(`Mapping plan for ${team}:`, proposal.diff, planHash).slice(0, -1))
|
|
227
|
+
ctx.stdout(line);
|
|
228
|
+
const saved = fromSdk(await (await client()).save({ team, mode, gitAutomation, rows: proposal.rows, expectedMappingHash: reply.body.mappingHash }));
|
|
229
|
+
if (isRefusal(saved))
|
|
230
|
+
return refusal(ctx, args, saved);
|
|
231
|
+
emit(ctx, args, { ...preview, decision: "applied", result: saved.body }, readinessLines(saved.body.readiness));
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (sub === "adopt") {
|
|
235
|
+
const undo = flagBool(args, "undo");
|
|
236
|
+
const previewReply = fromSdk(undo ? await (await client()).undoPreview(team) : await (await client()).adoptPreview(team));
|
|
237
|
+
if (isRefusal(previewReply))
|
|
238
|
+
return refusal(ctx, args, previewReply);
|
|
239
|
+
const hashName = undo ? "undoHash" : "planHash";
|
|
240
|
+
const hash = previewReply.body[hashName];
|
|
241
|
+
if (typeof hash !== "string" || !Array.isArray(previewReply.body[undo ? "candidates" : "stages"]))
|
|
242
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: `adopt preview omitted ${hashName} or scope` } });
|
|
243
|
+
const planHash = approvalHash(undo ? "adopt-undo" : "adopt", {
|
|
244
|
+
serverHash: hash, teamId: previewReply.body.teamId,
|
|
245
|
+
stages: previewReply.body.stages, labels: previewReply.body.labels,
|
|
246
|
+
candidates: previewReply.body.candidates, unfilledLoadBearing: previewReply.body.unfilledLoadBearing,
|
|
247
|
+
});
|
|
248
|
+
const plan = { team, decision: "not-confirmed", preview: previewReply.body, planHash };
|
|
249
|
+
const scope = [
|
|
250
|
+
...previewReply.body[undo ? "candidates" : "stages"],
|
|
251
|
+
...(undo ? [] : Array.isArray(previewReply.body.labels) ? previewReply.body.labels : []),
|
|
252
|
+
];
|
|
253
|
+
if (!flagBool(args, "yes")) {
|
|
254
|
+
emit(ctx, args, plan, planLines(`${undo ? "Undo" : "Adopt"} plan for ${team}:`, scope, planHash));
|
|
255
|
+
return 3;
|
|
256
|
+
}
|
|
257
|
+
if (flagString(args, "plan-hash") !== planHash)
|
|
258
|
+
return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
|
|
259
|
+
if (!args.json)
|
|
260
|
+
for (const line of planLines(`${undo ? "Undo" : "Adopt"} plan for ${team}:`, scope, planHash).slice(0, -1))
|
|
261
|
+
ctx.stdout(line);
|
|
262
|
+
const applied = fromSdk(undo ? await (await client()).undoApply(team, hash) : await (await client()).adoptApply(team, hash));
|
|
263
|
+
if (isRefusal(applied))
|
|
264
|
+
return refusal(ctx, args, applied);
|
|
265
|
+
const outcomes = (undo ? [applied.body.archived, applied.body.kept, applied.body.failed] : [applied.body.stages, applied.body.labels, applied.body.labelsNotCreated, applied.body.provenanceGaps, applied.body.labelProvenanceGaps]).flatMap((v) => Array.isArray(v) ? v : []);
|
|
266
|
+
emit(ctx, args, { team, decision: "applied", preview: previewReply.body, result: applied.body }, [...outcomes.map((o) => ` ${JSON.stringify(o)}`), ...readinessLines(applied.body.readiness)]);
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
const choices = assignments(flagList(args, "choice"), "choice").map(([sourceStateId, destinationStateId]) => ({ sourceStateId, destinationStateId }));
|
|
270
|
+
const previewReply = fromSdk(await (await client()).migratePreview(team, choices));
|
|
271
|
+
if (isRefusal(previewReply))
|
|
272
|
+
return refusal(ctx, args, previewReply);
|
|
273
|
+
const preview = previewReply.body.preview;
|
|
274
|
+
if (!preview || typeof preview.migrationHash !== "string" || !Array.isArray(preview.sources))
|
|
275
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "migration preview omitted hash or source rows" } });
|
|
276
|
+
if (preview.actionsAvailable === false)
|
|
277
|
+
return refusal(ctx, args, { status: 409, body: { error: "migration-approval-unavailable", reason: "Catalyst cannot verify the exact tickets in this migration preview, so moves and retirement are paused. Use Linear's bulk editor to move tickets, or retry when approved migration snapshots are available." } });
|
|
278
|
+
const step = flagBool(args, "retire") ? "retire" : "migrate";
|
|
279
|
+
const planHash = approvalHash(step, { preview, choices });
|
|
280
|
+
const plan = { team, step, decision: "not-confirmed", preview, planHash };
|
|
281
|
+
const blocked = preview.overLimit || preview.sources.some((source) => source.outcome === "needs-a-choice");
|
|
282
|
+
if (!flagBool(args, "yes")) {
|
|
283
|
+
emit(ctx, args, plan, planLines(`Migration ${plan.step} plan for ${team}:`, preview.sources, planHash));
|
|
284
|
+
return 3;
|
|
285
|
+
}
|
|
286
|
+
if (flagString(args, "plan-hash") !== planHash)
|
|
287
|
+
return refusal(ctx, args, { status: 409, body: { error: "plan-hash-mismatch", reason: `Preview again and review the updated plan hash ${planHash}.` } });
|
|
288
|
+
if (blocked)
|
|
289
|
+
return refusal(ctx, args, { status: 400, body: { error: "migration-plan-blocked", reason: "Resolve the preview's blocked sources or issue limit before applying." } });
|
|
290
|
+
if (!args.json)
|
|
291
|
+
for (const line of planLines(`Migration ${plan.step} plan for ${team}:`, preview.sources, planHash).slice(0, -1))
|
|
292
|
+
ctx.stdout(line);
|
|
293
|
+
const results = [];
|
|
294
|
+
const deadline = Date.now() + MAX_RUN_MS;
|
|
295
|
+
let hash = preview.migrationHash;
|
|
296
|
+
for (let n = 0; n < (step === "retire" ? 1 : MAX_CHUNKS); n++) {
|
|
297
|
+
if (Date.now() >= deadline)
|
|
298
|
+
return refusal(ctx, args, { status: 503, body: { error: "migration-time-budget", reason: "The bounded run ended; preview again before continuing." } });
|
|
299
|
+
const result = fromSdk(step === "retire" ? await (await client()).migrateRetire(team, hash, choices) : await (await client()).migrateChunk(team, hash, choices));
|
|
300
|
+
if (isRefusal(result))
|
|
301
|
+
return refusal(ctx, args, result);
|
|
302
|
+
results.push(result.body);
|
|
303
|
+
const partial = Array.isArray(result.body.sources) && result.body.sources.some((source) => source.outcome === "partially-moved");
|
|
304
|
+
if (!args.json) {
|
|
305
|
+
ctx.stdout(`${step} chunk ${n + 1}: moved ${String(result.body.moved ?? 0)}, remaining ${String(result.body.remaining ?? 0)}`);
|
|
306
|
+
for (const source of Array.isArray(result.body.sources) ? result.body.sources : [])
|
|
307
|
+
ctx.stdout(` ${JSON.stringify(source)}`);
|
|
308
|
+
for (const [name, value] of Object.entries(result.body))
|
|
309
|
+
if (name === "failed" || name === "blockers" || name === "logGaps")
|
|
310
|
+
ctx.stdout(` ${name}: ${JSON.stringify(value)}`);
|
|
311
|
+
}
|
|
312
|
+
if (partial) {
|
|
313
|
+
emit(ctx, args, { ...plan, decision: "partial", results }, [`Migration partially completed; at least one source reported a refused or incomplete move.`, ...readinessLines(result.body.readiness)]);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
if (step === "retire" && Array.isArray(result.body.failed) && result.body.failed.length > 0) {
|
|
317
|
+
emit(ctx, args, { ...plan, decision: "partial", results }, [`Source retirement partially completed: ${JSON.stringify(result.body.failed)}`]);
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
if (step === "retire" || result.body.remaining === 0) {
|
|
321
|
+
emit(ctx, args, { ...plan, decision: "applied", results }, [...readinessLines(result.body.readiness), ...(step === "migrate" ? ["Tickets moved. Retiring source stages requires a separate team migrate --retire confirmation."] : [])]);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (typeof result.body.migrationHash !== "string")
|
|
325
|
+
return refusal(ctx, args, { status: 502, body: { error: "bad-response", reason: "migration chunk omitted the next hash" } });
|
|
326
|
+
hash = result.body.migrationHash;
|
|
327
|
+
}
|
|
328
|
+
return refusal(ctx, args, { status: 503, body: { error: "migration-chunk-limit", reason: "The bounded run ended; preview again before continuing." } });
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Customer skill bundle for Catalyst Cloud, named from the executive's seat — get set up, see what is happening, see what needs you, run a project, unstick what is stuck — plus the catalyst CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command. The skills directory is the roster; this line never counts it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -4,7 +4,7 @@ description: >-
|
|
|
4
4
|
Catalyst's GitHub: show a ticket's pull request with its checks, reviews and review threads, say whether it is mergeable under this repository's policy, and explain what a PR accumulates as the ticket moves (the branch, the draft, the rewrite, the force-pushes, the labels, the queue). Use when someone asks "show me the PR", "what are the checks saying", "why hasn't it merged", "what does the queue need", "what does that label mean", or wants the branch and merge conventions Catalyst follows.
|
|
5
5
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
6
6
|
---
|
|
7
|
-
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.
|
|
7
|
+
<!-- vendored-from: @catalyst-cloud/catalyst-skills@0.9.5 — written in this repository for customer tenants -->
|
|
8
8
|
|
|
9
9
|
# Catalyst's GitHub
|
|
10
10
|
|