diff --git a/README.md b/README.md index 8852fe4..3da5738 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,24 @@ must not be edited independently and treated as authoritative. See [Local application](docs/local-application.md) for more detail. For moving development to another machine, see [Agent handoff](docs/agent-handoff.md). +## Command-line interface + +The local CLI exposes the application's validated domain operations without a +browser. It emits JSON for automation and uses the same SQLite database as the +web application. + +```bash +npm run cli -- recipe list --query chipotle +npm run cli -- recipe get RECIPE_ID --target-yield 4000 --target-yield-unit gram +npm run cli -- recipe save --input recipe.json --dry-run +npm run cli -- recipe save --input recipe.json +``` + +Run `npm run cli -- help` for the command index or +`npm run cli -- cli commands --compact` for a machine-readable command schema. +See the [CLI guide](docs/cli-guide.md) for payloads, safety controls, exit codes, +database selection, and backup workflows. + ## Development Run the editor: diff --git a/docs/cli-guide.md b/docs/cli-guide.md new file mode 100644 index 0000000..a63cae6 --- /dev/null +++ b/docs/cli-guide.md @@ -0,0 +1,119 @@ +# Formulation CLI + +The Formulation CLI is the supported local automation interface for recipes, +ingredients, purchases, prices, units, equipment, prep actions, inventory, +recipe books, archives, and database backups. It calls the same domain layer as +the web application instead of editing SQLite or generated files directly. + +## Running it + +From the repository: + +```bash +npm run cli -- help +npm run cli -- recipe list --query chicken --limit 10 +``` + +After `npm link`, the same commands can be invoked as `formulation ...`. +`formulation help recipe save` shows command-specific arguments. For agents and +other programs, `formulation cli commands --compact` returns the complete +command registry as JSON. + +The default database is `var/recipe-book.sqlite`. Select another database with +`--database path/to/file.sqlite` or the `FORMULATION_DATABASE` environment +variable. An explicitly selected database takes precedence. + +## Input and output + +Simple fields can be options. Hyphens in option names map to underscores in +the domain payload and numbers, booleans, `null`, arrays, and objects are +coerced from JSON-compatible values. + +```bash +formulation unit convert 2 kilogram gram +formulation recipe get RECIPE_ID --target-yield 4 --target-yield-unit kilogram +``` + +Nested writes should use a JSON object from a file or standard input: + +```bash +formulation ingredient save --input ingredient.json +formulation recipe save RECIPE_ID --input recipe.json +Get-Content recipe.json | formulation recipe save --input - +``` + +Command-line values override fields supplied by `--input`. Successful commands +emit this stable envelope: + +```json +{ + "success": true, + "command": "recipe get", + "dry_run": false, + "data": {} +} +``` + +Use `--compact` for one-line JSON. `--output file.json` writes the result to a +file and prints a small confirmation envelope. Database exports are written as +raw backup bundles so they can be passed directly to validate, diff, or import. + +Errors are emitted to standard error: + +```json +{"success":false,"error":{"code":"USAGE_ERROR","message":"..."}} +``` + +Exit codes are `0` for success, `2` for invalid commands or input, `3` for a +domain or validation rejection, and `4` for filesystem, database, or other +system failures. + +## Safety + +Add `--dry-run` to any mutation. The CLI copies the selected database, performs +the complete operation on the copy, returns the result, and deletes the copy. +The original database and generated site projection remain unchanged. + +```bash +formulation recipe save RECIPE_ID --input recipe.json --dry-run +``` + +Include `expected_save_version` when updating a recipe. A stale version is +rejected, preventing an automation run from silently overwriting a newer edit. + +Permanent archive deletion and database import require `--yes`. They can be +tested without confirmation using `--dry-run`. + +## Command families + +```text +recipe list, get, save, archive, quality, cost, nutrition +ingredient list, get, save, archive +purchase list, get, save, price +unit list, convert +equipment list +prep-action list +inventory list, get, create, update +book list, get, save +archive list, restore, purge +db stats, export, validate, import, diff +cli commands +``` + +IDs are positional where practical. Save commands accept the complete domain +object through `--input`; their required top-level fields are listed by +command-specific help. + +## Backup workflow + +```bash +formulation db export --output data/backup.json +formulation db validate --input data/backup.json +formulation db diff --input data/backup.json +formulation db import --input data/backup.json --dry-run +formulation db import --input data/backup.json --yes +``` + +Import defaults to transactional replacement. Pass `--mode merge` to merge the +bundle. A missing database selected with `--database` is created and migrated +before import. Invalid bundles are rejected before their contents are applied. diff --git a/package-lock.json b/package-lock.json index 7bd4cd9..f1f61a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "yaml": "2.9.0", "zod": "^4.4.3" }, + "bin": { + "formulation": "scripts/formulation.mjs" + }, "devDependencies": { "@astrojs/check": "0.9.4", "@types/node": "^22.10.0", diff --git a/src/cli/commands.ts b/src/cli/commands.ts index fa6495d..d3b1f2b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -6,24 +6,24 @@ export type CommandDefinition = { required?: string[]; mutation?: boolean; destructive?: boolean; - custom?: "export" | "import" | "validate" | "diff"; + custom?: "commands" | "export" | "import" | "validate" | "diff"; }; export const commands: Record = { "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 save": { summary: "Create or update a recipe from JSON", method: "saveRecipe", positionals: ["id"], options: ["title", "summary", "yield_quantity", "yield_unit_id", "yield_basis", "expected_save_version", "categories", "tags", "components", "steps", "notes", "serving_size"], required: ["title", "yield_quantity", "yield_unit_id", "components", "steps"], 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 save": { summary: "Create or update an ingredient from JSON", method: "saveIngredient", positionals: ["id"], options: ["name", "description", "categories", "tags", "aliases", "density"], required: ["name"], 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 save": { summary: "Create or update a purchase item from JSON", method: "savePurchaseItem", positionals: ["id"], options: ["ingredient_id", "name", "brand", "supplier_id", "supplier_sku", "status", "package_quantity", "package_unit_id", "units_per_case", "usable_yield_factor", "initial_price"], required: ["ingredient_id", "name", "package_quantity", "package_unit_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"] }, @@ -32,13 +32,14 @@ export const commands: Record = { "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 }, + "inventory update": { summary: "Update an inventory count from JSON", method: "updateInventoryCount", positionals: ["count_id"], options: ["status", "notes", "items"], 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 }, + "book save": { summary: "Create or update a recipe book from JSON", method: "saveRecipeBook", positionals: ["id"], options: ["name", "description", "recipe_ids"], required: ["name"], 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 }, + "archive restore": { summary: "Restore archived entities from JSON", method: "restoreArchived", options: ["items"], required: ["items"], mutation: true }, + "archive purge": { summary: "Permanently delete archived entities", method: "permanentlyDeleteArchived", options: ["items"], required: ["items"], mutation: true, destructive: true }, + "cli commands": { summary: "Describe every command as JSON", custom: "commands" }, "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" }, diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts new file mode 100644 index 0000000..7bc3a69 --- /dev/null +++ b/src/cli/run.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DatabaseSync } from "node:sqlite"; +import { runCli, EXIT_DOMAIN, EXIT_USAGE } from "./run"; + +describe("Formulation CLI", () => { + let directory: string; + let databasePath: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "formulation-cli-test-")); + databasePath = path.join(directory, "formulation.sqlite"); + const database = new DatabaseSync(databasePath); + const migrations = fs.readdirSync(path.resolve("migrations")).filter((file) => file.endsWith(".sql")).sort(); + for (const migration of migrations) database.exec(fs.readFileSync(path.resolve("migrations", migration), "utf8")); + database.exec(` + INSERT INTO units (id, name, symbol, dimension, system, base_unit_id, factor) VALUES + ('gram', 'Gram', 'g', 'mass', 'metric', NULL, 1), + ('kilogram', 'Kilogram', 'kg', 'mass', 'metric', 'gram', 1000); + INSERT INTO ingredients (id, schema_version, name, status) VALUES + ('flour', 2, 'Flour', 'active'); + INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id) VALUES + ('cake', 2, 1, 'Simple Cake', 500, 'gram'); + INSERT INTO recipe_components (recipe_id, id, position, name) VALUES + ('cake', 'main', 1, 'Main'); + INSERT INTO recipe_items (recipe_id, component_id, id, position, ingredient_id, quantity, unit_id) VALUES + ('cake', 'main', 'flour', 1, 'flour', 500, 'gram'); + INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES + ('cake', 'mix', 1, 'Mix.'); + `); + database.close(); + }); + + afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + + async function invoke(args: string[], input = "") { + let stdout = ""; + let stderr = ""; + const code = await runCli([...args, "--database", databasePath], { + stdout: (text) => { stdout += text; }, + stderr: (text) => { stderr += text; }, + readStdin: () => input, + }); + return { + code, + stdout, + stderr, + output: stdout.startsWith("{") ? JSON.parse(stdout) : undefined, + error: stderr ? JSON.parse(stderr) : undefined, + }; + } + + it("provides human and machine-readable command discovery", async () => { + const help = await runCli(["help"], { stdout: (text) => expect(text).toContain("recipe save") }); + const result = await invoke(["cli", "commands"]); + + expect(help).toBe(0); + expect(result.code).toBe(0); + expect(result.output.data["db import"].destructive).toBe(true); + }); + + it("uses the selected database and coerces command options", async () => { + const list = await invoke(["recipe", "list", "--query", "simple", "--limit", "1"]); + const conversion = await invoke(["unit", "convert", "2", "kilogram", "gram"]); + + expect(list.output.data).toHaveLength(1); + expect(list.output.data[0].id).toBe("cake"); + expect(conversion.output.data.to.quantity).toBe(2000); + }); + + it("rejects unknown options and missing required input", async () => { + const unknown = await invoke(["recipe", "list", "--typo", "value"]); + const missing = await invoke(["ingredient", "save"]); + + expect(unknown.code).toBe(EXIT_USAGE); + expect(unknown.error.error.code).toBe("USAGE_ERROR"); + expect(missing.code).toBe(EXIT_USAGE); + expect(missing.error.error.message).toContain("name"); + }); + + it("runs mutations against a disposable clone with --dry-run", async () => { + const result = await invoke(["ingredient", "save", "--input", "-", "--dry-run"], JSON.stringify({ name: "Salt" })); + const database = new DatabaseSync(databasePath, { readOnly: true }); + const saved = database.prepare("SELECT id FROM ingredients WHERE name = 'Salt'").get(); + database.close(); + + expect(result.code).toBe(0); + expect(result.output.dry_run).toBe(true); + expect(result.output.data.ingredient_id).toBe("salt"); + expect(saved).toBeUndefined(); + }); + + it("enforces optimistic recipe versions", async () => { + const input = JSON.stringify({ + title: "Changed Cake", + yield_quantity: 500, + yield_unit_id: "gram", + expected_save_version: 0, + components: [{ id: "main", name: "Main", items: [{ id: "flour", ingredient_id: "flour", quantity: 500, unit_id: "gram" }] }], + steps: [{ id: "mix", instruction: "Mix." }], + }); + const result = await invoke(["recipe", "save", "cake", "--input", "-"], input); + + expect(result.code).toBe(EXIT_DOMAIN); + expect(result.error.error.message).toMatch(/version|changed/i); + }); + + it("exports, validates, and compares backup bundles", async () => { + const backupPath = path.join(directory, "backup.json"); + const exported = await invoke(["db", "export", "--output", backupPath]); + const validated = await invoke(["db", "validate", "--input", backupPath]); + const diff = await invoke(["db", "diff", "--input", backupPath]); + + expect(exported.code).toBe(0); + expect(fs.existsSync(backupPath)).toBe(true); + expect(validated.output.data.valid).toBe(true); + expect(diff.output.data.equal).toBe(true); + }); + + it("requires explicit confirmation for destructive operations", async () => { + const result = await invoke(["archive", "purge", "--input", "-"], JSON.stringify({ items: [{ type: "ingredient", id: "flour" }] })); + + expect(result.code).toBe(EXIT_USAGE); + expect(result.error.error.message).toContain("--yes"); + }); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts index e1a10e5..1c096c9 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -4,7 +4,7 @@ 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 { databasePath as defaultDatabasePath } 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"; @@ -76,7 +76,9 @@ function optionTokens(tokens: string[]) { } function readJson(location: string, io: Io) { - const source = location === "-" ? io.readStdin() : fs.readFileSync(path.resolve(location), "utf8"); + let source: string; + try { source = location === "-" ? io.readStdin() : fs.readFileSync(path.resolve(location), "utf8"); } + catch (cause) { throw new CliError(`Unable to read input: ${cause instanceof Error ? cause.message : String(cause)}`, EXIT_SYSTEM, "INPUT_ERROR"); } try { const value = JSON.parse(source); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object"); @@ -87,12 +89,19 @@ function readJson(location: string, io: Io) { } function parse(argv: string[], io: Io): Parsed { - if (!argv.length || argv.includes("--help") || argv[0] === "help") throw new CliError(helpText(), 0, "HELP"); + if (!argv.length) throw new CliError(helpText(), 0, "HELP"); + if (argv[0] === "help" && argv.length >= 3) { + const command = `${argv[1]} ${argv[2]}`; + if (!commands[command]) throw new CliError(`Unknown command: ${command}`); + throw new CliError(commandHelp(command, commands[command]), 0, "HELP"); + } + if (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}`); + if (argv.includes("--help")) throw new CliError(commandHelp(command, definition), 0, "HELP"); const parsed = optionTokens(argv.slice(2)); const databaseValue = parsed.values.database; const databasePath = path.resolve(String(databaseValue ?? process.env.FORMULATION_DATABASE ?? defaultDatabasePath)); @@ -105,7 +114,7 @@ function parse(argv: string[], io: Io): Parsed { 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("_", "-")}`); + if (!commandOptions.has(name)) throw new CliError(`Unknown option for ${command}: --${name.replaceAll("_", "-")}`); values[name] = value; } parsed.positionals.forEach((value, index) => { @@ -164,6 +173,7 @@ function diffBundles(current: any, incoming: any) { async function execute(parsed: Parsed, io: Io) { const { definition } = parsed; + if (definition.custom === "commands") return commands; if (definition.custom === "validate") { if (!parsed.inputPath) throw new CliError("db validate requires --input ."); const result = validateBackupBundle(readJson(parsed.inputPath, io) as any); @@ -173,6 +183,9 @@ async function execute(parsed: Parsed, io: Io) { let activePath = parsed.databasePath; let temporaryDirectory: string | undefined; + const projectionPath = path.resolve(process.cwd(), "generated/site-projection.json"); + const preserveProjection = definition.mutation && (parsed.dryRun || parsed.databasePath !== defaultDatabasePath); + const projectionBefore = preserveProjection && fs.existsSync(projectionPath) ? fs.readFileSync(projectionPath) : 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-")); @@ -204,8 +217,10 @@ async function execute(parsed: Parsed, io: Io) { database.close(); if (temporaryDirectory) { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); - const source = openDatabase(parsed.databasePath, true); - try { refreshSiteProjection(source); } finally { source.close(); } + } + if (preserveProjection) { + if (projectionBefore) fs.writeFileSync(projectionPath, projectionBefore); + else fs.rmSync(projectionPath, { force: true }); } } } @@ -227,6 +242,13 @@ export function helpText() { return `Formulation CLI ${CLI_VERSION}\n\nUsage:\n formulation [arguments] [options]\n\nCommands:\n${rows}\n\nGlobal options:\n --database SQLite database (default: var/recipe-book.sqlite)\n --input Merge a JSON object from a file or stdin\n --output 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 --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`; } +function commandHelp(command: string, definition: CommandDefinition) { + const positionals = (definition.positionals ?? []).map((name) => `<${name}>`).join(" "); + const options = (definition.options ?? []).map((name) => ` --${name.replaceAll("_", "-")}`).join("\n"); + const required = definition.required?.length ? `\nRequired values: ${definition.required.join(", ")}` : ""; + return `${command}\n\n${definition.summary}\n\nUsage:\n formulation ${command}${positionals ? ` ${positionals}` : ""} [options]${required}${options ? `\n\nCommand options:\n${options}` : ""}\n\nComplex and nested values may be supplied with --input .`; +} + export async function runCli(argv: string[], overrides: Partial = {}) { const io: Io = { stdout: overrides.stdout ?? ((text) => process.stdout.write(text)),