@staix/agent-console 0.0.0-stage → 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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +321 -2
- package/bin/acon.js +8 -0
- package/package.json +38 -4
- package/shell/icon.svg +1 -0
- package/shell/index.html +52 -0
- package/shell/shell.css +534 -0
- package/shell/shell.js +2106 -0
- package/shell/space-view.js +164 -0
- package/shell/space.js +241 -0
- package/shell/space3d.js +364 -0
- package/shell/vendor/BufferGeometryUtils.js +1523 -0
- package/shell/vendor/GLTFLoader.js +5006 -0
- package/shell/vendor/NOTICE +30 -0
- package/shell/vendor/SkeletonUtils.js +496 -0
- package/shell/vendor/manifest.json +42 -0
- package/shell/vendor/mermaid/NOTICE +25 -0
- package/shell/vendor/mermaid/manifest.json +19 -0
- package/shell/vendor/mermaid/mermaid.js +3580 -0
- package/shell/vendor/three.core.js +61162 -0
- package/shell/vendor/three.module.js +19537 -0
- package/shell/visual-frame.css +5 -0
- package/shell/visual-frame.html +14 -0
- package/shell/visual-frame.js +54 -0
- package/src/assets.ts +200 -0
- package/src/canonical.ts +16 -0
- package/src/cli.ts +155 -0
- package/src/conformance.ts +299 -0
- package/src/contract.ts +276 -0
- package/src/install.ts +248 -0
- package/src/pets.ts +63 -0
- package/src/plugins.ts +357 -0
- package/src/serve.ts +175 -0
- package/src/server.ts +429 -0
- package/src/space.ts +106 -0
- package/test/fixtures/review/cases.json +726 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* acon visual frame (#29): the frame's own styles, selected by the light/dark class on <html>. No inline styles. */
|
|
2
|
+
html.light { color-scheme: light; background: #ffffff; }
|
|
3
|
+
html.dark { color-scheme: dark; background: #16181d; }
|
|
4
|
+
body { margin: 0; padding: 8px; background: inherit; }
|
|
5
|
+
#diagram img { display: block; max-width: 100%; height: auto; margin: 0 auto; }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Diagram</title>
|
|
7
|
+
<link rel="stylesheet" href="/visual/frame.css">
|
|
8
|
+
<script src="/visual/mermaid.js" defer></script>
|
|
9
|
+
<script src="/visual/frame.js" defer></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="diagram" role="img" aria-label="Diagram"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// acon visual frame (#29). Runs in a sandbox="allow-scripts" iframe: opaque origin, no cookies, no storage and,
|
|
2
|
+
// by its own CSP, no network. It renders one Mermaid source per message and reports only {ok, height} or an error.
|
|
3
|
+
"use strict";
|
|
4
|
+
(() => {
|
|
5
|
+
const MAX_SOURCE = 20 * 1024;
|
|
6
|
+
const root = document.getElementById("diagram");
|
|
7
|
+
let seq = 0;
|
|
8
|
+
const reply = (msg) => parent.postMessage({ acon: "visual", ...msg }, "*");
|
|
9
|
+
// The vendored IIFE build assigns globalThis.mermaid.
|
|
10
|
+
const mermaid = typeof globalThis.mermaid?.render === "function" ? globalThis.mermaid : null;
|
|
11
|
+
const ready = mermaid !== null;
|
|
12
|
+
// The page's own content height: body padding plus the image box.
|
|
13
|
+
const height = () => Math.ceil(document.body.getBoundingClientRect().height) + 2;
|
|
14
|
+
/** Width and height from the SVG's viewBox (Mermaid always sets one), read without parsing it into this page. */
|
|
15
|
+
function size(svg) {
|
|
16
|
+
const box = /viewBox="\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*"/.exec(svg);
|
|
17
|
+
return box ? { w: Number(box[1]), h: Number(box[2]) } : null;
|
|
18
|
+
}
|
|
19
|
+
async function render(data) {
|
|
20
|
+
const id = Number(data.id);
|
|
21
|
+
const source = typeof data.source === "string" ? data.source : "";
|
|
22
|
+
if (!mermaid) return reply({ id, ok: false, error: "renderer unavailable" });
|
|
23
|
+
if (!source || source.length > MAX_SOURCE) return reply({ id, ok: false, error: "source is empty or over 20 KB" });
|
|
24
|
+
const dark = data.theme === "dark";
|
|
25
|
+
try {
|
|
26
|
+
// Colors, margins and image sizing come from /visual/frame.css by this class; the frame sets no inline styles.
|
|
27
|
+
document.documentElement.className = dark ? "dark" : "light";
|
|
28
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: dark ? "dark" : "default", darkMode: dark,
|
|
29
|
+
// Edge labels sit on a box that matches the canvas, so they stay readable in dark mode.
|
|
30
|
+
themeVariables: dark ? { edgeLabelBackground: "#16181d", textColor: "#e6e8ee" } : { edgeLabelBackground: "#ffffff" }, htmlLabels: false, flowchart: { htmlLabels: false }, fontFamily: "system-ui, sans-serif" });
|
|
31
|
+
// Mermaid lays out in its own temporary element during render(); this page assigns no markup. The result is
|
|
32
|
+
// shown as an image of the SVG, which runs no script and loads nothing (FRAME_CSP allows img-src data: only).
|
|
33
|
+
const { svg } = await mermaid.render(`acon-visual-${++seq}`, source);
|
|
34
|
+
const img = document.createElement("img");
|
|
35
|
+
img.alt = typeof data.title === "string" && data.title ? data.title : "Diagram";
|
|
36
|
+
const dims = size(svg);
|
|
37
|
+
if (dims) { img.width = Math.round(dims.w); img.height = Math.round(dims.h); }
|
|
38
|
+
await new Promise((done) => { img.onload = done; img.onerror = done; img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; });
|
|
39
|
+
root.replaceChildren(img);
|
|
40
|
+
root.setAttribute("aria-label", img.alt);
|
|
41
|
+
requestAnimationFrame(() => reply({ id, ok: true, height: height() }));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
root.replaceChildren();
|
|
44
|
+
reply({ id, ok: false, error: String(error && error.message ? error.message : error).split("\n")[0].slice(0, 200) });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
window.addEventListener("message", (event) => {
|
|
48
|
+
if (event.source !== parent) return;
|
|
49
|
+
const data = event.data;
|
|
50
|
+
if (!data || typeof data !== "object" || data.acon !== "visual-render") return;
|
|
51
|
+
void render(data);
|
|
52
|
+
});
|
|
53
|
+
reply({ ready: true, ok: ready });
|
|
54
|
+
})();
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ImageType = "image/webp" | "image/png";
|
|
5
|
+
export interface AssetImage { file: string; type: ImageType }
|
|
6
|
+
export interface AssetModel { file: string; type: "model/gltf-binary" }
|
|
7
|
+
export interface AssetPack {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
version: 1;
|
|
11
|
+
tile: [number, number];
|
|
12
|
+
images: Record<string, AssetImage>;
|
|
13
|
+
models?: Record<string, AssetModel>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ASSET_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
17
|
+
const IMAGE_FILE = /^[A-Za-z0-9._-]+\.(webp|png)$/;
|
|
18
|
+
const MODEL_FILE = /^[A-Za-z0-9._-]+\.glb$/;
|
|
19
|
+
const MANIFEST_MAX = 16 * 1024;
|
|
20
|
+
const IMAGE_MAX = 4 * 1024 * 1024;
|
|
21
|
+
const codeOf = (error: unknown) => (error as NodeJS.ErrnoException | null)?.code ?? "error";
|
|
22
|
+
const unreadable = (error: unknown) => `could not be read (${codeOf(error)})`;
|
|
23
|
+
const limitText = (bytes: number) => bytes % (1024 * 1024) === 0 ? `${bytes / (1024 * 1024)} MB` : bytes % 1024 === 0 ? `${bytes / 1024} KB` : `${bytes} bytes`;
|
|
24
|
+
|
|
25
|
+
/** Opens once without following a leaf symlink; all checks and bounded reads use that descriptor. */
|
|
26
|
+
function readRegularFile(file: string, maxBytes: number, label: string): Buffer | string {
|
|
27
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return `${label} size limit must be a positive safe integer`;
|
|
28
|
+
let fd: number | undefined;
|
|
29
|
+
try {
|
|
30
|
+
if (lstatSync(dirname(file), { throwIfNoEntry: false })?.isSymbolicLink()) return `${label} package directory is not a regular directory`;
|
|
31
|
+
const entry = lstatSync(file, { throwIfNoEntry: false });
|
|
32
|
+
if (!entry?.isFile()) return `${label} is missing or not a regular file`;
|
|
33
|
+
if (entry.size > maxBytes) return `${label} is larger than ${limitText(maxBytes)}`;
|
|
34
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
35
|
+
const before = fstatSync(fd);
|
|
36
|
+
if (!before.isFile()) return `${label} is missing or not a regular file`;
|
|
37
|
+
if (before.size > maxBytes) return `${label} is larger than ${limitText(maxBytes)}`;
|
|
38
|
+
// One extra byte detects growth, without reading an unbounded stream or reopening the path.
|
|
39
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
40
|
+
let length = 0;
|
|
41
|
+
while (length < bytes.length) {
|
|
42
|
+
const count = readSync(fd, bytes, length, bytes.length - length, null);
|
|
43
|
+
if (count === 0) break;
|
|
44
|
+
length += count;
|
|
45
|
+
}
|
|
46
|
+
const after = fstatSync(fd);
|
|
47
|
+
if (length > maxBytes || after.size > maxBytes) return `${label} is larger than ${limitText(maxBytes)}`;
|
|
48
|
+
if (length !== before.size || after.size !== before.size) return `${label} changed while being read`;
|
|
49
|
+
return bytes.subarray(0, length);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return unreadable(error);
|
|
52
|
+
} finally {
|
|
53
|
+
if (fd !== undefined) try { closeSync(fd); } catch { /* a failed close must not hide the validation result */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function imageType(bytes: Uint8Array): ImageType | undefined {
|
|
58
|
+
const head = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
59
|
+
if (head.length >= 12 && head.toString("latin1", 0, 4) === "RIFF" && head.toString("latin1", 8, 12) === "WEBP") return "image/webp";
|
|
60
|
+
if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns the bytes that were checked, suitable for an HTTP response without a later path lookup. */
|
|
64
|
+
export function readValidatedImage(file: string, maxBytes: number, label = "image"): { bytes: Uint8Array; type: ImageType } | string {
|
|
65
|
+
const bytes = readRegularFile(file, maxBytes, label);
|
|
66
|
+
if (typeof bytes === "string") return bytes;
|
|
67
|
+
const type = imageType(bytes);
|
|
68
|
+
return type ? { bytes, type } : `${label} is not a WebP or PNG image`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shared by local asset packs, pet packages and the separate installer. */
|
|
72
|
+
export function validateImage(file: string, maxBytes: number, label = "image"): AssetImage | string {
|
|
73
|
+
const result = readValidatedImage(file, maxBytes, label);
|
|
74
|
+
return typeof result === "string" ? result : { file, type: result.type };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** GLB 2 files are self-contained: the browser never follows a model's resource URLs. */
|
|
78
|
+
function modelProblem(bytes: Buffer): string | undefined {
|
|
79
|
+
if (bytes.length < 20 || bytes.toString("latin1", 0, 4) !== "glTF" || bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) return "must be a GLB 2 model with a valid glTF header and length";
|
|
80
|
+
let offset = 12, json: unknown, binaryLength = 0, binarySeen = false;
|
|
81
|
+
while (offset < bytes.length) {
|
|
82
|
+
if (offset + 8 > bytes.length) return "has a truncated chunk header";
|
|
83
|
+
const length = bytes.readUInt32LE(offset), type = bytes.readUInt32LE(offset + 4);
|
|
84
|
+
const start = offset + 8;
|
|
85
|
+
if (length % 4 !== 0 || start + length > bytes.length) return "has an invalid chunk length";
|
|
86
|
+
if (offset === 12) {
|
|
87
|
+
if (type !== 0x4e4f534a || length === 0) return "must start with a JSON chunk";
|
|
88
|
+
try { json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start, start + length))); }
|
|
89
|
+
catch { return "has an invalid JSON chunk"; }
|
|
90
|
+
} else if (type === 0x004e4942 && !binarySeen) { binaryLength = length; binarySeen = true; }
|
|
91
|
+
else return "has an unsupported or duplicate chunk";
|
|
92
|
+
offset = start + length;
|
|
93
|
+
}
|
|
94
|
+
const object = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
95
|
+
if (!object(json) || !object(json.asset) || json.asset.version !== "2.0") return "must declare glTF asset version 2.0";
|
|
96
|
+
if (json.buffers !== undefined) {
|
|
97
|
+
if (!Array.isArray(json.buffers) || json.buffers.length > 1) return "must use a single embedded buffer";
|
|
98
|
+
for (const buffer of json.buffers) {
|
|
99
|
+
if (!object(buffer) || buffer.uri !== undefined) return "must use embedded buffers without resource URLs";
|
|
100
|
+
if (!Number.isSafeInteger(buffer.byteLength) || (buffer.byteLength as number) < 0 || (buffer.byteLength as number) > binaryLength || binaryLength - (buffer.byteLength as number) > 3) return "has an invalid embedded buffer length";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (json.bufferViews !== undefined) {
|
|
104
|
+
if (!Array.isArray(json.bufferViews)) return "bufferViews must be an array";
|
|
105
|
+
const bufferLength = Array.isArray(json.buffers) && object(json.buffers[0]) ? json.buffers[0].byteLength as number : 0;
|
|
106
|
+
for (const view of json.bufferViews) {
|
|
107
|
+
if (!object(view) || view.buffer !== 0) return "has an invalid embedded buffer view";
|
|
108
|
+
const start = view.byteOffset ?? 0, length = view.byteLength;
|
|
109
|
+
if (typeof start !== "number" || typeof length !== "number" || !Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > bufferLength) return "has an invalid embedded buffer view";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (json.images !== undefined) {
|
|
113
|
+
if (!Array.isArray(json.images)) return "images must be an array";
|
|
114
|
+
for (const image of json.images) {
|
|
115
|
+
if (!object(image) || image.uri !== undefined) return "must use embedded images without resource URLs";
|
|
116
|
+
if (!Number.isSafeInteger(image.bufferView) || (image.bufferView as number) < 0 || !Array.isArray(json.bufferViews) || (image.bufferView as number) >= json.bufferViews.length || !["image/png", "image/jpeg", "image/webp"].includes(String(image.mimeType))) return "has an invalid embedded image";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Returns exactly the descriptor-checked bytes, with the same 4 MB cap used at serve time. */
|
|
122
|
+
export function readValidatedModel(file: string, maxBytes = IMAGE_MAX, label = "model"): { bytes: Uint8Array; type: "model/gltf-binary" } | string {
|
|
123
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) return `${label} size limit must be a positive safe integer`;
|
|
124
|
+
const bytes = readRegularFile(file, Math.min(maxBytes, IMAGE_MAX), label);
|
|
125
|
+
if (typeof bytes === "string") return bytes;
|
|
126
|
+
const problem = modelProblem(bytes);
|
|
127
|
+
return problem ? `${label} ${problem}` : { bytes, type: "model/gltf-binary" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Shared with local installation; model paths are never sent to the page. */
|
|
131
|
+
export function validateModel(file: string, maxBytes = IMAGE_MAX, label = "model"): AssetModel | string {
|
|
132
|
+
const result = readValidatedModel(file, maxBytes, label);
|
|
133
|
+
return typeof result === "string" ? result : { file, type: result.type };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Validates one local pack. No package, manifest, image or model leaf may be a symlink. */
|
|
137
|
+
export function validateAssetPack(dir: string): AssetPack | string {
|
|
138
|
+
try {
|
|
139
|
+
const directory = resolve(dir);
|
|
140
|
+
if (!lstatSync(directory, { throwIfNoEntry: false })?.isDirectory()) return "pack path is missing or not a directory";
|
|
141
|
+
const manifest = readRegularFile(join(directory, "pack.json"), MANIFEST_MAX, "pack.json");
|
|
142
|
+
if (typeof manifest === "string") return manifest;
|
|
143
|
+
let value: unknown;
|
|
144
|
+
try { value = JSON.parse(manifest.toString("utf8")); } catch { return "pack.json is not valid JSON"; }
|
|
145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "pack.json must be an object";
|
|
146
|
+
const row = value as Record<string, unknown>;
|
|
147
|
+
if (typeof row.id !== "string" || !ASSET_ID.test(row.id)) return "id must match [a-z0-9][a-z0-9-]{0,31}";
|
|
148
|
+
if (typeof row.name !== "string" || !row.name.trim() || row.name.length > 64) return "name must be a string of 1-64 characters";
|
|
149
|
+
if (row.version !== 1) return "version must be 1";
|
|
150
|
+
if (!Array.isArray(row.tile) || row.tile.length !== 2 || row.tile.some((size) => !Number.isSafeInteger(size) || size <= 0)) return "tile must be a pair of positive safe integers";
|
|
151
|
+
if (!row.images || typeof row.images !== "object" || Array.isArray(row.images)) return "images must be an object";
|
|
152
|
+
const images: Record<string, AssetImage> = Object.create(null) as Record<string, AssetImage>;
|
|
153
|
+
for (const [name, file] of Object.entries(row.images)) {
|
|
154
|
+
if (!ASSET_ID.test(name)) return `image ${name}: logical name must match [a-z0-9][a-z0-9-]{0,31}`;
|
|
155
|
+
if (typeof file !== "string" || !IMAGE_FILE.test(file)) return `image ${name}: file must be a .webp or .png file name in the package`;
|
|
156
|
+
const image = validateImage(join(directory, file), IMAGE_MAX, `image ${name}`);
|
|
157
|
+
if (typeof image === "string") return `image ${name}: ${image}`;
|
|
158
|
+
images[name] = image;
|
|
159
|
+
}
|
|
160
|
+
const models: Record<string, AssetModel> = Object.create(null) as Record<string, AssetModel>;
|
|
161
|
+
if (row.models !== undefined) {
|
|
162
|
+
if (!row.models || typeof row.models !== "object" || Array.isArray(row.models)) return "models must be an object";
|
|
163
|
+
for (const [name, file] of Object.entries(row.models)) {
|
|
164
|
+
if (!ASSET_ID.test(name)) return `model ${name}: logical name must match [a-z0-9][a-z0-9-]{0,31}`;
|
|
165
|
+
if (typeof file !== "string" || !MODEL_FILE.test(file)) return `model ${name}: file must be a .glb file name in the package`;
|
|
166
|
+
const model = validateModel(join(directory, file), IMAGE_MAX, `model ${name}`);
|
|
167
|
+
if (typeof model === "string") return `model ${name}: ${model}`;
|
|
168
|
+
models[name] = model;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { id: row.id, name: row.name.trim(), version: 1, tile: [row.tile[0] as number, row.tile[1] as number], images, ...(row.models !== undefined ? { models } : {}) };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return unreadable(error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Console asset directories in priority order; the first valid occurrence of each id wins. */
|
|
178
|
+
export function loadAssetPacks(dirs: string[]): { packs: AssetPack[]; problems: string[] } {
|
|
179
|
+
const packs: AssetPack[] = [];
|
|
180
|
+
const problems: string[] = [];
|
|
181
|
+
const ids = new Set<string>();
|
|
182
|
+
for (const dir of dirs) {
|
|
183
|
+
let names: string[];
|
|
184
|
+
try { names = readdirSync(dir).sort(); } catch (error) {
|
|
185
|
+
if (codeOf(error) !== "ENOENT") problems.push(`${dir}: ${unreadable(error)}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
const file = join(dir, name);
|
|
190
|
+
try {
|
|
191
|
+
const entry = lstatSync(file, { throwIfNoEntry: false });
|
|
192
|
+
if (entry && !entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
193
|
+
const pack = validateAssetPack(file);
|
|
194
|
+
if (typeof pack === "string") problems.push(`${file}: ${pack}`);
|
|
195
|
+
else if (!ids.has(pack.id)) { ids.add(pack.id); packs.push(pack); }
|
|
196
|
+
} catch (error) { problems.push(`${file}: ${unreadable(error)}`); }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { packs, problems };
|
|
200
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-sorted JSON, so the same input always binds to the same confirm token.
|
|
3
|
+
* Two different inputs never share a form: -0 is kept apart from 0, and non-finite numbers (JSON "1e400" parses to
|
|
4
|
+
* Infinity, which JSON.stringify would print as null) throw instead of colliding.
|
|
5
|
+
*/
|
|
6
|
+
export function canonical(value: unknown): string {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
8
|
+
if (typeof value === "number") {
|
|
9
|
+
if (!Number.isFinite(value)) throw new RangeError("numbers must be finite");
|
|
10
|
+
return Object.is(value, -0) ? "-0" : JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
if (value && typeof value === "object") {
|
|
13
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(value) ?? "null";
|
|
16
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import pkg from "../package.json" with { type: "json" };
|
|
2
|
+
import { familyHome, loadPlugins, scanPlugins, type PluginEntry } from "./plugins.ts";
|
|
3
|
+
import { startServer } from "./server.ts";
|
|
4
|
+
import { loadPets, petDirs } from "./pets.ts";
|
|
5
|
+
import { loadAssetPacks } from "./assets.ts";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { DEFAULT_INDEX, fetchIndex, installPackage, InstallError, printable } from "./install.ts";
|
|
8
|
+
import { serveConfig, type ServeOptions } from "./serve.ts";
|
|
9
|
+
|
|
10
|
+
const USAGE = `acon ${pkg.version}: local console for agent-hub, agent-mem and vault-mem
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
acon ui [--no-open] [--json] serve the console on 127.0.0.1 and print a one-time link
|
|
14
|
+
acon serve [--host H] [--port P] [--origin https://host]... [--json]
|
|
15
|
+
resident console behind Cloudflare Access or a bearer token
|
|
16
|
+
acon plugins [--json] list registered plugins and verify their module hashes
|
|
17
|
+
acon install <name|https URL|dir> [--force]
|
|
18
|
+
install a pet or asset package
|
|
19
|
+
acon install --list [--json] list the packages in the index
|
|
20
|
+
acon --version | help
|
|
21
|
+
|
|
22
|
+
Plugins register a manifest under $AGENTHUB_HOME/console/plugins (default ~/.agenthub).
|
|
23
|
+
Packages install to $AGENTHUB_HOME/console/pets/<id> or console/assets/<id>; ACON_ASSETS_INDEX overrides the index URL.
|
|
24
|
+
serve auth: ACON_ACCESS_TEAM + ACON_ACCESS_AUD (Cloudflare Access) or ACON_TOKEN_FILE (bearer); required off loopback.
|
|
25
|
+
The ui server exits 5 minutes after the last request; serve stays up.`;
|
|
26
|
+
|
|
27
|
+
const COMMANDS: Record<string, string[]> = { ui: ["--no-open", "--json"], plugins: ["--json"], install: ["--list", "--force", "--json"], serve: ["--json"] };
|
|
28
|
+
const VALUED: Record<string, string[]> = { serve: ["--host", "--port", "--origin"] };
|
|
29
|
+
|
|
30
|
+
function parse(argv: string[]): { command: string; flags: Set<string>; args: string[]; values: Record<string, string[]> } | string {
|
|
31
|
+
const [command = "help", ...all] = argv;
|
|
32
|
+
if (["help", "--help", "-h", "--version", "-v", "version"].includes(command)) return { command, flags: new Set(), args: [], values: {} };
|
|
33
|
+
const allowed = COMMANDS[command];
|
|
34
|
+
if (!allowed) return `unknown command: ${command}`;
|
|
35
|
+
const values: Record<string, string[]> = {};
|
|
36
|
+
const rest: string[] = [];
|
|
37
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
38
|
+
const arg = all[i]!;
|
|
39
|
+
const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
|
|
40
|
+
if (VALUED[command]?.includes(name)) {
|
|
41
|
+
const value = inline ?? all[++i];
|
|
42
|
+
if (value === undefined || value === "") return `${name} needs a value`;
|
|
43
|
+
(values[name] ??= []).push(value);
|
|
44
|
+
} else rest.push(arg);
|
|
45
|
+
}
|
|
46
|
+
for (const name of ["--host", "--port"]) if ((values[name]?.length ?? 0) > 1) return `${name} may be given once`;
|
|
47
|
+
const args = command === "install" ? rest.filter((arg) => !arg.startsWith("--")) : [];
|
|
48
|
+
const unknown = rest.find((arg) => !args.includes(arg) && !allowed.includes(arg));
|
|
49
|
+
if (unknown) return `unknown option for ${command}: ${unknown}`;
|
|
50
|
+
const flags = new Set(rest.filter((arg) => !args.includes(arg)));
|
|
51
|
+
if (command === "install") {
|
|
52
|
+
if (flags.has("--list") ? args.length > 0 || flags.has("--force") : args.length !== 1) return "install takes one package name, https URL or directory, or --list";
|
|
53
|
+
}
|
|
54
|
+
return { command, flags, args, values };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const summary = (p: PluginEntry) => ({ tool: p.tool, ...(p.version ? { version: p.version } : {}), status: p.status, ...(p.reason ? { reason: p.reason } : {}), file: p.file });
|
|
58
|
+
|
|
59
|
+
function openBrowser(url: string): void {
|
|
60
|
+
const argv = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
61
|
+
try { Bun.spawn(argv, { stdio: ["ignore", "ignore", "ignore"] }).unref(); }
|
|
62
|
+
catch { console.error("acon: could not open a browser; open the link above yourself"); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function install(home: string, source: string | undefined, flags: Set<string>): Promise<number> {
|
|
66
|
+
const index = process.env.ACON_ASSETS_INDEX || DEFAULT_INDEX;
|
|
67
|
+
try {
|
|
68
|
+
if (flags.has("--list")) {
|
|
69
|
+
const { packages } = await fetchIndex(index);
|
|
70
|
+
if (flags.has("--json")) console.log(JSON.stringify({ index, packages }));
|
|
71
|
+
else {
|
|
72
|
+
console.log(`Packages in ${printable(index, 500)}:`);
|
|
73
|
+
for (const p of packages) console.log(`${p.kind.padEnd(5)} ${p.id.padEnd(16)} ${`${Math.ceil(p.size / 1024)} KB`.padStart(8)} ${printable(p.name, 64)}${p.description ? `: ${printable(p.description)}` : ""}`);
|
|
74
|
+
console.log("Install one with acon install <id>.");
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
const r = await installPackage(source!, { home, index, force: flags.has("--force") });
|
|
79
|
+
if (flags.has("--json")) console.log(JSON.stringify(r));
|
|
80
|
+
else {
|
|
81
|
+
console.log(`acon: ${r.replaced ? "replaced" : "installed"} ${r.kind} ${r.id} in ${r.dir} (${r.files.length} files)`);
|
|
82
|
+
console.log(r.verified ? "acon: every file matched its sha256 in the index." : "acon: not hash-verified (installed by URL or directory); the package passed the same checks the console runs.");
|
|
83
|
+
console.log("acon: run acon ui again to see it.");
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`acon: ${error instanceof InstallError ? error.message : `install failed (${printable(String((error as NodeJS.ErrnoException).code ?? (error as Error).message), 500)})`}`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function main(argv: string[]): Promise<number> {
|
|
93
|
+
const parsed = parse(argv);
|
|
94
|
+
if (typeof parsed === "string") { console.error(`acon: ${parsed}\n\n${USAGE}`); return 2; }
|
|
95
|
+
const { command, flags, args, values } = parsed;
|
|
96
|
+
if (["--version", "-v", "version"].includes(command)) { console.log(pkg.version); return 0; }
|
|
97
|
+
if (["help", "--help", "-h"].includes(command)) { console.log(USAGE); return 0; }
|
|
98
|
+
const home = familyHome();
|
|
99
|
+
if (command === "install") return await install(home, args[0], flags);
|
|
100
|
+
let serve: ServeOptions | undefined;
|
|
101
|
+
if (command === "serve") {
|
|
102
|
+
const config = serveConfig({ host: values["--host"]?.[0], port: values["--port"]?.[0], origins: values["--origin"] ?? [] }, process.env);
|
|
103
|
+
if (typeof config === "string") { console.error(`acon: ${config}`); return 2; }
|
|
104
|
+
serve = config;
|
|
105
|
+
}
|
|
106
|
+
const scan = scanPlugins(home);
|
|
107
|
+
if (command === "plugins") {
|
|
108
|
+
if (flags.has("--json")) console.log(JSON.stringify({ dir: scan.dir, ...(scan.error ? { error: scan.error } : {}), plugins: scan.entries.map(summary) }));
|
|
109
|
+
else {
|
|
110
|
+
if (scan.error) console.error(`acon: ${scan.error}`);
|
|
111
|
+
if (!scan.entries.length) console.log(`No plugins registered in ${scan.dir}.`);
|
|
112
|
+
for (const p of scan.entries) console.log(`${`${p.tool}${p.version ? `@${p.version}` : ""}`.padEnd(24)} ${p.status.padEnd(9)} ${p.reason ?? p.module ?? ""}`);
|
|
113
|
+
}
|
|
114
|
+
return scan.error || scan.entries.some((p) => p.status !== "verified") ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
const log = (line: string) => console.error(`acon: ${line}`);
|
|
117
|
+
const plugins = await loadPlugins(scan, { home, log });
|
|
118
|
+
const { pets, problems: petProblems } = loadPets(petDirs(home));
|
|
119
|
+
const { packs: assetPacks, problems: assetProblems } = loadAssetPacks([join(home, "console", "assets")]);
|
|
120
|
+
const server = startServer({
|
|
121
|
+
plugins,
|
|
122
|
+
reload: (previous) => loadPlugins(scanPlugins(home), { home, log }, previous),
|
|
123
|
+
pets,
|
|
124
|
+
petProblems,
|
|
125
|
+
assetPacks,
|
|
126
|
+
assetProblems,
|
|
127
|
+
version: pkg.version,
|
|
128
|
+
pluginsDir: scan.dir,
|
|
129
|
+
pluginsError: scan.error,
|
|
130
|
+
log,
|
|
131
|
+
...(serve ? { serve } : { onIdle: () => { log("no requests for 5 minutes; exiting"); process.exit(0); } }),
|
|
132
|
+
});
|
|
133
|
+
const shutdown = () => { void server.stop().finally(() => process.exit(0)); };
|
|
134
|
+
process.on("SIGINT", shutdown);
|
|
135
|
+
process.on("SIGTERM", shutdown);
|
|
136
|
+
if (serve) {
|
|
137
|
+
const info = { origin: server.origin, port: server.port, mode: serve.mode, origins: serve.origins, pid: process.pid, plugins: plugins.map(summary) };
|
|
138
|
+
if (flags.has("--json")) console.log(JSON.stringify(info));
|
|
139
|
+
else {
|
|
140
|
+
console.log(`acon: serving on ${serve.hostname}:${server.port} (${serve.mode === "access" ? "Cloudflare Access" : serve.mode === "bearer" ? "bearer token" : "loopback, no auth"}); allowed origins: ${serve.origins.join(", ")}`);
|
|
141
|
+
if (scan.error) log(scan.error);
|
|
142
|
+
for (const p of plugins) if (p.status !== "ready") log(`${p.tool} not loaded (${p.status}): ${p.reason}`);
|
|
143
|
+
}
|
|
144
|
+
return await new Promise<number>(() => {});
|
|
145
|
+
}
|
|
146
|
+
const url = server.issue();
|
|
147
|
+
if (flags.has("--json")) console.log(JSON.stringify({ url, origin: server.origin, pid: process.pid, plugins: plugins.map(summary) }));
|
|
148
|
+
else {
|
|
149
|
+
console.log(`acon: console at ${url}\nacon: the link works once and expires in 60 s; run acon ui again for a new one.`);
|
|
150
|
+
if (scan.error) log(scan.error);
|
|
151
|
+
for (const p of plugins) if (p.status !== "ready") log(`${p.tool} not loaded (${p.status}): ${p.reason}`);
|
|
152
|
+
}
|
|
153
|
+
if (!flags.has("--no-open")) openBrowser(url);
|
|
154
|
+
return await new Promise<number>(() => {});
|
|
155
|
+
}
|