@autifyhq/aximo-cli 0.4.0 → 0.6.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 +219 -38
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +18 -0
- package/dist/commands/device/device-config.d.ts +14 -0
- package/dist/commands/device/device-config.js +46 -0
- package/dist/commands/device/device-config.test.d.ts +1 -0
- package/dist/commands/device/device-config.test.js +56 -0
- package/dist/commands/device/doctor.d.ts +13 -0
- package/dist/commands/device/doctor.js +142 -25
- package/dist/commands/device/doctor.test.js +61 -1
- package/dist/commands/device/register.d.ts +2 -0
- package/dist/commands/device/register.js +50 -32
- package/dist/commands/device/register.test.d.ts +1 -0
- package/dist/commands/device/register.test.js +95 -0
- package/dist/commands/device/unregister.d.ts +5 -3
- package/dist/commands/device/unregister.js +32 -33
- package/dist/commands/device/unregister.test.d.ts +1 -0
- package/dist/commands/device/unregister.test.js +73 -0
- package/dist/commands/mobile/connect-core.d.ts +12 -0
- package/dist/commands/mobile/connect-core.js +88 -0
- package/dist/commands/mobile/connect.d.ts +9 -0
- package/dist/commands/mobile/connect.js +35 -0
- package/dist/commands/mobile/connect.test.d.ts +1 -0
- package/dist/commands/mobile/connect.test.js +87 -0
- package/dist/commands/mobile/doctor.d.ts +28 -0
- package/dist/commands/mobile/doctor.js +162 -0
- package/dist/commands/mobile/index.d.ts +2 -0
- package/dist/commands/mobile/index.js +16 -0
- package/dist/commands/mobile/install.d.ts +43 -0
- package/dist/commands/mobile/install.js +76 -0
- package/dist/commands/mobile/install.test.d.ts +1 -0
- package/dist/commands/mobile/install.test.js +104 -0
- package/dist/commands/mobile/list.d.ts +10 -0
- package/dist/commands/mobile/list.js +30 -0
- package/dist/commands/mobile/paths.d.ts +14 -0
- package/dist/commands/mobile/paths.js +71 -0
- package/dist/commands/mobile/register-core.d.ts +19 -0
- package/dist/commands/mobile/register-core.js +46 -0
- package/dist/commands/mobile/register.d.ts +37 -0
- package/dist/commands/mobile/register.js +57 -0
- package/dist/commands/mobile/register.test.d.ts +1 -0
- package/dist/commands/mobile/register.test.js +91 -0
- package/dist/commands/mobile/setup.d.ts +27 -0
- package/dist/commands/mobile/setup.js +100 -0
- package/dist/commands/mobile/setup.test.d.ts +1 -0
- package/dist/commands/mobile/setup.test.js +49 -0
- package/dist/commands/mobile/unregister.d.ts +14 -0
- package/dist/commands/mobile/unregister.js +37 -0
- package/dist/commands/schema.js +29 -0
- package/dist/commands/session/create.d.ts +5 -0
- package/dist/commands/session/create.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mobile-control/app-install.d.ts +21 -0
- package/dist/mobile-control/app-install.js +28 -0
- package/dist/mobile-control/app-install.test.d.ts +1 -0
- package/dist/mobile-control/app-install.test.js +78 -0
- package/dist/mobile-control/appium-controller.d.ts +42 -0
- package/dist/mobile-control/appium-controller.js +372 -0
- package/dist/mobile-control/appium-controller.test.d.ts +1 -0
- package/dist/mobile-control/appium-controller.test.js +252 -0
- package/dist/mobile-control/appium-import.d.ts +7 -0
- package/dist/mobile-control/appium-import.js +6 -0
- package/dist/mobile-control/appium-import.test.d.ts +1 -0
- package/dist/mobile-control/appium-import.test.js +9 -0
- package/dist/mobile-control/appium-server.d.ts +30 -0
- package/dist/mobile-control/appium-server.js +110 -0
- package/dist/mobile-control/appium-server.test.d.ts +1 -0
- package/dist/mobile-control/appium-server.test.js +108 -0
- package/dist/mobile-control/controller.d.ts +22 -0
- package/dist/mobile-control/controller.js +1 -0
- package/dist/mobile-control/devices.d.ts +23 -0
- package/dist/mobile-control/devices.js +129 -0
- package/dist/mobile-control/devices.test.d.ts +1 -0
- package/dist/mobile-control/devices.test.js +75 -0
- package/dist/mobile-control/exec.d.ts +6 -0
- package/dist/mobile-control/exec.js +13 -0
- package/dist/mobile-control/ios-signing.d.ts +28 -0
- package/dist/mobile-control/ios-signing.js +38 -0
- package/dist/mobile-control/ios-signing.test.d.ts +1 -0
- package/dist/mobile-control/ios-signing.test.js +73 -0
- package/dist/mobile-control/tunnel.d.ts +15 -0
- package/dist/mobile-control/tunnel.js +165 -0
- package/dist/mobile-control/tunnel.test.d.ts +1 -0
- package/dist/mobile-control/tunnel.test.js +155 -0
- package/dist/mobile-control/webdriver.d.ts +31 -0
- package/dist/mobile-control/webdriver.js +137 -0
- package/dist/mobile-control/webdriver.test.d.ts +1 -0
- package/dist/mobile-control/webdriver.test.js +45 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +85 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +72 -0
- package/dist/types.d.ts +1 -0
- package/package.json +5 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { platform } from "node:os";
|
|
4
|
+
import { connect as tlsConnect } from "node:tls";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { WebSocket } from "ws";
|
|
6
7
|
import { getBaseUrl, request } from "../../client.js";
|
|
@@ -8,6 +9,7 @@ import { loadConfig } from "../../config.js";
|
|
|
8
9
|
import { readCachedManifest } from "../../desktop-server/agent-sync.js";
|
|
9
10
|
import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
|
|
10
11
|
import { formatSuccess, printResult } from "../../output.js";
|
|
12
|
+
import { readDeviceConfig } from "./device-config.js";
|
|
11
13
|
import { getAgentRequirementsPath, getAgentScriptPath, getDepsMarkerPath, getDeviceConfigPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, } from "./paths.js";
|
|
12
14
|
function redactKey(value) {
|
|
13
15
|
if (value.length <= 8)
|
|
@@ -68,8 +70,7 @@ async function checkApiHealth(deps) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
function checkDeviceConfig() {
|
|
71
|
-
|
|
72
|
-
if (!existsSync(configPath)) {
|
|
73
|
+
if (!existsSync(getDeviceConfigPath())) {
|
|
73
74
|
return {
|
|
74
75
|
check: {
|
|
75
76
|
name: "device registered",
|
|
@@ -79,37 +80,25 @@ function checkDeviceConfig() {
|
|
|
79
80
|
},
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (!config.deviceId) {
|
|
85
|
-
return {
|
|
86
|
-
check: {
|
|
87
|
-
name: "device registered",
|
|
88
|
-
status: "fail",
|
|
89
|
-
message: "device.json missing deviceId",
|
|
90
|
-
hint: "Re-register: aximo device register --name ...",
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
check: {
|
|
96
|
-
name: "device registered",
|
|
97
|
-
status: "pass",
|
|
98
|
-
message: `${config.name ?? "(unnamed)"} · ${config.deviceId}`,
|
|
99
|
-
},
|
|
100
|
-
config,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
83
|
+
const config = readDeviceConfig();
|
|
84
|
+
if (!config) {
|
|
104
85
|
return {
|
|
105
86
|
check: {
|
|
106
87
|
name: "device registered",
|
|
107
88
|
status: "fail",
|
|
108
|
-
message:
|
|
89
|
+
message: "device.json unreadable or missing required fields",
|
|
109
90
|
hint: "Delete the file and re-register",
|
|
110
91
|
},
|
|
111
92
|
};
|
|
112
93
|
}
|
|
94
|
+
return {
|
|
95
|
+
check: {
|
|
96
|
+
name: "device registered",
|
|
97
|
+
status: "pass",
|
|
98
|
+
message: `${config.name} · ${config.deviceId}`,
|
|
99
|
+
},
|
|
100
|
+
config,
|
|
101
|
+
};
|
|
113
102
|
}
|
|
114
103
|
function checkEmbeddedPython() {
|
|
115
104
|
if (!existsSync(getPythonDir())) {
|
|
@@ -303,6 +292,133 @@ function checkRelayReachable() {
|
|
|
303
292
|
});
|
|
304
293
|
});
|
|
305
294
|
}
|
|
295
|
+
const NET_ERROR_CODES = new Set([
|
|
296
|
+
"ECONNREFUSED",
|
|
297
|
+
"ENOTFOUND",
|
|
298
|
+
"ETIMEDOUT",
|
|
299
|
+
"ECONNRESET",
|
|
300
|
+
"EHOSTUNREACH",
|
|
301
|
+
"ENETUNREACH",
|
|
302
|
+
"EAI_AGAIN",
|
|
303
|
+
"EPIPE",
|
|
304
|
+
]);
|
|
305
|
+
function classifyTlsError(err) {
|
|
306
|
+
const code = err.code ?? "";
|
|
307
|
+
if (NET_ERROR_CODES.has(code))
|
|
308
|
+
return "conn";
|
|
309
|
+
if (/CERT|SELF_SIGNED/.test(code) ||
|
|
310
|
+
/certificate|self.signed|verify|altname/i.test(err.message)) {
|
|
311
|
+
return "cert";
|
|
312
|
+
}
|
|
313
|
+
return "conn";
|
|
314
|
+
}
|
|
315
|
+
// TLS handshake to host:port trusting ONLY `ca`. Mirrors how the Python agent
|
|
316
|
+
// verifies the relay via SSL_CERT_FILE, which replaces the whole trust store —
|
|
317
|
+
// passing `ca` to tls.connect likewise replaces Node's default roots.
|
|
318
|
+
function defaultTlsProbe(host, port, ca) {
|
|
319
|
+
return new Promise((resolve) => {
|
|
320
|
+
let settled = false;
|
|
321
|
+
const finish = (r) => {
|
|
322
|
+
if (settled)
|
|
323
|
+
return;
|
|
324
|
+
settled = true;
|
|
325
|
+
resolve(r);
|
|
326
|
+
};
|
|
327
|
+
const socket = tlsConnect({ host, port, ca, servername: host, rejectUnauthorized: true }, () => {
|
|
328
|
+
socket.end();
|
|
329
|
+
finish({ ok: true });
|
|
330
|
+
});
|
|
331
|
+
socket.setTimeout(5_000, () => {
|
|
332
|
+
socket.destroy();
|
|
333
|
+
finish({ ok: false, kind: "conn", reason: "timed out" });
|
|
334
|
+
});
|
|
335
|
+
socket.on("error", (err) => {
|
|
336
|
+
socket.destroy();
|
|
337
|
+
finish({ ok: false, kind: classifyTlsError(err), reason: err.message });
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const AGENT_TLS_CHECK_NAME = "agent TLS trust";
|
|
342
|
+
// The desktop agent (Python) verifies the wss:// relay against SSL_CERT_FILE — a
|
|
343
|
+
// different trust path than the Node CLI's NODE_EXTRA_CA_CERTS. A stale
|
|
344
|
+
// SSL_CERT_FILE (e.g. a bundle from a previous ephemeral on-prem box) makes the
|
|
345
|
+
// agent fail TLS even though every CLI check — including the relay handshake
|
|
346
|
+
// above — passes. Catch that gap by validating the relay against the same bundle
|
|
347
|
+
// the agent will use.
|
|
348
|
+
export async function checkAgentTlsTrust(deps = {}) {
|
|
349
|
+
const name = AGENT_TLS_CHECK_NAME;
|
|
350
|
+
const env = deps.env ?? process.env;
|
|
351
|
+
const rawBase = deps.baseUrl ?? getBaseUrl();
|
|
352
|
+
let url;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(rawBase);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return { name, status: "info", message: `base URL not parseable: ${rawBase}` };
|
|
358
|
+
}
|
|
359
|
+
if (url.protocol !== "https:") {
|
|
360
|
+
return {
|
|
361
|
+
name,
|
|
362
|
+
status: "info",
|
|
363
|
+
message: "relay is plain ws:// (no TLS); SSL_CERT_FILE not used",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const sslCertFile = env["SSL_CERT_FILE"];
|
|
367
|
+
if (!sslCertFile) {
|
|
368
|
+
return env["NODE_EXTRA_CA_CERTS"]
|
|
369
|
+
? {
|
|
370
|
+
name,
|
|
371
|
+
status: "info",
|
|
372
|
+
message: "SSL_CERT_FILE unset; agent bundle is built from NODE_EXTRA_CA_CERTS at connect",
|
|
373
|
+
}
|
|
374
|
+
: { name, status: "info", message: "SSL_CERT_FILE unset; agent trusts public CAs (certifi)" };
|
|
375
|
+
}
|
|
376
|
+
const unsetHint = "Unset SSL_CERT_FILE so the CLI rebuilds it from NODE_EXTRA_CA_CERTS, or regenerate the bundle for this server.";
|
|
377
|
+
if (!existsSync(sslCertFile)) {
|
|
378
|
+
return {
|
|
379
|
+
name,
|
|
380
|
+
status: "fail",
|
|
381
|
+
message: `SSL_CERT_FILE points at a missing file: ${sslCertFile}`,
|
|
382
|
+
hint: unsetHint,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
let ca;
|
|
386
|
+
try {
|
|
387
|
+
ca = readFileSync(sslCertFile);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
return {
|
|
391
|
+
name,
|
|
392
|
+
status: "fail",
|
|
393
|
+
message: `SSL_CERT_FILE unreadable (${sslCertFile}): ${err instanceof Error ? err.message : String(err)}`,
|
|
394
|
+
hint: unsetHint,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const host = url.hostname;
|
|
398
|
+
const port = url.port ? Number(url.port) : 443;
|
|
399
|
+
const probe = deps.probe ?? defaultTlsProbe;
|
|
400
|
+
const result = await probe(host, port, ca);
|
|
401
|
+
if (result.ok) {
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
status: "pass",
|
|
405
|
+
message: `SSL_CERT_FILE verifies ${host}:${port} (agent TLS OK)`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (result.kind === "cert") {
|
|
409
|
+
return {
|
|
410
|
+
name,
|
|
411
|
+
status: "fail",
|
|
412
|
+
message: `SSL_CERT_FILE is set (${sslCertFile}) but does not verify ${host}:${port}: ${result.reason}`,
|
|
413
|
+
hint: unsetHint,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
name,
|
|
418
|
+
status: "info",
|
|
419
|
+
message: `could not reach ${host}:${port} to test SSL_CERT_FILE trust: ${result.reason}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
306
422
|
async function checkCurrentStatus(deviceId, deps) {
|
|
307
423
|
const name = "device status";
|
|
308
424
|
try {
|
|
@@ -399,6 +515,7 @@ export async function runDeviceDoctor(options = {}, deps) {
|
|
|
399
515
|
}
|
|
400
516
|
if (!options.skipRelay) {
|
|
401
517
|
checks.push(await checkRelayReachable());
|
|
518
|
+
checks.push(await checkAgentTlsTrust());
|
|
402
519
|
}
|
|
403
520
|
if (config?.deviceId && !options.skipApi && checks[0]?.status !== "fail") {
|
|
404
521
|
checks.push(await checkCurrentStatus(config.deviceId, deps));
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
-
import { relayProbeUrl, runDeviceDoctor } from "./doctor.js";
|
|
6
|
+
import { checkAgentTlsTrust, relayProbeUrl, runDeviceDoctor } from "./doctor.js";
|
|
7
7
|
let tempHome;
|
|
8
8
|
beforeAll(() => {
|
|
9
9
|
tempHome = mkdtempSync(join(tmpdir(), "aximo-doctor-"));
|
|
@@ -93,3 +93,63 @@ describe("relayProbeUrl", () => {
|
|
|
93
93
|
expect(relayProbeUrl()).toBe(`${wsBase}/devices/tunnel/?EIO=4&transport=websocket`);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
describe("checkAgentTlsTrust", () => {
|
|
97
|
+
const HTTPS = "https://relay.test:8443";
|
|
98
|
+
it("is not applicable for a plain-http base (relay is ws://)", async () => {
|
|
99
|
+
const c = await checkAgentTlsTrust({ baseUrl: "http://relay.test:8080", env: {} });
|
|
100
|
+
expect(c.status).toBe("info");
|
|
101
|
+
expect(c.message).toContain("plain ws");
|
|
102
|
+
});
|
|
103
|
+
it("reports info when SSL_CERT_FILE is unset (CLI builds the bundle)", async () => {
|
|
104
|
+
const c = await checkAgentTlsTrust({
|
|
105
|
+
baseUrl: HTTPS,
|
|
106
|
+
env: { NODE_EXTRA_CA_CERTS: "/x/ca.pem" },
|
|
107
|
+
});
|
|
108
|
+
expect(c.status).toBe("info");
|
|
109
|
+
expect(c.message).toContain("NODE_EXTRA_CA_CERTS");
|
|
110
|
+
});
|
|
111
|
+
it("passes when SSL_CERT_FILE verifies the relay host", async () => {
|
|
112
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
113
|
+
writeFileSync(bundle, "x");
|
|
114
|
+
const c = await checkAgentTlsTrust({
|
|
115
|
+
baseUrl: HTTPS,
|
|
116
|
+
env: { SSL_CERT_FILE: bundle },
|
|
117
|
+
probe: () => Promise.resolve({ ok: true }),
|
|
118
|
+
});
|
|
119
|
+
expect(c.status).toBe("pass");
|
|
120
|
+
});
|
|
121
|
+
it("fails when SSL_CERT_FILE is stale and can't verify the relay (the AT-577 trap)", async () => {
|
|
122
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
123
|
+
writeFileSync(bundle, "x");
|
|
124
|
+
const c = await checkAgentTlsTrust({
|
|
125
|
+
baseUrl: HTTPS,
|
|
126
|
+
env: { SSL_CERT_FILE: bundle },
|
|
127
|
+
probe: () => Promise.resolve({
|
|
128
|
+
ok: false,
|
|
129
|
+
kind: "cert",
|
|
130
|
+
reason: "self signed certificate in certificate chain",
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
expect(c.status).toBe("fail");
|
|
134
|
+
expect(c.message).toContain("does not verify");
|
|
135
|
+
expect(c.hint).toContain("Unset SSL_CERT_FILE");
|
|
136
|
+
});
|
|
137
|
+
it("reports info (not fail) when the relay host is unreachable", async () => {
|
|
138
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
139
|
+
writeFileSync(bundle, "x");
|
|
140
|
+
const c = await checkAgentTlsTrust({
|
|
141
|
+
baseUrl: HTTPS,
|
|
142
|
+
env: { SSL_CERT_FILE: bundle },
|
|
143
|
+
probe: () => Promise.resolve({ ok: false, kind: "conn", reason: "ECONNREFUSED" }),
|
|
144
|
+
});
|
|
145
|
+
expect(c.status).toBe("info");
|
|
146
|
+
});
|
|
147
|
+
it("fails when SSL_CERT_FILE points at a missing file", async () => {
|
|
148
|
+
const c = await checkAgentTlsTrust({
|
|
149
|
+
baseUrl: HTTPS,
|
|
150
|
+
env: { SSL_CERT_FILE: join(tempHome, "nope.pem") },
|
|
151
|
+
});
|
|
152
|
+
expect(c.status).toBe("fail");
|
|
153
|
+
expect(c.message).toContain("missing file");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
1
|
import { platform, release } from "node:os";
|
|
4
|
-
import {
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { getBaseUrl, request } from "../../client.js";
|
|
5
4
|
import { handleApiError } from "../../errors.js";
|
|
6
5
|
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
7
|
-
import {
|
|
6
|
+
import { readDeviceConfig, writeDeviceConfig } from "./device-config.js";
|
|
8
7
|
function detectPlatform() {
|
|
9
8
|
const p = platform();
|
|
10
9
|
if (p === "win32")
|
|
@@ -22,42 +21,59 @@ function detectOs() {
|
|
|
22
21
|
return `macOS ${r}`;
|
|
23
22
|
return `Linux ${r}`;
|
|
24
23
|
}
|
|
24
|
+
/** Whether deviceId is present among the user's devices on the targeted server. */
|
|
25
|
+
async function deviceExistsOnServer(deviceId, deps) {
|
|
26
|
+
const res = await request("/api/devices/user", {}, deps);
|
|
27
|
+
return (res.devices ?? []).some((d) => d.id === deviceId);
|
|
28
|
+
}
|
|
25
29
|
export async function runDeviceRegister(options, deps) {
|
|
26
30
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const clientDeps = deps ?? {};
|
|
32
|
+
const baseUrl = clientDeps.baseUrl ?? getBaseUrl();
|
|
33
|
+
let replacedStale = false;
|
|
34
|
+
let previousBaseUrl;
|
|
35
|
+
const existing = readDeviceConfig();
|
|
36
|
+
if (existing && !options.force) {
|
|
37
|
+
// A local registration exists. It only truly conflicts if that device is
|
|
38
|
+
// still present on the server we're targeting. If it's gone — deleted, or
|
|
39
|
+
// registered against a different server (common when rotating ephemeral
|
|
40
|
+
// on-prem test boxes) — the local file is stale and we re-register instead
|
|
41
|
+
// of hard-blocking. When the server can't be reached to check, stay safe
|
|
42
|
+
// and block rather than silently clobber a possibly-valid registration.
|
|
43
|
+
let liveHere;
|
|
44
|
+
try {
|
|
45
|
+
liveHere = await deviceExistsOnServer(existing.deviceId, clientDeps);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return formatError("validation_error", `A device is already registered on this machine and its status on ${baseUrl} ` +
|
|
49
|
+
`could not be verified (${err instanceof Error ? err.message : String(err)}). ` +
|
|
50
|
+
"Run 'aximo device unregister' first, or pass --force to overwrite.", false);
|
|
51
|
+
}
|
|
52
|
+
if (liveHere) {
|
|
53
|
+
return formatError("validation_error", `A device (${existing.deviceId}) is already registered on this machine and active on ${baseUrl}. ` +
|
|
54
|
+
"Run 'aximo device unregister' first, or pass --force to overwrite (this orphans the previous server-side device).", false);
|
|
55
|
+
}
|
|
56
|
+
replacedStale = true;
|
|
57
|
+
previousBaseUrl = existing.baseUrl;
|
|
30
58
|
}
|
|
31
59
|
const res = await request("/api/devices/register", {
|
|
32
60
|
method: "POST",
|
|
33
|
-
body: {
|
|
34
|
-
|
|
35
|
-
platform: detectPlatform(),
|
|
36
|
-
os: detectOs(),
|
|
37
|
-
},
|
|
38
|
-
}, deps ?? {});
|
|
39
|
-
const aximoDir = getAximoDir();
|
|
40
|
-
if (!existsSync(aximoDir)) {
|
|
41
|
-
mkdirSync(aximoDir, { recursive: true });
|
|
42
|
-
}
|
|
61
|
+
body: { name: options.name, platform: detectPlatform(), os: detectOs() },
|
|
62
|
+
}, clientDeps);
|
|
43
63
|
const registeredAt = new Date().toISOString();
|
|
44
|
-
|
|
64
|
+
writeDeviceConfig({
|
|
45
65
|
deviceId: res.deviceId,
|
|
46
66
|
deviceToken: res.deviceToken,
|
|
47
67
|
name: options.name,
|
|
48
68
|
registeredAt,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
chmodSync(configPath, 0o600);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// chmod may fail on Windows, file-level ACLs apply instead
|
|
56
|
-
}
|
|
69
|
+
baseUrl,
|
|
70
|
+
});
|
|
57
71
|
return formatSuccess({
|
|
58
72
|
deviceId: res.deviceId,
|
|
59
73
|
name: options.name,
|
|
60
74
|
registeredAt,
|
|
75
|
+
...(replacedStale ? { replacedStale: true } : {}),
|
|
76
|
+
...(previousBaseUrl ? { previousBaseUrl } : {}),
|
|
61
77
|
});
|
|
62
78
|
}
|
|
63
79
|
catch (err) {
|
|
@@ -67,17 +83,19 @@ export async function runDeviceRegister(options, deps) {
|
|
|
67
83
|
export default defineCommand({
|
|
68
84
|
meta: { name: "register", description: "Register this machine as a BYOD device" },
|
|
69
85
|
args: {
|
|
70
|
-
name: {
|
|
71
|
-
type: "string",
|
|
72
|
-
description: "Device name",
|
|
73
|
-
required: true,
|
|
74
|
-
},
|
|
86
|
+
name: { type: "string", description: "Device name", required: true },
|
|
75
87
|
force: {
|
|
76
88
|
type: "boolean",
|
|
77
89
|
description: "Overwrite an existing local registration (orphans the previous device)",
|
|
78
90
|
},
|
|
79
91
|
},
|
|
80
92
|
async run({ args }) {
|
|
81
|
-
|
|
93
|
+
const result = await runDeviceRegister({ name: args.name, force: Boolean(args.force) });
|
|
94
|
+
printResult(result);
|
|
95
|
+
if (result.ok && result.data.replacedStale) {
|
|
96
|
+
const prev = result.data.previousBaseUrl;
|
|
97
|
+
console.error(`\nNote: replaced a stale local registration${prev ? ` (previously registered against ${prev})` : ""}. ` +
|
|
98
|
+
"If that device still exists on another server it is now orphaned — unregister it there if you no longer need it.");
|
|
99
|
+
}
|
|
82
100
|
},
|
|
83
101
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
+
import { runDeviceRegister } from "./register.js";
|
|
7
|
+
let tempHome;
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-register-"));
|
|
10
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
11
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
12
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
13
|
+
server.listen();
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
delete process.env["AXIMO_HOME"];
|
|
17
|
+
delete process.env["AXIMO_BASE_URL"];
|
|
18
|
+
delete process.env["AXIMO_API_KEY"];
|
|
19
|
+
server.close();
|
|
20
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
24
|
+
mkdirSync(tempHome, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => server.resetHandlers());
|
|
27
|
+
function devicePath() {
|
|
28
|
+
return join(tempHome, "device.json");
|
|
29
|
+
}
|
|
30
|
+
function seedDevice(cfg) {
|
|
31
|
+
writeFileSync(devicePath(), JSON.stringify(cfg), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
function readDevice() {
|
|
34
|
+
return JSON.parse(readFileSync(devicePath(), "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
describe("runDeviceRegister", () => {
|
|
37
|
+
it("registers fresh when no local device exists", async () => {
|
|
38
|
+
server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "new-1", deviceToken: "tok" })));
|
|
39
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
expect(result.data.deviceId).toBe("new-1");
|
|
43
|
+
expect(result.data.replacedStale).toBeUndefined();
|
|
44
|
+
}
|
|
45
|
+
expect(readDevice()).toMatchObject({ deviceId: "new-1", name: "mac", baseUrl: TEST_BASE_URL });
|
|
46
|
+
});
|
|
47
|
+
it("blocks when the local device is still live on the targeted server", async () => {
|
|
48
|
+
seedDevice({ deviceId: "dev-live", deviceToken: "t", name: "x", baseUrl: TEST_BASE_URL });
|
|
49
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: [{ id: "dev-live" }] })));
|
|
50
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
expect(result.error.code).toBe("validation_error");
|
|
54
|
+
expect(result.error.message).toContain("active on");
|
|
55
|
+
}
|
|
56
|
+
// Local file untouched (no POST happened).
|
|
57
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-live" });
|
|
58
|
+
});
|
|
59
|
+
it("re-registers when the local device is absent on the targeted server (stale)", async () => {
|
|
60
|
+
seedDevice({ deviceId: "dev-old", deviceToken: "t", name: "x", baseUrl: "https://old.server" });
|
|
61
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: [] })), http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "dev-new", deviceToken: "tok2" })));
|
|
62
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
if (result.ok) {
|
|
65
|
+
expect(result.data.deviceId).toBe("dev-new");
|
|
66
|
+
expect(result.data.replacedStale).toBe(true);
|
|
67
|
+
// The replaced registration's server is surfaced, not silently dropped.
|
|
68
|
+
expect(result.data.previousBaseUrl).toBe("https://old.server");
|
|
69
|
+
}
|
|
70
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-new", baseUrl: TEST_BASE_URL });
|
|
71
|
+
});
|
|
72
|
+
it("blocks conservatively when the server cannot be reached to verify", async () => {
|
|
73
|
+
seedDevice({ deviceId: "dev-x", deviceToken: "t", name: "x" });
|
|
74
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "boom" }, { status: 500 })));
|
|
75
|
+
const result = await runDeviceRegister({ name: "mac" });
|
|
76
|
+
expect(result.ok).toBe(false);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
expect(result.error.code).toBe("validation_error");
|
|
79
|
+
expect(result.error.message).toContain("could not be verified");
|
|
80
|
+
}
|
|
81
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-x" });
|
|
82
|
+
});
|
|
83
|
+
it("--force overwrites without checking the server", async () => {
|
|
84
|
+
seedDevice({ deviceId: "dev-live", deviceToken: "t", name: "x", baseUrl: TEST_BASE_URL });
|
|
85
|
+
// The device list would 500; if --force consulted it, register would block.
|
|
86
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "boom" }, { status: 500 })), http.post(`${TEST_BASE_URL}/api/devices/register`, () => HttpResponse.json({ deviceId: "dev-forced", deviceToken: "tok" })));
|
|
87
|
+
const result = await runDeviceRegister({ name: "mac", force: true });
|
|
88
|
+
expect(result.ok).toBe(true);
|
|
89
|
+
if (result.ok) {
|
|
90
|
+
expect(result.data.deviceId).toBe("dev-forced");
|
|
91
|
+
expect(result.data.replacedStale).toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
expect(readDevice()).toMatchObject({ deviceId: "dev-forced", baseUrl: TEST_BASE_URL });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ClientDeps } from "../../client.js";
|
|
2
2
|
import type { Envelope } from "../../types.js";
|
|
3
|
+
export interface UnregisterResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
serverDeleted: boolean;
|
|
6
|
+
}
|
|
3
7
|
export declare function runDeviceUnregister(options: {
|
|
4
8
|
deviceId?: string;
|
|
5
|
-
}, deps?: ClientDeps): Promise<Envelope<
|
|
6
|
-
success: boolean;
|
|
7
|
-
}>>;
|
|
9
|
+
}, deps?: ClientDeps): Promise<Envelope<UnregisterResult>>;
|
|
8
10
|
declare const _default: import("citty").CommandDef<{
|
|
9
11
|
readonly "device-id": {
|
|
10
12
|
readonly type: "string";
|
|
@@ -1,49 +1,44 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
|
-
import { existsSync, unlinkSync, readFileSync } from "node:fs";
|
|
3
3
|
import { request } from "../../client.js";
|
|
4
4
|
import { handleApiError } from "../../errors.js";
|
|
5
|
-
import {
|
|
5
|
+
import { formatError, formatSuccess, printResult } from "../../output.js";
|
|
6
|
+
import { readDeviceConfig } from "./device-config.js";
|
|
6
7
|
import { getDeviceConfigPath } from "./paths.js";
|
|
8
|
+
function isNotFound(err) {
|
|
9
|
+
return typeof err === "object" && err !== null && err.status === 404;
|
|
10
|
+
}
|
|
7
11
|
export async function runDeviceUnregister(options, deps) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const config = JSON.parse(readFileSync(getDeviceConfigPath(), "utf-8"));
|
|
12
|
-
if (config &&
|
|
13
|
-
typeof config === "object" &&
|
|
14
|
-
typeof config.deviceId === "string") {
|
|
15
|
-
deviceId = config.deviceId;
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
return formatError("validation_error", "Device config is malformed: missing deviceId", false);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return formatError("validation_error", "Failed to read device config", false);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
12
|
+
const local = readDeviceConfig();
|
|
13
|
+
const deviceId = options.deviceId ?? local?.deviceId;
|
|
25
14
|
if (!deviceId) {
|
|
15
|
+
if (existsSync(getDeviceConfigPath())) {
|
|
16
|
+
return formatError("validation_error", "Local device config is unreadable. Pass a device ID, or clear it with: aximo device reset --device", false);
|
|
17
|
+
}
|
|
26
18
|
return formatError("validation_error", "No device ID provided and no local device config found. " +
|
|
27
19
|
"Pass a device ID or register first with: aximo device register", false);
|
|
28
20
|
}
|
|
21
|
+
let serverDeleted = true;
|
|
29
22
|
try {
|
|
30
23
|
await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
|
|
31
|
-
if (existsSync(getDeviceConfigPath())) {
|
|
32
|
-
try {
|
|
33
|
-
const config = JSON.parse(readFileSync(getDeviceConfigPath(), "utf-8"));
|
|
34
|
-
if (config.deviceId === deviceId) {
|
|
35
|
-
unlinkSync(getDeviceConfigPath());
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// non-fatal
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return formatSuccess({ success: true });
|
|
43
24
|
}
|
|
44
25
|
catch (err) {
|
|
45
|
-
|
|
26
|
+
// Already gone server-side — local cleanup is still the goal, so clear the
|
|
27
|
+
// file and report success. Re-surface any other error.
|
|
28
|
+
if (!isNotFound(err))
|
|
29
|
+
return handleApiError(err);
|
|
30
|
+
serverDeleted = false;
|
|
31
|
+
}
|
|
32
|
+
// Remove the local registration when it matches the device we just unregistered.
|
|
33
|
+
if (local && local.deviceId === deviceId && existsSync(getDeviceConfigPath())) {
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(getDeviceConfigPath());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// non-fatal
|
|
39
|
+
}
|
|
46
40
|
}
|
|
41
|
+
return formatSuccess({ success: true, serverDeleted });
|
|
47
42
|
}
|
|
48
43
|
export default defineCommand({
|
|
49
44
|
meta: { name: "unregister", description: "Unregister a device" },
|
|
@@ -55,6 +50,10 @@ export default defineCommand({
|
|
|
55
50
|
},
|
|
56
51
|
async run({ args }) {
|
|
57
52
|
const deviceId = args["device-id"];
|
|
58
|
-
|
|
53
|
+
const result = await runDeviceUnregister(deviceId ? { deviceId } : {});
|
|
54
|
+
printResult(result);
|
|
55
|
+
if (result.ok && !result.data.serverDeleted) {
|
|
56
|
+
console.error("\nNote: the device was already absent on the server; cleared the local registration.");
|
|
57
|
+
}
|
|
59
58
|
},
|
|
60
59
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|