@autifyhq/aximo-cli 0.6.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 (96) hide show
  1. package/README.md +88 -70
  2. package/dist/client.js +16 -1
  3. package/dist/commands/mobile/connect-core.d.ts +2 -1
  4. package/dist/commands/mobile/connect-core.js +112 -9
  5. package/dist/commands/mobile/connect.js +6 -3
  6. package/dist/commands/mobile/connect.test.js +7 -1
  7. package/dist/commands/mobile/doctor.d.ts +4 -0
  8. package/dist/commands/mobile/doctor.js +62 -16
  9. package/dist/commands/mobile/doctor.test.d.ts +1 -0
  10. package/dist/commands/mobile/doctor.test.js +28 -0
  11. package/dist/commands/mobile/install.d.ts +10 -2
  12. package/dist/commands/mobile/install.js +57 -15
  13. package/dist/commands/mobile/install.test.js +44 -6
  14. package/dist/commands/mobile/list.d.ts +3 -0
  15. package/dist/commands/mobile/list.js +40 -14
  16. package/dist/commands/mobile/list.test.d.ts +1 -0
  17. package/dist/commands/mobile/list.test.js +24 -0
  18. package/dist/commands/mobile/output.d.ts +13 -0
  19. package/dist/commands/mobile/output.js +21 -0
  20. package/dist/commands/mobile/output.test.d.ts +1 -0
  21. package/dist/commands/mobile/output.test.js +54 -0
  22. package/dist/commands/mobile/register.d.ts +1 -0
  23. package/dist/commands/mobile/register.js +11 -3
  24. package/dist/commands/mobile/setup.d.ts +1 -0
  25. package/dist/commands/mobile/setup.js +7 -2
  26. package/dist/commands/mobile/unregister.d.ts +7 -3
  27. package/dist/commands/mobile/unregister.js +20 -9
  28. package/dist/mobile-control/app-install.d.ts +18 -5
  29. package/dist/mobile-control/app-install.js +33 -6
  30. package/dist/mobile-control/app-install.test.js +42 -7
  31. package/dist/mobile-control/appium-controller.d.ts +8 -0
  32. package/dist/mobile-control/appium-controller.js +19 -4
  33. package/dist/mobile-control/bundle-id-map.d.ts +9 -0
  34. package/dist/mobile-control/bundle-id-map.js +27 -0
  35. package/dist/mobile-control/devices.d.ts +6 -3
  36. package/dist/mobile-control/devices.js +26 -4
  37. package/dist/mobile-control/env-flags.d.ts +5 -0
  38. package/dist/mobile-control/env-flags.js +10 -0
  39. package/dist/mobile-control/env-flags.test.d.ts +1 -0
  40. package/dist/mobile-control/env-flags.test.js +21 -0
  41. package/dist/mobile-control/exec.d.ts +5 -1
  42. package/dist/mobile-control/exec.js +2 -2
  43. package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
  44. package/dist/mobile-control/ios-auto-resign.js +108 -0
  45. package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
  46. package/dist/mobile-control/ios-auto-resign.test.js +150 -0
  47. package/dist/mobile-control/ios-devicectl.d.ts +39 -0
  48. package/dist/mobile-control/ios-devicectl.js +87 -0
  49. package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
  50. package/dist/mobile-control/ios-devicectl.test.js +110 -0
  51. package/dist/mobile-control/ios-identity.d.ts +27 -0
  52. package/dist/mobile-control/ios-identity.js +49 -0
  53. package/dist/mobile-control/ios-identity.test.d.ts +1 -0
  54. package/dist/mobile-control/ios-identity.test.js +102 -0
  55. package/dist/mobile-control/ios-mint.d.ts +39 -0
  56. package/dist/mobile-control/ios-mint.js +78 -0
  57. package/dist/mobile-control/ios-mint.test.d.ts +1 -0
  58. package/dist/mobile-control/ios-mint.test.js +94 -0
  59. package/dist/mobile-control/ios-profiles.d.ts +46 -0
  60. package/dist/mobile-control/ios-profiles.js +106 -0
  61. package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
  62. package/dist/mobile-control/ios-profiles.test.js +154 -0
  63. package/dist/mobile-control/ios-resign.d.ts +37 -0
  64. package/dist/mobile-control/ios-resign.js +101 -0
  65. package/dist/mobile-control/ios-resign.test.d.ts +1 -0
  66. package/dist/mobile-control/ios-resign.test.js +84 -0
  67. package/dist/mobile-control/ios-session-error.d.ts +10 -0
  68. package/dist/mobile-control/ios-session-error.js +34 -0
  69. package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
  70. package/dist/mobile-control/ios-session-error.test.js +30 -0
  71. package/dist/mobile-control/ios-signing.d.ts +33 -5
  72. package/dist/mobile-control/ios-signing.js +56 -0
  73. package/dist/mobile-control/ios-signing.test.js +87 -0
  74. package/dist/mobile-control/ios-tunnel.d.ts +57 -0
  75. package/dist/mobile-control/ios-tunnel.js +150 -0
  76. package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
  77. package/dist/mobile-control/ios-tunnel.test.js +120 -0
  78. package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
  79. package/dist/mobile-control/ios-wda-cache.js +157 -0
  80. package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
  81. package/dist/mobile-control/ios-wda-cache.test.js +128 -0
  82. package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
  83. package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
  84. package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
  85. package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
  86. package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
  87. package/dist/mobile-control/ios-wda-preinstall.js +165 -0
  88. package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
  89. package/dist/mobile-control/ios-wda-preinstall.test.js +111 -0
  90. package/dist/mobile-control/mobile-errors.d.ts +12 -0
  91. package/dist/mobile-control/mobile-errors.js +91 -0
  92. package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
  93. package/dist/mobile-control/mobile-errors.test.js +48 -0
  94. package/dist/mobile-control/tunnel.js +79 -2
  95. package/dist/mobile-control/tunnel.test.js +82 -0
  96. package/package.json +2 -1
@@ -3,10 +3,12 @@ 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
- import { resolveIosSigning } from "../../mobile-control/ios-signing.js";
10
+ import { resolveIosSigningAuto } from "../../mobile-control/ios-signing.js";
11
+ import { DEFAULT_TUNNEL_REGISTRY_PORT, isTunnelHealthy } from "../../mobile-control/ios-tunnel.js";
10
12
  function which(cmd) {
11
13
  const probe = platform() === "win32" ? "where" : "which";
12
14
  const result = spawnSync(probe, [cmd], { encoding: "utf-8" });
@@ -78,7 +80,7 @@ function checkAndroid() {
78
80
  });
79
81
  return checks;
80
82
  }
81
- function checkIos() {
83
+ async function checkIos() {
82
84
  if (platform() !== "darwin") {
83
85
  return [
84
86
  { name: "ios", status: "info", message: "iOS device control is only available on macOS" },
@@ -104,43 +106,87 @@ function checkIos() {
104
106
  });
105
107
  const idb = which("idb");
106
108
  checks.push(idb
107
- ? { name: "idb", status: "pass", message: idb }
109
+ ? { name: "idb", status: "info", message: idb }
108
110
  : {
109
111
  name: "idb",
110
- status: "warn",
111
- message: "idb not found",
112
- hint: "Install idb (brew install idb-companion && pipx install fb-idb). It's used to discover connected iPhones and install .ipa builds.",
112
+ status: "info",
113
+ message: "idb not found (optional)",
114
+ 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.",
113
115
  });
114
- const teamId = resolveIosSigning().teamId;
115
- checks.push(teamId
116
- ? { name: "ios signing", status: "pass", message: `signing team id is set (${teamId})` }
116
+ // Zero-config signing: the identity + team come from the keychain; env vars
117
+ // (AXIMO_IOS_TEAM_ID etc.) are overrides, not requirements.
118
+ const signing = await resolveIosSigningAuto();
119
+ checks.push(signing.teamId
120
+ ? {
121
+ name: "ios signing",
122
+ status: "pass",
123
+ message: signing.identityHash
124
+ ? `signing identity found (team ${signing.teamId}, WDA id ${signing.wdaBundleId})`
125
+ : `signing team id set (${signing.teamId}) but no matching keychain identity`,
126
+ }
117
127
  : {
118
128
  name: "ios signing",
119
129
  status: "warn",
120
- message: "no signing team id is set",
121
- hint: "Driving a physical iPhone needs an Apple Developer Team ID to sign WebDriverAgent. Set AXIMO_IOS_TEAM_ID (or MobileLink's XCODE_TEAM_ID), and optionally AXIMO_IOS_SIGNING_ID / AXIMO_IOS_WDA_BUNDLE_ID.",
130
+ message: "no Apple Development signing identity found",
131
+ 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.",
122
132
  });
123
133
  checks.push({
124
134
  name: "ios webdriveragent",
125
135
  status: "info",
126
- message: "Physical iPhones are driven via Appium's XCUITest driver, which builds and installs a WebDriverAgent runner on the device.",
127
- hint: "Enable Developer Mode (Settings → Privacy & Security), trust this computer, and ensure the WDA bundle id is backed by a valid provisioning profile.",
136
+ 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.",
137
+ 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.",
128
138
  });
129
139
  return checks;
130
140
  }
141
+ // The RemoteXPC tunnel is host-level (a registry port), so doctor can probe it
142
+ // without a connected device. `connect` starts it automatically, so a missing
143
+ // tunnel is informational, not a failure.
144
+ export async function checkIosTunnel(isHealthy = isTunnelHealthy) {
145
+ const running = await isHealthy(DEFAULT_TUNNEL_REGISTRY_PORT);
146
+ return running
147
+ ? {
148
+ name: "ios tunnel",
149
+ status: "pass",
150
+ message: `RemoteXPC tunnel running (registry port ${DEFAULT_TUNNEL_REGISTRY_PORT})`,
151
+ }
152
+ : {
153
+ name: "ios tunnel",
154
+ status: "info",
155
+ message: "RemoteXPC tunnel not running",
156
+ hint: "iOS 17+ needs it to reach WebDriverAgent; `aximo mobile connect` starts it automatically (prompts for admin).",
157
+ };
158
+ }
131
159
  export async function runMobileDoctor(options = {}) {
132
160
  const checks = [await checkAppium()];
133
161
  if (options.platform !== "ios") {
134
162
  checks.push(...checkAndroid());
135
163
  }
136
164
  if (options.platform !== "android") {
137
- checks.push(...checkIos());
165
+ checks.push(...(await checkIos()));
166
+ if (platform() === "darwin") {
167
+ checks.push(await checkIosTunnel(options.isTunnelHealthy));
168
+ }
138
169
  }
139
170
  const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
140
171
  for (const c of checks)
141
172
  summary[c.status]++;
142
173
  return formatSuccess({ checks, summary });
143
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
+ }
144
190
  export default defineCommand({
145
191
  meta: {
146
192
  name: "doctor",
@@ -154,7 +200,7 @@ export default defineCommand({
154
200
  if (args.platform === "ios" || args.platform === "android")
155
201
  options.platform = args.platform;
156
202
  const result = await runMobileDoctor(options);
157
- printResult(result);
203
+ printMobileResult(result, { render: renderDoctorReport });
158
204
  if (result.ok && result.data.summary.fail > 0) {
159
205
  process.exitCode = 1;
160
206
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkIosTunnel, renderDoctorReport } 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
+ });
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
+ });
@@ -1,4 +1,6 @@
1
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";
2
4
  import type { Envelope } from "../../types.js";
3
5
  import type { MobilePlatform } from "./paths.js";
4
6
  export interface MobileInstallResult {
@@ -9,6 +11,10 @@ export interface MobileInstallResult {
9
11
  udid?: string;
10
12
  app: string;
11
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;
12
18
  }
13
19
  export interface MobileInstallOptions {
14
20
  app?: string | undefined;
@@ -18,9 +24,11 @@ export interface MobileInstallOptions {
18
24
  }
19
25
  /**
20
26
  * Install an app on the connected phone: Android `.apk` via `adb install -r`,
21
- * iOS `.ipa` via `idb install`. The iOS build must be signed for the device.
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).
22
29
  */
23
- export declare function runMobileInstall(options: MobileInstallOptions, exec?: ExecFn): Promise<Envelope<MobileInstallResult>>;
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;
24
32
  declare const _default: import("citty").CommandDef<{
25
33
  readonly app: {
26
34
  readonly type: "string";
@@ -1,9 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
2
3
  import { extname, resolve } from "node:path";
3
4
  import { defineCommand } from "citty";
4
5
  import { resolveAndroidSerial, resolveIosUdid } from "../../mobile-control/devices.js";
5
6
  import { execBinary } from "../../mobile-control/exec.js";
6
- import { formatError, formatSuccess, printResult } from "../../output.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 } from "../../output.js";
11
+ import { printMobileResult } from "./output.js";
7
12
  import { parsePlatformArg } from "./register-core.js";
8
13
  function inferPlatform(appPath) {
9
14
  const ext = extname(appPath).toLowerCase();
@@ -15,9 +20,10 @@ function inferPlatform(appPath) {
15
20
  }
16
21
  /**
17
22
  * Install an app on the connected phone: Android `.apk` via `adb install -r`,
18
- * iOS `.ipa` via `idb install`. The iOS build must be signed for the device.
23
+ * iOS `.ipa` via devicectl — auto-re-signed for the device first when its
24
+ * signature doesn't already cover it (disable with AXIMO_IOS_NO_RESIGN).
19
25
  */
20
- export async function runMobileInstall(options, exec = execBinary) {
26
+ export async function runMobileInstall(options, exec = execBinary, ensureInstallable = ensureInstallableIpa, installIos = installIosApp) {
21
27
  if (!options.app) {
22
28
  return formatError("validation_error", "App path is required (--app <path-to-.apk|.ipa>).", false);
23
29
  }
@@ -40,24 +46,60 @@ export async function runMobileInstall(options, exec = execBinary) {
40
46
  return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
41
47
  }
42
48
  try {
43
- const { stdout } = platform === "ios"
44
- ? await exec("idb", ["install", "--udid", target, appPath])
45
- : await exec("adb", ["-s", target, "install", "-r", appPath]);
46
- return formatSuccess({
47
- platform,
48
- ...(platform === "ios" ? { udid: target } : { serial: target }),
49
- app: appPath,
50
- output: stdout.toString("utf-8").trim(),
49
+ if (platform === "android") {
50
+ const { stdout } = await exec("adb", ["-s", target, "install", "-r", appPath]);
51
+ return formatSuccess({
52
+ platform,
53
+ serial: target,
54
+ app: appPath,
55
+ output: stdout.toString("utf-8").trim(),
56
+ });
57
+ }
58
+ const ensured = await ensureInstallable({
59
+ ipaPath: appPath,
60
+ udid: target,
61
+ log: (message) => console.error(message),
51
62
  });
63
+ try {
64
+ const output = await installIos(target, ensured.ipaPath, exec);
65
+ return formatSuccess({
66
+ platform,
67
+ udid: target,
68
+ app: appPath,
69
+ output,
70
+ ...(ensured.resigned
71
+ ? {
72
+ resigned: true,
73
+ originalBundleId: ensured.originalBundleId,
74
+ installedBundleId: ensured.installedBundleId,
75
+ }
76
+ : {}),
77
+ });
78
+ }
79
+ finally {
80
+ if (ensured.resigned)
81
+ await rm(ensured.ipaPath, { force: true }).catch(() => { });
82
+ }
52
83
  }
53
84
  catch (err) {
54
- return formatError("api_error", err instanceof Error ? err.message : String(err), false);
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ return formatError("api_error", message, false, mobileErrorHint(message, platform));
55
87
  }
56
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
+ }
57
99
  export default defineCommand({
58
100
  meta: {
59
101
  name: "install",
60
- description: "Install an app on the connected phone (.apk via adb, .ipa via idb)",
102
+ description: "Install an app on the connected phone (.apk via adb, .ipa auto-re-signed + devicectl)",
61
103
  },
62
104
  args: {
63
105
  app: { type: "string", description: "Path to the .apk or .ipa to install", required: true },
@@ -66,11 +108,11 @@ export default defineCommand({
66
108
  udid: { type: "string", description: "iOS device udid (defaults to the connected one)" },
67
109
  },
68
110
  async run({ args }) {
69
- printResult(await runMobileInstall({
111
+ printMobileResult(await runMobileInstall({
70
112
  app: args.app,
71
113
  platform: args.platform,
72
114
  serial: args.serial,
73
115
  udid: args.udid,
74
- }));
116
+ }), { render: renderInstallResult });
75
117
  },
76
118
  });
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -74,17 +74,55 @@ describe("runMobileInstall", () => {
74
74
  expect(install().args.slice(0, 2)).toEqual(["-s", "R1"]);
75
75
  expect(install().args).toEqual(expect.arrayContaining(["install", "-r"]));
76
76
  });
77
- it("installs an .ipa via `idb install --udid <udid>`", async () => {
78
- const { exec, install } = execWith({ out: "Installed" });
79
- const res = await runMobileInstall({ app: ipaPath }, exec);
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);
80
87
  expect(res.ok).toBe(true);
81
88
  if (res.ok) {
82
89
  expect(res.data.platform).toBe("ios");
83
90
  expect(res.data.udid).toBe("00008030-ABC");
84
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");
85
95
  }
86
- expect(install().file).toBe("idb");
87
- expect(install().args).toEqual(["install", "--udid", "00008030-ABC", ipaPath]);
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/);
88
126
  });
89
127
  it("honors an explicit --platform override", async () => {
90
128
  const { exec, install } = execWith();
@@ -4,7 +4,10 @@ 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>>;
11
+ export declare function renderMobileList(data: MobileListResult): string;
9
12
  declare const _default: import("citty").CommandDef<{}>;
10
13
  export default _default;
@@ -1,30 +1,56 @@
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
- async function safe(fn, fallback) {
5
- try {
6
- return await fn();
7
- }
8
- catch {
9
- return fallback;
10
- }
11
- }
12
5
  export async function runMobileList(exec) {
6
+ const warnings = [];
7
+ const safe = async (label, fn, fallback) => {
8
+ try {
9
+ return await fn();
10
+ }
11
+ catch (err) {
12
+ warnings.push(`${label}: ${err instanceof Error ? err.message : String(err)}`);
13
+ return fallback;
14
+ }
15
+ };
13
16
  const [allAndroid, iosDevices] = await Promise.all([
14
- safe(() => listAndroidDevices(exec), []),
15
- safe(() => listIosPhysicalDevices(exec), []),
17
+ safe("android (adb)", () => listAndroidDevices(exec), []),
18
+ safe("ios (idb)", () => listIosPhysicalDevices(exec), []),
16
19
  ]);
17
20
  // Physical phones only: drop emulator serials.
18
21
  const androidDevices = allAndroid.filter((d) => !d.serial.startsWith("emulator-"));
19
- return formatSuccess({ androidDevices, iosDevices });
22
+ return formatSuccess({
23
+ androidDevices,
24
+ iosDevices,
25
+ ...(warnings.length > 0 ? { warnings } : {}),
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");
20
46
  }
21
47
  export default defineCommand({
22
48
  meta: {
23
49
  name: "list",
24
- description: "List physically-connected phones (Android via adb, iOS via idb)",
50
+ description: "List physically-connected phones (Android via adb, iOS via devicectl)",
25
51
  },
26
52
  args: {},
27
53
  async run() {
28
- printResult(await runMobileList());
54
+ printMobileResult(await runMobileList(), { render: renderMobileList });
29
55
  },
30
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";