foundry-design 0.2.0-beta.1 → 0.2.0-beta.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/installer.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
2
3
  import { accessSync } from 'node:fs';
3
- import { access, mkdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
4
+ import { access, mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
4
5
  import { dirname, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { promisify } from 'node:util';
5
8
  import { detectPlatform } from './project.js';
6
9
  const START = '>>> Foundry Design Control';
7
10
  const END = '<<< Foundry Design Control';
11
+ const GENERATOR_VERSION = '0.2.0-beta.3';
12
+ const TRANSACTION_FILE = 'setup-transaction.json';
13
+ const execFileAsync = promisify(execFile);
14
+ const DEFAULT_SKILL_ROOT = fileURLToPath(new URL('./skill/foundry-design-control/', import.meta.url));
8
15
  async function exists(path) {
9
16
  try {
10
17
  await access(path);
@@ -25,6 +32,156 @@ async function readText(path) {
25
32
  function digest(content) {
26
33
  return createHash('sha256').update(content).digest('hex');
27
34
  }
35
+ const LEGACY_NEXT_LOADER_DIGESTS = new Set([
36
+ 'ec45810b798224907a947c3bf9e01e5e342b64f506c910a35f08fdc0399aac0e',
37
+ '8a5757b906c3db7b720cb5a61c4b4dd189bf1f61cb299cf31e107d277b75b4b3',
38
+ ]);
39
+ function transactionPath(root) {
40
+ return join(root, '.foundry', TRANSACTION_FILE);
41
+ }
42
+ async function snapshotFiles(paths) {
43
+ return Promise.all([...new Set(paths)].map(async (path) => ({
44
+ path,
45
+ content: await readFile(path)
46
+ .then((value) => value.toString('base64'))
47
+ .catch(() => null),
48
+ })));
49
+ }
50
+ async function restoreSnapshots(root, snapshots) {
51
+ for (const snapshot of snapshots) {
52
+ if (snapshot.content == null) {
53
+ await rm(snapshot.path, { force: true }).catch(() => undefined);
54
+ continue;
55
+ }
56
+ await mkdir(dirname(snapshot.path), { recursive: true });
57
+ await writeFile(snapshot.path, Buffer.from(snapshot.content, 'base64'));
58
+ }
59
+ const directories = [...new Set(snapshots.map((snapshot) => dirname(snapshot.path)))]
60
+ .filter((path) => path.startsWith(root) && path !== root)
61
+ .sort((left, right) => right.length - left.length);
62
+ for (const directory of directories)
63
+ await rmdir(directory).catch(() => undefined);
64
+ }
65
+ async function recoverInterruptedSetup(root) {
66
+ const path = transactionPath(root);
67
+ const journal = await readFile(path, 'utf8')
68
+ .then((value) => JSON.parse(value))
69
+ .catch(() => undefined);
70
+ if (!journal?.snapshots?.length || journal.root !== root)
71
+ return;
72
+ await restoreSnapshots(root, journal.snapshots);
73
+ await rm(path, { force: true });
74
+ await rmdir(dirname(path)).catch(() => undefined);
75
+ }
76
+ async function writeTransaction(root, snapshots) {
77
+ const path = transactionPath(root);
78
+ await mkdir(dirname(path), { recursive: true });
79
+ const journal = { version: 1, root, snapshots };
80
+ await writeFile(path, `${JSON.stringify(journal, null, 2)}\n`);
81
+ }
82
+ async function validationCommands(root) {
83
+ const pkg = await packageJson(root);
84
+ const manager = packageManager(root);
85
+ const commands = [];
86
+ if (pkg.scripts?.typecheck) {
87
+ commands.push({
88
+ name: 'typecheck',
89
+ command: manager.command,
90
+ args: manager.runArgs('typecheck'),
91
+ });
92
+ }
93
+ else if (existsSync(join(root, 'node_modules', '.bin', 'tsc'))) {
94
+ commands.push({
95
+ name: 'typecheck',
96
+ command: join(root, 'node_modules', '.bin', 'tsc'),
97
+ args: ['--noEmit', '--pretty', 'false'],
98
+ });
99
+ }
100
+ if (pkg.scripts?.lint) {
101
+ commands.push({ name: 'lint', command: manager.command, args: manager.runArgs('lint') });
102
+ }
103
+ else if (existsSync(join(root, 'node_modules', '.bin', 'eslint'))) {
104
+ commands.push({
105
+ name: 'lint',
106
+ command: join(root, 'node_modules', '.bin', 'eslint'),
107
+ args: ['.'],
108
+ });
109
+ }
110
+ return commands;
111
+ }
112
+ async function runValidationCommand(root, check) {
113
+ try {
114
+ const result = await execFileAsync(check.command, check.args, {
115
+ cwd: root,
116
+ env: { ...process.env, CI: '1', NO_COLOR: '1' },
117
+ timeout: 180_000,
118
+ maxBuffer: 8_000_000,
119
+ });
120
+ return { exitCode: 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}` };
121
+ }
122
+ catch (error) {
123
+ const failure = error;
124
+ if (failure.code === 'ENOENT') {
125
+ return { exitCode: 127, output: failure.message ?? 'Command unavailable', unavailable: true };
126
+ }
127
+ return {
128
+ exitCode: typeof failure.code === 'number' ? failure.code : 1,
129
+ output: `${failure.stdout ?? ''}${failure.stderr ?? ''}${failure.message ?? ''}`,
130
+ };
131
+ }
132
+ }
133
+ function introducedManagedDiagnostic(root, before, after, paths) {
134
+ if (before.exitCode === 0 && after.exitCode !== 0)
135
+ return true;
136
+ if (after.exitCode === 0 || before.output === after.output)
137
+ return false;
138
+ return paths.some((path) => {
139
+ const relativePath = slash(relative(root, path));
140
+ return after.output.includes(relativePath) && !before.output.includes(relativePath);
141
+ });
142
+ }
143
+ async function validateInstalledProject(root, checks, baseline, changed) {
144
+ if (!checks.length) {
145
+ return [
146
+ { name: 'typecheck', status: 'skipped', message: 'No project validation command found.' },
147
+ { name: 'lint', status: 'skipped', message: 'No project validation command found.' },
148
+ ];
149
+ }
150
+ const results = [];
151
+ for (const check of checks) {
152
+ const before = baseline.get(check.name);
153
+ const after = await runValidationCommand(root, check);
154
+ const command = [check.command, ...check.args].join(' ');
155
+ if (after.unavailable) {
156
+ results.push({
157
+ name: check.name,
158
+ status: 'skipped',
159
+ command,
160
+ message: 'The project command is not available in this environment.',
161
+ });
162
+ continue;
163
+ }
164
+ if (introducedManagedDiagnostic(root, before, after, changed)) {
165
+ const detail = after.output.trim().split('\n').slice(-12).join('\n');
166
+ throw new Error(`Foundry setup introduced a ${check.name} failure.\n${detail || `Command failed: ${command}`}`);
167
+ }
168
+ results.push({
169
+ name: check.name,
170
+ status: after.exitCode === 0 ? 'passed' : 'pre-existing-failure',
171
+ command,
172
+ ...(after.exitCode === 0
173
+ ? {}
174
+ : {
175
+ message: 'The project had existing failures and Foundry added no managed-file diagnostic.',
176
+ }),
177
+ });
178
+ }
179
+ for (const name of ['typecheck', 'lint']) {
180
+ if (!results.some((result) => result.name === name))
181
+ results.push({ name, status: 'skipped', message: `No ${name} command found.` });
182
+ }
183
+ return results;
184
+ }
28
185
  function slash(path) {
29
186
  return path.replaceAll('\\', '/');
30
187
  }
@@ -86,7 +243,16 @@ function defaultTarget(framework) {
86
243
  return 'http://127.0.0.1:3000';
87
244
  return undefined;
88
245
  }
89
- async function detectAgents(root) {
246
+ async function detectAgents(root, environment = process.env) {
247
+ const active = [];
248
+ if (environment.CODEX_THREAD_ID || environment.CODEX_CI || environment.CODEX_HOME)
249
+ active.push('codex');
250
+ if (environment.CLAUDECODE || environment.CLAUDE_CODE_SESSION_ID)
251
+ active.push('claude');
252
+ if (environment.CURSOR_TRACE_ID || environment.CURSOR_AGENT)
253
+ active.push('cursor');
254
+ if (active.length)
255
+ return [...new Set(active)];
90
256
  const detected = [];
91
257
  if (await exists(join(root, '.cursor')))
92
258
  detected.push('cursor');
@@ -96,6 +262,13 @@ async function detectAgents(root) {
96
262
  detected.push('codex');
97
263
  return detected.length ? [...new Set(detected)] : ['codex'];
98
264
  }
265
+ function skillDirectory(root, agent) {
266
+ if (agent === 'codex')
267
+ return join(root, '.agents', 'skills', 'foundry-design-control');
268
+ if (agent === 'cursor')
269
+ return join(root, '.cursor', 'skills', 'foundry-design-control');
270
+ return join(root, '.claude', 'skills', 'foundry-design-control');
271
+ }
99
272
  async function findFirst(root, candidates) {
100
273
  for (const candidate of candidates) {
101
274
  const path = join(root, candidate);
@@ -130,8 +303,9 @@ export async function createSetupPlan(rootInput, options = {}) {
130
303
  const root = resolve(rootInput);
131
304
  const platform = await detectPlatform(root);
132
305
  const framework = platform === 'web' ? await detectWebFramework(root) : undefined;
133
- const agents = options.agents ?? (await detectAgents(root));
306
+ const agents = options.agents ?? (await detectAgents(root, options.environment));
134
307
  const integration = await integrationFile(root, framework);
308
+ const skillDirectories = agents.map((agent) => skillDirectory(root, agent));
135
309
  const files = [
136
310
  join(root, '.foundry', 'foundry.config.json'),
137
311
  join(root, '.foundry', 'install-manifest.json'),
@@ -148,6 +322,7 @@ export async function createSetupPlan(rootInput, options = {}) {
148
322
  files.push(join(root, '.cursor', 'mcp.json'));
149
323
  if (agents.includes('claude'))
150
324
  files.push(join(root, '.mcp.json'));
325
+ files.push(...skillDirectories);
151
326
  if (integration) {
152
327
  files.push(integration);
153
328
  if (framework === 'next')
@@ -163,6 +338,7 @@ export async function createSetupPlan(rootInput, options = {}) {
163
338
  integrationFile: integration,
164
339
  targetUrl: options.targetUrl ?? defaultTarget(framework),
165
340
  devCommand: await detectDevCommand(root),
341
+ skillDirectories,
166
342
  };
167
343
  }
168
344
  function managedBlock(content, syntax) {
@@ -193,25 +369,101 @@ async function writeGenerated(path, content) {
193
369
  await writeFile(path, content);
194
370
  return { path, sha256: digest(content) };
195
371
  }
372
+ async function writeOwnedGenerated(path, content, ownedFiles, allowLegacyLoader = false, preserveModified = false, preserved = []) {
373
+ const existing = await readFile(path, 'utf8').catch(() => undefined);
374
+ if (existing != null && existing !== content) {
375
+ const currentDigest = digest(existing);
376
+ const ownedDigest = ownedFiles.get(path);
377
+ const ownedAndUnchanged = ownedDigest === currentDigest;
378
+ const knownLoader = allowLegacyLoader && LEGACY_NEXT_LOADER_DIGESTS.has(currentDigest);
379
+ if (!ownedAndUnchanged && !knownLoader) {
380
+ if (preserveModified) {
381
+ preserved.push(path);
382
+ return ownedDigest ? { path, sha256: ownedDigest } : undefined;
383
+ }
384
+ throw new Error(`Foundry preserved your edited generated file at ${path}.`);
385
+ }
386
+ }
387
+ return writeGenerated(path, content);
388
+ }
389
+ async function readSkill(sourceRoot) {
390
+ if (!(await exists(join(sourceRoot, 'SKILL.md')))) {
391
+ throw new Error(`Foundry skill bundle is missing from ${sourceRoot}`);
392
+ }
393
+ const files = [];
394
+ const visit = async (directory) => {
395
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
396
+ const sourcePath = join(directory, entry.name);
397
+ if (entry.isDirectory()) {
398
+ await visit(sourcePath);
399
+ }
400
+ else if (entry.isFile()) {
401
+ files.push({ path: relative(sourceRoot, sourcePath), content: await readFile(sourcePath) });
402
+ }
403
+ }
404
+ };
405
+ await visit(sourceRoot);
406
+ return files;
407
+ }
408
+ async function validateSkillTarget(files, targetRoot, ownedFiles, preserveModified = false, preserved = []) {
409
+ for (const file of files) {
410
+ const targetPath = join(targetRoot, file.path);
411
+ const existing = await readFile(targetPath).catch(() => undefined);
412
+ if (existing && !ownedFiles.has(targetPath) && !existing.equals(file.content)) {
413
+ if (preserveModified) {
414
+ preserved.push(targetPath);
415
+ continue;
416
+ }
417
+ throw new Error(`A Foundry skill already exists at ${targetRoot}. Move or remove it before setup.`);
418
+ }
419
+ const ownedDigest = ownedFiles.get(targetPath);
420
+ if (existing && ownedDigest && digest(existing.toString()) !== ownedDigest) {
421
+ if (preserveModified) {
422
+ preserved.push(targetPath);
423
+ continue;
424
+ }
425
+ throw new Error(`Foundry preserved your edited skill at ${targetRoot}. Move it before updating setup.`);
426
+ }
427
+ }
428
+ }
429
+ async function copySkill(skillFiles, targetRoot, ownedFiles = new Map(), preserved = new Set()) {
430
+ const managedFiles = [];
431
+ const directories = new Set();
432
+ directories.add(targetRoot);
433
+ for (const file of skillFiles) {
434
+ const targetPath = join(targetRoot, file.path);
435
+ if (preserved.has(targetPath)) {
436
+ const ownedDigest = ownedFiles.get(targetPath);
437
+ if (ownedDigest)
438
+ managedFiles.push({ path: targetPath, sha256: ownedDigest });
439
+ continue;
440
+ }
441
+ const directory = dirname(targetPath);
442
+ directories.add(directory);
443
+ await mkdir(directory, { recursive: true });
444
+ await writeFile(targetPath, file.content);
445
+ managedFiles.push({ path: targetPath, sha256: digest(file.content.toString()) });
446
+ }
447
+ return { files: managedFiles, directories: [...directories] };
448
+ }
196
449
  function adapterSource(runtimeUrl) {
197
450
  return `/** Foundry development adapter. Safe to commit; inactive outside an explicit session. */
198
451
  export async function installFoundryDesignControl(): Promise<void> {
199
452
  if (typeof window === 'undefined') return;
200
453
  const query = new URLSearchParams(window.location.search);
201
454
  if (!query.has('__foundry_session')) return;
202
- const module = await import(/* @vite-ignore */ '${runtimeUrl}/adapter.js');
203
- module.installFoundryInspector();
455
+ const adapter = await import(/* @vite-ignore */ '${runtimeUrl}/adapter.js');
456
+ adapter.installFoundryInspector();
204
457
  }
205
458
  `;
206
459
  }
207
- async function configureWebIntegration(plan, generated, managedBlocks) {
460
+ async function configureWebIntegration(plan, generated, managedBlocks, ownedFiles, runtimeUrl, preserveModified = false, preserved = []) {
208
461
  const entry = plan.integrationFile;
209
462
  if (!entry)
210
463
  return undefined;
211
464
  if (plan.framework === 'next') {
212
465
  const appRoot = dirname(entry);
213
466
  const loader = join(appRoot, 'foundry-loader.tsx');
214
- const runtimeUrl = 'http://127.0.0.1:4387';
215
467
  const loaderContent = `'use client';
216
468
 
217
469
  import { useEffect } from 'react';
@@ -220,14 +472,19 @@ export function FoundryLoader(): null {
220
472
  useEffect(() => {
221
473
  const query = new URLSearchParams(window.location.search);
222
474
  if (!query.has('__foundry_session')) return;
223
- void import(/* webpackIgnore: true */ '${runtimeUrl}/adapter.js').then((module) =>
224
- module.installFoundryInspector(),
225
- );
475
+ if (document.querySelector('script[data-foundry-bootstrap]')) return;
476
+ const script = document.createElement('script');
477
+ script.type = 'module';
478
+ script.src = '${runtimeUrl}/adapter-bootstrap.js';
479
+ script.dataset.foundryBootstrap = 'true';
480
+ document.head.append(script);
226
481
  }, []);
227
482
  return null;
228
483
  }
229
484
  `;
230
- generated.push(await writeGenerated(loader, loaderContent));
485
+ const generatedLoader = await writeOwnedGenerated(loader, loaderContent, ownedFiles, true, preserveModified, preserved);
486
+ if (generatedLoader)
487
+ generated.push(generatedLoader);
231
488
  const original = await readText(entry);
232
489
  if (!original.includes('FoundryLoader')) {
233
490
  const withImport = `import { FoundryLoader } from './foundry-loader';\n${original}`;
@@ -249,7 +506,7 @@ export function FoundryLoader(): null {
249
506
  const script = managedBlock(`<script type="module">
250
507
  const query = new URLSearchParams(location.search);
251
508
  if (query.has('__foundry_session')) {
252
- const module = await import('http://127.0.0.1:4387/adapter.js');
509
+ const module = await import('${runtimeUrl}/adapter.js');
253
510
  module.installFoundryInspector();
254
511
  }
255
512
  </script>`, 'html');
@@ -285,7 +542,7 @@ function mcpServer(packageRoot) {
285
542
  }
286
543
  return {
287
544
  command: 'npx',
288
- args: ['-y', 'foundry-design-mcp-server@0.1.0'],
545
+ args: ['-y', 'foundry-design-mcp-server@beta'],
289
546
  env: { FOUNDRY_DESIGN_RUNTIME_URL: 'http://127.0.0.1:4387' },
290
547
  };
291
548
  }
@@ -314,88 +571,192 @@ async function configureJson(path, packageRoot) {
314
571
  await mkdir(dirname(path), { recursive: true });
315
572
  await writeFile(path, `${JSON.stringify(current, null, 2)}\n`);
316
573
  }
574
+ export async function installAgentIntegration(rootInput, agent, packageRoot) {
575
+ const root = resolve(rootInput);
576
+ if (agent === 'codex') {
577
+ const path = join(root, '.codex', 'config.toml');
578
+ await configureCodex(path, packageRoot);
579
+ return path;
580
+ }
581
+ const path = agent === 'cursor' ? join(root, '.cursor', 'mcp.json') : join(root, '.mcp.json');
582
+ await configureJson(path, packageRoot);
583
+ return path;
584
+ }
585
+ export async function createUpdatePlan(rootInput, options = {}) {
586
+ const root = resolve(rootInput);
587
+ const manifest = await readFile(join(root, '.foundry', 'install-manifest.json'), 'utf8')
588
+ .then((content) => JSON.parse(content))
589
+ .catch(() => undefined);
590
+ if (!manifest)
591
+ throw new Error(`Foundry is not installed in ${root}. Run setup first.`);
592
+ const config = await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8')
593
+ .then((content) => JSON.parse(content))
594
+ .catch(() => undefined);
595
+ return createSetupPlan(root, {
596
+ ...options,
597
+ agents: options.agents ?? (manifest.agents.length ? manifest.agents : undefined),
598
+ targetUrl: options.targetUrl ?? config?.targetUrl,
599
+ update: true,
600
+ });
601
+ }
602
+ export async function updateProject(rootInput, options = {}) {
603
+ const root = resolve(rootInput);
604
+ const plan = await createUpdatePlan(root, options);
605
+ const config = await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8')
606
+ .then((content) => JSON.parse(content))
607
+ .catch(() => undefined);
608
+ return setupProject(rootInput, {
609
+ ...options,
610
+ agents: plan.agents,
611
+ targetUrl: plan.targetUrl,
612
+ runtimeUrl: options.runtimeUrl ?? config?.runtimeUrl,
613
+ update: true,
614
+ });
615
+ }
317
616
  export async function setupProject(rootInput, options = {}) {
318
- const plan = await createSetupPlan(rootInput, options);
617
+ const root = resolve(rootInput);
618
+ await recoverInterruptedSetup(root);
619
+ const plan = await createSetupPlan(root, options);
319
620
  const runtimeUrl = options.runtimeUrl ?? 'http://127.0.0.1:4387';
320
621
  const generated = [];
321
622
  const managedBlocks = [];
322
623
  const jsonConfigs = [];
624
+ const skillDirectories = [];
323
625
  const changed = [];
324
- const config = {
325
- version: 2,
326
- platform: plan.platform,
327
- ...(plan.framework ? { framework: plan.framework } : {}),
328
- runtimeUrl,
329
- ...(plan.targetUrl ? { targetUrl: plan.targetUrl } : {}),
330
- ...(plan.devCommand ? { devCommand: plan.devCommand } : {}),
331
- instrumented: plan.platform !== 'web' || Boolean(plan.integrationFile),
332
- design: {
333
- componentRoots: ['src', 'app', 'components'],
334
- exclude: ['node_modules', 'dist', 'build', '.next', 'coverage'],
335
- viewports: [
336
- { id: 'mobile', label: 'Mobile', width: 390, height: 844 },
337
- { id: 'tablet', label: 'Tablet', width: 768, height: 1024 },
338
- { id: 'desktop', label: 'Desktop', width: 1440, height: 900 },
339
- ],
340
- themes: [
341
- { id: 'light', label: 'Light', attribute: 'data-theme', value: 'light' },
342
- { id: 'dark', label: 'Dark', attribute: 'data-theme', value: 'dark' },
343
- ],
344
- },
345
- };
346
- const configFile = join(plan.root, '.foundry', 'foundry.config.json');
347
- generated.push(await writeGenerated(configFile, `${JSON.stringify(config, null, 2)}\n`));
348
- changed.push(configFile);
349
- if (plan.platform === 'web') {
350
- const adapter = join(plan.root, '.foundry', 'web-adapter.ts');
351
- generated.push(await writeGenerated(adapter, adapterSource(runtimeUrl)));
352
- changed.push(adapter);
353
- const integration = await configureWebIntegration(plan, generated, managedBlocks);
354
- if (integration)
355
- changed.push(integration);
356
- }
357
- else {
358
- const setup = join(plan.root, '.foundry', `${plan.platform}-setup.md`);
359
- const content = plan.platform === 'swiftui'
360
- ? '# Foundry SwiftUI setup\n\nAdd FoundryDesignControl only to DEBUG builds and mark meaningful views with `.foundryInspectable`.\n'
361
- : '# Foundry React Native setup\n\nCreate the debug adapter and register semantic targets using `measureInWindow`.\n';
362
- generated.push(await writeGenerated(setup, content));
363
- changed.push(setup);
364
- }
365
- const gitignore = join(plan.root, '.gitignore');
366
- await setManagedBlock(gitignore, managedBlock('.foundry/sessions/', 'hash'));
367
- managedBlocks.push(gitignore);
368
- changed.push(gitignore);
369
- if (plan.agents.includes('codex')) {
370
- const path = join(plan.root, '.codex', 'config.toml');
371
- await configureCodex(path, options.packageRoot);
372
- managedBlocks.push(path);
373
- changed.push(path);
374
- }
375
- if (plan.agents.includes('cursor')) {
376
- const path = join(plan.root, '.cursor', 'mcp.json');
377
- await configureJson(path, options.packageRoot);
378
- jsonConfigs.push(path);
379
- changed.push(path);
380
- }
381
- if (plan.agents.includes('claude')) {
382
- const path = join(plan.root, '.mcp.json');
383
- await configureJson(path, options.packageRoot);
384
- jsonConfigs.push(path);
385
- changed.push(path);
386
- }
387
- const manifest = {
388
- version: 1,
389
- agents: plan.agents,
390
- generatedFiles: generated,
391
- managedBlocks,
392
- jsonConfigs,
393
- installedAt: new Date().toISOString(),
394
- };
395
- const manifestFile = join(plan.root, '.foundry', 'install-manifest.json');
396
- await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
397
- changed.push(manifestFile);
398
- return { ...plan, changed: [...new Set(changed)] };
626
+ const preserved = [];
627
+ const preserveModified = options.update === true;
628
+ const previousManifest = await readFile(join(plan.root, '.foundry', 'install-manifest.json'), 'utf8')
629
+ .then((content) => JSON.parse(content))
630
+ .catch(() => undefined);
631
+ if (options.update && !previousManifest) {
632
+ throw new Error(`Foundry is not installed in ${plan.root}. Run setup first.`);
633
+ }
634
+ const ownedFiles = new Map((previousManifest?.generatedFiles ?? []).map((file) => [file.path, file.sha256]));
635
+ const skillFiles = plan.skillDirectories.length
636
+ ? await readSkill(resolve(options.skillRoot ?? DEFAULT_SKILL_ROOT))
637
+ : [];
638
+ for (const directory of plan.skillDirectories) {
639
+ await validateSkillTarget(skillFiles, directory, ownedFiles, preserveModified, preserved);
640
+ }
641
+ const transactionFiles = [
642
+ ...plan.files.filter((path) => !plan.skillDirectories.includes(path)),
643
+ ...plan.skillDirectories.flatMap((directory) => skillFiles.map((file) => join(directory, file.path))),
644
+ ];
645
+ const snapshots = await snapshotFiles(transactionFiles);
646
+ const checks = await validationCommands(plan.root);
647
+ const baseline = new Map();
648
+ for (const check of checks)
649
+ baseline.set(check.name, await runValidationCommand(plan.root, check));
650
+ await writeTransaction(plan.root, snapshots);
651
+ try {
652
+ const config = {
653
+ version: 2,
654
+ platform: plan.platform,
655
+ ...(plan.framework ? { framework: plan.framework } : {}),
656
+ runtimeUrl,
657
+ ...(plan.targetUrl ? { targetUrl: plan.targetUrl } : {}),
658
+ ...(plan.devCommand ? { devCommand: plan.devCommand } : {}),
659
+ instrumented: plan.platform !== 'web' || Boolean(plan.integrationFile),
660
+ design: {
661
+ componentRoots: ['src', 'app', 'components'],
662
+ exclude: ['node_modules', 'dist', 'build', '.next', 'coverage'],
663
+ viewports: [
664
+ { id: 'mobile', label: 'Mobile', width: 390, height: 844 },
665
+ { id: 'tablet', label: 'Tablet', width: 768, height: 1024 },
666
+ { id: 'desktop', label: 'Desktop', width: 1440, height: 900 },
667
+ ],
668
+ themes: [
669
+ { id: 'light', label: 'Light', attribute: 'data-theme', value: 'light' },
670
+ { id: 'dark', label: 'Dark', attribute: 'data-theme', value: 'dark' },
671
+ ],
672
+ },
673
+ };
674
+ const configFile = join(plan.root, '.foundry', 'foundry.config.json');
675
+ const generatedConfig = await writeOwnedGenerated(configFile, `${JSON.stringify(config, null, 2)}\n`, ownedFiles, false, preserveModified, preserved);
676
+ if (generatedConfig)
677
+ generated.push(generatedConfig);
678
+ if (!preserved.includes(configFile))
679
+ changed.push(configFile);
680
+ if (plan.platform === 'web') {
681
+ const adapter = join(plan.root, '.foundry', 'web-adapter.ts');
682
+ const generatedAdapter = await writeOwnedGenerated(adapter, adapterSource(runtimeUrl), ownedFiles, false, preserveModified, preserved);
683
+ if (generatedAdapter)
684
+ generated.push(generatedAdapter);
685
+ if (!preserved.includes(adapter))
686
+ changed.push(adapter);
687
+ const integration = await configureWebIntegration(plan, generated, managedBlocks, ownedFiles, runtimeUrl, preserveModified, preserved);
688
+ if (integration)
689
+ changed.push(integration);
690
+ }
691
+ else {
692
+ const setup = join(plan.root, '.foundry', `${plan.platform}-setup.md`);
693
+ const content = plan.platform === 'swiftui'
694
+ ? '# Foundry SwiftUI setup\n\nAdd FoundryDesignControl only to DEBUG builds and mark meaningful views with `.foundryInspectable`.\n'
695
+ : '# Foundry React Native setup\n\nCreate the debug adapter and register semantic targets using `measureInWindow`.\n';
696
+ const generatedSetup = await writeOwnedGenerated(setup, content, ownedFiles, false, preserveModified, preserved);
697
+ if (generatedSetup)
698
+ generated.push(generatedSetup);
699
+ if (!preserved.includes(setup))
700
+ changed.push(setup);
701
+ }
702
+ const gitignore = join(plan.root, '.gitignore');
703
+ await setManagedBlock(gitignore, managedBlock('.foundry/sessions/', 'hash'));
704
+ managedBlocks.push(gitignore);
705
+ changed.push(gitignore);
706
+ if (plan.agents.includes('codex')) {
707
+ const path = join(plan.root, '.codex', 'config.toml');
708
+ await configureCodex(path, options.packageRoot);
709
+ managedBlocks.push(path);
710
+ changed.push(path);
711
+ }
712
+ if (plan.agents.includes('cursor')) {
713
+ const path = join(plan.root, '.cursor', 'mcp.json');
714
+ await configureJson(path, options.packageRoot);
715
+ jsonConfigs.push(path);
716
+ changed.push(path);
717
+ }
718
+ if (plan.agents.includes('claude')) {
719
+ const path = join(plan.root, '.mcp.json');
720
+ await configureJson(path, options.packageRoot);
721
+ jsonConfigs.push(path);
722
+ changed.push(path);
723
+ }
724
+ for (const directory of plan.skillDirectories) {
725
+ const copied = await copySkill(skillFiles, directory, ownedFiles, new Set(preserved));
726
+ generated.push(...copied.files);
727
+ skillDirectories.push(...copied.directories);
728
+ changed.push(directory);
729
+ }
730
+ const validation = await validateInstalledProject(plan.root, checks, baseline, changed);
731
+ const manifest = {
732
+ version: 2,
733
+ agents: plan.agents,
734
+ generatedFiles: generated,
735
+ managedBlocks,
736
+ jsonConfigs,
737
+ skillDirectories: [...new Set(skillDirectories)],
738
+ installedAt: previousManifest?.installedAt ?? new Date().toISOString(),
739
+ ...(options.update ? { updatedAt: new Date().toISOString() } : {}),
740
+ generatorVersion: GENERATOR_VERSION,
741
+ validation,
742
+ };
743
+ const manifestFile = join(plan.root, '.foundry', 'install-manifest.json');
744
+ await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
745
+ changed.push(manifestFile);
746
+ await rm(transactionPath(plan.root), { force: true });
747
+ return {
748
+ ...plan,
749
+ changed: [...new Set(changed)],
750
+ preserved: [...new Set(preserved)],
751
+ validation,
752
+ };
753
+ }
754
+ catch (error) {
755
+ await restoreSnapshots(plan.root, snapshots);
756
+ await rm(transactionPath(plan.root), { force: true });
757
+ await rmdir(join(plan.root, '.foundry')).catch(() => undefined);
758
+ throw error;
759
+ }
399
760
  }
400
761
  async function removeJsonServer(path) {
401
762
  try {
@@ -445,6 +806,15 @@ export async function uninstallProject(rootInput) {
445
806
  await rm(file.path);
446
807
  removed.push(file.path);
447
808
  }
809
+ for (const directory of [...(manifest.skillDirectories ?? [])].sort((left, right) => right.length - left.length)) {
810
+ try {
811
+ await rmdir(directory);
812
+ removed.push(directory);
813
+ }
814
+ catch {
815
+ /* Preserve non-empty directories and user additions. */
816
+ }
817
+ }
448
818
  await rm(manifestFile);
449
819
  removed.push(manifestFile);
450
820
  try {