@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.
- package/LICENSE +21 -0
- package/README.md +379 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +21 -0
- package/dist/client.d.ts +18 -0
- package/dist/client.js +90 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +127 -0
- package/dist/commands/configure.d.ts +24 -0
- package/dist/commands/configure.js +103 -0
- package/dist/commands/configure.test.d.ts +1 -0
- package/dist/commands/configure.test.js +96 -0
- package/dist/commands/device/connect.d.ts +2 -0
- package/dist/commands/device/connect.js +84 -0
- package/dist/commands/device/doctor.d.ts +34 -0
- package/dist/commands/device/doctor.js +421 -0
- package/dist/commands/device/doctor.test.d.ts +1 -0
- package/dist/commands/device/doctor.test.js +89 -0
- package/dist/commands/device/index.d.ts +2 -0
- package/dist/commands/device/index.js +16 -0
- package/dist/commands/device/list.d.ts +20 -0
- package/dist/commands/device/list.js +40 -0
- package/dist/commands/device/list.test.d.ts +1 -0
- package/dist/commands/device/list.test.js +38 -0
- package/dist/commands/device/logs.d.ts +34 -0
- package/dist/commands/device/logs.js +160 -0
- package/dist/commands/device/logs.test.d.ts +1 -0
- package/dist/commands/device/logs.test.js +70 -0
- package/dist/commands/device/paths.d.ts +12 -0
- package/dist/commands/device/paths.js +40 -0
- package/dist/commands/device/ping.d.ts +21 -0
- package/dist/commands/device/ping.js +66 -0
- package/dist/commands/device/ping.test.d.ts +1 -0
- package/dist/commands/device/ping.test.js +68 -0
- package/dist/commands/device/register.d.ts +23 -0
- package/dist/commands/device/register.js +83 -0
- package/dist/commands/device/reset.d.ts +30 -0
- package/dist/commands/device/reset.js +67 -0
- package/dist/commands/device/reset.test.d.ts +1 -0
- package/dist/commands/device/reset.test.js +69 -0
- package/dist/commands/device/setup.d.ts +25 -0
- package/dist/commands/device/setup.js +105 -0
- package/dist/commands/device/setup.test.d.ts +1 -0
- package/dist/commands/device/setup.test.js +64 -0
- package/dist/commands/device/status.d.ts +16 -0
- package/dist/commands/device/status.js +20 -0
- package/dist/commands/device/unregister.d.ts +14 -0
- package/dist/commands/device/unregister.js +60 -0
- package/dist/commands/health/check.d.ts +5 -0
- package/dist/commands/health/check.js +20 -0
- package/dist/commands/health/check.test.d.ts +1 -0
- package/dist/commands/health/check.test.js +26 -0
- package/dist/commands/health/index.d.ts +2 -0
- package/dist/commands/health/index.js +7 -0
- package/dist/commands/project/index.d.ts +2 -0
- package/dist/commands/project/index.js +7 -0
- package/dist/commands/project/list.d.ts +7 -0
- package/dist/commands/project/list.js +20 -0
- package/dist/commands/project/list.test.d.ts +1 -0
- package/dist/commands/project/list.test.js +28 -0
- package/dist/commands/schema.d.ts +27 -0
- package/dist/commands/schema.js +146 -0
- package/dist/commands/schema.test.d.ts +1 -0
- package/dist/commands/schema.test.js +38 -0
- package/dist/commands/session/create.d.ts +78 -0
- package/dist/commands/session/create.js +89 -0
- package/dist/commands/session/get.d.ts +11 -0
- package/dist/commands/session/get.js +30 -0
- package/dist/commands/session/index.d.ts +2 -0
- package/dist/commands/session/index.js +8 -0
- package/dist/commands/session/session.test.d.ts +1 -0
- package/dist/commands/session/session.test.js +128 -0
- package/dist/commands/trigger/case/get.d.ts +14 -0
- package/dist/commands/trigger/case/get.js +33 -0
- package/dist/commands/trigger/case/index.d.ts +2 -0
- package/dist/commands/trigger/case/index.js +8 -0
- package/dist/commands/trigger/case/run.d.ts +58 -0
- package/dist/commands/trigger/case/run.js +81 -0
- package/dist/commands/trigger/index.d.ts +2 -0
- package/dist/commands/trigger/index.js +8 -0
- package/dist/commands/trigger/plan/get.d.ts +14 -0
- package/dist/commands/trigger/plan/get.js +33 -0
- package/dist/commands/trigger/plan/index.d.ts +2 -0
- package/dist/commands/trigger/plan/index.js +8 -0
- package/dist/commands/trigger/plan/run.d.ts +33 -0
- package/dist/commands/trigger/plan/run.js +63 -0
- package/dist/commands/trigger/trigger.test.d.ts +1 -0
- package/dist/commands/trigger/trigger.test.js +107 -0
- package/dist/commands/upload/create.d.ts +32 -0
- package/dist/commands/upload/create.js +65 -0
- package/dist/commands/upload/get.d.ts +10 -0
- package/dist/commands/upload/get.js +30 -0
- package/dist/commands/upload/index.d.ts +2 -0
- package/dist/commands/upload/index.js +11 -0
- package/dist/commands/upload/list.d.ts +15 -0
- package/dist/commands/upload/list.js +31 -0
- package/dist/commands/upload/put.d.ts +24 -0
- package/dist/commands/upload/put.js +62 -0
- package/dist/commands/upload/submit.d.ts +19 -0
- package/dist/commands/upload/submit.js +36 -0
- package/dist/commands/upload/upload.test.d.ts +1 -0
- package/dist/commands/upload/upload.test.js +133 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +36 -0
- package/dist/desktop-server/agent-sync.d.ts +19 -0
- package/dist/desktop-server/agent-sync.js +141 -0
- package/dist/desktop-server/agent-sync.test.d.ts +1 -0
- package/dist/desktop-server/agent-sync.test.js +130 -0
- package/dist/desktop-server/setup.d.ts +19 -0
- package/dist/desktop-server/setup.js +200 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +71 -0
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +88 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/mutate.d.ts +13 -0
- package/dist/mutate.js +25 -0
- package/dist/mutate.test.d.ts +1 -0
- package/dist/mutate.test.js +49 -0
- package/dist/output.d.ts +5 -0
- package/dist/output.js +23 -0
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +42 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +13 -0
- package/dist/test-utils.d.ts +4 -0
- package/dist/test-utils.js +5 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.js +2 -0
- package/dist/validate.d.ts +14 -0
- package/dist/validate.js +103 -0
- package/dist/validate.test.d.ts +1 -0
- package/dist/validate.test.js +96 -0
- package/package.json +72 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { arch as osArch, platform } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { pipeline } from "node:stream/promises";
|
|
8
|
+
import { getAximoDir, getCacheDir, getDepsMarkerPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, getRemoteLogPath, } from "../commands/device/paths.js";
|
|
9
|
+
import { ensureAgent } from "./agent-sync.js";
|
|
10
|
+
export const PYTHON_RELEASE = "20260414";
|
|
11
|
+
export const PYTHON_VERSION = "3.12.13";
|
|
12
|
+
const BUILDS = {
|
|
13
|
+
"darwin-x64": {
|
|
14
|
+
target: "x86_64-apple-darwin",
|
|
15
|
+
sha256: "801b03fbe004181d55a02ebd8b4e04d74973e70d716062aebe3b3cf32e9be297",
|
|
16
|
+
},
|
|
17
|
+
"darwin-arm64": {
|
|
18
|
+
target: "aarch64-apple-darwin",
|
|
19
|
+
sha256: "8966b2bcd9fa03ba22c080ad15a86bc12e41a00122b16f4b3740e302261124d9",
|
|
20
|
+
},
|
|
21
|
+
"linux-x64": {
|
|
22
|
+
target: "x86_64-unknown-linux-gnu",
|
|
23
|
+
sha256: "cdcf8724d46e4857f8db5ee9f4252dc2f5da34f7940294ec6b312389dd3f41e0",
|
|
24
|
+
},
|
|
25
|
+
"linux-arm64": {
|
|
26
|
+
target: "aarch64-unknown-linux-gnu",
|
|
27
|
+
sha256: "355d981eafb9b2870af79ddc106ced7266b6f6d2101d8fbcb05620fa386642b9",
|
|
28
|
+
},
|
|
29
|
+
"win32-x64": {
|
|
30
|
+
target: "x86_64-pc-windows-msvc",
|
|
31
|
+
sha256: "c5a9e011e284c49c48106ca177342f3e3f64e95b4c6652d4a382cc7c9bb1cc46",
|
|
32
|
+
},
|
|
33
|
+
"win32-arm64": {
|
|
34
|
+
target: "aarch64-pc-windows-msvc",
|
|
35
|
+
sha256: "f55326c894fde76fc0faffe95d2bce60be533c88a8c44c1b88bbbc17bf6a5cd5",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
function selectBuild() {
|
|
39
|
+
const key = `${platform()}-${osArch()}`;
|
|
40
|
+
const build = BUILDS[key];
|
|
41
|
+
if (!build) {
|
|
42
|
+
throw new Error(`Unsupported platform/arch: ${key}`);
|
|
43
|
+
}
|
|
44
|
+
return build;
|
|
45
|
+
}
|
|
46
|
+
function buildDownloadUrl(build) {
|
|
47
|
+
const filename = `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}-${build.target}-install_only.tar.gz`;
|
|
48
|
+
return `https://github.com/astral-sh/python-build-standalone/releases/download/${PYTHON_RELEASE}/${filename}`;
|
|
49
|
+
}
|
|
50
|
+
export function installedPythonTag() {
|
|
51
|
+
return `${PYTHON_RELEASE}-${PYTHON_VERSION}`;
|
|
52
|
+
}
|
|
53
|
+
async function downloadTarball(url, dest, expectedSha256) {
|
|
54
|
+
const response = await fetch(url);
|
|
55
|
+
if (!response.ok || !response.body) {
|
|
56
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText} (${url})`);
|
|
57
|
+
}
|
|
58
|
+
const totalBytes = Number(response.headers.get("content-length") ?? 0);
|
|
59
|
+
const hash = createHash("sha256");
|
|
60
|
+
let received = 0;
|
|
61
|
+
let lastProgressMs = 0;
|
|
62
|
+
const source = Readable.fromWeb(response.body);
|
|
63
|
+
source.on("data", (chunk) => {
|
|
64
|
+
hash.update(chunk);
|
|
65
|
+
received += chunk.length;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (totalBytes && now - lastProgressMs > 250 && process.stderr.isTTY) {
|
|
68
|
+
const pct = Math.floor((received / totalBytes) * 100);
|
|
69
|
+
const mb = (received / 1_000_000).toFixed(1);
|
|
70
|
+
const totalMb = (totalBytes / 1_000_000).toFixed(1);
|
|
71
|
+
process.stderr.write(`\rDownloading Python... ${pct}% (${mb}/${totalMb} MB)`);
|
|
72
|
+
lastProgressMs = now;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await pipeline(source, createWriteStream(dest));
|
|
76
|
+
if (totalBytes && process.stderr.isTTY)
|
|
77
|
+
process.stderr.write("\n");
|
|
78
|
+
const actual = hash.digest("hex");
|
|
79
|
+
if (actual !== expectedSha256) {
|
|
80
|
+
rmSync(dest, { force: true });
|
|
81
|
+
throw new Error(`SHA256 mismatch for ${url}\n expected ${expectedSha256}\n actual ${actual}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function extractTarball(tarball, destDir) {
|
|
85
|
+
mkdirSync(destDir, { recursive: true });
|
|
86
|
+
const result = spawnSync("tar", ["-xzf", tarball, "-C", destDir], { stdio: "inherit" });
|
|
87
|
+
if (result.error) {
|
|
88
|
+
throw new Error(`Failed to run tar: ${result.error.message}. ` +
|
|
89
|
+
"A system 'tar' is required to extract the Python archive.");
|
|
90
|
+
}
|
|
91
|
+
if (result.status !== 0) {
|
|
92
|
+
throw new Error(`tar extraction failed (exit ${result.status})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function installEmbeddedPython() {
|
|
96
|
+
const pythonDir = getPythonDir();
|
|
97
|
+
const cacheDir = getCacheDir();
|
|
98
|
+
const pythonMarker = getPythonMarkerPath();
|
|
99
|
+
const depsMarker = getDepsMarkerPath();
|
|
100
|
+
if (existsSync(pythonDir)) {
|
|
101
|
+
rmSync(pythonDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
const build = selectBuild();
|
|
104
|
+
const url = buildDownloadUrl(build);
|
|
105
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
106
|
+
const tarball = join(cacheDir, `python-${installedPythonTag()}.tar.gz`);
|
|
107
|
+
const staging = join(cacheDir, "python-staging");
|
|
108
|
+
if (existsSync(staging))
|
|
109
|
+
rmSync(staging, { recursive: true, force: true });
|
|
110
|
+
console.log(`Fetching embedded Python ${PYTHON_VERSION} (${build.target})...`);
|
|
111
|
+
await downloadTarball(url, tarball, build.sha256);
|
|
112
|
+
console.log("Extracting...");
|
|
113
|
+
extractTarball(tarball, staging);
|
|
114
|
+
const extracted = join(staging, "python");
|
|
115
|
+
if (!existsSync(extracted)) {
|
|
116
|
+
throw new Error(`Unexpected archive layout: 'python/' not found after extracting ${tarball}`);
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(getAximoDir(), { recursive: true });
|
|
119
|
+
renameSync(extracted, pythonDir);
|
|
120
|
+
rmSync(staging, { recursive: true, force: true });
|
|
121
|
+
rmSync(tarball, { force: true });
|
|
122
|
+
writeFileSync(pythonMarker, installedPythonTag(), { mode: 0o644 });
|
|
123
|
+
if (existsSync(depsMarker))
|
|
124
|
+
rmSync(depsMarker, { force: true });
|
|
125
|
+
}
|
|
126
|
+
async function ensurePython() {
|
|
127
|
+
const binary = getEmbeddedPythonBinary();
|
|
128
|
+
const markerPath = getPythonMarkerPath();
|
|
129
|
+
const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf-8").trim() : "";
|
|
130
|
+
if (marker === installedPythonTag() && existsSync(binary)) {
|
|
131
|
+
return binary;
|
|
132
|
+
}
|
|
133
|
+
await installEmbeddedPython();
|
|
134
|
+
return binary;
|
|
135
|
+
}
|
|
136
|
+
function hashFile(path) {
|
|
137
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
138
|
+
}
|
|
139
|
+
function ensureDependencies(python, requirementsPath) {
|
|
140
|
+
if (!existsSync(requirementsPath)) {
|
|
141
|
+
throw new Error(`requirements.txt not found at ${requirementsPath}`);
|
|
142
|
+
}
|
|
143
|
+
const requirementsHash = hashFile(requirementsPath);
|
|
144
|
+
const depsMarker = getDepsMarkerPath();
|
|
145
|
+
const marker = existsSync(depsMarker) ? readFileSync(depsMarker, "utf-8").trim() : "";
|
|
146
|
+
if (marker === requirementsHash)
|
|
147
|
+
return;
|
|
148
|
+
console.log("Installing Python dependencies...");
|
|
149
|
+
const result = spawnSync(python, [
|
|
150
|
+
"-m",
|
|
151
|
+
"pip",
|
|
152
|
+
"install",
|
|
153
|
+
"--disable-pip-version-check",
|
|
154
|
+
"--no-warn-script-location",
|
|
155
|
+
"-r",
|
|
156
|
+
requirementsPath,
|
|
157
|
+
], { stdio: "inherit" });
|
|
158
|
+
if (result.status !== 0) {
|
|
159
|
+
throw new Error(`pip install failed (exit ${result.status})`);
|
|
160
|
+
}
|
|
161
|
+
writeFileSync(depsMarker, requirementsHash, { mode: 0o644 });
|
|
162
|
+
}
|
|
163
|
+
export async function ensurePythonReady(deps) {
|
|
164
|
+
const pythonPath = await ensurePython();
|
|
165
|
+
const agent = await ensureAgent(deps);
|
|
166
|
+
if (agent.changed) {
|
|
167
|
+
console.log(`Agent script updated to version ${agent.version}`);
|
|
168
|
+
}
|
|
169
|
+
ensureDependencies(pythonPath, agent.requirementsPath);
|
|
170
|
+
return {
|
|
171
|
+
pythonPath,
|
|
172
|
+
scriptPath: agent.scriptPath,
|
|
173
|
+
agentVersion: agent.version,
|
|
174
|
+
agentChanged: agent.changed,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export function startDesktopRemote(pythonPath, scriptPath, options) {
|
|
178
|
+
const child = spawn(pythonPath, [scriptPath], {
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
AXIMO_HOME: getAximoDir(),
|
|
182
|
+
LOG_FILE: getRemoteLogPath(),
|
|
183
|
+
RELAY_URL: options.relayUrl,
|
|
184
|
+
DEVICE_TOKEN: options.deviceToken,
|
|
185
|
+
DEVICE_NAME: options.deviceName,
|
|
186
|
+
},
|
|
187
|
+
stdio: "pipe",
|
|
188
|
+
});
|
|
189
|
+
child.stdout?.on("data", (data) => {
|
|
190
|
+
const msg = data.toString().trim();
|
|
191
|
+
if (msg)
|
|
192
|
+
console.log(msg);
|
|
193
|
+
});
|
|
194
|
+
child.stderr?.on("data", (data) => {
|
|
195
|
+
const msg = data.toString().trim();
|
|
196
|
+
if (msg)
|
|
197
|
+
console.error(`[remote] ${msg}`);
|
|
198
|
+
});
|
|
199
|
+
return child;
|
|
200
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { formatError } from "./output.js";
|
|
2
|
+
function isHttpError(error) {
|
|
3
|
+
if (typeof error !== "object" || error === null)
|
|
4
|
+
return false;
|
|
5
|
+
const e = error;
|
|
6
|
+
return typeof e["status"] === "number" && typeof e["message"] === "string";
|
|
7
|
+
}
|
|
8
|
+
const NOT_FOUND_HINT = "Check the ID exists and you have access. List resources: aximo project list / aximo device list / aximo upload list";
|
|
9
|
+
const AUTH_HINT = "Run `aximo configure` to save credentials, or set AXIMO_API_KEY via `export AXIMO_API_KEY=your-key` or `node --env-file=.env`.";
|
|
10
|
+
// Node's net layer surfaces transient transport failures via these system error codes.
|
|
11
|
+
const RETRYABLE_NET_CODES = new Set([
|
|
12
|
+
"ECONNRESET",
|
|
13
|
+
"ECONNREFUSED",
|
|
14
|
+
"ENOTFOUND",
|
|
15
|
+
"EAI_AGAIN",
|
|
16
|
+
"ETIMEDOUT",
|
|
17
|
+
"EPIPE",
|
|
18
|
+
"EHOSTUNREACH",
|
|
19
|
+
"ENETUNREACH",
|
|
20
|
+
"UND_ERR_SOCKET",
|
|
21
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
22
|
+
]);
|
|
23
|
+
function isTransientNetworkError(error) {
|
|
24
|
+
// global fetch raises TypeError("fetch failed") when no HTTP response is received
|
|
25
|
+
if (error instanceof TypeError)
|
|
26
|
+
return true;
|
|
27
|
+
if (typeof error !== "object" || error === null)
|
|
28
|
+
return false;
|
|
29
|
+
const cause = error.cause;
|
|
30
|
+
if (typeof cause !== "object" || cause === null)
|
|
31
|
+
return false;
|
|
32
|
+
const code = cause.code;
|
|
33
|
+
return typeof code === "string" && RETRYABLE_NET_CODES.has(code);
|
|
34
|
+
}
|
|
35
|
+
export function handleApiError(error) {
|
|
36
|
+
if (isHttpError(error)) {
|
|
37
|
+
const { status, message, retryAfter } = error;
|
|
38
|
+
if (status === 401) {
|
|
39
|
+
return formatError("unauthorized", message, false, AUTH_HINT);
|
|
40
|
+
}
|
|
41
|
+
if (status === 403) {
|
|
42
|
+
return formatError("unauthorized", message, false, AUTH_HINT);
|
|
43
|
+
}
|
|
44
|
+
if (status === 404) {
|
|
45
|
+
return formatError("not_found", message, false, NOT_FOUND_HINT);
|
|
46
|
+
}
|
|
47
|
+
if (status === 429) {
|
|
48
|
+
const base = "Aximo enforces a daily session limit. Retry after cooldown.";
|
|
49
|
+
const hint = retryAfter !== undefined ? `Retry after ${retryAfter}s. ${base}` : base;
|
|
50
|
+
return formatError("rate_limited", message, true, hint);
|
|
51
|
+
}
|
|
52
|
+
if (status >= 500) {
|
|
53
|
+
return formatError("api_error", message, true);
|
|
54
|
+
}
|
|
55
|
+
if (status >= 400) {
|
|
56
|
+
return formatError("validation_error", message, false);
|
|
57
|
+
}
|
|
58
|
+
return formatError("api_error", message, false);
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof SyntaxError) {
|
|
61
|
+
return formatError("validation_error", error.message, false);
|
|
62
|
+
}
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
if (message.includes("No API key configured")) {
|
|
65
|
+
return formatError("unauthorized", message, false, AUTH_HINT);
|
|
66
|
+
}
|
|
67
|
+
if (isTransientNetworkError(error)) {
|
|
68
|
+
return formatError("api_error", message, true);
|
|
69
|
+
}
|
|
70
|
+
return formatError("api_error", message, false);
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { handleApiError } from "./errors.js";
|
|
3
|
+
function httpErr(status, message = `status ${status}`, retryAfter) {
|
|
4
|
+
const err = Object.assign(new Error(message), { status });
|
|
5
|
+
if (retryAfter !== undefined)
|
|
6
|
+
err.retryAfter = retryAfter;
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
describe("handleApiError", () => {
|
|
10
|
+
it("maps 401 to unauthorized with AXIMO_API_KEY hint", () => {
|
|
11
|
+
const r = handleApiError(httpErr(401, "Unauthorized"));
|
|
12
|
+
expect(r.ok).toBe(false);
|
|
13
|
+
expect(r.error.code).toBe("unauthorized");
|
|
14
|
+
expect(r.error.retryable).toBe(false);
|
|
15
|
+
expect(r.error.hint).toContain("AXIMO_API_KEY");
|
|
16
|
+
expect(r.error.hint).toContain("--env-file");
|
|
17
|
+
});
|
|
18
|
+
it("maps 403 to unauthorized", () => {
|
|
19
|
+
expect(handleApiError(httpErr(403)).error.code).toBe("unauthorized");
|
|
20
|
+
});
|
|
21
|
+
it("maps 404 to not_found with list hint", () => {
|
|
22
|
+
const r = handleApiError(httpErr(404));
|
|
23
|
+
expect(r.error.code).toBe("not_found");
|
|
24
|
+
expect(r.error.retryable).toBe(false);
|
|
25
|
+
expect(r.error.hint).toContain("aximo project list");
|
|
26
|
+
});
|
|
27
|
+
it("maps 429 with Retry-After header into hint", () => {
|
|
28
|
+
const r = handleApiError(httpErr(429, "Too many", "60"));
|
|
29
|
+
expect(r.error.code).toBe("rate_limited");
|
|
30
|
+
expect(r.error.hint).toContain("Retry after 60s");
|
|
31
|
+
expect(r.error.hint).toContain("daily");
|
|
32
|
+
});
|
|
33
|
+
it("maps 429 to rate_limited (retryable)", () => {
|
|
34
|
+
const r = handleApiError(httpErr(429));
|
|
35
|
+
expect(r.error.code).toBe("rate_limited");
|
|
36
|
+
expect(r.error.retryable).toBe(true);
|
|
37
|
+
expect(r.error.hint).toContain("daily");
|
|
38
|
+
});
|
|
39
|
+
it("maps 400 to validation_error", () => {
|
|
40
|
+
expect(handleApiError(httpErr(400)).error.code).toBe("validation_error");
|
|
41
|
+
});
|
|
42
|
+
it("maps 422 to validation_error", () => {
|
|
43
|
+
expect(handleApiError(httpErr(422)).error.code).toBe("validation_error");
|
|
44
|
+
});
|
|
45
|
+
it("maps 500 to api_error retryable", () => {
|
|
46
|
+
const r = handleApiError(httpErr(500));
|
|
47
|
+
expect(r.error.code).toBe("api_error");
|
|
48
|
+
expect(r.error.retryable).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("maps 503 to api_error retryable", () => {
|
|
51
|
+
expect(handleApiError(httpErr(503)).error.retryable).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it("treats SyntaxError as validation_error", () => {
|
|
54
|
+
expect(handleApiError(new SyntaxError("bad json")).error.code).toBe("validation_error");
|
|
55
|
+
});
|
|
56
|
+
it("treats unknown Error as api_error", () => {
|
|
57
|
+
const r = handleApiError(new Error("boom"));
|
|
58
|
+
expect(r.error.code).toBe("api_error");
|
|
59
|
+
expect(r.error.message).toBe("boom");
|
|
60
|
+
});
|
|
61
|
+
it("routes 'No API key configured' Error to unauthorized (parity with notion/slack/excalidraw)", () => {
|
|
62
|
+
const r = handleApiError(new Error("No API key configured. Run `aximo configure` ..."));
|
|
63
|
+
expect(r.error.code).toBe("unauthorized");
|
|
64
|
+
expect(r.error.retryable).toBe(false);
|
|
65
|
+
expect(r.error.hint).toContain("AXIMO_API_KEY");
|
|
66
|
+
});
|
|
67
|
+
it("treats fetch network failure (TypeError) as retryable api_error", () => {
|
|
68
|
+
// Node's global fetch throws TypeError on DNS / TLS / connection failure
|
|
69
|
+
const r = handleApiError(new TypeError("fetch failed"));
|
|
70
|
+
expect(r.error.code).toBe("api_error");
|
|
71
|
+
expect(r.error.retryable).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it("treats Error with cause.code=ECONNRESET as retryable api_error", () => {
|
|
74
|
+
const err = new Error("socket hang up");
|
|
75
|
+
err.cause = { code: "ECONNRESET" };
|
|
76
|
+
const r = handleApiError(err);
|
|
77
|
+
expect(r.error.code).toBe("api_error");
|
|
78
|
+
expect(r.error.retryable).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it("treats Error with cause.code=ENOTFOUND as retryable api_error", () => {
|
|
81
|
+
const err = new Error("getaddrinfo");
|
|
82
|
+
err.cause = { code: "ENOTFOUND" };
|
|
83
|
+
expect(handleApiError(err).error.retryable).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it("handles non-Error values", () => {
|
|
86
|
+
expect(handleApiError("weird").error.message).toBe("weird");
|
|
87
|
+
});
|
|
88
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { request, getApiKey, getBaseUrl } from "./client.js";
|
|
2
|
+
export type { ClientDeps, RequestOptions } from "./client.js";
|
|
3
|
+
export { formatSuccess, formatError, printResult, validationError } from "./output.js";
|
|
4
|
+
export { handleApiError } from "./errors.js";
|
|
5
|
+
export { runHealthCheck } from "./commands/health/check.js";
|
|
6
|
+
export { runProjectList } from "./commands/project/list.js";
|
|
7
|
+
export { runDeviceList } from "./commands/device/list.js";
|
|
8
|
+
export { runSessionCreate } from "./commands/session/create.js";
|
|
9
|
+
export { runSessionGet } from "./commands/session/get.js";
|
|
10
|
+
export { runTriggerCaseRun } from "./commands/trigger/case/run.js";
|
|
11
|
+
export { runTriggerCaseGet } from "./commands/trigger/case/get.js";
|
|
12
|
+
export { runTriggerPlanRun } from "./commands/trigger/plan/run.js";
|
|
13
|
+
export { runTriggerPlanGet } from "./commands/trigger/plan/get.js";
|
|
14
|
+
export { runUploadCreate } from "./commands/upload/create.js";
|
|
15
|
+
export { runUploadList } from "./commands/upload/list.js";
|
|
16
|
+
export { runUploadGet } from "./commands/upload/get.js";
|
|
17
|
+
export { runUploadPut } from "./commands/upload/put.js";
|
|
18
|
+
export { runUploadSubmit } from "./commands/upload/submit.js";
|
|
19
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { request, getApiKey, getBaseUrl } from "./client.js";
|
|
2
|
+
export { formatSuccess, formatError, printResult, validationError } from "./output.js";
|
|
3
|
+
export { handleApiError } from "./errors.js";
|
|
4
|
+
export { runHealthCheck } from "./commands/health/check.js";
|
|
5
|
+
export { runProjectList } from "./commands/project/list.js";
|
|
6
|
+
export { runDeviceList } from "./commands/device/list.js";
|
|
7
|
+
export { runSessionCreate } from "./commands/session/create.js";
|
|
8
|
+
export { runSessionGet } from "./commands/session/get.js";
|
|
9
|
+
export { runTriggerCaseRun } from "./commands/trigger/case/run.js";
|
|
10
|
+
export { runTriggerCaseGet } from "./commands/trigger/case/get.js";
|
|
11
|
+
export { runTriggerPlanRun } from "./commands/trigger/plan/run.js";
|
|
12
|
+
export { runTriggerPlanGet } from "./commands/trigger/plan/get.js";
|
|
13
|
+
export { runUploadCreate } from "./commands/upload/create.js";
|
|
14
|
+
export { runUploadList } from "./commands/upload/list.js";
|
|
15
|
+
export { runUploadGet } from "./commands/upload/get.js";
|
|
16
|
+
export { runUploadPut } from "./commands/upload/put.js";
|
|
17
|
+
export { runUploadSubmit } from "./commands/upload/submit.js";
|
package/dist/mutate.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SuccessEnvelope, ErrorEnvelope } from "./types.js";
|
|
2
|
+
export interface DryRunResult {
|
|
3
|
+
dryRun: true;
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function dryRunEnvelope(method: string, path: string, body?: unknown): SuccessEnvelope<DryRunResult>;
|
|
9
|
+
export declare function resolveJsonOverride(jsonFlag: unknown): {
|
|
10
|
+
body: Record<string, unknown>;
|
|
11
|
+
} | {
|
|
12
|
+
error: ErrorEnvelope;
|
|
13
|
+
} | undefined;
|
package/dist/mutate.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatError, formatSuccess, validationError } from "./output.js";
|
|
2
|
+
import { parseJsonBody, JsonBodyError, OPENAPI_URL } from "./validate.js";
|
|
3
|
+
import { getBaseUrl } from "./client.js";
|
|
4
|
+
export function dryRunEnvelope(method, path, body) {
|
|
5
|
+
const url = `${getBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
|
|
6
|
+
const data = { dryRun: true, method, url };
|
|
7
|
+
if (body !== undefined)
|
|
8
|
+
data.body = body;
|
|
9
|
+
return formatSuccess(data);
|
|
10
|
+
}
|
|
11
|
+
export function resolveJsonOverride(jsonFlag) {
|
|
12
|
+
if (jsonFlag === undefined || jsonFlag === "")
|
|
13
|
+
return undefined;
|
|
14
|
+
try {
|
|
15
|
+
return { body: parseJsonBody(jsonFlag) };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err instanceof JsonBodyError) {
|
|
19
|
+
return {
|
|
20
|
+
error: formatError("validation_error", err.message, false, `See ${OPENAPI_URL} for the request body shape.`),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { error: validationError(err) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { dryRunEnvelope } from "./mutate.js";
|
|
3
|
+
const ORIG_URL = process.env["AXIMO_BASE_URL"];
|
|
4
|
+
beforeAll(() => {
|
|
5
|
+
process.env["AXIMO_BASE_URL"] = "https://aximo.test";
|
|
6
|
+
});
|
|
7
|
+
afterAll(() => {
|
|
8
|
+
if (ORIG_URL !== undefined)
|
|
9
|
+
process.env["AXIMO_BASE_URL"] = ORIG_URL;
|
|
10
|
+
else
|
|
11
|
+
delete process.env["AXIMO_BASE_URL"];
|
|
12
|
+
});
|
|
13
|
+
describe("dryRunEnvelope", () => {
|
|
14
|
+
it("returns flat envelope with dryRun, method, url, body at top level (no `request` wrapper)", () => {
|
|
15
|
+
const env = dryRunEnvelope("POST", "/api/sessions", { title: "t" });
|
|
16
|
+
expect(env).toEqual({
|
|
17
|
+
ok: true,
|
|
18
|
+
data: {
|
|
19
|
+
dryRun: true,
|
|
20
|
+
method: "POST",
|
|
21
|
+
url: "https://aximo.test/api/sessions",
|
|
22
|
+
body: { title: "t" },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it("omits body when undefined (GET-style preview)", () => {
|
|
27
|
+
const env = dryRunEnvelope("POST", "/api/uploads/u1/submit");
|
|
28
|
+
expect(env.ok).toBe(true);
|
|
29
|
+
if (env.ok) {
|
|
30
|
+
expect(env.data.dryRun).toBe(true);
|
|
31
|
+
expect(env.data.method).toBe("POST");
|
|
32
|
+
expect(env.data.url).toBe("https://aximo.test/api/uploads/u1/submit");
|
|
33
|
+
expect("body" in env.data).toBe(false);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it("constructs full URL from AXIMO_BASE_URL + path", () => {
|
|
37
|
+
process.env["AXIMO_BASE_URL"] = "https://staging.aximo.test/";
|
|
38
|
+
const env = dryRunEnvelope("PUT", "/api/uploads/u1");
|
|
39
|
+
if (env.ok)
|
|
40
|
+
expect(env.data.url).toBe("https://staging.aximo.test/api/uploads/u1");
|
|
41
|
+
process.env["AXIMO_BASE_URL"] = "https://aximo.test";
|
|
42
|
+
});
|
|
43
|
+
it("does NOT have a nested `request` field", () => {
|
|
44
|
+
const env = dryRunEnvelope("POST", "/api/sessions", {});
|
|
45
|
+
if (env.ok) {
|
|
46
|
+
expect("request" in env.data).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SuccessEnvelope, SuccessMeta, ErrorEnvelope, ErrorCode, Envelope } from "./types.js";
|
|
2
|
+
export declare function formatSuccess<T>(data: T, meta?: SuccessMeta): SuccessEnvelope<T>;
|
|
3
|
+
export declare function formatError(code: ErrorCode, message: string, retryable: boolean, hint?: string): ErrorEnvelope;
|
|
4
|
+
export declare function validationError(err: unknown): ErrorEnvelope;
|
|
5
|
+
export declare function printResult(result: Envelope<unknown>): void;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function formatSuccess(data, meta) {
|
|
2
|
+
const envelope = { ok: true, data };
|
|
3
|
+
if (meta) {
|
|
4
|
+
envelope.meta = meta;
|
|
5
|
+
}
|
|
6
|
+
return envelope;
|
|
7
|
+
}
|
|
8
|
+
export function formatError(code, message, retryable, hint) {
|
|
9
|
+
const error = { code, message, retryable };
|
|
10
|
+
if (hint !== undefined) {
|
|
11
|
+
error.hint = hint;
|
|
12
|
+
}
|
|
13
|
+
return { ok: false, error };
|
|
14
|
+
}
|
|
15
|
+
export function validationError(err) {
|
|
16
|
+
return formatError("validation_error", err instanceof Error ? err.message : String(err), false);
|
|
17
|
+
}
|
|
18
|
+
export function printResult(result) {
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
2
|
+
import { formatSuccess, formatError, printResult } from "./output.js";
|
|
3
|
+
describe("formatSuccess", () => {
|
|
4
|
+
it("returns envelope without meta by default", () => {
|
|
5
|
+
expect(formatSuccess({ a: 1 })).toEqual({ ok: true, data: { a: 1 } });
|
|
6
|
+
});
|
|
7
|
+
it("includes meta when provided", () => {
|
|
8
|
+
expect(formatSuccess({ a: 1 }, { total: 3 })).toEqual({
|
|
9
|
+
ok: true,
|
|
10
|
+
data: { a: 1 },
|
|
11
|
+
meta: { total: 3 },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("formatError", () => {
|
|
16
|
+
it("builds error envelope without hint", () => {
|
|
17
|
+
expect(formatError("not_found", "nope", false)).toEqual({
|
|
18
|
+
ok: false,
|
|
19
|
+
error: { code: "not_found", message: "nope", retryable: false },
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
it("includes hint when provided", () => {
|
|
23
|
+
const env = formatError("unauthorized", "401", false, "set AXIMO_API_KEY");
|
|
24
|
+
expect(env.error.hint).toBe("set AXIMO_API_KEY");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("printResult", () => {
|
|
28
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
logSpy.mockClear();
|
|
31
|
+
process.exitCode = 0;
|
|
32
|
+
});
|
|
33
|
+
it("prints success as JSON and does not set exitCode", () => {
|
|
34
|
+
printResult({ ok: true, data: { x: 1 } });
|
|
35
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
36
|
+
expect(process.exitCode).not.toBe(1);
|
|
37
|
+
});
|
|
38
|
+
it("sets exitCode=1 on error envelope", () => {
|
|
39
|
+
printResult({ ok: false, error: { code: "api_error", message: "boom", retryable: false } });
|
|
40
|
+
expect(process.exitCode).toBe(1);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// Isolate $AXIMO_HOME for every test file so a developer's real
|
|
5
|
+
// ~/.aximo/config.json never leaks into a test run. Individual test
|
|
6
|
+
// files may override AXIMO_HOME again in their own beforeAll hooks.
|
|
7
|
+
if (!process.env["AXIMO_HOME"]) {
|
|
8
|
+
const tempHome = mkdtempSync(join(tmpdir(), "aximo-test-"));
|
|
9
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
10
|
+
process.on("exit", () => {
|
|
11
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
}
|