@canaryai/cli 0.1.5 → 0.1.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/dist/index.js CHANGED
@@ -1,1574 +1,25 @@
1
- #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
5
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
6
- }) : x)(function(x) {
7
- if (typeof require !== "undefined") return require.apply(this, arguments);
8
- throw Error('Dynamic require of "' + x + '" is not supported');
9
- });
10
- var __esm = (fn, res) => function __init() {
11
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
- };
13
- var __export = (target, all) => {
14
- for (var name in all)
15
- __defProp(target, name, { get: all[name], enumerable: true });
16
- };
17
-
18
- // src/auth.ts
19
- import fs3 from "fs/promises";
20
- import os2 from "os";
21
- import path3 from "path";
22
- async function readStoredToken() {
23
- try {
24
- const filePath = path3.join(os2.homedir(), ".config", "canary-cli", "auth.json");
25
- const content = await fs3.readFile(filePath, "utf8");
26
- const parsed = JSON.parse(content);
27
- return typeof parsed.token === "string" ? parsed.token : null;
28
- } catch {
29
- return null;
30
- }
31
- }
32
- var init_auth = __esm({
33
- "src/auth.ts"() {
34
- "use strict";
35
- }
36
- });
37
-
38
- // src/local-run.ts
39
- import process2 from "process";
40
- function getArgValue(argv, key) {
41
- const index = argv.indexOf(key);
42
- if (index === -1) return void 0;
43
- return argv[index + 1];
44
- }
45
- async function runLocalTest(argv) {
46
- const apiUrl = getArgValue(argv, "--api-url") ?? process2.env.CANARY_API_URL ?? "https://api.trycanary.ai";
47
- const token = getArgValue(argv, "--token") ?? process2.env.CANARY_API_TOKEN ?? await readStoredToken();
48
- const title = getArgValue(argv, "--title");
49
- const featureSpec = getArgValue(argv, "--feature");
50
- const startUrl = getArgValue(argv, "--start-url");
51
- const tunnelUrl = getArgValue(argv, "--tunnel-url");
52
- if (!tunnelUrl && !startUrl) {
53
- console.error("Missing --tunnel-url or --start-url");
54
- process2.exit(1);
55
- }
56
- if (!token) {
57
- console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
58
- process2.exit(1);
59
- }
60
- const result = await createLocalRun({
61
- apiUrl,
62
- token,
63
- title,
64
- featureSpec,
65
- startUrl,
66
- tunnelUrl
67
- });
68
- console.log(`Local test queued: ${result.runId}`);
69
- if (result.watchUrl) {
70
- console.log(`Watch: ${result.watchUrl}`);
71
- }
72
- }
73
- async function createLocalRun(input) {
74
- const body = {
75
- title: input.title ?? null,
76
- featureSpec: input.featureSpec ?? null,
77
- startUrl: input.startUrl ?? null,
78
- tunnelPublicUrl: input.tunnelUrl ?? null
79
- };
80
- const response = await fetch(`${input.apiUrl}/local-tests/runs`, {
81
- method: "POST",
82
- headers: {
83
- "content-type": "application/json",
84
- authorization: `Bearer ${input.token}`
85
- },
86
- body: JSON.stringify(body)
87
- });
88
- const json = await response.json();
89
- if (!response.ok || !json.ok || !json.runId) {
90
- throw new Error(json.error ?? response.statusText);
91
- }
92
- return { runId: json.runId, watchUrl: json.watchUrl };
93
- }
94
- var init_local_run = __esm({
95
- "src/local-run.ts"() {
96
- "use strict";
97
- init_auth();
98
- }
99
- });
100
-
101
- // src/tunnel.ts
102
- import { createHash } from "crypto";
103
- import os3 from "os";
104
- import process3 from "process";
105
- function getArgValue2(argv, key) {
106
- const index = argv.indexOf(key);
107
- if (index === -1) return void 0;
108
- return argv[index + 1];
109
- }
110
- function toWebSocketUrl(apiUrl) {
111
- const url = new URL(apiUrl);
112
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
113
- return url.toString();
114
- }
115
- function createFingerprint() {
116
- const raw = `${os3.hostname()}-${os3.userInfo().username}-${process3.version}`;
117
- return createHash("sha256").update(raw).digest("hex").slice(0, 16);
118
- }
119
- async function runTunnel(argv) {
120
- const apiUrl = getArgValue2(argv, "--api-url") ?? process3.env.CANARY_API_URL ?? "https://api.trycanary.ai";
121
- const token = getArgValue2(argv, "--token") ?? process3.env.CANARY_API_TOKEN ?? await readStoredToken();
122
- const portRaw = getArgValue2(argv, "--port") ?? process3.env.CANARY_LOCAL_PORT;
123
- if (!portRaw) {
124
- console.error("Missing --port");
125
- process3.exit(1);
126
- }
127
- const port = Number(portRaw);
128
- if (Number.isNaN(port) || port <= 0) {
129
- console.error("Invalid --port value");
130
- process3.exit(1);
131
- }
132
- if (!token) {
133
- console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
134
- process3.exit(1);
135
- }
136
- const maxReconnectAttempts = 10;
137
- const baseReconnectDelayMs = 1e3;
138
- let reconnectAttempts = 0;
139
- const connect = async () => {
140
- try {
141
- const data = await createTunnel({
142
- apiUrl,
143
- token,
144
- port
145
- });
146
- console.log(`Tunnel connected: ${data.publicUrl ?? data.tunnelId}`);
147
- if (data.publicUrl) {
148
- console.log(`Public URL: ${data.publicUrl}`);
149
- console.log("");
150
- console.log("To use this tunnel for sandbox agent callbacks, add to apps/api/.env:");
151
- console.log(` SANDBOX_AGENT_API_URL=${data.publicUrl}`);
152
- console.log("");
153
- }
154
- const ws = connectTunnel({
155
- apiUrl,
156
- tunnelId: data.tunnelId,
157
- token: data.token,
158
- port,
159
- onReady: () => {
160
- reconnectAttempts = 0;
161
- }
162
- });
163
- return new Promise((resolve, reject) => {
164
- ws.onclose = (event) => {
165
- console.log(`Tunnel closed (code: ${event.code})`);
166
- if (reconnectAttempts < maxReconnectAttempts) {
167
- const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
168
- reconnectAttempts++;
169
- console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
170
- setTimeout(() => {
171
- connect().then(resolve).catch(reject);
172
- }, delay);
173
- } else {
174
- console.error("Max reconnection attempts reached. Exiting.");
175
- process3.exit(1);
176
- }
177
- };
178
- ws.onerror = (event) => {
179
- console.error("Tunnel error:", event);
180
- };
181
- });
182
- } catch (error) {
183
- if (reconnectAttempts < maxReconnectAttempts) {
184
- const delay = Math.min(baseReconnectDelayMs * Math.pow(2, reconnectAttempts), 3e4);
185
- reconnectAttempts++;
186
- console.error(`Failed to create tunnel: ${error}`);
187
- console.log(`Retrying in ${delay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})...`);
188
- await new Promise((resolve) => setTimeout(resolve, delay));
189
- return connect();
190
- } else {
191
- console.error("Max reconnection attempts reached. Exiting.");
192
- process3.exit(1);
193
- }
194
- }
195
- };
196
- await connect();
197
- }
198
- async function createTunnel(input) {
199
- const response = await fetch(`${input.apiUrl}/local-tests/tunnels`, {
200
- method: "POST",
201
- headers: {
202
- "content-type": "application/json",
203
- authorization: `Bearer ${input.token}`
204
- },
205
- body: JSON.stringify({
206
- requestedPort: input.port,
207
- clientFingerprint: createFingerprint()
208
- })
209
- });
210
- const data = await response.json();
211
- if (!response.ok || !data.ok || !data.tunnelId || !data.token) {
212
- throw new Error(data.error ?? response.statusText);
213
- }
214
- return { tunnelId: data.tunnelId, publicUrl: data.publicUrl, token: data.token };
215
- }
216
- function connectTunnel(input) {
217
- const wsUrl = toWebSocketUrl(
218
- `${input.apiUrl}/local-tests/tunnels/${input.tunnelId}/connect?token=${input.token}`
219
- );
220
- const ws = new WebSocket(wsUrl);
221
- const wsConnections = /* @__PURE__ */ new Map();
222
- const wsQueues = /* @__PURE__ */ new Map();
223
- ws.onopen = () => {
224
- input.onReady?.();
225
- };
226
- ws.onerror = (event) => {
227
- console.error("Tunnel error", event);
228
- };
229
- ws.onclose = () => {
230
- console.log("Tunnel closed");
231
- };
232
- ws.onmessage = async (event) => {
233
- try {
234
- const raw = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString();
235
- const payload = JSON.parse(raw);
236
- if (payload.type === "http_request") {
237
- const request = payload;
238
- const targetUrl = `http://localhost:${input.port}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
239
- const body = request.bodyBase64 ? Buffer.from(request.bodyBase64, "base64") : void 0;
240
- const headers = { ...request.headers };
241
- delete headers.host;
242
- delete headers["content-length"];
243
- try {
244
- const res = await fetch(targetUrl, {
245
- method: request.method,
246
- headers,
247
- body: body ?? void 0
248
- });
249
- const resBody = await res.arrayBuffer();
250
- const resHeaders = Object.fromEntries(res.headers.entries());
251
- delete resHeaders["set-cookie"];
252
- const getSetCookie = res.headers.getSetCookie;
253
- const setCookieValues = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
254
- const fallbackSetCookie = res.headers.get("set-cookie");
255
- if (setCookieValues.length === 0 && fallbackSetCookie) {
256
- setCookieValues.push(fallbackSetCookie);
257
- }
258
- if (setCookieValues.length > 0) {
259
- resHeaders["set-cookie"] = setCookieValues;
260
- }
261
- const responsePayload = {
262
- type: "http_response",
263
- id: request.id,
264
- status: res.status,
265
- headers: resHeaders,
266
- bodyBase64: resBody.byteLength ? Buffer.from(resBody).toString("base64") : null
267
- };
268
- ws.send(JSON.stringify(responsePayload));
269
- } catch (error) {
270
- const responsePayload = {
271
- type: "http_response",
272
- id: request.id,
273
- status: 502,
274
- headers: { "content-type": "text/plain" },
275
- bodyBase64: Buffer.from(`Tunnel error: ${String(error)}`).toString("base64")
276
- };
277
- ws.send(JSON.stringify(responsePayload));
278
- }
279
- }
280
- if (payload.type === "ws_open") {
281
- const request = payload;
282
- const targetUrl = `ws://localhost:${input.port}${request.path.startsWith("/") ? request.path : `/${request.path}`}`;
283
- const protocolsHeader = request.headers["sec-websocket-protocol"] ?? request.headers["Sec-WebSocket-Protocol"];
284
- const protocols = protocolsHeader ? protocolsHeader.split(",").map((value) => value.trim()).filter(Boolean) : void 0;
285
- const localWs = new WebSocket(targetUrl, protocols);
286
- wsConnections.set(request.id, localWs);
287
- localWs.onopen = () => {
288
- ws.send(JSON.stringify({ type: "ws_ready", id: request.id }));
289
- const queued = wsQueues.get(request.id);
290
- if (queued) {
291
- for (const message of queued) {
292
- ws.send(JSON.stringify(message));
293
- }
294
- wsQueues.delete(request.id);
295
- }
296
- };
297
- localWs.onmessage = (event2) => {
298
- const data = typeof event2.data === "string" ? Buffer.from(event2.data) : Buffer.from(event2.data);
299
- const response = {
300
- type: "ws_message",
301
- id: request.id,
302
- dataBase64: data.toString("base64"),
303
- isBinary: typeof event2.data !== "string"
304
- };
305
- ws.send(JSON.stringify(response));
306
- };
307
- localWs.onclose = (event2) => {
308
- wsConnections.delete(request.id);
309
- const response = {
310
- type: "ws_close",
311
- id: request.id,
312
- code: event2.code,
313
- reason: event2.reason
314
- };
315
- ws.send(JSON.stringify(response));
316
- };
317
- localWs.onerror = () => {
318
- wsConnections.delete(request.id);
319
- const response = {
320
- type: "ws_close",
321
- id: request.id,
322
- code: 1011,
323
- reason: "local_ws_error"
324
- };
325
- ws.send(JSON.stringify(response));
326
- };
327
- }
328
- if (payload.type === "ws_message") {
329
- const message = payload;
330
- const localWs = wsConnections.get(message.id);
331
- const data = Buffer.from(message.dataBase64, "base64");
332
- if (!localWs || localWs.readyState !== WebSocket.OPEN) {
333
- const queued = wsQueues.get(message.id) ?? [];
334
- queued.push(message);
335
- wsQueues.set(message.id, queued);
336
- return;
337
- }
338
- if (message.isBinary) {
339
- localWs.send(data);
340
- } else {
341
- localWs.send(data.toString());
342
- }
343
- }
344
- if (payload.type === "ws_close") {
345
- const message = payload;
346
- const localWs = wsConnections.get(message.id);
347
- if (!localWs) {
348
- const queued = wsQueues.get(message.id) ?? [];
349
- queued.push(message);
350
- wsQueues.set(message.id, queued);
351
- return;
352
- }
353
- localWs.close(message.code ?? 1e3, message.reason ?? "");
354
- wsConnections.delete(message.id);
355
- }
356
- if (payload.type === "health_ping") {
357
- ws.send(JSON.stringify({ type: "health_pong" }));
358
- }
359
- } catch (error) {
360
- console.error("Tunnel message error", error);
361
- }
362
- };
363
- return ws;
364
- }
365
- var init_tunnel = __esm({
366
- "src/tunnel.ts"() {
367
- "use strict";
368
- init_auth();
369
- }
370
- });
371
-
372
- // src/local-browser/host.ts
373
- import { chromium } from "playwright";
374
- var HEARTBEAT_INTERVAL_MS, RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS, MAX_RECONNECT_ATTEMPTS, LocalBrowserHost;
375
- var init_host = __esm({
376
- "src/local-browser/host.ts"() {
377
- "use strict";
378
- HEARTBEAT_INTERVAL_MS = 3e4;
379
- RECONNECT_DELAY_MS = 1e3;
380
- MAX_RECONNECT_DELAY_MS = 3e4;
381
- MAX_RECONNECT_ATTEMPTS = 10;
382
- LocalBrowserHost = class {
383
- options;
384
- ws = null;
385
- browser = null;
386
- context = null;
387
- page = null;
388
- pendingDialogs = [];
389
- heartbeatTimer = null;
390
- reconnectAttempts = 0;
391
- isShuttingDown = false;
392
- lastSnapshotYaml = "";
393
- constructor(options) {
394
- this.options = options;
395
- }
396
- log(level, message, data) {
397
- if (this.options.onLog) {
398
- this.options.onLog(level, message, data);
399
- } else {
400
- const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
401
- fn(`[LocalBrowserHost] ${message}`, data ?? "");
402
- }
403
- }
404
- // =========================================================================
405
- // Lifecycle
406
- // =========================================================================
407
- async start() {
408
- this.log("info", "Starting local browser host", {
409
- browserMode: this.options.browserMode,
410
- sessionId: this.options.sessionId
411
- });
412
- await this.connectWebSocket();
413
- await this.launchBrowser();
414
- this.sendSessionEvent("browser_ready");
415
- }
416
- async stop() {
417
- this.isShuttingDown = true;
418
- this.log("info", "Stopping local browser host");
419
- this.stopHeartbeat();
420
- if (this.ws) {
421
- try {
422
- this.ws.close(1e3, "Shutdown");
423
- } catch {
424
- }
425
- this.ws = null;
426
- }
427
- if (this.context) {
428
- try {
429
- await this.context.close();
430
- } catch {
431
- }
432
- this.context = null;
433
- }
434
- if (this.browser) {
435
- try {
436
- await this.browser.close();
437
- } catch {
438
- }
439
- this.browser = null;
440
- }
441
- this.page = null;
442
- this.log("info", "Local browser host stopped");
443
- }
444
- // =========================================================================
445
- // WebSocket Connection
446
- // =========================================================================
447
- async connectWebSocket() {
448
- return new Promise((resolve, reject) => {
449
- const wsUrl = `${this.options.apiUrl.replace("http", "ws")}/local-browser/sessions/${this.options.sessionId}/connect?token=${this.options.wsToken}`;
450
- this.log("info", "Connecting to cloud API", { url: wsUrl.replace(/token=.*/, "token=***") });
451
- const ws = new WebSocket(wsUrl);
452
- ws.onopen = () => {
453
- this.log("info", "Connected to cloud API");
454
- this.ws = ws;
455
- this.reconnectAttempts = 0;
456
- this.startHeartbeat();
457
- resolve();
458
- };
459
- ws.onmessage = (event) => {
460
- this.handleMessage(event.data);
461
- };
462
- ws.onerror = (event) => {
463
- this.log("error", "WebSocket error", event);
464
- };
465
- ws.onclose = () => {
466
- this.log("info", "WebSocket closed");
467
- this.stopHeartbeat();
468
- this.ws = null;
469
- if (!this.isShuttingDown) {
470
- this.scheduleReconnect();
471
- }
472
- };
473
- setTimeout(() => {
474
- if (!this.ws) {
475
- reject(new Error("WebSocket connection timeout"));
476
- }
477
- }, 3e4);
478
- });
479
- }
480
- scheduleReconnect() {
481
- if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
482
- this.log("error", "Max reconnection attempts reached, giving up");
483
- this.stop();
484
- return;
485
- }
486
- const delay = Math.min(
487
- RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
488
- MAX_RECONNECT_DELAY_MS
489
- );
490
- this.reconnectAttempts++;
491
- this.log("info", `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
492
- setTimeout(async () => {
493
- try {
494
- await this.connectWebSocket();
495
- this.sendSessionEvent("connected");
496
- if (this.page) {
497
- this.sendSessionEvent("browser_ready");
498
- }
499
- } catch (error) {
500
- this.log("error", "Reconnection failed", error);
501
- this.scheduleReconnect();
502
- }
503
- }, delay);
504
- }
505
- // =========================================================================
506
- // Heartbeat
507
- // =========================================================================
508
- startHeartbeat() {
509
- this.stopHeartbeat();
510
- this.heartbeatTimer = setInterval(() => {
511
- if (this.ws?.readyState === WebSocket.OPEN) {
512
- const ping = {
513
- type: "heartbeat",
514
- id: crypto.randomUUID(),
515
- timestamp: Date.now(),
516
- direction: "pong"
517
- };
518
- this.ws.send(JSON.stringify(ping));
519
- }
520
- }, HEARTBEAT_INTERVAL_MS);
521
- }
522
- stopHeartbeat() {
523
- if (this.heartbeatTimer) {
524
- clearInterval(this.heartbeatTimer);
525
- this.heartbeatTimer = null;
526
- }
527
- }
528
- // =========================================================================
529
- // Browser Management
530
- // =========================================================================
531
- async launchBrowser() {
532
- const { browserMode, cdpUrl, headless = true, storageStatePath } = this.options;
533
- if (browserMode === "cdp" && cdpUrl) {
534
- this.log("info", "Connecting to existing Chrome via CDP", { cdpUrl });
535
- this.browser = await chromium.connectOverCDP(cdpUrl);
536
- const contexts = this.browser.contexts();
537
- this.context = contexts[0] ?? await this.browser.newContext();
538
- const pages = this.context.pages();
539
- this.page = pages[0] ?? await this.context.newPage();
540
- } else {
541
- this.log("info", "Launching new Playwright browser", { headless });
542
- this.browser = await chromium.launch({
543
- headless,
544
- args: ["--no-sandbox"]
545
- });
546
- const contextOptions = {
547
- viewport: { width: 1920, height: 1080 }
548
- };
549
- if (storageStatePath) {
550
- try {
551
- await Bun.file(storageStatePath).exists();
552
- contextOptions.storageState = storageStatePath;
553
- this.log("info", "Loading storage state", { storageStatePath });
554
- } catch {
555
- this.log("debug", "Storage state file not found, starting fresh");
556
- }
557
- }
558
- this.context = await this.browser.newContext(contextOptions);
559
- this.page = await this.context.newPage();
560
- }
561
- this.page.on("dialog", (dialog) => {
562
- this.pendingDialogs.push(dialog);
563
- });
564
- this.log("info", "Browser ready");
565
- }
566
- // =========================================================================
567
- // Message Handling
568
- // =========================================================================
569
- handleMessage(data) {
570
- try {
571
- const message = JSON.parse(data);
572
- if (message.type === "heartbeat" && message.direction === "ping") {
573
- const pong = {
574
- type: "heartbeat",
575
- id: crypto.randomUUID(),
576
- timestamp: Date.now(),
577
- direction: "pong"
578
- };
579
- this.ws?.send(JSON.stringify(pong));
580
- return;
581
- }
582
- if (message.type === "command") {
583
- this.handleCommand(message);
584
- return;
585
- }
586
- this.log("debug", "Received unknown message type", message);
587
- } catch (error) {
588
- this.log("error", "Failed to parse message", { error, data });
589
- }
590
- }
591
- async handleCommand(command) {
592
- const startTime = Date.now();
593
- this.log("debug", `Executing command: ${command.method}`, { id: command.id });
594
- try {
595
- const result = await this.executeMethod(command.method, command.args);
596
- const response = {
597
- type: "response",
598
- id: crypto.randomUUID(),
599
- timestamp: Date.now(),
600
- requestId: command.id,
601
- success: true,
602
- result
603
- };
604
- this.ws?.send(JSON.stringify(response));
605
- this.log("debug", `Command completed: ${command.method}`, {
606
- id: command.id,
607
- durationMs: Date.now() - startTime
608
- });
609
- } catch (error) {
610
- const errorMessage = error instanceof Error ? error.message : String(error);
611
- const response = {
612
- type: "response",
613
- id: crypto.randomUUID(),
614
- timestamp: Date.now(),
615
- requestId: command.id,
616
- success: false,
617
- error: errorMessage,
618
- stack: error instanceof Error ? error.stack : void 0
619
- };
620
- this.ws?.send(JSON.stringify(response));
621
- this.log("error", `Command failed: ${command.method}`, {
622
- id: command.id,
623
- error: errorMessage
624
- });
625
- }
626
- }
627
- sendSessionEvent(event, error) {
628
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
629
- const message = {
630
- type: "session",
631
- id: crypto.randomUUID(),
632
- timestamp: Date.now(),
633
- event,
634
- browserMode: this.options.browserMode,
635
- error
636
- };
637
- this.ws.send(JSON.stringify(message));
638
- }
639
- // =========================================================================
640
- // Method Execution
641
- // =========================================================================
642
- async executeMethod(method, args) {
643
- switch (method) {
644
- // Lifecycle
645
- case "connect":
646
- return this.connect(args[0]);
647
- case "disconnect":
648
- return this.disconnect();
649
- // Navigation
650
- case "navigate":
651
- return this.navigate(args[0], args[1]);
652
- case "navigateBack":
653
- return this.navigateBack(args[0]);
654
- // Page Inspection
655
- case "snapshot":
656
- return this.snapshot(args[0]);
657
- case "takeScreenshot":
658
- return this.takeScreenshot(args[0]);
659
- case "evaluate":
660
- return this.evaluate(args[0], args[1]);
661
- case "runCode":
662
- return this.runCode(args[0], args[1]);
663
- case "consoleMessages":
664
- return this.consoleMessages(args[0]);
665
- case "networkRequests":
666
- return this.networkRequests(args[0]);
667
- // Interaction
668
- case "click":
669
- return this.click(args[0], args[1], args[2]);
670
- case "clickAtCoordinates":
671
- return this.clickAtCoordinates(
672
- args[0],
673
- args[1],
674
- args[2],
675
- args[3]
676
- );
677
- case "moveToCoordinates":
678
- return this.moveToCoordinates(
679
- args[0],
680
- args[1],
681
- args[2],
682
- args[3]
683
- );
684
- case "dragCoordinates":
685
- return this.dragCoordinates(
686
- args[0],
687
- args[1],
688
- args[2],
689
- args[3],
690
- args[4],
691
- args[5]
692
- );
693
- case "hover":
694
- return this.hover(args[0], args[1], args[2]);
695
- case "drag":
696
- return this.drag(
697
- args[0],
698
- args[1],
699
- args[2],
700
- args[3],
701
- args[4]
702
- );
703
- case "type":
704
- return this.type(
705
- args[0],
706
- args[1],
707
- args[2],
708
- args[3],
709
- args[4]
710
- );
711
- case "pressKey":
712
- return this.pressKey(args[0], args[1]);
713
- case "fillForm":
714
- return this.fillForm(args[0], args[1]);
715
- case "selectOption":
716
- return this.selectOption(
717
- args[0],
718
- args[1],
719
- args[2],
720
- args[3]
721
- );
722
- case "fileUpload":
723
- return this.fileUpload(args[0], args[1]);
724
- // Dialogs
725
- case "handleDialog":
726
- return this.handleDialog(args[0], args[1], args[2]);
727
- // Waiting
728
- case "waitFor":
729
- return this.waitFor(args[0]);
730
- // Browser Management
731
- case "close":
732
- return this.closePage(args[0]);
733
- case "resize":
734
- return this.resize(args[0], args[1], args[2]);
735
- case "tabs":
736
- return this.tabs(args[0], args[1], args[2]);
737
- // Storage
738
- case "getStorageState":
739
- return this.getStorageState(args[0]);
740
- case "getCurrentUrl":
741
- return this.getCurrentUrl(args[0]);
742
- case "getTitle":
743
- return this.getTitle(args[0]);
744
- case "getLinks":
745
- return this.getLinks(args[0]);
746
- case "getElementBoundingBox":
747
- return this.getElementBoundingBox(args[0], args[1]);
748
- // Tracing
749
- case "startTracing":
750
- return this.startTracing(args[0]);
751
- case "stopTracing":
752
- return this.stopTracing(args[0]);
753
- // Video
754
- case "isVideoRecordingEnabled":
755
- return false;
756
- // Video not supported in CLI host currently
757
- case "saveVideo":
758
- return null;
759
- case "getVideoPath":
760
- return null;
761
- default:
762
- throw new Error(`Unknown method: ${method}`);
763
- }
764
- }
765
- // =========================================================================
766
- // IBrowserClient Method Implementations
767
- // =========================================================================
768
- getPage() {
769
- if (!this.page) throw new Error("No page available");
770
- return this.page;
771
- }
772
- resolveRef(ref) {
773
- return this.getPage().locator(`aria-ref=${ref}`);
774
- }
775
- async connect(_options) {
776
- return;
777
- }
778
- async disconnect() {
779
- await this.stop();
780
- }
781
- async navigate(url, _opts) {
782
- const page = this.getPage();
783
- await page.goto(url, { waitUntil: "domcontentloaded" });
784
- await page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
785
- });
786
- return this.captureSnapshot();
787
- }
788
- async navigateBack(_opts) {
789
- await this.getPage().goBack();
790
- return this.captureSnapshot();
791
- }
792
- async snapshot(_opts) {
793
- return this.captureSnapshot();
794
- }
795
- async captureSnapshot() {
796
- const page = this.getPage();
797
- this.lastSnapshotYaml = await page._snapshotForAI({ mode: "full" });
798
- return this.lastSnapshotYaml;
799
- }
800
- async takeScreenshot(opts) {
801
- const page = this.getPage();
802
- const buffer = await page.screenshot({
803
- type: opts?.type ?? "jpeg",
804
- fullPage: opts?.fullPage ?? false
805
- });
806
- const mime = opts?.type === "png" ? "image/png" : "image/jpeg";
807
- return `data:${mime};base64,${buffer.toString("base64")}`;
808
- }
809
- async evaluate(fn, _opts) {
810
- const page = this.getPage();
811
- return page.evaluate(new Function(`return (${fn})()`));
812
- }
813
- async runCode(code, _opts) {
814
- const page = this.getPage();
815
- const fn = new Function("page", `return (async () => { ${code} })()`);
816
- return fn(page);
817
- }
818
- async consoleMessages(_opts) {
819
- return "Console message capture not implemented in CLI host";
820
- }
821
- async networkRequests(_opts) {
822
- return "Network request capture not implemented in CLI host";
823
- }
824
- async click(ref, _elementDesc, opts) {
825
- const locator = this.resolveRef(ref);
826
- await locator.scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
827
- });
828
- const box = await locator.boundingBox();
829
- if (box) {
830
- const centerX = box.x + box.width / 2;
831
- const centerY = box.y + box.height / 2;
832
- const page = this.getPage();
833
- if (opts?.modifiers?.length) {
834
- for (const mod of opts.modifiers) {
835
- await page.keyboard.down(mod);
836
- }
837
- }
838
- if (opts?.doubleClick) {
839
- await page.mouse.dblclick(centerX, centerY);
840
- } else {
841
- await page.mouse.click(centerX, centerY);
842
- }
843
- if (opts?.modifiers?.length) {
844
- for (const mod of opts.modifiers) {
845
- await page.keyboard.up(mod);
846
- }
847
- }
848
- } else {
849
- if (opts?.doubleClick) {
850
- await locator.dblclick({ timeout: opts?.timeoutMs ?? 3e4 });
851
- } else {
852
- await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
853
- }
854
- }
855
- }
856
- async clickAtCoordinates(x, y, _elementDesc, opts) {
857
- const page = this.getPage();
858
- if (opts?.doubleClick) {
859
- await page.mouse.dblclick(x, y);
860
- } else {
861
- await page.mouse.click(x, y);
862
- }
863
- }
864
- async moveToCoordinates(x, y, _elementDesc, _opts) {
865
- await this.getPage().mouse.move(x, y);
866
- }
867
- async dragCoordinates(startX, startY, endX, endY, _elementDesc, _opts) {
868
- const page = this.getPage();
869
- await page.mouse.move(startX, startY);
870
- await page.mouse.down();
871
- await page.mouse.move(endX, endY);
872
- await page.mouse.up();
873
- }
874
- async hover(ref, _elementDesc, opts) {
875
- await this.resolveRef(ref).hover({ timeout: opts?.timeoutMs ?? 3e4 });
876
- }
877
- async drag(startRef, _startElement, endRef, _endElement, opts) {
878
- const startLocator = this.resolveRef(startRef);
879
- const endLocator = this.resolveRef(endRef);
880
- await startLocator.dragTo(endLocator, { timeout: opts?.timeoutMs ?? 6e4 });
881
- }
882
- async type(ref, text, _elementDesc, submit, opts) {
883
- const locator = this.resolveRef(ref);
884
- await locator.clear();
885
- await locator.pressSequentially(text, {
886
- delay: opts?.delay ?? 0,
887
- timeout: opts?.timeoutMs ?? 3e4
888
- });
889
- if (submit) {
890
- await locator.press("Enter");
891
- }
892
- }
893
- async pressKey(key, _opts) {
894
- await this.getPage().keyboard.press(key);
895
- }
896
- async fillForm(fields, opts) {
897
- for (const field of fields) {
898
- const locator = this.resolveRef(field.ref);
899
- const fieldType = field.type ?? "textbox";
900
- switch (fieldType) {
901
- case "checkbox": {
902
- const isChecked = await locator.isChecked();
903
- const shouldBeChecked = field.value === "true";
904
- if (shouldBeChecked !== isChecked) {
905
- await locator.click({ timeout: opts?.timeoutMs ?? 3e4 });
906
- }
907
- break;
908
- }
909
- case "radio":
910
- await locator.check({ timeout: opts?.timeoutMs ?? 3e4 });
911
- break;
912
- case "combobox":
913
- await locator.selectOption(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
914
- break;
915
- default:
916
- await locator.fill(field.value, { timeout: opts?.timeoutMs ?? 3e4 });
917
- }
918
- }
919
- }
920
- async selectOption(ref, value, _elementDesc, opts) {
921
- await this.resolveRef(ref).selectOption(value, { timeout: opts?.timeoutMs ?? 3e4 });
922
- }
923
- async fileUpload(paths, opts) {
924
- const fileChooser = await this.getPage().waitForEvent("filechooser", {
925
- timeout: opts?.timeoutMs ?? 3e4
926
- });
927
- await fileChooser.setFiles(paths);
928
- }
929
- async handleDialog(action, promptText, _opts) {
930
- const dialog = this.pendingDialogs.shift();
931
- if (dialog) {
932
- if (action === "accept") {
933
- await dialog.accept(promptText);
934
- } else {
935
- await dialog.dismiss();
936
- }
937
- }
938
- }
939
- async waitFor(opts) {
940
- const page = this.getPage();
941
- const timeout = opts?.timeout ?? opts?.timeoutMs ?? 3e4;
942
- if (opts?.timeSec) {
943
- await page.waitForTimeout(opts.timeSec * 1e3);
944
- return;
945
- }
946
- if (opts?.text) {
947
- await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
948
- return;
949
- }
950
- if (opts?.textGone) {
951
- await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
952
- return;
953
- }
954
- if (opts?.selector) {
955
- await page.locator(opts.selector).waitFor({
956
- state: opts.state ?? "visible",
957
- timeout
958
- });
959
- }
960
- }
961
- async closePage(_opts) {
962
- await this.getPage().close();
963
- this.page = null;
964
- }
965
- async resize(width, height, _opts) {
966
- await this.getPage().setViewportSize({ width, height });
967
- }
968
- async tabs(action, index, _opts) {
969
- if (!this.context) throw new Error("No context available");
970
- const pages = this.context.pages();
971
- switch (action) {
972
- case "list":
973
- return Promise.all(
974
- pages.map(async (p, i) => ({
975
- index: i,
976
- url: p.url(),
977
- title: await p.title().catch(() => "")
978
- }))
979
- );
980
- case "new": {
981
- const newPage = await this.context.newPage();
982
- this.page = newPage;
983
- newPage.on("dialog", (dialog) => this.pendingDialogs.push(dialog));
984
- return { index: pages.length };
985
- }
986
- case "close":
987
- if (index !== void 0 && pages[index]) {
988
- await pages[index].close();
989
- } else {
990
- await this.page?.close();
991
- }
992
- this.page = this.context.pages()[0] ?? null;
993
- break;
994
- case "select":
995
- if (index !== void 0 && pages[index]) {
996
- this.page = pages[index];
997
- }
998
- break;
999
- }
1000
- return null;
1001
- }
1002
- async getStorageState(_opts) {
1003
- if (!this.context) throw new Error("No context available");
1004
- return this.context.storageState();
1005
- }
1006
- async getCurrentUrl(_opts) {
1007
- return this.getPage().url();
1008
- }
1009
- async getTitle(_opts) {
1010
- return this.getPage().title();
1011
- }
1012
- async getLinks(_opts) {
1013
- const page = this.getPage();
1014
- return page.$$eval(
1015
- "a[href]",
1016
- (links) => links.map((a) => a.href).filter((h) => !!h && (h.startsWith("http://") || h.startsWith("https://")))
1017
- );
1018
- }
1019
- async getElementBoundingBox(ref, _opts) {
1020
- const locator = this.resolveRef(ref);
1021
- const box = await locator.boundingBox();
1022
- if (!box) return null;
1023
- return { x: box.x, y: box.y, width: box.width, height: box.height };
1024
- }
1025
- async startTracing(_opts) {
1026
- if (!this.context) throw new Error("No context available");
1027
- await this.context.tracing.start({ screenshots: true, snapshots: true });
1028
- }
1029
- async stopTracing(_opts) {
1030
- if (!this.context) throw new Error("No context available");
1031
- const tracePath = `/tmp/trace-${Date.now()}.zip`;
1032
- await this.context.tracing.stop({ path: tracePath });
1033
- return {
1034
- trace: tracePath,
1035
- network: "",
1036
- resources: "",
1037
- directory: null,
1038
- legend: `Trace saved to ${tracePath}`
1039
- };
1040
- }
1041
- };
1042
- }
1043
- });
1044
-
1045
- // src/mcp.ts
1046
- var mcp_exports = {};
1047
- __export(mcp_exports, {
1048
- runMcp: () => runMcp
1049
- });
1050
- import process7 from "process";
1051
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1052
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1053
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1054
- import { createParser as createParser2 } from "eventsource-parser";
1055
- function resolveApiUrl(input) {
1056
- return input ?? process7.env.CANARY_API_URL ?? DEFAULT_API_URL;
1057
- }
1058
- async function resolveToken() {
1059
- const token = process7.env.CANARY_API_TOKEN ?? await readStoredToken();
1060
- if (!token) {
1061
- throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
1062
- }
1063
- return token;
1064
- }
1065
- function toolText(text) {
1066
- return { content: [{ type: "text", text }] };
1067
- }
1068
- function toolJson(data) {
1069
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1070
- }
1071
- async function runMcp(argv) {
1072
- const server = new Server(
1073
- { name: "canary-cli", version: "0.1.0" },
1074
- { capabilities: { tools: {} } }
1075
- );
1076
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
1077
- tools: [
1078
- {
1079
- name: "local_run_tests",
1080
- description: "Start an async local test run. A tunnel is opened automatically. Returns runId and watchUrl.",
1081
- inputSchema: {
1082
- type: "object",
1083
- properties: {
1084
- port: { type: "number" },
1085
- instructions: { type: "string" },
1086
- title: { type: "string" }
1087
- },
1088
- required: ["port", "instructions"]
1089
- }
1090
- },
1091
- {
1092
- name: "local_wait_for_results",
1093
- description: "Wait for a local test run to complete. Streams until completion and returns a compact report.",
1094
- inputSchema: {
1095
- type: "object",
1096
- properties: {
1097
- runId: { type: "string" }
1098
- },
1099
- required: ["runId"]
1100
- }
1101
- },
1102
- {
1103
- name: "local_browser_start",
1104
- description: "Start a local browser session that connects to the cloud agent. The cloud agent can then control this browser to test local applications. Returns sessionId for tracking.",
1105
- inputSchema: {
1106
- type: "object",
1107
- properties: {
1108
- mode: {
1109
- type: "string",
1110
- enum: ["playwright", "cdp"],
1111
- description: "Browser mode: 'playwright' for fresh browser, 'cdp' to connect to existing Chrome"
1112
- },
1113
- cdpUrl: {
1114
- type: "string",
1115
- description: "CDP endpoint URL when mode is 'cdp' (e.g. http://localhost:9222)"
1116
- },
1117
- headless: {
1118
- type: "boolean",
1119
- description: "Run browser headless (default: true for playwright mode)"
1120
- },
1121
- storageStatePath: {
1122
- type: "string",
1123
- description: "Path to Playwright storage state JSON for pre-authenticated sessions"
1124
- },
1125
- instructions: {
1126
- type: "string",
1127
- description: "Instructions for the cloud agent on what to test"
1128
- }
1129
- }
1130
- }
1131
- },
1132
- {
1133
- name: "local_browser_status",
1134
- description: "Check the status of a local browser session.",
1135
- inputSchema: {
1136
- type: "object",
1137
- properties: {
1138
- sessionId: { type: "string" }
1139
- },
1140
- required: ["sessionId"]
1141
- }
1142
- },
1143
- {
1144
- name: "local_browser_stop",
1145
- description: "Stop a local browser session and close the browser.",
1146
- inputSchema: {
1147
- type: "object",
1148
- properties: {
1149
- sessionId: { type: "string" }
1150
- },
1151
- required: ["sessionId"]
1152
- }
1153
- },
1154
- {
1155
- name: "local_browser_list",
1156
- description: "List all active local browser sessions.",
1157
- inputSchema: {
1158
- type: "object",
1159
- properties: {}
1160
- }
1161
- },
1162
- {
1163
- name: "local_browser_run",
1164
- description: "Start a test run on an active local browser session. The cloud agent will control the local browser according to the instructions.",
1165
- inputSchema: {
1166
- type: "object",
1167
- properties: {
1168
- sessionId: {
1169
- type: "string",
1170
- description: "The session ID from local_browser_start"
1171
- },
1172
- instructions: {
1173
- type: "string",
1174
- description: "Instructions for the cloud agent on what to test"
1175
- },
1176
- startUrl: {
1177
- type: "string",
1178
- description: "Optional URL to navigate to before starting"
1179
- }
1180
- },
1181
- required: ["sessionId", "instructions"]
1182
- }
1183
- }
1184
- ]
1185
- }));
1186
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
1187
- const token = await resolveToken();
1188
- const tool = req.params.name;
1189
- if (tool === "local_run_tests") {
1190
- const input = req.params.arguments;
1191
- const apiUrl = resolveApiUrl();
1192
- const tunnel = await createTunnel({ apiUrl, token, port: input.port });
1193
- connectTunnel({
1194
- apiUrl,
1195
- tunnelId: tunnel.tunnelId,
1196
- token: tunnel.token,
1197
- port: input.port
1198
- });
1199
- const tunnelUrl = tunnel.publicUrl;
1200
- const run2 = await createLocalRun({
1201
- apiUrl,
1202
- token,
1203
- title: input.title ?? "Local MCP run",
1204
- featureSpec: input.instructions,
1205
- startUrl: void 0,
1206
- tunnelUrl
1207
- });
1208
- return toolJson({
1209
- runId: run2.runId,
1210
- watchUrl: run2.watchUrl,
1211
- tunnelUrl,
1212
- note: "Testing is asynchronous. Use local_wait_for_results with the runId to wait for completion."
1213
- });
1214
- }
1215
- if (tool === "local_wait_for_results") {
1216
- const input = req.params.arguments;
1217
- const apiUrl = resolveApiUrl();
1218
- const report = await waitForResult({ apiUrl, token, runId: input.runId });
1219
- return toolJson(report);
1220
- }
1221
- if (tool === "local_browser_start") {
1222
- const input = req.params.arguments;
1223
- const apiUrl = resolveApiUrl();
1224
- const mode = input.mode ?? "playwright";
1225
- const sessionResponse = await fetch(`${apiUrl}/local-browser/sessions`, {
1226
- method: "POST",
1227
- headers: {
1228
- "Content-Type": "application/json",
1229
- Authorization: `Bearer ${token}`
1230
- },
1231
- body: JSON.stringify({
1232
- browserMode: mode,
1233
- instructions: input.instructions ?? null
1234
- })
1235
- });
1236
- if (!sessionResponse.ok) {
1237
- const text = await sessionResponse.text();
1238
- return toolJson({ ok: false, error: `Failed to create session: ${text}` });
1239
- }
1240
- const session = await sessionResponse.json();
1241
- const host = new LocalBrowserHost({
1242
- apiUrl,
1243
- wsToken: session.wsToken,
1244
- sessionId: session.sessionId,
1245
- browserMode: mode,
1246
- cdpUrl: input.cdpUrl,
1247
- headless: input.headless ?? true,
1248
- storageStatePath: input.storageStatePath,
1249
- onLog: (level, message) => {
1250
- if (level === "error") {
1251
- console.error(`[LocalBrowser] ${message}`);
1252
- }
1253
- }
1254
- });
1255
- host.start().catch((err) => {
1256
- console.error("Failed to start local browser:", err);
1257
- browserSessions.delete(session.sessionId);
1258
- });
1259
- browserSessions.set(session.sessionId, {
1260
- sessionId: session.sessionId,
1261
- host,
1262
- startedAt: Date.now(),
1263
- mode
1264
- });
1265
- return toolJson({
1266
- ok: true,
1267
- sessionId: session.sessionId,
1268
- mode,
1269
- expiresAt: session.expiresAt,
1270
- note: "Browser session started. The cloud agent can now control this browser. Use local_browser_stop to end the session."
1271
- });
1272
- }
1273
- if (tool === "local_browser_status") {
1274
- const input = req.params.arguments;
1275
- const session = browserSessions.get(input.sessionId);
1276
- if (!session) {
1277
- return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
1278
- }
1279
- return toolJson({
1280
- ok: true,
1281
- sessionId: session.sessionId,
1282
- mode: session.mode,
1283
- startedAt: new Date(session.startedAt).toISOString(),
1284
- uptimeMs: Date.now() - session.startedAt
1285
- });
1286
- }
1287
- if (tool === "local_browser_stop") {
1288
- const input = req.params.arguments;
1289
- const session = browserSessions.get(input.sessionId);
1290
- if (!session) {
1291
- return toolJson({ ok: false, error: "Session not found", sessionId: input.sessionId });
1292
- }
1293
- await session.host.stop();
1294
- browserSessions.delete(input.sessionId);
1295
- return toolJson({
1296
- ok: true,
1297
- sessionId: input.sessionId,
1298
- note: "Browser session stopped and browser closed."
1299
- });
1300
- }
1301
- if (tool === "local_browser_list") {
1302
- const sessions = Array.from(browserSessions.values()).map((s) => ({
1303
- sessionId: s.sessionId,
1304
- mode: s.mode,
1305
- startedAt: new Date(s.startedAt).toISOString(),
1306
- uptimeMs: Date.now() - s.startedAt
1307
- }));
1308
- return toolJson({
1309
- ok: true,
1310
- count: sessions.length,
1311
- sessions
1312
- });
1313
- }
1314
- if (tool === "local_browser_run") {
1315
- const input = req.params.arguments;
1316
- const apiUrl = resolveApiUrl();
1317
- const session = browserSessions.get(input.sessionId);
1318
- if (!session) {
1319
- return toolJson({
1320
- ok: false,
1321
- error: "Session not found locally. Make sure you started it with local_browser_start.",
1322
- sessionId: input.sessionId
1323
- });
1324
- }
1325
- const response = await fetch(`${apiUrl}/local-browser/sessions/${input.sessionId}/run`, {
1326
- method: "POST",
1327
- headers: {
1328
- "Content-Type": "application/json",
1329
- Authorization: `Bearer ${token}`
1330
- },
1331
- body: JSON.stringify({
1332
- instructions: input.instructions,
1333
- startUrl: input.startUrl ?? null
1334
- })
1335
- });
1336
- if (!response.ok) {
1337
- const text = await response.text();
1338
- return toolJson({ ok: false, error: `Failed to start run: ${text}` });
1339
- }
1340
- const result = await response.json();
1341
- return toolJson({
1342
- ok: true,
1343
- jobId: result.jobId,
1344
- sessionId: result.sessionId,
1345
- note: "Test run started. The cloud agent is now controlling your local browser. You can watch the browser to see the test in action."
1346
- });
1347
- }
1348
- return toolText(`Unknown tool: ${tool}`);
1349
- });
1350
- const transport = new StdioServerTransport();
1351
- await server.connect(transport);
1352
- return new Promise(() => void 0);
1353
- }
1354
- async function waitForResult(input) {
1355
- await streamUntilComplete(input);
1356
- const response = await fetch(`${input.apiUrl}/local-tests/runs/${input.runId}`, {
1357
- credentials: "include",
1358
- headers: { authorization: `Bearer ${input.token}` }
1359
- });
1360
- const data = await response.json();
1361
- const run2 = data?.data?.run ?? data?.run ?? data?.data;
1362
- const summary = run2?.summaryJson;
1363
- return formatReport({ run: run2, summary });
1364
- }
1365
- async function streamUntilComplete(input) {
1366
- const response = await fetch(`${input.apiUrl}/local-tests/runs/${input.runId}/stream`, {
1367
- headers: { authorization: `Bearer ${input.token}` }
1368
- });
1369
- if (!response.body) return;
1370
- const reader = response.body.getReader();
1371
- const decoder = new TextDecoder();
1372
- const parser = createParser2({
1373
- onEvent: (event) => {
1374
- if (event.event === "status") {
1375
- try {
1376
- const payload = JSON.parse(event.data);
1377
- if (payload?.status === "completed" || payload?.status === "failed") {
1378
- reader.cancel().catch(() => void 0);
1379
- }
1380
- } catch {
1381
- }
1382
- }
1383
- if (event.event === "complete" || event.event === "error") {
1384
- reader.cancel().catch(() => void 0);
1385
- }
1386
- }
1387
- });
1388
- while (true) {
1389
- const { done, value } = await reader.read();
1390
- if (done) break;
1391
- parser.feed(decoder.decode(value, { stream: true }));
1392
- }
1393
- }
1394
- function formatReport(input) {
1395
- if (!input.summary) {
1396
- return {
1397
- runId: input.run?.id,
1398
- status: input.run?.status ?? "unknown",
1399
- summary: "No final report available."
1400
- };
1401
- }
1402
- const tested = Array.isArray(input.summary.testedItems) ? input.summary.testedItems : [];
1403
- const status = input.summary.status ?? input.run?.status ?? "unknown";
1404
- const issues = status === "issues_found" ? input.summary.notes ? [input.summary.notes] : ["Issues reported."] : [];
1405
- return {
1406
- runId: input.run?.id,
1407
- status,
1408
- summary: input.summary.summary ?? "Run completed.",
1409
- testedItems: tested,
1410
- issues,
1411
- notes: input.summary.notes ?? null
1412
- };
1413
- }
1414
- var browserSessions, DEFAULT_API_URL;
1415
- var init_mcp = __esm({
1416
- "src/mcp.ts"() {
1417
- "use strict";
1418
- init_auth();
1419
- init_local_run();
1420
- init_tunnel();
1421
- init_host();
1422
- browserSessions = /* @__PURE__ */ new Map();
1423
- DEFAULT_API_URL = "https://api.trycanary.ai";
1424
- }
1425
- });
1426
-
1427
- // src/local-browser/index.ts
1428
- var local_browser_exports = {};
1429
- __export(local_browser_exports, {
1430
- runLocalBrowser: () => runLocalBrowser
1431
- });
1432
- import process8 from "process";
1433
- function parseArgs(args) {
1434
- const options = {
1435
- mode: "playwright",
1436
- headless: true,
1437
- apiUrl: process8.env.CANARY_API_URL ?? DEFAULT_API_URL2
1438
- };
1439
- for (let i = 0; i < args.length; i++) {
1440
- const arg = args[i];
1441
- const nextArg = args[i + 1];
1442
- switch (arg) {
1443
- case "--mode":
1444
- if (nextArg === "playwright" || nextArg === "cdp") {
1445
- options.mode = nextArg;
1446
- i++;
1447
- }
1448
- break;
1449
- case "--cdp-url":
1450
- options.cdpUrl = nextArg;
1451
- options.mode = "cdp";
1452
- i++;
1453
- break;
1454
- case "--headless":
1455
- options.headless = true;
1456
- break;
1457
- case "--no-headless":
1458
- options.headless = false;
1459
- break;
1460
- case "--storage-state":
1461
- options.storageStatePath = nextArg;
1462
- i++;
1463
- break;
1464
- case "--api-url":
1465
- options.apiUrl = nextArg;
1466
- i++;
1467
- break;
1468
- case "--instructions":
1469
- options.instructions = nextArg;
1470
- i++;
1471
- break;
1472
- }
1473
- }
1474
- return options;
1475
- }
1476
- async function resolveToken2() {
1477
- const token = process8.env.CANARY_API_TOKEN ?? await readStoredToken();
1478
- if (!token) {
1479
- throw new Error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
1480
- }
1481
- return token;
1482
- }
1483
- async function createSession(apiUrl, token, options) {
1484
- const response = await fetch(`${apiUrl}/local-browser/sessions`, {
1485
- method: "POST",
1486
- headers: {
1487
- "Content-Type": "application/json",
1488
- Authorization: `Bearer ${token}`
1489
- },
1490
- body: JSON.stringify({
1491
- browserMode: options.mode,
1492
- instructions: options.instructions ?? null
1493
- })
1494
- });
1495
- if (!response.ok) {
1496
- const text = await response.text();
1497
- throw new Error(`Failed to create session: ${response.status} ${text}`);
1498
- }
1499
- return response.json();
1500
- }
1501
- async function runLocalBrowser(args) {
1502
- const options = parseArgs(args);
1503
- console.log("Starting local browser...");
1504
- console.log(` Mode: ${options.mode}`);
1505
- if (options.cdpUrl) {
1506
- console.log(` CDP URL: ${options.cdpUrl}`);
1507
- }
1508
- console.log(` Headless: ${options.headless}`);
1509
- console.log(` API URL: ${options.apiUrl}`);
1510
- console.log();
1511
- const token = await resolveToken2();
1512
- console.log("Creating session with cloud API...");
1513
- const session = await createSession(options.apiUrl, token, options);
1514
- if (!session.ok) {
1515
- throw new Error(`Failed to create session: ${session.error}`);
1516
- }
1517
- console.log(`Session created: ${session.sessionId}`);
1518
- console.log(`Expires at: ${session.expiresAt}`);
1519
- console.log();
1520
- const host = new LocalBrowserHost({
1521
- apiUrl: options.apiUrl,
1522
- wsToken: session.wsToken,
1523
- sessionId: session.sessionId,
1524
- browserMode: options.mode,
1525
- cdpUrl: options.cdpUrl,
1526
- headless: options.headless,
1527
- storageStatePath: options.storageStatePath,
1528
- onLog: (level, message, data) => {
1529
- const prefix = `[${level.toUpperCase()}]`;
1530
- if (data) {
1531
- console.log(prefix, message, data);
1532
- } else {
1533
- console.log(prefix, message);
1534
- }
1535
- }
1536
- });
1537
- const shutdown = async () => {
1538
- console.log("\nShutting down...");
1539
- await host.stop();
1540
- process8.exit(0);
1541
- };
1542
- process8.on("SIGINT", shutdown);
1543
- process8.on("SIGTERM", shutdown);
1544
- try {
1545
- await host.start();
1546
- console.log();
1547
- console.log("Local browser is ready and connected to cloud.");
1548
- console.log("Press Ctrl+C to stop.");
1549
- console.log();
1550
- await new Promise(() => {
1551
- });
1552
- } catch (error) {
1553
- console.error("Failed to start local browser:", error);
1554
- await host.stop();
1555
- process8.exit(1);
1556
- }
1557
- }
1558
- var DEFAULT_API_URL2;
1559
- var init_local_browser = __esm({
1560
- "src/local-browser/index.ts"() {
1561
- "use strict";
1562
- init_auth();
1563
- init_host();
1564
- DEFAULT_API_URL2 = "https://api.trycanary.ai";
1565
- }
1566
- });
1
+ import {
2
+ connectTunnel,
3
+ createLocalRun,
4
+ createTunnel,
5
+ runLocalTest,
6
+ runTunnel
7
+ } from "./chunk-NRMZHITS.js";
8
+ import {
9
+ readStoredApiUrl,
10
+ readStoredAuth,
11
+ readStoredToken,
12
+ saveAuth
13
+ } from "./chunk-SGNA6N2N.js";
14
+ import {
15
+ __require
16
+ } from "./chunk-DGUM43GV.js";
1567
17
 
1568
18
  // src/index.ts
1569
19
  import { spawnSync as spawnSync2 } from "child_process";
1570
- import process9 from "process";
1571
- import path5 from "path";
20
+ import { createRequire as createRequire2 } from "module";
21
+ import process7 from "process";
22
+ import path4 from "path";
1572
23
  import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "url";
1573
24
 
1574
25
  // src/runner/common.ts
@@ -1876,18 +327,29 @@ function countHealed(eventLogPath) {
1876
327
  }
1877
328
  }
1878
329
 
1879
- // src/index.ts
1880
- init_local_run();
1881
- init_tunnel();
1882
-
1883
330
  // src/login.ts
1884
- import fs4 from "fs/promises";
1885
- import os4 from "os";
1886
- import path4 from "path";
1887
- import process4 from "process";
331
+ import process2 from "process";
332
+ import readline from "readline";
1888
333
  import { spawn as spawn2 } from "child_process";
1889
- var DEFAULT_APP_URL = "https://app.trycanary.ai";
1890
- function getArgValue3(argv, key) {
334
+ var ENV_URLS = {
335
+ prod: {
336
+ api: "https://api.trycanary.ai",
337
+ app: "https://app.trycanary.ai"
338
+ },
339
+ production: {
340
+ api: "https://api.trycanary.ai",
341
+ app: "https://app.trycanary.ai"
342
+ },
343
+ dev: {
344
+ api: "https://api.dev.trycanary.ai",
345
+ app: "https://app.dev.trycanary.ai"
346
+ },
347
+ local: {
348
+ api: "http://localhost:3000",
349
+ app: "http://localhost:5173"
350
+ }
351
+ };
352
+ function getArgValue(argv, key) {
1891
353
  const index = argv.indexOf(key);
1892
354
  if (index === -1) return void 0;
1893
355
  return argv[index + 1];
@@ -1896,7 +358,7 @@ function shouldOpenBrowser(argv) {
1896
358
  return !argv.includes("--no-open");
1897
359
  }
1898
360
  function openUrl(url) {
1899
- const platform = process4.platform;
361
+ const platform = process2.platform;
1900
362
  if (platform === "darwin") {
1901
363
  spawn2("open", [url], { stdio: "ignore" });
1902
364
  return;
@@ -1907,16 +369,48 @@ function openUrl(url) {
1907
369
  }
1908
370
  spawn2("xdg-open", [url], { stdio: "ignore" });
1909
371
  }
1910
- async function writeToken(token) {
1911
- const dir = path4.join(os4.homedir(), ".config", "canary-cli");
1912
- const filePath = path4.join(dir, "auth.json");
1913
- await fs4.mkdir(dir, { recursive: true });
1914
- await fs4.writeFile(filePath, JSON.stringify({ token }, null, 2), "utf8");
1915
- return filePath;
372
+ function promptChoice(question) {
373
+ const rl = readline.createInterface({ input: process2.stdin, output: process2.stdout });
374
+ return new Promise((resolve) => {
375
+ rl.question(question, (answer) => {
376
+ rl.close();
377
+ resolve(answer.trim());
378
+ });
379
+ });
380
+ }
381
+ async function fetchOrgs(apiUrl, token) {
382
+ try {
383
+ const res = await fetch(`${apiUrl}/cli-login/orgs`, {
384
+ headers: { Authorization: `Bearer ${token}` }
385
+ });
386
+ if (!res.ok) return null;
387
+ return await res.json();
388
+ } catch {
389
+ return null;
390
+ }
391
+ }
392
+ async function switchOrg(apiUrl, token, orgId) {
393
+ const res = await fetch(`${apiUrl}/cli-login/switch-org`, {
394
+ method: "POST",
395
+ headers: {
396
+ Authorization: `Bearer ${token}`,
397
+ "Content-Type": "application/json"
398
+ },
399
+ body: JSON.stringify({ orgId })
400
+ });
401
+ return await res.json();
1916
402
  }
1917
403
  async function runLogin(argv) {
1918
- const apiUrl = getArgValue3(argv, "--api-url") ?? process4.env.CANARY_API_URL ?? "https://api.trycanary.ai";
1919
- const appUrl = getArgValue3(argv, "--app-url") ?? process4.env.CANARY_APP_URL ?? DEFAULT_APP_URL;
404
+ const env = getArgValue(argv, "--env");
405
+ const envUrls = env ? ENV_URLS[env] : void 0;
406
+ if (env && !envUrls) {
407
+ console.error(`Unknown environment: ${env}`);
408
+ console.error("Valid environments: prod, dev, local");
409
+ process2.exit(1);
410
+ }
411
+ const apiUrl = getArgValue(argv, "--api-url") ?? envUrls?.api ?? process2.env.CANARY_API_URL ?? "https://api.trycanary.ai";
412
+ const appUrl = getArgValue(argv, "--app-url") ?? envUrls?.app ?? process2.env.CANARY_APP_URL ?? "https://app.trycanary.ai";
413
+ const orgFlag = getArgValue(argv, "--org");
1920
414
  const startRes = await fetch(`${apiUrl}/cli-login/start`, {
1921
415
  method: "POST",
1922
416
  headers: { "content-type": "application/json" },
@@ -1925,7 +419,7 @@ async function runLogin(argv) {
1925
419
  const startJson = await startRes.json();
1926
420
  if (!startRes.ok || !startJson.ok || !startJson.deviceCode || !startJson.userCode) {
1927
421
  console.error("Login start failed", startJson.error ?? startRes.statusText);
1928
- process4.exit(1);
422
+ process2.exit(1);
1929
423
  }
1930
424
  console.log("Login required.");
1931
425
  console.log(`User code: ${startJson.userCode}`);
@@ -1941,10 +435,12 @@ async function runLogin(argv) {
1941
435
  }
1942
436
  const intervalMs = (startJson.intervalSeconds ?? 3) * 1e3;
1943
437
  const expiresAt = startJson.expiresAt ? new Date(startJson.expiresAt).getTime() : null;
438
+ let token;
439
+ let initialOrgId;
1944
440
  while (true) {
1945
441
  if (expiresAt && Date.now() > expiresAt) {
1946
442
  console.error("Login code expired.");
1947
- process4.exit(1);
443
+ process2.exit(1);
1948
444
  }
1949
445
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
1950
446
  const pollRes = await fetch(`${apiUrl}/cli-login/poll`, {
@@ -1955,50 +451,137 @@ async function runLogin(argv) {
1955
451
  const pollJson = await pollRes.json();
1956
452
  if (!pollRes.ok || !pollJson.ok) {
1957
453
  console.error("Login poll failed", pollJson.error ?? pollRes.statusText);
1958
- process4.exit(1);
454
+ process2.exit(1);
1959
455
  }
1960
456
  if (pollJson.status === "approved" && pollJson.accessToken) {
1961
- const filePath = await writeToken(pollJson.accessToken);
1962
- console.log(`Login successful. Token saved to ${filePath}`);
1963
- console.log("Set CANARY_API_TOKEN to use the CLI without re-login.");
1964
- return;
457
+ token = pollJson.accessToken;
458
+ initialOrgId = pollJson.orgId;
459
+ break;
1965
460
  }
1966
461
  if (pollJson.status === "rejected") {
1967
462
  console.error("Login rejected.");
1968
- process4.exit(1);
463
+ process2.exit(1);
1969
464
  }
1970
465
  if (pollJson.status === "expired") {
1971
466
  console.error("Login expired.");
1972
- process4.exit(1);
467
+ process2.exit(1);
468
+ }
469
+ }
470
+ const orgsData = await fetchOrgs(apiUrl, token);
471
+ const orgs = orgsData?.organizations ?? [];
472
+ let finalToken = token;
473
+ let finalOrgId = initialOrgId;
474
+ let finalOrgName;
475
+ if (orgs.length <= 1) {
476
+ finalOrgName = orgs[0]?.name;
477
+ } else {
478
+ let selectedOrg;
479
+ if (orgFlag) {
480
+ selectedOrg = orgs.find(
481
+ (o) => o.name.toLowerCase() === orgFlag.toLowerCase() || o.id === orgFlag
482
+ );
483
+ if (!selectedOrg) {
484
+ console.error(`Organization "${orgFlag}" not found. Available orgs:`);
485
+ for (const o of orgs) {
486
+ console.error(` - ${o.name} (${o.id})`);
487
+ }
488
+ process2.exit(1);
489
+ }
490
+ } else if (process2.stdin.isTTY) {
491
+ console.log("\nYou belong to multiple organizations. Select one:");
492
+ for (let i = 0; i < orgs.length; i++) {
493
+ const marker = orgs[i].id === initialOrgId ? " (current)" : "";
494
+ console.log(` ${i + 1}. ${orgs[i].name}${marker}`);
495
+ }
496
+ const answer = await promptChoice(`
497
+ Choice [1-${orgs.length}]: `);
498
+ const idx = parseInt(answer, 10) - 1;
499
+ if (isNaN(idx) || idx < 0 || idx >= orgs.length) {
500
+ console.error("Invalid selection.");
501
+ process2.exit(1);
502
+ }
503
+ selectedOrg = orgs[idx];
504
+ } else {
505
+ const defaultOrg = orgs.find((o) => o.id === initialOrgId);
506
+ console.log(
507
+ `Warning: Multiple organizations available but running non-interactively. Using "${defaultOrg?.name ?? initialOrgId}".`
508
+ );
509
+ console.log("Tip: Use --org <name> to select a specific organization.");
510
+ selectedOrg = defaultOrg;
1973
511
  }
512
+ if (selectedOrg && selectedOrg.id !== initialOrgId) {
513
+ const switchRes = await switchOrg(apiUrl, token, selectedOrg.id);
514
+ if (!switchRes.ok || !switchRes.accessToken) {
515
+ console.error("Failed to switch organization:", switchRes.error ?? "Unknown error");
516
+ process2.exit(1);
517
+ }
518
+ finalToken = switchRes.accessToken;
519
+ finalOrgId = switchRes.orgId;
520
+ finalOrgName = switchRes.orgName;
521
+ } else if (selectedOrg) {
522
+ finalOrgName = selectedOrg.name;
523
+ }
524
+ }
525
+ const filePath = await saveAuth({ token: finalToken, apiUrl, orgId: finalOrgId, orgName: finalOrgName });
526
+ const displayName = finalOrgName ? ` to ${finalOrgName}` : "";
527
+ console.log(`Login successful${displayName}. Token saved to ${filePath}`);
528
+ console.log("Set CANARY_API_TOKEN to use the CLI without re-login.");
529
+ }
530
+
531
+ // src/orgs.ts
532
+ import process3 from "process";
533
+ async function runOrgs(argv) {
534
+ const token = process3.env.CANARY_API_TOKEN ?? await readStoredToken();
535
+ if (!token) {
536
+ console.error("Not logged in. Run: canary login");
537
+ process3.exit(1);
1974
538
  }
539
+ const auth = await readStoredAuth();
540
+ const apiUrl = process3.env.CANARY_API_URL ?? auth?.apiUrl ?? "https://api.trycanary.ai";
541
+ const res = await fetch(`${apiUrl}/cli-login/orgs`, {
542
+ headers: { Authorization: `Bearer ${token}` }
543
+ });
544
+ if (!res.ok) {
545
+ console.error("Failed to fetch organizations. You may need to re-login: canary login");
546
+ process3.exit(1);
547
+ }
548
+ const data = await res.json();
549
+ if (!data.ok || !data.organizations) {
550
+ console.error("Failed to fetch organizations:", data.error ?? "Unknown error");
551
+ process3.exit(1);
552
+ }
553
+ const currentOrgId = auth?.orgId ?? data.currentOrgId;
554
+ console.log("Organizations:\n");
555
+ for (const org of data.organizations) {
556
+ const marker = org.id === currentOrgId ? " *" : "";
557
+ console.log(` ${org.name} (${org.role})${marker}`);
558
+ }
559
+ console.log("\n* = current organization");
560
+ console.log("To switch: canary login --org <name>");
1975
561
  }
1976
562
 
1977
563
  // src/run-local.ts
1978
- init_auth();
1979
- init_local_run();
1980
- init_tunnel();
1981
- import process5 from "process";
1982
- function getArgValue4(argv, key) {
564
+ import process4 from "process";
565
+ function getArgValue2(argv, key) {
1983
566
  const index = argv.indexOf(key);
1984
567
  if (index === -1) return void 0;
1985
568
  return argv[index + 1];
1986
569
  }
1987
570
  async function runLocalSession(argv) {
1988
- const apiUrl = getArgValue4(argv, "--api-url") ?? process5.env.CANARY_API_URL ?? "https://api.trycanary.ai";
1989
- const token = getArgValue4(argv, "--token") ?? process5.env.CANARY_API_TOKEN ?? await readStoredToken();
571
+ const apiUrl = getArgValue2(argv, "--api-url") ?? process4.env.CANARY_API_URL ?? "https://api.trycanary.ai";
572
+ const token = getArgValue2(argv, "--token") ?? process4.env.CANARY_API_TOKEN ?? await readStoredToken();
1990
573
  if (!token) {
1991
574
  console.error("Missing token. Run `canary login` first or set CANARY_API_TOKEN.");
1992
- process5.exit(1);
575
+ process4.exit(1);
1993
576
  }
1994
- const portRaw = getArgValue4(argv, "--port") ?? process5.env.CANARY_LOCAL_PORT;
1995
- const tunnelUrl = getArgValue4(argv, "--tunnel-url");
1996
- const title = getArgValue4(argv, "--title");
1997
- const featureSpec = getArgValue4(argv, "--feature");
1998
- const startUrl = getArgValue4(argv, "--start-url");
577
+ const portRaw = getArgValue2(argv, "--port") ?? process4.env.CANARY_LOCAL_PORT;
578
+ const tunnelUrl = getArgValue2(argv, "--tunnel-url");
579
+ const title = getArgValue2(argv, "--title");
580
+ const featureSpec = getArgValue2(argv, "--feature");
581
+ const startUrl = getArgValue2(argv, "--start-url");
1999
582
  if (!tunnelUrl && !portRaw) {
2000
583
  console.error("Missing --port or --tunnel-url");
2001
- process5.exit(1);
584
+ process4.exit(1);
2002
585
  }
2003
586
  let publicUrl = tunnelUrl;
2004
587
  let ws = null;
@@ -2006,7 +589,7 @@ async function runLocalSession(argv) {
2006
589
  const port = Number(portRaw);
2007
590
  if (Number.isNaN(port) || port <= 0) {
2008
591
  console.error("Invalid --port value");
2009
- process5.exit(1);
592
+ process4.exit(1);
2010
593
  }
2011
594
  const tunnel = await createTunnel({ apiUrl, token, port });
2012
595
  publicUrl = tunnel.publicUrl;
@@ -2022,7 +605,7 @@ async function runLocalSession(argv) {
2022
605
  }
2023
606
  if (!publicUrl) {
2024
607
  console.error("Failed to resolve tunnel URL");
2025
- process5.exit(1);
608
+ process4.exit(1);
2026
609
  }
2027
610
  const run2 = await createLocalRun({
2028
611
  apiUrl,
@@ -2038,19 +621,18 @@ async function runLocalSession(argv) {
2038
621
  }
2039
622
  if (ws) {
2040
623
  console.log("Tunnel active. Press Ctrl+C to stop.");
2041
- process5.on("SIGINT", () => {
624
+ process4.on("SIGINT", () => {
2042
625
  ws?.close();
2043
- process5.exit(0);
626
+ process4.exit(0);
2044
627
  });
2045
628
  await new Promise(() => void 0);
2046
629
  }
2047
630
  }
2048
631
 
2049
632
  // src/remote-test.ts
2050
- init_auth();
2051
- import process6 from "process";
633
+ import process5 from "process";
2052
634
  import { createParser } from "eventsource-parser";
2053
- function getArgValue5(argv, key) {
635
+ function getArgValue3(argv, key) {
2054
636
  const index = argv.indexOf(key);
2055
637
  if (index === -1 || index >= argv.length - 1) return void 0;
2056
638
  return argv[index + 1];
@@ -2058,11 +640,20 @@ function getArgValue5(argv, key) {
2058
640
  function hasFlag(argv, ...flags) {
2059
641
  return flags.some((flag) => argv.includes(flag));
2060
642
  }
643
+ function formatFailedTests(failedTests, appUrl) {
644
+ if (failedTests.length === 0 || !appUrl) return null;
645
+ const lines = ["", "Failed tests:"];
646
+ for (const t of failedTests) {
647
+ lines.push(` \u2717 ${t.name}`);
648
+ lines.push(` ${appUrl}/runs/tests/${t.testRunId}`);
649
+ }
650
+ return lines.join("\n");
651
+ }
2061
652
  async function runRemoteTest(argv) {
2062
- const apiUrl = getArgValue5(argv, "--api-url") ?? process6.env.CANARY_API_URL ?? "https://api.trycanary.ai";
2063
- const token = getArgValue5(argv, "--token") ?? process6.env.CANARY_API_TOKEN ?? await readStoredToken();
2064
- const tag = getArgValue5(argv, "--tag");
2065
- const namePattern = getArgValue5(argv, "--name-pattern");
653
+ const apiUrl = getArgValue3(argv, "--api-url") ?? process5.env.CANARY_API_URL ?? "https://api.trycanary.ai";
654
+ const token = getArgValue3(argv, "--token") ?? process5.env.CANARY_API_TOKEN ?? await readStoredToken();
655
+ const tag = getArgValue3(argv, "--tag");
656
+ const namePattern = getArgValue3(argv, "--name-pattern");
2066
657
  const verbose = hasFlag(argv, "--verbose", "-v");
2067
658
  if (!token) {
2068
659
  console.error("Error: No API token found.");
@@ -2072,7 +663,7 @@ async function runRemoteTest(argv) {
2072
663
  console.error("");
2073
664
  console.error("Or create an API key in Settings > API Keys and pass it:");
2074
665
  console.error(" canary test --remote --token cnry_...");
2075
- process6.exit(1);
666
+ process5.exit(1);
2076
667
  }
2077
668
  console.log("Starting remote workflow tests...");
2078
669
  if (tag) console.log(` Filtering by tag: ${tag}`);
@@ -2093,20 +684,20 @@ async function runRemoteTest(argv) {
2093
684
  });
2094
685
  } catch (err) {
2095
686
  console.error(`Failed to connect to API: ${err}`);
2096
- process6.exit(1);
687
+ process5.exit(1);
2097
688
  }
2098
689
  if (!triggerRes.ok) {
2099
690
  const errorText = await triggerRes.text();
2100
691
  console.error(`Failed to start tests: ${triggerRes.status}`);
2101
692
  console.error(errorText);
2102
- process6.exit(1);
693
+ process5.exit(1);
2103
694
  }
2104
695
  const triggerData = await triggerRes.json();
2105
696
  if (!triggerData.ok || !triggerData.suiteId) {
2106
697
  console.error(`Failed to start tests: ${triggerData.error ?? "Unknown error"}`);
2107
- process6.exit(1);
698
+ process5.exit(1);
2108
699
  }
2109
- const { suiteId, jobId } = triggerData;
700
+ const { suiteId, jobId, appUrl } = triggerData;
2110
701
  if (verbose) {
2111
702
  console.log(`Suite ID: ${suiteId}`);
2112
703
  console.log(`Job ID: ${jobId}`);
@@ -2123,15 +714,16 @@ async function runRemoteTest(argv) {
2123
714
  });
2124
715
  } catch (err) {
2125
716
  console.error(`Failed to connect to event stream: ${err}`);
2126
- process6.exit(1);
717
+ process5.exit(1);
2127
718
  }
2128
719
  if (!streamRes.ok || !streamRes.body) {
2129
720
  console.error(`Failed to connect to event stream: ${streamRes.status}`);
2130
- process6.exit(1);
721
+ process5.exit(1);
2131
722
  }
2132
723
  let exitCode = 0;
2133
724
  let hasCompleted = false;
2134
725
  const workflowNames = /* @__PURE__ */ new Map();
726
+ const failedTests = [];
2135
727
  let totalWorkflows = 0;
2136
728
  let completedWorkflows = 0;
2137
729
  let failedWorkflows = 0;
@@ -2160,6 +752,7 @@ async function runRemoteTest(argv) {
2160
752
  if (errorMessage) {
2161
753
  console.log(` Error: ${errorMessage.slice(0, 200)}`);
2162
754
  }
755
+ failedTests.push({ name, testRunId: testEvent.testRunId });
2163
756
  exitCode = 1;
2164
757
  } else if (status === "running") {
2165
758
  } else if (status === "waiting") {
@@ -2201,7 +794,7 @@ async function runRemoteTest(argv) {
2201
794
  console.log("\u2500".repeat(50));
2202
795
  if (totalWorkflows === 0) {
2203
796
  console.log("No workflows found matching the filter criteria.");
2204
- process6.exit(0);
797
+ process5.exit(0);
2205
798
  }
2206
799
  const passRate = totalWorkflows > 0 ? Math.round(successfulWorkflows / totalWorkflows * 100) : 0;
2207
800
  if (failedWorkflows > 0) {
@@ -2214,83 +807,336 @@ async function runRemoteTest(argv) {
2214
807
  if (waitingWorkflows > 0) {
2215
808
  console.log(`Note: ${waitingWorkflows} workflow(s) are still waiting (scheduled for later)`);
2216
809
  }
2217
- process6.exit(exitCode);
810
+ const failedSection = formatFailedTests(failedTests, appUrl);
811
+ if (failedSection) {
812
+ console.log(failedSection);
813
+ }
814
+ process5.exit(exitCode);
815
+ }
816
+
817
+ // src/debug-session.ts
818
+ import fs3 from "fs/promises";
819
+ import os2 from "os";
820
+ import path3 from "path";
821
+ import process6 from "process";
822
+ var ENV_URLS2 = {
823
+ prod: {
824
+ api: "https://api.trycanary.ai",
825
+ app: "https://app.trycanary.ai"
826
+ },
827
+ production: {
828
+ api: "https://api.trycanary.ai",
829
+ app: "https://app.trycanary.ai"
830
+ },
831
+ dev: {
832
+ api: "https://api.dev.trycanary.ai",
833
+ app: "https://app.dev.trycanary.ai"
834
+ },
835
+ local: {
836
+ api: "http://localhost:3000",
837
+ app: "http://localhost:5173"
838
+ }
839
+ };
840
+ function getArgValue4(argv, key) {
841
+ const index = argv.indexOf(key);
842
+ if (index === -1 || index >= argv.length - 1) return void 0;
843
+ return argv[index + 1];
844
+ }
845
+ function hasFlag2(argv, ...flags) {
846
+ return flags.some((flag) => argv.includes(flag));
847
+ }
848
+ async function writeDebugSession(loginUrl, expiresAt, apiUrl) {
849
+ const dir = path3.join(os2.homedir(), ".config", "canary-cli");
850
+ const filePath = path3.join(dir, "debug-session.json");
851
+ await fs3.mkdir(dir, { recursive: true, mode: 448 });
852
+ await fs3.writeFile(
853
+ filePath,
854
+ JSON.stringify({ loginUrl, expiresAt, apiUrl, createdAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
855
+ { encoding: "utf8", mode: 384 }
856
+ );
857
+ return filePath;
858
+ }
859
+ async function runDebugSession(argv) {
860
+ const env = getArgValue4(argv, "--env");
861
+ const envUrls = env ? ENV_URLS2[env] : void 0;
862
+ if (env && !envUrls) {
863
+ console.error(`Unknown environment: ${env}`);
864
+ console.error("Valid environments: prod, dev, local");
865
+ process6.exit(1);
866
+ }
867
+ const storedApiUrl = await readStoredApiUrl();
868
+ const apiUrl = getArgValue4(argv, "--api-url") ?? envUrls?.api ?? process6.env.CANARY_API_URL ?? storedApiUrl ?? "https://api.trycanary.ai";
869
+ const token = getArgValue4(argv, "--token") ?? process6.env.CANARY_API_TOKEN ?? await readStoredToken();
870
+ const jsonOutput = hasFlag2(argv, "--json");
871
+ if (!token) {
872
+ console.error("Error: No API token found.");
873
+ console.error("Run: canary login [--env <env>]");
874
+ process6.exit(1);
875
+ }
876
+ try {
877
+ const res = await fetch(`${apiUrl}/auth/debug-session/create`, {
878
+ method: "POST",
879
+ headers: {
880
+ Authorization: `Bearer ${token}`,
881
+ "Content-Type": "application/json"
882
+ }
883
+ });
884
+ if (!res.ok) {
885
+ const text = await res.text();
886
+ if (res.status === 401) {
887
+ console.error("Error: Unauthorized. Your session may have expired.");
888
+ console.error("Run: canary login");
889
+ process6.exit(1);
890
+ }
891
+ if (res.status === 403) {
892
+ console.error("Error: Forbidden. Debug session creation requires superadmin access.");
893
+ process6.exit(1);
894
+ }
895
+ if (res.status === 404) {
896
+ console.error(
897
+ "Error: Endpoint not found. The debug-session feature may not be deployed to this environment."
898
+ );
899
+ process6.exit(1);
900
+ }
901
+ try {
902
+ const errorJson = JSON.parse(text);
903
+ console.error(`Error: ${errorJson.message ?? errorJson.error ?? text}`);
904
+ } catch {
905
+ console.error(`Error (${res.status}): ${text || res.statusText}`);
906
+ }
907
+ process6.exit(1);
908
+ }
909
+ const json = await res.json();
910
+ if (!json.ok || !json.loginUrl) {
911
+ console.error(`Error: ${json.message ?? json.error ?? "Failed to create debug session"}`);
912
+ process6.exit(1);
913
+ }
914
+ const filePath = await writeDebugSession(json.loginUrl, json.expiresAt ?? "", apiUrl);
915
+ if (jsonOutput) {
916
+ console.log(
917
+ JSON.stringify(
918
+ {
919
+ loginUrl: json.loginUrl,
920
+ expiresAt: json.expiresAt,
921
+ sessionFile: filePath
922
+ },
923
+ null,
924
+ 2
925
+ )
926
+ );
927
+ } else {
928
+ console.log("Debug session created successfully.");
929
+ console.log("");
930
+ console.log(`Login URL: ${json.loginUrl}`);
931
+ console.log(`Expires: ${json.expiresAt}`);
932
+ console.log(`Session saved to: ${filePath}`);
933
+ console.log("");
934
+ console.log("Use this URL with Playwright to authenticate as the debug agent.");
935
+ console.log("The token is single-use and expires in 5 minutes.");
936
+ }
937
+ } catch (err) {
938
+ console.error(`Failed to create debug session: ${err}`);
939
+ process6.exit(1);
940
+ }
941
+ }
942
+
943
+ // src/jwt.ts
944
+ function decodeJwtPayload(token) {
945
+ try {
946
+ const parts = token.split(".");
947
+ if (parts.length !== 3) return null;
948
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
949
+ const json = Buffer.from(base64, "base64").toString("utf8");
950
+ const payload = JSON.parse(json);
951
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
952
+ return null;
953
+ }
954
+ return payload;
955
+ } catch {
956
+ return null;
957
+ }
958
+ }
959
+ function isSuperadminToken(token) {
960
+ if (!token) return false;
961
+ const payload = decodeJwtPayload(token);
962
+ return payload?.is_superadmin === true;
2218
963
  }
2219
964
 
2220
965
  // src/index.ts
2221
- var loadMcp = () => Promise.resolve().then(() => (init_mcp(), mcp_exports)).then((m) => m.runMcp);
2222
- var loadLocalBrowser = () => Promise.resolve().then(() => (init_local_browser(), local_browser_exports)).then((m) => m.runLocalBrowser);
966
+ var require2 = createRequire2(import.meta.url);
967
+ var pkg = require2("../package.json");
968
+ var loadMcp = () => import("./mcp-5N5Z343W.js").then((m) => m.runMcp);
969
+ var loadLocalBrowser = () => import("./local-browser-REU2RIYX.js").then((m) => m.runLocalBrowser);
2223
970
  var canary = { run };
2224
- var baseDir = typeof __dirname !== "undefined" ? __dirname : path5.dirname(fileURLToPath2(import.meta.url));
2225
- var preloadPath = path5.join(baseDir, "runner", "preload.js");
971
+ var baseDir = typeof __dirname !== "undefined" ? __dirname : path4.dirname(fileURLToPath2(import.meta.url));
972
+ var preloadPath = path4.join(baseDir, "runner", "preload.js");
2226
973
  var requireFn = makeRequire();
2227
974
  function runPlaywrightTests(args) {
2228
975
  const playwrightCli = requireFn.resolve("@playwright/test/cli");
2229
976
  const { runnerBin, preloadFlag } = resolveRunner(preloadPath);
2230
- const nodeOptions = process9.env.NODE_OPTIONS && preloadFlag ? `${process9.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process9.env.NODE_OPTIONS;
977
+ const nodeOptions = process7.env.NODE_OPTIONS && preloadFlag ? `${process7.env.NODE_OPTIONS} ${preloadFlag}` : preloadFlag ?? process7.env.NODE_OPTIONS;
2231
978
  const env = {
2232
- ...process9.env,
2233
- CANARY_ENABLED: process9.env.CANARY_ENABLED ?? "1",
979
+ ...process7.env,
980
+ CANARY_ENABLED: process7.env.CANARY_ENABLED ?? "1",
2234
981
  CANARY_RUNNER: "canary",
2235
982
  ...nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}
2236
983
  };
2237
984
  const result = spawnSync2(runnerBin, [playwrightCli, "test", ...args], {
2238
985
  env,
2239
986
  stdio: "inherit",
2240
- cwd: process9.cwd()
987
+ cwd: process7.cwd()
2241
988
  });
2242
989
  if (result.error) {
2243
990
  console.error("canary failed to launch Playwright:", result.error);
2244
- process9.exit(1);
991
+ process7.exit(1);
2245
992
  }
2246
- process9.exit(result.status ?? 1);
993
+ process7.exit(result.status ?? 1);
994
+ }
995
+ function printVersion() {
996
+ console.log(`canary v${pkg.version}`);
997
+ }
998
+ function printHelp({ isSuperadmin }) {
999
+ const lines = [
1000
+ `canary v${pkg.version}: Local and remote testing CLI`,
1001
+ "",
1002
+ "Usage:",
1003
+ " canary test [playwright options] Run local Playwright tests",
1004
+ " canary test --remote [options] Run remote workflow tests",
1005
+ " canary local-run --tunnel-url <url> [options]",
1006
+ " canary tunnel --port <localPort> [options]",
1007
+ " canary run --port <localPort> [options]",
1008
+ " canary mcp",
1009
+ " canary browser [--mode playwright|cdp] [--cdp-url <url>] [--no-headless]",
1010
+ " canary login [--org <name>] [--app-url https://app.trycanary.ai] [--no-open]",
1011
+ " canary orgs List organizations"
1012
+ ];
1013
+ if (isSuperadmin) {
1014
+ lines.push(
1015
+ " canary debug-session [--env dev|local] [--json] Create browser debug session",
1016
+ " canary psql <query> [--json] Execute read-only SQL",
1017
+ " canary redis <command> [--json] Execute read-only Redis commands",
1018
+ " canary feature-flag <sub-command> Manage feature flags"
1019
+ );
1020
+ }
1021
+ lines.push(
1022
+ " canary version Show version",
1023
+ " canary help",
1024
+ "",
1025
+ "Remote test options:",
1026
+ " --token <key> API key (or set CANARY_API_TOKEN)",
1027
+ " --api-url <url> API URL (default: https://api.trycanary.ai)",
1028
+ " --tag <tag> Filter workflows by tag",
1029
+ " --name-pattern <pat> Filter workflows by name pattern",
1030
+ " --verbose, -v Show all events",
1031
+ "",
1032
+ "Browser options:",
1033
+ " --mode <playwright|cdp> Browser mode (default: playwright)",
1034
+ " --cdp-url <url> CDP endpoint for existing Chrome",
1035
+ " --no-headless Run browser with visible UI",
1036
+ " --storage-state <path> Path to storage state JSON",
1037
+ " --instructions <text> Instructions for the cloud agent",
1038
+ "",
1039
+ "Login options:",
1040
+ " --org <name> Select organization by name or ID (for multi-org users)",
1041
+ "",
1042
+ "Login environments:",
1043
+ " Production: canary login",
1044
+ " Dev: canary login --app-url https://app.dev.trycanary.ai --api-url https://api.dev.trycanary.ai",
1045
+ " Local: canary login --app-url http://localhost:5173 --api-url http://localhost:3000",
1046
+ "",
1047
+ " Or set CANARY_API_URL env var for non-production environments:",
1048
+ " export CANARY_API_URL=http://localhost:3000"
1049
+ );
1050
+ if (isSuperadmin) {
1051
+ lines.push(
1052
+ "",
1053
+ "PSQL options:",
1054
+ " --json Output results as JSON",
1055
+ " --query <sql> SQL query (alternative to positional)",
1056
+ "",
1057
+ "Redis options:",
1058
+ " --json Output results as JSON",
1059
+ "",
1060
+ "Feature flag sub-commands:",
1061
+ " list List all flags",
1062
+ " create <name> [--description <text>] Create a flag",
1063
+ " delete <name> Delete a flag and its gates",
1064
+ " enable <name> --org <orgId> Enable for an org",
1065
+ " disable <name> --org <orgId> Disable for an org"
1066
+ );
1067
+ }
1068
+ lines.push(
1069
+ "",
1070
+ "Flags:",
1071
+ " -h, --help Show help",
1072
+ " -V, --version Show version"
1073
+ );
1074
+ console.log(lines.join("\n"));
2247
1075
  }
2248
- function printHelp() {
1076
+ function printTestHelp() {
2249
1077
  console.log(
2250
1078
  [
2251
- "canary: Local and remote testing CLI",
1079
+ `canary v${pkg.version}: Test command`,
2252
1080
  "",
2253
1081
  "Usage:",
2254
1082
  " canary test [playwright options] Run local Playwright tests",
2255
1083
  " canary test --remote [options] Run remote workflow tests",
2256
- " canary local-run --tunnel-url <url> [options]",
2257
- " canary tunnel --port <localPort> [options]",
2258
- " canary run --port <localPort> [options]",
2259
- " canary mcp",
2260
- " canary browser [--mode playwright|cdp] [--cdp-url <url>] [--no-headless]",
2261
- " canary login [--app-url https://app.trycanary.ai] [--no-open]",
2262
- " canary help",
2263
1084
  "",
2264
- "Remote test options:",
2265
- " --token <key> API key (or set CANARY_API_TOKEN)",
2266
- " --api-url <url> API URL (default: https://api.trycanary.ai)",
2267
- " --tag <tag> Filter workflows by tag",
2268
- " --name-pattern <pat> Filter workflows by name pattern",
2269
- " --verbose, -v Show all events",
1085
+ "Local Playwright options (passed through to Playwright):",
1086
+ " --grep <pattern> Only run tests matching pattern",
1087
+ " --headed Run in headed browser mode",
1088
+ " --workers <n> Number of parallel workers",
1089
+ " --project <name> Run specific project",
1090
+ " --reporter <reporter> Use a specific reporter",
1091
+ " --retries <n> Number of retries for failed tests",
1092
+ " --timeout <ms> Test timeout in milliseconds",
2270
1093
  "",
2271
- "Browser options:",
2272
- " --mode <playwright|cdp> Browser mode (default: playwright)",
2273
- " --cdp-url <url> CDP endpoint for existing Chrome",
2274
- " --no-headless Run browser with visible UI",
2275
- " --storage-state <path> Path to storage state JSON",
2276
- " --instructions <text> Instructions for the cloud agent",
1094
+ "Remote test options:",
1095
+ " --remote Run tests remotely (required)",
1096
+ " --token <key> API key (or set CANARY_API_TOKEN)",
1097
+ " --api-url <url> API URL (default: https://api.trycanary.ai)",
1098
+ " --tag <tag> Filter workflows by tag",
1099
+ " --name-pattern <pat> Filter workflows by name pattern",
1100
+ " --verbose, -v Show all events",
2277
1101
  "",
2278
- "Flags:",
2279
- " -h, --help Show help"
1102
+ "Examples:",
1103
+ " canary test Run all local tests",
1104
+ ' canary test --grep "login" Run tests matching "login"',
1105
+ " canary test --headed --workers 1 Debug with visible browser",
1106
+ " canary test --remote --tag smoke Run remote smoke tests"
2280
1107
  ].join("\n")
2281
1108
  );
2282
1109
  }
1110
+ var COMMANDS_WITH_HELP = /* @__PURE__ */ new Set(["test"]);
1111
+ async function resolveToken() {
1112
+ return process7.env.CANARY_API_TOKEN ?? await readStoredToken();
1113
+ }
2283
1114
  async function main(argv) {
2284
- if (argv.includes("--help") || argv.includes("-h")) {
2285
- printHelp();
1115
+ if (argv.includes("--version") || argv.includes("-V")) {
1116
+ printVersion();
2286
1117
  return;
2287
1118
  }
2288
1119
  const [command, ...rest] = argv;
1120
+ const hasHelpFlag = argv.includes("--help") || argv.includes("-h");
1121
+ if (hasHelpFlag && (!command || !COMMANDS_WITH_HELP.has(command))) {
1122
+ const token2 = await resolveToken();
1123
+ printHelp({ isSuperadmin: isSuperadminToken(token2) });
1124
+ return;
1125
+ }
2289
1126
  if (!command || command === "help") {
2290
- printHelp();
1127
+ const token2 = await resolveToken();
1128
+ printHelp({ isSuperadmin: isSuperadminToken(token2) });
1129
+ return;
1130
+ }
1131
+ if (command === "version") {
1132
+ printVersion();
2291
1133
  return;
2292
1134
  }
2293
1135
  if (command === "test") {
1136
+ if (rest.includes("--help") || rest.includes("-h")) {
1137
+ printTestHelp();
1138
+ return;
1139
+ }
2294
1140
  if (rest.includes("--remote")) {
2295
1141
  const remoteArgs = rest.filter((arg) => arg !== "--remote");
2296
1142
  await runRemoteTest(remoteArgs);
@@ -2308,8 +1154,8 @@ async function main(argv) {
2308
1154
  return;
2309
1155
  }
2310
1156
  if (command === "mcp") {
2311
- const runMcp2 = await loadMcp();
2312
- await runMcp2(rest);
1157
+ const runMcp = await loadMcp();
1158
+ await runMcp(rest);
2313
1159
  return;
2314
1160
  }
2315
1161
  if (command === "tunnel") {
@@ -2320,17 +1166,41 @@ async function main(argv) {
2320
1166
  await runLogin(rest);
2321
1167
  return;
2322
1168
  }
1169
+ if (command === "orgs") {
1170
+ await runOrgs(rest);
1171
+ return;
1172
+ }
2323
1173
  if (command === "browser") {
2324
- const runLocalBrowser2 = await loadLocalBrowser();
2325
- await runLocalBrowser2(rest);
1174
+ const runLocalBrowser = await loadLocalBrowser();
1175
+ await runLocalBrowser(rest);
1176
+ return;
1177
+ }
1178
+ if (command === "debug-session") {
1179
+ await runDebugSession(rest);
1180
+ return;
1181
+ }
1182
+ if (command === "psql") {
1183
+ const { runPsql } = await import("./psql-7AEFGJWI.js");
1184
+ await runPsql(rest);
1185
+ return;
1186
+ }
1187
+ if (command === "redis") {
1188
+ const { runRedis } = await import("./redis-BXYEPX4T.js");
1189
+ await runRedis(rest);
1190
+ return;
1191
+ }
1192
+ if (command === "feature-flag") {
1193
+ const { runFeatureFlag } = await import("./feature-flag-43WAHIUZ.js");
1194
+ await runFeatureFlag(rest);
2326
1195
  return;
2327
1196
  }
2328
1197
  console.log(`Unknown command "${command}".`);
2329
- printHelp();
2330
- process9.exit(1);
1198
+ const token = await resolveToken();
1199
+ printHelp({ isSuperadmin: isSuperadminToken(token) });
1200
+ process7.exit(1);
2331
1201
  }
2332
- if (import.meta.url === pathToFileURL2(process9.argv[1]).href) {
2333
- void main(process9.argv.slice(2));
1202
+ if (import.meta.url === pathToFileURL2(process7.argv[1]).href) {
1203
+ void main(process7.argv.slice(2));
2334
1204
  }
2335
1205
  export {
2336
1206
  canary,