@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.
- package/README.md +39 -2
- 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/doctor.d.ts +10 -0
- package/dist/commands/mobile/doctor.js +149 -1
- package/dist/commands/mobile/doctor.test.js +66 -1
- 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/types.d.ts +49 -0
- package/package.json +4 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { arch as osArch, platform } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { requestBuffer, requestToFile } from "../client.js";
|
|
6
|
+
import { getCacheDir } from "../commands/device/paths.js";
|
|
7
|
+
/**
|
|
8
|
+
* Fetch the on-prem CLI bundle assets from the Aximo server.
|
|
9
|
+
*
|
|
10
|
+
* An air-gapped device cannot reach GitHub or PyPI, but it can always reach the
|
|
11
|
+
* server — registering and running a test go through it. When the server's
|
|
12
|
+
* manifest advertises an embedded Python archive and wheels for this platform,
|
|
13
|
+
* setup takes them from there instead.
|
|
14
|
+
*/
|
|
15
|
+
const BUNDLE_BASE_PATH = "/api/devices/agent/bundle";
|
|
16
|
+
/** Key the manifest uses for per-platform assets, e.g. win32-x64. */
|
|
17
|
+
export function platformKey() {
|
|
18
|
+
return `${platform()}-${osArch()}`;
|
|
19
|
+
}
|
|
20
|
+
function bundlePath(info) {
|
|
21
|
+
return `${BUNDLE_BASE_PATH}/${info.path}`;
|
|
22
|
+
}
|
|
23
|
+
function hashFile(path) {
|
|
24
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
function fileMatches(path, expected) {
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return false;
|
|
29
|
+
return statSync(path).size === expected.size && hashFile(path) === expected.sha256;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The server's Python archive for this platform, when it matches what this CLI
|
|
33
|
+
* expects.
|
|
34
|
+
*
|
|
35
|
+
* An archive name encodes the interpreter version and target, so comparing it to
|
|
36
|
+
* the name the CLI would have downloaded is the version check: a server offering
|
|
37
|
+
* a different interpreter than this CLI is pinned to is ignored rather than
|
|
38
|
+
* installed, because the CLI's compiled-in SHA256 would reject it anyway.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveServerPython(manifest, expectedArchive) {
|
|
41
|
+
const info = manifest.python?.[platformKey()];
|
|
42
|
+
if (!info)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (info.archive === expectedArchive)
|
|
45
|
+
return info;
|
|
46
|
+
// Say so rather than falling through quietly: air-gapped, the fallback is a
|
|
47
|
+
// GitHub download that cannot succeed, and the resulting network error would
|
|
48
|
+
// give no hint that the real problem is a version mismatch.
|
|
49
|
+
console.error(`[aximo] The Aximo server offers ${info.archive}, but this CLI is pinned to ` +
|
|
50
|
+
`${expectedArchive}. Ignoring the server's interpreter — install the aximo-cli ` +
|
|
51
|
+
"that ships with your current Aximo release.");
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** Download the server's Python archive into the cache and verify it. */
|
|
55
|
+
export async function downloadServerPython(info, deps = {}) {
|
|
56
|
+
const cacheDir = getCacheDir();
|
|
57
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
58
|
+
const dest = join(cacheDir, info.archive);
|
|
59
|
+
if (fileMatches(dest, info))
|
|
60
|
+
return dest;
|
|
61
|
+
console.log(`Fetching embedded Python from the Aximo server (${info.archive})...`);
|
|
62
|
+
await requestToFile(bundlePath(info), dest, deps);
|
|
63
|
+
// Size first: the download streams straight to its final path, so an interrupted
|
|
64
|
+
// transfer leaves a short file. Checking length separately keeps that from being
|
|
65
|
+
// reported as the server serving the wrong bytes.
|
|
66
|
+
const size = statSync(dest).size;
|
|
67
|
+
if (size !== info.size) {
|
|
68
|
+
rmSync(dest, { force: true });
|
|
69
|
+
throw new Error(`Incomplete download of ${info.path}: expected ${info.size} bytes, got ${size}. ` +
|
|
70
|
+
"Re-run `aximo device setup` to retry.");
|
|
71
|
+
}
|
|
72
|
+
const actual = hashFile(dest);
|
|
73
|
+
if (actual !== info.sha256) {
|
|
74
|
+
rmSync(dest, { force: true });
|
|
75
|
+
throw new Error(`SHA256 mismatch for ${info.path} served by the Aximo server\n` +
|
|
76
|
+
` expected ${info.sha256}\n actual ${actual}`);
|
|
77
|
+
}
|
|
78
|
+
return dest;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Mirror the server's wheels for this platform into the cache.
|
|
82
|
+
*
|
|
83
|
+
* Returns the directory to point pip at, or undefined when the server offers no
|
|
84
|
+
* wheels for this platform. Files the manifest no longer lists are removed:
|
|
85
|
+
* a leftover wheel from an older release could otherwise satisfy a requirement
|
|
86
|
+
* with the wrong version.
|
|
87
|
+
*/
|
|
88
|
+
export async function ensureServerWheels(manifest, deps = {}) {
|
|
89
|
+
const wheels = manifest.wheels?.[platformKey()];
|
|
90
|
+
if (!wheels || wheels.length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
const dir = join(getCacheDir(), "wheels", platformKey());
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
// basename from node:path, not a hand-rolled split: on Windows it also strips
|
|
95
|
+
// backslash segments, so a manifest path cannot walk out of the cache dir.
|
|
96
|
+
const expected = new Map();
|
|
97
|
+
for (const wheel of wheels) {
|
|
98
|
+
const name = basename(wheel.path);
|
|
99
|
+
if (!name.endsWith(".whl") || name.includes("/") || name.includes("\\")) {
|
|
100
|
+
throw new Error(`Refusing to write wheel with unexpected filename: ${wheel.path}`);
|
|
101
|
+
}
|
|
102
|
+
expected.set(name, wheel);
|
|
103
|
+
}
|
|
104
|
+
for (const name of readdirSync(dir)) {
|
|
105
|
+
if (!expected.has(name))
|
|
106
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
let fetched = 0;
|
|
109
|
+
for (const [name, wheel] of expected) {
|
|
110
|
+
const dest = join(dir, name);
|
|
111
|
+
if (fileMatches(dest, wheel))
|
|
112
|
+
continue;
|
|
113
|
+
const buffer = await requestBuffer(bundlePath(wheel), deps);
|
|
114
|
+
if (buffer.byteLength !== wheel.size) {
|
|
115
|
+
throw new Error(`Wheel ${wheel.path} size mismatch: expected ${wheel.size} bytes, got ${buffer.byteLength}`);
|
|
116
|
+
}
|
|
117
|
+
const actual = createHash("sha256").update(buffer).digest("hex");
|
|
118
|
+
if (actual !== wheel.sha256) {
|
|
119
|
+
throw new Error(`Wheel ${wheel.path} sha256 mismatch: expected ${wheel.sha256}, got ${actual}`);
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(dest, buffer, { mode: 0o644 });
|
|
122
|
+
fetched++;
|
|
123
|
+
}
|
|
124
|
+
if (fetched > 0) {
|
|
125
|
+
console.log(`Fetched ${fetched} Python package(s) from the Aximo server.`);
|
|
126
|
+
}
|
|
127
|
+
return dir;
|
|
128
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { getCacheDir } from "../commands/device/paths.js";
|
|
7
|
+
import { http, HttpResponse, server, TEST_BASE_URL } from "../test-utils.js";
|
|
8
|
+
import { downloadServerPython, ensureServerWheels, platformKey, resolveServerPython, } from "./bundle-sync.js";
|
|
9
|
+
let tempHome;
|
|
10
|
+
const ARCHIVE = "cpython-3.12.13+20260414-x86_64-pc-windows-msvc-install_only.tar.gz";
|
|
11
|
+
const ARCHIVE_BODY = "fake-python-archive";
|
|
12
|
+
const WHEEL_BODY = "fake-wheel-bytes";
|
|
13
|
+
function sha256(value) {
|
|
14
|
+
return createHash("sha256").update(value).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
/** Manifest advertising a payload for whatever platform the test runs on. */
|
|
17
|
+
function manifestWithPayload(overrides = {}) {
|
|
18
|
+
const platform = overrides.platform ?? platformKey();
|
|
19
|
+
const archive = overrides.archive ?? ARCHIVE;
|
|
20
|
+
const wheelNames = overrides.wheels ?? ["mss-10.0.0-py3-none-any.whl"];
|
|
21
|
+
return {
|
|
22
|
+
manifestVersion: 2,
|
|
23
|
+
version: "abcd1234",
|
|
24
|
+
script: { sha256: sha256("s"), size: 1 },
|
|
25
|
+
requirements: { sha256: sha256("r"), size: 1 },
|
|
26
|
+
python: {
|
|
27
|
+
[platform]: {
|
|
28
|
+
archive,
|
|
29
|
+
path: `python/${platform}/${archive}`,
|
|
30
|
+
sha256: sha256(ARCHIVE_BODY),
|
|
31
|
+
size: Buffer.byteLength(ARCHIVE_BODY),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
wheels: {
|
|
35
|
+
[platform]: wheelNames.map((name) => ({
|
|
36
|
+
path: `wheels/${platform}/${name}`,
|
|
37
|
+
sha256: sha256(WHEEL_BODY),
|
|
38
|
+
size: Buffer.byteLength(WHEEL_BODY),
|
|
39
|
+
})),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function mockPayloadEndpoints(body = ARCHIVE_BODY, wheelBody = WHEEL_BODY) {
|
|
44
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/bundle/python/*`, () => new HttpResponse(body)), http.get(`${TEST_BASE_URL}/api/devices/agent/bundle/wheels/*`, () => new HttpResponse(wheelBody)));
|
|
45
|
+
}
|
|
46
|
+
beforeAll(() => server.listen());
|
|
47
|
+
afterAll(() => server.close());
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-payload-"));
|
|
50
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
server.resetHandlers();
|
|
55
|
+
delete process.env["AXIMO_HOME"];
|
|
56
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
describe("resolveServerPython", () => {
|
|
59
|
+
it("returns the entry when the served archive is the one this CLI expects", () => {
|
|
60
|
+
expect(resolveServerPython(manifestWithPayload(), ARCHIVE)?.archive).toBe(ARCHIVE);
|
|
61
|
+
});
|
|
62
|
+
it("ignores an archive for a different interpreter version", () => {
|
|
63
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
64
|
+
const manifest = manifestWithPayload({
|
|
65
|
+
archive: "cpython-3.13.0+20260414-x86_64-pc-windows-msvc-install_only.tar.gz",
|
|
66
|
+
});
|
|
67
|
+
expect(resolveServerPython(manifest, ARCHIVE)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
it("says why it ignored a mismatched interpreter, since the fallback cannot work offline", () => {
|
|
70
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
71
|
+
const manifest = manifestWithPayload({
|
|
72
|
+
archive: "cpython-3.13.0+20260414-x86_64-pc-windows-msvc-install_only.tar.gz",
|
|
73
|
+
});
|
|
74
|
+
resolveServerPython(manifest, ARCHIVE);
|
|
75
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("pinned to"));
|
|
76
|
+
});
|
|
77
|
+
it("stays quiet when the platform simply is not offered", () => {
|
|
78
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
79
|
+
resolveServerPython(manifestWithPayload({ platform: "sunos-sparc" }), ARCHIVE);
|
|
80
|
+
expect(spy).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
it("ignores a payload that has nothing for this platform", () => {
|
|
83
|
+
const manifest = manifestWithPayload({ platform: "sunos-sparc" });
|
|
84
|
+
expect(resolveServerPython(manifest, ARCHIVE)).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
it("reads the payload from a v2 manifest — the shape production actually ships", () => {
|
|
87
|
+
// The server never bumps the version for additive fields, so v2-with-payload is
|
|
88
|
+
// the real-world case. Keep one v3 test for forward tolerance.
|
|
89
|
+
const manifest = manifestWithPayload();
|
|
90
|
+
expect(manifest.manifestVersion).toBe(2);
|
|
91
|
+
expect(resolveServerPython(manifest, ARCHIVE)?.archive).toBe(ARCHIVE);
|
|
92
|
+
});
|
|
93
|
+
it("still tolerates a v3 manifest, so a later bump needs no lockstep release", () => {
|
|
94
|
+
const manifest = { ...manifestWithPayload(), manifestVersion: 3 };
|
|
95
|
+
expect(resolveServerPython(manifest, ARCHIVE)?.archive).toBe(ARCHIVE);
|
|
96
|
+
});
|
|
97
|
+
it("treats a manifest with no payload fields as offering nothing", () => {
|
|
98
|
+
const manifest = {
|
|
99
|
+
manifestVersion: 2,
|
|
100
|
+
version: "abcd1234",
|
|
101
|
+
script: { sha256: sha256("s"), size: 1 },
|
|
102
|
+
requirements: { sha256: sha256("r"), size: 1 },
|
|
103
|
+
};
|
|
104
|
+
expect(resolveServerPython(manifest, ARCHIVE)).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("downloadServerPython", () => {
|
|
108
|
+
it("streams the archive into the cache", async () => {
|
|
109
|
+
mockPayloadEndpoints();
|
|
110
|
+
const info = manifestWithPayload().python?.[platformKey()];
|
|
111
|
+
const path = await downloadServerPython(info, { baseUrl: TEST_BASE_URL, apiKey: "k" });
|
|
112
|
+
expect(readFileSync(path, "utf-8")).toBe(ARCHIVE_BODY);
|
|
113
|
+
expect(path).toBe(join(getCacheDir(), ARCHIVE));
|
|
114
|
+
});
|
|
115
|
+
it("reports an interrupted transfer as such, not as the server serving bad bytes", async () => {
|
|
116
|
+
mockPayloadEndpoints("short");
|
|
117
|
+
const info = manifestWithPayload().python?.[platformKey()];
|
|
118
|
+
await expect(downloadServerPython(info, { baseUrl: TEST_BASE_URL, apiKey: "k" })).rejects.toThrow(/Incomplete download/);
|
|
119
|
+
expect(existsSync(join(getCacheDir(), ARCHIVE))).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it("rejects and deletes an archive whose checksum does not match", async () => {
|
|
122
|
+
// Same length as ARCHIVE_BODY so the checksum is what rejects it, not the size.
|
|
123
|
+
const tampered = "fake-python-ARCHIVE";
|
|
124
|
+
expect(tampered).toHaveLength(ARCHIVE_BODY.length);
|
|
125
|
+
mockPayloadEndpoints(tampered);
|
|
126
|
+
const info = manifestWithPayload().python?.[platformKey()];
|
|
127
|
+
await expect(downloadServerPython(info, { baseUrl: TEST_BASE_URL, apiKey: "k" })).rejects.toThrow(/SHA256 mismatch/);
|
|
128
|
+
expect(existsSync(join(getCacheDir(), ARCHIVE))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
it("reuses a cached archive instead of downloading again", async () => {
|
|
131
|
+
let hits = 0;
|
|
132
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/bundle/python/*`, () => {
|
|
133
|
+
hits++;
|
|
134
|
+
return new HttpResponse(ARCHIVE_BODY);
|
|
135
|
+
}));
|
|
136
|
+
const info = manifestWithPayload().python?.[platformKey()];
|
|
137
|
+
const deps = { baseUrl: TEST_BASE_URL, apiKey: "k" };
|
|
138
|
+
await downloadServerPython(info, deps);
|
|
139
|
+
await downloadServerPython(info, deps);
|
|
140
|
+
expect(hits).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("ensureServerWheels", () => {
|
|
144
|
+
it("mirrors the served wheels and returns the directory to point pip at", async () => {
|
|
145
|
+
mockPayloadEndpoints();
|
|
146
|
+
const dir = await ensureServerWheels(manifestWithPayload(), {
|
|
147
|
+
baseUrl: TEST_BASE_URL,
|
|
148
|
+
apiKey: "k",
|
|
149
|
+
});
|
|
150
|
+
expect(dir).toBe(join(getCacheDir(), "wheels", platformKey()));
|
|
151
|
+
expect(readFileSync(join(dir, "mss-10.0.0-py3-none-any.whl"), "utf-8")).toBe(WHEEL_BODY);
|
|
152
|
+
});
|
|
153
|
+
it("is undefined when the server offers no wheels for this platform", async () => {
|
|
154
|
+
const manifest = manifestWithPayload({ platform: "sunos-sparc" });
|
|
155
|
+
expect(await ensureServerWheels(manifest, { baseUrl: TEST_BASE_URL, apiKey: "k" })).toBe(undefined);
|
|
156
|
+
});
|
|
157
|
+
it("removes a wheel the manifest no longer lists", async () => {
|
|
158
|
+
mockPayloadEndpoints();
|
|
159
|
+
const dir = join(getCacheDir(), "wheels", platformKey());
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
const stale = join(dir, "Pillow-1.0.0-cp312-cp312-win_amd64.whl");
|
|
162
|
+
writeFileSync(stale, "old");
|
|
163
|
+
await ensureServerWheels(manifestWithPayload(), { baseUrl: TEST_BASE_URL, apiKey: "k" });
|
|
164
|
+
// A leftover wheel could satisfy a requirement with the wrong version.
|
|
165
|
+
expect(existsSync(stale)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
it("rejects a wheel whose checksum does not match", async () => {
|
|
168
|
+
// Same length as WHEEL_BODY, so the checksum is what rejects it.
|
|
169
|
+
const tampered = "fake-wheel-BYTES";
|
|
170
|
+
expect(tampered).toHaveLength(WHEEL_BODY.length);
|
|
171
|
+
mockPayloadEndpoints(ARCHIVE_BODY, tampered);
|
|
172
|
+
await expect(ensureServerWheels(manifestWithPayload(), { baseUrl: TEST_BASE_URL, apiKey: "k" })).rejects.toThrow(/sha256 mismatch/);
|
|
173
|
+
});
|
|
174
|
+
it("rejects a wheel whose length does not match", async () => {
|
|
175
|
+
mockPayloadEndpoints(ARCHIVE_BODY, "short");
|
|
176
|
+
await expect(ensureServerWheels(manifestWithPayload(), { baseUrl: TEST_BASE_URL, apiKey: "k" })).rejects.toThrow(/size mismatch/);
|
|
177
|
+
});
|
|
178
|
+
it("does not re-download wheels already present and matching", async () => {
|
|
179
|
+
let hits = 0;
|
|
180
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/bundle/wheels/*`, () => {
|
|
181
|
+
hits++;
|
|
182
|
+
return new HttpResponse(WHEEL_BODY);
|
|
183
|
+
}));
|
|
184
|
+
const deps = { baseUrl: TEST_BASE_URL, apiKey: "k" };
|
|
185
|
+
await ensureServerWheels(manifestWithPayload(), deps);
|
|
186
|
+
await ensureServerWheels(manifestWithPayload(), deps);
|
|
187
|
+
expect(hits).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AgentManifest } from "./agent-sync.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compare two dotted numeric versions.
|
|
4
|
+
*
|
|
5
|
+
* Returns a negative number when a < b, zero when equal, positive when a > b.
|
|
6
|
+
* Prerelease suffixes are ignored: `0.11.0-rc.1` compares as `0.11.0`, which is
|
|
7
|
+
* deliberate — a release candidate of the required version is close enough that
|
|
8
|
+
* warning about it would be noise.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
11
|
+
/** True when this CLI is older than the server's stated minimum. */
|
|
12
|
+
export declare function isCliOutdated(minCliVersion: string | undefined, current?: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Warn when the server expects a newer CLI than this one.
|
|
15
|
+
*
|
|
16
|
+
* A warning rather than a hard failure: an on-prem server can be upgraded before
|
|
17
|
+
* every device is, and blocking setup would strand machines that were working a
|
|
18
|
+
* moment earlier. The message matters most air-gapped, where the CLI is carried
|
|
19
|
+
* in by hand and drifting behind the server is easy to do by accident.
|
|
20
|
+
*/
|
|
21
|
+
export declare function warnIfCliOutdated(manifest: AgentManifest): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
2
|
+
/**
|
|
3
|
+
* Compare two dotted numeric versions.
|
|
4
|
+
*
|
|
5
|
+
* Returns a negative number when a < b, zero when equal, positive when a > b.
|
|
6
|
+
* Prerelease suffixes are ignored: `0.11.0-rc.1` compares as `0.11.0`, which is
|
|
7
|
+
* deliberate — a release candidate of the required version is close enough that
|
|
8
|
+
* warning about it would be noise.
|
|
9
|
+
*/
|
|
10
|
+
export function compareVersions(a, b) {
|
|
11
|
+
const parse = (v) => (v.split("-")[0] ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
12
|
+
const left = parse(a);
|
|
13
|
+
const right = parse(b);
|
|
14
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
15
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
16
|
+
if (diff !== 0)
|
|
17
|
+
return diff;
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
/** True when this CLI is older than the server's stated minimum. */
|
|
22
|
+
export function isCliOutdated(minCliVersion, current = pkg.version) {
|
|
23
|
+
if (!minCliVersion)
|
|
24
|
+
return false;
|
|
25
|
+
return compareVersions(current, minCliVersion) < 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Warn when the server expects a newer CLI than this one.
|
|
29
|
+
*
|
|
30
|
+
* A warning rather than a hard failure: an on-prem server can be upgraded before
|
|
31
|
+
* every device is, and blocking setup would strand machines that were working a
|
|
32
|
+
* moment earlier. The message matters most air-gapped, where the CLI is carried
|
|
33
|
+
* in by hand and drifting behind the server is easy to do by accident.
|
|
34
|
+
*/
|
|
35
|
+
export function warnIfCliOutdated(manifest) {
|
|
36
|
+
if (!isCliOutdated(manifest.minCliVersion))
|
|
37
|
+
return;
|
|
38
|
+
console.error(`[aximo] This CLI is ${pkg.version}, but the Aximo server expects ` +
|
|
39
|
+
`${manifest.minCliVersion} or newer. Device setup may fail or behave unexpectedly — ` +
|
|
40
|
+
"install the aximo-cli that ships with your current Aximo release.");
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { compareVersions, isCliOutdated, warnIfCliOutdated } from "./cli-version.js";
|
|
3
|
+
function manifest(minCliVersion) {
|
|
4
|
+
return {
|
|
5
|
+
manifestVersion: 3,
|
|
6
|
+
version: "abcd1234",
|
|
7
|
+
...(minCliVersion ? { minCliVersion } : {}),
|
|
8
|
+
script: { sha256: "a".repeat(64), size: 1 },
|
|
9
|
+
requirements: { sha256: "b".repeat(64), size: 1 },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
describe("compareVersions", () => {
|
|
16
|
+
it("orders by each numeric component", () => {
|
|
17
|
+
expect(compareVersions("0.10.0", "0.11.0")).toBeLessThan(0);
|
|
18
|
+
expect(compareVersions("0.11.0", "0.10.0")).toBeGreaterThan(0);
|
|
19
|
+
expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
|
|
20
|
+
expect(compareVersions("0.11.0", "0.11.0")).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
it("compares numerically rather than lexically", () => {
|
|
23
|
+
expect(compareVersions("0.9.0", "0.10.0")).toBeLessThan(0);
|
|
24
|
+
});
|
|
25
|
+
it("treats missing components as zero", () => {
|
|
26
|
+
expect(compareVersions("1", "1.0.0")).toBe(0);
|
|
27
|
+
expect(compareVersions("1.2", "1.2.1")).toBeLessThan(0);
|
|
28
|
+
});
|
|
29
|
+
it("ignores a prerelease suffix", () => {
|
|
30
|
+
expect(compareVersions("0.11.0-rc.1", "0.11.0")).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("isCliOutdated", () => {
|
|
34
|
+
it("is false when the server states no minimum", () => {
|
|
35
|
+
expect(isCliOutdated(undefined, "0.1.0")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("is true only when this CLI is behind", () => {
|
|
38
|
+
expect(isCliOutdated("0.11.0", "0.10.0")).toBe(true);
|
|
39
|
+
expect(isCliOutdated("0.11.0", "0.11.0")).toBe(false);
|
|
40
|
+
expect(isCliOutdated("0.11.0", "0.12.0")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("warnIfCliOutdated", () => {
|
|
44
|
+
it("stays quiet when the server states no minimum", () => {
|
|
45
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
46
|
+
warnIfCliOutdated(manifest());
|
|
47
|
+
expect(spy).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
it("names the version to install when the CLI is behind", () => {
|
|
50
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
51
|
+
warnIfCliOutdated(manifest("999.0.0"));
|
|
52
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining("999.0.0"));
|
|
53
|
+
});
|
|
54
|
+
it("warns rather than throwing, so an upgraded server never strands a device", () => {
|
|
55
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
56
|
+
expect(() => warnIfCliOutdated(manifest("999.0.0"))).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Configured offline asset directory, or undefined when unset/blank. */
|
|
2
|
+
export declare function getOfflineDir(): string | undefined;
|
|
3
|
+
/** Path to the staged Python archive, or undefined when it isn't present. */
|
|
4
|
+
export declare function findOfflinePythonArchive(archiveName: string): string | undefined;
|
|
5
|
+
/** Path to the staged wheel directory, or undefined when it isn't present. */
|
|
6
|
+
export declare function findOfflineWheelsDir(): string | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Locally staged setup assets.
|
|
5
|
+
*
|
|
6
|
+
* On an air-gapped network `aximo device setup` cannot reach GitHub or PyPI, so
|
|
7
|
+
* the embedded Python archive and the agent's wheels have to come from
|
|
8
|
+
* somewhere on disk. AXIMO_OFFLINE_DIR points at a directory holding them:
|
|
9
|
+
*
|
|
10
|
+
* <dir>/cpython-<version>+<release>-<target>-install_only.tar.gz
|
|
11
|
+
* <dir>/wheels/*.whl
|
|
12
|
+
*
|
|
13
|
+
* An on-prem server can serve the same assets (see agent-sync), in which case
|
|
14
|
+
* the CLI downloads into its own cache and no env var is needed. This module is
|
|
15
|
+
* only about files a human placed on the machine.
|
|
16
|
+
*/
|
|
17
|
+
const OFFLINE_DIR_ENV = "AXIMO_OFFLINE_DIR";
|
|
18
|
+
/** Configured offline asset directory, or undefined when unset/blank. */
|
|
19
|
+
export function getOfflineDir() {
|
|
20
|
+
const raw = process.env[OFFLINE_DIR_ENV];
|
|
21
|
+
if (!raw || raw.trim() === "")
|
|
22
|
+
return undefined;
|
|
23
|
+
const dir = raw.trim();
|
|
24
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
25
|
+
throw new Error(`${OFFLINE_DIR_ENV} is set to ${dir}, which is not a directory.`);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
/** Path to the staged Python archive, or undefined when it isn't present. */
|
|
30
|
+
export function findOfflinePythonArchive(archiveName) {
|
|
31
|
+
const dir = getOfflineDir();
|
|
32
|
+
if (!dir)
|
|
33
|
+
return undefined;
|
|
34
|
+
const path = join(dir, archiveName);
|
|
35
|
+
return existsSync(path) ? path : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Path to the staged wheel directory, or undefined when it isn't present. */
|
|
38
|
+
export function findOfflineWheelsDir() {
|
|
39
|
+
const dir = getOfflineDir();
|
|
40
|
+
if (!dir)
|
|
41
|
+
return undefined;
|
|
42
|
+
const wheels = join(dir, "wheels");
|
|
43
|
+
return existsSync(wheels) && statSync(wheels).isDirectory() ? wheels : undefined;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|