@autifyhq/aximo-cli 0.7.0 → 0.8.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 (33) hide show
  1. package/README.md +10 -7
  2. package/dist/commands/mobile/connect-core.js +9 -4
  3. package/dist/commands/mobile/connect.js +4 -3
  4. package/dist/commands/mobile/connect.test.js +7 -1
  5. package/dist/commands/mobile/doctor.d.ts +1 -0
  6. package/dist/commands/mobile/doctor.js +18 -2
  7. package/dist/commands/mobile/doctor.test.js +15 -1
  8. package/dist/commands/mobile/install.d.ts +1 -0
  9. package/dist/commands/mobile/install.js +14 -3
  10. package/dist/commands/mobile/list.d.ts +1 -0
  11. package/dist/commands/mobile/list.js +23 -3
  12. package/dist/commands/mobile/list.test.d.ts +1 -0
  13. package/dist/commands/mobile/list.test.js +24 -0
  14. package/dist/commands/mobile/output.d.ts +13 -0
  15. package/dist/commands/mobile/output.js +21 -0
  16. package/dist/commands/mobile/output.test.d.ts +1 -0
  17. package/dist/commands/mobile/output.test.js +54 -0
  18. package/dist/commands/mobile/register.d.ts +1 -0
  19. package/dist/commands/mobile/register.js +11 -3
  20. package/dist/commands/mobile/setup.d.ts +1 -0
  21. package/dist/commands/mobile/setup.js +7 -2
  22. package/dist/commands/mobile/unregister.d.ts +7 -3
  23. package/dist/commands/mobile/unregister.js +10 -4
  24. package/dist/mobile-control/exec.d.ts +5 -1
  25. package/dist/mobile-control/exec.js +2 -2
  26. package/dist/mobile-control/ios-tunnel.d.ts +5 -2
  27. package/dist/mobile-control/ios-tunnel.js +25 -5
  28. package/dist/mobile-control/ios-tunnel.test.js +8 -3
  29. package/dist/mobile-control/ios-wda-preinstall.js +6 -3
  30. package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
  31. package/dist/mobile-control/tunnel.js +34 -1
  32. package/dist/mobile-control/tunnel.test.js +82 -0
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -34,6 +34,8 @@ aximo session get SESSION_ID
34
34
 
35
35
  Get your API key from **Settings → API Keys** in the [Aximo dashboard](https://aximo.autify.com).
36
36
 
37
+ Want to run tests on **your own hardware** instead of the cloud? See [Device (BYOD)](#device-byod--bring-your-own-device) to register this desktop (Windows/macOS/Linux), or [Mobile](#mobile-local-android-phone-or-iphone) to drive a USB-connected Android phone or iPhone — each section starts with the exact prerequisites for a fresh machine.
38
+
37
39
  ## Configuration
38
40
 
39
41
  Each credential resolves independently: the value in `$AXIMO_HOME/config.json` (written by `aximo configure`) wins, then the environment variable, then the default.
@@ -208,9 +210,9 @@ Terminology note: **`aximo device list` returns mobile devices** from Device Far
208
210
 
209
211
  ### Prerequisites
210
212
 
211
- - **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
213
+ - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
212
214
  - **An Aximo API key**, configured via `aximo configure` or env vars.
213
- - **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run.
215
+ - **Active desktop session** — the agent uses `pyautogui` for screenshots and input, so the screen must be unlocked and awake while tests run. On **macOS**, grant the terminal **Accessibility** and **Screen Recording** permissions (System Settings → Privacy & Security) — without them actions silently do nothing.
214
216
  - **Outbound WebSocket to port 443** — most corporate firewalls allow this; if yours doesn't, see [Troubleshooting](#byod-troubleshooting).
215
217
 
216
218
  > Python is **not** a prerequisite. On first `connect` (or via `device setup`), the CLI downloads a self-contained CPython build into `$AXIMO_HOME/python/` (~35 MB download, ~130 MB installed) and installs the required packages. Your system Python is untouched.
@@ -411,7 +413,7 @@ Terminology note: **`aximo mobile list`** shows phones plugged into _this_ machi
411
413
 
412
414
  ### Prerequisites
413
415
 
414
- - **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
416
+ - **Node.js 20.18+**, plus `aximo-cli` installed globally (see [Install](#install)).
415
417
  - **An Aximo API key**, configured via `aximo configure` or env vars.
416
418
  - **A USB-connected phone**:
417
419
  - **Android** — **USB debugging** enabled and authorized — see [Enable USB debugging on the phone](#enable-usb-debugging-on-the-phone).
@@ -429,12 +431,13 @@ You only need **`adb`** and the **Android SDK** on the machine running `aximo mo
429
431
  **macOS**
430
432
 
431
433
  ```bash
432
- # Option A — standalone platform-tools (just adb):
433
- brew install --cask android-platform-tools
434
+ # Option A — standalone SDK Platform-Tools (no Android Studio): download the zip from
435
+ # https://developer.android.com/tools/releases/platform-tools and unzip it so adb ends up at
436
+ # ~/Library/Android/sdk/platform-tools/adb (the driver needs this SDK layout, not just adb on PATH)
434
437
  # Option B — full Android Studio (also installs the SDK):
435
438
  brew install --cask android-studio
436
439
 
437
- # Point ANDROID_HOME at the SDK (Android Studio's default location shown):
440
+ # Point ANDROID_HOME at the SDK (both options above use this location):
438
441
  echo 'export ANDROID_HOME="$HOME/Library/Android/sdk"' >> ~/.zshrc
439
442
  echo 'export PATH="$PATH:$ANDROID_HOME/platform-tools"' >> ~/.zshrc
440
443
  source ~/.zshrc
@@ -496,7 +499,7 @@ Run `aximo mobile doctor --platform ios` to see what was discovered (identity, t
496
499
 
497
500
  > **On-device steps you can't skip** (Apple requires them once per phone): enable **Developer Mode** (Settings → Privacy & Security → Developer Mode; the phone restarts), tap **Trust This Computer**, and — after the first install of an app signed by your team — **trust the developer certificate** (Settings → General → VPN & Device Management → your "Apple Development" profile → Trust; the phone needs internet). On **free/personal teams** profiles expire every ~7 days; the CLI re-mints them automatically, but a re-mint **resets the certificate trust**, so expect that trust tap to recur weekly. Free teams are also capped at 10 App IDs per week.
498
501
  >
499
- > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs, so at most once per boot). Devices below iOS 17 skip the tunnel entirely. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
502
+ > On **iOS 17+**, `aximo mobile connect` also starts a small **device tunnel that needs admin rights** — it prompts once for your macOS password / Touch ID (reused on later runs, so at most once per boot). The tunnel deliberately keeps running after `connect` exits; stop it anytime with `sudo pkill -f tunnel-creation`. Devices below iOS 17 skip the tunnel entirely, and Android never uses it. If anything mentions "iOS X is not installed", run `xcodebuild -downloadPlatform iOS` (only the classic build flow needs it).
500
503
 
501
504
  ### Enable USB debugging on the phone
502
505
 
@@ -5,7 +5,7 @@ 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
7
  import { getIosDeviceInfo, iosMajorVersion } from "../../mobile-control/ios-devicectl.js";
8
- import { ensureIosTunnel } from "../../mobile-control/ios-tunnel.js";
8
+ import { ensureIosTunnel, TUNNEL_STOP_COMMAND } from "../../mobile-control/ios-tunnel.js";
9
9
  import { resolveIosSigning, resolveIosSigningAuto, } from "../../mobile-control/ios-signing.js";
10
10
  import { resolveWdaCaching } from "../../mobile-control/ios-wda-cache.js";
11
11
  import { resolvePreinstalledWdaStrategy } from "../../mobile-control/ios-wda-preinstall.js";
@@ -28,9 +28,8 @@ export function resolveDeviceConfig(deviceId) {
28
28
  return formatError("validation_error", 'No registered devices. Register one first: aximo mobile register --name "My Pixel" --platform android', false);
29
29
  }
30
30
  if (all.length > 1) {
31
- return formatError("validation_error", `Multiple devices registered; pass --device-id. Registered: ${all
32
- .map((c) => `${c.name} (${c.deviceId})`)
33
- .join(", ")}`, false);
31
+ const lines = all.map((c) => ` - ${c.name} (${c.platform}) — device-id: ${c.deviceId}`);
32
+ return formatError("validation_error", `Multiple devices are registered on this machine; pass --device-id to pick one:\n${lines.join("\n")}`, false, `Re-run with --device-id, e.g. --device-id ${all[0]?.deviceId}`);
34
33
  }
35
34
  const only = all[0];
36
35
  if (!only) {
@@ -90,6 +89,7 @@ async function preflightIosDevice(udid) {
90
89
  export async function connectWithConfig(config) {
91
90
  await assertPrerequisites(config.platform);
92
91
  let iosMajor = null;
92
+ let tunnelRunning = false;
93
93
  if (config.platform === "ios") {
94
94
  iosMajor = await preflightIosDevice(config.target);
95
95
  // Physical iPhones on iOS 17+ need the RemoteXPC tunnel before XCUITest can
@@ -100,6 +100,7 @@ export async function connectWithConfig(config) {
100
100
  }
101
101
  else {
102
102
  const tunnel = await ensureIosTunnel();
103
+ tunnelRunning = tunnel.started || tunnel.reused;
103
104
  if (tunnel.reused)
104
105
  console.error("Reusing the running iOS device tunnel.");
105
106
  }
@@ -182,5 +183,9 @@ export async function connectWithConfig(config) {
182
183
  finally {
183
184
  await controller.close();
184
185
  await server.stop();
186
+ if (tunnelRunning) {
187
+ console.error("The iOS device tunnel is still running in the background (admin-started; the next " +
188
+ `connect won't prompt again). Stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
189
+ }
185
190
  }
186
191
  }
@@ -1,7 +1,8 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
3
- import { formatError, printResult } from "../../output.js";
3
+ import { formatError } from "../../output.js";
4
4
  import { connectWithConfig, resolveDeviceConfig } from "./connect-core.js";
5
+ import { printMobileResult } from "./output.js";
5
6
  const IOS_PHYSICAL_WARNING = "Driving a physical iPhone goes through Appium's XCUITest driver, which builds and " +
6
7
  "installs a WebDriverAgent runner on the device. The device must have Developer Mode " +
7
8
  "enabled (Settings → Privacy & Security) and be trusted, and WDA needs an Apple Developer " +
@@ -20,7 +21,7 @@ export default defineCommand({
20
21
  async run({ args }) {
21
22
  const resolved = resolveDeviceConfig(args["device-id"]);
22
23
  if (!resolved.ok) {
23
- printResult(resolved);
24
+ printMobileResult(resolved);
24
25
  return;
25
26
  }
26
27
  const warning = iosPhysicalWarning(resolved.config);
@@ -31,7 +32,7 @@ export default defineCommand({
31
32
  }
32
33
  catch (error) {
33
34
  const message = error instanceof Error ? error.message : String(error);
34
- printResult(formatError("api_error", message, true, mobileErrorHint(message, resolved.config.platform)));
35
+ printMobileResult(formatError("api_error", message, true, mobileErrorHint(message, resolved.config.platform)));
35
36
  }
36
37
  },
37
38
  });
@@ -61,7 +61,7 @@ describe("resolveDeviceConfig", () => {
61
61
  if (result.ok)
62
62
  expect(result.config.deviceId).toBe("only-1");
63
63
  });
64
- it("requires --device-id when multiple are registered", () => {
64
+ it("requires --device-id when multiple are registered, listing each candidate", () => {
65
65
  for (const id of ["a", "b"]) {
66
66
  writeMobileDeviceConfig({
67
67
  deviceId: id,
@@ -73,6 +73,12 @@ describe("resolveDeviceConfig", () => {
73
73
  }
74
74
  const ambiguous = resolveDeviceConfig(undefined);
75
75
  expect(ambiguous.ok).toBe(false);
76
+ if (!ambiguous.ok) {
77
+ expect(ambiguous.error.message).toContain("pass --device-id");
78
+ expect(ambiguous.error.message).toContain("device-id: a");
79
+ expect(ambiguous.error.message).toContain("device-id: b");
80
+ expect(ambiguous.error.hint).toContain("--device-id");
81
+ }
76
82
  const picked = resolveDeviceConfig("b");
77
83
  expect(picked.ok).toBe(true);
78
84
  if (picked.ok)
@@ -22,6 +22,7 @@ export interface RunMobileDoctorOptions {
22
22
  isTunnelHealthy?: (port: number) => Promise<boolean>;
23
23
  }
24
24
  export declare function runMobileDoctor(options?: RunMobileDoctorOptions): Promise<Envelope<MobileDoctorReport>>;
25
+ export declare function renderDoctorReport(data: MobileDoctorReport): string;
25
26
  declare const _default: import("citty").CommandDef<{
26
27
  readonly platform: {
27
28
  readonly type: "string";
@@ -3,7 +3,8 @@ import { existsSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { platform } from "node:os";
5
5
  import { join } from "node:path";
6
- import { formatSuccess, printResult } from "../../output.js";
6
+ import { formatSuccess } from "../../output.js";
7
+ import { printMobileResult } from "./output.js";
7
8
  import { importAppium } from "../../mobile-control/appium-import.js";
8
9
  import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
9
10
  import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
@@ -171,6 +172,21 @@ export async function runMobileDoctor(options = {}) {
171
172
  summary[c.status]++;
172
173
  return formatSuccess({ checks, summary });
173
174
  }
175
+ const STATUS_MARK = {
176
+ pass: "✓",
177
+ warn: "!",
178
+ fail: "✗",
179
+ info: "i",
180
+ };
181
+ export function renderDoctorReport(data) {
182
+ const lines = data.checks.map((c) => {
183
+ const line = `${STATUS_MARK[c.status]} ${c.name}: ${c.message}`;
184
+ return c.hint ? `${line}\n ${c.hint}` : line;
185
+ });
186
+ const s = data.summary;
187
+ lines.push(`Summary: ${s.pass} pass, ${s.warn} warn, ${s.fail} fail, ${s.info} info`);
188
+ return lines.join("\n");
189
+ }
174
190
  export default defineCommand({
175
191
  meta: {
176
192
  name: "doctor",
@@ -184,7 +200,7 @@ export default defineCommand({
184
200
  if (args.platform === "ios" || args.platform === "android")
185
201
  options.platform = args.platform;
186
202
  const result = await runMobileDoctor(options);
187
- printResult(result);
203
+ printMobileResult(result, { render: renderDoctorReport });
188
204
  if (result.ok && result.data.summary.fail > 0) {
189
205
  process.exitCode = 1;
190
206
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { checkIosTunnel } from "./doctor.js";
2
+ import { checkIosTunnel, renderDoctorReport } from "./doctor.js";
3
3
  describe("checkIosTunnel", () => {
4
4
  it("passes when the RemoteXPC tunnel is reachable", async () => {
5
5
  const check = await checkIosTunnel(async () => true);
@@ -12,3 +12,17 @@ describe("checkIosTunnel", () => {
12
12
  expect(check.hint).toContain("aximo mobile connect");
13
13
  });
14
14
  });
15
+ describe("renderDoctorReport", () => {
16
+ it("renders one line per check with status marks, indented hints, and a summary", () => {
17
+ const text = renderDoctorReport({
18
+ checks: [
19
+ { name: "appium", status: "pass", message: "bundled Appium found" },
20
+ { name: "adb", status: "fail", message: "adb not found", hint: "install platform-tools" },
21
+ ],
22
+ summary: { pass: 1, warn: 0, fail: 1, info: 0 },
23
+ });
24
+ expect(text).toContain("✓ appium: bundled Appium found");
25
+ expect(text).toContain("✗ adb: adb not found\n install platform-tools");
26
+ expect(text).toContain("Summary: 1 pass, 0 warn, 1 fail, 0 info");
27
+ });
28
+ });
@@ -28,6 +28,7 @@ export interface MobileInstallOptions {
28
28
  * signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
29
29
  */
30
30
  export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn, ensureInstallable?: typeof ensureInstallableIpa, installIos?: typeof installIosApp): Promise<Envelope<MobileInstallResult>>;
31
+ export declare function renderInstallResult(data: MobileInstallResult): string;
31
32
  declare const _default: import("citty").CommandDef<{
32
33
  readonly app: {
33
34
  readonly type: "string";
@@ -7,7 +7,8 @@ import { execBinary } from "../../mobile-control/exec.js";
7
7
  import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
8
8
  import { installIosApp } from "../../mobile-control/ios-devicectl.js";
9
9
  import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
10
- import { formatError, formatSuccess, printResult } from "../../output.js";
10
+ import { formatError, formatSuccess } from "../../output.js";
11
+ import { printMobileResult } from "./output.js";
11
12
  import { parsePlatformArg } from "./register-core.js";
12
13
  function inferPlatform(appPath) {
13
14
  const ext = extname(appPath).toLowerCase();
@@ -85,6 +86,16 @@ export async function runMobileInstall(options, exec = execBinary, ensureInstall
85
86
  return formatError("api_error", message, false, mobileErrorHint(message, platform));
86
87
  }
87
88
  }
89
+ export function renderInstallResult(data) {
90
+ const target = data.serial ?? data.udid ?? "device";
91
+ const lines = [`Installed ${data.app} on ${data.platform} device ${target}.`];
92
+ if (data.resigned) {
93
+ lines.push(`Re-signed for this device: ${data.originalBundleId} installed as ${data.installedBundleId}.`);
94
+ }
95
+ if (data.output)
96
+ lines.push(data.output);
97
+ return lines.join("\n");
98
+ }
88
99
  export default defineCommand({
89
100
  meta: {
90
101
  name: "install",
@@ -97,11 +108,11 @@ export default defineCommand({
97
108
  udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
98
109
  },
99
110
  async run({ args }) {
100
- printResult(await runMobileInstall({
111
+ printMobileResult(await runMobileInstall({
101
112
  app: args.app,
102
113
  platform: args.platform,
103
114
  serial: args.serial,
104
115
  udid: args.udid,
105
- }));
116
+ }), { render: renderInstallResult });
106
117
  },
107
118
  });
@@ -8,5 +8,6 @@ export interface MobileListResult {
8
8
  warnings?: string[];
9
9
  }
10
10
  export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
11
+ export declare function renderMobileList(data: MobileListResult): string;
11
12
  declare const _default: import("citty").CommandDef<{}>;
12
13
  export default _default;
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
- import { formatSuccess, printResult } from "../../output.js";
2
+ import { formatSuccess } from "../../output.js";
3
+ import { printMobileResult } from "./output.js";
3
4
  import { listAndroidDevices, listIosPhysicalDevices, } from "../../mobile-control/devices.js";
4
5
  export async function runMobileList(exec) {
5
6
  const warnings = [];
@@ -24,13 +25,32 @@ export async function runMobileList(exec) {
24
25
  ...(warnings.length > 0 ? { warnings } : {}),
25
26
  });
26
27
  }
28
+ export function renderMobileList(data) {
29
+ const lines = ["Android phones (adb):"];
30
+ if (data.androidDevices.length === 0)
31
+ lines.push(" (none connected)");
32
+ for (const d of data.androidDevices) {
33
+ const label = d.model ? `${d.model} — ` : "";
34
+ lines.push(` - ${label}serial: ${d.serial} (${d.state})`);
35
+ }
36
+ lines.push("iOS phones (devicectl):");
37
+ if (data.iosDevices.length === 0)
38
+ lines.push(" (none connected)");
39
+ for (const d of data.iosDevices) {
40
+ lines.push(` - ${d.name} — udid: ${d.udid}${d.state ? ` (${d.state})` : ""}`);
41
+ }
42
+ for (const w of data.warnings ?? [])
43
+ lines.push(`Warning: ${w}`);
44
+ lines.push('Next: aximo mobile register --name "My Phone" --platform android|ios');
45
+ return lines.join("\n");
46
+ }
27
47
  export default defineCommand({
28
48
  meta: {
29
49
  name: "list",
30
- description: "List physically-connected phones (Android via adb, iOS via idb)",
50
+ description: "List physically-connected phones (Android via adb, iOS via devicectl)",
31
51
  },
32
52
  args: {},
33
53
  async run() {
34
- printResult(await runMobileList());
54
+ printMobileResult(await runMobileList(), { render: renderMobileList });
35
55
  },
36
56
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderMobileList } from "./list.js";
3
+ describe("renderMobileList", () => {
4
+ it("lists devices per platform with serial/udid and state", () => {
5
+ const text = renderMobileList({
6
+ androidDevices: [{ serial: "1A2B3C", state: "device", model: "Pixel_7" }],
7
+ iosDevices: [{ udid: "00008120-XYZ", name: "My iPhone", state: "Booted" }],
8
+ });
9
+ expect(text).toContain("Android phones (adb):");
10
+ expect(text).toContain(" - Pixel_7 — serial: 1A2B3C (device)");
11
+ expect(text).toContain("iOS phones (devicectl):");
12
+ expect(text).toContain(" - My iPhone — udid: 00008120-XYZ (Booted)");
13
+ expect(text).not.toContain("(none connected)");
14
+ });
15
+ it("marks empty platforms and surfaces discovery warnings", () => {
16
+ const text = renderMobileList({
17
+ androidDevices: [],
18
+ iosDevices: [],
19
+ warnings: ["ios (idb): idb not found"],
20
+ });
21
+ expect(text).toContain("(none connected)");
22
+ expect(text).toContain("Warning: ios (idb): idb not found");
23
+ });
24
+ });
@@ -0,0 +1,13 @@
1
+ import type { Envelope } from "../../types.js";
2
+ export interface PrintMobileResultOptions<T> {
3
+ /** Human rendering of the success payload; falls back to pretty-printed data. */
4
+ render?: (data: T) => string;
5
+ /** Defaults to stdout being a TTY; tests override. */
6
+ interactive?: boolean;
7
+ }
8
+ /**
9
+ * Mobile commands are usually run by a person at a terminal, where the raw JSON
10
+ * envelope is hard to read. Print human-readable text when stdout is a TTY, and
11
+ * keep the JSON envelope when piped — scripts and agents parse it.
12
+ */
13
+ export declare function printMobileResult<T>(result: Envelope<T>, options?: PrintMobileResultOptions<T>): void;
@@ -0,0 +1,21 @@
1
+ import { printResult } from "../../output.js";
2
+ /**
3
+ * Mobile commands are usually run by a person at a terminal, where the raw JSON
4
+ * envelope is hard to read. Print human-readable text when stdout is a TTY, and
5
+ * keep the JSON envelope when piped — scripts and agents parse it.
6
+ */
7
+ export function printMobileResult(result, options = {}) {
8
+ const interactive = options.interactive ?? process.stdout.isTTY === true;
9
+ if (!interactive) {
10
+ printResult(result);
11
+ return;
12
+ }
13
+ if (!result.ok) {
14
+ console.error(`Error: ${result.error.message}`);
15
+ if (result.error.hint)
16
+ console.error(`Hint: ${result.error.hint}`);
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+ console.log(options.render ? options.render(result.data) : JSON.stringify(result.data, null, 2));
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { formatError, formatSuccess } from "../../output.js";
3
+ import { printMobileResult } from "./output.js";
4
+ afterEach(() => {
5
+ vi.restoreAllMocks();
6
+ process.exitCode = undefined;
7
+ });
8
+ describe("printMobileResult", () => {
9
+ it("prints the JSON envelope when not interactive", () => {
10
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
11
+ printMobileResult(formatSuccess({ a: 1 }), { interactive: false });
12
+ expect(log).toHaveBeenCalledOnce();
13
+ expect(JSON.parse(log.mock.calls[0]?.[0])).toEqual({ ok: true, data: { a: 1 } });
14
+ });
15
+ it("prints the JSON envelope for errors when not interactive and sets the exit code", () => {
16
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
17
+ printMobileResult(formatError("validation_error", "boom", false), { interactive: false });
18
+ expect(JSON.parse(log.mock.calls[0]?.[0])).toMatchObject({
19
+ ok: false,
20
+ error: { message: "boom" },
21
+ });
22
+ expect(process.exitCode).toBe(1);
23
+ });
24
+ it("prints a human-readable error with hint to stderr when interactive", () => {
25
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
26
+ const error = vi.spyOn(console, "error").mockImplementation(() => { });
27
+ printMobileResult(formatError("validation_error", "boom", false, "try this"), {
28
+ interactive: true,
29
+ });
30
+ expect(log).not.toHaveBeenCalled();
31
+ expect(error).toHaveBeenCalledWith("Error: boom");
32
+ expect(error).toHaveBeenCalledWith("Hint: try this");
33
+ expect(process.exitCode).toBe(1);
34
+ });
35
+ it("omits the hint line when the error has none", () => {
36
+ const error = vi.spyOn(console, "error").mockImplementation(() => { });
37
+ printMobileResult(formatError("api_error", "boom", true), { interactive: true });
38
+ expect(error).toHaveBeenCalledTimes(1);
39
+ });
40
+ it("renders success data with the provided renderer when interactive", () => {
41
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
42
+ printMobileResult(formatSuccess({ n: 2 }), {
43
+ interactive: true,
44
+ render: (d) => `n is ${d.n}`,
45
+ });
46
+ expect(log).toHaveBeenCalledWith("n is 2");
47
+ expect(process.exitCode).toBeUndefined();
48
+ });
49
+ it("falls back to pretty-printed data without a renderer when interactive", () => {
50
+ const log = vi.spyOn(console, "log").mockImplementation(() => { });
51
+ printMobileResult(formatSuccess({ n: 2 }), { interactive: true });
52
+ expect(JSON.parse(log.mock.calls[0]?.[0])).toEqual({ n: 2 });
53
+ });
54
+ });
@@ -10,6 +10,7 @@ export interface MobileRegisterCmdOptions {
10
10
  os?: string | undefined;
11
11
  }
12
12
  export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
13
+ export declare function renderRegisterResult(data: MobileRegisterResult): string;
13
14
  declare const _default: import("citty").CommandDef<{
14
15
  readonly name: {
15
16
  readonly type: "string";
@@ -1,7 +1,8 @@
1
1
  import { defineCommand } from "citty";
2
2
  import {} from "../../client.js";
3
- import { formatError, printResult } from "../../output.js";
3
+ import { formatError } from "../../output.js";
4
4
  import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
5
+ import { printMobileResult } from "./output.js";
5
6
  import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
6
7
  export async function runMobileRegister(options, deps, exec) {
7
8
  const platform = parsePlatformArg(options.platform);
@@ -29,6 +30,13 @@ export async function runMobileRegister(options, deps, exec) {
29
30
  ...(options.os ? { os: options.os } : {}),
30
31
  }, deps);
31
32
  }
33
+ export function renderRegisterResult(data) {
34
+ return [
35
+ `Registered "${data.name}" (${data.platform}, target ${data.target}) as device ${data.deviceId}.`,
36
+ `Config: ${data.configPath}`,
37
+ "Next: aximo mobile connect",
38
+ ].join("\n");
39
+ }
32
40
  export default defineCommand({
33
41
  meta: {
34
42
  name: "register",
@@ -46,12 +54,12 @@ export default defineCommand({
46
54
  os: { type: "string", description: "Optional OS version label" },
47
55
  },
48
56
  async run({ args }) {
49
- printResult(await runMobileRegister({
57
+ printMobileResult(await runMobileRegister({
50
58
  name: args.name,
51
59
  platform: args.platform,
52
60
  serial: args.serial,
53
61
  udid: args.udid,
54
62
  os: args.os,
55
- }));
63
+ }), { render: renderRegisterResult });
56
64
  },
57
65
  });
@@ -18,6 +18,7 @@ export interface RunMobileSetupOptions {
18
18
  }) => Promise<Envelope<MobileDoctorReport>>;
19
19
  }
20
20
  export declare function runMobileSetup(options?: RunMobileSetupOptions): Promise<Envelope<SetupResult>>;
21
+ export declare function renderSetupResult(data: SetupResult): string;
21
22
  declare const _default: import("citty").CommandDef<{
22
23
  readonly platform: {
23
24
  readonly type: "string";
@@ -3,7 +3,8 @@ import { execFile } from "node:child_process";
3
3
  import { existsSync, mkdirSync } from "node:fs";
4
4
  import { createRequire } from "node:module";
5
5
  import { join } from "node:path";
6
- import { formatError, formatSuccess, printResult } from "../../output.js";
6
+ import { formatError, formatSuccess } from "../../output.js";
7
+ import { printMobileResult } from "./output.js";
7
8
  import { getAppiumHome } from "../../mobile-control/appium-server.js";
8
9
  import { runMobileDoctor } from "./doctor.js";
9
10
  const DRIVER_PACKAGE = {
@@ -82,6 +83,10 @@ export async function runMobileSetup(options = {}) {
82
83
  printNextSteps(report.ok ? report.data.checks : []);
83
84
  return formatSuccess({ appiumHome, drivers: results });
84
85
  }
86
+ export function renderSetupResult(data) {
87
+ const lines = data.drivers.map((d) => ` - ${d.driver}: ${d.alreadyInstalled ? "already installed" : "installed"}`);
88
+ return [`Appium home: ${data.appiumHome}`, ...lines].join("\n");
89
+ }
85
90
  export default defineCommand({
86
91
  meta: {
87
92
  name: "setup",
@@ -95,6 +100,6 @@ export default defineCommand({
95
100
  if (args.platform === "ios" || args.platform === "android")
96
101
  options.platform = args.platform;
97
102
  const result = await runMobileSetup(options);
98
- printResult(result);
103
+ printMobileResult(result, { render: renderSetupResult });
99
104
  },
100
105
  });
@@ -1,10 +1,14 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
2
  import type { Envelope } from "../../types.js";
3
+ export interface MobileUnregisterResult {
4
+ success: boolean;
5
+ deviceId: string;
6
+ name: string;
7
+ }
3
8
  export declare function runMobileUnregister(options: {
4
9
  deviceId?: string;
5
- }, deps?: ClientDeps): Promise<Envelope<{
6
- success: boolean;
7
- }>>;
10
+ }, deps?: ClientDeps): Promise<Envelope<MobileUnregisterResult>>;
11
+ export declare function renderUnregisterResult(data: MobileUnregisterResult): string;
8
12
  declare const _default: import("citty").CommandDef<{
9
13
  readonly "device-id": {
10
14
  readonly type: "string";
@@ -2,14 +2,15 @@ import { defineCommand } from "citty";
2
2
  import { existsSync, unlinkSync } from "node:fs";
3
3
  import { request } from "../../client.js";
4
4
  import { handleApiError } from "../../errors.js";
5
- import { formatSuccess, printResult } from "../../output.js";
5
+ import { formatSuccess } from "../../output.js";
6
6
  import { resolveDeviceConfig } from "./connect-core.js";
7
+ import { printMobileResult } from "./output.js";
7
8
  import { getMobileDeviceConfigPath } from "./paths.js";
8
9
  export async function runMobileUnregister(options, deps) {
9
10
  const resolved = resolveDeviceConfig(options.deviceId);
10
11
  if (!resolved.ok)
11
12
  return resolved;
12
- const { deviceId } = resolved.config;
13
+ const { deviceId, name } = resolved.config;
13
14
  try {
14
15
  await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
15
16
  }
@@ -25,7 +26,10 @@ export async function runMobileUnregister(options, deps) {
25
26
  if (existsSync(configPath)) {
26
27
  unlinkSync(configPath);
27
28
  }
28
- return formatSuccess({ success: true });
29
+ return formatSuccess({ success: true, deviceId, name });
30
+ }
31
+ export function renderUnregisterResult(data) {
32
+ return `Unregistered "${data.name}" (device ${data.deviceId}).`;
29
33
  }
30
34
  export default defineCommand({
31
35
  meta: { name: "unregister", description: "Unregister a local mobile device" },
@@ -37,6 +41,8 @@ export default defineCommand({
37
41
  },
38
42
  async run({ args }) {
39
43
  const deviceId = args["device-id"];
40
- printResult(await runMobileUnregister(deviceId ? { deviceId } : {}));
44
+ printMobileResult(await runMobileUnregister(deviceId ? { deviceId } : {}), {
45
+ render: renderUnregisterResult,
46
+ });
41
47
  },
42
48
  });
@@ -2,5 +2,9 @@ export interface ExecResult {
2
2
  stdout: Buffer;
3
3
  stderr: string;
4
4
  }
5
- export type ExecFn = (file: string, args: string[]) => Promise<ExecResult>;
5
+ export interface ExecOptions {
6
+ /** Working directory for the child (e.g. to zip relative paths). */
7
+ cwd?: string;
8
+ }
9
+ export type ExecFn = (file: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
6
10
  export declare const execBinary: ExecFn;
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  // Explicit argv, no shell — payloads can't inject into the local machine.
3
- export const execBinary = (file, args) => new Promise((resolve, reject) => {
4
- execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
3
+ export const execBinary = (file, args, opts) => new Promise((resolve, reject) => {
4
+ execFile(file, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...(opts?.cwd ? { cwd: opts.cwd } : {}) }, (error, stdout, stderr) => {
5
5
  const stderrText = stderr.toString("utf-8");
6
6
  if (error) {
7
7
  const message = stderrText.trim() || error.message;
@@ -6,6 +6,8 @@
6
6
  * Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
7
7
  */
8
8
  export declare const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
9
+ /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
10
+ export declare const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
9
11
  /**
10
12
  * True when the tunnel registry server answers its HTTP API on `port`. A raw
11
13
  * TCP connect is not enough — an unrelated listener on the port would be
@@ -20,8 +22,9 @@ export interface TunnelScriptOptions {
20
22
  }
21
23
  /**
22
24
  * A self-contained shell script that launches the driver's `tunnel-creation` as a
23
- * detached background daemon. Run as root via the macOS auth prompt; it returns
24
- * immediately while the tunnel keeps running (health-checked via the registry port).
25
+ * fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
26
+ * the macOS auth prompt; it returns immediately while the tunnel keeps running
27
+ * (health-checked via the registry port).
25
28
  */
26
29
  export declare function buildTunnelScript(opts: TunnelScriptOptions): string;
27
30
  /** Prompt for admin rights (password/Touch ID) and run the tunnel script as root. */
@@ -13,6 +13,8 @@ import { envFlag } from "./env-flags.js";
13
13
  * Older iOS and self-managed setups can opt out with AXIMO_IOS_SKIP_TUNNEL.
14
14
  */
15
15
  export const DEFAULT_TUNNEL_REGISTRY_PORT = 42314;
16
+ /** How to stop the elevated tunnel daemon — surfaced whenever we start or leave it running. */
17
+ export const TUNNEL_STOP_COMMAND = "sudo pkill -f tunnel-creation";
16
18
  /**
17
19
  * True when the tunnel registry server answers its HTTP API on `port`. A raw
18
20
  * TCP connect is not enough — an unrelated listener on the port would be
@@ -36,17 +38,34 @@ export async function isTunnelHealthy(port = DEFAULT_TUNNEL_REGISTRY_PORT, host
36
38
  function shQuote(value) {
37
39
  return `'${value.replace(/'/g, "'\\''")}'`;
38
40
  }
41
+ // Daemonize via perl (always present on macOS). A plain `nohup … &` does NOT
42
+ // survive `do shell script … with administrator privileges`: the security
43
+ // trampoline tears down its child's process group when the script returns, and
44
+ // nohup only blocks SIGHUP — so the tunnel is killed before it initializes.
45
+ // Double-fork + setsid moves the daemon into its own session and process group,
46
+ // out of the trampoline's reach. $ARGV[0] is the log file; $ARGV[1..] the argv.
47
+ const DAEMONIZE_PERL = [
48
+ "use POSIX qw(setsid);",
49
+ "open(STDIN, '<', '/dev/null');",
50
+ "open(STDOUT, '>', $ARGV[0]) or die $!;",
51
+ "open(STDERR, '>&', STDOUT);",
52
+ "exit if fork;", // top process (the trampoline's child) returns immediately
53
+ "setsid();", // child leads a new session — detached from the trampoline
54
+ "exit if fork;", // grandchild can never reacquire a controlling terminal
55
+ "exec(@ARGV[1 .. $#ARGV]) or die $!;",
56
+ ].join(" ");
39
57
  /**
40
58
  * A self-contained shell script that launches the driver's `tunnel-creation` as a
41
- * detached background daemon. Run as root via the macOS auth prompt; it returns
42
- * immediately while the tunnel keeps running (health-checked via the registry port).
59
+ * fully detached daemon (own session, via {@link DAEMONIZE_PERL}). Run as root via
60
+ * the macOS auth prompt; it returns immediately while the tunnel keeps running
61
+ * (health-checked via the registry port).
43
62
  */
44
63
  export function buildTunnelScript(opts) {
45
64
  return [
46
65
  "#!/bin/sh",
47
66
  `export APPIUM_HOME=${shQuote(opts.appiumHome)}`,
48
67
  'export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"',
49
- `nohup ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation > ${shQuote(opts.logFile)} 2>&1 &`,
68
+ `exec perl -e ${shQuote(DAEMONIZE_PERL)} ${shQuote(opts.logFile)} ${shQuote(opts.nodePath)} ${shQuote(opts.appiumBin)} driver run xcuitest tunnel-creation`,
50
69
  "",
51
70
  ].join("\n");
52
71
  }
@@ -81,7 +100,7 @@ async function pollHealthy(isHealthy, port, timeoutMs, logFile) {
81
100
  }
82
101
  throw new Error(`iOS device tunnel did not become ready within ${timeoutMs}ms. ` +
83
102
  `Check ${logFile} for the daemon's output. A partially-started daemon may still ` +
84
- "be running; stop it before retrying: sudo pkill -f tunnel-creation");
103
+ `be running; stop it before retrying: ${TUNNEL_STOP_COMMAND}`);
85
104
  }
86
105
  /**
87
106
  * Ensure the RemoteXPC tunnel is running so XCUITest can reach WebDriverAgent.
@@ -125,6 +144,7 @@ export async function ensureIosTunnel(options = {}) {
125
144
  `${shQuote(appiumBin)} driver run xcuitest tunnel-creation`);
126
145
  }
127
146
  await pollHealthy(isHealthy, port, options.readyTimeoutMs ?? 45_000, logFile);
128
- log(`iOS device tunnel is up (registry on port ${port}).`);
147
+ log(`iOS device tunnel is up (registry on port ${port}). It keeps running in the background ` +
148
+ `so later connects don't prompt again; stop it anytime with: ${TUNNEL_STOP_COMMAND}`);
129
149
  return { reused: false, started: true, skipped: false, port };
130
150
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { buildTunnelScript, DEFAULT_TUNNEL_REGISTRY_PORT, ensureIosTunnel } from "./ios-tunnel.js";
3
3
  describe("buildTunnelScript", () => {
4
- it("backgrounds tunnel-creation as a detached daemon with APPIUM_HOME set", () => {
4
+ it("launches tunnel-creation as a setsid-detached daemon with APPIUM_HOME set", () => {
5
5
  const script = buildTunnelScript({
6
6
  appiumHome: "/home/u/.aximo/appium-home",
7
7
  nodePath: "/usr/bin/node",
@@ -9,8 +9,13 @@ describe("buildTunnelScript", () => {
9
9
  logFile: "/home/u/.aximo/ios-tunnel/tunnel.log",
10
10
  });
11
11
  expect(script).toContain("export APPIUM_HOME='/home/u/.aximo/appium-home'");
12
- expect(script).toContain("nohup '/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
13
- expect(script).toContain("> '/home/u/.aximo/ios-tunnel/tunnel.log' 2>&1 &");
12
+ // Detached via perl double-fork + setsid so the admin-elevation trampoline
13
+ // can't reap it (a plain `nohup … &` was killed on script exit).
14
+ expect(script).toContain("setsid()");
15
+ expect(script).toContain("'/usr/bin/node' '/cli/node_modules/appium/index.js' driver run xcuitest tunnel-creation");
16
+ // Log file is passed as the perl daemon's first argv (STDOUT/STDERR sink).
17
+ expect(script).toContain("'/home/u/.aximo/ios-tunnel/tunnel.log' '/usr/bin/node'");
18
+ expect(script).not.toContain("nohup");
14
19
  });
15
20
  it("escapes single quotes in paths", () => {
16
21
  const script = buildTunnelScript({
@@ -68,8 +68,11 @@ export async function preinstallWda(options) {
68
68
  }
69
69
  const scratch = mkdtempSync(join(tmpdir(), "aximo-wda-preinstall-"));
70
70
  try {
71
- // Wrap the bare .app into Payload/ so it can go through the same resign path
72
- // as any other IPA (ditto ships with macOS and handles both directions).
71
+ // Wrap the bare .app into Payload/ so it goes through the same resign path as
72
+ // any other IPA. Extract with ditto (it reads the Apple-produced zip cleanly)
73
+ // but REPACKAGE with `zip`: applesign's own extractor can't parse ditto's
74
+ // PKZip variant and rejects a ditto-built IPA as "Invalid IPA" (no .app found
75
+ // in Payload). `zip -qr Payload` from the wrap dir keeps Payload/ at the root.
73
76
  const payloadRoot = join(scratch, "wrap");
74
77
  const extractDir = join(payloadRoot, "Payload");
75
78
  mkdirSync(extractDir, { recursive: true });
@@ -78,7 +81,7 @@ export async function preinstallWda(options) {
78
81
  throw new Error(`Prebuilt WDA zip did not contain ${RUNNER_APP} — re-download or report.`);
79
82
  }
80
83
  const wrappedIpa = join(scratch, "wda.ipa");
81
- await exec("ditto", ["-c", "-k", payloadRoot, wrappedIpa]);
84
+ await exec("zip", ["-qr", wrappedIpa, "Payload"], { cwd: payloadRoot });
82
85
  const runnerBundleId = `${options.wdaBundleId}.xctrunner`;
83
86
  const resignedIpa = join(scratch, "wda-resigned.ipa");
84
87
  log(`Signing WebDriverAgent as ${runnerBundleId}…`);
@@ -33,15 +33,19 @@ describe("preinstallWda", () => {
33
33
  afterEach(() => {
34
34
  rmSync(cacheDir, { recursive: true, force: true });
35
35
  });
36
- /** ditto fake: -x -k extract creates the runner app; -c -k creates the archive. */
37
- const dittoExec = async (file, args) => {
36
+ /** exec fake: `ditto -x` extracts the runner app; `zip` writes the IPA archive. */
37
+ const execFake = async (file, args, opts) => {
38
38
  if (file === "ditto" && args[0] === "-x") {
39
39
  const dest = args[3];
40
40
  if (dest)
41
41
  mkdirSync(join(dest, "WebDriverAgentRunner-Runner.app"), { recursive: true });
42
42
  }
43
- if (file === "ditto" && args[0] === "-c") {
44
- const out = args[3];
43
+ if (file === "zip") {
44
+ // Like real zip: the relative Payload argument only resolves against opts.cwd.
45
+ if (!opts?.cwd || !existsSync(join(opts.cwd, "Payload"))) {
46
+ throw new Error("zip error: Nothing to do (Payload not found in cwd)");
47
+ }
48
+ const out = args[1];
45
49
  if (out)
46
50
  writeFileSync(out, "zip");
47
51
  }
@@ -53,7 +57,7 @@ describe("preinstallWda", () => {
53
57
  identityHash: "A".repeat(40),
54
58
  profilePath: "/profiles/wda.mobileprovision",
55
59
  wdaBundleId: "com.aximo.wda.testteam99",
56
- exec: dittoExec,
60
+ exec: execFake,
57
61
  cacheDir,
58
62
  wdaVersion: "15.0.0",
59
63
  fetchImpl: vi.fn(async (_input) => new Response(new ArrayBuffer(8), { status: 200 })),
@@ -76,7 +80,7 @@ describe("preinstallWda", () => {
76
80
  profilePath: "/profiles/wda.mobileprovision",
77
81
  bundleId: "com.aximo.wda.testteam99.xctrunner",
78
82
  }));
79
- expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"), dittoExec);
83
+ expect(opts.install).toHaveBeenCalledWith("UDID-1", expect.stringContaining("wda-resigned"), execFake);
80
84
  // Second run: cache hit, no new download.
81
85
  await preinstallWda(opts);
82
86
  expect(opts.fetchImpl).toHaveBeenCalledOnce();
@@ -93,7 +97,7 @@ describe("preinstallWda", () => {
93
97
  await expect(preinstallWda(opts)).rejects.toThrow(/aximo mobile setup/);
94
98
  });
95
99
  it("fails loudly when the zip has an unexpected layout", async () => {
96
- const badDitto = async (file, args) => {
100
+ const badExec = async (file, args) => {
97
101
  if (file === "ditto" && args[0] === "-x") {
98
102
  const dest = args[3];
99
103
  if (dest)
@@ -101,7 +105,7 @@ describe("preinstallWda", () => {
101
105
  }
102
106
  return { stdout: Buffer.from(""), stderr: "" };
103
107
  };
104
- const opts = { ...makeOptions(), exec: badDitto };
108
+ const opts = { ...makeOptions(), exec: badExec };
105
109
  await expect(preinstallWda(opts)).rejects.toThrow(/did not contain/);
106
110
  });
107
111
  });
@@ -49,10 +49,43 @@ function defaultConnect(wsUrl, deviceToken) {
49
49
  timeout: 10000,
50
50
  });
51
51
  }
52
+ // Mirrors the gateway's lock lease: a session quiet for longer than this has lost
53
+ // its server-side lock, so a new session id is a legitimate takeover, not a stray.
54
+ const SESSION_LEASE_MS = 25 * 60 * 1000;
52
55
  export function registerHandlers(socket, controller, installApp) {
56
+ // Device-side guard against interleaved sessions: the gateway announces owner
57
+ // changes (session_acquired/session_released) and tags each command with its
58
+ // session id, so commands from a session that no longer owns the device are
59
+ // dropped even if a gateway instance lost its in-memory lock.
60
+ let activeSession = null;
61
+ socket.on("session_acquired", (args) => {
62
+ const sessionId = args?.sessionId;
63
+ if (typeof sessionId === "string" && sessionId) {
64
+ activeSession = { id: sessionId, lastSeenAt: Date.now() };
65
+ }
66
+ });
67
+ socket.on("session_released", (args) => {
68
+ // Only the owner's release clears the guard; a takeover by a new session is
69
+ // announced via session_acquired, so an unmatched release is ignored.
70
+ const sessionId = args?.sessionId;
71
+ if (activeSession && activeSession.id === sessionId) {
72
+ activeSession = null;
73
+ }
74
+ });
53
75
  const on = (event, handler) => {
54
76
  socket.on(event, (rawArgs, ack) => {
55
- const args = (rawArgs ?? {});
77
+ // Destructure the routing-only tag off a clone so the caller's payload
78
+ // object is never mutated.
79
+ const { __sessionId: sessionId, ...args } = (rawArgs ?? {});
80
+ if (typeof sessionId === "string" && sessionId) {
81
+ if (activeSession &&
82
+ activeSession.id !== sessionId &&
83
+ Date.now() - activeSession.lastSeenAt < SESSION_LEASE_MS) {
84
+ ack?.(fail(new Error("Device is busy with another session")));
85
+ return;
86
+ }
87
+ activeSession = { id: sessionId, lastSeenAt: Date.now() };
88
+ }
56
89
  void handler(args)
57
90
  .then((result) => ack?.(result))
58
91
  .catch((err) => ack?.(fail(err)));
@@ -153,3 +153,85 @@ describe("registerHandlers", () => {
153
153
  });
154
154
  });
155
155
  });
156
+ describe("registerHandlers session guard", () => {
157
+ const BUSY = { ok: false, error: "Device is busy with another session" };
158
+ function fire(socket, event, args) {
159
+ socket.handlers.get(event)?.(args);
160
+ }
161
+ it("strips __sessionId for the handler without mutating the incoming payload", async () => {
162
+ const tap = vi.fn(async () => { });
163
+ const socket = wire(stubController({ tap }));
164
+ const payload = { x: 5, y: 9, __sessionId: "s1" };
165
+ await expect(socket.emit("tap", payload)).resolves.toEqual({ ok: true });
166
+ expect(tap).toHaveBeenCalledWith(5, 9);
167
+ expect(payload.__sessionId).toBe("s1");
168
+ });
169
+ it("rejects a second session's command while the first is active", async () => {
170
+ const tap = vi.fn(async () => { });
171
+ const socket = wire(stubController({ tap }));
172
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
173
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
174
+ expect(tap).toHaveBeenCalledTimes(1);
175
+ });
176
+ it("accepts a new session after session_released for the active one", async () => {
177
+ const tap = vi.fn(async () => { });
178
+ const socket = wire(stubController({ tap }));
179
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
180
+ fire(socket, "session_released", { sessionId: "s1" });
181
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
182
+ ok: true,
183
+ });
184
+ expect(tap).toHaveBeenCalledTimes(2);
185
+ });
186
+ it("ignores session_released for a session that is not active", async () => {
187
+ const tap = vi.fn(async () => { });
188
+ const socket = wire(stubController({ tap }));
189
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
190
+ fire(socket, "session_released", { sessionId: "other" });
191
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
192
+ expect(tap).toHaveBeenCalledTimes(1);
193
+ });
194
+ it("ignores session_released without a sessionId (malformed release)", async () => {
195
+ const tap = vi.fn(async () => { });
196
+ const socket = wire(stubController({ tap }));
197
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
198
+ fire(socket, "session_released", {});
199
+ fire(socket, "session_released", { sessionId: null });
200
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual(BUSY);
201
+ expect(tap).toHaveBeenCalledTimes(1);
202
+ });
203
+ it("switches owner on session_acquired and rejects the superseded session", async () => {
204
+ const tap = vi.fn(async () => { });
205
+ const socket = wire(stubController({ tap }));
206
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
207
+ fire(socket, "session_acquired", { sessionId: "s2" });
208
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s1" })).resolves.toEqual(BUSY);
209
+ await expect(socket.emit("tap", { x: 5, y: 6, __sessionId: "s2" })).resolves.toEqual({
210
+ ok: true,
211
+ });
212
+ expect(tap).toHaveBeenCalledTimes(2);
213
+ });
214
+ it("allows takeover once the active session is idle past the lease", async () => {
215
+ vi.useFakeTimers();
216
+ try {
217
+ const tap = vi.fn(async () => { });
218
+ const socket = wire(stubController({ tap }));
219
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
220
+ vi.advanceTimersByTime(26 * 60 * 1000);
221
+ await expect(socket.emit("tap", { x: 3, y: 4, __sessionId: "s2" })).resolves.toEqual({
222
+ ok: true,
223
+ });
224
+ expect(tap).toHaveBeenCalledTimes(2);
225
+ }
226
+ finally {
227
+ vi.useRealTimers();
228
+ }
229
+ });
230
+ it("leaves untagged commands ungated (pre-tagging gateways)", async () => {
231
+ const tap = vi.fn(async () => { });
232
+ const socket = wire(stubController({ tap }));
233
+ await socket.emit("tap", { x: 1, y: 2, __sessionId: "s1" });
234
+ await expect(socket.emit("tap", { x: 3, y: 4 })).resolves.toEqual({ ok: true });
235
+ expect(tap).toHaveBeenCalledTimes(2);
236
+ });
237
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",