@autifyhq/aximo-cli 0.15.0 → 0.16.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 (72) hide show
  1. package/README.md +19 -1
  2. package/dist/commands/device/device-config.d.ts +2 -2
  3. package/dist/commands/device/device-config.js +5 -8
  4. package/dist/commands/device/index.js +2 -1
  5. package/dist/commands/device/paths.d.ts +4 -0
  6. package/dist/commands/device/paths.js +12 -0
  7. package/dist/commands/device/reset.d.ts +1 -0
  8. package/dist/commands/device/reset.js +18 -3
  9. package/dist/commands/device/reset.test.js +13 -0
  10. package/dist/commands/device/testbox/box.d.ts +90 -0
  11. package/dist/commands/device/testbox/box.js +379 -0
  12. package/dist/commands/device/testbox/box.test.d.ts +1 -0
  13. package/dist/commands/device/testbox/box.test.js +815 -0
  14. package/dist/commands/device/testbox/container.d.ts +58 -0
  15. package/dist/commands/device/testbox/container.js +289 -0
  16. package/dist/commands/device/testbox/docker.d.ts +31 -0
  17. package/dist/commands/device/testbox/docker.js +78 -0
  18. package/dist/commands/device/testbox/docker.test.d.ts +1 -0
  19. package/dist/commands/device/testbox/docker.test.js +82 -0
  20. package/dist/commands/device/testbox/down.d.ts +7 -0
  21. package/dist/commands/device/testbox/down.js +14 -0
  22. package/dist/commands/device/testbox/errors.d.ts +4 -0
  23. package/dist/commands/device/testbox/errors.js +7 -0
  24. package/dist/commands/device/testbox/fake-docker.d.ts +25 -0
  25. package/dist/commands/device/testbox/fake-docker.js +35 -0
  26. package/dist/commands/device/testbox/index.d.ts +2 -0
  27. package/dist/commands/device/testbox/index.js +16 -0
  28. package/dist/commands/device/testbox/logs.d.ts +15 -0
  29. package/dist/commands/device/testbox/logs.js +43 -0
  30. package/dist/commands/device/testbox/prune.d.ts +11 -0
  31. package/dist/commands/device/testbox/prune.js +17 -0
  32. package/dist/commands/device/testbox/registration.d.ts +9 -0
  33. package/dist/commands/device/testbox/registration.js +56 -0
  34. package/dist/commands/device/testbox/reset.d.ts +7 -0
  35. package/dist/commands/device/testbox/reset.js +14 -0
  36. package/dist/commands/device/testbox/result.d.ts +8 -0
  37. package/dist/commands/device/testbox/result.js +23 -0
  38. package/dist/commands/device/testbox/result.test.d.ts +1 -0
  39. package/dist/commands/device/testbox/result.test.js +34 -0
  40. package/dist/commands/device/testbox/self-command.d.ts +12 -0
  41. package/dist/commands/device/testbox/self-command.js +36 -0
  42. package/dist/commands/device/testbox/self-command.test.d.ts +1 -0
  43. package/dist/commands/device/testbox/self-command.test.js +58 -0
  44. package/dist/commands/device/testbox/status.d.ts +7 -0
  45. package/dist/commands/device/testbox/status.js +11 -0
  46. package/dist/commands/device/testbox/up.d.ts +47 -0
  47. package/dist/commands/device/testbox/up.js +76 -0
  48. package/dist/commands/device/testbox/up.test.d.ts +1 -0
  49. package/dist/commands/device/testbox/up.test.js +52 -0
  50. package/dist/commands/device/testbox/url.d.ts +7 -0
  51. package/dist/commands/device/testbox/url.js +20 -0
  52. package/dist/commands/device/user-devices.d.ts +4 -0
  53. package/dist/commands/device/user-devices.js +18 -0
  54. package/dist/commands/mcp/server.d.ts +2 -2
  55. package/dist/commands/mcp/server.test.js +6 -2
  56. package/dist/commands/mcp/testbox-tools.test.d.ts +1 -0
  57. package/dist/commands/mcp/testbox-tools.test.js +278 -0
  58. package/dist/commands/mcp/tools.d.ts +8 -1
  59. package/dist/commands/mcp/tools.js +151 -18
  60. package/dist/commands/mcp/tools.test.js +27 -2
  61. package/dist/commands/schema.js +4 -0
  62. package/dist/commands/session/cancel.d.ts +20 -0
  63. package/dist/commands/session/cancel.js +39 -0
  64. package/dist/commands/session/index.js +1 -0
  65. package/dist/commands/session/session.test.js +38 -0
  66. package/dist/errors.d.ts +2 -0
  67. package/dist/errors.js +5 -0
  68. package/dist/errors.test.js +6 -1
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.js +1 -0
  71. package/dist/types.d.ts +5 -0
  72. package/package.json +1 -1
@@ -0,0 +1,379 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { getBaseUrl } from "../../../client.js";
4
+ import { ensureAgent } from "../../../desktop-server/agent-sync.js";
5
+ import { realClock } from "../../mcp/wait.js";
6
+ import { getTestboxConfigPath, getTestboxDir, getTestboxLogPath, getTestboxStatePath, } from "../paths.js";
7
+ import { listUserDevices } from "../user-devices.js";
8
+ import { AGENT_LOG, containerLog, containerName, DEFAULT_SCREEN, ensureImage, injectAgent, inspectContainer, legacyVolumes, liveViewUrl, nextNovncPort, parsePorts, probePorts, profileVolume, readInContainer, removeContainer, reservedPorts, resolveImage, RUN_DIR, runArgs, validateBoxName, validateNovncPort, validateScreen, } from "./container.js";
9
+ import { docker, isDockerProblem, preflightDocker, runDocker, } from "./docker.js";
10
+ import { TestboxError } from "./errors.js";
11
+ import { ensureRegistration, readRegistration, unregister } from "./registration.js";
12
+ const DEFAULT_PORTS = "3000";
13
+ const ONLINE_TIMEOUT_MS = 120_000;
14
+ const ONLINE_POLL_MS = 3_000;
15
+ // Backstop for a pid recycled after a reboot; a live pid wins until then.
16
+ const STATE_TTL_MS = 60 * 60_000;
17
+ export function readState(name) {
18
+ const path = getTestboxStatePath(name);
19
+ if (!existsSync(path))
20
+ return null;
21
+ try {
22
+ return JSON.parse(readFileSync(path, "utf-8"));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function writeState(name, state) {
29
+ mkdirSync(getTestboxDir(), { recursive: true });
30
+ writeFileSync(getTestboxStatePath(name), JSON.stringify(state, null, 2), { mode: 0o600 });
31
+ }
32
+ export function clearState(name) {
33
+ rmSync(getTestboxStatePath(name), { force: true });
34
+ }
35
+ // Exclusive create: of two ups racing for the same box, exactly one gets the file.
36
+ function claimState(name, state) {
37
+ mkdirSync(getTestboxDir(), { recursive: true });
38
+ try {
39
+ writeFileSync(getTestboxStatePath(name), JSON.stringify(state, null, 2), {
40
+ mode: 0o600,
41
+ flag: "wx",
42
+ });
43
+ return true;
44
+ }
45
+ catch (err) {
46
+ if (err.code === "EEXIST")
47
+ return false;
48
+ throw err;
49
+ }
50
+ }
51
+ function pidAlive(pid) {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ // A pid from the state file may have been recycled; only signal a process
61
+ // that is visibly one of ours. Windows has no cheap equivalent, so it skips.
62
+ function isTestboxUp(pid) {
63
+ if (process.platform === "win32")
64
+ return false;
65
+ const ps = spawnSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf-8" });
66
+ return ps.status === 0 && /testbox/.test(ps.stdout);
67
+ }
68
+ function stateIsLive(state, clock) {
69
+ if (state.phase === "error")
70
+ return true;
71
+ return pidAlive(state.pid) && clock.now() - Date.parse(state.startedAt) < STATE_TTL_MS;
72
+ }
73
+ /** The detached `up` still working on this box, if any. */
74
+ export function pendingUp(name, clock = realClock) {
75
+ const state = readState(name);
76
+ return state && state.phase !== "error" && stateIsLive(state, clock) ? state : null;
77
+ }
78
+ function chooseNetwork(info) {
79
+ if (info.desktop || process.platform !== "linux")
80
+ return { network: "bridge" };
81
+ if (info.rootless) {
82
+ return {
83
+ network: "bridge",
84
+ note: "Rootless Docker cannot share the host network; dev servers must listen on 0.0.0.0, not 127.0.0.1.",
85
+ };
86
+ }
87
+ return { network: "host" };
88
+ }
89
+ async function waitOnline(deviceId, deps, clock, timeoutMs) {
90
+ const deadline = clock.now() + timeoutMs;
91
+ for (;;) {
92
+ let device;
93
+ try {
94
+ device = (await listUserDevices(deps)).find((d) => d.id === deviceId);
95
+ }
96
+ catch (err) {
97
+ // A 403 means the BYOD flag was switched off under us; anything else is a blip.
98
+ if (err.status === 403)
99
+ throw err;
100
+ }
101
+ if (device?.status === "online")
102
+ return device;
103
+ if (clock.now() + ONLINE_POLL_MS > deadline)
104
+ return device;
105
+ await clock.sleep(ONLINE_POLL_MS);
106
+ }
107
+ }
108
+ export async function upTestbox(options, deps = {}) {
109
+ const run = deps.docker ?? runDocker;
110
+ const clock = deps.clock ?? realClock;
111
+ const name = validateBoxName(options.name);
112
+ const container = containerName(name);
113
+ const notes = [];
114
+ // Reported without touching any state, so a machine without Docker is left as found.
115
+ const info = await preflightDocker(run);
116
+ if (isDockerProblem(info))
117
+ throw new TestboxError(info.message, info.kind === "unreachable");
118
+ const started = new Date().toISOString();
119
+ const stateFor = (phase, lastError) => ({
120
+ phase,
121
+ startedAt: started,
122
+ pid: process.pid,
123
+ ...(lastError ? { lastError } : {}),
124
+ });
125
+ const state = (phase, lastError) => writeState(name, stateFor(phase, lastError));
126
+ // Two concurrent ups would each register a device and fight over the container.
127
+ // A stale file (a finished error, a dead pid) is replaced; a live one wins.
128
+ for (let attempt = 0; !claimState(name, stateFor("pulling")); attempt++) {
129
+ const pending = pendingUp(name, clock);
130
+ if (attempt > 0 || (pending && pending.pid !== process.pid)) {
131
+ const who = pending ? ` (pid ${pending.pid}, ${pending.phase})` : "";
132
+ throw new TestboxError(`Another 'aximo device testbox up' is already working on this box${who}; wait for it or run 'aximo device testbox status'.`, true);
133
+ }
134
+ clearState(name);
135
+ }
136
+ try {
137
+ // Validates the key, the base URL and the BYOD flag before anything is pulled.
138
+ const own = await listUserDevices(deps);
139
+ const existing = await inspectContainer(run, container);
140
+ const previous = existing?.config ?? null;
141
+ const { network, note } = chooseNetwork(info);
142
+ if (note)
143
+ notes.push(note);
144
+ const cfg = {
145
+ image: resolveImage(options.image, previous?.image),
146
+ ports: options.ports ?? previous?.ports ?? DEFAULT_PORTS,
147
+ blockHosts: options.blockHosts ?? previous?.blockHosts ?? [],
148
+ startUrl: options.startUrl ?? previous?.startUrl,
149
+ network,
150
+ novncPort: options.novncPort ?? previous?.novncPort ?? (await nextNovncPort(run)),
151
+ screen: validateScreen(options.screen ?? previous?.screen ?? DEFAULT_SCREEN),
152
+ };
153
+ validateNovncPort(cfg.novncPort, cfg.network);
154
+ const clash = parsePorts(cfg.ports).filter((p) => reservedPorts(cfg).includes(p));
155
+ if (clash.length > 0) {
156
+ throw new TestboxError(`Port ${clash.join(", ")} is used by the live view inside the box; choose other ports or another --novnc-port`);
157
+ }
158
+ const registration = await ensureRegistration(name, own, deps);
159
+ const agent = await (deps.fetchAgent ?? ensureAgent)(deps);
160
+ await ensureImage(run, cfg.image);
161
+ state("starting");
162
+ const reuse = previous !== null && JSON.stringify(previous) === JSON.stringify(cfg);
163
+ if (existing && !reuse) {
164
+ await removeContainer(run, container);
165
+ for (const volume of legacyVolumes(name))
166
+ await run(["volume", "rm", "-f", volume]);
167
+ }
168
+ const created = !existing || !reuse;
169
+ if (created) {
170
+ await docker(run, runArgs(name, cfg));
171
+ }
172
+ else if (!existing.running) {
173
+ await docker(run, ["start", container]);
174
+ }
175
+ try {
176
+ await injectAgent(run, container, {
177
+ deviceToken: registration.deviceToken,
178
+ deviceName: registration.name,
179
+ baseUrl: registration.baseUrl ?? "",
180
+ }, cfg.network, agent);
181
+ }
182
+ catch (err) {
183
+ // The entrypoint's own FATAL line is the useful part. A box we did not
184
+ // create is left alone: it may be mid-session.
185
+ const log = await containerLog(run, container, 20);
186
+ if (created)
187
+ await run(["rm", "-f", container]);
188
+ const message = err instanceof Error ? err.message : String(err);
189
+ throw new TestboxError(log ? `${message}\n${log}` : message, true);
190
+ }
191
+ state("connecting");
192
+ let device;
193
+ if (options.wait !== false) {
194
+ device = await waitOnline(registration.deviceId, deps, clock, ONLINE_TIMEOUT_MS);
195
+ if (device?.status !== "online") {
196
+ notes.push("The device has not come online yet; 'aximo device testbox status' shows the agent's last message.");
197
+ }
198
+ }
199
+ const url = await liveViewUrl(run, container, cfg.novncPort);
200
+ clearState(name);
201
+ return {
202
+ name,
203
+ container,
204
+ userDeviceId: registration.deviceId,
205
+ deviceName: registration.name,
206
+ state: device?.status === "online" ? "online" : "connecting",
207
+ config: cfg,
208
+ ...(url ? { liveViewUrl: url } : {}),
209
+ notes,
210
+ };
211
+ }
212
+ catch (err) {
213
+ state("error", err instanceof Error ? err.message : String(err));
214
+ throw err;
215
+ }
216
+ }
217
+ export async function statusTestbox(nameInput, deps = {}) {
218
+ const run = deps.docker ?? runDocker;
219
+ const clock = deps.clock ?? realClock;
220
+ const name = validateBoxName(nameInput);
221
+ const container = containerName(name);
222
+ const status = { name, state: "down", container: "absent" };
223
+ const pending = readState(name);
224
+ const live = pending && stateIsLive(pending, clock) ? pending : null;
225
+ let info = null;
226
+ try {
227
+ info = await inspectContainer(run, container);
228
+ }
229
+ catch (err) {
230
+ // Without the docker binary the box cannot be running; anything else is reported.
231
+ if (err.code !== "ENOENT")
232
+ throw err;
233
+ }
234
+ if (info) {
235
+ status.container = info.running ? "running" : "stopped";
236
+ if (info.config)
237
+ status.config = info.config;
238
+ if (info.running && info.config) {
239
+ status.agent =
240
+ (await run(["exec", container, "pgrep", "-f", "remote.py"])).code === 0
241
+ ? "running"
242
+ : "stopped";
243
+ const last = await readInContainer(run, container, `${RUN_DIR}/agent.status`);
244
+ if (last?.trim())
245
+ status.agentStatus = last.trim();
246
+ status.ports = await probePorts(run, container, info.config.ports);
247
+ const url = await liveViewUrl(run, container, info.config.novncPort);
248
+ if (url)
249
+ status.liveViewUrl = url;
250
+ }
251
+ }
252
+ const registration = readRegistration(name);
253
+ if (registration) {
254
+ try {
255
+ const device = (await listUserDevices(deps)).find((d) => d.id === registration.deviceId);
256
+ if (device) {
257
+ status.device = { userDeviceId: device.id, name: device.name, status: device.status };
258
+ }
259
+ }
260
+ catch (err) {
261
+ status.lastError = err instanceof Error ? err.message : String(err);
262
+ }
263
+ }
264
+ // The last up attempt failed: say so even if an older box is still running,
265
+ // or a reconfigure that never happened would look like success.
266
+ if (live?.phase === "error") {
267
+ status.state = "error";
268
+ if (live.lastError)
269
+ status.lastError = live.lastError;
270
+ }
271
+ else if (live) {
272
+ status.state = live.phase;
273
+ }
274
+ else if (status.container !== "running") {
275
+ status.state = "down";
276
+ }
277
+ else {
278
+ status.state = status.device?.status === "online" ? "online" : "connecting";
279
+ }
280
+ return status;
281
+ }
282
+ /** Status now, or once the box is online or has stopped trying, whichever comes first. */
283
+ export async function waitForTestbox(name, timeoutSeconds, deps = {}) {
284
+ const clock = deps.clock ?? realClock;
285
+ const deadline = clock.now() + timeoutSeconds * 1000;
286
+ for (;;) {
287
+ const status = await statusTestbox(name, deps);
288
+ if (status.state === "online" || status.state === "error" || status.state === "down") {
289
+ return status;
290
+ }
291
+ if (clock.now() + ONLINE_POLL_MS > deadline)
292
+ return status;
293
+ await clock.sleep(ONLINE_POLL_MS);
294
+ }
295
+ }
296
+ export async function downTestbox(nameInput, deps = {}) {
297
+ const run = deps.docker ?? runDocker;
298
+ const name = validateBoxName(nameInput);
299
+ const pending = pendingUp(name, deps.clock);
300
+ if (pending && pending.pid !== process.pid && isTestboxUp(pending.pid)) {
301
+ try {
302
+ process.kill(pending.pid, "SIGTERM");
303
+ }
304
+ catch {
305
+ // already gone
306
+ }
307
+ }
308
+ const existed = (await inspectContainer(run, containerName(name))) !== null;
309
+ if (existed)
310
+ await removeContainer(run, containerName(name));
311
+ clearState(name);
312
+ return { name, removed: existed };
313
+ }
314
+ export async function resetTestbox(nameInput, deps = {}) {
315
+ const run = deps.docker ?? runDocker;
316
+ const down = await downTestbox(nameInput, deps);
317
+ await docker(run, ["volume", "rm", "-f", profileVolume(down.name)]);
318
+ return { ...down, profileDropped: true };
319
+ }
320
+ export async function pruneTestbox(nameInput, options = {}, deps = {}) {
321
+ const run = deps.docker ?? runDocker;
322
+ const name = validateBoxName(nameInput);
323
+ const registration = readRegistration(name);
324
+ const baseUrl = deps.baseUrl ?? getBaseUrl();
325
+ if (registration?.baseUrl !== undefined && registration.baseUrl !== baseUrl) {
326
+ throw new TestboxError(`This testbox is registered against ${registration.baseUrl}, not ${baseUrl}; point the CLI at that server to prune it.`);
327
+ }
328
+ // The server row and the local files go even on a machine that lost Docker;
329
+ // any other Docker failure is reported, not papered over.
330
+ let existing;
331
+ try {
332
+ existing = await inspectContainer(run, containerName(name));
333
+ }
334
+ catch (err) {
335
+ if (err.code !== "ENOENT")
336
+ throw err;
337
+ existing = undefined;
338
+ }
339
+ const serverDeleted = registration ? await unregister(registration.deviceId, deps) : false;
340
+ let removed = false;
341
+ if (existing !== undefined) {
342
+ const image = existing?.config?.image ?? resolveImage();
343
+ removed = (await downTestbox(name, deps)).removed;
344
+ for (const volume of [profileVolume(name), ...legacyVolumes(name)]) {
345
+ await docker(run, ["volume", "rm", "-f", volume]);
346
+ }
347
+ // Best effort: another box may still run this image.
348
+ if (!options.keepImage)
349
+ await run(["rmi", "-f", image]);
350
+ }
351
+ clearState(name);
352
+ rmSync(getTestboxConfigPath(name), { force: true });
353
+ rmSync(getTestboxLogPath(name), { force: true });
354
+ return { name, removed, deviceId: registration?.deviceId, serverDeleted };
355
+ }
356
+ export async function agentLog(nameInput, lines, deps = {}) {
357
+ const run = deps.docker ?? runDocker;
358
+ const name = validateBoxName(nameInput);
359
+ const out = await docker(run, [
360
+ "exec",
361
+ containerName(name),
362
+ "tail",
363
+ "-n",
364
+ String(lines),
365
+ AGENT_LOG,
366
+ ]);
367
+ return { name, lines: out.split("\n").filter(Boolean) };
368
+ }
369
+ export function agentLogFollowArgs(name, lines) {
370
+ return [
371
+ "exec",
372
+ containerName(validateBoxName(name)),
373
+ "tail",
374
+ "-n",
375
+ String(lines),
376
+ "-f",
377
+ AGENT_LOG,
378
+ ];
379
+ }
@@ -0,0 +1 @@
1
+ export {};