diff --git a/README.md b/README.md index b0ca3f3..cfdb059 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ mba models stage qwen --harness cursor mba connect qwen --harness cursor mba servers # list / boot / stop (TTY) mba s logs +mba migrate models ~/models # local GGUFs → hub (copy/hardlink) mba machine # enforce | warn | off mba estimate-memory eval "$(mba completion)" # bash; or: mba completion zsh @@ -101,8 +102,6 @@ eval "$(mba completion)" # bash; or: mba completion zsh Defaults are OS-aware: XDG on Linux, `%APPDATA%` / `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS. -Upgrading from a pre-0.1.1 install: `mba migrate-paths` once (local, never overwrites). - ## MCP The service must already be running. diff --git a/docs/workflows/cli-development.md b/docs/workflows/cli-development.md index 8ef4884..8abe8a6 100644 --- a/docs/workflows/cli-development.md +++ b/docs/workflows/cli-development.md @@ -14,9 +14,9 @@ Industry notes (Claude Code architecture review + git/gh/kubectl): steal TTY-vs- ## What `mba` is -A **thin client** of the MBA daemon. It does not own adapter files, sessions, or llama-server. Reads and writes go through the service (`GET` / `POST`). Local exceptions (no daemon): `migrate-paths`, `estimate-memory`, `completion`, `--help`. +A **thin client** of the MBA daemon. It does not own adapter files, sessions, or llama-server. Reads and writes go through the service (`GET` / `POST`). Local exceptions (no daemon): `estimate-memory`, `completion`, `--help`. Scan of operator GGUFs for `mba migrate` is local; the hub write is `POST /models/adopt`. -Nouns: `models` (`m`), `servers` (`s`), `clients` (`c`), `machine`, `status`. Old flat verbs stay as aliases. +Nouns: `models` (`m`), `servers` (`s`), `clients` (`c`), `migrate`, `machine`, `status`. Old flat verbs stay as aliases. `migrate` has no shortcut. TTY and `--json` are two skins of the same route. JSON field names are the contract. TTY labels can change in a polish; JSON must not. @@ -32,7 +32,7 @@ TTY and `--json` are two skins of the same route. JSON field names are the contr | `client.ts` | `fail`, `serviceGet` / `servicePost`, `resolveServiceUrl` | | `style.ts` | paint, `brand`, `kv`, `heading`, `shortenHome` | | `interactive.ts` | raw-mode pickers and one-line prompts | -| `status.ts` / `clients.ts` / `models.ts` / `servers.ts` / `machine.ts` | one noun each | +| `status.ts` / `clients.ts` / `models.ts` / `servers.ts` / `machine.ts` / `migrate.ts` | one noun each | | `slot-print.ts` | TTY grouping for paired sessions (status + clients) | | `list-print.ts` | TTY rows for servers, models, registered clients | | `harness-choices.ts` | built-in + operator envelopes for pickers | diff --git a/packages/core/README.md b/packages/core/README.md index 4917c37..823d414 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,7 +18,7 @@ configure adapter → BCB (system watch) → AMPI (system live response) - **BCB** — behavioral circuit breakers. You name known failure modes on this model and configure an escalation ladder plus a programmatic response. - **AMPI** — automated multi-process intervention. A deterministic named recipe that runs when a breaker fires (Sanitize, Assist, Sanction, Recover). - **Service** — binds `127.0.0.1` on an OS-assigned port and writes `/mba/service.json`. -- **CLI** — `mba` (`models`, `servers`, `machine`, `status`). The published bin is the CLI, not the service. +- **CLI** — `mba` (`models`, `servers`, `clients`, `migrate`, `machine`, `status`). The published bin is the CLI, not the service. ## Install (library) @@ -58,7 +58,7 @@ Node ≥ 22. llama.cpp on `PATH` if you boot with `mba servers boot`. | `MBA_SWITCH_PORT` | Default boot port (8080) | | `MBA_UPSTREAM_URL` | Fallback upstream when the registry is empty | -Defaults are OS-aware (see `src/service/paths.ts`). Upgrading from a pre-0.1.1 install: `mba migrate-paths` once (local, never overwrites). +Defaults are OS-aware (see `src/service/paths.ts`). ## Develop diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index a5f32d8..89ef4ff 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -3,10 +3,11 @@ * Install: eval "$(mba completion)" or mba completion zsh */ -const GROUPS = "models m servers server s clients client c machine status help completion migrate-paths estimate-memory connect"; +const GROUPS = "models m servers server s clients client c migrate machine status help completion estimate-memory connect"; const MODEL_SUB = "list show set open path pull search edit stage connect"; const SERVER_SUB = "list boot stop logs slots binaries builds"; const CLIENT_SUB = "list add connect revoke remove"; +const MIGRATE_SUB = "models find"; const MACHINE_SUB = "enforce warn off"; function bashScript(): string { @@ -49,6 +50,11 @@ _mba() { COMPREPLY=( $(compgen -W "${CLIENT_SUB}" -- "\$cur") ) fi ;; + migrate) + if [[ \${COMP_CWORD} -eq 2 ]]; then + COMPREPLY=( $(compgen -W "${MIGRATE_SUB}" -- "\$cur") ) + fi + ;; machine|machine-overlay) COMPREPLY=( $(compgen -W "${MACHINE_SUB}" -- "\$cur") ) ;; @@ -56,7 +62,7 @@ _mba() { COMPREPLY=( $(compgen -W "bash zsh" -- "\$cur") ) ;; help) - COMPREPLY=( $(compgen -W "models servers clients machine status" -- "\$cur") ) + COMPREPLY=( $(compgen -W "models servers clients migrate machine status" -- "\$cur") ) ;; esac } @@ -67,11 +73,12 @@ complete -F _mba mba function zshScript(): string { return `#compdef mba _mba() { - local -a groups modelsubs serversubs clientsubs - groups=(models m servers server s clients client c machine status help completion migrate-paths estimate-memory connect) + local -a groups modelsubs serversubs clientsubs migratesubs + groups=(models m servers server s clients client c migrate machine status help completion estimate-memory connect) modelsubs=(list show set open path pull search edit stage connect) serversubs=(list boot stop logs slots binaries builds) clientsubs=(list add connect revoke remove) + migratesubs=(models find) case $CURRENT in 2) _describe 'command' groups ;; *) @@ -83,9 +90,10 @@ _mba() { ;; servers|server|s) _describe 'servers' serversubs ;; clients|client|c) _describe 'clients' clientsubs ;; + migrate) _describe 'migrate' migratesubs ;; machine) _describe 'mode' '(enforce warn off)' ;; completion) _describe 'shell' '(bash zsh)' ;; - help) _describe 'topic' '(models servers clients machine status)' ;; + help) _describe 'topic' '(models servers clients migrate machine status)' ;; esac ;; esac diff --git a/packages/core/src/cli/help.test.ts b/packages/core/src/cli/help.test.ts index e2cc460..cb64460 100644 --- a/packages/core/src/cli/help.test.ts +++ b/packages/core/src/cli/help.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { usageServers } from "./help.js"; +import { usageMigrate, usageServers } from "./help.js"; describe("help", () => { const prevNoColor = process.env.NO_COLOR; @@ -18,4 +18,14 @@ describe("help", () => { expect(text).toContain("MBA_SWITCH_PORT"); expect(text).toContain("--json"); }); + + it("names migrate models, find, and --from", () => { + process.env.NO_COLOR = "1"; + const text = usageMigrate(); + expect(text).toContain("mba migrate models"); + expect(text).toContain("mba migrate find"); + expect(text).toContain("--from"); + expect(text).toContain("--move"); + expect(text).toContain("--yes"); + }); }); diff --git a/packages/core/src/cli/help.ts b/packages/core/src/cli/help.ts index 27b5030..92810f8 100644 --- a/packages/core/src/cli/help.ts +++ b/packages/core/src/cli/help.ts @@ -1,6 +1,13 @@ import { brand, dim, heading, paint, BOLD } from "./style.js"; -export type HelpTopic = "overview" | "models" | "servers" | "clients" | "machine" | "status"; +export type HelpTopic = + | "overview" + | "models" + | "servers" + | "clients" + | "machine" + | "status" + | "migrate"; function cmd(line: string, note: string): string { return ` ${paint(line.padEnd(36), BOLD)} ${dim(note)}`; @@ -14,11 +21,11 @@ export function usageOverview(): string { cmd("mba models", "edit, search, pull (m)"), cmd("mba servers", "list, boot, stop, logs, slots, builds (s)"), cmd("mba clients", "list, add, connect, revoke (c)"), + cmd("mba migrate", "local GGUFs → hub"), cmd("mba machine", "hardware clamp mode"), cmd("mba status", "service, loaded model, pairing slots"), "", heading("Local"), - cmd("mba migrate-paths", "move legacy state + store"), cmd("mba estimate-memory ", "RAM/VRAM estimate"), cmd("mba completion [bash|zsh]", "print shell completion"), "", @@ -26,7 +33,7 @@ export function usageOverview(): string { dim(" mba --help details for that group"), dim(" shortcuts m → models s → servers c → clients"), dim(" --yes skip confirm (restart / boot preview)"), - dim(" --json machine-readable list/show/status/stage/connect"), + dim(" --json machine-readable list/show/status/stage/connect/migrate"), ].join("\n"); } @@ -110,6 +117,24 @@ export function usageStatus(): string { ].join("\n"); } +export function usageMigrate(): string { + return [ + `${brand("migrate")}`, + "", + cmd("mba migrate", "models / find (TTY menu)"), + cmd("mba migrate models [dir]", "GGUFs in that folder → hub"), + cmd("mba migrate find [query]", "fuzzy-find GGUFs → hub"), + "", + dim(" copies into the hub (hardlink when possible)"), + dim(" find ~/.cache/huggingface/hub and ~/models"), + dim(" --from limit find to one directory"), + dim(" TTY asks whether to remove the source (enter = keep)"), + dim(" --move remove source without asking"), + dim(" --yes skip asks; keeps source unless --move"), + dim(" --json same as --yes, machine-readable results"), + ].join("\n"); +} + export function usageFor(topic: HelpTopic): string { switch (topic) { case "models": @@ -122,6 +147,8 @@ export function usageFor(topic: HelpTopic): string { return usageMachine(); case "status": return usageStatus(); + case "migrate": + return usageMigrate(); default: return usageOverview(); } diff --git a/packages/core/src/cli/interactive.test.ts b/packages/core/src/cli/interactive.test.ts index 46fb994..e8c58e8 100644 --- a/packages/core/src/cli/interactive.test.ts +++ b/packages/core/src/cli/interactive.test.ts @@ -7,6 +7,7 @@ import { pickLabeledInteractive, pickModelInteractive, pickPreviewInteractive, + pickManyInteractive, pickServerInteractive, searchHfInteractive, type ModelEntry, @@ -574,3 +575,43 @@ describe("askYesNoInteractive", () => { await expect(p).resolves.toBeNull(); }); }); + +describe("pickManyInteractive", () => { + let stdin: ReturnType; + const items = [ + { label: "a.gguf", value: "/tmp/a.gguf", preview: [["file", "a.gguf"]] as const }, + { label: "b.gguf", value: "/tmp/b.gguf", preview: [["file", "b.gguf"]] as const }, + ]; + beforeEach(() => { + stdin = fakeStdin(); + vi.spyOn(process, "stdin", "get").mockReturnValue(stdin as unknown as NodeJS.ReadStream & { fd: 0 }); + vi.spyOn(process.stdout, "write").mockReturnValue(true as unknown as ReturnType); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("adopts the highlighted row on enter with nothing marked", async () => { + const p = pickManyInteractive("adopt", items); + await tick(); + stdin.emit("\r"); + await expect(p).resolves.toEqual(["/tmp/a.gguf"]); + }); + + it("toggles with space and confirms the marked set", async () => { + const p = pickManyInteractive("adopt", items); + await tick(); + stdin.emit(" "); + stdin.emit("\x1b[B"); + stdin.emit(" "); + stdin.emit("\r"); + await expect(p).resolves.toEqual(["/tmp/a.gguf", "/tmp/b.gguf"]); + }); + + it("resolves null on Esc", async () => { + const p = pickManyInteractive("adopt", items); + await tick(); + stdin.emit("\x1b"); + await expect(p).resolves.toBeNull(); + }); +}); diff --git a/packages/core/src/cli/interactive.ts b/packages/core/src/cli/interactive.ts index 2b0e140..bc20f59 100644 --- a/packages/core/src/cli/interactive.ts +++ b/packages/core/src/cli/interactive.ts @@ -102,17 +102,26 @@ function previewPickLines( allCount: number, list: readonly PreviewPickItem[], cursor: number, + marked?: ReadonlySet, ): string[] { const win = sliceWindow(list, cursor, PREVIEW_LIST_WINDOW); const left = win.items.length === 0 ? [dim(" (no matches)")] - : win.items.map((it, i) => option(win.start + i === cursor, it.label)); + : win.items.map((it, i) => + option(win.start + i === cursor, it.label, "", marked?.has(it.value)), + ); const current = list[cursor]; + const count = + marked !== undefined ? `${marked.size} sel · ${list.length}/${allCount}` : `${list.length}/${allCount}`; return previewBox({ title, - detail: filter ? `filter: ${filter}` : undefined, - count: `${list.length}/${allCount}`, + detail: filter + ? `filter: ${filter}` + : marked !== undefined + ? "space toggle · enter adopt" + : undefined, + count, left, preview: current?.preview ?? [], }); @@ -737,6 +746,93 @@ export function pickPreviewInteractive( }); } +/** + * Multi-select sibling of `pickPreviewInteractive`. Space toggles the + * current row (does not add to the filter). Enter confirms the marked + * set; if nothing is marked, the highlighted row is adopted. Esc clears + * the filter first, then cancels. + */ +export function pickManyInteractive( + title: string, + items: readonly PreviewPickItem[], +): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin; + const frame = createMenuFrame(); + let query = ""; + let cursor = 0; + const marked = new Set(); + + const filtered = () => (query ? items.filter((it) => matchesQuery(it, query)) : items); + + const render = () => { + frame.draw(previewPickLines(brand(title), query, items.length, filtered(), cursor, marked)); + }; + + const finish = (ok: () => void) => { + endInteractive(stdin, onData); + frame.close({ erase: true }); + ok(); + }; + + const onData = (buf: Buffer) => { + for (const key of tokenizeKeys(buf.toString("utf8"))) { + const list = filtered(); + if (key === "\x1b[A") { + if (list.length === 0) continue; + cursor = (cursor - 1 + list.length) % list.length; + render(); + } else if (key === "\x1b[B") { + if (list.length === 0) continue; + cursor = (cursor + 1) % list.length; + render(); + } else if (key === " ") { + const row = list[cursor]; + if (!row) continue; + if (marked.has(row.value)) marked.delete(row.value); + else marked.add(row.value); + render(); + } else if (key === "\r" || key === "\n") { + const chosen = + marked.size > 0 + ? items.filter((it) => marked.has(it.value)).map((it) => it.value) + : list[cursor] + ? [list[cursor]!.value] + : []; + if (chosen.length === 0) continue; + finish(() => resolve(chosen)); + return; + } else if (key === "\x1b") { + if (query.length > 0) { + query = ""; + cursor = 0; + render(); + continue; + } + finish(() => resolve(null)); + return; + } else if (key === "\x7f" || key === "\b") { + query = query.slice(0, -1); + cursor = Math.min(cursor, Math.max(0, filtered().length - 1)); + render(); + } else if (key === "\x03") { + finish(() => reject(new Error("cancelled"))); + return; + } else if (key.length === 1 && !key.startsWith("\x1b")) { + query += key; + cursor = 0; + render(); + } + } + }; + + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + render(); + }); +} + /** * Raw-mode text prompt. Enter with an empty input returns `defaultValue`; * otherwise returns the typed text. Esc returns null (cancel). diff --git a/packages/core/src/cli/mba.ts b/packages/core/src/cli/mba.ts index 346f90f..b351d86 100644 --- a/packages/core/src/cli/mba.ts +++ b/packages/core/src/cli/mba.ts @@ -13,7 +13,6 @@ import { cmdEstimateMemory } from "./estimate-memory.js"; import { usageFor } from "./help.js"; import { cmdHome } from "./home.js"; import { cmdMachine } from "./machine.js"; -import { cmdMigratePaths } from "./migrate.js"; import { cmdModelsEdit, cmdModelsList, @@ -28,6 +27,7 @@ import { cmdModelsConnect, } from "./models.js"; import { parseMbaArgv } from "./route.js"; +import { cmdMigrate } from "./migrate.js"; import { cmdServers } from "./servers.js"; import { cmdStatus } from "./status.js"; @@ -63,10 +63,6 @@ async function main(argv: readonly string[]): Promise { cmdCompletion(route.args); return; } - if (route.cmd === "migrate-paths") { - cmdMigratePaths(); - return; - } if (route.cmd === "estimate-memory") { cmdEstimateMemory([...route.args]); return; @@ -91,6 +87,9 @@ async function main(argv: readonly string[]): Promise { case "clients": await cmdClients(baseUrl, route.args, json); return; + case "migrate": + await cmdMigrate(baseUrl, route.action, route.args, skipRestart, json); + return; case "models": switch (route.action) { case "pick": diff --git a/packages/core/src/cli/migrate.ts b/packages/core/src/cli/migrate.ts index d72f8f9..7ff29da 100644 --- a/packages/core/src/cli/migrate.ts +++ b/packages/core/src/cli/migrate.ts @@ -1,53 +1,345 @@ -import { existsSync, readdirSync } from "node:fs"; +/** + * Adopt local GGUFs into the model hub (scan here, write on the daemon). + * + * `mba migrate models [dir]` — walk one folder. + * `mba migrate find [query] [--from ]` — fuzzy-find, then the same adopt. + */ + +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { fail, formatBytes, serviceGet, servicePost } from "./client.js"; import { - defaultModelStoreRoot, - defaultStateDir, - ensureDir, - executeMigration, - legacyModelStoreRoot, - legacyStateDir, -} from "../service/paths.js"; + askTextInteractive, + askYesNoInteractive, + pickLabeledInteractive, + pickManyInteractive, + type ModelEntry, + type PreviewPickItem, +} from "./interactive.js"; +import { + catalogSkipFromModelFiles, + defaultFindRoots, + isAlreadyInHub, + rankGgufs, + scanGgufs, + type FoundGguf, +} from "../model/gguf-scan.js"; +import { deriveModelId } from "../model/model-id.js"; +import { adoptedLine, brand, dim, shortenHome } from "./style.js"; + +const MODELS_USAGE = "usage: mba migrate models [dir] [--move] [--yes] [--json]"; +const FIND_USAGE = "usage: mba migrate find [query] [--from ] [--move] [--yes] [--json]"; +const GROUP_USAGE = + "usage: mba migrate \n" + + " models [dir] GGUFs in that folder → hub\n" + + " find [query] [--from dir] fuzzy-find GGUFs → hub\n" + + " --move skip the ask; remove source after adopt"; -function probeDir(dir: string): { exists: boolean; empty: boolean } { - const exists = existsSync(dir); - const empty = exists && readdirSync(dir).length === 0; - return { exists, empty }; +interface AdoptResult { + readonly id: string; + readonly family: string; + readonly sha256: string; + readonly modelDir: string; + readonly adapterPath: string; + readonly familyCreated: boolean; + readonly placed: "copy" | "hardlink" | "inplace"; + readonly moved: boolean; } -/** - * `mba migrate-paths` — one-time move of state + model store from the - * legacy locations to the OS-aware ones. Local only; the service can be down. - */ -export function cmdMigratePaths(): void { - const homes: ReadonlyArray<{ label: string; from: string; to: string }> = [ - { label: "state", from: legacyStateDir(), to: defaultStateDir() }, - { label: "store", from: legacyModelStoreRoot(), to: defaultModelStoreRoot() }, - ]; - let moved = 0; - for (const home of homes) { - const src = probeDir(home.from); - const dst = probeDir(home.to); - const result = executeMigration(home.from, home.to, src.exists, dst.exists, dst.empty); - switch (result.status) { - case "moved": - moved += 1; - process.stdout.write(`[mba] ${home.label}: moved ${home.from} → ${home.to}\n`); - break; - case "skipped-missing-source": - process.stdout.write(`[mba] ${home.label}: nothing to move (no ${home.from})\n`); - break; - case "skipped-destination-exists": - process.stdout.write( - `[mba] ${home.label}: SKIPPED — ${home.to} already has data; not overwriting. ` + - `Move or merge ${home.from} by hand if you need it.\n`, - ); - break; - } - } - ensureDir(defaultStateDir()); - ensureDir(defaultModelStoreRoot()); - process.stdout.write( - `[mba] migrate-paths done — ${moved} home(s) moved. ` + - `State: ${defaultStateDir()}\n[mba] Store: ${defaultModelStoreRoot()}\n`, - ); +function expandHome(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +function absPath(path: string): string { + const expanded = expandHome(path); + return isAbsolute(expanded) ? expanded : resolve(expanded); +} + +function parseMigrateFlags( + args: readonly string[], + allowFrom: boolean, + usage: string, +): { move: boolean; from?: string; rest: string[] } { + const rest: string[] = []; + let move = false; + let from: string | undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--move") { + move = true; + continue; + } + if (a === "--from" || a.startsWith("--from=")) { + if (!allowFrom) fail(`unknown flag: ${a}\n${usage}`); + if (a.startsWith("--from=")) { + from = a.slice("--from=".length); + if (!from) fail(usage); + continue; + } + const next = args[i + 1]; + if (!next || next.startsWith("-")) fail(usage); + from = next; + i++; + continue; + } + if (a.startsWith("-")) fail(`unknown flag: ${a}\n${usage}`); + rest.push(a); + } + return { move, from, rest }; +} + +function foundItems(files: readonly FoundGguf[]): PreviewPickItem[] { + return files.map((f) => ({ + label: f.fileName, + value: f.path, + preview: [ + ["file", shortenHome(f.path)], + ["size", formatBytes(f.bytes)], + ["quant", f.quant ?? "—"], + ["name", f.ggufName ?? "—"], + ], + })); +} + +async function hubSkip(baseUrl: string) { + const { models } = await serviceGet<{ models: ModelEntry[] }>(baseUrl, "/models"); + return catalogSkipFromModelFiles(models.map((m) => m.modelFile)); +} + +function dropCataloged(files: readonly FoundGguf[], skip: ReturnType): FoundGguf[] { + return files.filter((f) => !isAlreadyInHub(f.path, skip)); +} + +async function confirmIds( + files: readonly FoundGguf[], + assumeNo: boolean, +): Promise | null> { + const out: Array<{ file: FoundGguf; id: string; family: string }> = []; + for (const file of files) { + const defaultId = deriveModelId(file.fileName); + if (assumeNo) { + out.push({ file, id: defaultId, family: defaultId }); + continue; + } + if (!process.stdin.isTTY) { + out.push({ file, id: defaultId, family: defaultId }); + continue; + } + const id = await askTextInteractive("model id", defaultId); + if (id === null) return null; + const family = await askTextInteractive("family", id); + if (family === null) return null; + out.push({ file, id, family }); + } + return out; +} + +async function adoptOne( + baseUrl: string, + path: string, + id: string, + family: string, + move: boolean, +): Promise { + const body: Record = { path, id, family }; + if (move) body.move = true; + return servicePost(baseUrl, "/models/adopt", body); +} + +async function adoptPicked( + baseUrl: string, + files: readonly FoundGguf[], + assumeNo: boolean, + json: boolean, + move: boolean, +): Promise { + if (files.length === 0) { + if (json) { + process.stdout.write("[]\n"); + return; + } + process.stdout.write(`${brand("migrate")}\n ${dim("none new")}\n`); + return; + } + + let chosen: FoundGguf[] = [...files]; + const batch = json || assumeNo || !process.stdin.isTTY; + if (!batch) { + const picked = await pickManyInteractive("adopt", foundItems(files)); + if (picked === null) { + process.stdout.write("[mba] cancelled\n"); + return; + } + const byPath = new Map(files.map((f) => [f.path, f])); + chosen = picked.map((p) => byPath.get(p)).filter((f): f is FoundGguf => f !== undefined); + } + + const named = await confirmIds(chosen, batch); + if (named === null) { + process.stdout.write("[mba] cancelled\n"); + return; + } + if (named.length === 0) return; + + let doMove = move; + if (!doMove && !batch && process.stdin.isTTY) { + const prompt = + named.length === 1 ? "remove source after adopt?" : "remove source files after adopt?"; + const answer = await askYesNoInteractive(prompt); + if (answer === null) { + process.stdout.write("[mba] cancelled\n"); + return; + } + doMove = answer; + } + + const results: AdoptResult[] = []; + let failed = 0; + for (const row of named) { + if (!json) process.stdout.write(`[mba] adopting ${row.id}...\n`); + try { + const result = await adoptOne(baseUrl, row.file.path, row.id, row.family, doMove); + results.push(result); + if (!json) { + process.stdout.write(`${adoptedLine(result.id, result.family)}\n`); + process.stdout.write(`${dim(` ${shortenHome(result.modelDir)}`)}\n`); + if (result.moved) process.stdout.write(`${dim(" source removed")}\n`); + } + } catch (err) { + failed += 1; + process.stderr.write( + `[mba] error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + if (json) { + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + } + if (failed > 0) process.exit(1); +} + +async function scanRoots(roots: readonly string[]): Promise { + const seen = new Set(); + const out: FoundGguf[] = []; + for (const root of roots) { + for (const file of scanGgufs(root)) { + if (seen.has(file.path)) continue; + seen.add(file.path); + out.push(file); + } + } + return out; +} + +async function cmdMigrateModels( + baseUrl: string, + args: readonly string[], + assumeNo: boolean, + json: boolean, + move: boolean, +): Promise { + let dir = args[0]; + if (args.length > 1) fail(MODELS_USAGE); + if (!dir && process.stdin.isTTY && !json && !assumeNo) { + dir = (await askTextInteractive("source", join(homedir(), "models"))) ?? undefined; + if (!dir) { + process.stdout.write("[mba] cancelled\n"); + return; + } + } + if (!dir) fail(MODELS_USAGE); + + const root = absPath(dir); + const skip = await hubSkip(baseUrl); + const found = dropCataloged(await scanRoots([root]), skip); + await adoptPicked(baseUrl, found, assumeNo, json, move); +} + +async function cmdMigrateFind( + baseUrl: string, + queryArg: string, + from: string | undefined, + assumeNo: boolean, + json: boolean, + move: boolean, +): Promise { + let query = queryArg.trim(); + if (!query && process.stdin.isTTY && !json && !assumeNo) { + query = (await askTextInteractive("find", "")) ?? ""; + if (!query.trim()) { + process.stdout.write("[mba] cancelled\n"); + return; + } + } + if (!query.trim()) fail(FIND_USAGE); + + const roots = from ? [absPath(from)] : defaultFindRoots(); + if (roots.length === 0) { + if (json) { + process.stdout.write("[]\n"); + return; + } + process.stdout.write( + `${brand("migrate")}\n ${dim("no search roots — pass --from or add ~/models")}\n`, + ); + return; + } + + const skip = await hubSkip(baseUrl); + const ranked = dropCataloged(rankGgufs(await scanRoots(roots), query), skip); + await adoptPicked(baseUrl, ranked, assumeNo, json, move); +} + +async function migrateMenu( + baseUrl: string, + assumeNo: boolean, + json: boolean, + move: boolean, +): Promise { + for (;;) { + const pick = await pickLabeledInteractive("migrate", [ + { label: "models", value: "models", preview: [["do", "GGUFs in one folder → hub"]] }, + { label: "find", value: "find", preview: [["do", "fuzzy-find GGUFs → hub"]] }, + ]); + if (pick === null) return; + if (pick === "models") await cmdMigrateModels(baseUrl, [], assumeNo, json, move); + else await cmdMigrateFind(baseUrl, "", undefined, assumeNo, json, move); + } +} + +export async function cmdMigrate( + baseUrl: string, + action: "menu" | "models" | "find", + args: readonly string[], + assumeNo: boolean, + json: boolean, +): Promise { + switch (action) { + case "menu": { + const { move, rest } = parseMigrateFlags(args, false, GROUP_USAGE); + if (rest.length > 0) fail(GROUP_USAGE); + if (json) { + process.stdout.write(`${JSON.stringify({ actions: ["models", "find"] }, null, 2)}\n`); + return; + } + if (process.stdin.isTTY) { + await migrateMenu(baseUrl, assumeNo, json, move); + return; + } + fail(GROUP_USAGE); + return; + } + case "models": { + const { move, rest } = parseMigrateFlags(args, false, MODELS_USAGE); + await cmdMigrateModels(baseUrl, rest, assumeNo, json, move); + return; + } + case "find": { + const { move, from, rest } = parseMigrateFlags(args, true, FIND_USAGE); + await cmdMigrateFind(baseUrl, rest.join(" "), from, assumeNo, json, move); + return; + } + } } diff --git a/packages/core/src/cli/models.ts b/packages/core/src/cli/models.ts index 86a1b29..9f6087d 100644 --- a/packages/core/src/cli/models.ts +++ b/packages/core/src/cli/models.ts @@ -17,6 +17,7 @@ import { type ModelEntry, } from "./interactive.js"; import { listHfGgufs, searchHfModels } from "../model/hf-resolve.js"; +import { deriveModelId } from "../model/model-id.js"; import { handleRestartPrompt, parseValue } from "./restart.js"; import type { ModelConfig, SetResult } from "./types.js"; import { KNOWN_HARNESSES } from "../mba/envelope.js"; @@ -268,15 +269,6 @@ export async function cmdModelsPull( } } -function deriveModelId(repo: string): string { - return ( - repo - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "model" - ); -} - function deriveFamily(owner: string): string { return ( owner diff --git a/packages/core/src/cli/route.test.ts b/packages/core/src/cli/route.test.ts index c97d821..37eb3b7 100644 --- a/packages/core/src/cli/route.test.ts +++ b/packages/core/src/cli/route.test.ts @@ -103,7 +103,6 @@ describe("parseMbaArgv", () => { args: ["logs", "s1", "--follow"], }); expect(parseMbaArgv(["machine"]).route).toEqual({ cmd: "machine", args: [] }); - expect(parseMbaArgv(["migrate-paths"]).route).toEqual({ cmd: "migrate-paths" }); expect(parseMbaArgv(["estimate-memory", "m.gguf"]).route).toEqual({ cmd: "estimate-memory", args: ["m.gguf"], @@ -147,4 +146,35 @@ describe("parseMbaArgv", () => { it("flags unknown top-level commands", () => { expect(parseMbaArgv(["context-gc"]).route).toEqual({ cmd: "unknown", command: "context-gc" }); }); + + it("parses migrate as a group", () => { + expect(parseMbaArgv(["migrate"]).route).toEqual({ cmd: "migrate", action: "menu", args: [] }); + expect(parseMbaArgv(["migrate", "models", "/tmp/weights"]).route).toEqual({ + cmd: "migrate", + action: "models", + args: ["/tmp/weights"], + }); + expect(parseMbaArgv(["migrate", "find", "--from", "/tmp", "deepseek"]).route).toEqual({ + cmd: "migrate", + action: "find", + args: ["--from", "/tmp", "deepseek"], + }); + expect(parseMbaArgv(["migrate", "--help"]).route).toEqual({ cmd: "help", topic: "migrate" }); + expect(parseMbaArgv(["help", "migrate"]).route).toEqual({ cmd: "help", topic: "migrate" }); + expect(parseMbaArgv(["migrate", "models", "--json", "~/models"]).json).toBe(true); + expect(parseMbaArgv(["migrate", "--move"]).route).toEqual({ + cmd: "migrate", + action: "menu", + args: ["--move"], + }); + expect(parseMbaArgv(["migrate", "--move", "models", "/tmp/weights"]).route).toEqual({ + cmd: "migrate", + action: "models", + args: ["--move", "/tmp/weights"], + }); + expect(parseMbaArgv(["migrate", "oops"]).route).toEqual({ + cmd: "unknown", + command: "migrate oops", + }); + }); }); diff --git a/packages/core/src/cli/route.ts b/packages/core/src/cli/route.ts index f0348f9..f436c96 100644 --- a/packages/core/src/cli/route.ts +++ b/packages/core/src/cli/route.ts @@ -19,17 +19,19 @@ export type ModelsAction = | "stage" | "connect"; +export type MigrateAction = "menu" | "models" | "find"; + export type MbaRoute = | { readonly cmd: "home" } | { readonly cmd: "help"; readonly topic: HelpTopic } | { readonly cmd: "status" } | { readonly cmd: "completion"; readonly args: readonly string[] } - | { readonly cmd: "migrate-paths" } | { readonly cmd: "estimate-memory"; readonly args: readonly string[] } | { readonly cmd: "machine"; readonly args: readonly string[] } | { readonly cmd: "servers"; readonly args: readonly string[] } | { readonly cmd: "clients"; readonly args: readonly string[] } | { readonly cmd: "models"; readonly action: ModelsAction; readonly args: readonly string[] } + | { readonly cmd: "migrate"; readonly action: MigrateAction; readonly args: readonly string[] } | { readonly cmd: "unknown"; readonly command: string }; export interface ParsedMba { @@ -50,9 +52,24 @@ function helpTopic(name: string | undefined): HelpTopic { if (name === "machine" || name === "machine-overlay") return "machine"; if (name === "clients" || name === "client" || name === "c") return "clients"; if (name === "status") return "status"; + if (name === "migrate") return "migrate"; return "overview"; } +function parseMigrate(rest: readonly string[]): MbaRoute { + if (wantsHelp(rest)) return { cmd: "help", topic: "migrate" }; + const kept = rest.filter((a) => a !== "--help" && a !== "-h"); + const i = kept.findIndex((a) => a === "models" || a === "find"); + if (i < 0) { + const leftover = kept.filter((a) => a !== "--move"); + if (leftover.length > 0) return { cmd: "unknown", command: `migrate ${leftover[0]}` }; + return { cmd: "migrate", action: "menu", args: kept }; + } + const sub = kept[i] as MigrateAction; + const tail = [...kept.slice(0, i), ...kept.slice(i + 1)]; + return { cmd: "migrate", action: sub, args: tail }; +} + function parseModels(rest: readonly string[]): MbaRoute { if (wantsHelp(rest)) return { cmd: "help", topic: "models" }; const [sub, ...tail] = rest.filter((a) => a !== "--help" && a !== "-h"); @@ -86,7 +103,6 @@ export function parseMbaArgv(argv: readonly string[]): ParsedMba { if (command === "completion") { return { assumeNo, json, route: { cmd: "completion", args: rest } }; } - if (command === "migrate-paths") return { assumeNo, json, route: { cmd: "migrate-paths" } }; if (command === "estimate-memory") { return { assumeNo, json, route: { cmd: "estimate-memory", args: rest } }; } @@ -120,6 +136,9 @@ export function parseMbaArgv(argv: readonly string[]): ParsedMba { if (command === "connect") { return { assumeNo, json, route: { cmd: "models", action: "connect", args: rest } }; } + if (command === "migrate") { + return { assumeNo, json, route: parseMigrate(rest) }; + } return { assumeNo, json, route: { cmd: "unknown", command } }; } diff --git a/packages/core/src/cli/style.test.ts b/packages/core/src/cli/style.test.ts index 849d906..10b1b80 100644 --- a/packages/core/src/cli/style.test.ts +++ b/packages/core/src/cli/style.test.ts @@ -11,6 +11,7 @@ import { visibleLen, bootedLine, pulledLine, + adoptedLine, BOLD, CYAN, colorEnabled, @@ -61,6 +62,13 @@ describe("cli style", () => { ); }); + it("renders a one-line ADOPTED result", () => { + process.env.NO_COLOR = "1"; + expect(adoptedLine("local_r1", "deepseek")).toBe( + " ADOPTED local_r1 deepseek next mba s boot local_r1", + ); + }); + it("renders a done box with a title", () => { process.env.NO_COLOR = "1"; const box = doneBox("BOOTED", [ diff --git a/packages/core/src/cli/style.ts b/packages/core/src/cli/style.ts index 219da51..a2d59fd 100644 --- a/packages/core/src/cli/style.ts +++ b/packages/core/src/cli/style.ts @@ -36,11 +36,14 @@ export function rule(width = 40): string { return paint(` ${"─".repeat(width)}`, DIM); } -/** Selected: cyan ▸ + green ● + bold name. Idle: dim ○ + dim name. */ -export function option(selected: boolean, name: string, desc = ""): string { +/** Selected: cyan ▸ + green ● + bold name. Idle: dim ○ + dim name. + * `marked` splits cursor from selection (multi-select). When omitted, + * marked follows `selected` — same as a single-select row. */ +export function option(selected: boolean, name: string, desc = "", marked?: boolean): string { + const isMarked = marked ?? selected; const arrow = selected ? paint("▸ ", CYAN) : " "; - const dot = selected ? paint("●", GRN) : paint("○", DIM); - const label = selected ? paint(name, BOLD) : paint(name, DIM); + const dot = isMarked ? paint("●", GRN) : paint("○", DIM); + const label = selected || isMarked ? paint(name, BOLD) : paint(name, DIM); const extra = desc ? paint(` ${desc}`, DIM) : ""; return ` ${arrow}${dot} ${label}${extra}`; } @@ -117,6 +120,12 @@ export function pulledLine(id: string, family?: string): string { return ` ${paint("PULLED", BOLD, GRN)} ${paint(id, BOLD)}${familyBit} ${dim("next")} mba s boot ${id}`; } +/** One-line adopt result. Path prints on the next line. */ +export function adoptedLine(id: string, family?: string): string { + const familyBit = family ? ` ${dim(family)}` : ""; + return ` ${paint("ADOPTED", BOLD, GRN)} ${paint(id, BOLD)}${familyBit} ${dim("next")} mba s boot ${id}`; +} + export const HIDE_CURSOR = "\x1b[?25l"; export const SHOW_CURSOR = "\x1b[?25h"; diff --git a/packages/core/src/model/gguf-scan.test.ts b/packages/core/src/model/gguf-scan.test.ts new file mode 100644 index 0000000..a94fb2a --- /dev/null +++ b/packages/core/src/model/gguf-scan.test.ts @@ -0,0 +1,124 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + catalogSkipFromModelFiles, + defaultFindRoots, + fuzzyScore, + isAlreadyInHub, + rankGgufs, + scanGgufs, + type FoundGguf, +} from "./gguf-scan.js"; +import { deriveModelId } from "./model-id.js"; + +function freshDir(): string { + return mkdtempSync(join(tmpdir(), "mba-gguf-scan-")); +} + +function stub(path: string, fileName: string): FoundGguf { + return { path, fileName, bytes: 1 }; +} + +describe("deriveModelId", () => { + it("slugs a GGUF filename", () => { + expect(deriveModelId("DeepSeek-R1-Q4_K_M.gguf")).toBe("deepseek-r1-q4-k-m"); + expect(deriveModelId("owner/Repo_Name")).toBe("owner-repo-name"); + expect(deriveModelId("...")).toBe("model"); + }); +}); + +describe("fuzzyScore", () => { + it("matches subsequences and rejects misses", () => { + expect(fuzzyScore("DeepSeek-R1-Q4_K_M.gguf", "dpsk")).toBeGreaterThan(0); + expect(fuzzyScore("DeepSeek-R1-Q4_K_M.gguf", "zzz")).toBe(0); + expect(fuzzyScore("anything", "")).toBe(1); + }); + + it("ranks a tighter filename above a weak parent hit", () => { + const files: FoundGguf[] = [ + stub("/models/other/noise.gguf", "noise.gguf"), + stub("/models/cache/DeepSeek-R1-Q4_K_M.gguf", "DeepSeek-R1-Q4_K_M.gguf"), + ]; + expect(rankGgufs(files, "deepseek").map((f) => f.fileName)).toEqual([ + "DeepSeek-R1-Q4_K_M.gguf", + ]); + }); +}); + +describe("scanGgufs", () => { + it("finds nested GGUFs and skips node_modules", () => { + const root = freshDir(); + try { + mkdirSync(join(root, "nested"), { recursive: true }); + mkdirSync(join(root, "node_modules"), { recursive: true }); + writeFileSync(join(root, "a.gguf"), "gguf"); + writeFileSync(join(root, "nested", "b.gguf"), "gguf"); + writeFileSync(join(root, "node_modules", "skip.gguf"), "gguf"); + writeFileSync(join(root, "readme.txt"), "no"); + const found = scanGgufs(root).map((f) => f.fileName).sort(); + expect(found).toEqual(["a.gguf", "b.gguf"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("respects maxDepth", () => { + const root = freshDir(); + try { + const deep = join(root, "d1", "d2", "d3"); + mkdirSync(deep, { recursive: true }); + writeFileSync(join(deep, "far.gguf"), "gguf"); + expect(scanGgufs(root, { maxDepth: 2 }).map((f) => f.fileName)).toEqual([]); + expect(scanGgufs(root, { maxDepth: 3 }).map((f) => f.fileName)).toEqual(["far.gguf"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("follows a symlink file but not a symlink directory", () => { + const root = freshDir(); + try { + const realDir = join(root, "real"); + mkdirSync(realDir); + writeFileSync(join(realDir, "inside.gguf"), "gguf"); + writeFileSync(join(root, "target.gguf"), "gguf"); + symlinkSync(join(root, "target.gguf"), join(root, "link.gguf")); + symlinkSync(realDir, join(root, "linked-dir")); + const names = scanGgufs(root).map((f) => f.fileName).sort(); + expect(names).toContain("target.gguf"); + expect(names).toContain("link.gguf"); + expect(names).toContain("inside.gguf"); + expect(names.filter((n) => n === "inside.gguf")).toHaveLength(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips cataloged paths and hardlinked inodes", () => { + const root = freshDir(); + try { + const a = join(root, "a.gguf"); + const b = join(root, "b.gguf"); + writeFileSync(a, "gguf"); + writeFileSync(b, "other"); + const skip = catalogSkipFromModelFiles([a]); + expect(isAlreadyInHub(a, skip)).toBe(true); + expect(isAlreadyInHub(b, skip)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("omits missing default find roots", () => { + const home = freshDir(); + try { + expect(defaultFindRoots(home)).toEqual([]); + mkdirSync(join(home, "models")); + expect(defaultFindRoots(home)).toEqual([join(home, "models")]); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/model/gguf-scan.ts b/packages/core/src/model/gguf-scan.ts new file mode 100644 index 0000000..ad028d8 --- /dev/null +++ b/packages/core/src/model/gguf-scan.ts @@ -0,0 +1,221 @@ +/** + * Bounded walk + fuzzy rank for local GGUF files (mba migrate). + * + * The CLI lists; the daemon writes. Scan never copies and never hashes + * whole files — listing 18 GiB weights by sha256 would stall the picker. + * Already-in-hub skip is path or inode (a hardlinked adopt shares inode). + */ + +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { homedir as osHomedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { parseGgufMetadata } from "./gguf-metadata.js"; +import { quantFromFilename } from "./gguf-profile.js"; + +/** Depth from the scan root. HuggingFace hub layout is ~5; keep a little headroom. */ +export const GGUF_WALK_DEPTH = 8; + +const SKIP_DIR_NAMES = new Set([ + "node_modules", + ".git", + ".svn", + ".hg", + ".npm", + ".nvm", + ".pnpm-store", + ".pnpm", + ".yarn", + "__pycache__", + ".venv", + "venv", + ".Trash", + "Trash", +]); + +export interface FoundGguf { + readonly path: string; + readonly fileName: string; + readonly bytes: number; + readonly quant?: string; + readonly ggufName?: string; +} + +export interface CatalogSkip { + readonly paths: ReadonlySet; + readonly inodes: ReadonlySet; +} + +function inodeKey(path: string): string | undefined { + try { + const st = statSync(path); + return `${st.dev}:${st.ino}`; + } catch { + return undefined; + } +} + +function realOrAbs(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function ggufGeneralName(path: string): string | undefined { + try { + const meta = parseGgufMetadata(path); + const n = meta.fields["general.name"]; + return typeof n === "string" && n.length > 0 ? n : undefined; + } catch { + return undefined; + } +} + +/** + * Paths and inodes already in the adapter catalog (`GET /models` `modelFile`). + * A later adopt that hardlinked into the hub matches on inode. + */ +export function catalogSkipFromModelFiles( + modelFiles: readonly (string | undefined)[], +): CatalogSkip { + const paths = new Set(); + const inodes = new Set(); + for (const file of modelFiles) { + if (!file || file.length === 0) continue; + paths.add(realOrAbs(file)); + const key = inodeKey(file); + if (key) inodes.add(key); + } + return { paths, inodes }; +} + +export function isAlreadyInHub(path: string, skip: CatalogSkip): boolean { + if (skip.paths.has(realOrAbs(path))) return true; + const key = inodeKey(path); + return key !== undefined && skip.inodes.has(key); +} + +/** Default find roots: HF hub cache and ~/models. Missing dirs are omitted. */ +export function defaultFindRoots(homedir = osHomedir()): string[] { + return [join(homedir, ".cache", "huggingface", "hub"), join(homedir, "models")].filter((dir) => { + try { + return statSync(dir).isDirectory(); + } catch { + return false; + } + }); +} + +function pushGguf(out: FoundGguf[], path: string): void { + let bytes = 0; + try { + const st = statSync(path); + if (!st.isFile()) return; + bytes = st.size; + } catch { + return; + } + const fileName = basename(path); + out.push({ + path: resolve(path), + fileName, + bytes, + quant: quantFromFilename(fileName), + ggufName: ggufGeneralName(path), + }); +} + +/** + * Walk `root` for `*.gguf`. Follows a symlinked root and symlink files; + * does not recurse into symlink directories. Skips noisy trees. + */ +export function scanGgufs( + root: string, + opts?: { maxDepth?: number }, +): FoundGguf[] { + const maxDepth = opts?.maxDepth ?? GGUF_WALK_DEPTH; + const out: FoundGguf[] = []; + const seen = new Set(); + let start: string; + try { + start = realpathSync(root); + } catch { + return []; + } + const stack: Array<{ dir: string; depth: number }> = [{ dir: start, depth: 0 }]; + while (stack.length > 0) { + const { dir, depth } = stack.pop()!; + if (depth > maxDepth) continue; + const key = inodeKey(dir); + if (key !== undefined) { + if (seen.has(key)) continue; + seen.add(key); + } + let entries; + try { + if (!statSync(dir).isDirectory()) continue; + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const ent of entries) { + const child = join(dir, ent.name); + if (ent.isSymbolicLink()) { + if (ent.name.toLowerCase().endsWith(".gguf")) pushGguf(out, child); + continue; + } + if (ent.isDirectory()) { + if (SKIP_DIR_NAMES.has(ent.name)) continue; + stack.push({ dir: child, depth: depth + 1 }); + continue; + } + if (ent.isFile() && ent.name.toLowerCase().endsWith(".gguf")) { + pushGguf(out, child); + } + } + } + return out; +} + +/** + * Subsequence score. 0 = no match. Higher is better. + * Empty query matches everything with score 1. + */ +export function fuzzyScore(haystack: string, query: string): number { + if (query.length === 0) return 1; + const h = haystack.toLowerCase(); + const q = query.toLowerCase(); + let hi = 0; + let score = 0; + let consecutive = 0; + let first = -1; + for (let qi = 0; qi < q.length; qi++) { + const found = h.indexOf(q.charAt(qi), hi); + if (found < 0) return 0; + if (first < 0) first = found; + if (found === hi) consecutive += 1; + else consecutive = 1; + score += consecutive * 8; + if (found === 0 || /[^a-z0-9]/.test(h.charAt(found - 1))) score += 4; + hi = found + 1; + } + return score * 1000 - first * 2 - h.length; +} + +export function rankGgufs(files: readonly FoundGguf[], query: string): FoundGguf[] { + const q = query.trim(); + const scored = files.map((file) => { + const parent = basename(dirname(file.path)); + const s = Math.max( + fuzzyScore(file.fileName, q), + fuzzyScore(parent, q), + file.ggufName ? fuzzyScore(file.ggufName, q) : 0, + ); + return { file, s }; + }); + return scored + .filter((row) => row.s > 0) + .sort((a, b) => b.s - a.s || a.file.fileName.localeCompare(b.file.fileName)) + .map((row) => row.file); +} diff --git a/packages/core/src/model/model-id.ts b/packages/core/src/model/model-id.ts new file mode 100644 index 0000000..823d059 --- /dev/null +++ b/packages/core/src/model/model-id.ts @@ -0,0 +1,13 @@ +/** + * Slug a string into a store-safe model id (letters, digits, hyphen). + * Used by HuggingFace search (repo name) and migrate (GGUF filename). + */ +export function deriveModelId(raw: string): string { + return ( + raw + .toLowerCase() + .replace(/\.gguf$/i, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "model" + ); +} diff --git a/packages/core/src/model/model-pull.test.ts b/packages/core/src/model/model-pull.test.ts index f86feec..1bf721b 100644 --- a/packages/core/src/model/model-pull.test.ts +++ b/packages/core/src/model/model-pull.test.ts @@ -6,7 +6,15 @@ import type { AddressInfo } from "node:net"; import type { Server } from "node:http"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import YAML from "yaml"; -import { pullModel, PullValidationError, sha256OfFile, type PullModelOptions } from "./model-pull.js"; +import { + adoptLocalGguf, + AdoptSourceError, + PullConflictError, + PullValidationError, + pullModel, + sha256OfFile, + type PullModelOptions, +} from "./model-pull.js"; /** * Build a minimal valid GGUF v3 buffer with one string kv pair, so the @@ -527,3 +535,150 @@ describe("sha256OfFile", () => { } }); }); + +describe("adoptLocalGguf", () => { + it("hardlinks into the store and scaffolds the same house as pull", async () => { + const store = freshStore(); + const srcDir = freshStore(); + try { + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + const result = await adoptLocalGguf({ + sourcePath: source, + id: "local-model", + storeRoot: store, + }); + expect(result.id).toBe("local-model"); + expect(result.family).toBe("local-model"); + expect(result.sha256).toBe(SHA256); + expect(["hardlink", "copy"]).toContain(result.placed); + expect(result.familyCreated).toBe(true); + expect(result.moved).toBe(false); + + const dest = join(store, "local-model", "local-model", "weights.gguf"); + expect(readFileSync(dest)).toEqual(GGUF); + expect(readFileSync(source)).toEqual(GGUF); + if (result.placed === "hardlink") { + expect(statSync(source).ino).toBe(statSync(dest).ino); + } + + const yaml = YAML.parse( + readFileSync(join(store, "local-model", "local-model", "local-model.yaml"), "utf8"), + ) as { identity: { model: { file: string } } }; + expect(yaml.identity.model.file).toBe("./weights.gguf"); + expect(existsSync(join(store, "local-model", "family.yaml"))).toBe(true); + } finally { + rmSync(store, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("finishes the house when the GGUF is already at dest", async () => { + const store = freshStore(); + try { + const modelDir = join(store, "already", "already"); + mkdirSync(modelDir, { recursive: true }); + const dest = join(modelDir, "weights.gguf"); + writeFileSync(dest, GGUF); + const result = await adoptLocalGguf({ + sourcePath: dest, + id: "already", + storeRoot: store, + }); + expect(result.placed).toBe("inplace"); + expect(result.moved).toBe(false); + expect(existsSync(join(modelDir, "already.yaml"))).toBe(true); + } finally { + rmSync(store, { recursive: true, force: true }); + } + }); + + it("unlinks the source after a successful adopt when move is set", async () => { + const store = freshStore(); + const srcDir = freshStore(); + try { + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + const result = await adoptLocalGguf({ + sourcePath: source, + id: "moved-model", + storeRoot: store, + move: true, + }); + expect(result.moved).toBe(true); + expect(existsSync(source)).toBe(false); + const dest = join(store, "moved-model", "moved-model", "weights.gguf"); + expect(readFileSync(dest)).toEqual(GGUF); + } finally { + rmSync(store, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("does not unlink when move is set but the source is already dest", async () => { + const store = freshStore(); + try { + const modelDir = join(store, "keep", "keep"); + mkdirSync(modelDir, { recursive: true }); + const dest = join(modelDir, "weights.gguf"); + writeFileSync(dest, GGUF); + const result = await adoptLocalGguf({ + sourcePath: dest, + id: "keep", + storeRoot: store, + move: true, + }); + expect(result.placed).toBe("inplace"); + expect(result.moved).toBe(false); + expect(readFileSync(dest)).toEqual(GGUF); + } finally { + rmSync(store, { recursive: true, force: true }); + } + }); + + it("conflicts when the model folder is already adopted", async () => { + const store = freshStore(); + const srcDir = freshStore(); + try { + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + await adoptLocalGguf({ sourcePath: source, id: "dup", storeRoot: store }); + await expect( + adoptLocalGguf({ sourcePath: source, id: "dup", storeRoot: store }), + ).rejects.toBeInstanceOf(PullConflictError); + } finally { + rmSync(store, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("404s when the source file is missing", async () => { + const store = freshStore(); + try { + await expect( + adoptLocalGguf({ + sourcePath: join(store, "missing.gguf"), + id: "gone", + storeRoot: store, + }), + ).rejects.toBeInstanceOf(AdoptSourceError); + } finally { + rmSync(store, { recursive: true, force: true }); + } + }); + + it("rejects an unsafe id", async () => { + const store = freshStore(); + const srcDir = freshStore(); + try { + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + await expect( + adoptLocalGguf({ sourcePath: source, id: "../escape", storeRoot: store }), + ).rejects.toBeInstanceOf(PullValidationError); + } finally { + rmSync(store, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/model/model-pull.ts b/packages/core/src/model/model-pull.ts index 7529551..c4172ed 100644 --- a/packages/core/src/model/model-pull.ts +++ b/packages/core/src/model/model-pull.ts @@ -29,17 +29,21 @@ import { createHash, type Hash } from "node:crypto"; import { + copyFileSync, createReadStream, createWriteStream, existsSync, + linkSync, mkdirSync, readdirSync, + realpathSync, renameSync, rmSync, statSync, + unlinkSync, writeFileSync, } from "node:fs"; -import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { pipeline } from "node:stream"; import { mkdir, writeFile } from "node:fs/promises"; import { slotSavePath } from "../mba/server-lifecycle.js"; @@ -168,6 +172,8 @@ export class PullValidationError extends Error {} export class PullConflictError extends Error {} /** Downloaded content does not match the expected digest → HTTP 422. */ export class PullVerifyError extends Error {} +/** Source path missing or not a file → HTTP 404. */ +export class AdoptSourceError extends Error {} /** Family and model folder names — one store segment, no `..` or separators. */ const STORE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -467,3 +473,178 @@ export async function pullModel(opts: PullModelOptions): Promise//.gguf`, hashes, + * parses the header, and writes empty scaffolds + draft YAML. If the + * source is already the dest file and YAML is missing, this is finish-house. + * `move` unlinks the source only after that house is written, and never + * when source and dest are the same file. + */ +export async function adoptLocalGguf(opts: AdoptLocalOptions): Promise { + const { id } = opts; + if (!id || id.length === 0) throw new PullValidationError("adopt requires id"); + const family = opts.family && opts.family.length > 0 ? opts.family : id; + assertSafeStoreSegment("id", id); + assertSafeStoreSegment("family", family); + + if (!opts.sourcePath || opts.sourcePath.length === 0) { + throw new PullValidationError("adopt requires path"); + } + const source = resolve(opts.sourcePath); + const srcStat = statSync(source, { throwIfNoEntry: false }); + if (!srcStat || !srcStat.isFile()) { + throw new AdoptSourceError(`source GGUF not found: ${source}`); + } + + const storeRoot = resolveStoreRoot(opts.storeRoot); + const familyDir = join(storeRoot, family); + const modelDir = join(familyDir, id); + const fileName = basename(source); + if (fileName === "." || fileName === ".." || fileName.includes("/") || fileName.includes("\\")) { + throw new PullValidationError("source path must end in a file name inside the model folder"); + } + const dest = join(modelDir, fileName); + assertInsideStore(storeRoot, familyDir); + assertInsideStore(storeRoot, modelDir); + assertInsideStore(storeRoot, dest); + + const dirPlan = planModelDir(modelDir, fileName, id); + if (dirPlan === "conflict") { + throw new PullConflictError( + `model folder already exists: ${modelDir} — remove it first to re-adopt`, + ); + } + if (dirPlan === "resume") { + throw new PullConflictError( + `model folder has an unfinished download: ${modelDir} — remove the .partial or finish the pull first`, + ); + } + + mkdirSync(modelDir, { recursive: true }); + let placed: AdoptPlacement; + try { + placed = placeWeights(source, dest); + } catch (err) { + if (err instanceof PullConflictError) throw err; + throw err; + } + + const sha256 = await sha256OfFile(dest); + const adapterPath = join(modelDir, `${id}.yaml`); + let familyCreated: boolean; + try { + const scaffold = writeEmptyScaffolds(modelDir, familyDir, family, dest); + const meta = parseGgufMetadata(dest); + const profile = deriveGgufProfile(meta, fileName, sha256); + const ggufName = + typeof meta.fields["general.name"] === "string" + ? (meta.fields["general.name"] as string) + : undefined; + familyCreated = await scaffold; + if (!existsSync(adapterPath)) { + writeFileSync( + adapterPath, + draftAdapterYaml({ id, family, fileName, sha256, profile, ggufName }), + ); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + `weights at ${dest}; scaffold failed: ${detail} — re-run the same adopt to finish the house`, + ); + } + + let moved = false; + if (opts.move && placed !== "inplace" && realOrAbs(source) !== realOrAbs(dest)) { + try { + unlinkSync(source); + moved = true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + moved = true; + } else { + const detail = err instanceof Error ? err.message : String(err); + throw new Error( + `adopted into ${modelDir}; could not remove source ${source}: ${detail}`, + ); + } + } + } + return { + id, + family, + sha256, + modelDir, + adapterPath, + familyCreated, + placed, + moved, + }; +} diff --git a/packages/core/src/service/paths.test.ts b/packages/core/src/service/paths.test.ts index 85775de..cd81bfe 100644 --- a/packages/core/src/service/paths.test.ts +++ b/packages/core/src/service/paths.test.ts @@ -1,16 +1,12 @@ -import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { defaultStateDir, defaultModelStoreRoot, - legacyStateDir, - legacyModelStoreRoot, ensureDir, normalizePlatform, - planMigration, - executeMigration, type PathContext, } from "./paths.js"; @@ -119,15 +115,6 @@ describe("defaultModelStoreRoot", () => { }); }); -describe("legacy locations (migration source only)", () => { - it("state: ~/.mba", () => { - expect(legacyStateDir(ctx())).toBe("/home/user/.mba"); - }); - it("store: ~/models/adapters", () => { - expect(legacyModelStoreRoot(ctx())).toBe("/home/user/models/adapters"); - }); -}); - describe("ensureDir", () => { let root: string; beforeEach(() => { @@ -148,111 +135,3 @@ describe("ensureDir", () => { expect(() => ensureDir(target)).not.toThrow(); }); }); - -describe("planMigration (pure decision table)", () => { - it("moves when source exists and destination is absent", () => { - expect(planMigration(true, false, true, "/old", "/new")).toEqual({ - status: "moved", - from: "/old", - to: "/new", - }); - }); - it("moves when source exists and destination is present but empty", () => { - expect(planMigration(true, true, true, "/old", "/new")).toEqual({ - status: "moved", - from: "/old", - to: "/new", - }); - }); - it("skips when the source is missing (fresh install / already migrated)", () => { - expect(planMigration(false, false, true, "/old", "/new")).toEqual({ - status: "skipped-missing-source", - from: "/old", - to: "/new", - }); - }); - it("refuses when the destination exists and is non-empty", () => { - expect(planMigration(true, true, false, "/old", "/new")).toEqual({ - status: "skipped-destination-exists", - from: "/old", - to: "/new", - }); - }); -}); - -describe("executeMigration (real filesystem)", () => { - let root: string; - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "mba-migrate-")); - }); - afterEach(() => { - rmSync(root, { recursive: true, force: true }); - }); - - /** Probe helpers mirroring what the CLI does before calling executeMigration. */ - function probe(dir: string): { exists: boolean; empty: boolean } { - const exists = existsSync(dir); - const empty = exists && readdirSync(dir).length === 0; - return { exists, empty }; - } - - it("moves a populated source into an absent destination", () => { - const from = join(root, "old"); - const to = join(root, "new"); - ensureDir(from); - writeFileSync(join(from, "a.txt"), "hello"); - const p = probe(from); - const d = probe(to); - const result = executeMigration(from, to, p.exists, d.exists, d.empty); - expect(result.status).toBe("moved"); - expect(existsSync(join(to, "a.txt"))).toBe(true); - expect(existsSync(from)).toBe(false); - }); - - it("creates the destination's missing parents (nested new home)", () => { - const from = join(root, "old"); - // Destination sits under parents that do not exist yet — the real store - // case (…/mba/model_hub/adapters on a fresh install). - const to = join(root, "a", "b", "c", "new"); - ensureDir(from); - writeFileSync(join(from, "a.txt"), "hello"); - const p = probe(from); - const d = probe(to); - const result = executeMigration(from, to, p.exists, d.exists, d.empty); - expect(result.status).toBe("moved"); - expect(existsSync(join(to, "a.txt"))).toBe(true); - expect(existsSync(from)).toBe(false); - }); - - it("is idempotent — a second run finds no source and skips", () => { - const from = join(root, "old"); - const to = join(root, "new"); - ensureDir(from); - writeFileSync(join(from, "a.txt"), "hello"); - executeMigration(from, to, probe(from).exists, probe(to).exists, probe(to).empty); - // Second run: source is gone now. - const p = probe(from); - const d = probe(to); - const result = executeMigration(from, to, p.exists, d.exists, d.empty); - expect(result.status).toBe("skipped-missing-source"); - // Data still intact at the destination. - expect(existsSync(join(to, "a.txt"))).toBe(true); - }); - - it("refuses to overwrite a non-empty destination", () => { - const from = join(root, "old"); - const to = join(root, "new"); - ensureDir(from); - writeFileSync(join(from, "a.txt"), "from"); - ensureDir(to); - writeFileSync(join(to, "b.txt"), "to"); - const p = probe(from); - const d = probe(to); - const result = executeMigration(from, to, p.exists, d.exists, d.empty); - expect(result.status).toBe("skipped-destination-exists"); - // Source untouched, destination untouched. - expect(existsSync(join(from, "a.txt"))).toBe(true); - expect(existsSync(join(to, "b.txt"))).toBe(true); - expect(existsSync(join(to, "a.txt"))).toBe(false); - }); -}); diff --git a/packages/core/src/service/paths.ts b/packages/core/src/service/paths.ts index eb8eff3..465529c 100644 --- a/packages/core/src/service/paths.ts +++ b/packages/core/src/service/paths.ts @@ -24,9 +24,9 @@ * the real process. No globals read at module scope. */ -import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { mkdirSync } from "node:fs"; import { homedir as osHomedir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; /** The subset of `process.platform` values MBA cares about. */ export type Platform = "darwin" | "win32" | "linux" | "other"; @@ -102,18 +102,6 @@ export function defaultModelStoreRoot(ctx: PathContext = livePathContext()): str } } -/** - * The legacy (pre-Phase-4) locations, kept ONLY so `migrate-paths` can find - * what to move. Do not use these as live defaults. - */ -export function legacyStateDir(ctx: PathContext = livePathContext()): string { - return join(ctx.homedir, ".mba"); -} - -export function legacyModelStoreRoot(ctx: PathContext = livePathContext()): string { - return join(ctx.homedir, "models", "adapters"); -} - /** * Windows %APPDATA% (roaming). Throws if unset — there is no sane fallback on * Windows, and a missing %APPDATA% means the environment is broken. @@ -147,80 +135,3 @@ function requireLocalAppData(env: NodeJS.ProcessEnv): string { export function ensureDir(dir: string): void { mkdirSync(dir, { recursive: true }); } - -// --- One-time migration (legacy → OS-aware) --------------------------------- -// -// `migrate-paths` moves MBA's two homes from the old hardcoded locations to -// the OS-aware ones. It is explicit (the user runs it), idempotent (a second -// run finds nothing to move), and conservative (it never overwrites a -// non-empty destination). The pure core below is filesystem-free so the -// decision logic is testable; the CLI wires it to the real paths. - -/** The outcome of migrating a single home (state or store). */ -export type MigrationOutcome = - | { readonly status: "moved"; readonly from: string; readonly to: string } - | { readonly status: "skipped-missing-source"; readonly from: string; readonly to: string } - | { readonly status: "skipped-destination-exists"; readonly from: string; readonly to: string }; - -/** - * Decide what to do for ONE home, given the current state of the two - * directories. Pure — no filesystem access — so the decision table is - * directly testable. - * - * - source missing → nothing to move (fresh install, or already - * migrated). Skip. - * - destination exists non-empty → REFUSE. We will not clobber data the - * user may have placed there. Skip with a - * distinct status so the caller can warn. - * - otherwise (source present, destination absent or empty) → move. - */ -export function planMigration( - sourceExists: boolean, - destinationExists: boolean, - destinationEmpty: boolean, - from: string, - to: string, -): MigrationOutcome { - if (!sourceExists) { - return { status: "skipped-missing-source", from, to }; - } - if (destinationExists && !destinationEmpty) { - return { status: "skipped-destination-exists", from, to }; - } - return { status: "moved", from, to }; -} - -/** - * Perform the actual move for one home, applying the plan from - * {@link planMigration}. `sourceExists` / `destinationExists` / - * `destinationEmpty` are read by the caller (which owns the filesystem - * probes) and passed in, keeping this function's contract explicit. - * - * The move is a `renameSync` when source and destination are on the same - * filesystem (the common case — both under the user's home), which is a - * metadata op, not a copy. If they are on different devices, `renameSync` - * throws EXDEV and we fall back to a recursive copy + delete. - */ -export function executeMigration( - from: string, - to: string, - sourceExists: boolean, - destinationExists: boolean, - destinationEmpty: boolean, -): MigrationOutcome { - const plan = planMigration(sourceExists, destinationExists, destinationEmpty, from, to); - if (plan.status !== "moved") return plan; - // renameSync does not create the destination's parents. The store's new home - // (…/mba/model_hub/adapters) sits under parents that a fresh install has not - // made yet, so create them first. - mkdirSync(dirname(to), { recursive: true }); - try { - renameSync(from, to); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; - // Cross-device: copy the tree, then remove the source. - cpSync(from, to, { recursive: true }); - rmSync(from, { recursive: true, force: true }); - } - return { status: "moved", from, to }; -} diff --git a/packages/core/src/service/server-model-adopt.test.ts b/packages/core/src/service/server-model-adopt.test.ts new file mode 100644 index 0000000..2a0eb0a --- /dev/null +++ b/packages/core/src/service/server-model-adopt.test.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { createMbaServiceApp } from "./server.js"; +import { defaultStorePaths } from "./config-store.js"; + +function makeGgufBuffer(arch: string): Buffer { + const key = Buffer.from(arch, "utf8"); + const val = Buffer.from("qwen35", "utf8"); + const buf = Buffer.alloc( + 4 + 4 + 8 + 8 + 8 + key.length + 4 + 8 + val.length + 8, + ); + let o = 0; + buf.write("GGUF", o, "ascii"); + o += 4; + buf.writeUInt32LE(3, o); + o += 4; + buf.writeBigUInt64LE(1n, o); + o += 8; + buf.writeBigUInt64LE(1n, o); + o += 8; + buf.writeBigUInt64LE(BigInt(key.length), o); + o += 8; + key.copy(buf, o); + o += key.length; + buf.writeUInt32LE(8, o); + o += 4; + buf.writeBigUInt64LE(BigInt(val.length), o); + o += 8; + val.copy(buf, o); + o += val.length; + buf.writeBigUInt64LE(0n, o); + return buf; +} + +const GGUF = makeGgufBuffer("general.architecture"); +const SHA256 = createHash("sha256").update(GGUF).digest("hex"); + +describe("POST /models/adopt", () => { + it("copies a local GGUF into the adapter dir and returns the house", async () => { + const paths = defaultStorePaths(mkdtempSync(join(tmpdir(), "mba-svc-adopt-"))); + const adapterDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-adapters-")); + const srcDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-src-")); + const app = createMbaServiceApp({ paths, adapterDir }); + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + + try { + const res = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: source, id: "local-model" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; sha256: string; placed: string; moved: boolean }; + expect(body.id).toBe("local-model"); + expect(body.sha256).toBe(SHA256); + expect(["hardlink", "copy"]).toContain(body.placed); + expect(body.moved).toBe(false); + expect(existsSync(join(adapterDir, "local-model", "local-model", "weights.gguf"))).toBe(true); + expect(existsSync(source)).toBe(true); + } finally { + rmSync(paths.baseDir, { recursive: true, force: true }); + rmSync(adapterDir, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("returns 400 without path/id, 404 when the source is missing, 409 on conflict", async () => { + const paths = defaultStorePaths(mkdtempSync(join(tmpdir(), "mba-svc-adopt-"))); + const adapterDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-adapters-")); + const srcDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-src-")); + const app = createMbaServiceApp({ paths, adapterDir }); + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + + try { + const bad = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: source }), + }); + expect(bad.status).toBe(400); + + const missing = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: join(srcDir, "nope.gguf"), id: "gone" }), + }); + expect(missing.status).toBe(404); + + const first = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: source, id: "dup" }), + }); + expect(first.status).toBe(200); + const second = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: source, id: "dup" }), + }); + expect(second.status).toBe(409); + } finally { + rmSync(paths.baseDir, { recursive: true, force: true }); + rmSync(adapterDir, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("unlinks the source when move is true", async () => { + const paths = defaultStorePaths(mkdtempSync(join(tmpdir(), "mba-svc-adopt-"))); + const adapterDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-adapters-")); + const srcDir = mkdtempSync(join(tmpdir(), "mba-svc-adopt-src-")); + const app = createMbaServiceApp({ paths, adapterDir }); + const source = join(srcDir, "weights.gguf"); + writeFileSync(source, GGUF); + + try { + const res = await app.request("/models/adopt", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: source, id: "moved-model", move: true }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { moved: boolean; placed: string }; + expect(body.moved).toBe(true); + expect(existsSync(source)).toBe(false); + expect(existsSync(join(adapterDir, "moved-model", "moved-model", "weights.gguf"))).toBe(true); + } finally { + rmSync(paths.baseDir, { recursive: true, force: true }); + rmSync(adapterDir, { recursive: true, force: true }); + rmSync(srcDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/service/server.ts b/packages/core/src/service/server.ts index 8126a6e..83428ff 100644 --- a/packages/core/src/service/server.ts +++ b/packages/core/src/service/server.ts @@ -37,6 +37,11 @@ * sha256 mismatch, download failure) arrives as an `error` event — * the HTTP status is 200 for the whole stream; the CLI renders the * message and exits non-zero. + * POST /models/adopt → AdoptModelResult + * Body: { path, id, family?, move? }. Copy (or hardlink) a local GGUF + * into the model store and finish the same house as pull. `move` + * unlinks the source after success (not when source is already dest). + * 400 bad id/path, 404 source missing, 409 model folder exists. * GET /models/config?id= → { modelId, files, fields: [{ field, file, current, restartRequired, hint?, machineHint? }] } * POST /models/config → { file, field, before, after, restartRequired, modelLoaded } * Body: { id, file: 'server_setup'|'client', field, value }. The @@ -109,7 +114,13 @@ import { } from "./sessions.js"; import { createModelProxyRoutes } from "./model-proxy.js"; import { reasoningGateForModel } from "./reasoning-gate.js"; -import { pullModel } from "../model/model-pull.js"; +import { + adoptLocalGguf, + AdoptSourceError, + PullConflictError, + PullValidationError, + pullModel, +} from "../model/model-pull.js"; import { listUpstreams, readRegistry, @@ -449,6 +460,46 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { }); }); + app.post("/models/adopt", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "invalid JSON body" }, 400); + } + const input = body as { path?: unknown; id?: unknown; family?: unknown; move?: unknown }; + if ( + !input || + typeof input.path !== "string" || + input.path.length === 0 || + typeof input.id !== "string" || + input.id.length === 0 || + (input.family !== undefined && typeof input.family !== "string") || + (input.move !== undefined && typeof input.move !== "boolean") + ) { + return c.json( + { error: "body requires path, id (strings); family is an optional string; move is an optional boolean" }, + 400, + ); + } + try { + const result = await adoptLocalGguf({ + sourcePath: input.path, + id: input.id, + family: input.family, + move: input.move, + storeRoot: opts.adapterDir, + }); + return c.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof AdoptSourceError) return c.json({ error: message }, 404); + if (err instanceof PullValidationError) return c.json({ error: message }, 400); + if (err instanceof PullConflictError) return c.json({ error: message }, 409); + return c.json({ error: message }, 500); + } + }); + app.get("/models/config", (c) => { const id = c.req.query("id"); if (!id || id.length === 0) {