@autifyhq/aximo-cli 0.5.0 → 0.6.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 (36) hide show
  1. package/README.md +181 -64
  2. package/dist/cli.js +4 -0
  3. package/dist/client.d.ts +2 -0
  4. package/dist/client.js +18 -0
  5. package/dist/commands/mobile/connect-core.js +27 -13
  6. package/dist/commands/mobile/doctor.js +22 -3
  7. package/dist/commands/mobile/index.js +1 -0
  8. package/dist/commands/mobile/install.d.ts +43 -0
  9. package/dist/commands/mobile/install.js +76 -0
  10. package/dist/commands/mobile/install.test.d.ts +1 -0
  11. package/dist/commands/mobile/install.test.js +104 -0
  12. package/dist/commands/mobile/register.d.ts +6 -1
  13. package/dist/commands/mobile/register.js +12 -6
  14. package/dist/commands/mobile/register.test.js +34 -3
  15. package/dist/commands/schema.js +15 -8
  16. package/dist/mobile-control/app-install.d.ts +21 -0
  17. package/dist/mobile-control/app-install.js +28 -0
  18. package/dist/mobile-control/app-install.test.d.ts +1 -0
  19. package/dist/mobile-control/app-install.test.js +78 -0
  20. package/dist/mobile-control/appium-controller.d.ts +3 -0
  21. package/dist/mobile-control/appium-controller.js +5 -0
  22. package/dist/mobile-control/devices.d.ts +3 -1
  23. package/dist/mobile-control/devices.js +33 -3
  24. package/dist/mobile-control/devices.test.js +41 -1
  25. package/dist/mobile-control/ios-signing.d.ts +28 -0
  26. package/dist/mobile-control/ios-signing.js +38 -0
  27. package/dist/mobile-control/ios-signing.test.d.ts +1 -0
  28. package/dist/mobile-control/ios-signing.test.js +73 -0
  29. package/dist/mobile-control/tunnel.d.ts +5 -1
  30. package/dist/mobile-control/tunnel.js +15 -2
  31. package/dist/mobile-control/tunnel.test.js +1 -1
  32. package/dist/proxy.d.ts +11 -0
  33. package/dist/proxy.js +85 -0
  34. package/dist/proxy.test.d.ts +1 -0
  35. package/dist/proxy.test.js +72 -0
  36. package/package.json +3 -2
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { iosSigningCapabilities, resolveIosSigning } from "./ios-signing.js";
3
+ describe("resolveIosSigning", () => {
4
+ it("falls back to defaults when env is empty", () => {
5
+ const config = resolveIosSigning({});
6
+ expect(config).toEqual({
7
+ signingId: "Apple Development",
8
+ wdaBundleId: "com.facebook.WebDriverAgentRunner",
9
+ });
10
+ expect(config.teamId).toBeUndefined();
11
+ });
12
+ it("reads team id, signing id, wda bundle id and derived data path from env", () => {
13
+ const config = resolveIosSigning({
14
+ AXIMO_IOS_TEAM_ID: "ABCDE12345",
15
+ AXIMO_IOS_SIGNING_ID: "Apple Distribution",
16
+ AXIMO_IOS_WDA_BUNDLE_ID: "com.example.WDA",
17
+ AXIMO_IOS_DERIVED_DATA_PATH: "/tmp/wda",
18
+ });
19
+ expect(config).toEqual({
20
+ teamId: "ABCDE12345",
21
+ signingId: "Apple Distribution",
22
+ wdaBundleId: "com.example.WDA",
23
+ derivedDataPath: "/tmp/wda",
24
+ });
25
+ });
26
+ it("treats blank env values as unset", () => {
27
+ const config = resolveIosSigning({ AXIMO_IOS_TEAM_ID: " " });
28
+ expect(config.teamId).toBeUndefined();
29
+ });
30
+ it("falls back to the MobileLink (XCODE_*/WDA_BUNDLE_ID) names", () => {
31
+ const config = resolveIosSigning({
32
+ XCODE_TEAM_ID: "ABCDE12345",
33
+ XCODE_SIGN_ID: "Apple Distribution",
34
+ WDA_BUNDLE_ID: "com.example.WDA",
35
+ });
36
+ expect(config).toEqual({
37
+ teamId: "ABCDE12345",
38
+ signingId: "Apple Distribution",
39
+ wdaBundleId: "com.example.WDA",
40
+ });
41
+ });
42
+ it("prefers AXIMO_IOS_* over the MobileLink fallbacks", () => {
43
+ const config = resolveIosSigning({
44
+ AXIMO_IOS_TEAM_ID: "AXIMO00000",
45
+ XCODE_TEAM_ID: "XCODE00000",
46
+ AXIMO_IOS_SIGNING_ID: "Apple Development",
47
+ XCODE_SIGN_ID: "Apple Distribution",
48
+ });
49
+ expect(config.teamId).toBe("AXIMO00000");
50
+ expect(config.signingId).toBe("Apple Development");
51
+ });
52
+ });
53
+ describe("iosSigningCapabilities", () => {
54
+ it("is empty without a team id (simulator / automatic signing)", () => {
55
+ expect(iosSigningCapabilities(resolveIosSigning({}))).toEqual({});
56
+ });
57
+ it("emits XCUITest signing caps when a team id is set", () => {
58
+ const caps = iosSigningCapabilities(resolveIosSigning({ AXIMO_IOS_TEAM_ID: "ABCDE12345" }));
59
+ expect(caps).toEqual({
60
+ "appium:xcodeOrgId": "ABCDE12345",
61
+ "appium:xcodeSigningId": "Apple Development",
62
+ "appium:updatedWDABundleId": "com.facebook.WebDriverAgentRunner",
63
+ "appium:allowProvisioningDeviceRegistration": true,
64
+ });
65
+ });
66
+ it("includes derivedDataPath when configured", () => {
67
+ const caps = iosSigningCapabilities(resolveIosSigning({
68
+ AXIMO_IOS_TEAM_ID: "ABCDE12345",
69
+ AXIMO_IOS_DERIVED_DATA_PATH: "/tmp/wda",
70
+ }));
71
+ expect(caps["appium:derivedDataPath"]).toBe("/tmp/wda");
72
+ });
73
+ });
@@ -5,7 +5,11 @@ export interface RunControlTunnelOptions {
5
5
  deviceToken: string;
6
6
  deviceName: string;
7
7
  controller: MobileController;
8
+ /** Installs the app under test on the device (fetch by upload id -> adb/idb install). */
9
+ installApp?: (args: {
10
+ uploadId: string;
11
+ }) => Promise<void>;
8
12
  connect?: (wsUrl: string, deviceToken: string) => Socket;
9
13
  }
10
- export declare function registerHandlers(socket: Socket, controller: MobileController): void;
14
+ export declare function registerHandlers(socket: Socket, controller: MobileController, installApp?: RunControlTunnelOptions["installApp"]): void;
11
15
  export declare function runControlTunnel(options: RunControlTunnelOptions): Promise<void>;
@@ -49,7 +49,7 @@ function defaultConnect(wsUrl, deviceToken) {
49
49
  timeout: 10000,
50
50
  });
51
51
  }
52
- export function registerHandlers(socket, controller) {
52
+ export function registerHandlers(socket, controller, installApp) {
53
53
  const on = (event, handler) => {
54
54
  socket.on(event, (rawArgs, ack) => {
55
55
  const args = (rawArgs ?? {});
@@ -100,6 +100,15 @@ export function registerHandlers(socket, controller) {
100
100
  on("input", async (args) => asActionResult(await controller.typeText(String(args["text"] ?? ""))));
101
101
  on("key", async (args) => asActionResult(await controller.pressKey(String(args["key"] ?? ""))));
102
102
  on("launch_app", async (args) => asActionResult(await controller.launchApp(String(args["app"] ?? ""))));
103
+ if (installApp) {
104
+ on("install_app", async (args) => {
105
+ const uploadId = String(args["uploadId"] ?? "");
106
+ if (!uploadId)
107
+ return { ok: false, error: "install_app requires an uploadId" };
108
+ await installApp({ uploadId });
109
+ return ok();
110
+ });
111
+ }
103
112
  on("find_element", async (args) => asActionResult(await controller.findElement(String(args["text"] ?? ""))));
104
113
  on("clear_text", async () => asActionResult(await controller.clearText()));
105
114
  on("hide_keyboard", async () => asActionResult(await controller.hideKeyboard()));
@@ -107,7 +116,7 @@ export function registerHandlers(socket, controller) {
107
116
  export async function runControlTunnel(options) {
108
117
  const connect = options.connect ?? defaultConnect;
109
118
  const socket = connect(options.wsUrl, options.deviceToken);
110
- registerHandlers(socket, options.controller);
119
+ registerHandlers(socket, options.controller, options.installApp);
111
120
  await new Promise((resolve, reject) => {
112
121
  let shuttingDown = false;
113
122
  const cleanup = () => {
@@ -130,6 +139,10 @@ export async function runControlTunnel(options) {
130
139
  console.error(`Connection error: ${err.message}`);
131
140
  });
132
141
  socket.on("disconnect", (reason) => {
142
+ // A SIGINT/SIGTERM teardown closes the socket itself; cleanup() already
143
+ // resolved the promise, so don't report our own close as an error.
144
+ if (shuttingDown)
145
+ return;
133
146
  // socket.active is false when the disconnect is terminal (the client won't reconnect).
134
147
  if (!socket.active) {
135
148
  socket.close();
@@ -127,7 +127,7 @@ describe("registerHandlers", () => {
127
127
  const result = (await socket.emit("datetime", {}));
128
128
  expect(typeof result.datetime).toBe("string");
129
129
  expect(result.datetime.length).toBeGreaterThan(0);
130
- expect(result.datetime).not.toContain("T"); // humanized, not raw ISO
130
+ expect(result.datetime).not.toMatch(/\dT\d/); // humanized, not raw ISO (e.g. 2026-06-30T14:41)
131
131
  expect(typeof result.timezone).toBe("string");
132
132
  expect(result.timezone.length).toBeGreaterThan(0);
133
133
  });
@@ -0,0 +1,11 @@
1
+ /** True for loopback, RFC1918, link-local, or IPv6 ULA hosts — never proxied. */
2
+ export declare function isPrivateHost(host: string): boolean;
3
+ /** Merge any existing NO_PROXY with the hosts we always force direct. */
4
+ export declare function resolveNoProxy(env: NodeJS.ProcessEnv, host: string | null): string;
5
+ /**
6
+ * Route the CLI's global fetch() through any configured HTTP(S) proxy. No-op
7
+ * when no proxy env var is set. Writes the resolved NO_PROXY back onto the
8
+ * environment so the pip subprocess and the spawned Python agent inherit the
9
+ * same bypass rules. Returns whether a proxy was installed.
10
+ */
11
+ export declare function installProxySupport(env?: NodeJS.ProcessEnv): Promise<boolean>;
package/dist/proxy.js ADDED
@@ -0,0 +1,85 @@
1
+ import { getBaseUrl } from "./client.js";
2
+ // Make every fetch() the CLI issues honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY,
3
+ // the same way npm, curl and pip do. Node's global fetch (undici) ignores these
4
+ // by default, so the embedded-Python download and the API calls would bypass a
5
+ // corporate proxy and fail on a locked-down on-prem network.
6
+ //
7
+ // Two hosts are always forced direct, even when a proxy is set:
8
+ // - loopback: the local Appium / WebDriver servers the mobile commands drive.
9
+ // - a private-range Aximo server: an on-prem box (e.g. 172.30.x.x) is reached
10
+ // directly, and an internet proxy usually cannot route to an internal IP —
11
+ // proxying it would break a link that currently works. NO_PROXY can't
12
+ // express CIDRs reliably, so we match the base-URL host against the private
13
+ // ranges ourselves and force it direct when it's an internal IP literal.
14
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "::1"];
15
+ /** True for loopback, RFC1918, link-local, or IPv6 ULA hosts — never proxied. */
16
+ export function isPrivateHost(host) {
17
+ const h = host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
18
+ if (LOOPBACK_HOSTS.includes(h))
19
+ return true;
20
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
21
+ if (v4) {
22
+ const a = Number(v4[1]);
23
+ const b = Number(v4[2]);
24
+ if (a === 10 || a === 127)
25
+ return true;
26
+ if (a === 172 && b >= 16 && b <= 31)
27
+ return true;
28
+ if (a === 192 && b === 168)
29
+ return true;
30
+ if (a === 169 && b === 254)
31
+ return true;
32
+ return false;
33
+ }
34
+ // IPv6 ULA / link-local — only for IPv6 literals (which contain a colon), so
35
+ // ordinary DNS names like "fd.example.com" or "fc00.example.com" stay public.
36
+ if (h.includes(":")) {
37
+ if (/^fe[89ab]/.test(h))
38
+ return true; // fe80::/10 link-local
39
+ if (/^f[cd]/.test(h))
40
+ return true; // fc00::/7 unique-local
41
+ }
42
+ return false;
43
+ }
44
+ function hasProxyEnv(env) {
45
+ return Boolean(env["HTTP_PROXY"] || env["http_proxy"] || env["HTTPS_PROXY"] || env["https_proxy"]);
46
+ }
47
+ function serverHost() {
48
+ try {
49
+ return new URL(getBaseUrl()).hostname || null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Merge any existing NO_PROXY with the hosts we always force direct. */
56
+ export function resolveNoProxy(env, host) {
57
+ const parts = (env["NO_PROXY"] ?? env["no_proxy"] ?? "")
58
+ .split(",")
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ const direct = [...LOOPBACK_HOSTS];
62
+ if (host && isPrivateHost(host))
63
+ direct.push(host);
64
+ for (const d of direct) {
65
+ if (!parts.includes(d))
66
+ parts.push(d);
67
+ }
68
+ return parts.join(",");
69
+ }
70
+ /**
71
+ * Route the CLI's global fetch() through any configured HTTP(S) proxy. No-op
72
+ * when no proxy env var is set. Writes the resolved NO_PROXY back onto the
73
+ * environment so the pip subprocess and the spawned Python agent inherit the
74
+ * same bypass rules. Returns whether a proxy was installed.
75
+ */
76
+ export async function installProxySupport(env = process.env) {
77
+ if (!hasProxyEnv(env))
78
+ return false;
79
+ const noProxy = resolveNoProxy(env, serverHost());
80
+ env["NO_PROXY"] = noProxy;
81
+ env["no_proxy"] = noProxy;
82
+ const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
83
+ setGlobalDispatcher(new EnvHttpProxyAgent({ noProxy }));
84
+ return true;
85
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
3
+ import { isPrivateHost, installProxySupport, resolveNoProxy } from "./proxy.js";
4
+ describe("isPrivateHost", () => {
5
+ it("treats loopback as private", () => {
6
+ expect(isPrivateHost("localhost")).toBe(true);
7
+ expect(isPrivateHost("127.0.0.1")).toBe(true);
8
+ expect(isPrivateHost("::1")).toBe(true);
9
+ expect(isPrivateHost("[::1]")).toBe(true);
10
+ });
11
+ it("treats RFC1918 / link-local IPv4 as private", () => {
12
+ expect(isPrivateHost("10.1.2.3")).toBe(true);
13
+ expect(isPrivateHost("172.30.193.138")).toBe(true); // the on-prem repro
14
+ expect(isPrivateHost("192.168.0.5")).toBe(true);
15
+ expect(isPrivateHost("169.254.10.10")).toBe(true);
16
+ });
17
+ it("treats 172.x outside 16-31 as public", () => {
18
+ expect(isPrivateHost("172.15.0.1")).toBe(false);
19
+ expect(isPrivateHost("172.32.0.1")).toBe(false);
20
+ });
21
+ it("treats IPv6 ULA / link-local as private", () => {
22
+ expect(isPrivateHost("fd00::1")).toBe(true);
23
+ expect(isPrivateHost("fe80::1")).toBe(true);
24
+ });
25
+ it("treats public hosts as not private", () => {
26
+ expect(isPrivateHost("aximo.autify.com")).toBe(false);
27
+ expect(isPrivateHost("8.8.8.8")).toBe(false);
28
+ expect(isPrivateHost("2606:4700::1")).toBe(false);
29
+ });
30
+ it("treats DNS names that start like IPv6 ULA/link-local as public", () => {
31
+ expect(isPrivateHost("fd.example.com")).toBe(false);
32
+ expect(isPrivateHost("fc00.example.com")).toBe(false);
33
+ expect(isPrivateHost("fe80.example.com")).toBe(false);
34
+ });
35
+ });
36
+ describe("resolveNoProxy", () => {
37
+ it("always forces loopback direct", () => {
38
+ expect(resolveNoProxy({}, null).split(",")).toEqual(["localhost", "127.0.0.1", "::1"]);
39
+ });
40
+ it("adds a private server host so the on-prem link stays direct", () => {
41
+ const result = resolveNoProxy({}, "172.30.193.138").split(",");
42
+ expect(result).toContain("172.30.193.138");
43
+ });
44
+ it("leaves a public server host to the proxy", () => {
45
+ expect(resolveNoProxy({}, "aximo.autify.com")).not.toContain("aximo.autify.com");
46
+ });
47
+ it("preserves an existing NO_PROXY and dedupes", () => {
48
+ const result = resolveNoProxy({ NO_PROXY: "example.com, localhost" }, "10.0.0.1");
49
+ const parts = result.split(",");
50
+ expect(parts).toContain("example.com");
51
+ expect(parts).toContain("10.0.0.1");
52
+ expect(parts.filter((p) => p === "localhost")).toHaveLength(1);
53
+ });
54
+ });
55
+ describe("installProxySupport", () => {
56
+ // installProxySupport sets a global undici dispatcher; restore it so the
57
+ // proxy agent doesn't leak into other tests' fetch() calls.
58
+ const original = getGlobalDispatcher();
59
+ afterEach(() => setGlobalDispatcher(original));
60
+ it("is a no-op when no proxy env is set", async () => {
61
+ const env = {};
62
+ expect(await installProxySupport(env)).toBe(false);
63
+ expect(env["NO_PROXY"]).toBeUndefined();
64
+ });
65
+ it("installs a dispatcher and writes NO_PROXY when a proxy is set", async () => {
66
+ const env = { HTTPS_PROXY: "http://proxy.local:8080" };
67
+ expect(await installProxySupport(env)).toBe(true);
68
+ const noProxy = env["NO_PROXY"] ?? "";
69
+ expect(noProxy.split(",")).toContain("127.0.0.1");
70
+ expect(env["no_proxy"]).toBe(noProxy);
71
+ });
72
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",
@@ -40,6 +40,7 @@
40
40
  "appium": "^3.5.2",
41
41
  "citty": "0.2.2",
42
42
  "socket.io-client": "^4.8.1",
43
+ "undici": "^7.28.0",
43
44
  "ws": "^8.18.0",
44
45
  "zod": "^3.25.76"
45
46
  },
@@ -61,7 +62,7 @@
61
62
  "vitest": "4.1.4"
62
63
  },
63
64
  "engines": {
64
- "node": ">=20"
65
+ "node": ">=20.18.1"
65
66
  },
66
67
  "scripts": {
67
68
  "build": "tsc",