@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
@@ -9,10 +9,34 @@ beforeAll(() => {
9
9
  afterEach(() => server.resetHandlers());
10
10
  afterAll(() => server.close());
11
11
  describe("runHealthCheck", () => {
12
- it("returns ok envelope on 200", async () => {
13
- server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })));
12
+ it("returns ok envelope with auth when the API key is valid", async () => {
13
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ projects: [] })));
14
14
  const result = await runHealthCheck();
15
- expect(result).toEqual({ ok: true, data: { status: "ok" } });
15
+ expect(result).toEqual({ ok: true, data: { status: "ok", auth: "ok" } });
16
+ });
17
+ it("tolerates non-JSON bodies from both probes", async () => {
18
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse("OK", { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => new HttpResponse("<html>projects</html>", { status: 200 })));
19
+ const result = await runHealthCheck();
20
+ expect(result).toEqual({ ok: true, data: { status: "ok", auth: "ok" } });
21
+ });
22
+ it("maps a rejected API key to unauthorized when the API is healthy", async () => {
23
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ error: "Invalid API key" }, { status: 401 })));
24
+ const result = await runHealthCheck();
25
+ expect(result.ok).toBe(false);
26
+ if (!result.ok) {
27
+ expect(result.error.code).toBe("unauthorized");
28
+ expect(result.error.message).toContain("API is reachable but the API key was rejected");
29
+ expect(result.error.message).toContain("Invalid API key");
30
+ }
31
+ });
32
+ it("maps an auth-probe 500 to api_error without the rejected-key prefix", async () => {
33
+ server.use(http.get(`${TEST_BASE_URL}/api/health`, () => new HttpResponse(null, { status: 200 })), http.get(`${TEST_BASE_URL}/api/organizations/projects`, () => HttpResponse.json({ error: "Internal" }, { status: 500 })));
34
+ const result = await runHealthCheck();
35
+ expect(result.ok).toBe(false);
36
+ if (!result.ok) {
37
+ expect(result.error.code).toBe("api_error");
38
+ expect(result.error.message).not.toContain("rejected");
39
+ }
16
40
  });
17
41
  it("maps 500 to api_error retryable", async () => {
18
42
  server.use(http.get(`${TEST_BASE_URL}/api/health`, () => HttpResponse.json({ error: "Internal" }, { status: 500 })));
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 16 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 18 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
@@ -44,6 +44,8 @@ describe("createMcpServer wiring", () => {
44
44
  "aximo_create_plan",
45
45
  "aximo_add_case_to_plan",
46
46
  "aximo_remove_case_from_plan",
47
+ "aximo_list_plan_runs",
48
+ "aximo_get_plan_run",
47
49
  "aximo_save_session_as_case",
48
50
  ].sort());
49
51
  const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
@@ -260,6 +260,34 @@ export function defineTools(deps = {}) {
260
260
  return await request(`/api/plans/${encodeURIComponent(planId)}/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
261
261
  }),
262
262
  },
263
+ {
264
+ name: "aximo_list_plan_runs",
265
+ title: "List plan runs",
266
+ description: "List a plan's runs, newest first, without per-case detail. Use aximo_get_plan_run for one run's per-case breakdown. Paginate by passing the previous page's nextCursor as cursor. A run's status reflects execution, not the test verdict: read passedCases/failedCases to judge outcomes.",
267
+ inputSchema: {
268
+ planId: z.string().uuid(),
269
+ limit: z.number().int().positive().max(50).optional(),
270
+ cursor: z
271
+ .string()
272
+ .optional()
273
+ .describe("nextCursor from the previous page (ISO 8601 createdAt of its last run)."),
274
+ },
275
+ handler: async ({ planId, ...rest }) => call(async () => {
276
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs`, { query: buildQuery(rest) }, deps);
277
+ }),
278
+ },
279
+ {
280
+ name: "aximo_get_plan_run",
281
+ title: "Get plan run status",
282
+ description: "Get the overall status and per-case breakdown of a plan run (from aximo_list_plan_runs, or the planRunId returned when a run was triggered). Poll this to observe a run in progress; wait at least 5 seconds between polls. A run's status reflects execution, not the test verdict: a run can complete with failedCases > 0, so read passedCases/failedCases and the per-case statuses to judge outcomes.",
283
+ inputSchema: {
284
+ planId: z.string().uuid(),
285
+ planRunId: z.string().uuid(),
286
+ },
287
+ handler: async ({ planId, planRunId }) => call(async () => {
288
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/runs/${encodeURIComponent(planRunId)}`, {}, deps);
289
+ }),
290
+ },
263
291
  {
264
292
  name: "aximo_save_session_as_case",
265
293
  title: "Save session as test case",
@@ -36,6 +36,8 @@ describe("defineTools", () => {
36
36
  "aximo_create_plan",
37
37
  "aximo_add_case_to_plan",
38
38
  "aximo_remove_case_from_plan",
39
+ "aximo_list_plan_runs",
40
+ "aximo_get_plan_run",
39
41
  "aximo_save_session_as_case",
40
42
  ]);
41
43
  });
@@ -141,6 +143,94 @@ describe("aximo_list_projects", () => {
141
143
  expect(new URL(captured.url).pathname).toBe("/api/organizations/projects");
142
144
  });
143
145
  });
146
+ describe("aximo_list_plan_runs", () => {
147
+ const planId = "77777777-7777-4777-9777-777777777777";
148
+ it("calls /api/triggers/plans/:id/runs, passing limit and cursor through", async () => {
149
+ let url = null;
150
+ const payload = {
151
+ items: [
152
+ {
153
+ id: "88888888-8888-4888-9888-888888888888",
154
+ status: "succeeded",
155
+ triggerSource: "api",
156
+ executionMode: "parallel",
157
+ totalCases: 3,
158
+ completedCases: 3,
159
+ passedCases: 2,
160
+ failedCases: 1,
161
+ startedAt: "2026-07-01T00:00:00.000Z",
162
+ completedAt: "2026-07-01T00:10:00.000Z",
163
+ createdAt: "2026-07-01T00:00:00.000Z",
164
+ },
165
+ ],
166
+ nextCursor: "2026-07-01T00:00:00.000Z",
167
+ };
168
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, ({ request: req }) => {
169
+ url = new URL(req.url);
170
+ return HttpResponse.json(payload);
171
+ }));
172
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
173
+ const result = await tool.handler({
174
+ planId,
175
+ limit: 5,
176
+ cursor: "2026-07-02T00:00:00.000Z",
177
+ });
178
+ expect(result.isError).toBeUndefined();
179
+ expect(parsePayload(result)).toEqual(payload);
180
+ expect(url).not.toBeNull();
181
+ expect(url.searchParams.get("limit")).toBe("5");
182
+ expect(url.searchParams.get("cursor")).toBe("2026-07-02T00:00:00.000Z");
183
+ });
184
+ it("preserves the backend's 404 for an unknown plan", async () => {
185
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
186
+ const tool = byName(defineTools(), "aximo_list_plan_runs");
187
+ const result = await tool.handler({ planId });
188
+ expect(result.isError).toBe(true);
189
+ expect(parsePayload(result).error).toBe("Test plan not found");
190
+ });
191
+ });
192
+ describe("aximo_get_plan_run", () => {
193
+ const planId = "77777777-7777-4777-9777-777777777777";
194
+ const planRunId = "99999999-9999-4999-9999-999999999999";
195
+ it("calls /api/triggers/plans/:id/runs/:runId and returns the run with cases", async () => {
196
+ const payload = {
197
+ id: planRunId,
198
+ status: "succeeded",
199
+ triggerSource: "api",
200
+ executionMode: "sequential",
201
+ totalCases: 1,
202
+ completedCases: 1,
203
+ passedCases: 0,
204
+ failedCases: 1,
205
+ startedAt: "2026-07-01T00:00:00.000Z",
206
+ completedAt: "2026-07-01T00:05:00.000Z",
207
+ createdAt: "2026-07-01T00:00:00.000Z",
208
+ cases: [
209
+ {
210
+ id: "aaaaaaaa-aaaa-4aaa-9aaa-aaaaaaaaaaaa",
211
+ caseId: "bbbbbbbb-bbbb-4bbb-9bbb-bbbbbbbbbbbb",
212
+ title: "Login flow",
213
+ type: "web",
214
+ status: "failed",
215
+ startedAt: "2026-07-01T00:00:10.000Z",
216
+ completedAt: "2026-07-01T00:04:40.000Z",
217
+ },
218
+ ],
219
+ };
220
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json(payload)));
221
+ const tool = byName(defineTools(), "aximo_get_plan_run");
222
+ const result = await tool.handler({ planId, planRunId });
223
+ expect(result.isError).toBeUndefined();
224
+ expect(parsePayload(result)).toEqual(payload);
225
+ });
226
+ it("preserves the backend's 404 for an unknown run id", async () => {
227
+ server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/${planId}/runs/${planRunId}`, () => HttpResponse.json({ error: "Plan run not found" }, { status: 404 })));
228
+ const tool = byName(defineTools(), "aximo_get_plan_run");
229
+ const result = await tool.handler({ planId, planRunId });
230
+ expect(result.isError).toBe(true);
231
+ expect(parsePayload(result).error).toBe("Plan run not found");
232
+ });
233
+ });
144
234
  describe("call() error mapping", () => {
145
235
  const sessionId = "66666666-6666-4666-9666-666666666666";
146
236
  it("maps 403 with the original message and a hint", async () => {
@@ -1,5 +1,5 @@
1
1
  import type { ErrorEnvelope } from "../../types.js";
2
- import type { MobileController } from "../../mobile-control/controller.js";
2
+ import { AppiumController } from "../../mobile-control/appium-controller.js";
3
3
  import { type IosSigningConfig } from "../../mobile-control/ios-signing.js";
4
4
  import { type MobileDeviceConfig } from "./paths.js";
5
5
  export declare function getTunnelOrigin(): string;
@@ -8,6 +8,8 @@ interface ConfigOk {
8
8
  config: MobileDeviceConfig;
9
9
  }
10
10
  export declare function resolveDeviceConfig(deviceId: string | undefined): ConfigOk | ErrorEnvelope;
11
- export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>)): MobileController;
12
- export declare function connectWithConfig(config: MobileDeviceConfig): Promise<void>;
11
+ export declare function buildController(config: MobileDeviceConfig, endpoint: string, iosSigning?: IosSigningConfig, wdaLocalPort?: number | (() => Promise<number>), mjpegServerPort?: number | (() => Promise<number>)): AppiumController;
12
+ export declare function connectWithConfig(config: MobileDeviceConfig, options?: {
13
+ assumeYes?: boolean;
14
+ }): Promise<void>;
13
15
  export {};
@@ -4,6 +4,8 @@ import { AppiumController } from "../../mobile-control/appium-controller.js";
4
4
  import { installUploadedApp } from "../../mobile-control/app-install.js";
5
5
  import { pickFreePort, startAppiumServer } from "../../mobile-control/appium-server.js";
6
6
  import { withBundleIdTranslation } from "../../mobile-control/bundle-id-map.js";
7
+ import { FrameSender } from "../../mobile-control/frame-sender.js";
8
+ import { MjpegStreamClient } from "../../mobile-control/mjpeg-stream.js";
7
9
  import { getIosDeviceInfo, iosMajorVersion } from "../../mobile-control/ios-devicectl.js";
8
10
  import { ensureIosTunnel, TUNNEL_STOP_COMMAND } from "../../mobile-control/ios-tunnel.js";
9
11
  import { resolveIosSigning, resolveIosSigningAuto, } from "../../mobile-control/ios-signing.js";
@@ -11,6 +13,7 @@ import { resolveWdaCaching } from "../../mobile-control/ios-wda-cache.js";
11
13
  import { resolvePreinstalledWdaStrategy } from "../../mobile-control/ios-wda-preinstall.js";
12
14
  import { runControlTunnel } from "../../mobile-control/tunnel.js";
13
15
  import { runMobileDoctor } from "./doctor.js";
16
+ import { confirmIosSigning } from "./signing-consent.js";
14
17
  import { listMobileDeviceConfigs, readMobileDeviceConfig, } from "./paths.js";
15
18
  export function getTunnelOrigin() {
16
19
  return getBaseUrl().replace(/^http/, "ws");
@@ -37,11 +40,12 @@ export function resolveDeviceConfig(deviceId) {
37
40
  }
38
41
  return { ok: true, config: only };
39
42
  }
40
- export function buildController(config, endpoint, iosSigning, wdaLocalPort) {
43
+ export function buildController(config, endpoint, iosSigning, wdaLocalPort, mjpegServerPort) {
41
44
  return new AppiumController({
42
45
  endpoint,
43
46
  platform: config.platform,
44
47
  target: config.target,
48
+ ...(mjpegServerPort ? { mjpegServerPort } : {}),
45
49
  ...(config.platform === "ios"
46
50
  ? { iosSigning: iosSigning ?? resolveIosSigning(), ...(wdaLocalPort ? { wdaLocalPort } : {}) }
47
51
  : {}),
@@ -86,12 +90,34 @@ async function preflightIosDevice(udid) {
86
90
  }
87
91
  return iosMajorVersion(info.osVersion);
88
92
  }
89
- export async function connectWithConfig(config) {
93
+ export async function connectWithConfig(config, options) {
90
94
  await assertPrerequisites(config.platform);
91
95
  let iosMajor = null;
92
96
  let tunnelRunning = false;
97
+ let signing;
93
98
  if (config.platform === "ios") {
94
99
  iosMajor = await preflightIosDevice(config.target);
100
+ // Resolve and confirm the signing identity before any signing side effects —
101
+ // the RemoteXPC tunnel (admin prompt), Appium, WDA install, and profile
102
+ // minting against the user's Apple Developer account all come after, so
103
+ // declining costs nothing.
104
+ signing = await resolveIosSigningAuto({ udid: config.target });
105
+ if (signing.teamId) {
106
+ const accepted = await confirmIosSigning({
107
+ signing,
108
+ ...(options?.assumeYes !== undefined ? { assumeYes: options.assumeYes } : {}),
109
+ });
110
+ if (!accepted) {
111
+ console.error("Signing not accepted — not connecting. Re-run with --yes to skip this prompt, " +
112
+ "or set AXIMO_IOS_TEAM_ID to sign with a different team.");
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ }
117
+ else {
118
+ console.error("No Apple signing identity found — physical-device WebDriverAgent signing will fail. " +
119
+ "Open Xcode → Settings → Accounts to add your Apple ID (see `aximo mobile doctor`).");
120
+ }
95
121
  // Physical iPhones on iOS 17+ need the RemoteXPC tunnel before XCUITest can
96
122
  // reach WebDriverAgent; start it (prompting for admin) unless one is running.
97
123
  // Older devices use the classic lockdown path — no tunnel, no admin prompt.
@@ -114,15 +140,7 @@ export async function connectWithConfig(config) {
114
140
  // installed profiles; env vars only override.
115
141
  let iosSigning;
116
142
  let wdaLocalPort;
117
- if (config.platform === "ios") {
118
- const signing = await resolveIosSigningAuto({ udid: config.target });
119
- if (signing.teamId) {
120
- console.error(`Signing as team ${signing.teamId} (WDA id ${signing.wdaBundleId}).`);
121
- }
122
- else {
123
- console.error("No Apple signing identity found — physical-device WebDriverAgent signing will fail. " +
124
- "Open Xcode → Settings → Accounts to add your Apple ID (see `aximo mobile doctor`).");
125
- }
143
+ if (config.platform === "ios" && signing) {
126
144
  const preinstalled = await resolvePreinstalledWdaStrategy(signing, {
127
145
  udid: config.target,
128
146
  iosMajor,
@@ -145,10 +163,35 @@ export async function connectWithConfig(config) {
145
163
  }
146
164
  // Re-signing can remap bundle ids; launches from the backend use the original.
147
165
  const installedBundleIds = new Map();
148
- const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort);
166
+ const rawController = buildController(config, server.endpoint, iosSigning, wdaLocalPort, pickFreePort);
149
167
  const controller = config.platform === "ios"
150
168
  ? withBundleIdTranslation(rawController, installedBundleIds)
151
169
  : rawController;
170
+ // Live-view pipeline: capture (device MJPEG server -> local socket) feeds the
171
+ // sender (dedicated tunnel connection -> gateway). Started/stopped on demand
172
+ // by the gateway; frame acks reporting "no viewers" also stop it.
173
+ const frameSender = new FrameSender({
174
+ wsUrl: getTunnelOrigin(),
175
+ deviceToken: config.deviceToken,
176
+ onInactive: () => stream.stop(),
177
+ log: (message) => console.error(message),
178
+ });
179
+ const streamClient = new MjpegStreamClient({
180
+ ensureReady: () => rawController.ensureReady(),
181
+ getPort: () => rawController.getMjpegPort(),
182
+ onFrame: (frame) => frameSender.push(frame),
183
+ log: (message) => console.error(message),
184
+ });
185
+ const stream = {
186
+ start: () => {
187
+ frameSender.start();
188
+ streamClient.start();
189
+ },
190
+ stop: () => {
191
+ void streamClient.stop();
192
+ frameSender.stop();
193
+ },
194
+ };
152
195
  try {
153
196
  // Warm up the device session before connecting to Aximo so WDA build/signing/
154
197
  // trust problems surface here — with a clear, actionable message — instead of
@@ -163,6 +206,7 @@ export async function connectWithConfig(config) {
163
206
  deviceToken: config.deviceToken,
164
207
  deviceName: config.name,
165
208
  controller,
209
+ stream,
166
210
  installApp: async ({ uploadId }) => {
167
211
  const result = await installUploadedApp({
168
212
  uploadId,
@@ -181,6 +225,10 @@ export async function connectWithConfig(config) {
181
225
  });
182
226
  }
183
227
  finally {
228
+ // Await the capture loop before tearing down the controller/server so a
229
+ // mid-flight ensureReady/stream read can't race the Appium session close.
230
+ frameSender.stop();
231
+ await streamClient.stop();
184
232
  await controller.close();
185
233
  await server.stop();
186
234
  if (tunnelRunning) {
@@ -5,5 +5,10 @@ declare const _default: import("citty").CommandDef<{
5
5
  readonly type: "string";
6
6
  readonly description: "Registered device id (required if multiple)";
7
7
  };
8
+ readonly yes: {
9
+ readonly type: "boolean";
10
+ readonly alias: "y";
11
+ readonly description: "Skip the iOS code-signing confirmation prompt";
12
+ };
8
13
  }>;
9
14
  export default _default;
@@ -17,6 +17,11 @@ export default defineCommand({
17
17
  },
18
18
  args: {
19
19
  "device-id": { type: "string", description: "Registered device id (required if multiple)" },
20
+ yes: {
21
+ type: "boolean",
22
+ alias: "y",
23
+ description: "Skip the iOS code-signing confirmation prompt",
24
+ },
20
25
  },
21
26
  async run({ args }) {
22
27
  const resolved = resolveDeviceConfig(args["device-id"]);
@@ -28,7 +33,7 @@ export default defineCommand({
28
33
  if (warning)
29
34
  console.error(warning);
30
35
  try {
31
- await connectWithConfig(resolved.config);
36
+ await connectWithConfig(resolved.config, { assumeYes: Boolean(args.yes) });
32
37
  }
33
38
  catch (error) {
34
39
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,18 @@
1
+ import type { IosSigningConfig } from "../../mobile-control/ios-signing.js";
2
+ export interface ConfirmIosSigningOptions {
3
+ signing: IosSigningConfig;
4
+ /** Skip the prompt entirely (--yes). The notice still prints. */
5
+ assumeYes?: boolean;
6
+ /** Whether a human can answer; defaults to stdin being a TTY. */
7
+ interactive?: boolean;
8
+ ask?: (question: string) => Promise<string>;
9
+ log?: (message: string) => void;
10
+ }
11
+ /**
12
+ * Informed consent before using the user's Apple signing identity: connecting
13
+ * can re-sign apps/WDA with it, register the device to the team's Apple
14
+ * Developer account, and create aximo App IDs in it. Defaults to yes (Enter
15
+ * proceeds); only an explicit n/no declines. Unattended connects (no TTY)
16
+ * proceed after printing the notice, so daemonized devices keep working.
17
+ */
18
+ export declare function confirmIosSigning(options: ConfirmIosSigningOptions): Promise<boolean>;
@@ -0,0 +1,22 @@
1
+ import { confirmYesNo } from "../../prompt.js";
2
+ /**
3
+ * Informed consent before using the user's Apple signing identity: connecting
4
+ * can re-sign apps/WDA with it, register the device to the team's Apple
5
+ * Developer account, and create aximo App IDs in it. Defaults to yes (Enter
6
+ * proceeds); only an explicit n/no declines. Unattended connects (no TTY)
7
+ * proceed after printing the notice, so daemonized devices keep working.
8
+ */
9
+ export async function confirmIosSigning(options) {
10
+ const { signing } = options;
11
+ const log = options.log ?? ((message) => console.error(message));
12
+ log(`Code signing: team ${signing.teamId} (WDA id ${signing.wdaBundleId}). Connecting may ` +
13
+ "re-sign apps and WebDriverAgent with this identity, register this device to the " +
14
+ "team's Apple Developer account, and create aximo App IDs in it. " +
15
+ "Set AXIMO_IOS_TEAM_ID to sign with a different team.");
16
+ if (options.assumeYes)
17
+ return true;
18
+ return confirmYesNo("Proceed with this signing identity?", {
19
+ ...(options.interactive === undefined ? {} : { interactive: options.interactive }),
20
+ ...(options.ask ? { ask: options.ask } : {}),
21
+ });
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { confirmIosSigning } from "./signing-consent.js";
3
+ const SIGNING = {
4
+ teamId: "TEAM123456",
5
+ signingId: "Apple Development",
6
+ wdaBundleId: "com.aximo.wda.TEAM123456",
7
+ };
8
+ describe("confirmIosSigning", () => {
9
+ it("prints a notice naming the team and its account-facing effects", async () => {
10
+ const log = vi.fn();
11
+ await confirmIosSigning({ signing: SIGNING, assumeYes: true, log });
12
+ const notice = log.mock.calls.map((c) => c[0]).join("\n");
13
+ expect(notice).toContain("TEAM123456");
14
+ expect(notice).toContain("Apple Developer account");
15
+ expect(notice).toContain("AXIMO_IOS_TEAM_ID");
16
+ });
17
+ it("skips the prompt with --yes", async () => {
18
+ const ask = vi.fn();
19
+ await expect(confirmIosSigning({
20
+ signing: SIGNING,
21
+ assumeYes: true,
22
+ interactive: true,
23
+ ask,
24
+ log: vi.fn(),
25
+ })).resolves.toBe(true);
26
+ expect(ask).not.toHaveBeenCalled();
27
+ });
28
+ it("proceeds without prompting when not interactive (daemonized connect)", async () => {
29
+ const ask = vi.fn();
30
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: false, ask, log: vi.fn() })).resolves.toBe(true);
31
+ expect(ask).not.toHaveBeenCalled();
32
+ });
33
+ it("defaults to yes on Enter", async () => {
34
+ const ask = vi.fn(async () => "");
35
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
36
+ expect(ask).toHaveBeenCalledWith("Proceed with this signing identity? [Y/n] ");
37
+ });
38
+ it("accepts y/yes and arbitrary input as yes", async () => {
39
+ for (const answer of ["y", "Y", "yes", " sure "]) {
40
+ const ask = vi.fn(async () => answer);
41
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
42
+ }
43
+ });
44
+ it("declines only on an explicit n/no", async () => {
45
+ for (const answer of ["n", "N", "no", " NO "]) {
46
+ const ask = vi.fn(async () => answer);
47
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(false);
48
+ }
49
+ });
50
+ });
@@ -3,6 +3,7 @@ export default defineCommand({
3
3
  meta: { name: "plan", description: "Test plan 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
  "add-case": () => import("./add-case.js").then((m) => m.default),
7
8
  "remove-case": () => import("./remove-case.js").then((m) => m.default),
8
9
  },
@@ -2,6 +2,7 @@ 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 { runPlanAddCase } from "./add-case.js";
4
4
  import { runPlanCreate } from "./create.js";
5
+ import { runPlanUpdate } from "./update.js";
5
6
  import { runPlanRemoveCase } from "./remove-case.js";
6
7
  beforeAll(() => {
7
8
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -102,3 +103,63 @@ describe("runPlanRemoveCase", () => {
102
103
  }
103
104
  });
104
105
  });
106
+ describe("runPlanUpdate", () => {
107
+ it("PATCHes the provided fields and returns the updated plan", async () => {
108
+ let seen;
109
+ server.use(http.patch(`${TEST_BASE_URL}/api/plans/pl1`, async ({ request: req }) => {
110
+ seen = await req.json();
111
+ return HttpResponse.json({
112
+ success: true,
113
+ plan: {
114
+ id: "pl1",
115
+ title: "Renamed",
116
+ description: null,
117
+ executionMode: "sequential",
118
+ stopOnFailure: true,
119
+ updatedAt: "2026-07-16T00:00:00Z",
120
+ },
121
+ });
122
+ }));
123
+ const result = await runPlanUpdate({
124
+ planId: "pl1",
125
+ title: "Renamed",
126
+ executionMode: "sequential",
127
+ stopOnFailure: "true",
128
+ });
129
+ expect(result.ok).toBe(true);
130
+ if (result.ok && !("dryRun" in result.data)) {
131
+ expect(result.data.plan.executionMode).toBe("sequential");
132
+ }
133
+ expect(seen).toMatchObject({
134
+ title: "Renamed",
135
+ executionMode: "sequential",
136
+ stopOnFailure: true,
137
+ });
138
+ });
139
+ it("rejects when no fields are provided", async () => {
140
+ const result = await runPlanUpdate({ planId: "pl1" });
141
+ expect(result.ok).toBe(false);
142
+ if (!result.ok)
143
+ expect(result.error.code).toBe("validation_error");
144
+ });
145
+ it("rejects an invalid executionMode", async () => {
146
+ const result = await runPlanUpdate({ planId: "pl1", executionMode: "fastest" });
147
+ expect(result.ok).toBe(false);
148
+ if (!result.ok)
149
+ expect(result.error.code).toBe("validation_error");
150
+ });
151
+ it("rejects a non-boolean stop-on-failure", async () => {
152
+ const result = await runPlanUpdate({ planId: "pl1", stopOnFailure: "yes" });
153
+ expect(result.ok).toBe(false);
154
+ if (!result.ok)
155
+ expect(result.error.code).toBe("validation_error");
156
+ });
157
+ it("--dry-run returns the PATCH request without calling the API", async () => {
158
+ const result = await runPlanUpdate({ planId: "pl1", title: "t", dryRun: true });
159
+ expect(result.ok).toBe(true);
160
+ if (result.ok && "dryRun" in result.data) {
161
+ expect(result.data.method).toBe("PATCH");
162
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/plans/pl1`);
163
+ }
164
+ });
165
+ });
@@ -0,0 +1,42 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { Envelope, UpdatePlanResult } from "../../types.js";
4
+ export interface UpdatePlanOptions {
5
+ planId?: unknown;
6
+ title?: unknown;
7
+ description?: unknown;
8
+ executionMode?: unknown;
9
+ stopOnFailure?: unknown;
10
+ json?: unknown;
11
+ dryRun?: unknown;
12
+ }
13
+ export declare function runPlanUpdate(options: UpdatePlanOptions, deps?: ClientDeps): Promise<Envelope<UpdatePlanResult | DryRunResult>>;
14
+ declare const _default: import("citty").CommandDef<{
15
+ readonly planId: {
16
+ readonly type: "positional";
17
+ readonly required: true;
18
+ };
19
+ readonly title: {
20
+ readonly type: "string";
21
+ };
22
+ readonly description: {
23
+ readonly type: "string";
24
+ };
25
+ readonly "execution-mode": {
26
+ readonly type: "string";
27
+ readonly description: "parallel or sequential";
28
+ };
29
+ readonly "stop-on-failure": {
30
+ readonly type: "string";
31
+ readonly description: "true or false; sequential runs only";
32
+ };
33
+ readonly json: {
34
+ readonly type: "string";
35
+ readonly description: "Raw JSON request body (replaces individual flags)";
36
+ };
37
+ readonly "dry-run": {
38
+ readonly type: "boolean";
39
+ readonly description: "Validate and show request without calling API";
40
+ };
41
+ }>;
42
+ export default _default;