foundry-design 0.2.0-beta.2 → 0.2.0-beta.20
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 +163 -16
- package/dist/companion.d.ts +24 -0
- package/dist/companion.d.ts.map +1 -0
- package/dist/companion.js +89 -0
- package/dist/companion.js.map +1 -0
- package/dist/delivery-export.d.ts +27 -0
- package/dist/delivery-export.d.ts.map +1 -0
- package/dist/delivery-export.js +200 -0
- package/dist/delivery-export.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +206 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +442 -120
- package/dist/index.js.map +1 -1
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +649 -27
- package/dist/indexer.js.map +1 -1
- package/dist/installer.d.ts +31 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +551 -100
- package/dist/installer.js.map +1 -1
- package/dist/project.d.ts +8 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +46 -1
- package/dist/project.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 +34 -11
- package/dist/skill/foundry-design-control/references/apply-run-contract.md +9 -4
- package/dist/skill/foundry-design-control/references/change-contract.md +2 -0
- package/dist/skill/foundry-design-control/references/component-workshop.md +46 -0
- package/dist/skill/foundry-design-control/references/design-branches.md +47 -0
- package/dist/skill/foundry-design-control/references/design-system-intelligence.md +40 -0
- package/dist/skill/foundry-design-control/references/motion-studio.md +101 -0
- package/dist/skill/foundry-design-control/references/portable-branch-records.md +38 -0
- package/dist/skill/foundry-design-control/references/responsive-design-lab.md +34 -0
- package/dist/skill/foundry-design-control/references/typography-studio.md +14 -0
- package/dist/skill/foundry-design-control/references/visual-agent-conversation.md +14 -0
- package/dist/skill/foundry-design-control/references/visual-workbench.md +27 -4
- package/dist/skill/foundry-design-control/scripts/foundry.sh +1 -5
- package/package.json +3 -3
package/dist/installer.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
2
3
|
import { accessSync } from 'node:fs';
|
|
3
4
|
import { access, mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import {
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { detectPlatform, normalizeTargetUrl } from './project.js';
|
|
9
|
+
import { FOUNDRY_MCP_PACKAGE_SPEC, FOUNDRY_VERSION } from './release.js';
|
|
7
10
|
const START = '>>> Foundry Design Control';
|
|
8
11
|
const END = '<<< Foundry Design Control';
|
|
12
|
+
const GENERATOR_VERSION = FOUNDRY_VERSION;
|
|
13
|
+
const TRANSACTION_FILE = 'setup-transaction.json';
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
9
15
|
const DEFAULT_SKILL_ROOT = fileURLToPath(new URL('./skill/foundry-design-control/', import.meta.url));
|
|
10
16
|
async function exists(path) {
|
|
11
17
|
try {
|
|
@@ -27,6 +33,156 @@ async function readText(path) {
|
|
|
27
33
|
function digest(content) {
|
|
28
34
|
return createHash('sha256').update(content).digest('hex');
|
|
29
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
|
+
}
|
|
30
186
|
function slash(path) {
|
|
31
187
|
return path.replaceAll('\\', '/');
|
|
32
188
|
}
|
|
@@ -88,7 +244,16 @@ function defaultTarget(framework) {
|
|
|
88
244
|
return 'http://127.0.0.1:3000';
|
|
89
245
|
return undefined;
|
|
90
246
|
}
|
|
91
|
-
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)];
|
|
92
257
|
const detected = [];
|
|
93
258
|
if (await exists(join(root, '.cursor')))
|
|
94
259
|
detected.push('cursor');
|
|
@@ -139,7 +304,7 @@ export async function createSetupPlan(rootInput, options = {}) {
|
|
|
139
304
|
const root = resolve(rootInput);
|
|
140
305
|
const platform = await detectPlatform(root);
|
|
141
306
|
const framework = platform === 'web' ? await detectWebFramework(root) : undefined;
|
|
142
|
-
const agents = options.agents ?? (await detectAgents(root));
|
|
307
|
+
const agents = options.agents ?? (await detectAgents(root, options.environment));
|
|
143
308
|
const integration = await integrationFile(root, framework);
|
|
144
309
|
const skillDirectories = agents.map((agent) => skillDirectory(root, agent));
|
|
145
310
|
const files = [
|
|
@@ -172,7 +337,7 @@ export async function createSetupPlan(rootInput, options = {}) {
|
|
|
172
337
|
agents,
|
|
173
338
|
files: [...new Set(files)],
|
|
174
339
|
integrationFile: integration,
|
|
175
|
-
targetUrl: options.targetUrl ?? defaultTarget(framework),
|
|
340
|
+
targetUrl: normalizeTargetUrl(options.targetUrl ?? defaultTarget(framework)),
|
|
176
341
|
devCommand: await detectDevCommand(root),
|
|
177
342
|
skillDirectories,
|
|
178
343
|
};
|
|
@@ -205,6 +370,23 @@ async function writeGenerated(path, content) {
|
|
|
205
370
|
await writeFile(path, content);
|
|
206
371
|
return { path, sha256: digest(content) };
|
|
207
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
|
+
}
|
|
208
390
|
async function readSkill(sourceRoot) {
|
|
209
391
|
if (!(await exists(join(sourceRoot, 'SKILL.md')))) {
|
|
210
392
|
throw new Error(`Foundry skill bundle is missing from ${sourceRoot}`);
|
|
@@ -224,25 +406,39 @@ async function readSkill(sourceRoot) {
|
|
|
224
406
|
await visit(sourceRoot);
|
|
225
407
|
return files;
|
|
226
408
|
}
|
|
227
|
-
async function validateSkillTarget(files, targetRoot, ownedFiles) {
|
|
409
|
+
async function validateSkillTarget(files, targetRoot, ownedFiles, preserveModified = false, preserved = []) {
|
|
228
410
|
for (const file of files) {
|
|
229
411
|
const targetPath = join(targetRoot, file.path);
|
|
230
412
|
const existing = await readFile(targetPath).catch(() => undefined);
|
|
231
413
|
if (existing && !ownedFiles.has(targetPath) && !existing.equals(file.content)) {
|
|
414
|
+
if (preserveModified) {
|
|
415
|
+
preserved.push(targetPath);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
232
418
|
throw new Error(`A Foundry skill already exists at ${targetRoot}. Move or remove it before setup.`);
|
|
233
419
|
}
|
|
234
420
|
const ownedDigest = ownedFiles.get(targetPath);
|
|
235
421
|
if (existing && ownedDigest && digest(existing.toString()) !== ownedDigest) {
|
|
422
|
+
if (preserveModified) {
|
|
423
|
+
preserved.push(targetPath);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
236
426
|
throw new Error(`Foundry preserved your edited skill at ${targetRoot}. Move it before updating setup.`);
|
|
237
427
|
}
|
|
238
428
|
}
|
|
239
429
|
}
|
|
240
|
-
async function copySkill(skillFiles, targetRoot) {
|
|
430
|
+
async function copySkill(skillFiles, targetRoot, ownedFiles = new Map(), preserved = new Set()) {
|
|
241
431
|
const managedFiles = [];
|
|
242
432
|
const directories = new Set();
|
|
243
433
|
directories.add(targetRoot);
|
|
244
434
|
for (const file of skillFiles) {
|
|
245
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
|
+
}
|
|
246
442
|
const directory = dirname(targetPath);
|
|
247
443
|
directories.add(directory);
|
|
248
444
|
await mkdir(directory, { recursive: true });
|
|
@@ -257,19 +453,18 @@ export async function installFoundryDesignControl(): Promise<void> {
|
|
|
257
453
|
if (typeof window === 'undefined') return;
|
|
258
454
|
const query = new URLSearchParams(window.location.search);
|
|
259
455
|
if (!query.has('__foundry_session')) return;
|
|
260
|
-
const
|
|
261
|
-
|
|
456
|
+
const adapter = await import(/* @vite-ignore */ '${runtimeUrl}/adapter.js');
|
|
457
|
+
adapter.installFoundryInspector();
|
|
262
458
|
}
|
|
263
459
|
`;
|
|
264
460
|
}
|
|
265
|
-
async function configureWebIntegration(plan, generated, managedBlocks) {
|
|
461
|
+
async function configureWebIntegration(plan, generated, managedBlocks, ownedFiles, runtimeUrl, preserveModified = false, preserved = []) {
|
|
266
462
|
const entry = plan.integrationFile;
|
|
267
463
|
if (!entry)
|
|
268
464
|
return undefined;
|
|
269
465
|
if (plan.framework === 'next') {
|
|
270
466
|
const appRoot = dirname(entry);
|
|
271
467
|
const loader = join(appRoot, 'foundry-loader.tsx');
|
|
272
|
-
const runtimeUrl = 'http://127.0.0.1:4387';
|
|
273
468
|
const loaderContent = `'use client';
|
|
274
469
|
|
|
275
470
|
import { useEffect } from 'react';
|
|
@@ -278,14 +473,19 @@ export function FoundryLoader(): null {
|
|
|
278
473
|
useEffect(() => {
|
|
279
474
|
const query = new URLSearchParams(window.location.search);
|
|
280
475
|
if (!query.has('__foundry_session')) return;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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);
|
|
284
482
|
}, []);
|
|
285
483
|
return null;
|
|
286
484
|
}
|
|
287
485
|
`;
|
|
288
|
-
|
|
486
|
+
const generatedLoader = await writeOwnedGenerated(loader, loaderContent, ownedFiles, true, preserveModified, preserved);
|
|
487
|
+
if (generatedLoader)
|
|
488
|
+
generated.push(generatedLoader);
|
|
289
489
|
const original = await readText(entry);
|
|
290
490
|
if (!original.includes('FoundryLoader')) {
|
|
291
491
|
const withImport = `import { FoundryLoader } from './foundry-loader';\n${original}`;
|
|
@@ -307,7 +507,7 @@ export function FoundryLoader(): null {
|
|
|
307
507
|
const script = managedBlock(`<script type="module">
|
|
308
508
|
const query = new URLSearchParams(location.search);
|
|
309
509
|
if (query.has('__foundry_session')) {
|
|
310
|
-
const module = await import('
|
|
510
|
+
const module = await import('${runtimeUrl}/adapter.js');
|
|
311
511
|
module.installFoundryInspector();
|
|
312
512
|
}
|
|
313
513
|
</script>`, 'html');
|
|
@@ -343,7 +543,7 @@ function mcpServer(packageRoot) {
|
|
|
343
543
|
}
|
|
344
544
|
return {
|
|
345
545
|
command: 'npx',
|
|
346
|
-
args: ['-y', '
|
|
546
|
+
args: ['-y', '--prefer-online', FOUNDRY_MCP_PACKAGE_SPEC],
|
|
347
547
|
env: { FOUNDRY_DESIGN_RUNTIME_URL: 'http://127.0.0.1:4387' },
|
|
348
548
|
};
|
|
349
549
|
}
|
|
@@ -372,106 +572,355 @@ async function configureJson(path, packageRoot) {
|
|
|
372
572
|
await mkdir(dirname(path), { recursive: true });
|
|
373
573
|
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`);
|
|
374
574
|
}
|
|
575
|
+
async function ensureMcpServersShape(path) {
|
|
576
|
+
const content = await readFile(path, 'utf8').catch(() => '');
|
|
577
|
+
if (!content)
|
|
578
|
+
return;
|
|
579
|
+
let current;
|
|
580
|
+
try {
|
|
581
|
+
current = JSON.parse(content);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
587
|
+
return;
|
|
588
|
+
const config = current;
|
|
589
|
+
if (config.mcpServers === undefined) {
|
|
590
|
+
config.mcpServers = {};
|
|
591
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
export async function installAgentIntegration(rootInput, agent, packageRoot) {
|
|
595
|
+
const root = resolve(rootInput);
|
|
596
|
+
if (agent === 'codex') {
|
|
597
|
+
const path = join(root, '.codex', 'config.toml');
|
|
598
|
+
await configureCodex(path, packageRoot);
|
|
599
|
+
return path;
|
|
600
|
+
}
|
|
601
|
+
const path = agent === 'cursor' ? join(root, '.cursor', 'mcp.json') : join(root, '.mcp.json');
|
|
602
|
+
await configureJson(path, packageRoot);
|
|
603
|
+
return path;
|
|
604
|
+
}
|
|
605
|
+
/** Install one reusable user-level connection without placing MCP config in every project. */
|
|
606
|
+
export async function installHostAgentIntegration(homeInput, agent, options = {}) {
|
|
607
|
+
const home = resolve(homeInput);
|
|
608
|
+
const agentRoot = agent === 'codex'
|
|
609
|
+
? join(home, '.codex')
|
|
610
|
+
: agent === 'cursor'
|
|
611
|
+
? join(home, '.cursor')
|
|
612
|
+
: join(home, '.claude');
|
|
613
|
+
const configFile = agent === 'codex'
|
|
614
|
+
? join(agentRoot, 'config.toml')
|
|
615
|
+
: agent === 'cursor'
|
|
616
|
+
? join(agentRoot, 'mcp.json')
|
|
617
|
+
: join(home, '.claude.json');
|
|
618
|
+
const skillDirectory = join(agentRoot, 'skills', 'foundry-design-control');
|
|
619
|
+
if (agent === 'codex')
|
|
620
|
+
await configureCodex(configFile, options.packageRoot);
|
|
621
|
+
else
|
|
622
|
+
await configureJson(configFile, options.packageRoot);
|
|
623
|
+
if (agent === 'claude') {
|
|
624
|
+
// Some Claude hosts validate ~/.mcp.json before loading ~/.claude.json. A bare
|
|
625
|
+
// object is valid JSON but fails that schema and can prevent every user MCP
|
|
626
|
+
// server from loading, including Foundry.
|
|
627
|
+
await ensureMcpServersShape(join(home, '.mcp.json'));
|
|
628
|
+
}
|
|
629
|
+
const skillFiles = await readSkill(resolve(options.skillRoot ?? DEFAULT_SKILL_ROOT));
|
|
630
|
+
const manifestFile = join(skillDirectory, '.foundry-install.json');
|
|
631
|
+
const manifest = await readFile(manifestFile, 'utf8')
|
|
632
|
+
.then((content) => JSON.parse(content))
|
|
633
|
+
.catch(() => undefined);
|
|
634
|
+
const ownedFiles = new Map((manifest?.generatedFiles ?? []).map((file) => [file.path, file.sha256]));
|
|
635
|
+
const preserved = [];
|
|
636
|
+
await validateSkillTarget(skillFiles, skillDirectory, ownedFiles, true, preserved);
|
|
637
|
+
const copied = await copySkill(skillFiles, skillDirectory, ownedFiles, new Set(preserved));
|
|
638
|
+
await writeFile(manifestFile, `${JSON.stringify({
|
|
639
|
+
version: 1,
|
|
640
|
+
generatorVersion: GENERATOR_VERSION,
|
|
641
|
+
generatedFiles: copied.files,
|
|
642
|
+
updatedAt: new Date().toISOString(),
|
|
643
|
+
}, null, 2)}\n`);
|
|
644
|
+
return { configFile, skillDirectory, preserved };
|
|
645
|
+
}
|
|
646
|
+
/** Remove only Foundry-owned user-level configuration and skill files. */
|
|
647
|
+
export async function uninstallHostAgentIntegration(homeInput, agent) {
|
|
648
|
+
const home = resolve(homeInput);
|
|
649
|
+
const agentRoot = agent === 'codex'
|
|
650
|
+
? join(home, '.codex')
|
|
651
|
+
: agent === 'cursor'
|
|
652
|
+
? join(home, '.cursor')
|
|
653
|
+
: join(home, '.claude');
|
|
654
|
+
const configFile = agent === 'codex'
|
|
655
|
+
? join(agentRoot, 'config.toml')
|
|
656
|
+
: agent === 'cursor'
|
|
657
|
+
? join(agentRoot, 'mcp.json')
|
|
658
|
+
: join(home, '.claude.json');
|
|
659
|
+
const skillDirectory = join(agentRoot, 'skills', 'foundry-design-control');
|
|
660
|
+
const manifestFile = join(skillDirectory, '.foundry-install.json');
|
|
661
|
+
const manifest = await readFile(manifestFile, 'utf8')
|
|
662
|
+
.then((content) => JSON.parse(content))
|
|
663
|
+
.catch(() => undefined);
|
|
664
|
+
const removed = [];
|
|
665
|
+
const preserved = [];
|
|
666
|
+
if (agent === 'codex') {
|
|
667
|
+
const original = await readText(configFile);
|
|
668
|
+
if (original.includes(START)) {
|
|
669
|
+
await writeFile(configFile, stripManagedBlock(original));
|
|
670
|
+
removed.push(configFile);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
const original = await readText(configFile);
|
|
675
|
+
if (original.includes('foundry-design-control')) {
|
|
676
|
+
await removeJsonServer(configFile);
|
|
677
|
+
removed.push(configFile);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
for (const file of manifest?.generatedFiles ?? []) {
|
|
681
|
+
const content = await readText(file.path);
|
|
682
|
+
if (!content)
|
|
683
|
+
continue;
|
|
684
|
+
if (digest(content) !== file.sha256) {
|
|
685
|
+
preserved.push(file.path);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
await rm(file.path);
|
|
689
|
+
removed.push(file.path);
|
|
690
|
+
}
|
|
691
|
+
await rm(manifestFile, { force: true });
|
|
692
|
+
removed.push(manifestFile);
|
|
693
|
+
const directories = [
|
|
694
|
+
join(skillDirectory, 'references'),
|
|
695
|
+
join(skillDirectory, 'scripts'),
|
|
696
|
+
skillDirectory,
|
|
697
|
+
];
|
|
698
|
+
for (const directory of directories)
|
|
699
|
+
await rmdir(directory).catch(() => undefined);
|
|
700
|
+
return { configFile, removed: [...new Set(removed)], preserved };
|
|
701
|
+
}
|
|
702
|
+
export async function createUpdatePlan(rootInput, options = {}) {
|
|
703
|
+
const root = resolve(rootInput);
|
|
704
|
+
const manifest = await readFile(join(root, '.foundry', 'install-manifest.json'), 'utf8')
|
|
705
|
+
.then((content) => JSON.parse(content))
|
|
706
|
+
.catch(() => undefined);
|
|
707
|
+
if (!manifest)
|
|
708
|
+
throw new Error(`Foundry is not installed in ${root}. Run setup first.`);
|
|
709
|
+
const config = await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8')
|
|
710
|
+
.then((content) => JSON.parse(content))
|
|
711
|
+
.catch(() => undefined);
|
|
712
|
+
return createSetupPlan(root, {
|
|
713
|
+
...options,
|
|
714
|
+
// Beta.14 makes the reusable host bridge the default. Passing agents
|
|
715
|
+
// explicitly remains the opt-in escape hatch for project-scoped installs.
|
|
716
|
+
agents: options.agents ?? [],
|
|
717
|
+
targetUrl: options.targetUrl ?? config?.targetUrl,
|
|
718
|
+
update: true,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
export async function updateProject(rootInput, options = {}) {
|
|
722
|
+
const root = resolve(rootInput);
|
|
723
|
+
const plan = await createUpdatePlan(root, options);
|
|
724
|
+
const config = await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8')
|
|
725
|
+
.then((content) => JSON.parse(content))
|
|
726
|
+
.catch(() => undefined);
|
|
727
|
+
return setupProject(rootInput, {
|
|
728
|
+
...options,
|
|
729
|
+
agents: plan.agents,
|
|
730
|
+
targetUrl: plan.targetUrl,
|
|
731
|
+
runtimeUrl: options.runtimeUrl ?? config?.runtimeUrl,
|
|
732
|
+
update: true,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
375
735
|
export async function setupProject(rootInput, options = {}) {
|
|
376
|
-
const
|
|
736
|
+
const root = resolve(rootInput);
|
|
737
|
+
await recoverInterruptedSetup(root);
|
|
738
|
+
const plan = await createSetupPlan(root, options);
|
|
377
739
|
const runtimeUrl = options.runtimeUrl ?? 'http://127.0.0.1:4387';
|
|
378
740
|
const generated = [];
|
|
379
741
|
const managedBlocks = [];
|
|
380
742
|
const jsonConfigs = [];
|
|
381
743
|
const skillDirectories = [];
|
|
382
744
|
const changed = [];
|
|
745
|
+
const preserved = [];
|
|
746
|
+
const preserveModified = options.update === true;
|
|
383
747
|
const previousManifest = await readFile(join(plan.root, '.foundry', 'install-manifest.json'), 'utf8')
|
|
384
748
|
.then((content) => JSON.parse(content))
|
|
385
749
|
.catch(() => undefined);
|
|
750
|
+
const hasLegacyProjectAgentIntegration = Boolean(previousManifest &&
|
|
751
|
+
((previousManifest.agents ?? []).length ||
|
|
752
|
+
(previousManifest.jsonConfigs ?? []).length ||
|
|
753
|
+
previousManifest.skillDirectories?.length ||
|
|
754
|
+
(previousManifest.managedBlocks ?? []).some((path) => path.endsWith(join('.codex', 'config.toml')))));
|
|
755
|
+
if (options.update && !previousManifest) {
|
|
756
|
+
throw new Error(`Foundry is not installed in ${plan.root}. Run setup first.`);
|
|
757
|
+
}
|
|
386
758
|
const ownedFiles = new Map((previousManifest?.generatedFiles ?? []).map((file) => [file.path, file.sha256]));
|
|
387
759
|
const skillFiles = plan.skillDirectories.length
|
|
388
760
|
? await readSkill(resolve(options.skillRoot ?? DEFAULT_SKILL_ROOT))
|
|
389
761
|
: [];
|
|
390
762
|
for (const directory of plan.skillDirectories) {
|
|
391
|
-
await validateSkillTarget(skillFiles, directory, ownedFiles);
|
|
392
|
-
}
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
...(plan.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
763
|
+
await validateSkillTarget(skillFiles, directory, ownedFiles, preserveModified, preserved);
|
|
764
|
+
}
|
|
765
|
+
const transactionFiles = [
|
|
766
|
+
...plan.files.filter((path) => !plan.skillDirectories.includes(path)),
|
|
767
|
+
...plan.skillDirectories.flatMap((directory) => skillFiles.map((file) => join(directory, file.path))),
|
|
768
|
+
...(plan.agents.length === 0 && hasLegacyProjectAgentIntegration
|
|
769
|
+
? [
|
|
770
|
+
...(previousManifest?.managedBlocks ?? []).filter((path) => path.endsWith(join('.codex', 'config.toml'))),
|
|
771
|
+
...(previousManifest?.jsonConfigs ?? []),
|
|
772
|
+
...(previousManifest?.generatedFiles ?? [])
|
|
773
|
+
.filter((file) => (previousManifest?.skillDirectories ?? []).some((directory) => file.path === directory || file.path.startsWith(`${directory}/`)))
|
|
774
|
+
.map((file) => file.path),
|
|
775
|
+
]
|
|
776
|
+
: []),
|
|
777
|
+
];
|
|
778
|
+
const snapshots = await snapshotFiles(transactionFiles);
|
|
779
|
+
const checks = await validationCommands(plan.root);
|
|
780
|
+
const baseline = new Map();
|
|
781
|
+
for (const check of checks)
|
|
782
|
+
baseline.set(check.name, await runValidationCommand(plan.root, check));
|
|
783
|
+
await writeTransaction(plan.root, snapshots);
|
|
784
|
+
try {
|
|
785
|
+
const config = {
|
|
786
|
+
version: 2,
|
|
787
|
+
platform: plan.platform,
|
|
788
|
+
...(plan.framework ? { framework: plan.framework } : {}),
|
|
789
|
+
runtimeUrl,
|
|
790
|
+
...(plan.targetUrl ? { targetUrl: plan.targetUrl } : {}),
|
|
791
|
+
...(plan.devCommand ? { devCommand: plan.devCommand } : {}),
|
|
792
|
+
instrumented: plan.platform !== 'web' || Boolean(plan.integrationFile),
|
|
793
|
+
mode: plan.platform === 'web' && !plan.integrationFile ? 'basic' : 'precision',
|
|
794
|
+
connection: { mode: plan.agents.length ? 'project' : 'global' },
|
|
795
|
+
design: {
|
|
796
|
+
componentRoots: ['src', 'app', 'components'],
|
|
797
|
+
exclude: ['node_modules', 'dist', 'build', '.next', 'coverage'],
|
|
798
|
+
viewports: [
|
|
799
|
+
{ id: 'mobile', label: 'Mobile', width: 390, height: 844 },
|
|
800
|
+
{ id: 'tablet', label: 'Tablet', width: 768, height: 1024 },
|
|
801
|
+
{ id: 'desktop', label: 'Desktop', width: 1440, height: 900 },
|
|
802
|
+
],
|
|
803
|
+
themes: [
|
|
804
|
+
{ id: 'light', label: 'Light', attribute: 'data-theme', value: 'light' },
|
|
805
|
+
{ id: 'dark', label: 'Dark', attribute: 'data-theme', value: 'dark' },
|
|
806
|
+
],
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
const configFile = join(plan.root, '.foundry', 'foundry.config.json');
|
|
810
|
+
const generatedConfig = await writeOwnedGenerated(configFile, `${JSON.stringify(config, null, 2)}\n`, ownedFiles, false, preserveModified, preserved);
|
|
811
|
+
if (generatedConfig)
|
|
812
|
+
generated.push(generatedConfig);
|
|
813
|
+
if (!preserved.includes(configFile))
|
|
814
|
+
changed.push(configFile);
|
|
815
|
+
if (plan.platform === 'web') {
|
|
816
|
+
const adapter = join(plan.root, '.foundry', 'web-adapter.ts');
|
|
817
|
+
const generatedAdapter = await writeOwnedGenerated(adapter, adapterSource(runtimeUrl), ownedFiles, false, preserveModified, preserved);
|
|
818
|
+
if (generatedAdapter)
|
|
819
|
+
generated.push(generatedAdapter);
|
|
820
|
+
if (!preserved.includes(adapter))
|
|
821
|
+
changed.push(adapter);
|
|
822
|
+
const integration = await configureWebIntegration(plan, generated, managedBlocks, ownedFiles, runtimeUrl, preserveModified, preserved);
|
|
823
|
+
if (integration)
|
|
824
|
+
changed.push(integration);
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
const setup = join(plan.root, '.foundry', `${plan.platform}-setup.md`);
|
|
828
|
+
const content = plan.platform === 'swiftui'
|
|
829
|
+
? '# Foundry SwiftUI setup\n\nAdd FoundryDesignControl only to DEBUG builds and mark meaningful views with `.foundryInspectable`.\n'
|
|
830
|
+
: '# Foundry React Native setup\n\nCreate the debug adapter and register semantic targets using `measureInWindow`.\n';
|
|
831
|
+
const generatedSetup = await writeOwnedGenerated(setup, content, ownedFiles, false, preserveModified, preserved);
|
|
832
|
+
if (generatedSetup)
|
|
833
|
+
generated.push(generatedSetup);
|
|
834
|
+
if (!preserved.includes(setup))
|
|
835
|
+
changed.push(setup);
|
|
836
|
+
}
|
|
837
|
+
const gitignore = join(plan.root, '.gitignore');
|
|
838
|
+
await setManagedBlock(gitignore, managedBlock('.foundry/sessions/', 'hash'));
|
|
839
|
+
managedBlocks.push(gitignore);
|
|
840
|
+
changed.push(gitignore);
|
|
841
|
+
if (plan.agents.includes('codex')) {
|
|
842
|
+
const path = join(plan.root, '.codex', 'config.toml');
|
|
843
|
+
await configureCodex(path, options.packageRoot);
|
|
844
|
+
managedBlocks.push(path);
|
|
845
|
+
changed.push(path);
|
|
846
|
+
}
|
|
847
|
+
if (plan.agents.includes('cursor')) {
|
|
848
|
+
const path = join(plan.root, '.cursor', 'mcp.json');
|
|
849
|
+
await configureJson(path, options.packageRoot);
|
|
850
|
+
jsonConfigs.push(path);
|
|
851
|
+
changed.push(path);
|
|
852
|
+
}
|
|
853
|
+
if (plan.agents.includes('claude')) {
|
|
854
|
+
const path = join(plan.root, '.mcp.json');
|
|
855
|
+
await configureJson(path, options.packageRoot);
|
|
856
|
+
jsonConfigs.push(path);
|
|
857
|
+
changed.push(path);
|
|
858
|
+
}
|
|
859
|
+
for (const directory of plan.skillDirectories) {
|
|
860
|
+
const copied = await copySkill(skillFiles, directory, ownedFiles, new Set(preserved));
|
|
861
|
+
generated.push(...copied.files);
|
|
862
|
+
skillDirectories.push(...copied.directories);
|
|
863
|
+
changed.push(directory);
|
|
864
|
+
}
|
|
865
|
+
if (plan.agents.length === 0 && previousManifest && hasLegacyProjectAgentIntegration) {
|
|
866
|
+
const legacySkillDirectories = previousManifest.skillDirectories ?? [];
|
|
867
|
+
for (const path of (previousManifest.managedBlocks ?? []).filter((candidate) => candidate.endsWith(join('.codex', 'config.toml')))) {
|
|
868
|
+
const original = await readText(path);
|
|
869
|
+
if (!original)
|
|
870
|
+
continue;
|
|
871
|
+
await writeFile(path, stripManagedBlock(original));
|
|
872
|
+
changed.push(path);
|
|
873
|
+
}
|
|
874
|
+
for (const path of previousManifest.jsonConfigs ?? []) {
|
|
875
|
+
await removeJsonServer(path);
|
|
876
|
+
changed.push(path);
|
|
877
|
+
}
|
|
878
|
+
for (const file of previousManifest.generatedFiles.filter((candidate) => legacySkillDirectories.some((directory) => candidate.path === directory || candidate.path.startsWith(`${directory}/`)))) {
|
|
879
|
+
const content = await readText(file.path);
|
|
880
|
+
if (!content)
|
|
881
|
+
continue;
|
|
882
|
+
if (digest(content) !== file.sha256) {
|
|
883
|
+
preserved.push(file.path);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
await rm(file.path);
|
|
887
|
+
changed.push(file.path);
|
|
888
|
+
}
|
|
889
|
+
for (const directory of [...legacySkillDirectories].sort((left, right) => right.length - left.length)) {
|
|
890
|
+
await rmdir(directory).catch(() => undefined);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const validation = await validateInstalledProject(plan.root, checks, baseline, changed);
|
|
894
|
+
const manifest = {
|
|
895
|
+
version: 3,
|
|
896
|
+
connectionMode: plan.agents.length ? 'project' : 'global',
|
|
897
|
+
agents: plan.agents,
|
|
898
|
+
generatedFiles: generated,
|
|
899
|
+
managedBlocks,
|
|
900
|
+
jsonConfigs,
|
|
901
|
+
skillDirectories: [...new Set(skillDirectories)],
|
|
902
|
+
installedAt: previousManifest?.installedAt ?? new Date().toISOString(),
|
|
903
|
+
...(options.update ? { updatedAt: new Date().toISOString() } : {}),
|
|
904
|
+
generatorVersion: GENERATOR_VERSION,
|
|
905
|
+
validation,
|
|
906
|
+
};
|
|
907
|
+
const manifestFile = join(plan.root, '.foundry', 'install-manifest.json');
|
|
908
|
+
await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
909
|
+
changed.push(manifestFile);
|
|
910
|
+
await rm(transactionPath(plan.root), { force: true });
|
|
911
|
+
return {
|
|
912
|
+
...plan,
|
|
913
|
+
changed: [...new Set(changed)],
|
|
914
|
+
preserved: [...new Set(preserved)],
|
|
915
|
+
validation,
|
|
916
|
+
};
|
|
455
917
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
918
|
+
catch (error) {
|
|
919
|
+
await restoreSnapshots(plan.root, snapshots);
|
|
920
|
+
await rm(transactionPath(plan.root), { force: true });
|
|
921
|
+
await rmdir(join(plan.root, '.foundry')).catch(() => undefined);
|
|
922
|
+
throw error;
|
|
461
923
|
}
|
|
462
|
-
const manifest = {
|
|
463
|
-
version: 1,
|
|
464
|
-
agents: plan.agents,
|
|
465
|
-
generatedFiles: generated,
|
|
466
|
-
managedBlocks,
|
|
467
|
-
jsonConfigs,
|
|
468
|
-
skillDirectories: [...new Set(skillDirectories)],
|
|
469
|
-
installedAt: new Date().toISOString(),
|
|
470
|
-
};
|
|
471
|
-
const manifestFile = join(plan.root, '.foundry', 'install-manifest.json');
|
|
472
|
-
await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
473
|
-
changed.push(manifestFile);
|
|
474
|
-
return { ...plan, changed: [...new Set(changed)] };
|
|
475
924
|
}
|
|
476
925
|
async function removeJsonServer(path) {
|
|
477
926
|
try {
|
|
@@ -479,8 +928,10 @@ async function removeJsonServer(path) {
|
|
|
479
928
|
if (!current.mcpServers?.['foundry-design-control'])
|
|
480
929
|
return;
|
|
481
930
|
delete current.mcpServers['foundry-design-control'];
|
|
931
|
+
// Keep the required object even when Foundry was its only entry. Claude and
|
|
932
|
+
// some other hosts reject a bare object and may skip every user MCP server.
|
|
482
933
|
if (Object.keys(current.mcpServers).length === 0)
|
|
483
|
-
|
|
934
|
+
current.mcpServers = {};
|
|
484
935
|
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`);
|
|
485
936
|
}
|
|
486
937
|
catch {
|