@organcli/composed-cli 0.1.0 → 0.1.1

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
@@ -10,6 +10,30 @@ import pc from "picocolors";
10
10
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
11
  import { dirname as dirname2, join as join4, relative } from "path";
12
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
+
13
37
  // src/lib/i18n.ts
14
38
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
15
39
  import { join as join2 } from "path";
@@ -37,6 +61,7 @@ function loadFromBundle() {
37
61
  );
38
62
  return {
39
63
  registry: bundle.registry,
64
+ featureRegistry: bundle.featureRegistry ?? [],
40
65
  readFile: (path) => {
41
66
  const content = bundle.files[path];
42
67
  if (content === void 0) {
@@ -44,22 +69,34 @@ function loadFromBundle() {
44
69
  }
45
70
  return content;
46
71
  },
72
+ hasFile: (path) => path in bundle.files,
73
+ listFiles: (prefix) => Object.keys(bundle.files).filter((p) => p === prefix || p.startsWith(prefix)),
47
74
  readMessages: (locale) => bundle.messages[locale] ?? {}
48
75
  };
49
76
  }
50
77
  async function loadFromBoilerplateTree() {
51
- const boilerplateRoot = findAncestorContaining(
52
- here,
53
- join("src", "registry", "components.ts")
54
- );
78
+ const boilerplateRoot = overrideRoot ?? findAncestorContaining(here, join("src", "registry", "components.ts"));
55
79
  if (!boilerplateRoot) return void 0;
80
+ if (!existsSync(join(boilerplateRoot, "src", "registry", "components.ts"))) {
81
+ return void 0;
82
+ }
56
83
  const registryPath = join(boilerplateRoot, "src", "registry", "components.ts");
57
84
  const mod = await import(
58
85
  /* @vite-ignore */
59
86
  pathToFileURL(registryPath).href
60
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
+ }
61
97
  return {
62
98
  registry: mod.componentRegistry,
99
+ featureRegistry,
63
100
  readFile: (path) => {
64
101
  const abs = join(boilerplateRoot, path);
65
102
  if (!existsSync(abs)) {
@@ -67,28 +104,54 @@ async function loadFromBoilerplateTree() {
67
104
  }
68
105
  return readFileSync(abs, "utf-8");
69
106
  },
107
+ hasFile: (path) => existsSync(join(boilerplateRoot, path)),
108
+ listFiles: (prefix) => listFilesFromTree(boilerplateRoot, prefix),
70
109
  readMessages: (locale) => readMessagesFromTree(boilerplateRoot, locale)
71
110
  };
72
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
+ }
73
126
  function readMessagesFromTree(boilerplateRoot, locale) {
74
127
  const dir = join(boilerplateRoot, "messages", locale);
75
128
  const out = {};
76
129
  if (!existsSync(dir)) return out;
77
130
  for (const file of readdirSync(dir)) {
78
131
  if (!file.endsWith(".json")) continue;
79
- const namespace = file.replace(/\.json$/, "");
80
132
  const json = JSON.parse(readFileSync(join(dir, file), "utf-8"));
81
133
  for (const [key, value] of Object.entries(json)) {
82
134
  if (key === "$schema") continue;
83
- out[`${namespace}_${key}`] = String(value);
135
+ out[key] = String(value);
84
136
  }
85
137
  }
86
138
  return out;
87
139
  }
88
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
+ }
89
152
  async function getComponentSource() {
90
153
  if (cached) return cached;
91
- const source = loadFromBundle() ?? await loadFromBoilerplateTree();
154
+ const source = overrideRoot ? await loadFromBoilerplateTree() : loadFromBundle() ?? await loadFromBoilerplateTree();
92
155
  if (!source) {
93
156
  throw new Error(
94
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)."
@@ -205,8 +268,41 @@ async function copyComponentFiles(cwd, entry, opts) {
205
268
  writeFileSync2(targetPath, content, "utf-8");
206
269
  results.push({ entryName: entry.name, targetPath, skipped: false });
207
270
  }
271
+ if (entry.controller) {
272
+ const controller = await copyController(cwd, entry, opts);
273
+ if (controller) results.push(controller);
274
+ }
208
275
  return results;
209
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
+ }
210
306
  async function copyLocalUtil(cwd, localDep, opts) {
211
307
  if (localDep !== "@/lib/utils") return void 0;
212
308
  const source = await getComponentSource();
@@ -294,6 +390,10 @@ function installNpmPackages(cwd, packages, pm) {
294
390
  return { ok: true };
295
391
  }
296
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
+
297
397
  // src/lib/registry.ts
298
398
  async function loadRegistry() {
299
399
  const source = await getComponentSource();
@@ -316,6 +416,20 @@ async function resolveQueries(queries) {
316
416
  const ambiguous = {};
317
417
  const seen = /* @__PURE__ */ new Set();
318
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
+ }
319
433
  const match = await findComponent(query);
320
434
  if (!match) {
321
435
  notFound.push(query);
@@ -353,13 +467,58 @@ function unionDependencies(entries) {
353
467
  async function allComponentNames() {
354
468
  return (await loadRegistry()).map((e) => `${e.name} (${e.slug})`);
355
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
+ }
356
515
 
357
516
  // src/lib/shadcn.ts
358
- import { existsSync as existsSync6 } from "fs";
517
+ import { existsSync as existsSync7 } from "fs";
359
518
  import { join as join6 } from "path";
360
519
  import { spawnSync as spawnSync2 } from "child_process";
361
520
  function hasShadcnConfig(cwd) {
362
- return existsSync6(join6(cwd, "components.json"));
521
+ return existsSync7(join6(cwd, "components.json"));
363
522
  }
364
523
  function installShadcnPrimitives(cwd, slugs, pm) {
365
524
  if (slugs.length === 0) return [];
@@ -378,11 +537,11 @@ function installShadcnPrimitives(cwd, slugs, pm) {
378
537
  }
379
538
 
380
539
  // src/lib/validate-install.ts
381
- import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
540
+ import { existsSync as existsSync8, readFileSync as readFileSync4 } from "fs";
382
541
  function validateCopiedFiles(files) {
383
542
  const issues = [];
384
543
  for (const file of files) {
385
- if (!existsSync7(file.targetPath)) {
544
+ if (!existsSync8(file.targetPath)) {
386
545
  issues.push({ file: file.targetPath, message: "file was not written" });
387
546
  continue;
388
547
  }
@@ -523,12 +682,487 @@ async function runAdd(queries, options) {
523
682
  Done. Installed ${entries.length} component(s).`)));
524
683
  }
525
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
+
526
1157
  // src/index.ts
527
1158
  var program = new Command();
528
1159
  program.name("composed-cli").description(
529
1160
  "Install individual composed UI components from the BIM frontend boilerplate into a Next.js + shadcn project."
530
- ).version("0.1.0");
531
- program.command("add").description("Add one or more composed components to the current project").argument("<components...>", "component name(s) or slug(s), e.g. select dialog table").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) => {
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) => {
532
1166
  await runAdd(components, {
533
1167
  cwd: opts.cwd,
534
1168
  overwrite: opts.overwrite,
@@ -537,4 +1171,71 @@ program.command("add").description("Add one or more composed components to the c
537
1171
  skipI18n: opts.skipI18n
538
1172
  });
539
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
+ });
540
1241
  program.parseAsync(process.argv);