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