@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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { envFlag } from "./env-flags.js";
|
|
5
|
+
import { listSigningIdentities, selectIdentity } from "./ios-identity.js";
|
|
6
|
+
import { mintedAppId, mintProfileViaWdaRig } from "./ios-mint.js";
|
|
7
|
+
import { listProvisioningProfiles, selectProfile, } from "./ios-profiles.js";
|
|
8
|
+
import { inspectIpa, isInstallableOn, resignIpa } from "./ios-resign.js";
|
|
9
|
+
/** Stable per-team bundle id used for remapped resigns (minted once, reused). */
|
|
10
|
+
export function aximoResignBundleId(teamId) {
|
|
11
|
+
return `com.aximo.resign.${teamId.toLowerCase()}`;
|
|
12
|
+
}
|
|
13
|
+
export async function ensureInstallableIpa(options) {
|
|
14
|
+
const env = options.env ?? process.env;
|
|
15
|
+
const log = options.log ?? (() => { });
|
|
16
|
+
const now = options.now ?? Date.now();
|
|
17
|
+
const inspect = options.inspect ?? inspectIpa;
|
|
18
|
+
// Opt-out first, before any structural requirements on the .ipa.
|
|
19
|
+
if (envFlag(env, "AXIMO_IOS_NO_RESIGN")) {
|
|
20
|
+
const optOutInfo = await inspect(options.ipaPath).catch(() => null);
|
|
21
|
+
const id = optOutInfo?.bundleId ?? "";
|
|
22
|
+
return {
|
|
23
|
+
ipaPath: options.ipaPath,
|
|
24
|
+
resigned: false,
|
|
25
|
+
originalBundleId: id,
|
|
26
|
+
installedBundleId: id,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// An .ipa whose layout inspectIpa can't parse falls through to a plain install
|
|
30
|
+
// attempt (the pre-resign behavior) instead of failing before the device sees it.
|
|
31
|
+
let info;
|
|
32
|
+
try {
|
|
33
|
+
info = await inspect(options.ipaPath);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
log(`Skipping auto-resign (${err instanceof Error ? err.message : String(err)}).`);
|
|
37
|
+
return {
|
|
38
|
+
ipaPath: options.ipaPath,
|
|
39
|
+
resigned: false,
|
|
40
|
+
originalBundleId: "",
|
|
41
|
+
installedBundleId: "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const asIs = {
|
|
45
|
+
ipaPath: options.ipaPath,
|
|
46
|
+
resigned: false,
|
|
47
|
+
originalBundleId: info.bundleId,
|
|
48
|
+
installedBundleId: info.bundleId,
|
|
49
|
+
};
|
|
50
|
+
if (isInstallableOn(info, options.udid, now))
|
|
51
|
+
return asIs;
|
|
52
|
+
if (info.encrypted === true) {
|
|
53
|
+
throw new Error(`${options.ipaPath} is FairPlay-encrypted (an App Store download) and cannot be re-signed. ` +
|
|
54
|
+
"Use a development/ad-hoc build — the same .ipa you upload to a device farm works.");
|
|
55
|
+
}
|
|
56
|
+
const identity = selectIdentity(await (options.listIdentities ?? listSigningIdentities)(), pickEnvTeam(env));
|
|
57
|
+
if (!identity) {
|
|
58
|
+
throw new Error("This .ipa is not signed for this device, and no Apple Development signing identity " +
|
|
59
|
+
"was found in the keychain to re-sign it. Open Xcode → Settings → Accounts, add your " +
|
|
60
|
+
"Apple ID, then Manage Certificates → + → Apple Development (or import your team's .p12).");
|
|
61
|
+
}
|
|
62
|
+
const teamId = pickEnvTeam(env) ?? identity.teamId;
|
|
63
|
+
if (!teamId) {
|
|
64
|
+
throw new Error(`Could not determine the Apple Team ID from certificate "${identity.name}". ` +
|
|
65
|
+
"Set AXIMO_IOS_TEAM_ID to your team id and retry.");
|
|
66
|
+
}
|
|
67
|
+
const listProfiles = options.listProfiles ?? listProvisioningProfiles;
|
|
68
|
+
let profiles = await listProfiles();
|
|
69
|
+
const select = (bundleId, list) => selectProfile(list, { bundleId, udid: options.udid, teamId, now });
|
|
70
|
+
// Preferred: a profile covering the app's own bundle id (keeps identity stable).
|
|
71
|
+
let profile = select(info.bundleId, profiles);
|
|
72
|
+
let installedBundleId = info.bundleId;
|
|
73
|
+
if (!profile) {
|
|
74
|
+
const dedicatedAppId = mintedAppId(aximoResignBundleId(teamId));
|
|
75
|
+
profile = select(dedicatedAppId, profiles);
|
|
76
|
+
if (!profile) {
|
|
77
|
+
log("No usable provisioning profile found — minting one via Xcode automatic signing " +
|
|
78
|
+
"(first run; takes a minute or two)…");
|
|
79
|
+
await (options.mint ?? mintProfileViaWdaRig)({
|
|
80
|
+
teamId,
|
|
81
|
+
bundleId: aximoResignBundleId(teamId),
|
|
82
|
+
udid: options.udid,
|
|
83
|
+
});
|
|
84
|
+
profiles = await listProfiles();
|
|
85
|
+
profile = select(dedicatedAppId, profiles);
|
|
86
|
+
if (!profile) {
|
|
87
|
+
throw new Error("Profile minting succeeded but the fresh profile was not found on disk. " +
|
|
88
|
+
"Check Xcode → Settings → Accounts and retry.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
installedBundleId = dedicatedAppId;
|
|
92
|
+
}
|
|
93
|
+
const outPath = join(tmpdir(), `aximo-resigned-${randomBytes(6).toString("hex")}.ipa`);
|
|
94
|
+
log(installedBundleId === info.bundleId
|
|
95
|
+
? `Re-signing ${info.bundleId} for this device (team ${teamId})…`
|
|
96
|
+
: `Re-signing ${info.bundleId} for this device as ${installedBundleId} (team ${teamId})…`);
|
|
97
|
+
await (options.resign ?? resignIpa)({
|
|
98
|
+
ipaPath: options.ipaPath,
|
|
99
|
+
outPath,
|
|
100
|
+
identityHash: identity.hash,
|
|
101
|
+
profilePath: profile.path,
|
|
102
|
+
...(installedBundleId !== info.bundleId ? { bundleId: installedBundleId } : {}),
|
|
103
|
+
});
|
|
104
|
+
return { ipaPath: outPath, resigned: true, originalBundleId: info.bundleId, installedBundleId };
|
|
105
|
+
}
|
|
106
|
+
function pickEnvTeam(env) {
|
|
107
|
+
return env["AXIMO_IOS_TEAM_ID"]?.trim() || env["XCODE_TEAM_ID"]?.trim() || undefined;
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { aximoResignBundleId, ensureInstallableIpa, } from "./ios-auto-resign.js";
|
|
3
|
+
const NOW = new Date("2026-07-13T00:00:00Z").getTime();
|
|
4
|
+
const UDID = "00008030-TESTUDID";
|
|
5
|
+
const identity = {
|
|
6
|
+
hash: "0B0DF9CFF90EC3358C9A2CB52F006EB83FD7748C",
|
|
7
|
+
name: "Apple Development: test@example.com (ABC123DEF4)",
|
|
8
|
+
teamId: "TESTTEAM99",
|
|
9
|
+
};
|
|
10
|
+
function profile(over) {
|
|
11
|
+
return {
|
|
12
|
+
path: "/profiles/p.mobileprovision",
|
|
13
|
+
name: "p",
|
|
14
|
+
teamId: "TESTTEAM99",
|
|
15
|
+
bundleId: "com.example.app",
|
|
16
|
+
isWildcard: false,
|
|
17
|
+
expirationDate: new Date("2026-07-20T00:00:00Z"),
|
|
18
|
+
provisionedDevices: [UDID],
|
|
19
|
+
...over,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function ipaInfo(over = {}) {
|
|
23
|
+
return { bundleId: "com.example.app", profile: null, encrypted: false, ...over };
|
|
24
|
+
}
|
|
25
|
+
function makeOptions(over = {}) {
|
|
26
|
+
return {
|
|
27
|
+
ipaPath: "/apps/app.ipa",
|
|
28
|
+
udid: UDID,
|
|
29
|
+
env: {},
|
|
30
|
+
now: NOW,
|
|
31
|
+
inspect: vi.fn(async () => ipaInfo()),
|
|
32
|
+
listIdentities: vi.fn(async () => [identity]),
|
|
33
|
+
listProfiles: vi.fn(async () => []),
|
|
34
|
+
mint: vi.fn(async () => { }),
|
|
35
|
+
resign: vi.fn(async () => { }),
|
|
36
|
+
...over,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
describe("ensureInstallableIpa", () => {
|
|
40
|
+
it("returns the IPA untouched when its profile already covers the device", async () => {
|
|
41
|
+
const opts = makeOptions({
|
|
42
|
+
inspect: vi.fn(async () => ipaInfo({ profile: profile({ bundleId: "com.example.app" }) })),
|
|
43
|
+
});
|
|
44
|
+
const result = await ensureInstallableIpa(opts);
|
|
45
|
+
expect(result).toEqual({
|
|
46
|
+
ipaPath: "/apps/app.ipa",
|
|
47
|
+
resigned: false,
|
|
48
|
+
originalBundleId: "com.example.app",
|
|
49
|
+
installedBundleId: "com.example.app",
|
|
50
|
+
});
|
|
51
|
+
expect(opts.resign).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it("respects AXIMO_IOS_NO_RESIGN", async () => {
|
|
54
|
+
const opts = makeOptions({ env: { AXIMO_IOS_NO_RESIGN: "1" } });
|
|
55
|
+
const result = await ensureInstallableIpa(opts);
|
|
56
|
+
expect(result.resigned).toBe(false);
|
|
57
|
+
expect(opts.resign).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
it("rejects FairPlay-encrypted binaries with an actionable error", async () => {
|
|
60
|
+
const opts = makeOptions({ inspect: vi.fn(async () => ipaInfo({ encrypted: true })) });
|
|
61
|
+
await expect(ensureInstallableIpa(opts)).rejects.toThrow(/FairPlay-encrypted/);
|
|
62
|
+
});
|
|
63
|
+
it("re-signs keeping the original bundle id when a covering profile exists", async () => {
|
|
64
|
+
const covering = profile({ bundleId: "com.example.app" });
|
|
65
|
+
const opts = makeOptions({ listProfiles: vi.fn(async () => [covering]) });
|
|
66
|
+
const result = await ensureInstallableIpa(opts);
|
|
67
|
+
expect(result.resigned).toBe(true);
|
|
68
|
+
expect(result.installedBundleId).toBe("com.example.app");
|
|
69
|
+
expect(result.ipaPath).toMatch(/aximo-resigned-.*\.ipa$/);
|
|
70
|
+
expect(opts.resign).toHaveBeenCalledWith(expect.objectContaining({
|
|
71
|
+
ipaPath: "/apps/app.ipa",
|
|
72
|
+
identityHash: identity.hash,
|
|
73
|
+
profilePath: covering.path,
|
|
74
|
+
}));
|
|
75
|
+
// No bundleId remap when the profile covers the app's own id.
|
|
76
|
+
expect(vi.mocked(opts.resign).mock.calls[0]?.[0]?.bundleId).toBeUndefined();
|
|
77
|
+
expect(opts.mint).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
it("re-signs via a wildcard profile without remapping", async () => {
|
|
80
|
+
const wildcard = profile({ bundleId: "*", isWildcard: true });
|
|
81
|
+
const opts = makeOptions({ listProfiles: vi.fn(async () => [wildcard]) });
|
|
82
|
+
const result = await ensureInstallableIpa(opts);
|
|
83
|
+
expect(result.installedBundleId).toBe("com.example.app");
|
|
84
|
+
expect(vi.mocked(opts.resign).mock.calls[0]?.[0]?.bundleId).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
it("falls back to the dedicated Aximo profile and remaps", async () => {
|
|
87
|
+
const dedicated = profile({
|
|
88
|
+
bundleId: `${aximoResignBundleId("TESTTEAM99")}.xctrunner`,
|
|
89
|
+
});
|
|
90
|
+
const opts = makeOptions({ listProfiles: vi.fn(async () => [dedicated]) });
|
|
91
|
+
const result = await ensureInstallableIpa(opts);
|
|
92
|
+
expect(result.installedBundleId).toBe("com.aximo.resign.testteam99.xctrunner");
|
|
93
|
+
expect(vi.mocked(opts.resign).mock.calls[0]?.[0]?.bundleId).toBe("com.aximo.resign.testteam99.xctrunner");
|
|
94
|
+
expect(opts.mint).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
it("mints the dedicated profile when nothing usable exists, then re-signs", async () => {
|
|
97
|
+
const dedicated = profile({
|
|
98
|
+
bundleId: `${aximoResignBundleId("TESTTEAM99")}.xctrunner`,
|
|
99
|
+
});
|
|
100
|
+
const listProfiles = vi
|
|
101
|
+
.fn(async () => [])
|
|
102
|
+
.mockResolvedValueOnce([]) // before minting
|
|
103
|
+
.mockResolvedValueOnce([dedicated]); // after minting
|
|
104
|
+
const log = vi.fn();
|
|
105
|
+
const opts = makeOptions({ listProfiles, log });
|
|
106
|
+
const result = await ensureInstallableIpa(opts);
|
|
107
|
+
expect(opts.mint).toHaveBeenCalledWith({
|
|
108
|
+
teamId: "TESTTEAM99",
|
|
109
|
+
bundleId: "com.aximo.resign.testteam99",
|
|
110
|
+
udid: UDID,
|
|
111
|
+
});
|
|
112
|
+
expect(result.installedBundleId).toBe("com.aximo.resign.testteam99.xctrunner");
|
|
113
|
+
expect(log).toHaveBeenCalledWith(expect.stringMatching(/minting one via Xcode/i));
|
|
114
|
+
});
|
|
115
|
+
it("excludes expired and wrong-device profiles from selection", async () => {
|
|
116
|
+
const expired = profile({ expirationDate: new Date("2026-07-13T06:00:00Z") });
|
|
117
|
+
const wrongDevice = profile({ provisionedDevices: ["OTHER-UDID"] });
|
|
118
|
+
const dedicated = profile({
|
|
119
|
+
bundleId: `${aximoResignBundleId("TESTTEAM99")}.xctrunner`,
|
|
120
|
+
});
|
|
121
|
+
const listProfiles = vi
|
|
122
|
+
.fn(async () => [])
|
|
123
|
+
.mockResolvedValueOnce([expired, wrongDevice]) // before minting: nothing usable
|
|
124
|
+
.mockResolvedValueOnce([expired, wrongDevice, dedicated]); // after minting
|
|
125
|
+
const opts = makeOptions({ listProfiles });
|
|
126
|
+
await ensureInstallableIpa(opts);
|
|
127
|
+
expect(opts.mint).toHaveBeenCalled(); // had to mint despite two on-disk profiles
|
|
128
|
+
});
|
|
129
|
+
it("errors actionably when the keychain has no development identity", async () => {
|
|
130
|
+
const opts = makeOptions({ listIdentities: vi.fn(async () => []) });
|
|
131
|
+
await expect(ensureInstallableIpa(opts)).rejects.toThrow(/Xcode → Settings → Accounts/);
|
|
132
|
+
});
|
|
133
|
+
it("errors when the certificate has no readable team id and no override is set", async () => {
|
|
134
|
+
const opts = makeOptions({
|
|
135
|
+
listIdentities: vi.fn(async () => [{ ...identity, teamId: null }]),
|
|
136
|
+
});
|
|
137
|
+
await expect(ensureInstallableIpa(opts)).rejects.toThrow(/AXIMO_IOS_TEAM_ID/);
|
|
138
|
+
});
|
|
139
|
+
it("uses the env team override to pick among identities", async () => {
|
|
140
|
+
const other = { ...identity, hash: "F".repeat(40), teamId: "OTHERTEAM0" };
|
|
141
|
+
const covering = profile({ teamId: "OTHERTEAM0", bundleId: "com.example.app" });
|
|
142
|
+
const opts = makeOptions({
|
|
143
|
+
env: { AXIMO_IOS_TEAM_ID: "OTHERTEAM0" },
|
|
144
|
+
listIdentities: vi.fn(async () => [identity, other]),
|
|
145
|
+
listProfiles: vi.fn(async () => [covering]),
|
|
146
|
+
});
|
|
147
|
+
await ensureInstallableIpa(opts);
|
|
148
|
+
expect(vi.mocked(opts.resign).mock.calls[0]?.[0]?.identityHash).toBe("F".repeat(40));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
/**
|
|
3
|
+
* CoreDevice (`xcrun devicectl`) access to physical iOS devices.
|
|
4
|
+
*
|
|
5
|
+
* devicectl ships with Xcode (which iOS BYOD requires anyway), so it replaces
|
|
6
|
+
* the idb dependency for discovery and app install. It is also the authoritative
|
|
7
|
+
* source for onboarding state — Developer Mode and pairing — which lets preflight
|
|
8
|
+
* tell the user exactly which on-device toggle is missing instead of failing
|
|
9
|
+
* later with an opaque error. devicectl only writes JSON to a file
|
|
10
|
+
* (`--json-output`), so results go through a temp file.
|
|
11
|
+
*/
|
|
12
|
+
export interface IosDeviceInfo {
|
|
13
|
+
udid: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
/** Marketing name, e.g. "iPhone SE". */
|
|
16
|
+
model: string | null;
|
|
17
|
+
/** e.g. "26.5". */
|
|
18
|
+
osVersion: string | null;
|
|
19
|
+
developerModeEnabled: boolean | null;
|
|
20
|
+
paired: boolean | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* List currently-attached physical iPhones/iPads. `devicectl list devices` also
|
|
24
|
+
* remembers past devices; `bootState === "booted"` filters to live connections.
|
|
25
|
+
* Returns [] when devicectl is unavailable (caller can fall back to idb).
|
|
26
|
+
*/
|
|
27
|
+
export declare function listIosDevicesViaDevicectl(exec?: ExecFn): Promise<IosDeviceInfo[]>;
|
|
28
|
+
/** Details for one device (from the list call), or null when not found. */
|
|
29
|
+
export declare function getIosDeviceInfo(udid: string, exec?: ExecFn): Promise<IosDeviceInfo | null>;
|
|
30
|
+
/** Major version from an osVersion string ("26.5" → 26), null when unknown. */
|
|
31
|
+
export declare function iosMajorVersion(osVersion: string | null): number | null;
|
|
32
|
+
/** Install an .ipa (already signed for this device) via CoreDevice. */
|
|
33
|
+
export declare function installAppViaDevicectl(udid: string, ipaPath: string, exec?: ExecFn): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Install an .ipa, preferring devicectl but falling back to `idb install`:
|
|
36
|
+
* CoreDevice cannot address iOS ≤16 devices, which idb still handles. When both
|
|
37
|
+
* fail, the devicectl error is thrown — its messages match our hint patterns.
|
|
38
|
+
*/
|
|
39
|
+
export declare function installIosApp(udid: string, ipaPath: string, exec?: ExecFn): Promise<string>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { execBinary } from "./exec.js";
|
|
5
|
+
async function devicectlJson(exec, args) {
|
|
6
|
+
const scratch = mkdtempSync(join(tmpdir(), "aximo-devicectl-"));
|
|
7
|
+
const outPath = join(scratch, "out.json");
|
|
8
|
+
try {
|
|
9
|
+
await exec("xcrun", ["devicectl", ...args, "--json-output", outPath]);
|
|
10
|
+
return JSON.parse(readFileSync(outPath, "utf-8"));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null; // devicectl missing (no Xcode) or command failed
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function mapDevice(device) {
|
|
20
|
+
const developerMode = device.deviceProperties?.developerModeStatus;
|
|
21
|
+
const pairing = device.connectionProperties?.pairingState;
|
|
22
|
+
return {
|
|
23
|
+
udid: device.hardwareProperties?.udid ?? "",
|
|
24
|
+
name: device.deviceProperties?.name ?? null,
|
|
25
|
+
model: device.hardwareProperties?.marketingName ?? null,
|
|
26
|
+
osVersion: device.deviceProperties?.osVersionNumber ?? null,
|
|
27
|
+
developerModeEnabled: developerMode ? developerMode === "enabled" : null,
|
|
28
|
+
paired: pairing ? pairing === "paired" : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* List currently-attached physical iPhones/iPads. `devicectl list devices` also
|
|
33
|
+
* remembers past devices; `bootState === "booted"` filters to live connections.
|
|
34
|
+
* Returns [] when devicectl is unavailable (caller can fall back to idb).
|
|
35
|
+
*/
|
|
36
|
+
export async function listIosDevicesViaDevicectl(exec = execBinary) {
|
|
37
|
+
const json = (await devicectlJson(exec, ["list", "devices"]));
|
|
38
|
+
const devices = json?.result?.devices ?? [];
|
|
39
|
+
return devices
|
|
40
|
+
.filter((d) => d.hardwareProperties?.platform === "iOS" &&
|
|
41
|
+
d.hardwareProperties?.reality === "physical" &&
|
|
42
|
+
d.deviceProperties?.bootState === "booted" &&
|
|
43
|
+
d.hardwareProperties?.udid)
|
|
44
|
+
.map(mapDevice);
|
|
45
|
+
}
|
|
46
|
+
/** Details for one device (from the list call), or null when not found. */
|
|
47
|
+
export async function getIosDeviceInfo(udid, exec = execBinary) {
|
|
48
|
+
const devices = await listIosDevicesViaDevicectl(exec);
|
|
49
|
+
return devices.find((d) => d.udid === udid) ?? null;
|
|
50
|
+
}
|
|
51
|
+
/** Major version from an osVersion string ("26.5" → 26), null when unknown. */
|
|
52
|
+
export function iosMajorVersion(osVersion) {
|
|
53
|
+
const major = osVersion?.match(/^(\d+)/)?.[1];
|
|
54
|
+
return major ? Number(major) : null;
|
|
55
|
+
}
|
|
56
|
+
/** Install an .ipa (already signed for this device) via CoreDevice. */
|
|
57
|
+
export async function installAppViaDevicectl(udid, ipaPath, exec = execBinary) {
|
|
58
|
+
const { stdout } = await exec("xcrun", [
|
|
59
|
+
"devicectl",
|
|
60
|
+
"device",
|
|
61
|
+
"install",
|
|
62
|
+
"app",
|
|
63
|
+
"--device",
|
|
64
|
+
udid,
|
|
65
|
+
ipaPath,
|
|
66
|
+
]);
|
|
67
|
+
return stdout.toString("utf-8").trim();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Install an .ipa, preferring devicectl but falling back to `idb install`:
|
|
71
|
+
* CoreDevice cannot address iOS ≤16 devices, which idb still handles. When both
|
|
72
|
+
* fail, the devicectl error is thrown — its messages match our hint patterns.
|
|
73
|
+
*/
|
|
74
|
+
export async function installIosApp(udid, ipaPath, exec = execBinary) {
|
|
75
|
+
try {
|
|
76
|
+
return await installAppViaDevicectl(udid, ipaPath, exec);
|
|
77
|
+
}
|
|
78
|
+
catch (devicectlErr) {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await exec("idb", ["install", "--udid", udid, ipaPath]);
|
|
81
|
+
return stdout.toString("utf-8").trim();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw devicectlErr;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { getIosDeviceInfo, installAppViaDevicectl, iosMajorVersion, listIosDevicesViaDevicectl, } from "./ios-devicectl.js";
|
|
4
|
+
function deviceJson(over = {}) {
|
|
5
|
+
return {
|
|
6
|
+
hardwareProperties: {
|
|
7
|
+
udid: "00008030-TESTUDID",
|
|
8
|
+
platform: "iOS",
|
|
9
|
+
reality: "physical",
|
|
10
|
+
marketingName: "iPhone SE",
|
|
11
|
+
},
|
|
12
|
+
deviceProperties: {
|
|
13
|
+
name: "Test iPhone",
|
|
14
|
+
osVersionNumber: "26.5",
|
|
15
|
+
developerModeStatus: "enabled",
|
|
16
|
+
bootState: "booted",
|
|
17
|
+
},
|
|
18
|
+
connectionProperties: { pairingState: "paired" },
|
|
19
|
+
...over,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Fake exec that writes the fixture to devicectl's --json-output path. */
|
|
23
|
+
function fakeDevicectl(devices) {
|
|
24
|
+
return async (file, args) => {
|
|
25
|
+
if (file !== "xcrun")
|
|
26
|
+
throw new Error(`unexpected: ${file}`);
|
|
27
|
+
const outIdx = args.indexOf("--json-output");
|
|
28
|
+
const outPath = args[outIdx + 1];
|
|
29
|
+
if (outIdx === -1 || !outPath)
|
|
30
|
+
throw new Error("no --json-output");
|
|
31
|
+
writeFileSync(outPath, JSON.stringify({ result: { devices } }));
|
|
32
|
+
return { stdout: Buffer.from(""), stderr: "" };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
describe("listIosDevicesViaDevicectl", () => {
|
|
36
|
+
it("maps booted physical iOS devices", async () => {
|
|
37
|
+
const devices = await listIosDevicesViaDevicectl(fakeDevicectl([deviceJson()]));
|
|
38
|
+
expect(devices).toEqual([
|
|
39
|
+
{
|
|
40
|
+
udid: "00008030-TESTUDID",
|
|
41
|
+
name: "Test iPhone",
|
|
42
|
+
model: "iPhone SE",
|
|
43
|
+
osVersion: "26.5",
|
|
44
|
+
developerModeEnabled: true,
|
|
45
|
+
paired: true,
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
it("filters out simulators, remembered-offline, and non-iOS entries", async () => {
|
|
50
|
+
const simulator = deviceJson({
|
|
51
|
+
hardwareProperties: { udid: "SIM", platform: "iOS", reality: "virtual" },
|
|
52
|
+
});
|
|
53
|
+
const remembered = deviceJson({
|
|
54
|
+
deviceProperties: { name: "Old", bootState: undefined },
|
|
55
|
+
});
|
|
56
|
+
const devices = await listIosDevicesViaDevicectl(fakeDevicectl([deviceJson(), simulator, remembered]));
|
|
57
|
+
expect(devices).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
it("reports null for unknown developer-mode / pairing states", async () => {
|
|
60
|
+
const bare = deviceJson({
|
|
61
|
+
deviceProperties: { name: "Bare", osVersionNumber: "16.4", bootState: "booted" },
|
|
62
|
+
connectionProperties: {},
|
|
63
|
+
});
|
|
64
|
+
const [device] = await listIosDevicesViaDevicectl(fakeDevicectl([bare]));
|
|
65
|
+
expect(device?.developerModeEnabled).toBeNull();
|
|
66
|
+
expect(device?.paired).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
it("returns [] when devicectl is unavailable", async () => {
|
|
69
|
+
const exec = async () => {
|
|
70
|
+
throw new Error("ENOENT");
|
|
71
|
+
};
|
|
72
|
+
expect(await listIosDevicesViaDevicectl(exec)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("getIosDeviceInfo", () => {
|
|
76
|
+
it("finds a device by udid and returns null otherwise", async () => {
|
|
77
|
+
const exec = fakeDevicectl([deviceJson()]);
|
|
78
|
+
expect((await getIosDeviceInfo("00008030-TESTUDID", exec))?.model).toBe("iPhone SE");
|
|
79
|
+
expect(await getIosDeviceInfo("MISSING", exec)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("iosMajorVersion", () => {
|
|
83
|
+
it("parses major versions and handles unknowns", () => {
|
|
84
|
+
expect(iosMajorVersion("26.5")).toBe(26);
|
|
85
|
+
expect(iosMajorVersion("16.4.1")).toBe(16);
|
|
86
|
+
expect(iosMajorVersion(null)).toBeNull();
|
|
87
|
+
expect(iosMajorVersion("beta")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("installAppViaDevicectl", () => {
|
|
91
|
+
it("invokes devicectl device install app", async () => {
|
|
92
|
+
let seen = [];
|
|
93
|
+
const exec = async (file, args) => {
|
|
94
|
+
seen = [file, ...args];
|
|
95
|
+
return { stdout: Buffer.from("App installed:\n"), stderr: "" };
|
|
96
|
+
};
|
|
97
|
+
const out = await installAppViaDevicectl("UDID-1", "/tmp/app.ipa", exec);
|
|
98
|
+
expect(seen).toEqual([
|
|
99
|
+
"xcrun",
|
|
100
|
+
"devicectl",
|
|
101
|
+
"device",
|
|
102
|
+
"install",
|
|
103
|
+
"app",
|
|
104
|
+
"--device",
|
|
105
|
+
"UDID-1",
|
|
106
|
+
"/tmp/app.ipa",
|
|
107
|
+
]);
|
|
108
|
+
expect(out).toBe("App installed:");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ExecFn } from "./exec.js";
|
|
2
|
+
/**
|
|
3
|
+
* Zero-config discovery of Apple code-signing identities from the login keychain.
|
|
4
|
+
*
|
|
5
|
+
* Physical-iPhone automation needs a development signing identity (for WDA and
|
|
6
|
+
* for re-signing the app under test). Instead of requiring AXIMO_IOS_TEAM_ID to
|
|
7
|
+
* be exported, the identity and its Team ID are discovered from the keychain:
|
|
8
|
+
* `security find-identity` lists the certs, and the Team ID is the certificate
|
|
9
|
+
* subject's OU (NOT the parenthetical in the CN, which is a machine id). Env
|
|
10
|
+
* vars remain as overrides for CI and multi-team hosts.
|
|
11
|
+
*/
|
|
12
|
+
export interface SigningIdentity {
|
|
13
|
+
/** SHA-1 hash accepted by codesign/applesign as the signing identity. */
|
|
14
|
+
hash: string;
|
|
15
|
+
/** Certificate common name, e.g. "Apple Development: dev@example.com (ABC123DEF4)". */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Apple Developer Team ID (certificate subject OU), when resolvable. */
|
|
18
|
+
teamId: string | null;
|
|
19
|
+
}
|
|
20
|
+
/** List valid development signing identities (Apple Development / iPhone Developer). */
|
|
21
|
+
export declare function listSigningIdentities(exec?: ExecFn): Promise<SigningIdentity[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Pick the identity to sign with. An explicit team id (env override) filters the
|
|
24
|
+
* candidates; otherwise the first development identity wins. Returns null when
|
|
25
|
+
* the keychain has no usable identity (the caller decides how to degrade).
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectIdentity(identities: SigningIdentity[], preferredTeamId?: string): SigningIdentity | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import { execBinary } from "./exec.js";
|
|
3
|
+
const IDENTITY_LINE = /^\s*\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"/;
|
|
4
|
+
const DEVELOPMENT_CERT = /^(Apple Development|iPhone Developer)\b/;
|
|
5
|
+
/** List valid development signing identities (Apple Development / iPhone Developer). */
|
|
6
|
+
export async function listSigningIdentities(exec = execBinary) {
|
|
7
|
+
let stdout;
|
|
8
|
+
try {
|
|
9
|
+
({ stdout } = await exec("security", ["find-identity", "-v", "-p", "codesigning"]).then((r) => ({ stdout: r.stdout.toString("utf-8") })));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return []; // no keychain access / not macOS → no identities
|
|
13
|
+
}
|
|
14
|
+
const identities = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const line of stdout.split("\n")) {
|
|
17
|
+
const match = line.match(IDENTITY_LINE);
|
|
18
|
+
const hash = match?.[1];
|
|
19
|
+
const name = match?.[2];
|
|
20
|
+
if (!hash || !name || !DEVELOPMENT_CERT.test(name) || seen.has(hash))
|
|
21
|
+
continue;
|
|
22
|
+
seen.add(hash);
|
|
23
|
+
identities.push({ hash, name, teamId: await readTeamId(name, exec) });
|
|
24
|
+
}
|
|
25
|
+
return identities;
|
|
26
|
+
}
|
|
27
|
+
/** Team ID = subject OU of the signing certificate. */
|
|
28
|
+
async function readTeamId(certName, exec) {
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await exec("security", ["find-certificate", "-c", certName, "-p"]);
|
|
31
|
+
const subject = new X509Certificate(stdout).subject;
|
|
32
|
+
const ou = subject.match(/(?:^|\n)OU=([^\n]+)/);
|
|
33
|
+
return ou?.[1]?.trim() || null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pick the identity to sign with. An explicit team id (env override) filters the
|
|
41
|
+
* candidates; otherwise the first development identity wins. Returns null when
|
|
42
|
+
* the keychain has no usable identity (the caller decides how to degrade).
|
|
43
|
+
*/
|
|
44
|
+
export function selectIdentity(identities, preferredTeamId) {
|
|
45
|
+
if (preferredTeamId) {
|
|
46
|
+
return identities.find((i) => i.teamId === preferredTeamId) ?? null;
|
|
47
|
+
}
|
|
48
|
+
return identities[0] ?? null;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|