@autifyhq/aximo-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +117 -50
  2. package/dist/cli.js +3 -1
  3. package/dist/commands/case/case.test.d.ts +1 -0
  4. package/dist/commands/case/case.test.js +120 -0
  5. package/dist/commands/case/create.d.ts +53 -0
  6. package/dist/commands/case/create.js +71 -0
  7. package/dist/commands/case/delete.d.ts +20 -0
  8. package/dist/commands/case/delete.js +39 -0
  9. package/dist/commands/case/index.d.ts +2 -0
  10. package/dist/commands/case/index.js +8 -0
  11. package/dist/commands/device/doctor.d.ts +1 -0
  12. package/dist/commands/device/doctor.js +13 -4
  13. package/dist/commands/device/doctor.test.js +9 -3
  14. package/dist/commands/device/paths.d.ts +1 -0
  15. package/dist/commands/device/paths.js +3 -0
  16. package/dist/commands/mcp/server.test.js +9 -3
  17. package/dist/commands/mcp/tools.js +81 -0
  18. package/dist/commands/mcp/tools.test.js +77 -1
  19. package/dist/commands/plan/add-case.d.ts +30 -0
  20. package/dist/commands/plan/add-case.js +58 -0
  21. package/dist/commands/plan/create.d.ts +31 -0
  22. package/dist/commands/plan/create.js +56 -0
  23. package/dist/commands/plan/index.d.ts +2 -0
  24. package/dist/commands/plan/index.js +9 -0
  25. package/dist/commands/plan/plan.test.d.ts +1 -0
  26. package/dist/commands/plan/plan.test.js +104 -0
  27. package/dist/commands/plan/remove-case.d.ts +26 -0
  28. package/dist/commands/plan/remove-case.js +43 -0
  29. package/dist/commands/schema.js +40 -1
  30. package/dist/commands/session/index.js +1 -0
  31. package/dist/commands/session/save-as-case.d.ts +43 -0
  32. package/dist/commands/session/save-as-case.js +69 -0
  33. package/dist/commands/session/save-as-case.test.d.ts +1 -0
  34. package/dist/commands/session/save-as-case.test.js +58 -0
  35. package/dist/desktop-server/setup-tls.test.d.ts +1 -0
  36. package/dist/desktop-server/setup-tls.test.js +83 -0
  37. package/dist/desktop-server/setup.d.ts +17 -0
  38. package/dist/desktop-server/setup.js +53 -1
  39. package/dist/index.d.ts +12 -7
  40. package/dist/index.js +12 -7
  41. package/dist/types.d.ts +40 -0
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -36,11 +36,7 @@ Get your API key from **Settings → API Keys** in the [Aximo dashboard](https:/
36
36
 
37
37
  ## Configuration
38
38
 
39
- The CLI resolves each credential independently in this order:
40
-
41
- 1. Value in `$AXIMO_HOME/config.json` (written by `aximo configure`)
42
- 2. Environment variable
43
- 3. Default (for base URL only: `https://aximo.autify.com`)
39
+ Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
44
40
 
45
41
  | Variable | Required | Default | Notes |
46
42
  | ---------------- | -------- | -------------------------- | ------------------------------------------------------------ |
@@ -48,60 +44,60 @@ The CLI resolves each credential independently in this order:
48
44
  | `AXIMO_BASE_URL` | No | `https://aximo.autify.com` | Override for sandbox / self-hosted |
49
45
  | `AXIMO_HOME` | No | `~/.aximo` | Data directory (config, device credentials, embedded Python) |
50
46
 
51
- ### Using `aximo configure`
47
+ Set them interactively or pass flags — config is written to `$AXIMO_HOME/config.json` (mode `0600` where the OS supports it; Windows falls back to file ACLs), and an empty prompt keeps the existing value:
52
48
 
53
49
  ```bash
54
50
  aximo configure # interactive prompts
55
51
  aximo configure --base-url URL --api-key KEY # non-interactive
56
52
  ```
57
53
 
58
- The interactive flow prompts first for the base URL (empty → keep existing, else falls back to the Aximo default), then for the API key (empty → keep existing; errors if nothing is saved). Config is written to `$AXIMO_HOME/config.json` with mode `0600`. To change sandbox/prod, re-run `aximo configure` and enter a new URL.
59
-
60
- ### Using environment variables
61
-
62
- If you'd rather keep credentials out of a file (CI, ephemeral shells), set env vars:
54
+ Or keep credentials out of a file (CI, ephemeral shells) with env vars:
63
55
 
64
56
  ```bash
65
- # macOS / Linux
66
57
  export AXIMO_API_KEY=your-api-key
67
58
  export AXIMO_BASE_URL=https://aximo-sbx.autify.dev # optional, prod is the default
68
-
69
- # Windows (PowerShell, persistent)
70
- [Environment]::SetEnvironmentVariable("AXIMO_API_KEY", "your-api-key", "User")
71
- [Environment]::SetEnvironmentVariable("AXIMO_BASE_URL", "https://aximo-sbx.autify.dev", "User")
72
59
  ```
73
60
 
61
+ Credentials and device registrations are **per-environment** — to switch between production and sandbox (`https://aximo-sbx.autify.dev`), re-run `aximo configure` with the other `--base-url`.
62
+
74
63
  ## Commands
75
64
 
76
65
  Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` returns machine-readable argument introspection (e.g. `aximo schema session.create`).
77
66
 
78
- | Command | Description |
79
- | ------------------------- | ---------------------------------------------------- |
80
- | `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
81
- | `aximo health check` | Check API reachability |
82
- | `aximo project list` | List organization projects |
83
- | `aximo session create` | Create and queue a test session |
84
- | `aximo session get` | Get session status |
85
- | `aximo trigger case run` | Trigger a dashboard-created test case |
86
- | `aximo trigger case get` | Get triggered case status |
87
- | `aximo trigger plan run` | Trigger a test plan |
88
- | `aximo trigger plan get` | Get plan run status |
89
- | `aximo upload create` | Create an upload slot for an APK/IPA |
90
- | `aximo upload list` | List previously uploaded apps |
91
- | `aximo upload get` | Poll upload processing status |
92
- | `aximo upload put` | Upload binary file to the slot |
93
- | `aximo upload submit` | Finalize upload and trigger processing |
94
- | `aximo device list` | List mobile devices from Device Farm |
95
- | `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
96
- | `aximo device register` | Register this machine as a BYOD device |
97
- | `aximo device connect` | Start the BYOD agent and connect to the relay |
98
- | `aximo device status` | List your organization's BYOD devices |
99
- | `aximo device ping` | Check your BYOD device's server-side status |
100
- | `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
101
- | `aximo device logs` | Show the BYOD agent log |
102
- | `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
103
- | `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
104
- | `aximo schema` | Show CLI surface as JSON |
67
+ | Command | Description |
68
+ | ---------------------------- | ---------------------------------------------------- |
69
+ | `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
70
+ | `aximo health check` | Check API reachability |
71
+ | `aximo project list` | List organization projects |
72
+ | `aximo session create` | Create and queue a test session |
73
+ | `aximo session get` | Get session status |
74
+ | `aximo session save-as-case` | Save a finished session as a reusable test case |
75
+ | `aximo case create` | Create a test case |
76
+ | `aximo case delete` | Delete a test case |
77
+ | `aximo plan create` | Create a test plan |
78
+ | `aximo plan add-case` | Add a case to a plan |
79
+ | `aximo plan remove-case` | Remove a case from a plan |
80
+ | `aximo trigger case run` | Trigger a dashboard-created test case |
81
+ | `aximo trigger case get` | Get triggered case status |
82
+ | `aximo trigger plan run` | Trigger a test plan |
83
+ | `aximo trigger plan get` | Get plan run status |
84
+ | `aximo upload create` | Create an upload slot for an APK/IPA |
85
+ | `aximo upload list` | List previously uploaded apps |
86
+ | `aximo upload get` | Poll upload processing status |
87
+ | `aximo upload put` | Upload binary file to the slot |
88
+ | `aximo upload submit` | Finalize upload and trigger processing |
89
+ | `aximo device list` | List mobile devices from Device Farm |
90
+ | `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
91
+ | `aximo device register` | Register this machine as a BYOD device |
92
+ | `aximo device connect` | Start the BYOD agent and connect to the relay |
93
+ | `aximo device status` | List your organization's BYOD devices |
94
+ | `aximo device ping` | Check your BYOD device's server-side status |
95
+ | `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
96
+ | `aximo device logs` | Show the BYOD agent log |
97
+ | `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
98
+ | `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
99
+ | `aximo schema` | Show CLI surface as JSON |
100
+ | `aximo mcp` | Run a stdio MCP server for agents (see below) |
105
101
 
106
102
  Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and `--dry-run` to preview requests without executing.
107
103
 
@@ -137,6 +133,34 @@ aximo session get <sessionId>
137
133
 
138
134
  Desktop sessions default to `platform=desktop`, `model=sonnet`. Mobile sessions require both `--device-arn` and `--upload-arn`.
139
135
 
136
+ ### Test cases
137
+
138
+ ```bash
139
+ aximo case create --project-id <id> --scenario <text> \
140
+ [--type web|mobile|desktop] [--title <t>] [--description <d>] \
141
+ [--format gherkin|markdown|plain_text] [--start-url <url>] \
142
+ [--directory-id <id>] [--json '<body>'] [--dry-run]
143
+
144
+ aximo case delete <caseId> [--dry-run]
145
+
146
+ aximo session save-as-case <sessionId> \
147
+ [--title <t>] [--type web|mobile|desktop] \
148
+ [--scenario <text>] [--format gherkin|markdown|plain_text] \
149
+ [--json '<body>'] [--dry-run]
150
+ ```
151
+
152
+ `case create` returns a `caseId` you can run with `aximo trigger case run`. `session save-as-case` persists a finished session (terminal status, not yet linked to a case) as a reusable case, carrying over its scenario and run config. Cases created either way have API access enabled by default, so they are triggerable immediately. `case delete` permanently removes a case: it is dropped from any plans it belongs to, and past run sessions are kept but detached.
153
+
154
+ ### Test plans
155
+
156
+ ```bash
157
+ aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<body>'] [--dry-run]
158
+ aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
159
+ aximo plan remove-case <planId> <caseId> [--dry-run]
160
+ ```
161
+
162
+ Build a runnable plan: `plan create` → `plan add-case` (repeat per case) → `aximo trigger plan run`. Plans created this way have API access enabled by default. Cases must belong to the same workspace as the plan.
163
+
140
164
  ### Triggers (test cases and plans)
141
165
 
142
166
  ```bash
@@ -146,7 +170,7 @@ aximo trigger plan run <planId> [...]
146
170
  aximo trigger plan get <planId> <planRunId>
147
171
  ```
148
172
 
149
- Triggers dashboard-created test cases or plans. API access must be enabled in the Aximo dashboard settings. `trigger case run` returns a `sessionId` you can poll; `trigger plan run` returns per-case breakdown.
173
+ Triggers a test case or plan. API access must be enabled on the resource — on by default for cases/plans created via this CLI; for dashboard-created ones, enable it in the Aximo dashboard settings. `trigger case run` returns a `sessionId` you can poll; `trigger plan run` returns a per-case breakdown.
150
174
 
151
175
  ### Uploads
152
176
 
@@ -226,6 +250,33 @@ aximo device connect # start the agent; keep the terminal open while tests r
226
250
 
227
251
  Your device shows up in the Aximo UI under **Settings → Devices** with status `online` while connected. In the session form, pick your device as the execution target; Aximo routes the agent's actions through the tunnel to your machine.
228
252
 
253
+ ### Self-hosted servers (self-signed / custom CA)
254
+
255
+ On-prem Aximo servers often present a self-signed certificate (a private "Aximo CA"). BYOD has **two** TLS clients: the Node CLI (API calls) and the embedded Python desktop agent (the `wss://…/devices/tunnel` relay). They trust a custom CA differently, so configure both:
256
+
257
+ ```bash
258
+ # 1. Save the server's CA on this machine (or copy aximo-ca.pem from your admin).
259
+ openssl s_client -connect YOUR_SERVER:8443 -showcerts </dev/null 2>/dev/null \
260
+ | awk '/BEGIN CERTIFICATE/{i++} i==2{print} /END CERTIFICATE/{if(i==2) exit}' > ~/aximo-ca.pem
261
+
262
+ # 2. Point the CLI (Node) at it. Used for API calls AND auto-forwarded to the agent.
263
+ export NODE_EXTRA_CA_CERTS="$HOME/aximo-ca.pem"
264
+
265
+ aximo device connect
266
+ ```
267
+
268
+ With `NODE_EXTRA_CA_CERTS` set, `device connect` automatically builds a combined trust store (the embedded Python's `certifi` roots **plus** your CA) and points the agent at it via `SSL_CERT_FILE`, so the relay connection verifies without extra steps.
269
+
270
+ To override the agent's trust store explicitly, set `SSL_CERT_FILE` yourself. It must include public roots **and** your CA — `SSL_CERT_FILE` replaces the entire store, so your CA alone would break any other TLS the agent does (browser launch, uploads):
271
+
272
+ ```bash
273
+ PYBIN="$HOME/.aximo/python/bin/python3"
274
+ cat "$($PYBIN -m certifi)" ~/aximo-ca.pem > ~/.aximo/agent-ca-bundle.pem
275
+ export SSL_CERT_FILE="$HOME/.aximo/agent-ca-bundle.pem"
276
+ ```
277
+
278
+ Persist whichever variables you use in your shell profile. Public-CA servers (e.g. `aximo.autify.com`) need none of this.
279
+
229
280
  ### Managing devices
230
281
 
231
282
  ```bash
@@ -335,17 +386,33 @@ The agent disables DPI-awareness and uses a calibrated scroll amount — if you
335
386
 
336
387
  Default `$AXIMO_HOME` is `~/.aximo` on macOS/Linux, `%USERPROFILE%\.aximo` on Windows. Override via the `AXIMO_HOME` env var.
337
388
 
338
- ### Sandbox vs production
389
+ > Devices are registered per-environment (see [Configuration](#configuration)) — a device registered against sandbox can't connect to production and vice versa.
390
+
391
+ ## MCP server
392
+
393
+ `aximo mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, exposing your recorded sessions, screenshots, and test cases to MCP-capable agents (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, …). The motivating use: let an agent read a finished session and generate a Playwright spec without you piping JSON between commands.
394
+
395
+ It reuses the same credentials as every other command, so run `aximo configure` (or set `AXIMO_API_KEY`) first.
396
+
397
+ **Claude Code** — one command:
339
398
 
340
399
  ```bash
341
- # Sandbox
342
- aximo configure --base-url https://aximo-sbx.autify.dev --api-key <sbx-key>
400
+ claude mcp add aximo -- aximo mcp
401
+ ```
343
402
 
344
- # Production (default)
345
- aximo configure --base-url https://aximo.autify.com --api-key <prod-key>
403
+ **Other clients** — point them at the `aximo mcp` stdio command. Claude Desktop, Cursor, and Windsurf use the `mcpServers` key:
404
+
405
+ ```json
406
+ {
407
+ "mcpServers": {
408
+ "aximo": { "command": "aximo", "args": ["mcp"] }
409
+ }
410
+ }
346
411
  ```
347
412
 
348
- Registrations are per-environment — a device registered against sandbox can't connect to production and vice versa.
413
+ VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
414
+
415
+ The server exposes 16 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 6 that author content (create and delete cases, create plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
349
416
 
350
417
  ## Agent Skills
351
418
 
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import pkg from "../package.json" with { type: "json" };
5
5
  // is JSON-RPC.
6
6
  const args = process.argv.slice(2);
7
7
  if (args[0] === "mcp" && args.some((a) => a === "--help" || a === "-h")) {
8
- process.stderr.write(`aximo mcp — stdio MCP server exposing read-only Aximo data.\n` +
8
+ process.stderr.write(`aximo mcp — stdio MCP server exposing Aximo data plus create operations.\n` +
9
9
  `\n` +
10
10
  `Wire into an MCP client (e.g. Claude Desktop, Claude Code) by pointing it\n` +
11
11
  `at this binary. Auth uses ~/.aximo/config.json (run \`aximo configure\`).\n` +
@@ -23,6 +23,8 @@ const main = defineCommand({
23
23
  configure: () => import("./commands/configure.js").then((m) => m.default),
24
24
  health: () => import("./commands/health/index.js").then((m) => m.default),
25
25
  session: () => import("./commands/session/index.js").then((m) => m.default),
26
+ case: () => import("./commands/case/index.js").then((m) => m.default),
27
+ plan: () => import("./commands/plan/index.js").then((m) => m.default),
26
28
  trigger: () => import("./commands/trigger/index.js").then((m) => m.default),
27
29
  device: () => import("./commands/device/index.js").then((m) => m.default),
28
30
  upload: () => import("./commands/upload/index.js").then((m) => m.default),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
+ import { runCaseCreate } from "./create.js";
4
+ import { runCaseDelete } from "./delete.js";
5
+ beforeAll(() => {
6
+ process.env["AXIMO_API_KEY"] = "test-key";
7
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
8
+ server.listen();
9
+ });
10
+ afterEach(() => server.resetHandlers());
11
+ afterAll(() => server.close());
12
+ const MIN = {
13
+ projectId: "proj-1",
14
+ scenario: "Open the login page and verify it loads",
15
+ };
16
+ describe("runCaseCreate", () => {
17
+ it("POSTs body and returns success envelope", async () => {
18
+ let seen;
19
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
20
+ seen = await req.json();
21
+ return HttpResponse.json({ success: true, caseId: "c1" }, { status: 201 });
22
+ }));
23
+ const result = await runCaseCreate({ ...MIN, type: "web", title: "Login" });
24
+ expect(result.ok).toBe(true);
25
+ if (result.ok && !("dryRun" in result.data))
26
+ expect(result.data.caseId).toBe("c1");
27
+ expect(seen).toMatchObject({
28
+ projectId: "proj-1",
29
+ scenario: "Open the login page and verify it loads",
30
+ type: "web",
31
+ title: "Login",
32
+ });
33
+ });
34
+ it("rejects missing projectId", async () => {
35
+ const result = await runCaseCreate({ scenario: "x" });
36
+ expect(result.ok).toBe(false);
37
+ if (!result.ok)
38
+ expect(result.error.code).toBe("validation_error");
39
+ });
40
+ it("rejects missing scenario", async () => {
41
+ const result = await runCaseCreate({ projectId: "p1" });
42
+ expect(result.ok).toBe(false);
43
+ if (!result.ok)
44
+ expect(result.error.code).toBe("validation_error");
45
+ });
46
+ it("--json replaces individual flags and POSTs raw body", async () => {
47
+ let seen;
48
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, async ({ request: req }) => {
49
+ seen = await req.json();
50
+ return HttpResponse.json({ success: true, caseId: "c2" }, { status: 201 });
51
+ }));
52
+ const raw = JSON.stringify({ projectId: "p1", scenario: "raw", extra: true });
53
+ const result = await runCaseCreate({ json: raw });
54
+ expect(result.ok).toBe(true);
55
+ expect(seen).toEqual({ projectId: "p1", scenario: "raw", extra: true });
56
+ });
57
+ it("--dry-run returns request without calling API", async () => {
58
+ const result = await runCaseCreate({ ...MIN, dryRun: true });
59
+ expect(result.ok).toBe(true);
60
+ if (result.ok && "dryRun" in result.data) {
61
+ expect(result.data.dryRun).toBe(true);
62
+ expect(result.data.method).toBe("POST");
63
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases`);
64
+ }
65
+ });
66
+ it("returns unauthorized envelope when AXIMO_API_KEY unset", async () => {
67
+ const prev = process.env["AXIMO_API_KEY"];
68
+ delete process.env["AXIMO_API_KEY"];
69
+ try {
70
+ const result = await runCaseCreate(MIN);
71
+ expect(result.ok).toBe(false);
72
+ if (!result.ok)
73
+ expect(result.error.code).toBe("unauthorized");
74
+ }
75
+ finally {
76
+ if (prev !== undefined)
77
+ process.env["AXIMO_API_KEY"] = prev;
78
+ }
79
+ });
80
+ it("maps 404 to not_found", async () => {
81
+ server.use(http.post(`${TEST_BASE_URL}/api/cases`, () => HttpResponse.json({ error: "Project not found" }, { status: 404 })));
82
+ const result = await runCaseCreate(MIN);
83
+ expect(result.ok).toBe(false);
84
+ if (!result.ok)
85
+ expect(result.error.code).toBe("not_found");
86
+ });
87
+ });
88
+ describe("runCaseDelete", () => {
89
+ it("DELETEs the case path and returns success", async () => {
90
+ let called = false;
91
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, () => {
92
+ called = true;
93
+ return HttpResponse.json({ success: true });
94
+ }));
95
+ const result = await runCaseDelete({ caseId: "c1" });
96
+ expect(result.ok).toBe(true);
97
+ expect(called).toBe(true);
98
+ });
99
+ it("rejects missing caseId", async () => {
100
+ const result = await runCaseDelete({});
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok)
103
+ expect(result.error.code).toBe("validation_error");
104
+ });
105
+ it("maps 404 to not_found", async () => {
106
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
107
+ const result = await runCaseDelete({ caseId: "c1" });
108
+ expect(result.ok).toBe(false);
109
+ if (!result.ok)
110
+ expect(result.error.code).toBe("not_found");
111
+ });
112
+ it("--dry-run returns a DELETE request without calling API", async () => {
113
+ const result = await runCaseDelete({ caseId: "c1", dryRun: true });
114
+ expect(result.ok).toBe(true);
115
+ if (result.ok && "dryRun" in result.data) {
116
+ expect(result.data.method).toBe("DELETE");
117
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1`);
118
+ }
119
+ });
120
+ });
@@ -0,0 +1,53 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { CreateCaseResult, Envelope } from "../../types.js";
4
+ export interface CreateCaseOptions {
5
+ projectId?: unknown;
6
+ scenario?: unknown;
7
+ type?: unknown;
8
+ title?: unknown;
9
+ description?: unknown;
10
+ format?: unknown;
11
+ startUrl?: unknown;
12
+ directoryId?: unknown;
13
+ json?: unknown;
14
+ dryRun?: unknown;
15
+ }
16
+ export declare function runCaseCreate(options: CreateCaseOptions, deps?: ClientDeps): Promise<Envelope<CreateCaseResult | DryRunResult>>;
17
+ declare const _default: import("citty").CommandDef<{
18
+ readonly "project-id": {
19
+ readonly type: "string";
20
+ };
21
+ readonly scenario: {
22
+ readonly type: "string";
23
+ };
24
+ readonly type: {
25
+ readonly type: "string";
26
+ readonly description: "web | mobile | desktop";
27
+ };
28
+ readonly title: {
29
+ readonly type: "string";
30
+ };
31
+ readonly description: {
32
+ readonly type: "string";
33
+ };
34
+ readonly format: {
35
+ readonly type: "string";
36
+ readonly description: "gherkin | markdown | plain_text";
37
+ };
38
+ readonly "start-url": {
39
+ readonly type: "string";
40
+ };
41
+ readonly "directory-id": {
42
+ readonly type: "string";
43
+ };
44
+ readonly json: {
45
+ readonly type: "string";
46
+ readonly description: "Raw JSON request body (replaces individual flags)";
47
+ };
48
+ readonly "dry-run": {
49
+ readonly type: "boolean";
50
+ readonly description: "Validate and show request without calling API";
51
+ };
52
+ }>;
53
+ export default _default;
@@ -0,0 +1,71 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { optionalString, requireString } from "../../validate.js";
7
+ export async function runCaseCreate(options, deps) {
8
+ let body;
9
+ const override = resolveJsonOverride(options.json);
10
+ if (override && "error" in override)
11
+ return override.error;
12
+ if (override) {
13
+ body = override.body;
14
+ }
15
+ else {
16
+ try {
17
+ body = {
18
+ projectId: requireString("projectId", options.projectId),
19
+ scenario: requireString("scenario", options.scenario),
20
+ type: optionalString("type", options.type),
21
+ title: optionalString("title", options.title),
22
+ description: optionalString("description", options.description),
23
+ format: optionalString("format", options.format),
24
+ startUrl: optionalString("startUrl", options.startUrl),
25
+ directoryId: optionalString("directoryId", options.directoryId),
26
+ };
27
+ }
28
+ catch (err) {
29
+ return validationError(err);
30
+ }
31
+ }
32
+ if (options.dryRun) {
33
+ return dryRunEnvelope("POST", "/api/cases", body);
34
+ }
35
+ try {
36
+ const res = await request("/api/cases", { method: "POST", body }, deps ?? {});
37
+ return formatSuccess(res);
38
+ }
39
+ catch (err) {
40
+ return handleApiError(err);
41
+ }
42
+ }
43
+ export default defineCommand({
44
+ meta: { name: "create", description: "Create a test case" },
45
+ args: {
46
+ "project-id": { type: "string" },
47
+ scenario: { type: "string" },
48
+ type: { type: "string", description: "web | mobile | desktop" },
49
+ title: { type: "string" },
50
+ description: { type: "string" },
51
+ format: { type: "string", description: "gherkin | markdown | plain_text" },
52
+ "start-url": { type: "string" },
53
+ "directory-id": { type: "string" },
54
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
55
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
56
+ },
57
+ async run({ args }) {
58
+ printResult(await runCaseCreate({
59
+ projectId: args["project-id"],
60
+ scenario: args.scenario,
61
+ type: args.type,
62
+ title: args.title,
63
+ description: args.description,
64
+ format: args.format,
65
+ startUrl: args["start-url"],
66
+ directoryId: args["directory-id"],
67
+ json: args.json,
68
+ dryRun: args["dry-run"],
69
+ }));
70
+ },
71
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { DeleteCaseResult, Envelope } from "../../types.js";
4
+ export interface DeleteCaseOptions {
5
+ caseId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runCaseDelete(options: DeleteCaseOptions, deps?: ClientDeps): Promise<Envelope<DeleteCaseResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly caseId: {
11
+ readonly type: "positional";
12
+ readonly description: "Case ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;
@@ -0,0 +1,39 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runCaseDelete(options, deps) {
8
+ let caseId;
9
+ try {
10
+ caseId = requireId("caseId", options.caseId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/cases/${encodeURIComponent(caseId)}`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("DELETE", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "DELETE" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: { name: "delete", description: "Delete a test case" },
29
+ args: {
30
+ caseId: { type: "positional", description: "Case ID", required: true },
31
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
32
+ },
33
+ async run({ args }) {
34
+ printResult(await runCaseDelete({
35
+ caseId: args.caseId,
36
+ dryRun: args["dry-run"],
37
+ }));
38
+ },
39
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("citty").CommandDef<import("citty").ArgsDef>;
2
+ export default _default;
@@ -0,0 +1,8 @@
1
+ import { defineCommand } from "citty";
2
+ export default defineCommand({
3
+ meta: { name: "case", description: "Test case commands" },
4
+ subCommands: {
5
+ create: () => import("./create.js").then((m) => m.default),
6
+ delete: () => import("./delete.js").then((m) => m.default),
7
+ },
8
+ });
@@ -16,6 +16,7 @@ export interface DoctorReport {
16
16
  info: number;
17
17
  };
18
18
  }
19
+ export declare function relayProbeUrl(): string;
19
20
  export interface RunDoctorOptions {
20
21
  skipRelay?: boolean;
21
22
  skipApi?: boolean;
@@ -1,13 +1,13 @@
1
- import { defineCommand } from "citty";
2
1
  import { spawnSync } from "node:child_process";
3
2
  import { existsSync, readFileSync, statSync } from "node:fs";
4
3
  import { platform } from "node:os";
4
+ import { defineCommand } from "citty";
5
5
  import { WebSocket } from "ws";
6
6
  import { getBaseUrl, request } from "../../client.js";
7
- import { formatSuccess, printResult } from "../../output.js";
8
7
  import { loadConfig } from "../../config.js";
9
8
  import { readCachedManifest } from "../../desktop-server/agent-sync.js";
10
9
  import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
10
+ import { formatSuccess, printResult } from "../../output.js";
11
11
  import { getAgentRequirementsPath, getAgentScriptPath, getDepsMarkerPath, getDeviceConfigPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, } from "./paths.js";
12
12
  function redactKey(value) {
13
13
  if (value.length <= 8)
@@ -249,12 +249,21 @@ async function checkAgentUpToDate(deps) {
249
249
  function relayUrlFromBase() {
250
250
  return `${getBaseUrl().replace(/^http/, "ws")}/devices/tunnel`;
251
251
  }
252
+ // The relay is a Socket.IO / engine.io endpoint (websocket-only), which answers
253
+ // only at <path>/?EIO=4&transport=websocket. A raw WebSocket to the bare path
254
+ // 404s or hangs, so the old probe reported a false negative even when the relay
255
+ // was healthy. Probe the engine.io upgrade directly — a successful open (HTTP
256
+ // 101) means the relay is up; device auth is enforced later, after this opens.
257
+ export function relayProbeUrl() {
258
+ return `${relayUrlFromBase()}/?EIO=4&transport=websocket`;
259
+ }
252
260
  const RELAY_CHECK_NAME = "relay handshake";
253
261
  function checkRelayReachable() {
254
262
  return new Promise((resolve) => {
255
263
  const url = relayUrlFromBase();
264
+ const probeUrl = relayProbeUrl();
256
265
  let settled = false;
257
- const ws = new WebSocket(url, { handshakeTimeout: 5_000 });
266
+ const ws = new WebSocket(probeUrl, { handshakeTimeout: 5_000 });
258
267
  const done = (check) => {
259
268
  if (settled)
260
269
  return;
@@ -280,7 +289,7 @@ function checkRelayReachable() {
280
289
  done({
281
290
  name: RELAY_CHECK_NAME,
282
291
  status: "pass",
283
- message: `WebSocket handshake to ${url} succeeded (device auth not verified)`,
292
+ message: `engine.io upgrade to ${probeUrl} succeeded (device auth not verified)`,
284
293
  });
285
294
  });
286
295
  ws.on("error", (err) => {