bosia 0.1.5 → 0.1.8

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add.ts +29 -75
  3. package/src/cli/create.ts +32 -5
  4. package/src/cli/feat.ts +55 -93
  5. package/src/cli/registry.ts +168 -0
  6. package/src/core/cookies.ts +40 -17
  7. package/src/core/env.ts +40 -10
  8. package/src/core/server.ts +1 -1
  9. package/templates/drizzle/package.json +3 -9
  10. package/templates/drizzle/template.json +3 -0
  11. package/templates/drizzle/drizzle.config.ts +0 -10
  12. package/templates/drizzle/src/features/drizzle/index.ts +0 -15
  13. package/templates/drizzle/src/features/drizzle/migrations/.gitkeep +0 -0
  14. package/templates/drizzle/src/features/drizzle/schemas.ts +0 -1
  15. package/templates/drizzle/src/features/drizzle/seeds/001_initial_todos.ts +0 -11
  16. package/templates/drizzle/src/features/drizzle/seeds/runner.ts +0 -80
  17. package/templates/drizzle/src/features/todo/index.ts +0 -3
  18. package/templates/drizzle/src/features/todo/queries.ts +0 -36
  19. package/templates/drizzle/src/features/todo/schemas/todo.table.ts +0 -9
  20. package/templates/drizzle/src/features/todo/types.ts +0 -5
  21. package/templates/drizzle/src/lib/components/todo/index.ts +0 -3
  22. package/templates/drizzle/src/lib/components/todo/todo-form.svelte +0 -23
  23. package/templates/drizzle/src/lib/components/todo/todo-item.svelte +0 -63
  24. package/templates/drizzle/src/lib/components/todo/todo-list.svelte +0 -21
  25. package/templates/drizzle/src/routes/api/todos/+server.ts +0 -18
  26. package/templates/drizzle/src/routes/api/todos/[id]/+server.ts +0 -42
  27. package/templates/drizzle/src/routes/todos/+page.server.ts +0 -52
  28. package/templates/drizzle/src/routes/todos/+page.svelte +0 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
package/src/cli/add.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
- import { spawn } from "bun";
4
3
  import * as p from "@clack/prompts";
4
+ import {
5
+ type InstallOptions,
6
+ REGISTRY_URL,
7
+ resolveLocalRegistryOrExit,
8
+ readRegistryJSON,
9
+ readRegistryFile,
10
+ mergePkgJson,
11
+ bunAdd,
12
+ } from "./registry.ts";
5
13
 
6
14
  // ─── bosia add <component> ────────────────────────────────
7
15
  // Fetches a component from the GitHub registry (or local registry
@@ -11,8 +19,6 @@ import * as p from "@clack/prompts";
11
19
  // bosia add button → src/lib/components/ui/button/
12
20
  // bosia add shop/cart → src/lib/components/shop/cart/
13
21
 
14
- const REMOTE_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
15
-
16
22
  interface ComponentMeta {
17
23
  name: string;
18
24
  description: string;
@@ -46,8 +52,7 @@ export async function runAdd(name: string | undefined, flags: string[] = []) {
46
52
  }
47
53
 
48
54
  if (flags.includes("--local")) {
49
- // Walk up from this file to find the repo's registry/ directory
50
- registryRoot = resolveLocalRegistry();
55
+ registryRoot = resolveLocalRegistryOrExit();
51
56
  console.log(`⬡ Using local registry: ${registryRoot}\n`);
52
57
  }
53
58
 
@@ -88,31 +93,35 @@ async function loadIndex(): Promise<RegistryIndex | null> {
88
93
  if (existsSync(path)) return JSON.parse(readFileSync(path, "utf-8"));
89
94
  return null;
90
95
  }
91
- return await fetchJSON<RegistryIndex>(`${REMOTE_BASE}/index.json`);
96
+ const res = await fetch(`${REGISTRY_URL}/index.json`);
97
+ if (!res.ok) return null;
98
+ return await res.json() as RegistryIndex;
92
99
  } catch {
93
100
  return null;
94
101
  }
95
102
  }
96
103
 
97
- export async function addComponent(name: string, root = false) {
104
+ export async function addComponent(name: string, root = false, options?: InstallOptions) {
98
105
  // Resolve the full path (e.g. "button" → "ui/button", "shop/cart" stays "shop/cart")
99
106
  const fullPath = resolveDestPath(name);
100
107
 
101
108
  if (installed.has(fullPath)) return;
102
109
  installed.add(fullPath);
103
110
 
111
+ const cwd = options?.cwd ?? process.cwd();
112
+
104
113
  console.log(root ? `⬡ Installing component: ${name}\n` : ` 📦 Dependency: ${name}`);
105
114
 
106
- const meta = await readMeta(fullPath);
115
+ const meta = await readRegistryJSON<ComponentMeta>(registryRoot, "components", fullPath, "meta.json");
107
116
 
108
117
  // Install component dependencies first (recursive)
109
118
  for (const dep of meta.dependencies) {
110
- await addComponent(dep, false);
119
+ await addComponent(dep, false, options);
111
120
  }
112
121
 
113
- // Check if component already exists
114
- const destDir = join(process.cwd(), "src", "lib", "components", fullPath);
115
- if (existsSync(destDir)) {
122
+ // Check if component already exists (skip check entirely in non-interactive mode)
123
+ const destDir = join(cwd, "src", "lib", "components", fullPath);
124
+ if (!options?.skipPrompts && existsSync(destDir)) {
116
125
  const replace = await p.confirm({
117
126
  message: `Component "${name}" already exists at src/lib/components/${fullPath}/. Replace it?`,
118
127
  });
@@ -126,25 +135,20 @@ export async function addComponent(name: string, root = false) {
126
135
  mkdirSync(destDir, { recursive: true });
127
136
 
128
137
  for (const file of meta.files) {
129
- const content = await readFile(fullPath, file);
138
+ const content = await readRegistryFile(registryRoot, "components", fullPath, file);
130
139
  const dest = join(destDir, file);
131
- mkdirSync(dirname(dest), { recursive: true });
140
+ if (file.includes("/")) mkdirSync(dirname(dest), { recursive: true });
132
141
  writeFileSync(dest, content, "utf-8");
133
142
  console.log(` ✍️ src/lib/components/${fullPath}/${file}`);
134
143
  }
135
144
 
136
145
  // Install npm dependencies
137
- const npmEntries = Object.entries(meta.npmDeps);
138
- if (npmEntries.length > 0) {
139
- const packages = npmEntries.map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
140
- console.log(` 📥 npm: ${packages.join(", ")}`);
141
- const proc = spawn(["bun", "add", ...packages], {
142
- stdout: "inherit",
143
- stderr: "inherit",
144
- cwd: process.cwd(),
145
- });
146
- if ((await proc.exited) !== 0) {
147
- console.warn(` ⚠️ bun add failed for: ${packages.join(", ")}`);
146
+ if (Object.keys(meta.npmDeps).length > 0) {
147
+ if (options?.skipInstall) {
148
+ const { addedDeps } = mergePkgJson(cwd, { deps: meta.npmDeps });
149
+ if (addedDeps.length > 0) console.log(` 📥 Added to package.json: ${addedDeps.join(", ")}`);
150
+ } else {
151
+ await bunAdd(cwd, meta.npmDeps);
148
152
  }
149
153
  }
150
154
 
@@ -169,53 +173,3 @@ function ensureUtils() {
169
173
  console.log(" ✍️ src/lib/utils.ts (cn utility)\n");
170
174
  }
171
175
  }
172
-
173
- // ─── Registry resolvers ──────────────────────────────────────
174
-
175
- function resolveLocalRegistry(): string {
176
- // Walk up from this file's directory to find registry/
177
- let dir = dirname(new URL(import.meta.url).pathname);
178
- for (let i = 0; i < 10; i++) {
179
- const candidate = join(dir, "registry");
180
- if (existsSync(join(candidate, "index.json"))) return candidate;
181
- const parent = dirname(dir);
182
- if (parent === dir) break;
183
- dir = parent;
184
- }
185
- console.error("❌ Could not find local registry/ directory.");
186
- process.exit(1);
187
- }
188
-
189
- async function readMeta(name: string): Promise<ComponentMeta> {
190
- if (registryRoot) {
191
- const path = join(registryRoot, "components", name, "meta.json");
192
- if (!existsSync(path)) {
193
- throw new Error(`Component "${name}" not found in local registry`);
194
- }
195
- return JSON.parse(readFileSync(path, "utf-8"));
196
- }
197
- return fetchJSON<ComponentMeta>(`${REMOTE_BASE}/components/${name}/meta.json`);
198
- }
199
-
200
- async function readFile(name: string, file: string): Promise<string> {
201
- if (registryRoot) {
202
- const path = join(registryRoot, "components", name, file);
203
- if (!existsSync(path)) {
204
- throw new Error(`File "${file}" not found for component "${name}" in local registry`);
205
- }
206
- return readFileSync(path, "utf-8");
207
- }
208
- return fetchText(`${REMOTE_BASE}/components/${name}/${file}`);
209
- }
210
-
211
- async function fetchJSON<T>(url: string): Promise<T> {
212
- const res = await fetch(url);
213
- if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
214
- return res.json() as Promise<T>;
215
- }
216
-
217
- async function fetchText(url: string): Promise<string> {
218
- const res = await fetch(url);
219
- if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
220
- return res.text();
221
- }
package/src/cli/create.ts CHANGED
@@ -2,6 +2,8 @@ import { resolve, join, basename, relative } from "path";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { spawn } from "bun";
4
4
  import * as p from "@clack/prompts";
5
+ import { installFeature, initFeatRegistry, resolveLocalRegistry } from "./feat.ts";
6
+ import { initAddRegistry } from "./add.ts";
5
7
 
6
8
  // ─── bosia create <name> [--template <name>] ──────────────
7
9
 
@@ -59,7 +61,32 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
59
61
  writeFileSync(join(targetDir, ".env"), readFileSync(join(targetDir, ".env.example"), "utf-8"));
60
62
  }
61
63
 
62
- console.log(`✅ Project created at ${targetDir}\n`);
64
+ // Install template features from registry
65
+ const templateConfigPath = join(templateDir, "template.json");
66
+ if (existsSync(templateConfigPath)) {
67
+ const config = JSON.parse(readFileSync(templateConfigPath, "utf-8"));
68
+ if (config.features?.length) {
69
+ let localRegistry: string | null = null;
70
+ try {
71
+ localRegistry = resolveLocalRegistry();
72
+ } catch {
73
+ // Local registry not found — will use remote
74
+ }
75
+
76
+ await initAddRegistry(localRegistry);
77
+ initFeatRegistry(localRegistry);
78
+
79
+ for (const feat of config.features) {
80
+ await installFeature(feat, true, {
81
+ skipInstall: true,
82
+ skipPrompts: true,
83
+ cwd: targetDir,
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ console.log(`\n✅ Project created at ${targetDir}\n`);
63
90
 
64
91
  console.log("Installing dependencies...");
65
92
  const proc = spawn(["bun", "install"], {
@@ -72,7 +99,7 @@ export async function runCreate(name: string | undefined, args: string[] = []) {
72
99
  console.warn("⚠️ bun install failed — run it manually.");
73
100
  } else {
74
101
  console.log(`\n🎉 Ready!\n\ncd ${name}`);
75
-
102
+
76
103
  const instPath = join(templateDir, "instructions.txt");
77
104
  if (existsSync(instPath)) {
78
105
  const instructions = readFileSync(instPath, "utf-8").trimEnd();
@@ -117,9 +144,9 @@ function copyDir(src: string, dest: string, projectName: string, isLocal: boolea
117
144
  for (const entry of readdirSync(src, { withFileTypes: true })) {
118
145
  const srcPath = join(src, entry.name);
119
146
  const destPath = join(dest, entry.name);
120
-
121
- // Do not copy instructions.txt to the final project
122
- if (entry.name === "instructions.txt") continue;
147
+
148
+ // Do not copy instructions.txt or template.json to the final project
149
+ if (entry.name === "instructions.txt" || entry.name === "template.json") continue;
123
150
 
124
151
  if (entry.isDirectory()) {
125
152
  copyDir(srcPath, destPath, projectName, isLocal);
package/src/cli/feat.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  import { join, dirname } from "path";
2
2
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
3
- import { spawn } from "bun";
4
3
  import * as p from "@clack/prompts";
5
4
  import { addComponent, initAddRegistry } from "./add.ts";
5
+ import {
6
+ type InstallOptions,
7
+ resolveLocalRegistryOrExit,
8
+ readRegistryJSON,
9
+ readRegistryFile,
10
+ mergePkgJson,
11
+ bunAdd,
12
+ } from "./registry.ts";
6
13
 
7
14
  // ─── bosia feat <feature> [--local] ──────────────────────
8
15
  // Fetches a feature scaffold from the GitHub registry (or local
9
16
  // registry with --local) and copies route/lib files, installs npm deps.
10
17
  // Supports nested feature dependencies (e.g. todo → drizzle).
11
18
 
12
- const REGISTRY_BASE = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
13
-
14
19
  interface FeatureMeta {
15
20
  name: string;
16
21
  description: string;
@@ -19,6 +24,7 @@ interface FeatureMeta {
19
24
  files: string[]; // source filenames in the registry feature dir
20
25
  targets: string[]; // destination paths relative to project root
21
26
  npmDeps: Record<string, string>;
27
+ npmDevDeps?: Record<string, string>;
22
28
  scripts?: Record<string, string>; // package.json scripts to add
23
29
  envVars?: Record<string, string>; // env vars to append to .env if missing
24
30
  }
@@ -35,7 +41,7 @@ export async function runFeat(name: string | undefined, flags: string[] = []) {
35
41
  }
36
42
 
37
43
  if (flags.includes("--local")) {
38
- registryRoot = resolveLocalRegistry();
44
+ registryRoot = resolveLocalRegistryOrExit();
39
45
  console.log(`⬡ Using local registry: ${registryRoot}\n`);
40
46
  }
41
47
 
@@ -45,18 +51,25 @@ export async function runFeat(name: string | undefined, flags: string[] = []) {
45
51
  await installFeature(name, true);
46
52
  }
47
53
 
48
- async function installFeature(name: string, isRoot: boolean) {
54
+ /** Set the registry root for feature resolution. Called by create.ts for template features. */
55
+ export function initFeatRegistry(root: string | null) {
56
+ registryRoot = root;
57
+ }
58
+
59
+ export async function installFeature(name: string, isRoot: boolean, options?: InstallOptions) {
49
60
  if (installedFeats.has(name)) return;
50
61
  installedFeats.add(name);
51
62
 
63
+ const cwd = options?.cwd ?? process.cwd();
64
+
52
65
  console.log(isRoot ? `⬡ Installing feature: ${name}\n` : `\n⬡ Installing dependency feature: ${name}\n`);
53
66
 
54
- const meta = await readMeta(name);
67
+ const meta = await readRegistryJSON<FeatureMeta>(registryRoot, "features", name, "meta.json");
55
68
 
56
69
  // Install required feature dependencies first (recursive)
57
70
  if (meta.features && meta.features.length > 0) {
58
71
  for (const feat of meta.features) {
59
- await installFeature(feat, false);
72
+ await installFeature(feat, false, options);
60
73
  }
61
74
  }
62
75
 
@@ -64,19 +77,20 @@ async function installFeature(name: string, isRoot: boolean) {
64
77
  if (meta.components.length > 0) {
65
78
  console.log("📦 Installing required components...");
66
79
  for (const comp of meta.components) {
67
- await addComponent(comp, false);
80
+ await addComponent(comp, false, options);
68
81
  }
69
82
  console.log("");
70
83
  }
71
84
 
72
85
  // Copy feature files to their target paths
86
+ const createdDirs = new Set<string>();
73
87
  for (let i = 0; i < meta.files.length; i++) {
74
88
  const file = meta.files[i]!;
75
89
  const target = meta.targets[i] ?? file;
76
- const dest = join(process.cwd(), target);
90
+ const dest = join(cwd, target);
77
91
 
78
- // Prompt before overwriting existing files
79
- if (existsSync(dest)) {
92
+ // Prompt before overwriting existing files (skip check entirely in non-interactive mode)
93
+ if (!options?.skipPrompts && existsSync(dest)) {
80
94
  const replace = await p.confirm({
81
95
  message: `File "${target}" already exists. Replace it?`,
82
96
  });
@@ -86,52 +100,46 @@ async function installFeature(name: string, isRoot: boolean) {
86
100
  }
87
101
  }
88
102
 
89
- const content = await readFile(name, file);
90
- mkdirSync(dirname(dest), { recursive: true });
103
+ const content = await readRegistryFile(registryRoot, "features", name, file);
104
+ const dir = dirname(dest);
105
+ if (!createdDirs.has(dir)) {
106
+ mkdirSync(dir, { recursive: true });
107
+ createdDirs.add(dir);
108
+ }
91
109
  writeFileSync(dest, content, "utf-8");
92
110
  console.log(` ✍️ ${target}`);
93
111
  }
94
112
 
95
113
  // Install npm dependencies
96
- const npmEntries = Object.entries(meta.npmDeps);
97
- if (npmEntries.length > 0) {
98
- const packages = npmEntries.map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
99
- console.log(`\n📥 npm: ${packages.join(", ")}`);
100
- const proc = spawn(["bun", "add", ...packages], {
101
- stdout: "inherit",
102
- stderr: "inherit",
103
- cwd: process.cwd(),
104
- });
105
- if ((await proc.exited) !== 0) {
106
- console.warn(`⚠️ bun add failed for: ${packages.join(", ")}`);
107
- }
108
- }
109
-
110
- // Add package.json scripts
111
- const scriptEntries = Object.entries(meta.scripts ?? {});
112
- if (scriptEntries.length > 0) {
113
- const pkgPath = join(process.cwd(), "package.json");
114
- if (existsSync(pkgPath)) {
115
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
116
- pkg.scripts = pkg.scripts ?? {};
117
- const added: string[] = [];
118
- for (const [key, val] of scriptEntries) {
119
- if (!pkg.scripts[key]) {
120
- pkg.scripts[key] = val;
121
- added.push(key);
122
- }
123
- }
124
- if (added.length > 0) {
125
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
126
- console.log(`\n📜 Added scripts: ${added.join(", ")}`);
114
+ const hasDeps = Object.keys(meta.npmDeps).length > 0;
115
+ const hasDevDeps = Object.keys(meta.npmDevDeps ?? {}).length > 0;
116
+ const hasScripts = Object.keys(meta.scripts ?? {}).length > 0;
117
+
118
+ if (hasDeps || hasDevDeps) {
119
+ if (options?.skipInstall) {
120
+ const { addedDeps, addedScripts } = mergePkgJson(cwd, {
121
+ deps: meta.npmDeps,
122
+ devDeps: meta.npmDevDeps,
123
+ scripts: meta.scripts,
124
+ });
125
+ if (addedDeps.length > 0) console.log(`\n📥 Added to package.json: ${addedDeps.join(", ")}`);
126
+ if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
127
+ } else {
128
+ await bunAdd(cwd, meta.npmDeps, meta.npmDevDeps);
129
+ if (hasScripts) {
130
+ const { addedScripts } = mergePkgJson(cwd, { scripts: meta.scripts });
131
+ if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
127
132
  }
128
133
  }
134
+ } else if (hasScripts) {
135
+ const { addedScripts } = mergePkgJson(cwd, { scripts: meta.scripts });
136
+ if (addedScripts.length > 0) console.log(`\n📜 Added scripts: ${addedScripts.join(", ")}`);
129
137
  }
130
138
 
131
139
  // Append env vars to .env if missing
132
140
  const envEntries = Object.entries(meta.envVars ?? {});
133
141
  if (envEntries.length > 0) {
134
- const envPath = join(process.cwd(), ".env");
142
+ const envPath = join(cwd, ".env");
135
143
  const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
136
144
  const toAdd: string[] = [];
137
145
  for (const [key, val] of envEntries) {
@@ -154,51 +162,5 @@ async function installFeature(name: string, isRoot: boolean) {
154
162
  }
155
163
  }
156
164
 
157
- // ─── Registry resolvers ──────────────────────────────────────
158
-
159
- function resolveLocalRegistry(): string {
160
- let dir = dirname(new URL(import.meta.url).pathname);
161
- for (let i = 0; i < 10; i++) {
162
- const candidate = join(dir, "registry");
163
- if (existsSync(join(candidate, "index.json"))) return candidate;
164
- const parent = dirname(dir);
165
- if (parent === dir) break;
166
- dir = parent;
167
- }
168
- console.error("❌ Could not find local registry/ directory.");
169
- process.exit(1);
170
- }
171
-
172
- async function readMeta(name: string): Promise<FeatureMeta> {
173
- if (registryRoot) {
174
- const path = join(registryRoot, "features", name, "meta.json");
175
- if (!existsSync(path)) {
176
- throw new Error(`Feature "${name}" not found in local registry`);
177
- }
178
- return JSON.parse(readFileSync(path, "utf-8"));
179
- }
180
- return fetchJSON<FeatureMeta>(`${REGISTRY_BASE}/features/${name}/meta.json`);
181
- }
182
-
183
- async function readFile(name: string, file: string): Promise<string> {
184
- if (registryRoot) {
185
- const path = join(registryRoot, "features", name, file);
186
- if (!existsSync(path)) {
187
- throw new Error(`File "${file}" not found for feature "${name}" in local registry`);
188
- }
189
- return readFileSync(path, "utf-8");
190
- }
191
- return fetchText(`${REGISTRY_BASE}/features/${name}/${file}`);
192
- }
193
-
194
- async function fetchJSON<T>(url: string): Promise<T> {
195
- const res = await fetch(url);
196
- if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
197
- return res.json() as Promise<T>;
198
- }
199
-
200
- async function fetchText(url: string): Promise<string> {
201
- const res = await fetch(url);
202
- if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
203
- return res.text();
204
- }
165
+ // Re-exports for create.ts
166
+ export { resolveLocalRegistry, type InstallOptions } from "./registry.ts";
@@ -0,0 +1,168 @@
1
+ import { join, dirname } from "path";
2
+ import { writeFileSync, readFileSync, existsSync } from "fs";
3
+ import { spawn } from "bun";
4
+
5
+ // ─── Shared registry utilities for feat.ts and add.ts ─────
6
+
7
+ export const REGISTRY_URL = "https://raw.githubusercontent.com/bosapi/bosia/main/registry";
8
+
9
+ export interface InstallOptions {
10
+ skipInstall?: boolean; // write deps to package.json instead of `bun add`
11
+ skipPrompts?: boolean; // auto-overwrite, no interactive prompts
12
+ cwd?: string; // override process.cwd() for file operations
13
+ }
14
+
15
+ // ─── Local registry resolution ────────────────────────────
16
+
17
+ export function resolveLocalRegistry(): string {
18
+ let dir = dirname(new URL(import.meta.url).pathname);
19
+ for (let i = 0; i < 10; i++) {
20
+ const candidate = join(dir, "registry");
21
+ if (existsSync(join(candidate, "index.json"))) return candidate;
22
+ const parent = dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ throw new Error("Could not find local registry/ directory.");
27
+ }
28
+
29
+ /** Resolve local registry, exiting with error message on failure. For CLI entry points. */
30
+ export function resolveLocalRegistryOrExit(): string {
31
+ try {
32
+ return resolveLocalRegistry();
33
+ } catch {
34
+ console.error("❌ Could not find local registry/ directory.");
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ // ─── Registry file readers ────────────────────────────────
40
+
41
+ /** Read and parse a JSON file from the registry (local or remote). */
42
+ export async function readRegistryJSON<T>(
43
+ registryRoot: string | null,
44
+ category: string,
45
+ name: string,
46
+ file: string,
47
+ ): Promise<T> {
48
+ if (registryRoot) {
49
+ const path = join(registryRoot, category, name, file);
50
+ if (!existsSync(path)) {
51
+ throw new Error(`"${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
52
+ }
53
+ return JSON.parse(readFileSync(path, "utf-8"));
54
+ }
55
+ return fetchJSON<T>(`${REGISTRY_URL}/${category}/${name}/${file}`);
56
+ }
57
+
58
+ /** Read a text file from the registry (local or remote). */
59
+ export async function readRegistryFile(
60
+ registryRoot: string | null,
61
+ category: string,
62
+ name: string,
63
+ file: string,
64
+ ): Promise<string> {
65
+ if (registryRoot) {
66
+ const path = join(registryRoot, category, name, file);
67
+ if (!existsSync(path)) {
68
+ throw new Error(`File "${file}" not found for ${category.slice(0, -1)} "${name}" in local registry`);
69
+ }
70
+ return readFileSync(path, "utf-8");
71
+ }
72
+ return fetchText(`${REGISTRY_URL}/${category}/${name}/${file}`);
73
+ }
74
+
75
+ // ─── package.json helpers ─────────────────────────────────
76
+
77
+ export interface PkgDeps {
78
+ deps?: Record<string, string>;
79
+ devDeps?: Record<string, string>;
80
+ scripts?: Record<string, string>;
81
+ }
82
+
83
+ /**
84
+ * Merge dependencies and scripts into package.json in a single read/write.
85
+ * Returns the list of added keys, or empty arrays if nothing changed.
86
+ */
87
+ export function mergePkgJson(cwd: string, changes: PkgDeps): { addedDeps: string[]; addedScripts: string[] } {
88
+ const pkgPath = join(cwd, "package.json");
89
+ if (!existsSync(pkgPath)) return { addedDeps: [], addedScripts: [] };
90
+
91
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
92
+ let changed = false;
93
+ const addedDeps: string[] = [];
94
+ const addedScripts: string[] = [];
95
+
96
+ if (changes.deps && Object.keys(changes.deps).length > 0) {
97
+ pkg.dependencies = pkg.dependencies ?? {};
98
+ for (const [name, ver] of Object.entries(changes.deps)) {
99
+ if (!pkg.dependencies[name]) {
100
+ pkg.dependencies[name] = ver;
101
+ addedDeps.push(name);
102
+ changed = true;
103
+ }
104
+ }
105
+ }
106
+
107
+ if (changes.devDeps && Object.keys(changes.devDeps).length > 0) {
108
+ pkg.devDependencies = pkg.devDependencies ?? {};
109
+ for (const [name, ver] of Object.entries(changes.devDeps)) {
110
+ if (!pkg.devDependencies[name]) {
111
+ pkg.devDependencies[name] = ver;
112
+ addedDeps.push(name);
113
+ changed = true;
114
+ }
115
+ }
116
+ }
117
+
118
+ if (changes.scripts && Object.keys(changes.scripts).length > 0) {
119
+ pkg.scripts = pkg.scripts ?? {};
120
+ for (const [key, val] of Object.entries(changes.scripts)) {
121
+ if (!pkg.scripts[key]) {
122
+ pkg.scripts[key] = val;
123
+ addedScripts.push(key);
124
+ changed = true;
125
+ }
126
+ }
127
+ }
128
+
129
+ if (changed) {
130
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
131
+ }
132
+
133
+ return { addedDeps, addedScripts };
134
+ }
135
+
136
+ /** Run `bun add` for deps and optionally `bun add --dev` for devDeps. */
137
+ export async function bunAdd(cwd: string, deps: Record<string, string>, devDeps?: Record<string, string>): Promise<void> {
138
+ const packages = Object.entries(deps).map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
139
+ if (packages.length > 0) {
140
+ console.log(`\n📥 npm: ${packages.join(", ")}`);
141
+ const proc = spawn(["bun", "add", ...packages], { stdout: "inherit", stderr: "inherit", cwd });
142
+ if ((await proc.exited) !== 0) {
143
+ console.warn(`⚠️ bun add failed for: ${packages.join(", ")}`);
144
+ }
145
+ }
146
+ const devPackages = Object.entries(devDeps ?? {}).map(([pkg, ver]) => (ver ? `${pkg}@${ver}` : pkg));
147
+ if (devPackages.length > 0) {
148
+ console.log(`\n📥 npm (dev): ${devPackages.join(", ")}`);
149
+ const proc = spawn(["bun", "add", "--dev", ...devPackages], { stdout: "inherit", stderr: "inherit", cwd });
150
+ if ((await proc.exited) !== 0) {
151
+ console.warn(`⚠️ bun add --dev failed for: ${devPackages.join(", ")}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // ─── HTTP helpers ─────────────────────────────────────────
157
+
158
+ async function fetchJSON<T>(url: string): Promise<T> {
159
+ const res = await fetch(url);
160
+ if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
161
+ return res.json() as Promise<T>;
162
+ }
163
+
164
+ async function fetchText(url: string): Promise<string> {
165
+ const res = await fetch(url);
166
+ if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
167
+ return res.text();
168
+ }