@organcli/composed-cli 0.1.2 → 0.1.3

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.
package/dist/index.js CHANGED
@@ -1,1241 +1,1356 @@
1
- #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { Command } from "commander";
5
-
6
- // src/commands/add.ts
7
- import pc from "picocolors";
8
-
9
- // src/lib/install-files.ts
10
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
- import { dirname as dirname2, join as join4, relative } from "path";
12
-
13
- // src/lib/controllers.ts
14
- var CONTROLLERS_PREFIX = "src/components/common/controllers/";
15
- function toKebabCase(name) {
16
- return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
17
- }
18
- function findControllerPath(source, exportName) {
19
- const guessed = `${CONTROLLERS_PREFIX}${toKebabCase(exportName)}.tsx`;
20
- if (source.hasFile(guessed) && source.readFile(guessed).includes(`export function ${exportName}`)) {
21
- return guessed;
22
- }
23
- for (const path of source.listFiles(CONTROLLERS_PREFIX)) {
24
- if (!path.endsWith(".tsx")) continue;
25
- if (path.endsWith("/index.tsx") || path.endsWith("/index.ts")) continue;
26
- if (source.readFile(path).includes(`export function ${exportName}`)) {
27
- return path;
28
- }
29
- }
30
- return void 0;
31
- }
32
- function controllerBarrelExport(exportName, relPath) {
33
- const fileName = relPath.split("/").pop().replace(/\.tsx?$/, "");
34
- return `export { ${exportName} } from './${fileName}';`;
35
- }
36
-
37
- // src/lib/i18n.ts
38
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
39
- import { join as join2 } from "path";
40
-
41
- // src/lib/source.ts
42
- import { existsSync, readFileSync, readdirSync } from "fs";
43
- import { dirname, join } from "path";
44
- import { fileURLToPath, pathToFileURL } from "url";
45
- var here = dirname(fileURLToPath(import.meta.url));
46
- function findAncestorContaining(startDir, markerRelPath, maxLevels = 6) {
47
- let dir = startDir;
48
- for (let i = 0; i <= maxLevels; i++) {
49
- if (existsSync(join(dir, markerRelPath))) return dir;
50
- const parent = dirname(dir);
51
- if (parent === dir) return void 0;
52
- dir = parent;
53
- }
54
- return void 0;
55
- }
56
- function loadFromBundle() {
57
- const distDir = findAncestorContaining(here, "registry-bundle.json");
58
- if (!distDir) return void 0;
59
- const bundle = JSON.parse(
60
- readFileSync(join(distDir, "registry-bundle.json"), "utf-8")
61
- );
62
- return {
63
- registry: bundle.registry,
64
- featureRegistry: bundle.featureRegistry ?? [],
65
- readFile: (path) => {
66
- const content = bundle.files[path];
67
- if (content === void 0) {
68
- throw new Error(`registry-bundle.json is missing file: ${path}`);
69
- }
70
- return content;
71
- },
72
- hasFile: (path) => path in bundle.files,
73
- listFiles: (prefix) => Object.keys(bundle.files).filter((p) => p === prefix || p.startsWith(prefix)),
74
- readMessages: (locale) => bundle.messages[locale] ?? {}
75
- };
76
- }
77
- async function loadFromBoilerplateTree() {
78
- const boilerplateRoot = overrideRoot ?? findAncestorContaining(here, join("src", "registry", "components.ts"));
79
- if (!boilerplateRoot) return void 0;
80
- if (!existsSync(join(boilerplateRoot, "src", "registry", "components.ts"))) {
81
- return void 0;
82
- }
83
- const registryPath = join(boilerplateRoot, "src", "registry", "components.ts");
84
- const mod = await import(
85
- /* @vite-ignore */
86
- pathToFileURL(registryPath).href
87
- );
88
- const featuresPath = join(boilerplateRoot, "src", "registry", "features.ts");
89
- let featureRegistry = [];
90
- if (existsSync(featuresPath)) {
91
- const featuresMod = await import(
92
- /* @vite-ignore */
93
- pathToFileURL(featuresPath).href
94
- );
95
- featureRegistry = featuresMod.featureRegistry;
96
- }
97
- return {
98
- registry: mod.componentRegistry,
99
- featureRegistry,
100
- readFile: (path) => {
101
- const abs = join(boilerplateRoot, path);
102
- if (!existsSync(abs)) {
103
- throw new Error(`Source file missing from boilerplate tree: ${path}`);
104
- }
105
- return readFileSync(abs, "utf-8");
106
- },
107
- hasFile: (path) => existsSync(join(boilerplateRoot, path)),
108
- listFiles: (prefix) => listFilesFromTree(boilerplateRoot, prefix),
109
- readMessages: (locale) => readMessagesFromTree(boilerplateRoot, locale)
110
- };
111
- }
112
- function listFilesFromTree(boilerplateRoot, prefix) {
113
- const abs = join(boilerplateRoot, prefix);
114
- if (!existsSync(abs)) return [];
115
- const out = [];
116
- for (const entry of readdirSync(abs, { withFileTypes: true })) {
117
- const rel = `${prefix.replace(/\\/g, "/").replace(/\/$/, "")}/${entry.name}`;
118
- if (entry.isDirectory()) {
119
- out.push(...listFilesFromTree(boilerplateRoot, rel));
120
- } else {
121
- out.push(rel);
122
- }
123
- }
124
- return out;
125
- }
126
- function readMessagesFromTree(boilerplateRoot, locale) {
127
- const dir = join(boilerplateRoot, "messages", locale);
128
- const out = {};
129
- if (!existsSync(dir)) return out;
130
- for (const file of readdirSync(dir)) {
131
- if (!file.endsWith(".json")) continue;
132
- const json = JSON.parse(readFileSync(join(dir, file), "utf-8"));
133
- for (const [key, value] of Object.entries(json)) {
134
- if (key === "$schema") continue;
135
- out[key] = String(value);
136
- }
137
- }
138
- return out;
139
- }
140
- var cached;
141
- var overrideRoot;
142
- function setSourceRoot(root) {
143
- cached = void 0;
144
- overrideRoot = root;
145
- }
146
- function getSourceRoot() {
147
- return overrideRoot;
148
- }
149
- function resolveSourceRoot() {
150
- return overrideRoot ?? findAncestorContaining(here, join("src", "registry", "components.ts"));
151
- }
152
- async function getComponentSource() {
153
- if (cached) return cached;
154
- const source = overrideRoot ? await loadFromBoilerplateTree() : loadFromBundle() ?? await loadFromBoilerplateTree();
155
- if (!source) {
156
- throw new Error(
157
- "Could not locate component source. Expected either a built dist/registry-bundle.json (published package) or a sibling src/registry/components.ts (running inside the boilerplate repo)."
158
- );
159
- }
160
- cached = source;
161
- return source;
162
- }
163
-
164
- // src/lib/i18n.ts
165
- var STRINGS_RUNTIME = `// Generated by composed-cli. Minimal string lookup for components copied
166
- // from the BIM boilerplate \u2014 replace with your own i18n solution as needed.
167
- import strings from './strings.json';
168
-
169
- export function t(key: keyof typeof strings, params?: Record<string, string | number>) {
170
- let value = strings[key] ?? key;
171
- if (params) {
172
- for (const [name, val] of Object.entries(params)) {
173
- value = value.replaceAll(\`{\${name}}\`, String(val));
174
- }
175
- }
176
- return value;
177
- }
178
- `;
179
- async function installI18nStrings(cwd, keys, destDir = "src/lib/composed-strings") {
180
- if (keys.length === 0) return { ok: true, addedKeys: [], path: "" };
181
- const source = await getComponentSource();
182
- const enMessages = source.readMessages("en");
183
- const outDir = join2(cwd, destDir);
184
- mkdirSync(outDir, { recursive: true });
185
- const stringsPath = join2(outDir, "strings.json");
186
- const existing = existsSync2(stringsPath) ? JSON.parse(readFileSync2(stringsPath, "utf-8")) : {};
187
- const addedKeys = [];
188
- for (const key of keys) {
189
- if (!(key in enMessages)) continue;
190
- if (!(key in existing)) addedKeys.push(key);
191
- existing[key] = enMessages[key];
192
- }
193
- const sorted = Object.fromEntries(
194
- Object.keys(existing).sort().map((key) => [key, existing[key]])
195
- );
196
- writeFileSync(stringsPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
197
- const runtimePath = join2(outDir, "index.ts");
198
- if (!existsSync2(runtimePath)) {
199
- writeFileSync(runtimePath, STRINGS_RUNTIME, "utf-8");
200
- }
201
- return { ok: true, addedKeys, path: join2(destDir, "index.ts") };
202
- }
203
- function rewriteParaglideUsage(source, importPath) {
204
- let out = source.replace(
205
- /import\s*\{\s*m\s*\}\s*from\s*['"]#\/paraglide\/messages['"];?\n?/,
206
- `import { t } from '${importPath}';
207
- `
208
- );
209
- out = out.replace(/\bm\.([a-zA-Z0-9_]+)\(\s*\)/g, "t('$1')");
210
- out = out.replace(/\bm\.([a-zA-Z0-9_]+)\(\s*(\{[^}]*\})\s*\)/g, "t('$1', $2)");
211
- return out;
212
- }
213
-
214
- // src/lib/target-config.ts
215
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
216
- import { join as join3 } from "path";
217
- var defaultAliases = {
218
- components: "@/components",
219
- ui: "@/components/ui",
220
- utils: "@/lib/utils",
221
- lib: "@/lib"
222
- };
223
- function readTargetAliases(cwd) {
224
- const configPath = join3(cwd, "components.json");
225
- if (!existsSync3(configPath)) return defaultAliases;
226
- try {
227
- const config = JSON.parse(readFileSync3(configPath, "utf-8"));
228
- return {
229
- components: config.aliases?.components ?? defaultAliases.components,
230
- ui: config.aliases?.ui ?? defaultAliases.ui,
231
- utils: config.aliases?.utils ?? defaultAliases.utils,
232
- lib: config.aliases?.lib ?? defaultAliases.lib
233
- };
234
- } catch {
235
- return defaultAliases;
236
- }
237
- }
238
- function aliasToPath(cwd, alias) {
239
- const bareAlias = alias.replace(/^[@#]\//, "");
240
- const base = existsSync3(join3(cwd, "src")) ? join3(cwd, "src") : cwd;
241
- return join3(base, bareAlias);
242
- }
243
-
244
- // src/lib/install-files.ts
245
- async function copyComponentFiles(cwd, entry, opts) {
246
- const source = await getComponentSource();
247
- const aliases = readTargetAliases(cwd);
248
- const uiDir = aliasToPath(cwd, aliases.ui);
249
- const composedDir = join4(uiDir, "composed", entry.slug);
250
- const results = [];
251
- for (const file of entry.files) {
252
- const fileName = file.path.split("/").pop();
253
- const targetPath = join4(composedDir, fileName);
254
- if (existsSync4(targetPath) && !opts.overwrite) {
255
- results.push({
256
- entryName: entry.name,
257
- targetPath,
258
- skipped: true,
259
- reason: "already exists (use --overwrite to replace)"
260
- });
261
- continue;
262
- }
263
- let content = source.readFile(file.path);
264
- if (entry.i18n.required) {
265
- content = rewriteParaglideUsage(content, opts.stringsImportPath);
266
- }
267
- mkdirSync2(dirname2(targetPath), { recursive: true });
268
- writeFileSync2(targetPath, content, "utf-8");
269
- results.push({ entryName: entry.name, targetPath, skipped: false });
270
- }
271
- if (entry.controller) {
272
- const controller = await copyController(cwd, entry, opts);
273
- if (controller) results.push(controller);
274
- }
275
- return results;
276
- }
277
- async function copyController(cwd, entry, opts) {
278
- if (!entry.controller) return void 0;
279
- const source = await getComponentSource();
280
- const controllerPath = findControllerPath(source, entry.controller);
281
- if (!controllerPath) {
282
- throw new Error(
283
- `Could not find controller file for ${entry.name} (${entry.controller})`
284
- );
285
- }
286
- const aliases = readTargetAliases(cwd);
287
- const fileName = controllerPath.split("/").pop();
288
- const targetPath = join4(
289
- aliasToPath(cwd, aliases.components),
290
- "common",
291
- "controllers",
292
- fileName
293
- );
294
- if (existsSync4(targetPath) && !opts.overwrite) {
295
- return {
296
- entryName: entry.controller,
297
- targetPath,
298
- skipped: true,
299
- reason: "already exists (use --overwrite to replace)"
300
- };
301
- }
302
- mkdirSync2(dirname2(targetPath), { recursive: true });
303
- writeFileSync2(targetPath, source.readFile(controllerPath), "utf-8");
304
- return { entryName: entry.controller, targetPath, skipped: false };
305
- }
306
- async function copyLocalUtil(cwd, localDep, opts) {
307
- if (localDep !== "@/lib/utils") return void 0;
308
- const source = await getComponentSource();
309
- const aliases = readTargetAliases(cwd);
310
- const utilsAbsPath = aliasToPath(cwd, aliases.utils) + ".ts";
311
- if (existsSync4(utilsAbsPath) && !opts.overwrite) {
312
- return {
313
- entryName: "cn",
314
- targetPath: utilsAbsPath,
315
- skipped: true,
316
- reason: "already exists"
317
- };
318
- }
319
- const content = source.readFile("src/lib/utils.ts");
320
- mkdirSync2(dirname2(utilsAbsPath), { recursive: true });
321
- writeFileSync2(utilsAbsPath, content, "utf-8");
322
- return { entryName: "cn", targetPath: utilsAbsPath, skipped: false };
323
- }
324
- function toDisplayPath(cwd, absPath) {
325
- return relative(cwd, absPath).split("\\").join("/");
326
- }
327
-
328
- // src/lib/npm-install.ts
329
- import { spawnSync } from "child_process";
330
-
331
- // src/lib/package-manager.ts
332
- import { existsSync as existsSync5 } from "fs";
333
- import { join as join5 } from "path";
334
- var lockfiles = {
335
- "bun.lock": "bun",
336
- "bun.lockb": "bun",
337
- "pnpm-lock.yaml": "pnpm",
338
- "yarn.lock": "yarn",
339
- "package-lock.json": "npm"
340
- };
341
- function detectPackageManager(cwd) {
342
- for (const [lockfile, pm] of Object.entries(lockfiles)) {
343
- if (existsSync5(join5(cwd, lockfile))) return pm;
344
- }
345
- const userAgent = process.env.npm_config_user_agent ?? "";
346
- if (userAgent.startsWith("bun")) return "bun";
347
- if (userAgent.startsWith("pnpm")) return "pnpm";
348
- if (userAgent.startsWith("yarn")) return "yarn";
349
- return "npm";
350
- }
351
- function installCommand(pm, packages) {
352
- switch (pm) {
353
- case "bun":
354
- return ["bun", ["add", ...packages]];
355
- case "pnpm":
356
- return ["pnpm", ["add", ...packages]];
357
- case "yarn":
358
- return ["yarn", ["add", ...packages]];
359
- case "npm":
360
- default:
361
- return ["npm", ["install", ...packages]];
362
- }
363
- }
364
- function dlxCommand(pm, args) {
365
- switch (pm) {
366
- case "bun":
367
- return ["bunx", args];
368
- case "pnpm":
369
- return ["pnpm", ["dlx", ...args]];
370
- case "yarn":
371
- return ["yarn", ["dlx", ...args]];
372
- case "npm":
373
- default:
374
- return ["npx", args];
375
- }
376
- }
377
-
378
- // src/lib/npm-install.ts
379
- function installNpmPackages(cwd, packages, pm) {
380
- if (packages.length === 0) return { ok: true };
381
- const [cmd, args] = installCommand(pm, packages);
382
- const result = spawnSync(cmd, args, {
383
- cwd,
384
- stdio: "inherit",
385
- shell: process.platform === "win32"
386
- });
387
- if (result.status !== 0) {
388
- return { ok: false, message: `${cmd} exited with code ${result.status}` };
389
- }
390
- return { ok: true };
391
- }
392
-
393
- // src/lib/resolve.ts
394
- import { existsSync as existsSync6 } from "fs";
395
- import { dirname as dirname3, isAbsolute, relative as relative2, resolve } from "path";
396
-
397
- // src/lib/registry.ts
398
- async function loadRegistry() {
399
- const source = await getComponentSource();
400
- return source.registry;
401
- }
402
- async function findComponent(query) {
403
- const registry = await loadRegistry();
404
- const byName = registry.find((e) => e.name === query);
405
- if (byName) return byName;
406
- const bySlug = registry.filter((e) => e.slug === query);
407
- if (bySlug.length === 1) return bySlug[0];
408
- if (bySlug.length > 1) return bySlug;
409
- return void 0;
410
- }
411
-
412
- // src/lib/resolve.ts
413
- async function resolveQueries(queries) {
414
- const entries = [];
415
- const notFound = [];
416
- const ambiguous = {};
417
- const seen = /* @__PURE__ */ new Set();
418
- for (const query of queries) {
419
- const fromPath = looksLikePath(query) ? await findComponentsByPath(query) : void 0;
420
- if (fromPath) {
421
- if (fromPath.length === 0) {
422
- notFound.push(query);
423
- continue;
424
- }
425
- for (const match2 of fromPath) {
426
- if (!seen.has(match2.name)) {
427
- seen.add(match2.name);
428
- entries.push(match2);
429
- }
430
- }
431
- continue;
432
- }
433
- const match = await findComponent(query);
434
- if (!match) {
435
- notFound.push(query);
436
- continue;
437
- }
438
- if (Array.isArray(match)) {
439
- ambiguous[query] = match.map((e) => e.name);
440
- continue;
441
- }
442
- if (!seen.has(match.name)) {
443
- seen.add(match.name);
444
- entries.push(match);
445
- }
446
- }
447
- return { entries, notFound, ambiguous };
448
- }
449
- function unionDependencies(entries) {
450
- const shadcn = /* @__PURE__ */ new Set();
451
- const npm = /* @__PURE__ */ new Set();
452
- const local = /* @__PURE__ */ new Set();
453
- const i18nKeys = /* @__PURE__ */ new Set();
454
- for (const entry of entries) {
455
- entry.dependencies.shadcn.forEach((s) => shadcn.add(s));
456
- entry.dependencies.npm.forEach((n) => npm.add(n));
457
- entry.dependencies.local.forEach((l) => local.add(l));
458
- entry.i18n.keys.forEach((k) => i18nKeys.add(k));
459
- }
460
- return {
461
- shadcn: [...shadcn],
462
- npm: [...npm],
463
- local: [...local],
464
- i18nKeys: [...i18nKeys]
465
- };
466
- }
467
- async function allComponentNames() {
468
- return (await loadRegistry()).map((e) => `${e.name} (${e.slug})`);
469
- }
470
- function looksLikePath(query) {
471
- return isAbsolute(query) || /[\\/]/.test(query);
472
- }
473
- function toPosix(query) {
474
- return query.replace(/\\/g, "/").replace(/\/$/, "");
475
- }
476
- function registryPrefixes(query) {
477
- const posix = toPosix(query);
478
- if (!posix || isAbsolute(query)) return [];
479
- const prefixes = [posix];
480
- if (!posix.startsWith("src/")) prefixes.push(`src/${posix}`);
481
- return prefixes;
482
- }
483
- function matchesPrefix(filePath, prefix) {
484
- return filePath === prefix || filePath.startsWith(`${prefix}/`);
485
- }
486
- function findBoilerplateRoot(start) {
487
- let dir = start;
488
- for (let i = 0; i <= 8; i++) {
489
- if (existsSync6(resolve(dir, "src", "registry", "components.ts"))) return dir;
490
- const parent = dirname3(dir);
491
- if (parent === dir) return void 0;
492
- dir = parent;
493
- }
494
- return void 0;
495
- }
496
- function queryToRelPath(query) {
497
- const base = getSourceRoot() ?? process.cwd();
498
- const abs = isAbsolute(query) ? query : resolve(base, query);
499
- if (!existsSync6(abs)) return void 0;
500
- const root = getSourceRoot() ?? findBoilerplateRoot(abs) ?? findBoilerplateRoot(process.cwd());
501
- if (!root) return void 0;
502
- const rel = relative2(root, abs).split("\\").join("/");
503
- if (!rel || rel.startsWith("..")) return void 0;
504
- return rel.replace(/\/$/, "");
505
- }
506
- async function findComponentsByPath(query) {
507
- const rel = queryToRelPath(query);
508
- const prefixes = rel ? [rel] : registryPrefixes(query);
509
- if (prefixes.length === 0) return void 0;
510
- const registry = await loadRegistry();
511
- return registry.filter(
512
- (entry) => entry.files.some((file) => prefixes.some((prefix) => matchesPrefix(file.path, prefix)))
513
- );
514
- }
515
-
516
- // src/lib/shadcn.ts
517
- import { existsSync as existsSync7 } from "fs";
518
- import { join as join6 } from "path";
519
- import { spawnSync as spawnSync2 } from "child_process";
520
- function hasShadcnConfig(cwd) {
521
- return existsSync7(join6(cwd, "components.json"));
522
- }
523
- function installShadcnPrimitives(cwd, slugs, pm) {
524
- if (slugs.length === 0) return [];
525
- const [cmd, baseArgs] = dlxCommand(pm, ["shadcn@latest", "add", ...slugs, "-y"]);
526
- const result = spawnSync2(cmd, baseArgs, {
527
- cwd,
528
- stdio: "inherit",
529
- shell: process.platform === "win32"
530
- });
531
- const ok = result.status === 0;
532
- return slugs.map((slug) => ({
533
- slug,
534
- ok,
535
- message: ok ? void 0 : `shadcn add exited with code ${result.status}`
536
- }));
537
- }
538
-
539
- // src/lib/validate-install.ts
540
- import { existsSync as existsSync8, readFileSync as readFileSync4 } from "fs";
541
- function validateCopiedFiles(files) {
542
- const issues = [];
543
- for (const file of files) {
544
- if (!existsSync8(file.targetPath)) {
545
- issues.push({ file: file.targetPath, message: "file was not written" });
546
- continue;
547
- }
548
- const content = readFileSync4(file.targetPath, "utf-8");
549
- if (/from\s*['"]#\/paraglide\/messages['"]/.test(content)) {
550
- issues.push({
551
- file: file.targetPath,
552
- message: "still imports Paraglide messages (rewrite did not apply)"
553
- });
554
- }
555
- if (/\bm\.[a-zA-Z0-9_]+\(/.test(content)) {
556
- issues.push({
557
- file: file.targetPath,
558
- message: "still calls m.*() (rewrite missed a usage)"
559
- });
560
- }
561
- }
562
- return issues;
563
- }
564
-
565
- // src/commands/add.ts
566
- async function runAdd(queries, options) {
567
- const { cwd } = options;
568
- if (queries.length === 0) {
569
- console.error(pc.red("Usage: composed-cli add <component> [components...]"));
570
- console.error("");
571
- console.error("Available components:");
572
- for (const name of await allComponentNames()) console.error(` ${name}`);
573
- process.exitCode = 1;
574
- return;
575
- }
576
- const { entries, notFound, ambiguous } = await resolveQueries(queries);
577
- if (notFound.length > 0) {
578
- console.error(pc.red(`Unknown component(s): ${notFound.join(", ")}`));
579
- }
580
- for (const [query, names] of Object.entries(ambiguous)) {
581
- console.error(
582
- pc.red(
583
- `"${query}" matches multiple components: ${names.join(", ")}. Use one of those exact names.`
584
- )
585
- );
586
- }
587
- if (entries.length === 0) {
588
- process.exitCode = 1;
589
- return;
590
- }
591
- if (!hasShadcnConfig(cwd)) {
592
- console.error(
593
- pc.red(
594
- "No components.json found in this project. Run `shadcn init` before `composed-cli add`."
595
- )
596
- );
597
- process.exitCode = 1;
598
- return;
599
- }
600
- console.log(pc.bold(`Installing: ${entries.map((e) => e.name).join(", ")}`));
601
- const deps = unionDependencies(entries);
602
- const pm = detectPackageManager(cwd);
603
- if (deps.shadcn.length > 0 && !options.skipShadcn) {
604
- console.log(pc.cyan(`
605
- > Installing shadcn primitives: ${deps.shadcn.join(", ")}`));
606
- const results = installShadcnPrimitives(cwd, deps.shadcn, pm);
607
- const failed = results.filter((r) => !r.ok);
608
- if (failed.length > 0) {
609
- console.error(
610
- pc.red(`shadcn add failed for: ${failed.map((f) => f.slug).join(", ")}`)
611
- );
612
- process.exitCode = 1;
613
- return;
614
- }
615
- } else if (deps.shadcn.length > 0) {
616
- console.log(pc.dim(`
617
- > Skipped shadcn primitives (--skip-shadcn): ${deps.shadcn.join(", ")}`));
618
- }
619
- if (deps.npm.length > 0 && !options.skipNpm) {
620
- console.log(pc.cyan(`
621
- > Installing npm packages: ${deps.npm.join(", ")}`));
622
- const result = installNpmPackages(cwd, deps.npm, pm);
623
- if (!result.ok) {
624
- console.error(pc.red(`npm install failed: ${result.message}`));
625
- process.exitCode = 1;
626
- return;
627
- }
628
- } else if (deps.npm.length > 0) {
629
- console.log(pc.dim(`
630
- > Skipped npm packages (--skip-npm): ${deps.npm.join(", ")}`));
631
- }
632
- const stringsImportPath = "@/lib/composed-strings";
633
- let i18nAdded = [];
634
- if (deps.i18nKeys.length > 0 && !options.skipI18n) {
635
- const result = await installI18nStrings(cwd, deps.i18nKeys);
636
- i18nAdded = result.addedKeys;
637
- }
638
- console.log(pc.cyan(`
639
- > Copying component files`));
640
- const copiedFiles = [];
641
- for (const entry of entries) {
642
- const copied = await copyComponentFiles(cwd, entry, {
643
- overwrite: options.overwrite,
644
- stringsImportPath
645
- });
646
- copiedFiles.push(...copied);
647
- for (const file of copied) {
648
- if (file.skipped) {
649
- console.log(pc.yellow(` skip ${toDisplayPath(cwd, file.targetPath)} (${file.reason})`));
650
- } else {
651
- console.log(pc.green(` add ${toDisplayPath(cwd, file.targetPath)}`));
652
- }
653
- }
654
- if (entry.knownIssues?.length) {
655
- for (const issue of entry.knownIssues) {
656
- console.log(pc.yellow(` ! ${entry.name}: ${issue}`));
657
- }
658
- }
659
- }
660
- for (const localDep of deps.local) {
661
- const copied = await copyLocalUtil(cwd, localDep, { overwrite: options.overwrite });
662
- if (copied) {
663
- copiedFiles.push(copied);
664
- const line = copied.skipped ? pc.yellow(` skip ${toDisplayPath(cwd, copied.targetPath)} (${copied.reason})`) : pc.green(` add ${toDisplayPath(cwd, copied.targetPath)}`);
665
- console.log(line);
666
- }
667
- }
668
- if (i18nAdded.length > 0) {
669
- console.log(pc.green(` add src/lib/composed-strings/ (${i18nAdded.length} key(s))`));
670
- }
671
- const issues = validateCopiedFiles(copiedFiles);
672
- if (issues.length > 0) {
673
- console.log(pc.red(`
674
- > Validation found ${issues.length} issue(s):`));
675
- for (const issue of issues) {
676
- console.log(pc.red(` ${toDisplayPath(cwd, issue.file)}: ${issue.message}`));
677
- }
678
- process.exitCode = 1;
679
- return;
680
- }
681
- console.log(pc.bold(pc.green(`
682
- Done. Installed ${entries.length} component(s).`)));
683
- }
684
-
685
- // src/commands/create.ts
686
- import { existsSync as existsSync12 } from "fs";
687
- import pc2 from "picocolors";
688
-
689
- // src/lib/framework-detect.ts
690
- import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
691
- import { join as join7 } from "path";
692
- function readPackageJson(cwd) {
693
- const pkgPath = join7(cwd, "package.json");
694
- if (!existsSync9(pkgPath)) return void 0;
695
- try {
696
- return JSON.parse(readFileSync5(pkgPath, "utf-8"));
697
- } catch {
698
- return void 0;
699
- }
700
- }
701
- function allDeps(pkg) {
702
- return { ...pkg.dependencies, ...pkg.devDependencies };
703
- }
704
- function detectTargetFramework(cwd) {
705
- const pkg = readPackageJson(cwd);
706
- if (pkg) {
707
- const deps = allDeps(pkg);
708
- if ("@tanstack/react-start" in deps || "@tanstack/start" in deps) {
709
- return {
710
- framework: "tanstack",
711
- reason: "package.json depends on @tanstack/react-start"
712
- };
713
- }
714
- if ("next" in deps) {
715
- return { framework: "nextjs", reason: "package.json depends on next" };
716
- }
717
- }
718
- if (existsSync9(join7(cwd, "next.config.js")) || existsSync9(join7(cwd, "next.config.ts")) || existsSync9(join7(cwd, "next.config.mjs"))) {
719
- return { framework: "nextjs", reason: "found a next.config.* file" };
720
- }
721
- if (existsSync9(join7(cwd, "app")) && (existsSync9(join7(cwd, "app", "layout.tsx")) || existsSync9(join7(cwd, "app", "layout.js")))) {
722
- return { framework: "nextjs", reason: "found app/layout.tsx (Next.js App Router)" };
723
- }
724
- if (existsSync9(join7(cwd, "src", "app")) && (existsSync9(join7(cwd, "src", "app", "layout.tsx")) || existsSync9(join7(cwd, "src", "app", "layout.js")))) {
725
- return { framework: "nextjs", reason: "found src/app/layout.tsx (Next.js App Router)" };
726
- }
727
- if (existsSync9(join7(cwd, "src", "routeTree.gen.ts"))) {
728
- return {
729
- framework: "tanstack",
730
- reason: "found src/routeTree.gen.ts (TanStack Router)"
731
- };
732
- }
733
- if (existsSync9(join7(cwd, "src", "routes")) && existsSync9(join7(cwd, "src", "router.tsx"))) {
734
- return {
735
- framework: "tanstack",
736
- reason: "found src/routes and src/router.tsx (TanStack Start layout)"
737
- };
738
- }
739
- return void 0;
740
- }
741
-
742
- // src/lib/feature-registry.ts
743
- async function loadFeatureRegistry() {
744
- const source = await getComponentSource();
745
- return source.featureRegistry;
746
- }
747
- async function findFeature(name) {
748
- const registry = await loadFeatureRegistry();
749
- return registry.find((e) => e.name === name);
750
- }
751
- async function listFeatureNames() {
752
- return (await loadFeatureRegistry()).map((e) => e.name);
753
- }
754
- function frameworkLabel(framework) {
755
- return framework === "tanstack" ? "TanStack Start" : "Next.js";
756
- }
757
- function supportedFrameworksFor(entry) {
758
- return Object.keys(entry.frameworks);
759
- }
760
-
761
- // src/lib/install-feature.ts
762
- import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
763
- import { dirname as dirname4, join as join8 } from "path";
764
- function nextAppDir(cwd) {
765
- if (existsSync10(join8(cwd, "src", "app"))) return "src/app";
766
- if (existsSync10(join8(cwd, "app"))) return "app";
767
- if (existsSync10(join8(cwd, "src"))) return "src/app";
768
- return "app";
769
- }
770
- function toFeatureTargetPath(cwd, sourceRelPath, impl, framework) {
771
- let rel = sourceRelPath.replace(/\\/g, "/");
772
- const prefix = impl.sourcePrefix?.replace(/\\/g, "/");
773
- if (prefix && rel.startsWith(prefix)) rel = rel.slice(prefix.length);
774
- if (framework === "nextjs" && (rel === "src/app" || rel.startsWith("src/app/"))) {
775
- const appDir = nextAppDir(cwd);
776
- if (appDir === "app") rel = rel.replace(/^src\/app/, "app");
777
- }
778
- return rel;
779
- }
780
- function adaptContent(content, framework) {
781
- if (framework !== "nextjs") return content;
782
- return rewriteParaglideUsage(content, "@/lib/composed-strings").replaceAll("#/", "@/");
783
- }
784
- function sourceContent(source, relPath, framework) {
785
- return adaptContent(source.readFile(relPath), framework);
786
- }
787
- function fileMatchesSource(cwd, sourceRelPath, targetRelPath, source, framework) {
788
- const targetPath = join8(cwd, targetRelPath);
789
- if (!existsSync10(targetPath)) return false;
790
- return readFileSync6(targetPath, "utf-8") === sourceContent(source, sourceRelPath, framework);
791
- }
792
- async function installFeatureFiles(cwd, impl, opts) {
793
- const source = await getComponentSource();
794
- const results = [];
795
- for (const relPath of impl.files) {
796
- const targetRel = toFeatureTargetPath(cwd, relPath, impl, opts.framework);
797
- const targetPath = join8(cwd, targetRel);
798
- if (existsSync10(targetPath) && !opts.overwrite) {
799
- if (fileMatchesSource(cwd, relPath, targetRel, source, opts.framework)) {
800
- results.push({
801
- path: targetRel,
802
- status: "skipped",
803
- reason: "already installed (identical)"
804
- });
805
- } else {
806
- results.push({
807
- path: targetRel,
808
- status: "skipped",
809
- reason: "already exists (use --overwrite to replace)"
810
- });
811
- }
812
- continue;
813
- }
814
- const content = sourceContent(source, relPath, opts.framework);
815
- mkdirSync3(dirname4(targetPath), { recursive: true });
816
- writeFileSync3(targetPath, content, "utf-8");
817
- results.push({ path: targetRel, status: "added" });
818
- }
819
- return results;
820
- }
821
- async function findFeatureFileConflicts(cwd, impl, framework) {
822
- const source = await getComponentSource();
823
- const conflicts = [];
824
- for (const relPath of impl.files) {
825
- const targetRel = toFeatureTargetPath(cwd, relPath, impl, framework);
826
- if (existsSync10(join8(cwd, targetRel)) && !fileMatchesSource(cwd, relPath, targetRel, source, framework)) {
827
- conflicts.push(targetRel);
828
- }
829
- }
830
- return conflicts;
831
- }
832
-
833
- // src/lib/move-files.ts
834
- import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync7, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
835
- import { dirname as dirname5, join as join9 } from "path";
836
- async function copyEntryToRepo(destRoot, entry, opts) {
837
- const source = await getComponentSource();
838
- const results = [];
839
- for (const file of entry.files) {
840
- results.push(writeRelPath(destRoot, file.path, source.readFile(file.path), entry.name, opts));
841
- }
842
- if (entry.controller) {
843
- const controllerPath = findControllerPath(source, entry.controller);
844
- if (!controllerPath) {
845
- throw new Error(
846
- `Could not find controller file for ${entry.name} (${entry.controller})`
847
- );
848
- }
849
- results.push(
850
- writeRelPath(
851
- destRoot,
852
- controllerPath,
853
- source.readFile(controllerPath),
854
- entry.controller,
855
- opts
856
- )
857
- );
858
- upsertControllerBarrel(destRoot, entry.controller, controllerPath, results);
859
- }
860
- return results;
861
- }
862
- async function mergeI18nIntoRepo(destRoot, keys) {
863
- if (keys.length === 0) return { added: [] };
864
- const messagesRoot = join9(destRoot, "messages");
865
- const source = await getComponentSource();
866
- const added = [];
867
- for (const locale of ["en", "ar"]) {
868
- const localeDir = join9(messagesRoot, locale);
869
- const messages = source.readMessages(locale);
870
- const namespaces = existsSync11(localeDir) ? readdirSync2(localeDir).filter((file) => file.endsWith(".json")).map((file) => file.replace(/\.json$/, "")) : [];
871
- const grouped = {};
872
- for (const key of keys) {
873
- if (!(key in messages)) continue;
874
- const ns = namespaceForKey(key, namespaces);
875
- grouped[ns] ??= {};
876
- grouped[ns][key] = messages[key];
877
- }
878
- for (const [ns, kvs] of Object.entries(grouped)) {
879
- const filePath = join9(localeDir, `${ns}.json`);
880
- const existing = existsSync11(filePath) ? JSON.parse(readFileSync7(filePath, "utf-8")) : { $schema: "https://inlang.com/schema/inlang-message-format" };
881
- for (const [key, value] of Object.entries(kvs)) {
882
- if (!(key in existing)) added.push(`${locale}/${ns}.json#${key}`);
883
- existing[key] = value;
884
- }
885
- const schema = existing.$schema;
886
- delete existing.$schema;
887
- const sorted = Object.fromEntries(
888
- Object.keys(existing).sort().map((key) => [key, existing[key]])
889
- );
890
- const out = schema ? { $schema: schema, ...sorted } : sorted;
891
- mkdirSync4(dirname5(filePath), { recursive: true });
892
- writeFileSync4(filePath, JSON.stringify(out, null, 2) + "\n", "utf-8");
893
- }
894
- }
895
- return { added };
896
- }
897
- function writeRelPath(destRoot, relPath, content, entryName, opts) {
898
- const targetPath = join9(destRoot, relPath);
899
- if (existsSync11(targetPath) && !opts.overwrite) {
900
- return {
901
- entryName,
902
- targetPath,
903
- skipped: true,
904
- reason: "already exists (use --overwrite to replace)"
905
- };
906
- }
907
- mkdirSync4(dirname5(targetPath), { recursive: true });
908
- writeFileSync4(targetPath, content, "utf-8");
909
- return { entryName, targetPath, skipped: false };
910
- }
911
- function upsertControllerBarrel(destRoot, exportName, controllerPath, results) {
912
- const barrelRel = "src/components/common/controllers/index.ts";
913
- const barrelAbs = join9(destRoot, barrelRel);
914
- const line = controllerBarrelExport(exportName, controllerPath);
915
- const existing = existsSync11(barrelAbs) ? readFileSync7(barrelAbs, "utf-8") : "";
916
- if (existing.includes(`export { ${exportName} }`)) return;
917
- const next = existing.trimEnd() ? `${existing.replace(/\s*$/, "\n")}${line}
918
- ` : `${line}
919
- `;
920
- mkdirSync4(dirname5(barrelAbs), { recursive: true });
921
- writeFileSync4(barrelAbs, next, "utf-8");
922
- results.push({ entryName: exportName, targetPath: barrelAbs, skipped: false });
923
- }
924
- function namespaceForKey(key, namespaces) {
925
- const existing = namespaces.filter((ns) => key === ns || key.startsWith(`${ns}_`)).sort((a, b) => b.length - a.length)[0];
926
- if (existing) return existing;
927
- const underscoreIndex = key.indexOf("_");
928
- return underscoreIndex === -1 ? key : key.slice(0, underscoreIndex);
929
- }
930
-
931
- // src/commands/create.ts
932
- async function runCreateFeature(featureName, options) {
933
- const { cwd } = options;
934
- if (!featureName) {
935
- console.error(pc2.red("Usage: composed-cli create feature <name> [--protected|--public]"));
936
- process.exitCode = 1;
937
- return;
938
- }
939
- if (options.protected && options.public) {
940
- console.error(pc2.red("Pass either --protected or --public, not both."));
941
- process.exitCode = 1;
942
- return;
943
- }
944
- if (!existsSync12(cwd)) {
945
- console.error(pc2.red(`Target directory does not exist: ${cwd}`));
946
- process.exitCode = 1;
947
- return;
948
- }
949
- const feature = await findFeature(featureName);
950
- if (!feature) {
951
- const available = await listFeatureNames();
952
- console.error(pc2.red(`Feature "${featureName}" is not available in the boilerplate.`));
953
- console.error("");
954
- console.error(pc2.bold("Available features:"));
955
- for (const name of available) console.error(pc2.dim(` - ${name}`));
956
- process.exitCode = 1;
957
- return;
958
- }
959
- const detected = detectTargetFramework(cwd);
960
- if (!detected) {
961
- console.error(
962
- pc2.red(
963
- `Could not detect the target project's framework at ${cwd}. Expected to find a package.json with a recognizable framework dependency (e.g. "next" or "@tanstack/react-start"), or matching project structure.`
964
- )
965
- );
966
- process.exitCode = 1;
967
- return;
968
- }
969
- const impl = feature.frameworks[detected.framework];
970
- if (!impl) {
971
- const supported = supportedFrameworksFor(feature);
972
- console.error(
973
- pc2.red(
974
- `Feature "${feature.name}" exists, but ${frameworkLabel(detected.framework)} is not currently supported for this feature.`
975
- )
976
- );
977
- console.error("");
978
- console.error(pc2.bold("Supported frameworks:"));
979
- for (const fw of supported) console.error(pc2.dim(` - ${frameworkLabel(fw)}`));
980
- process.exitCode = 1;
981
- return;
982
- }
983
- const requestedScope = options.protected ? "protected" : options.public ? "public" : void 0;
984
- if (requestedScope && impl.routeScope && requestedScope !== impl.routeScope) {
985
- console.error(
986
- pc2.red(
987
- `--${requestedScope} does not apply to "${feature.name}" for ${frameworkLabel(detected.framework)}: this implementation's route is fixed at "${impl.routeScope}".`
988
- )
989
- );
990
- process.exitCode = 1;
991
- return;
992
- }
993
- if (requestedScope && !impl.routeScope) {
994
- console.error(
995
- pc2.red(
996
- `--${requestedScope} does not apply to "${feature.name}" for ${frameworkLabel(detected.framework)}: this implementation has no protected/public route distinction.`
997
- )
998
- );
999
- process.exitCode = 1;
1000
- return;
1001
- }
1002
- console.log(pc2.dim(`Detected framework: ${frameworkLabel(detected.framework)} (${detected.reason})`));
1003
- const conflicts = await findFeatureFileConflicts(cwd, impl, detected.framework);
1004
- if (conflicts.length > 0 && !options.overwrite) {
1005
- console.error(
1006
- pc2.red(`Feature "${feature.name}" already exists \u2014 the following file(s) already have different content:`)
1007
- );
1008
- for (const conflict of conflicts) {
1009
- console.error(pc2.red(` ${conflict}`));
1010
- }
1011
- console.error("");
1012
- console.error(pc2.dim("Pass --overwrite to replace them."));
1013
- process.exitCode = 1;
1014
- return;
1015
- }
1016
- if (conflicts.length > 0) {
1017
- console.log(pc2.yellow(`Overwriting ${conflicts.length} existing file(s).`));
1018
- }
1019
- console.log(pc2.bold(`\u2714 Feature: ${feature.name}`));
1020
- console.log(pc2.bold(`\u2714 Framework implementation: ${frameworkLabel(detected.framework)}`));
1021
- const copied = await installFeatureFiles(cwd, impl, {
1022
- overwrite: options.overwrite,
1023
- framework: detected.framework
1024
- });
1025
- for (const file of copied) {
1026
- if (file.status === "skipped") {
1027
- console.log(pc2.yellow(` skip ${file.path} (${file.reason})`));
1028
- } else {
1029
- console.log(pc2.green(` add ${toDisplayPath(cwd, `${cwd}/${file.path}`)}`));
1030
- }
1031
- }
1032
- if (detected.framework === "nextjs") {
1033
- const i18n = await installI18nStrings(cwd, impl.i18nKeys);
1034
- if (i18n.addedKeys.length > 0) {
1035
- console.log(pc2.green(` add ${i18n.path} (${i18n.addedKeys.length} key(s))`));
1036
- }
1037
- } else {
1038
- const i18n = await mergeI18nIntoRepo(cwd, impl.i18nKeys);
1039
- if (i18n.added.length > 0) {
1040
- console.log(pc2.green(` add messages/ (${i18n.added.length} key(s))`));
1041
- }
1042
- }
1043
- console.log("");
1044
- console.log(pc2.bold(pc2.green(`Done. Installed feature "${feature.name}" (${frameworkLabel(detected.framework)}) into ${cwd}.`)));
1045
- if (detected.framework === "tanstack") {
1046
- console.log(
1047
- pc2.dim(
1048
- "Restart `bun run dev` (or run a build) so paraglideVitePlugin regenerates src/paraglide/ with any new message keys."
1049
- )
1050
- );
1051
- }
1052
- }
1053
-
1054
- // src/commands/move.ts
1055
- import { existsSync as existsSync13, statSync } from "fs";
1056
- import { resolve as resolve2 } from "path";
1057
- import pc3 from "picocolors";
1058
- async function runMove(queries, options) {
1059
- if (queries.length === 0) {
1060
- console.error(
1061
- pc3.red(
1062
- "Usage: composed-cli move <component-or-path> [--to <repo>]"
1063
- )
1064
- );
1065
- console.error("");
1066
- console.error("Pass a registry name (labeled-select) or a path like components/ui.");
1067
- console.error("Available components:");
1068
- for (const name of await allComponentNames()) console.error(` ${name}`);
1069
- process.exitCode = 1;
1070
- return;
1071
- }
1072
- if (options.from) {
1073
- const fromRoot = resolve2(options.from);
1074
- if (!existsSync13(fromRoot) || !statSync(fromRoot).isDirectory()) {
1075
- console.error(pc3.red(`Source is not a directory: ${fromRoot}`));
1076
- process.exitCode = 1;
1077
- return;
1078
- }
1079
- setSourceRoot(fromRoot);
1080
- }
1081
- const destRoot = resolve2(options.to);
1082
- if (!existsSync13(destRoot) || !statSync(destRoot).isDirectory()) {
1083
- console.error(pc3.red(`Destination is not a directory: ${destRoot}`));
1084
- process.exitCode = 1;
1085
- return;
1086
- }
1087
- const sourceRoot = resolveSourceRoot();
1088
- if (sourceRoot && resolve2(sourceRoot) === destRoot) {
1089
- console.error(pc3.red("Source and destination are the same repository."));
1090
- console.error("Pass --to <other-repo>, for example:");
1091
- console.error(
1092
- pc3.cyan(" npx tsx cli/src/index.ts move src/components/ui --to M:\\my-next-app")
1093
- );
1094
- process.exitCode = 1;
1095
- return;
1096
- }
1097
- const { entries, notFound, ambiguous } = await resolveQueries(queries);
1098
- if (notFound.length > 0) {
1099
- console.error(pc3.red(`Unknown component(s): ${notFound.join(", ")}`));
1100
- console.error(
1101
- pc3.dim(
1102
- "Use a name like labeled-select, or a path like components/ui. No local boilerplate is required."
1103
- )
1104
- );
1105
- }
1106
- for (const [query, names] of Object.entries(ambiguous)) {
1107
- console.error(
1108
- pc3.red(
1109
- `"${query}" matches multiple components: ${names.join(", ")}. Use one of those exact names.`
1110
- )
1111
- );
1112
- }
1113
- if (entries.length === 0) {
1114
- process.exitCode = 1;
1115
- return;
1116
- }
1117
- if (!existsSync13(resolve2(destRoot, ".git"))) {
1118
- console.log(pc3.yellow(`Warning: ${destRoot} has no .git directory.`));
1119
- }
1120
- if (hasShadcnConfig(destRoot)) {
1121
- await runAdd(
1122
- entries.map((e) => e.name),
1123
- {
1124
- cwd: destRoot,
1125
- overwrite: options.overwrite,
1126
- skipShadcn: false,
1127
- skipNpm: false,
1128
- skipI18n: false
1129
- }
1130
- );
1131
- return;
1132
- }
1133
- console.log(
1134
- pc3.bold(`Moving into ${destRoot}: ${entries.map((e) => e.name).join(", ")}`)
1135
- );
1136
- for (const entry of entries) {
1137
- const copied = await copyEntryToRepo(destRoot, entry, {
1138
- overwrite: options.overwrite
1139
- });
1140
- for (const file of copied) {
1141
- if (file.skipped) {
1142
- console.log(pc3.yellow(` skip ${toDisplayPath(destRoot, file.targetPath)} (${file.reason})`));
1143
- } else {
1144
- console.log(pc3.green(` add ${toDisplayPath(destRoot, file.targetPath)}`));
1145
- }
1146
- }
1147
- }
1148
- const deps = unionDependencies(entries);
1149
- const i18n = await mergeI18nIntoRepo(destRoot, deps.i18nKeys);
1150
- if (i18n.added.length > 0) {
1151
- console.log(pc3.green(` add messages/ (${i18n.added.length} key(s))`));
1152
- }
1153
- console.log(pc3.bold(pc3.green(`
1154
- Done. Copied ${entries.length} component(s) into ${destRoot}.`)));
1155
- }
1156
-
1157
- // src/index.ts
1158
- var program = new Command();
1159
- program.name("composed-cli").description(
1160
- "Install individual composed UI components from the BIM frontend boilerplate into a Next.js + shadcn project."
1161
- ).version("0.1.1");
1162
- program.command("add").description("Add one or more composed components to the current project").argument(
1163
- "<components...>",
1164
- "component name(s), slug(s), or path(s) like labeled-select or components/ui"
1165
- ).option("-c, --cwd <path>", "target project directory", process.cwd()).option("-o, --overwrite", "overwrite files that already exist", false).option("--skip-shadcn", "do not run `shadcn add` for required primitives", false).option("--skip-npm", "do not install required npm packages", false).option("--skip-i18n", "do not generate the local i18n strings file", false).action(async (components, opts) => {
1166
- await runAdd(components, {
1167
- cwd: opts.cwd,
1168
- overwrite: opts.overwrite,
1169
- skipShadcn: opts.skipShadcn,
1170
- skipNpm: opts.skipNpm,
1171
- skipI18n: opts.skipI18n
1172
- });
1173
- });
1174
- program.command("move").description(
1175
- "Copy a composed component and its related files (controller, i18n) into another repository"
1176
- ).argument(
1177
- "<components...>",
1178
- "component name(s), slug(s), or path(s) like labeled-select or components/ui"
1179
- ).option("-t, --to <path>", "destination repository path", process.cwd()).option(
1180
- "-f, --from <path>",
1181
- "source boilerplate repository (optional; the published package uses its bundled registry)"
1182
- ).option("-o, --overwrite", "overwrite files that already exist", false).action(async (components, opts) => {
1183
- await runMove(components, {
1184
- to: opts.to,
1185
- from: opts.from,
1186
- overwrite: opts.overwrite
1187
- });
1188
- });
1189
- var create = program.command("create").description(
1190
- "Install a feature that already exists in the boilerplate into a target project"
1191
- ).addHelpText(
1192
- "after",
1193
- `
1194
- The boilerplate is the single source of truth for which features exist. This
1195
- does not generate arbitrary/new features \u2014 it installs an existing, registered
1196
- boilerplate feature's real files into a target project, in the implementation
1197
- that matches the target project's own framework.
1198
-
1199
- Boilerplate -> Feature Registry -> Feature -> Framework Implementation -> Target Project
1200
-
1201
- See \`composed-cli create feature --help\` for the feature installer.`
1202
- );
1203
- create.command("feature").description(
1204
- "Install an existing boilerplate feature (route, screen, API layer, translations) into a target project, using the implementation registered for that project's detected framework."
1205
- ).argument("<name>", "registered feature name, e.g. login, dashboard, about, contact").option("-c, --cwd <path>", "target project directory", process.cwd()).option("--protected", "require/assert the protected-route implementation").option("--public", "require/assert the public-route implementation").option("-o, --overwrite", "overwrite files that already exist", false).addHelpText(
1206
- "after",
1207
- `
1208
- The target project's framework is detected automatically (package.json
1209
- dependencies, then project structure) \u2014 it does not need to be a copy of this
1210
- boilerplate. Only frameworks with a registered implementation for the
1211
- requested feature are supported; run the command to see the registry's error
1212
- message list available features or supported frameworks.
1213
-
1214
- --protected / --public are validated against the resolved implementation's
1215
- own route scope (e.g. dashboard is always protected, about is always public)
1216
- rather than choosing it \u2014 pass one only to assert the scope you expect;
1217
- omit both to just install whatever the registry defines.
1218
-
1219
- Examples:
1220
- $ composed-cli create feature login
1221
- $ composed-cli create feature dashboard
1222
- $ composed-cli create feature login --cwd ./next-app
1223
- $ composed-cli create feature dashboard --cwd ./tanstack-app
1224
- $ composed-cli create feature login --overwrite
1225
-
1226
- Behavior:
1227
- - Unknown feature name -> rejected, lists available features.
1228
- - Feature exists but no implementation for the detected framework -> rejected, lists supported frameworks.
1229
- - Next.js targets install App Router pages from cli/templates/nextjs/ and rewrite Paraglide to @/lib/composed-strings.
1230
- - Every target file is checked for conflicts before anything is written; existing files are reported and left untouched unless --overwrite is passed.
1231
- - Files are copied byte-for-byte from the boilerplate's real source tree \u2014 nothing is templated or invented.
1232
- - Translation keys are merged into the target's messages/<locale>/*.json when that folder exists; skipped otherwise.`
1233
- ).action(async (name, opts) => {
1234
- await runCreateFeature(name, {
1235
- cwd: opts.cwd,
1236
- protected: opts.protected ?? false,
1237
- public: opts.public ?? false,
1238
- overwrite: opts.overwrite
1239
- });
1240
- });
1241
- program.parseAsync(process.argv);
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/add.ts
7
+ import pc from "picocolors";
8
+
9
+ // src/lib/install-files.ts
10
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
+ import { dirname as dirname2, join as join4, relative } from "path";
12
+
13
+ // src/lib/controllers.ts
14
+ var CONTROLLERS_PREFIX = "src/components/common/controllers/";
15
+ function toKebabCase(name) {
16
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
17
+ }
18
+ function findControllerPath(source, exportName) {
19
+ const guessed = `${CONTROLLERS_PREFIX}${toKebabCase(exportName)}.tsx`;
20
+ if (source.hasFile(guessed) && source.readFile(guessed).includes(`export function ${exportName}`)) {
21
+ return guessed;
22
+ }
23
+ for (const path of source.listFiles(CONTROLLERS_PREFIX)) {
24
+ if (!path.endsWith(".tsx")) continue;
25
+ if (path.endsWith("/index.tsx") || path.endsWith("/index.ts")) continue;
26
+ if (source.readFile(path).includes(`export function ${exportName}`)) {
27
+ return path;
28
+ }
29
+ }
30
+ return void 0;
31
+ }
32
+ function controllerBarrelExport(exportName, relPath) {
33
+ const fileName = relPath.split("/").pop().replace(/\.tsx?$/, "");
34
+ return `export { ${exportName} } from './${fileName}';`;
35
+ }
36
+
37
+ // src/lib/i18n.ts
38
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
39
+ import { join as join2 } from "path";
40
+
41
+ // src/lib/source.ts
42
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
43
+ import { dirname, join } from "path";
44
+ import { fileURLToPath, pathToFileURL } from "url";
45
+ var here = dirname(fileURLToPath(import.meta.url));
46
+ function findAncestorContaining(startDir, markerRelPath, maxLevels = 6) {
47
+ let dir = startDir;
48
+ for (let i = 0; i <= maxLevels; i++) {
49
+ if (existsSync(join(dir, markerRelPath))) return dir;
50
+ const parent = dirname(dir);
51
+ if (parent === dir) return void 0;
52
+ dir = parent;
53
+ }
54
+ return void 0;
55
+ }
56
+ function loadFromBundle() {
57
+ const distDir = findAncestorContaining(here, "registry-bundle.json");
58
+ if (!distDir) return void 0;
59
+ const bundle = JSON.parse(
60
+ readFileSync(join(distDir, "registry-bundle.json"), "utf-8")
61
+ );
62
+ return {
63
+ registry: bundle.registry,
64
+ featureRegistry: bundle.featureRegistry ?? [],
65
+ readFile: (path) => {
66
+ const content = bundle.files[path];
67
+ if (content === void 0) {
68
+ throw new Error(`registry-bundle.json is missing file: ${path}`);
69
+ }
70
+ return content;
71
+ },
72
+ hasFile: (path) => path in bundle.files,
73
+ listFiles: (prefix) => {
74
+ const normalized = prefix.replace(/\\/g, "/").replace(/\/$/, "");
75
+ return Object.keys(bundle.files).filter(
76
+ (p) => p === normalized || p.startsWith(`${normalized}/`)
77
+ );
78
+ },
79
+ readMessages: (locale) => bundle.messages[locale] ?? {}
80
+ };
81
+ }
82
+ async function loadFromBoilerplateTree() {
83
+ const boilerplateRoot = overrideRoot ?? findAncestorContaining(here, join("src", "registry", "components.ts"));
84
+ if (!boilerplateRoot) return void 0;
85
+ if (!existsSync(join(boilerplateRoot, "src", "registry", "components.ts"))) {
86
+ return void 0;
87
+ }
88
+ const registryPath = join(boilerplateRoot, "src", "registry", "components.ts");
89
+ const mod = await import(
90
+ /* @vite-ignore */
91
+ pathToFileURL(registryPath).href
92
+ );
93
+ const featuresPath = join(boilerplateRoot, "src", "registry", "features.ts");
94
+ let featureRegistry = [];
95
+ if (existsSync(featuresPath)) {
96
+ const featuresMod = await import(
97
+ /* @vite-ignore */
98
+ pathToFileURL(featuresPath).href
99
+ );
100
+ featureRegistry = featuresMod.featureRegistry;
101
+ }
102
+ return {
103
+ registry: mod.componentRegistry,
104
+ featureRegistry,
105
+ readFile: (path) => {
106
+ const abs = join(boilerplateRoot, path);
107
+ if (!existsSync(abs)) {
108
+ throw new Error(`Source file missing from boilerplate tree: ${path}`);
109
+ }
110
+ return readFileSync(abs, "utf-8");
111
+ },
112
+ hasFile: (path) => existsSync(join(boilerplateRoot, path)),
113
+ listFiles: (prefix) => listFilesFromTree(boilerplateRoot, prefix),
114
+ readMessages: (locale) => readMessagesFromTree(boilerplateRoot, locale)
115
+ };
116
+ }
117
+ function listFilesFromTree(boilerplateRoot, prefix) {
118
+ const abs = join(boilerplateRoot, prefix);
119
+ if (!existsSync(abs)) return [];
120
+ const posixPrefix = prefix.replace(/\\/g, "/").replace(/\/$/, "");
121
+ if (statSync(abs).isFile()) return [posixPrefix];
122
+ const out = [];
123
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
124
+ const rel = `${posixPrefix}/${entry.name}`;
125
+ if (entry.isDirectory()) {
126
+ out.push(...listFilesFromTree(boilerplateRoot, rel));
127
+ } else {
128
+ out.push(rel);
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+ function readMessagesFromTree(boilerplateRoot, locale) {
134
+ const dir = join(boilerplateRoot, "messages", locale);
135
+ const out = {};
136
+ if (!existsSync(dir)) return out;
137
+ for (const file of readdirSync(dir)) {
138
+ if (!file.endsWith(".json")) continue;
139
+ const json = JSON.parse(readFileSync(join(dir, file), "utf-8"));
140
+ for (const [key, value] of Object.entries(json)) {
141
+ if (key === "$schema") continue;
142
+ out[key] = String(value);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+ var cached;
148
+ var overrideRoot;
149
+ function setSourceRoot(root) {
150
+ cached = void 0;
151
+ overrideRoot = root;
152
+ }
153
+ function getSourceRoot() {
154
+ return overrideRoot;
155
+ }
156
+ function resolveSourceRoot() {
157
+ return overrideRoot ?? findAncestorContaining(here, join("src", "registry", "components.ts"));
158
+ }
159
+ async function getComponentSource() {
160
+ if (cached) return cached;
161
+ const source = overrideRoot ? await loadFromBoilerplateTree() : loadFromBundle() ?? await loadFromBoilerplateTree();
162
+ if (!source) {
163
+ throw new Error(
164
+ "Could not locate component source. Expected either a built dist/registry-bundle.json (published package) or a sibling src/registry/components.ts (running inside the boilerplate repo)."
165
+ );
166
+ }
167
+ cached = source;
168
+ return source;
169
+ }
170
+
171
+ // src/lib/i18n.ts
172
+ var STRINGS_RUNTIME = `// Generated by composed-cli. Minimal string lookup for components copied
173
+ // from the BIM boilerplate \u2014 replace with your own i18n solution as needed.
174
+ import strings from './strings.json';
175
+
176
+ export function t(key: keyof typeof strings, params?: Record<string, string | number>) {
177
+ let value = strings[key] ?? key;
178
+ if (params) {
179
+ for (const [name, val] of Object.entries(params)) {
180
+ value = value.replaceAll(\`{\${name}}\`, String(val));
181
+ }
182
+ }
183
+ return value;
184
+ }
185
+ `;
186
+ async function installI18nStrings(cwd, keys, destDir = "src/lib/composed-strings") {
187
+ if (keys.length === 0) return { ok: true, addedKeys: [], path: "" };
188
+ const source = await getComponentSource();
189
+ const enMessages = source.readMessages("en");
190
+ const outDir = join2(cwd, destDir);
191
+ mkdirSync(outDir, { recursive: true });
192
+ const stringsPath = join2(outDir, "strings.json");
193
+ const existing = existsSync2(stringsPath) ? JSON.parse(readFileSync2(stringsPath, "utf-8")) : {};
194
+ const addedKeys = [];
195
+ for (const key of keys) {
196
+ if (!(key in enMessages)) continue;
197
+ if (!(key in existing)) addedKeys.push(key);
198
+ existing[key] = enMessages[key];
199
+ }
200
+ const sorted = Object.fromEntries(
201
+ Object.keys(existing).sort().map((key) => [key, existing[key]])
202
+ );
203
+ writeFileSync(stringsPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
204
+ const runtimePath = join2(outDir, "index.ts");
205
+ if (!existsSync2(runtimePath)) {
206
+ writeFileSync(runtimePath, STRINGS_RUNTIME, "utf-8");
207
+ }
208
+ return { ok: true, addedKeys, path: join2(destDir, "index.ts") };
209
+ }
210
+ function rewriteParaglideUsage(source, importPath) {
211
+ let out = source.replace(
212
+ /import\s*\{\s*m\s*\}\s*from\s*['"]#\/paraglide\/messages['"];?\n?/,
213
+ `import { t } from '${importPath}';
214
+ `
215
+ );
216
+ out = out.replace(/\bm\.([a-zA-Z0-9_]+)\(\s*\)/g, "t('$1')");
217
+ out = out.replace(/\bm\.([a-zA-Z0-9_]+)\(\s*(\{[^}]*\})\s*\)/g, "t('$1', $2)");
218
+ return out;
219
+ }
220
+
221
+ // src/lib/target-config.ts
222
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
223
+ import { join as join3 } from "path";
224
+ var defaultAliases = {
225
+ components: "@/components",
226
+ ui: "@/components/ui",
227
+ utils: "@/lib/utils",
228
+ lib: "@/lib"
229
+ };
230
+ function readTargetAliases(cwd) {
231
+ const configPath = join3(cwd, "components.json");
232
+ if (!existsSync3(configPath)) return defaultAliases;
233
+ try {
234
+ const config = JSON.parse(readFileSync3(configPath, "utf-8"));
235
+ return {
236
+ components: config.aliases?.components ?? defaultAliases.components,
237
+ ui: config.aliases?.ui ?? defaultAliases.ui,
238
+ utils: config.aliases?.utils ?? defaultAliases.utils,
239
+ lib: config.aliases?.lib ?? defaultAliases.lib
240
+ };
241
+ } catch {
242
+ return defaultAliases;
243
+ }
244
+ }
245
+ function aliasToPath(cwd, alias) {
246
+ const bareAlias = alias.replace(/^[@#]\//, "");
247
+ const base = existsSync3(join3(cwd, "src")) ? join3(cwd, "src") : cwd;
248
+ return join3(base, bareAlias);
249
+ }
250
+
251
+ // src/lib/install-files.ts
252
+ async function copyComponentFiles(cwd, entry, opts) {
253
+ const source = await getComponentSource();
254
+ const aliases = readTargetAliases(cwd);
255
+ const uiDir = aliasToPath(cwd, aliases.ui);
256
+ const composedDir = join4(uiDir, "composed", entry.slug);
257
+ const results = [];
258
+ for (const file of entry.files) {
259
+ const fileName = file.path.split("/").pop();
260
+ const targetPath = join4(composedDir, fileName);
261
+ if (existsSync4(targetPath) && !opts.overwrite) {
262
+ results.push({
263
+ entryName: entry.name,
264
+ targetPath,
265
+ skipped: true,
266
+ reason: "already exists (use --overwrite to replace)"
267
+ });
268
+ continue;
269
+ }
270
+ let content = source.readFile(file.path);
271
+ if (entry.i18n.required) {
272
+ content = rewriteParaglideUsage(content, opts.stringsImportPath);
273
+ }
274
+ mkdirSync2(dirname2(targetPath), { recursive: true });
275
+ writeFileSync2(targetPath, content, "utf-8");
276
+ results.push({ entryName: entry.name, targetPath, skipped: false });
277
+ }
278
+ if (entry.controller) {
279
+ const controller = await copyController(cwd, entry, opts);
280
+ if (controller) results.push(controller);
281
+ }
282
+ return results;
283
+ }
284
+ async function copyController(cwd, entry, opts) {
285
+ if (!entry.controller) return void 0;
286
+ const source = await getComponentSource();
287
+ const controllerPath = findControllerPath(source, entry.controller);
288
+ if (!controllerPath) {
289
+ throw new Error(
290
+ `Could not find controller file for ${entry.name} (${entry.controller})`
291
+ );
292
+ }
293
+ const aliases = readTargetAliases(cwd);
294
+ const fileName = controllerPath.split("/").pop();
295
+ const targetPath = join4(
296
+ aliasToPath(cwd, aliases.components),
297
+ "common",
298
+ "controllers",
299
+ fileName
300
+ );
301
+ if (existsSync4(targetPath) && !opts.overwrite) {
302
+ return {
303
+ entryName: entry.controller,
304
+ targetPath,
305
+ skipped: true,
306
+ reason: "already exists (use --overwrite to replace)"
307
+ };
308
+ }
309
+ mkdirSync2(dirname2(targetPath), { recursive: true });
310
+ writeFileSync2(targetPath, source.readFile(controllerPath), "utf-8");
311
+ return { entryName: entry.controller, targetPath, skipped: false };
312
+ }
313
+ async function copyLocalUtil(cwd, localDep, opts) {
314
+ if (localDep !== "@/lib/utils") return void 0;
315
+ const source = await getComponentSource();
316
+ const aliases = readTargetAliases(cwd);
317
+ const utilsAbsPath = aliasToPath(cwd, aliases.utils) + ".ts";
318
+ if (existsSync4(utilsAbsPath) && !opts.overwrite) {
319
+ return {
320
+ entryName: "cn",
321
+ targetPath: utilsAbsPath,
322
+ skipped: true,
323
+ reason: "already exists"
324
+ };
325
+ }
326
+ const content = source.readFile("src/lib/utils.ts");
327
+ mkdirSync2(dirname2(utilsAbsPath), { recursive: true });
328
+ writeFileSync2(utilsAbsPath, content, "utf-8");
329
+ return { entryName: "cn", targetPath: utilsAbsPath, skipped: false };
330
+ }
331
+ function toDisplayPath(cwd, absPath) {
332
+ return relative(cwd, absPath).split("\\").join("/");
333
+ }
334
+ var UI_PREFIXES = ["src/components/ui/", "components/ui/"];
335
+ function uiRelativePath(relPath) {
336
+ const posix = relPath.replace(/\\/g, "/");
337
+ for (const prefix of UI_PREFIXES) {
338
+ if (posix.startsWith(prefix)) return posix.slice(prefix.length);
339
+ }
340
+ return void 0;
341
+ }
342
+ function extraPrimitiveSlugs(extraFiles) {
343
+ const slugs = [];
344
+ for (const file of extraFiles) {
345
+ const rel = uiRelativePath(file);
346
+ if (!rel || rel.includes("/")) continue;
347
+ if (rel.endsWith(".tsx") || rel.endsWith(".ts")) {
348
+ slugs.push(rel.replace(/\.tsx?$/, ""));
349
+ }
350
+ }
351
+ return slugs;
352
+ }
353
+ async function copyExtraFiles(cwd, extraFiles, opts) {
354
+ if (extraFiles.length === 0) return [];
355
+ const source = await getComponentSource();
356
+ const aliases = readTargetAliases(cwd);
357
+ const uiDir = aliasToPath(cwd, aliases.ui);
358
+ const results = [];
359
+ for (const relPath of extraFiles) {
360
+ const uiRel = uiRelativePath(relPath);
361
+ const targetPath = opts.useAliases && uiRel !== void 0 ? join4(uiDir, uiRel) : join4(cwd, relPath);
362
+ if (existsSync4(targetPath) && !opts.overwrite) {
363
+ results.push({
364
+ entryName: relPath,
365
+ targetPath,
366
+ skipped: true,
367
+ reason: "already exists (use --overwrite to replace)"
368
+ });
369
+ continue;
370
+ }
371
+ let content = source.readFile(relPath);
372
+ if (opts.stringsImportPath) {
373
+ content = rewriteParaglideUsage(content, opts.stringsImportPath);
374
+ }
375
+ mkdirSync2(dirname2(targetPath), { recursive: true });
376
+ writeFileSync2(targetPath, content, "utf-8");
377
+ results.push({ entryName: relPath, targetPath, skipped: false });
378
+ }
379
+ return results;
380
+ }
381
+
382
+ // src/lib/npm-install.ts
383
+ import { spawnSync } from "child_process";
384
+
385
+ // src/lib/package-manager.ts
386
+ import { existsSync as existsSync5 } from "fs";
387
+ import { join as join5 } from "path";
388
+ var lockfiles = {
389
+ "bun.lock": "bun",
390
+ "bun.lockb": "bun",
391
+ "pnpm-lock.yaml": "pnpm",
392
+ "yarn.lock": "yarn",
393
+ "package-lock.json": "npm"
394
+ };
395
+ function detectPackageManager(cwd) {
396
+ for (const [lockfile, pm] of Object.entries(lockfiles)) {
397
+ if (existsSync5(join5(cwd, lockfile))) return pm;
398
+ }
399
+ const userAgent = process.env.npm_config_user_agent ?? "";
400
+ if (userAgent.startsWith("bun")) return "bun";
401
+ if (userAgent.startsWith("pnpm")) return "pnpm";
402
+ if (userAgent.startsWith("yarn")) return "yarn";
403
+ return "npm";
404
+ }
405
+ function installCommand(pm, packages) {
406
+ switch (pm) {
407
+ case "bun":
408
+ return ["bun", ["add", ...packages]];
409
+ case "pnpm":
410
+ return ["pnpm", ["add", ...packages]];
411
+ case "yarn":
412
+ return ["yarn", ["add", ...packages]];
413
+ case "npm":
414
+ default:
415
+ return ["npm", ["install", ...packages]];
416
+ }
417
+ }
418
+ function dlxCommand(pm, args) {
419
+ switch (pm) {
420
+ case "bun":
421
+ return ["bunx", args];
422
+ case "pnpm":
423
+ return ["pnpm", ["dlx", ...args]];
424
+ case "yarn":
425
+ return ["yarn", ["dlx", ...args]];
426
+ case "npm":
427
+ default:
428
+ return ["npx", args];
429
+ }
430
+ }
431
+
432
+ // src/lib/npm-install.ts
433
+ function installNpmPackages(cwd, packages, pm) {
434
+ if (packages.length === 0) return { ok: true };
435
+ const [cmd, args] = installCommand(pm, packages);
436
+ const result = spawnSync(cmd, args, {
437
+ cwd,
438
+ stdio: "inherit",
439
+ shell: process.platform === "win32"
440
+ });
441
+ if (result.status !== 0) {
442
+ return { ok: false, message: `${cmd} exited with code ${result.status}` };
443
+ }
444
+ return { ok: true };
445
+ }
446
+
447
+ // src/lib/resolve.ts
448
+ import { existsSync as existsSync6 } from "fs";
449
+ import { dirname as dirname3, isAbsolute, relative as relative2, resolve } from "path";
450
+
451
+ // src/lib/registry.ts
452
+ async function loadRegistry() {
453
+ const source = await getComponentSource();
454
+ return source.registry;
455
+ }
456
+ async function findComponent(query) {
457
+ const registry = await loadRegistry();
458
+ const byName = registry.find((e) => e.name === query);
459
+ if (byName) return byName;
460
+ const bySlug = registry.filter((e) => e.slug === query);
461
+ if (bySlug.length === 1) return bySlug[0];
462
+ if (bySlug.length > 1) return bySlug;
463
+ return void 0;
464
+ }
465
+
466
+ // src/lib/resolve.ts
467
+ async function resolveQueries(queries) {
468
+ const entries = [];
469
+ const extraFiles = [];
470
+ const notFound = [];
471
+ const ambiguous = {};
472
+ const seen = /* @__PURE__ */ new Set();
473
+ const seenExtra = /* @__PURE__ */ new Set();
474
+ for (const query of queries) {
475
+ const fromPath = looksLikePath(query) ? await findComponentsByPath(query) : void 0;
476
+ if (fromPath) {
477
+ if (fromPath.entries.length === 0 && fromPath.extraFiles.length === 0) {
478
+ notFound.push(query);
479
+ continue;
480
+ }
481
+ for (const match2 of fromPath.entries) {
482
+ if (!seen.has(match2.name)) {
483
+ seen.add(match2.name);
484
+ entries.push(match2);
485
+ }
486
+ }
487
+ for (const file of fromPath.extraFiles) {
488
+ if (!seenExtra.has(file)) {
489
+ seenExtra.add(file);
490
+ extraFiles.push(file);
491
+ }
492
+ }
493
+ continue;
494
+ }
495
+ const match = await findComponent(query);
496
+ if (!match) {
497
+ notFound.push(query);
498
+ continue;
499
+ }
500
+ if (Array.isArray(match)) {
501
+ ambiguous[query] = match.map((e) => e.name);
502
+ continue;
503
+ }
504
+ if (!seen.has(match.name)) {
505
+ seen.add(match.name);
506
+ entries.push(match);
507
+ }
508
+ }
509
+ return { entries, extraFiles, notFound, ambiguous };
510
+ }
511
+ function unionDependencies(entries) {
512
+ const shadcn = /* @__PURE__ */ new Set();
513
+ const npm = /* @__PURE__ */ new Set();
514
+ const local = /* @__PURE__ */ new Set();
515
+ const i18nKeys = /* @__PURE__ */ new Set();
516
+ for (const entry of entries) {
517
+ entry.dependencies.shadcn.forEach((s) => shadcn.add(s));
518
+ entry.dependencies.npm.forEach((n) => npm.add(n));
519
+ entry.dependencies.local.forEach((l) => local.add(l));
520
+ entry.i18n.keys.forEach((k) => i18nKeys.add(k));
521
+ }
522
+ return {
523
+ shadcn: [...shadcn],
524
+ npm: [...npm],
525
+ local: [...local],
526
+ i18nKeys: [...i18nKeys]
527
+ };
528
+ }
529
+ async function allComponentNames() {
530
+ return (await loadRegistry()).map((e) => `${e.name} (${e.slug})`);
531
+ }
532
+ function looksLikePath(query) {
533
+ return isAbsolute(query) || /[\\/]/.test(query);
534
+ }
535
+ function toPosix(query) {
536
+ return query.replace(/\\/g, "/").replace(/\/$/, "");
537
+ }
538
+ function registryPrefixes(query) {
539
+ const posix = toPosix(query);
540
+ if (!posix || isAbsolute(query)) return [];
541
+ const prefixes = [posix];
542
+ if (!posix.startsWith("src/")) prefixes.push(`src/${posix}`);
543
+ return prefixes;
544
+ }
545
+ function matchesPrefix(filePath, prefix) {
546
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
547
+ }
548
+ function findBoilerplateRoot(start) {
549
+ let dir = start;
550
+ for (let i = 0; i <= 8; i++) {
551
+ if (existsSync6(resolve(dir, "src", "registry", "components.ts"))) return dir;
552
+ const parent = dirname3(dir);
553
+ if (parent === dir) return void 0;
554
+ dir = parent;
555
+ }
556
+ return void 0;
557
+ }
558
+ function queryToRelPath(query) {
559
+ const base = getSourceRoot() ?? process.cwd();
560
+ const abs = isAbsolute(query) ? query : resolve(base, query);
561
+ if (!existsSync6(abs)) return void 0;
562
+ const root = getSourceRoot() ?? findBoilerplateRoot(abs) ?? findBoilerplateRoot(process.cwd());
563
+ if (!root) return void 0;
564
+ const rel = relative2(root, abs).split("\\").join("/");
565
+ if (!rel || rel.startsWith("..")) return void 0;
566
+ return rel.replace(/\/$/, "");
567
+ }
568
+ async function findComponentsByPath(query) {
569
+ const rel = queryToRelPath(query);
570
+ const prefixes = rel ? [rel] : registryPrefixes(query);
571
+ if (prefixes.length === 0) return void 0;
572
+ const source = await getComponentSource();
573
+ const entries = source.registry.filter(
574
+ (entry) => entry.files.some((file) => prefixes.some((prefix) => matchesPrefix(file.path, prefix)))
575
+ );
576
+ const registered = new Set(entries.flatMap((entry) => entry.files.map((file) => file.path)));
577
+ const extra = /* @__PURE__ */ new Set();
578
+ for (const prefix of prefixes) {
579
+ for (const file of source.listFiles(prefix)) {
580
+ if (!registered.has(file)) extra.add(file);
581
+ }
582
+ }
583
+ return { entries, extraFiles: [...extra].sort() };
584
+ }
585
+
586
+ // src/lib/shadcn.ts
587
+ import { existsSync as existsSync7 } from "fs";
588
+ import { join as join6 } from "path";
589
+ import { spawnSync as spawnSync2 } from "child_process";
590
+ function hasShadcnConfig(cwd) {
591
+ return existsSync7(join6(cwd, "components.json"));
592
+ }
593
+ function installShadcnPrimitives(cwd, slugs, pm) {
594
+ if (slugs.length === 0) return [];
595
+ const [cmd, baseArgs] = dlxCommand(pm, ["shadcn@latest", "add", ...slugs, "-y"]);
596
+ const result = spawnSync2(cmd, baseArgs, {
597
+ cwd,
598
+ stdio: "inherit",
599
+ shell: process.platform === "win32"
600
+ });
601
+ const ok = result.status === 0;
602
+ return slugs.map((slug) => ({
603
+ slug,
604
+ ok,
605
+ message: ok ? void 0 : `shadcn add exited with code ${result.status}`
606
+ }));
607
+ }
608
+
609
+ // src/lib/validate-install.ts
610
+ import { existsSync as existsSync8, readFileSync as readFileSync4 } from "fs";
611
+ function validateCopiedFiles(files) {
612
+ const issues = [];
613
+ for (const file of files) {
614
+ if (!existsSync8(file.targetPath)) {
615
+ issues.push({ file: file.targetPath, message: "file was not written" });
616
+ continue;
617
+ }
618
+ const content = readFileSync4(file.targetPath, "utf-8");
619
+ if (/from\s*['"]#\/paraglide\/messages['"]/.test(content)) {
620
+ issues.push({
621
+ file: file.targetPath,
622
+ message: "still imports Paraglide messages (rewrite did not apply)"
623
+ });
624
+ }
625
+ if (/\bm\.[a-zA-Z0-9_]+\(/.test(content)) {
626
+ issues.push({
627
+ file: file.targetPath,
628
+ message: "still calls m.*() (rewrite missed a usage)"
629
+ });
630
+ }
631
+ }
632
+ return issues;
633
+ }
634
+
635
+ // src/commands/add.ts
636
+ async function runAdd(queries, options) {
637
+ const { cwd } = options;
638
+ if (queries.length === 0) {
639
+ console.error(pc.red("Usage: composed-cli add <component> [components...]"));
640
+ console.error("");
641
+ console.error("Available components:");
642
+ for (const name of await allComponentNames()) console.error(` ${name}`);
643
+ process.exitCode = 1;
644
+ return;
645
+ }
646
+ const resolved = await resolveQueries(queries);
647
+ const { entries, notFound, ambiguous } = resolved;
648
+ const extraFiles = [
649
+ .../* @__PURE__ */ new Set([...options.extraFiles ?? [], ...resolved.extraFiles])
650
+ ];
651
+ if (notFound.length > 0) {
652
+ console.error(pc.red(`Unknown component(s): ${notFound.join(", ")}`));
653
+ }
654
+ for (const [query, names] of Object.entries(ambiguous)) {
655
+ console.error(
656
+ pc.red(
657
+ `"${query}" matches multiple components: ${names.join(", ")}. Use one of those exact names.`
658
+ )
659
+ );
660
+ }
661
+ if (entries.length === 0 && extraFiles.length === 0) {
662
+ process.exitCode = 1;
663
+ return;
664
+ }
665
+ if (!hasShadcnConfig(cwd)) {
666
+ console.error(
667
+ pc.red(
668
+ "No components.json found in this project. Run `shadcn init` before `composed-cli add`."
669
+ )
670
+ );
671
+ process.exitCode = 1;
672
+ return;
673
+ }
674
+ console.log(
675
+ pc.bold(
676
+ entries.length > 0 ? `Installing: ${entries.map((e) => e.name).join(", ")}` : `Copying ${extraFiles.length} file(s)`
677
+ )
678
+ );
679
+ const deps = unionDependencies(entries);
680
+ const pm = detectPackageManager(cwd);
681
+ const copiedPrimitiveSlugs = new Set(extraPrimitiveSlugs(extraFiles));
682
+ const shadcnToInstall = deps.shadcn.filter((s) => !copiedPrimitiveSlugs.has(s));
683
+ if (shadcnToInstall.length > 0 && !options.skipShadcn) {
684
+ console.log(pc.cyan(`
685
+ > Installing shadcn primitives: ${shadcnToInstall.join(", ")}`));
686
+ const results = installShadcnPrimitives(cwd, shadcnToInstall, pm);
687
+ const failed = results.filter((r) => !r.ok);
688
+ if (failed.length > 0) {
689
+ console.error(
690
+ pc.red(`shadcn add failed for: ${failed.map((f) => f.slug).join(", ")}`)
691
+ );
692
+ process.exitCode = 1;
693
+ return;
694
+ }
695
+ } else if (deps.shadcn.length > 0) {
696
+ const reason = options.skipShadcn ? "--skip-shadcn" : "copied from source";
697
+ console.log(
698
+ pc.dim(`
699
+ > Skipped shadcn primitives (${reason}): ${deps.shadcn.join(", ")}`)
700
+ );
701
+ }
702
+ if (deps.npm.length > 0 && !options.skipNpm) {
703
+ console.log(pc.cyan(`
704
+ > Installing npm packages: ${deps.npm.join(", ")}`));
705
+ const result = installNpmPackages(cwd, deps.npm, pm);
706
+ if (!result.ok) {
707
+ console.error(pc.red(`npm install failed: ${result.message}`));
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+ } else if (deps.npm.length > 0) {
712
+ console.log(pc.dim(`
713
+ > Skipped npm packages (--skip-npm): ${deps.npm.join(", ")}`));
714
+ }
715
+ const stringsImportPath = "@/lib/composed-strings";
716
+ let i18nAdded = [];
717
+ if (deps.i18nKeys.length > 0 && !options.skipI18n) {
718
+ const result = await installI18nStrings(cwd, deps.i18nKeys);
719
+ i18nAdded = result.addedKeys;
720
+ }
721
+ console.log(pc.cyan(`
722
+ > Copying component files`));
723
+ const copiedFiles = [];
724
+ for (const entry of entries) {
725
+ const copied = await copyComponentFiles(cwd, entry, {
726
+ overwrite: options.overwrite,
727
+ stringsImportPath
728
+ });
729
+ copiedFiles.push(...copied);
730
+ for (const file of copied) {
731
+ if (file.skipped) {
732
+ console.log(pc.yellow(` skip ${toDisplayPath(cwd, file.targetPath)} (${file.reason})`));
733
+ } else {
734
+ console.log(pc.green(` add ${toDisplayPath(cwd, file.targetPath)}`));
735
+ }
736
+ }
737
+ if (entry.knownIssues?.length) {
738
+ for (const issue of entry.knownIssues) {
739
+ console.log(pc.yellow(` ! ${entry.name}: ${issue}`));
740
+ }
741
+ }
742
+ }
743
+ const extraCopied = await copyExtraFiles(cwd, extraFiles, {
744
+ overwrite: options.overwrite,
745
+ useAliases: true,
746
+ stringsImportPath
747
+ });
748
+ copiedFiles.push(...extraCopied);
749
+ for (const file of extraCopied) {
750
+ if (file.skipped) {
751
+ console.log(pc.yellow(` skip ${toDisplayPath(cwd, file.targetPath)} (${file.reason})`));
752
+ } else {
753
+ console.log(pc.green(` add ${toDisplayPath(cwd, file.targetPath)}`));
754
+ }
755
+ }
756
+ for (const localDep of deps.local) {
757
+ const copied = await copyLocalUtil(cwd, localDep, { overwrite: options.overwrite });
758
+ if (copied) {
759
+ copiedFiles.push(copied);
760
+ const line = copied.skipped ? pc.yellow(` skip ${toDisplayPath(cwd, copied.targetPath)} (${copied.reason})`) : pc.green(` add ${toDisplayPath(cwd, copied.targetPath)}`);
761
+ console.log(line);
762
+ }
763
+ }
764
+ if (i18nAdded.length > 0) {
765
+ console.log(pc.green(` add src/lib/composed-strings/ (${i18nAdded.length} key(s))`));
766
+ }
767
+ const issues = validateCopiedFiles(copiedFiles);
768
+ if (issues.length > 0) {
769
+ console.log(pc.red(`
770
+ > Validation found ${issues.length} issue(s):`));
771
+ for (const issue of issues) {
772
+ console.log(pc.red(` ${toDisplayPath(cwd, issue.file)}: ${issue.message}`));
773
+ }
774
+ process.exitCode = 1;
775
+ return;
776
+ }
777
+ const extraNote = extraFiles.length > 0 ? ` and ${extraFiles.length} primitive file(s)` : "";
778
+ console.log(
779
+ pc.bold(pc.green(`
780
+ Done. Installed ${entries.length} component(s)${extraNote}.`))
781
+ );
782
+ }
783
+
784
+ // src/commands/create.ts
785
+ import { existsSync as existsSync12 } from "fs";
786
+ import pc2 from "picocolors";
787
+
788
+ // src/lib/framework-detect.ts
789
+ import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
790
+ import { join as join7 } from "path";
791
+ function readPackageJson(cwd) {
792
+ const pkgPath = join7(cwd, "package.json");
793
+ if (!existsSync9(pkgPath)) return void 0;
794
+ try {
795
+ return JSON.parse(readFileSync5(pkgPath, "utf-8"));
796
+ } catch {
797
+ return void 0;
798
+ }
799
+ }
800
+ function allDeps(pkg) {
801
+ return { ...pkg.dependencies, ...pkg.devDependencies };
802
+ }
803
+ function detectTargetFramework(cwd) {
804
+ const pkg = readPackageJson(cwd);
805
+ if (pkg) {
806
+ const deps = allDeps(pkg);
807
+ if ("@tanstack/react-start" in deps || "@tanstack/start" in deps) {
808
+ return {
809
+ framework: "tanstack",
810
+ reason: "package.json depends on @tanstack/react-start"
811
+ };
812
+ }
813
+ if ("next" in deps) {
814
+ return { framework: "nextjs", reason: "package.json depends on next" };
815
+ }
816
+ }
817
+ if (existsSync9(join7(cwd, "next.config.js")) || existsSync9(join7(cwd, "next.config.ts")) || existsSync9(join7(cwd, "next.config.mjs"))) {
818
+ return { framework: "nextjs", reason: "found a next.config.* file" };
819
+ }
820
+ if (existsSync9(join7(cwd, "app")) && (existsSync9(join7(cwd, "app", "layout.tsx")) || existsSync9(join7(cwd, "app", "layout.js")))) {
821
+ return { framework: "nextjs", reason: "found app/layout.tsx (Next.js App Router)" };
822
+ }
823
+ if (existsSync9(join7(cwd, "src", "app")) && (existsSync9(join7(cwd, "src", "app", "layout.tsx")) || existsSync9(join7(cwd, "src", "app", "layout.js")))) {
824
+ return { framework: "nextjs", reason: "found src/app/layout.tsx (Next.js App Router)" };
825
+ }
826
+ if (existsSync9(join7(cwd, "src", "routeTree.gen.ts"))) {
827
+ return {
828
+ framework: "tanstack",
829
+ reason: "found src/routeTree.gen.ts (TanStack Router)"
830
+ };
831
+ }
832
+ if (existsSync9(join7(cwd, "src", "routes")) && existsSync9(join7(cwd, "src", "router.tsx"))) {
833
+ return {
834
+ framework: "tanstack",
835
+ reason: "found src/routes and src/router.tsx (TanStack Start layout)"
836
+ };
837
+ }
838
+ return void 0;
839
+ }
840
+
841
+ // src/lib/feature-registry.ts
842
+ async function loadFeatureRegistry() {
843
+ const source = await getComponentSource();
844
+ return source.featureRegistry;
845
+ }
846
+ async function findFeature(name) {
847
+ const registry = await loadFeatureRegistry();
848
+ return registry.find((e) => e.name === name);
849
+ }
850
+ async function listFeatureNames() {
851
+ return (await loadFeatureRegistry()).map((e) => e.name);
852
+ }
853
+ function frameworkLabel(framework) {
854
+ return framework === "tanstack" ? "TanStack Start" : "Next.js";
855
+ }
856
+ function supportedFrameworksFor(entry) {
857
+ return Object.keys(entry.frameworks);
858
+ }
859
+
860
+ // src/lib/install-feature.ts
861
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
862
+ import { dirname as dirname4, join as join8 } from "path";
863
+ function nextAppDir(cwd) {
864
+ if (existsSync10(join8(cwd, "src", "app"))) return "src/app";
865
+ if (existsSync10(join8(cwd, "app"))) return "app";
866
+ if (existsSync10(join8(cwd, "src"))) return "src/app";
867
+ return "app";
868
+ }
869
+ function toFeatureTargetPath(cwd, sourceRelPath, impl, framework) {
870
+ let rel = sourceRelPath.replace(/\\/g, "/");
871
+ const prefix = impl.sourcePrefix?.replace(/\\/g, "/");
872
+ if (prefix && rel.startsWith(prefix)) rel = rel.slice(prefix.length);
873
+ if (framework === "nextjs" && (rel === "src/app" || rel.startsWith("src/app/"))) {
874
+ const appDir = nextAppDir(cwd);
875
+ if (appDir === "app") rel = rel.replace(/^src\/app/, "app");
876
+ }
877
+ return rel;
878
+ }
879
+ function adaptContent(content, framework) {
880
+ if (framework !== "nextjs") return content;
881
+ return rewriteParaglideUsage(content, "@/lib/composed-strings").replaceAll("#/", "@/");
882
+ }
883
+ function sourceContent(source, relPath, framework) {
884
+ return adaptContent(source.readFile(relPath), framework);
885
+ }
886
+ function fileMatchesSource(cwd, sourceRelPath, targetRelPath, source, framework) {
887
+ const targetPath = join8(cwd, targetRelPath);
888
+ if (!existsSync10(targetPath)) return false;
889
+ return readFileSync6(targetPath, "utf-8") === sourceContent(source, sourceRelPath, framework);
890
+ }
891
+ async function installFeatureFiles(cwd, impl, opts) {
892
+ const source = await getComponentSource();
893
+ const results = [];
894
+ for (const relPath of impl.files) {
895
+ const targetRel = toFeatureTargetPath(cwd, relPath, impl, opts.framework);
896
+ const targetPath = join8(cwd, targetRel);
897
+ if (existsSync10(targetPath) && !opts.overwrite) {
898
+ if (fileMatchesSource(cwd, relPath, targetRel, source, opts.framework)) {
899
+ results.push({
900
+ path: targetRel,
901
+ status: "skipped",
902
+ reason: "already installed (identical)"
903
+ });
904
+ } else {
905
+ results.push({
906
+ path: targetRel,
907
+ status: "skipped",
908
+ reason: "already exists (use --overwrite to replace)"
909
+ });
910
+ }
911
+ continue;
912
+ }
913
+ const content = sourceContent(source, relPath, opts.framework);
914
+ mkdirSync3(dirname4(targetPath), { recursive: true });
915
+ writeFileSync3(targetPath, content, "utf-8");
916
+ results.push({ path: targetRel, status: "added" });
917
+ }
918
+ return results;
919
+ }
920
+ async function findFeatureFileConflicts(cwd, impl, framework) {
921
+ const source = await getComponentSource();
922
+ const conflicts = [];
923
+ for (const relPath of impl.files) {
924
+ const targetRel = toFeatureTargetPath(cwd, relPath, impl, framework);
925
+ if (existsSync10(join8(cwd, targetRel)) && !fileMatchesSource(cwd, relPath, targetRel, source, framework)) {
926
+ conflicts.push(targetRel);
927
+ }
928
+ }
929
+ return conflicts;
930
+ }
931
+
932
+ // src/lib/move-files.ts
933
+ import { existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync7, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
934
+ import { dirname as dirname5, join as join9 } from "path";
935
+ async function copyEntryToRepo(destRoot, entry, opts) {
936
+ const source = await getComponentSource();
937
+ const results = [];
938
+ for (const file of entry.files) {
939
+ results.push(writeRelPath(destRoot, file.path, source.readFile(file.path), entry.name, opts));
940
+ }
941
+ if (entry.controller) {
942
+ const controllerPath = findControllerPath(source, entry.controller);
943
+ if (!controllerPath) {
944
+ throw new Error(
945
+ `Could not find controller file for ${entry.name} (${entry.controller})`
946
+ );
947
+ }
948
+ results.push(
949
+ writeRelPath(
950
+ destRoot,
951
+ controllerPath,
952
+ source.readFile(controllerPath),
953
+ entry.controller,
954
+ opts
955
+ )
956
+ );
957
+ upsertControllerBarrel(destRoot, entry.controller, controllerPath, results);
958
+ }
959
+ return results;
960
+ }
961
+ async function mergeI18nIntoRepo(destRoot, keys) {
962
+ if (keys.length === 0) return { added: [] };
963
+ const messagesRoot = join9(destRoot, "messages");
964
+ const source = await getComponentSource();
965
+ const added = [];
966
+ for (const locale of ["en", "ar"]) {
967
+ const localeDir = join9(messagesRoot, locale);
968
+ const messages = source.readMessages(locale);
969
+ const namespaces = existsSync11(localeDir) ? readdirSync2(localeDir).filter((file) => file.endsWith(".json")).map((file) => file.replace(/\.json$/, "")) : [];
970
+ const grouped = {};
971
+ for (const key of keys) {
972
+ if (!(key in messages)) continue;
973
+ const ns = namespaceForKey(key, namespaces);
974
+ grouped[ns] ??= {};
975
+ grouped[ns][key] = messages[key];
976
+ }
977
+ for (const [ns, kvs] of Object.entries(grouped)) {
978
+ const filePath = join9(localeDir, `${ns}.json`);
979
+ const existing = existsSync11(filePath) ? JSON.parse(readFileSync7(filePath, "utf-8")) : { $schema: "https://inlang.com/schema/inlang-message-format" };
980
+ for (const [key, value] of Object.entries(kvs)) {
981
+ if (!(key in existing)) added.push(`${locale}/${ns}.json#${key}`);
982
+ existing[key] = value;
983
+ }
984
+ const schema = existing.$schema;
985
+ delete existing.$schema;
986
+ const sorted = Object.fromEntries(
987
+ Object.keys(existing).sort().map((key) => [key, existing[key]])
988
+ );
989
+ const out = schema ? { $schema: schema, ...sorted } : sorted;
990
+ mkdirSync4(dirname5(filePath), { recursive: true });
991
+ writeFileSync4(filePath, JSON.stringify(out, null, 2) + "\n", "utf-8");
992
+ }
993
+ }
994
+ return { added };
995
+ }
996
+ function writeRelPath(destRoot, relPath, content, entryName, opts) {
997
+ const targetPath = join9(destRoot, relPath);
998
+ if (existsSync11(targetPath) && !opts.overwrite) {
999
+ return {
1000
+ entryName,
1001
+ targetPath,
1002
+ skipped: true,
1003
+ reason: "already exists (use --overwrite to replace)"
1004
+ };
1005
+ }
1006
+ mkdirSync4(dirname5(targetPath), { recursive: true });
1007
+ writeFileSync4(targetPath, content, "utf-8");
1008
+ return { entryName, targetPath, skipped: false };
1009
+ }
1010
+ function upsertControllerBarrel(destRoot, exportName, controllerPath, results) {
1011
+ const barrelRel = "src/components/common/controllers/index.ts";
1012
+ const barrelAbs = join9(destRoot, barrelRel);
1013
+ const line = controllerBarrelExport(exportName, controllerPath);
1014
+ const existing = existsSync11(barrelAbs) ? readFileSync7(barrelAbs, "utf-8") : "";
1015
+ if (existing.includes(`export { ${exportName} }`)) return;
1016
+ const next = existing.trimEnd() ? `${existing.replace(/\s*$/, "\n")}${line}
1017
+ ` : `${line}
1018
+ `;
1019
+ mkdirSync4(dirname5(barrelAbs), { recursive: true });
1020
+ writeFileSync4(barrelAbs, next, "utf-8");
1021
+ results.push({ entryName: exportName, targetPath: barrelAbs, skipped: false });
1022
+ }
1023
+ function namespaceForKey(key, namespaces) {
1024
+ const existing = namespaces.filter((ns) => key === ns || key.startsWith(`${ns}_`)).sort((a, b) => b.length - a.length)[0];
1025
+ if (existing) return existing;
1026
+ const underscoreIndex = key.indexOf("_");
1027
+ return underscoreIndex === -1 ? key : key.slice(0, underscoreIndex);
1028
+ }
1029
+
1030
+ // src/commands/create.ts
1031
+ async function runCreateFeature(featureName, options) {
1032
+ const { cwd } = options;
1033
+ if (!featureName) {
1034
+ console.error(pc2.red("Usage: composed-cli create feature <name> [--protected|--public]"));
1035
+ process.exitCode = 1;
1036
+ return;
1037
+ }
1038
+ if (options.protected && options.public) {
1039
+ console.error(pc2.red("Pass either --protected or --public, not both."));
1040
+ process.exitCode = 1;
1041
+ return;
1042
+ }
1043
+ if (!existsSync12(cwd)) {
1044
+ console.error(pc2.red(`Target directory does not exist: ${cwd}`));
1045
+ process.exitCode = 1;
1046
+ return;
1047
+ }
1048
+ const feature = await findFeature(featureName);
1049
+ if (!feature) {
1050
+ const available = await listFeatureNames();
1051
+ console.error(pc2.red(`Feature "${featureName}" is not available in the boilerplate.`));
1052
+ console.error("");
1053
+ console.error(pc2.bold("Available features:"));
1054
+ for (const name of available) console.error(pc2.dim(` - ${name}`));
1055
+ process.exitCode = 1;
1056
+ return;
1057
+ }
1058
+ const detected = detectTargetFramework(cwd);
1059
+ if (!detected) {
1060
+ console.error(
1061
+ pc2.red(
1062
+ `Could not detect the target project's framework at ${cwd}. Expected to find a package.json with a recognizable framework dependency (e.g. "next" or "@tanstack/react-start"), or matching project structure.`
1063
+ )
1064
+ );
1065
+ process.exitCode = 1;
1066
+ return;
1067
+ }
1068
+ const impl = feature.frameworks[detected.framework];
1069
+ if (!impl) {
1070
+ const supported = supportedFrameworksFor(feature);
1071
+ console.error(
1072
+ pc2.red(
1073
+ `Feature "${feature.name}" exists, but ${frameworkLabel(detected.framework)} is not currently supported for this feature.`
1074
+ )
1075
+ );
1076
+ console.error("");
1077
+ console.error(pc2.bold("Supported frameworks:"));
1078
+ for (const fw of supported) console.error(pc2.dim(` - ${frameworkLabel(fw)}`));
1079
+ process.exitCode = 1;
1080
+ return;
1081
+ }
1082
+ const requestedScope = options.protected ? "protected" : options.public ? "public" : void 0;
1083
+ if (requestedScope && impl.routeScope && requestedScope !== impl.routeScope) {
1084
+ console.error(
1085
+ pc2.red(
1086
+ `--${requestedScope} does not apply to "${feature.name}" for ${frameworkLabel(detected.framework)}: this implementation's route is fixed at "${impl.routeScope}".`
1087
+ )
1088
+ );
1089
+ process.exitCode = 1;
1090
+ return;
1091
+ }
1092
+ if (requestedScope && !impl.routeScope) {
1093
+ console.error(
1094
+ pc2.red(
1095
+ `--${requestedScope} does not apply to "${feature.name}" for ${frameworkLabel(detected.framework)}: this implementation has no protected/public route distinction.`
1096
+ )
1097
+ );
1098
+ process.exitCode = 1;
1099
+ return;
1100
+ }
1101
+ console.log(pc2.dim(`Detected framework: ${frameworkLabel(detected.framework)} (${detected.reason})`));
1102
+ const conflicts = await findFeatureFileConflicts(cwd, impl, detected.framework);
1103
+ if (conflicts.length > 0 && !options.overwrite) {
1104
+ console.error(
1105
+ pc2.red(`Feature "${feature.name}" already exists \u2014 the following file(s) already have different content:`)
1106
+ );
1107
+ for (const conflict of conflicts) {
1108
+ console.error(pc2.red(` ${conflict}`));
1109
+ }
1110
+ console.error("");
1111
+ console.error(pc2.dim("Pass --overwrite to replace them."));
1112
+ process.exitCode = 1;
1113
+ return;
1114
+ }
1115
+ if (conflicts.length > 0) {
1116
+ console.log(pc2.yellow(`Overwriting ${conflicts.length} existing file(s).`));
1117
+ }
1118
+ console.log(pc2.bold(`\u2714 Feature: ${feature.name}`));
1119
+ console.log(pc2.bold(`\u2714 Framework implementation: ${frameworkLabel(detected.framework)}`));
1120
+ const copied = await installFeatureFiles(cwd, impl, {
1121
+ overwrite: options.overwrite,
1122
+ framework: detected.framework
1123
+ });
1124
+ for (const file of copied) {
1125
+ if (file.status === "skipped") {
1126
+ console.log(pc2.yellow(` skip ${file.path} (${file.reason})`));
1127
+ } else {
1128
+ console.log(pc2.green(` add ${toDisplayPath(cwd, `${cwd}/${file.path}`)}`));
1129
+ }
1130
+ }
1131
+ if (detected.framework === "nextjs") {
1132
+ const i18n = await installI18nStrings(cwd, impl.i18nKeys);
1133
+ if (i18n.addedKeys.length > 0) {
1134
+ console.log(pc2.green(` add ${i18n.path} (${i18n.addedKeys.length} key(s))`));
1135
+ }
1136
+ } else {
1137
+ const i18n = await mergeI18nIntoRepo(cwd, impl.i18nKeys);
1138
+ if (i18n.added.length > 0) {
1139
+ console.log(pc2.green(` add messages/ (${i18n.added.length} key(s))`));
1140
+ }
1141
+ }
1142
+ console.log("");
1143
+ console.log(pc2.bold(pc2.green(`Done. Installed feature "${feature.name}" (${frameworkLabel(detected.framework)}) into ${cwd}.`)));
1144
+ if (detected.framework === "tanstack") {
1145
+ console.log(
1146
+ pc2.dim(
1147
+ "Restart `bun run dev` (or run a build) so paraglideVitePlugin regenerates src/paraglide/ with any new message keys."
1148
+ )
1149
+ );
1150
+ }
1151
+ }
1152
+
1153
+ // src/commands/move.ts
1154
+ import { existsSync as existsSync13, statSync as statSync2 } from "fs";
1155
+ import { resolve as resolve2 } from "path";
1156
+ import pc3 from "picocolors";
1157
+ async function runMove(queries, options) {
1158
+ if (queries.length === 0) {
1159
+ console.error(
1160
+ pc3.red(
1161
+ "Usage: composed-cli move <component-or-path> [--to <repo>]"
1162
+ )
1163
+ );
1164
+ console.error("");
1165
+ console.error("Pass a registry name (labeled-select) or a path like components/ui.");
1166
+ console.error("Available components:");
1167
+ for (const name of await allComponentNames()) console.error(` ${name}`);
1168
+ process.exitCode = 1;
1169
+ return;
1170
+ }
1171
+ if (options.from) {
1172
+ const fromRoot = resolve2(options.from);
1173
+ if (!existsSync13(fromRoot) || !statSync2(fromRoot).isDirectory()) {
1174
+ console.error(pc3.red(`Source is not a directory: ${fromRoot}`));
1175
+ process.exitCode = 1;
1176
+ return;
1177
+ }
1178
+ setSourceRoot(fromRoot);
1179
+ }
1180
+ const destRoot = resolve2(options.to);
1181
+ if (!existsSync13(destRoot) || !statSync2(destRoot).isDirectory()) {
1182
+ console.error(pc3.red(`Destination is not a directory: ${destRoot}`));
1183
+ process.exitCode = 1;
1184
+ return;
1185
+ }
1186
+ const sourceRoot = resolveSourceRoot();
1187
+ if (sourceRoot && resolve2(sourceRoot) === destRoot) {
1188
+ console.error(pc3.red("Source and destination are the same repository."));
1189
+ console.error("Pass --to <other-repo>, for example:");
1190
+ console.error(
1191
+ pc3.cyan(" npx tsx cli/src/index.ts move src/components/ui --to M:\\my-next-app")
1192
+ );
1193
+ process.exitCode = 1;
1194
+ return;
1195
+ }
1196
+ const { entries, extraFiles, notFound, ambiguous } = await resolveQueries(queries);
1197
+ if (notFound.length > 0) {
1198
+ console.error(pc3.red(`Unknown component(s): ${notFound.join(", ")}`));
1199
+ console.error(
1200
+ pc3.dim(
1201
+ "Use a name like labeled-select, or a path like components/ui. No local boilerplate is required."
1202
+ )
1203
+ );
1204
+ }
1205
+ for (const [query, names] of Object.entries(ambiguous)) {
1206
+ console.error(
1207
+ pc3.red(
1208
+ `"${query}" matches multiple components: ${names.join(", ")}. Use one of those exact names.`
1209
+ )
1210
+ );
1211
+ }
1212
+ if (entries.length === 0 && extraFiles.length === 0) {
1213
+ process.exitCode = 1;
1214
+ return;
1215
+ }
1216
+ if (!existsSync13(resolve2(destRoot, ".git"))) {
1217
+ console.log(pc3.yellow(`Warning: ${destRoot} has no .git directory.`));
1218
+ }
1219
+ if (hasShadcnConfig(destRoot)) {
1220
+ await runAdd(entries.length > 0 ? entries.map((e) => e.name) : queries, {
1221
+ cwd: destRoot,
1222
+ overwrite: options.overwrite,
1223
+ skipShadcn: false,
1224
+ skipNpm: false,
1225
+ skipI18n: false,
1226
+ extraFiles
1227
+ });
1228
+ return;
1229
+ }
1230
+ console.log(
1231
+ pc3.bold(
1232
+ `Moving into ${destRoot}: ${entries.length > 0 ? entries.map((e) => e.name).join(", ") : extraFiles.join(", ")}`
1233
+ )
1234
+ );
1235
+ for (const entry of entries) {
1236
+ const copied = await copyEntryToRepo(destRoot, entry, {
1237
+ overwrite: options.overwrite
1238
+ });
1239
+ for (const file of copied) {
1240
+ if (file.skipped) {
1241
+ console.log(pc3.yellow(` skip ${toDisplayPath(destRoot, file.targetPath)} (${file.reason})`));
1242
+ } else {
1243
+ console.log(pc3.green(` add ${toDisplayPath(destRoot, file.targetPath)}`));
1244
+ }
1245
+ }
1246
+ }
1247
+ const extraCopied = await copyExtraFiles(destRoot, extraFiles, {
1248
+ overwrite: options.overwrite,
1249
+ useAliases: false
1250
+ });
1251
+ for (const file of extraCopied) {
1252
+ if (file.skipped) {
1253
+ console.log(pc3.yellow(` skip ${toDisplayPath(destRoot, file.targetPath)} (${file.reason})`));
1254
+ } else {
1255
+ console.log(pc3.green(` add ${toDisplayPath(destRoot, file.targetPath)}`));
1256
+ }
1257
+ }
1258
+ const deps = unionDependencies(entries);
1259
+ const i18n = await mergeI18nIntoRepo(destRoot, deps.i18nKeys);
1260
+ if (i18n.added.length > 0) {
1261
+ console.log(pc3.green(` add messages/ (${i18n.added.length} key(s))`));
1262
+ }
1263
+ const extraNote = extraFiles.length > 0 ? ` and ${extraFiles.length} primitive file(s)` : "";
1264
+ console.log(
1265
+ pc3.bold(
1266
+ pc3.green(`
1267
+ Done. Copied ${entries.length} component(s)${extraNote} into ${destRoot}.`)
1268
+ )
1269
+ );
1270
+ }
1271
+
1272
+ // src/index.ts
1273
+ var program = new Command();
1274
+ program.name("composed-cli").description(
1275
+ "Install individual composed UI components from the BIM frontend boilerplate into a Next.js + shadcn project."
1276
+ ).version("0.1.3");
1277
+ program.command("add").description("Add one or more composed components to the current project").argument(
1278
+ "<components...>",
1279
+ "component name(s), slug(s), or path(s) like labeled-select or components/ui"
1280
+ ).option("-c, --cwd <path>", "target project directory", process.cwd()).option("-o, --overwrite", "overwrite files that already exist", false).option("--skip-shadcn", "do not run `shadcn add` for required primitives", false).option("--skip-npm", "do not install required npm packages", false).option("--skip-i18n", "do not generate the local i18n strings file", false).action(async (components, opts) => {
1281
+ await runAdd(components, {
1282
+ cwd: opts.cwd,
1283
+ overwrite: opts.overwrite,
1284
+ skipShadcn: opts.skipShadcn,
1285
+ skipNpm: opts.skipNpm,
1286
+ skipI18n: opts.skipI18n
1287
+ });
1288
+ });
1289
+ program.command("move").description(
1290
+ "Copy a composed component and its related files (controller, i18n) into another repository"
1291
+ ).argument(
1292
+ "<components...>",
1293
+ "component name(s), slug(s), or path(s) like labeled-select or components/ui"
1294
+ ).option("-t, --to <path>", "destination repository path", process.cwd()).option(
1295
+ "-f, --from <path>",
1296
+ "source boilerplate repository (optional; the published package uses its bundled registry)"
1297
+ ).option("-o, --overwrite", "overwrite files that already exist", false).action(async (components, opts) => {
1298
+ await runMove(components, {
1299
+ to: opts.to,
1300
+ from: opts.from,
1301
+ overwrite: opts.overwrite
1302
+ });
1303
+ });
1304
+ var create = program.command("create").description(
1305
+ "Install a feature that already exists in the boilerplate into a target project"
1306
+ ).addHelpText(
1307
+ "after",
1308
+ `
1309
+ The boilerplate is the single source of truth for which features exist. This
1310
+ does not generate arbitrary/new features \u2014 it installs an existing, registered
1311
+ boilerplate feature's real files into a target project, in the implementation
1312
+ that matches the target project's own framework.
1313
+
1314
+ Boilerplate -> Feature Registry -> Feature -> Framework Implementation -> Target Project
1315
+
1316
+ See \`composed-cli create feature --help\` for the feature installer.`
1317
+ );
1318
+ create.command("feature").description(
1319
+ "Install an existing boilerplate feature (route, screen, API layer, translations) into a target project, using the implementation registered for that project's detected framework."
1320
+ ).argument("<name>", "registered feature name, e.g. login, dashboard, about, contact").option("-c, --cwd <path>", "target project directory", process.cwd()).option("--protected", "require/assert the protected-route implementation").option("--public", "require/assert the public-route implementation").option("-o, --overwrite", "overwrite files that already exist", false).addHelpText(
1321
+ "after",
1322
+ `
1323
+ The target project's framework is detected automatically (package.json
1324
+ dependencies, then project structure) \u2014 it does not need to be a copy of this
1325
+ boilerplate. Only frameworks with a registered implementation for the
1326
+ requested feature are supported; run the command to see the registry's error
1327
+ message list available features or supported frameworks.
1328
+
1329
+ --protected / --public are validated against the resolved implementation's
1330
+ own route scope (e.g. dashboard is always protected, about is always public)
1331
+ rather than choosing it \u2014 pass one only to assert the scope you expect;
1332
+ omit both to just install whatever the registry defines.
1333
+
1334
+ Examples:
1335
+ $ composed-cli create feature login
1336
+ $ composed-cli create feature dashboard
1337
+ $ composed-cli create feature login --cwd ./next-app
1338
+ $ composed-cli create feature dashboard --cwd ./tanstack-app
1339
+ $ composed-cli create feature login --overwrite
1340
+
1341
+ Behavior:
1342
+ - Unknown feature name -> rejected, lists available features.
1343
+ - Feature exists but no implementation for the detected framework -> rejected, lists supported frameworks.
1344
+ - Next.js targets install App Router pages from cli/templates/nextjs/ and rewrite Paraglide to @/lib/composed-strings.
1345
+ - Every target file is checked for conflicts before anything is written; existing files are reported and left untouched unless --overwrite is passed.
1346
+ - Files are copied byte-for-byte from the boilerplate's real source tree \u2014 nothing is templated or invented.
1347
+ - Translation keys are merged into the target's messages/<locale>/*.json when that folder exists; skipped otherwise.`
1348
+ ).action(async (name, opts) => {
1349
+ await runCreateFeature(name, {
1350
+ cwd: opts.cwd,
1351
+ protected: opts.protected ?? false,
1352
+ public: opts.public ?? false,
1353
+ overwrite: opts.overwrite
1354
+ });
1355
+ });
1356
+ program.parseAsync(process.argv);