kerfjs 4.4.0 → 4.5.0-beta.1
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/CHANGELOG.md +284 -104
- package/LICENSE +1 -1
- package/README.md +73 -54
- package/ai/cursorrules +130 -48
- package/ai/manifest.json +63 -5
- package/ai/skill.md +148 -65
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +4 -4
- package/dist/actions.js.map +1 -1
- package/dist/array-signal.js +5 -5
- package/dist/async.js +34 -24
- package/dist/async.js.map +1 -1
- package/dist/attach.d.ts +11 -8
- package/dist/attach.js +54 -4
- package/dist/attach.js.map +1 -1
- package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +4 -3
- package/dist/chunk-5WRGJZV6.js.map +1 -0
- package/dist/{chunk-GY4XV2UV.js → chunk-BDX3R4OM.js} +4 -3
- package/dist/chunk-BDX3R4OM.js.map +1 -0
- package/dist/{chunk-VVDJLWMP.js → chunk-CEQMZYLR.js} +2 -2
- package/dist/chunk-CEQMZYLR.js.map +1 -0
- package/dist/{chunk-KEZTD6H4.js → chunk-E5R5GNKE.js} +15 -5
- package/dist/chunk-E5R5GNKE.js.map +1 -0
- package/dist/{chunk-MRYM3O3V.js → chunk-ELXVRKY2.js} +12 -9
- package/dist/chunk-ELXVRKY2.js.map +1 -0
- package/dist/{chunk-U32TFTGZ.js → chunk-MK42GLPV.js} +3 -3
- package/dist/chunk-MK42GLPV.js.map +1 -0
- package/dist/{chunk-SUPUPSBE.js → chunk-QFUNWHKH.js} +42 -20
- package/dist/chunk-QFUNWHKH.js.map +1 -0
- package/dist/{chunk-SRWQKB33.js → chunk-V757BT6U.js} +206 -111
- package/dist/chunk-V757BT6U.js.map +1 -0
- package/dist/{chunk-YHH7OUFA.js → chunk-WKIPLNVO.js} +3 -3
- package/dist/chunk-WKIPLNVO.js.map +1 -0
- package/dist/{chunk-3APBEVHF.js → chunk-Y2FOYPBV.js} +3 -3
- package/dist/{chunk-3APBEVHF.js.map → chunk-Y2FOYPBV.js.map} +1 -1
- package/dist/{chunk-SAYPJ6XR.js → chunk-ZOIERTUW.js} +10 -6
- package/dist/chunk-ZOIERTUW.js.map +1 -0
- package/dist/dev.d.ts +9 -6
- package/dist/dev.js +66 -19
- package/dist/dev.js.map +1 -1
- package/dist/html.d.ts +1 -1
- package/dist/html.js +8 -7
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +28 -19
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.js +5 -5
- package/dist/list.d.ts +1 -1
- package/dist/list.js +304 -213
- package/dist/list.js.map +1 -1
- package/dist/overlay.d.ts +190 -280
- package/dist/overlay.js +471 -364
- package/dist/overlay.js.map +1 -1
- package/dist/remount.d.ts +5 -3
- package/dist/remount.js +29 -10
- package/dist/remount.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +73 -28
- package/dist/router.js.map +1 -1
- package/dist/scope.d.ts +4 -3
- package/dist/scope.js +11 -10
- package/dist/scope.js.map +1 -1
- package/dist/testing.js +4 -4
- package/dist/timing.d.ts +3 -2
- package/dist/timing.js +5 -5
- package/dist/timing.js.map +1 -1
- package/llms.txt +13 -6
- package/package.json +35 -13
- package/setup/cli.mjs +90 -0
- package/setup/index.d.mts +37 -0
- package/setup/index.mjs +1347 -0
- package/setup/jsonc.mjs +201 -0
- package/setup/state.schema.json +56 -0
- package/dist/chunk-GY4XV2UV.js.map +0 -1
- package/dist/chunk-KEZTD6H4.js.map +0 -1
- package/dist/chunk-MRYM3O3V.js.map +0 -1
- package/dist/chunk-QIP723L4.js.map +0 -1
- package/dist/chunk-SAYPJ6XR.js.map +0 -1
- package/dist/chunk-SRWQKB33.js.map +0 -1
- package/dist/chunk-SUPUPSBE.js.map +0 -1
- package/dist/chunk-U32TFTGZ.js.map +0 -1
- package/dist/chunk-VVDJLWMP.js.map +0 -1
- package/dist/chunk-YHH7OUFA.js.map +0 -1
- /package/dist/{attrSelector-Cmu2ZoGO.d.ts → attr-Cmu2ZoGO.d.ts} +0 -0
package/setup/index.mjs
ADDED
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
realpath,
|
|
8
|
+
readdir,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
rmdir,
|
|
12
|
+
lstat,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
17
|
+
import process from 'node:process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
|
|
21
|
+
import { braceExpand, minimatch } from 'minimatch';
|
|
22
|
+
import { parse as parseYaml } from 'yaml';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
applyJsoncEdits,
|
|
26
|
+
jsoncInsertProperties,
|
|
27
|
+
jsoncValueEdit,
|
|
28
|
+
parseJsoncDocument,
|
|
29
|
+
} from './jsonc.mjs';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const marker = '<!-- KERF-APP-CANONICAL-END · your customizations below -->';
|
|
34
|
+
|
|
35
|
+
const exists = async (path) => {
|
|
36
|
+
try {
|
|
37
|
+
await access(path);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const portable = (root, path) =>
|
|
45
|
+
relative(root, path).replaceAll('\\', '/') || '.';
|
|
46
|
+
const contained = (root, path) => {
|
|
47
|
+
const offset = relative(root, path);
|
|
48
|
+
return (
|
|
49
|
+
offset === '' ||
|
|
50
|
+
(!offset.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) &&
|
|
51
|
+
offset !== '..' &&
|
|
52
|
+
!isAbsolute(offset))
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
56
|
+
const sha256 = (value) =>
|
|
57
|
+
createHash('sha256').update(value, 'utf8').digest('hex');
|
|
58
|
+
const redactText = (value) =>
|
|
59
|
+
String(value).replace(
|
|
60
|
+
/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+(?::[^\s/@]*)?@/gi,
|
|
61
|
+
'$1<redacted>@',
|
|
62
|
+
);
|
|
63
|
+
const describeValue = (key, value) =>
|
|
64
|
+
/token|secret|password|credential/i.test(key)
|
|
65
|
+
? '<redacted>'
|
|
66
|
+
: redactText(JSON.stringify(value));
|
|
67
|
+
|
|
68
|
+
async function readText(path) {
|
|
69
|
+
try {
|
|
70
|
+
return await readFile(path, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readJson(path) {
|
|
77
|
+
const source = await readText(path);
|
|
78
|
+
if (source === null) return null;
|
|
79
|
+
return JSON.parse(source);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readWorkspaceManifest(path) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error?.code === 'ENOENT') return null;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateSetupState(state) {
|
|
92
|
+
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
93
|
+
throw new Error('.kerf-ai-setup.json must be an object.');
|
|
94
|
+
if (![1, 2].includes(state.schemaVersion))
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Unsupported .kerf-ai-setup.json schemaVersion ${state.schemaVersion}.`,
|
|
97
|
+
);
|
|
98
|
+
if (
|
|
99
|
+
typeof state.setupVersion !== 'string' ||
|
|
100
|
+
!['npm', 'pnpm', 'yarn'].includes(state.packageManager) ||
|
|
101
|
+
(state.packageManagerVersion !== undefined &&
|
|
102
|
+
typeof state.packageManagerVersion !== 'string') ||
|
|
103
|
+
(state.packageManagerVariant !== undefined &&
|
|
104
|
+
!['classic', 'berry'].includes(state.packageManagerVariant)) ||
|
|
105
|
+
(state.packageManagerVariant !== undefined &&
|
|
106
|
+
state.packageManager !== 'yarn')
|
|
107
|
+
)
|
|
108
|
+
throw new Error(
|
|
109
|
+
'.kerf-ai-setup.json has invalid version or package-manager metadata.',
|
|
110
|
+
);
|
|
111
|
+
const packageStates =
|
|
112
|
+
state.schemaVersion === 2 ? state.packages : { [state.packagePath]: state };
|
|
113
|
+
if (!packageStates || typeof packageStates !== 'object')
|
|
114
|
+
throw new Error('.kerf-ai-setup.json packages must be an object.');
|
|
115
|
+
for (const [key, value] of Object.entries(packageStates)) {
|
|
116
|
+
if (
|
|
117
|
+
!value ||
|
|
118
|
+
typeof value !== 'object' ||
|
|
119
|
+
value.packagePath !== key ||
|
|
120
|
+
typeof value.package !== 'string' ||
|
|
121
|
+
!['core', 'ui'].includes(value.mode) ||
|
|
122
|
+
!value.managed ||
|
|
123
|
+
typeof value.managed !== 'object' ||
|
|
124
|
+
!value.resolutions ||
|
|
125
|
+
typeof value.resolutions !== 'object'
|
|
126
|
+
)
|
|
127
|
+
throw new Error(`Invalid setup state for package ${key}.`);
|
|
128
|
+
for (const resolution of Object.values(value.resolutions ?? {}))
|
|
129
|
+
if (!['keep', 'kerf'].includes(resolution))
|
|
130
|
+
throw new Error(`Invalid persisted resolution for package ${key}.`);
|
|
131
|
+
for (const item of Object.values(value.managed ?? {}))
|
|
132
|
+
if (!item || !/^[a-f0-9]{64}$/.test(item.sha256 ?? ''))
|
|
133
|
+
throw new Error(`Invalid managed-value hash for package ${key}.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function dependencies(manifest) {
|
|
138
|
+
return {
|
|
139
|
+
...(manifest.dependencies ?? {}),
|
|
140
|
+
...(manifest.devDependencies ?? {}),
|
|
141
|
+
...(manifest.peerDependencies ?? {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function allowsVersion(range, version) {
|
|
146
|
+
if (typeof range !== 'string') return false;
|
|
147
|
+
if (range === version || range === '*' || range === 'latest') return true;
|
|
148
|
+
const requested = version
|
|
149
|
+
.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
150
|
+
?.slice(1)
|
|
151
|
+
.map(Number);
|
|
152
|
+
const declared = range.match(/^(\^|~|>=)\s*(\d+)\.(\d+)\.(\d+)/);
|
|
153
|
+
if (!requested || !declared) return false;
|
|
154
|
+
const base = declared.slice(2).map(Number);
|
|
155
|
+
const compare =
|
|
156
|
+
requested[0] - base[0] || requested[1] - base[1] || requested[2] - base[2];
|
|
157
|
+
if (compare < 0) return false;
|
|
158
|
+
if (declared[1] === '^') return requested[0] === base[0];
|
|
159
|
+
if (declared[1] === '~')
|
|
160
|
+
return requested[0] === base[0] && requested[1] === base[1];
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validatedWorkspacePattern(pattern) {
|
|
165
|
+
if (typeof pattern !== 'string' || pattern !== pattern.trim() || !pattern)
|
|
166
|
+
throw new Error(`Unsafe workspace pattern ${String(pattern)}.`);
|
|
167
|
+
const negated = pattern.startsWith('!');
|
|
168
|
+
const body = negated ? pattern.slice(1) : pattern;
|
|
169
|
+
if (
|
|
170
|
+
!body ||
|
|
171
|
+
body.startsWith('!') ||
|
|
172
|
+
isAbsolute(body) ||
|
|
173
|
+
/^[A-Za-z]:/.test(body) ||
|
|
174
|
+
body.startsWith('//') ||
|
|
175
|
+
body.includes('\\') ||
|
|
176
|
+
body.includes('\0')
|
|
177
|
+
)
|
|
178
|
+
throw new Error(`Unsafe workspace pattern ${pattern}.`);
|
|
179
|
+
let expansions;
|
|
180
|
+
try {
|
|
181
|
+
expansions = braceExpand(body);
|
|
182
|
+
// Compile as well as expand so malformed extglobs/classes fail here.
|
|
183
|
+
minimatch('', body, { nonegate: true });
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(`Invalid workspace pattern ${pattern}.`);
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
!expansions.length ||
|
|
189
|
+
expansions.some(
|
|
190
|
+
(expanded) =>
|
|
191
|
+
isAbsolute(expanded) ||
|
|
192
|
+
expanded.split('/').some((segment) => segment === '..'),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
throw new Error(`Unsafe workspace pattern ${pattern}.`);
|
|
196
|
+
return { body, negated };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pnpmWorkspacePatterns(root) {
|
|
200
|
+
const source = await readText(resolve(root, 'pnpm-workspace.yaml'));
|
|
201
|
+
if (source === null) return [];
|
|
202
|
+
let document;
|
|
203
|
+
try {
|
|
204
|
+
document = parseYaml(source);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`pnpm-workspace.yaml is invalid: ${error.message}`, {
|
|
207
|
+
cause: error,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (
|
|
211
|
+
!document ||
|
|
212
|
+
typeof document !== 'object' ||
|
|
213
|
+
!Array.isArray(document.packages) ||
|
|
214
|
+
document.packages.some((pattern) => typeof pattern !== 'string')
|
|
215
|
+
)
|
|
216
|
+
throw new Error(
|
|
217
|
+
'pnpm-workspace.yaml packages must be an array of strings.',
|
|
218
|
+
);
|
|
219
|
+
return [...document.packages];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function workspacePackages(root) {
|
|
223
|
+
const rootManifest = await readJson(resolve(root, 'package.json'));
|
|
224
|
+
if (!rootManifest) throw new Error(`${root} has no package.json.`);
|
|
225
|
+
let manifestPatterns = [];
|
|
226
|
+
if (rootManifest.workspaces !== undefined) {
|
|
227
|
+
const declaredPatterns = Array.isArray(rootManifest.workspaces)
|
|
228
|
+
? rootManifest.workspaces
|
|
229
|
+
: rootManifest.workspaces?.packages;
|
|
230
|
+
if (!Array.isArray(declaredPatterns))
|
|
231
|
+
throw new Error(
|
|
232
|
+
'package.json workspaces must be an array or an object with a packages array.',
|
|
233
|
+
);
|
|
234
|
+
manifestPatterns = [...declaredPatterns];
|
|
235
|
+
}
|
|
236
|
+
const patterns = [
|
|
237
|
+
...manifestPatterns,
|
|
238
|
+
...(await pnpmWorkspacePatterns(root)),
|
|
239
|
+
].map(validatedWorkspacePattern);
|
|
240
|
+
const positivePatterns = patterns.filter(({ negated }) => !negated);
|
|
241
|
+
const rootReal = await realpath(root);
|
|
242
|
+
const candidates = new Map();
|
|
243
|
+
const ignored = new Set([
|
|
244
|
+
'.git',
|
|
245
|
+
'.hg',
|
|
246
|
+
'.pnpm-store',
|
|
247
|
+
'.svn',
|
|
248
|
+
'.yarn',
|
|
249
|
+
'node_modules',
|
|
250
|
+
]);
|
|
251
|
+
const visit = async (directory, relativeDirectory = '') => {
|
|
252
|
+
const entries = (await readdir(directory, { withFileTypes: true })).sort(
|
|
253
|
+
(left, right) => left.name.localeCompare(right.name),
|
|
254
|
+
);
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (ignored.has(entry.name)) continue;
|
|
257
|
+
const childRelative = relativeDirectory
|
|
258
|
+
? `${relativeDirectory}/${entry.name}`
|
|
259
|
+
: entry.name;
|
|
260
|
+
if (
|
|
261
|
+
!positivePatterns.some(({ body }) =>
|
|
262
|
+
minimatch(childRelative, body, { nonegate: true, partial: true }),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
continue;
|
|
266
|
+
const child = resolve(directory, entry.name);
|
|
267
|
+
if (entry.isSymbolicLink()) {
|
|
268
|
+
const actual = await realpath(child);
|
|
269
|
+
if (!contained(rootReal, actual))
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Workspace package symlink escapes the workspace: ${childRelative}.`,
|
|
272
|
+
);
|
|
273
|
+
const manifest = await readWorkspaceManifest(
|
|
274
|
+
resolve(child, 'package.json'),
|
|
275
|
+
);
|
|
276
|
+
if (manifest)
|
|
277
|
+
candidates.set(childRelative, { directory: child, manifest });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (!entry.isDirectory()) continue;
|
|
281
|
+
const manifest = await readWorkspaceManifest(
|
|
282
|
+
resolve(child, 'package.json'),
|
|
283
|
+
);
|
|
284
|
+
if (manifest)
|
|
285
|
+
candidates.set(childRelative, { directory: child, manifest });
|
|
286
|
+
await visit(child, childRelative);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
if (positivePatterns.length) await visit(root);
|
|
290
|
+
const selected = new Map();
|
|
291
|
+
for (const { body, negated } of patterns)
|
|
292
|
+
for (const [path, candidate] of [...candidates].sort(([left], [right]) =>
|
|
293
|
+
left.localeCompare(right),
|
|
294
|
+
))
|
|
295
|
+
if (minimatch(path, body, { nonegate: true })) {
|
|
296
|
+
if (negated) selected.delete(path);
|
|
297
|
+
else selected.set(path, candidate);
|
|
298
|
+
}
|
|
299
|
+
const packages = [
|
|
300
|
+
{ directory: root, manifest: rootManifest },
|
|
301
|
+
...[...selected]
|
|
302
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
303
|
+
.map(([, candidate]) => candidate),
|
|
304
|
+
];
|
|
305
|
+
const actualDirectories = new Map([[rootReal, '.']]);
|
|
306
|
+
for (const { directory } of packages) {
|
|
307
|
+
if (directory === root) continue;
|
|
308
|
+
const actual = await realpath(directory);
|
|
309
|
+
const previous = actualDirectories.get(actual);
|
|
310
|
+
if (previous)
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Workspace package aliases ${previous} and ${portable(root, directory)} resolve to the same directory.`,
|
|
313
|
+
);
|
|
314
|
+
actualDirectories.set(actual, portable(root, directory));
|
|
315
|
+
}
|
|
316
|
+
return packages;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function declaredMode(manifest) {
|
|
320
|
+
const deps = dependencies(manifest);
|
|
321
|
+
if (deps['@kerfjs/ui']) return 'ui';
|
|
322
|
+
if (deps.kerfjs) return 'core';
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function sourceMode(directory) {
|
|
327
|
+
const queue = [resolve(directory, 'src')];
|
|
328
|
+
let inspected = 0;
|
|
329
|
+
let core = false;
|
|
330
|
+
while (queue.length && inspected < 200) {
|
|
331
|
+
const current = queue.shift();
|
|
332
|
+
let entries;
|
|
333
|
+
try {
|
|
334
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
335
|
+
} catch {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
const path = resolve(current, entry.name);
|
|
340
|
+
if (entry.isDirectory()) queue.push(path);
|
|
341
|
+
else if (/\.(?:[cm]?[jt]sx?)$/.test(entry.name)) {
|
|
342
|
+
inspected += 1;
|
|
343
|
+
const source = await readText(path);
|
|
344
|
+
if (/['"]@kerfjs\/ui(?:\/[^'"]*)?['"]/.test(source)) return 'ui';
|
|
345
|
+
if (/['"]kerfjs(?:\/[^'"]*)?['"]/.test(source)) core = true;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return core ? 'core' : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function detectMode(candidate) {
|
|
353
|
+
return declaredMode(candidate.manifest) ?? sourceMode(candidate.directory);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function selectPackage(root, selector, requestedMode) {
|
|
357
|
+
const packages = await workspacePackages(root);
|
|
358
|
+
if (selector) {
|
|
359
|
+
const direct = resolve(root, selector);
|
|
360
|
+
const pathMatch = packages.find(({ directory }) => directory === direct);
|
|
361
|
+
const nameMatches = packages.filter(
|
|
362
|
+
({ manifest }) => manifest.name === selector,
|
|
363
|
+
);
|
|
364
|
+
if (!pathMatch && nameMatches.length > 1)
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Workspace package name ${selector} is ambiguous (${nameMatches
|
|
367
|
+
.map(({ directory }) => portable(root, directory))
|
|
368
|
+
.join(', ')}); select a relative package path.`,
|
|
369
|
+
);
|
|
370
|
+
const selected = pathMatch ?? nameMatches[0];
|
|
371
|
+
if (!selected)
|
|
372
|
+
throw new Error(`Workspace package ${selector} was not found.`);
|
|
373
|
+
return selected;
|
|
374
|
+
}
|
|
375
|
+
const candidates = [];
|
|
376
|
+
for (const candidate of packages)
|
|
377
|
+
if ((await detectMode(candidate)) || requestedMode)
|
|
378
|
+
candidates.push(candidate);
|
|
379
|
+
if (candidates.length === 1) return candidates[0];
|
|
380
|
+
if (!candidates.length)
|
|
381
|
+
throw new Error('No Kerf package detected; pass --core or --ui.');
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Multiple Kerf packages detected (${candidates
|
|
384
|
+
.map(({ manifest }) => manifest.name ?? '<unnamed>')
|
|
385
|
+
.join(', ')}); pass --package <name|path>.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function splitCanonical(source) {
|
|
390
|
+
const first = source.indexOf(marker);
|
|
391
|
+
if (first < 0) return { error: 'missing canonical marker' };
|
|
392
|
+
if (source.indexOf(marker, first + marker.length) >= 0)
|
|
393
|
+
return { error: 'multiple canonical markers' };
|
|
394
|
+
const end = first + marker.length + (source[first + marker.length] === '\n');
|
|
395
|
+
return { canonical: source.slice(0, end), append: source.slice(end) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function mergeCanonical(current, bundled, history, version) {
|
|
399
|
+
if (current === null) return { value: bundled };
|
|
400
|
+
const local = splitCanonical(current);
|
|
401
|
+
if (local.error) return { conflict: local.error };
|
|
402
|
+
const localVersion = current
|
|
403
|
+
.slice(0, 512)
|
|
404
|
+
.match(/kerf-skill-version:\s*([\w.-]+)/)?.[1];
|
|
405
|
+
if (!localVersion) return { conflict: 'missing kerf-skill-version' };
|
|
406
|
+
const expected =
|
|
407
|
+
localVersion === version ? sha256(bundled) : history[localVersion];
|
|
408
|
+
if (!expected || sha256(local.canonical) !== expected)
|
|
409
|
+
return { conflict: 'canonical content was customized above the marker' };
|
|
410
|
+
return { value: bundled + local.append };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const hashValue = (value) => sha256(JSON.stringify(value) ?? 'undefined');
|
|
414
|
+
|
|
415
|
+
function reconcileValue({
|
|
416
|
+
target,
|
|
417
|
+
key,
|
|
418
|
+
value,
|
|
419
|
+
id,
|
|
420
|
+
conflicts,
|
|
421
|
+
managedBefore,
|
|
422
|
+
managedAfter,
|
|
423
|
+
resolutions,
|
|
424
|
+
activeConflictIds,
|
|
425
|
+
knownResolutionIds,
|
|
426
|
+
}) {
|
|
427
|
+
knownResolutionIds.add(id);
|
|
428
|
+
const current = target[key];
|
|
429
|
+
const previouslyManaged = managedBefore[id]?.sha256 === hashValue(current);
|
|
430
|
+
const divergent = key in target && current !== value && !previouslyManaged;
|
|
431
|
+
if (divergent) activeConflictIds.add(id);
|
|
432
|
+
const resolution = resolutions[id];
|
|
433
|
+
if (divergent && !resolution) {
|
|
434
|
+
conflicts.push({
|
|
435
|
+
id,
|
|
436
|
+
message: `existing value ${describeValue(id, current)} differs from ${describeValue(id, value)}`,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (divergent && resolution === 'keep') return;
|
|
441
|
+
target[key] = value;
|
|
442
|
+
managedAfter[id] = { sha256: hashValue(value) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function addMapValues(target, additions, path, context) {
|
|
446
|
+
for (const [key, value] of Object.entries(additions)) {
|
|
447
|
+
reconcileValue({ target, key, value, id: `${path}.${key}`, ...context });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function eslintTemplate(mode, format = 'module') {
|
|
452
|
+
const preset = mode === 'ui' ? 'recommended-ui' : 'recommended';
|
|
453
|
+
if (format === 'commonjs')
|
|
454
|
+
return `module.exports = (async () => {\n const { default: parser } = await import('@typescript-eslint/parser');\n const { default: kerfjs } = await import('eslint-plugin-kerfjs');\n return [\n { files: ['**/*.{ts,tsx}'], languageOptions: { parser } },\n kerfjs.configs['${preset}'],\n ];\n})();\n`;
|
|
455
|
+
return `import parser from '@typescript-eslint/parser';\nimport kerfjs from 'eslint-plugin-kerfjs';\n\nexport default [\n { files: ['**/*.{ts,tsx}'], languageOptions: { parser } },\n kerfjs.configs['${preset}'],\n];\n`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function mergeEslintConfig(source, mode) {
|
|
459
|
+
const imported = source.match(
|
|
460
|
+
/^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]eslint-plugin-kerfjs['"]\s*;?\s*$/m,
|
|
461
|
+
);
|
|
462
|
+
if (imported) {
|
|
463
|
+
const preset = mode === 'ui' ? 'recommended-ui' : 'recommended';
|
|
464
|
+
const configured = new RegExp(
|
|
465
|
+
`^\\s*${imported[1]}\\.configs\\[['"]${preset}['"]\\],?\\s*$`,
|
|
466
|
+
'm',
|
|
467
|
+
);
|
|
468
|
+
if (configured.test(source)) return { value: source };
|
|
469
|
+
}
|
|
470
|
+
const match = source.match(/^\s*export\s+default\s*\[/m);
|
|
471
|
+
if (!match || (source.includes('`') && !imported))
|
|
472
|
+
return {
|
|
473
|
+
conflict: 'config does not use a provable static export default array',
|
|
474
|
+
};
|
|
475
|
+
const preset = mode === 'ui' ? 'recommended-ui' : 'recommended';
|
|
476
|
+
const importLine = imported
|
|
477
|
+
? ''
|
|
478
|
+
: "import kerfjs from 'eslint-plugin-kerfjs';\n";
|
|
479
|
+
const binding = imported?.[1] ?? 'kerfjs';
|
|
480
|
+
return {
|
|
481
|
+
value: `${importLine}${source.slice(0, match.index + match[0].length)}\n ${binding}.configs['${preset}'],${source.slice(match.index + match[0].length)}`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function profileTemplate(packageName, catalogPath) {
|
|
486
|
+
return {
|
|
487
|
+
$schema:
|
|
488
|
+
'https://raw.githubusercontent.com/brianwestphal/kerf/main/ui/ai/application-ui-profile.schema.json',
|
|
489
|
+
schemaVersion: 1,
|
|
490
|
+
scope: 'workspace',
|
|
491
|
+
catalogs: [
|
|
492
|
+
{
|
|
493
|
+
package: packageName,
|
|
494
|
+
composition: { path: catalogPath, schemaVersion: 2 },
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function metadataTemplate() {
|
|
501
|
+
return {
|
|
502
|
+
$schema:
|
|
503
|
+
'./node_modules/create-kerf-component/component-metadata.schema.json',
|
|
504
|
+
schemaVersion: 1,
|
|
505
|
+
v1Catalog: 'not-applicable',
|
|
506
|
+
components: [],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function packageManagerInfo(root) {
|
|
511
|
+
const manifest = await readJson(resolve(root, 'package.json'));
|
|
512
|
+
const declared = manifest?.packageManager?.match(/^(npm|pnpm|yarn)@(.+)$/);
|
|
513
|
+
if (manifest?.packageManager && !declared)
|
|
514
|
+
throw new Error(
|
|
515
|
+
`Unsupported packageManager ${manifest.packageManager}; expected npm@<version>, pnpm@<version>, or yarn@<numeric-version>.`,
|
|
516
|
+
);
|
|
517
|
+
if (declared) {
|
|
518
|
+
if (declared[1] !== 'yarn')
|
|
519
|
+
return { name: declared[1], version: declared[2] };
|
|
520
|
+
const major = Number(declared[2].match(/^\d+/)?.[0]);
|
|
521
|
+
if (!Number.isInteger(major))
|
|
522
|
+
throw new Error(
|
|
523
|
+
'Cannot distinguish Yarn Classic from Berry; declare a numeric yarn packageManager version.',
|
|
524
|
+
);
|
|
525
|
+
return {
|
|
526
|
+
name: 'yarn',
|
|
527
|
+
version: declared[2],
|
|
528
|
+
variant: major === 1 ? 'classic' : 'berry',
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
if (await exists(resolve(root, 'pnpm-lock.yaml'))) return { name: 'pnpm' };
|
|
532
|
+
const yarnLock = await readText(resolve(root, 'yarn.lock'));
|
|
533
|
+
const berryConfig = await exists(resolve(root, '.yarnrc.yml'));
|
|
534
|
+
const classicConfig = await exists(resolve(root, '.yarnrc'));
|
|
535
|
+
if (yarnLock !== null || berryConfig || classicConfig) {
|
|
536
|
+
const berryLock = /^__metadata:\s*$/m.test(yarnLock ?? '');
|
|
537
|
+
const classicLock = /^# yarn lockfile v1\s*$/m.test(yarnLock ?? '');
|
|
538
|
+
if ((berryConfig || berryLock) && !(classicConfig || classicLock))
|
|
539
|
+
return { name: 'yarn', variant: 'berry' };
|
|
540
|
+
if ((classicConfig || classicLock) && !(berryConfig || berryLock))
|
|
541
|
+
return { name: 'yarn', variant: 'classic' };
|
|
542
|
+
throw new Error(
|
|
543
|
+
'Cannot distinguish Yarn Classic from Berry; declare packageManager as yarn@<version>.',
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return { name: 'npm' };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function installedVersion(directory, name) {
|
|
550
|
+
try {
|
|
551
|
+
const require = createRequire(resolve(directory, 'package.json'));
|
|
552
|
+
let current = dirname(require.resolve(name));
|
|
553
|
+
while (dirname(current) !== current) {
|
|
554
|
+
const manifest = await readJson(resolve(current, 'package.json'));
|
|
555
|
+
if (manifest?.name === name) return manifest.version;
|
|
556
|
+
current = dirname(current);
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function mergeProfile(current, recommended, context) {
|
|
565
|
+
if (current === undefined) {
|
|
566
|
+
for (const key of ['$schema', 'schemaVersion', 'scope'])
|
|
567
|
+
context.managedAfter[`.kerf-ui-profile.json#${key}`] = {
|
|
568
|
+
sha256: hashValue(recommended[key]),
|
|
569
|
+
};
|
|
570
|
+
const local = recommended.catalogs[0];
|
|
571
|
+
context.managedAfter[`.kerf-ui-profile.json#catalogs.${local.package}`] = {
|
|
572
|
+
sha256: hashValue(local),
|
|
573
|
+
};
|
|
574
|
+
return recommended;
|
|
575
|
+
}
|
|
576
|
+
const idPrefix = '.kerf-ui-profile.json';
|
|
577
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
|
578
|
+
context.knownResolutionIds.add(idPrefix);
|
|
579
|
+
context.activeConflictIds.add(idPrefix);
|
|
580
|
+
if (!context.resolutions[idPrefix]) {
|
|
581
|
+
context.conflicts.push({
|
|
582
|
+
id: idPrefix,
|
|
583
|
+
message: 'profile root must be an object',
|
|
584
|
+
});
|
|
585
|
+
return current;
|
|
586
|
+
}
|
|
587
|
+
if (context.resolutions[idPrefix] === 'keep') return current;
|
|
588
|
+
current = {};
|
|
589
|
+
}
|
|
590
|
+
const output = JSON.parse(JSON.stringify(current));
|
|
591
|
+
for (const key of ['$schema', 'schemaVersion', 'scope']) {
|
|
592
|
+
reconcileValue({
|
|
593
|
+
target: output,
|
|
594
|
+
key,
|
|
595
|
+
value: recommended[key],
|
|
596
|
+
id: `.kerf-ui-profile.json#${key}`,
|
|
597
|
+
...context,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (output.catalogs !== undefined && !Array.isArray(output.catalogs)) {
|
|
601
|
+
const id = `${idPrefix}#catalogs`;
|
|
602
|
+
context.knownResolutionIds.add(id);
|
|
603
|
+
context.activeConflictIds.add(id);
|
|
604
|
+
if (!context.resolutions[id]) {
|
|
605
|
+
context.conflicts.push({ id, message: 'catalogs must be an array' });
|
|
606
|
+
return output;
|
|
607
|
+
}
|
|
608
|
+
if (context.resolutions[id] === 'keep') return output;
|
|
609
|
+
output.catalogs = [];
|
|
610
|
+
}
|
|
611
|
+
output.catalogs ??= [];
|
|
612
|
+
const local = recommended.catalogs[0];
|
|
613
|
+
const existing = output.catalogs.find(
|
|
614
|
+
(item) => item.package === local.package,
|
|
615
|
+
);
|
|
616
|
+
const id = `.kerf-ui-profile.json#catalogs.${local.package}`;
|
|
617
|
+
context.knownResolutionIds.add(id);
|
|
618
|
+
if (!existing) {
|
|
619
|
+
output.catalogs.push(local);
|
|
620
|
+
context.managedAfter[id] = { sha256: hashValue(local) };
|
|
621
|
+
} else {
|
|
622
|
+
const previouslyManaged =
|
|
623
|
+
context.managedBefore[id]?.sha256 === hashValue(existing);
|
|
624
|
+
const divergent =
|
|
625
|
+
JSON.stringify(existing) !== JSON.stringify(local) && !previouslyManaged;
|
|
626
|
+
if (divergent) context.activeConflictIds.add(id);
|
|
627
|
+
if (divergent && !context.resolutions[id])
|
|
628
|
+
context.conflicts.push({
|
|
629
|
+
id,
|
|
630
|
+
message: 'existing catalog declaration differs',
|
|
631
|
+
});
|
|
632
|
+
else if (!divergent || context.resolutions[id] === 'kerf') {
|
|
633
|
+
output.catalogs[output.catalogs.indexOf(existing)] = local;
|
|
634
|
+
context.managedAfter[id] = { sha256: hashValue(local) };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return output;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function planKerfSetup({
|
|
641
|
+
root = process.cwd(),
|
|
642
|
+
package: selector,
|
|
643
|
+
mode: requestedMode,
|
|
644
|
+
version,
|
|
645
|
+
resolutions = {},
|
|
646
|
+
} = {}) {
|
|
647
|
+
root = resolve(root);
|
|
648
|
+
const selected = await selectPackage(root, selector, requestedMode);
|
|
649
|
+
const detectedMode = await detectMode(selected);
|
|
650
|
+
const mode = requestedMode ?? detectedMode;
|
|
651
|
+
if (!mode) throw new Error('Pass --core or --ui for a new Kerf package.');
|
|
652
|
+
if (requestedMode && detectedMode && requestedMode !== detectedMode)
|
|
653
|
+
throw new Error(
|
|
654
|
+
`Requested ${requestedMode} conflicts with detected ${detectedMode} usage.`,
|
|
655
|
+
);
|
|
656
|
+
const ownManifest = await readJson(resolve(packageRoot, 'package.json'));
|
|
657
|
+
const setupVersion = version ?? ownManifest.version;
|
|
658
|
+
const conflicts = [];
|
|
659
|
+
const actions = [];
|
|
660
|
+
const generatedPaths = [];
|
|
661
|
+
const statePath = resolve(root, '.kerf-ai-setup.json');
|
|
662
|
+
const priorState = (await readJson(statePath)) ?? {};
|
|
663
|
+
if (Object.keys(priorState).length) validateSetupState(priorState);
|
|
664
|
+
const packageKey = portable(root, selected.directory);
|
|
665
|
+
const legacyPackageState =
|
|
666
|
+
priorState.packagePath === packageKey ? priorState : {};
|
|
667
|
+
const priorPackageState =
|
|
668
|
+
priorState.packages?.[packageKey] ?? legacyPackageState;
|
|
669
|
+
if (
|
|
670
|
+
priorPackageState.package &&
|
|
671
|
+
priorPackageState.package !== (selected.manifest.name ?? packageKey)
|
|
672
|
+
)
|
|
673
|
+
throw new Error(`Setup state package identity changed at ${packageKey}.`);
|
|
674
|
+
const requestedResolutions = { ...resolutions };
|
|
675
|
+
resolutions = { ...(priorPackageState.resolutions ?? {}), ...resolutions };
|
|
676
|
+
const managedBefore = priorPackageState.managed ?? {};
|
|
677
|
+
const managedAfter = {};
|
|
678
|
+
const knownResolutionIds = new Set();
|
|
679
|
+
const activeConflictIds = new Set();
|
|
680
|
+
const context = {
|
|
681
|
+
conflicts,
|
|
682
|
+
managedBefore,
|
|
683
|
+
managedAfter,
|
|
684
|
+
resolutions,
|
|
685
|
+
knownResolutionIds,
|
|
686
|
+
activeConflictIds,
|
|
687
|
+
};
|
|
688
|
+
const addAction = async (path, value, kind = 'write') => {
|
|
689
|
+
const before = await readText(path);
|
|
690
|
+
if (before !== value)
|
|
691
|
+
actions.push({
|
|
692
|
+
path: portable(root, path),
|
|
693
|
+
absolutePath: path,
|
|
694
|
+
before,
|
|
695
|
+
after: value,
|
|
696
|
+
kind,
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const aiManifest = await readJson(resolve(packageRoot, 'ai/manifest.json'));
|
|
701
|
+
const projectKerfVersion = await installedVersion(
|
|
702
|
+
selected.directory,
|
|
703
|
+
'kerfjs',
|
|
704
|
+
);
|
|
705
|
+
knownResolutionIds.add('kerfjs-version');
|
|
706
|
+
if (projectKerfVersion && projectKerfVersion !== setupVersion) {
|
|
707
|
+
activeConflictIds.add('kerfjs-version');
|
|
708
|
+
if (!resolutions['kerfjs-version'])
|
|
709
|
+
conflicts.push({
|
|
710
|
+
id: 'kerfjs-version',
|
|
711
|
+
message: `running setup ${setupVersion} differs from installed kerfjs ${projectKerfVersion}; rerun with kerfjs@${projectKerfVersion}, or choose keep to retain project declarations or kerf to update them`,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
for (const file of aiManifest.files) {
|
|
715
|
+
if (
|
|
716
|
+
file.name === 'cursorrules' &&
|
|
717
|
+
!(await exists(resolve(root, '.cursor'))) &&
|
|
718
|
+
!(await exists(resolve(root, '.cursorrules')))
|
|
719
|
+
)
|
|
720
|
+
continue;
|
|
721
|
+
const destination = resolve(root, file.dest);
|
|
722
|
+
const bundled = await readFile(resolve(packageRoot, file.bundle), 'utf8');
|
|
723
|
+
const merged = mergeCanonical(
|
|
724
|
+
await readText(destination),
|
|
725
|
+
bundled,
|
|
726
|
+
file.history ?? {},
|
|
727
|
+
file.version,
|
|
728
|
+
);
|
|
729
|
+
knownResolutionIds.add(file.dest);
|
|
730
|
+
if (merged.conflict) {
|
|
731
|
+
activeConflictIds.add(file.dest);
|
|
732
|
+
if (!resolutions[file.dest])
|
|
733
|
+
conflicts.push({ id: file.dest, message: merged.conflict });
|
|
734
|
+
else if (resolutions[file.dest] === 'kerf') {
|
|
735
|
+
const local = splitCanonical((await readText(destination)) ?? '');
|
|
736
|
+
const replacement = local.error ? bundled : bundled + local.append;
|
|
737
|
+
await addAction(destination, replacement, 'canonical');
|
|
738
|
+
managedAfter[file.dest] = { sha256: sha256(bundled) };
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
await addAction(destination, merged.value, 'canonical');
|
|
742
|
+
managedAfter[file.dest] = {
|
|
743
|
+
sha256: sha256(splitCanonical(merged.value).canonical),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const packagePath = resolve(selected.directory, 'package.json');
|
|
749
|
+
const nextManifest = JSON.parse(JSON.stringify(selected.manifest));
|
|
750
|
+
nextManifest.scripts ??= {};
|
|
751
|
+
nextManifest.devDependencies ??= {};
|
|
752
|
+
nextManifest.dependencies ??= {};
|
|
753
|
+
const workspaceDoctorArgs =
|
|
754
|
+
selected.directory === root
|
|
755
|
+
? ''
|
|
756
|
+
: ` --root ${portable(selected.directory, root)} --package ${nextManifest.name}`;
|
|
757
|
+
addMapValues(
|
|
758
|
+
nextManifest.scripts,
|
|
759
|
+
mode === 'ui'
|
|
760
|
+
? {
|
|
761
|
+
'kerf:check': 'tsc --noEmit && eslint .',
|
|
762
|
+
'kerf:doctor': `kerf-ui-doctor --full${workspaceDoctorArgs}`,
|
|
763
|
+
'kerf:doctor:changed': `kerf-ui-doctor --changed${workspaceDoctorArgs}`,
|
|
764
|
+
'catalog:generate': 'kerf-component-catalog --write',
|
|
765
|
+
'catalog:check': 'kerf-component-catalog --check',
|
|
766
|
+
}
|
|
767
|
+
: { 'kerf:check': 'tsc --noEmit && eslint .' },
|
|
768
|
+
'package.json#scripts',
|
|
769
|
+
context,
|
|
770
|
+
);
|
|
771
|
+
addMapValues(
|
|
772
|
+
nextManifest.devDependencies,
|
|
773
|
+
{
|
|
774
|
+
'eslint-plugin-kerfjs': setupVersion,
|
|
775
|
+
'@typescript-eslint/parser': '^8.0.0',
|
|
776
|
+
eslint: '^9.0.0',
|
|
777
|
+
typescript: '^5.0.0 || ^6.0.0',
|
|
778
|
+
...(mode === 'ui' ? { 'create-kerf-component': setupVersion } : {}),
|
|
779
|
+
},
|
|
780
|
+
'package.json#devDependencies',
|
|
781
|
+
context,
|
|
782
|
+
);
|
|
783
|
+
const reconcileDependency = (name) => {
|
|
784
|
+
const section =
|
|
785
|
+
['dependencies', 'devDependencies', 'peerDependencies'].find(
|
|
786
|
+
(candidate) => name in (nextManifest[candidate] ?? {}),
|
|
787
|
+
) ?? 'dependencies';
|
|
788
|
+
nextManifest[section] ??= {};
|
|
789
|
+
const id = `package.json#${section}.${name}`;
|
|
790
|
+
if (
|
|
791
|
+
allowsVersion(nextManifest[section][name], setupVersion) &&
|
|
792
|
+
managedBefore[id]?.sha256 !== hashValue(nextManifest[section][name])
|
|
793
|
+
) {
|
|
794
|
+
knownResolutionIds.add(id);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const dependencyContext =
|
|
798
|
+
name === 'kerfjs' && resolutions['kerfjs-version']
|
|
799
|
+
? {
|
|
800
|
+
...context,
|
|
801
|
+
resolutions: {
|
|
802
|
+
...resolutions,
|
|
803
|
+
[id]: resolutions['kerfjs-version'],
|
|
804
|
+
},
|
|
805
|
+
}
|
|
806
|
+
: context;
|
|
807
|
+
reconcileValue({
|
|
808
|
+
target: nextManifest[section],
|
|
809
|
+
key: name,
|
|
810
|
+
value: setupVersion,
|
|
811
|
+
id,
|
|
812
|
+
...dependencyContext,
|
|
813
|
+
});
|
|
814
|
+
};
|
|
815
|
+
reconcileDependency('kerfjs');
|
|
816
|
+
if (mode === 'ui') reconcileDependency('@kerfjs/ui');
|
|
817
|
+
if (mode === 'ui') {
|
|
818
|
+
reconcileValue({
|
|
819
|
+
target: nextManifest,
|
|
820
|
+
key: 'kerfComponentCatalog',
|
|
821
|
+
value: {
|
|
822
|
+
source: './kerf.components.json',
|
|
823
|
+
output: './component-catalog-v2.json',
|
|
824
|
+
},
|
|
825
|
+
id: 'package.json#kerfComponentCatalog',
|
|
826
|
+
...context,
|
|
827
|
+
});
|
|
828
|
+
for (const [key, configuredPath] of Object.entries(
|
|
829
|
+
nextManifest.kerfComponentCatalog,
|
|
830
|
+
))
|
|
831
|
+
if (
|
|
832
|
+
typeof configuredPath !== 'string' ||
|
|
833
|
+
!contained(
|
|
834
|
+
selected.directory,
|
|
835
|
+
resolve(selected.directory, configuredPath),
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
throw new Error(
|
|
839
|
+
`kerfComponentCatalog.${key} must stay inside the selected package.`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
await addAction(packagePath, stableJson(nextManifest), 'json');
|
|
843
|
+
|
|
844
|
+
const tsconfigPath = resolve(selected.directory, 'tsconfig.json');
|
|
845
|
+
const tsconfigSource = await readText(tsconfigPath);
|
|
846
|
+
const tsconfigRecommendations = {
|
|
847
|
+
target: 'ES2022',
|
|
848
|
+
module: 'ESNext',
|
|
849
|
+
moduleResolution: 'Bundler',
|
|
850
|
+
strict: true,
|
|
851
|
+
jsx: 'react-jsx',
|
|
852
|
+
jsxImportSource: 'kerfjs',
|
|
853
|
+
};
|
|
854
|
+
if (tsconfigSource === null) {
|
|
855
|
+
const tsconfig = { compilerOptions: {}, include: ['src'] };
|
|
856
|
+
addMapValues(
|
|
857
|
+
tsconfig.compilerOptions,
|
|
858
|
+
tsconfigRecommendations,
|
|
859
|
+
'tsconfig.json#compilerOptions',
|
|
860
|
+
context,
|
|
861
|
+
);
|
|
862
|
+
await addAction(tsconfigPath, stableJson(tsconfig), 'json');
|
|
863
|
+
} else {
|
|
864
|
+
const parsed = parseJsoncDocument(tsconfigSource, 'tsconfig.json');
|
|
865
|
+
const rootId = 'tsconfig.json';
|
|
866
|
+
if (parsed.root.type !== 'object') {
|
|
867
|
+
knownResolutionIds.add(rootId);
|
|
868
|
+
activeConflictIds.add(rootId);
|
|
869
|
+
if (!resolutions[rootId])
|
|
870
|
+
conflicts.push({ id: rootId, message: 'root value must be an object' });
|
|
871
|
+
else if (resolutions[rootId] === 'kerf') {
|
|
872
|
+
const replacement = {
|
|
873
|
+
compilerOptions: { ...tsconfigRecommendations },
|
|
874
|
+
include: ['src'],
|
|
875
|
+
};
|
|
876
|
+
for (const [key, value] of Object.entries(tsconfigRecommendations))
|
|
877
|
+
managedAfter[`tsconfig.json#compilerOptions.${key}`] = {
|
|
878
|
+
sha256: hashValue(value),
|
|
879
|
+
};
|
|
880
|
+
await addAction(tsconfigPath, stableJson(replacement), 'json');
|
|
881
|
+
}
|
|
882
|
+
} else {
|
|
883
|
+
const edits = [];
|
|
884
|
+
const rootInsertions = [];
|
|
885
|
+
const compilerProperty = parsed.root.properties.get('compilerOptions');
|
|
886
|
+
if (compilerProperty && compilerProperty.valueNode.type !== 'object') {
|
|
887
|
+
const id = 'tsconfig.json#compilerOptions';
|
|
888
|
+
knownResolutionIds.add(id);
|
|
889
|
+
activeConflictIds.add(id);
|
|
890
|
+
if (!resolutions[id])
|
|
891
|
+
conflicts.push({ id, message: 'existing value must be an object' });
|
|
892
|
+
else if (resolutions[id] === 'kerf') {
|
|
893
|
+
edits.push(
|
|
894
|
+
jsoncValueEdit(
|
|
895
|
+
compilerProperty.valueNode,
|
|
896
|
+
tsconfigRecommendations,
|
|
897
|
+
tsconfigSource,
|
|
898
|
+
),
|
|
899
|
+
);
|
|
900
|
+
for (const [key, value] of Object.entries(tsconfigRecommendations))
|
|
901
|
+
managedAfter[`tsconfig.json#compilerOptions.${key}`] = {
|
|
902
|
+
sha256: hashValue(value),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
const compilerOptions = compilerProperty?.valueNode.value ?? {};
|
|
907
|
+
const before = { ...compilerOptions };
|
|
908
|
+
addMapValues(
|
|
909
|
+
compilerOptions,
|
|
910
|
+
tsconfigRecommendations,
|
|
911
|
+
'tsconfig.json#compilerOptions',
|
|
912
|
+
context,
|
|
913
|
+
);
|
|
914
|
+
if (compilerProperty) {
|
|
915
|
+
const missing = [];
|
|
916
|
+
for (const [key, value] of Object.entries(compilerOptions)) {
|
|
917
|
+
const property = compilerProperty.valueNode.properties.get(key);
|
|
918
|
+
if (!property) missing.push([key, value]);
|
|
919
|
+
else if (before[key] !== value)
|
|
920
|
+
edits.push(
|
|
921
|
+
jsoncValueEdit(property.valueNode, value, tsconfigSource),
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
edits.push(
|
|
925
|
+
...jsoncInsertProperties(
|
|
926
|
+
tsconfigSource,
|
|
927
|
+
compilerProperty.valueNode,
|
|
928
|
+
missing,
|
|
929
|
+
),
|
|
930
|
+
);
|
|
931
|
+
} else {
|
|
932
|
+
rootInsertions.push(['compilerOptions', compilerOptions]);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!parsed.root.properties.has('include'))
|
|
936
|
+
rootInsertions.push(['include', ['src']]);
|
|
937
|
+
edits.push(
|
|
938
|
+
...jsoncInsertProperties(tsconfigSource, parsed.root, rootInsertions),
|
|
939
|
+
);
|
|
940
|
+
await addAction(
|
|
941
|
+
tsconfigPath,
|
|
942
|
+
applyJsoncEdits(tsconfigSource, edits),
|
|
943
|
+
'jsonc',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const eslintNames = [
|
|
949
|
+
'eslint.config.js',
|
|
950
|
+
'eslint.config.mjs',
|
|
951
|
+
'eslint.config.cjs',
|
|
952
|
+
];
|
|
953
|
+
let eslintPath;
|
|
954
|
+
for (const name of eslintNames)
|
|
955
|
+
if (await exists(resolve(selected.directory, name))) {
|
|
956
|
+
eslintPath = resolve(selected.directory, name);
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
if (!eslintPath) {
|
|
960
|
+
const generatedPath = resolve(selected.directory, 'eslint.config.mjs');
|
|
961
|
+
const generated = eslintTemplate(mode);
|
|
962
|
+
await addAction(generatedPath, generated);
|
|
963
|
+
managedAfter[portable(root, generatedPath)] = { sha256: sha256(generated) };
|
|
964
|
+
} else {
|
|
965
|
+
const source = await readText(eslintPath);
|
|
966
|
+
const id = portable(root, eslintPath);
|
|
967
|
+
knownResolutionIds.add(id);
|
|
968
|
+
const commonjs =
|
|
969
|
+
eslintPath.endsWith('.cjs') ||
|
|
970
|
+
(eslintPath.endsWith('.js') && nextManifest.type !== 'module');
|
|
971
|
+
const template = eslintTemplate(mode, commonjs ? 'commonjs' : 'module');
|
|
972
|
+
const wasManaged = managedBefore[id]?.sha256 === sha256(source);
|
|
973
|
+
const wasEditedManaged = Boolean(managedBefore[id]) && !wasManaged;
|
|
974
|
+
const merged = wasManaged
|
|
975
|
+
? { value: template }
|
|
976
|
+
: wasEditedManaged
|
|
977
|
+
? { conflict: 'setup-authored config was customized' }
|
|
978
|
+
: commonjs
|
|
979
|
+
? {
|
|
980
|
+
conflict:
|
|
981
|
+
'CommonJS config requires an explicit Kerf preset merge',
|
|
982
|
+
}
|
|
983
|
+
: mergeEslintConfig(source, mode);
|
|
984
|
+
if (merged.conflict) {
|
|
985
|
+
activeConflictIds.add(id);
|
|
986
|
+
if (!resolutions[id])
|
|
987
|
+
conflicts.push({
|
|
988
|
+
id,
|
|
989
|
+
message: `${merged.conflict}; choose keep or kerf`,
|
|
990
|
+
});
|
|
991
|
+
else if (resolutions[id] === 'kerf') {
|
|
992
|
+
const generated = template;
|
|
993
|
+
await addAction(eslintPath, generated);
|
|
994
|
+
managedAfter[id] = { sha256: sha256(generated) };
|
|
995
|
+
}
|
|
996
|
+
} else {
|
|
997
|
+
await addAction(eslintPath, merged.value);
|
|
998
|
+
if (wasManaged) managedAfter[id] = { sha256: sha256(merged.value) };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (mode === 'ui') {
|
|
1002
|
+
if (!nextManifest.name)
|
|
1003
|
+
throw new Error(
|
|
1004
|
+
'UI setup requires package.json name for catalog identity.',
|
|
1005
|
+
);
|
|
1006
|
+
const profilePath = resolve(root, '.kerf-ui-profile.json');
|
|
1007
|
+
const currentProfileSource = await readText(profilePath);
|
|
1008
|
+
const currentProfile =
|
|
1009
|
+
currentProfileSource === null
|
|
1010
|
+
? undefined
|
|
1011
|
+
: JSON.parse(currentProfileSource);
|
|
1012
|
+
const catalogRelative = portable(
|
|
1013
|
+
root,
|
|
1014
|
+
resolve(selected.directory, 'component-catalog-v2.json'),
|
|
1015
|
+
);
|
|
1016
|
+
const profile = mergeProfile(
|
|
1017
|
+
currentProfile,
|
|
1018
|
+
profileTemplate(
|
|
1019
|
+
nextManifest.name,
|
|
1020
|
+
catalogRelative.startsWith('.')
|
|
1021
|
+
? catalogRelative
|
|
1022
|
+
: `./${catalogRelative}`,
|
|
1023
|
+
),
|
|
1024
|
+
context,
|
|
1025
|
+
);
|
|
1026
|
+
await addAction(profilePath, stableJson(profile), 'json');
|
|
1027
|
+
const metadataPath = resolve(selected.directory, 'kerf.components.json');
|
|
1028
|
+
if (!(await exists(metadataPath)))
|
|
1029
|
+
await addAction(metadataPath, stableJson(metadataTemplate()), 'json');
|
|
1030
|
+
generatedPaths.push(
|
|
1031
|
+
resolve(selected.directory, nextManifest.kerfComponentCatalog.output),
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
for (const [id, resolution] of Object.entries(requestedResolutions)) {
|
|
1036
|
+
if (!['keep', 'kerf'].includes(resolution))
|
|
1037
|
+
throw new Error(`Resolution ${id} must be keep or kerf.`);
|
|
1038
|
+
if (!knownResolutionIds.has(id))
|
|
1039
|
+
throw new Error(`Unknown resolution id ${id}.`);
|
|
1040
|
+
if (!activeConflictIds.has(id))
|
|
1041
|
+
throw new Error(
|
|
1042
|
+
`Stale resolution id ${id}; the value no longer conflicts.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
const manager = await packageManagerInfo(root);
|
|
1046
|
+
const state = {
|
|
1047
|
+
schemaVersion: 2,
|
|
1048
|
+
setupVersion,
|
|
1049
|
+
packageManager: manager.name,
|
|
1050
|
+
...(manager.version ? { packageManagerVersion: manager.version } : {}),
|
|
1051
|
+
...(manager.variant ? { packageManagerVariant: manager.variant } : {}),
|
|
1052
|
+
packages: {
|
|
1053
|
+
...(priorState.packages ??
|
|
1054
|
+
(priorState.packagePath
|
|
1055
|
+
? {
|
|
1056
|
+
[priorState.packagePath]: {
|
|
1057
|
+
mode: priorState.mode,
|
|
1058
|
+
package: priorState.package,
|
|
1059
|
+
packagePath: priorState.packagePath,
|
|
1060
|
+
managed: priorState.managed ?? {},
|
|
1061
|
+
resolutions: priorState.resolutions ?? {},
|
|
1062
|
+
},
|
|
1063
|
+
}
|
|
1064
|
+
: {})),
|
|
1065
|
+
[packageKey]: {
|
|
1066
|
+
mode,
|
|
1067
|
+
package: nextManifest.name ?? packageKey,
|
|
1068
|
+
packagePath: packageKey,
|
|
1069
|
+
managed: managedAfter,
|
|
1070
|
+
resolutions: Object.fromEntries(
|
|
1071
|
+
[...activeConflictIds]
|
|
1072
|
+
.filter((id) => resolutions[id] === 'keep')
|
|
1073
|
+
.map((id) => [id, resolutions[id]]),
|
|
1074
|
+
),
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
await addAction(statePath, stableJson(state), 'state');
|
|
1079
|
+
return {
|
|
1080
|
+
schemaVersion: 1,
|
|
1081
|
+
root,
|
|
1082
|
+
packageRoot: selected.directory,
|
|
1083
|
+
packageName: nextManifest.name ?? packageKey,
|
|
1084
|
+
packageManager: state.packageManager,
|
|
1085
|
+
packageManagerVersion: manager.version,
|
|
1086
|
+
packageManagerVariant: manager.variant,
|
|
1087
|
+
mode,
|
|
1088
|
+
setupVersion,
|
|
1089
|
+
actions,
|
|
1090
|
+
conflicts,
|
|
1091
|
+
generatedPaths,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
export function formatSetupPlan(plan) {
|
|
1096
|
+
const lines = [
|
|
1097
|
+
`Kerf AI setup: ${plan.mode} package ${portable(plan.root, plan.packageRoot)}`,
|
|
1098
|
+
];
|
|
1099
|
+
let shown = 0;
|
|
1100
|
+
const render = (key, value) =>
|
|
1101
|
+
/token|secret|password|credential/i.test(key)
|
|
1102
|
+
? '<redacted>'
|
|
1103
|
+
: value === undefined
|
|
1104
|
+
? '<missing>'
|
|
1105
|
+
: redactText(JSON.stringify(value));
|
|
1106
|
+
const leaves = (value, prefix = '$', output = new Map()) => {
|
|
1107
|
+
if (value && typeof value === 'object') {
|
|
1108
|
+
for (const [key, child] of Object.entries(value))
|
|
1109
|
+
leaves(
|
|
1110
|
+
child,
|
|
1111
|
+
Array.isArray(value) ? `${prefix}[${key}]` : `${prefix}.${key}`,
|
|
1112
|
+
output,
|
|
1113
|
+
);
|
|
1114
|
+
} else output.set(prefix, value);
|
|
1115
|
+
return output;
|
|
1116
|
+
};
|
|
1117
|
+
for (const action of plan.actions) {
|
|
1118
|
+
lines.push(
|
|
1119
|
+
`${action.before === null ? 'CREATE' : 'UPDATE'} ${action.path}`,
|
|
1120
|
+
);
|
|
1121
|
+
if (shown >= 120) continue;
|
|
1122
|
+
if (
|
|
1123
|
+
action.kind === 'json' ||
|
|
1124
|
+
action.kind === 'jsonc' ||
|
|
1125
|
+
action.kind === 'state'
|
|
1126
|
+
) {
|
|
1127
|
+
const before = leaves(
|
|
1128
|
+
action.before === null
|
|
1129
|
+
? {}
|
|
1130
|
+
: action.kind === 'jsonc'
|
|
1131
|
+
? parseJsoncDocument(action.before, action.path).value
|
|
1132
|
+
: JSON.parse(action.before),
|
|
1133
|
+
);
|
|
1134
|
+
const after = leaves(
|
|
1135
|
+
action.kind === 'jsonc'
|
|
1136
|
+
? parseJsoncDocument(action.after, action.path).value
|
|
1137
|
+
: JSON.parse(action.after),
|
|
1138
|
+
);
|
|
1139
|
+
const keys = new Set([...before.keys(), ...after.keys()]);
|
|
1140
|
+
for (const key of keys) {
|
|
1141
|
+
if (action.kind === 'state' && key.includes('.managed.')) continue;
|
|
1142
|
+
if (JSON.stringify(before.get(key)) === JSON.stringify(after.get(key)))
|
|
1143
|
+
continue;
|
|
1144
|
+
if (shown++ >= 120) break;
|
|
1145
|
+
lines.push(
|
|
1146
|
+
` ${key}: ${render(key, before.get(key))} -> ${render(key, after.get(key))}`,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
} else {
|
|
1150
|
+
const beforeLines = action.before?.split('\n').length ?? 0;
|
|
1151
|
+
const afterLines = action.after.split('\n').length;
|
|
1152
|
+
lines.push(` content: ${beforeLines} line(s) -> ${afterLines} line(s)`);
|
|
1153
|
+
shown += 1;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (shown >= 120) lines.push(' … additional changes omitted');
|
|
1157
|
+
for (const conflict of plan.conflicts)
|
|
1158
|
+
lines.push(
|
|
1159
|
+
`CONFLICT ${conflict.id}: ${conflict.message} (use --resolve ${conflict.id}=keep|kerf)`,
|
|
1160
|
+
);
|
|
1161
|
+
if (!plan.actions.length && !plan.conflicts.length) lines.push('No changes.');
|
|
1162
|
+
return lines.join('\n');
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export async function applyKerfSetup(
|
|
1166
|
+
plan,
|
|
1167
|
+
{ install = true, offline = false, runner = execFileAsync } = {},
|
|
1168
|
+
) {
|
|
1169
|
+
if (plan.conflicts.length)
|
|
1170
|
+
throw new Error('Resolve setup conflicts before applying changes.');
|
|
1171
|
+
const written = [];
|
|
1172
|
+
const temporaryPaths = [];
|
|
1173
|
+
const createdDirectories = [];
|
|
1174
|
+
const lockPath = resolve(plan.root, '.kerf-ai-setup.lock');
|
|
1175
|
+
try {
|
|
1176
|
+
await mkdir(lockPath);
|
|
1177
|
+
} catch {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
'Another Kerf setup is already applying in this workspace.',
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
const lockfiles = [
|
|
1183
|
+
...new Set(
|
|
1184
|
+
[plan.root, plan.packageRoot].flatMap((directory) => [
|
|
1185
|
+
resolve(directory, 'package-lock.json'),
|
|
1186
|
+
resolve(directory, 'pnpm-lock.yaml'),
|
|
1187
|
+
resolve(directory, 'yarn.lock'),
|
|
1188
|
+
]),
|
|
1189
|
+
),
|
|
1190
|
+
];
|
|
1191
|
+
const lockfileSnapshots = new Map();
|
|
1192
|
+
const generatedSnapshots = new Map();
|
|
1193
|
+
let actualPackageRoot;
|
|
1194
|
+
const isSymlink = async (path) => {
|
|
1195
|
+
try {
|
|
1196
|
+
return (await lstat(path)).isSymbolicLink();
|
|
1197
|
+
} catch {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
const validateGeneratedPath = async (
|
|
1202
|
+
path,
|
|
1203
|
+
{ allowLeafSymlink = false } = {},
|
|
1204
|
+
) => {
|
|
1205
|
+
if (!contained(plan.packageRoot, path))
|
|
1206
|
+
throw new Error('A generated catalog path escapes the selected package.');
|
|
1207
|
+
let ancestor = dirname(path);
|
|
1208
|
+
while (!(await exists(ancestor))) ancestor = dirname(ancestor);
|
|
1209
|
+
if (!contained(actualPackageRoot, await realpath(ancestor)))
|
|
1210
|
+
throw new Error(
|
|
1211
|
+
'A generated catalog path traverses a symlink outside the selected package.',
|
|
1212
|
+
);
|
|
1213
|
+
if (!allowLeafSymlink && (await isSymlink(path)))
|
|
1214
|
+
throw new Error('Setup refuses a symlinked generated catalog output.');
|
|
1215
|
+
};
|
|
1216
|
+
try {
|
|
1217
|
+
const actualRoot = await realpath(plan.root);
|
|
1218
|
+
if (
|
|
1219
|
+
!contained(plan.root, plan.packageRoot) ||
|
|
1220
|
+
!contained(actualRoot, await realpath(plan.packageRoot))
|
|
1221
|
+
)
|
|
1222
|
+
throw new Error('Selected package escapes the setup workspace.');
|
|
1223
|
+
actualPackageRoot = await realpath(plan.packageRoot);
|
|
1224
|
+
for (const path of lockfiles) {
|
|
1225
|
+
if (!contained(plan.root, path))
|
|
1226
|
+
throw new Error('A lockfile path escapes the setup workspace.');
|
|
1227
|
+
lockfileSnapshots.set(path, await readText(path));
|
|
1228
|
+
}
|
|
1229
|
+
for (const path of plan.generatedPaths ?? []) {
|
|
1230
|
+
await validateGeneratedPath(path);
|
|
1231
|
+
generatedSnapshots.set(path, await readText(path));
|
|
1232
|
+
}
|
|
1233
|
+
for (const action of plan.actions) {
|
|
1234
|
+
const expectedPath = resolve(plan.root, action.path);
|
|
1235
|
+
if (
|
|
1236
|
+
action.absolutePath !== expectedPath ||
|
|
1237
|
+
!contained(plan.root, action.absolutePath)
|
|
1238
|
+
)
|
|
1239
|
+
throw new Error(`Setup action escapes the workspace: ${action.path}.`);
|
|
1240
|
+
let ancestor = dirname(action.absolutePath);
|
|
1241
|
+
while (!(await exists(ancestor))) ancestor = dirname(ancestor);
|
|
1242
|
+
if (!contained(actualRoot, await realpath(ancestor)))
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
`Setup action traverses a symlink outside the workspace: ${action.path}.`,
|
|
1245
|
+
);
|
|
1246
|
+
if (
|
|
1247
|
+
(await exists(action.absolutePath)) &&
|
|
1248
|
+
(await lstat(action.absolutePath)).isSymbolicLink()
|
|
1249
|
+
)
|
|
1250
|
+
throw new Error(`Setup refuses to replace symlink ${action.path}.`);
|
|
1251
|
+
if ((await readText(action.absolutePath)) !== action.before)
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
`Setup plan is stale for ${action.path}; rerun the dry-run.`,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
for (const action of plan.actions) {
|
|
1257
|
+
let directory = dirname(action.absolutePath);
|
|
1258
|
+
const missing = [];
|
|
1259
|
+
while (!(await exists(directory))) {
|
|
1260
|
+
missing.push(directory);
|
|
1261
|
+
directory = dirname(directory);
|
|
1262
|
+
}
|
|
1263
|
+
await mkdir(dirname(action.absolutePath), { recursive: true });
|
|
1264
|
+
createdDirectories.push(...missing);
|
|
1265
|
+
if (!contained(actualRoot, await realpath(dirname(action.absolutePath))))
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`Setup action traverses a symlink outside the workspace: ${action.path}.`,
|
|
1268
|
+
);
|
|
1269
|
+
const temporary = `${action.absolutePath}.kerf-setup-${process.pid}-${randomUUID()}.tmp`;
|
|
1270
|
+
temporaryPaths.push(temporary);
|
|
1271
|
+
await writeFile(temporary, action.after);
|
|
1272
|
+
if ((await readText(action.absolutePath)) !== action.before)
|
|
1273
|
+
throw new Error(
|
|
1274
|
+
`Setup plan became stale for ${action.path}; rerun the dry-run.`,
|
|
1275
|
+
);
|
|
1276
|
+
await rename(temporary, action.absolutePath);
|
|
1277
|
+
written.push(action);
|
|
1278
|
+
}
|
|
1279
|
+
if (install) {
|
|
1280
|
+
if (plan.packageManager === 'yarn' && !plan.packageManagerVariant)
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
'Cannot install with Yarn until Classic or Berry is identified.',
|
|
1283
|
+
);
|
|
1284
|
+
const berry = plan.packageManagerVariant === 'berry';
|
|
1285
|
+
const offlineArgs = offline
|
|
1286
|
+
? berry
|
|
1287
|
+
? ['--immutable-cache']
|
|
1288
|
+
: ['--offline']
|
|
1289
|
+
: [];
|
|
1290
|
+
const workspace = plan.packageRoot !== plan.root;
|
|
1291
|
+
let args;
|
|
1292
|
+
let installCwd = plan.root;
|
|
1293
|
+
if (!workspace || berry) args = ['install', ...offlineArgs];
|
|
1294
|
+
else if (plan.packageManager === 'npm')
|
|
1295
|
+
args = ['install', '--workspace', plan.packageName, ...offlineArgs];
|
|
1296
|
+
else if (plan.packageManager === 'pnpm')
|
|
1297
|
+
args = ['--filter', plan.packageName, 'install', ...offlineArgs];
|
|
1298
|
+
else {
|
|
1299
|
+
args = ['install', '--focus', ...offlineArgs];
|
|
1300
|
+
installCwd = plan.packageRoot;
|
|
1301
|
+
}
|
|
1302
|
+
await runner(plan.packageManager, args, {
|
|
1303
|
+
cwd: installCwd,
|
|
1304
|
+
...(offline && berry
|
|
1305
|
+
? { env: { ...process.env, YARN_ENABLE_NETWORK: '0' } }
|
|
1306
|
+
: {}),
|
|
1307
|
+
});
|
|
1308
|
+
if (plan.mode === 'ui') {
|
|
1309
|
+
for (const path of plan.generatedPaths ?? [])
|
|
1310
|
+
await validateGeneratedPath(path);
|
|
1311
|
+
await runner(plan.packageManager, ['run', 'catalog:generate'], {
|
|
1312
|
+
cwd: plan.packageRoot,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
for (const action of written.reverse()) {
|
|
1318
|
+
if (action.before === null)
|
|
1319
|
+
await rm(action.absolutePath, { force: true });
|
|
1320
|
+
else await writeFile(action.absolutePath, action.before);
|
|
1321
|
+
}
|
|
1322
|
+
for (const [path, source] of lockfileSnapshots) {
|
|
1323
|
+
if (source === null) await rm(path, { force: true });
|
|
1324
|
+
else await writeFile(path, source);
|
|
1325
|
+
}
|
|
1326
|
+
for (const [path, source] of generatedSnapshots) {
|
|
1327
|
+
await validateGeneratedPath(path, { allowLeafSymlink: true });
|
|
1328
|
+
if (await isSymlink(path)) await rm(path, { force: true });
|
|
1329
|
+
if (source === null) await rm(path, { force: true });
|
|
1330
|
+
else await writeFile(path, source);
|
|
1331
|
+
}
|
|
1332
|
+
throw error;
|
|
1333
|
+
} finally {
|
|
1334
|
+
for (const path of temporaryPaths) await rm(path, { force: true });
|
|
1335
|
+
for (const path of [...new Set(createdDirectories)].sort(
|
|
1336
|
+
(left, right) => right.length - left.length,
|
|
1337
|
+
)) {
|
|
1338
|
+
try {
|
|
1339
|
+
await rmdir(path);
|
|
1340
|
+
} catch {
|
|
1341
|
+
// Preserve non-empty directories, including directories used by other tools.
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
1345
|
+
}
|
|
1346
|
+
return { changed: plan.actions.map(({ path }) => path) };
|
|
1347
|
+
}
|