test(cli): harden automation interface

This commit is contained in:
nicholas committed 2026-08-31 09:48:23 -05:00
1 parent 01e8dac359
commit 76b7cf1f80
6 files changed
+305 -14

No files matched your search

+18
View File
@@ -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:
+119
View File
@@ -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.
+3
View File
@@ -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",
+9 -8
View File
@@ -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<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 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<string, CommandDefinition> = {
"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" },
+128
View File
@@ -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");
});
});
+28 -6
View File
@@ -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 <backup.json>.");
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 <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`;
}
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 <file|->.`;
}
export async function runCli(argv: string[], overrides: Partial<Io> = {}) {
const io: Io = {
stdout: overrides.stdout ?? ((text) => process.stdout.write(text)),