auto-model-router 0.10.0 → 0.11.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +15 -0
- package/omp-extension/remote-logic.ts +7 -0
- package/package.json +1 -1
- package/src/cli/build-executable.ts +142 -0
- package/src/cli/connect.ts +90 -13
- package/src/cli/embedded.ts +78 -0
- package/src/cli/refresh.ts +8 -1
- package/src/index.ts +7 -3
- package/src/lib.ts +1 -0
- package/test/executable.test.ts +132 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.11.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.11.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1340,6 +1340,21 @@ sends the name itself, which the router does not accept as a scope (a scope is a
|
|
|
1340
1340
|
lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
|
|
1341
1341
|
whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
|
|
1342
1342
|
|
|
1343
|
+
### One file to install
|
|
1344
|
+
|
|
1345
|
+
A remote that serves members can hand them this package as a single executable: the CLI
|
|
1346
|
+
compiled by `bun build --compile` for their operating system (`buildExecutable` in the
|
|
1347
|
+
library, one of `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`,
|
|
1348
|
+
`windows-x64`), with the package's own source files embedded. Nothing else is installed —
|
|
1349
|
+
no bun, no npm. omp still loads the extensions from disk and Hermes still copies its
|
|
1350
|
+
plugin, so the first `connect` from the executable writes the embedded files out under
|
|
1351
|
+
`<router home>/package/<version>/` and points every harness there; the executable itself
|
|
1352
|
+
becomes Claude Code's key helper and, with `--profile`, goes on PATH. `connect
|
|
1353
|
+
--setup-token <token>` trades a one-time onboarding token at the remote's
|
|
1354
|
+
`/setup/exchange` for the credential, so the install command carries no key at all.
|
|
1355
|
+
`remote.json` records the executable, and a refresh from inside omp keeps the helper
|
|
1356
|
+
pointed at it.
|
|
1357
|
+
|
|
1343
1358
|
## Multiple coding harnesses, one router
|
|
1344
1359
|
|
|
1345
1360
|
**One router process for everything.** omp's embed extension binds a private
|
|
@@ -36,6 +36,12 @@ export interface RemoteRouter {
|
|
|
36
36
|
refreshExpiresAtMs?: number;
|
|
37
37
|
/** What the remote calls this machine. */
|
|
38
38
|
device?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The compiled executable that ran `connect`, when one did. A refresh from
|
|
41
|
+
* inside omp re-writes every harness config and must point Claude Code's key
|
|
42
|
+
* helper at it, not at a `bun run` of the extracted source.
|
|
43
|
+
*/
|
|
44
|
+
executable?: string;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export function remoteFilePath(routerHome: string): string {
|
|
@@ -59,6 +65,7 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
59
65
|
...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
|
|
60
66
|
...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
|
|
61
67
|
...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
|
|
68
|
+
...(typeof raw.executable === "string" && raw.executable !== "" ? { executable: raw.executable } : {}),
|
|
62
69
|
};
|
|
63
70
|
} catch {
|
|
64
71
|
return null;
|
package/package.json
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the single-file member install: this package compiled by
|
|
3
|
+
* `bun build --compile` for one operating system, with the package's own source
|
|
4
|
+
* files embedded so `connect` can write out what omp and Hermes load from disk
|
|
5
|
+
* (see `embedded.ts`). A team server calls this once per router version and
|
|
6
|
+
* target and serves the result; nothing here runs on a member's machine.
|
|
7
|
+
*
|
|
8
|
+
* Cross-compiling needs bun's runtime for the target, which bun downloads on
|
|
9
|
+
* first use and caches; a host without network access to bun's releases can
|
|
10
|
+
* only build its own platform.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { EMBEDDED_GLOBAL, type EmbeddedPackage } from "./embedded.ts";
|
|
17
|
+
|
|
18
|
+
export const EXECUTABLE_TARGETS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64", "windows-x64"] as const;
|
|
19
|
+
export type ExecutableTarget = (typeof EXECUTABLE_TARGETS)[number];
|
|
20
|
+
|
|
21
|
+
export function isExecutableTarget(value: string): value is ExecutableTarget {
|
|
22
|
+
return (EXECUTABLE_TARGETS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The target of the machine this process runs on, or null when bun has no build for it. */
|
|
26
|
+
export function hostTarget(platform = process.platform, arch = process.arch): ExecutableTarget | null {
|
|
27
|
+
const cpu = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
|
|
28
|
+
if (cpu === null) return null;
|
|
29
|
+
if (platform === "linux") return `linux-${cpu}`;
|
|
30
|
+
if (platform === "darwin") return `darwin-${cpu}`;
|
|
31
|
+
if (platform === "win32" && cpu === "x64") return "windows-x64";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The file name a build for `target` is served under. */
|
|
36
|
+
export function executableFileName(target: ExecutableTarget): string {
|
|
37
|
+
return `auto-model-router-${target}${target.startsWith("windows") ? ".exe" : ""}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The package entries the install needs: the CLI, the harness integrations, and the two runtime deps. */
|
|
41
|
+
const PACKAGE_ENTRIES = ["package.json", "README.md", "src", "omp-extension", "hermes-plugin", "opencode-plugin"] as const;
|
|
42
|
+
const RUNTIME_DEPS = ["yaml", "zod"] as const;
|
|
43
|
+
|
|
44
|
+
function walk(root: string, rel: string, keep: (rel: string) => boolean, out: Record<string, string>): void {
|
|
45
|
+
const abs = join(root, ...rel.split("/").filter((s) => s !== ""));
|
|
46
|
+
const st = statSync(abs);
|
|
47
|
+
if (st.isDirectory()) {
|
|
48
|
+
for (const name of readdirSync(abs)) walk(root, rel === "" ? name : `${rel}/${name}`, keep, out);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!keep(rel)) return;
|
|
52
|
+
// Everything the filters let through is text (ts, py, yaml, js, json, md). No byte-level
|
|
53
|
+
// sniffing: src/util/hash.ts holds a NUL inside a string literal and was silently dropped
|
|
54
|
+
// by one, which broke the extension on a member machine and nowhere else.
|
|
55
|
+
out[rel] = readFileSync(abs, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keepPackageFile = (rel: string): boolean => !rel.includes("__pycache__") && !rel.endsWith(".pyc") && !rel.endsWith(".test.ts");
|
|
59
|
+
const keepDepFile = (rel: string): boolean => !/(^|\/)(tests?|__tests__)\//.test(rel) && !/\.(d\.[cm]?ts|map)$/.test(rel) && !/\.test\.[cm]?[jt]sx?$/.test(rel);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every file the executable embeds, keyed by path relative to the package
|
|
63
|
+
* root. The runtime dependencies are resolved from the package's own location,
|
|
64
|
+
* so a `bun link`ed package finds them too.
|
|
65
|
+
*/
|
|
66
|
+
export function collectPackageFiles(packageDir: string): EmbeddedPackage {
|
|
67
|
+
const files: Record<string, string> = {};
|
|
68
|
+
for (const entry of PACKAGE_ENTRIES) {
|
|
69
|
+
if (existsSync(join(packageDir, entry))) walk(packageDir, entry, keepPackageFile, files);
|
|
70
|
+
}
|
|
71
|
+
for (const dep of RUNTIME_DEPS) {
|
|
72
|
+
let depDir: string;
|
|
73
|
+
try {
|
|
74
|
+
depDir = dirname(Bun.resolveSync(`${dep}/package.json`, packageDir));
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const depFiles: Record<string, string> = {};
|
|
79
|
+
walk(depDir, "", keepDepFile, depFiles);
|
|
80
|
+
for (const [rel, content] of Object.entries(depFiles)) files[`node_modules/${dep}/${rel}`] = content;
|
|
81
|
+
}
|
|
82
|
+
const version = (JSON.parse(files["package.json"] ?? "{}") as { version?: string }).version ?? "0.0.0";
|
|
83
|
+
return { version, files };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BuildExecutableOptions {
|
|
87
|
+
packageDir: string;
|
|
88
|
+
target: ExecutableTarget;
|
|
89
|
+
/** Where the executable goes; created or replaced. */
|
|
90
|
+
outFile: string;
|
|
91
|
+
/** The bun binary to build with; this process's own when it is bun. */
|
|
92
|
+
bun?: string;
|
|
93
|
+
/** A directory for the manifest and entry module; a temp dir when absent, removed afterwards. */
|
|
94
|
+
stageDir?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type BuildExecutableResult = { ok: true; path: string; bytes: number } | { ok: false; reason: string };
|
|
98
|
+
|
|
99
|
+
function bunBinary(explicit: string | undefined): string | null {
|
|
100
|
+
if (explicit !== undefined) return explicit;
|
|
101
|
+
if (/(^|[\\/])bun(\.exe)?$/i.test(process.execPath)) return process.execPath;
|
|
102
|
+
return Bun.which("bun");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compiles the package at `packageDir` into `outFile` for `target`. The entry
|
|
107
|
+
* module sets the global `embedded.ts` reads and then loads the CLI, so the CLI
|
|
108
|
+
* itself never knows at build time whether it will be compiled.
|
|
109
|
+
*/
|
|
110
|
+
export async function buildExecutable(opts: BuildExecutableOptions): Promise<BuildExecutableResult> {
|
|
111
|
+
const bun = bunBinary(opts.bun);
|
|
112
|
+
if (bun === null) return { ok: false, reason: "bun is not available to build with" };
|
|
113
|
+
const stage = opts.stageDir ?? mkdtempSync(join(tmpdir(), "amr-exe-"));
|
|
114
|
+
mkdirSync(stage, { recursive: true });
|
|
115
|
+
try {
|
|
116
|
+
const pkg = collectPackageFiles(opts.packageDir);
|
|
117
|
+
if (pkg.files["src/index.ts"] === undefined) return { ok: false, reason: `${opts.packageDir} does not hold the router package (no src/index.ts)` };
|
|
118
|
+
writeFileSync(join(stage, "manifest.json"), JSON.stringify(pkg), "utf8");
|
|
119
|
+
const entry = join(opts.packageDir, "src", "index.ts").replaceAll("\\", "/");
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(stage, "entry.ts"),
|
|
122
|
+
[
|
|
123
|
+
`import manifest from "./manifest.json" with { type: "file" };`,
|
|
124
|
+
`(globalThis as Record<string, unknown>)[${JSON.stringify(EMBEDDED_GLOBAL)}] = { manifestPath: manifest };`,
|
|
125
|
+
`await import(${JSON.stringify(entry)});`,
|
|
126
|
+
"",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
"utf8",
|
|
129
|
+
);
|
|
130
|
+
mkdirSync(dirname(opts.outFile), { recursive: true });
|
|
131
|
+
const proc = Bun.spawn([bun, "build", "--compile", `--target=bun-${opts.target}`, join(stage, "entry.ts"), "--outfile", opts.outFile], { stdout: "pipe", stderr: "pipe", cwd: opts.packageDir });
|
|
132
|
+
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
133
|
+
if (exitCode !== 0 || !existsSync(opts.outFile)) {
|
|
134
|
+
const tail = `${stderr}\n${stdout}`.trim().split("\n").slice(-6).join("\n");
|
|
135
|
+
return { ok: false, reason: `bun build --compile for ${opts.target} failed (exit ${exitCode}): ${tail}` };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, path: opts.outFile, bytes: statSync(opts.outFile).size };
|
|
138
|
+
} finally {
|
|
139
|
+
if (opts.stageDir === undefined) rmSync(stage, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
package/src/cli/connect.ts
CHANGED
|
@@ -25,11 +25,12 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
-
import { homedir } from "node:os";
|
|
28
|
+
import { homedir, hostname } from "node:os";
|
|
29
29
|
import { dirname, join, resolve } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
32
32
|
import { SCOPE_ENV } from "../context/scope.ts";
|
|
33
|
+
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
33
34
|
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
34
35
|
import { flagString, type CliArgs } from "./args.ts";
|
|
35
36
|
|
|
@@ -58,6 +59,12 @@ export interface ConnectOptions {
|
|
|
58
59
|
keyExpiresAtMs?: number;
|
|
59
60
|
refreshExpiresAtMs?: number;
|
|
60
61
|
device?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The compiled executable running this, when one is (see embedded.ts). It
|
|
64
|
+
* becomes Claude Code's key helper and goes on PATH with --profile, and
|
|
65
|
+
* remote.json records it so a refresh from omp keeps pointing at it.
|
|
66
|
+
*/
|
|
67
|
+
exePath?: string;
|
|
61
68
|
platform: string;
|
|
62
69
|
pathHas: (bin: string) => boolean;
|
|
63
70
|
}
|
|
@@ -257,6 +264,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
257
264
|
...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
|
|
258
265
|
...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
|
|
259
266
|
...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
|
|
267
|
+
...(o.exePath !== undefined && o.exePath !== "" ? { executable: o.exePath } : {}),
|
|
260
268
|
},
|
|
261
269
|
null,
|
|
262
270
|
2,
|
|
@@ -339,8 +347,9 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
339
347
|
const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
|
|
340
348
|
// Never leave a stale key beside the helper: the helper is the source now.
|
|
341
349
|
delete env.ANTHROPIC_API_KEY;
|
|
342
|
-
|
|
343
|
-
const
|
|
350
|
+
// The executable is its own helper; under bun the source entry is.
|
|
351
|
+
const helper = o.exePath !== undefined && o.exePath !== "" ? `"${o.exePath.replaceAll("\\", "/")}" token` : `bun run "${resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/")}" token`;
|
|
352
|
+
const next = { ...settings, env, apiKeyHelper: helper };
|
|
344
353
|
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
345
354
|
if (after !== before) {
|
|
346
355
|
if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
|
|
@@ -358,11 +367,14 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
358
367
|
const [k, ...v] = line.split("=");
|
|
359
368
|
Bun.spawnSync(["setx", k!, v.join("=")], { stdout: "ignore", stderr: "ignore" });
|
|
360
369
|
}
|
|
370
|
+
// Not setx for PATH: it truncates at 1024 characters and would eat the rest.
|
|
371
|
+
if (o.exePath !== undefined && o.exePath !== "") addToUserPathWindows(dirname(o.exePath));
|
|
361
372
|
report.notes.push("user environment variables set with setx; open a new terminal");
|
|
362
373
|
} else {
|
|
363
374
|
const shell = o.env.SHELL ?? "";
|
|
364
375
|
const rc = shell.includes("zsh") ? join(o.home, ".zshrc") : join(o.home, ".bashrc");
|
|
365
|
-
const
|
|
376
|
+
const pathLine = o.exePath !== undefined && o.exePath !== "" ? [`export PATH="${dirname(o.exePath)}:$PATH"`] : [];
|
|
377
|
+
const block = `\n# auto-model-router remote (added by \`auto-model-router connect\`)\n${[...report.envLines.map((l) => `export ${l}`), ...pathLine].join("\n")}\n`;
|
|
366
378
|
const before = existsSync(rc) ? readFileSync(rc, "utf8") : "";
|
|
367
379
|
if (!before.includes("# auto-model-router remote") && !before.includes("# auto-model-router team")) appendFileSync(rc, block, "utf8");
|
|
368
380
|
else write(rc, before.replace(/\n# auto-model-router (?:remote|team)[^\n]*\n(?:export [^\n]*\n)*/, block));
|
|
@@ -374,16 +386,84 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
374
386
|
return report;
|
|
375
387
|
}
|
|
376
388
|
|
|
389
|
+
/** What a team's one-time setup token is traded for. */
|
|
390
|
+
export interface IssuedCredential {
|
|
391
|
+
key: string;
|
|
392
|
+
refreshToken: string;
|
|
393
|
+
keyExpiresAtMs?: number;
|
|
394
|
+
refreshExpiresAtMs?: number;
|
|
395
|
+
userId: string;
|
|
396
|
+
name: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Trades a one-time setup token for this machine's credential at the team's
|
|
401
|
+
* exchange route, so the install needs nothing on the machine but this
|
|
402
|
+
* program: the token is the only secret in the install command and dies on use.
|
|
403
|
+
*/
|
|
404
|
+
export async function exchangeSetupToken(url: string, token: string, device: string, fetchImpl: typeof fetch = fetch): Promise<IssuedCredential> {
|
|
405
|
+
const res = await fetchImpl(`${url}/setup/exchange`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, device }), signal: AbortSignal.timeout(15_000) });
|
|
406
|
+
if (res.status === 401) throw new Error("the setup token was refused (expired or already used); get a new one from the team's portal");
|
|
407
|
+
if (!res.ok) throw new Error(`the team's setup exchange answered ${res.status}`);
|
|
408
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
409
|
+
if (typeof body.key !== "string" || body.key === "") throw new Error("the team's setup exchange returned no key");
|
|
410
|
+
return {
|
|
411
|
+
key: body.key,
|
|
412
|
+
refreshToken: typeof body.refreshToken === "string" ? body.refreshToken : "",
|
|
413
|
+
...(typeof body.keyExpiresAtMs === "number" ? { keyExpiresAtMs: body.keyExpiresAtMs } : {}),
|
|
414
|
+
...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}),
|
|
415
|
+
userId: typeof body.userId === "string" ? body.userId : "",
|
|
416
|
+
name: typeof body.name === "string" ? body.name : "",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Adds `dir` to the user's PATH on Windows, once, through the registry-backed API rather than setx. */
|
|
421
|
+
function addToUserPathWindows(dir: string): void {
|
|
422
|
+
const quoted = `'${dir.replaceAll("'", "''")}'`;
|
|
423
|
+
const script = `$d=${quoted}; $p=[Environment]::GetEnvironmentVariable('Path','User'); if ($null -eq $p) { $p='' }; if (($p -split ';') -notcontains $d) { [Environment]::SetEnvironmentVariable('Path', (($p.TrimEnd(';') + ';' + $d).TrimStart(';')), 'User') }`;
|
|
424
|
+
Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], { stdout: "ignore", stderr: "ignore" });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Where the harness integrations are read from. Under bun that is this
|
|
429
|
+
* package; in the compiled executable it is the copy written out from the
|
|
430
|
+
* executable's own embedded files.
|
|
431
|
+
*/
|
|
432
|
+
async function resolvePackageDir(): Promise<string> {
|
|
433
|
+
const embedded = await readEmbeddedPackage();
|
|
434
|
+
if (embedded === null) return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
435
|
+
const raw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
436
|
+
return materializePackage(expand(raw, homedir()), embedded);
|
|
437
|
+
}
|
|
438
|
+
|
|
377
439
|
export async function connectCommand(args: CliArgs): Promise<void> {
|
|
378
440
|
const url = (flagString(args, "url") ?? process.env.AUTO_MODEL_ROUTER_URL ?? "").replace(/\/+$/, "");
|
|
379
|
-
|
|
380
|
-
|
|
441
|
+
let key = flagString(args, "key") ?? process.env.AUTO_MODEL_ROUTER_API_KEY ?? "";
|
|
442
|
+
const setupToken = flagString(args, "setup-token") ?? "";
|
|
443
|
+
if (url === "" || (key === "" && setupToken === "")) throw new Error("connect needs --url <remote router> and either --key <its key> or --setup-token <one-time token from the team>");
|
|
381
444
|
const only = (flagString(args, "harness") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
|
|
382
445
|
const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
|
|
383
|
-
const packageDir =
|
|
384
|
-
|
|
446
|
+
const packageDir = await resolvePackageDir();
|
|
447
|
+
const exePath = executablePath();
|
|
385
448
|
let name = flagString(args, "name") ?? "";
|
|
386
449
|
let userId = flagString(args, "user-id") ?? "";
|
|
450
|
+
let device = flagString(args, "device") ?? "";
|
|
451
|
+
// A remote that issues short-lived keys hands these over beside the key.
|
|
452
|
+
let refreshToken = flagString(args, "refresh-token") ?? "";
|
|
453
|
+
let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
|
|
454
|
+
let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
|
|
455
|
+
if (setupToken !== "") {
|
|
456
|
+
if (device === "") device = hostname();
|
|
457
|
+
const issued = await exchangeSetupToken(url, setupToken, device);
|
|
458
|
+
key = issued.key;
|
|
459
|
+
refreshToken = issued.refreshToken;
|
|
460
|
+
keyExpires = issued.keyExpiresAtMs ?? Number.NaN;
|
|
461
|
+
refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN;
|
|
462
|
+
if (issued.userId !== "") userId = issued.userId;
|
|
463
|
+
if (issued.name !== "") name = issued.name;
|
|
464
|
+
console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`);
|
|
465
|
+
}
|
|
466
|
+
// Verify the key against the route every router serves before touching anything.
|
|
387
467
|
try {
|
|
388
468
|
const res = await fetch(`${url}/v1/models`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
|
|
389
469
|
if (res.status === 401) throw new Error("the remote router rejected this key");
|
|
@@ -396,11 +476,6 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
396
476
|
// A single-project machine can label every request; a machine with several
|
|
397
477
|
// repos should leave it off and let the extensions send the workspace's own.
|
|
398
478
|
const scopeFlag = flagString(args, "scope");
|
|
399
|
-
// A remote that issues short-lived keys hands these over beside the key.
|
|
400
|
-
const refreshToken = flagString(args, "refresh-token") ?? "";
|
|
401
|
-
const keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
|
|
402
|
-
const refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
|
|
403
|
-
const device = flagString(args, "device") ?? "";
|
|
404
479
|
const report = connectRemote({
|
|
405
480
|
url,
|
|
406
481
|
key,
|
|
@@ -419,7 +494,9 @@ export async function connectCommand(args: CliArgs): Promise<void> {
|
|
|
419
494
|
...(Number.isFinite(keyExpires) ? { keyExpiresAtMs: keyExpires } : {}),
|
|
420
495
|
...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
|
|
421
496
|
...(device === "" ? {} : { device }),
|
|
497
|
+
...(exePath === null ? {} : { exePath }),
|
|
422
498
|
});
|
|
499
|
+
if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
|
|
423
500
|
console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
|
|
424
501
|
for (const c of report.configured) console.log(` configured ${c}`);
|
|
425
502
|
for (const s of report.skipped) console.log(` skipped ${s}`);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The router package carried inside a compiled executable.
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` turns this CLI into one file per operating system, and
|
|
5
|
+
* that is what a team member downloads: no bun, no npm, nothing else. But the
|
|
6
|
+
* harness integrations are not the CLI — omp loads `omp-extension/*.ts` with
|
|
7
|
+
* its own runtime and Hermes copies `hermes-plugin/` — so the executable also
|
|
8
|
+
* carries the package's source files and writes them out under the router home
|
|
9
|
+
* the first time `connect` runs. The build side (`build-executable.ts`) puts a
|
|
10
|
+
* JSON manifest of those files into the executable and sets a global that names
|
|
11
|
+
* it; this side reads it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
export interface EmbeddedPackage {
|
|
18
|
+
/** The router package's version, which also names the extracted directory. */
|
|
19
|
+
version: string;
|
|
20
|
+
/** Relative path (forward slashes) → file content. Text only: the package has no binaries. */
|
|
21
|
+
files: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the build's entry module sets before the CLI loads. */
|
|
25
|
+
export interface EmbeddedHandle {
|
|
26
|
+
/** Path of the manifest inside the executable's embedded filesystem. */
|
|
27
|
+
manifestPath: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EMBEDDED_GLOBAL = "AUTO_MODEL_ROUTER_EMBEDDED";
|
|
31
|
+
|
|
32
|
+
export function embeddedHandle(): EmbeddedHandle | null {
|
|
33
|
+
const h = (globalThis as Record<string, unknown>)[EMBEDDED_GLOBAL];
|
|
34
|
+
return typeof h === "object" && h !== null && typeof (h as EmbeddedHandle).manifestPath === "string" ? (h as EmbeddedHandle) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when this process is the compiled executable rather than `bun run src/index.ts`. */
|
|
38
|
+
export function isCompiled(): boolean {
|
|
39
|
+
return embeddedHandle() !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The executable's own path when compiled, for key helpers and PATH; null under bun. */
|
|
43
|
+
export function executablePath(): string | null {
|
|
44
|
+
return isCompiled() ? process.execPath : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readEmbeddedPackage(): Promise<EmbeddedPackage | null> {
|
|
48
|
+
const h = embeddedHandle();
|
|
49
|
+
if (h === null) return null;
|
|
50
|
+
const parsed = JSON.parse(await Bun.file(h.manifestPath).text()) as EmbeddedPackage;
|
|
51
|
+
return typeof parsed.version === "string" && typeof parsed.files === "object" && parsed.files !== null ? parsed : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const MARKER = ".materialized";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Writes the embedded package under `<routerHome>/package/<version>/` and
|
|
58
|
+
* returns that directory, the `packageDir` every harness config then points
|
|
59
|
+
* at. Idempotent: a marker records the manifest's hash, so an unchanged
|
|
60
|
+
* executable writes nothing on later runs and a rebuilt one of the same
|
|
61
|
+
* version refreshes the files.
|
|
62
|
+
*/
|
|
63
|
+
export function materializePackage(routerHome: string, pkg: EmbeddedPackage): string {
|
|
64
|
+
const dir = join(routerHome, "package", pkg.version);
|
|
65
|
+
const digest = Bun.hash(JSON.stringify(pkg.files)).toString(16);
|
|
66
|
+
try {
|
|
67
|
+
if (readFileSync(join(dir, MARKER), "utf8").trim() === digest) return dir;
|
|
68
|
+
} catch {
|
|
69
|
+
/* not written yet */
|
|
70
|
+
}
|
|
71
|
+
for (const [rel, content] of Object.entries(pkg.files)) {
|
|
72
|
+
const target = join(dir, ...rel.split("/"));
|
|
73
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
74
|
+
writeFileSync(target, content, "utf8");
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(join(dir, MARKER), `${digest}\n`, "utf8");
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* its own expiry, so a session still holding it is never cut.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
|
|
16
17
|
import { homedir } from "node:os";
|
|
17
18
|
import { dirname, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -89,7 +90,12 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
89
90
|
const rh = opts.routerHome ?? routerHome();
|
|
90
91
|
const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
|
|
91
92
|
const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
|
|
92
|
-
|
|
93
|
+
// The compiled executable rewrites from its own extracted package and keeps
|
|
94
|
+
// itself as the key helper; the omp extension, running from that extracted
|
|
95
|
+
// package, learns the executable from remote.json.
|
|
96
|
+
const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
|
|
97
|
+
const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
|
|
98
|
+
const exePath = opts.remote.executable ?? executablePath() ?? undefined;
|
|
93
99
|
connectRemote({
|
|
94
100
|
url: opts.remote.url,
|
|
95
101
|
key: fresh.key,
|
|
@@ -110,6 +116,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
110
116
|
// The store that already holds it keeps it; a machine never silently changes store.
|
|
111
117
|
...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
|
|
112
118
|
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
119
|
+
...(exePath !== undefined ? { exePath } : {}),
|
|
113
120
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
114
121
|
});
|
|
115
122
|
return fresh;
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { configCommand } from "./cli/config-cmd.ts";
|
|
|
13
13
|
import { explainCommand } from "./cli/explain.ts";
|
|
14
14
|
import { exportCommand } from "./cli/export.ts";
|
|
15
15
|
import { connectCommand } from "./cli/connect.ts";
|
|
16
|
+
import { isCompiled, readEmbeddedPackage } from "./cli/embedded.ts";
|
|
16
17
|
import { refreshCommand, tokenCommand } from "./cli/refresh.ts";
|
|
17
18
|
import { modelsCommand } from "./cli/models.ts";
|
|
18
19
|
import { reportCommand } from "./cli/report.ts";
|
|
@@ -27,7 +28,7 @@ Usage: auto-model-router <command> [options]
|
|
|
27
28
|
stats Show routed spend, per-model share, and escalation rates
|
|
28
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
29
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
30
|
-
connect Point this machine at a remote router (--url
|
|
31
|
+
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH)
|
|
31
32
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
32
33
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
33
34
|
models Show what each complexity tier would consider, and why
|
|
@@ -56,7 +57,9 @@ async function main(): Promise<number> {
|
|
|
56
57
|
const args = parseArgv(process.argv.slice(2));
|
|
57
58
|
|
|
58
59
|
if (args.flags.has("version")) {
|
|
59
|
-
|
|
60
|
+
// The compiled executable has no package.json beside it; its embedded copy answers.
|
|
61
|
+
const embedded = await readEmbeddedPackage();
|
|
62
|
+
const pkg: unknown = embedded ?? (await Bun.file(join(import.meta.dir, "..", "package.json")).json());
|
|
60
63
|
const value =
|
|
61
64
|
typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string"
|
|
62
65
|
? pkg.version
|
|
@@ -108,7 +111,8 @@ async function main(): Promise<number> {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
// The compiled executable loads this module from its entry, so it is never import.meta.main there.
|
|
115
|
+
if (import.meta.main || isCompiled()) {
|
|
112
116
|
try {
|
|
113
117
|
const code = await main();
|
|
114
118
|
if (code !== 0) process.exit(code);
|
package/src/lib.ts
CHANGED
|
@@ -23,5 +23,6 @@ export { openDb } from "./util/sqlite.ts";
|
|
|
23
23
|
export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
|
|
24
24
|
export { createLedger } from "./cost/ledger.ts";
|
|
25
25
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
26
|
+
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
|
26
27
|
export type { RequestPolicy } from "./wire/types.ts";
|
|
27
28
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget } from "../src/cli/build-executable.ts";
|
|
6
|
+
import { connectRemote, exchangeSetupToken } from "../src/cli/connect.ts";
|
|
7
|
+
import { EMBEDDED_GLOBAL, embeddedHandle, isCompiled, materializePackage, readEmbeddedPackage } from "../src/cli/embedded.ts";
|
|
8
|
+
import { parseRemoteRouter } from "../omp-extension/remote-logic.ts";
|
|
9
|
+
|
|
10
|
+
const NL = String.fromCharCode(10);
|
|
11
|
+
|
|
12
|
+
describe("the single-file member install", () => {
|
|
13
|
+
test("the embedded package is the install: CLI, harness integrations, runtime deps; no tests, no caches", () => {
|
|
14
|
+
const pkg = collectPackageFiles(process.cwd());
|
|
15
|
+
expect(pkg.version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
|
|
16
|
+
expect(pkg.files["src/index.ts"]).toContain("connect");
|
|
17
|
+
expect(pkg.files["omp-extension/router-embed.ts"]).toBeDefined();
|
|
18
|
+
expect(pkg.files["hermes-plugin/native/__init__.py"] ?? pkg.files[Object.keys(pkg.files).find((k) => k.startsWith("hermes-plugin/")) ?? ""]).toBeDefined();
|
|
19
|
+
expect(pkg.files["node_modules/zod/package.json"]).toBeDefined();
|
|
20
|
+
expect(pkg.files["node_modules/yaml/package.json"]).toBeDefined();
|
|
21
|
+
const names = Object.keys(pkg.files);
|
|
22
|
+
// Every non-test source file, none missing: a dropped one only fails on a member machine, when the extension imports it.
|
|
23
|
+
const onDisk = readdirSync("src", { recursive: true }).map(String).filter((f) => /\.(ts|py|yaml)$/.test(f) && !f.endsWith(".test.ts")).map((f) => `src/${f.replaceAll("\\", "/")}`);
|
|
24
|
+
expect(onDisk.length).toBeGreaterThan(50);
|
|
25
|
+
for (const f of onDisk) expect(names).toContain(f);
|
|
26
|
+
expect(names.some((n) => n.endsWith(".test.ts"))).toBe(false);
|
|
27
|
+
expect(names.some((n) => n.includes("__pycache__") || n.endsWith(".pyc"))).toBe(false);
|
|
28
|
+
expect(names.some((n) => /\.d\.ts$/.test(n) && n.startsWith("node_modules/"))).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("targets and file names", () => {
|
|
32
|
+
expect(isExecutableTarget("linux-x64")).toBe(true);
|
|
33
|
+
expect(isExecutableTarget("linux-x86")).toBe(false);
|
|
34
|
+
expect(executableFileName("windows-x64")).toBe("auto-model-router-windows-x64.exe");
|
|
35
|
+
expect(executableFileName("darwin-arm64")).toBe("auto-model-router-darwin-arm64");
|
|
36
|
+
expect(hostTarget("win32", "x64")).toBe("windows-x64");
|
|
37
|
+
expect(hostTarget("darwin", "arm64")).toBe("darwin-arm64");
|
|
38
|
+
expect(hostTarget("win32", "arm64")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("under bun there is no embedded package", async () => {
|
|
42
|
+
expect(isCompiled()).toBe(false);
|
|
43
|
+
expect(embeddedHandle()).toBeNull();
|
|
44
|
+
expect(await readEmbeddedPackage()).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("materializing writes the files once, keyed by content, under the router home", () => {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "amr-mat-"));
|
|
49
|
+
try {
|
|
50
|
+
const pkg = { version: "9.9.9", files: { "package.json": `{"version":"9.9.9"}${NL}`, "src/index.ts": `console.log(1)${NL}`, "omp-extension/x.ts": "export {}" } };
|
|
51
|
+
const dir = materializePackage(home, pkg);
|
|
52
|
+
expect(dir).toBe(join(home, "package", "9.9.9"));
|
|
53
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe(`console.log(1)${NL}`);
|
|
54
|
+
// Unchanged: a second call leaves a hand-edited file alone (nothing is rewritten).
|
|
55
|
+
writeFileSync(join(dir, "src", "index.ts"), "edited", "utf8");
|
|
56
|
+
expect(materializePackage(home, pkg)).toBe(dir);
|
|
57
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("edited");
|
|
58
|
+
// A rebuilt executable of the same version with different content refreshes it.
|
|
59
|
+
expect(materializePackage(home, { ...pkg, files: { ...pkg.files, "src/index.ts": "v2" } })).toBe(dir);
|
|
60
|
+
expect(readFileSync(join(dir, "src", "index.ts"), "utf8")).toBe("v2");
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("connect from the executable: it is Claude Code's key helper, goes on PATH, and remote.json names it", () => {
|
|
67
|
+
const home = mkdtempSync(join(tmpdir(), "amr-exe-connect-"));
|
|
68
|
+
const claude = join(home, ".claude");
|
|
69
|
+
mkdirSync(claude, { recursive: true });
|
|
70
|
+
const rh = join(home, ".auto-model-router");
|
|
71
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes"), SHELL: "/bin/zsh" };
|
|
72
|
+
const exe = join(rh, "bin", "auto-model-router");
|
|
73
|
+
try {
|
|
74
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
|
|
75
|
+
expect(r.configured.some((c) => c.startsWith("Claude Code ("))).toBe(true);
|
|
76
|
+
const s = JSON.parse(readFileSync(join(claude, "settings.json"), "utf8")) as { apiKeyHelper: string };
|
|
77
|
+
expect(s.apiKeyHelper).toBe(`"${exe.replaceAll("\\", "/")}" token`);
|
|
78
|
+
expect(parseRemoteRouter(readFileSync(join(rh, "remote.json"), "utf8"))?.executable).toBe(exe);
|
|
79
|
+
const rc = readFileSync(join(home, ".zshrc"), "utf8");
|
|
80
|
+
expect(rc).toContain(`export PATH="${join(rh, "bin")}:$PATH"`);
|
|
81
|
+
expect(rc).toContain("export AUTO_MODEL_ROUTER_URL=https://team.example");
|
|
82
|
+
// Re-running keeps one block.
|
|
83
|
+
connectRemote({ url: "https://team.example", key: "amrt_k2", userId: "u_ada", name: "Ada", profile: true, dryRun: false, only: ["claude"], env, home, packageDir: join(rh, "package", "1.0.0"), exePath: exe, platform: "linux", pathHas: () => false });
|
|
84
|
+
expect(readFileSync(join(home, ".zshrc"), "utf8").split("# auto-model-router remote").length).toBe(2);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(home, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a setup token is traded for the credential; a refused token says so", async () => {
|
|
91
|
+
const seen: { url: string; body: string }[] = [];
|
|
92
|
+
const ok = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
93
|
+
seen.push({ url: String(url), body: String(init?.body) });
|
|
94
|
+
return Response.json({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada", teamUrl: "https://team.example" });
|
|
95
|
+
}) as unknown as typeof fetch;
|
|
96
|
+
const issued = await exchangeSetupToken("https://team.example", "amrs_t", "laptop", ok);
|
|
97
|
+
expect(issued).toEqual({ key: "amrt_new", refreshToken: "amrr_new", keyExpiresAtMs: 10, refreshExpiresAtMs: 20, userId: "u_ada", name: "Ada" });
|
|
98
|
+
expect(seen[0]?.url).toBe("https://team.example/setup/exchange");
|
|
99
|
+
expect(JSON.parse(seen[0]?.body ?? "{}")).toEqual({ token: "amrs_t", device: "laptop" });
|
|
100
|
+
const refused = (async () => Response.json({ error: "invalid_token" }, { status: 401 })) as unknown as typeof fetch;
|
|
101
|
+
await expect(exchangeSetupToken("https://team.example", "amrs_old", "laptop", refused)).rejects.toThrow("refused");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the executable builds for this host and knows its version from the embedded package", async () => {
|
|
105
|
+
const target = hostTarget();
|
|
106
|
+
if (target === null) return;
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), "amr-build-"));
|
|
108
|
+
try {
|
|
109
|
+
const out = join(dir, executableFileName(target));
|
|
110
|
+
const r = await buildExecutable({ packageDir: process.cwd(), target, outFile: out });
|
|
111
|
+
if (!r.ok) throw new Error(r.reason);
|
|
112
|
+
expect(existsSync(out)).toBe(true);
|
|
113
|
+
expect(r.bytes).toBeGreaterThan(10_000_000);
|
|
114
|
+
const version = Bun.spawnSync([out, "--version"], { stdout: "pipe", stderr: "pipe" }).stdout.toString().trim();
|
|
115
|
+
expect(version).toBe((JSON.parse(readFileSync("package.json", "utf8")) as { version: string }).version);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}, 120_000);
|
|
120
|
+
|
|
121
|
+
test("the global handle is what marks a compiled process", () => {
|
|
122
|
+
const g = globalThis as Record<string, unknown>;
|
|
123
|
+
g[EMBEDDED_GLOBAL] = { manifestPath: "/$bunfs/root/manifest.json" };
|
|
124
|
+
try {
|
|
125
|
+
expect(isCompiled()).toBe(true);
|
|
126
|
+
expect(embeddedHandle()?.manifestPath).toBe("/$bunfs/root/manifest.json");
|
|
127
|
+
} finally {
|
|
128
|
+
delete g[EMBEDDED_GLOBAL];
|
|
129
|
+
}
|
|
130
|
+
expect(isCompiled()).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|