@introspection-ai/pi-recipes 0.1.0-beta.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,691 @@
1
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { readPiPackageManifest, validatePiPackageManifest, } from "./recipe-package.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const STORE_LOCK_STALE_MS = 30_000;
10
+ const STORE_LOCK_TIMEOUT_MS = 10_000;
11
+ const STORE_LOCK_RETRY_MS = 25;
12
+ function newDefaultRecipeStoreDir() {
13
+ return join(homedir(), ".pi", "recipes");
14
+ }
15
+ export function defaultRecipeStoreDir(env = process.env) {
16
+ return env.PI_RECIPES_HOME ?? newDefaultRecipeStoreDir();
17
+ }
18
+ export function recipeStoreFilePath(storeDir = defaultRecipeStoreDir()) {
19
+ return join(storeDir, "recipes.json");
20
+ }
21
+ function recipeStoreLockPath(storeDir) {
22
+ return `${recipeStoreFilePath(storeDir)}.lock`;
23
+ }
24
+ function sleepSync(ms) {
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
26
+ }
27
+ async function sleep(ms) {
28
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
29
+ }
30
+ function removeStaleRecipeStoreLock(lockPath) {
31
+ try {
32
+ const stats = statSync(lockPath);
33
+ if (Date.now() - stats.mtimeMs < STORE_LOCK_STALE_MS)
34
+ return false;
35
+ rmSync(lockPath, { recursive: true, force: true });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function acquireLock(lockPath) {
43
+ const startedAt = Date.now();
44
+ mkdirSync(dirname(lockPath), { recursive: true });
45
+ while (true) {
46
+ try {
47
+ mkdirSync(lockPath);
48
+ try {
49
+ writeFileSync(join(lockPath, "owner"), JSON.stringify({
50
+ pid: process.pid,
51
+ createdAt: new Date().toISOString(),
52
+ }));
53
+ }
54
+ catch (err) {
55
+ rmSync(lockPath, { recursive: true, force: true });
56
+ throw err;
57
+ }
58
+ return () => {
59
+ rmSync(lockPath, { recursive: true, force: true });
60
+ };
61
+ }
62
+ catch (err) {
63
+ const code = err && typeof err === "object" ? err.code : undefined;
64
+ if (code !== "EEXIST")
65
+ throw err;
66
+ if (!removeStaleRecipeStoreLock(lockPath) && Date.now() - startedAt > STORE_LOCK_TIMEOUT_MS) {
67
+ throw new Error(`Timed out waiting for recipe store lock: ${lockPath}`);
68
+ }
69
+ sleepSync(STORE_LOCK_RETRY_MS);
70
+ }
71
+ }
72
+ }
73
+ function acquireRecipeStoreLock(storeDir) {
74
+ return acquireLock(recipeStoreLockPath(storeDir));
75
+ }
76
+ async function acquireLockAsync(lockPath) {
77
+ const startedAt = Date.now();
78
+ mkdirSync(dirname(lockPath), { recursive: true });
79
+ while (true) {
80
+ try {
81
+ mkdirSync(lockPath);
82
+ try {
83
+ writeFileSync(join(lockPath, "owner"), JSON.stringify({
84
+ pid: process.pid,
85
+ createdAt: new Date().toISOString(),
86
+ }));
87
+ }
88
+ catch (err) {
89
+ rmSync(lockPath, { recursive: true, force: true });
90
+ throw err;
91
+ }
92
+ return () => {
93
+ rmSync(lockPath, { recursive: true, force: true });
94
+ };
95
+ }
96
+ catch (err) {
97
+ const code = err && typeof err === "object" ? err.code : undefined;
98
+ if (code !== "EEXIST")
99
+ throw err;
100
+ if (!removeStaleRecipeStoreLock(lockPath) && Date.now() - startedAt > STORE_LOCK_TIMEOUT_MS) {
101
+ throw new Error(`Timed out waiting for recipe store lock: ${lockPath}`);
102
+ }
103
+ await sleep(STORE_LOCK_RETRY_MS);
104
+ }
105
+ }
106
+ }
107
+ function withRecipeStoreLock(storeDir, fn) {
108
+ const release = acquireRecipeStoreLock(storeDir);
109
+ try {
110
+ return fn();
111
+ }
112
+ finally {
113
+ release();
114
+ }
115
+ }
116
+ function sanitizeSegment(value) {
117
+ const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-|-$/g, "") || "_";
118
+ return sanitized === "." || sanitized === ".." ? "_" : sanitized;
119
+ }
120
+ function hashedCacheSegment(value) {
121
+ const slug = sanitizeSegment(redactUrl(value)).slice(0, 80);
122
+ const hash = createHash("sha256").update(value).digest("hex").slice(0, 16);
123
+ return `${slug}-${hash}`;
124
+ }
125
+ function expandHome(path) {
126
+ if (path === "~")
127
+ return homedir();
128
+ if (path.startsWith("~/"))
129
+ return join(homedir(), path.slice(2));
130
+ return path;
131
+ }
132
+ function splitRef(input) {
133
+ const index = input.indexOf("#");
134
+ if (index < 0)
135
+ return { spec: input };
136
+ const spec = input.slice(0, index);
137
+ const ref = input.slice(index + 1).trim();
138
+ return { spec, ...(ref ? { ref } : {}) };
139
+ }
140
+ function normalizeSubdir(value) {
141
+ const subdir = value?.replace(/^\/+|\/+$/g, "");
142
+ if (subdir && !isSafeRelativePath(subdir))
143
+ return undefined;
144
+ return subdir ? subdir : undefined;
145
+ }
146
+ function hasUnsafePathSegment(value) {
147
+ return value
148
+ .replace(/\\/g, "/")
149
+ .split("/")
150
+ .some((segment) => segment === "." || segment === "..");
151
+ }
152
+ function isSafeRelativePath(value) {
153
+ return !isAbsolute(value) && !hasUnsafePathSegment(value);
154
+ }
155
+ function isSafeGithubName(value) {
156
+ return /^[a-zA-Z0-9_.-]+$/.test(value) && value !== "." && value !== "..";
157
+ }
158
+ function stripGitSuffix(value) {
159
+ return value.replace(/\.git$/i, "");
160
+ }
161
+ function parseExplicitGithubShorthand(input) {
162
+ if (!input.startsWith("github:"))
163
+ return undefined;
164
+ const source = parseGithubShorthand(input.slice("github:".length));
165
+ return source ? { ...source, input } : undefined;
166
+ }
167
+ function parseGithubUrl(input) {
168
+ let url;
169
+ try {
170
+ url = new URL(input);
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ if (url.hostname !== "github.com")
176
+ return undefined;
177
+ const parts = url.pathname.split("/").filter(Boolean);
178
+ const [owner, repoWithGit] = parts;
179
+ if (!owner || !repoWithGit)
180
+ return undefined;
181
+ const repo = stripGitSuffix(repoWithGit);
182
+ if (!isSafeGithubName(owner) || !isSafeGithubName(repo))
183
+ return undefined;
184
+ const hashRef = url.hash ? decodeURIComponent(url.hash.slice(1)) : undefined;
185
+ if (hashRef && !isSafeRelativePath(hashRef))
186
+ return undefined;
187
+ if (parts[2] === "tree" && parts[3]) {
188
+ if (!isSafeRelativePath(parts[3]))
189
+ return undefined;
190
+ const subdirInput = parts.slice(4).join("/");
191
+ const subdir = normalizeSubdir(subdirInput);
192
+ if (subdirInput && !subdir)
193
+ return undefined;
194
+ return {
195
+ kind: "github",
196
+ input,
197
+ host: "github.com",
198
+ owner,
199
+ repo,
200
+ ref: hashRef ?? parts[3],
201
+ ...(subdir ? { subdir } : {}),
202
+ };
203
+ }
204
+ return {
205
+ kind: "github",
206
+ input,
207
+ host: "github.com",
208
+ owner,
209
+ repo,
210
+ ...(hashRef ? { ref: hashRef } : {}),
211
+ };
212
+ }
213
+ function parseGithubShorthand(input) {
214
+ const { spec, ref } = splitRef(input);
215
+ const parts = spec.split("/").filter(Boolean);
216
+ if (parts.length < 2)
217
+ return undefined;
218
+ const [owner, repo, ...subdirParts] = parts;
219
+ if (!owner || !repo)
220
+ return undefined;
221
+ if (!isSafeGithubName(owner) || !isSafeGithubName(repo)) {
222
+ return undefined;
223
+ }
224
+ if (ref && !isSafeRelativePath(ref))
225
+ return undefined;
226
+ const subdirInput = subdirParts.join("/");
227
+ const subdir = normalizeSubdir(subdirInput);
228
+ if (subdirInput && !subdir)
229
+ return undefined;
230
+ return {
231
+ kind: "github",
232
+ input,
233
+ host: "github.com",
234
+ owner,
235
+ repo,
236
+ ...(ref ? { ref } : {}),
237
+ ...(subdir ? { subdir } : {}),
238
+ };
239
+ }
240
+ function isLikelyGitUrl(value) {
241
+ return (/^git\+https?:\/\//i.test(value) ||
242
+ /^https?:\/\/.+\.git(?:#.*)?$/i.test(value) ||
243
+ /^ssh:\/\/.+/i.test(value) ||
244
+ /^file:\/\/.+/i.test(value) ||
245
+ /^git@[^:]+:.+/i.test(value));
246
+ }
247
+ function parseGitSource(input) {
248
+ if (!isLikelyGitUrl(input))
249
+ return undefined;
250
+ const { spec, ref } = splitRef(input.replace(/^git\+/i, ""));
251
+ return {
252
+ kind: "git",
253
+ input,
254
+ url: spec,
255
+ ...(ref ? { ref } : {}),
256
+ };
257
+ }
258
+ export function parseRecipeSource(input, opts = {}) {
259
+ const trimmed = input.trim();
260
+ if (!trimmed)
261
+ throw new Error("Recipe source is required");
262
+ const cwd = opts.cwd ?? process.cwd();
263
+ const expanded = expandHome(trimmed);
264
+ const localPath = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
265
+ if (existsSync(localPath) ||
266
+ trimmed.startsWith(".") ||
267
+ trimmed.startsWith("/") ||
268
+ trimmed.startsWith("~")) {
269
+ return { kind: "local", input, path: localPath };
270
+ }
271
+ const explicitGithub = parseExplicitGithubShorthand(trimmed);
272
+ if (explicitGithub)
273
+ return explicitGithub;
274
+ const gitSource = parseGitSource(trimmed);
275
+ if (gitSource)
276
+ return gitSource;
277
+ const githubUrl = parseGithubUrl(trimmed);
278
+ if (githubUrl)
279
+ return githubUrl;
280
+ const githubShorthand = parseGithubShorthand(trimmed);
281
+ if (githubShorthand)
282
+ return githubShorthand;
283
+ throw new Error(`Unsupported recipe source: ${input}`);
284
+ }
285
+ export function recipeSourceId(source) {
286
+ if (source.kind === "local")
287
+ return `local:${source.path}`;
288
+ if (source.kind === "git") {
289
+ return [
290
+ "git:",
291
+ source.url,
292
+ source.ref ? `#${source.ref}` : "",
293
+ ].join("");
294
+ }
295
+ const suffix = [
296
+ `${source.owner}/${source.repo}`,
297
+ source.subdir,
298
+ ].filter(Boolean).join("/");
299
+ return `github:${suffix}${source.ref ? `#${source.ref}` : ""}`;
300
+ }
301
+ function cloneDirectoryForSource(source, storeDir) {
302
+ const ref = hashedCacheSegment(source.ref ?? "HEAD");
303
+ return join(storeDir, "sources", source.host, sanitizeSegment(source.owner), sanitizeSegment(source.repo), ref);
304
+ }
305
+ function cloneDirectoryForGitSource(source, storeDir) {
306
+ const ref = hashedCacheSegment(source.ref ?? "HEAD");
307
+ return join(storeDir, "sources", "git", hashedCacheSegment(source.url), ref);
308
+ }
309
+ function recipeDirectoryForSource(source, storeDir) {
310
+ if (source.kind === "local")
311
+ return source.path;
312
+ const cloned = source.kind === "github"
313
+ ? cloneDirectoryForSource(source, storeDir)
314
+ : cloneDirectoryForGitSource(source, storeDir);
315
+ const recipeDir = source.kind === "github" && source.subdir
316
+ ? resolve(cloned, source.subdir)
317
+ : cloned;
318
+ const relativePath = relative(cloned, recipeDir);
319
+ if (relativePath === ".." ||
320
+ relativePath.startsWith("../") ||
321
+ relativePath.startsWith("..\\") ||
322
+ isAbsolute(relativePath)) {
323
+ throw new Error(`Recipe source resolves outside its clone: ${source.input}`);
324
+ }
325
+ return recipeDir;
326
+ }
327
+ function localRecipeDirectoryForName(name, storeDir) {
328
+ return join(storeDir, "local", sanitizeSegment(name));
329
+ }
330
+ function copyRecipeDirectory(sourceDir, targetDir, opts) {
331
+ if (resolve(sourceDir) === resolve(targetDir))
332
+ return false;
333
+ const existed = existsSync(targetDir);
334
+ if (existed) {
335
+ if (!opts.force) {
336
+ throw new Error(`Local editable recipe already exists at ${targetDir}. Re-run with --force to overwrite it.`);
337
+ }
338
+ rmSync(targetDir, { recursive: true, force: true });
339
+ }
340
+ mkdirSync(dirname(targetDir), { recursive: true });
341
+ cpSync(sourceDir, targetDir, {
342
+ recursive: true,
343
+ filter(source) {
344
+ const relativePath = relative(sourceDir, source).replace(/\\/g, "/");
345
+ const parts = relativePath.split("/");
346
+ return !parts.includes(".git") && !parts.includes("node_modules");
347
+ },
348
+ });
349
+ return existed;
350
+ }
351
+ export function readRecipeStore(storeDir = defaultRecipeStoreDir()) {
352
+ const path = recipeStoreFilePath(storeDir);
353
+ if (existsSync(path)) {
354
+ return JSON.parse(readFileSync(path, "utf8"));
355
+ }
356
+ return { version: 1, recipes: [] };
357
+ }
358
+ function writeRecipeStore(store, storeDir) {
359
+ const path = recipeStoreFilePath(storeDir);
360
+ mkdirSync(dirname(path), { recursive: true });
361
+ const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
362
+ writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`);
363
+ renameSync(tempPath, path);
364
+ }
365
+ function asRecord(value) {
366
+ return value && typeof value === "object" && !Array.isArray(value)
367
+ ? value
368
+ : {};
369
+ }
370
+ function readPackageJson(path) {
371
+ try {
372
+ return asRecord(JSON.parse(readFileSync(path, "utf8")));
373
+ }
374
+ catch (err) {
375
+ throw new Error(`Recipe dependency manifest at ${path} has invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
376
+ }
377
+ }
378
+ function hasRuntimeDependencies(pkg) {
379
+ return [pkg.dependencies, pkg.optionalDependencies].some((value) => Object.keys(asRecord(value)).length > 0);
380
+ }
381
+ function packageManagerFromSpec(value) {
382
+ if (typeof value !== "string")
383
+ return undefined;
384
+ const name = value.split("@")[0];
385
+ return name === "npm" || name === "pnpm" || name === "yarn" ? name : undefined;
386
+ }
387
+ function packageManagerForRecipe(recipeDir, pkg) {
388
+ return packageManagerFromSpec(pkg.packageManager)
389
+ ?? (existsSync(join(recipeDir, "pnpm-lock.yaml"))
390
+ ? "pnpm"
391
+ : existsSync(join(recipeDir, "yarn.lock"))
392
+ ? "yarn"
393
+ : "npm");
394
+ }
395
+ function hasDependencyLockfile(recipeDir) {
396
+ return [
397
+ "package-lock.json",
398
+ "npm-shrinkwrap.json",
399
+ "pnpm-lock.yaml",
400
+ "yarn.lock",
401
+ ].some((name) => existsSync(join(recipeDir, name)));
402
+ }
403
+ function dependencyInstallCommand(manager, recipeDir, requireLockfile) {
404
+ if (manager === "pnpm") {
405
+ return {
406
+ command: "pnpm",
407
+ args: [
408
+ "install",
409
+ "--prod",
410
+ "--ignore-scripts",
411
+ ...(requireLockfile ? ["--frozen-lockfile"] : []),
412
+ ],
413
+ };
414
+ }
415
+ if (manager === "yarn") {
416
+ return {
417
+ command: "yarn",
418
+ args: [
419
+ "install",
420
+ "--production",
421
+ "--ignore-scripts",
422
+ ...(requireLockfile ? ["--frozen-lockfile"] : []),
423
+ ],
424
+ };
425
+ }
426
+ if (requireLockfile &&
427
+ (existsSync(join(recipeDir, "package-lock.json")) ||
428
+ existsSync(join(recipeDir, "npm-shrinkwrap.json")))) {
429
+ return { command: "npm", args: ["ci", "--omit=dev", "--ignore-scripts"] };
430
+ }
431
+ return { command: "npm", args: ["install", "--omit=dev", "--ignore-scripts"] };
432
+ }
433
+ async function installRecipeDependencies(recipeDir, opts) {
434
+ const packagePath = join(recipeDir, "package.json");
435
+ if (!existsSync(packagePath))
436
+ return;
437
+ const pkg = readPackageJson(packagePath);
438
+ if (!hasRuntimeDependencies(pkg))
439
+ return;
440
+ if (opts.requireLockfile && !hasDependencyLockfile(recipeDir)) {
441
+ throw new Error(`Recipe ${recipeDir} declares extension dependencies but has no lockfile; add package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, or yarn.lock`);
442
+ }
443
+ const manager = packageManagerForRecipe(recipeDir, pkg);
444
+ const { command, args } = dependencyInstallCommand(manager, recipeDir, opts.requireLockfile);
445
+ try {
446
+ await execFileAsync(command, args, {
447
+ cwd: recipeDir,
448
+ env: opts.env ?? process.env,
449
+ });
450
+ }
451
+ catch (err) {
452
+ throw new Error(`Failed to install recipe extension dependencies with ${command} ${args.join(" ")}:\n${gitErrorText(err)}`);
453
+ }
454
+ }
455
+ function githubToken(env) {
456
+ return env.GITHUB_TOKEN ?? env.GH_TOKEN;
457
+ }
458
+ function githubCloneUrl(source, env) {
459
+ return `https://github.com/${source.owner}/${source.repo}.git`;
460
+ }
461
+ function githubGitEnv(source, env, dir) {
462
+ const token = githubToken(env);
463
+ if (!token)
464
+ return { env, cleanup() { } };
465
+ const askpass = join(dir, `.recipes-git-askpass-${sanitizeSegment(source.owner)}-${sanitizeSegment(source.repo)}-${Date.now()}.sh`);
466
+ writeFileSync(askpass, [
467
+ "#!/bin/sh",
468
+ "case \"$1\" in",
469
+ " *Username*) printf '%s\\n' x-access-token ;;",
470
+ " *) printf '%s\\n' \"$GITHUB_TOKEN\" ;;",
471
+ "esac",
472
+ "",
473
+ ].join("\n"));
474
+ chmodSync(askpass, 0o700);
475
+ return {
476
+ env: {
477
+ ...env,
478
+ GITHUB_TOKEN: token,
479
+ GIT_ASKPASS: askpass,
480
+ GIT_TERMINAL_PROMPT: "0",
481
+ },
482
+ cleanup() {
483
+ rmSync(askpass, { force: true });
484
+ },
485
+ };
486
+ }
487
+ function redactUrl(value) {
488
+ return value.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@");
489
+ }
490
+ function gitErrorText(err) {
491
+ if (err && typeof err === "object") {
492
+ const maybe = err;
493
+ const text = [maybe.stderr, maybe.stdout]
494
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
495
+ .join("\n")
496
+ .trim();
497
+ if (text)
498
+ return redactUrl(text);
499
+ if (typeof maybe.message === "string" && maybe.message.trim()) {
500
+ return redactUrl(maybe.message);
501
+ }
502
+ }
503
+ return redactUrl(err instanceof Error ? err.message : String(err));
504
+ }
505
+ function githubAuthHint(source) {
506
+ return [
507
+ `Could not install github:${source.owner}/${source.repo}.`,
508
+ "",
509
+ "If this repository is private, use standard git authentication:",
510
+ ` recipes install git@github.com:${source.owner}/${source.repo}.git`,
511
+ "",
512
+ "For CI or noninteractive installs, set GITHUB_TOKEN or GH_TOKEN:",
513
+ ` GITHUB_TOKEN=... recipes install github:${source.owner}/${source.repo}`,
514
+ ].join("\n");
515
+ }
516
+ async function cloneGitSource(source, storeDir, opts) {
517
+ const target = source.kind === "github"
518
+ ? cloneDirectoryForSource(source, storeDir)
519
+ : cloneDirectoryForGitSource(source, storeDir);
520
+ const release = await acquireLockAsync(`${target}.lock`);
521
+ try {
522
+ if (opts.force)
523
+ rmSync(target, { recursive: true, force: true });
524
+ if (existsSync(target))
525
+ return;
526
+ mkdirSync(dirname(target), { recursive: true });
527
+ const baseEnv = opts.env ?? process.env;
528
+ const gitEnv = source.kind === "github"
529
+ ? githubGitEnv(source, baseEnv, dirname(target))
530
+ : { env: baseEnv, cleanup() { } };
531
+ const url = source.kind === "github" ? githubCloneUrl(source, gitEnv.env) : source.url;
532
+ const baseArgs = ["clone", "--depth", "1"];
533
+ const args = source.ref
534
+ ? [...baseArgs, "--branch", source.ref, url, target]
535
+ : [...baseArgs, url, target];
536
+ try {
537
+ await execFileAsync("git", args, { env: gitEnv.env });
538
+ }
539
+ catch (err) {
540
+ if (!source.ref) {
541
+ const message = source.kind === "github"
542
+ ? `${githubAuthHint(source)}\n\nUnderlying git error:\n${gitErrorText(err)}`
543
+ : `Could not install git source ${redactUrl(source.input)}.\n\nUnderlying git error:\n${gitErrorText(err)}`;
544
+ throw new Error(message);
545
+ }
546
+ rmSync(target, { recursive: true, force: true });
547
+ try {
548
+ await execFileAsync("git", ["clone", url, target], { env: gitEnv.env });
549
+ await execFileAsync("git", ["checkout", source.ref], { cwd: target, env: gitEnv.env });
550
+ }
551
+ catch (fallbackErr) {
552
+ rmSync(target, { recursive: true, force: true });
553
+ const message = source.kind === "github"
554
+ ? `${githubAuthHint(source)}\n\nUnderlying git error:\n${gitErrorText(fallbackErr)}`
555
+ : `Could not install git source ${redactUrl(source.input)}.\n\nUnderlying git error:\n${gitErrorText(fallbackErr)}`;
556
+ throw new Error(message);
557
+ }
558
+ }
559
+ finally {
560
+ gitEnv.cleanup();
561
+ }
562
+ }
563
+ finally {
564
+ release();
565
+ }
566
+ }
567
+ function installedRecipeFromManifest(id, source, path, manifest) {
568
+ return {
569
+ id,
570
+ source,
571
+ path,
572
+ installedAt: new Date().toISOString(),
573
+ name: manifest.name,
574
+ version: manifest.version,
575
+ ...(manifest.description ? { description: manifest.description } : {}),
576
+ };
577
+ }
578
+ export async function addRecipe(input, opts = {}) {
579
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
580
+ const source = parseRecipeSource(input, opts);
581
+ if (source.kind === "github" || source.kind === "git") {
582
+ await cloneGitSource(source, storeDir, opts);
583
+ }
584
+ const path = recipeDirectoryForSource(source, storeDir);
585
+ const manifest = readPiPackageManifest(path);
586
+ const validation = validatePiPackageManifest(manifest);
587
+ const errors = validation.findings.filter((finding) => finding.severity === "error");
588
+ if (errors.length > 0) {
589
+ throw new Error(errors.map((finding) => finding.message).join("\n"));
590
+ }
591
+ await installRecipeDependencies(path, {
592
+ ...opts,
593
+ requireLockfile: source.kind !== "local",
594
+ });
595
+ const id = recipeSourceId(source);
596
+ const installed = installedRecipeFromManifest(id, input, path, manifest);
597
+ withRecipeStoreLock(storeDir, () => {
598
+ const store = readRecipeStore(storeDir);
599
+ store.recipes = [
600
+ ...store.recipes.filter((recipe) => recipe.id !== id && recipe.name !== manifest.name),
601
+ installed,
602
+ ].sort((a, b) => a.name.localeCompare(b.name));
603
+ writeRecipeStore(store, storeDir);
604
+ });
605
+ return installed;
606
+ }
607
+ export function listRecipes(opts = {}) {
608
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
609
+ return readRecipeStore(storeDir).recipes;
610
+ }
611
+ export function recipeScopedIdentifier(recipe) {
612
+ return recipe.name.startsWith("@") ? recipe.name.slice(1) : recipe.name;
613
+ }
614
+ export function recipePreferredIdentifier(recipe) {
615
+ const scoped = recipeScopedIdentifier(recipe);
616
+ return scoped.split("/").at(-1) ?? scoped;
617
+ }
618
+ function findInstalledRecipeByIdentifier(recipes, identifier) {
619
+ const sourceMatches = recipes.filter((recipe) => recipe.id === identifier || recipe.source === identifier);
620
+ if (sourceMatches.length === 1)
621
+ return sourceMatches[0];
622
+ if (sourceMatches.length > 1) {
623
+ throw new Error([
624
+ `Recipe source "${identifier}" is ambiguous.`,
625
+ "Use one of these recipe names:",
626
+ ...sourceMatches.map((recipe) => ` ${recipeScopedIdentifier(recipe)}`),
627
+ ].join("\n"));
628
+ }
629
+ const matches = identifier.includes("/")
630
+ ? recipes.filter((recipe) => recipeScopedIdentifier(recipe) === identifier)
631
+ : recipes.filter((recipe) => recipePreferredIdentifier(recipe) === identifier);
632
+ if (matches.length > 1) {
633
+ throw new Error([
634
+ `Recipe name "${identifier}" is ambiguous.`,
635
+ "Use one of these scoped recipe names:",
636
+ ...matches.map((recipe) => ` ${recipeScopedIdentifier(recipe)}`),
637
+ ].join("\n"));
638
+ }
639
+ return matches[0];
640
+ }
641
+ export function findInstalledRecipe(identifier, opts = {}) {
642
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
643
+ return findInstalledRecipeByIdentifier(readRecipeStore(storeDir).recipes, identifier);
644
+ }
645
+ export function removeRecipe(identifier, opts = {}) {
646
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
647
+ return withRecipeStoreLock(storeDir, () => {
648
+ const store = readRecipeStore(storeDir);
649
+ const removed = findInstalledRecipeByIdentifier(store.recipes, identifier);
650
+ if (!removed)
651
+ return undefined;
652
+ store.recipes = store.recipes.filter((recipe) => recipe !== removed);
653
+ writeRecipeStore(store, storeDir);
654
+ return removed;
655
+ });
656
+ }
657
+ export async function customizeRecipe(identifier, opts = {}) {
658
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
659
+ const original = withRecipeStoreLock(storeDir, () => findInstalledRecipeByIdentifier(readRecipeStore(storeDir).recipes, identifier));
660
+ if (!original)
661
+ throw new Error(`Recipe not found: ${identifier}`);
662
+ const targetPath = localRecipeDirectoryForName(original.name, storeDir);
663
+ const overwritten = copyRecipeDirectory(original.path, targetPath, opts);
664
+ const recipe = await addRecipe(targetPath, opts);
665
+ return {
666
+ original,
667
+ recipe,
668
+ path: targetPath,
669
+ overwritten,
670
+ };
671
+ }
672
+ export function resolveRecipeDirectory(input, opts = {}) {
673
+ const cwd = opts.cwd ?? process.cwd();
674
+ const expanded = expandHome(input.trim());
675
+ const localPath = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
676
+ if (existsSync(localPath))
677
+ return localPath;
678
+ const storeDir = opts.storeDir ?? defaultRecipeStoreDir(opts.env);
679
+ const store = readRecipeStore(storeDir);
680
+ const direct = findInstalledRecipeByIdentifier(store.recipes, input);
681
+ if (direct)
682
+ return direct.path;
683
+ return localPath;
684
+ }
685
+ export function recipeDisplayName(recipe) {
686
+ return `${recipe.name}@${recipe.version}`;
687
+ }
688
+ export function recipeBasename(path) {
689
+ return basename(path.replace(/\/+$/g, ""));
690
+ }
691
+ //# sourceMappingURL=recipe-store.js.map