@canaryai/cli 0.2.7 → 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.
- package/README.md +77 -92
- package/dist/chunk-C2PGZRYK.js +167 -0
- package/dist/chunk-C2PGZRYK.js.map +1 -0
- package/dist/{chunk-TEHABH2E.js → chunk-LC7ZVXPH.js} +2 -2
- package/dist/{chunk-6WWHXWCS.js → chunk-QLFSJG5O.js} +33 -5
- package/dist/chunk-QLFSJG5O.js.map +1 -0
- package/dist/{chunk-RL5Y6V3C.js → chunk-XGO62PO2.js} +2443 -1073
- package/dist/chunk-XGO62PO2.js.map +1 -0
- package/dist/{debug-workflow-ZFRF3JMY.js → debug-workflow-I3F36JBL.js} +57 -36
- package/dist/debug-workflow-I3F36JBL.js.map +1 -0
- package/dist/{docs-RPFT7ZJB.js → docs-REHST3YB.js} +2 -2
- package/dist/{feature-flag-2FDSKOVX.js → feature-flag-3HB5NTMY.js} +3 -2
- package/dist/{feature-flag-2FDSKOVX.js.map → feature-flag-3HB5NTMY.js.map} +1 -1
- package/dist/index.js +22 -9
- package/dist/index.js.map +1 -1
- package/dist/{issues-6ZDNDSD6.js → issues-YU57CHXS.js} +3 -2
- package/dist/{issues-6ZDNDSD6.js.map → issues-YU57CHXS.js.map} +1 -1
- package/dist/{knobs-MZRTYS3P.js → knobs-QJ4IBLCT.js} +3 -2
- package/dist/{knobs-MZRTYS3P.js.map → knobs-QJ4IBLCT.js.map} +1 -1
- package/dist/{local-browser-I2ANCFFH.js → local-browser-MKTJ36KY.js} +3 -3
- package/dist/{mcp-EOWUKFEB.js → mcp-ZOKM2AUE.js} +49 -238
- package/dist/mcp-ZOKM2AUE.js.map +1 -0
- package/dist/{record-TSF726OB.js → record-TNDBT3NY.js} +130 -28
- package/dist/record-TNDBT3NY.js.map +1 -0
- package/dist/session-RNLKFS2Z.js +751 -0
- package/dist/session-RNLKFS2Z.js.map +1 -0
- package/dist/skill-CZ7SHI3P.js +156 -0
- package/dist/skill-CZ7SHI3P.js.map +1 -0
- package/dist/{src-SCKO6YUB.js → src-2WSMYBMJ.js} +20 -2
- package/package.json +2 -2
- package/dist/chunk-6WWHXWCS.js.map +0 -1
- package/dist/chunk-RL5Y6V3C.js.map +0 -1
- package/dist/debug-workflow-ZFRF3JMY.js.map +0 -1
- package/dist/mcp-EOWUKFEB.js.map +0 -1
- package/dist/record-TSF726OB.js.map +0 -1
- /package/dist/{chunk-TEHABH2E.js.map → chunk-LC7ZVXPH.js.map} +0 -0
- /package/dist/{docs-RPFT7ZJB.js.map → docs-REHST3YB.js.map} +0 -0
- /package/dist/{local-browser-I2ANCFFH.js.map → local-browser-MKTJ36KY.js.map} +0 -0
- /package/dist/{src-SCKO6YUB.js.map → src-2WSMYBMJ.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Canary CLI
|
|
2
2
|
|
|
3
|
-
Run
|
|
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
|
|
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
|
-
- `--
|
|
23
|
-
- `--
|
|
24
|
-
- `--
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
export CANARY_API_URL=http://localhost:3000
|
|
31
|
+
canary login --app-url http://localhost:5173
|
|
34
32
|
```
|
|
35
33
|
|
|
36
|
-
|
|
34
|
+
## Run Tests
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
### Local Playwright tests
|
|
39
37
|
|
|
40
38
|
```bash
|
|
41
|
-
canary
|
|
39
|
+
canary test
|
|
40
|
+
canary test --grep "login" --headed --workers 1
|
|
42
41
|
```
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
All standard Playwright options are passed through.
|
|
44
|
+
|
|
45
|
+
### Remote workflow tests
|
|
45
46
|
|
|
46
47
|
```bash
|
|
47
|
-
canary
|
|
48
|
+
canary test --remote
|
|
49
|
+
canary test --remote --tag smoke
|
|
50
|
+
canary test --remote --property "My App" --environment staging
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
- `
|
|
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
|
-
|
|
64
|
+
Search and inspect QA issues detected by Canary.
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
### List issues
|
|
57
67
|
|
|
58
68
|
```bash
|
|
59
|
-
canary
|
|
60
|
-
canary
|
|
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
|
-
- `--
|
|
65
|
-
- `--
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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-
|
|
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-
|
|
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,
|
|
27
|
-
const res = await fetch(`${apiUrl}${
|
|
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
|
|
43
|
-
const
|
|
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-
|
|
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"]}
|