@aexhq/cli 0.28.4 → 0.29.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/dist/index.js +17 -0
- package/dist/tools-build.d.ts +9 -0
- package/dist/tools-build.js +308 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -5,11 +5,13 @@ import { dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { Aex, AexError } from "@aexhq/sdk";
|
|
7
7
|
import * as z from "zod";
|
|
8
|
+
import { buildTools, watchTools } from "./tools-build.js";
|
|
8
9
|
const HELP = `Aex — the session backend for AI apps
|
|
9
10
|
|
|
10
11
|
Usage:
|
|
11
12
|
aex login
|
|
12
13
|
aex doctor
|
|
14
|
+
aex tools build [--watch]
|
|
13
15
|
aex session list
|
|
14
16
|
aex session get <session-id>
|
|
15
17
|
aex session send <session-id> <message>
|
|
@@ -29,6 +31,21 @@ async function main(argv) {
|
|
|
29
31
|
await login();
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
34
|
+
if (command === "tools" && rest[0] === "build") {
|
|
35
|
+
if (rest.length > 2 || (rest.length === 2 && rest[1] !== "--watch")) {
|
|
36
|
+
usage("tools build accepts only --watch");
|
|
37
|
+
}
|
|
38
|
+
const report = (built) => {
|
|
39
|
+
for (const artifact of built) {
|
|
40
|
+
process.stdout.write(`${artifact.name} -> ${artifact.digest} (${artifact.bytes} bytes)\n`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
if (rest[1] === "--watch")
|
|
44
|
+
await watchTools(process.cwd(), report);
|
|
45
|
+
else
|
|
46
|
+
report(await buildTools());
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
32
49
|
const aex = await client();
|
|
33
50
|
if (command === "doctor") {
|
|
34
51
|
await aex.sessions.list({ limit: 1 });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface BuiltTool {
|
|
2
|
+
name: string;
|
|
3
|
+
output: string;
|
|
4
|
+
digest: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildTools(packageDirectory?: string): Promise<readonly BuiltTool[]>;
|
|
8
|
+
export declare function watchTools(packageDirectory?: string, onBuild?: (built: readonly BuiltTool[]) => void): Promise<never>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, watch, writeFile } from "node:fs/promises";
|
|
3
|
+
import { builtinModules } from "node:module";
|
|
4
|
+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { build } from "esbuild";
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx", ".jsx"]);
|
|
9
|
+
const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]));
|
|
10
|
+
const NODE_VERSION = "22.23.2";
|
|
11
|
+
const NODE_RUNTIME_FILE = `node-v${NODE_VERSION}-linux-arm64.tar.xz`;
|
|
12
|
+
const NODE_RUNTIME_DIGEST = "fff4078c5def658577f92c88db7db3bc0072924bfb93fe52c1e744a54e94abb8";
|
|
13
|
+
const NODE_RUNTIME_URL = `https://nodejs.org/dist/v${NODE_VERSION}/${NODE_RUNTIME_FILE}`;
|
|
14
|
+
export async function buildTools(packageDirectory = process.cwd()) {
|
|
15
|
+
const root = resolve(packageDirectory);
|
|
16
|
+
const packageJsonPath = resolve(root, "package.json");
|
|
17
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
18
|
+
const config = packageJson.aex?.tools;
|
|
19
|
+
if (config === undefined || !isRecord(config.entries) || typeof config.outDir !== "string") {
|
|
20
|
+
throw new TypeError("package.json must declare aex.tools.entries and aex.tools.outDir");
|
|
21
|
+
}
|
|
22
|
+
const outDir = inside(root, resolve(root, config.outDir), "aex.tools.outDir");
|
|
23
|
+
await mkdir(outDir, { recursive: true });
|
|
24
|
+
const runtime = await ensureNodeRuntime(outDir);
|
|
25
|
+
const entries = Object.entries(config.entries).sort(([left], [right]) => left.localeCompare(right, "en"));
|
|
26
|
+
if (entries.length === 0)
|
|
27
|
+
throw new TypeError("aex.tools.entries must contain at least one entry");
|
|
28
|
+
const outputs = [];
|
|
29
|
+
for (const [exportName, sourceValue] of entries) {
|
|
30
|
+
if (typeof sourceValue !== "string")
|
|
31
|
+
throw new TypeError(`Tool entry ${JSON.stringify(exportName)} must be a path`);
|
|
32
|
+
const outputName = outputNameOf(exportName);
|
|
33
|
+
const source = inside(root, resolve(root, sourceValue), `Tool entry ${JSON.stringify(exportName)}`);
|
|
34
|
+
if (!SOURCE_EXTENSIONS.has(extname(source)))
|
|
35
|
+
throw new TypeError(`Unsupported Tool module extension ${extname(source) || "(none)"}`);
|
|
36
|
+
outputs.push(await buildOne(root, outDir, outputName, source, runtime));
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze(outputs);
|
|
39
|
+
}
|
|
40
|
+
export async function watchTools(packageDirectory = process.cwd(), onBuild = () => undefined) {
|
|
41
|
+
const root = resolve(packageDirectory);
|
|
42
|
+
const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
|
|
43
|
+
const configuredOutDir = packageJson.aex?.tools?.outDir;
|
|
44
|
+
if (typeof configuredOutDir !== "string")
|
|
45
|
+
throw new TypeError("package.json must declare aex.tools.outDir");
|
|
46
|
+
const outputPrefix = `${relative(root, inside(root, resolve(root, configuredOutDir), "aex.tools.outDir"))
|
|
47
|
+
.replaceAll(sep, "/")}/`;
|
|
48
|
+
onBuild(await buildTools(root));
|
|
49
|
+
let rebuilding = false;
|
|
50
|
+
let pending = false;
|
|
51
|
+
for await (const event of watch(root, { recursive: true })) {
|
|
52
|
+
const filename = event.filename?.replaceAll("\\", "/");
|
|
53
|
+
if (filename === undefined || filename.startsWith("node_modules/") || filename.startsWith(outputPrefix))
|
|
54
|
+
continue;
|
|
55
|
+
if (rebuilding) {
|
|
56
|
+
pending = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
do {
|
|
60
|
+
pending = false;
|
|
61
|
+
rebuilding = true;
|
|
62
|
+
try {
|
|
63
|
+
onBuild(await buildTools(root));
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
rebuilding = false;
|
|
67
|
+
}
|
|
68
|
+
} while (pending);
|
|
69
|
+
}
|
|
70
|
+
throw new Error("Tool watch ended unexpectedly");
|
|
71
|
+
}
|
|
72
|
+
async function buildOne(root, outDir, name, source, nodeRuntime) {
|
|
73
|
+
const sourceFromOutput = moduleSpecifier(relative(outDir, source));
|
|
74
|
+
const inspection = await bundle({
|
|
75
|
+
contents: `import selected from ${JSON.stringify(sourceFromOutput)}; export default selected;`,
|
|
76
|
+
resolveDir: outDir,
|
|
77
|
+
sourcefile: `${name}.inspect.mjs`,
|
|
78
|
+
});
|
|
79
|
+
const inspectionPath = resolve(outDir, `.${name}.${process.pid}.inspect.mjs`);
|
|
80
|
+
await writeFile(inspectionPath, inspection);
|
|
81
|
+
let selected;
|
|
82
|
+
try {
|
|
83
|
+
selected = (await import(`${pathToFileURL(inspectionPath).href}?build=${Date.now()}`)).default;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rm(inspectionPath, { force: true });
|
|
87
|
+
}
|
|
88
|
+
if (!isRecord(selected) || selected.kind !== "aex.tool" || typeof selected.handler !== "function") {
|
|
89
|
+
throw new TypeError(`Tool entry ${JSON.stringify(name)} must default-export one completed Tool`);
|
|
90
|
+
}
|
|
91
|
+
const toolName = selected.name;
|
|
92
|
+
if (typeof toolName !== "string")
|
|
93
|
+
throw new TypeError(`Tool entry ${JSON.stringify(name)} must have a name`);
|
|
94
|
+
const description = typeof selected.description === "string" ? selected.description : null;
|
|
95
|
+
const inputSchema = z.toJSONSchema(selected.input, { target: "draft-2020-12", unrepresentable: "throw" });
|
|
96
|
+
const outputSchema = selected.output === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: z.toJSONSchema(selected.output, { target: "draft-2020-12", unrepresentable: "throw" });
|
|
99
|
+
const contractDigest = createHash("sha256").update(canonicalJson({
|
|
100
|
+
name: toolName,
|
|
101
|
+
...(description === null ? {} : { description }),
|
|
102
|
+
input_schema: inputSchema,
|
|
103
|
+
...(outputSchema === undefined ? {} : { output_schema: outputSchema }),
|
|
104
|
+
})).digest("hex");
|
|
105
|
+
const requiredEnv = isRecord(selected.requirements) && Array.isArray(selected.requirements.env)
|
|
106
|
+
? selected.requirements.env
|
|
107
|
+
: [];
|
|
108
|
+
const hasSetup = typeof selected.setupHandler === "function";
|
|
109
|
+
const runtime = await bundle({
|
|
110
|
+
contents: `
|
|
111
|
+
import selected from ${JSON.stringify(sourceFromOutput)};
|
|
112
|
+
if (selected?.kind !== "aex.tool" || typeof selected.handler !== "function") throw new TypeError("invalid prepared Tool");
|
|
113
|
+
export default Object.freeze({
|
|
114
|
+
kind: "tool-runtime/v1",
|
|
115
|
+
name: ${JSON.stringify(toolName)},
|
|
116
|
+
description: ${JSON.stringify(description)},
|
|
117
|
+
contractDigest: ${JSON.stringify(contractDigest)},
|
|
118
|
+
requiredEnv: ${JSON.stringify(requiredEnv)},
|
|
119
|
+
execute: selected.handler,
|
|
120
|
+
setup: selected.setupHandler,
|
|
121
|
+
});
|
|
122
|
+
`,
|
|
123
|
+
resolveDir: outDir,
|
|
124
|
+
sourcefile: `${name}.runtime.mjs`,
|
|
125
|
+
});
|
|
126
|
+
const codeDigest = createHash("sha256").update(runtime).digest("hex");
|
|
127
|
+
const runtimeName = `${name}.${codeDigest}.mjs`;
|
|
128
|
+
await writeFile(resolve(outDir, runtimeName), runtime);
|
|
129
|
+
const layers = [
|
|
130
|
+
{
|
|
131
|
+
digest: nodeRuntime.digest,
|
|
132
|
+
bytes: nodeRuntime.bytes,
|
|
133
|
+
mediaType: "application/x-xz",
|
|
134
|
+
mountPath: "/runtime",
|
|
135
|
+
unpack: "tar.xz",
|
|
136
|
+
file: nodeRuntime.file,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
digest: codeDigest,
|
|
140
|
+
bytes: runtime.byteLength,
|
|
141
|
+
mediaType: "application/javascript+esm",
|
|
142
|
+
mountPath: "/tool/runtime.mjs",
|
|
143
|
+
unpack: "file",
|
|
144
|
+
file: runtimeName,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const execute = "/tool/runtime.mjs";
|
|
148
|
+
const setup = hasSetup ? "/tool/runtime.mjs" : undefined;
|
|
149
|
+
const digest = createHash("sha256").update(canonicalJson({
|
|
150
|
+
profile: "computer/v1",
|
|
151
|
+
target: "linux-arm64",
|
|
152
|
+
execute_path: execute,
|
|
153
|
+
setup_path: setup ?? null,
|
|
154
|
+
layers: layers.map(({ digest, bytes, mediaType, mountPath, unpack }) => ({
|
|
155
|
+
checksum: digest,
|
|
156
|
+
bytes,
|
|
157
|
+
media_type: mediaType,
|
|
158
|
+
mount_path: mountPath,
|
|
159
|
+
unpack,
|
|
160
|
+
})),
|
|
161
|
+
})).digest("hex");
|
|
162
|
+
const artifact = {
|
|
163
|
+
digest,
|
|
164
|
+
target: "linux-arm64",
|
|
165
|
+
bytes: layers.reduce((total, layer) => total + layer.bytes, 0),
|
|
166
|
+
execute,
|
|
167
|
+
...(setup === undefined ? {} : { setup }),
|
|
168
|
+
};
|
|
169
|
+
const artifactExpression = `{...${JSON.stringify(artifact)},layers:[${layers.map((layer) => `{...${JSON.stringify({
|
|
170
|
+
digest: layer.digest,
|
|
171
|
+
bytes: layer.bytes,
|
|
172
|
+
mediaType: layer.mediaType,
|
|
173
|
+
mountPath: layer.mountPath,
|
|
174
|
+
unpack: layer.unpack,
|
|
175
|
+
})},source:new URL(${JSON.stringify(`./${layer.file}`)},import.meta.url)}`).join(",")}]} `;
|
|
176
|
+
const prepared = await bundle({
|
|
177
|
+
contents: `
|
|
178
|
+
import selected from ${JSON.stringify(sourceFromOutput)};
|
|
179
|
+
import { withPreparedArtifact } from "@aexhq/sdk/internal";
|
|
180
|
+
export default withPreparedArtifact(selected, ${artifactExpression});
|
|
181
|
+
`,
|
|
182
|
+
resolveDir: outDir,
|
|
183
|
+
sourcefile: `${name}.prepared.mjs`,
|
|
184
|
+
external: ["@aexhq/sdk", "@aexhq/sdk/internal", "zod"],
|
|
185
|
+
});
|
|
186
|
+
const output = resolve(outDir, `${name}.js`);
|
|
187
|
+
await writeFile(output, prepared);
|
|
188
|
+
await writeFile(resolve(outDir, `${name}.d.ts`), `import type { Tool } from "@aexhq/sdk";\ndeclare const value: Tool;\nexport default value;\n`);
|
|
189
|
+
await writeFile(resolve(outDir, `${name}.artifact.json`), `${JSON.stringify({
|
|
190
|
+
profile: "computer/v1",
|
|
191
|
+
target: "linux-arm64",
|
|
192
|
+
digest,
|
|
193
|
+
bytes: artifact.bytes,
|
|
194
|
+
execute: artifact.execute,
|
|
195
|
+
...(artifact.setup === undefined ? {} : { setup: artifact.setup }),
|
|
196
|
+
blobs: layers.map(({ digest, file }) => ({ digest, file })),
|
|
197
|
+
}, null, 2)}\n`);
|
|
198
|
+
return Object.freeze({ name, output: relative(root, output).replaceAll(sep, "/"), digest, bytes: artifact.bytes });
|
|
199
|
+
}
|
|
200
|
+
async function ensureNodeRuntime(outDir) {
|
|
201
|
+
const path = resolve(outDir, NODE_RUNTIME_FILE);
|
|
202
|
+
try {
|
|
203
|
+
const bytes = await readFile(path);
|
|
204
|
+
assertDigest(bytes, NODE_RUNTIME_DIGEST, "cached Node runtime");
|
|
205
|
+
return Object.freeze({ file: NODE_RUNTIME_FILE, digest: NODE_RUNTIME_DIGEST, bytes: bytes.byteLength });
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.code !== "ENOENT")
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const response = await fetch(NODE_RUNTIME_URL);
|
|
212
|
+
if (!response.ok)
|
|
213
|
+
throw new Error(`Could not download pinned Node runtime: HTTP ${response.status}`);
|
|
214
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
215
|
+
assertDigest(bytes, NODE_RUNTIME_DIGEST, "downloaded Node runtime");
|
|
216
|
+
const temporary = `${path}.${process.pid}.download`;
|
|
217
|
+
await writeFile(temporary, bytes, { flag: "wx" });
|
|
218
|
+
try {
|
|
219
|
+
await rename(temporary, path);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
await rm(temporary, { force: true });
|
|
223
|
+
if (error.code !== "EEXIST")
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
return Object.freeze({ file: NODE_RUNTIME_FILE, digest: NODE_RUNTIME_DIGEST, bytes: bytes.byteLength });
|
|
227
|
+
}
|
|
228
|
+
function assertDigest(bytes, expected, label) {
|
|
229
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
230
|
+
if (actual !== expected)
|
|
231
|
+
throw new Error(`${label} SHA-256 mismatch`);
|
|
232
|
+
}
|
|
233
|
+
function canonicalJson(value) {
|
|
234
|
+
const visit = (current) => {
|
|
235
|
+
if (Array.isArray(current))
|
|
236
|
+
return current.map(visit);
|
|
237
|
+
if (isRecord(current)) {
|
|
238
|
+
return Object.fromEntries(Object.keys(current).sort().map((key) => [key, visit(current[key])]));
|
|
239
|
+
}
|
|
240
|
+
return current;
|
|
241
|
+
};
|
|
242
|
+
return JSON.stringify(visit(value));
|
|
243
|
+
}
|
|
244
|
+
async function bundle(options) {
|
|
245
|
+
let result;
|
|
246
|
+
try {
|
|
247
|
+
result = await build({
|
|
248
|
+
bundle: true,
|
|
249
|
+
format: "esm",
|
|
250
|
+
platform: "node",
|
|
251
|
+
target: "node22",
|
|
252
|
+
write: false,
|
|
253
|
+
minify: true,
|
|
254
|
+
keepNames: true,
|
|
255
|
+
legalComments: "none",
|
|
256
|
+
sourcemap: false,
|
|
257
|
+
metafile: true,
|
|
258
|
+
treeShaking: true,
|
|
259
|
+
charset: "utf8",
|
|
260
|
+
logLevel: "silent",
|
|
261
|
+
...(options.external === undefined ? {} : { external: options.external }),
|
|
262
|
+
stdin: {
|
|
263
|
+
contents: options.contents,
|
|
264
|
+
resolveDir: options.resolveDir,
|
|
265
|
+
sourcefile: options.sourcefile,
|
|
266
|
+
loader: "js",
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch (cause) {
|
|
271
|
+
throw new TypeError(`Tool ${options.sourcefile} could not be bundled for Node 22`, { cause });
|
|
272
|
+
}
|
|
273
|
+
if (result.warnings.length !== 0)
|
|
274
|
+
throw new TypeError(result.warnings[0]?.text ?? "Tool build warning");
|
|
275
|
+
for (const output of Object.values(result.metafile?.outputs ?? {})) {
|
|
276
|
+
for (const imported of output.imports) {
|
|
277
|
+
if (imported.external && !(options.external ?? []).includes(imported.path) && !builtins.has(imported.path)) {
|
|
278
|
+
throw new TypeError(`Tool bundle leaves an unsupported runtime import: ${imported.path}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const output = result.outputFiles?.[0];
|
|
283
|
+
if (output === undefined || result.outputFiles?.length !== 1)
|
|
284
|
+
throw new TypeError("Tool build did not produce one ESM file");
|
|
285
|
+
return output.contents;
|
|
286
|
+
}
|
|
287
|
+
function outputNameOf(value) {
|
|
288
|
+
if (!/^\.\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value) || value.includes("..")) {
|
|
289
|
+
throw new TypeError(`Invalid Tool export name ${JSON.stringify(value)}`);
|
|
290
|
+
}
|
|
291
|
+
const name = value.slice(2);
|
|
292
|
+
if (name.includes("/"))
|
|
293
|
+
throw new TypeError("MVP Tool export names cannot contain nested paths");
|
|
294
|
+
return name;
|
|
295
|
+
}
|
|
296
|
+
function inside(root, candidate, label) {
|
|
297
|
+
const rel = relative(root, candidate);
|
|
298
|
+
if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)))
|
|
299
|
+
return candidate;
|
|
300
|
+
throw new TypeError(`${label} must stay inside the package directory`);
|
|
301
|
+
}
|
|
302
|
+
function moduleSpecifier(value) {
|
|
303
|
+
const normalized = value.replaceAll(sep, "/");
|
|
304
|
+
return normalized.startsWith(".") ? normalized : `./${normalized}`;
|
|
305
|
+
}
|
|
306
|
+
function isRecord(value) {
|
|
307
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
308
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aexhq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Command-line client for Aex sessions",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://aex.dev",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"test": "tsc -p tsconfig.json && node --test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@aexhq/sdk": "0.
|
|
33
|
+
"@aexhq/sdk": "0.58.0",
|
|
34
|
+
"esbuild": "0.25.9",
|
|
34
35
|
"zod": "4.4.3"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|