feat(cli): add local domain command surface

This commit is contained in:
nicholas committed 2026-08-31 09:38:38 -05:00
1 parent f50b182c17
commit 01e8dac359
6 files changed
+328 -14

No files matched your search

+4
View File
@@ -2,6 +2,9 @@
"name": "formulation",
"private": true,
"type": "module",
"bin": {
"formulation": "scripts/formulation.mjs"
},
"engines": {
"node": ">=22.12.0",
"npm": ">=9.6.5"
@@ -19,6 +22,7 @@
"start:readonly": "FORMULATION_READ_ONLY=true HOST=127.0.0.1 PORT=4399 node dist/app/server/entry.mjs",
"preview:app": "astro preview --config astro.app.config.mjs --port 4322",
"test": "vitest run",
"cli": "node scripts/formulation.mjs",
"db:backup": "node scripts/backup.mjs export",
"db:restore": "node scripts/backup.mjs import",
"db:reset": "node scripts/backup.mjs import data/recipe-book-backup.json",
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runCli } from "../src/cli/run.ts";
process.exitCode = await runCli(process.argv.slice(2));
+49
View File
@@ -0,0 +1,49 @@
export type CommandDefinition = {
summary: string;
method?: string;
positionals?: string[];
options?: string[];
required?: string[];
mutation?: boolean;
destructive?: boolean;
custom?: "export" | "import" | "validate" | "diff";
};
export const commands: Record<string, CommandDefinition> = {
"recipe list": { summary: "List or search recipes", method: "searchRecipes", options: ["query", "category", "tag", "limit"] },
"recipe get": { summary: "Get a recipe, optionally scaled", method: "getRecipe", positionals: ["id"], options: ["scale_factor", "target_yield", "target_yield_unit"], required: ["id"] },
"recipe save": { summary: "Create or update a recipe from JSON", method: "saveRecipe", positionals: ["id"], mutation: true },
"recipe archive": { summary: "Archive a recipe", method: "deleteRecipe", positionals: ["id"], required: ["id"], mutation: true },
"recipe quality": { summary: "Audit recipe data quality", method: "auditRecipeQuality" },
"recipe cost": { summary: "Calculate recipe cost", method: "calculateRecipeCost", positionals: ["recipe_id"], options: ["currency"], required: ["recipe_id"] },
"recipe nutrition": { summary: "Calculate recipe nutrition", method: "calculateRecipeNutrition", positionals: ["recipe_id"], options: ["serving_size_g"], required: ["recipe_id"] },
"ingredient list": { summary: "List or search ingredients", method: "searchIngredients", options: ["query", "category", "missing_cost", "limit"] },
"ingredient get": { summary: "Get ingredient details", method: "getIngredient", positionals: ["id"], required: ["id"] },
"ingredient save": { summary: "Create or update an ingredient from JSON", method: "saveIngredient", positionals: ["id"], mutation: true },
"ingredient archive": { summary: "Archive an ingredient", method: "deleteIngredient", positionals: ["id"], required: ["id"], mutation: true },
"purchase list": { summary: "List purchase items", method: "listPurchaseItems", options: ["ingredient_id", "status", "limit"] },
"purchase get": { summary: "Get a purchase item", method: "getPurchaseItem", positionals: ["id"], required: ["id"] },
"purchase save": { summary: "Create or update a purchase item from JSON", method: "savePurchaseItem", positionals: ["id"], mutation: true },
"purchase price": { summary: "Record a price observation", method: "recordPriceObservation", positionals: ["purchase_item_id"], options: ["amount", "currency", "effective_at"], required: ["purchase_item_id", "amount"], mutation: true },
"unit list": { summary: "List units", method: "listUnits", options: ["dimension", "system"] },
"unit convert": { summary: "Convert a quantity", method: "convertUnits", positionals: ["quantity", "from_unit", "to_unit"], options: ["ingredient_id"], required: ["quantity", "from_unit", "to_unit"] },
"equipment list": { summary: "List equipment", method: "listEquipment", options: ["category"] },
"prep-action list": { summary: "List preparation actions", method: "listPrepActions" },
"inventory list": { summary: "List inventory counts", method: "listInventoryCounts", options: ["status"] },
"inventory get": { summary: "Get an inventory count", method: "getInventoryCount", positionals: ["id"], required: ["id"] },
"inventory create": { summary: "Create an inventory count from JSON", method: "createInventoryCountSession", options: ["title", "counted_at", "notes", "prepopulate"], required: ["title", "counted_at"], mutation: true },
"inventory update": { summary: "Update an inventory count from JSON", method: "updateInventoryCount", positionals: ["count_id"], required: ["count_id"], mutation: true },
"book list": { summary: "List recipe books", method: "listRecipeBooks" },
"book get": { summary: "Get a recipe book", method: "getRecipeBook", positionals: ["id"], required: ["id"] },
"book save": { summary: "Create or update a recipe book from JSON", method: "saveRecipeBook", positionals: ["id"], mutation: true },
"archive list": { summary: "List archived entities", method: "listArchivedItems" },
"archive restore": { summary: "Restore archived entities from JSON", method: "restoreArchived", mutation: true },
"archive purge": { summary: "Permanently delete archived entities", method: "permanentlyDeleteArchived", mutation: true, destructive: true },
"db stats": { summary: "Show database record counts", method: "getDatabaseStats" },
"db export": { summary: "Export a validated backup bundle", custom: "export" },
"db validate": { summary: "Validate a backup bundle", custom: "validate" },
"db import": { summary: "Import a backup bundle", custom: "import", options: ["mode"], mutation: true, destructive: true },
"db diff": { summary: "Compare the database with a backup bundle", custom: "diff" },
};
export const globalOptions = new Set(["database", "input", "output", "compact", "dry_run", "yes", "help"]);
+252
View File
@@ -0,0 +1,252 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { backup, DatabaseSync } from "node:sqlite";
import { commands, globalOptions, type CommandDefinition } from "./commands.ts";
import { createDomainTools } from "../lib/domain-tools.ts";
import { databasePath as defaultDatabasePath, refreshSiteProjection } from "../lib/database.ts";
import { exportDatabase } from "../lib/backup/export-database.ts";
import { importDatabase } from "../lib/backup/import-database.ts";
import { validateBackupBundle } from "../lib/backup/validate-backup.ts";
export const CLI_VERSION = "1.0.0";
export const EXIT_USAGE = 2;
export const EXIT_DOMAIN = 3;
export const EXIT_SYSTEM = 4;
type Io = {
stdout: (text: string) => void;
stderr: (text: string) => void;
readStdin: () => string;
};
type Parsed = {
command: string;
definition: CommandDefinition;
values: Record<string, unknown>;
compact: boolean;
dryRun: boolean;
yes: boolean;
databasePath: string;
inputPath?: string;
outputPath?: string;
};
class CliError extends Error {
readonly exitCode: number;
readonly code: string;
constructor(message: string, exitCode = EXIT_USAGE, code = "USAGE_ERROR") {
super(message);
this.exitCode = exitCode;
this.code = code;
}
}
const normalizeOption = (name: string) => name.replace(/^--/, "").replaceAll("-", "_");
function coerce(value: string): unknown {
if (value === "true") return true;
if (value === "false") return false;
if (value === "null") return null;
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(value)) return Number(value);
if (/^[\[{]/.test(value)) {
try { return JSON.parse(value); } catch {}
}
return value;
}
function optionTokens(tokens: string[]) {
const values: Record<string, unknown> = {};
const positionals: string[] = [];
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (!token.startsWith("--")) {
positionals.push(token);
continue;
}
const [rawName, inline] = token.split("=", 2);
const name = normalizeOption(rawName);
if (!name) throw new CliError(`Invalid option: ${token}`);
if (inline !== undefined) values[name] = coerce(inline);
else if (tokens[index + 1] && !tokens[index + 1].startsWith("--")) values[name] = coerce(tokens[++index]);
else values[name] = true;
}
return { values, positionals };
}
function readJson(location: string, io: Io) {
const source = location === "-" ? io.readStdin() : fs.readFileSync(path.resolve(location), "utf8");
try {
const value = JSON.parse(source);
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object");
return value as Record<string, unknown>;
} catch (cause) {
throw new CliError(`Invalid JSON input: ${cause instanceof Error ? cause.message : String(cause)}`);
}
}
function parse(argv: string[], io: Io): Parsed {
if (!argv.length || argv.includes("--help") || argv[0] === "help") throw new CliError(helpText(), 0, "HELP");
if (argv[0] === "--version" || argv[0] === "version") throw new CliError(CLI_VERSION, 0, "VERSION");
if (argv.length < 2) throw new CliError("Expected a resource and action. Run `formulation help` for commands.");
const command = `${argv[0]} ${argv[1]}`;
const definition = commands[command];
if (!definition) throw new CliError(`Unknown command: ${command}`);
const parsed = optionTokens(argv.slice(2));
const databaseValue = parsed.values.database;
const databasePath = path.resolve(String(databaseValue ?? process.env.FORMULATION_DATABASE ?? defaultDatabasePath));
const inputPath = parsed.values.input == null ? undefined : String(parsed.values.input);
const outputPath = parsed.values.output == null ? undefined : String(parsed.values.output);
const compact = parsed.values.compact === true;
const dryRun = parsed.values.dry_run === true;
const yes = parsed.values.yes === true;
let values = inputPath ? readJson(inputPath, io) : {};
const commandOptions = new Set(definition.options ?? []);
for (const [name, value] of Object.entries(parsed.values)) {
if (globalOptions.has(name)) continue;
if (definition.options && !commandOptions.has(name)) throw new CliError(`Unknown option for ${command}: --${name.replaceAll("_", "-")}`);
values[name] = value;
}
parsed.positionals.forEach((value, index) => {
const name = definition.positionals?.[index];
if (!name) throw new CliError(`Unexpected argument: ${value}`);
values[name] = coerce(value);
});
for (const required of definition.required ?? []) {
if (values[required] == null || values[required] === "") throw new CliError(`Missing required value: ${required}`);
}
if (dryRun && !definition.mutation) throw new CliError(`--dry-run is only valid for mutation commands.`);
if (definition.destructive && !dryRun && !yes) throw new CliError(`${command} requires --yes (or use --dry-run).`);
return { command, definition, values, compact, dryRun, yes, databasePath, inputPath, outputPath };
}
function migrate(database: DatabaseSync) {
database.exec("PRAGMA foreign_keys = ON");
const directory = path.resolve(process.cwd(), "migrations");
for (const file of fs.readdirSync(directory).filter((name) => name.endsWith(".sql")).sort()) {
database.exec(fs.readFileSync(path.join(directory, file), "utf8"));
}
}
function openDatabase(databasePath: string, readOnly: boolean, create = false) {
if (!fs.existsSync(databasePath) && !create) throw new CliError(`Database not found: ${databasePath}`, EXIT_SYSTEM, "DATABASE_NOT_FOUND");
if (create) fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const database = new DatabaseSync(databasePath, { readOnly });
database.exec("PRAGMA foreign_keys = ON");
if (create && !database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes'").get()) migrate(database);
return database;
}
function stableKey(row: Record<string, unknown>, index: number) {
const fields = ["id", "recipe_id", "ingredient_id", "purchase_item_id", "count_id", "collection_id", "action_id", "name"];
const values = fields.filter((field) => row[field] != null).map((field) => `${field}:${row[field]}`);
return values.length ? values.join("|") : String(index);
}
function diffBundles(current: any, incoming: any) {
const tables: Record<string, unknown> = {};
const names = new Set([...Object.keys(current.data ?? {}), ...Object.keys(incoming.data ?? {})]);
let equal = true;
for (const name of [...names].sort()) {
const left = (current.data?.[name] ?? []) as Record<string, unknown>[];
const right = (incoming.data?.[name] ?? []) as Record<string, unknown>[];
const leftMap = new Map(left.map((row, index) => [stableKey(row, index), JSON.stringify(row)]));
const rightMap = new Map(right.map((row, index) => [stableKey(row, index), JSON.stringify(row)]));
const added = [...rightMap.keys()].filter((key) => !leftMap.has(key));
const removed = [...leftMap.keys()].filter((key) => !rightMap.has(key));
const changed = [...leftMap.keys()].filter((key) => rightMap.has(key) && leftMap.get(key) !== rightMap.get(key));
if (added.length || removed.length || changed.length) equal = false;
tables[name] = { current_count: left.length, input_count: right.length, added, removed, changed };
}
return { equal, current_summary: current.summary, input_summary: incoming.summary, tables };
}
async function execute(parsed: Parsed, io: Io) {
const { definition } = parsed;
if (definition.custom === "validate") {
if (!parsed.inputPath) throw new CliError("db validate requires --input <backup.json>.");
const result = validateBackupBundle(readJson(parsed.inputPath, io) as any);
if (!result.valid) throw new CliError(`Backup validation failed: ${result.errors.join("; ")}`, EXIT_DOMAIN, "VALIDATION_ERROR");
return result;
}
let activePath = parsed.databasePath;
let temporaryDirectory: string | undefined;
if (parsed.dryRun) {
if (!fs.existsSync(parsed.databasePath)) throw new CliError(`Database not found: ${parsed.databasePath}`, EXIT_SYSTEM, "DATABASE_NOT_FOUND");
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "formulation-cli-"));
activePath = path.join(temporaryDirectory, "dry-run.sqlite");
const source = openDatabase(parsed.databasePath, true);
try { await backup(source, activePath); } finally { source.close(); }
}
const create = definition.custom === "import" && !fs.existsSync(activePath);
const database = openDatabase(activePath, !definition.mutation, create);
try {
if (definition.custom === "export") return exportDatabase(database);
if (definition.custom === "diff") {
if (!parsed.inputPath) throw new CliError("db diff requires --input <backup.json>.");
const incoming = readJson(parsed.inputPath, io);
const validation = validateBackupBundle(incoming as any);
if (!validation.valid) throw new CliError(`Backup validation failed: ${validation.errors.join("; ")}`, EXIT_DOMAIN, "VALIDATION_ERROR");
return diffBundles(exportDatabase(database), incoming);
}
if (definition.custom === "import") {
if (!parsed.inputPath) throw new CliError("db import requires --input <backup.json>.");
return importDatabase(database, readJson(parsed.inputPath, io) as any, { mode: parsed.values.mode === "merge" ? "merge" : "replace", rebuildProjections: !parsed.dryRun });
}
const tools = createDomainTools(() => database) as unknown as Record<string, (values?: Record<string, unknown>) => unknown>;
const method = tools[definition.method!];
if (!method) throw new CliError(`CLI command is not implemented: ${parsed.command}`, EXIT_SYSTEM, "NOT_IMPLEMENTED");
return method(parsed.values);
} finally {
database.close();
if (temporaryDirectory) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
const source = openDatabase(parsed.databasePath, true);
try { refreshSiteProjection(source); } finally { source.close(); }
}
}
}
function writeResult(parsed: Parsed, result: unknown, io: Io) {
const envelope = { success: true, command: parsed.command, dry_run: parsed.dryRun, data: result };
const serialized = JSON.stringify(envelope, null, parsed.compact ? 0 : 2);
if (parsed.outputPath) {
const target = path.resolve(parsed.outputPath);
fs.mkdirSync(path.dirname(target), { recursive: true });
const content = parsed.definition.custom === "export" ? JSON.stringify(result, null, 2) : serialized;
fs.writeFileSync(target, `${content}\n`, "utf8");
io.stdout(`${JSON.stringify({ success: true, command: parsed.command, dry_run: parsed.dryRun, output: target })}\n`);
} else io.stdout(`${serialized}\n`);
}
export function helpText() {
const rows = Object.entries(commands).map(([name, definition]) => ` ${name.padEnd(22)} ${definition.summary}`).join("\n");
return `Formulation CLI ${CLI_VERSION}\n\nUsage:\n formulation <resource> <action> [arguments] [options]\n\nCommands:\n${rows}\n\nGlobal options:\n --database <path> SQLite database (default: var/recipe-book.sqlite)\n --input <file|-> Merge a JSON object from a file or stdin\n --output <file> Write command data to a file\n --dry-run Execute a mutation against a temporary database clone\n --yes Confirm permanent deletion or database replacement\n --compact Emit compact JSON\n --help Show this help\n --version Show the CLI version\n\nExamples:\n formulation recipe list --query chipotle --limit 5\n formulation recipe get <id> --target-yield 4000 --target-yield-unit gram\n formulation recipe save --input recipe.json --dry-run\n formulation db export --output data/recipe-book-backup.json\n formulation db diff --input data/recipe-book-backup.json\n`;
}
export async function runCli(argv: string[], overrides: Partial<Io> = {}) {
const io: Io = {
stdout: overrides.stdout ?? ((text) => process.stdout.write(text)),
stderr: overrides.stderr ?? ((text) => process.stderr.write(text)),
readStdin: overrides.readStdin ?? (() => fs.readFileSync(0, "utf8")),
};
try {
const parsed = parse(argv, io);
const result = await execute(parsed, io);
writeResult(parsed, result, io);
return 0;
} catch (cause) {
if (cause instanceof CliError && cause.exitCode === 0) {
io.stdout(`${cause.message}\n`);
return 0;
}
const exitCode = cause instanceof CliError ? cause.exitCode : cause instanceof Error ? EXIT_DOMAIN : EXIT_SYSTEM;
const code = cause instanceof CliError ? cause.code : exitCode === EXIT_DOMAIN ? "DOMAIN_ERROR" : "SYSTEM_ERROR";
const message = cause instanceof Error ? cause.message : String(cause);
io.stderr(`${JSON.stringify({ success: false, error: { code, message } })}\n`);
return exitCode;
}
}
+4 -3
View File
@@ -7,10 +7,11 @@ import type {
Unit,
SourceMapping,
} from "./types.ts";
import type { DatabaseSync } from "node:sqlite";
import { databaseProjection, openDatabase } from "./database.ts";
export function loadCatalogs() {
const database = openDatabase();
export function loadCatalogs(existingDatabase?: DatabaseSync) {
const database = existingDatabase ?? openDatabase();
if (!database) throw new Error("Database unavailable. Restore from backup with: npm run db:restore -- path/to/backup.json");
try {
const projection = databaseProjection(database) as {
@@ -35,6 +36,6 @@ export function loadCatalogs() {
sourceMappings: map(projection.sourceMappings),
};
} finally {
database.close();
if (!existingDatabase) database.close();
}
}
+15 -11
View File
@@ -26,7 +26,7 @@ import { newRecipeId } from "./recipe-id.ts";
export function createDomainTools(getDb: () => DatabaseSync) {
return {
searchRecipes(args: { query?: string; category?: string; tag?: string; limit?: number }) {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const q = (args.query ?? "").trim().toLowerCase();
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
let results = [...catalogs.recipes.values()] as Recipe[];
@@ -67,7 +67,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
getRecipe(args: { id: string; scale_factor?: number; target_yield?: number; target_yield_unit?: string }) {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const recipe = catalogs.recipes.get(args.id);
if (!recipe) throw new Error(`Recipe not found: ${args.id}`);
@@ -152,6 +152,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
yield_quantity: number;
yield_unit_id: string;
serving_size?: { quantity: number; unit_id: string } | null;
expected_save_version?: number;
yield_basis?: string | null;
categories?: string[];
tags?: string[];
@@ -183,6 +184,9 @@ export function createDomainTools(getDb: () => DatabaseSync) {
let recipeId: string;
const existing = args.id ? (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(args.id) as any) : null;
if (args.id && !existing) throw new Error("Unknown recipe ID. Omit id when creating a recipe.");
if (existing && args.expected_save_version != null && existing.save_version !== args.expected_save_version) {
throw new Error(`Recipe changed: expected save version ${args.expected_save_version}, found ${existing.save_version}.`);
}
const isNew = !existing;
if (isNew) {
@@ -251,7 +255,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
saveRecipeStructure(db, recipeId, structure as any);
if(args.serving_size){
const catalogs=loadCatalogs(),savedRecipe=catalogs.recipes.get(recipeId);
const catalogs=loadCatalogs(db),savedRecipe=catalogs.recipes.get(recipeId);
if(!savedRecipe)throw new Error("Saved recipe could not be reloaded.");
const conversionSubject={schema_version:2 as const,id:recipeId,name:args.title,status:"active",categories:[],measure_conversions:savedRecipe.measure_conversions};
try{
@@ -335,7 +339,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
calculateRecipeCost(args: { recipe_id: string; currency?: string }): CostResult {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const recipe = catalogs.recipes.get(args.recipe_id);
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
@@ -349,7 +353,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
calculateRecipeNutrition(args: { recipe_id: string; serving_size_g?: number }): NutritionResult {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const recipe = catalogs.recipes.get(args.recipe_id);
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
@@ -362,7 +366,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
searchIngredients(args: { query?: string; category?: string; missing_cost?: boolean; limit?: number }) {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const q = (args.query ?? "").trim().toLowerCase();
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
@@ -416,7 +420,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
getIngredient(args: { id: string }) {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const ing = catalogs.ingredients.get(args.id);
if (!ing) throw new Error(`Ingredient not found: ${args.id}`);
@@ -752,7 +756,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
convertUnits(args: { ingredient_id?: string; quantity: number; from_unit: string; to_unit: string }) {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
const units = catalogs.units;
const fromUnit = units.get(args.from_unit);
@@ -792,7 +796,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
listUnits(args?: { dimension?: string; system?: string }): Unit[] {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
let units = [...catalogs.units.values()];
if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension);
if (args?.system) units = units.filter((u) => u.system === args.system);
@@ -800,14 +804,14 @@ export function createDomainTools(getDb: () => DatabaseSync) {
},
listEquipment(args?: { category?: string }): Equipment[] {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
let items = [...catalogs.equipment.values()];
if (args?.category) items = items.filter((e) => e.category === args.category);
return items;
},
listPrepActions(): PrepAction[] {
const catalogs = loadCatalogs();
const catalogs = loadCatalogs(getDb());
return [...catalogs.prepActions.values()];
},