@gemini-designer/mcp-server 0.1.2 → 0.1.29

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 (129) hide show
  1. package/dist/components/catalog.d.ts.map +1 -1
  2. package/dist/components/catalog.js +10 -4
  3. package/dist/components/catalog.js.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +11 -6
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/context/builder.d.ts.map +1 -1
  8. package/dist/context/builder.js.map +1 -1
  9. package/dist/context/filter.d.ts.map +1 -1
  10. package/dist/context/filter.js +5 -1
  11. package/dist/context/filter.js.map +1 -1
  12. package/dist/context/grounding.d.ts.map +1 -1
  13. package/dist/context/grounding.js +7 -3
  14. package/dist/context/grounding.js.map +1 -1
  15. package/dist/context/guards.d.ts.map +1 -1
  16. package/dist/context/guards.js +53 -0
  17. package/dist/context/guards.js.map +1 -1
  18. package/dist/context/repo-hints.js.map +1 -1
  19. package/dist/context/styling-detector.d.ts +24 -0
  20. package/dist/context/styling-detector.d.ts.map +1 -0
  21. package/dist/context/styling-detector.js +337 -0
  22. package/dist/context/styling-detector.js.map +1 -0
  23. package/dist/design/principles.js.map +1 -1
  24. package/dist/generation/gemini-client.d.ts.map +1 -1
  25. package/dist/generation/gemini-client.js.map +1 -1
  26. package/dist/generation/litellm-client.d.ts.map +1 -1
  27. package/dist/generation/litellm-client.js +14 -7
  28. package/dist/generation/litellm-client.js.map +1 -1
  29. package/dist/generation/remote-client.d.ts +10 -5
  30. package/dist/generation/remote-client.d.ts.map +1 -1
  31. package/dist/generation/remote-client.js +13 -2
  32. package/dist/generation/remote-client.js.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/output/file-writer.d.ts.map +1 -1
  35. package/dist/output/file-writer.js +4 -4
  36. package/dist/output/file-writer.js.map +1 -1
  37. package/dist/output/formatter.d.ts.map +1 -1
  38. package/dist/output/formatter.js +5 -2
  39. package/dist/output/formatter.js.map +1 -1
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +2 -1
  42. package/dist/server.js.map +1 -1
  43. package/dist/stack/detect.d.ts.map +1 -1
  44. package/dist/stack/detect.js +42 -9
  45. package/dist/stack/detect.js.map +1 -1
  46. package/dist/tokens/sync.d.ts.map +1 -1
  47. package/dist/tokens/sync.js +22 -5
  48. package/dist/tokens/sync.js.map +1 -1
  49. package/dist/tools/analyze-screenshot-ui.d.ts.map +1 -1
  50. package/dist/tools/analyze-screenshot-ui.js +5 -5
  51. package/dist/tools/analyze-screenshot-ui.js.map +1 -1
  52. package/dist/tools/analyze-tokens.d.ts.map +1 -1
  53. package/dist/tools/analyze-tokens.js +3 -1
  54. package/dist/tools/analyze-tokens.js.map +1 -1
  55. package/dist/tools/catalog-components.d.ts.map +1 -1
  56. package/dist/tools/catalog-components.js +1 -4
  57. package/dist/tools/catalog-components.js.map +1 -1
  58. package/dist/tools/create-ui.d.ts +3 -0
  59. package/dist/tools/create-ui.d.ts.map +1 -1
  60. package/dist/tools/create-ui.js +203 -75
  61. package/dist/tools/create-ui.js.map +1 -1
  62. package/dist/tools/detect-ui-stack.js.map +1 -1
  63. package/dist/tools/generate-component-variants.d.ts.map +1 -1
  64. package/dist/tools/generate-component-variants.js +15 -4
  65. package/dist/tools/generate-component-variants.js.map +1 -1
  66. package/dist/tools/generate-vibes.d.ts.map +1 -1
  67. package/dist/tools/generate-vibes.js +7 -3
  68. package/dist/tools/generate-vibes.js.map +1 -1
  69. package/dist/tools/index.js.map +1 -1
  70. package/dist/tools/modify-ui.d.ts.map +1 -1
  71. package/dist/tools/modify-ui.js +7 -2
  72. package/dist/tools/modify-ui.js.map +1 -1
  73. package/dist/tools/scaffold-project.d.ts.map +1 -1
  74. package/dist/tools/scaffold-project.js +3 -1
  75. package/dist/tools/scaffold-project.js.map +1 -1
  76. package/dist/tools/snippet-ui.d.ts +3 -1
  77. package/dist/tools/snippet-ui.d.ts.map +1 -1
  78. package/dist/tools/snippet-ui.js +219 -88
  79. package/dist/tools/snippet-ui.js.map +1 -1
  80. package/dist/tools/sync-design-tokens.d.ts.map +1 -1
  81. package/dist/tools/sync-design-tokens.js +26 -11
  82. package/dist/tools/sync-design-tokens.js.map +1 -1
  83. package/dist/utils/walk.d.ts.map +1 -1
  84. package/dist/utils/walk.js.map +1 -1
  85. package/dist/version.d.ts +2 -0
  86. package/dist/version.d.ts.map +1 -0
  87. package/dist/version.js +5 -0
  88. package/dist/version.js.map +1 -0
  89. package/package.json +55 -55
  90. package/src/__tests__/builder.test.ts +19 -19
  91. package/src/__tests__/config.test.ts +63 -31
  92. package/src/__tests__/filter.test.ts +98 -92
  93. package/src/__tests__/remote-client.test.ts +179 -0
  94. package/src/components/catalog.ts +170 -166
  95. package/src/config/index.ts +185 -177
  96. package/src/context/builder.ts +157 -157
  97. package/src/context/filter.ts +110 -104
  98. package/src/context/grounding.ts +143 -129
  99. package/src/context/guards.ts +97 -38
  100. package/src/context/repo-hints.ts +24 -24
  101. package/src/context/styling-detector.ts +460 -0
  102. package/src/design/principles.ts +14 -14
  103. package/src/generation/gemini-client.ts +53 -56
  104. package/src/generation/litellm-client.ts +102 -86
  105. package/src/generation/remote-client.ts +100 -77
  106. package/src/index.ts +16 -16
  107. package/src/output/file-writer.ts +123 -123
  108. package/src/output/formatter.ts +139 -132
  109. package/src/server.ts +12 -11
  110. package/src/stack/detect.ts +226 -175
  111. package/src/tokens/sync.ts +189 -155
  112. package/src/tools/analyze-screenshot-ui.ts +89 -88
  113. package/src/tools/analyze-tokens.ts +80 -78
  114. package/src/tools/catalog-components.ts +68 -68
  115. package/src/tools/create-ui.ts +295 -142
  116. package/src/tools/detect-ui-stack.ts +36 -36
  117. package/src/tools/generate-component-variants.ts +155 -135
  118. package/src/tools/generate-vibes.ts +121 -117
  119. package/src/tools/index.ts +14 -14
  120. package/src/tools/modify-ui.ts +170 -165
  121. package/src/tools/scaffold-project.ts +68 -66
  122. package/src/tools/snippet-ui.ts +323 -172
  123. package/src/tools/sync-design-tokens.ts +217 -195
  124. package/src/utils/walk.ts +47 -45
  125. package/src/version.ts +6 -0
  126. package/tsconfig.json +23 -33
  127. package/vitest.config.ts +10 -10
  128. package/.prettierrc +0 -9
  129. package/eslint.config.js +0 -37
@@ -16,134 +16,140 @@ import * as path from 'node:path';
16
16
  import { Config } from '../config/index.js';
17
17
  import { detectUiStack, type StackDetectionResult } from '../stack/detect.js';
18
18
  import { walkFiles, toPosixPath } from '../utils/walk.js';
19
- import { buildComponentCatalog, type CatalogResult, type ComponentExport } from '../components/catalog.js';
19
+ import {
20
+ buildComponentCatalog,
21
+ type CatalogResult,
22
+ type ComponentExport,
23
+ } from '../components/catalog.js';
20
24
 
21
25
  type Cached = {
22
- ts: number;
23
- stack: StackDetectionResult;
24
- catalog: CatalogResult;
26
+ ts: number;
27
+ stack: StackDetectionResult;
28
+ catalog: CatalogResult;
25
29
  };
26
30
 
27
31
  const CACHE_TTL_MS = 60_000;
28
32
  const cache = new Map<string, Cached>();
29
33
 
30
34
  function resolveRootForFile(absFile: string | null, config: Config): string {
31
- const allowed = (config.allowedPaths || []).map((p) => path.resolve(p));
32
- const fallback = allowed[0] || process.cwd();
33
- if (!absFile) return fallback;
35
+ const allowed = (config.allowedPaths || []).map((p) => path.resolve(p));
36
+ const fallback = allowed[0] || process.cwd();
37
+ if (!absFile) return fallback;
34
38
 
35
- const abs = path.resolve(absFile);
39
+ const abs = path.resolve(absFile);
36
40
 
37
- // pick the deepest allowed root that contains the file
38
- const matches = allowed
39
- .filter((root) => abs === root || abs.startsWith(root + path.sep))
40
- .sort((a, b) => b.length - a.length);
41
+ // pick the deepest allowed root that contains the file
42
+ const matches = allowed
43
+ .filter((root) => abs === root || abs.startsWith(root + path.sep))
44
+ .sort((a, b) => b.length - a.length);
41
45
 
42
- return matches[0] || fallback;
46
+ return matches[0] || fallback;
43
47
  }
44
48
 
45
49
  function summarizeStack(stack: StackDetectionResult): string {
46
- const summary = {
47
- framework: stack.framework,
48
- typescript: stack.language.typescript,
49
- styling: stack.styling,
50
- uiLibraries: stack.uiLibraries,
51
- iconLibraries: stack.iconLibraries,
52
- tooling: stack.tooling,
53
- conventions: {
54
- srcDir: stack.conventions.srcDir,
55
- hasAppDir: stack.conventions.hasAppDir,
56
- hasPagesDir: stack.conventions.hasPagesDir,
57
- tsconfigPaths: stack.conventions.tsconfigPaths ? Object.keys(stack.conventions.tsconfigPaths) : undefined,
58
- },
59
- files: {
60
- tailwindConfig: stack.files.tailwindConfig,
61
- componentsJson: stack.files.componentsJson,
62
- storybookDir: stack.files.storybookDir,
63
- },
64
- warnings: stack.warnings,
65
- };
66
- return JSON.stringify(summary, null, 2);
50
+ const summary = {
51
+ framework: stack.framework,
52
+ typescript: stack.language.typescript,
53
+ styling: stack.styling,
54
+ uiLibraries: stack.uiLibraries,
55
+ iconLibraries: stack.iconLibraries,
56
+ tooling: stack.tooling,
57
+ conventions: {
58
+ srcDir: stack.conventions.srcDir,
59
+ hasAppDir: stack.conventions.hasAppDir,
60
+ hasPagesDir: stack.conventions.hasPagesDir,
61
+ tsconfigPaths: stack.conventions.tsconfigPaths
62
+ ? Object.keys(stack.conventions.tsconfigPaths)
63
+ : undefined,
64
+ },
65
+ files: {
66
+ tailwindConfig: stack.files.tailwindConfig,
67
+ componentsJson: stack.files.componentsJson,
68
+ storybookDir: stack.files.storybookDir,
69
+ },
70
+ warnings: stack.warnings,
71
+ };
72
+ return JSON.stringify(summary, null, 2);
67
73
  }
68
74
 
69
75
  function tokenizeInstruction(instruction?: string): Set<string> {
70
- const set = new Set<string>();
71
- if (!instruction) return set;
72
- const tokens = instruction.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
73
- for (const t of tokens) set.add(t.toLowerCase());
74
- return set;
76
+ const set = new Set<string>();
77
+ if (!instruction) return set;
78
+ const tokens = instruction.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
79
+ for (const t of tokens) set.add(t.toLowerCase());
80
+ return set;
75
81
  }
76
82
 
77
83
  function scoreComponent(
78
- c: ComponentExport,
79
- focusDirRel: string | null,
80
- instructionTokens: Set<string>
84
+ c: ComponentExport,
85
+ focusDirRel: string | null,
86
+ instructionTokens: Set<string>
81
87
  ): number {
82
- let score = 0;
88
+ let score = 0;
83
89
 
84
- // Mentioned explicitly in instruction
85
- if (instructionTokens.has(c.name.toLowerCase())) score += 8;
90
+ // Mentioned explicitly in instruction
91
+ if (instructionTokens.has(c.name.toLowerCase())) score += 8;
86
92
 
87
- // Same directory as focused file
88
- if (focusDirRel && c.file.startsWith(focusDirRel + '/')) score += 6;
93
+ // Same directory as focused file
94
+ if (focusDirRel && c.file.startsWith(focusDirRel + '/')) score += 6;
89
95
 
90
- // Common reusable directories
91
- if (
92
- c.file.includes('/components/') ||
93
- c.file.startsWith('components/') ||
94
- c.file.includes('/ui/') ||
95
- c.file.includes('/shared/')
96
- ) {
97
- score += 3;
98
- }
96
+ // Common reusable directories
97
+ if (
98
+ c.file.includes('/components/') ||
99
+ c.file.startsWith('components/') ||
100
+ c.file.includes('/ui/') ||
101
+ c.file.includes('/shared/')
102
+ ) {
103
+ score += 3;
104
+ }
99
105
 
100
- // Prefer TSX
101
- if (c.file.endsWith('.tsx')) score += 1;
106
+ // Prefer TSX
107
+ if (c.file.endsWith('.tsx')) score += 1;
102
108
 
103
- return score;
109
+ return score;
104
110
  }
105
111
 
106
112
  function formatCatalogSubset(catalog: CatalogResult, subset: ComponentExport[]): string {
107
- const lines: string[] = [];
108
- const header = `scanned_files=${catalog.filesScanned}, total_exports=${catalog.components.length}`;
109
- lines.push(header);
110
- if (catalog.warnings.length) {
111
- lines.push(`warnings: ${catalog.warnings.join(' | ')}`);
112
- }
113
- lines.push('');
114
- lines.push('components:');
115
- for (const c of subset) {
116
- const extras: string[] = [];
117
- if (c.exportType) extras.push(c.exportType);
118
- if (c.propsType) extras.push(`props: ${c.propsType}`);
119
- if (c.jsDoc) extras.push(`doc: ${c.jsDoc}`);
120
- const extra = extras.length ? ` (${extras.join(', ')})` : '';
121
- lines.push(`- ${c.name} — ${c.file}${extra}`);
122
- }
123
- return lines.join('\n');
113
+ const lines: string[] = [];
114
+ const header = `scanned_files=${catalog.filesScanned}, total_exports=${catalog.components.length}`;
115
+ lines.push(header);
116
+ if (catalog.warnings.length) {
117
+ lines.push(`warnings: ${catalog.warnings.join(' | ')}`);
118
+ }
119
+ lines.push('');
120
+ lines.push('components:');
121
+ for (const c of subset) {
122
+ const extras: string[] = [];
123
+ if (c.exportType) extras.push(c.exportType);
124
+ if (c.propsType) extras.push(`props: ${c.propsType}`);
125
+ if (c.jsDoc) extras.push(`doc: ${c.jsDoc}`);
126
+ const extra = extras.length ? ` (${extras.join(', ')})` : '';
127
+ lines.push(`- ${c.name} — ${c.file}${extra}`);
128
+ }
129
+ return lines.join('\n');
124
130
  }
125
131
 
126
132
  async function getOrBuildRepoData(root: string): Promise<Cached> {
127
- const now = Date.now();
128
- const hit = cache.get(root);
129
- if (hit && now - hit.ts < CACHE_TTL_MS) return hit;
130
-
131
- const stack = detectUiStack(root);
132
- const files = walkFiles(root, {
133
- includeExtensions: ['.tsx', '.jsx'],
134
- maxFiles: 5000,
135
- });
136
- const catalog = await buildComponentCatalog(root, files);
137
-
138
- const fresh: Cached = { ts: now, stack, catalog };
139
- cache.set(root, fresh);
140
- return fresh;
133
+ const now = Date.now();
134
+ const hit = cache.get(root);
135
+ if (hit && now - hit.ts < CACHE_TTL_MS) return hit;
136
+
137
+ const stack = detectUiStack(root);
138
+ const files = walkFiles(root, {
139
+ includeExtensions: ['.tsx', '.jsx'],
140
+ maxFiles: 5000,
141
+ });
142
+ const catalog = await buildComponentCatalog(root, files);
143
+
144
+ const fresh: Cached = { ts: now, stack, catalog };
145
+ cache.set(root, fresh);
146
+ return fresh;
141
147
  }
142
148
 
143
149
  export interface RepoGroundingOptions {
144
- focusFileAbs?: string;
145
- instruction?: string;
146
- maxComponents?: number;
150
+ focusFileAbs?: string;
151
+ instruction?: string;
152
+ maxComponents?: number;
147
153
  }
148
154
 
149
155
  /**
@@ -152,40 +158,48 @@ export interface RepoGroundingOptions {
152
158
  * This is safe to include in prompts because it contains only local, deterministic
153
159
  * project metadata (no secrets).
154
160
  */
155
- export async function buildRepoGrounding(config: Config, options: RepoGroundingOptions = {}): Promise<string> {
156
- const maxComponents = typeof options.maxComponents === 'number' ? options.maxComponents : 120;
157
-
158
- const root = resolveRootForFile(options.focusFileAbs ? path.resolve(options.focusFileAbs) : null, config);
159
- const data = await getOrBuildRepoData(root);
160
-
161
- const focusRel = options.focusFileAbs ? toPosixPath(path.relative(root, path.resolve(options.focusFileAbs))) : null;
162
- const focusDirRel = focusRel ? toPosixPath(path.posix.dirname(focusRel)) : null;
163
- const instructionTokens = tokenizeInstruction(options.instruction);
164
-
165
- // Score and select a small subset for reuse.
166
- const scored = data.catalog.components
167
- .map((c) => ({ c, s: scoreComponent(c, focusDirRel, instructionTokens) }))
168
- .sort((a, b) => b.s - a.s);
169
-
170
- const subset: ComponentExport[] = [];
171
- const seen = new Set<string>();
172
- for (const item of scored) {
173
- if (subset.length >= maxComponents) break;
174
- const key = `${item.c.name}|${item.c.file}|${item.c.exportType}`;
175
- if (seen.has(key)) continue;
176
- // Skip ultra-low relevance if we already have enough
177
- if (subset.length > 30 && item.s <= 0) break;
178
- subset.push(item.c);
179
- seen.add(key);
180
- }
181
-
182
- return [
183
- 'AUTO PROJECT CONTEXT (deterministic):',
184
- '',
185
- 'STACK (json):',
186
- summarizeStack(data.stack),
187
- '',
188
- 'COMPONENT CATALOG (subset for reuse):',
189
- formatCatalogSubset(data.catalog, subset),
190
- ].join('\n');
161
+ export async function buildRepoGrounding(
162
+ config: Config,
163
+ options: RepoGroundingOptions = {}
164
+ ): Promise<string> {
165
+ const maxComponents = typeof options.maxComponents === 'number' ? options.maxComponents : 120;
166
+
167
+ const root = resolveRootForFile(
168
+ options.focusFileAbs ? path.resolve(options.focusFileAbs) : null,
169
+ config
170
+ );
171
+ const data = await getOrBuildRepoData(root);
172
+
173
+ const focusRel = options.focusFileAbs
174
+ ? toPosixPath(path.relative(root, path.resolve(options.focusFileAbs)))
175
+ : null;
176
+ const focusDirRel = focusRel ? toPosixPath(path.posix.dirname(focusRel)) : null;
177
+ const instructionTokens = tokenizeInstruction(options.instruction);
178
+
179
+ // Score and select a small subset for reuse.
180
+ const scored = data.catalog.components
181
+ .map((c) => ({ c, s: scoreComponent(c, focusDirRel, instructionTokens) }))
182
+ .sort((a, b) => b.s - a.s);
183
+
184
+ const subset: ComponentExport[] = [];
185
+ const seen = new Set<string>();
186
+ for (const item of scored) {
187
+ if (subset.length >= maxComponents) break;
188
+ const key = `${item.c.name}|${item.c.file}|${item.c.exportType}`;
189
+ if (seen.has(key)) continue;
190
+ // Skip ultra-low relevance if we already have enough
191
+ if (subset.length > 30 && item.s <= 0) break;
192
+ subset.push(item.c);
193
+ seen.add(key);
194
+ }
195
+
196
+ return [
197
+ 'AUTO PROJECT CONTEXT (deterministic):',
198
+ '',
199
+ 'STACK (json):',
200
+ summarizeStack(data.stack),
201
+ '',
202
+ 'COMPONENT CATALOG (subset for reuse):',
203
+ formatCatalogSubset(data.catalog, subset),
204
+ ].join('\n');
191
205
  }
@@ -14,11 +14,36 @@ import * as path from 'node:path';
14
14
  import { Config } from '../config/index.js';
15
15
  import { isPathAllowed, isSensitiveFile } from './filter.js';
16
16
 
17
+ function isRealPathAllowed(filePath: string, allowedPaths: string[]): boolean {
18
+ let absPath: string;
19
+ try {
20
+ absPath = fs.realpathSync(filePath);
21
+ } catch {
22
+ absPath = path.resolve(filePath);
23
+ }
24
+
25
+ for (const allowed of allowedPaths) {
26
+ let absAllowed: string;
27
+ try {
28
+ absAllowed = fs.realpathSync(allowed);
29
+ } catch {
30
+ absAllowed = path.resolve(allowed);
31
+ }
32
+
33
+ const rel = path.relative(absAllowed, absPath);
34
+ if (rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel))) {
35
+ return true;
36
+ }
37
+ }
38
+
39
+ return false;
40
+ }
41
+
17
42
  /**
18
43
  * Resolve a path to an absolute path (relative paths are resolved from process.cwd()).
19
44
  */
20
45
  export function resolveToAbs(filePath: string): string {
21
- return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
46
+ return path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
22
47
  }
23
48
 
24
49
  /**
@@ -26,26 +51,31 @@ export function resolveToAbs(filePath: string): string {
26
51
  * Returns the resolved absolute path.
27
52
  */
28
53
  export function assertReadablePath(filePath: string, config: Config): string {
29
- const absPath = resolveToAbs(filePath);
54
+ const absPath = resolveToAbs(filePath);
30
55
 
31
- if (!isPathAllowed(absPath, config.allowedPaths)) {
32
- throw new Error(`Path is outside allowedPaths: ${filePath}`);
33
- }
56
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
57
+ throw new Error(`Path is outside allowedPaths: ${filePath}`);
58
+ }
34
59
 
35
- if (isSensitiveFile(absPath)) {
36
- throw new Error(`Refusing to read sensitive file: ${filePath}`);
37
- }
60
+ if (isSensitiveFile(absPath)) {
61
+ throw new Error(`Refusing to read sensitive file: ${filePath}`);
62
+ }
38
63
 
39
- if (!fs.existsSync(absPath)) {
40
- throw new Error(`File not found: ${filePath}`);
41
- }
64
+ if (!fs.existsSync(absPath)) {
65
+ throw new Error(`File not found: ${filePath}`);
66
+ }
42
67
 
43
- const stat = fs.statSync(absPath);
44
- if (!stat.isFile()) {
45
- throw new Error(`Not a file: ${filePath}`);
46
- }
68
+ // Prevent symlink escapes (resolve real path and re-check allowedPaths).
69
+ if (!isRealPathAllowed(absPath, config.allowedPaths)) {
70
+ throw new Error(`Path resolves outside allowedPaths: ${filePath}`);
71
+ }
47
72
 
48
- return absPath;
73
+ const stat = fs.statSync(absPath);
74
+ if (!stat.isFile()) {
75
+ throw new Error(`Not a file: ${filePath}`);
76
+ }
77
+
78
+ return absPath;
49
79
  }
50
80
 
51
81
  /**
@@ -53,17 +83,41 @@ export function assertReadablePath(filePath: string, config: Config): string {
53
83
  * Returns the resolved absolute path.
54
84
  */
55
85
  export function assertWritablePath(filePath: string, config: Config): string {
56
- const absPath = resolveToAbs(filePath);
86
+ const absPath = resolveToAbs(filePath);
57
87
 
58
- if (!isPathAllowed(absPath, config.allowedPaths)) {
59
- throw new Error(`Path is outside allowedPaths: ${filePath}`);
60
- }
88
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
89
+ throw new Error(`Path is outside allowedPaths: ${filePath}`);
90
+ }
61
91
 
62
- if (isSensitiveFile(absPath)) {
63
- throw new Error(`Refusing to write to sensitive path: ${filePath}`);
64
- }
92
+ if (isSensitiveFile(absPath)) {
93
+ throw new Error(`Refusing to write to sensitive path: ${filePath}`);
94
+ }
65
95
 
96
+ // If the target already exists, disallow writing through symlinks.
97
+ if (fs.existsSync(absPath)) {
98
+ const st = fs.lstatSync(absPath);
99
+ if (st.isSymbolicLink()) {
100
+ throw new Error(`Refusing to write through symlink: ${filePath}`);
101
+ }
102
+ if (!isRealPathAllowed(absPath, config.allowedPaths)) {
103
+ throw new Error(`Path resolves outside allowedPaths: ${filePath}`);
104
+ }
66
105
  return absPath;
106
+ }
107
+
108
+ // If the file doesn't exist yet, validate the closest existing parent directory.
109
+ let parent = path.dirname(absPath);
110
+ while (parent !== path.dirname(parent) && !fs.existsSync(parent)) {
111
+ parent = path.dirname(parent);
112
+ }
113
+ if (!fs.existsSync(parent)) {
114
+ throw new Error(`Parent directory not found for path: ${filePath}`);
115
+ }
116
+ if (!isRealPathAllowed(parent, config.allowedPaths)) {
117
+ throw new Error(`Parent directory resolves outside allowedPaths: ${filePath}`);
118
+ }
119
+
120
+ return absPath;
67
121
  }
68
122
 
69
123
  /**
@@ -71,24 +125,29 @@ export function assertWritablePath(filePath: string, config: Config): string {
71
125
  * Returns the resolved absolute path.
72
126
  */
73
127
  export function assertReadableDir(dirPath: string, config: Config): string {
74
- const absPath = resolveToAbs(dirPath);
128
+ const absPath = resolveToAbs(dirPath);
75
129
 
76
- if (!isPathAllowed(absPath, config.allowedPaths)) {
77
- throw new Error(`Path is outside allowedPaths: ${dirPath}`);
78
- }
130
+ if (!isPathAllowed(absPath, config.allowedPaths)) {
131
+ throw new Error(`Path is outside allowedPaths: ${dirPath}`);
132
+ }
79
133
 
80
- if (isSensitiveFile(absPath)) {
81
- throw new Error(`Refusing to read sensitive directory: ${dirPath}`);
82
- }
134
+ if (isSensitiveFile(absPath)) {
135
+ throw new Error(`Refusing to read sensitive directory: ${dirPath}`);
136
+ }
83
137
 
84
- if (!fs.existsSync(absPath)) {
85
- throw new Error(`Directory not found: ${dirPath}`);
86
- }
138
+ if (!fs.existsSync(absPath)) {
139
+ throw new Error(`Directory not found: ${dirPath}`);
140
+ }
87
141
 
88
- const stat = fs.statSync(absPath);
89
- if (!stat.isDirectory()) {
90
- throw new Error(`Not a directory: ${dirPath}`);
91
- }
142
+ // Prevent symlink escapes.
143
+ if (!isRealPathAllowed(absPath, config.allowedPaths)) {
144
+ throw new Error(`Path resolves outside allowedPaths: ${dirPath}`);
145
+ }
92
146
 
93
- return absPath;
147
+ const stat = fs.statSync(absPath);
148
+ if (!stat.isDirectory()) {
149
+ throw new Error(`Not a directory: ${dirPath}`);
150
+ }
151
+
152
+ return absPath;
94
153
  }
@@ -13,31 +13,31 @@ import { Config } from '../config/index.js';
13
13
  import { detectUiStack } from '../stack/detect.js';
14
14
 
15
15
  export function buildRepoHints(config: Config, rootDir: string = process.cwd()): string {
16
- try {
17
- const stack = detectUiStack(rootDir);
16
+ try {
17
+ const stack = detectUiStack(rootDir);
18
18
 
19
- const hints = {
20
- root: path.basename(stack.root),
21
- framework: stack.framework,
22
- typescript: stack.language.typescript,
23
- styling: stack.styling,
24
- uiLibraries: stack.uiLibraries,
25
- iconLibraries: stack.iconLibraries,
26
- tooling: stack.tooling,
27
- conventions: {
28
- srcDir: stack.conventions.srcDir,
29
- hasAppDir: stack.conventions.hasAppDir,
30
- hasPagesDir: stack.conventions.hasPagesDir,
31
- tsconfigPaths: stack.conventions.tsconfigPaths,
32
- },
33
- };
19
+ const hints = {
20
+ root: path.basename(stack.root),
21
+ framework: stack.framework,
22
+ typescript: stack.language.typescript,
23
+ styling: stack.styling,
24
+ uiLibraries: stack.uiLibraries,
25
+ iconLibraries: stack.iconLibraries,
26
+ tooling: stack.tooling,
27
+ conventions: {
28
+ srcDir: stack.conventions.srcDir,
29
+ hasAppDir: stack.conventions.hasAppDir,
30
+ hasPagesDir: stack.conventions.hasPagesDir,
31
+ tsconfigPaths: stack.conventions.tsconfigPaths,
32
+ },
33
+ };
34
34
 
35
- return `REPO_HINTS (deterministic):\n${JSON.stringify(hints, null, 2)}`;
36
- } catch (error) {
37
- if (config.debug) {
38
- const msg = error instanceof Error ? error.message : 'unknown error';
39
- console.error('[repo-hints] Failed to detect stack:', msg);
40
- }
41
- return '';
35
+ return `REPO_HINTS (deterministic):\n${JSON.stringify(hints, null, 2)}`;
36
+ } catch (error) {
37
+ if (config.debug) {
38
+ const msg = error instanceof Error ? error.message : 'unknown error';
39
+ console.error('[repo-hints] Failed to detect stack:', msg);
42
40
  }
41
+ return '';
42
+ }
43
43
  }