@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
@@ -6,6 +6,8 @@ import { join } from "node:path";
6
6
  import { formatSuccess, printResult } from "../../output.js";
7
7
  import { importAppium } from "../../mobile-control/appium-import.js";
8
8
  import { getAppiumHome, resolveAndroidSdk } from "../../mobile-control/appium-server.js";
9
+ import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
10
+ import { DEFAULT_TUNNEL_REGISTRY_PORT, isTunnelHealthy } from "../../mobile-control/ios-tunnel.js";
9
11
  function which(cmd) {
10
12
  const probe = platform() === "win32" ? "where" : "which";
11
13
  const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
@@ -77,7 +79,7 @@ function checkAndroid() {
77
79
  });
78
80
  return checks;
79
81
  }
80
- function checkIos() {
82
+ async function checkIos() {
81
83
  if (platform() !== "darwin") {
82
84
  return [
83
85
  { name: "ios", status: "info", message: "iOS device control is only available on macOS" },
@@ -97,25 +99,72 @@ function checkIos() {
97
99
  ? { name: "xcrun", status: "pass", message: xcrun }
98
100
  : {
99
101
  name: "xcrun",
100
- status: "warn",
102
+ status: "fail",
101
103
  message: "xcrun not found",
102
- hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode.",
104
+ hint: "Install Xcode command line tools: xcode-select --install. Appium's XCUITest driver delegates to Xcode and cannot run without it.",
105
+ });
106
+ const idb = which("idb");
107
+ checks.push(idb
108
+ ? { name: "idb", status: "info", message: idb }
109
+ : {
110
+ name: "idb",
111
+ status: "info",
112
+ message: "idb not found (optional)",
113
+ hint: "Device discovery and installs use Xcode's devicectl; idb is only a fallback. Install it with `brew install idb-companion && pipx install fb-idb` if devicectl can't see your device.",
114
+ });
115
+ // Zero-config signing: the identity + team come from the keychain; env vars
116
+ // (AXIMO_IOS_TEAM_ID etc.) are overrides, not requirements.
117
+ const signing = await resolveIosSigningAuto();
118
+ checks.push(signing.teamId
119
+ ? {
120
+ name: "ios signing",
121
+ status: "pass",
122
+ message: signing.identityHash
123
+ ? `signing identity found (team ${signing.teamId}, WDA id ${signing.wdaBundleId})`
124
+ : `signing team id set (${signing.teamId}) but no matching keychain identity`,
125
+ }
126
+ : {
127
+ name: "ios signing",
128
+ status: "warn",
129
+ message: "no Apple Development signing identity found",
130
+ hint: "Open Xcode → Settings → Accounts, add your Apple ID, then Manage Certificates → + → Apple Development. A free Apple ID works (with weekly re-signing); AXIMO_IOS_TEAM_ID overrides team selection on multi-team machines.",
103
131
  });
104
132
  checks.push({
105
133
  name: "ios webdriveragent",
106
134
  status: "info",
107
- message: "Physical iPhones are driven via Appium's XCUITest driver, which builds and installs a WebDriverAgent runner on the device.",
108
- hint: "WDA needs an Apple Developer signing identity; enable Developer Mode (Settings → Privacy & Security) and trust the device.",
135
+ message: "On iOS 17+ a prebuilt WebDriverAgent is downloaded, re-signed and preinstalled (no Xcode build); older devices fall back to the Xcode build flow.",
136
+ hint: "One-time on the phone: enable Developer Mode (Settings → Privacy & Security), trust this computer, and trust the developer certificate (Settings → General → VPN & Device Management) after the first install.",
109
137
  });
110
138
  return checks;
111
139
  }
140
+ // The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
141
+ // without a connected device. `connect` starts it automatically, so a missing
142
+ // tunnel is informational, not a failure.
143
+ export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
144
+ const running = await isHealthy(DEFAULT_TUNNEL_REGISTRY_PORT);
145
+ return running
146
+ ? {
147
+ name: "ios tunnel",
148
+ status: "pass",
149
+ message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
150
+ }
151
+ : {
152
+ name: "ios tunnel",
153
+ status: "info",
154
+ message: "RemoteXPC tunnel not running",
155
+ hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
156
+ };
157
+ }
112
158
  export async function runMobileDoctor(options = {}) {
113
159
  const checks = [await checkAppium()];
114
160
  if (options.platform !== "ios") {
115
161
  checks.push(...checkAndroid());
116
162
  }
117
163
  if (options.platform !== "android") {
118
- checks.push(...checkIos());
164
+ checks.push(...(await checkIos()));
165
+ if (platform() === "darwin") {
166
+ checks.push(await checkIosTunnel(options.isTunnelHealthy));
167
+ }
119
168
  }
120
169
  const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
121
170
  for (const c of checks)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkIosTunnel } from "./doctor.js";
3
+ describe("checkIosTunnel", () => {
4
+ it("passes when the RemoteXPC tunnel is reachable", async () => {
5
+ const check = await checkIosTunnel(async () => true);
6
+ expect(check.status).toBe("pass");
7
+ expect(check.name).toBe("ios tunnel");
8
+ });
9
+ it("is informational (not a failure) when the tunnel is down", async () => {
10
+ const check = await checkIosTunnel(async () => false);
11
+ expect(check.status).toBe("info");
12
+ expect(check.hint).toContain("aximo mobile connect");
13
+ });
14
+ });
@@ -7,6 +7,7 @@ export default defineCommand({
7
7
  subCommands: {
8
8
  list: () => import("./list.js").then((m) => m.default),
9
9
  register: () => import("./register.js").then((m) => m.default),
10
+ install: () => import("./install.js").then((m) => m.default),
10
11
  unregister: () => import("./unregister.js").then((m) => m.default),
11
12
  connect: () => import("./connect.js").then((m) => m.default),
12
13
  setup: () => import("./setup.js").then((m) => m.default),
@@ -0,0 +1,50 @@
1
+ import { type ExecFn } from "../../mobile-control/exec.js";
2
+ import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
3
+ import { installIosApp } from "../../mobile-control/ios-devicectl.js";
4
+ import type { Envelope } from "../../types.js";
5
+ import type { MobilePlatform } from "./paths.js";
6
+ export interface MobileInstallResult {
7
+ platform: MobilePlatform;
8
+ /** adb serial (Android). */
9
+ serial?: string;
10
+ /** device udid (iOS). */
11
+ udid?: string;
12
+ app: string;
13
+ output: string;
14
+ /** iOS only: set when the app was auto-re-signed for this device. */
15
+ resigned?: boolean;
16
+ originalBundleId?: string;
17
+ installedBundleId?: string;
18
+ }
19
+ export interface MobileInstallOptions {
20
+ app?: string | undefined;
21
+ platform?: string | undefined;
22
+ serial?: string | undefined;
23
+ udid?: string | undefined;
24
+ }
25
+ /**
26
+ * Install an app on the connected phone: Android `.apk` via `adb install -r`,
27
+ * iOS `.ipa` via devicectl — auto-re-signed for the device first when its
28
+ * signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
29
+ */
30
+ export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn, ensureInstallable?: typeof ensureInstallableIpa, installIos?: typeof installIosApp): Promise<Envelope<MobileInstallResult>>;
31
+ declare const _default: import("citty").CommandDef<{
32
+ readonly app: {
33
+ readonly type: "string";
34
+ readonly description: "Path to the .apk or .ipa to install";
35
+ readonly required: true;
36
+ };
37
+ readonly platform: {
38
+ readonly type: "string";
39
+ readonly description: "android | ios (inferred from the file extension)";
40
+ };
41
+ readonly serial: {
42
+ readonly type: "string";
43
+ readonly description: "Android adb serial (defaults to the connected one)";
44
+ };
45
+ readonly udid: {
46
+ readonly type: "string";
47
+ readonly description: "iOS device udid (defaults to the connected one)";
48
+ };
49
+ }>;
50
+ export default _default;
@@ -0,0 +1,107 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { extname, resolve } from "node:path";
4
+ import { defineCommand } from "citty";
5
+ import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
6
+ import { execBinary } from "../../mobile-control/exec.js";
7
+ import { ensureInstallableIpa } from "../../mobile-control/ios-auto-resign.js";
8
+ import { installIosApp } from "../../mobile-control/ios-devicectl.js";
9
+ import { mobileErrorHint } from "../../mobile-control/mobile-errors.js";
10
+ import { formatError, formatSuccess, printResult } from "../../output.js";
11
+ import { parsePlatformArg } from "./register-core.js";
12
+ function inferPlatform(appPath) {
13
+ const ext = extname(appPath).toLowerCase();
14
+ if (ext === ".apk")
15
+ return "android";
16
+ if (ext === ".ipa")
17
+ return "ios";
18
+ return null;
19
+ }
20
+ /**
21
+ * Install an app on the connected phone: Android `.apk` via `adb install -r`,
22
+ * iOS `.ipa` via devicectl — auto-re-signed for the device first when its
23
+ * signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
24
+ */
25
+ export async function runMobileInstall(options, exec = execBinary, ensureInstallable = ensureInstallableIpa, installIos = installIosApp) {
26
+ if (!options.app) {
27
+ return formatError("validation_error", "App path is required (--app <path-to-.apk|.ipa>).", false);
28
+ }
29
+ const appPath = resolve(options.app);
30
+ if (!existsSync(appPath)) {
31
+ return formatError("validation_error", `App not found: ${appPath}`, false);
32
+ }
33
+ const platform = options.platform ? parsePlatformArg(options.platform) : inferPlatform(appPath);
34
+ if (!platform) {
35
+ return formatError("validation_error", "Could not determine platform; pass --platform android|ios (or use a .apk/.ipa file).", false);
36
+ }
37
+ let target;
38
+ try {
39
+ target =
40
+ platform === "ios"
41
+ ? await resolveIosUdid(options.udid, exec)
42
+ : await resolveAndroidSerial(options.serial, exec);
43
+ }
44
+ catch (err) {
45
+ return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
46
+ }
47
+ try {
48
+ if (platform === "android") {
49
+ const { stdout } = await exec("adb", ["-s", target, "install", "-r", appPath]);
50
+ return formatSuccess({
51
+ platform,
52
+ serial: target,
53
+ app: appPath,
54
+ output: stdout.toString("utf-8").trim(),
55
+ });
56
+ }
57
+ const ensured = await ensureInstallable({
58
+ ipaPath: appPath,
59
+ udid: target,
60
+ log: (message) => console.error(message),
61
+ });
62
+ try {
63
+ const output = await installIos(target, ensured.ipaPath, exec);
64
+ return formatSuccess({
65
+ platform,
66
+ udid: target,
67
+ app: appPath,
68
+ output,
69
+ ...(ensured.resigned
70
+ ? {
71
+ resigned: true,
72
+ originalBundleId: ensured.originalBundleId,
73
+ installedBundleId: ensured.installedBundleId,
74
+ }
75
+ : {}),
76
+ });
77
+ }
78
+ finally {
79
+ if (ensured.resigned)
80
+ await rm(ensured.ipaPath, { force: true }).catch(() => { });
81
+ }
82
+ }
83
+ catch (err) {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ return formatError("api_error", message, false, mobileErrorHint(message, platform));
86
+ }
87
+ }
88
+ export default defineCommand({
89
+ meta: {
90
+ name: "install",
91
+ description: "Install an app on the connected phone (.apk via adb, .ipa auto-re-signed + devicectl)",
92
+ },
93
+ args: {
94
+ app: { type: "string", description: "Path to the .apk or .ipa to install", required: true },
95
+ platform: { type: "string", description: "android | ios (inferred from the file extension)" },
96
+ serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
97
+ udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
98
+ },
99
+ async run({ args }) {
100
+ printResult(await runMobileInstall({
101
+ app: args.app,
102
+ platform: args.platform,
103
+ serial: args.serial,
104
+ udid: args.udid,
105
+ }));
106
+ },
107
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runMobileInstall } from "./install.js";
6
+ const ONE_ANDROID = "List of devices attached\nR1 device\n";
7
+ const ONE_IOS = `${JSON.stringify({ udid: "00008030-ABC", name: "iPhone", type: "device" })}\n`;
8
+ let dir;
9
+ let apkPath;
10
+ let ipaPath;
11
+ let txtPath;
12
+ beforeAll(() => {
13
+ dir = mkdtempSync(join(tmpdir(), "aximo-mob-install-"));
14
+ apkPath = join(dir, "app.apk");
15
+ ipaPath = join(dir, "app.ipa");
16
+ txtPath = join(dir, "app.txt");
17
+ for (const p of [apkPath, ipaPath, txtPath])
18
+ writeFileSync(p, "x");
19
+ });
20
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
21
+ /** Mock adb/idb: serve device discovery and capture the `install` invocation. */
22
+ function execWith(opts = {}) {
23
+ let captured = { file: "", args: [] };
24
+ const exec = async (file, args) => {
25
+ if (file === "adb" && args[0] === "devices") {
26
+ return { stdout: Buffer.from(ONE_ANDROID), stderr: "" };
27
+ }
28
+ if (file === "idb" && args[0] === "list-targets") {
29
+ return { stdout: Buffer.from(ONE_IOS), stderr: "" };
30
+ }
31
+ if ((file === "adb" || file === "idb") && args.includes("install")) {
32
+ captured = { file, args };
33
+ if (opts.throwMsg)
34
+ throw new Error(opts.throwMsg);
35
+ return { stdout: Buffer.from(opts.out ?? "Success"), stderr: "" };
36
+ }
37
+ throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
38
+ };
39
+ return { exec, install: () => captured };
40
+ }
41
+ describe("runMobileInstall", () => {
42
+ it("requires --app", async () => {
43
+ const { exec } = execWith();
44
+ const res = await runMobileInstall({}, exec);
45
+ expect(res.ok).toBe(false);
46
+ if (!res.ok)
47
+ expect(res.error.code).toBe("validation_error");
48
+ });
49
+ it("errors when the app file is missing", async () => {
50
+ const { exec } = execWith();
51
+ const res = await runMobileInstall({ app: "definitely-not-here-xyz.apk" }, exec);
52
+ expect(res.ok).toBe(false);
53
+ if (!res.ok)
54
+ expect(res.error.message).toMatch(/not found/i);
55
+ });
56
+ it("errors when the platform cannot be inferred", async () => {
57
+ const { exec } = execWith();
58
+ const res = await runMobileInstall({ app: txtPath }, exec);
59
+ expect(res.ok).toBe(false);
60
+ if (!res.ok)
61
+ expect(res.error.message).toMatch(/determine platform/i);
62
+ });
63
+ it("installs an .apk via `adb -s <serial> install -r`", async () => {
64
+ const { exec, install } = execWith({ out: "Performing Streamed Install\nSuccess" });
65
+ const res = await runMobileInstall({ app: apkPath }, exec);
66
+ expect(res.ok).toBe(true);
67
+ if (res.ok) {
68
+ expect(res.data.platform).toBe("android");
69
+ expect(res.data.serial).toBe("R1");
70
+ expect(res.data.udid).toBeUndefined();
71
+ expect(res.data.output).toContain("Success");
72
+ }
73
+ expect(install().file).toBe("adb");
74
+ expect(install().args.slice(0, 2)).toEqual(["-s", "R1"]);
75
+ expect(install().args).toEqual(expect.arrayContaining(["install", "-r"]));
76
+ });
77
+ it("installs an .ipa via devicectl, auto-resigning when needed", async () => {
78
+ const { exec } = execWith();
79
+ const ensureInstallable = vi.fn(async (_opts) => ({
80
+ ipaPath: join(dir, "app-resigned.ipa"),
81
+ resigned: true,
82
+ originalBundleId: "com.example.app",
83
+ installedBundleId: "com.aximo.resign.team.xctrunner",
84
+ }));
85
+ const installIos = vi.fn(async () => "App installed:");
86
+ const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
87
+ expect(res.ok).toBe(true);
88
+ if (res.ok) {
89
+ expect(res.data.platform).toBe("ios");
90
+ expect(res.data.udid).toBe("00008030-ABC");
91
+ expect(res.data.serial).toBeUndefined();
92
+ expect(res.data.resigned).toBe(true);
93
+ expect(res.data.originalBundleId).toBe("com.example.app");
94
+ expect(res.data.installedBundleId).toBe("com.aximo.resign.team.xctrunner");
95
+ }
96
+ expect(vi.mocked(ensureInstallable).mock.calls[0]?.[0]).toMatchObject({
97
+ ipaPath,
98
+ udid: "00008030-ABC",
99
+ });
100
+ expect(installIos).toHaveBeenCalledWith("00008030-ABC", join(dir, "app-resigned.ipa"), exec);
101
+ });
102
+ it("omits resign metadata when the .ipa was already installable", async () => {
103
+ const { exec } = execWith();
104
+ const ensureInstallable = vi.fn(async () => ({
105
+ ipaPath,
106
+ resigned: false,
107
+ originalBundleId: "com.example.app",
108
+ installedBundleId: "com.example.app",
109
+ }));
110
+ const installIos = vi.fn(async () => "App installed:");
111
+ const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
112
+ expect(res.ok).toBe(true);
113
+ if (res.ok)
114
+ expect(res.data.resigned).toBeUndefined();
115
+ });
116
+ it("maps an iOS resign failure to an error envelope with hint", async () => {
117
+ const { exec } = execWith();
118
+ const ensureInstallable = vi.fn(async () => {
119
+ throw new Error("app.ipa is FairPlay-encrypted (an App Store download)");
120
+ });
121
+ const installIos = vi.fn(async () => "");
122
+ const res = await runMobileInstall({ app: ipaPath }, exec, ensureInstallable, installIos);
123
+ expect(res.ok).toBe(false);
124
+ if (!res.ok)
125
+ expect(res.error.message).toMatch(/FairPlay/);
126
+ });
127
+ it("honors an explicit --platform override", async () => {
128
+ const { exec, install } = execWith();
129
+ const res = await runMobileInstall({ app: txtPath, platform: "android" }, exec);
130
+ expect(res.ok).toBe(true);
131
+ expect(install().file).toBe("adb");
132
+ });
133
+ it("maps an install failure to an error envelope", async () => {
134
+ const { exec } = execWith({ throwMsg: "adb: failed to install: INSTALL_FAILED_INVALID_APK" });
135
+ const res = await runMobileInstall({ app: apkPath }, exec);
136
+ expect(res.ok).toBe(false);
137
+ if (!res.ok) {
138
+ expect(res.error.code).toBe("api_error");
139
+ expect(res.error.message).toMatch(/INSTALL_FAILED/);
140
+ }
141
+ });
142
+ });
@@ -4,6 +4,8 @@ import type { ExecFn } from "../../mobile-control/exec.js";
4
4
  export interface MobileListResult {
5
5
  androidDevices: AndroidDevice[];
6
6
  iosDevices: IosPhysicalDevice[];
7
+ /** Per-toolchain discovery failures (broken adb/idb) — an empty list alone is ambiguous. */
8
+ warnings?: string[];
7
9
  }
8
10
  export declare function runMobileList(exec?: ExecFn): Promise<Envelope<MobileListResult>>;
9
11
  declare const _default: import("citty").CommandDef<{}>;
@@ -1,22 +1,28 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { formatSuccess, printResult } from "../../output.js";
3
3
  import { listAndroidDevices, listIosPhysicalDevices, } from "../../mobile-control/devices.js";
4
- async function safe(fn, fallback) {
5
- try {
6
- return await fn();
7
- }
8
- catch {
9
- return fallback;
10
- }
11
- }
12
4
  export async function runMobileList(exec) {
5
+ const warnings = [];
6
+ const safe = async (label, fn, fallback) => {
7
+ try {
8
+ return await fn();
9
+ }
10
+ catch (err) {
11
+ warnings.push(`${label}: ${err instanceof Error ? err.message : String(err)}`);
12
+ return fallback;
13
+ }
14
+ };
13
15
  const [allAndroid, iosDevices] = await Promise.all([
14
- safe(() => listAndroidDevices(exec), []),
15
- safe(() => listIosPhysicalDevices(exec), []),
16
+ safe("android (adb)", () => listAndroidDevices(exec), []),
17
+ safe("ios (idb)", () => listIosPhysicalDevices(exec), []),
16
18
  ]);
17
19
  // Physical phones only: drop emulator serials.
18
20
  const androidDevices = allAndroid.filter((d) => !d.serial.startsWith("emulator-"));
19
- return formatSuccess({ androidDevices, iosDevices });
21
+ return formatSuccess({
22
+ androidDevices,
23
+ iosDevices,
24
+ ...(warnings.length > 0 ? { warnings } : {}),
25
+ });
20
26
  }
21
27
  export default defineCommand({
22
28
  meta: {
@@ -6,6 +6,7 @@ export interface MobileRegisterCmdOptions {
6
6
  name: string;
7
7
  platform: string;
8
8
  serial?: string | undefined;
9
+ udid?: string | undefined;
9
10
  os?: string | undefined;
10
11
  }
11
12
  export declare function runMobileRegister(options: MobileRegisterCmdOptions, deps?: ClientDeps, exec?: ExecFn): Promise<Envelope<MobileRegisterResult>>;
@@ -17,13 +18,17 @@ declare const _default: import("citty").CommandDef<{
17
18
  };
18
19
  readonly platform: {
19
20
  readonly type: "string";
20
- readonly description: "Device platform (only `android` is supported today)";
21
+ readonly description: "Device platform (`android` or `ios`)";
21
22
  readonly default: "android";
22
23
  };
23
24
  readonly serial: {
24
25
  readonly type: "string";
25
26
  readonly description: "Android adb serial (defaults to the connected one)";
26
27
  };
28
+ readonly udid: {
29
+ readonly type: "string";
30
+ readonly description: "iOS device udid (defaults to the connected one)";
31
+ };
27
32
  readonly os: {
28
33
  readonly type: "string";
29
34
  readonly description: "Optional OS version label";
@@ -1,19 +1,23 @@
1
1
  import { defineCommand } from "citty";
2
2
  import {} from "../../client.js";
3
3
  import { formatError, printResult } from "../../output.js";
4
- import { resolveAndroidSerial } from "../../mobile-control/devices.js";
4
+ import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
5
5
  import { parsePlatformArg, registerMobileDevice, } from "./register-core.js";
6
6
  export async function runMobileRegister(options, deps, exec) {
7
7
  const platform = parsePlatformArg(options.platform);
8
- if (platform !== "android") {
9
- return formatError("validation_error", "Only --platform android is supported (iOS local devices are coming in a future release).", false);
8
+ if (!platform) {
9
+ return formatError("validation_error", "Invalid --platform; expected `android` or `ios`.", false);
10
10
  }
11
11
  if (!options.name) {
12
12
  return formatError("validation_error", "Device name is required (--name).", false);
13
13
  }
14
+ // Resolve the device target: an adb serial for Android, a udid for iOS.
14
15
  let target;
15
16
  try {
16
- target = await resolveAndroidSerial(options.serial, exec);
17
+ target =
18
+ platform === "ios"
19
+ ? await resolveIosUdid(options.udid, exec)
20
+ : await resolveAndroidSerial(options.serial, exec);
17
21
  }
18
22
  catch (err) {
19
23
  return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
@@ -28,16 +32,17 @@ export async function runMobileRegister(options, deps, exec) {
28
32
  export default defineCommand({
29
33
  meta: {
30
34
  name: "register",
31
- description: "Register a physically-connected Android phone as an Aximo device",
35
+ description: "Register a physically-connected Android phone or iPhone as an Aximo device",
32
36
  },
33
37
  args: {
34
38
  name: { type: "string", description: "Device name", required: true },
35
39
  platform: {
36
40
  type: "string",
37
- description: "Device platform (only `android` is supported today)",
41
+ description: "Device platform (`android` or `ios`)",
38
42
  default: "android",
39
43
  },
40
44
  serial: { type: "string", description: "Android adb serial (defaults to the connected one)" },
45
+ udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
41
46
  os: { type: "string", description: "Optional OS version label" },
42
47
  },
43
48
  async run({ args }) {
@@ -45,6 +50,7 @@ export default defineCommand({
45
50
  name: args.name,
46
51
  platform: args.platform,
47
52
  serial: args.serial,
53
+ udid: args.udid,
48
54
  os: args.os,
49
55
  }));
50
56
  },
@@ -26,6 +26,20 @@ const oneAndroidDevice = async () => ({
26
26
  stdout: Buffer.from("List of devices attached\nR58M12345 device model:Pixel_7\n"),
27
27
  stderr: "",
28
28
  });
29
+ const oneIosDevice = async (file, args) => {
30
+ if (file === "idb" && args[0] === "list-targets") {
31
+ return {
32
+ stdout: Buffer.from(`${JSON.stringify({
33
+ udid: "00008030-ABCDEF",
34
+ name: "Abdul's iPhone",
35
+ type: "device",
36
+ state: "connected",
37
+ })}\n`),
38
+ stderr: "",
39
+ };
40
+ }
41
+ throw new Error(`unexpected exec: ${file} ${args.join(" ")}`);
42
+ };
29
43
  describe("runMobileRegister", () => {
30
44
  it("posts the resolved android serial and writes config", async () => {
31
45
  let body;
@@ -49,12 +63,29 @@ describe("runMobileRegister", () => {
49
63
  expect(saved).toMatchObject({ platform: "android", target: "R58M12345" });
50
64
  expect(saved).not.toHaveProperty("category");
51
65
  });
52
- it("rejects iOS registration as unsupported", async () => {
53
- const result = await runMobileRegister({ name: "iPhone", platform: "ios" });
66
+ it("posts the resolved iOS udid and writes config", async () => {
67
+ let body;
68
+ server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, async ({ request }) => {
69
+ body = (await request.json());
70
+ return HttpResponse.json({ deviceId: "dev-ios", deviceToken: "tok-ios" }, { status: 201 });
71
+ }));
72
+ const result = await runMobileRegister({ name: "iPhone", platform: "ios" }, undefined, oneIosDevice);
73
+ expect(result.ok).toBe(true);
74
+ if (!result.ok)
75
+ return;
76
+ expect(body).toMatchObject({ name: "iPhone", platform: "ios" });
77
+ expect(result.data.target).toBe("00008030-ABCDEF");
78
+ const configPath = join(tempHome, "mobile-devices", "dev-ios.json");
79
+ expect(existsSync(configPath)).toBe(true);
80
+ const saved = JSON.parse(readFileSync(configPath, "utf-8"));
81
+ expect(saved).toMatchObject({ platform: "ios", target: "00008030-ABCDEF" });
82
+ });
83
+ it("rejects an unknown platform", async () => {
84
+ const result = await runMobileRegister({ name: "x", platform: "windows" });
54
85
  expect(result.ok).toBe(false);
55
86
  if (!result.ok) {
56
87
  expect(result.error.code).toBe("validation_error");
57
- expect(result.error.message).toContain("Only --platform android is supported");
88
+ expect(result.error.message).toContain("Invalid --platform");
58
89
  }
59
90
  });
60
91
  });
@@ -12,15 +12,20 @@ export async function runMobileUnregister(options, deps) {
12
12
  const { deviceId } = resolved.config;
13
13
  try {
14
14
  await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
15
- const configPath = getMobileDeviceConfigPath(deviceId);
16
- if (existsSync(configPath)) {
17
- unlinkSync(configPath);
18
- }
19
- return formatSuccess({ success: true });
20
15
  }
21
16
  catch (err) {
22
- return handleApiError(err);
17
+ // 404 = the server row is already gone (deleted via the web UI). Still clear
18
+ // the local config, otherwise the device can never be unregistered locally.
19
+ const status = err.status;
20
+ if (status !== 404) {
21
+ return handleApiError(err);
22
+ }
23
+ }
24
+ const configPath = getMobileDeviceConfigPath(deviceId);
25
+ if (existsSync(configPath)) {
26
+ unlinkSync(configPath);
23
27
  }
28
+ return formatSuccess({ success: true });
24
29
  }
25
30
  export default defineCommand({
26
31
  meta: { name: "unregister", description: "Unregister a local mobile device" },