@catalyst-cloud/catalyst-skills 0.3.1 → 0.4.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 +4 -0
- package/README.md +16 -6
- package/dist/args.js +1 -1
- package/dist/ask.js +1 -1
- package/dist/browser.js +20 -0
- package/dist/cli.js +46 -21
- package/dist/config.js +48 -6
- package/dist/contract.js +1 -1
- package/dist/execution.js +1 -1
- package/dist/oauth.js +300 -0
- package/dist/query.js +1 -1
- package/dist/ready.js +1 -1
- package/dist/replica.js +3 -2
- package/dist/{http.js → transport.js} +16 -8
- package/dist/watch/consumer.js +2 -2
- package/dist/watch.js +4 -2
- package/dist/write.js +1 -1
- package/package.json +2 -2
- package/skills/connect-me/SKILL.md +16 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
You can connect keyless now: run `catalyst-skills login` with no key and it opens a browser device-code login — it prints a short code and a URL, you approve in your browser (or from your phone on a machine with no browser), and this machine is connected as you, with nothing to mint or paste. The short-lived session token refreshes silently on every request and the config is rewritten atomically each time, so you stay connected for months and log in again only if the session is revoked or you have been away long enough to lapse — then one clear line tells you to run `login`. `customer.json` now holds exactly one of a login session (`auth`) or a personal key; `status` names which ("Credential: your login (expires …)" vs "Credential: personal key"). The personal key rail is unchanged and remains the fallback for scripts and unattended shells — `--key` or `CATALYST_CLOUD_TOKEN` — and keyless is the preferred rail everywhere the docs and the connect-me skill lead with it. Under the hood the auth layer is reworked around one credential provider (SDK 0.9): the replica and the live watch use the SDK's bearer auth strategy and resolve a fresh token per connect, so an OAuth session rotates underneath a long-running watch without a reconnect, and every HTTP read and write refreshes on the same path.
|
|
6
|
+
|
|
3
7
|
## 0.3.1
|
|
4
8
|
|
|
5
9
|
`ready` now names the person you connected as: when your key is a personal key, the config line reads "joined <tenant> as <you> (<role>)" instead of the generic "as service" that every api key showed, and an account (host) key still names the tenant with no person. `ready` also learns the bundle version your tenant expects, when the cloud publishes one: if this installed bundle is older than that minimum you get a one-line warning that names both versions and the upgrade command, but it stays a note — `ready` never refuses over a stale bundle, and a cloud that does not publish a minimum changes nothing. `explain` no longer calls every ticket with no dispatch row "unknown": a ticket that merely sits in Backlog (or any non-dispatch state) is now named as known to the mirror with the state it is in, and only a ticket the mirror has never seen keeps the unknown wording.
|
package/README.md
CHANGED
|
@@ -55,27 +55,37 @@ Without `--all` the installer asks which skills to take and which agents to inst
|
|
|
55
55
|
|
|
56
56
|
### Then connect to your tenant
|
|
57
57
|
|
|
58
|
-
The skills call one CLI, and the CLI holds your credential. Install it once and connect this machine
|
|
58
|
+
The skills call one CLI, and the CLI holds your credential. Install it once and connect this machine — the keyless way logs you in as yourself, with nothing to mint or paste:
|
|
59
59
|
|
|
60
60
|
```sh
|
|
61
61
|
npm install -g @catalyst-cloud/catalyst-skills
|
|
62
|
-
|
|
62
|
+
catalyst-skills login
|
|
63
63
|
catalyst-skills ready
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
`catalyst-skills login` with no key opens a device-code login: it prints a short code and a URL, you approve it in your browser, and this machine is connected as you. On a machine with no browser (a remote box, a container) the code and URL still work — approve them from your phone. The short-lived session refreshes silently afterwards, so you log in about once a year. `npx @catalyst-cloud/catalyst-skills login` works without the global install, and `bunx` works in place of `npx`. A non-default cloud is set with `CATALYST_CLOUD_BASE_URL` or `--base-url <url>`.
|
|
67
|
+
|
|
68
|
+
Prefer a key? Pass one instead — mint a **personal key** at Settings → API keys in the Catalyst Cloud app (every member can; no admin needed). The environment form keeps it out of your shell history:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`--key <your-personal-key>` is the third form, for a script.
|
|
67
75
|
|
|
68
76
|
That is the whole setup. Everything below explains what you just installed.
|
|
69
77
|
|
|
70
78
|
## What this is
|
|
71
79
|
|
|
72
|
-
Eight skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own personal key is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
|
|
80
|
+
Eight skills that let your coding agent run your own Catalyst Cloud tenant (https://staging.catalystcloud.dev) from your seat: what is happening, what needs you, and what to do about it. They read your tenant through the Catalyst Cloud SDK, write to it through the tenant's agent proxy, and never compose a URL or run a tool of their own; every read, write and subscription is a `catalyst-skills` verb with `--help`. Every skill is plain Markdown under `skills/<name>/SKILL.md` in this repository, and your own login — a keyless device-code session, or a personal key — is the only credential. Your agent acts as you: the asks it raises and the comments it posts carry your name, and "what needs me" means you.
|
|
73
81
|
|
|
74
82
|
## What connecting does
|
|
75
83
|
|
|
76
|
-
`login` does three things: it calls `GET /api/v1/me` with your
|
|
84
|
+
`login` does three things: it calls `GET /api/v1/me` with your credential, which discovers your tenant and who you are from the credential alone; it writes `~/.config/catalyst-cloud/customer.json` with file mode `0600`, holding your credential (a keyless login session, or a personal key), who you are (id, label, role, your Linear user id) and the absolute path of the CLI so every skill script can spawn the same binary; and it fetches the tenant contract (`GET /api/v1/agent/contract`) and caches it at `~/.config/catalyst-cloud/contract.json` with its ETag, so stage names and ids, label ids, the ask template, thresholds and the route table come from your tenant, live, and no skill restates them.
|
|
85
|
+
|
|
86
|
+
A keyless session's access token is short-lived and rotates on its own: every request refreshes it within a minute of expiry and rewrites the config atomically, so you stay connected for months without logging in again. You only log in again if the session is revoked or you have been away long enough to lapse — then one clear line tells you to run `login`.
|
|
77
87
|
|
|
78
|
-
`login` does not install skills; the install command above did that. Re-running `login` after a key rotation rewrites the config. `catalyst-skills status` prints which tenant this machine is connected to and as whom, `catalyst-skills ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst-skills --version` prints the package version and its pinned tenant contract range.
|
|
88
|
+
`login` does not install skills; the install command above did that. Re-running `login` after a key rotation, or to switch rails, rewrites the config. `catalyst-skills status` prints which tenant this machine is connected to and as whom, `catalyst-skills ready` prints one READY or NOT READY verdict with the fix for each failure and who can apply it, and `catalyst-skills --version` prints the package version and its pinned tenant contract range.
|
|
79
89
|
|
|
80
90
|
The setup skill Catalyst seeds into your repository ends by pointing at this same command. That served skill lives in the Catalyst Cloud application, not here; this is the only connect step a customer runs. The tenant's **account key** (Settings → Account keys, admin-minted) is a different credential — for a host or daemon that runs unattended — and is not what a person connects their own agent with: it would strip your name from everything your agent writes, and `login` says so if you use one.
|
|
81
91
|
|
package/dist/args.js
CHANGED
|
@@ -94,7 +94,7 @@ export const FLAG_TABLES = {
|
|
|
94
94
|
accounts: {},
|
|
95
95
|
};
|
|
96
96
|
export const VERB_USAGE = {
|
|
97
|
-
login: "login [--
|
|
97
|
+
login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
|
|
98
98
|
install: "install [--skills-dir <dir>] [--force]",
|
|
99
99
|
status: "status",
|
|
100
100
|
notice: "notice",
|
package/dist/ask.js
CHANGED
|
@@ -5,7 +5,7 @@ import { flagBool, flagList, flagString, positionals } from "./args.js";
|
|
|
5
5
|
import { requireConfig } from "./config.js";
|
|
6
6
|
import { loadContract, teamByKey } from "./contract.js";
|
|
7
7
|
import { UsageError } from "./errors.js";
|
|
8
|
-
import { apiClient } from "./
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
9
|
import { rowsOf } from "./query.js";
|
|
10
10
|
import { fetchWorkflowStates, postAgent, resolveIssue } from "./write.js";
|
|
11
11
|
export async function cmdAsk(args, ctx) {
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// browser.ts — open the device-flow verification page in the person's default browser. Best-effort:
|
|
2
|
+
// a machine with no browser (a headless mini, a container) simply shows the code and URL instead, so
|
|
3
|
+
// this never throws into the login flow — the caller wraps it, and a spawn failure is swallowed here.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
/** The platform's "open this URL" command. `open` on macOS, `start` via cmd on Windows, else xdg-open. */
|
|
6
|
+
export function openBrowser(url) {
|
|
7
|
+
const [cmd, args] = process.platform === "darwin"
|
|
8
|
+
? ["open", [url]]
|
|
9
|
+
: process.platform === "win32"
|
|
10
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
11
|
+
: ["xdg-open", [url]];
|
|
12
|
+
try {
|
|
13
|
+
const child = spawn(cmd, [...args], { stdio: "ignore", detached: true });
|
|
14
|
+
child.on("error", () => { }); // no opener installed — the printed URL is the fallback
|
|
15
|
+
child.unref();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// spawn itself threw (unusual) — the code and URL on screen are the fallback
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,12 +5,14 @@ import { parseArgs, positionals, verbHelp } from "./args.js";
|
|
|
5
5
|
import { CONFIG_MODE, DEFAULT_BASE_URL, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, loadConfig, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
|
|
6
6
|
import { loadContract, pickPath } from "./contract.js";
|
|
7
7
|
import { CliError, MeError, UsageError } from "./errors.js";
|
|
8
|
-
import { fetchMe } from "./
|
|
8
|
+
import { fetchMe } from "./transport.js";
|
|
9
|
+
import { bearerFor, deviceFlowLogin } from "./oauth.js";
|
|
10
|
+
import { openBrowser as defaultOpenBrowser } from "./browser.js";
|
|
9
11
|
import { cmdAccounts, cmdExplain, cmdHistory, cmdQueue, cmdRunning } from "./execution.js";
|
|
10
12
|
import { cmdQuery } from "./query.js";
|
|
11
13
|
import { cmdReady } from "./ready.js";
|
|
12
14
|
import { cmdReplica } from "./replica.js";
|
|
13
|
-
import {
|
|
15
|
+
import { stdinIsTty } from "./prompt.js";
|
|
14
16
|
import { PROVENANCE_MARKER } from "./skill-shape.js";
|
|
15
17
|
import { installSkills, parseChangelogEntry, readChangelog, resolveSkillsDir, skillsSourceDir, updateNoticeLine } from "./skills.js";
|
|
16
18
|
import { cmdWatch } from "./watch.js";
|
|
@@ -37,7 +39,8 @@ export function usageText() {
|
|
|
37
39
|
`${PACKAGE_NAME} — the Catalyst Cloud customer CLI: connect to your tenant, read through the SDK, write through the agent proxy`,
|
|
38
40
|
"",
|
|
39
41
|
"Usage:",
|
|
40
|
-
" catalyst-skills login [--
|
|
42
|
+
" catalyst-skills login [--base-url <url>] [--start-replica] (keyless: logs you in as yourself)",
|
|
43
|
+
" catalyst-skills login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
|
|
41
44
|
" catalyst-skills join ... (deprecated alias of login; removed in the next minor version)",
|
|
42
45
|
" catalyst-skills install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
|
|
43
46
|
" catalyst-skills status | notice | me | ready | accounts",
|
|
@@ -51,14 +54,16 @@ export function usageText() {
|
|
|
51
54
|
"",
|
|
52
55
|
"Every verb takes --help. --json makes the output machine-readable.",
|
|
53
56
|
"",
|
|
54
|
-
"
|
|
55
|
-
|
|
57
|
+
"With no key, login is keyless: it opens a WorkOS device-code flow, prints a short code and a URL,",
|
|
58
|
+
"and connects you as yourself once you approve in the browser — nothing to mint or paste. Pass",
|
|
59
|
+
"--key (or set CATALYST_CLOUD_TOKEN) to use a personal or account key instead. The base URL defaults",
|
|
60
|
+
`to CATALYST_CLOUD_BASE_URL or ${DEFAULT_BASE_URL}.`,
|
|
56
61
|
"",
|
|
57
|
-
"login
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
+
"login discovers your tenant from GET /api/v1/me, writes ~/.config/catalyst-cloud/customer.json",
|
|
63
|
+
"(0600) holding your login session (or key) and this CLI's path, and caches the tenant contract",
|
|
64
|
+
"beside it. A keyless session's short-lived token refreshes silently on every request. login does",
|
|
65
|
+
"not install skills: your agent's own install command does that, and `install` is only here to",
|
|
66
|
+
"repair a copy this package made.",
|
|
62
67
|
].join("\n");
|
|
63
68
|
}
|
|
64
69
|
/**
|
|
@@ -190,14 +195,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
190
195
|
const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts"].map((v) => [v, true]));
|
|
191
196
|
async function cmdLogin(args, ctx, deps) {
|
|
192
197
|
const manifest = readManifest();
|
|
193
|
-
|
|
194
|
-
if (!key && (deps.isTty ?? stdinIsTty)()) {
|
|
195
|
-
key = (await (deps.promptSecret ?? promptSecret)("Personal key (not echoed): ")).trim();
|
|
196
|
-
}
|
|
197
|
-
if (!key)
|
|
198
|
-
throw new UsageError("login needs your personal key: pass --key <personal-key> or set CATALYST_CLOUD_TOKEN");
|
|
198
|
+
const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
|
|
199
199
|
const baseUrl = normalizeBaseUrl(args.baseUrl ?? ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL);
|
|
200
|
-
|
|
200
|
+
// Keyless is the preferred rail: with no key in flag or env, run the WorkOS device flow and log in
|
|
201
|
+
// as the person. A key (flag or CATALYST_CLOUD_TOKEN) still takes the token rail unchanged.
|
|
202
|
+
let auth;
|
|
203
|
+
const bearer = key
|
|
204
|
+
? key
|
|
205
|
+
: (auth = await deviceFlowLogin(ctx, baseUrl, {
|
|
206
|
+
isTty: deps.isTty ?? stdinIsTty,
|
|
207
|
+
openBrowser: deps.openBrowser ?? defaultOpenBrowser,
|
|
208
|
+
sleep: deps.sleep,
|
|
209
|
+
})).accessToken;
|
|
210
|
+
const me = await fetchMe(baseUrl, bearer, ctx.fetch);
|
|
201
211
|
let existing;
|
|
202
212
|
try {
|
|
203
213
|
existing = loadConfig(ctx.home);
|
|
@@ -209,7 +219,8 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
209
219
|
const skillsDir = resolveSkillsDir(args, ctx, existing);
|
|
210
220
|
const config = {
|
|
211
221
|
baseUrl,
|
|
212
|
-
key,
|
|
222
|
+
...(key ? { key } : {}),
|
|
223
|
+
...(auth ? { auth } : {}),
|
|
213
224
|
account: me.account,
|
|
214
225
|
slug: me.slug,
|
|
215
226
|
name: me.name,
|
|
@@ -230,7 +241,7 @@ async function cmdLogin(args, ctx, deps) {
|
|
|
230
241
|
else {
|
|
231
242
|
ctx.stderr(`[catalyst-skills] this is the tenant's account key (a host credential), not your own — the skills work, but nothing your agent writes will carry your name and "what needs me" cannot mean you. Mint a personal key at Settings → API keys and log in with that.`);
|
|
232
243
|
}
|
|
233
|
-
ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your key and the CLI path)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
|
|
244
|
+
ctx.stdout(`Config written to ${written.path} (mode ${formatMode(written.mode)}, holds your ${auth ? "login session" : "key"} and the CLI path)${written.mode === CONFIG_MODE ? "" : ` — expected ${formatMode(CONFIG_MODE)}; chmod it by hand`}`);
|
|
234
245
|
ctx.stdout(`Tenant contract range: ${manifest.tenantContractRange}`);
|
|
235
246
|
try {
|
|
236
247
|
const loaded = await loadContract(ctx, config, { refresh: true });
|
|
@@ -280,21 +291,35 @@ function cmdStatus(ctx) {
|
|
|
280
291
|
const manifest = readManifest();
|
|
281
292
|
const cfg = loadConfig(ctx.home);
|
|
282
293
|
if (!cfg) {
|
|
283
|
-
ctx.stdout(`Not connected yet — run:
|
|
294
|
+
ctx.stdout(`Not connected yet — run: npx ${PACKAGE_NAME} login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)`);
|
|
284
295
|
return 0;
|
|
285
296
|
}
|
|
286
297
|
ctx.stdout(`Tenant: ${cfg.name} (${cfg.slug}) — account ${cfg.account}`);
|
|
287
298
|
ctx.stdout(`API: ${cfg.baseUrl} (principal: ${cfg.principal})`);
|
|
288
299
|
ctx.stdout(cfg.user ? `As: ${cfg.user.label} (${cfg.user.role})` : "As: the tenant's account key (a host credential — no person)");
|
|
300
|
+
ctx.stdout(cfg.auth ? `Credential: your login (expires ${relativeExpiry(cfg.auth.expiresAt, ctx.now())})` : "Credential: personal key");
|
|
289
301
|
ctx.stdout(`Bundle: ${PACKAGE_NAME} ${manifest.version} (tenant contract range: ${manifest.tenantContractRange})`);
|
|
290
302
|
if (cfg.cliPath)
|
|
291
303
|
ctx.stdout(`CLI: ${cfg.cliPath}${existsSync(cfg.cliPath) ? "" : " (missing — re-run login)"}`);
|
|
292
304
|
ctx.stdout(`Contract: ${existsSync(contractPathFor(ctx.home)) ? contractPathFor(ctx.home) : "not cached (run: catalyst-skills contract --refresh)"}`);
|
|
293
305
|
return 0;
|
|
294
306
|
}
|
|
307
|
+
/** A short human relative time for an ISO expiry, e.g. "in 14m", "in 2h", "in 6d", or "now" once past. */
|
|
308
|
+
function relativeExpiry(iso, now) {
|
|
309
|
+
const ms = Date.parse(iso) - now.getTime();
|
|
310
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
311
|
+
return "now";
|
|
312
|
+
const mins = Math.round(ms / 60_000);
|
|
313
|
+
if (mins < 60)
|
|
314
|
+
return `in ${mins}m`;
|
|
315
|
+
const hours = Math.round(mins / 60);
|
|
316
|
+
if (hours < 48)
|
|
317
|
+
return `in ${hours}h`;
|
|
318
|
+
return `in ${Math.round(hours / 24)}d`;
|
|
319
|
+
}
|
|
295
320
|
async function cmdMe(args, ctx) {
|
|
296
321
|
const cfg = requireConfig(ctx);
|
|
297
|
-
const me = await fetchMe(cfg.baseUrl, cfg
|
|
322
|
+
const me = await fetchMe(cfg.baseUrl, await bearerFor(ctx, cfg), ctx.fetch);
|
|
298
323
|
if (args.json)
|
|
299
324
|
ctx.stdout(JSON.stringify(me));
|
|
300
325
|
else {
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// config.ts — ~/.config/catalyst-cloud/customer.json and its siblings. The ONLY place the base URL
|
|
2
2
|
// and the `/api/v1` prefix are joined: the SDK wants the base with the prefix, GET /me without.
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { CliError } from "./errors.js";
|
|
@@ -29,6 +30,10 @@ export function contractPathFor(home) {
|
|
|
29
30
|
export function watchCursorPathFor(home) {
|
|
30
31
|
return join(configDirFor(home), "watch-cursor.json");
|
|
31
32
|
}
|
|
33
|
+
/** CTC-2112 — where the cached CLI-auth discovery document lives (the `/api/v1/auth/cli` answer). */
|
|
34
|
+
export function discoveryCachePathFor(home) {
|
|
35
|
+
return join(configDirFor(home), "auth-discovery.json");
|
|
36
|
+
}
|
|
32
37
|
export function defaultReplicaDbFor(home) {
|
|
33
38
|
return join(configDirFor(home), "replica.db");
|
|
34
39
|
}
|
|
@@ -61,28 +66,65 @@ export function loadConfig(home) {
|
|
|
61
66
|
throw new CliError(`config at ${path} is not valid JSON — re-run login to rewrite it`, "config-corrupt");
|
|
62
67
|
}
|
|
63
68
|
const cfg = parsed;
|
|
64
|
-
if (typeof cfg.account !== "string" || typeof cfg.
|
|
69
|
+
if (typeof cfg.account !== "string" || typeof cfg.baseUrl !== "string") {
|
|
65
70
|
throw new CliError(`config at ${path} is missing required fields — re-run login to rewrite it`, "config-corrupt");
|
|
66
71
|
}
|
|
72
|
+
const hasKey = typeof cfg.key === "string" && cfg.key !== "";
|
|
73
|
+
const hasOauth = isOauthAuth(cfg.auth);
|
|
74
|
+
if (hasKey === hasOauth) {
|
|
75
|
+
throw new CliError(`config at ${path} must hold exactly one of a personal key or an OAuth session — re-run login to rewrite it`, "config-corrupt");
|
|
76
|
+
}
|
|
67
77
|
return cfg;
|
|
68
78
|
}
|
|
69
79
|
/** Load the config or refuse with the one line every verb prints when the machine is not connected. */
|
|
70
80
|
export function requireConfig(ctx) {
|
|
71
81
|
const cfg = loadConfig(ctx.home);
|
|
72
82
|
if (!cfg) {
|
|
73
|
-
throw new CliError(`not connected yet — run:
|
|
83
|
+
throw new CliError(`not connected yet — run: npx ${PACKAGE_NAME} login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN for a key)`, "not-configured");
|
|
74
84
|
}
|
|
75
85
|
return cfg;
|
|
76
86
|
}
|
|
77
87
|
export function saveConfig(home, cfg) {
|
|
78
88
|
return writeConfig(home, cfg).path;
|
|
79
89
|
}
|
|
80
|
-
/**
|
|
81
|
-
|
|
90
|
+
/** True when `v` is a well-formed OAuth session block. */
|
|
91
|
+
export function isOauthAuth(v) {
|
|
92
|
+
if (typeof v !== "object" || v === null)
|
|
93
|
+
return false;
|
|
94
|
+
const a = v;
|
|
95
|
+
return (a.kind === "oauth" &&
|
|
96
|
+
typeof a.accessToken === "string" &&
|
|
97
|
+
typeof a.refreshToken === "string" &&
|
|
98
|
+
typeof a.expiresAt === "string" &&
|
|
99
|
+
typeof a.sessionId === "string");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Write the config atomically (a rotated token must never be half-written under a crash) and return
|
|
103
|
+
* the mode the file ACTUALLY carries afterwards. Write to a sibling `.tmp` at 0600, then rename over
|
|
104
|
+
* the target — rename is atomic within a directory — and chmod once more (a pre-existing target keeps
|
|
105
|
+
* its own mode through the rename on some platforms).
|
|
106
|
+
*/
|
|
82
107
|
export function writeConfig(home, cfg) {
|
|
83
108
|
const path = configPathFor(home);
|
|
84
109
|
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
-
|
|
110
|
+
// A UNIQUE sibling temp per write (pid + randomness): two concurrent CLI processes reaching the
|
|
111
|
+
// OAuth refresh window must not share `customer.json.tmp`, or one renames/removes it out from under
|
|
112
|
+
// the other and the second write fails with ENOENT or lands the wrong contents (Codex P2).
|
|
113
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: CONFIG_MODE });
|
|
116
|
+
chmodSync(tmp, CONFIG_MODE);
|
|
117
|
+
renameSync(tmp, path);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
try {
|
|
121
|
+
rmSync(tmp, { force: true });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// best-effort cleanup of the temp file
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
86
128
|
chmodSync(path, CONFIG_MODE);
|
|
87
129
|
return { path, mode: statSync(path).mode & 0o777 };
|
|
88
130
|
}
|
package/dist/contract.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
import { contractPathFor, readManifest } from "./config.js";
|
|
7
7
|
import { CliError, MeError } from "./errors.js";
|
|
8
|
-
import { apiClient } from "./
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
9
|
export const CONTRACT_ROUTE = "/api/v1/agent/contract";
|
|
10
10
|
export function readContractCache(home) {
|
|
11
11
|
const path = contractPathFor(home);
|
package/dist/execution.js
CHANGED
|
@@ -5,7 +5,7 @@ import { flagBool, flagString, positionals } from "./args.js";
|
|
|
5
5
|
import { normalizeBaseUrl, requireConfig } from "./config.js";
|
|
6
6
|
import { loadContract } from "./contract.js";
|
|
7
7
|
import { CliError, UsageError } from "./errors.js";
|
|
8
|
-
import { apiClient } from "./
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
9
|
/** Every exclusion reason the eligibility evaluator names, in plain English. */
|
|
10
10
|
export const EXCLUSION_REASONS = {
|
|
11
11
|
ticket_terminal: "the ticket is in a done or canceled state, so there is nothing left to run",
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { discoveryCachePathFor, loadConfig, normalizeBaseUrl, writeConfig } from "./config.js";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
const DISCOVERY_TTL_MS = 60 * 60_000;
|
|
5
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
7
|
+
const SLOW_DOWN_BUMP_MS = 5_000;
|
|
8
|
+
const MAX_POLL_BACKOFF_MS = 30_000;
|
|
9
|
+
const REFRESH_MAX_ATTEMPTS = 4;
|
|
10
|
+
const REFRESH_BASE_BACKOFF_MS = 500;
|
|
11
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
|
+
let memo = null;
|
|
13
|
+
/** Test seam: forget the cached discovery document. */
|
|
14
|
+
export function resetDiscoveryCache() {
|
|
15
|
+
memo = null;
|
|
16
|
+
}
|
|
17
|
+
function readDiscoveryDisk(home) {
|
|
18
|
+
const path = discoveryCachePathFor(home);
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
23
|
+
if (isDiscovery(parsed.doc) && typeof parsed.fetchedAt === "number" && typeof parsed.baseUrl === "string")
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// a corrupt cache is just a cache miss
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function isDiscovery(v) {
|
|
32
|
+
if (typeof v !== "object" || v === null)
|
|
33
|
+
return false;
|
|
34
|
+
const d = v;
|
|
35
|
+
return ["clientId", "issuer", "deviceAuthorizationUrl", "tokenUrl", "jwksUrl"].every((k) => typeof d[k] === "string");
|
|
36
|
+
}
|
|
37
|
+
export async function fetchDiscovery(ctx, baseUrl, opts = {}) {
|
|
38
|
+
const base = normalizeBaseUrl(baseUrl);
|
|
39
|
+
const nowMs = ctx.now().getTime();
|
|
40
|
+
const fresh = (e) => e !== null && e.baseUrl === base && nowMs - e.fetchedAt < DISCOVERY_TTL_MS;
|
|
41
|
+
if (!opts.refresh) {
|
|
42
|
+
if (fresh(memo))
|
|
43
|
+
return memo.doc;
|
|
44
|
+
const disk = readDiscoveryDisk(ctx.home);
|
|
45
|
+
if (fresh(disk)) {
|
|
46
|
+
memo = disk;
|
|
47
|
+
return disk.doc;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const url = `${base}/api/v1/auth/cli`;
|
|
51
|
+
const res = await safeFetch(ctx, url, { headers: { accept: "application/json" } });
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new CliError(`could not read the login discovery document (${res.status}) at ${url} — is this a Catalyst Cloud origin?`, "discovery-failed");
|
|
54
|
+
}
|
|
55
|
+
const doc = (await parseJson(res, url));
|
|
56
|
+
if (!isDiscovery(doc))
|
|
57
|
+
throw new CliError(`the login discovery document at ${url} is not the expected shape`, "discovery-shape");
|
|
58
|
+
const entry = { baseUrl: base, fetchedAt: nowMs, doc };
|
|
59
|
+
memo = entry;
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(discoveryCachePathFor(ctx.home), JSON.stringify(entry, null, 2) + "\n");
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// the disk cache is an optimisation; the in-memory memo is enough
|
|
65
|
+
}
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run the device-authorization grant to completion and return the stored session block. Prints the
|
|
70
|
+
* user code and where to enter it; on a TTY it also opens the completion URL. Polls at the server's
|
|
71
|
+
* `interval`, honours `slow_down`, and is bounded by `AbortSignal.timeout(expires_in)` so it can
|
|
72
|
+
* never poll past the device code's own lifetime.
|
|
73
|
+
*/
|
|
74
|
+
export async function deviceFlowLogin(ctx, baseUrl, deps = {}) {
|
|
75
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
76
|
+
const discovery = await fetchDiscovery(ctx, baseUrl);
|
|
77
|
+
const auth = await deviceAuthorize(ctx, discovery);
|
|
78
|
+
ctx.stdout("");
|
|
79
|
+
ctx.stdout(`To connect this machine, visit: ${auth.verification_uri}`);
|
|
80
|
+
ctx.stdout(`and enter the code: ${auth.user_code}`);
|
|
81
|
+
if ((deps.isTty ?? (() => false))()) {
|
|
82
|
+
try {
|
|
83
|
+
(deps.openBrowser ?? (() => { }))(auth.verification_uri_complete);
|
|
84
|
+
ctx.stdout("Opened your browser to that page — approve there, or use the code above.");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// a browser that will not open is not a failure; the code and URL still work
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
ctx.stdout("Waiting for you to approve… (Ctrl-C to cancel)");
|
|
91
|
+
// Terminal states print ONE clear, actionable line and exit 2 (CliError's default). The duration
|
|
92
|
+
// comes from the device code's own `expires_in`, so it is right whatever the server set.
|
|
93
|
+
const mins = Math.round(auth.expires_in / 60);
|
|
94
|
+
const expired = () => new CliError(`the login code expired${mins >= 1 ? ` after ${mins} minute${mins === 1 ? "" : "s"}` : ""} — run: catalyst-skills login again`, "login-expired");
|
|
95
|
+
const deadline = AbortSignal.timeout(auth.expires_in * 1_000);
|
|
96
|
+
let intervalMs = Math.max(1, auth.interval) * 1_000;
|
|
97
|
+
for (;;) {
|
|
98
|
+
await sleep(intervalMs);
|
|
99
|
+
// The device code's own lifetime bounds the loop: once it lapses, say so plainly rather than
|
|
100
|
+
// letting the next fetch's aborted signal surface as a generic network error (Codex P2).
|
|
101
|
+
if (deadline.aborted)
|
|
102
|
+
throw expired();
|
|
103
|
+
let res;
|
|
104
|
+
try {
|
|
105
|
+
res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: auth.device_code, client_id: discovery.clientId }) }, deadline);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// The deadline firing mid-fetch is an expiry, not a failure; anything else is a transient
|
|
109
|
+
// network blip — retry with backoff until the deadline, never abandon the login (CTC-2112 P1).
|
|
110
|
+
if (deadline.aborted)
|
|
111
|
+
throw expired();
|
|
112
|
+
intervalMs = backoff(intervalMs);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (res.ok) {
|
|
116
|
+
const pair = (await parseJson(res, discovery.tokenUrl));
|
|
117
|
+
if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string") {
|
|
118
|
+
throw new CliError("the login token response was missing a token", "login-shape");
|
|
119
|
+
}
|
|
120
|
+
return tokensToAuth(ctx, pair.access_token, pair.refresh_token);
|
|
121
|
+
}
|
|
122
|
+
const err = await oauthError(res, discovery.tokenUrl);
|
|
123
|
+
if (err === "authorization_pending")
|
|
124
|
+
continue;
|
|
125
|
+
if (err === "slow_down") {
|
|
126
|
+
intervalMs += SLOW_DOWN_BUMP_MS;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (err === "access_denied")
|
|
130
|
+
throw new CliError("login was denied — run: catalyst-skills login to try again", "login-denied");
|
|
131
|
+
if (err === "expired_token")
|
|
132
|
+
throw expired();
|
|
133
|
+
// A transient HTTP failure (request timeout, rate limit, server error) is retried, honouring
|
|
134
|
+
// Retry-After for a 429; only a genuine, non-transient refusal ends the loop.
|
|
135
|
+
if (res.status === 408 || res.status === 429 || res.status >= 500) {
|
|
136
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
137
|
+
intervalMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : backoff(intervalMs);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw new CliError(`login failed (${res.status}): ${err}`, "login-failed");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Grow the poll interval on a transient failure, capped, so retries never hammer the endpoint. */
|
|
144
|
+
function backoff(intervalMs) {
|
|
145
|
+
return Math.min(intervalMs * 2, MAX_POLL_BACKOFF_MS);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Persist a rotated token pair WITHOUT clobbering a newer login (Codex P1). A long-running watch or
|
|
149
|
+
* detached replica holds the `cfg` it started with; if the person meanwhile re-ran `login` (a new
|
|
150
|
+
* session, a different tenant, or the key rail), that config is already on disk and must survive. So
|
|
151
|
+
* we reload disk and only write when it is still the same session we just refreshed — or when there
|
|
152
|
+
* is nothing on disk yet. The reloaded doc is written (not our in-memory `cfg`), so its tenant, user
|
|
153
|
+
* and CLI-path fields are preserved and only the auth block advances.
|
|
154
|
+
*/
|
|
155
|
+
function persistRotation(ctx, cfg, sessionId, rotated) {
|
|
156
|
+
let disk;
|
|
157
|
+
try {
|
|
158
|
+
disk = loadConfig(ctx.home);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return; // a corrupt/unreadable disk config: don't overwrite what we can't understand
|
|
162
|
+
}
|
|
163
|
+
if (disk && disk.auth?.sessionId !== sessionId)
|
|
164
|
+
return; // a newer login (or a switch to a key) — leave it
|
|
165
|
+
const target = disk ?? cfg;
|
|
166
|
+
target.auth = rotated;
|
|
167
|
+
writeConfig(ctx.home, target); // atomic; the rotated pair survives a crash
|
|
168
|
+
}
|
|
169
|
+
async function deviceAuthorize(ctx, discovery) {
|
|
170
|
+
const res = await safeFetch(ctx, discovery.deviceAuthorizationUrl, { method: "POST", headers: formHeaders(), body: form({ client_id: discovery.clientId }) });
|
|
171
|
+
if (!res.ok)
|
|
172
|
+
throw new CliError(`could not start login (${res.status}) at ${discovery.deviceAuthorizationUrl}`, "login-failed");
|
|
173
|
+
const d = (await parseJson(res, discovery.deviceAuthorizationUrl));
|
|
174
|
+
if (typeof d.device_code !== "string" || typeof d.user_code !== "string" || typeof d.verification_uri !== "string") {
|
|
175
|
+
throw new CliError("the login authorization response was not the expected shape", "login-shape");
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
device_code: d.device_code,
|
|
179
|
+
user_code: d.user_code,
|
|
180
|
+
verification_uri: d.verification_uri,
|
|
181
|
+
verification_uri_complete: d.verification_uri_complete ?? d.verification_uri,
|
|
182
|
+
expires_in: typeof d.expires_in === "number" ? d.expires_in : 300,
|
|
183
|
+
interval: typeof d.interval === "number" ? d.interval : 5,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// ── the silent refresh ─────────────────────────────────────────────────────────────────────────
|
|
187
|
+
const inFlight = new Map();
|
|
188
|
+
/**
|
|
189
|
+
* The current bearer for `cfg`: the personal key verbatim, or (for an OAuth session) the access
|
|
190
|
+
* token, refreshed in place when it is within 60s of expiry. Concurrent callers that both hit the
|
|
191
|
+
* refresh window share one refresh (single-flight, keyed by the config's home) so the rotating
|
|
192
|
+
* refresh token is spent exactly once.
|
|
193
|
+
*/
|
|
194
|
+
export async function bearerFor(ctx, cfg, deps = {}) {
|
|
195
|
+
if (typeof cfg.key === "string" && cfg.key !== "")
|
|
196
|
+
return cfg.key;
|
|
197
|
+
if (!cfg.auth)
|
|
198
|
+
throw new CliError("this machine is not connected — run: catalyst-skills login", "not-configured");
|
|
199
|
+
const msToExpiry = Date.parse(cfg.auth.expiresAt) - ctx.now().getTime();
|
|
200
|
+
if (Number.isFinite(msToExpiry) && msToExpiry > REFRESH_SKEW_MS)
|
|
201
|
+
return cfg.auth.accessToken;
|
|
202
|
+
const pending = inFlight.get(ctx.home);
|
|
203
|
+
if (pending) {
|
|
204
|
+
const rotated = await pending;
|
|
205
|
+
return rotated.accessToken;
|
|
206
|
+
}
|
|
207
|
+
const promise = refreshAndPersist(ctx, cfg, deps).finally(() => inFlight.delete(ctx.home));
|
|
208
|
+
inFlight.set(ctx.home, promise);
|
|
209
|
+
const rotated = await promise;
|
|
210
|
+
return rotated.accessToken;
|
|
211
|
+
}
|
|
212
|
+
async function refreshAndPersist(ctx, cfg, deps) {
|
|
213
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
214
|
+
const discovery = await fetchDiscovery(ctx, cfg.baseUrl);
|
|
215
|
+
const current = cfg.auth;
|
|
216
|
+
let lastStatus = 0;
|
|
217
|
+
for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
|
|
218
|
+
if (attempt > 0)
|
|
219
|
+
await sleep(REFRESH_BASE_BACKOFF_MS * 2 ** (attempt - 1));
|
|
220
|
+
const res = await safeFetch(ctx, discovery.tokenUrl, { method: "POST", headers: formHeaders(), body: form({ grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: discovery.clientId }) });
|
|
221
|
+
if (res.ok) {
|
|
222
|
+
const pair = (await parseJson(res, discovery.tokenUrl));
|
|
223
|
+
if (typeof pair.access_token !== "string" || typeof pair.refresh_token !== "string")
|
|
224
|
+
throw new CliError("the refresh response was missing a token", "session-refresh-shape");
|
|
225
|
+
const rotated = tokensToAuth(ctx, pair.access_token, pair.refresh_token, current.sessionId);
|
|
226
|
+
cfg.auth = rotated; // this process keeps using its own refreshed token
|
|
227
|
+
persistRotation(ctx, cfg, current.sessionId, rotated);
|
|
228
|
+
return rotated;
|
|
229
|
+
}
|
|
230
|
+
lastStatus = res.status;
|
|
231
|
+
const err = await oauthError(res, discovery.tokenUrl);
|
|
232
|
+
if (err === "invalid_grant") {
|
|
233
|
+
// revocation or > inactivity window — the ONLY session-expired case; NOT routine expiry.
|
|
234
|
+
throw new CliError("your login expired or was revoked — run: catalyst-skills login", "session-expired");
|
|
235
|
+
}
|
|
236
|
+
const transient = res.status === 429 || res.status >= 500;
|
|
237
|
+
if (!transient)
|
|
238
|
+
throw new CliError(`could not refresh your login (${res.status}): ${err}`, "session-refresh-failed");
|
|
239
|
+
// transient: fall through to backoff + retry, keeping the stored tokens untouched
|
|
240
|
+
}
|
|
241
|
+
throw new CliError(`could not refresh your login after ${REFRESH_MAX_ATTEMPTS} attempts (last status ${lastStatus}) — your tokens are unchanged; try again`, "session-refresh-failed");
|
|
242
|
+
}
|
|
243
|
+
/** The SDK auth strategy for `cfg`: a static token for the key rail, a fresh-per-connect bearer for
|
|
244
|
+
* the OAuth rail (its `getToken` runs the same refresh path as every HTTP request). */
|
|
245
|
+
export function authStrategyFor(ctx, cfg) {
|
|
246
|
+
if (typeof cfg.key === "string" && cfg.key !== "")
|
|
247
|
+
return { kind: "token", token: cfg.key };
|
|
248
|
+
return { kind: "bearer", getToken: () => bearerFor(ctx, cfg) };
|
|
249
|
+
}
|
|
250
|
+
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
function tokensToAuth(ctx, accessToken, refreshToken, fallbackSid) {
|
|
252
|
+
const claims = decodeJwtClaims(accessToken);
|
|
253
|
+
const exp = typeof claims.exp === "number" ? claims.exp * 1_000 : ctx.now().getTime() + 15 * 60_000;
|
|
254
|
+
const sid = typeof claims.sid === "string" ? claims.sid : (fallbackSid ?? "");
|
|
255
|
+
return { kind: "oauth", accessToken, refreshToken, expiresAt: new Date(exp).toISOString(), sessionId: sid };
|
|
256
|
+
}
|
|
257
|
+
function decodeJwtClaims(token) {
|
|
258
|
+
const parts = token.split(".");
|
|
259
|
+
if (parts.length < 2)
|
|
260
|
+
return {};
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function formHeaders() {
|
|
269
|
+
return { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
270
|
+
}
|
|
271
|
+
function form(fields) {
|
|
272
|
+
return new URLSearchParams(fields).toString();
|
|
273
|
+
}
|
|
274
|
+
async function safeFetch(ctx, url, init, signal) {
|
|
275
|
+
const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
276
|
+
try {
|
|
277
|
+
return await ctx.fetch(url, { ...init, signal: sig });
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
throw new CliError(`could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`, "network");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function parseJson(res, url) {
|
|
284
|
+
try {
|
|
285
|
+
return await res.json();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
throw new CliError(`${url} returned a non-JSON body`, "shape");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** The OAuth `error` code from a 4xx body, or a short reason when there is none. */
|
|
292
|
+
async function oauthError(res, url) {
|
|
293
|
+
try {
|
|
294
|
+
const body = (await res.json());
|
|
295
|
+
return body.error ?? body.error_description ?? `HTTP ${res.status}`;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return `HTTP ${res.status}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
package/dist/query.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { flagInt, flagString, positionals } from "./args.js";
|
|
6
6
|
import { requireConfig, replicaDbPath } from "./config.js";
|
|
7
7
|
import { CliError, UsageError } from "./errors.js";
|
|
8
|
-
import { apiClient } from "./
|
|
8
|
+
import { apiClient } from "./transport.js";
|
|
9
9
|
import { engineFor, replicaStatus } from "./replica.js";
|
|
10
10
|
import { loadSdk } from "./sdk.js";
|
|
11
11
|
const REPLICA_CAPABLE = new Set(["issues", "issue", "pulls", "projects"]);
|
package/dist/ready.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function readyReport(ctx, deps) {
|
|
|
48
48
|
? `config: joined ${cfg.name} as ${cfg.user.label} (${cfg.user.role})`
|
|
49
49
|
: `config: joined ${cfg.name} (${cfg.slug}) as ${cfg.principal}`,
|
|
50
50
|
}
|
|
51
|
-
: { id: "config", ok: false, line: "config: not connected", fix: "
|
|
51
|
+
: { id: "config", ok: false, line: "config: not connected", fix: "npx @catalyst-cloud/catalyst-skills login (keyless; or pass --key / set CATALYST_CLOUD_TOKEN)", who: "you (approve the login in your browser)" });
|
|
52
52
|
}
|
|
53
53
|
catch (err) {
|
|
54
54
|
checks.push({ id: "config", ok: false, line: `config: ${err instanceof CliError ? err.message : String(err)}`, fix: "re-run login to rewrite it", who: "you" });
|
package/dist/replica.js
CHANGED
|
@@ -10,7 +10,8 @@ import { flagBool, flagInt, flagString, positionals } from "./args.js";
|
|
|
10
10
|
import { apiBase, loadConfig, replicaDbPath } from "./config.js";
|
|
11
11
|
import { detachSelf } from "./detach.js";
|
|
12
12
|
import { CliError, UsageError } from "./errors.js";
|
|
13
|
-
import { apiClient } from "./
|
|
13
|
+
import { apiClient } from "./transport.js";
|
|
14
|
+
import { authStrategyFor } from "./oauth.js";
|
|
14
15
|
import { loadSdk } from "./sdk.js";
|
|
15
16
|
export const DEFAULT_STALE_MS = 15_000;
|
|
16
17
|
export function pidfilePath(dbPath) {
|
|
@@ -204,7 +205,7 @@ async function cmdStart(args, ctx, cfg, dbPath, deps) {
|
|
|
204
205
|
baseUrl: apiBase(cfg),
|
|
205
206
|
account: cfg.account,
|
|
206
207
|
accountSource: "declared",
|
|
207
|
-
auth:
|
|
208
|
+
auth: authStrategyFor(ctx, cfg),
|
|
208
209
|
dbPath,
|
|
209
210
|
engine,
|
|
210
211
|
fetchImpl: ctx.fetch,
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { normalizeBaseUrl } from "./config.js";
|
|
2
2
|
import { CliError, MeError } from "./errors.js";
|
|
3
|
+
import { bearerFor } from "./oauth.js";
|
|
3
4
|
export const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
5
|
export class ApiClient {
|
|
5
|
-
|
|
6
|
+
baseUrl;
|
|
7
|
+
credential;
|
|
6
8
|
fetchImpl;
|
|
7
|
-
constructor(
|
|
8
|
-
|
|
9
|
+
constructor(baseUrl,
|
|
10
|
+
/** Resolves the current bearer FRESH per request — a personal key verbatim, or the OAuth access
|
|
11
|
+
* token refreshed within 60s of expiry (CTC-2112). Never captured once. */
|
|
12
|
+
credential, fetchImpl) {
|
|
13
|
+
this.baseUrl = baseUrl;
|
|
14
|
+
this.credential = credential;
|
|
9
15
|
this.fetchImpl = fetchImpl;
|
|
10
16
|
}
|
|
11
17
|
/** `path` is absolute under the origin (e.g. `/api/v1/issues`). */
|
|
12
18
|
url(path, query) {
|
|
13
|
-
const u = new URL(`${normalizeBaseUrl(this.
|
|
19
|
+
const u = new URL(`${normalizeBaseUrl(this.baseUrl)}${path}`);
|
|
14
20
|
for (const [k, v] of Object.entries(query ?? {}))
|
|
15
21
|
if (v !== undefined)
|
|
16
22
|
u.searchParams.set(k, String(v));
|
|
@@ -18,7 +24,7 @@ export class ApiClient {
|
|
|
18
24
|
}
|
|
19
25
|
async getJson(path, opts = {}) {
|
|
20
26
|
const headers = {
|
|
21
|
-
authorization: `Bearer ${this.
|
|
27
|
+
authorization: `Bearer ${await this.credential()}`,
|
|
22
28
|
accept: "application/json",
|
|
23
29
|
};
|
|
24
30
|
if (opts.etag)
|
|
@@ -44,7 +50,7 @@ export class ApiClient {
|
|
|
44
50
|
const url = this.url(path, opts.query);
|
|
45
51
|
const res = await this.send(url, {
|
|
46
52
|
method: "GET",
|
|
47
|
-
headers: { authorization: `Bearer ${this.
|
|
53
|
+
headers: { authorization: `Bearer ${await this.credential()}`, accept: "application/x-ndjson, application/json" },
|
|
48
54
|
});
|
|
49
55
|
if (opts.accept?.includes(res.status))
|
|
50
56
|
return { status: res.status, body: [], headers: res.headers };
|
|
@@ -69,7 +75,7 @@ export class ApiClient {
|
|
|
69
75
|
const res = await this.send(url, {
|
|
70
76
|
method: "POST",
|
|
71
77
|
headers: {
|
|
72
|
-
authorization: `Bearer ${this.
|
|
78
|
+
authorization: `Bearer ${await this.credential()}`,
|
|
73
79
|
accept: "application/json",
|
|
74
80
|
"content-type": "application/json",
|
|
75
81
|
},
|
|
@@ -121,8 +127,10 @@ export class ApiClient {
|
|
|
121
127
|
throw new CliError(`${what} failed (${res.status})${reason ? `: ${reason}` : ""}`, "http", 2, res.status);
|
|
122
128
|
}
|
|
123
129
|
}
|
|
130
|
+
/** Build the authenticated client for a config: the bearer resolves through {@link bearerFor}, so a
|
|
131
|
+
* key config sends the key and an OAuth config sends a freshly-refreshed access token per request. */
|
|
124
132
|
export function apiClient(cfg, ctx) {
|
|
125
|
-
return new ApiClient(cfg, ctx.fetch);
|
|
133
|
+
return new ApiClient(cfg.baseUrl, () => bearerFor(ctx, cfg), ctx.fetch);
|
|
126
134
|
}
|
|
127
135
|
/** GET /api/v1/me — how a key learns its own tenant. Unchanged contract from 0.1. */
|
|
128
136
|
export async function fetchMe(baseUrl, key, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS) {
|
package/dist/watch/consumer.js
CHANGED
|
@@ -11,7 +11,7 @@ export function printFrame(frame) {
|
|
|
11
11
|
}
|
|
12
12
|
async function fetchHeadCursor(opts, signal) {
|
|
13
13
|
const url = `${opts.baseUrl.replace(/\/+$/, "")}/snapshot?head=1`;
|
|
14
|
-
const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${opts.
|
|
14
|
+
const res = await (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${await opts.getToken()}` }, signal });
|
|
15
15
|
if (!res.ok) {
|
|
16
16
|
const body = (await res.text()).slice(0, 200);
|
|
17
17
|
throw new Error(`head-only snapshot fetch failed: ${res.status} ${res.statusText} at ${url} — ${body}`);
|
|
@@ -111,7 +111,7 @@ export function createLiveEventsClient(sdk, opts) {
|
|
|
111
111
|
const client = new sdk.LiveSyncClient({
|
|
112
112
|
baseUrl: opts.baseUrl,
|
|
113
113
|
accountId: opts.accountId,
|
|
114
|
-
auth:
|
|
114
|
+
auth: opts.auth,
|
|
115
115
|
reseed: reseedHead,
|
|
116
116
|
getCursor: () => state?.cursor ?? null,
|
|
117
117
|
onChange: (frame) => {
|
package/dist/watch.js
CHANGED
|
@@ -7,7 +7,8 @@ import { flagList, flagString } from "./args.js";
|
|
|
7
7
|
import { apiBase, requireConfig, watchCursorPathFor } from "./config.js";
|
|
8
8
|
import { loadContract } from "./contract.js";
|
|
9
9
|
import { CliError, UsageError } from "./errors.js";
|
|
10
|
-
import { apiClient } from "./
|
|
10
|
+
import { apiClient } from "./transport.js";
|
|
11
|
+
import { authStrategyFor, bearerFor } from "./oauth.js";
|
|
11
12
|
import { loadSdk } from "./sdk.js";
|
|
12
13
|
import { createLiveEventsClient } from "./watch/consumer.js";
|
|
13
14
|
import { CursorFileError } from "./watch/cursor-file.js";
|
|
@@ -135,7 +136,8 @@ export async function runWatch(ctx, cfg, opts, deps = {}) {
|
|
|
135
136
|
return createLiveEventsClient(sdk, {
|
|
136
137
|
baseUrl: apiBase(cfg),
|
|
137
138
|
accountId: cfg.account,
|
|
138
|
-
|
|
139
|
+
auth: authStrategyFor(ctx, cfg),
|
|
140
|
+
getToken: () => bearerFor(ctx, cfg),
|
|
139
141
|
cursorFile: opts.cursorFile ?? watchCursorPathFor(ctx.home),
|
|
140
142
|
fromHead: opts.fromHead,
|
|
141
143
|
fetchImpl: ctx.fetch,
|
package/dist/write.js
CHANGED
|
@@ -6,7 +6,7 @@ import { flagBool, flagInt, flagList, flagString, positionals } from "./args.js"
|
|
|
6
6
|
import { requireConfig } from "./config.js";
|
|
7
7
|
import { bookkeepingPrefix, labelId, loadContract, routePath, stageIdForSlot, teamByKey, teamForTicket } from "./contract.js";
|
|
8
8
|
import { CliError, UsageError } from "./errors.js";
|
|
9
|
-
import { apiClient } from "./
|
|
9
|
+
import { apiClient } from "./transport.js";
|
|
10
10
|
/** A ticket identifier → its Linear id (and team/project) through GET /api/v1/issues/:identifier. */
|
|
11
11
|
export async function resolveIssue(api, ticket) {
|
|
12
12
|
const res = await api.getJson(`/api/v1/issues/${encodeURIComponent(ticket)}`, { accept: [404] });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/catalyst-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Customer skill bundle for Catalyst Cloud: eight skills named from the executive's seat (whats-happening, what-needs-me, run-this-project, catalyst-setup, catalyst-linear, catalyst-github, how-catalyst-works, connect-me) plus the catalyst-skills CLI that reads through the Catalyst Cloud SDK, writes through the agent proxy, and connects your machine to your tenant with one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"version:sync": "node scripts/sync-plugin-version.mjs"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@catalyst-cloud/sdk": "^0.
|
|
44
|
+
"@catalyst-cloud/sdk": "^0.9.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^26.3.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: connect-me
|
|
3
3
|
description: >-
|
|
4
|
-
Connect this machine to the person's Catalyst Cloud tenant with their own personal key
|
|
4
|
+
Connect this machine to the person's Catalyst Cloud tenant as themselves — keyless (a browser device-code login) or with their own personal key — cache the tenant contract, and verify. Use when a person is getting started, when their config is missing or broken, when their login expired or they rotated their key, when a skill script exits 2 saying the machine is not connected, or when they ask which tenant this machine belongs to. The login names the tenant and the person, so nobody types an account id. Installing the skills is not this skill's job; the agent's own install command did that.
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:*)
|
|
7
7
|
---
|
|
@@ -9,28 +9,36 @@ allowed-tools: Bash(catalyst-skills:*) Bash(npx @catalyst-cloud/catalyst-skills:
|
|
|
9
9
|
|
|
10
10
|
# Connect me
|
|
11
11
|
|
|
12
|
-
Connecting is one command.
|
|
12
|
+
Connecting is one command. Keyless is the preferred rail — with no key, `login` opens a browser device-code flow and logs the person in as themselves:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
catalyst-skills login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It prints a short code and a URL; the person approves in a browser (or from a phone, on a machine with no browser) and this machine is connected as them. The short-lived session refreshes silently on every request, so they stay connected for months.
|
|
19
|
+
|
|
20
|
+
A **personal key** still works for a script or an unattended shell — the environment form keeps it out of shell history; `--key <personal-key>` is the third form:
|
|
13
21
|
|
|
14
22
|
```sh
|
|
15
23
|
CATALYST_CLOUD_TOKEN=<your-personal-key> catalyst-skills login
|
|
16
24
|
```
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
`npx @catalyst-cloud/catalyst-skills login` works when the package is not installed globally. A non-default cloud is pinned with `CATALYST_CLOUD_BASE_URL` or `--base-url`.
|
|
19
27
|
|
|
20
28
|
**This skill never installs skills.** You are already reading one, so the person's agent installed the set with its own command. Connecting is only about the credential and the contract.
|
|
21
29
|
|
|
22
|
-
**What it writes.** `~/.config/catalyst-cloud/customer.json` (mode 0600) holding the
|
|
30
|
+
**What it writes.** `~/.config/catalyst-cloud/customer.json` (mode 0600) holding the credential — a keyless login session (`auth`), or a personal key — the tenant it resolved, the person it resolved (`user`: id, label, role, Linear user id), and the absolute path of this CLI so skill scripts can spawn it; `~/.config/catalyst-cloud/contract.json`, the tenant contract with its ETag. A keyless session's token rotates on its own and the file is rewritten atomically each time.
|
|
23
31
|
|
|
24
32
|
**The replica is optional and one command away.** Nothing has to be running for reads, writes, asks or explanations; the API is origin-fresh. `catalyst-skills replica start --detach` starts the local replica for cheap repeated reads and ad hoc SQL; `login --start-replica` does the same at the end. Every skill checks `catalyst-skills replica status` first and falls back to the API when the replica is absent or stale, saying so.
|
|
25
33
|
|
|
26
34
|
## What you do
|
|
27
35
|
|
|
28
|
-
1.
|
|
36
|
+
1. Prefer keyless: run `catalyst-skills login` with no key and have the person approve the code in their browser (or from a phone). Nothing to mint, nothing to paste, nothing to keep out of the transcript. A **personal key** is the fallback for a script or unattended shell — they mint it at Settings → API keys (every active member can; no admin is needed) and hand it to the login command via `CATALYST_CLOUD_TOKEN`; it is shown once, so never put it in a file, a ticket, or this conversation beyond that one command. Either way, do not ask an admin for the tenant's **account key** (`ctc_acct_`, Settings → Account keys): that is a host credential — runners, daemons, the host replica — and a person connected with it has no name on anything their agent writes, and "what needs me" cannot mean them. If they connect with one anyway, login says so on stderr and still works.
|
|
29
37
|
2. Run the login command. Its output names the tenant (`Connected to <name> (<slug>)`), the person (`Connected as <label> (<role>)`), the config path, and the cached contract version. If the person line says their Linear identity is not matched yet, tell them: an admin matches it in Settings → Members, and until then "what needs me" shows everyone's asks.
|
|
30
38
|
3. Verify with `node scripts/verify-connection.mjs`: one line each for the tenant, the contract version, and the replica; exit 1 when the machine is not connected.
|
|
31
39
|
4. Run `catalyst-skills ready` and read the verdict to them.
|
|
32
40
|
5. Offer the replica: `catalyst-skills replica start --detach`, then `catalyst-skills replica status`. For keeping it alive across a reboot, load `references/keeping-the-replica-running.md`.
|
|
33
|
-
6. If login fails: a `401` means a stale, mistyped or revoked key — mint a new one at Settings → API keys, never a retry loop; a network error names the URL, check `--base-url`. A `403` on the contract naming an older cloud means the cloud has not yet deployed personal-key access: update the cloud, or connect with the account key until it has. If login succeeds but prints a line naming two contract versions, the tenant serves a contract outside this bundle's range: update the bundle before using the other skills.
|
|
41
|
+
6. If login fails: a keyless session that reports "your login expired or was revoked" needs one fresh `catalyst-skills login`, never a retry loop (it means the session was revoked or lapsed past the inactivity window — routine expiry refreshes silently and never surfaces). A `401` on the key rail means a stale, mistyped or revoked key — mint a new one at Settings → API keys, never a retry loop; a network error names the URL, check `--base-url`. A `403` on the contract naming an older cloud means the cloud has not yet deployed personal-key access: update the cloud, or connect with the account key until it has. If login succeeds but prints a line naming two contract versions, the tenant serves a contract outside this bundle's range: update the bundle before using the other skills.
|
|
34
42
|
|
|
35
43
|
## The verbs a session runs first
|
|
36
44
|
|
|
@@ -50,6 +58,6 @@ Every skill session opens with these, in this order, before doing anything else:
|
|
|
50
58
|
|
|
51
59
|
## Rules
|
|
52
60
|
|
|
53
|
-
- **The
|
|
54
|
-
- **Never guess a tenant.** There is no `--account`; the
|
|
61
|
+
- **The credential is a secret.** Keyless is safest — nothing to handle. A key goes into the login command or `CATALYST_CLOUD_TOKEN` and nowhere else: not into tickets, transcripts, or any file you write besides the one login writes; and never read back the `auth` block a keyless session stores.
|
|
62
|
+
- **Never guess a tenant.** There is no `--account`; the credential is the only tenant selector, on purpose. If the cloud names no tenant for the login, stop and say so.
|
|
55
63
|
- **Read the update notice.** After a bundle update, the next command prints one line with the changelog entry and what to run; read it to the person if they are watching.
|