@autifyhq/aximo-cli 0.10.1 → 0.11.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 (38) hide show
  1. package/README.md +39 -2
  2. package/dist/cli.js +14 -4
  3. package/dist/commands/mcp/server.test.js +4 -1
  4. package/dist/commands/mcp/tools.js +43 -0
  5. package/dist/commands/mcp/tools.test.js +104 -0
  6. package/dist/commands/mobile/doctor.d.ts +10 -0
  7. package/dist/commands/mobile/doctor.js +149 -1
  8. package/dist/commands/mobile/doctor.test.js +66 -1
  9. package/dist/desktop-server/agent-sync.d.ts +21 -1
  10. package/dist/desktop-server/agent-sync.js +6 -2
  11. package/dist/desktop-server/bundle-sync.d.ts +25 -0
  12. package/dist/desktop-server/bundle-sync.js +128 -0
  13. package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
  14. package/dist/desktop-server/bundle-sync.test.js +189 -0
  15. package/dist/desktop-server/cli-version.d.ts +21 -0
  16. package/dist/desktop-server/cli-version.js +41 -0
  17. package/dist/desktop-server/cli-version.test.d.ts +1 -0
  18. package/dist/desktop-server/cli-version.test.js +58 -0
  19. package/dist/desktop-server/offline.d.ts +6 -0
  20. package/dist/desktop-server/offline.js +44 -0
  21. package/dist/desktop-server/offline.test.d.ts +1 -0
  22. package/dist/desktop-server/offline.test.js +74 -0
  23. package/dist/desktop-server/setup-deps.test.d.ts +1 -0
  24. package/dist/desktop-server/setup-deps.test.js +112 -0
  25. package/dist/desktop-server/setup-python.test.d.ts +1 -0
  26. package/dist/desktop-server/setup-python.test.js +272 -0
  27. package/dist/desktop-server/setup.d.ts +26 -1
  28. package/dist/desktop-server/setup.js +136 -29
  29. package/dist/mobile-control/android-recovery.d.ts +5 -0
  30. package/dist/mobile-control/android-recovery.js +19 -0
  31. package/dist/mobile-control/android-recovery.test.d.ts +1 -0
  32. package/dist/mobile-control/android-recovery.test.js +33 -0
  33. package/dist/mobile-control/appium-controller.d.ts +4 -0
  34. package/dist/mobile-control/appium-controller.js +15 -1
  35. package/dist/mobile-control/appium-controller.test.js +78 -0
  36. package/dist/mobile-control/appium-import.js +19 -3
  37. package/dist/types.d.ts +49 -0
  38. package/package.json +4 -1
@@ -0,0 +1,112 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { getDepsMarkerPath } from "../commands/device/paths.js";
6
+ import { ensureDependencies, pipInstallArgs } from "./setup.js";
7
+ let tempDir;
8
+ let requirements;
9
+ const savedEnv = {};
10
+ beforeEach(() => {
11
+ tempDir = mkdtempSync(join(tmpdir(), "aximo-deps-"));
12
+ savedEnv["AXIMO_HOME"] = process.env["AXIMO_HOME"];
13
+ process.env["AXIMO_HOME"] = tempDir;
14
+ requirements = join(tempDir, "requirements.txt");
15
+ writeFileSync(requirements, "pyautogui>=0.9.54\n");
16
+ });
17
+ afterEach(() => {
18
+ for (const [key, value] of Object.entries(savedEnv)) {
19
+ if (value === undefined)
20
+ delete process.env[key];
21
+ else
22
+ process.env[key] = value;
23
+ }
24
+ rmSync(tempDir, { recursive: true, force: true });
25
+ });
26
+ describe("pipInstallArgs", () => {
27
+ it("resolves from PyPI when no wheel directory is given", () => {
28
+ const args = pipInstallArgs("/tmp/requirements.txt");
29
+ expect(args).toEqual([
30
+ "-m",
31
+ "pip",
32
+ "install",
33
+ "--disable-pip-version-check",
34
+ "--no-warn-script-location",
35
+ "-r",
36
+ "/tmp/requirements.txt",
37
+ ]);
38
+ });
39
+ it("pins pip to a local wheel directory and blocks the index entirely", () => {
40
+ const args = pipInstallArgs("/tmp/requirements.txt", "/payload/wheels");
41
+ expect(args).toContain("--no-index");
42
+ expect(args.slice(args.indexOf("--find-links"), args.indexOf("--find-links") + 2)).toEqual([
43
+ "--find-links",
44
+ "/payload/wheels",
45
+ ]);
46
+ // -r must still come last so the requirements file drives resolution.
47
+ expect(args.slice(-2)).toEqual(["-r", "/tmp/requirements.txt"]);
48
+ });
49
+ });
50
+ describe("ensureDependencies", () => {
51
+ it("installs offline and records the requirements hash", () => {
52
+ const calls = [];
53
+ ensureDependencies("/py", requirements, {
54
+ wheelsDir: "/payload/wheels",
55
+ exec: (_file, args) => {
56
+ calls.push(args);
57
+ return { status: 0 };
58
+ },
59
+ });
60
+ expect(calls).toHaveLength(1);
61
+ expect(calls[0]).toContain("--no-index");
62
+ expect(existsSync(getDepsMarkerPath())).toBe(true);
63
+ });
64
+ it("skips the install when the requirements have not changed", () => {
65
+ let runs = 0;
66
+ const exec = () => {
67
+ runs++;
68
+ return { status: 0 };
69
+ };
70
+ ensureDependencies("/py", requirements, { exec });
71
+ ensureDependencies("/py", requirements, { exec });
72
+ expect(runs).toBe(1);
73
+ });
74
+ it("reinstalls when the requirements change", () => {
75
+ let runs = 0;
76
+ const exec = () => {
77
+ runs++;
78
+ return { status: 0 };
79
+ };
80
+ ensureDependencies("/py", requirements, { exec });
81
+ writeFileSync(requirements, "pyautogui>=0.9.54\nmss>=10.0\n");
82
+ ensureDependencies("/py", requirements, { exec });
83
+ expect(runs).toBe(2);
84
+ });
85
+ it("blames a stale payload when an offline install cannot be satisfied", () => {
86
+ expect(() => ensureDependencies("/py", requirements, {
87
+ wheelsDir: "/payload/wheels",
88
+ exec: () => ({ status: 1 }),
89
+ })).toThrow(/older than the requirements/);
90
+ });
91
+ it("names the wheel directory in the offline failure so it can be checked", () => {
92
+ expect(() => ensureDependencies("/py", requirements, {
93
+ wheelsDir: "/payload/wheels",
94
+ exec: () => ({ status: 1 }),
95
+ })).toThrow(/\/payload\/wheels/);
96
+ });
97
+ it("keeps the plain failure message for an online install", () => {
98
+ expect(() => ensureDependencies("/py", requirements, { exec: () => ({ status: 1 }) })).toThrow(/^pip install failed \(exit 1\)$/);
99
+ });
100
+ it("does not record the marker when the install fails", () => {
101
+ expect(() => ensureDependencies("/py", requirements, { exec: () => ({ status: 1 }) })).toThrow();
102
+ expect(existsSync(getDepsMarkerPath())).toBe(false);
103
+ });
104
+ it("rejects a missing requirements file", () => {
105
+ expect(() => ensureDependencies("/py", join(tempDir, "nope.txt"))).toThrow(/requirements.txt not found/);
106
+ });
107
+ it("records the hash of the requirements it actually installed", () => {
108
+ ensureDependencies("/py", requirements, { exec: () => ({ status: 0 }) });
109
+ const marker = readFileSync(getDepsMarkerPath(), "utf-8").trim();
110
+ expect(marker).toMatch(/^[0-9a-f]{64}$/);
111
+ });
112
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,272 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { getPythonDir, getPythonMarkerPath } from "../commands/device/paths.js";
8
+ import { installedPythonTag, installPythonArchive, pythonArchiveName } from "./setup.js";
9
+ let tempDir;
10
+ const savedEnv = {};
11
+ /** Build a tar.gz shaped like python-build-standalone's install_only archive. */
12
+ function writeFakeArchive(dest, layoutDir = "python") {
13
+ const src = mkdtempSync(join(tmpdir(), "aximo-fake-py-"));
14
+ try {
15
+ const root = join(src, layoutDir);
16
+ mkdirSync(join(root, "bin"), { recursive: true });
17
+ writeFileSync(join(root, "bin", "python3"), "#!/bin/sh\n");
18
+ writeFileSync(join(root, "python.exe"), "MZ");
19
+ execFileSync("tar", ["-czf", dest, "-C", src, layoutDir]);
20
+ }
21
+ finally {
22
+ rmSync(src, { recursive: true, force: true });
23
+ }
24
+ }
25
+ beforeEach(() => {
26
+ tempDir = mkdtempSync(join(tmpdir(), "aximo-setup-py-"));
27
+ for (const key of ["AXIMO_HOME", "AXIMO_OFFLINE_DIR"]) {
28
+ savedEnv[key] = process.env[key];
29
+ delete process.env[key];
30
+ }
31
+ process.env["AXIMO_HOME"] = tempDir;
32
+ });
33
+ afterEach(() => {
34
+ for (const [key, value] of Object.entries(savedEnv)) {
35
+ if (value === undefined)
36
+ delete process.env[key];
37
+ else
38
+ process.env[key] = value;
39
+ }
40
+ rmSync(tempDir, { recursive: true, force: true });
41
+ });
42
+ describe("pythonArchiveName", () => {
43
+ it("matches the filename python-build-standalone publishes", () => {
44
+ const name = pythonArchiveName({
45
+ target: "x86_64-pc-windows-msvc",
46
+ sha256: "irrelevant",
47
+ });
48
+ expect(name).toBe("cpython-3.12.13+20260414-x86_64-pc-windows-msvc-install_only.tar.gz");
49
+ });
50
+ });
51
+ describe("installPythonArchive", () => {
52
+ it("installs from a local archive and stamps the version marker", () => {
53
+ const archive = join(tempDir, "python.tar.gz");
54
+ writeFakeArchive(archive);
55
+ installPythonArchive(archive, { keepArchive: true });
56
+ expect(existsSync(join(getPythonDir(), "bin", "python3"))).toBe(true);
57
+ expect(readFileSync(getPythonMarkerPath(), "utf-8")).toBe(installedPythonTag());
58
+ });
59
+ it("keeps a staged archive in place so a shared payload survives setup", () => {
60
+ const archive = join(tempDir, "python.tar.gz");
61
+ writeFakeArchive(archive);
62
+ installPythonArchive(archive, { keepArchive: true });
63
+ expect(existsSync(archive)).toBe(true);
64
+ });
65
+ it("removes an archive it downloaded itself", () => {
66
+ const archive = join(tempDir, "python.tar.gz");
67
+ writeFakeArchive(archive);
68
+ installPythonArchive(archive, { keepArchive: false });
69
+ expect(existsSync(archive)).toBe(false);
70
+ });
71
+ it("replaces an existing interpreter rather than merging into it", () => {
72
+ const stalePath = join(getPythonDir(), "stale-file");
73
+ mkdirSync(getPythonDir(), { recursive: true });
74
+ writeFileSync(stalePath, "old");
75
+ const archive = join(tempDir, "python.tar.gz");
76
+ writeFakeArchive(archive);
77
+ installPythonArchive(archive, { keepArchive: true });
78
+ expect(existsSync(stalePath)).toBe(false);
79
+ expect(existsSync(join(getPythonDir(), "bin", "python3"))).toBe(true);
80
+ });
81
+ it("rejects an archive whose layout is not a python/ root", () => {
82
+ const archive = join(tempDir, "python.tar.gz");
83
+ writeFakeArchive(archive, "cpython-3.12.13");
84
+ expect(() => installPythonArchive(archive, { keepArchive: true })).toThrow(/Unexpected archive layout/);
85
+ });
86
+ });
87
+ describe("acquirePythonTarball fallback diagnostics", () => {
88
+ // Found by local air-gap testing: when the server offers no interpreter the CLI
89
+ // falls back to GitHub, and on an air-gapped network that produced a bare
90
+ // "fetch failed" with no hint that the missing server payload was the cause.
91
+ it("explains a failed GitHub fallback instead of surfacing a bare network error", async () => {
92
+ const script = 'print("x")\n';
93
+ const requirements = "mss>=10.0\n";
94
+ const sha = (v) => createHash("sha256").update(v).digest("hex");
95
+ const manifest = {
96
+ manifestVersion: 3,
97
+ version: "abcd1234",
98
+ script: { sha256: sha(script), size: Buffer.byteLength(script) },
99
+ requirements: { sha256: sha(requirements), size: Buffer.byteLength(requirements) },
100
+ // Deliberately no python/wheels: this is a server without the CLI bundle.
101
+ };
102
+ const fetchImpl = ((input) => {
103
+ const url = String(input);
104
+ if (url.endsWith("/manifest")) {
105
+ return Promise.resolve(new Response(JSON.stringify(manifest), {
106
+ status: 200,
107
+ headers: { "Content-Type": "application/json" },
108
+ }));
109
+ }
110
+ if (url.endsWith("remote.py"))
111
+ return Promise.resolve(new Response(script, { status: 200 }));
112
+ if (url.endsWith("requirements.txt")) {
113
+ return Promise.resolve(new Response(requirements, { status: 200 }));
114
+ }
115
+ return Promise.reject(new Error("unexpected request"));
116
+ });
117
+ // The GitHub download uses global fetch; make the air gap real.
118
+ const originalFetch = globalThis.fetch;
119
+ globalThis.fetch = (() => Promise.reject(new Error("fetch failed")));
120
+ try {
121
+ const { ensurePythonReady } = await import("./setup.js");
122
+ await expect(ensurePythonReady({ baseUrl: "https://aximo.test", apiKey: "k", fetchImpl })).rejects.toThrow(/offered no embedded Python/);
123
+ }
124
+ finally {
125
+ globalThis.fetch = originalFetch;
126
+ }
127
+ });
128
+ // The same GitHub path is the normal one for cloud, where a transient failure
129
+ // has nothing to do with on-prem bundles. Telling a cloud user their server
130
+ // needs a CLI bundle would be actively misleading.
131
+ it("stays quiet about on-prem bundles when pointed at cloud", async () => {
132
+ const script = 'print("x")\n';
133
+ const requirements = "mss>=10.0\n";
134
+ const sha = (v) => createHash("sha256").update(v).digest("hex");
135
+ const manifest = {
136
+ manifestVersion: 2,
137
+ version: "abcd1234",
138
+ script: { sha256: sha(script), size: Buffer.byteLength(script) },
139
+ requirements: { sha256: sha(requirements), size: Buffer.byteLength(requirements) },
140
+ };
141
+ const fetchImpl = ((input) => {
142
+ const url = String(input);
143
+ if (url.endsWith("/manifest")) {
144
+ return Promise.resolve(new Response(JSON.stringify(manifest), {
145
+ status: 200,
146
+ headers: { "Content-Type": "application/json" },
147
+ }));
148
+ }
149
+ if (url.endsWith("remote.py"))
150
+ return Promise.resolve(new Response(script, { status: 200 }));
151
+ if (url.endsWith("requirements.txt")) {
152
+ return Promise.resolve(new Response(requirements, { status: 200 }));
153
+ }
154
+ return Promise.reject(new Error("unexpected request"));
155
+ });
156
+ const originalFetch = globalThis.fetch;
157
+ globalThis.fetch = (() => Promise.reject(new Error("fetch failed")));
158
+ try {
159
+ const { ensurePythonReady } = await import("./setup.js");
160
+ const { DEFAULT_BASE_URL } = await import("../client.js");
161
+ await expect(ensurePythonReady({ baseUrl: DEFAULT_BASE_URL, apiKey: "k", fetchImpl })).rejects.toThrow(/^fetch failed$/);
162
+ }
163
+ finally {
164
+ globalThis.fetch = originalFetch;
165
+ }
166
+ });
167
+ });
168
+ describe("interpreter source precedence", () => {
169
+ // README and ADR-003 both promise staged -> server -> GitHub. Each source was
170
+ // tested alone; the ordering between them was not.
171
+ const ARCHIVE = pythonArchiveName();
172
+ const BODY = "staged-archive-bytes";
173
+ function manifestOffering(archive) {
174
+ const sha = (v) => createHash("sha256").update(v).digest("hex");
175
+ const script = 'print("x")\n';
176
+ const requirements = "mss>=10.0\n";
177
+ return {
178
+ manifest: {
179
+ manifestVersion: 2,
180
+ version: "abcd1234",
181
+ script: { sha256: sha(script), size: Buffer.byteLength(script) },
182
+ requirements: { sha256: sha(requirements), size: Buffer.byteLength(requirements) },
183
+ python: {
184
+ [`${process.platform}-${process.arch}`]: {
185
+ archive,
186
+ path: `python/x/${archive}`,
187
+ sha256: sha("server-bytes"),
188
+ size: Buffer.byteLength("server-bytes"),
189
+ },
190
+ },
191
+ },
192
+ script,
193
+ requirements,
194
+ };
195
+ }
196
+ it("prefers a staged archive over the server's", async () => {
197
+ const staged = join(tempDir, "staged");
198
+ mkdirSync(staged, { recursive: true });
199
+ writeFileSync(join(staged, ARCHIVE), BODY);
200
+ process.env["AXIMO_OFFLINE_DIR"] = staged;
201
+ const { manifest, script, requirements } = manifestOffering(ARCHIVE);
202
+ let serverAssetRequested = false;
203
+ const fetchImpl = ((input) => {
204
+ const url = String(input);
205
+ if (url.endsWith("/manifest")) {
206
+ return Promise.resolve(new Response(JSON.stringify(manifest), {
207
+ status: 200,
208
+ headers: { "Content-Type": "application/json" },
209
+ }));
210
+ }
211
+ if (url.endsWith("remote.py"))
212
+ return Promise.resolve(new Response(script, { status: 200 }));
213
+ if (url.endsWith("requirements.txt")) {
214
+ return Promise.resolve(new Response(requirements, { status: 200 }));
215
+ }
216
+ if (url.includes("/bundle/python/"))
217
+ serverAssetRequested = true;
218
+ return Promise.reject(new Error("unexpected request"));
219
+ });
220
+ const originalFetch = globalThis.fetch;
221
+ globalThis.fetch = (() => Promise.reject(new Error("github must not be reached")));
222
+ try {
223
+ const { ensurePythonReady } = await import("./setup.js");
224
+ // Fails on the staged archive's checksum, which proves it got that far with
225
+ // the staged file — and never asked the server or GitHub for one.
226
+ await expect(ensurePythonReady({ baseUrl: "https://aximo.test", apiKey: "k", fetchImpl })).rejects.toThrow(new RegExp(staged.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
227
+ expect(serverAssetRequested).toBe(false);
228
+ }
229
+ finally {
230
+ globalThis.fetch = originalFetch;
231
+ delete process.env["AXIMO_OFFLINE_DIR"];
232
+ }
233
+ });
234
+ it("prefers the server over GitHub when it offers a matching archive", async () => {
235
+ const { manifest, script, requirements } = manifestOffering(ARCHIVE);
236
+ let serverAssetRequested = false;
237
+ const fetchImpl = ((input) => {
238
+ const url = String(input);
239
+ if (url.endsWith("/manifest")) {
240
+ return Promise.resolve(new Response(JSON.stringify(manifest), {
241
+ status: 200,
242
+ headers: { "Content-Type": "application/json" },
243
+ }));
244
+ }
245
+ if (url.endsWith("remote.py"))
246
+ return Promise.resolve(new Response(script, { status: 200 }));
247
+ if (url.endsWith("requirements.txt")) {
248
+ return Promise.resolve(new Response(requirements, { status: 200 }));
249
+ }
250
+ if (url.includes("/bundle/python/")) {
251
+ serverAssetRequested = true;
252
+ return Promise.resolve(new Response("server-bytes", { status: 200 }));
253
+ }
254
+ return Promise.reject(new Error("unexpected request"));
255
+ });
256
+ const originalFetch = globalThis.fetch;
257
+ let githubReached = false;
258
+ globalThis.fetch = (() => {
259
+ githubReached = true;
260
+ return Promise.reject(new Error("fetch failed"));
261
+ });
262
+ try {
263
+ const { ensurePythonReady } = await import("./setup.js");
264
+ await expect(ensurePythonReady({ baseUrl: "https://aximo.test", apiKey: "k", fetchImpl })).rejects.toThrow(/SHA256 mismatch/);
265
+ expect(serverAssetRequested).toBe(true);
266
+ expect(githubReached).toBe(false);
267
+ }
268
+ finally {
269
+ globalThis.fetch = originalFetch;
270
+ }
271
+ });
272
+ });
@@ -1,8 +1,33 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
- import type { ClientDeps } from "../client.js";
2
+ import { type ClientDeps } from "../client.js";
3
3
  export declare const PYTHON_RELEASE = "20260414";
4
4
  export declare const PYTHON_VERSION = "3.12.13";
5
+ interface PythonBuild {
6
+ target: string;
7
+ sha256: string;
8
+ }
9
+ /** Filename python-build-standalone publishes for this build, also used on disk. */
10
+ export declare function pythonArchiveName(build?: PythonBuild): string;
5
11
  export declare function installedPythonTag(): string;
12
+ /**
13
+ * Extract an already-verified Python archive into place and stamp the markers.
14
+ *
15
+ * Callers are responsible for integrity: by the time an archive reaches here it
16
+ * must have been checked against the expected SHA256.
17
+ */
18
+ export declare function installPythonArchive(tarball: string, { keepArchive }: {
19
+ keepArchive: boolean;
20
+ }): void;
21
+ /** pip argv for the agent's requirements, resolving from wheelsDir when given. */
22
+ export declare function pipInstallArgs(requirementsPath: string, wheelsDir?: string): string[];
23
+ export interface DependencyOptions {
24
+ /** Local wheel directory to resolve from instead of PyPI; undefined means online. */
25
+ wheelsDir?: string | undefined;
26
+ exec?: (file: string, args: string[]) => {
27
+ status: number | null;
28
+ };
29
+ }
30
+ export declare function ensureDependencies(python: string, requirementsPath: string, options?: DependencyOptions): void;
6
31
  export interface PythonReadyResult {
7
32
  pythonPath: string;
8
33
  scriptPath: string;
@@ -1,12 +1,15 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
4
- import { arch as osArch, platform } from "node:os";
5
4
  import { join } from "node:path";
6
5
  import { Readable } from "node:stream";
7
6
  import { pipeline } from "node:stream/promises";
7
+ import { DEFAULT_BASE_URL, getBaseUrl } from "../client.js";
8
8
  import { getAgentCaBundlePath, getAximoDir, getCacheDir, getDepsMarkerPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, getRemoteLogPath, } from "../commands/device/paths.js";
9
9
  import { ensureAgent } from "./agent-sync.js";
10
+ import { warnIfCliOutdated } from "./cli-version.js";
11
+ import { findOfflinePythonArchive, findOfflineWheelsDir } from "./offline.js";
12
+ import { downloadServerPython, ensureServerWheels, platformKey, resolveServerPython, } from "./bundle-sync.js";
10
13
  export const PYTHON_RELEASE = "20260414";
11
14
  export const PYTHON_VERSION = "3.12.13";
12
15
  const BUILDS = {
@@ -36,16 +39,19 @@ const BUILDS = {
36
39
  },
37
40
  };
38
41
  function selectBuild() {
39
- const key = `${platform()}-${osArch()}`;
42
+ const key = platformKey();
40
43
  const build = BUILDS[key];
41
44
  if (!build) {
42
45
  throw new Error(`Unsupported platform/arch: ${key}`);
43
46
  }
44
47
  return build;
45
48
  }
49
+ /** Filename python-build-standalone publishes for this build, also used on disk. */
50
+ export function pythonArchiveName(build = selectBuild()) {
51
+ return `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}-${build.target}-install_only.tar.gz`;
52
+ }
46
53
  function buildDownloadUrl(build) {
47
- const filename = `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}-${build.target}-install_only.tar.gz`;
48
- return `https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RELEASE}/${filename}`;
54
+ return `https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RELEASE}/${pythonArchiveName(build)}`;
49
55
  }
50
56
  export function installedPythonTag() {
51
57
  return `${PYTHON_RELEASE}-${PYTHON_VERSION}`;
@@ -92,23 +98,93 @@ function extractTarball(tarball, destDir) {
92
98
  throw new Error(`tar extraction failed (exit ${result.status})`);
93
99
  }
94
100
  }
95
- async function installEmbeddedPython() {
101
+ /**
102
+ * Resolve a verified Python archive from the nearest available source.
103
+ *
104
+ * Order is staged on disk, then the Aximo server, then GitHub. Every branch
105
+ * checks the same compiled-in SHA256, so nothing gets a weaker guarantee for
106
+ * being local — and because that constant ships with the CLI, a mismatched
107
+ * archive from any source is caught.
108
+ */
109
+ async function acquirePythonTarball(build, manifest, deps) {
110
+ const archiveName = pythonArchiveName(build);
111
+ const staged = findOfflinePythonArchive(archiveName);
112
+ if (staged) {
113
+ console.log(`Using staged embedded Python ${PYTHON_VERSION} (${build.target})...`);
114
+ // Never delete a staged archive — it belongs to the user, not the cache.
115
+ verifyArchive(staged, build.sha256, { removeOnMismatch: false });
116
+ return { path: staged, ephemeral: false };
117
+ }
118
+ const served = manifest && resolveServerPython(manifest, archiveName);
119
+ if (served) {
120
+ const path = await downloadServerPython(served, deps ?? {});
121
+ verifyArchive(path, build.sha256, { removeOnMismatch: true });
122
+ // Kept in the cache: re-running setup on the same machine then costs nothing,
123
+ // which matters most on the slow internal links these servers often sit on.
124
+ return { path, ephemeral: false };
125
+ }
126
+ const path = join(getCacheDir(), `python-${installedPythonTag()}.tar.gz`);
127
+ console.log(`Fetching embedded Python ${PYTHON_VERSION} (${build.target})...`);
128
+ try {
129
+ await downloadTarball(buildDownloadUrl(build), path, build.sha256);
130
+ }
131
+ catch (err) {
132
+ throw new Error(`${err instanceof Error ? err.message : String(err)}${fallbackHint(deps)}`);
133
+ }
134
+ return { path, ephemeral: true };
135
+ }
136
+ /**
137
+ * Extra context for a failed GitHub download, when it would actually help.
138
+ *
139
+ * Reaching GitHub means the server offered nothing for this platform. Air-gapped
140
+ * that can never succeed, and the bare network error gives no hint that a missing
141
+ * server-side bundle is the cause. But this same path is the normal one for cloud,
142
+ * where a transient GitHub failure has nothing to do with on-prem bundles — so
143
+ * only mention them when the CLI is pointed somewhere other than cloud.
144
+ */
145
+ function fallbackHint(deps) {
146
+ const baseUrl = deps?.baseUrl ?? getBaseUrl();
147
+ if (baseUrl === DEFAULT_BASE_URL)
148
+ return "";
149
+ return (`\n The Aximo server at ${baseUrl} offered no embedded Python for ${platformKey()}, ` +
150
+ "so this fell back to downloading from GitHub.\n" +
151
+ " If this machine has no internet access, that server needs the CLI bundle from its " +
152
+ "on-prem release, or stage the archive locally and set AXIMO_OFFLINE_DIR.");
153
+ }
154
+ /**
155
+ * Check an archive against the CLI's own compiled-in checksum.
156
+ *
157
+ * `removeOnMismatch` matters for recovery, not tidiness: a cached download still
158
+ * matches the manifest that produced it, so leaving a rejected file in place makes
159
+ * every later run fail identically until someone clears the cache by hand. A
160
+ * staged archive belongs to the user, so that one is never removed.
161
+ */
162
+ function verifyArchive(path, expectedSha256, { removeOnMismatch }) {
163
+ const actual = hashFile(path);
164
+ if (actual === expectedSha256)
165
+ return;
166
+ if (removeOnMismatch)
167
+ rmSync(path, { force: true });
168
+ throw new Error(`SHA256 mismatch for ${path}\n expected ${expectedSha256}\n actual ${actual}\n` +
169
+ " This CLI and the interpreter it was given do not match. Install the aximo-cli " +
170
+ "that ships with your current Aximo release.");
171
+ }
172
+ /**
173
+ * Extract an already-verified Python archive into place and stamp the markers.
174
+ *
175
+ * Callers are responsible for integrity: by the time an archive reaches here it
176
+ * must have been checked against the expected SHA256.
177
+ */
178
+ export function installPythonArchive(tarball, { keepArchive }) {
96
179
  const pythonDir = getPythonDir();
97
180
  const cacheDir = getCacheDir();
98
- const pythonMarker = getPythonMarkerPath();
99
- const depsMarker = getDepsMarkerPath();
100
181
  if (existsSync(pythonDir)) {
101
182
  rmSync(pythonDir, { recursive: true, force: true });
102
183
  }
103
- const build = selectBuild();
104
- const url = buildDownloadUrl(build);
105
184
  mkdirSync(cacheDir, { recursive: true });
106
- const tarball = join(cacheDir, `python-${installedPythonTag()}.tar.gz`);
107
185
  const staging = join(cacheDir, "python-staging");
108
186
  if (existsSync(staging))
109
187
  rmSync(staging, { recursive: true, force: true });
110
- console.log(`Fetching embedded Python ${PYTHON_VERSION} (${build.target})...`);
111
- await downloadTarball(url, tarball, build.sha256);
112
188
  console.log("Extracting...");
113
189
  extractTarball(tarball, staging);
114
190
  const extracted = join(staging, "python");
@@ -118,25 +194,47 @@ async function installEmbeddedPython() {
118
194
  mkdirSync(getAximoDir(), { recursive: true });
119
195
  renameSync(extracted, pythonDir);
120
196
  rmSync(staging, { recursive: true, force: true });
121
- rmSync(tarball, { force: true });
122
- writeFileSync(pythonMarker, installedPythonTag(), { mode: 0o644 });
197
+ // A staged or server-cached archive is kept: it belongs to the user, or paying
198
+ // the download again on the next run costs more than the disk.
199
+ if (!keepArchive)
200
+ rmSync(tarball, { force: true });
201
+ writeFileSync(getPythonMarkerPath(), installedPythonTag(), { mode: 0o644 });
202
+ const depsMarker = getDepsMarkerPath();
123
203
  if (existsSync(depsMarker))
124
204
  rmSync(depsMarker, { force: true });
125
205
  }
126
- async function ensurePython() {
206
+ async function installEmbeddedPython(manifest, deps) {
207
+ mkdirSync(getCacheDir(), { recursive: true });
208
+ const source = await acquirePythonTarball(selectBuild(), manifest, deps);
209
+ installPythonArchive(source.path, { keepArchive: !source.ephemeral });
210
+ }
211
+ async function ensurePython(manifest, deps) {
127
212
  const binary = getEmbeddedPythonBinary();
128
213
  const markerPath = getPythonMarkerPath();
129
214
  const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf-8").trim() : "";
130
215
  if (marker === installedPythonTag() && existsSync(binary)) {
131
216
  return binary;
132
217
  }
133
- await installEmbeddedPython();
218
+ await installEmbeddedPython(manifest, deps);
134
219
  return binary;
135
220
  }
136
221
  function hashFile(path) {
137
222
  return createHash("sha256").update(readFileSync(path)).digest("hex");
138
223
  }
139
- function ensureDependencies(python, requirementsPath) {
224
+ /** pip argv for the agent's requirements, resolving from wheelsDir when given. */
225
+ export function pipInstallArgs(requirementsPath, wheelsDir) {
226
+ const args = ["-m", "pip", "install", "--disable-pip-version-check", "--no-warn-script-location"];
227
+ // --no-index is what makes this airtight: pip must not reach PyPI even for a
228
+ // single unsatisfied transitive dependency.
229
+ if (wheelsDir)
230
+ args.push("--no-index", "--find-links", wheelsDir);
231
+ args.push("-r", requirementsPath);
232
+ return args;
233
+ }
234
+ function runPip(file, args) {
235
+ return spawnSync(file, args, { stdio: "inherit" });
236
+ }
237
+ export function ensureDependencies(python, requirementsPath, options = {}) {
140
238
  if (!existsSync(requirementsPath)) {
141
239
  throw new Error(`requirements.txt not found at ${requirementsPath}`);
142
240
  }
@@ -145,28 +243,37 @@ function ensureDependencies(python, requirementsPath) {
145
243
  const marker = existsSync(depsMarker) ? readFileSync(depsMarker, "utf-8").trim() : "";
146
244
  if (marker === requirementsHash)
147
245
  return;
148
- console.log("Installing Python dependencies...");
149
- const result = spawnSync(python, [
150
- "-m",
151
- "pip",
152
- "install",
153
- "--disable-pip-version-check",
154
- "--no-warn-script-location",
155
- "-r",
156
- requirementsPath,
157
- ], { stdio: "inherit" });
246
+ const { wheelsDir, exec = runPip } = options;
247
+ console.log(wheelsDir
248
+ ? `Installing Python dependencies from ${wheelsDir}...`
249
+ : "Installing Python dependencies...");
250
+ const result = exec(python, pipInstallArgs(requirementsPath, wheelsDir));
158
251
  if (result.status !== 0) {
252
+ if (wheelsDir) {
253
+ // Offline, so the usual causes (network, index) don't apply — an
254
+ // incomplete wheel set almost always means the bundle predates the
255
+ // requirements the server is now serving.
256
+ throw new Error(`pip install failed (exit ${result.status}). The wheels in ${wheelsDir} do not satisfy ` +
257
+ `${requirementsPath}. This usually means the CLI bundle is older than the requirements ` +
258
+ "your Aximo server is serving — install the CLI bundle from your current on-prem release.");
259
+ }
159
260
  throw new Error(`pip install failed (exit ${result.status})`);
160
261
  }
161
262
  writeFileSync(depsMarker, requirementsHash, { mode: 0o644 });
162
263
  }
163
264
  export async function ensurePythonReady(deps) {
164
- const pythonPath = await ensurePython();
265
+ // The agent manifest comes first: it is what tells us whether the server can
266
+ // supply the interpreter and wheels, which decides where Python comes from.
165
267
  const agent = await ensureAgent(deps);
166
268
  if (agent.changed) {
167
269
  console.log(`Agent script updated to version ${agent.version}`);
168
270
  }
169
- ensureDependencies(pythonPath, agent.requirementsPath);
271
+ warnIfCliOutdated(agent.manifest);
272
+ const pythonPath = await ensurePython(agent.manifest, deps);
273
+ // Staged files win over the server so a machine that cannot reach it at
274
+ // all — or is being prepared ahead of time — still works.
275
+ const wheelsDir = findOfflineWheelsDir() ?? (await ensureServerWheels(agent.manifest, deps));
276
+ ensureDependencies(pythonPath, agent.requirementsPath, { wheelsDir });
170
277
  return {
171
278
  pythonPath,
172
279
  scriptPath: agent.scriptPath,
@@ -0,0 +1,5 @@
1
+ import { type ExecFn } from "./exec.js";
2
+ export declare function isWedgedInstrumentationError(err: unknown): boolean;
3
+ export declare const APPIUM_HELPER_PACKAGES: readonly ["io.appium.uiautomator2.server.test", "io.appium.uiautomator2.server", "io.appium.settings"];
4
+ /** Remove stale Appium helper APKs (best-effort) so the driver reinstalls fresh ones. */
5
+ export declare function uninstallAppiumHelpers(serial: string, exec?: ExecFn): Promise<void>;