@canaryai/cli 0.2.8 → 0.2.9

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.
Files changed (39) hide show
  1. package/README.md +77 -92
  2. package/dist/chunk-C2PGZRYK.js +167 -0
  3. package/dist/chunk-C2PGZRYK.js.map +1 -0
  4. package/dist/{chunk-K2OB72B6.js → chunk-LC7ZVXPH.js} +2 -2
  5. package/dist/{chunk-6WWHXWCS.js → chunk-QLFSJG5O.js} +33 -5
  6. package/dist/chunk-QLFSJG5O.js.map +1 -0
  7. package/dist/{chunk-FK3EZADZ.js → chunk-XGO62PO2.js} +1829 -868
  8. package/dist/chunk-XGO62PO2.js.map +1 -0
  9. package/dist/{debug-workflow-55G4Y6YT.js → debug-workflow-I3F36JBL.js} +57 -36
  10. package/dist/debug-workflow-I3F36JBL.js.map +1 -0
  11. package/dist/{docs-RPFT7ZJB.js → docs-REHST3YB.js} +2 -2
  12. package/dist/{feature-flag-2FDSKOVX.js → feature-flag-3HB5NTMY.js} +3 -2
  13. package/dist/{feature-flag-2FDSKOVX.js.map → feature-flag-3HB5NTMY.js.map} +1 -1
  14. package/dist/index.js +22 -9
  15. package/dist/index.js.map +1 -1
  16. package/dist/{issues-6ZDNDSD6.js → issues-YU57CHXS.js} +3 -2
  17. package/dist/{issues-6ZDNDSD6.js.map → issues-YU57CHXS.js.map} +1 -1
  18. package/dist/{knobs-MZRTYS3P.js → knobs-QJ4IBLCT.js} +3 -2
  19. package/dist/{knobs-MZRTYS3P.js.map → knobs-QJ4IBLCT.js.map} +1 -1
  20. package/dist/{local-browser-X7J27IGS.js → local-browser-MKTJ36KY.js} +3 -3
  21. package/dist/{mcp-4JVLADZL.js → mcp-ZOKM2AUE.js} +49 -238
  22. package/dist/mcp-ZOKM2AUE.js.map +1 -0
  23. package/dist/{record-4OX7HXWQ.js → record-TNDBT3NY.js} +130 -28
  24. package/dist/record-TNDBT3NY.js.map +1 -0
  25. package/dist/session-RNLKFS2Z.js +751 -0
  26. package/dist/session-RNLKFS2Z.js.map +1 -0
  27. package/dist/skill-CZ7SHI3P.js +156 -0
  28. package/dist/skill-CZ7SHI3P.js.map +1 -0
  29. package/dist/{src-I4EXB5OD.js → src-2WSMYBMJ.js} +18 -2
  30. package/package.json +1 -1
  31. package/dist/chunk-6WWHXWCS.js.map +0 -1
  32. package/dist/chunk-FK3EZADZ.js.map +0 -1
  33. package/dist/debug-workflow-55G4Y6YT.js.map +0 -1
  34. package/dist/mcp-4JVLADZL.js.map +0 -1
  35. package/dist/record-4OX7HXWQ.js.map +0 -1
  36. /package/dist/{chunk-K2OB72B6.js.map → chunk-LC7ZVXPH.js.map} +0 -0
  37. /package/dist/{docs-RPFT7ZJB.js.map → docs-REHST3YB.js.map} +0 -0
  38. /package/dist/{local-browser-X7J27IGS.js.map → local-browser-MKTJ36KY.js.map} +0 -0
  39. /package/dist/{src-I4EXB5OD.js.map → src-2WSMYBMJ.js.map} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Canary CLI
2
2
 
3
- Run local test runs, expose your local app via a tunnel, and stream results into Canary.
3
+ Run tests, query issues, and manage AI agent skill templates from the command line.
4
4
 
5
5
  ## Install
6
6
 
@@ -12,122 +12,107 @@ bun add -g @canaryai/cli
12
12
 
13
13
  ## Login
14
14
 
15
+ Authenticate with your Canary account before using the CLI:
16
+
15
17
  ```bash
16
- canary login # production (default)
17
- canary login --env dev # dev environment
18
- canary login --env local # local development
18
+ canary login
19
19
  ```
20
20
 
21
21
  Options:
22
- - `--env <env>` - Environment to login to: `prod`, `dev`, or `local`
23
- - `--api-url <url>` - Custom API URL (overrides --env)
24
- - `--app-url <url>` - Custom app URL (overrides --env)
22
+ - `--org <name>` - Select organization by name or ID (for multi-org users)
23
+ - `--app-url <url>` - Custom app URL (e.g. `http://localhost:5173` for local dev)
24
+ - `--api-url <url>` - Custom API URL (e.g. `http://localhost:3000` for local dev)
25
25
  - `--no-open` - Don't auto-open browser
26
26
 
27
- ## Quickstart (local testing)
28
-
29
- 1) Start your app locally.
30
- 2) Start a run (auto-tunnel + run):
27
+ To switch environments, set `CANARY_API_URL`:
31
28
 
32
29
  ```bash
33
- canary run --port 5173 --title "Login smoke"
30
+ export CANARY_API_URL=http://localhost:3000
31
+ canary login --app-url http://localhost:5173
34
32
  ```
35
33
 
36
- 3) Open the watch URL printed in the terminal.
34
+ ## Run Tests
37
35
 
38
- ## Tunnel only
36
+ ### Local Playwright tests
39
37
 
40
38
  ```bash
41
- canary tunnel --port 5173
39
+ canary test
40
+ canary test --grep "login" --headed --workers 1
42
41
  ```
43
42
 
44
- ## MCP server
43
+ All standard Playwright options are passed through.
44
+
45
+ ### Remote workflow tests
45
46
 
46
47
  ```bash
47
- canary mcp
48
+ canary test --remote
49
+ canary test --remote --tag smoke
50
+ canary test --remote --property "My App" --environment staging
48
51
  ```
49
52
 
50
- Tools:
51
- - `local_run_tests` (port, instructions, title)
52
- - `local_wait_for_results` (runId)
53
+ Options:
54
+ - `--token <key>` - API key (or set `CANARY_API_TOKEN`)
55
+ - `--api-url <url>` - API URL (default: `https://api.trycanary.ai`)
56
+ - `--property <name|id>` - Target a specific property
57
+ - `--environment <name|id>` - Target a specific environment
58
+ - `--tag <tag>` - Filter workflows by tag
59
+ - `--name-pattern <pat>` - Filter workflows by name pattern
60
+ - `--verbose, -v` - Show all events
61
+
62
+ ## Issues
53
63
 
54
- ## PSQL (superadmin only)
64
+ Search and inspect QA issues detected by Canary.
55
65
 
56
- Execute read-only SQL queries against the production database. Requires superadmin privileges and the `cli.psql.enabled` knob to be enabled.
66
+ ### List issues
57
67
 
58
68
  ```bash
59
- canary psql "SELECT id, status FROM jobs LIMIT 5"
60
- canary psql "SELECT * FROM jobs WHERE status = 'running'" --json
69
+ canary issues list
70
+ canary issues list --severity high --status open
71
+ canary issues list --search "timeout" --format markdown
61
72
  ```
62
73
 
63
74
  Options:
64
- - `--json` - Output results as JSON instead of a table
65
- - `--query <sql>` - Alternative to positional query argument
66
-
67
- Limits:
68
- - Query size: 10KB max (for larger queries, use psql directly)
69
- - Query timeout: 30s default (configurable via `cli.psql.timeout_ms` knob)
70
- - Result rows: 10K max (results truncated if exceeded)
71
-
72
- ### Security Model
73
-
74
- The read-only PostgreSQL user (`debug_agent`) provides the **primary security layer** - it has SELECT-only privileges enforced at the database level. Any modification attempts will fail at the database regardless of other controls.
75
-
76
- Keyword validation serves as a **secondary defense-in-depth** measure that:
77
- 1. Prevents modification attempts from reaching the database
78
- 2. Triggers Slack alerts and auto-disables the feature on suspicious activity
79
- 3. Provides an audit trail of attempted misuse
80
-
81
- Blocked keywords include: INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, GRANT, REVOKE, VACUUM, REINDEX, COPY, EXECUTE, CALL, DO, PREPARE, SET, RESET, LOCK, COMMIT, ROLLBACK, LISTEN, NOTIFY.
82
-
83
- ### Security Controls Summary
84
-
85
- | Control | Purpose |
86
- |---------|---------|
87
- | Superadmin auth | Only trusted operators can access |
88
- | `cli.psql.enabled` knob | Feature disabled by default, requires explicit enablement |
89
- | Read-only DB user | Database-level protection against modifications |
90
- | Keyword detection | Early blocking + alerting on suspicious queries |
91
- | Auto-disable | Feature self-disables on modification attempts |
92
- | Slack alerts | Immediate notification to security team |
93
- | Query timeout | Prevents long-running queries from impacting production |
94
- | Row limits | Prevents accidental full table dumps |
95
- | RDS query logging | Infrastructure-level audit logging of all queries
96
-
97
- ## Environment variables
98
-
99
- - `CANARY_API_URL` (default `https://api.trycanary.ai`)
100
- - `CANARY_APP_URL` (default `https://app.trycanary.ai`)
101
- - `CANARY_API_TOKEN` (optional; `canary login` stores a token automatically)
102
- - `CANARY_LOCAL_PORT` (optional default port for `canary run` / `canary tunnel`)
103
-
104
- ## Programmatic usage
105
-
106
- You can trigger a suite programmatically without shelling out to the CLI:
107
-
108
- ```ts
109
- import { canary } from "@canaryai/cli";
110
-
111
- const result = await canary.run({
112
- projectRoot: "/path/to/repo",
113
- testDir: ["tests/smoke"],
114
- cliArgs: ["--grep", "login"],
115
- healing: {
116
- apiKey: process.env.AI_API_KEY,
117
- provider: "openai",
118
- model: "gpt-4o-mini",
119
- timeoutMs: 120_000,
120
- maxActions: 50,
121
- warnOnly: true,
122
- },
123
- stdio: "pipe",
124
- });
125
-
126
- if (!result.ok) {
127
- console.error("suite failed", result.summary);
128
- }
75
+ - `--search <query>` - Full-text search
76
+ - `--severity <level>` - Filter: `low`, `medium`, `high`, `unknown`
77
+ - `--status <statuses>` - Filter: `open`, `closed`, `not_a_bug` (comma-separated)
78
+ - `--property-id <uuid>` - Filter by property
79
+ - `--page <n>` - Page number (default: 1)
80
+ - `--page-size <n>` - Page size (default: 25)
81
+ - `--json` - Output raw JSON
82
+ - `--format markdown` - Output as markdown
83
+
84
+ ### Get issue details
85
+
86
+ ```bash
87
+ canary issues get <issue-id>
88
+ canary issues get <issue-id> --format markdown
129
89
  ```
130
90
 
131
- Notes:
132
- - Defaults mirror the CLI: healing on, Playwright config respected.
133
- - `result.summary` is derived from Playwright’s JSON reporter plus healed counts from the AI event log.
91
+ ## AI Agent Skill Templates
92
+
93
+ Output reusable skill templates that teach AI agents (Claude Code, Cursor, etc.) how to use the Canary CLI:
94
+
95
+ ```bash
96
+ canary skill help # list available templates
97
+ canary skill issue-log-xref # output a template to stdout
98
+ canary skill issue-log-xref > .claude/skills/issue-log-xref.md # install it
99
+ ```
100
+
101
+ ## Release QA (CI/CD)
102
+
103
+ Trigger and monitor Release QA runs from CI pipelines:
104
+
105
+ ```bash
106
+ canary release trigger --property-id <uuid>
107
+ canary release status <run-id>
108
+ canary release run --property-id <uuid> --timeout 600
109
+ ```
110
+
111
+ ## Environment Variables
112
+
113
+ | Variable | Default | Description |
114
+ |----------|---------|-------------|
115
+ | `CANARY_API_URL` | `https://api.trycanary.ai` | API endpoint |
116
+ | `CANARY_APP_URL` | `https://app.trycanary.ai` | App URL for login |
117
+ | `CANARY_API_TOKEN` | — | API key (alternative to `canary login`) |
118
+ | `CANARY_LOCAL_PORT` | — | Default port for `canary run` / `canary tunnel` |
@@ -0,0 +1,167 @@
1
+ import { createRequire as __cr } from "module"; const require = __cr(import.meta.url);
2
+
3
+ // src/session/daemon-client.ts
4
+ import { spawn } from "child_process";
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ import os from "os";
8
+ var PIDFILE_DIR = path.join(os.homedir(), ".config", "canary-cli");
9
+ var PIDFILE_PATH = path.join(PIDFILE_DIR, "daemon.json");
10
+ var HEALTH_POLL_INTERVAL_MS = 100;
11
+ var HEALTH_POLL_TIMEOUT_MS = 15e3;
12
+ async function readPidfile() {
13
+ try {
14
+ const content = await fs.readFile(PIDFILE_PATH, "utf-8");
15
+ return JSON.parse(content);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ function isProcessAlive(pid) {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function daemonFetch(port, method, path2, body) {
29
+ const url = `http://127.0.0.1:${port}${path2}`;
30
+ const res = await fetch(url, {
31
+ method,
32
+ headers: body ? { "Content-Type": "application/json" } : void 0,
33
+ body: body ? JSON.stringify(body) : void 0
34
+ });
35
+ return res.json();
36
+ }
37
+ async function healthCheck(port) {
38
+ try {
39
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
40
+ signal: AbortSignal.timeout(2e3)
41
+ });
42
+ return res.ok;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ async function spawnDaemon() {
48
+ const cliEntry = path.resolve(
49
+ path.dirname(new URL(import.meta.url).pathname),
50
+ "..",
51
+ "index.ts"
52
+ );
53
+ const child = spawn(process.execPath, ["--bun", cliEntry, "session", "daemon"], {
54
+ detached: true,
55
+ stdio: ["ignore", "pipe", "ignore"],
56
+ env: { ...process.env }
57
+ });
58
+ child.unref();
59
+ return new Promise((resolve, reject) => {
60
+ let output = "";
61
+ const timeout = setTimeout(() => {
62
+ reject(new Error("Daemon startup timed out"));
63
+ }, HEALTH_POLL_TIMEOUT_MS);
64
+ child.stdout.on("data", (data) => {
65
+ output += data.toString();
66
+ const match = output.match(/DAEMON_READY:(\d+)/);
67
+ if (match) {
68
+ clearTimeout(timeout);
69
+ resolve(parseInt(match[1], 10));
70
+ }
71
+ });
72
+ child.on("error", (err) => {
73
+ clearTimeout(timeout);
74
+ reject(err);
75
+ });
76
+ child.on("exit", (code) => {
77
+ clearTimeout(timeout);
78
+ if (!output.includes("DAEMON_READY")) {
79
+ reject(new Error(`Daemon exited with code ${code} before becoming ready`));
80
+ }
81
+ });
82
+ });
83
+ }
84
+ async function ensureDaemon() {
85
+ const state = await readPidfile();
86
+ if (state) {
87
+ if (isProcessAlive(state.pid)) {
88
+ if (await healthCheck(state.port)) {
89
+ return state.port;
90
+ }
91
+ }
92
+ try {
93
+ await fs.unlink(PIDFILE_PATH);
94
+ } catch {
95
+ }
96
+ }
97
+ const port = await spawnDaemon();
98
+ const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;
99
+ while (Date.now() < deadline) {
100
+ if (await healthCheck(port)) return port;
101
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
102
+ }
103
+ throw new Error("Daemon failed to become healthy after spawn");
104
+ }
105
+ async function createSession(params) {
106
+ const port = await ensureDaemon();
107
+ return daemonFetch(port, "POST", "/sessions", params);
108
+ }
109
+ async function listSessions() {
110
+ const port = await ensureDaemon();
111
+ return daemonFetch(port, "GET", "/sessions");
112
+ }
113
+ async function getSession(sessionId) {
114
+ const port = await ensureDaemon();
115
+ return daemonFetch(port, "GET", `/sessions/${sessionId}`);
116
+ }
117
+ async function deleteSession(sessionId) {
118
+ const port = await ensureDaemon();
119
+ return daemonFetch(port, "DELETE", `/sessions/${sessionId}`);
120
+ }
121
+ async function deleteAllSessions() {
122
+ const port = await ensureDaemon();
123
+ return daemonFetch(port, "DELETE", "/sessions");
124
+ }
125
+ async function callTool(sessionId, toolName, args) {
126
+ const port = await ensureDaemon();
127
+ return daemonFetch(port, "POST", `/sessions/${sessionId}/tools/${toolName}`, args);
128
+ }
129
+ async function resolveTargetSession(sessionIdOrName) {
130
+ const result = await listSessions();
131
+ if (!result.ok || !result.data) {
132
+ throw new Error("Failed to list sessions");
133
+ }
134
+ const sessions = result.data;
135
+ if (sessions.length === 0) {
136
+ throw new Error("No active sessions. Start one with: canary session start");
137
+ }
138
+ if (sessionIdOrName) {
139
+ const match = sessions.find(
140
+ (s) => s.id === sessionIdOrName || s.name === sessionIdOrName
141
+ );
142
+ if (!match) {
143
+ throw new Error(
144
+ `Session "${sessionIdOrName}" not found. Active sessions: ${sessions.map((s) => s.id).join(", ")}`
145
+ );
146
+ }
147
+ return match;
148
+ }
149
+ if (sessions.length === 1) {
150
+ return sessions[0];
151
+ }
152
+ throw new Error(
153
+ `Multiple sessions active. Specify one with --session:
154
+ ${sessions.map((s) => ` ${s.id} (${s.name})`).join("\n")}`
155
+ );
156
+ }
157
+
158
+ export {
159
+ createSession,
160
+ listSessions,
161
+ getSession,
162
+ deleteSession,
163
+ deleteAllSessions,
164
+ callTool,
165
+ resolveTargetSession
166
+ };
167
+ //# sourceMappingURL=chunk-C2PGZRYK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/session/daemon-client.ts"],"sourcesContent":["/**\n * Daemon client — HTTP client for the session daemon.\n *\n * Handles pidfile read/write, stale PID detection, auto-start,\n * and provides typed HTTP helpers for daemon communication.\n *\n * @module\n */\n\nimport { spawn } from 'node:child_process';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport os from 'node:os';\nimport type {\n DaemonState,\n DaemonResponse,\n SessionInfo,\n CreateSessionRequest,\n ToolResponse,\n} from './types.js';\n\nconst PIDFILE_DIR = path.join(os.homedir(), '.config', 'canary-cli');\nconst PIDFILE_PATH = path.join(PIDFILE_DIR, 'daemon.json');\nconst HEALTH_POLL_INTERVAL_MS = 100;\nconst HEALTH_POLL_TIMEOUT_MS = 15_000;\n\n/* ── Pidfile helpers ─────────────────────────────────────────────────── */\n\nasync function readPidfile(): Promise<DaemonState | null> {\n try {\n const content = await fs.readFile(PIDFILE_PATH, 'utf-8');\n return JSON.parse(content) as DaemonState;\n } catch {\n return null;\n }\n}\n\nfunction isProcessAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/* ── HTTP helpers ────────────────────────────────────────────────────── */\n\nasync function daemonFetch(\n port: number,\n method: string,\n path: string,\n body?: unknown\n): Promise<unknown> {\n const url = `http://127.0.0.1:${port}${path}`;\n const res = await fetch(url, {\n method,\n headers: body ? { 'Content-Type': 'application/json' } : undefined,\n body: body ? JSON.stringify(body) : undefined,\n });\n return res.json();\n}\n\nasync function healthCheck(port: number): Promise<boolean> {\n try {\n const res = await fetch(`http://127.0.0.1:${port}/health`, {\n signal: AbortSignal.timeout(2000),\n });\n return res.ok;\n } catch {\n return false;\n }\n}\n\n/* ── Auto-start ──────────────────────────────────────────────────────── */\n\nasync function spawnDaemon(): Promise<number> {\n // Resolve the canary CLI entry point\n const cliEntry = path.resolve(\n path.dirname(new URL(import.meta.url).pathname),\n '..',\n 'index.ts'\n );\n\n const child = spawn(process.execPath, ['--bun', cliEntry, 'session', 'daemon'], {\n detached: true,\n stdio: ['ignore', 'pipe', 'ignore'],\n env: { ...process.env },\n });\n\n child.unref();\n\n // Wait for \"DAEMON_READY:<port>\" on stdout\n return new Promise<number>((resolve, reject) => {\n let output = '';\n const timeout = setTimeout(() => {\n reject(new Error('Daemon startup timed out'));\n }, HEALTH_POLL_TIMEOUT_MS);\n\n child.stdout!.on('data', (data: Buffer) => {\n output += data.toString();\n const match = output.match(/DAEMON_READY:(\\d+)/);\n if (match) {\n clearTimeout(timeout);\n resolve(parseInt(match[1], 10));\n }\n });\n\n child.on('error', (err) => {\n clearTimeout(timeout);\n reject(err);\n });\n\n child.on('exit', (code) => {\n clearTimeout(timeout);\n if (!output.includes('DAEMON_READY')) {\n reject(new Error(`Daemon exited with code ${code} before becoming ready`));\n }\n });\n });\n}\n\n/* ── Public API ──────────────────────────────────────────────────────── */\n\n/**\n * Ensure the daemon is running and return its port.\n * Starts the daemon if needed, cleans up stale pidfiles.\n */\nexport async function ensureDaemon(): Promise<number> {\n const state = await readPidfile();\n\n if (state) {\n if (isProcessAlive(state.pid)) {\n // Verify it actually responds\n if (await healthCheck(state.port)) {\n return state.port;\n }\n }\n // Stale pidfile — clean up\n try {\n await fs.unlink(PIDFILE_PATH);\n } catch {\n // ignore\n }\n }\n\n // Spawn new daemon\n const port = await spawnDaemon();\n\n // Poll until healthy\n const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;\n while (Date.now() < deadline) {\n if (await healthCheck(port)) return port;\n await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));\n }\n\n throw new Error('Daemon failed to become healthy after spawn');\n}\n\n/**\n * Get daemon port if running, null otherwise.\n */\nexport async function getDaemonPort(): Promise<number | null> {\n const state = await readPidfile();\n if (!state) return null;\n if (!isProcessAlive(state.pid)) return null;\n if (!(await healthCheck(state.port))) return null;\n return state.port;\n}\n\n/* ── Session operations ──────────────────────────────────────────────── */\n\nexport async function createSession(params: CreateSessionRequest): Promise<DaemonResponse<SessionInfo>> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'POST', '/sessions', params) as Promise<DaemonResponse<SessionInfo>>;\n}\n\nexport async function listSessions(): Promise<DaemonResponse<SessionInfo[]>> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'GET', '/sessions') as Promise<DaemonResponse<SessionInfo[]>>;\n}\n\nexport async function getSession(sessionId: string): Promise<DaemonResponse<SessionInfo>> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'GET', `/sessions/${sessionId}`) as Promise<DaemonResponse<SessionInfo>>;\n}\n\nexport async function deleteSession(sessionId: string): Promise<DaemonResponse> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'DELETE', `/sessions/${sessionId}`) as Promise<DaemonResponse>;\n}\n\nexport async function deleteAllSessions(): Promise<DaemonResponse> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'DELETE', '/sessions') as Promise<DaemonResponse>;\n}\n\nexport async function callTool(\n sessionId: string,\n toolName: string,\n args: Record<string, unknown>\n): Promise<ToolResponse> {\n const port = await ensureDaemon();\n return daemonFetch(port, 'POST', `/sessions/${sessionId}/tools/${toolName}`, args) as Promise<ToolResponse>;\n}\n\n/**\n * Resolve the target session for a command.\n * If there's exactly one session, auto-targets it.\n * If a sessionId or name is provided, looks it up.\n */\nexport async function resolveTargetSession(sessionIdOrName?: string): Promise<SessionInfo> {\n const result = await listSessions();\n if (!result.ok || !result.data) {\n throw new Error('Failed to list sessions');\n }\n const sessions = result.data;\n\n if (sessions.length === 0) {\n throw new Error('No active sessions. Start one with: canary session start');\n }\n\n if (sessionIdOrName) {\n const match = sessions.find(\n (s) => s.id === sessionIdOrName || s.name === sessionIdOrName\n );\n if (!match) {\n throw new Error(\n `Session \"${sessionIdOrName}\" not found. Active sessions: ${sessions.map((s) => s.id).join(', ')}`\n );\n }\n return match;\n }\n\n if (sessions.length === 1) {\n return sessions[0];\n }\n\n throw new Error(\n `Multiple sessions active. Specify one with --session:\\n${sessions.map((s) => ` ${s.id} (${s.name})`).join('\\n')}`\n );\n}\n"],"mappings":"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","names":["path"]}
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __cr } from "module"; const require = __cr(import.meta.url);
2
2
  import {
3
3
  PlaywrightClient
4
- } from "./chunk-FK3EZADZ.js";
4
+ } from "./chunk-XGO62PO2.js";
5
5
 
6
6
  // src/local-browser/host.ts
7
7
  var HEARTBEAT_INTERVAL_MS = 3e4;
@@ -370,4 +370,4 @@ var LocalBrowserHost = class {
370
370
  export {
371
371
  LocalBrowserHost
372
372
  };
373
- //# sourceMappingURL=chunk-K2OB72B6.js.map
373
+ //# sourceMappingURL=chunk-LC7ZVXPH.js.map
@@ -2,8 +2,13 @@ import { createRequire as __cr } from "module"; const require = __cr(import.meta
2
2
  import {
3
3
  getArgValue
4
4
  } from "./chunk-PWWQGYFG.js";
5
+ import {
6
+ getCanaryTmpDir
7
+ } from "./chunk-XAA5VQ5N.js";
5
8
 
6
9
  // src/cli-helpers.ts
10
+ import fs from "fs/promises";
11
+ import path from "path";
7
12
  import process from "process";
8
13
  function toLifecycleLabel(stage) {
9
14
  switch (stage) {
@@ -23,8 +28,8 @@ function parseLifecycleStage(argv) {
23
28
  }
24
29
  return stage;
25
30
  }
26
- async function apiRequest(apiUrl, token, method, path, body) {
27
- const res = await fetch(`${apiUrl}${path}`, {
31
+ async function apiRequest(apiUrl, token, method, path2, body) {
32
+ const res = await fetch(`${apiUrl}${path2}`, {
28
33
  method,
29
34
  headers: {
30
35
  Authorization: `Bearer ${token}`,
@@ -39,8 +44,30 @@ async function apiRequest(apiUrl, token, method, path, body) {
39
44
  }
40
45
  return await res.json();
41
46
  }
42
- async function fetchList(apiUrl, token, path, listKey) {
43
- const res = await fetch(`${apiUrl}${path}`, {
47
+ async function downloadStorageState(opts) {
48
+ const tmpFile = path.join(
49
+ getCanaryTmpDir(),
50
+ `${opts.prefix ?? "canary-ss"}-${Date.now()}.json`
51
+ );
52
+ try {
53
+ const res = await fetch(
54
+ `${opts.apiUrl}/org/properties/${opts.propertyId}/credentials/${opts.credentialId}/storage-state/download`,
55
+ {
56
+ headers: { Authorization: `Bearer ${opts.token}` },
57
+ redirect: "follow"
58
+ }
59
+ );
60
+ if (res.ok) {
61
+ const body = await res.text();
62
+ await fs.writeFile(tmpFile, body, "utf-8");
63
+ return tmpFile;
64
+ }
65
+ } catch {
66
+ }
67
+ return void 0;
68
+ }
69
+ async function fetchList(apiUrl, token, path2, listKey) {
70
+ const res = await fetch(`${apiUrl}${path2}`, {
44
71
  headers: { Authorization: `Bearer ${token}` }
45
72
  });
46
73
  if (res.status === 401) {
@@ -60,6 +87,7 @@ export {
60
87
  toLifecycleLabel,
61
88
  parseLifecycleStage,
62
89
  apiRequest,
90
+ downloadStorageState,
63
91
  fetchList
64
92
  };
65
- //# sourceMappingURL=chunk-6WWHXWCS.js.map
93
+ //# sourceMappingURL=chunk-QLFSJG5O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli-helpers.ts"],"sourcesContent":["/**\n * Shared CLI helpers for superadmin management commands (knobs, feature-flags).\n */\n\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport process from \"node:process\";\nimport { getCanaryTmpDir } from \"@chatsdet/tmp\";\nimport { getArgValue } from \"./auth.js\";\n\nexport type LifecycleStage = \"active\" | \"deprecated\" | \"ready_for_cleanup\";\n\nexport function toLifecycleLabel(stage: LifecycleStage): string {\n switch (stage) {\n case \"deprecated\":\n return \"deprecated\";\n case \"ready_for_cleanup\":\n return \"ready_for_cleanup\";\n default:\n return \"active\";\n }\n}\n\nexport function parseLifecycleStage(argv: string[]): LifecycleStage {\n const stage = getArgValue(argv, \"--stage\");\n if (!stage || ![\"active\", \"deprecated\", \"ready_for_cleanup\"].includes(stage)) {\n console.error(\"Error: --stage is required and must be one of: active, deprecated, ready_for_cleanup\");\n process.exit(1);\n }\n return stage as LifecycleStage;\n}\n\nexport async function apiRequest<T extends { ok: boolean; error?: string }>(\n apiUrl: string,\n token: string,\n method: string,\n path: string,\n body?: Record<string, unknown>\n): Promise<T> {\n const res = await fetch(`${apiUrl}${path}`, {\n method,\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n ...(body ? { body: JSON.stringify(body) } : {}),\n });\n\n if (res.status === 401) {\n console.error(\"Error: Unauthorized. Your session may have expired.\");\n console.error(\"Run: canary login\");\n process.exit(1);\n }\n\n return (await res.json()) as T;\n}\n\nexport async function downloadStorageState(opts: {\n apiUrl: string;\n token: string;\n propertyId: string;\n credentialId: string;\n prefix?: string;\n}): Promise<string | undefined> {\n const tmpFile = path.join(\n getCanaryTmpDir(),\n `${opts.prefix ?? \"canary-ss\"}-${Date.now()}.json`\n );\n try {\n const res = await fetch(\n `${opts.apiUrl}/org/properties/${opts.propertyId}/credentials/${opts.credentialId}/storage-state/download`,\n {\n headers: { Authorization: `Bearer ${opts.token}` },\n redirect: \"follow\",\n }\n );\n if (res.ok) {\n const body = await res.text();\n await fs.writeFile(tmpFile, body, \"utf-8\");\n return tmpFile;\n }\n } catch {\n // Caller handles missing storage state\n }\n return undefined;\n}\n\nexport async function fetchList<T>(\n apiUrl: string,\n token: string,\n path: string,\n listKey: string\n): Promise<T[]> {\n const res = await fetch(`${apiUrl}${path}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n\n if (res.status === 401) {\n console.error(\"Error: Unauthorized. Your session may have expired.\");\n console.error(\"Run: canary login\");\n process.exit(1);\n }\n\n const json = (await res.json()) as Record<string, unknown>;\n\n if (!json.ok) {\n console.error(`Error: ${json.error}`);\n process.exit(1);\n }\n\n return (json[listKey] as T[]) ?? [];\n}\n"],"mappings":";;;;;;;;;AAIA,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,OAAO,aAAa;AAMb,SAAS,iBAAiB,OAA+B;AAC9D,UAAQ,OAAO;AAAA,IACb,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAEO,SAAS,oBAAoB,MAAgC;AAClE,QAAM,QAAQ,YAAY,MAAM,SAAS;AACzC,MAAI,CAAC,SAAS,CAAC,CAAC,UAAU,cAAc,mBAAmB,EAAE,SAAS,KAAK,GAAG;AAC5E,YAAQ,MAAM,sFAAsF;AACpG,YAAQ,KAAK,CAAC;AAAA,EAChB;AACA,SAAO;AACT;AAEA,eAAsB,WACpB,QACA,OACA,QACAA,OACA,MACY;AACZ,QAAM,MAAM,MAAM,MAAM,GAAG,MAAM,GAAGA,KAAI,IAAI;AAAA,IAC1C;AAAA,IACA,SAAS;AAAA,MACP,eAAe,UAAU,KAAK;AAAA,MAC9B,gBAAgB;AAAA,IAClB;AAAA,IACA,GAAI,OAAO,EAAE,MAAM,KAAK,UAAU,IAAI,EAAE,IAAI,CAAC;AAAA,EAC/C,CAAC;AAED,MAAI,IAAI,WAAW,KAAK;AACtB,YAAQ,MAAM,qDAAqD;AACnE,YAAQ,MAAM,mBAAmB;AACjC,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAQ,MAAM,IAAI,KAAK;AACzB;AAEA,eAAsB,qBAAqB,MAMX;AAC9B,QAAM,UAAU,KAAK;AAAA,IACnB,gBAAgB;AAAA,IAChB,GAAG,KAAK,UAAU,WAAW,IAAI,KAAK,IAAI,CAAC;AAAA,EAC7C;AACA,MAAI;AACF,UAAM,MAAM,MAAM;AAAA,MAChB,GAAG,KAAK,MAAM,mBAAmB,KAAK,UAAU,gBAAgB,KAAK,YAAY;AAAA,MACjF;AAAA,QACE,SAAS,EAAE,eAAe,UAAU,KAAK,KAAK,GAAG;AAAA,QACjD,UAAU;AAAA,MACZ;AAAA,IACF;AACA,QAAI,IAAI,IAAI;AACV,YAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,YAAM,GAAG,UAAU,SAAS,MAAM,OAAO;AACzC,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAEA,eAAsB,UACpB,QACA,OACAA,OACA,SACc;AACd,QAAM,MAAM,MAAM,MAAM,GAAG,MAAM,GAAGA,KAAI,IAAI;AAAA,IAC1C,SAAS,EAAE,eAAe,UAAU,KAAK,GAAG;AAAA,EAC9C,CAAC;AAED,MAAI,IAAI,WAAW,KAAK;AACtB,YAAQ,MAAM,qDAAqD;AACnE,YAAQ,MAAM,mBAAmB;AACjC,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAE7B,MAAI,CAAC,KAAK,IAAI;AACZ,YAAQ,MAAM,UAAU,KAAK,KAAK,EAAE;AACpC,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAQ,KAAK,OAAO,KAAa,CAAC;AACpC;","names":["path"]}