bsmnt 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.changeset/2026-02-11-test-patch-bump.md +5 -0
  2. package/.changeset/README.md +10 -0
  3. package/.changeset/config.json +16 -0
  4. package/.cursor/rules/README.md +184 -0
  5. package/.cursor/rules/architecture.mdc +437 -0
  6. package/.cursor/rules/components.mdc +436 -0
  7. package/.cursor/rules/integrations.mdc +447 -0
  8. package/.cursor/rules/main.mdc +278 -0
  9. package/.cursor/rules/styling.mdc +433 -0
  10. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  11. package/.github/workflows/.gitkeep +0 -0
  12. package/.github/workflows/ci.yml +37 -0
  13. package/.github/workflows/release.yml +54 -0
  14. package/.tldr/cache/call_graph.json +7 -0
  15. package/.tldr/languages.json +6 -0
  16. package/.tldr/status +1 -0
  17. package/.tldrignore +84 -0
  18. package/.vscode/extensions.json +20 -0
  19. package/.vscode/settings.json +98 -0
  20. package/CHANGELOG.md +13 -0
  21. package/CLAUDE.md +138 -0
  22. package/README.md +176 -0
  23. package/bin/index.js +262 -0
  24. package/biome.json +44 -0
  25. package/bun.lock +496 -0
  26. package/changelog/04-02-26.md +86 -0
  27. package/changelog/05-02-26.md +101 -0
  28. package/changelog/09-02-26.md +83 -0
  29. package/docs/fix-studio-hydration.md +46 -0
  30. package/docs/plans/2026-01-29-sanity-smart-merge-design.md +196 -0
  31. package/docs/plans/2026-01-29-sanity-smart-merge-implementation.md +695 -0
  32. package/docs/sanity-setup-steps.md +199 -0
  33. package/integrations/basehub/README.md +3 -0
  34. package/integrations/sanity/app/api/draft-mode/disable/route.ts +7 -0
  35. package/integrations/sanity/app/api/draft-mode/enable/route.ts +21 -0
  36. package/integrations/sanity/app/api/revalidate/route.ts +37 -0
  37. package/integrations/sanity/app/layout.tsx +111 -0
  38. package/integrations/sanity/app/sitemap.ts +80 -0
  39. package/integrations/sanity/app/studio/[[...tool]]/page.tsx +8 -0
  40. package/integrations/sanity/app/studio/layout.tsx +7 -0
  41. package/integrations/sanity/components/ui/sanity-image/index.tsx +37 -0
  42. package/integrations/sanity/lib/integrations/README.md +58 -0
  43. package/integrations/sanity/lib/integrations/check-integration.ts +62 -0
  44. package/integrations/sanity/lib/integrations/sanity/README.md +144 -0
  45. package/integrations/sanity/lib/integrations/sanity/client.ts +30 -0
  46. package/integrations/sanity/lib/integrations/sanity/components/disable-draft-mode.tsx +29 -0
  47. package/integrations/sanity/lib/integrations/sanity/components/rich-text.tsx +73 -0
  48. package/integrations/sanity/lib/integrations/sanity/env.ts +38 -0
  49. package/integrations/sanity/lib/integrations/sanity/live/index.tsx +34 -0
  50. package/integrations/sanity/lib/integrations/sanity/queries.ts +99 -0
  51. package/integrations/sanity/lib/integrations/sanity/sanity.cli.ts +20 -0
  52. package/integrations/sanity/lib/integrations/sanity/sanity.config.ts +94 -0
  53. package/integrations/sanity/lib/integrations/sanity/sanity.types.ts +337 -0
  54. package/integrations/sanity/lib/integrations/sanity/schema.json +1850 -0
  55. package/integrations/sanity/lib/integrations/sanity/schemas/article.ts +132 -0
  56. package/integrations/sanity/lib/integrations/sanity/schemas/example.ts +203 -0
  57. package/integrations/sanity/lib/integrations/sanity/schemas/index.ts +37 -0
  58. package/integrations/sanity/lib/integrations/sanity/schemas/link.ts +127 -0
  59. package/integrations/sanity/lib/integrations/sanity/schemas/metadata.ts +68 -0
  60. package/integrations/sanity/lib/integrations/sanity/schemas/navigation.ts +39 -0
  61. package/integrations/sanity/lib/integrations/sanity/schemas/page.ts +77 -0
  62. package/integrations/sanity/lib/integrations/sanity/schemas/richText.ts +59 -0
  63. package/integrations/sanity/lib/integrations/sanity/structure.ts +5 -0
  64. package/integrations/sanity/lib/integrations/sanity/utils/image.ts +11 -0
  65. package/integrations/sanity/lib/integrations/sanity/utils/link.ts +61 -0
  66. package/integrations/sanity/lib/scripts/copy-sanity-mcp.ts +23 -0
  67. package/integrations/sanity/lib/scripts/generate-page.ts +310 -0
  68. package/integrations/sanity/lib/utils/metadata.ts +190 -0
  69. package/layers/experiment/components/layout/header/index.tsx +58 -0
  70. package/layers/experiment/components/layout/navigation-menu.tsx +127 -0
  71. package/layers/experiment/lib/constants.ts +12 -0
  72. package/layers/webgl/app/page.tsx +10 -0
  73. package/layers/webgl/components/webgl/canvas/dynamic.tsx +34 -0
  74. package/layers/webgl/components/webgl/canvas/index.tsx +43 -0
  75. package/layers/webgl/components/webgl/components/scene/index.tsx +21 -0
  76. package/layers/webgpu/.gitkeep +0 -0
  77. package/package.json +44 -0
  78. package/plugins/README.md +21 -0
  79. package/plugins/no-anchor-element.grit +11 -0
  80. package/plugins/no-relative-parent-imports.grit +6 -0
  81. package/plugins/no-unnecessary-forwardref.grit +5 -0
  82. package/src/commands/add-integration.js +325 -0
  83. package/src/commands/create.js +415 -0
  84. package/src/commands/setup-sanity.js +426 -0
  85. package/src/commands/worktree.js +805 -0
  86. package/src/mergers/check-integration-merger.js +105 -0
  87. package/src/mergers/config.js +137 -0
  88. package/src/mergers/index.js +355 -0
  89. package/src/mergers/layout-merger.js +223 -0
  90. package/src/mergers/next-config-merger.js +63 -0
  91. package/src/mergers/sitemap-merger.js +121 -0
  92. package/tasks/prd-next-starter-dynamic-layers.md +184 -0
  93. package/tasks/prd.json +153 -0
  94. package/tasks/progress.txt +115 -0
  95. package/template-hooks/use-battery.ts +126 -0
  96. package/template-hooks/use-device-perf.ts +184 -0
  97. package/template-hooks/use-intersection-observer.ts +32 -0
  98. package/template-hooks/use-media.ts +33 -0
@@ -0,0 +1,105 @@
1
+ // src/mergers/check-integration-merger.js
2
+ import fs from "fs-extra";
3
+
4
+ /**
5
+ * Sanity configuration check function to add
6
+ */
7
+ const SANITY_FUNCTION = `
8
+ /**
9
+ * Check if Sanity CMS is configured
10
+ * Requires: NEXT_PUBLIC_SANITY_PROJECT_ID and NEXT_PUBLIC_SANITY_DATASET
11
+ */
12
+ export function isSanityConfigured(): boolean {
13
+ return Boolean(
14
+ process.env.NEXT_PUBLIC_SANITY_PROJECT_ID &&
15
+ process.env.NEXT_PUBLIC_SANITY_DATASET
16
+ )
17
+ }
18
+ `;
19
+
20
+ /**
21
+ * Update to getConfiguredIntegrations to include Sanity
22
+ */
23
+ const SANITY_CONFIGURED_CHECK = ` if (isSanityConfigured()) integrations.push("Sanity")`;
24
+
25
+ /**
26
+ * Update to getUnconfiguredIntegrations to include Sanity
27
+ */
28
+ const SANITY_UNCONFIGURED_CHECK = ` if (!isSanityConfigured()) integrations.push("Sanity")`;
29
+
30
+ /**
31
+ * Merge Sanity integration into template check-integration
32
+ * @param {string} templatePath - Path to the template's check-integration.ts
33
+ * @returns {Promise<{skipped?: boolean, reason?: string, success?: boolean}>}
34
+ */
35
+ export async function mergeCheckIntegration(templatePath) {
36
+ let content = await fs.readFile(templatePath, "utf-8");
37
+
38
+ // Skip if already has isSanityConfigured
39
+ if (content.includes("isSanityConfigured")) {
40
+ return { skipped: true, reason: "Already has isSanityConfigured" };
41
+ }
42
+
43
+ // 1. Add isSanityConfigured function after isAnalyticsConfigured
44
+ const analyticsMatch = content.match(
45
+ /(export function isAnalyticsConfigured\(\)[^}]+\})/s,
46
+ );
47
+ if (analyticsMatch) {
48
+ const insertPos = analyticsMatch.index + analyticsMatch[0].length;
49
+ content =
50
+ content.slice(0, insertPos) +
51
+ "\n" +
52
+ SANITY_FUNCTION +
53
+ content.slice(insertPos);
54
+ } else {
55
+ // If no isAnalyticsConfigured, add before getConfiguredIntegrations
56
+ const getConfiguredMatch = content.match(
57
+ /export function getConfiguredIntegrations/,
58
+ );
59
+ if (getConfiguredMatch) {
60
+ content =
61
+ content.slice(0, getConfiguredMatch.index) +
62
+ SANITY_FUNCTION +
63
+ "\n" +
64
+ content.slice(getConfiguredMatch.index);
65
+ } else {
66
+ // Fallback: append to end
67
+ content = `${content.trimEnd()}\n${SANITY_FUNCTION}`;
68
+ }
69
+ }
70
+
71
+ // 2. Update getConfiguredIntegrations to include Sanity check
72
+ const configuredFnMatch = content.match(
73
+ /(export function getConfiguredIntegrations\(\)[^{]*\{[\s\S]*?)(if \(isAnalyticsConfigured\(\)\))/m,
74
+ );
75
+ if (configuredFnMatch) {
76
+ const insertPos = content.indexOf(
77
+ configuredFnMatch[2],
78
+ configuredFnMatch.index,
79
+ );
80
+ content =
81
+ content.slice(0, insertPos) +
82
+ SANITY_CONFIGURED_CHECK +
83
+ "\n " +
84
+ content.slice(insertPos);
85
+ }
86
+
87
+ // 3. Update getUnconfiguredIntegrations to include Sanity check
88
+ const unconfiguredFnMatch = content.match(
89
+ /(export function getUnconfiguredIntegrations\(\)[^{]*\{[\s\S]*?)(if \(!isAnalyticsConfigured\(\)\))/m,
90
+ );
91
+ if (unconfiguredFnMatch) {
92
+ const insertPos = content.indexOf(
93
+ unconfiguredFnMatch[2],
94
+ unconfiguredFnMatch.index,
95
+ );
96
+ content =
97
+ content.slice(0, insertPos) +
98
+ SANITY_UNCONFIGURED_CHECK +
99
+ "\n " +
100
+ content.slice(insertPos);
101
+ }
102
+
103
+ await fs.writeFile(templatePath, content);
104
+ return { success: true };
105
+ }
@@ -0,0 +1,137 @@
1
+ // src/mergers/config.js
2
+
3
+ /**
4
+ * CMS-specific configuration for smart merge integration
5
+ * Each CMS has its own branch, merge files, and additive paths
6
+ */
7
+ export const CMS_CONFIG = {
8
+ sanity: {
9
+ // Git branch containing the Sanity integration files
10
+ branch: "sanity-integration",
11
+
12
+ // Files that need smart merging (exist in both template and integration)
13
+ mergeFiles: [
14
+ "app/layout.tsx",
15
+ "app/sitemap.ts",
16
+ "lib/integrations/check-integration.ts",
17
+ ],
18
+
19
+ // Paths that are purely additive (copy directly, no conflict)
20
+ additivePaths: [
21
+ "lib/integrations/sanity",
22
+ "components/ui/sanity-image",
23
+ "app/api/draft-mode",
24
+ "app/api/revalidate",
25
+ "app/studio",
26
+ "lib/utils/metadata.ts",
27
+ "lib/scripts/generate-page.ts",
28
+ "lib/scripts/copy-sanity-mcp.ts",
29
+ "lib/integrations/README.md",
30
+ ],
31
+ },
32
+
33
+ basehub: {
34
+ // Git branch containing the BaseHub integration files
35
+ branch: "basehub-integration",
36
+
37
+ // Files that need smart merging for BaseHub
38
+ mergeFiles: [],
39
+
40
+ // Paths that are purely additive for BaseHub
41
+ additivePaths: ["lib/integrations/basehub"],
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Get configuration for a specific CMS
47
+ * @param {string} cms - The CMS name (e.g., 'sanity', 'basehub')
48
+ * @returns {object|null} CMS configuration or null if not supported
49
+ */
50
+ export function getCmsConfig(cms) {
51
+ return CMS_CONFIG[cms] || null;
52
+ }
53
+
54
+ /**
55
+ * Check if a CMS is supported
56
+ * @param {string} cms - The CMS name
57
+ * @returns {boolean} Whether the CMS has a configuration
58
+ */
59
+ export function isCmsSupported(cms) {
60
+ return cms in CMS_CONFIG;
61
+ }
62
+
63
+ /**
64
+ * Technology layer configuration for overlay on next-starter base
65
+ * Each layer defines files to replace, additive paths, and dependencies
66
+ */
67
+ /**
68
+ * Technology layer configuration for overlay on next-starter base.
69
+ *
70
+ * next-starter already provides: clsx, tailwind-merge, zustand, zod, cva, etc.
71
+ * Layers only add packages that are NOT in next-starter.
72
+ */
73
+ export const LAYER_CONFIG = {
74
+ webgl: {
75
+ replaceFiles: ["app/page.tsx"],
76
+ additivePaths: [
77
+ "components/webgl/canvas/dynamic.tsx",
78
+ "components/webgl/canvas/index.tsx",
79
+ "components/webgl/components/scene/index.tsx",
80
+ ],
81
+ dependencies: {
82
+ "@react-three/fiber": "^9.5.0",
83
+ "@react-three/drei": "^10.7.7",
84
+ three: "^0.182.0",
85
+ "@radix-ui/react-navigation-menu": "^1.2.5",
86
+ leva: "^0.9.35",
87
+ },
88
+ devDependencies: {
89
+ "@types/three": "^0.182.0",
90
+ },
91
+ },
92
+
93
+ webgpu: {
94
+ replaceFiles: [],
95
+ additivePaths: [],
96
+ dependencies: {
97
+ "@react-three/fiber": "10.0.0-alpha.2",
98
+ "@react-three/drei": "^11.0.0-alpha.4",
99
+ three: "^0.182.0",
100
+ leva: "^0.9.35",
101
+ "lucide-react": "^0.474.0",
102
+ "@radix-ui/react-navigation-menu": "^1.2.5",
103
+ },
104
+ devDependencies: {
105
+ "@types/three": "^0.182.0",
106
+ },
107
+ },
108
+
109
+ experiment: {
110
+ replaceFiles: ["components/layout/header/index.tsx"],
111
+ additivePaths: [
112
+ "components/layout/navigation-menu.tsx",
113
+ "lib/constants.ts",
114
+ ],
115
+ dependencies: {
116
+ "@react-three/fiber": "^9.5.0",
117
+ "@react-three/drei": "^10.7.7",
118
+ three: "^0.182.0",
119
+ "class-variance-authority": "^0.7.0",
120
+ leva: "^0.9.35",
121
+ "lucide-react": "^0.474.0",
122
+ "@radix-ui/react-navigation-menu": "^1.2.5",
123
+ },
124
+ devDependencies: {
125
+ "@types/three": "^0.182.0",
126
+ },
127
+ },
128
+ };
129
+
130
+ /**
131
+ * Get configuration for a specific technology layer
132
+ * @param {string} layer - The layer name (e.g., 'webgl', 'webgpu', 'experiment')
133
+ * @returns {object|null} Layer configuration or null if not found
134
+ */
135
+ export function getLayerConfig(layer) {
136
+ return LAYER_CONFIG[layer] || null;
137
+ }
@@ -0,0 +1,355 @@
1
+ // src/mergers/index.js
2
+
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import fs from "fs-extra";
7
+ import tiged from "tiged";
8
+ import { getCmsConfig, getLayerConfig } from "./config.js";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+
13
+ /**
14
+ * CMS-specific merger functions (using base paths without src/ prefix)
15
+ * Only loaded when the specific CMS is selected
16
+ */
17
+ const CMS_MERGERS = {
18
+ sanity: {
19
+ "app/layout.tsx": () =>
20
+ import("./layout-merger.js").then((m) => m.mergeLayout),
21
+ "app/sitemap.ts": () =>
22
+ import("./sitemap-merger.js").then((m) => m.mergeSitemap),
23
+ "lib/integrations/check-integration.ts": () =>
24
+ import("./check-integration-merger.js").then(
25
+ (m) => m.mergeCheckIntegration,
26
+ ),
27
+ },
28
+ basehub: {
29
+ // BaseHub mergers would go here when implemented
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Detect if the target project uses src/ prefix for app, lib, components
35
+ * @param {string} targetDir - The project directory
36
+ * @returns {Promise<string>} Empty string or 'src/' prefix
37
+ */
38
+ async function detectPathPrefix(targetDir) {
39
+ // Check for src/app/ directory (Next.js App Router in src/)
40
+ const srcAppExists = await fs.pathExists(path.join(targetDir, "src/app"));
41
+ if (srcAppExists) {
42
+ return "src/";
43
+ }
44
+ return "";
45
+ }
46
+
47
+ /**
48
+ * Transform a path by adding prefix where needed
49
+ * Only transforms paths that start with app/, lib/, or components/
50
+ * @param {string} filePath - Original path
51
+ * @param {string} prefix - Prefix to add (e.g., 'src/')
52
+ * @returns {string} Transformed path
53
+ */
54
+ function transformPath(filePath, prefix) {
55
+ if (!prefix) return filePath;
56
+
57
+ // Paths that should be prefixed when using src/ structure
58
+ const prefixablePaths = ["app/", "lib/", "components/"];
59
+
60
+ for (const prefixable of prefixablePaths) {
61
+ if (filePath.startsWith(prefixable)) {
62
+ return prefix + filePath;
63
+ }
64
+ }
65
+
66
+ const prefixableFiles = ["middleware.ts", "middleware.js"];
67
+ for (const prefixableFile of prefixableFiles) {
68
+ if (filePath === prefixableFile) {
69
+ return prefix + filePath;
70
+ }
71
+ }
72
+
73
+ return filePath;
74
+ }
75
+
76
+ /**
77
+ * Get merger function for a specific CMS and file
78
+ * @param {string} cms - The CMS name
79
+ * @param {string} basePath - The base file path (without src/ prefix)
80
+ * @returns {Promise<Function|null>} Merger function or null
81
+ */
82
+ async function getMerger(cms, basePath) {
83
+ const cmsmergers = CMS_MERGERS[cms];
84
+ if (!cmsmergers || !cmsmergers[basePath]) {
85
+ return null;
86
+ }
87
+ return await cmsmergers[basePath]();
88
+ }
89
+
90
+ /**
91
+ * Inject CMS integration using smart merge
92
+ * @param {string} targetDir - The project directory
93
+ * @param {string} cms - The CMS name (e.g., 'sanity', 'basehub')
94
+ * @param {object} spinner - Ora spinner for progress
95
+ * @returns {object} Results of the merge operation
96
+ */
97
+ export async function injectIntegration(targetDir, cms, spinner) {
98
+ const results = {
99
+ merged: [],
100
+ copied: [],
101
+ skipped: [],
102
+ failed: [],
103
+ };
104
+
105
+ // Get CMS-specific configuration
106
+ const cmsConfig = getCmsConfig(cms);
107
+
108
+ if (!cmsConfig) {
109
+ results.failed.push({
110
+ path: cms,
111
+ error: `CMS "${cms}" is not supported. Supported: sanity, basehub`,
112
+ });
113
+ return results;
114
+ }
115
+
116
+ const { branch, mergeFiles, additivePaths } = cmsConfig;
117
+
118
+ // Detect if project uses src/ prefix (e.g., src/app/ instead of app/)
119
+ const pathPrefix = await detectPathPrefix(targetDir);
120
+ if (pathPrefix) {
121
+ spinner.text = `Detected src/ directory structure...`;
122
+ }
123
+
124
+ // 1. Clone integration to temp directory
125
+ const tempDir = path.join(os.tmpdir(), `integration-${Date.now()}`);
126
+
127
+ try {
128
+ spinner.text = `Downloading ${cms} integration...`;
129
+
130
+ // Use CMS-specific branch
131
+ const integrationRepoPath = `github:basementstudio/basement-cli/integrations/${cms}#${branch}`;
132
+ const emitter = tiged(integrationRepoPath, {
133
+ cache: false,
134
+ force: true,
135
+ mode: "git",
136
+ });
137
+
138
+ await emitter.clone(tempDir);
139
+
140
+ // 2. Copy additive files/directories (CMS-specific paths)
141
+ spinner.text = `Adding ${cms} files...`;
142
+
143
+ for (const additivePath of additivePaths) {
144
+ const src = path.join(tempDir, additivePath);
145
+ // Transform destination path based on project structure
146
+ const transformedPath = transformPath(additivePath, pathPrefix);
147
+ const dest = path.join(targetDir, transformedPath);
148
+
149
+ try {
150
+ if (await fs.pathExists(src)) {
151
+ await fs.copy(src, dest, { overwrite: false });
152
+ results.copied.push(transformedPath);
153
+ }
154
+ } catch (error) {
155
+ results.failed.push({ path: transformedPath, error: error.message });
156
+ }
157
+ }
158
+
159
+ // 3. Smart merge files that exist in both (CMS-specific mergers)
160
+ if (mergeFiles.length > 0) {
161
+ spinner.text = `Merging ${cms} integration...`;
162
+
163
+ for (const mergeFile of mergeFiles) {
164
+ // Transform path for the target project
165
+ const transformedPath = transformPath(mergeFile, pathPrefix);
166
+ const templateFile = path.join(targetDir, transformedPath);
167
+ const integrationFile = path.join(tempDir, mergeFile);
168
+
169
+ try {
170
+ // Check if template file exists
171
+ if (!(await fs.pathExists(templateFile))) {
172
+ // Template doesn't have this file - copy from integration
173
+ if (await fs.pathExists(integrationFile)) {
174
+ await fs.ensureDir(path.dirname(templateFile));
175
+ await fs.copy(integrationFile, templateFile);
176
+ results.copied.push(transformedPath);
177
+ } else {
178
+ results.skipped.push({
179
+ path: transformedPath,
180
+ reason: "Not in integration",
181
+ });
182
+ }
183
+ continue;
184
+ }
185
+
186
+ // Check if integration file exists
187
+ if (!(await fs.pathExists(integrationFile))) {
188
+ results.skipped.push({
189
+ path: transformedPath,
190
+ reason: "Not in integration",
191
+ });
192
+ continue;
193
+ }
194
+
195
+ // Get the CMS-specific merger function (use base path for lookup)
196
+ const merger = await getMerger(cms, mergeFile);
197
+
198
+ if (merger) {
199
+ const result = await merger(templateFile, {
200
+ targetDir,
201
+ pathPrefix,
202
+ });
203
+ if (result.skipped) {
204
+ results.skipped.push({
205
+ path: transformedPath,
206
+ reason: result.reason,
207
+ });
208
+ } else {
209
+ results.merged.push(transformedPath);
210
+ }
211
+ } else {
212
+ results.skipped.push({
213
+ path: transformedPath,
214
+ reason: `No merger for ${cms}`,
215
+ });
216
+ }
217
+ } catch (error) {
218
+ results.failed.push({ path: transformedPath, error: error.message });
219
+ }
220
+ }
221
+ }
222
+ } finally {
223
+ // 4. Cleanup temp directory
224
+ try {
225
+ await fs.remove(tempDir);
226
+ } catch {
227
+ // Ignore cleanup errors
228
+ }
229
+ }
230
+
231
+ return results;
232
+ }
233
+
234
+ /**
235
+ * Inject technology layer files on top of next-starter base
236
+ * @param {string} targetDir - The project directory
237
+ * @param {string} layer - The layer name (e.g., 'webgl', 'webgpu', 'experiment')
238
+ * @param {object} spinner - Ora spinner for progress
239
+ * @returns {object} Results of the layer injection
240
+ */
241
+ export async function injectLayer(targetDir, layer, spinner) {
242
+ const results = {
243
+ replaced: [],
244
+ copied: [],
245
+ skipped: [],
246
+ failed: [],
247
+ };
248
+
249
+ const layerConfig = getLayerConfig(layer);
250
+
251
+ if (!layerConfig || layer === "default") {
252
+ results.skipped.push({ path: layer, reason: "No layer to apply" });
253
+ return results;
254
+ }
255
+
256
+ // Resolve the layer directory from the CLI repo's layers/{type}/ path
257
+ const layerDir = path.resolve(__dirname, "../../layers", layer);
258
+
259
+ if (!(await fs.pathExists(layerDir))) {
260
+ results.skipped.push({
261
+ path: layer,
262
+ reason: `Layer directory not found: ${layerDir}`,
263
+ });
264
+ return results;
265
+ }
266
+
267
+ // Detect if project uses src/ prefix
268
+ const pathPrefix = await detectPathPrefix(targetDir);
269
+ if (pathPrefix && spinner) {
270
+ spinner.text = `Detected src/ directory structure...`;
271
+ }
272
+
273
+ const { replaceFiles, additivePaths } = layerConfig;
274
+
275
+ // 1. Replace files (overwrite base version)
276
+ for (const replaceFile of replaceFiles) {
277
+ const src = path.join(layerDir, replaceFile);
278
+ const transformedPath = transformPath(replaceFile, pathPrefix);
279
+ const dest = path.join(targetDir, transformedPath);
280
+
281
+ try {
282
+ if (await fs.pathExists(src)) {
283
+ await fs.ensureDir(path.dirname(dest));
284
+ await fs.copy(src, dest, { overwrite: true });
285
+ results.replaced.push(transformedPath);
286
+ } else {
287
+ results.skipped.push({
288
+ path: transformedPath,
289
+ reason: "Source file not found in layer",
290
+ });
291
+ }
292
+ } catch (error) {
293
+ results.failed.push({ path: transformedPath, error: error.message });
294
+ }
295
+ }
296
+
297
+ // 2. Copy additive files
298
+ for (const additivePath of additivePaths) {
299
+ const src = path.join(layerDir, additivePath);
300
+ const transformedPath = transformPath(additivePath, pathPrefix);
301
+ const dest = path.join(targetDir, transformedPath);
302
+
303
+ try {
304
+ if (await fs.pathExists(src)) {
305
+ await fs.ensureDir(path.dirname(dest));
306
+ await fs.copy(src, dest, { overwrite: false });
307
+ results.copied.push(transformedPath);
308
+ } else {
309
+ results.skipped.push({
310
+ path: transformedPath,
311
+ reason: "Source file not found in layer",
312
+ });
313
+ }
314
+ } catch (error) {
315
+ results.failed.push({ path: transformedPath, error: error.message });
316
+ }
317
+ }
318
+
319
+ return results;
320
+ }
321
+
322
+ /**
323
+ * Format merge results for display
324
+ * @param {object} results - Results from injectIntegration or injectLayer
325
+ * @returns {string[]} Lines to display
326
+ */
327
+ export function formatMergeResults(results) {
328
+ const lines = [];
329
+
330
+ if (results.replaced && results.replaced.length > 0) {
331
+ lines.push(...results.replaced.map((f) => ` ✓ Replaced: ${f}`));
332
+ }
333
+
334
+ if (results.merged && results.merged.length > 0) {
335
+ lines.push(...results.merged.map((f) => ` ✓ Merged: ${f}`));
336
+ }
337
+
338
+ if (results.copied && results.copied.length > 0) {
339
+ lines.push(...results.copied.map((f) => ` ✓ Added: ${f}`));
340
+ }
341
+
342
+ if (results.skipped && results.skipped.length > 0) {
343
+ lines.push(
344
+ ...results.skipped.map((s) => ` ○ Skipped: ${s.path} (${s.reason})`),
345
+ );
346
+ }
347
+
348
+ if (results.failed && results.failed.length > 0) {
349
+ lines.push(
350
+ ...results.failed.map((f) => ` ✗ Failed: ${f.path} (${f.error})`),
351
+ );
352
+ }
353
+
354
+ return lines;
355
+ }