@autifyhq/aximo-cli 0.8.0 → 0.10.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 (70) hide show
  1. package/README.md +19 -7
  2. package/dist/client.d.ts +7 -1
  3. package/dist/client.js +21 -2
  4. package/dist/client.test.js +36 -0
  5. package/dist/commands/case/case.test.js +62 -0
  6. package/dist/commands/case/create.d.ts +1 -1
  7. package/dist/commands/case/create.js +4 -4
  8. package/dist/commands/case/index.js +1 -0
  9. package/dist/commands/case/update.d.ts +50 -0
  10. package/dist/commands/case/update.js +78 -0
  11. package/dist/commands/device/connect.js +19 -1
  12. package/dist/commands/device/doctor.d.ts +2 -0
  13. package/dist/commands/device/doctor.js +37 -10
  14. package/dist/commands/device/doctor.test.js +26 -1
  15. package/dist/commands/health/check.js +15 -3
  16. package/dist/commands/health/check.test.js +27 -3
  17. package/dist/commands/mcp/server.test.js +3 -1
  18. package/dist/commands/mcp/tools.js +28 -0
  19. package/dist/commands/mcp/tools.test.js +90 -0
  20. package/dist/commands/mobile/connect-core.d.ts +5 -3
  21. package/dist/commands/mobile/connect-core.js +60 -12
  22. package/dist/commands/mobile/connect.d.ts +5 -0
  23. package/dist/commands/mobile/connect.js +6 -1
  24. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  25. package/dist/commands/mobile/signing-consent.js +22 -0
  26. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  27. package/dist/commands/mobile/signing-consent.test.js +50 -0
  28. package/dist/commands/plan/index.js +1 -0
  29. package/dist/commands/plan/plan.test.js +61 -0
  30. package/dist/commands/plan/update.d.ts +42 -0
  31. package/dist/commands/plan/update.js +78 -0
  32. package/dist/commands/schema.js +1 -1
  33. package/dist/commands/session/save-as-case.js +3 -3
  34. package/dist/commands/trigger/plan/run.d.ts +6 -1
  35. package/dist/commands/trigger/plan/run.js +9 -3
  36. package/dist/commands/trigger/trigger.test.js +22 -3
  37. package/dist/commands/upload/put.d.ts +5 -0
  38. package/dist/commands/upload/put.js +44 -11
  39. package/dist/commands/upload/upload.test.js +82 -0
  40. package/dist/desktop-server/preflight.d.ts +43 -0
  41. package/dist/desktop-server/preflight.js +78 -0
  42. package/dist/desktop-server/preflight.test.d.ts +1 -0
  43. package/dist/desktop-server/preflight.test.js +115 -0
  44. package/dist/mobile-control/appium-controller.d.ts +28 -0
  45. package/dist/mobile-control/appium-controller.js +271 -92
  46. package/dist/mobile-control/appium-controller.test.js +258 -8
  47. package/dist/mobile-control/bundle-id-map.js +3 -0
  48. package/dist/mobile-control/controller.d.ts +3 -0
  49. package/dist/mobile-control/frame-sender.d.ts +34 -0
  50. package/dist/mobile-control/frame-sender.js +96 -0
  51. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  52. package/dist/mobile-control/frame-sender.test.js +108 -0
  53. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  54. package/dist/mobile-control/mjpeg-stream.js +160 -0
  55. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  56. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  57. package/dist/mobile-control/tunnel.d.ts +10 -1
  58. package/dist/mobile-control/tunnel.js +43 -2
  59. package/dist/mobile-control/tunnel.test.js +108 -0
  60. package/dist/mobile-control/webdriver.d.ts +9 -1
  61. package/dist/mobile-control/webdriver.js +17 -5
  62. package/dist/mutate.d.ts +1 -1
  63. package/dist/mutate.js +4 -2
  64. package/dist/prompt.d.ts +11 -0
  65. package/dist/prompt.js +19 -0
  66. package/dist/types.d.ts +66 -4
  67. package/dist/validate.d.ts +6 -1
  68. package/dist/validate.js +46 -0
  69. package/dist/validate.test.js +39 -1
  70. package/package.json +1 -1
package/README.md CHANGED
@@ -38,7 +38,7 @@ Want to run tests on **your own hardware** instead of the cloud? See [Device (BY
38
38
 
39
39
  ## Configuration
40
40
 
41
- Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
41
+ Each credential resolves independently: the environment variable wins, then the value in `$AXIMO_HOME/config.json` (written by `aximo configure`), then the default.
42
42
 
43
43
  | Variable | Required | Default | Notes |
44
44
  | ---------------- | -------- | -------------------------- | ------------------------------------------------------------ |
@@ -60,7 +60,7 @@ export AXIMO_API_KEY=your-api-key
60
60
  export AXIMO_BASE_URL=https://aximo-sbx.autify.dev # optional, prod is the default
61
61
  ```
62
62
 
63
- 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`.
63
+ 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`, or export `AXIMO_BASE_URL`/`AXIMO_API_KEY`, which override the saved config.
64
64
 
65
65
  ## Commands
66
66
 
@@ -75,8 +75,10 @@ Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` r
75
75
  | `aximo session get` | Get session status |
76
76
  | `aximo session save-as-case` | Save a finished session as a reusable test case |
77
77
  | `aximo case create` | Create a test case |
78
+ | `aximo case update` | Update a test case |
78
79
  | `aximo case delete` | Delete a test case |
79
80
  | `aximo plan create` | Create a test plan |
81
+ | `aximo plan update` | Update a test plan |
80
82
  | `aximo plan add-case` | Add a case to a plan |
81
83
  | `aximo plan remove-case` | Remove a case from a plan |
82
84
  | `aximo trigger case run` | Trigger a dashboard-created test case |
@@ -116,7 +118,7 @@ Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and
116
118
  aximo health check
117
119
  ```
118
120
 
119
- Returns `{ok: true, data: {status: "ok"}}` when the API is reachable and your API key is valid.
121
+ Returns `{ok: true, data: {status: "ok", auth: "ok"}}` when the API is reachable and your API key is valid — after the health endpoint responds, the command verifies the key with an authenticated probe. If the API is reachable but the key is rejected, it exits non-zero with an `unauthorized` error envelope.
120
122
 
121
123
  ### Projects
122
124
 
@@ -150,6 +152,9 @@ aximo case create --project-id <id> --scenario <text> \
150
152
  [--format gherkin|markdown|plain_text] [--start-url <url>] \
151
153
  [--directory-id <id>] [--json '<body>'] [--dry-run]
152
154
 
155
+ aximo case update <caseId> [--title <t>] [--description <d>] [--scenario <text>] \
156
+ [--type web|mobile|desktop|multi_device] [--format gherkin|markdown|plain_text] \
157
+ [--start-url <url>] [--json '<body>'] [--dry-run]
153
158
  aximo case delete <caseId> [--dry-run]
154
159
 
155
160
  aximo session save-as-case <sessionId> \
@@ -164,11 +169,14 @@ aximo session save-as-case <sessionId> \
164
169
 
165
170
  ```bash
166
171
  aximo plan create --project-id <id> --title <t> [--description <d>] [--json '<body>'] [--dry-run]
172
+ aximo plan update <planId> [--title <t>] [--description <d>] \
173
+ [--execution-mode parallel|sequential] [--stop-on-failure true|false] \
174
+ [--json '<body>'] [--dry-run]
167
175
  aximo plan add-case <planId> --case-id <caseId> [--json '<body>'] [--dry-run]
168
176
  aximo plan remove-case <planId> <caseId> [--dry-run]
169
177
  ```
170
178
 
171
- 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.
179
+ 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. `plan update` changes only the flags you pass; `--execution-mode` sets the plan's stored Execution setting (sequential runs one case at a time in plan order) and `--stop-on-failure` marks the remaining cases as skipped after a failure, which only takes effect on sequential runs.
172
180
 
173
181
  ### Triggers (test cases and plans)
174
182
 
@@ -181,17 +189,21 @@ aximo trigger plan get <planId> <planRunId>
181
189
 
182
190
  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.
183
191
 
192
+ `trigger plan run` accepts `--execution-mode parallel|sequential` to override the plan's stored Execution setting for that run only (omitted = plan setting; plans default to parallel). Sequential runs one case at a time in plan order, and the plan's stop-on-failure option applies only when the effective mode is sequential.
193
+
184
194
  ### Uploads
185
195
 
186
196
  ```bash
187
197
  aximo upload create --file-name <name> [--file-size <bytes>] [--platform ANDROID|IOS]
188
- aximo upload put <uploadId> --file <path>
198
+ aximo upload put <uploadId> --file <path> [--url <presignedUrl>]
189
199
  aximo upload submit <uploadId>
190
200
  aximo upload get <uploadId>
191
201
  aximo upload list [--file-type ANDROID_APP|IOS_APP]
192
202
  ```
193
203
 
194
- Typical flow: `create` → `put` (sends binary to the presigned URL) → `submit` → poll `get` until `status=succeeded` → extract `uploadArn` for mobile sessions. `upload list` auto-re-uploads expired Device Farm entries.
204
+ Typical flow: `create` → take `presignedUrl` from the output → `put <uploadId> --file app.apk --url "<presignedUrl>"` → `submit` → poll `get` until `status=succeeded` → extract `uploadArn` for mobile sessions. `upload list` auto-re-uploads expired Device Farm entries.
205
+
206
+ When `presignedUrl` is an absolute S3 URL (cloud deployments) the CLI PUTs the bytes straight to it with no API-key header. When it is a relative API path (local-storage deployments) — or `--url` is omitted — the CLI falls back to an authenticated PUT through the API. If that fallback fails with HTTP 405, the deployment is S3-backed: re-run with `--url`.
195
207
 
196
208
  ### Schema
197
209
 
@@ -611,7 +623,7 @@ claude mcp add aximo -- aximo mcp
611
623
 
612
624
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
613
625
 
614
- 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.
626
+ The server exposes 18 tools: 12 read-only ones for inspecting sessions, cases, and plan runs (`aximo_get_session_timeline` is the load-bearing one for Playwright export; `aximo_list_plan_runs` and `aximo_get_plan_run` cover run-status polling) 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.
615
627
 
616
628
  ## Agent Skills
617
629
 
package/dist/client.d.ts CHANGED
@@ -2,7 +2,7 @@ export declare const DEFAULT_BASE_URL = "https://aximo.autify.com";
2
2
  export declare function getApiKey(): string;
3
3
  export declare function getBaseUrl(): string;
4
4
  export interface RequestOptions {
5
- method?: "GET" | "POST" | "PUT" | "DELETE";
5
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
6
6
  query?: Record<string, string | number | boolean | undefined>;
7
7
  body?: unknown;
8
8
  rawBody?: BodyInit | undefined;
@@ -15,6 +15,12 @@ export interface ClientDeps {
15
15
  fetchImpl?: typeof fetch;
16
16
  }
17
17
  export declare function request<T>(path: string, options?: RequestOptions, deps?: ClientDeps): Promise<T>;
18
+ /**
19
+ * Unauthenticated PUT to an absolute URL (e.g. an S3 presigned URL). No
20
+ * x-api-key/Authorization header — presigned URLs carry their auth in the
21
+ * query string, and extra auth headers can break S3 signature validation.
22
+ */
23
+ export declare function putToPresignedUrl(url: string, rawBody: BodyInit, contentType: string, deps?: ClientDeps): Promise<void>;
18
24
  export declare function requestBuffer(path: string, deps?: ClientDeps): Promise<Buffer>;
19
25
  /** Authenticated GET that streams the response body to a file (no full-buffering). */
20
26
  export declare function requestToFile(path: string, destPath: string, deps?: ClientDeps): Promise<void>;
package/dist/client.js CHANGED
@@ -2,7 +2,9 @@ import { loadConfig } from "./config.js";
2
2
  export const DEFAULT_BASE_URL = "https://aximo.autify.com";
3
3
  export function getApiKey() {
4
4
  const config = loadConfig();
5
- const key = config.apiKey ?? process.env["AXIMO_API_KEY"];
5
+ // Explicit env vars win over saved config so an exported key is never
6
+ // silently overridden by a stale config.json from another environment.
7
+ const key = process.env["AXIMO_API_KEY"] ?? config.apiKey;
6
8
  if (!key) {
7
9
  throw new Error("No API key configured. Run `aximo configure`, set AXIMO_API_KEY in your environment, or use `node --env-file=.env`.");
8
10
  }
@@ -10,7 +12,7 @@ export function getApiKey() {
10
12
  }
11
13
  export function getBaseUrl() {
12
14
  const config = loadConfig();
13
- const raw = config.baseUrl ?? process.env["AXIMO_BASE_URL"] ?? DEFAULT_BASE_URL;
15
+ const raw = process.env["AXIMO_BASE_URL"] ?? config.baseUrl ?? DEFAULT_BASE_URL;
14
16
  return raw.replace(/\/+$/, "");
15
17
  }
16
18
  function httpError(status, message, retryAfter) {
@@ -76,6 +78,23 @@ export async function request(path, options = {}, deps = {}) {
76
78
  return undefined;
77
79
  return JSON.parse(text);
78
80
  }
81
+ /**
82
+ * Unauthenticated PUT to an absolute URL (e.g. an S3 presigned URL). No
83
+ * x-api-key/Authorization header — presigned URLs carry their auth in the
84
+ * query string, and extra auth headers can break S3 signature validation.
85
+ */
86
+ export async function putToPresignedUrl(url, rawBody, contentType, deps = {}) {
87
+ const fetchImpl = deps.fetchImpl ?? fetch;
88
+ const res = await fetchImpl(url, {
89
+ method: "PUT",
90
+ headers: { "Content-Type": contentType },
91
+ body: rawBody,
92
+ });
93
+ if (!res.ok) {
94
+ const retryAfter = res.headers.get("retry-after") ?? undefined;
95
+ throw httpError(res.status, await errorMessage(res), retryAfter);
96
+ }
97
+ }
79
98
  export async function requestBuffer(path, deps = {}) {
80
99
  const apiKey = deps.apiKey ?? getApiKey();
81
100
  const baseUrl = deps.baseUrl ?? getBaseUrl();
@@ -1,3 +1,6 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
5
  import { server, http, HttpResponse, TEST_BASE_URL } from "./test-utils.js";
3
6
  import { getApiKey, getBaseUrl, request } from "./client.js";
@@ -48,6 +51,39 @@ describe("getBaseUrl", () => {
48
51
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
49
52
  });
50
53
  });
54
+ describe("credential precedence", () => {
55
+ let tempHome;
56
+ let prevHome;
57
+ beforeAll(() => {
58
+ prevHome = process.env["AXIMO_HOME"];
59
+ tempHome = mkdtempSync(join(tmpdir(), "aximo-client-"));
60
+ writeFileSync(join(tempHome, "config.json"), JSON.stringify({ apiKey: "config-key", baseUrl: "https://config.example.test" }));
61
+ process.env["AXIMO_HOME"] = tempHome;
62
+ });
63
+ afterAll(() => {
64
+ if (prevHome !== undefined)
65
+ process.env["AXIMO_HOME"] = prevHome;
66
+ else
67
+ delete process.env["AXIMO_HOME"];
68
+ rmSync(tempHome, { recursive: true, force: true });
69
+ });
70
+ it("prefers env vars over config.json values", () => {
71
+ expect(getApiKey()).toBe("test-key");
72
+ expect(getBaseUrl()).toBe(TEST_BASE_URL);
73
+ });
74
+ it("falls back to config.json when env vars are unset", () => {
75
+ delete process.env["AXIMO_API_KEY"];
76
+ delete process.env["AXIMO_BASE_URL"];
77
+ try {
78
+ expect(getApiKey()).toBe("config-key");
79
+ expect(getBaseUrl()).toBe("https://config.example.test");
80
+ }
81
+ finally {
82
+ process.env["AXIMO_API_KEY"] = "test-key";
83
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
84
+ }
85
+ });
86
+ });
51
87
  describe("request", () => {
52
88
  it("sends x-api-key header and parses JSON", async () => {
53
89
  let seenKey = null;
@@ -1,6 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
2
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { runCaseCreate } from "./create.js";
4
+ import { runCaseUpdate } from "./update.js";
4
5
  import { runCaseDelete } from "./delete.js";
5
6
  beforeAll(() => {
6
7
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -118,3 +119,64 @@ describe("runCaseDelete", () => {
118
119
  }
119
120
  });
120
121
  });
122
+ describe("runCaseUpdate", () => {
123
+ it("PATCHes the provided fields and returns the updated case", async () => {
124
+ let seen;
125
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/c1`, async ({ request: req }) => {
126
+ seen = await req.json();
127
+ return HttpResponse.json({
128
+ success: true,
129
+ case: {
130
+ id: "c1",
131
+ projectId: "p1",
132
+ type: "web",
133
+ title: "Renamed case",
134
+ description: null,
135
+ scenario: "Given a user",
136
+ format: "plain_text",
137
+ startUrl: null,
138
+ updatedAt: "2026-07-16T00:00:00Z",
139
+ },
140
+ });
141
+ }));
142
+ const result = await runCaseUpdate({
143
+ caseId: "c1",
144
+ title: "Renamed case",
145
+ scenario: "Given a user",
146
+ format: "plain_text",
147
+ });
148
+ expect(result.ok).toBe(true);
149
+ if (result.ok && !("dryRun" in result.data)) {
150
+ expect(result.data.case.title).toBe("Renamed case");
151
+ }
152
+ expect(seen).toMatchObject({
153
+ title: "Renamed case",
154
+ scenario: "Given a user",
155
+ format: "plain_text",
156
+ });
157
+ });
158
+ it("rejects when no fields are provided", async () => {
159
+ const result = await runCaseUpdate({ caseId: "c1" });
160
+ expect(result.ok).toBe(false);
161
+ if (!result.ok)
162
+ expect(result.error.code).toBe("validation_error");
163
+ });
164
+ it("rejects invalid type and format values client-side", async () => {
165
+ const badType = await runCaseUpdate({ caseId: "c1", type: "banana" });
166
+ expect(badType.ok).toBe(false);
167
+ if (!badType.ok)
168
+ expect(badType.error.code).toBe("validation_error");
169
+ const badFormat = await runCaseUpdate({ caseId: "c1", format: "klingon" });
170
+ expect(badFormat.ok).toBe(false);
171
+ if (!badFormat.ok)
172
+ expect(badFormat.error.code).toBe("validation_error");
173
+ });
174
+ it("--dry-run returns the PATCH request without calling the API", async () => {
175
+ const result = await runCaseUpdate({ caseId: "c1", title: "t", dryRun: true });
176
+ expect(result.ok).toBe(true);
177
+ if (result.ok && "dryRun" in result.data) {
178
+ expect(result.data.method).toBe("PATCH");
179
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1`);
180
+ }
181
+ });
182
+ });
@@ -23,7 +23,7 @@ declare const _default: import("citty").CommandDef<{
23
23
  };
24
24
  readonly type: {
25
25
  readonly type: "string";
26
- readonly description: "web | mobile | desktop";
26
+ readonly description: "web | mobile | desktop | multi_device";
27
27
  };
28
28
  readonly title: {
29
29
  readonly type: "string";
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, requireString } from "../../validate.js";
6
+ import { optionalString, requireString, parseCaseFormat, parseCaseType } from "../../validate.js";
7
7
  export async function runCaseCreate(options, deps) {
8
8
  let body;
9
9
  const override = resolveJsonOverride(options.json);
@@ -17,10 +17,10 @@ export async function runCaseCreate(options, deps) {
17
17
  body = {
18
18
  projectId: requireString("projectId", options.projectId),
19
19
  scenario: requireString("scenario", options.scenario),
20
- type: optionalString("type", options.type),
20
+ type: parseCaseType(options.type),
21
21
  title: optionalString("title", options.title),
22
22
  description: optionalString("description", options.description),
23
- format: optionalString("format", options.format),
23
+ format: parseCaseFormat(options.format),
24
24
  startUrl: optionalString("startUrl", options.startUrl),
25
25
  directoryId: optionalString("directoryId", options.directoryId),
26
26
  };
@@ -45,7 +45,7 @@ export default defineCommand({
45
45
  args: {
46
46
  "project-id": { type: "string" },
47
47
  scenario: { type: "string" },
48
- type: { type: "string", description: "web | mobile | desktop" },
48
+ type: { type: "string", description: "web | mobile | desktop | multi_device" },
49
49
  title: { type: "string" },
50
50
  description: { type: "string" },
51
51
  format: { type: "string", description: "gherkin | markdown | plain_text" },
@@ -3,6 +3,7 @@ export default defineCommand({
3
3
  meta: { name: "case", description: "Test case commands" },
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
+ update: () => import("./update.js").then((m) => m.default),
6
7
  delete: () => import("./delete.js").then((m) => m.default),
7
8
  },
8
9
  });
@@ -0,0 +1,50 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { Envelope, UpdateCaseResult } from "../../types.js";
4
+ export interface UpdateCaseOptions {
5
+ caseId?: unknown;
6
+ title?: unknown;
7
+ description?: unknown;
8
+ scenario?: unknown;
9
+ type?: unknown;
10
+ format?: unknown;
11
+ startUrl?: unknown;
12
+ json?: unknown;
13
+ dryRun?: unknown;
14
+ }
15
+ export declare function runCaseUpdate(options: UpdateCaseOptions, deps?: ClientDeps): Promise<Envelope<UpdateCaseResult | DryRunResult>>;
16
+ declare const _default: import("citty").CommandDef<{
17
+ readonly caseId: {
18
+ readonly type: "positional";
19
+ readonly required: true;
20
+ };
21
+ readonly title: {
22
+ readonly type: "string";
23
+ };
24
+ readonly description: {
25
+ readonly type: "string";
26
+ };
27
+ readonly scenario: {
28
+ readonly type: "string";
29
+ };
30
+ readonly type: {
31
+ readonly type: "string";
32
+ readonly description: "web | mobile | desktop | multi_device";
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 json: {
42
+ readonly type: "string";
43
+ readonly description: "Raw JSON request body (replaces individual flags)";
44
+ };
45
+ readonly "dry-run": {
46
+ readonly type: "boolean";
47
+ readonly description: "Validate and show request without calling API";
48
+ };
49
+ }>;
50
+ export default _default;
@@ -0,0 +1,78 @@
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, parseCaseFormat, parseCaseType, requireId } from "../../validate.js";
7
+ export async function runCaseUpdate(options, deps) {
8
+ let caseId;
9
+ let body;
10
+ const override = resolveJsonOverride(options.json);
11
+ if (override && "error" in override)
12
+ return override.error;
13
+ try {
14
+ caseId = requireId("caseId", options.caseId);
15
+ }
16
+ catch (err) {
17
+ return validationError(err);
18
+ }
19
+ if (override) {
20
+ body = override.body;
21
+ }
22
+ else {
23
+ try {
24
+ body = {
25
+ title: optionalString("title", options.title),
26
+ description: optionalString("description", options.description),
27
+ scenario: optionalString("scenario", options.scenario),
28
+ type: parseCaseType(options.type),
29
+ format: parseCaseFormat(options.format),
30
+ startUrl: optionalString("startUrl", options.startUrl),
31
+ };
32
+ }
33
+ catch (err) {
34
+ return validationError(err);
35
+ }
36
+ if (Object.values(body).every((v) => v === undefined)) {
37
+ return validationError(new Error("at least one field to update is required"));
38
+ }
39
+ }
40
+ const path = `/api/cases/${encodeURIComponent(caseId)}`;
41
+ if (options.dryRun) {
42
+ return dryRunEnvelope("PATCH", path, body);
43
+ }
44
+ try {
45
+ const res = await request(path, { method: "PATCH", body }, deps ?? {});
46
+ return formatSuccess(res);
47
+ }
48
+ catch (err) {
49
+ return handleApiError(err);
50
+ }
51
+ }
52
+ export default defineCommand({
53
+ meta: { name: "update", description: "Update a test case" },
54
+ args: {
55
+ caseId: { type: "positional", required: true },
56
+ title: { type: "string" },
57
+ description: { type: "string" },
58
+ scenario: { type: "string" },
59
+ type: { type: "string", description: "web | mobile | desktop | multi_device" },
60
+ format: { type: "string", description: "gherkin | markdown | plain_text" },
61
+ "start-url": { type: "string" },
62
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
63
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
64
+ },
65
+ async run({ args }) {
66
+ printResult(await runCaseUpdate({
67
+ caseId: args.caseId,
68
+ title: args.title,
69
+ description: args.description,
70
+ scenario: args.scenario,
71
+ type: args.type,
72
+ format: args.format,
73
+ startUrl: args["start-url"],
74
+ json: args.json,
75
+ dryRun: args["dry-run"],
76
+ }));
77
+ },
78
+ });
@@ -1,5 +1,5 @@
1
- import { defineCommand } from "citty";
2
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { defineCommand } from "citty";
3
3
  import { getBaseUrl } from "../../client.js";
4
4
  import { formatError, printResult } from "../../output.js";
5
5
  import { getDeviceConfigPath } from "./paths.js";
@@ -29,10 +29,28 @@ function getRelayUrl() {
29
29
  }
30
30
  async function connectToRelay(config) {
31
31
  const { ensurePythonReady, startDesktopRemote } = await import("../../desktop-server/setup.js");
32
+ const { confirmProceed, formatPreflightLines, runLocalPreflight } = await import("../../desktop-server/preflight.js");
32
33
  console.log("Preparing Python environment...");
33
34
  const { pythonPath, scriptPath, agentVersion } = await ensurePythonReady();
34
35
  console.log(`Python ready: ${pythonPath}`);
35
36
  console.log(`Agent version: ${agentVersion}`);
37
+ // Last point where a human is present: sessions are triggered remotely, so this
38
+ // is the only place a failed check can offer to stop before it wastes a run.
39
+ const preflight = runLocalPreflight({ pythonPath, scriptPath });
40
+ if (preflight.kind === "ok") {
41
+ for (const line of formatPreflightLines(preflight.report))
42
+ console.log(line);
43
+ if (!(await confirmProceed(preflight.report))) {
44
+ console.log("Not connecting. Fix the checks above and run 'aximo device connect' again.");
45
+ return;
46
+ }
47
+ }
48
+ else if (preflight.kind === "error") {
49
+ console.log(`Desktop environment: could not be checked (${preflight.detail}).`);
50
+ }
51
+ else {
52
+ console.log("Desktop environment: checks unavailable for this agent version.");
53
+ }
36
54
  const relayUrl = getRelayUrl();
37
55
  console.log(`Starting desktop agent, relay: ${relayUrl}`);
38
56
  let remoteProcess = startDesktopRemote(pythonPath, scriptPath, {
@@ -1,4 +1,5 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
+ import { type LocalPreflight } from "../../desktop-server/preflight.js";
2
3
  import type { Envelope } from "../../types.js";
3
4
  export type CheckStatus = "pass" | "warn" | "fail" | "info";
4
5
  export interface Check {
@@ -30,6 +31,7 @@ export interface AgentTlsDeps {
30
31
  probe?: (host: string, port: number, ca: Buffer) => Promise<TlsProbeResult>;
31
32
  }
32
33
  export declare function checkAgentTlsTrust(deps?: AgentTlsDeps): Promise<Check>;
34
+ export declare function checkDesktopEnvironment(runPreflight?: () => LocalPreflight): Check[];
33
35
  export interface RunDoctorOptions {
34
36
  skipRelay?: boolean;
35
37
  skipApi?: boolean;
@@ -7,6 +7,7 @@ import { WebSocket } from "ws";
7
7
  import { getBaseUrl, request } from "../../client.js";
8
8
  import { loadConfig } from "../../config.js";
9
9
  import { readCachedManifest } from "../../desktop-server/agent-sync.js";
10
+ import { runLocalPreflight } from "../../desktop-server/preflight.js";
10
11
  import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
11
12
  import { formatSuccess, printResult } from "../../output.js";
12
13
  import { readDeviceConfig } from "./device-config.js";
@@ -450,6 +451,41 @@ async function checkCurrentStatus(deviceId, deps) {
450
451
  };
451
452
  }
452
453
  }
454
+ // The desktop agent is the only thing that can see the tester's screen, input
455
+ // source and permissions, so doctor reports what it reports rather than
456
+ // reimplementing the probes here.
457
+ const MANUAL_DESKTOP_HINT = {
458
+ darwin: "Grant Accessibility + Screen Recording in System Settings → Privacy & Security",
459
+ win32: "Run from an elevated terminal if the test touches admin windows",
460
+ linux: "Use an X11/Xorg session; Wayland blocks capture and synthetic input",
461
+ };
462
+ export function checkDesktopEnvironment(runPreflight = runLocalPreflight) {
463
+ const result = runPreflight();
464
+ if (result.kind === "ok") {
465
+ return result.report.checks.map((check) => ({ ...check, name: `desktop: ${check.name}` }));
466
+ }
467
+ const hint = MANUAL_DESKTOP_HINT[platform()];
468
+ if (result.kind === "error") {
469
+ return [
470
+ {
471
+ name: "desktop environment",
472
+ status: "fail",
473
+ message: `the agent could not check this machine: ${result.detail}`,
474
+ hint: "Run: aximo device reset --deps && aximo device setup",
475
+ },
476
+ ];
477
+ }
478
+ return [
479
+ {
480
+ name: "desktop environment",
481
+ status: "info",
482
+ message: "not checked — the agent is not installed yet",
483
+ ...(hint
484
+ ? { hint: `Run: aximo device setup. ${hint}` }
485
+ : { hint: "Run: aximo device setup" }),
486
+ },
487
+ ];
488
+ }
453
489
  function checkPlatformExtras() {
454
490
  const p = platform();
455
491
  if (p === "linux") {
@@ -468,16 +504,6 @@ function checkPlatformExtras() {
468
504
  },
469
505
  ];
470
506
  }
471
- if (p === "darwin") {
472
- return [
473
- {
474
- name: "macos permissions",
475
- status: "info",
476
- message: "Accessibility + Screen Recording must be granted manually",
477
- hint: "System Settings → Privacy & Security → Accessibility / Screen Recording",
478
- },
479
- ];
480
- }
481
507
  return [];
482
508
  }
483
509
  function logDirUsage() {
@@ -509,6 +535,7 @@ export async function runDeviceDoctor(options = {}, deps) {
509
535
  checks.push(checkPythonDeps());
510
536
  checks.push(checkAgentFiles());
511
537
  checks.push(...checkPlatformExtras());
538
+ checks.push(...checkDesktopEnvironment());
512
539
  checks.push(logDirUsage());
513
540
  if (!options.skipApi && checks[0]?.status !== "fail") {
514
541
  checks.push(await checkAgentUpToDate(deps));
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5
5
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
6
- import { checkAgentTlsTrust, relayProbeUrl, runDeviceDoctor } from "./doctor.js";
6
+ import { checkAgentTlsTrust, checkDesktopEnvironment, relayProbeUrl, runDeviceDoctor, } from "./doctor.js";
7
7
  let tempHome;
8
8
  beforeAll(() => {
9
9
  tempHome = mkdtempSync(join(tmpdir(), "aximo-doctor-"));
@@ -153,3 +153,28 @@ describe("checkAgentTlsTrust", () => {
153
153
  expect(c.message).toContain("missing file");
154
154
  });
155
155
  });
156
+ describe("checkDesktopEnvironment", () => {
157
+ const REPORT = {
158
+ agent: { platform: "Darwin" },
159
+ checks: [
160
+ { name: "accessibility permission", status: "fail", message: "not granted" },
161
+ { name: "capture target", status: "info", message: "capturing the primary monitor" },
162
+ ],
163
+ };
164
+ it("reports what the agent found, namespaced so its checks are distinguishable", () => {
165
+ const checks = checkDesktopEnvironment(() => ({ kind: "ok", report: REPORT }));
166
+ expect(checks.map((c) => c.name)).toEqual([
167
+ "desktop: accessibility permission",
168
+ "desktop: capture target",
169
+ ]);
170
+ expect(checks[0]?.status).toBe("fail");
171
+ });
172
+ // Before `aximo device setup` there is no agent to ask, so doctor still has to
173
+ // say something useful rather than claim the desktop is fine.
174
+ it("falls back to a manual note when the agent cannot be run", () => {
175
+ const checks = checkDesktopEnvironment(() => ({ kind: "not-installed" }));
176
+ expect(checks).toHaveLength(1);
177
+ expect(checks[0]?.status).toBe("info");
178
+ expect(checks[0]?.hint ?? checks[0]?.message).toContain(process.platform === "darwin" ? "System Settings" : "aximo device setup");
179
+ });
180
+ });
@@ -4,13 +4,25 @@ import { handleApiError } from "../../errors.js";
4
4
  import { request } from "../../client.js";
5
5
  export async function runHealthCheck(deps) {
6
6
  try {
7
- // /api/health may return empty body; we normalize to { status: "ok" }
8
- await request("/api/health", {}, deps ?? {});
9
- return formatSuccess({ status: "ok" });
7
+ // Only reachability matters; skip body parsing so a non-JSON body can't fail the check.
8
+ await request("/api/health", { expectJson: false }, deps ?? {});
10
9
  }
11
10
  catch (err) {
12
11
  return handleApiError(err);
13
12
  }
13
+ try {
14
+ // /api/health requires no auth, so verify the API key with an authenticated probe.
15
+ await request("/api/organizations/projects", { expectJson: false }, deps ?? {});
16
+ return formatSuccess({ status: "ok", auth: "ok" });
17
+ }
18
+ catch (err) {
19
+ const envelope = handleApiError(err);
20
+ const status = err.status;
21
+ if (status === 401 || status === 403) {
22
+ envelope.error.message = `API is reachable but the API key was rejected: ${envelope.error.message}`;
23
+ }
24
+ return envelope;
25
+ }
14
26
  }
15
27
  export default defineCommand({
16
28
  meta: { name: "check", description: "Check Aximo API health" },