test(cli): harden automation interface
This commit is contained in:
1 parent
01e8dac359
commit
76b7cf1f80
6 files changed
+305
-14
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
Generated
+3
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user