@autifyhq/aximo-cli 0.5.0 → 0.7.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 (98) hide show
  1. package/README.md +196 -64
  2. package/dist/cli.js +4 -0
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +33 -0
  5. package/dist/commands/mobile/connect-core.d.ts +2 -1
  6. package/dist/commands/mobile/connect-core.js +127 -15
  7. package/dist/commands/mobile/connect.js +3 -1
  8. package/dist/commands/mobile/doctor.d.ts +3 -0
  9. package/dist/commands/mobile/doctor.js +55 -6
  10. package/dist/commands/mobile/doctor.test.d.ts +1 -0
  11. package/dist/commands/mobile/doctor.test.js +14 -0
  12. package/dist/commands/mobile/index.js +1 -0
  13. package/dist/commands/mobile/install.d.ts +50 -0
  14. package/dist/commands/mobile/install.js +107 -0
  15. package/dist/commands/mobile/install.test.d.ts +1 -0
  16. package/dist/commands/mobile/install.test.js +142 -0
  17. package/dist/commands/mobile/list.d.ts +2 -0
  18. package/dist/commands/mobile/list.js +17 -11
  19. package/dist/commands/mobile/register.d.ts +6 -1
  20. package/dist/commands/mobile/register.js +12 -6
  21. package/dist/commands/mobile/register.test.js +34 -3
  22. package/dist/commands/mobile/unregister.js +11 -6
  23. package/dist/commands/schema.js +15 -8
  24. package/dist/mobile-control/app-install.d.ts +34 -0
  25. package/dist/mobile-control/app-install.js +55 -0
  26. package/dist/mobile-control/app-install.test.d.ts +1 -0
  27. package/dist/mobile-control/app-install.test.js +113 -0
  28. package/dist/mobile-control/appium-controller.d.ts +11 -0
  29. package/dist/mobile-control/appium-controller.js +24 -4
  30. package/dist/mobile-control/bundle-id-map.d.ts +9 -0
  31. package/dist/mobile-control/bundle-id-map.js +27 -0
  32. package/dist/mobile-control/devices.d.ts +8 -3
  33. package/dist/mobile-control/devices.js +57 -5
  34. package/dist/mobile-control/devices.test.js +41 -1
  35. package/dist/mobile-control/env-flags.d.ts +5 -0
  36. package/dist/mobile-control/env-flags.js +10 -0
  37. package/dist/mobile-control/env-flags.test.d.ts +1 -0
  38. package/dist/mobile-control/env-flags.test.js +21 -0
  39. package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
  40. package/dist/mobile-control/ios-auto-resign.js +108 -0
  41. package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
  42. package/dist/mobile-control/ios-auto-resign.test.js +150 -0
  43. package/dist/mobile-control/ios-devicectl.d.ts +39 -0
  44. package/dist/mobile-control/ios-devicectl.js +87 -0
  45. package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
  46. package/dist/mobile-control/ios-devicectl.test.js +110 -0
  47. package/dist/mobile-control/ios-identity.d.ts +27 -0
  48. package/dist/mobile-control/ios-identity.js +49 -0
  49. package/dist/mobile-control/ios-identity.test.d.ts +1 -0
  50. package/dist/mobile-control/ios-identity.test.js +102 -0
  51. package/dist/mobile-control/ios-mint.d.ts +39 -0
  52. package/dist/mobile-control/ios-mint.js +78 -0
  53. package/dist/mobile-control/ios-mint.test.d.ts +1 -0
  54. package/dist/mobile-control/ios-mint.test.js +94 -0
  55. package/dist/mobile-control/ios-profiles.d.ts +46 -0
  56. package/dist/mobile-control/ios-profiles.js +106 -0
  57. package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
  58. package/dist/mobile-control/ios-profiles.test.js +154 -0
  59. package/dist/mobile-control/ios-resign.d.ts +37 -0
  60. package/dist/mobile-control/ios-resign.js +101 -0
  61. package/dist/mobile-control/ios-resign.test.d.ts +1 -0
  62. package/dist/mobile-control/ios-resign.test.js +84 -0
  63. package/dist/mobile-control/ios-session-error.d.ts +10 -0
  64. package/dist/mobile-control/ios-session-error.js +34 -0
  65. package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
  66. package/dist/mobile-control/ios-session-error.test.js +30 -0
  67. package/dist/mobile-control/ios-signing.d.ts +56 -0
  68. package/dist/mobile-control/ios-signing.js +94 -0
  69. package/dist/mobile-control/ios-signing.test.d.ts +1 -0
  70. package/dist/mobile-control/ios-signing.test.js +160 -0
  71. package/dist/mobile-control/ios-tunnel.d.ts +54 -0
  72. package/dist/mobile-control/ios-tunnel.js +130 -0
  73. package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
  74. package/dist/mobile-control/ios-tunnel.test.js +115 -0
  75. package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
  76. package/dist/mobile-control/ios-wda-cache.js +157 -0
  77. package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
  78. package/dist/mobile-control/ios-wda-cache.test.js +128 -0
  79. package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
  80. package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
  81. package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
  82. package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
  83. package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
  84. package/dist/mobile-control/ios-wda-preinstall.js +162 -0
  85. package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
  86. package/dist/mobile-control/ios-wda-preinstall.test.js +107 -0
  87. package/dist/mobile-control/mobile-errors.d.ts +12 -0
  88. package/dist/mobile-control/mobile-errors.js +91 -0
  89. package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
  90. package/dist/mobile-control/mobile-errors.test.js +48 -0
  91. package/dist/mobile-control/tunnel.d.ts +5 -1
  92. package/dist/mobile-control/tunnel.js +60 -3
  93. package/dist/mobile-control/tunnel.test.js +1 -1
  94. package/dist/proxy.d.ts +11 -0
  95. package/dist/proxy.js +85 -0
  96. package/dist/proxy.test.d.ts +1 -0
  97. package/dist/proxy.test.js +72 -0
  98. package/package.json +4 -2
@@ -120,25 +120,32 @@ export function getSchema() {
120
120
  arg("available-only", "boolean", ""),
121
121
  ]),
122
122
  ]),
123
- cmd("mobile", "Register and connect a physically-connected Android phone (via adb)", [], [
124
- cmd("list", "List physically-connected phones (adb)"),
125
- cmd("register", "Register a physically-connected Android phone as an Aximo device", [
123
+ cmd("mobile", "Register and connect a physically-connected Android phone (adb) or iPhone (idb/Xcode)", [], [
124
+ cmd("list", "List physically-connected phones (Android via adb, iOS via idb)"),
125
+ cmd("register", "Register a physically-connected Android phone or iPhone as an Aximo device", [
126
126
  arg("name", "string", "Device name", true),
127
- arg("platform", "string", "Device platform (only `android` is supported today)"),
127
+ arg("platform", "string", "Device platform (`android` or `ios`)"),
128
128
  arg("serial", "string", "Android adb serial (defaults to the connected one)"),
129
+ arg("udid", "string", "iOS device udid (defaults to the connected one)"),
129
130
  arg("os", "string", "Optional OS version label"),
130
131
  ]),
132
+ cmd("install", "Install an app on the connected phone (.apk via adb, .ipa via idb)", [
133
+ arg("app", "string", "Path to the .apk or .ipa to install", true),
134
+ arg("platform", "string", "android | ios (inferred from the file extension)"),
135
+ arg("serial", "string", "Android adb serial (defaults to the connected one)"),
136
+ arg("udid", "string", "iOS device udid (defaults to the connected one)"),
137
+ ]),
131
138
  cmd("unregister", "Unregister a local mobile device", [
132
139
  arg("device-id", "string", "Device ID (defaults to the sole registered local device)"),
133
140
  ]),
134
- cmd("connect", "Connect a registered Android phone via a CLI-managed Appium server", [
141
+ cmd("connect", "Connect a registered phone via a CLI-managed Appium server", [
135
142
  arg("device-id", "string", "Registered device id (required if multiple)"),
136
143
  ]),
137
144
  cmd("setup", "Install the Appium drivers used to drive registered phones", [
138
- arg("platform", "string", "Limit checks to android"),
145
+ arg("platform", "string", "Limit to ios | android driver"),
139
146
  ]),
140
- cmd("doctor", "Diagnose physical-device tooling (adb authorization, Appium setup)", [
141
- arg("platform", "string", "Limit checks to android"),
147
+ cmd("doctor", "Diagnose physical-device tooling (adb/idb, Xcode, Appium setup)", [
148
+ arg("platform", "string", "Limit checks to ios | android"),
142
149
  ]),
143
150
  ]),
144
151
  cmd("upload", "Upload commands", [], [
@@ -0,0 +1,34 @@
1
+ import { type ClientDeps } from "../client.js";
2
+ import { type ExecFn } from "./exec.js";
3
+ import { ensureInstallableIpa } from "./ios-auto-resign.js";
4
+ import { installIosApp } from "./ios-devicectl.js";
5
+ export interface InstallUploadedAppOptions {
6
+ /** Aximo upload id of the app under test. */
7
+ uploadId: string;
8
+ platform: "ios" | "android";
9
+ /** adb serial (Android) or udid (iOS) of the registered device. */
10
+ target: string;
11
+ exec?: ExecFn;
12
+ /** Client overrides (apiKey/baseUrl/fetchImpl) — used in tests. */
13
+ deps?: ClientDeps;
14
+ /** Progress reporting for the (potentially slow) resign path. */
15
+ log?: (message: string) => void;
16
+ /** Injectable for tests. */
17
+ ensureInstallable?: typeof ensureInstallableIpa;
18
+ installIos?: typeof installIosApp;
19
+ }
20
+ export interface InstallUploadedAppResult {
21
+ output: string;
22
+ /** iOS only: set when the app was auto-re-signed for this device. */
23
+ originalBundleId?: string;
24
+ installedBundleId?: string;
25
+ }
26
+ /**
27
+ * Download the app under test from Aximo (authenticated via the org API key, which
28
+ * redirects to short-lived storage) and install it on the connected device —
29
+ * Android `.apk` via `adb install -r`, iOS `.ipa` via devicectl, auto-re-signed for
30
+ * the device first when its signature doesn't already cover it (what AWS Device
31
+ * Farm does server-side). The app binary never crosses the control tunnel; only
32
+ * the opaque upload id does.
33
+ */
34
+ export declare function installUploadedApp(opts: InstallUploadedAppOptions): Promise<InstallUploadedAppResult>;
@@ -0,0 +1,55 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { requestToFile } from "../client.js";
6
+ import { execBinary } from "./exec.js";
7
+ import { ensureInstallableIpa } from "./ios-auto-resign.js";
8
+ import { installIosApp } from "./ios-devicectl.js";
9
+ import { withMobileHint } from "./mobile-errors.js";
10
+ /**
11
+ * Download the app under test from Aximo (authenticated via the org API key, which
12
+ * redirects to short-lived storage) and install it on the connected device —
13
+ * Android `.apk` via `adb install -r`, iOS `.ipa` via devicectl, auto-re-signed for
14
+ * the device first when its signature doesn't already cover it (what AWS Device
15
+ * Farm does server-side). The app binary never crosses the control tunnel; only
16
+ * the opaque upload id does.
17
+ */
18
+ export async function installUploadedApp(opts) {
19
+ const exec = opts.exec ?? execBinary;
20
+ const ext = opts.platform === "ios" ? ".ipa" : ".apk";
21
+ const tmpPath = join(tmpdir(), `aximo-install-${randomBytes(8).toString("hex")}${ext}`);
22
+ let resignedPath;
23
+ try {
24
+ // Stream the (authenticated) download straight to disk — apps can be large.
25
+ await requestToFile(`/api/devices/apps/${encodeURIComponent(opts.uploadId)}/download`, tmpPath, opts.deps ?? {});
26
+ if (opts.platform === "android") {
27
+ const { stdout } = await exec("adb", ["-s", opts.target, "install", "-r", tmpPath]);
28
+ return { output: stdout.toString("utf-8").trim() };
29
+ }
30
+ const ensured = await (opts.ensureInstallable ?? ensureInstallableIpa)({
31
+ ipaPath: tmpPath,
32
+ udid: opts.target,
33
+ ...(opts.log ? { log: opts.log } : {}),
34
+ });
35
+ if (ensured.resigned)
36
+ resignedPath = ensured.ipaPath;
37
+ const output = await (opts.installIos ?? installIosApp)(opts.target, ensured.ipaPath, exec);
38
+ return ensured.resigned
39
+ ? {
40
+ output,
41
+ originalBundleId: ensured.originalBundleId,
42
+ installedBundleId: ensured.installedBundleId,
43
+ }
44
+ : { output };
45
+ }
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ throw new Error(withMobileHint(message, opts.platform));
49
+ }
50
+ finally {
51
+ await rm(tmpPath, { force: true }).catch(() => { });
52
+ if (resignedPath)
53
+ await rm(resignedPath, { force: true }).catch(() => { });
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { installUploadedApp } from "./app-install.js";
3
+ const DEPS = { apiKey: "test-key", baseUrl: "https://aximo.test" };
4
+ function fakeFetch(ok, bytes = "apk-bytes") {
5
+ let seen = "";
6
+ const impl = vi.fn(async (url) => {
7
+ seen = url;
8
+ return {
9
+ ok,
10
+ status: ok ? 200 : 403,
11
+ statusText: ok ? "OK" : "Forbidden",
12
+ text: async () => (ok ? "" : "forbidden"),
13
+ body: ok
14
+ ? new ReadableStream({
15
+ start(controller) {
16
+ controller.enqueue(new TextEncoder().encode(bytes));
17
+ controller.close();
18
+ },
19
+ })
20
+ : null,
21
+ headers: { get: () => null },
22
+ };
23
+ });
24
+ return { impl, url: () => seen };
25
+ }
26
+ function captureExec() {
27
+ const calls = [];
28
+ const exec = async (file, args) => {
29
+ calls.push({ file, args });
30
+ return { stdout: Buffer.from("Success"), stderr: "" };
31
+ };
32
+ return { exec, calls: () => calls };
33
+ }
34
+ describe("installUploadedApp", () => {
35
+ it("fetches the app authenticated by upload id and installs via adb", async () => {
36
+ const { exec, calls } = captureExec();
37
+ const { impl, url } = fakeFetch(true);
38
+ const res = await installUploadedApp({
39
+ uploadId: "up-123",
40
+ platform: "android",
41
+ target: "R1",
42
+ exec,
43
+ deps: { ...DEPS, fetchImpl: impl },
44
+ });
45
+ expect(res.output).toContain("Success");
46
+ expect(url()).toBe("https://aximo.test/api/devices/apps/up-123/download");
47
+ const call = calls()[0];
48
+ expect(call?.file).toBe("adb");
49
+ expect(call?.args.slice(0, 4)).toEqual(["-s", "R1", "install", "-r"]);
50
+ expect(call?.args[4]).toMatch(/\.apk$/);
51
+ });
52
+ it("auto-resigns when needed and installs an iOS app via devicectl", async () => {
53
+ const { exec } = captureExec();
54
+ const { impl } = fakeFetch(true);
55
+ const ensureInstallable = vi.fn(async (opts) => ({
56
+ ipaPath: opts.ipaPath.replace(/\.ipa$/, "-resigned.ipa"),
57
+ resigned: true,
58
+ originalBundleId: "com.example.app",
59
+ installedBundleId: "com.aximo.resign.team.xctrunner",
60
+ }));
61
+ const installIos = vi.fn(async () => "App installed:");
62
+ const res = await installUploadedApp({
63
+ uploadId: "up-9",
64
+ platform: "ios",
65
+ target: "00008030-ABC",
66
+ exec,
67
+ deps: { ...DEPS, fetchImpl: impl },
68
+ ensureInstallable,
69
+ installIos,
70
+ });
71
+ expect(vi.mocked(ensureInstallable).mock.calls[0]?.[0]).toMatchObject({
72
+ udid: "00008030-ABC",
73
+ });
74
+ expect(installIos).toHaveBeenCalledWith("00008030-ABC", expect.stringMatching(/-resigned\.ipa$/), exec);
75
+ expect(res).toEqual({
76
+ output: "App installed:",
77
+ originalBundleId: "com.example.app",
78
+ installedBundleId: "com.aximo.resign.team.xctrunner",
79
+ });
80
+ });
81
+ it("installs an already-installable iOS app without resign metadata", async () => {
82
+ const { exec } = captureExec();
83
+ const { impl } = fakeFetch(true);
84
+ const ensureInstallable = vi.fn(async (opts) => ({
85
+ ipaPath: opts.ipaPath,
86
+ resigned: false,
87
+ originalBundleId: "com.example.app",
88
+ installedBundleId: "com.example.app",
89
+ }));
90
+ const installIos = vi.fn(async () => "App installed:");
91
+ const res = await installUploadedApp({
92
+ uploadId: "up-9",
93
+ platform: "ios",
94
+ target: "00008030-ABC",
95
+ exec,
96
+ deps: { ...DEPS, fetchImpl: impl },
97
+ ensureInstallable,
98
+ installIos,
99
+ });
100
+ expect(res).toEqual({ output: "App installed:" });
101
+ });
102
+ it("throws when the authenticated download fails", async () => {
103
+ const { exec } = captureExec();
104
+ const { impl } = fakeFetch(false);
105
+ await expect(installUploadedApp({
106
+ uploadId: "missing",
107
+ platform: "android",
108
+ target: "R1",
109
+ exec,
110
+ deps: { ...DEPS, fetchImpl: impl },
111
+ })).rejects.toThrow();
112
+ });
113
+ });
@@ -1,15 +1,26 @@
1
1
  import type { ActionResult, MobileController } from "./controller.js";
2
+ import { type IosSigningConfig } from "./ios-signing.js";
2
3
  import { type WebDriverTransport } from "./webdriver.js";
3
4
  export interface AppiumControllerOptions {
4
5
  endpoint: string;
5
6
  platform: "ios" | "android";
6
7
  target: string;
8
+ iosSigning?: IosSigningConfig;
9
+ /**
10
+ * Local port XCUITest forwards to WebDriverAgent; unique-per-session avoids
11
+ * collisions. A provider is resolved just before session creation so the port
12
+ * isn't picked long before it is bound.
13
+ */
14
+ wdaLocalPort?: number | (() => Promise<number>);
7
15
  transport?: WebDriverTransport;
8
16
  }
9
17
  export declare class AppiumController implements MobileController {
10
18
  private readonly session;
11
19
  private readonly platform;
12
20
  private readonly target;
21
+ private readonly iosSigning;
22
+ private readonly wdaLocalPort;
23
+ private resolvedWdaPort;
13
24
  private sessionReady;
14
25
  private scaling;
15
26
  constructor(opts: AppiumControllerOptions);
@@ -1,3 +1,5 @@
1
+ import { enrichIosSessionError } from "./ios-session-error.js";
2
+ import { iosSigningCapabilities } from "./ios-signing.js";
1
3
  import { fetchTransport, WebDriverSession } from "./webdriver.js";
2
4
  const ANDROID_KEY_CODES = {
3
5
  enter: 66,
@@ -24,11 +26,16 @@ export class AppiumController {
24
26
  session;
25
27
  platform;
26
28
  target;
29
+ iosSigning;
30
+ wdaLocalPort;
31
+ resolvedWdaPort;
27
32
  sessionReady = null;
28
33
  scaling = null;
29
34
  constructor(opts) {
30
35
  this.platform = opts.platform;
31
36
  this.target = opts.target;
37
+ this.iosSigning = opts.iosSigning;
38
+ this.wdaLocalPort = opts.wdaLocalPort;
32
39
  const transport = opts.transport ?? fetchTransport(opts.endpoint);
33
40
  this.session = new WebDriverSession(transport);
34
41
  }
@@ -48,15 +55,28 @@ export class AppiumController {
48
55
  "appium:automationName": "XCUITest",
49
56
  "appium:udid": this.target,
50
57
  "appium:newCommandTimeout": 0,
58
+ // Unique local->device WDA port so a second connected phone doesn't collide on 8100.
59
+ ...(this.resolvedWdaPort ? { "appium:wdaLocalPort": this.resolvedWdaPort } : {}),
60
+ // WDA code-signing caps for physical iPhones (empty unless a team id is set).
61
+ ...(this.iosSigning ? iosSigningCapabilities(this.iosSigning) : {}),
51
62
  };
52
63
  }
53
64
  ensureSession() {
54
65
  if (!this.sessionReady) {
55
- this.sessionReady = this.session
56
- .createSession(this.capabilities())
57
- .then(() => undefined)
58
- .catch((err) => {
66
+ this.sessionReady = (async () => {
67
+ this.resolvedWdaPort =
68
+ typeof this.wdaLocalPort === "function" ? await this.wdaLocalPort() : this.wdaLocalPort;
69
+ await this.session.createSession(this.capabilities());
70
+ })().catch(async (err) => {
59
71
  this.sessionReady = null;
72
+ // A retry must re-pick a free port — the one resolved here may be taken by then.
73
+ this.resolvedWdaPort = undefined;
74
+ // XCUITest reports session-start failures generically ("xcodebuild failed
75
+ // with code 65"); enrich with the real reason from the WDA .xcresult and
76
+ // an actionable hint so the user isn't left with an opaque error.
77
+ if (this.platform === "ios") {
78
+ throw await enrichIosSessionError(err, this.iosSigning?.derivedDataPath);
79
+ }
60
80
  throw err;
61
81
  });
62
82
  }
@@ -0,0 +1,9 @@
1
+ import type { MobileController } from "./controller.js";
2
+ /**
3
+ * Re-signing can remap an app's bundle id (free teams can't always register the
4
+ * original id), but the Aximo backend keeps launching apps by their ORIGINAL id
5
+ * from the upload metadata. This wrapper translates launch targets through the
6
+ * original→installed map that app installs populate, so remapping stays
7
+ * invisible to the cloud side.
8
+ */
9
+ export declare function withBundleIdTranslation(controller: MobileController, map: ReadonlyMap<string, string>): MobileController;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Re-signing can remap an app's bundle id (free teams can't always register the
3
+ * original id), but the Aximo backend keeps launching apps by their ORIGINAL id
4
+ * from the upload metadata. This wrapper translates launch targets through the
5
+ * original→installed map that app installs populate, so remapping stays
6
+ * invisible to the cloud side.
7
+ */
8
+ export function withBundleIdTranslation(controller, map) {
9
+ const wrapped = {
10
+ screenshot: () => controller.screenshot(),
11
+ tap: (x, y) => controller.tap(x, y),
12
+ doubleTap: (x, y) => controller.doubleTap(x, y),
13
+ longPress: (x, y, durationMs) => controller.longPress(x, y, durationMs),
14
+ swipe: (x1, y1, x2, y2, durationMs) => controller.swipe(x1, y1, x2, y2, durationMs),
15
+ typeText: (text) => controller.typeText(text),
16
+ pressKey: (key) => controller.pressKey(key),
17
+ launchApp: (app) => controller.launchApp(map.get(app) ?? app),
18
+ findElement: (text) => controller.findElement(text),
19
+ clearText: () => controller.clearText(),
20
+ hideKeyboard: () => controller.hideKeyboard(),
21
+ close: () => controller.close(),
22
+ };
23
+ if (controller.getDeviceTime) {
24
+ wrapped.getDeviceTime = () => controller.getDeviceTime?.() ?? Promise.resolve(null);
25
+ }
26
+ return wrapped;
27
+ }
@@ -13,9 +13,14 @@ export interface IosPhysicalDevice {
13
13
  name: string;
14
14
  state?: string;
15
15
  }
16
+ /** Resolve an iOS udid: explicit (validated), else the single connected device. */
17
+ export declare function resolveIosUdid(preferred: string | undefined, exec?: ExecFn): Promise<string>;
16
18
  /**
17
- * List physically-connected iOS devices via `idb list-targets --json` (one JSON
18
- * object per line). Only entries whose `type` is `device` are returned. Returns
19
- * an empty list if idb is unavailable.
19
+ * List physically-connected iOS devices by MERGING devicectl and idb results
20
+ * (deduped by udid, devicectl wins). devicectl (CoreDevice) only sees iOS 17+
21
+ * devices, so an early-return on its results would make an iOS ≤16 phone vanish
22
+ * whenever a newer one is also attached — idb covers the older devices. idb is
23
+ * optional: not-installed means "no extra devices"; a real idb failure is only
24
+ * surfaced when devicectl found nothing (otherwise discovery still works).
20
25
  */
21
26
  export declare function listIosPhysicalDevices(exec?: ExecFn): Promise<IosPhysicalDevice[]>;
@@ -1,4 +1,5 @@
1
1
  import { execBinary } from "./exec.js";
2
+ import { listIosDevicesViaDevicectl } from "./ios-devicectl.js";
2
3
  /** Parse `adb devices -l` into serial + state (+ model when present). */
3
4
  export async function listAndroidDevices(exec = execBinary) {
4
5
  const { stdout } = await exec("adb", ["devices", "-l"]);
@@ -62,18 +63,69 @@ export async function resolveAndroidSerial(preferred, exec = execBinary) {
62
63
  .map((d) => d.serial)
63
64
  .join(", ")}`);
64
65
  }
66
+ /** Resolve an iOS udid: explicit (validated), else the single connected device. */
67
+ export async function resolveIosUdid(preferred, exec = execBinary) {
68
+ const devices = await listIosPhysicalDevices(exec);
69
+ if (preferred) {
70
+ const match = devices.find((d) => d.udid === preferred);
71
+ if (!match) {
72
+ throw new Error(`No iOS device with udid '${preferred}' is connected. ` +
73
+ "Connect it over USB, unlock it, and trust this computer.");
74
+ }
75
+ return match.udid;
76
+ }
77
+ if (devices.length === 1 && devices[0])
78
+ return devices[0].udid;
79
+ if (devices.length === 0) {
80
+ throw new Error("No connected iOS devices found. Connect an iPhone/iPad over USB, unlock it, and " +
81
+ "trust this computer. Verify with `xcrun devicectl list devices` (or `aximo mobile doctor`).");
82
+ }
83
+ throw new Error(`Multiple iOS devices found; pass --udid. Available: ${devices
84
+ .map((d) => `${d.name} (${d.udid})`)
85
+ .join(", ")}`);
86
+ }
87
+ /** True when a spawn failed because the binary isn't installed (ENOENT). */
88
+ function isCommandNotFound(err) {
89
+ if (err?.code === "ENOENT")
90
+ return true;
91
+ return err instanceof Error && /\bENOENT\b/.test(err.message);
92
+ }
65
93
  /**
66
- * List physically-connected iOS devices via `idb list-targets --json` (one JSON
67
- * object per line). Only entries whose `type` is `device` are returned. Returns
68
- * an empty list if idb is unavailable.
94
+ * List physically-connected iOS devices by MERGING devicectl and idb results
95
+ * (deduped by udid, devicectl wins). devicectl (CoreDevice) only sees iOS 17+
96
+ * devices, so an early-return on its results would make an iOS ≤16 phone vanish
97
+ * whenever a newer one is also attached — idb covers the older devices. idb is
98
+ * optional: not-installed means "no extra devices"; a real idb failure is only
99
+ * surfaced when devicectl found nothing (otherwise discovery still works).
69
100
  */
70
101
  export async function listIosPhysicalDevices(exec = execBinary) {
102
+ const byUdid = new Map();
103
+ for (const d of await listIosDevicesViaDevicectl(exec)) {
104
+ byUdid.set(d.udid, { udid: d.udid, name: d.name ?? d.model ?? d.udid, state: "connected" });
105
+ }
106
+ try {
107
+ for (const d of await listIosViaIdb(exec)) {
108
+ if (!byUdid.has(d.udid))
109
+ byUdid.set(d.udid, d);
110
+ }
111
+ }
112
+ catch (err) {
113
+ if (byUdid.size === 0)
114
+ throw err;
115
+ }
116
+ return [...byUdid.values()];
117
+ }
118
+ /** `idb list-targets --json` (one JSON object per line), physical devices only. */
119
+ async function listIosViaIdb(exec) {
71
120
  let stdout;
72
121
  try {
73
122
  ({ stdout } = await exec("idb", ["list-targets", "--json"]));
74
123
  }
75
- catch {
76
- return [];
124
+ catch (err) {
125
+ // idb not installed → "no iOS devices"; any other idb failure should surface.
126
+ if (isCommandNotFound(err))
127
+ return [];
128
+ throw err;
77
129
  }
78
130
  const result = [];
79
131
  for (const line of stdout.toString("utf-8").split("\n")) {
@@ -1,8 +1,16 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { listAndroidDevices, resolveAndroidSerial } from "./devices.js";
2
+ import { listAndroidDevices, listIosPhysicalDevices, resolveAndroidSerial, resolveIosUdid, } from "./devices.js";
3
3
  function fixed(stdout) {
4
4
  return async () => ({ stdout: Buffer.from(stdout), stderr: "" });
5
5
  }
6
+ function iosTargets(...devices) {
7
+ return async () => ({
8
+ stdout: Buffer.from(devices.map((d) => JSON.stringify(d)).join("\n")),
9
+ stderr: "",
10
+ });
11
+ }
12
+ const PHONE = { udid: "00008030-ABC", name: "iPhone", type: "device", state: "connected" };
13
+ const SIM = { udid: "SIM-1", name: "iPhone 15", type: "simulator", state: "Booted" };
6
14
  describe("listAndroidDevices / resolveAndroidSerial", () => {
7
15
  it("parses adb devices -l output", async () => {
8
16
  const exec = fixed("List of devices attached\nR1 device model:Pixel_7\nR2 unauthorized\n");
@@ -33,3 +41,35 @@ describe("listAndroidDevices / resolveAndroidSerial", () => {
33
41
  await expect(resolveAndroidSerial("R1", exec)).rejects.toThrow(/offline/i);
34
42
  });
35
43
  });
44
+ describe("listIosPhysicalDevices / resolveIosUdid", () => {
45
+ it("returns only physical devices (ignores simulators)", async () => {
46
+ const devices = await listIosPhysicalDevices(iosTargets(PHONE, SIM));
47
+ expect(devices).toEqual([{ udid: "00008030-ABC", name: "iPhone", state: "connected" }]);
48
+ });
49
+ it("returns an empty list when idb is not installed (ENOENT)", async () => {
50
+ const exec = async () => {
51
+ throw new Error("spawn idb ENOENT");
52
+ };
53
+ await expect(listIosPhysicalDevices(exec)).resolves.toEqual([]);
54
+ });
55
+ it("surfaces a real idb failure instead of masking it as no devices", async () => {
56
+ const exec = async () => {
57
+ throw new Error("idb list-targets --json failed: could not connect to companion");
58
+ };
59
+ await expect(listIosPhysicalDevices(exec)).rejects.toThrow(/companion/);
60
+ });
61
+ it("auto-selects the only connected device", async () => {
62
+ await expect(resolveIosUdid(undefined, iosTargets(PHONE))).resolves.toBe("00008030-ABC");
63
+ });
64
+ it("errors when no devices are connected", async () => {
65
+ await expect(resolveIosUdid(undefined, iosTargets())).rejects.toThrow(/no connected ios/i);
66
+ });
67
+ it("requires --udid when multiple devices are connected", async () => {
68
+ const second = { ...PHONE, udid: "00008030-DEF", name: "iPad" };
69
+ await expect(resolveIosUdid(undefined, iosTargets(PHONE, second))).rejects.toThrow(/--udid/);
70
+ });
71
+ it("validates an explicitly-passed udid", async () => {
72
+ await expect(resolveIosUdid("nope", iosTargets(PHONE))).rejects.toThrow(/no ios device/i);
73
+ await expect(resolveIosUdid("00008030-ABC", iosTargets(PHONE))).resolves.toBe("00008030-ABC");
74
+ });
75
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Boolean env-var toggle: set and not an explicit "off" value. Plain truthiness
3
+ * would treat AXIMO_IOS_SKIP_TUNNEL=0 or =false as enabled, which surprises users.
4
+ */
5
+ export declare function envFlag(env: NodeJS.ProcessEnv, name: string): boolean;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Boolean env-var toggle: set and not an explicit "off" value. Plain truthiness
3
+ * would treat AXIMO_IOS_SKIP_TUNNEL=0 or =false as enabled, which surprises users.
4
+ */
5
+ export function envFlag(env, name) {
6
+ const value = env[name]?.trim().toLowerCase();
7
+ if (!value)
8
+ return false;
9
+ return !["0", "false", "no", "off"].includes(value);
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { envFlag } from "./env-flags.js";
3
+ describe("envFlag", () => {
4
+ it("is false when unset or empty", () => {
5
+ expect(envFlag({}, "X")).toBe(false);
6
+ expect(envFlag({ X: "" }, "X")).toBe(false);
7
+ expect(envFlag({ X: " " }, "X")).toBe(false);
8
+ });
9
+ it("is true for ordinary truthy values", () => {
10
+ expect(envFlag({ X: "1" }, "X")).toBe(true);
11
+ expect(envFlag({ X: "true" }, "X")).toBe(true);
12
+ expect(envFlag({ X: "yes" }, "X")).toBe(true);
13
+ });
14
+ it("treats explicit off-values as false", () => {
15
+ expect(envFlag({ X: "0" }, "X")).toBe(false);
16
+ expect(envFlag({ X: "false" }, "X")).toBe(false);
17
+ expect(envFlag({ X: "FALSE" }, "X")).toBe(false);
18
+ expect(envFlag({ X: "no" }, "X")).toBe(false);
19
+ expect(envFlag({ X: "off" }, "X")).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,45 @@
1
+ import { listSigningIdentities } from "./ios-identity.js";
2
+ import { mintProfileViaWdaRig } from "./ios-mint.js";
3
+ import { listProvisioningProfiles } from "./ios-profiles.js";
4
+ import { inspectIpa, resignIpa } from "./ios-resign.js";
5
+ /**
6
+ * Zero-config auto-resign: make any dev/ad-hoc .ipa installable on the target
7
+ * device, mirroring what AWS Device Farm does to every upload.
8
+ *
9
+ * Decision ladder (first hit wins):
10
+ * 1. IPA already installs on this device → leave it alone.
11
+ * 2. A local profile covers the app's own bundle id + this UDID → re-sign,
12
+ * keeping the bundle id (wildcard/paid-team and rebuilt-locally cases).
13
+ * 3. Fall back to the dedicated Aximo resign App ID for this team — reuse its
14
+ * profile, or mint one via the Xcode automatic-signing rig (free teams) —
15
+ * and re-sign with the bundle id remapped. The caller gets the remapped id
16
+ * back so app launches can be translated.
17
+ *
18
+ * FairPlay-encrypted (App Store) binaries are rejected with a clear error; the
19
+ * whole step can be disabled with AXIMO_IOS_NO_RESIGN.
20
+ */
21
+ export interface AutoResignResult {
22
+ /** Path to install: the original IPA, or a re-signed temp copy. */
23
+ ipaPath: string;
24
+ /** True when `ipaPath` is a temp file the caller should delete after install. */
25
+ resigned: boolean;
26
+ originalBundleId: string;
27
+ /** Bundle id actually installed (differs from original when remapped). */
28
+ installedBundleId: string;
29
+ }
30
+ /** Stable per-team bundle id used for remapped resigns (minted once, reused). */
31
+ export declare function aximoResignBundleId(teamId: string): string;
32
+ export interface EnsureInstallableOptions {
33
+ ipaPath: string;
34
+ udid: string;
35
+ env?: NodeJS.ProcessEnv;
36
+ /** Progress reporting (console.error in the CLI; captured in tests). */
37
+ log?: (message: string) => void;
38
+ now?: number;
39
+ inspect?: typeof inspectIpa;
40
+ listIdentities?: typeof listSigningIdentities;
41
+ listProfiles?: typeof listProvisioningProfiles;
42
+ mint?: typeof mintProfileViaWdaRig;
43
+ resign?: typeof resignIpa;
44
+ }
45
+ export declare function ensureInstallableIpa(options: EnsureInstallableOptions): Promise<AutoResignResult>;