@hesed/recipe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class RecipeImport extends Command {
3
+ static args: {
4
+ path: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static enableJsonFlag: boolean;
8
+ static examples: string[];
9
+ static flags: {
10
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ static summary: string;
14
+ run(): Promise<{
15
+ name: string;
16
+ path: string;
17
+ }>;
18
+ }
@@ -0,0 +1,33 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { cyan } from 'ansis';
3
+ import { loadRecipeFile, recipeExists, saveRecipe } from '../../recipe/store.js';
4
+ export default class RecipeImport extends Command {
5
+ static args = {
6
+ path: Args.string({ description: 'Path to a recipe JSON file to import.', required: true }),
7
+ };
8
+ static description = 'Import a recipe from a JSON file into the recipe store so it can be run by name.';
9
+ static enableJsonFlag = true;
10
+ static examples = [
11
+ '<%= config.bin %> <%= command.id %> ./close-user-tickets.json',
12
+ '<%= config.bin %> <%= command.id %> ./shared-recipe.json --name my-copy',
13
+ ];
14
+ static flags = {
15
+ force: Flags.boolean({ char: 'f', description: 'Overwrite an existing recipe with the same name.' }),
16
+ name: Flags.string({ description: 'Save the imported recipe under a different name.' }),
17
+ };
18
+ static summary = 'Import a recipe from a file.';
19
+ async run() {
20
+ const { args, flags } = await this.parse(RecipeImport);
21
+ const recipe = await loadRecipeFile(args.path);
22
+ if (flags.name)
23
+ recipe.name = flags.name;
24
+ if (!flags.force && (await recipeExists(this.config, recipe.name))) {
25
+ this.error(`Recipe "${recipe.name}" already exists. Use --force to overwrite or --name to rename.`);
26
+ }
27
+ const path = await saveRecipe(this.config, recipe);
28
+ if (!this.jsonEnabled()) {
29
+ this.log(`Imported recipe ${cyan(recipe.name)} to ${path}`);
30
+ }
31
+ return { name: recipe.name, path };
32
+ }
33
+ }
@@ -0,0 +1,9 @@
1
+ import { Command } from '@oclif/core';
2
+ import { Recipe } from '../../recipe/types.js';
3
+ export default class RecipeIndex extends Command {
4
+ static description: string;
5
+ static enableJsonFlag: boolean;
6
+ static examples: string[];
7
+ static summary: string;
8
+ run(): Promise<Recipe[]>;
9
+ }
@@ -0,0 +1,27 @@
1
+ import { Command } from '@oclif/core';
2
+ import { dim } from 'ansis';
3
+ import { listRecipes, recipesDir } from '../../recipe/store.js';
4
+ export default class RecipeIndex extends Command {
5
+ static description = 'List saved recipes.';
6
+ static enableJsonFlag = true;
7
+ static examples = ['<%= config.bin %> <%= command.id %>'];
8
+ static summary = 'List saved recipes.';
9
+ async run() {
10
+ const recipes = await listRecipes(this.config);
11
+ if (!this.jsonEnabled()) {
12
+ if (recipes.length === 0) {
13
+ this.log(`No recipes saved. Create one with "${this.config.bin} recipe create <name>".`);
14
+ this.log(dim(`Recipes are stored in ${recipesDir(this.config)}`));
15
+ }
16
+ else {
17
+ for (const recipe of recipes) {
18
+ const steps = `${recipe.steps.length} step${recipe.steps.length === 1 ? '' : 's'}`;
19
+ this.log(`${recipe.name} ${dim(steps)}`);
20
+ if (recipe.description)
21
+ this.log(dim(` ${recipe.description}`));
22
+ }
23
+ }
24
+ }
25
+ return recipes;
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class RecipeRemove extends Command {
3
+ static aliases: string[];
4
+ static args: {
5
+ recipe: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static summary: string;
11
+ run(): Promise<{
12
+ name: string;
13
+ removed: boolean;
14
+ }>;
15
+ }
@@ -0,0 +1,23 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { recipeExists, removeRecipe } from '../../recipe/store.js';
3
+ export default class RecipeRemove extends Command {
4
+ static aliases = ['recipe:delete'];
5
+ static args = {
6
+ recipe: Args.string({ description: 'Name of the saved recipe to remove.', required: true }),
7
+ };
8
+ static description = 'Delete a recipe from the recipe store.';
9
+ static enableJsonFlag = true;
10
+ static examples = ['<%= config.bin %> <%= command.id %> close-user-tickets'];
11
+ static summary = 'Remove a saved recipe.';
12
+ async run() {
13
+ const { args } = await this.parse(RecipeRemove);
14
+ if (!(await recipeExists(this.config, args.recipe))) {
15
+ this.error(`Recipe "${args.recipe}" not found.`);
16
+ }
17
+ await removeRecipe(this.config, args.recipe);
18
+ if (!this.jsonEnabled()) {
19
+ this.log(`Removed recipe ${args.recipe}.`);
20
+ }
21
+ return { name: args.recipe, removed: true };
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ import { Command } from '@oclif/core';
2
+ import { Context } from '../../recipe/types.js';
3
+ export default class RecipeRun extends Command {
4
+ static args: {
5
+ recipe: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static flags: {
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ var: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ static summary: string;
15
+ run(): Promise<Context>;
16
+ }
@@ -0,0 +1,64 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { bold, cyan, dim } from 'ansis';
3
+ import { executeRecipe } from '../../recipe/engine.js';
4
+ import { execShell } from '../../recipe/exec.js';
5
+ import { resolveRecipe } from '../../recipe/store.js';
6
+ /** Parses a `--var key=value` flag, JSON-decoding the value when possible. */
7
+ function parseVar(input) {
8
+ const eq = input.indexOf('=');
9
+ if (eq === -1)
10
+ throw new Error(`Invalid --var "${input}". Expected key=value.`);
11
+ const key = input.slice(0, eq);
12
+ const raw = input.slice(eq + 1);
13
+ try {
14
+ return [key, JSON.parse(raw)];
15
+ }
16
+ catch {
17
+ return [key, raw];
18
+ }
19
+ }
20
+ export default class RecipeRun extends Command {
21
+ static args = {
22
+ recipe: Args.string({ description: 'Name of a saved recipe, or a path to a recipe file.', required: true }),
23
+ };
24
+ static description = `Each step runs a command, with optional conditions, loops and JSON operations between them.
25
+
26
+ Use --var to override the recipe's default variables, and --dry-run to preview the commands without running them.`;
27
+ static enableJsonFlag = true;
28
+ static examples = [
29
+ '<%= config.bin %> <%= command.id %> close-user-tickets',
30
+ '<%= config.bin %> <%= command.id %> close-user-tickets --var assignee=jdoe',
31
+ '<%= config.bin %> <%= command.id %> ./my-recipe.json --dry-run',
32
+ ];
33
+ static flags = {
34
+ 'dry-run': Flags.boolean({ description: 'Print the commands that would run without executing them.' }),
35
+ var: Flags.string({
36
+ description: 'Override a recipe variable (key=value). Repeatable. Values are parsed as JSON when possible.',
37
+ multiple: true,
38
+ }),
39
+ };
40
+ static summary = 'Run a recipe: a sequence of commands chained with conditions, loops and JSON operations.';
41
+ async run() {
42
+ const { args, flags } = await this.parse(RecipeRun);
43
+ const recipe = await resolveRecipe(this.config, args.recipe);
44
+ const overrides = Object.fromEntries((flags.var ?? []).map((entry) => parseVar(entry)));
45
+ if (!this.jsonEnabled()) {
46
+ this.log(bold(`Running recipe ${cyan(recipe.name)}`));
47
+ if (recipe.description)
48
+ this.log(dim(recipe.description));
49
+ if (flags['dry-run'])
50
+ this.log(dim('Dry run — no commands will be executed.\n'));
51
+ }
52
+ const runner = {
53
+ dryRun: flags['dry-run'],
54
+ exec: (command) => execShell(command),
55
+ log: (message) => this.log(message),
56
+ runCommand: (id, argv) => this.config.runCommand(id, argv),
57
+ };
58
+ const result = await executeRecipe(recipe, runner, overrides);
59
+ if (!this.jsonEnabled()) {
60
+ this.log(dim(`\n✅ Recipe ${recipe.name} finished (${result.steps} step${result.steps === 1 ? '' : 's'}).`));
61
+ }
62
+ return result.vars;
63
+ }
64
+ }
@@ -0,0 +1,12 @@
1
+ import { Command } from '@oclif/core';
2
+ import { Recipe } from '../../recipe/types.js';
3
+ export default class RecipeShow extends Command {
4
+ static args: {
5
+ recipe: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static enableJsonFlag: boolean;
9
+ static examples: string[];
10
+ static summary: string;
11
+ run(): Promise<Recipe>;
12
+ }
@@ -0,0 +1,19 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { resolveRecipe } from '../../recipe/store.js';
3
+ export default class RecipeShow extends Command {
4
+ static args = {
5
+ recipe: Args.string({ description: 'Name of a saved recipe, or a path to a recipe file.', required: true }),
6
+ };
7
+ static description = 'Print the full definition of a recipe.';
8
+ static enableJsonFlag = true;
9
+ static examples = ['<%= config.bin %> <%= command.id %> close-user-tickets'];
10
+ static summary = 'Print a recipe definition.';
11
+ async run() {
12
+ const { args } = await this.parse(RecipeShow);
13
+ const recipe = await resolveRecipe(this.config, args.recipe);
14
+ if (!this.jsonEnabled()) {
15
+ this.log(JSON.stringify(recipe, null, 2));
16
+ }
17
+ return recipe;
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class RecipeValidate extends Command {
3
+ static args: {
4
+ recipe: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static enableJsonFlag: boolean;
8
+ static examples: string[];
9
+ static summary: string;
10
+ run(): Promise<{
11
+ name: string;
12
+ valid: boolean;
13
+ }>;
14
+ }
@@ -0,0 +1,21 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { green } from 'ansis';
3
+ import { resolveRecipe } from '../../recipe/store.js';
4
+ export default class RecipeValidate extends Command {
5
+ static args = {
6
+ recipe: Args.string({ description: 'Name of a saved recipe, or a path to a recipe file.', required: true }),
7
+ };
8
+ static description = 'Check that a recipe is well-formed without running it.';
9
+ static enableJsonFlag = true;
10
+ static examples = ['<%= config.bin %> <%= command.id %> ./my-recipe.json'];
11
+ static summary = 'Validate a recipe.';
12
+ async run() {
13
+ const { args } = await this.parse(RecipeValidate);
14
+ // resolveRecipe validates the recipe as it loads it, throwing on any problem.
15
+ const recipe = await resolveRecipe(this.config, args.recipe);
16
+ if (!this.jsonEnabled()) {
17
+ this.log(`${green('✓')} Recipe ${recipe.name} is valid (${recipe.steps.length} steps).`);
18
+ }
19
+ return { name: recipe.name, valid: true };
20
+ }
21
+ }
@@ -0,0 +1,2 @@
1
+ import { Context } from './types.js';
2
+ export declare function evaluateCondition(expr: string, context: Context): boolean;
@@ -0,0 +1,78 @@
1
+ import { resolvePath } from './interpolate.js';
2
+ function splitOutsideTemplates(expr, separator) {
3
+ const parts = [];
4
+ let depth = 0;
5
+ let current = '';
6
+ let i = 0;
7
+ while (i < expr.length) {
8
+ if (expr[i] === '$' && expr[i + 1] === '{') {
9
+ depth++;
10
+ current += expr[i++];
11
+ }
12
+ else if (expr[i] === '}' && depth > 0) {
13
+ depth--;
14
+ current += expr[i++];
15
+ }
16
+ else if (depth === 0 && expr.startsWith(separator, i)) {
17
+ parts.push(current);
18
+ current = '';
19
+ i += separator.length;
20
+ }
21
+ else {
22
+ current += expr[i++];
23
+ }
24
+ }
25
+ parts.push(current);
26
+ return parts.length > 1 ? parts : [expr];
27
+ }
28
+ function applyOp(value, op, right) {
29
+ switch (op) {
30
+ case '!=': {
31
+ return String(value) !== right;
32
+ }
33
+ case '<': {
34
+ return Number(value) < Number(right);
35
+ }
36
+ case '<=': {
37
+ return Number(value) <= Number(right);
38
+ }
39
+ case '==': {
40
+ return String(value) === right;
41
+ }
42
+ case '>': {
43
+ return Number(value) > Number(right);
44
+ }
45
+ case '>=': {
46
+ return Number(value) >= Number(right);
47
+ }
48
+ case 'contains': {
49
+ return Array.isArray(value) ? value.includes(right) : String(value).includes(right);
50
+ }
51
+ case 'matches': {
52
+ return new RegExp(right).test(String(value));
53
+ }
54
+ default: {
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+ export function evaluateCondition(expr, context) {
60
+ expr = expr.trim();
61
+ const orParts = splitOutsideTemplates(expr, '||');
62
+ if (orParts.length > 1)
63
+ return orParts.some((part) => evaluateCondition(part.trim(), context));
64
+ const andParts = splitOutsideTemplates(expr, '&&');
65
+ if (andParts.length > 1)
66
+ return andParts.every((part) => evaluateCondition(part.trim(), context));
67
+ if (expr.startsWith('!'))
68
+ return !evaluateCondition(expr.slice(1).trim(), context);
69
+ const binaryMatch = /^\$\{([^}]+)\}\s*(==|!=|>=|<=|>|<|contains|matches)\s*(.+)$/.exec(expr);
70
+ if (binaryMatch) {
71
+ const [, path, op, right] = binaryMatch;
72
+ return applyOp(resolvePath(context, path), op, right.trim());
73
+ }
74
+ const simpleMatch = /^\$\{([^}]+)\}$/.exec(expr);
75
+ if (simpleMatch)
76
+ return Boolean(resolvePath(context, simpleMatch[1]));
77
+ return Boolean(expr);
78
+ }
@@ -0,0 +1,14 @@
1
+ import { Context, Recipe } from './types.js';
2
+ export interface RecipeRunner {
3
+ dryRun?: boolean;
4
+ exec(command: string): Promise<{
5
+ stderr: string;
6
+ stdout: string;
7
+ }>;
8
+ log(message: string): void;
9
+ runCommand(id: string, argv: string[]): Promise<unknown>;
10
+ }
11
+ export declare function executeRecipe(recipe: Recipe, runner: RecipeRunner, overrides?: Context): Promise<{
12
+ steps: number;
13
+ vars: Context;
14
+ }>;
@@ -0,0 +1,80 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ // Recipe steps execute sequentially by design: each step may read variables
3
+ // captured by the previous step, so parallel execution is not an option.
4
+ import { evaluateCondition } from './condition.js';
5
+ import { interpolate, interpolateDeep, toArg } from './interpolate.js';
6
+ export async function executeRecipe(recipe, runner, overrides = {}) {
7
+ const vars = { ...recipe.vars, ...overrides };
8
+ let stepCount = 0;
9
+ async function runSteps(steps, ctx) {
10
+ for (const step of steps) {
11
+ if ('then' in step || ('else' in step && !('run' in step) && !('exec' in step))) {
12
+ const cond = evaluateCondition(step.if, ctx);
13
+ if (cond) {
14
+ if (step.then)
15
+ await runSteps(step.then, ctx);
16
+ }
17
+ else if (step.else)
18
+ await runSteps(step.else, ctx);
19
+ stepCount++;
20
+ continue;
21
+ }
22
+ if ('if' in step && step.if && !evaluateCondition(step.if, ctx))
23
+ continue;
24
+ if ('log' in step) {
25
+ runner.log(String(interpolate(step.log, ctx)));
26
+ stepCount++;
27
+ }
28
+ else if ('set' in step) {
29
+ ctx[step.set] = interpolateDeep(step.value, ctx);
30
+ stepCount++;
31
+ }
32
+ else if ('run' in step) {
33
+ const argv = (step.args ?? []).map((a) => toArg(String(a), ctx));
34
+ if (runner.dryRun) {
35
+ runner.log(`[dry-run] ${step.run}${argv.length > 0 ? ' ' + argv.join(' ') : ''}`);
36
+ }
37
+ else {
38
+ const result = await runner.runCommand(step.run, argv);
39
+ if (step.capture)
40
+ ctx[step.capture] = result;
41
+ }
42
+ stepCount++;
43
+ }
44
+ else if ('exec' in step) {
45
+ const command = String(interpolate(step.exec, ctx));
46
+ if (runner.dryRun) {
47
+ runner.log(`[dry-run] ${command}`);
48
+ }
49
+ else {
50
+ const { stdout } = await runner.exec(command);
51
+ if (step.capture)
52
+ ctx[step.capture] = step.json ? JSON.parse(stdout) : stdout;
53
+ }
54
+ stepCount++;
55
+ }
56
+ else if ('repeat' in step) {
57
+ const raw = step.repeat;
58
+ const count = typeof raw === 'number' ? raw : Number(interpolate(String(raw), ctx));
59
+ for (let i = 0; i < count; i++) {
60
+ if (step.as)
61
+ ctx[step.as] = i;
62
+ await runSteps(step.steps, ctx);
63
+ }
64
+ stepCount++;
65
+ }
66
+ else if ('forEach' in step) {
67
+ const collection = interpolate(step.forEach, ctx);
68
+ if (Array.isArray(collection)) {
69
+ for (const item of collection) {
70
+ ctx[step.as] = item;
71
+ await runSteps(step.steps, ctx);
72
+ }
73
+ }
74
+ stepCount++;
75
+ }
76
+ }
77
+ }
78
+ await runSteps(recipe.steps, vars);
79
+ return { steps: stepCount, vars };
80
+ }
@@ -0,0 +1,4 @@
1
+ export declare function execShell(command: string): Promise<{
2
+ stderr: string;
3
+ stdout: string;
4
+ }>;
@@ -0,0 +1,9 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execAsync = promisify(exec);
4
+ // exec() is intentional: recipe exec steps are full shell commands that may use pipes,
5
+ // redirects, and other shell features. Commands come from user-authored recipe files.
6
+ export async function execShell(command) {
7
+ const { stderr, stdout } = await execAsync(command);
8
+ return { stderr, stdout };
9
+ }
@@ -0,0 +1,5 @@
1
+ import { Context } from './types.js';
2
+ export declare function resolvePath(context: Context, path: string): unknown;
3
+ export declare function interpolate(template: string, context: Context): unknown;
4
+ export declare function interpolateDeep(value: unknown, context: Context): unknown;
5
+ export declare function toArg(template: string, context: Context): string;
@@ -0,0 +1,85 @@
1
+ function parsePath(path) {
2
+ const parts = [];
3
+ let i = 0;
4
+ while (i < path.length) {
5
+ if (path[i] === '.') {
6
+ i++;
7
+ }
8
+ else if (path[i] === '[') {
9
+ i++;
10
+ if (path[i] === '*') {
11
+ parts.push('*');
12
+ i += 2; // skip * and ]
13
+ }
14
+ else if (path[i] === '"' || path[i] === "'") {
15
+ const quote = path[i];
16
+ i++;
17
+ const end = path.indexOf(quote, i);
18
+ parts.push(path.slice(i, end));
19
+ i = end + 2; // skip closing quote and ]
20
+ }
21
+ else {
22
+ const end = path.indexOf(']', i);
23
+ const n = Number(path.slice(i, end));
24
+ parts.push(Number.isNaN(n) ? path.slice(i, end) : n);
25
+ i = end + 1;
26
+ }
27
+ }
28
+ else {
29
+ let end = i;
30
+ while (end < path.length && path[end] !== '.' && path[end] !== '[')
31
+ end++;
32
+ parts.push(path.slice(i, end));
33
+ i = end;
34
+ }
35
+ }
36
+ return parts;
37
+ }
38
+ function resolveWithParts(obj, parts) {
39
+ if (parts.length === 0)
40
+ return obj;
41
+ const [head, ...tail] = parts;
42
+ if (head === '*') {
43
+ if (!Array.isArray(obj))
44
+ return undefined;
45
+ return obj.map((item) => resolveWithParts(item, tail));
46
+ }
47
+ // typeof null === 'object', so we need the explicit null check first
48
+ if (obj === null || typeof obj !== 'object')
49
+ return undefined;
50
+ const next = obj[String(head)];
51
+ return resolveWithParts(next, tail);
52
+ }
53
+ export function resolvePath(context, path) {
54
+ return resolveWithParts(context, parsePath(path));
55
+ }
56
+ export function interpolate(template, context) {
57
+ const fullMatch = /^\$\{([^}]+)\}$/.exec(template);
58
+ if (fullMatch)
59
+ return resolvePath(context, fullMatch[1]);
60
+ return template.replaceAll(/\$\{([^}]+)\}/g, (_, path) => {
61
+ const value = resolvePath(context, path);
62
+ return value === null || value === undefined ? '' : String(value);
63
+ });
64
+ }
65
+ export function interpolateDeep(value, context) {
66
+ if (typeof value === 'string')
67
+ return interpolate(value, context);
68
+ if (Array.isArray(value))
69
+ return value.map((item) => interpolateDeep(item, context));
70
+ // typeof null === 'object', so the null check is required here
71
+ if (value !== null && typeof value === 'object') {
72
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateDeep(v, context)]));
73
+ }
74
+ return value;
75
+ }
76
+ export function toArg(template, context) {
77
+ const value = interpolate(template, context);
78
+ if (typeof value === 'string')
79
+ return value;
80
+ if (value === null || value === undefined)
81
+ return '';
82
+ if (typeof value === 'object')
83
+ return JSON.stringify(value);
84
+ return String(value);
85
+ }
@@ -0,0 +1,23 @@
1
+ import { Recipe } from './types.js';
2
+ export declare function recipesDir(config: {
3
+ dataDir: string;
4
+ }): string;
5
+ export declare function recipeExists(config: {
6
+ dataDir: string;
7
+ }, name: string): Promise<boolean>;
8
+ export declare function saveRecipe(config: {
9
+ dataDir: string;
10
+ }, recipe: Recipe): Promise<string>;
11
+ export declare function readRecipe(config: {
12
+ dataDir: string;
13
+ }, name: string): Promise<Recipe>;
14
+ export declare function listRecipes(config: {
15
+ dataDir: string;
16
+ }): Promise<Recipe[]>;
17
+ export declare function removeRecipe(config: {
18
+ dataDir: string;
19
+ }, name: string): Promise<void>;
20
+ export declare function loadRecipeFile(filePath: string): Promise<Recipe>;
21
+ export declare function resolveRecipe(config: {
22
+ dataDir: string;
23
+ }, nameOrPath: string): Promise<Recipe>;
@@ -0,0 +1,46 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export function recipesDir(config) {
4
+ return join(config.dataDir, 'recipes');
5
+ }
6
+ export async function recipeExists(config, name) {
7
+ try {
8
+ await readFile(join(recipesDir(config), `${name}.json`));
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ export async function saveRecipe(config, recipe) {
16
+ const dir = recipesDir(config);
17
+ await mkdir(dir, { recursive: true });
18
+ const path = join(dir, `${recipe.name}.json`);
19
+ await writeFile(path, JSON.stringify(recipe, null, 2) + '\n');
20
+ return path;
21
+ }
22
+ export async function readRecipe(config, name) {
23
+ const content = await readFile(join(recipesDir(config), `${name}.json`), 'utf8');
24
+ return JSON.parse(content);
25
+ }
26
+ export async function listRecipes(config) {
27
+ try {
28
+ const files = await readdir(recipesDir(config));
29
+ return Promise.all(files.filter((f) => f.endsWith('.json')).map((f) => readRecipe(config, f.slice(0, -5))));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ export async function removeRecipe(config, name) {
36
+ await rm(join(recipesDir(config), `${name}.json`));
37
+ }
38
+ export async function loadRecipeFile(filePath) {
39
+ const content = await readFile(filePath, 'utf8');
40
+ return JSON.parse(content);
41
+ }
42
+ export async function resolveRecipe(config, nameOrPath) {
43
+ if (nameOrPath.includes('/') || nameOrPath.endsWith('.json'))
44
+ return loadRecipeFile(nameOrPath);
45
+ return readRecipe(config, nameOrPath);
46
+ }