@autifyhq/aximo-cli 0.10.1 → 0.11.1
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 +40 -3
- package/dist/cli.js +14 -4
- package/dist/commands/mcp/server.test.js +4 -1
- package/dist/commands/mcp/tools.js +43 -0
- package/dist/commands/mcp/tools.test.js +104 -0
- package/dist/commands/mobile/connect-core.js +1 -1
- package/dist/commands/mobile/doctor.d.ts +20 -3
- package/dist/commands/mobile/doctor.js +180 -13
- package/dist/commands/mobile/doctor.test.js +79 -3
- package/dist/desktop-server/agent-sync.d.ts +21 -1
- package/dist/desktop-server/agent-sync.js +6 -2
- package/dist/desktop-server/bundle-sync.d.ts +25 -0
- package/dist/desktop-server/bundle-sync.js +128 -0
- package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
- package/dist/desktop-server/bundle-sync.test.js +189 -0
- package/dist/desktop-server/cli-version.d.ts +21 -0
- package/dist/desktop-server/cli-version.js +41 -0
- package/dist/desktop-server/cli-version.test.d.ts +1 -0
- package/dist/desktop-server/cli-version.test.js +58 -0
- package/dist/desktop-server/offline.d.ts +6 -0
- package/dist/desktop-server/offline.js +44 -0
- package/dist/desktop-server/offline.test.d.ts +1 -0
- package/dist/desktop-server/offline.test.js +74 -0
- package/dist/desktop-server/setup-deps.test.d.ts +1 -0
- package/dist/desktop-server/setup-deps.test.js +112 -0
- package/dist/desktop-server/setup-python.test.d.ts +1 -0
- package/dist/desktop-server/setup-python.test.js +272 -0
- package/dist/desktop-server/setup.d.ts +26 -1
- package/dist/desktop-server/setup.js +136 -29
- package/dist/mobile-control/android-recovery.d.ts +5 -0
- package/dist/mobile-control/android-recovery.js +19 -0
- package/dist/mobile-control/android-recovery.test.d.ts +1 -0
- package/dist/mobile-control/android-recovery.test.js +33 -0
- package/dist/mobile-control/appium-controller.d.ts +4 -0
- package/dist/mobile-control/appium-controller.js +15 -1
- package/dist/mobile-control/appium-controller.test.js +78 -0
- package/dist/mobile-control/appium-import.js +19 -3
- package/dist/mobile-control/ios-tunnel.d.ts +28 -8
- package/dist/mobile-control/ios-tunnel.js +63 -27
- package/dist/mobile-control/ios-tunnel.test.js +153 -14
- package/dist/mobile-control/mobile-errors.js +6 -0
- package/dist/mobile-control/mobile-errors.test.js +3 -0
- package/dist/types.d.ts +49 -0
- package/package.json +4 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, 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 { findOfflinePythonArchive, findOfflineWheelsDir, getOfflineDir } from "./offline.js";
|
|
6
|
+
let tempDir;
|
|
7
|
+
let savedEnv;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
tempDir = mkdtempSync(join(tmpdir(), "aximo-offline-"));
|
|
10
|
+
savedEnv = process.env["AXIMO_OFFLINE_DIR"];
|
|
11
|
+
delete process.env["AXIMO_OFFLINE_DIR"];
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (savedEnv === undefined)
|
|
15
|
+
delete process.env["AXIMO_OFFLINE_DIR"];
|
|
16
|
+
else
|
|
17
|
+
process.env["AXIMO_OFFLINE_DIR"] = savedEnv;
|
|
18
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
describe("getOfflineDir", () => {
|
|
21
|
+
it("returns undefined when unset", () => {
|
|
22
|
+
expect(getOfflineDir()).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it("treats a blank value as unset so it can force the online path", () => {
|
|
25
|
+
process.env["AXIMO_OFFLINE_DIR"] = " ";
|
|
26
|
+
expect(getOfflineDir()).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
it("returns the configured directory", () => {
|
|
29
|
+
process.env["AXIMO_OFFLINE_DIR"] = tempDir;
|
|
30
|
+
expect(getOfflineDir()).toBe(tempDir);
|
|
31
|
+
});
|
|
32
|
+
it("throws on a path that is not a directory rather than silently going online", () => {
|
|
33
|
+
const file = join(tempDir, "not-a-dir");
|
|
34
|
+
writeFileSync(file, "x");
|
|
35
|
+
process.env["AXIMO_OFFLINE_DIR"] = file;
|
|
36
|
+
expect(() => getOfflineDir()).toThrow(/not a directory/);
|
|
37
|
+
});
|
|
38
|
+
it("throws when the directory is missing", () => {
|
|
39
|
+
process.env["AXIMO_OFFLINE_DIR"] = join(tempDir, "nope");
|
|
40
|
+
expect(() => getOfflineDir()).toThrow(/not a directory/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("findOfflinePythonArchive", () => {
|
|
44
|
+
const archive = "cpython-3.12.13+20260414-x86_64-pc-windows-msvc-install_only.tar.gz";
|
|
45
|
+
it("returns undefined when no payload directory is configured", () => {
|
|
46
|
+
expect(findOfflinePythonArchive(archive)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
it("returns undefined when the archive for this platform is absent", () => {
|
|
49
|
+
process.env["AXIMO_OFFLINE_DIR"] = tempDir;
|
|
50
|
+
writeFileSync(join(tempDir, "cpython-other-target.tar.gz"), "x");
|
|
51
|
+
expect(findOfflinePythonArchive(archive)).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
it("returns the archive path when present", () => {
|
|
54
|
+
process.env["AXIMO_OFFLINE_DIR"] = tempDir;
|
|
55
|
+
const path = join(tempDir, archive);
|
|
56
|
+
writeFileSync(path, "x");
|
|
57
|
+
expect(findOfflinePythonArchive(archive)).toBe(path);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("findOfflineWheelsDir", () => {
|
|
61
|
+
it("returns undefined when no payload directory is configured", () => {
|
|
62
|
+
expect(findOfflineWheelsDir()).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
it("returns undefined when the payload has no wheels directory", () => {
|
|
65
|
+
process.env["AXIMO_OFFLINE_DIR"] = tempDir;
|
|
66
|
+
expect(findOfflineWheelsDir()).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
it("returns the wheels directory when present", () => {
|
|
69
|
+
process.env["AXIMO_OFFLINE_DIR"] = tempDir;
|
|
70
|
+
const wheels = join(tempDir, "wheels");
|
|
71
|
+
mkdirSync(wheels);
|
|
72
|
+
expect(findOfflineWheelsDir()).toBe(wheels);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
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;
|