@autifyhq/aximo-cli 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +379 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +21 -0
  5. package/dist/client.d.ts +18 -0
  6. package/dist/client.js +90 -0
  7. package/dist/client.test.d.ts +1 -0
  8. package/dist/client.test.js +127 -0
  9. package/dist/commands/configure.d.ts +24 -0
  10. package/dist/commands/configure.js +103 -0
  11. package/dist/commands/configure.test.d.ts +1 -0
  12. package/dist/commands/configure.test.js +96 -0
  13. package/dist/commands/device/connect.d.ts +2 -0
  14. package/dist/commands/device/connect.js +84 -0
  15. package/dist/commands/device/doctor.d.ts +34 -0
  16. package/dist/commands/device/doctor.js +421 -0
  17. package/dist/commands/device/doctor.test.d.ts +1 -0
  18. package/dist/commands/device/doctor.test.js +89 -0
  19. package/dist/commands/device/index.d.ts +2 -0
  20. package/dist/commands/device/index.js +16 -0
  21. package/dist/commands/device/list.d.ts +20 -0
  22. package/dist/commands/device/list.js +40 -0
  23. package/dist/commands/device/list.test.d.ts +1 -0
  24. package/dist/commands/device/list.test.js +38 -0
  25. package/dist/commands/device/logs.d.ts +34 -0
  26. package/dist/commands/device/logs.js +160 -0
  27. package/dist/commands/device/logs.test.d.ts +1 -0
  28. package/dist/commands/device/logs.test.js +70 -0
  29. package/dist/commands/device/paths.d.ts +12 -0
  30. package/dist/commands/device/paths.js +40 -0
  31. package/dist/commands/device/ping.d.ts +21 -0
  32. package/dist/commands/device/ping.js +66 -0
  33. package/dist/commands/device/ping.test.d.ts +1 -0
  34. package/dist/commands/device/ping.test.js +68 -0
  35. package/dist/commands/device/register.d.ts +23 -0
  36. package/dist/commands/device/register.js +83 -0
  37. package/dist/commands/device/reset.d.ts +30 -0
  38. package/dist/commands/device/reset.js +67 -0
  39. package/dist/commands/device/reset.test.d.ts +1 -0
  40. package/dist/commands/device/reset.test.js +69 -0
  41. package/dist/commands/device/setup.d.ts +25 -0
  42. package/dist/commands/device/setup.js +105 -0
  43. package/dist/commands/device/setup.test.d.ts +1 -0
  44. package/dist/commands/device/setup.test.js +64 -0
  45. package/dist/commands/device/status.d.ts +16 -0
  46. package/dist/commands/device/status.js +20 -0
  47. package/dist/commands/device/unregister.d.ts +14 -0
  48. package/dist/commands/device/unregister.js +60 -0
  49. package/dist/commands/health/check.d.ts +5 -0
  50. package/dist/commands/health/check.js +20 -0
  51. package/dist/commands/health/check.test.d.ts +1 -0
  52. package/dist/commands/health/check.test.js +26 -0
  53. package/dist/commands/health/index.d.ts +2 -0
  54. package/dist/commands/health/index.js +7 -0
  55. package/dist/commands/project/index.d.ts +2 -0
  56. package/dist/commands/project/index.js +7 -0
  57. package/dist/commands/project/list.d.ts +7 -0
  58. package/dist/commands/project/list.js +20 -0
  59. package/dist/commands/project/list.test.d.ts +1 -0
  60. package/dist/commands/project/list.test.js +28 -0
  61. package/dist/commands/schema.d.ts +27 -0
  62. package/dist/commands/schema.js +146 -0
  63. package/dist/commands/schema.test.d.ts +1 -0
  64. package/dist/commands/schema.test.js +38 -0
  65. package/dist/commands/session/create.d.ts +78 -0
  66. package/dist/commands/session/create.js +89 -0
  67. package/dist/commands/session/get.d.ts +11 -0
  68. package/dist/commands/session/get.js +30 -0
  69. package/dist/commands/session/index.d.ts +2 -0
  70. package/dist/commands/session/index.js +8 -0
  71. package/dist/commands/session/session.test.d.ts +1 -0
  72. package/dist/commands/session/session.test.js +128 -0
  73. package/dist/commands/trigger/case/get.d.ts +14 -0
  74. package/dist/commands/trigger/case/get.js +33 -0
  75. package/dist/commands/trigger/case/index.d.ts +2 -0
  76. package/dist/commands/trigger/case/index.js +8 -0
  77. package/dist/commands/trigger/case/run.d.ts +58 -0
  78. package/dist/commands/trigger/case/run.js +81 -0
  79. package/dist/commands/trigger/index.d.ts +2 -0
  80. package/dist/commands/trigger/index.js +8 -0
  81. package/dist/commands/trigger/plan/get.d.ts +14 -0
  82. package/dist/commands/trigger/plan/get.js +33 -0
  83. package/dist/commands/trigger/plan/index.d.ts +2 -0
  84. package/dist/commands/trigger/plan/index.js +8 -0
  85. package/dist/commands/trigger/plan/run.d.ts +33 -0
  86. package/dist/commands/trigger/plan/run.js +63 -0
  87. package/dist/commands/trigger/trigger.test.d.ts +1 -0
  88. package/dist/commands/trigger/trigger.test.js +107 -0
  89. package/dist/commands/upload/create.d.ts +32 -0
  90. package/dist/commands/upload/create.js +65 -0
  91. package/dist/commands/upload/get.d.ts +10 -0
  92. package/dist/commands/upload/get.js +30 -0
  93. package/dist/commands/upload/index.d.ts +2 -0
  94. package/dist/commands/upload/index.js +11 -0
  95. package/dist/commands/upload/list.d.ts +15 -0
  96. package/dist/commands/upload/list.js +31 -0
  97. package/dist/commands/upload/put.d.ts +24 -0
  98. package/dist/commands/upload/put.js +62 -0
  99. package/dist/commands/upload/submit.d.ts +19 -0
  100. package/dist/commands/upload/submit.js +36 -0
  101. package/dist/commands/upload/upload.test.d.ts +1 -0
  102. package/dist/commands/upload/upload.test.js +133 -0
  103. package/dist/config.d.ts +7 -0
  104. package/dist/config.js +36 -0
  105. package/dist/desktop-server/agent-sync.d.ts +19 -0
  106. package/dist/desktop-server/agent-sync.js +141 -0
  107. package/dist/desktop-server/agent-sync.test.d.ts +1 -0
  108. package/dist/desktop-server/agent-sync.test.js +130 -0
  109. package/dist/desktop-server/setup.d.ts +19 -0
  110. package/dist/desktop-server/setup.js +200 -0
  111. package/dist/errors.d.ts +2 -0
  112. package/dist/errors.js +71 -0
  113. package/dist/errors.test.d.ts +1 -0
  114. package/dist/errors.test.js +88 -0
  115. package/dist/index.d.ts +19 -0
  116. package/dist/index.js +17 -0
  117. package/dist/mutate.d.ts +13 -0
  118. package/dist/mutate.js +25 -0
  119. package/dist/mutate.test.d.ts +1 -0
  120. package/dist/mutate.test.js +49 -0
  121. package/dist/output.d.ts +5 -0
  122. package/dist/output.js +23 -0
  123. package/dist/output.test.d.ts +1 -0
  124. package/dist/output.test.js +42 -0
  125. package/dist/test-setup.d.ts +1 -0
  126. package/dist/test-setup.js +13 -0
  127. package/dist/test-utils.d.ts +4 -0
  128. package/dist/test-utils.js +5 -0
  129. package/dist/types.d.ts +121 -0
  130. package/dist/types.js +2 -0
  131. package/dist/validate.d.ts +14 -0
  132. package/dist/validate.js +103 -0
  133. package/dist/validate.test.d.ts +1 -0
  134. package/dist/validate.test.js +96 -0
  135. package/package.json +72 -0
@@ -0,0 +1,19 @@
1
+ import type { Envelope, UploadSubmitResult } from "../../types.js";
2
+ import { type ClientDeps } from "../../client.js";
3
+ import { type DryRunResult } from "../../mutate.js";
4
+ export interface UploadSubmitOptions {
5
+ uploadId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runUploadSubmit(options: UploadSubmitOptions, deps?: ClientDeps): Promise<Envelope<UploadSubmitResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly uploadId: {
11
+ readonly type: "positional";
12
+ readonly required: true;
13
+ };
14
+ readonly "dry-run": {
15
+ readonly type: "boolean";
16
+ readonly description: "Validate and show request without calling API";
17
+ };
18
+ }>;
19
+ export default _default;
@@ -0,0 +1,36 @@
1
+ import { defineCommand } from "citty";
2
+ import { formatSuccess, printResult, validationError } from "../../output.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { request } from "../../client.js";
5
+ import { requireId } from "../../validate.js";
6
+ import { dryRunEnvelope } from "../../mutate.js";
7
+ export async function runUploadSubmit(options, deps) {
8
+ let uploadId;
9
+ try {
10
+ uploadId = requireId("uploadId", options.uploadId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/uploads/${encodeURIComponent(uploadId)}/submit`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("POST", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "POST" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: { name: "submit", description: "Submit an uploaded file for processing" },
29
+ args: {
30
+ uploadId: { type: "positional", required: true },
31
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
32
+ },
33
+ async run({ args }) {
34
+ printResult(await runUploadSubmit({ uploadId: args.uploadId, dryRun: args["dry-run"] }));
35
+ },
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
3
+ import { runUploadCreate } from "./create.js";
4
+ import { runUploadList } from "./list.js";
5
+ import { runUploadGet } from "./get.js";
6
+ import { runUploadPut } from "./put.js";
7
+ import { runUploadSubmit } from "./submit.js";
8
+ beforeAll(() => {
9
+ process.env["AXIMO_API_KEY"] = "test-key";
10
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
11
+ server.listen();
12
+ });
13
+ afterEach(() => server.resetHandlers());
14
+ afterAll(() => server.close());
15
+ describe("runUploadCreate", () => {
16
+ it("POSTs and returns presignedUrl", async () => {
17
+ server.use(http.post(`${TEST_BASE_URL}/api/uploads`, () => HttpResponse.json({ uploadId: "u1", presignedUrl: "https://s3/...", fileType: "IOS_APP" }, { status: 201 })));
18
+ const result = await runUploadCreate({ fileName: "app.ipa", platform: "IOS", fileSize: "100" });
19
+ expect(result.ok).toBe(true);
20
+ if (result.ok && !("dryRun" in result.data))
21
+ expect(result.data.uploadId).toBe("u1");
22
+ });
23
+ it("rejects missing fileName", async () => {
24
+ const result = await runUploadCreate({});
25
+ expect(result.ok).toBe(false);
26
+ });
27
+ it("--json replaces body", async () => {
28
+ let seen;
29
+ server.use(http.post(`${TEST_BASE_URL}/api/uploads`, async ({ request: req }) => {
30
+ seen = await req.json();
31
+ return HttpResponse.json({ uploadId: "u2", presignedUrl: "x", fileType: "IOS_APP" }, { status: 201 });
32
+ }));
33
+ const result = await runUploadCreate({ json: '{"fileName":"r.ipa"}' });
34
+ expect(result.ok).toBe(true);
35
+ expect(seen).toEqual({ fileName: "r.ipa" });
36
+ });
37
+ it("--dry-run skips call", async () => {
38
+ const result = await runUploadCreate({ fileName: "a.ipa", dryRun: true });
39
+ expect(result.ok).toBe(true);
40
+ if (result.ok && "dryRun" in result.data) {
41
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/uploads`);
42
+ }
43
+ });
44
+ it("rejects non-integer fileSize", async () => {
45
+ const result = await runUploadCreate({ fileName: "a", fileSize: "1.5" });
46
+ expect(result.ok).toBe(false);
47
+ if (!result.ok)
48
+ expect(result.error.code).toBe("validation_error");
49
+ });
50
+ });
51
+ describe("runUploadList", () => {
52
+ it("forwards fileType query", async () => {
53
+ let seenUrl = "";
54
+ server.use(http.get(`${TEST_BASE_URL}/api/uploads`, ({ request: req }) => {
55
+ seenUrl = req.url;
56
+ return HttpResponse.json({ uploads: [{ id: "u1" }] });
57
+ }));
58
+ const result = await runUploadList({ fileType: "ANDROID_APP" });
59
+ expect(result.ok).toBe(true);
60
+ expect(seenUrl).toContain("fileType=ANDROID_APP");
61
+ });
62
+ it("rejects invalid fileType", async () => {
63
+ const result = await runUploadList({ fileType: "EXE" });
64
+ expect(result.ok).toBe(false);
65
+ });
66
+ });
67
+ describe("runUploadGet", () => {
68
+ it("fetches by id", async () => {
69
+ server.use(http.get(`${TEST_BASE_URL}/api/uploads/u1`, () => HttpResponse.json({ id: "u1", fileName: "app.ipa" })));
70
+ const result = await runUploadGet("u1");
71
+ expect(result.ok).toBe(true);
72
+ });
73
+ it("maps 404", async () => {
74
+ server.use(http.get(`${TEST_BASE_URL}/api/uploads/missing`, () => HttpResponse.json({ error: "Not found" }, { status: 404 })));
75
+ const result = await runUploadGet("missing");
76
+ expect(result.ok).toBe(false);
77
+ if (!result.ok)
78
+ expect(result.error.code).toBe("not_found");
79
+ });
80
+ });
81
+ describe("runUploadPut", () => {
82
+ it("PUTs binary body with octet-stream content-type", async () => {
83
+ let seenCT = "";
84
+ let seenMethod = "";
85
+ let body = null;
86
+ server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, async ({ request: req }) => {
87
+ seenCT = req.headers.get("content-type") ?? "";
88
+ seenMethod = req.method;
89
+ body = await req.arrayBuffer();
90
+ return HttpResponse.json({ success: true });
91
+ }));
92
+ const result = await runUploadPut({ uploadId: "u1", bytes: new Uint8Array([9, 9]) });
93
+ expect(result.ok).toBe(true);
94
+ if (result.ok && !("dryRun" in result.data))
95
+ expect(result.data.success).toBe(true);
96
+ expect(seenMethod).toBe("PUT");
97
+ expect(seenCT).toContain("application/octet-stream");
98
+ expect(body).not.toBeNull();
99
+ expect(new Uint8Array(body)).toEqual(new Uint8Array([9, 9]));
100
+ });
101
+ it("rejects missing bytes", async () => {
102
+ const result = await runUploadPut({ uploadId: "u1" });
103
+ expect(result.ok).toBe(false);
104
+ });
105
+ it("--dry-run returns request without bytes", async () => {
106
+ const result = await runUploadPut({ uploadId: "u1", dryRun: true });
107
+ expect(result.ok).toBe(true);
108
+ if (result.ok && "dryRun" in result.data) {
109
+ expect(result.data.method).toBe("PUT");
110
+ }
111
+ });
112
+ it("rejects uploadId with forbidden chars", async () => {
113
+ const result = await runUploadPut({ uploadId: "u1?x=1", bytes: new Uint8Array([1]) });
114
+ expect(result.ok).toBe(false);
115
+ });
116
+ });
117
+ describe("runUploadSubmit", () => {
118
+ it("POSTs submit and returns workflowId", async () => {
119
+ server.use(http.post(`${TEST_BASE_URL}/api/uploads/u1/submit`, () => HttpResponse.json({ success: true, workflowId: "w1" }, { status: 202 })));
120
+ const result = await runUploadSubmit({ uploadId: "u1" });
121
+ expect(result.ok).toBe(true);
122
+ if (result.ok && !("dryRun" in result.data))
123
+ expect(result.data.workflowId).toBe("w1");
124
+ });
125
+ it("rejects empty id", async () => {
126
+ const result = await runUploadSubmit({ uploadId: "" });
127
+ expect(result.ok).toBe(false);
128
+ });
129
+ it("submit --dry-run", async () => {
130
+ const result = await runUploadSubmit({ uploadId: "u1", dryRun: true });
131
+ expect(result.ok).toBe(true);
132
+ });
133
+ });
@@ -0,0 +1,7 @@
1
+ export interface CliConfig {
2
+ apiKey?: string;
3
+ baseUrl?: string;
4
+ }
5
+ export declare function getConfigPath(): string;
6
+ export declare function loadConfig(): CliConfig;
7
+ export declare function saveConfig(config: CliConfig): void;
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAximoDir } from "./commands/device/paths.js";
4
+ export function getConfigPath() {
5
+ return join(getAximoDir(), "config.json");
6
+ }
7
+ export function loadConfig() {
8
+ const path = getConfigPath();
9
+ if (!existsSync(path))
10
+ return {};
11
+ try {
12
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
13
+ const result = {};
14
+ const apiKey = parsed["apiKey"];
15
+ const baseUrl = parsed["baseUrl"];
16
+ if (typeof apiKey === "string" && apiKey.length > 0)
17
+ result.apiKey = apiKey;
18
+ if (typeof baseUrl === "string" && baseUrl.length > 0)
19
+ result.baseUrl = baseUrl;
20
+ return result;
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ export function saveConfig(config) {
27
+ mkdirSync(getAximoDir(), { recursive: true });
28
+ const path = getConfigPath();
29
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: 0o600 });
30
+ try {
31
+ chmodSync(path, 0o600);
32
+ }
33
+ catch {
34
+ // chmod may fail on Windows, file-level ACLs apply instead
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ import { type ClientDeps } from "../client.js";
2
+ export interface AgentAssetInfo {
3
+ sha256: string;
4
+ size: number;
5
+ }
6
+ export interface AgentManifest {
7
+ manifestVersion: 2;
8
+ version: string;
9
+ script: AgentAssetInfo;
10
+ requirements: AgentAssetInfo;
11
+ }
12
+ export interface EnsureAgentResult {
13
+ scriptPath: string;
14
+ requirementsPath: string;
15
+ changed: boolean;
16
+ version: string;
17
+ }
18
+ export declare function ensureAgent(deps?: ClientDeps): Promise<EnsureAgentResult>;
19
+ export declare function readCachedManifest(): AgentManifest | null;
@@ -0,0 +1,141 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { request, requestBuffer } from "../client.js";
5
+ import { getAgentDir, getAgentManifestPath, getAgentRequirementsPath, getAgentScriptPath, } from "../commands/device/paths.js";
6
+ const MANIFEST_PATH = "/api/devices/agent/manifest";
7
+ const SCRIPT_ASSET_PATH = "/api/devices/agent/remote.py";
8
+ const REQUIREMENTS_ASSET_PATH = "/api/devices/agent/requirements.txt";
9
+ function hashBuffer(buffer) {
10
+ return createHash("sha256").update(buffer).digest("hex");
11
+ }
12
+ function readLocalManifest() {
13
+ const path = getAgentManifestPath();
14
+ if (!existsSync(path))
15
+ return null;
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
18
+ if (!parsed || typeof parsed !== "object")
19
+ return null;
20
+ const manifest = parsed;
21
+ if (manifest.manifestVersion === 2 &&
22
+ typeof manifest.version === "string" &&
23
+ manifest.script?.sha256 &&
24
+ manifest.requirements?.sha256) {
25
+ return manifest;
26
+ }
27
+ return null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ function writeManifest(manifest) {
34
+ writeFileSync(getAgentManifestPath(), JSON.stringify(manifest, null, 2), {
35
+ mode: 0o644,
36
+ });
37
+ }
38
+ function localFilesMatch(server) {
39
+ const scriptPath = getAgentScriptPath();
40
+ const requirementsPath = getAgentRequirementsPath();
41
+ if (!existsSync(scriptPath) || !existsSync(requirementsPath))
42
+ return false;
43
+ try {
44
+ const scriptHash = hashBuffer(readFileSync(scriptPath));
45
+ if (scriptHash !== server.script.sha256)
46
+ return false;
47
+ const reqHash = hashBuffer(readFileSync(requirementsPath));
48
+ if (reqHash !== server.requirements.sha256)
49
+ return false;
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function validateManifest(raw) {
57
+ if (!raw || typeof raw !== "object") {
58
+ throw new Error("Invalid agent manifest: response is not an object");
59
+ }
60
+ const m = raw;
61
+ if (m.manifestVersion !== 2) {
62
+ throw new Error(`Unsupported agent manifest version: ${m.manifestVersion}. Update aximo-cli to the latest version.`);
63
+ }
64
+ if (typeof m.version !== "string" ||
65
+ !m.script ||
66
+ typeof m.script.sha256 !== "string" ||
67
+ typeof m.script.size !== "number" ||
68
+ !m.requirements ||
69
+ typeof m.requirements.sha256 !== "string" ||
70
+ typeof m.requirements.size !== "number") {
71
+ throw new Error("Invalid agent manifest: missing or malformed fields");
72
+ }
73
+ return m;
74
+ }
75
+ async function fetchAsset(path, expected, deps) {
76
+ const buffer = await requestBuffer(path, deps);
77
+ if (buffer.byteLength !== expected.size) {
78
+ throw new Error(`Agent asset ${path} size mismatch: expected ${expected.size} bytes, got ${buffer.byteLength}`);
79
+ }
80
+ const actual = hashBuffer(buffer);
81
+ if (actual !== expected.sha256) {
82
+ throw new Error(`Agent asset ${path} sha256 mismatch: expected ${expected.sha256}, got ${actual}`);
83
+ }
84
+ return buffer;
85
+ }
86
+ function atomicWrite(finalPath, stagingPath, buffer) {
87
+ writeFileSync(stagingPath, buffer, { mode: 0o644 });
88
+ // Windows' renameSync fails when the destination already exists. Remove it
89
+ // first so agent updates work on Windows (primary BYOD target).
90
+ if (existsSync(finalPath)) {
91
+ rmSync(finalPath, { force: true });
92
+ }
93
+ renameSync(stagingPath, finalPath);
94
+ }
95
+ export async function ensureAgent(deps) {
96
+ const agentDir = getAgentDir();
97
+ mkdirSync(agentDir, { recursive: true });
98
+ let server;
99
+ try {
100
+ const raw = await request(MANIFEST_PATH, {}, deps ?? {});
101
+ server = validateManifest(raw);
102
+ }
103
+ catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ throw new Error(`Failed to fetch agent manifest: ${message}. Check AXIMO_BASE_URL and AXIMO_API_KEY.`);
106
+ }
107
+ const local = readLocalManifest();
108
+ if (local?.version === server.version && localFilesMatch(server)) {
109
+ return {
110
+ scriptPath: getAgentScriptPath(),
111
+ requirementsPath: getAgentRequirementsPath(),
112
+ changed: false,
113
+ version: server.version,
114
+ };
115
+ }
116
+ const [scriptBuffer, requirementsBuffer] = await Promise.all([
117
+ fetchAsset(SCRIPT_ASSET_PATH, server.script, deps ?? {}),
118
+ fetchAsset(REQUIREMENTS_ASSET_PATH, server.requirements, deps ?? {}),
119
+ ]);
120
+ const scriptStaging = join(agentDir, "remote.py.tmp");
121
+ const requirementsStaging = join(agentDir, "requirements.txt.tmp");
122
+ try {
123
+ atomicWrite(getAgentScriptPath(), scriptStaging, scriptBuffer);
124
+ atomicWrite(getAgentRequirementsPath(), requirementsStaging, requirementsBuffer);
125
+ writeManifest(server);
126
+ }
127
+ catch (err) {
128
+ rmSync(scriptStaging, { force: true });
129
+ rmSync(requirementsStaging, { force: true });
130
+ throw err;
131
+ }
132
+ return {
133
+ scriptPath: getAgentScriptPath(),
134
+ requirementsPath: getAgentRequirementsPath(),
135
+ changed: true,
136
+ version: server.version,
137
+ };
138
+ }
139
+ export function readCachedManifest() {
140
+ return readLocalManifest();
141
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,130 @@
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 } from "vitest";
6
+ import { getAgentManifestPath, getAgentRequirementsPath, getAgentScriptPath, } from "../commands/device/paths.js";
7
+ import { HttpResponse, TEST_BASE_URL, http, server } from "../test-utils.js";
8
+ import { ensureAgent, readCachedManifest } from "./agent-sync.js";
9
+ let tempHome;
10
+ function sha256(buffer) {
11
+ return createHash("sha256").update(buffer).digest("hex");
12
+ }
13
+ const SCRIPT = 'print("hello")\n';
14
+ const REQUIREMENTS = "requests==2.31.0\n";
15
+ function buildManifest() {
16
+ const scriptSha = sha256(SCRIPT);
17
+ const requirementsSha = sha256(REQUIREMENTS);
18
+ return {
19
+ manifestVersion: 2,
20
+ version: sha256(`${scriptSha}:${requirementsSha}`).slice(0, 8),
21
+ script: { sha256: scriptSha, size: Buffer.byteLength(SCRIPT) },
22
+ requirements: { sha256: requirementsSha, size: Buffer.byteLength(REQUIREMENTS) },
23
+ };
24
+ }
25
+ function mockAgentEndpoints(script = SCRIPT, requirements = REQUIREMENTS) {
26
+ const scriptSha = sha256(script);
27
+ const requirementsSha = sha256(requirements);
28
+ const manifest = {
29
+ manifestVersion: 2,
30
+ version: sha256(`${scriptSha}:${requirementsSha}`).slice(0, 8),
31
+ script: { sha256: scriptSha, size: Buffer.byteLength(script) },
32
+ requirements: { sha256: requirementsSha, size: Buffer.byteLength(requirements) },
33
+ };
34
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/manifest`, () => HttpResponse.json(manifest)), http.get(`${TEST_BASE_URL}/api/devices/agent/remote.py`, () => new HttpResponse(script, { headers: { "Content-Type": "text/x-python" } })), http.get(`${TEST_BASE_URL}/api/devices/agent/requirements.txt`, () => new HttpResponse(requirements, { headers: { "Content-Type": "text/plain" } })));
35
+ return manifest;
36
+ }
37
+ beforeAll(() => {
38
+ tempHome = mkdtempSync(join(tmpdir(), "aximo-agent-"));
39
+ process.env["AXIMO_HOME"] = tempHome;
40
+ process.env["AXIMO_API_KEY"] = "test-key";
41
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
42
+ server.listen();
43
+ });
44
+ afterAll(() => {
45
+ delete process.env["AXIMO_HOME"];
46
+ server.close();
47
+ rmSync(tempHome, { recursive: true, force: true });
48
+ });
49
+ beforeEach(() => {
50
+ rmSync(tempHome, { recursive: true, force: true });
51
+ mkdirSync(tempHome, { recursive: true });
52
+ });
53
+ afterEach(() => server.resetHandlers());
54
+ describe("ensureAgent", () => {
55
+ it("downloads both files on first run and writes manifest", async () => {
56
+ const manifest = mockAgentEndpoints();
57
+ const result = await ensureAgent();
58
+ expect(result.changed).toBe(true);
59
+ expect(result.version).toBe(manifest.version);
60
+ expect(readFileSync(getAgentScriptPath(), "utf-8")).toBe(SCRIPT);
61
+ expect(readFileSync(getAgentRequirementsPath(), "utf-8")).toBe(REQUIREMENTS);
62
+ const savedManifest = JSON.parse(readFileSync(getAgentManifestPath(), "utf-8"));
63
+ expect(savedManifest.version).toBe(manifest.version);
64
+ });
65
+ it("skips download when local manifest and files match server", async () => {
66
+ mockAgentEndpoints();
67
+ await ensureAgent();
68
+ let scriptHits = 0;
69
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/remote.py`, () => {
70
+ scriptHits++;
71
+ return new HttpResponse(SCRIPT);
72
+ }));
73
+ const second = await ensureAgent();
74
+ expect(second.changed).toBe(false);
75
+ expect(scriptHits).toBe(0);
76
+ });
77
+ it("refetches when server version differs", async () => {
78
+ mockAgentEndpoints();
79
+ await ensureAgent();
80
+ const updatedScript = 'print("hello v2")\n';
81
+ const updatedManifest = mockAgentEndpoints(updatedScript);
82
+ const result = await ensureAgent();
83
+ expect(result.changed).toBe(true);
84
+ expect(result.version).toBe(updatedManifest.version);
85
+ expect(readFileSync(getAgentScriptPath(), "utf-8")).toBe(updatedScript);
86
+ });
87
+ it("rejects a downloaded asset whose sha256 doesn't match manifest", async () => {
88
+ const manifest = buildManifest();
89
+ // Same length as SCRIPT ('print("hello")\n' = 15 bytes) to trip the sha check, not the size check
90
+ const tampered = 'print("HELLO")\n';
91
+ expect(tampered.length).toBe(SCRIPT.length);
92
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/manifest`, () => HttpResponse.json(manifest)), http.get(`${TEST_BASE_URL}/api/devices/agent/remote.py`, () => new HttpResponse(tampered)), http.get(`${TEST_BASE_URL}/api/devices/agent/requirements.txt`, () => HttpResponse.text(REQUIREMENTS)));
93
+ await expect(ensureAgent()).rejects.toThrow(/sha256 mismatch/);
94
+ expect(existsSync(getAgentScriptPath())).toBe(false);
95
+ expect(existsSync(getAgentManifestPath())).toBe(false);
96
+ });
97
+ it("rejects unsupported manifest version", async () => {
98
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/manifest`, () => HttpResponse.json({ manifestVersion: 99, version: "abc", script: {}, requirements: {} })));
99
+ await expect(ensureAgent()).rejects.toThrow(/manifestVersion|Unsupported/);
100
+ });
101
+ it("wraps manifest fetch failure with an actionable message", async () => {
102
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/agent/manifest`, () => HttpResponse.json({ error: "nope" }, { status: 500 })));
103
+ await expect(ensureAgent()).rejects.toThrow(/Failed to fetch agent manifest/);
104
+ });
105
+ it("refetches if local files were deleted, even when manifest matches", async () => {
106
+ mockAgentEndpoints();
107
+ await ensureAgent();
108
+ rmSync(getAgentScriptPath());
109
+ const result = await ensureAgent();
110
+ expect(result.changed).toBe(true);
111
+ expect(existsSync(getAgentScriptPath())).toBe(true);
112
+ });
113
+ });
114
+ describe("readCachedManifest", () => {
115
+ it("returns null when manifest is missing", () => {
116
+ expect(readCachedManifest()).toBeNull();
117
+ });
118
+ it("returns null when manifest is unparseable", () => {
119
+ mkdirSync(join(tempHome, "agent"), { recursive: true });
120
+ writeFileSync(getAgentManifestPath(), "not json");
121
+ expect(readCachedManifest()).toBeNull();
122
+ });
123
+ it("returns the parsed manifest when valid", async () => {
124
+ const expected = mockAgentEndpoints();
125
+ await ensureAgent();
126
+ const cached = readCachedManifest();
127
+ expect(cached?.version).toBe(expected.version);
128
+ expect(cached?.manifestVersion).toBe(2);
129
+ });
130
+ });
@@ -0,0 +1,19 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import type { ClientDeps } from "../client.js";
3
+ export declare const PYTHON_RELEASE = "20260414";
4
+ export declare const PYTHON_VERSION = "3.12.13";
5
+ export declare function installedPythonTag(): string;
6
+ export interface PythonReadyResult {
7
+ pythonPath: string;
8
+ scriptPath: string;
9
+ agentVersion: string;
10
+ agentChanged: boolean;
11
+ }
12
+ export declare function ensurePythonReady(deps?: ClientDeps): Promise<PythonReadyResult>;
13
+ interface RemoteOptions {
14
+ relayUrl: string;
15
+ deviceToken: string;
16
+ deviceName: string;
17
+ }
18
+ export declare function startDesktopRemote(pythonPath: string, scriptPath: string, options: RemoteOptions): ChildProcess;
19
+ export {};