@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.
Files changed (44) hide show
  1. package/README.md +40 -3
  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/connect-core.js +1 -1
  7. package/dist/commands/mobile/doctor.d.ts +20 -3
  8. package/dist/commands/mobile/doctor.js +180 -13
  9. package/dist/commands/mobile/doctor.test.js +79 -3
  10. package/dist/desktop-server/agent-sync.d.ts +21 -1
  11. package/dist/desktop-server/agent-sync.js +6 -2
  12. package/dist/desktop-server/bundle-sync.d.ts +25 -0
  13. package/dist/desktop-server/bundle-sync.js +128 -0
  14. package/dist/desktop-server/bundle-sync.test.d.ts +1 -0
  15. package/dist/desktop-server/bundle-sync.test.js +189 -0
  16. package/dist/desktop-server/cli-version.d.ts +21 -0
  17. package/dist/desktop-server/cli-version.js +41 -0
  18. package/dist/desktop-server/cli-version.test.d.ts +1 -0
  19. package/dist/desktop-server/cli-version.test.js +58 -0
  20. package/dist/desktop-server/offline.d.ts +6 -0
  21. package/dist/desktop-server/offline.js +44 -0
  22. package/dist/desktop-server/offline.test.d.ts +1 -0
  23. package/dist/desktop-server/offline.test.js +74 -0
  24. package/dist/desktop-server/setup-deps.test.d.ts +1 -0
  25. package/dist/desktop-server/setup-deps.test.js +112 -0
  26. package/dist/desktop-server/setup-python.test.d.ts +1 -0
  27. package/dist/desktop-server/setup-python.test.js +272 -0
  28. package/dist/desktop-server/setup.d.ts +26 -1
  29. package/dist/desktop-server/setup.js +136 -29
  30. package/dist/mobile-control/android-recovery.d.ts +5 -0
  31. package/dist/mobile-control/android-recovery.js +19 -0
  32. package/dist/mobile-control/android-recovery.test.d.ts +1 -0
  33. package/dist/mobile-control/android-recovery.test.js +33 -0
  34. package/dist/mobile-control/appium-controller.d.ts +4 -0
  35. package/dist/mobile-control/appium-controller.js +15 -1
  36. package/dist/mobile-control/appium-controller.test.js +78 -0
  37. package/dist/mobile-control/appium-import.js +19 -3
  38. package/dist/mobile-control/ios-tunnel.d.ts +28 -8
  39. package/dist/mobile-control/ios-tunnel.js +63 -27
  40. package/dist/mobile-control/ios-tunnel.test.js +153 -14
  41. package/dist/mobile-control/mobile-errors.js +6 -0
  42. package/dist/mobile-control/mobile-errors.test.js +3 -0
  43. package/dist/types.d.ts +49 -0
  44. package/package.json +4 -1
@@ -3,17 +3,37 @@ export interface AgentAssetInfo {
3
3
  sha256: string;
4
4
  size: number;
5
5
  }
6
+ export interface BundleFileInfo extends AgentAssetInfo {
7
+ /** Path under /api/devices/agent/bundle/ that serves this file. */
8
+ path: string;
9
+ }
10
+ export interface BundlePythonInfo extends BundleFileInfo {
11
+ /** python-build-standalone archive filename, which encodes version + target. */
12
+ archive: string;
13
+ }
6
14
  export interface AgentManifest {
7
- manifestVersion: 2;
15
+ /**
16
+ * The offline setup fields below are optional additive fields on v2 — the
17
+ * server does not bump the version for them, because every published CLI
18
+ * rejects a version it does not recognise. Never gate the offline path on
19
+ * this number; gate it on whether those fields are present.
20
+ */
21
+ manifestVersion: 2 | 3;
8
22
  version: string;
23
+ minCliVersion?: string;
9
24
  script: AgentAssetInfo;
10
25
  requirements: AgentAssetInfo;
26
+ /** Offline setup assets, keyed by `${platform}-${arch}`. Absent = use GitHub/PyPI. */
27
+ python?: Record<string, BundlePythonInfo>;
28
+ wheels?: Record<string, BundleFileInfo[]>;
11
29
  }
12
30
  export interface EnsureAgentResult {
13
31
  scriptPath: string;
14
32
  requirementsPath: string;
15
33
  changed: boolean;
16
34
  version: string;
35
+ /** The server's manifest, so callers can see what it offers. */
36
+ manifest: AgentManifest;
17
37
  }
18
38
  export declare function ensureAgent(deps?: ClientDeps): Promise<EnsureAgentResult>;
19
39
  export declare function readCachedManifest(): AgentManifest | null;
@@ -18,7 +18,7 @@ function readLocalManifest() {
18
18
  if (!parsed || typeof parsed !== "object")
19
19
  return null;
20
20
  const manifest = parsed;
21
- if (manifest.manifestVersion === 2 &&
21
+ if ((manifest.manifestVersion === 2 || manifest.manifestVersion === 3) &&
22
22
  typeof manifest.version === "string" &&
23
23
  manifest.script?.sha256 &&
24
24
  manifest.requirements?.sha256) {
@@ -58,7 +58,9 @@ function validateManifest(raw) {
58
58
  throw new Error("Invalid agent manifest: response is not an object");
59
59
  }
60
60
  const m = raw;
61
- if (m.manifestVersion !== 2) {
61
+ // v2 servers predate the offline assets; accept them so a newer CLI still
62
+ // works against an on-prem server that hasn't been upgraded yet.
63
+ if (m.manifestVersion !== 2 && m.manifestVersion !== 3) {
62
64
  throw new Error(`Unsupported agent manifest version: ${m.manifestVersion}. Update aximo-cli to the latest version.`);
63
65
  }
64
66
  if (typeof m.version !== "string" ||
@@ -111,6 +113,7 @@ export async function ensureAgent(deps) {
111
113
  requirementsPath: getAgentRequirementsPath(),
112
114
  changed: false,
113
115
  version: server.version,
116
+ manifest: server,
114
117
  };
115
118
  }
116
119
  const [scriptBuffer, requirementsBuffer] = await Promise.all([
@@ -134,6 +137,7 @@ export async function ensureAgent(deps) {
134
137
  requirementsPath: getAgentRequirementsPath(),
135
138
  changed: true,
136
139
  version: server.version,
140
+ manifest: server,
137
141
  };
138
142
  }
139
143
  export function readCachedManifest() {
@@ -0,0 +1,25 @@
1
+ import { type ClientDeps } from "../client.js";
2
+ import type { AgentManifest, BundlePythonInfo } from "./agent-sync.js";
3
+ /** Key the manifest uses for per-platform assets, e.g. win32-x64. */
4
+ export declare function platformKey(): string;
5
+ /**
6
+ * The server's Python archive for this platform, when it matches what this CLI
7
+ * expects.
8
+ *
9
+ * An archive name encodes the interpreter version and target, so comparing it to
10
+ * the name the CLI would have downloaded is the version check: a server offering
11
+ * a different interpreter than this CLI is pinned to is ignored rather than
12
+ * installed, because the CLI's compiled-in SHA256 would reject it anyway.
13
+ */
14
+ export declare function resolveServerPython(manifest: AgentManifest, expectedArchive: string): BundlePythonInfo | undefined;
15
+ /** Download the server's Python archive into the cache and verify it. */
16
+ export declare function downloadServerPython(info: BundlePythonInfo, deps?: ClientDeps): Promise<string>;
17
+ /**
18
+ * Mirror the server's wheels for this platform into the cache.
19
+ *
20
+ * Returns the directory to point pip at, or undefined when the server offers no
21
+ * wheels for this platform. Files the manifest no longer lists are removed:
22
+ * a leftover wheel from an older release could otherwise satisfy a requirement
23
+ * with the wrong version.
24
+ */
25
+ export declare function ensureServerWheels(manifest: AgentManifest, deps?: ClientDeps): Promise<string | undefined>;
@@ -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 {};