@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.
- package/README.md +88 -70
- package/dist/client.js +16 -1
- package/dist/commands/mobile/connect-core.d.ts +2 -1
- package/dist/commands/mobile/connect-core.js +112 -9
- package/dist/commands/mobile/connect.js +6 -3
- package/dist/commands/mobile/connect.test.js +7 -1
- package/dist/commands/mobile/doctor.d.ts +4 -0
- package/dist/commands/mobile/doctor.js +62 -16
- package/dist/commands/mobile/doctor.test.d.ts +1 -0
- package/dist/commands/mobile/doctor.test.js +28 -0
- package/dist/commands/mobile/install.d.ts +10 -2
- package/dist/commands/mobile/install.js +57 -15
- package/dist/commands/mobile/install.test.js +44 -6
- package/dist/commands/mobile/list.d.ts +3 -0
- package/dist/commands/mobile/list.js +40 -14
- package/dist/commands/mobile/list.test.d.ts +1 -0
- package/dist/commands/mobile/list.test.js +24 -0
- package/dist/commands/mobile/output.d.ts +13 -0
- package/dist/commands/mobile/output.js +21 -0
- package/dist/commands/mobile/output.test.d.ts +1 -0
- package/dist/commands/mobile/output.test.js +54 -0
- package/dist/commands/mobile/register.d.ts +1 -0
- package/dist/commands/mobile/register.js +11 -3
- package/dist/commands/mobile/setup.d.ts +1 -0
- package/dist/commands/mobile/setup.js +7 -2
- package/dist/commands/mobile/unregister.d.ts +7 -3
- package/dist/commands/mobile/unregister.js +20 -9
- package/dist/mobile-control/app-install.d.ts +18 -5
- package/dist/mobile-control/app-install.js +33 -6
- package/dist/mobile-control/app-install.test.js +42 -7
- package/dist/mobile-control/appium-controller.d.ts +8 -0
- package/dist/mobile-control/appium-controller.js +19 -4
- package/dist/mobile-control/bundle-id-map.d.ts +9 -0
- package/dist/mobile-control/bundle-id-map.js +27 -0
- package/dist/mobile-control/devices.d.ts +6 -3
- package/dist/mobile-control/devices.js +26 -4
- package/dist/mobile-control/env-flags.d.ts +5 -0
- package/dist/mobile-control/env-flags.js +10 -0
- package/dist/mobile-control/env-flags.test.d.ts +1 -0
- package/dist/mobile-control/env-flags.test.js +21 -0
- package/dist/mobile-control/exec.d.ts +5 -1
- package/dist/mobile-control/exec.js +2 -2
- package/dist/mobile-control/ios-auto-resign.d.ts +45 -0
- package/dist/mobile-control/ios-auto-resign.js +108 -0
- package/dist/mobile-control/ios-auto-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-auto-resign.test.js +150 -0
- package/dist/mobile-control/ios-devicectl.d.ts +39 -0
- package/dist/mobile-control/ios-devicectl.js +87 -0
- package/dist/mobile-control/ios-devicectl.test.d.ts +1 -0
- package/dist/mobile-control/ios-devicectl.test.js +110 -0
- package/dist/mobile-control/ios-identity.d.ts +27 -0
- package/dist/mobile-control/ios-identity.js +49 -0
- package/dist/mobile-control/ios-identity.test.d.ts +1 -0
- package/dist/mobile-control/ios-identity.test.js +102 -0
- package/dist/mobile-control/ios-mint.d.ts +39 -0
- package/dist/mobile-control/ios-mint.js +78 -0
- package/dist/mobile-control/ios-mint.test.d.ts +1 -0
- package/dist/mobile-control/ios-mint.test.js +94 -0
- package/dist/mobile-control/ios-profiles.d.ts +46 -0
- package/dist/mobile-control/ios-profiles.js +106 -0
- package/dist/mobile-control/ios-profiles.test.d.ts +1 -0
- package/dist/mobile-control/ios-profiles.test.js +154 -0
- package/dist/mobile-control/ios-resign.d.ts +37 -0
- package/dist/mobile-control/ios-resign.js +101 -0
- package/dist/mobile-control/ios-resign.test.d.ts +1 -0
- package/dist/mobile-control/ios-resign.test.js +84 -0
- package/dist/mobile-control/ios-session-error.d.ts +10 -0
- package/dist/mobile-control/ios-session-error.js +34 -0
- package/dist/mobile-control/ios-session-error.test.d.ts +1 -0
- package/dist/mobile-control/ios-session-error.test.js +30 -0
- package/dist/mobile-control/ios-signing.d.ts +33 -5
- package/dist/mobile-control/ios-signing.js +56 -0
- package/dist/mobile-control/ios-signing.test.js +87 -0
- package/dist/mobile-control/ios-tunnel.d.ts +57 -0
- package/dist/mobile-control/ios-tunnel.js +150 -0
- package/dist/mobile-control/ios-tunnel.test.d.ts +1 -0
- package/dist/mobile-control/ios-tunnel.test.js +120 -0
- package/dist/mobile-control/ios-wda-cache.d.ts +39 -0
- package/dist/mobile-control/ios-wda-cache.js +157 -0
- package/dist/mobile-control/ios-wda-cache.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-cache.test.js +128 -0
- package/dist/mobile-control/ios-wda-diagnostics.d.ts +14 -0
- package/dist/mobile-control/ios-wda-diagnostics.js +138 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-diagnostics.test.js +77 -0
- package/dist/mobile-control/ios-wda-preinstall.d.ts +64 -0
- package/dist/mobile-control/ios-wda-preinstall.js +165 -0
- package/dist/mobile-control/ios-wda-preinstall.test.d.ts +1 -0
- package/dist/mobile-control/ios-wda-preinstall.test.js +111 -0
- package/dist/mobile-control/mobile-errors.d.ts +12 -0
- package/dist/mobile-control/mobile-errors.js +91 -0
- package/dist/mobile-control/mobile-errors.test.d.ts +1 -0
- package/dist/mobile-control/mobile-errors.test.js +48 -0
- package/dist/mobile-control/tunnel.js +79 -2
- package/dist/mobile-control/tunnel.test.js +82 -0
- 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
|
|
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 {
|
|
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: "
|
|
109
|
+
? { name: "idb", status: "info", message: idb }
|
|
108
110
|
: {
|
|
109
111
|
name: "idb",
|
|
110
|
-
status: "
|
|
111
|
-
message: "idb not found",
|
|
112
|
-
hint: "
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
hint: "
|
|
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: "
|
|
127
|
-
hint: "
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
78
|
-
const { exec
|
|
79
|
-
const
|
|
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(
|
|
87
|
-
|
|
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
|
|
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({
|
|
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
|
|
50
|
+
description: "List physically-connected phones (Android via adb, iOS via devicectl)",
|
|
25
51
|
},
|
|
26
52
|
args: {},
|
|
27
53
|
async run() {
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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";
|