@mfeflow/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adopt/detect.d.ts +11 -0
- package/dist/adopt/disk.d.ts +20 -0
- package/dist/adopt/evaluate.d.ts +70 -0
- package/dist/adopt/federation.d.ts +20 -0
- package/dist/adopt/index.d.ts +4 -0
- package/dist/adopt/plan.d.ts +39 -0
- package/dist/adopt/types.d.ts +73 -0
- package/dist/adopt/workspace.d.ts +16 -0
- package/dist/app-file.d.ts +20 -0
- package/dist/apply.d.ts +2 -0
- package/dist/errors.d.ts +42 -9
- package/dist/features/load.d.ts +2 -0
- package/dist/fs.d.ts +5 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2248 -426
- package/dist/plan.d.ts +3 -0
- package/dist/transforms/add-env-key.d.ts +2 -0
- package/dist/transforms/ast.d.ts +1 -0
- package/dist/upgrade.d.ts +109 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { DOCS_URL, FEATURE_CONDITION, FEATURE_NAME, REGISTRY_NAMESPACE, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isLoopbackHttp, isMfeflowProject, isPrivateHost, isRegistryProtocol, isSafeRelativePath, redactUrl, redactUrlsIn, registryIndexUrl, registryTemplateProblem, validateFeatureManifest, validateFeatureRegistryIndex, validateMfeflowProject } from "@mfeflow/contracts";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import promises from "node:fs/promises";
|
|
2
|
+
import { DOCS_URL, EXPOSE, FEATURE_CONDITION, FEATURE_NAME, MFE_NAME, REGISTRY_NAMESPACE, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isLoopbackHttp, isMfeflowProject, isPrivateHost, isRegistryProtocol, isSafeRelativePath, redactUrl, redactUrlsIn, registryIndexUrl, registryTemplateProblem, validateFeatureManifest, validateFeatureRegistryIndex, validateMfeflowProject } from "@mfeflow/contracts";
|
|
5
3
|
import node_path from "node:path";
|
|
4
|
+
import promises from "node:fs/promises";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
6
|
import { formatPatch, structuredPatch } from "diff";
|
|
7
|
-
import { isMap, isSeq, parseDocument, stringify } from "yaml";
|
|
8
|
-
import { valid, validRange } from "semver";
|
|
7
|
+
import { isMap, isSeq, parse as external_yaml_parse, parseDocument, stringify } from "yaml";
|
|
8
|
+
import semver, { valid, validRange } from "semver";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
9
10
|
import { Eta } from "eta";
|
|
10
|
-
import { isArrayExpression, isArrowFunctionExpression, isBooleanLiteral, isCallExpression, isFunctionDeclaration, isFunctionExpression, isIdentifier, isImportDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isNode, isNullLiteral, isNumericLiteral, isObjectExpression, isObjectProperty, isParenthesizedExpression, isReturnStatement, isStringLiteral, isTSAsExpression, isTSNonNullExpression, isTSSatisfiesExpression, isUnaryExpression, isValidIdentifier, isVariableDeclaration } from "@babel/types";
|
|
11
|
+
import { isArrayExpression, isArrowFunctionExpression, isAssignmentExpression, isAwaitExpression, isBinaryExpression, isBooleanLiteral, isCallExpression, isClassDeclaration, isConditionalExpression, isExportDefaultDeclaration, isExportNamedDeclaration, isExportSpecifier, isExpressionStatement, isFunctionDeclaration, isFunctionExpression, isIdentifier, isImportDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, isJSXElement, isJSXFragment, isJSXIdentifier, isLogicalExpression, isMemberExpression, isMetaProperty, isNewExpression, isNode, isNullLiteral, isNumericLiteral, isObjectExpression, isObjectPattern, isObjectProperty, isParenthesizedExpression, isReturnStatement, isStringLiteral, isTSAsExpression, isTSNonNullExpression, isTSSatisfiesExpression, isTSTypeAssertion, isTemplateLiteral, isUnaryExpression, isValidIdentifier, isVariableDeclaration } from "@babel/types";
|
|
11
12
|
import { parse, parseExpression } from "@babel/parser";
|
|
12
13
|
import node_os from "node:os";
|
|
13
14
|
const ERROR_CATALOGUE = {
|
|
@@ -60,8 +61,8 @@ const ERROR_CATALOGUE = {
|
|
|
60
61
|
fix: 'Restore the folder, or remove the app from "apps" in mfeflow.json.'
|
|
61
62
|
},
|
|
62
63
|
E113: {
|
|
63
|
-
title: '
|
|
64
|
-
fix: `Give the app a valid
|
|
64
|
+
title: 'mfeflow.app.json is missing or invalid',
|
|
65
|
+
fix: `Give the app a valid mfeflow.app.json (name, owner, shells, routes, exposes). The schema is at ${SCHEMA_URLS.mfe}.`
|
|
65
66
|
},
|
|
66
67
|
E114: {
|
|
67
68
|
title: 'A registry entry is missing or invalid',
|
|
@@ -72,8 +73,8 @@ const ERROR_CATALOGUE = {
|
|
|
72
73
|
fix: 'Give one of them another port in its rsbuild config and in "apps" in mfeflow.json.'
|
|
73
74
|
},
|
|
74
75
|
E116: {
|
|
75
|
-
title: 'Two remotes claim routes that overlap, or an
|
|
76
|
-
fix: 'Give each route to one remote (a wildcard such as /checkout/* also covers /checkout/pay), and keep the name in
|
|
76
|
+
title: 'Two remotes claim routes that overlap, or an app file disagrees with mfeflow.json',
|
|
77
|
+
fix: 'Give each route to one remote (a wildcard such as /checkout/* also covers /checkout/pay), and keep the name in mfeflow.app.json equal to the app name.'
|
|
77
78
|
},
|
|
78
79
|
E117: {
|
|
79
80
|
title: 'A copied file changed since mfeflow wrote it',
|
|
@@ -95,6 +96,18 @@ const ERROR_CATALOGUE = {
|
|
|
95
96
|
title: 'A server env key is not listed in turbo.json',
|
|
96
97
|
fix: 'Add the key to "globalEnv" in turbo.json. Turborepo hides unlisted variables from tasks and replays a cached build without running env.ts, so a missing value would not fail the build.'
|
|
97
98
|
},
|
|
99
|
+
E122: {
|
|
100
|
+
title: 'An app has both mfeflow.app.json and the old mfe.json',
|
|
101
|
+
fix: `Keep mfeflow.app.json and delete mfe.json. Move any field you still need into mfeflow.app.json first. See ${DOCS_URL}/reference/mfeflow-app-json.`
|
|
102
|
+
},
|
|
103
|
+
E123: {
|
|
104
|
+
title: 'This folder already has an mfeflow.json',
|
|
105
|
+
fix: 'adopt only starts a project file; it never changes one. Use `mfeflow add` or `mfeflow upgrade` on this project, or run adopt in a folder that has no mfeflow.json.'
|
|
106
|
+
},
|
|
107
|
+
E124: {
|
|
108
|
+
title: 'adopt found nothing it can set up',
|
|
109
|
+
fix: 'adopt needs a Module Federation host (shell) it can read, and a literal `name` for each app. Run it in the folder that holds the shell, or a workspace root that contains it, and read `mfeflow adopt --dry-run` for what was skipped.'
|
|
110
|
+
},
|
|
98
111
|
E201: {
|
|
99
112
|
title: 'Feature not found',
|
|
100
113
|
fix: 'List what is available with `mfeflow features`, or check the spelling.'
|
|
@@ -161,7 +174,7 @@ const ERROR_CATALOGUE = {
|
|
|
161
174
|
},
|
|
162
175
|
E302: {
|
|
163
176
|
title: 'A remote is not compatible with its shell',
|
|
164
|
-
fix: 'Align the remote and the shell on the same major of the package, or update the "platform" range in the remote\'s
|
|
177
|
+
fix: 'Align the remote and the shell on the same major of the package, or update the "platform" range in the remote\'s mfeflow.app.json.'
|
|
165
178
|
},
|
|
166
179
|
E303: {
|
|
167
180
|
title: 'A runtime plugin imports a shared module',
|
|
@@ -169,7 +182,7 @@ const ERROR_CATALOGUE = {
|
|
|
169
182
|
},
|
|
170
183
|
E304: {
|
|
171
184
|
title: 'A bundle is over its budget',
|
|
172
|
-
fix: 'Cut what the app ships on first load, or raise the budget in mfeflow.json ("budget") or
|
|
185
|
+
fix: 'Cut what the app ships on first load, or raise the budget in mfeflow.json ("budget") or mfeflow.app.json ("budgets") on purpose.'
|
|
173
186
|
},
|
|
174
187
|
E305: {
|
|
175
188
|
title: 'There is no build output to measure',
|
|
@@ -179,6 +192,10 @@ const ERROR_CATALOGUE = {
|
|
|
179
192
|
title: 'A dev server could not start',
|
|
180
193
|
fix: "Free the port or change it in mfeflow.json and the app's rsbuild config, then run `mfeflow dev` again."
|
|
181
194
|
},
|
|
195
|
+
E307: {
|
|
196
|
+
title: 'The shell to check against could not be read',
|
|
197
|
+
fix: 'Pass --against a registry index, a deployed shell mf-manifest.json or a local file of either. A URL must be https (http only for localhost) and under 1 MB.'
|
|
198
|
+
},
|
|
182
199
|
E401: {
|
|
183
200
|
title: 'Could not fetch from the registry',
|
|
184
201
|
fix: 'Check the URL in "registries" and your network, then try again.'
|
|
@@ -197,7 +214,7 @@ const ERROR_CATALOGUE = {
|
|
|
197
214
|
},
|
|
198
215
|
E501: {
|
|
199
216
|
title: 'A deploy failed the schema gate',
|
|
200
|
-
fix: `Fix what the message names: the remote's
|
|
217
|
+
fix: `Fix what the message names: the remote's mfeflow.app.json, its mf-manifest.json or the version. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
201
218
|
},
|
|
202
219
|
E502: {
|
|
203
220
|
title: 'A route of this remote overlaps a route of another remote',
|
|
@@ -209,7 +226,7 @@ const ERROR_CATALOGUE = {
|
|
|
209
226
|
},
|
|
210
227
|
E504: {
|
|
211
228
|
title: 'The remote is over its first-load JS budget',
|
|
212
|
-
fix: `Cut what the remote loads first, or raise budgets.initialJsKb in its
|
|
229
|
+
fix: `Cut what the remote loads first, or raise budgets.initialJsKb in its mfeflow.app.json on purpose. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
213
230
|
},
|
|
214
231
|
E505: {
|
|
215
232
|
title: 'A rollout was asked for, and a static host cannot split traffic',
|
|
@@ -250,6 +267,22 @@ const ERROR_CATALOGUE = {
|
|
|
250
267
|
E406: {
|
|
251
268
|
title: 'The registry changed a feature since you added it',
|
|
252
269
|
fix: 'Nothing was written. Look at what changed; if you accept the new content, run the command again with --accept-changed-content.'
|
|
270
|
+
},
|
|
271
|
+
E601: {
|
|
272
|
+
title: 'Cannot tell which version of mfeflow made this project',
|
|
273
|
+
fix: 'Add "cli": "<version>" to mfeflow.json (the version you created the project with), or give the shell a plain @mfeflow/runtime range in its package.json, then run the command again.'
|
|
274
|
+
},
|
|
275
|
+
E602: {
|
|
276
|
+
title: 'The project is newer than the version you are upgrading to',
|
|
277
|
+
fix: 'Upgrade only moves forward. Install a newer mfeflow, or leave the project as it is.'
|
|
278
|
+
},
|
|
279
|
+
E603: {
|
|
280
|
+
title: 'A feature migration could not be loaded or failed',
|
|
281
|
+
fix: 'Nothing was written. Update mfeflow to a build that ships the migration, or report the feature and versions named above.'
|
|
282
|
+
},
|
|
283
|
+
E604: {
|
|
284
|
+
title: 'Files you changed need a decision',
|
|
285
|
+
fix: 'Nothing was written. Run `mfeflow upgrade --dry-run` to see each diff, then pass --accept <path> to take the new version of a file, --keep <path> to keep yours, or `all` for every file.'
|
|
253
286
|
}
|
|
254
287
|
};
|
|
255
288
|
class MfeflowError extends Error {
|
|
@@ -288,14 +321,15 @@ function readPackageJson() {
|
|
|
288
321
|
if (!isPackageJson(pkg)) throw new MfeflowError('E106', 'package.json is missing a name or version');
|
|
289
322
|
return pkg;
|
|
290
323
|
}
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
324
|
+
function resolveInRoot(root, relative) {
|
|
325
|
+
if (!isSafeRelativePath(relative)) throw new MfeflowError('E105', `"${relative}"`);
|
|
326
|
+
const absolute = node_path.resolve(root, ...relative.split('/'));
|
|
327
|
+
const inside = node_path.relative(root, absolute);
|
|
328
|
+
if (inside.startsWith('..') || node_path.isAbsolute(inside)) throw new MfeflowError('E105', `"${relative}"`);
|
|
329
|
+
return absolute;
|
|
296
330
|
}
|
|
297
|
-
|
|
298
|
-
|
|
331
|
+
function joinPosix(...parts) {
|
|
332
|
+
return node_path.posix.join(...parts.filter((p)=>'' !== p));
|
|
299
333
|
}
|
|
300
334
|
const realFs = {
|
|
301
335
|
readFile: (file)=>promises.readFile(file, 'utf8'),
|
|
@@ -351,15 +385,46 @@ async function writeFileAtomic(ops, file, content) {
|
|
|
351
385
|
});
|
|
352
386
|
}
|
|
353
387
|
}
|
|
354
|
-
function
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
388
|
+
async function symlinkedPrefix(ops, root, relative) {
|
|
389
|
+
const parts = relative.split('/');
|
|
390
|
+
const prefixes = parts.map((_, i)=>parts.slice(0, i + 1).join('/'));
|
|
391
|
+
const linked = await Promise.all(prefixes.map(async (prefix)=>({
|
|
392
|
+
prefix,
|
|
393
|
+
link: await ops.isSymlink(resolveInRoot(root, prefix))
|
|
394
|
+
})));
|
|
395
|
+
return linked.find((entry)=>entry.link)?.prefix;
|
|
396
|
+
}
|
|
397
|
+
const APP_FILE = 'mfeflow.app.json';
|
|
398
|
+
const LEGACY_APP_FILE = 'mfe.json';
|
|
399
|
+
async function readAppFile(dir, ops = realFs) {
|
|
400
|
+
const [text, legacyText] = await Promise.all([
|
|
401
|
+
readIfExists(ops, node_path.join(dir, APP_FILE)),
|
|
402
|
+
readIfExists(ops, node_path.join(dir, LEGACY_APP_FILE))
|
|
403
|
+
]);
|
|
404
|
+
if (void 0 !== text && void 0 !== legacyText) throw new MfeflowError('E122', `${dir} has both ${APP_FILE} and ${LEGACY_APP_FILE}`);
|
|
405
|
+
if (void 0 !== text) return {
|
|
406
|
+
name: APP_FILE,
|
|
407
|
+
text,
|
|
408
|
+
legacy: false
|
|
409
|
+
};
|
|
410
|
+
if (void 0 !== legacyText) return {
|
|
411
|
+
name: LEGACY_APP_FILE,
|
|
412
|
+
text: legacyText,
|
|
413
|
+
legacy: true
|
|
414
|
+
};
|
|
360
415
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
416
|
+
function legacyAppFileNotice(folders) {
|
|
417
|
+
const files = folders.map((dir)=>`${dir}/${LEGACY_APP_FILE}`).join(', ');
|
|
418
|
+
return `Notice: ${LEGACY_APP_FILE} is now ${APP_FILE}. Rename ${files}; the old name still works for now.`;
|
|
419
|
+
}
|
|
420
|
+
function normalizeEol(content) {
|
|
421
|
+
return content.replace(/\r\n/g, '\n');
|
|
422
|
+
}
|
|
423
|
+
function hashContent(content) {
|
|
424
|
+
return `sha256-${createHash('sha256').update(normalizeEol(content)).digest('hex')}`;
|
|
425
|
+
}
|
|
426
|
+
async function inOrder(items, step) {
|
|
427
|
+
for (const item of items)await step(item);
|
|
363
428
|
}
|
|
364
429
|
const PROJECT_FILE = 'mfeflow.json';
|
|
365
430
|
function describeIssues(issues) {
|
|
@@ -382,11 +447,15 @@ function serializeProject(project) {
|
|
|
382
447
|
if (issues.length > 0) throw new MfeflowError('E104', describeIssues(issues));
|
|
383
448
|
const { $schema, ...rest } = project;
|
|
384
449
|
const files = rest.files && Object.fromEntries(Object.entries(rest.files).toSorted(byKey));
|
|
450
|
+
const kept = rest.kept && Object.fromEntries(Object.entries(rest.kept).toSorted(byKey));
|
|
385
451
|
const ordered = {
|
|
386
452
|
$schema: $schema ?? SCHEMA_URLS.project,
|
|
387
453
|
...rest,
|
|
388
454
|
...files ? {
|
|
389
455
|
files
|
|
456
|
+
} : {},
|
|
457
|
+
...kept ? {
|
|
458
|
+
kept
|
|
390
459
|
} : {}
|
|
391
460
|
};
|
|
392
461
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
@@ -542,6 +611,9 @@ class PlanBuilder {
|
|
|
542
611
|
async content(file) {
|
|
543
612
|
return (await this.open(file)).current ?? void 0;
|
|
544
613
|
}
|
|
614
|
+
async existed(file) {
|
|
615
|
+
return null !== (await this.open(file)).original;
|
|
616
|
+
}
|
|
545
617
|
async captureProject() {
|
|
546
618
|
this.state.projectBefore = await this.read(PROJECT_FILE) ?? null;
|
|
547
619
|
}
|
|
@@ -609,26 +681,31 @@ class PlanBuilder {
|
|
|
609
681
|
if (!isRegistryRange(range)) throw new MfeflowError('E202', `${name}: "${range}" is not a semver range or a dist-tag`);
|
|
610
682
|
const source = await this.content(file);
|
|
611
683
|
if (void 0 === source) throw new MfeflowError('E208', `${file} does not exist`);
|
|
612
|
-
const existing = this.declaredRange(file, source, name);
|
|
684
|
+
const existing = this.declaredRange(file, source, name, range);
|
|
613
685
|
if (existing === range || existing.startsWith('workspace:')) return;
|
|
614
686
|
if (!existing.startsWith('catalog:') && !isRegistryRange(existing)) return void this.warn(`${file}: ${name} is ${JSON.stringify(existing)}; left as it is, not moved to ${range}`);
|
|
615
687
|
if ('third-party' === this.trust) throw new MfeflowError('E207', `${file}: ${name} is already ${JSON.stringify(existing)}; the feature wants ${range}`);
|
|
616
688
|
if (existing.startsWith('catalog:')) return void await this.setCatalogEntry(name, existing.slice(8), range);
|
|
617
689
|
await this.patch(file, (text)=>editJsonObject(file, text, (pkg)=>{
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
690
|
+
let changed = false;
|
|
691
|
+
for (const section of DEPENDENCY_SECTIONS){
|
|
692
|
+
const listed = asRecord(pkg[section])[name];
|
|
693
|
+
if ('string' == typeof listed && listed !== range && isRegistryRange(listed)) {
|
|
694
|
+
pkg[section] = {
|
|
695
|
+
...asRecord(pkg[section]),
|
|
696
|
+
[name]: range
|
|
697
|
+
};
|
|
698
|
+
changed = true;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return changed;
|
|
624
702
|
}));
|
|
625
703
|
}
|
|
626
|
-
declaredRange(file, source, name) {
|
|
704
|
+
declaredRange(file, source, name, wanted) {
|
|
627
705
|
const pkg = parseJsonObject(file, source);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
}
|
|
706
|
+
const listed = DEPENDENCY_SECTIONS.map((section)=>asRecord(pkg[section])[name]).filter((value)=>'string' == typeof value);
|
|
707
|
+
const found = listed.find((value)=>value !== wanted) ?? listed[0];
|
|
708
|
+
if (void 0 !== found) return found;
|
|
632
709
|
throw new MfeflowError('E208', `${file} does not depend on ${name}`);
|
|
633
710
|
}
|
|
634
711
|
async setCatalogEntry(name, catalogName, range) {
|
|
@@ -827,19 +904,19 @@ const DEVICE_NAMES = new Set([
|
|
|
827
904
|
'lpt³'
|
|
828
905
|
]);
|
|
829
906
|
const INVISIBLE = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/u;
|
|
830
|
-
function
|
|
907
|
+
function write_policy_refuse(file, why) {
|
|
831
908
|
throw new MfeflowError('E105', `"${file}" ${why}`);
|
|
832
909
|
}
|
|
833
910
|
function assertWritable(file) {
|
|
834
|
-
if (INVISIBLE.test(file))
|
|
911
|
+
if (INVISIBLE.test(file)) write_policy_refuse(file, 'has control or invisible characters');
|
|
835
912
|
const key = policyKey(file);
|
|
836
|
-
if ('mfeflow.json' === key)
|
|
913
|
+
if ('mfeflow.json' === key) write_policy_refuse(file, 'is not a place features write to');
|
|
837
914
|
for (const segment of key.split('/')){
|
|
838
|
-
if ('.git' === segment || 'node_modules' === segment)
|
|
839
|
-
if (segment.includes(':'))
|
|
840
|
-
if (/[. ]$/.test(segment))
|
|
915
|
+
if ('.git' === segment || 'node_modules' === segment) write_policy_refuse(file, 'is not a place features write to');
|
|
916
|
+
if (segment.includes(':')) write_policy_refuse(file, 'has a ":" (a Windows stream)');
|
|
917
|
+
if (/[. ]$/.test(segment)) write_policy_refuse(file, 'has a name ending in a dot or space');
|
|
841
918
|
const base = (segment.split('.')[0] ?? '').trimEnd();
|
|
842
|
-
if (DEVICE_NAMES.has(base))
|
|
919
|
+
if (DEVICE_NAMES.has(base)) write_policy_refuse(file, 'is a Windows device name');
|
|
843
920
|
}
|
|
844
921
|
}
|
|
845
922
|
const THIRD_PARTY_FOLDERS = new Set([
|
|
@@ -885,7 +962,7 @@ async function applyPlan(plan, options) {
|
|
|
885
962
|
const realRoot = await ops.realpath(options.root);
|
|
886
963
|
await inOrder(targets, async ({ change, file })=>{
|
|
887
964
|
await assertContained(ops, realRoot, file, change.path);
|
|
888
|
-
await assertTrustedRoute(ops, options.root, change);
|
|
965
|
+
await assertTrustedRoute(ops, options.root, change, true === options.refuseSymlinks);
|
|
889
966
|
});
|
|
890
967
|
const current = await verifyUnchanged(targets, ops);
|
|
891
968
|
if (options.dryRun) return {
|
|
@@ -902,7 +979,7 @@ async function applyPlan(plan, options) {
|
|
|
902
979
|
const dir = await ops.mkdir(node_path.dirname(file));
|
|
903
980
|
if (dir) createdDirs.push(dir);
|
|
904
981
|
await assertContained(ops, realRoot, node_path.dirname(file), change.path);
|
|
905
|
-
await assertTrustedRoute(ops, options.root, change);
|
|
982
|
+
await assertTrustedRoute(ops, options.root, change, true === options.refuseSymlinks);
|
|
906
983
|
const temp = tempSibling(file);
|
|
907
984
|
staged.set(file, temp);
|
|
908
985
|
await ops.writeFile(temp, change.after);
|
|
@@ -1045,17 +1122,13 @@ async function assertContained(ops, realRoot, target, shown) {
|
|
|
1045
1122
|
const inside = node_path.relative(realRoot, real);
|
|
1046
1123
|
if (inside.startsWith('..') || node_path.isAbsolute(inside)) throw new MfeflowError('E105', `"${shown}" resolves through a symlink to ${real}`);
|
|
1047
1124
|
}
|
|
1048
|
-
async function assertTrustedRoute(ops, root, change) {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
link: await ops.isSymlink(node_path.join(root, ...prefix.split('/')))
|
|
1056
|
-
})));
|
|
1057
|
-
const hit = linked.find((entry)=>entry.link);
|
|
1058
|
-
if (hit) throw new MfeflowError('E203', `"${change.path}" goes through a symlink at "${hit.prefix}", which third-party features may not write through`);
|
|
1125
|
+
async function assertTrustedRoute(ops, root, change, refuseAll) {
|
|
1126
|
+
const thirdParty = 'third-party' === change.trust;
|
|
1127
|
+
if (!thirdParty && !refuseAll) return;
|
|
1128
|
+
if (thirdParty) assertWritable(change.path);
|
|
1129
|
+
const hit = await symlinkedPrefix(ops, root, change.path);
|
|
1130
|
+
if (hit && !thirdParty) throw new MfeflowError('E105', `"${change.path}" goes through a symlink at "${hit}", which is never written through`);
|
|
1131
|
+
if (hit) throw new MfeflowError('E203', `"${change.path}" goes through a symlink at "${hit}", which third-party features may not write through`);
|
|
1059
1132
|
}
|
|
1060
1133
|
async function deepestRealpath(ops, target) {
|
|
1061
1134
|
try {
|
|
@@ -1144,11 +1217,6 @@ function substitute(source, data, name = 'template') {
|
|
|
1144
1217
|
return String(value);
|
|
1145
1218
|
});
|
|
1146
1219
|
}
|
|
1147
|
-
const RUNTIME_CONFIG_FILE = /^runtime-config(?:\.([a-z0-9-]+))?\.json$/;
|
|
1148
|
-
async function runtimeConfigFiles(folder) {
|
|
1149
|
-
const names = await promises.readdir(folder).catch(()=>[]);
|
|
1150
|
-
return names.filter((name)=>RUNTIME_CONFIG_FILE.test(name)).toSorted((a, b)=>a.localeCompare(b));
|
|
1151
|
-
}
|
|
1152
1220
|
function applyEdits(source, edits) {
|
|
1153
1221
|
return edits.toSorted((a, b)=>b.start - a.start).reduce((out, e)=>out.slice(0, e.start) + e.text + out.slice(e.end), source);
|
|
1154
1222
|
}
|
|
@@ -1617,7 +1685,8 @@ function schemaKeys(source, file, scope, object = 'runtimeConfigSchema') {
|
|
|
1617
1685
|
return [
|
|
1618
1686
|
{
|
|
1619
1687
|
name,
|
|
1620
|
-
optional: rawKey(p) !== name || acceptsMissing(p.value)
|
|
1688
|
+
optional: rawKey(p) !== name || acceptsMissing(p.value),
|
|
1689
|
+
schema: source.slice(p.value.start ?? 0, p.value.end ?? 0)
|
|
1621
1690
|
}
|
|
1622
1691
|
];
|
|
1623
1692
|
});
|
|
@@ -1837,170 +1906,6 @@ function applyTransform(source, file, op) {
|
|
|
1837
1906
|
throw new MfeflowError('E204', JSON.stringify(Reflect.get(op, 'op')));
|
|
1838
1907
|
}
|
|
1839
1908
|
}
|
|
1840
|
-
const MAX_BYTES = 1000000;
|
|
1841
|
-
const TIMEOUT_MS = 10000;
|
|
1842
|
-
const MAX_REDIRECTS = 5;
|
|
1843
|
-
const realFetch = (url, init)=>fetch(url, init);
|
|
1844
|
-
function registryUrl(name, registries, version) {
|
|
1845
|
-
const slash = name.indexOf('/');
|
|
1846
|
-
const namespace = name.slice(0, slash);
|
|
1847
|
-
const bare = name.slice(slash + 1);
|
|
1848
|
-
const template = registries[namespace];
|
|
1849
|
-
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1850
|
-
const url = template.replaceAll('{name}', encodeURIComponent(bare)).replaceAll('{version}', encodeURIComponent(version ?? 'latest'));
|
|
1851
|
-
let parsed;
|
|
1852
|
-
try {
|
|
1853
|
-
parsed = new URL(url);
|
|
1854
|
-
} catch (error) {
|
|
1855
|
-
throw new MfeflowError('E401', `${namespace} has an invalid URL (${redactUrl(url)})`, {
|
|
1856
|
-
cause: error
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${redactUrl(url)} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
|
|
1860
|
-
return parsed.href;
|
|
1861
|
-
}
|
|
1862
|
-
async function getHttps(url, fetchImpl, code, options = {}) {
|
|
1863
|
-
const fail = (at, why, cause)=>{
|
|
1864
|
-
throw new MfeflowError(code, redactUrlsIn(`${at}: ${why}`), void 0 === cause ? {} : {
|
|
1865
|
-
cause
|
|
1866
|
-
});
|
|
1867
|
-
};
|
|
1868
|
-
const startsPrivate = isPrivateHost(new URL(url).hostname);
|
|
1869
|
-
const refusePrivate = (target)=>{
|
|
1870
|
-
if (void 0 === options.blockPrivate || !isPrivateHost(target.hostname)) return;
|
|
1871
|
-
if (!options.blockPrivate && startsPrivate) return;
|
|
1872
|
-
const why = options.blockPrivate ? 'those are only requested from a trusted registry' : 'a public registry may not send you to one';
|
|
1873
|
-
throw new MfeflowError('E405', `${redactUrl(target.href)} is a loopback or private address; ${why}`);
|
|
1874
|
-
};
|
|
1875
|
-
refusePrivate(new URL(url));
|
|
1876
|
-
const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
|
|
1877
|
-
const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
|
|
1878
|
-
const addressOk = (address)=>{
|
|
1879
|
-
try {
|
|
1880
|
-
return permitted(new URL(address));
|
|
1881
|
-
} catch {
|
|
1882
|
-
return false;
|
|
1883
|
-
}
|
|
1884
|
-
};
|
|
1885
|
-
let current = url;
|
|
1886
|
-
for(let hop = 0; hop <= MAX_REDIRECTS; hop += 1){
|
|
1887
|
-
let response;
|
|
1888
|
-
try {
|
|
1889
|
-
response = await fetchImpl(current, {
|
|
1890
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
1891
|
-
redirect: 'manual'
|
|
1892
|
-
});
|
|
1893
|
-
} catch (error) {
|
|
1894
|
-
return fail(current, messageOf(error), error);
|
|
1895
|
-
}
|
|
1896
|
-
if (void 0 !== response.url && '' !== response.url && !addressOk(response.url)) fail(url, `ended up at ${response.url}, which is not https`);
|
|
1897
|
-
if (void 0 !== response.url && '' !== response.url) refusePrivate(new URL(response.url));
|
|
1898
|
-
if (response.status < 300 || response.status >= 400) return response;
|
|
1899
|
-
const location = response.headers?.get('location');
|
|
1900
|
-
await response.body?.cancel().catch(()=>void 0);
|
|
1901
|
-
if (null == location || '' === location) fail(current, `redirected (${String(response.status)}) with no address`);
|
|
1902
|
-
let next;
|
|
1903
|
-
try {
|
|
1904
|
-
next = new URL(location ?? '', current);
|
|
1905
|
-
} catch (error) {
|
|
1906
|
-
return fail(current, 'redirected to a bad address', error);
|
|
1907
|
-
}
|
|
1908
|
-
if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
|
|
1909
|
-
refusePrivate(next);
|
|
1910
|
-
current = next.href;
|
|
1911
|
-
}
|
|
1912
|
-
return fail(url, 'redirected too many times');
|
|
1913
|
-
}
|
|
1914
|
-
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version, trusted = false) {
|
|
1915
|
-
const url = registryUrl(name, registries, version);
|
|
1916
|
-
const shown = redactUrl(url);
|
|
1917
|
-
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1918
|
-
loopback: true,
|
|
1919
|
-
blockPrivate: !trusted
|
|
1920
|
-
});
|
|
1921
|
-
if (!response.ok) {
|
|
1922
|
-
const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
|
|
1923
|
-
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
|
|
1924
|
-
}
|
|
1925
|
-
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${shown} is larger than 1 MB`));
|
|
1926
|
-
}
|
|
1927
|
-
function checkIntegrity(name, recorded, served, accept) {
|
|
1928
|
-
if (recorded === served) return false;
|
|
1929
|
-
if (!accept) throw new MfeflowError('E406', `${name} was ${recorded} when you added it and is now ${served}`);
|
|
1930
|
-
return true;
|
|
1931
|
-
}
|
|
1932
|
-
function integrityOf(text) {
|
|
1933
|
-
return `sha256-${createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
1934
|
-
}
|
|
1935
|
-
async function readCappedText(response, maxBytes, tooBig) {
|
|
1936
|
-
const declared = Number(response.headers?.get('content-length') ?? 0 / 0);
|
|
1937
|
-
if (declared > maxBytes) {
|
|
1938
|
-
await response.body?.cancel().catch(()=>void 0);
|
|
1939
|
-
throw tooBig();
|
|
1940
|
-
}
|
|
1941
|
-
if (!response.body) {
|
|
1942
|
-
const text = await response.text();
|
|
1943
|
-
if (Buffer.byteLength(text, 'utf8') > maxBytes) throw tooBig();
|
|
1944
|
-
return text;
|
|
1945
|
-
}
|
|
1946
|
-
const chunks = [];
|
|
1947
|
-
let total = 0;
|
|
1948
|
-
for await (const chunk of response.body){
|
|
1949
|
-
total += chunk.byteLength;
|
|
1950
|
-
if (total > maxBytes) throw tooBig();
|
|
1951
|
-
chunks.push(chunk);
|
|
1952
|
-
}
|
|
1953
|
-
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
1954
|
-
}
|
|
1955
|
-
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch, trusted = false) {
|
|
1956
|
-
const items = await readIndex(namespace, registries, fetchImpl, trusted, false);
|
|
1957
|
-
return items ?? [];
|
|
1958
|
-
}
|
|
1959
|
-
async function indexedVersion(namespace, name, registries, fetchImpl = realFetch, trusted = false) {
|
|
1960
|
-
const items = await readIndex(namespace, registries, fetchImpl, trusted, true);
|
|
1961
|
-
return items?.find((item)=>item.name === name)?.version;
|
|
1962
|
-
}
|
|
1963
|
-
async function readIndex(namespace, registries, fetchImpl, trusted, missingOk) {
|
|
1964
|
-
const template = registries[namespace];
|
|
1965
|
-
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1966
|
-
const problem = registryTemplateProblem(template);
|
|
1967
|
-
if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
|
|
1968
|
-
const url = registryIndexUrl(template);
|
|
1969
|
-
const shown = redactUrl(url);
|
|
1970
|
-
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1971
|
-
loopback: true,
|
|
1972
|
-
blockPrivate: !trusted
|
|
1973
|
-
});
|
|
1974
|
-
if (!response.ok) {
|
|
1975
|
-
if (missingOk && 404 === response.status) return;
|
|
1976
|
-
const hint = 404 === response.status ? ': this registry has no index.json' : '';
|
|
1977
|
-
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint}`);
|
|
1978
|
-
}
|
|
1979
|
-
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${shown} is larger than 1 MB`));
|
|
1980
|
-
let value;
|
|
1981
|
-
try {
|
|
1982
|
-
value = JSON.parse(text);
|
|
1983
|
-
} catch (error) {
|
|
1984
|
-
const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
|
|
1985
|
-
throw new MfeflowError('E403', `${shown} is not JSON (${reason})`, {
|
|
1986
|
-
cause: error
|
|
1987
|
-
});
|
|
1988
|
-
}
|
|
1989
|
-
const issues = validateFeatureRegistryIndex(value);
|
|
1990
|
-
if (issues.length > 0) {
|
|
1991
|
-
const first = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
|
|
1992
|
-
const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
|
|
1993
|
-
throw new MfeflowError('E403', `${shown}: ${first.join('; ')}${more}`);
|
|
1994
|
-
}
|
|
1995
|
-
return itemsOf(value);
|
|
1996
|
-
}
|
|
1997
|
-
function itemsOf(value) {
|
|
1998
|
-
const holder = {
|
|
1999
|
-
items: []
|
|
2000
|
-
};
|
|
2001
|
-
Object.assign(holder, value);
|
|
2002
|
-
return holder.items;
|
|
2003
|
-
}
|
|
2004
1909
|
const ROOTS = new Set([
|
|
2005
1910
|
'z',
|
|
2006
1911
|
'v',
|
|
@@ -2179,200 +2084,14 @@ function assertThirdPartyTransform(t, label) {
|
|
|
2179
2084
|
for (const key of Object.keys(t.props ?? {}))if (!JSX_ATTRIBUTE.test(key)) deny(`has a prop "${key}" that is not a JSX attribute name`);
|
|
2180
2085
|
}
|
|
2181
2086
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
}
|
|
2191
|
-
if ('object' != typeof value || null === value || Array.isArray(value)) throw new MfeflowError('E202', `${label}: must be a JSON object`);
|
|
2192
|
-
const record = Object.fromEntries(Object.entries(value));
|
|
2193
|
-
if ('third-party' === trust) assertAllowedKeys(record, label);
|
|
2194
|
-
assertKnownOps(record, label);
|
|
2195
|
-
const issues = validateFeatureManifest(record);
|
|
2196
|
-
if (issues.length > 0) {
|
|
2197
|
-
const detail = issues.map((i)=>`${i.path || '(root)'} ${i.message}`).join('; ');
|
|
2198
|
-
throw new MfeflowError('E202', `${label}: ${detail}`);
|
|
2199
|
-
}
|
|
2200
|
-
const manifest = toManifest(record, label);
|
|
2201
|
-
if ('third-party' === trust) assertThirdPartyContent(manifest, label);
|
|
2202
|
-
return manifest;
|
|
2203
|
-
}
|
|
2204
|
-
function toManifest(record, label) {
|
|
2205
|
-
const manifest = {
|
|
2206
|
-
name: '',
|
|
2207
|
-
version: ''
|
|
2208
|
-
};
|
|
2209
|
-
Object.assign(manifest, record);
|
|
2210
|
-
if ('' === manifest.name) throw new MfeflowError('E202', `${label}: name is required`);
|
|
2211
|
-
return manifest;
|
|
2212
|
-
}
|
|
2213
|
-
async function loadFeature(name, options) {
|
|
2214
|
-
if (!FEATURE_NAME.test(name)) throw new MfeflowError('E201', `"${name}" is not a feature name`);
|
|
2215
|
-
if (name.startsWith('@')) return loadRemote(name, options);
|
|
2216
|
-
return loadLocal(name, options);
|
|
2217
|
-
}
|
|
2218
|
-
async function loadLocal(name, options) {
|
|
2219
|
-
const ops = options.fs ?? realFs;
|
|
2220
|
-
if (!options.featuresDir) throw new MfeflowError('E201', `"${name}" (no features folder configured)`);
|
|
2221
|
-
const dir = node_path.join(options.featuresDir, name);
|
|
2222
|
-
const text = await readIfExists(ops, node_path.join(dir, 'feature.json'));
|
|
2223
|
-
if (void 0 === text) throw new MfeflowError('E201', `"${name}" (looked in ${options.featuresDir})`);
|
|
2224
|
-
const manifest = parseFeature(text, 'first-party', `${name}/feature.json`);
|
|
2225
|
-
if (manifest.name !== name) throw new MfeflowError('E202', `${name}/feature.json: name is "${manifest.name}", expected "${name}"`);
|
|
2226
|
-
return {
|
|
2227
|
-
manifest,
|
|
2228
|
-
trust: 'first-party',
|
|
2229
|
-
readAsset: async (relative)=>{
|
|
2230
|
-
if (!isSafeRelativePath(relative)) throw new MfeflowError('E105', `"${relative}"`);
|
|
2231
|
-
const content = await readIfExists(ops, node_path.join(dir, ...relative.split('/')));
|
|
2232
|
-
if (void 0 === content) throw new MfeflowError('E201', `${name}: file "${relative}" is missing`);
|
|
2233
|
-
return content;
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
}
|
|
2237
|
-
async function loadRemote(name, options) {
|
|
2238
|
-
const registries = options.registries ?? {};
|
|
2239
|
-
const text = await fetchRemoteText(name, registries, options.fetch, options.version, true === options.trusted);
|
|
2240
|
-
const manifest = parseFeature(text, 'third-party', name);
|
|
2241
|
-
if (manifest.name !== name) throw new MfeflowError('E402', `asked for ${name}, got ${manifest.name}`);
|
|
2242
|
-
if (true === options.checkIndex) await assertIndexAgrees(name, manifest.version, registries, options);
|
|
2243
|
-
return {
|
|
2244
|
-
manifest,
|
|
2245
|
-
trust: 'third-party',
|
|
2246
|
-
integrity: integrityOf(text)
|
|
2247
|
-
};
|
|
2248
|
-
}
|
|
2249
|
-
async function assertIndexAgrees(name, version, registries, options) {
|
|
2250
|
-
const slash = name.indexOf('/');
|
|
2251
|
-
let listed;
|
|
2252
|
-
try {
|
|
2253
|
-
listed = await indexedVersion(name.slice(0, slash), name.slice(slash + 1), registries, options.fetch, true === options.trusted);
|
|
2254
|
-
} catch (error) {
|
|
2255
|
-
if (error instanceof MfeflowError && 'MFEFLOW_E401' === error.code) return;
|
|
2256
|
-
throw error;
|
|
2257
|
-
}
|
|
2258
|
-
if (void 0 !== listed && listed !== version) throw new MfeflowError('E402', `the index lists ${name} at ${listed} but its feature.json is ${version}`);
|
|
2259
|
-
}
|
|
2260
|
-
async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
2261
|
-
let dirs;
|
|
2262
|
-
try {
|
|
2263
|
-
dirs = await ops.listDirs(featuresDir);
|
|
2264
|
-
} catch {
|
|
2265
|
-
return [];
|
|
2266
|
-
}
|
|
2267
|
-
const checked = await Promise.all(dirs.map(async (dir)=>await readIfExists(ops, node_path.join(featuresDir, dir, 'feature.json')) === void 0 ? void 0 : dir));
|
|
2268
|
-
return checked.filter((d)=>void 0 !== d).toSorted((a, b)=>a.localeCompare(b));
|
|
2269
|
-
}
|
|
2270
|
-
const TRUST_FILE = 'trusted-registries.json';
|
|
2271
|
-
const TRUST_ENV = 'MFEFLOW_TRUST_REGISTRIES';
|
|
2272
|
-
const set = (value)=>void 0 === value || '' === value ? void 0 : value;
|
|
2273
|
-
function configDirOf(env, platform = process.platform, home = node_os.homedir()) {
|
|
2274
|
-
const override = set(env.MFEFLOW_CONFIG_DIR);
|
|
2275
|
-
if (void 0 !== override) return override;
|
|
2276
|
-
const xdg = set(env.XDG_CONFIG_HOME);
|
|
2277
|
-
if (void 0 !== xdg) return node_path.join(xdg, 'mfeflow');
|
|
2278
|
-
const appData = set(env.APPDATA);
|
|
2279
|
-
if ('win32' === platform && void 0 !== appData) return node_path.join(appData, 'mfeflow');
|
|
2280
|
-
return node_path.join(home, '.config', 'mfeflow');
|
|
2281
|
-
}
|
|
2282
|
-
function trustFileIn(configDir) {
|
|
2283
|
-
return node_path.join(configDir, TRUST_FILE);
|
|
2284
|
-
}
|
|
2285
|
-
function isEntry(value) {
|
|
2286
|
-
if ('object' != typeof value || null === value) return false;
|
|
2287
|
-
const { namespace, template } = Object.fromEntries(Object.entries(value));
|
|
2288
|
-
return 'string' == typeof namespace && REGISTRY_NAMESPACE.test(namespace) && 'string' == typeof template && void 0 === registryTemplateProblem(template);
|
|
2289
|
-
}
|
|
2290
|
-
async function readTrusted(configDir, ops = realFs) {
|
|
2291
|
-
let text;
|
|
2292
|
-
try {
|
|
2293
|
-
text = await readIfExists(ops, trustFileIn(configDir));
|
|
2294
|
-
} catch {
|
|
2295
|
-
return [];
|
|
2296
|
-
}
|
|
2297
|
-
if (void 0 === text) return [];
|
|
2298
|
-
try {
|
|
2299
|
-
const value = JSON.parse(text);
|
|
2300
|
-
if ('object' != typeof value || null === value) return [];
|
|
2301
|
-
const { registries } = Object.fromEntries(Object.entries(value));
|
|
2302
|
-
return Array.isArray(registries) ? registries.filter(isEntry) : [];
|
|
2303
|
-
} catch {
|
|
2304
|
-
return [];
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
async function save(configDir, entries, ops) {
|
|
2308
|
-
const sorted = entries.toSorted((a, b)=>a.namespace.localeCompare(b.namespace) || a.template.localeCompare(b.template));
|
|
2309
|
-
const body = `${JSON.stringify({
|
|
2310
|
-
version: 1,
|
|
2311
|
-
registries: sorted
|
|
2312
|
-
}, null, 2)}\n`;
|
|
2313
|
-
try {
|
|
2314
|
-
await writeFileAtomic(ops, trustFileIn(configDir), body);
|
|
2315
|
-
} catch (error) {
|
|
2316
|
-
throw new MfeflowError('E106', trustFileIn(configDir), {
|
|
2317
|
-
cause: error
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
async function addTrusted(configDir, entry, ops = realFs) {
|
|
2322
|
-
const current = await readTrusted(configDir, ops);
|
|
2323
|
-
if (current.some((e)=>e.namespace === entry.namespace && e.template === entry.template)) return false;
|
|
2324
|
-
await save(configDir, [
|
|
2325
|
-
...current,
|
|
2326
|
-
entry
|
|
2327
|
-
], ops);
|
|
2328
|
-
return true;
|
|
2329
|
-
}
|
|
2330
|
-
async function removeTrusted(configDir, namespace, ops = realFs) {
|
|
2331
|
-
const current = await readTrusted(configDir, ops);
|
|
2332
|
-
const kept = current.filter((e)=>e.namespace !== namespace);
|
|
2333
|
-
if (kept.length === current.length) return 0;
|
|
2334
|
-
await save(configDir, kept, ops);
|
|
2335
|
-
return current.length - kept.length;
|
|
2336
|
-
}
|
|
2337
|
-
function parseTrustEnv(value) {
|
|
2338
|
-
return (value ?? '').split(',').map((part)=>part.trim()).flatMap((part)=>{
|
|
2339
|
-
const eq = part.indexOf('=');
|
|
2340
|
-
const namespace = -1 === eq ? part : part.slice(0, eq).trim();
|
|
2341
|
-
if (!REGISTRY_NAMESPACE.test(namespace)) return [];
|
|
2342
|
-
const template = -1 === eq ? '' : part.slice(eq + 1).trim();
|
|
2343
|
-
return [
|
|
2344
|
-
'' === template ? {
|
|
2345
|
-
namespace
|
|
2346
|
-
} : {
|
|
2347
|
-
namespace,
|
|
2348
|
-
template
|
|
2349
|
-
}
|
|
2350
|
-
];
|
|
2351
|
-
});
|
|
2352
|
-
}
|
|
2353
|
-
function trustedNamespaces(registries, recorded, fromEnv = []) {
|
|
2354
|
-
const fetch1 = new Set();
|
|
2355
|
-
const privateOk = new Set();
|
|
2356
|
-
for (const [namespace, template] of Object.entries(registries)){
|
|
2357
|
-
const inFile = recorded.some((e)=>e.namespace === namespace && e.template === template);
|
|
2358
|
-
const items = fromEnv.filter((e)=>e.namespace === namespace);
|
|
2359
|
-
const pinned = items.some((e)=>e.template === template);
|
|
2360
|
-
if (inFile || pinned) privateOk.add(namespace);
|
|
2361
|
-
if (inFile || items.some((e)=>void 0 === e.template || e.template === template)) fetch1.add(namespace);
|
|
2362
|
-
}
|
|
2363
|
-
return {
|
|
2364
|
-
fetch: fetch1,
|
|
2365
|
-
privateOk
|
|
2366
|
-
};
|
|
2367
|
-
}
|
|
2368
|
-
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
2369
|
-
function shortHash(text) {
|
|
2370
|
-
let hash = 0x811c9dc5;
|
|
2371
|
-
for (const char of text)hash = Math.imul(hash ^ char.codePointAt(0), 0x01000193) >>> 0;
|
|
2372
|
-
let out = '';
|
|
2373
|
-
for(let i = 0; i < 4; i++){
|
|
2374
|
-
out += LETTERS[hash % 26];
|
|
2375
|
-
hash = Math.floor(hash / 26);
|
|
2087
|
+
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
2088
|
+
function shortHash(text) {
|
|
2089
|
+
let hash = 0x811c9dc5;
|
|
2090
|
+
for (const char of text)hash = Math.imul(hash ^ char.codePointAt(0), 0x01000193) >>> 0;
|
|
2091
|
+
let out = '';
|
|
2092
|
+
for(let i = 0; i < 4; i++){
|
|
2093
|
+
out += LETTERS[hash % 26];
|
|
2094
|
+
hash = Math.floor(hash / 26);
|
|
2376
2095
|
}
|
|
2377
2096
|
return out;
|
|
2378
2097
|
}
|
|
@@ -2559,6 +2278,2109 @@ async function editFile(builder, file, edit, op) {
|
|
|
2559
2278
|
if (isConfig && !await builder.exists(file)) return void await builder.create(file, edit('{}\n'));
|
|
2560
2279
|
await builder.patch(file, edit);
|
|
2561
2280
|
}
|
|
2281
|
+
const upgrade_WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
2282
|
+
const upgrade_TURBO_FILE = 'turbo.json';
|
|
2283
|
+
const upgrade_DEPENDENCY_SECTIONS = [
|
|
2284
|
+
'dependencies',
|
|
2285
|
+
'devDependencies',
|
|
2286
|
+
'optionalDependencies'
|
|
2287
|
+
];
|
|
2288
|
+
const OWN_PREFIX = '@mfeflow/';
|
|
2289
|
+
const OWN_CLI = 'mfeflow';
|
|
2290
|
+
const PLACEHOLDER_RANGE = 'latest';
|
|
2291
|
+
const USER_DATA = /^registry\/envs\//;
|
|
2292
|
+
const ENV_KEY_FILES = /(^|\/)(env\.ts|src\/config\/schema\.ts|runtime-config(\.[^/]+)?\.json|\.env[^/]*)$/;
|
|
2293
|
+
const KEEP_RANGE = /^(workspace|link|file|portal):/;
|
|
2294
|
+
const byCodePoint = (a, b)=>a < b ? -1 : a > b ? 1 : 0;
|
|
2295
|
+
const isOwn = (name)=>name === OWN_CLI || name.startsWith(OWN_PREFIX);
|
|
2296
|
+
const isPackageFile = (file)=>'package.json' === file || file.endsWith('/package.json');
|
|
2297
|
+
const isStructured = (file)=>isPackageFile(file) || file === upgrade_WORKSPACE_FILE || file === upgrade_TURBO_FILE;
|
|
2298
|
+
const sameText = (a, b)=>normalizeEol(a) === normalizeEol(b);
|
|
2299
|
+
async function runtimeRange(pkgFile, read) {
|
|
2300
|
+
const source = await read(pkgFile);
|
|
2301
|
+
if (void 0 === source) return;
|
|
2302
|
+
const pkg = parseJsonObject(pkgFile, source);
|
|
2303
|
+
for (const section of upgrade_DEPENDENCY_SECTIONS){
|
|
2304
|
+
const value = asRecord(pkg[section])['@mfeflow/runtime'];
|
|
2305
|
+
if ('string' == typeof value) {
|
|
2306
|
+
if (!value.startsWith('catalog:')) return {
|
|
2307
|
+
range: value,
|
|
2308
|
+
catalog: void 0
|
|
2309
|
+
};
|
|
2310
|
+
return {
|
|
2311
|
+
range: value,
|
|
2312
|
+
catalog: value.slice(8)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
async function catalogRange(read, catalogName) {
|
|
2318
|
+
const source = await read(upgrade_WORKSPACE_FILE);
|
|
2319
|
+
if (void 0 === source) return;
|
|
2320
|
+
const doc = parseDocument(source);
|
|
2321
|
+
const where = '' === catalogName || 'default' === catalogName ? [
|
|
2322
|
+
'catalog'
|
|
2323
|
+
] : [
|
|
2324
|
+
'catalogs',
|
|
2325
|
+
catalogName
|
|
2326
|
+
];
|
|
2327
|
+
const value = doc.getIn([
|
|
2328
|
+
...where,
|
|
2329
|
+
'@mfeflow/runtime'
|
|
2330
|
+
]);
|
|
2331
|
+
return 'string' == typeof value ? value : void 0;
|
|
2332
|
+
}
|
|
2333
|
+
function resolveRange(found, read) {
|
|
2334
|
+
return void 0 === found.catalog ? Promise.resolve(found.range) : catalogRange(read, found.catalog);
|
|
2335
|
+
}
|
|
2336
|
+
async function detectBaseline(project, read) {
|
|
2337
|
+
if (void 0 !== project.cli) return {
|
|
2338
|
+
version: project.cli,
|
|
2339
|
+
source: 'mfeflow.json'
|
|
2340
|
+
};
|
|
2341
|
+
const apps = Object.values(project.apps).toSorted((a, b)=>Number('shell' === b.kind) - Number('shell' === a.kind));
|
|
2342
|
+
for (const app of apps){
|
|
2343
|
+
const file = '' === app.path ? 'package.json' : `${app.path}/package.json`;
|
|
2344
|
+
const found = await runtimeRange(file, read);
|
|
2345
|
+
if (void 0 === found) continue;
|
|
2346
|
+
const range = await resolveRange(found, read);
|
|
2347
|
+
const min = void 0 === range || KEEP_RANGE.test(range) ? void 0 : minimum(range);
|
|
2348
|
+
if (void 0 === min) throw new MfeflowError('E601', `${file} has @mfeflow/runtime at ${JSON.stringify(found.range)}, which does not name a version`);
|
|
2349
|
+
return {
|
|
2350
|
+
version: min,
|
|
2351
|
+
source: 'package.json'
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
throw new MfeflowError('E601', 'mfeflow.json has no "cli" and no app depends on @mfeflow/runtime');
|
|
2355
|
+
}
|
|
2356
|
+
function minimum(range) {
|
|
2357
|
+
if (null === semver.validRange(range)) return;
|
|
2358
|
+
const min = semver.minVersion(range)?.version;
|
|
2359
|
+
return '0.0.0' === min ? void 0 : min;
|
|
2360
|
+
}
|
|
2361
|
+
function recordedOptions(feature, project, warnings) {
|
|
2362
|
+
const recorded = project.features[feature.manifest.name]?.options ?? {};
|
|
2363
|
+
const known = new Set((feature.manifest.prompts ?? []).map((p)=>p.name));
|
|
2364
|
+
const kept = {};
|
|
2365
|
+
for (const [key, value] of Object.entries(recorded))if (known.has(key)) kept[key] = value;
|
|
2366
|
+
else warnings.push(`${feature.manifest.name} no longer has the option "${key}"; it was ignored`);
|
|
2367
|
+
return kept;
|
|
2368
|
+
}
|
|
2369
|
+
const depKey = (file, name)=>`${file}\0${name}`;
|
|
2370
|
+
async function replay(project, input) {
|
|
2371
|
+
const local = void 0 !== project.local;
|
|
2372
|
+
const builder = new PlanBuilder(()=>Promise.resolve(void 0), {
|
|
2373
|
+
catalog: true,
|
|
2374
|
+
...local ? {} : {
|
|
2375
|
+
rewriteRange: (name, range)=>isOwn(name) && range === PLACEHOLDER_RANGE ? `^${input.toVersion}` : void 0
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
const warnings = [];
|
|
2379
|
+
const names = [
|
|
2380
|
+
...input.features.map((f)=>f.manifest.name),
|
|
2381
|
+
...(input.skipped ?? []).map((s)=>s.name)
|
|
2382
|
+
];
|
|
2383
|
+
const bare = {
|
|
2384
|
+
...project,
|
|
2385
|
+
features: {}
|
|
2386
|
+
};
|
|
2387
|
+
const steps = [
|
|
2388
|
+
...input.features.map((feature)=>({
|
|
2389
|
+
feature,
|
|
2390
|
+
options: recordedOptions(feature, project, warnings)
|
|
2391
|
+
})),
|
|
2392
|
+
...(input.glue ?? []).map((feature)=>({
|
|
2393
|
+
feature,
|
|
2394
|
+
options: {}
|
|
2395
|
+
}))
|
|
2396
|
+
];
|
|
2397
|
+
for (const step of steps)await resolveFeature({
|
|
2398
|
+
...step,
|
|
2399
|
+
project: bare,
|
|
2400
|
+
builder,
|
|
2401
|
+
alsoAdding: names
|
|
2402
|
+
});
|
|
2403
|
+
const built = builder.build();
|
|
2404
|
+
const files = new Map();
|
|
2405
|
+
const tracked = new Set();
|
|
2406
|
+
const thirdParty = new Set();
|
|
2407
|
+
for (const change of built.changes){
|
|
2408
|
+
if ('third-party' === change.trust) thirdParty.add(change.path);
|
|
2409
|
+
if (null !== change.after) {
|
|
2410
|
+
files.set(change.path, change.after);
|
|
2411
|
+
if (change.track) tracked.add(change.path);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
const thirdPartyDeps = new Set(built.dependencies.filter((d)=>true === d.thirdParty).map((d)=>depKey(d.file, d.name)));
|
|
2415
|
+
const byName = new Map(built.features.map((f)=>[
|
|
2416
|
+
f.name,
|
|
2417
|
+
f
|
|
2418
|
+
]));
|
|
2419
|
+
return {
|
|
2420
|
+
files,
|
|
2421
|
+
tracked,
|
|
2422
|
+
thirdParty,
|
|
2423
|
+
thirdPartyDeps,
|
|
2424
|
+
features: [
|
|
2425
|
+
...byName.values()
|
|
2426
|
+
],
|
|
2427
|
+
warnings
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function keepLineEndings(change) {
|
|
2431
|
+
if (null === change.after || !change.before?.includes('\r\n')) return change;
|
|
2432
|
+
return {
|
|
2433
|
+
...change,
|
|
2434
|
+
after: normalizeEol(change.after).replaceAll('\n', '\r\n')
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
function unifiedDiff(file, yours, incoming) {
|
|
2438
|
+
return formatPatch(structuredPatch(`a/${file} (yours)`, `b/${file} (new)`, yours, incoming));
|
|
2439
|
+
}
|
|
2440
|
+
function entriesOf(file, source) {
|
|
2441
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
2442
|
+
try {
|
|
2443
|
+
if (base.endsWith('.json')) {
|
|
2444
|
+
const json = parseJsonObject(file, source);
|
|
2445
|
+
return new Map(Object.entries(json).map(([key, value])=>[
|
|
2446
|
+
key,
|
|
2447
|
+
JSON.stringify(value)
|
|
2448
|
+
]));
|
|
2449
|
+
}
|
|
2450
|
+
if (base.startsWith('.env')) {
|
|
2451
|
+
const values = new Map();
|
|
2452
|
+
for (const line of source.split('\n')){
|
|
2453
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
|
|
2454
|
+
if (match?.[1] !== void 0) values.set(match[1], (match[2] ?? '').trim());
|
|
2455
|
+
}
|
|
2456
|
+
return values;
|
|
2457
|
+
}
|
|
2458
|
+
const keys = schemaKeys(source, file, 'env.ts' === base ? 'server' : 'runtime');
|
|
2459
|
+
return void 0 === keys ? void 0 : new Map(keys.map((k)=>[
|
|
2460
|
+
k.name,
|
|
2461
|
+
k.schema.replaceAll(/\s+/g, ' ')
|
|
2462
|
+
]));
|
|
2463
|
+
} catch {
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
function lostKeys(file, disk, incoming) {
|
|
2468
|
+
const yours = entriesOf(file, disk);
|
|
2469
|
+
const wanted = entriesOf(file, incoming);
|
|
2470
|
+
if (void 0 === yours || void 0 === wanted) return;
|
|
2471
|
+
const missing = [
|
|
2472
|
+
...yours
|
|
2473
|
+
].filter(([key])=>!wanted.has(key)).map(([key])=>key);
|
|
2474
|
+
const changed = [
|
|
2475
|
+
...yours
|
|
2476
|
+
].filter(([key, value])=>wanted.has(key) && wanted.get(key) !== value).map(([key])=>key);
|
|
2477
|
+
return {
|
|
2478
|
+
missing,
|
|
2479
|
+
changed
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
const writes = (verdict)=>'create' === verdict || 'replace' === verdict || 'missing' !== verdict && 'matching' !== verdict && 'leave' !== verdict && 'symlink' !== verdict;
|
|
2483
|
+
function judge(file, disk, incoming, state) {
|
|
2484
|
+
const { recorded, kept, anySkipped } = state;
|
|
2485
|
+
if (void 0 === disk) {
|
|
2486
|
+
if (void 0 !== recorded) return 'missing';
|
|
2487
|
+
return USER_DATA.test(file) ? 'leave' : 'create';
|
|
2488
|
+
}
|
|
2489
|
+
if (sameText(disk, incoming)) return void 0 !== recorded && recorded !== hashContent(disk) ? 'matching' : 'leave';
|
|
2490
|
+
if (USER_DATA.test(file)) return 'leave';
|
|
2491
|
+
if (void 0 !== kept) return kept === hashContent(incoming) ? 'leave' : 'edited';
|
|
2492
|
+
if (void 0 !== recorded && recorded === hashContent(incoming)) return 'leave';
|
|
2493
|
+
if (void 0 === recorded) return 'untracked';
|
|
2494
|
+
if (recorded !== hashContent(disk)) return 'edited';
|
|
2495
|
+
if (ENV_KEY_FILES.test(file)) {
|
|
2496
|
+
const lost = lostKeys(file, disk, incoming);
|
|
2497
|
+
if (void 0 === lost || lost.missing.length > 0) return 'edited-by-cli';
|
|
2498
|
+
}
|
|
2499
|
+
return anySkipped ? 'skipped-feature' : 'replace';
|
|
2500
|
+
}
|
|
2501
|
+
async function compareFiles(project, fresh, read, builder, options) {
|
|
2502
|
+
const out = {
|
|
2503
|
+
diverged: [],
|
|
2504
|
+
kept: [],
|
|
2505
|
+
missing: [],
|
|
2506
|
+
matching: [],
|
|
2507
|
+
notes: []
|
|
2508
|
+
};
|
|
2509
|
+
const entries = [
|
|
2510
|
+
...fresh.files
|
|
2511
|
+
].toSorted(([a], [b])=>a < b ? -1 : 1);
|
|
2512
|
+
for (const [file, incoming] of entries){
|
|
2513
|
+
const disk = await read(file);
|
|
2514
|
+
if (isStructured(file) && void 0 !== disk) continue;
|
|
2515
|
+
let verdict = judge(file, disk, incoming, {
|
|
2516
|
+
...project.files?.[file] === void 0 ? {} : {
|
|
2517
|
+
recorded: project.files[file]
|
|
2518
|
+
},
|
|
2519
|
+
...project.kept?.[file] === void 0 ? {} : {
|
|
2520
|
+
kept: project.kept[file]
|
|
2521
|
+
},
|
|
2522
|
+
anySkipped: options.anySkipped
|
|
2523
|
+
});
|
|
2524
|
+
if (writes(verdict) && await options.isSymlink?.(file)) verdict = 'symlink';
|
|
2525
|
+
if ('missing' === verdict) out.missing.push(file);
|
|
2526
|
+
else if ('matching' === verdict) out.matching.push(file);
|
|
2527
|
+
else if ('create' === verdict) await builder.create(file, incoming, fresh.tracked.has(file));
|
|
2528
|
+
else if ('replace' === verdict) {
|
|
2529
|
+
const changed = ENV_KEY_FILES.test(file) ? lostKeys(file, disk ?? '', incoming)?.changed : [];
|
|
2530
|
+
if (void 0 !== changed && changed.length > 0) out.notes.push(`${file}: the values of ${changed.join(', ')} change to the new version's; if you set them with mfeflow add env-key, set them again`);
|
|
2531
|
+
await builder.replace(file, incoming);
|
|
2532
|
+
} else if ('leave' !== verdict) {
|
|
2533
|
+
const entry = diverge(file, verdict, disk ?? '', incoming);
|
|
2534
|
+
const decision = 'symlink' === verdict ? void 0 : decide(file, options.decisions);
|
|
2535
|
+
if (void 0 !== decision) entry.decision = decision;
|
|
2536
|
+
out.diverged.push(entry);
|
|
2537
|
+
if ('accept' === decision) await builder.replace(file, incoming);
|
|
2538
|
+
else if ('keep' === decision) out.kept.push(entry);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return out;
|
|
2542
|
+
}
|
|
2543
|
+
function diverge(file, reason, yours, incoming) {
|
|
2544
|
+
const entry = {
|
|
2545
|
+
path: file,
|
|
2546
|
+
reason,
|
|
2547
|
+
yours,
|
|
2548
|
+
incoming,
|
|
2549
|
+
diff: unifiedDiff(file, yours, incoming)
|
|
2550
|
+
};
|
|
2551
|
+
const lost = ENV_KEY_FILES.test(file) ? lostKeys(file, yours, incoming) : void 0;
|
|
2552
|
+
if (void 0 !== lost && lost.missing.length + lost.changed.length > 0) {
|
|
2553
|
+
const all = new Set([
|
|
2554
|
+
...lost.missing,
|
|
2555
|
+
...lost.changed
|
|
2556
|
+
]);
|
|
2557
|
+
entry.lostKeys = [
|
|
2558
|
+
...entriesOf(file, yours)?.keys() ?? []
|
|
2559
|
+
].filter((key)=>all.has(key));
|
|
2560
|
+
if (lost.changed.length > 0) entry.changedValues = lost.changed;
|
|
2561
|
+
}
|
|
2562
|
+
return entry;
|
|
2563
|
+
}
|
|
2564
|
+
const upgrade_named = (set, file)=>'all' === set || set?.has(file) === true;
|
|
2565
|
+
function decide(file, decisions) {
|
|
2566
|
+
const accept = upgrade_named(decisions.accept, file);
|
|
2567
|
+
const keep = upgrade_named(decisions.keep, file);
|
|
2568
|
+
if (accept && keep) return 'all' === decisions.accept && 'all' !== decisions.keep ? 'keep' : 'accept';
|
|
2569
|
+
if (accept) return 'accept';
|
|
2570
|
+
return keep ? 'keep' : void 0;
|
|
2571
|
+
}
|
|
2572
|
+
async function editWorkspace(fresh, read, builder, local) {
|
|
2573
|
+
const incoming = fresh.files.get(upgrade_WORKSPACE_FILE);
|
|
2574
|
+
const disk = await read(upgrade_WORKSPACE_FILE);
|
|
2575
|
+
if (void 0 === incoming || void 0 === disk) return;
|
|
2576
|
+
const wanted = asRecord(parseDocument(incoming).toJSON());
|
|
2577
|
+
const doc = parseDocument(disk);
|
|
2578
|
+
let changed = false;
|
|
2579
|
+
for (const section of [
|
|
2580
|
+
'catalog',
|
|
2581
|
+
'allowBuilds'
|
|
2582
|
+
]){
|
|
2583
|
+
const have = doc.get(section);
|
|
2584
|
+
for (const [name, value] of Object.entries(asRecord(wanted[section])))if (!(isMap(have) && have.has(name) || local && isOwn(name))) {
|
|
2585
|
+
doc.setIn([
|
|
2586
|
+
section,
|
|
2587
|
+
name
|
|
2588
|
+
], value);
|
|
2589
|
+
changed = true;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (changed) await builder.patch(upgrade_WORKSPACE_FILE, ()=>doc.toString());
|
|
2593
|
+
}
|
|
2594
|
+
async function editTurbo(fresh, read, builder) {
|
|
2595
|
+
const incoming = fresh.files.get(upgrade_TURBO_FILE);
|
|
2596
|
+
if (void 0 === incoming || await read(upgrade_TURBO_FILE) === void 0) return;
|
|
2597
|
+
const wanted = asRecord(asRecord(parseJsonObject(upgrade_TURBO_FILE, incoming)).tasks);
|
|
2598
|
+
await builder.patch(upgrade_TURBO_FILE, (source)=>editJsonObject(upgrade_TURBO_FILE, source, (turbo)=>{
|
|
2599
|
+
const tasks = asRecord(turbo.tasks);
|
|
2600
|
+
const missing = Object.entries(wanted).filter(([name])=>!(name in tasks));
|
|
2601
|
+
if (0 === missing.length) return false;
|
|
2602
|
+
turbo.tasks = {
|
|
2603
|
+
...tasks,
|
|
2604
|
+
...Object.fromEntries(missing)
|
|
2605
|
+
};
|
|
2606
|
+
return true;
|
|
2607
|
+
}));
|
|
2608
|
+
}
|
|
2609
|
+
function upgrade_listed(pkg) {
|
|
2610
|
+
return upgrade_DEPENDENCY_SECTIONS.flatMap((section)=>Object.entries(asRecord(pkg[section])).flatMap(([name, range])=>'string' == typeof range ? [
|
|
2611
|
+
[
|
|
2612
|
+
name,
|
|
2613
|
+
range
|
|
2614
|
+
]
|
|
2615
|
+
] : []));
|
|
2616
|
+
}
|
|
2617
|
+
function upgrade_declared(pkg) {
|
|
2618
|
+
const out = new Map();
|
|
2619
|
+
for (const section of upgrade_DEPENDENCY_SECTIONS)for (const [name, range] of Object.entries(asRecord(pkg[section])))if ('string' == typeof range) out.set(name, range);
|
|
2620
|
+
return out;
|
|
2621
|
+
}
|
|
2622
|
+
async function addMissing(file, pkg, wanted, local, builder) {
|
|
2623
|
+
const have = upgrade_declared(pkg);
|
|
2624
|
+
for (const [section, dev] of [
|
|
2625
|
+
[
|
|
2626
|
+
'dependencies',
|
|
2627
|
+
false
|
|
2628
|
+
],
|
|
2629
|
+
[
|
|
2630
|
+
'devDependencies',
|
|
2631
|
+
true
|
|
2632
|
+
]
|
|
2633
|
+
])for (const [name, range] of Object.entries(asRecord(wanted[section])))if (!(have.has(name) || 'string' != typeof range || local && isOwn(name))) await builder.addDependency(file, name, range, dev);
|
|
2634
|
+
const scripts = asRecord(pkg.scripts);
|
|
2635
|
+
for (const [name, command] of Object.entries(asRecord(wanted.scripts)))if (!(name in scripts) && 'string' == typeof command) await builder.addScript(file, name, command);
|
|
2636
|
+
}
|
|
2637
|
+
async function editPackage(file, fresh, toVersion, local, builder) {
|
|
2638
|
+
const source = await builder.content(file);
|
|
2639
|
+
if (void 0 === source) return;
|
|
2640
|
+
const incoming = fresh.files.get(file);
|
|
2641
|
+
if (void 0 !== incoming) await addMissing(file, parseJsonObject(file, source), parseJsonObject(file, incoming), local, builder);
|
|
2642
|
+
if (local) return;
|
|
2643
|
+
const target = `^${toVersion}`;
|
|
2644
|
+
const current = parseJsonObject(file, await builder.content(file) ?? source);
|
|
2645
|
+
for (const [name, range] of upgrade_listed(current))if (!(!isOwn(name) || range === target || KEEP_RANGE.test(range))) await builder.setRange(file, name, target);
|
|
2646
|
+
}
|
|
2647
|
+
function pendingMigrations(features, project) {
|
|
2648
|
+
const pending = [];
|
|
2649
|
+
for (const feature of features){
|
|
2650
|
+
const { name, version, migrations } = feature.manifest;
|
|
2651
|
+
const recorded = project.features[name]?.version;
|
|
2652
|
+
if (void 0 !== recorded && null !== semver.valid(recorded)) {
|
|
2653
|
+
if ('first-party' === feature.trust) {
|
|
2654
|
+
for (const m of migrations ?? [])if (null !== semver.valid(m.to) && null !== semver.valid(version)) {
|
|
2655
|
+
if (semver.lt(recorded, m.to) && semver.lte(m.to, version)) pending.push({
|
|
2656
|
+
feature,
|
|
2657
|
+
...m
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
return pending.toSorted((a, b)=>semver.compare(a.to, b.to));
|
|
2664
|
+
}
|
|
2665
|
+
async function importDefault(file) {
|
|
2666
|
+
return import(pathToFileURL(file).href);
|
|
2667
|
+
}
|
|
2668
|
+
async function loadMigration(step, importModule) {
|
|
2669
|
+
const name = step.feature.manifest.name;
|
|
2670
|
+
const label = `${name} ${step.from} to ${step.to} (${step.module})`;
|
|
2671
|
+
const { dir } = step.feature;
|
|
2672
|
+
if (void 0 === dir || !isSafeRelativePath(step.module)) throw new MfeflowError('E603', `${label}: cannot find ${step.module}`);
|
|
2673
|
+
let loaded;
|
|
2674
|
+
try {
|
|
2675
|
+
loaded = await importModule(node_path.join(dir, ...step.module.split('/')));
|
|
2676
|
+
} catch (error) {
|
|
2677
|
+
throw new MfeflowError('E603', `${label}: ${messageOf(error)}`, {
|
|
2678
|
+
cause: error
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
const exported = asRecord(loaded);
|
|
2682
|
+
const fn = 'function' == typeof exported.default ? exported.default : exported.migrate;
|
|
2683
|
+
if ('function' != typeof fn) throw new MfeflowError('E603', `${label}: ${step.module} exports no migration function`);
|
|
2684
|
+
return async (builder, project)=>{
|
|
2685
|
+
try {
|
|
2686
|
+
await Reflect.apply(fn, void 0, [
|
|
2687
|
+
builder,
|
|
2688
|
+
project
|
|
2689
|
+
]);
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
throw new MfeflowError('E603', `${label}: ${messageOf(error)}`, {
|
|
2692
|
+
cause: error
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
async function runMigrations(project, input, builder) {
|
|
2698
|
+
const importModule = input.importModule ?? importDefault;
|
|
2699
|
+
const ran = [];
|
|
2700
|
+
for (const step of pendingMigrations(input.features, project)){
|
|
2701
|
+
const migrate = await loadMigration(step, importModule);
|
|
2702
|
+
await migrate(builder.scoped('first-party'), project);
|
|
2703
|
+
ran.push({
|
|
2704
|
+
feature: step.feature.manifest.name,
|
|
2705
|
+
from: step.from,
|
|
2706
|
+
to: step.to
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
return ran;
|
|
2710
|
+
}
|
|
2711
|
+
async function findRemoved(project, fresh, read) {
|
|
2712
|
+
const removed = [];
|
|
2713
|
+
for (const file of Object.keys(project.files ?? {}).toSorted(byCodePoint))if (!(fresh.files.has(file) || USER_DATA.test(file) || ENV_KEY_FILES.test(file))) {
|
|
2714
|
+
if (await read(file) !== void 0) removed.push(file);
|
|
2715
|
+
}
|
|
2716
|
+
return removed;
|
|
2717
|
+
}
|
|
2718
|
+
async function planUpgrade(project, input) {
|
|
2719
|
+
if (null === semver.valid(input.toVersion)) throw new MfeflowError('E601', `"${input.toVersion}" is not a version`);
|
|
2720
|
+
const baseline = await detectBaseline(project, input.read);
|
|
2721
|
+
if (null === semver.valid(baseline.version) || semver.gt(baseline.version, input.toVersion)) throw new MfeflowError('E602', `project is at ${baseline.version}, target is ${input.toVersion}`);
|
|
2722
|
+
const fresh = await replay(project, input);
|
|
2723
|
+
const builder = new PlanBuilder(input.read, {});
|
|
2724
|
+
await builder.captureProject();
|
|
2725
|
+
const compared = await compareFiles(project, fresh, input.read, builder, {
|
|
2726
|
+
anySkipped: (input.skipped ?? []).length > 0,
|
|
2727
|
+
decisions: input.decisions ?? {},
|
|
2728
|
+
...input.isSymlink ? {
|
|
2729
|
+
isSymlink: input.isSymlink
|
|
2730
|
+
} : {}
|
|
2731
|
+
});
|
|
2732
|
+
await editWorkspace(fresh, input.read, builder, void 0 !== project.local);
|
|
2733
|
+
await editTurbo(fresh, input.read, builder);
|
|
2734
|
+
const local = void 0 !== project.local;
|
|
2735
|
+
const packageFiles = new Set([
|
|
2736
|
+
...fresh.files.keys(),
|
|
2737
|
+
...Object.values(project.apps).map((a)=>('' === a.path ? '' : `${a.path}/`) + 'package.json'),
|
|
2738
|
+
'package.json'
|
|
2739
|
+
].filter(isPackageFile));
|
|
2740
|
+
for (const file of [
|
|
2741
|
+
...packageFiles
|
|
2742
|
+
].toSorted(byCodePoint))if (await input.read(file)) await editPackage(file, fresh, input.toVersion, local, builder);
|
|
2743
|
+
const migrations = await runMigrations(project, input, builder);
|
|
2744
|
+
const skipped = input.skipped ?? [];
|
|
2745
|
+
const planned = (file)=>builder.content(file);
|
|
2746
|
+
const removed = 0 === skipped.length ? await findRemoved(project, fresh, planned) : [];
|
|
2747
|
+
const built = builder.build();
|
|
2748
|
+
const plan = {
|
|
2749
|
+
...built,
|
|
2750
|
+
changes: built.changes.map((change)=>{
|
|
2751
|
+
const kept = keepLineEndings(change);
|
|
2752
|
+
kept.trust = fresh.thirdParty.has(kept.path) ? 'third-party' : kept.trust;
|
|
2753
|
+
return kept;
|
|
2754
|
+
}),
|
|
2755
|
+
dependencies: built.dependencies.map((dep)=>{
|
|
2756
|
+
if (fresh.thirdPartyDeps.has(depKey(dep.file, dep.name))) dep.thirdParty = true;
|
|
2757
|
+
return dep;
|
|
2758
|
+
}),
|
|
2759
|
+
features: fresh.features,
|
|
2760
|
+
warnings: [
|
|
2761
|
+
...fresh.warnings,
|
|
2762
|
+
...compared.notes,
|
|
2763
|
+
...built.warnings,
|
|
2764
|
+
...skipped.map(({ name, reason })=>`${name} was not replayed (${reason}); its files were left as they are`)
|
|
2765
|
+
]
|
|
2766
|
+
};
|
|
2767
|
+
const files = {
|
|
2768
|
+
...project.files
|
|
2769
|
+
};
|
|
2770
|
+
const kept = {
|
|
2771
|
+
...project.kept
|
|
2772
|
+
};
|
|
2773
|
+
for (const file of compared.matching)files[file] = hashContent(fresh.files.get(file) ?? '');
|
|
2774
|
+
for (const entry of compared.diverged)if ('accept' === entry.decision) {
|
|
2775
|
+
files[entry.path] = hashContent(entry.incoming);
|
|
2776
|
+
delete kept[entry.path];
|
|
2777
|
+
}
|
|
2778
|
+
for (const entry of compared.kept){
|
|
2779
|
+
files[entry.path] = hashContent(entry.yours);
|
|
2780
|
+
kept[entry.path] = hashContent(entry.incoming);
|
|
2781
|
+
}
|
|
2782
|
+
const next = {
|
|
2783
|
+
...project,
|
|
2784
|
+
cli: input.toVersion
|
|
2785
|
+
};
|
|
2786
|
+
if (Object.keys(files).length > 0) next.files = files;
|
|
2787
|
+
if (Object.keys(kept).length > 0) next.kept = kept;
|
|
2788
|
+
else delete next.kept;
|
|
2789
|
+
return {
|
|
2790
|
+
from: baseline.version,
|
|
2791
|
+
fromSource: baseline.source,
|
|
2792
|
+
to: input.toVersion,
|
|
2793
|
+
plan,
|
|
2794
|
+
project: next,
|
|
2795
|
+
diverged: compared.diverged,
|
|
2796
|
+
migrations,
|
|
2797
|
+
removed,
|
|
2798
|
+
missing: compared.missing
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
const RUNTIME_CONFIG_FILE = /^runtime-config(?:\.([a-z0-9-]+))?\.json$/;
|
|
2802
|
+
async function runtimeConfigFiles(folder) {
|
|
2803
|
+
const names = await promises.readdir(folder).catch(()=>[]);
|
|
2804
|
+
return names.filter((name)=>RUNTIME_CONFIG_FILE.test(name)).toSorted((a, b)=>a.localeCompare(b));
|
|
2805
|
+
}
|
|
2806
|
+
const ok = (value)=>({
|
|
2807
|
+
ok: true,
|
|
2808
|
+
value
|
|
2809
|
+
});
|
|
2810
|
+
const evaluate_fail = (reason)=>({
|
|
2811
|
+
ok: false,
|
|
2812
|
+
reason
|
|
2813
|
+
});
|
|
2814
|
+
function evaluate_unwrap(node) {
|
|
2815
|
+
if (!node) return;
|
|
2816
|
+
if (isTSAsExpression(node) || isTSSatisfiesExpression(node) || isTSNonNullExpression(node) || isTSTypeAssertion(node)) return evaluate_unwrap(node.expression);
|
|
2817
|
+
return node;
|
|
2818
|
+
}
|
|
2819
|
+
function requireSource(node) {
|
|
2820
|
+
const n = evaluate_unwrap(node);
|
|
2821
|
+
if (isCallExpression(n) && isIdentifier(n.callee, {
|
|
2822
|
+
name: 'require'
|
|
2823
|
+
})) {
|
|
2824
|
+
const arg = n.arguments[0];
|
|
2825
|
+
return isStringLiteral(arg) ? {
|
|
2826
|
+
source: arg.value,
|
|
2827
|
+
path: []
|
|
2828
|
+
} : void 0;
|
|
2829
|
+
}
|
|
2830
|
+
if (isMemberExpression(n) && !n.computed && isIdentifier(n.property)) {
|
|
2831
|
+
const inner = requireSource(n.object);
|
|
2832
|
+
return inner ? {
|
|
2833
|
+
source: inner.source,
|
|
2834
|
+
path: [
|
|
2835
|
+
...inner.path,
|
|
2836
|
+
n.property.name
|
|
2837
|
+
]
|
|
2838
|
+
} : void 0;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
function evaluate_keyName(key) {
|
|
2842
|
+
if (isIdentifier(key)) return key.name;
|
|
2843
|
+
if (isStringLiteral(key)) return key.value;
|
|
2844
|
+
}
|
|
2845
|
+
function bindVariables(statement, bindings) {
|
|
2846
|
+
if (!isVariableDeclaration(statement)) return;
|
|
2847
|
+
for (const decl of statement.declarations){
|
|
2848
|
+
const required = requireSource(decl.init);
|
|
2849
|
+
if (isIdentifier(decl.id)) if (required) bindings.set(decl.id.name, {
|
|
2850
|
+
kind: 'import',
|
|
2851
|
+
source: required.source,
|
|
2852
|
+
imported: required.path.at(-1) ?? 'default',
|
|
2853
|
+
depth: required.path.length
|
|
2854
|
+
});
|
|
2855
|
+
else if ('const' === statement.kind && decl.init) bindings.set(decl.id.name, {
|
|
2856
|
+
kind: 'const',
|
|
2857
|
+
init: decl.init
|
|
2858
|
+
});
|
|
2859
|
+
else bindings.set(decl.id.name, {
|
|
2860
|
+
kind: 'other',
|
|
2861
|
+
what: `a ${statement.kind} variable`
|
|
2862
|
+
});
|
|
2863
|
+
else if (isObjectPattern(decl.id)) for (const prop of decl.id.properties){
|
|
2864
|
+
if (!isObjectProperty(prop) || !isIdentifier(prop.value)) continue;
|
|
2865
|
+
const imported = evaluate_keyName(prop.key);
|
|
2866
|
+
if (required && imported) bindings.set(prop.value.name, {
|
|
2867
|
+
kind: 'import',
|
|
2868
|
+
source: required.source,
|
|
2869
|
+
imported,
|
|
2870
|
+
depth: required.path.length + 1
|
|
2871
|
+
});
|
|
2872
|
+
else bindings.set(prop.value.name, {
|
|
2873
|
+
kind: 'other',
|
|
2874
|
+
what: 'a destructured value'
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
function isModuleExports(node) {
|
|
2880
|
+
return isMemberExpression(node) && isIdentifier(node.object, {
|
|
2881
|
+
name: 'module'
|
|
2882
|
+
}) && isIdentifier(node.property, {
|
|
2883
|
+
name: 'exports'
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
const MUTATING_METHODS = new Set([
|
|
2887
|
+
'push',
|
|
2888
|
+
'pop',
|
|
2889
|
+
'shift',
|
|
2890
|
+
'unshift',
|
|
2891
|
+
'splice',
|
|
2892
|
+
'sort',
|
|
2893
|
+
'reverse',
|
|
2894
|
+
'fill',
|
|
2895
|
+
'copyWithin',
|
|
2896
|
+
'set',
|
|
2897
|
+
'add',
|
|
2898
|
+
'delete',
|
|
2899
|
+
'clear',
|
|
2900
|
+
'assign'
|
|
2901
|
+
]);
|
|
2902
|
+
const MUTATING_STATICS = new Set([
|
|
2903
|
+
'assign',
|
|
2904
|
+
'defineProperty',
|
|
2905
|
+
'defineProperties',
|
|
2906
|
+
'setPrototypeOf'
|
|
2907
|
+
]);
|
|
2908
|
+
function rootName(node) {
|
|
2909
|
+
let n = evaluate_unwrap(node);
|
|
2910
|
+
while(isMemberExpression(n) || n?.type === 'OptionalMemberExpression')n = evaluate_unwrap(n.object);
|
|
2911
|
+
return isIdentifier(n) ? n.name : void 0;
|
|
2912
|
+
}
|
|
2913
|
+
function mutatedNames(ast) {
|
|
2914
|
+
const names = new Set();
|
|
2915
|
+
const add = (name)=>{
|
|
2916
|
+
if (name) names.add(name);
|
|
2917
|
+
};
|
|
2918
|
+
for (const node of findAll(ast, (n)=>isAssignmentExpression(n) || 'UpdateExpression' === n.type || isUnaryExpression(n) || isCallExpression(n)))if (isAssignmentExpression(node)) add(rootName(node.left));
|
|
2919
|
+
else if ('UpdateExpression' === node.type) add(rootName(node.argument));
|
|
2920
|
+
else if (isUnaryExpression(node)) {
|
|
2921
|
+
if ('delete' === node.operator) add(rootName(node.argument));
|
|
2922
|
+
} else if (isCallExpression(node)) {
|
|
2923
|
+
const callee = evaluate_unwrap(node.callee);
|
|
2924
|
+
if (!isMemberExpression(callee) || callee.computed || !isIdentifier(callee.property)) continue;
|
|
2925
|
+
if (isIdentifier(callee.object, {
|
|
2926
|
+
name: 'Object'
|
|
2927
|
+
}) && MUTATING_STATICS.has(callee.property.name)) add(rootName(node.arguments[0]));
|
|
2928
|
+
else if (MUTATING_METHODS.has(callee.property.name)) add(rootName(callee.object));
|
|
2929
|
+
}
|
|
2930
|
+
return names;
|
|
2931
|
+
}
|
|
2932
|
+
function scopeOf(ast, file) {
|
|
2933
|
+
const bindings = new Map();
|
|
2934
|
+
const exports = new Map();
|
|
2935
|
+
const exportedFrom = new Map();
|
|
2936
|
+
const localExports = [];
|
|
2937
|
+
for (const statement of ast.program.body)if (isImportDeclaration(statement)) for (const spec of statement.specifiers){
|
|
2938
|
+
const imported = isImportDefaultSpecifier(spec) ? 'default' : isImportNamespaceSpecifier(spec) ? '*' : evaluate_keyName(spec.imported) ?? '*';
|
|
2939
|
+
bindings.set(spec.local.name, {
|
|
2940
|
+
kind: 'import',
|
|
2941
|
+
source: statement.source.value,
|
|
2942
|
+
imported
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
else if (isVariableDeclaration(statement)) bindVariables(statement, bindings);
|
|
2946
|
+
else if (isFunctionDeclaration(statement) || isClassDeclaration(statement)) {
|
|
2947
|
+
if (statement.id) bindings.set(statement.id.name, {
|
|
2948
|
+
kind: 'other',
|
|
2949
|
+
what: 'a function or class'
|
|
2950
|
+
});
|
|
2951
|
+
} else if (isExportNamedDeclaration(statement)) {
|
|
2952
|
+
const decl = statement.declaration;
|
|
2953
|
+
if (decl && isVariableDeclaration(decl)) {
|
|
2954
|
+
bindVariables(decl, bindings);
|
|
2955
|
+
for (const d of decl.declarations)if (isIdentifier(d.id) && d.init) {
|
|
2956
|
+
exports.set(d.id.name, d.init);
|
|
2957
|
+
exportedFrom.set(d.id.name, d.id.name);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
if (!statement.source) for (const spec of statement.specifiers){
|
|
2961
|
+
if (!isExportSpecifier(spec)) continue;
|
|
2962
|
+
const exported = evaluate_keyName(spec.exported);
|
|
2963
|
+
const local = evaluate_keyName(spec.local);
|
|
2964
|
+
if (exported && local) localExports.push([
|
|
2965
|
+
exported,
|
|
2966
|
+
local
|
|
2967
|
+
]);
|
|
2968
|
+
}
|
|
2969
|
+
} else if (isExportDefaultDeclaration(statement)) exports.set('default', statement.declaration);
|
|
2970
|
+
else if (isExpressionStatement(statement)) {
|
|
2971
|
+
const expr = statement.expression;
|
|
2972
|
+
if (isAssignmentExpression(expr) && isModuleExports(expr.left)) exports.set('default', expr.right);
|
|
2973
|
+
}
|
|
2974
|
+
for (const [exported, local] of localExports){
|
|
2975
|
+
const binding = bindings.get(local);
|
|
2976
|
+
if (binding?.kind === 'const') {
|
|
2977
|
+
exports.set(exported, binding.init);
|
|
2978
|
+
exportedFrom.set(exported, local);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
return {
|
|
2982
|
+
file,
|
|
2983
|
+
bindings,
|
|
2984
|
+
exports,
|
|
2985
|
+
exportedFrom,
|
|
2986
|
+
mutated: mutatedNames(ast)
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
function isEnvObject(n) {
|
|
2990
|
+
if (!isMemberExpression(n) || n.computed || !isIdentifier(n.property, {
|
|
2991
|
+
name: 'env'
|
|
2992
|
+
})) return;
|
|
2993
|
+
if (isIdentifier(n.object, {
|
|
2994
|
+
name: 'process'
|
|
2995
|
+
})) return 'process.env';
|
|
2996
|
+
if (isMetaProperty(n.object)) return 'import.meta.env';
|
|
2997
|
+
}
|
|
2998
|
+
function envAccess(node) {
|
|
2999
|
+
for (const found of findAll(node, (n)=>isMemberExpression(n) || isMetaProperty(n))){
|
|
3000
|
+
if (isMemberExpression(found)) {
|
|
3001
|
+
const base = isEnvObject(found.object);
|
|
3002
|
+
if (base) {
|
|
3003
|
+
const key = isIdentifier(found.property) ? found.property.name : isStringLiteral(found.property) ? found.property.value : void 0;
|
|
3004
|
+
return key && (!found.computed || isStringLiteral(found.property)) ? `${base}.${key}` : base;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
const whole = isEnvObject(found);
|
|
3008
|
+
if (whole) return whole;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
function calleeName(node) {
|
|
3012
|
+
const n = evaluate_unwrap(node);
|
|
3013
|
+
if (isIdentifier(n)) return n.name;
|
|
3014
|
+
if (isMemberExpression(n) && isIdentifier(n.property)) return `${calleeName(n.object)}.${n.property.name}`;
|
|
3015
|
+
return 'a function';
|
|
3016
|
+
}
|
|
3017
|
+
const CONVERSIONS = new Set([
|
|
3018
|
+
'Number',
|
|
3019
|
+
'String',
|
|
3020
|
+
'Boolean',
|
|
3021
|
+
'parseInt',
|
|
3022
|
+
'parseFloat'
|
|
3023
|
+
]);
|
|
3024
|
+
function reasonFor(node) {
|
|
3025
|
+
const call = 'CallExpression' !== node.type || CONVERSIONS.has(calleeName(node.callee)) ? void 0 : calleeName(node.callee);
|
|
3026
|
+
if (call) return `comes from a function call (${call}())`;
|
|
3027
|
+
const env = envAccess(node);
|
|
3028
|
+
if (env) return `reads an env variable (${env})`;
|
|
3029
|
+
if (isCallExpression(node)) return `comes from a function call (${calleeName(node.callee)}())`;
|
|
3030
|
+
if (isNewExpression(node)) return `is built with new ${calleeName(node.callee)}()`;
|
|
3031
|
+
if (isArrowFunctionExpression(node) || isFunctionExpression(node)) return 'is a function';
|
|
3032
|
+
if (isMemberExpression(node)) return `is read from another value (${calleeName(node)})`;
|
|
3033
|
+
if (isConditionalExpression(node)) return 'depends on a condition (a ? b : c)';
|
|
3034
|
+
if (isLogicalExpression(node) || isBinaryExpression(node)) return `is computed with ${node.operator}`;
|
|
3035
|
+
if (isTemplateLiteral(node)) return 'is a template string with ${} parts';
|
|
3036
|
+
if (isAwaitExpression(node)) return 'is awaited';
|
|
3037
|
+
return `is not a literal (${node.type})`;
|
|
3038
|
+
}
|
|
3039
|
+
function identifierReason(name, binding) {
|
|
3040
|
+
if (!binding) return `refers to ${name}, which is not a const in this file`;
|
|
3041
|
+
if ('import' === binding.kind) return `is imported (${name} from '${binding.source}')`;
|
|
3042
|
+
return `refers to ${name}, which is ${'other' === binding.kind ? binding.what : 'not a const'}`;
|
|
3043
|
+
}
|
|
3044
|
+
function resolveNode(node, scope, seen = new Set()) {
|
|
3045
|
+
const n = evaluate_unwrap(node);
|
|
3046
|
+
if (!n) return evaluate_fail('is missing');
|
|
3047
|
+
if (!isIdentifier(n)) return ok(n);
|
|
3048
|
+
const binding = scope.bindings.get(n.name);
|
|
3049
|
+
if (binding?.kind === 'const' && scope.mutated.has(n.name)) return evaluate_fail(`refers to ${n.name}, which is changed later in the file`);
|
|
3050
|
+
if (binding?.kind !== 'const' || seen.has(n.name)) return evaluate_fail(seen.has(n.name) ? `refers to ${n.name}, which refers back to itself` : identifierReason(n.name, binding));
|
|
3051
|
+
return resolveNode(binding.init, scope, new Set([
|
|
3052
|
+
...seen,
|
|
3053
|
+
n.name
|
|
3054
|
+
]));
|
|
3055
|
+
}
|
|
3056
|
+
function evaluate_entriesOf(object, scope, seen = new Set()) {
|
|
3057
|
+
const entries = [];
|
|
3058
|
+
const problems = [];
|
|
3059
|
+
for (const prop of object.properties){
|
|
3060
|
+
if ('SpreadElement' === prop.type) {
|
|
3061
|
+
const arg = evaluate_unwrap(prop.argument);
|
|
3062
|
+
if (isIdentifier(arg)) {
|
|
3063
|
+
const binding = scope.bindings.get(arg.name);
|
|
3064
|
+
if (binding?.kind === 'import') {
|
|
3065
|
+
problems.push({
|
|
3066
|
+
reason: `spreads an imported object (${arg.name} from '${binding.source}')`
|
|
3067
|
+
});
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
const target = resolveNode(arg, scope, seen);
|
|
3072
|
+
if (target.ok) if ('ObjectExpression' === target.value.type) {
|
|
3073
|
+
const name = isIdentifier(arg) ? arg.name : '';
|
|
3074
|
+
const inner = evaluate_entriesOf(target.value, scope, new Set([
|
|
3075
|
+
...seen,
|
|
3076
|
+
name
|
|
3077
|
+
]));
|
|
3078
|
+
entries.push(...inner.entries);
|
|
3079
|
+
problems.push(...inner.problems);
|
|
3080
|
+
} else problems.push({
|
|
3081
|
+
reason: `spreads a value that ${reasonFor(target.value)}`
|
|
3082
|
+
});
|
|
3083
|
+
else problems.push({
|
|
3084
|
+
reason: `spreads a value that ${target.reason}`
|
|
3085
|
+
});
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
const key = prop.computed ? void 0 : evaluate_keyName(prop.key);
|
|
3089
|
+
if (void 0 === key) {
|
|
3090
|
+
problems.push({
|
|
3091
|
+
reason: 'has a computed key'
|
|
3092
|
+
});
|
|
3093
|
+
continue;
|
|
3094
|
+
}
|
|
3095
|
+
if ('ObjectMethod' === prop.type) {
|
|
3096
|
+
problems.push({
|
|
3097
|
+
key,
|
|
3098
|
+
reason: 'is a method'
|
|
3099
|
+
});
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
entries.push({
|
|
3103
|
+
key,
|
|
3104
|
+
value: prop.value
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
return {
|
|
3108
|
+
entries,
|
|
3109
|
+
problems
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
function evaluateArray(array, scope, seen) {
|
|
3113
|
+
const out = [];
|
|
3114
|
+
for (const element of array.elements){
|
|
3115
|
+
if (!element) return evaluate_fail('has an empty slot');
|
|
3116
|
+
if ('SpreadElement' === element.type) return evaluate_fail('spreads into an array');
|
|
3117
|
+
const item = evaluate(element, scope, seen);
|
|
3118
|
+
if (!item.ok) return item;
|
|
3119
|
+
out.push(item.value);
|
|
3120
|
+
}
|
|
3121
|
+
return ok(out);
|
|
3122
|
+
}
|
|
3123
|
+
function evaluate(node, scope, seen = new Set()) {
|
|
3124
|
+
const n = evaluate_unwrap(node);
|
|
3125
|
+
if (!n) return evaluate_fail('is missing');
|
|
3126
|
+
if (isStringLiteral(n) || isNumericLiteral(n) || isBooleanLiteral(n)) return ok(n.value);
|
|
3127
|
+
if (isNullLiteral(n)) return ok(null);
|
|
3128
|
+
if (isTemplateLiteral(n)) return 0 === n.expressions.length ? ok(n.quasis.map((q)=>q.value.cooked ?? q.value.raw).join('')) : evaluate_fail(reasonFor(n));
|
|
3129
|
+
if (isUnaryExpression(n)) return '-' === n.operator && isNumericLiteral(n.argument) ? ok(-n.argument.value) : evaluate_fail(reasonFor(n));
|
|
3130
|
+
if (isIdentifier(n)) {
|
|
3131
|
+
const target = resolveNode(n, scope, seen);
|
|
3132
|
+
return target.ok ? evaluate(target.value, scope, new Set([
|
|
3133
|
+
...seen,
|
|
3134
|
+
n.name
|
|
3135
|
+
])) : target;
|
|
3136
|
+
}
|
|
3137
|
+
if (isObjectExpression(n)) {
|
|
3138
|
+
const { entries, problems } = evaluate_entriesOf(n, scope, seen);
|
|
3139
|
+
const problem = problems[0];
|
|
3140
|
+
if (problem) return evaluate_fail(problem.key ? `${problem.key} ${problem.reason}` : problem.reason);
|
|
3141
|
+
const out = {};
|
|
3142
|
+
for (const entry of entries){
|
|
3143
|
+
const value = evaluate(entry.value, scope, seen);
|
|
3144
|
+
if (!value.ok) return evaluate_fail(`${entry.key} ${value.reason}`);
|
|
3145
|
+
out[entry.key] = value.value;
|
|
3146
|
+
}
|
|
3147
|
+
return ok(out);
|
|
3148
|
+
}
|
|
3149
|
+
if (isArrayExpression(n)) return evaluateArray(n, scope, seen);
|
|
3150
|
+
return evaluate_fail(reasonFor(n));
|
|
3151
|
+
}
|
|
3152
|
+
const MAX_FILE_BYTES = 1000000;
|
|
3153
|
+
function isInside(root, target) {
|
|
3154
|
+
const rel = node_path.relative(root, target);
|
|
3155
|
+
return '' === rel || '..' !== rel && !rel.startsWith(`..${node_path.sep}`) && !node_path.isAbsolute(rel);
|
|
3156
|
+
}
|
|
3157
|
+
const inNodeModules = (root, target)=>node_path.relative(root, target).split(node_path.sep).includes('node_modules');
|
|
3158
|
+
const isMissing = (error)=>hasErrno(error, 'ENOENT') || hasErrno(error, 'ENOTDIR');
|
|
3159
|
+
function codeOf(error) {
|
|
3160
|
+
return 'object' == typeof error && null !== error && 'code' in error ? String(error.code) : 'unknown error';
|
|
3161
|
+
}
|
|
3162
|
+
function guardedDisk(root, refused) {
|
|
3163
|
+
const shown = (file)=>node_path.relative(root, file).split(node_path.sep).join('/') || '.';
|
|
3164
|
+
const refuse = (file, reason)=>{
|
|
3165
|
+
if (!refused.some((r)=>r.file === shown(file) && r.reason === reason)) refused.push({
|
|
3166
|
+
file: shown(file),
|
|
3167
|
+
reason
|
|
3168
|
+
});
|
|
3169
|
+
};
|
|
3170
|
+
let realRoot;
|
|
3171
|
+
const rootReal = ()=>realRoot ??= promises.realpath(root);
|
|
3172
|
+
async function allowed(file) {
|
|
3173
|
+
if (inNodeModules(root, file)) return void refuse(file, 'is inside node_modules, which is never read');
|
|
3174
|
+
try {
|
|
3175
|
+
const [realFile, base] = await Promise.all([
|
|
3176
|
+
promises.realpath(file),
|
|
3177
|
+
rootReal()
|
|
3178
|
+
]);
|
|
3179
|
+
if (!isInside(base, realFile)) return void refuse(file, 'is (or goes through) a link that leads outside the project');
|
|
3180
|
+
if (inNodeModules(base, realFile)) return void refuse(file, 'is (or goes through) a link into node_modules');
|
|
3181
|
+
return realFile;
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
if (!isMissing(error)) refuse(file, `could not be read (${codeOf(error)})`);
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
return {
|
|
3188
|
+
read: async (file)=>{
|
|
3189
|
+
const real = await allowed(file);
|
|
3190
|
+
if (void 0 === real) return;
|
|
3191
|
+
try {
|
|
3192
|
+
const { size } = await promises.stat(real);
|
|
3193
|
+
if (size > MAX_FILE_BYTES) return void refuse(file, `is larger than ${String(MAX_FILE_BYTES / 1000000)} MB, so it is not read`);
|
|
3194
|
+
return await promises.readFile(real, 'utf8');
|
|
3195
|
+
} catch (error) {
|
|
3196
|
+
if (!isMissing(error)) refuse(file, `could not be read (${codeOf(error)})`);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
},
|
|
3200
|
+
listDirs: async (dir)=>{
|
|
3201
|
+
const real = await allowed(dir);
|
|
3202
|
+
if (void 0 === real) return [];
|
|
3203
|
+
try {
|
|
3204
|
+
return (await promises.readdir(real, {
|
|
3205
|
+
withFileTypes: true
|
|
3206
|
+
})).filter((e)=>e.isDirectory()).map((e)=>e.name);
|
|
3207
|
+
} catch (error) {
|
|
3208
|
+
if (!isMissing(error)) refuse(dir, `could not be read (${codeOf(error)})`);
|
|
3209
|
+
return [];
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
const fieldOf = (base, key)=>/^[A-Za-z_$][\w$]*$/.test(key) ? `${base}.${key}` : `${base}['${key}']`;
|
|
3215
|
+
const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
3216
|
+
function parseRemote(value) {
|
|
3217
|
+
if (value.startsWith('promise ')) return evaluate_fail('is loaded by inline code (promise …)');
|
|
3218
|
+
const match = /^([^@/:\s]+)@(.+)$/.exec(value);
|
|
3219
|
+
const entry = match?.[2] ?? value;
|
|
3220
|
+
if (!entry.includes('/')) return evaluate_fail(`has no URL ("${value}")`);
|
|
3221
|
+
const type = /\.json([?#]|$)/.test(entry) ? 'manifest' : "script";
|
|
3222
|
+
return ok(match?.[1] ? {
|
|
3223
|
+
name: match[1],
|
|
3224
|
+
entry,
|
|
3225
|
+
type
|
|
3226
|
+
} : {
|
|
3227
|
+
entry,
|
|
3228
|
+
type
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
function objectAt(value, scope, field, report) {
|
|
3232
|
+
const target = resolveNode(value, scope);
|
|
3233
|
+
if (!target.ok) return void report(field, target.reason);
|
|
3234
|
+
if ('ObjectExpression' !== target.value.type) {
|
|
3235
|
+
const evaluated = evaluate(target.value, scope);
|
|
3236
|
+
report(field, evaluated.ok ? 'is not an object' : evaluated.reason);
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
const { entries, problems } = evaluate_entriesOf(target.value, scope);
|
|
3240
|
+
for (const problem of problems)report(problem.key ? fieldOf(field, problem.key) : field, problem.reason);
|
|
3241
|
+
return entries;
|
|
3242
|
+
}
|
|
3243
|
+
function readRemotes(value, scope, report) {
|
|
3244
|
+
const remotes = {};
|
|
3245
|
+
for (const { key, value: node } of objectAt(value, scope, 'remotes', report) ?? []){
|
|
3246
|
+
const field = fieldOf('remotes', key);
|
|
3247
|
+
const evaluated = evaluate(node, scope);
|
|
3248
|
+
if (!evaluated.ok) {
|
|
3249
|
+
report(field, evaluated.reason);
|
|
3250
|
+
continue;
|
|
3251
|
+
}
|
|
3252
|
+
const raw = evaluated.value;
|
|
3253
|
+
const text = isRecord(raw) ? raw['external'] : raw;
|
|
3254
|
+
if ('string' != typeof text) {
|
|
3255
|
+
report(field, isRecord(raw) ? 'has no string external' : 'is not a string');
|
|
3256
|
+
continue;
|
|
3257
|
+
}
|
|
3258
|
+
const remote = parseRemote(text);
|
|
3259
|
+
if (remote.ok) remotes[key] = remote.value;
|
|
3260
|
+
else report(field, remote.reason);
|
|
3261
|
+
}
|
|
3262
|
+
return remotes;
|
|
3263
|
+
}
|
|
3264
|
+
function readExposes(value, scope, report) {
|
|
3265
|
+
const exposes = {};
|
|
3266
|
+
const target = resolveNode(value, scope);
|
|
3267
|
+
if (target.ok && 'ArrayExpression' === target.value.type) {
|
|
3268
|
+
const evaluated = evaluate(target.value, scope);
|
|
3269
|
+
if (evaluated.ok) if (Array.isArray(evaluated.value)) for (const item of evaluated.value)if ('string' == typeof item) exposes[item] = item;
|
|
3270
|
+
else report('exposes', 'is an array with items that are not strings');
|
|
3271
|
+
else report('exposes', 'is not a list');
|
|
3272
|
+
else report('exposes', evaluated.reason);
|
|
3273
|
+
return exposes;
|
|
3274
|
+
}
|
|
3275
|
+
for (const { key, value: node } of objectAt(value, scope, 'exposes', report) ?? []){
|
|
3276
|
+
const field = fieldOf('exposes', key);
|
|
3277
|
+
const evaluated = evaluate(node, scope);
|
|
3278
|
+
if (!evaluated.ok) {
|
|
3279
|
+
report(field, evaluated.reason);
|
|
3280
|
+
continue;
|
|
3281
|
+
}
|
|
3282
|
+
const raw = evaluated.value;
|
|
3283
|
+
const path = isRecord(raw) ? raw['import'] : raw;
|
|
3284
|
+
if ('string' == typeof path) exposes[key] = path;
|
|
3285
|
+
else report(field, 'is not a module path');
|
|
3286
|
+
}
|
|
3287
|
+
return exposes;
|
|
3288
|
+
}
|
|
3289
|
+
const NOT_CARRIED = 'is set in the config but adopt does not carry it over';
|
|
3290
|
+
const NOT_CARRIED_SHARED = new Set([
|
|
3291
|
+
'eager',
|
|
3292
|
+
'strictVersion',
|
|
3293
|
+
'import'
|
|
3294
|
+
]);
|
|
3295
|
+
const NOT_CARRIED_OPTIONS = new Set([
|
|
3296
|
+
'filename',
|
|
3297
|
+
'shareStrategy',
|
|
3298
|
+
'runtimePlugins'
|
|
3299
|
+
]);
|
|
3300
|
+
function readSharedEntry(key, node, scope, report) {
|
|
3301
|
+
const field = fieldOf('shared', key);
|
|
3302
|
+
const target = resolveNode(node, scope);
|
|
3303
|
+
if (!target.ok) return void report(field, target.reason);
|
|
3304
|
+
if ('ObjectExpression' !== target.value.type) {
|
|
3305
|
+
const evaluated = evaluate(target.value, scope);
|
|
3306
|
+
if (evaluated.ok) {
|
|
3307
|
+
if ('string' == typeof evaluated.value) return {
|
|
3308
|
+
requiredVersion: evaluated.value
|
|
3309
|
+
};
|
|
3310
|
+
report(field, 'is not a version range or an options object');
|
|
3311
|
+
} else report(field, evaluated.reason);
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
const shared = {};
|
|
3315
|
+
const { entries, problems } = evaluate_entriesOf(target.value, scope);
|
|
3316
|
+
for (const problem of problems)report(problem.key ? fieldOf(field, problem.key) : field, problem.reason);
|
|
3317
|
+
for (const entry of entries){
|
|
3318
|
+
const at = fieldOf(field, entry.key);
|
|
3319
|
+
if (NOT_CARRIED_SHARED.has(entry.key)) {
|
|
3320
|
+
const evaluated = evaluate(entry.value, scope);
|
|
3321
|
+
const plainlyOff = 'import' !== entry.key && evaluated.ok && false === evaluated.value;
|
|
3322
|
+
if (!plainlyOff) report(at, NOT_CARRIED);
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
if ('singleton' !== entry.key && 'requiredVersion' !== entry.key) continue;
|
|
3326
|
+
const evaluated = evaluate(entry.value, scope);
|
|
3327
|
+
if (evaluated.ok) if ('singleton' === entry.key && 'boolean' == typeof evaluated.value) shared.singleton = evaluated.value;
|
|
3328
|
+
else if ('requiredVersion' === entry.key && ('string' == typeof evaluated.value || false === evaluated.value)) shared.requiredVersion = evaluated.value;
|
|
3329
|
+
else report(at, 'singleton' === entry.key ? 'is not true or false' : 'is not a version range');
|
|
3330
|
+
else report(at, evaluated.reason);
|
|
3331
|
+
}
|
|
3332
|
+
return shared;
|
|
3333
|
+
}
|
|
3334
|
+
function readShared(value, scope, report) {
|
|
3335
|
+
const shared = {};
|
|
3336
|
+
const target = resolveNode(value, scope);
|
|
3337
|
+
if (target.ok && 'ArrayExpression' === target.value.type) {
|
|
3338
|
+
for (const [index, element] of target.value.elements.entries()){
|
|
3339
|
+
const evaluated = evaluate(element, scope);
|
|
3340
|
+
if (evaluated.ok && 'string' == typeof evaluated.value) shared[evaluated.value] = {};
|
|
3341
|
+
else report(`shared[${String(index)}]`, evaluated.ok ? 'is not a package name' : evaluated.reason);
|
|
3342
|
+
}
|
|
3343
|
+
return shared;
|
|
3344
|
+
}
|
|
3345
|
+
for (const { key, value: node } of objectAt(value, scope, 'shared', report) ?? []){
|
|
3346
|
+
const entry = readSharedEntry(key, node, scope, report);
|
|
3347
|
+
if (entry) shared[key] = entry;
|
|
3348
|
+
}
|
|
3349
|
+
return shared;
|
|
3350
|
+
}
|
|
3351
|
+
function readFederationOptions(options, scope, report) {
|
|
3352
|
+
const result = {
|
|
3353
|
+
remotes: {},
|
|
3354
|
+
exposes: {},
|
|
3355
|
+
shared: {},
|
|
3356
|
+
exposing: false
|
|
3357
|
+
};
|
|
3358
|
+
const { entries, problems } = evaluate_entriesOf(options, scope);
|
|
3359
|
+
for (const problem of problems)report(problem.key ?? 'options', problem.reason);
|
|
3360
|
+
for (const { key, value } of entries)switch(key){
|
|
3361
|
+
case 'name':
|
|
3362
|
+
{
|
|
3363
|
+
const evaluated = evaluate(value, scope);
|
|
3364
|
+
if (evaluated.ok && 'string' == typeof evaluated.value) result.name = evaluated.value;
|
|
3365
|
+
else report('name', evaluated.ok ? 'is not a string' : evaluated.reason);
|
|
3366
|
+
break;
|
|
3367
|
+
}
|
|
3368
|
+
case 'remotes':
|
|
3369
|
+
result.remotes = readRemotes(value, scope, report);
|
|
3370
|
+
break;
|
|
3371
|
+
case 'exposes':
|
|
3372
|
+
{
|
|
3373
|
+
let unread = false;
|
|
3374
|
+
result.exposes = readExposes(value, scope, (field, reason)=>{
|
|
3375
|
+
unread = true;
|
|
3376
|
+
report(field, reason);
|
|
3377
|
+
});
|
|
3378
|
+
result.exposing = unread || Object.keys(result.exposes).length > 0;
|
|
3379
|
+
break;
|
|
3380
|
+
}
|
|
3381
|
+
case 'shared':
|
|
3382
|
+
result.shared = readShared(value, scope, report);
|
|
3383
|
+
break;
|
|
3384
|
+
default:
|
|
3385
|
+
if (NOT_CARRIED_OPTIONS.has(key)) report(key, NOT_CARRIED);
|
|
3386
|
+
break;
|
|
3387
|
+
}
|
|
3388
|
+
return result;
|
|
3389
|
+
}
|
|
3390
|
+
const SKIPPED_DIRS = new Set([
|
|
3391
|
+
'node_modules',
|
|
3392
|
+
'dist',
|
|
3393
|
+
'build',
|
|
3394
|
+
'out',
|
|
3395
|
+
'output',
|
|
3396
|
+
'coverage',
|
|
3397
|
+
'doc_build',
|
|
3398
|
+
'storybook-static'
|
|
3399
|
+
]);
|
|
3400
|
+
const workspace_MAX_DEPTH = 8;
|
|
3401
|
+
const MAX_VISITED = 5000;
|
|
3402
|
+
const workspace_skipped = (name)=>name.startsWith('.') || SKIPPED_DIRS.has(name) || name.startsWith('dist-');
|
|
3403
|
+
function toRegExp(segment) {
|
|
3404
|
+
const escaped = segment.replace(/[.+^${}()|[\]\\?]/g, String.raw`\$&`).replaceAll('*', '[^/]*');
|
|
3405
|
+
return new RegExp(`^${escaped}$`);
|
|
3406
|
+
}
|
|
3407
|
+
function globToRegExp(pattern) {
|
|
3408
|
+
const body = pattern.split('/').map((segment)=>'**' === segment ? '\0' : toRegExp(segment).source.slice(1, -1)).join('/').replaceAll('\0/', '(?:[^/]+/)*').replaceAll('/\0', '(?:/[^/]+)*').replaceAll('\0', '.*');
|
|
3409
|
+
return new RegExp(`^${body}$`);
|
|
3410
|
+
}
|
|
3411
|
+
function normalize(pattern) {
|
|
3412
|
+
const clean = pattern.replaceAll('\\', '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
3413
|
+
if ('' === clean || clean.startsWith('/') || clean.split('/').includes('..')) return;
|
|
3414
|
+
return clean;
|
|
3415
|
+
}
|
|
3416
|
+
function eachInSequence(items, fn) {
|
|
3417
|
+
return items.reduce((done, item)=>done.then(()=>fn(item)), Promise.resolve());
|
|
3418
|
+
}
|
|
3419
|
+
const collapse = (segments)=>segments.filter((segment, i)=>!('**' === segment && '**' === segments[i - 1]));
|
|
3420
|
+
async function childrenOf(walk, at) {
|
|
3421
|
+
walk.visited += 1;
|
|
3422
|
+
if (walk.visited > MAX_VISITED) return [];
|
|
3423
|
+
try {
|
|
3424
|
+
return (await walk.listDirs(node_path.join(walk.root, ...at))).filter((name)=>!workspace_skipped(name)).toSorted((a, b)=>a.localeCompare(b));
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
walk.problems.push({
|
|
3427
|
+
file: at.join('/') || '.',
|
|
3428
|
+
reason: `could not be listed (${messageOf(error)})`
|
|
3429
|
+
});
|
|
3430
|
+
return [];
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
async function expand(walk, segments, at = []) {
|
|
3434
|
+
const state = `${at.join('/')}|${segments.join('/')}`;
|
|
3435
|
+
if (walk.seen.has(state)) return;
|
|
3436
|
+
walk.seen.add(state);
|
|
3437
|
+
if (0 === segments.length) return void walk.found.add(at.join('/'));
|
|
3438
|
+
if (at.length > workspace_MAX_DEPTH) return;
|
|
3439
|
+
const [segment = '', ...rest] = segments;
|
|
3440
|
+
if (!segment.includes('*')) {
|
|
3441
|
+
if (!workspace_skipped(segment)) await expand(walk, rest, [
|
|
3442
|
+
...at,
|
|
3443
|
+
segment
|
|
3444
|
+
]);
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
const children = await childrenOf(walk, at);
|
|
3448
|
+
if ('**' === segment) {
|
|
3449
|
+
await expand(walk, rest, at);
|
|
3450
|
+
await eachInSequence(children, (child)=>expand(walk, segments, [
|
|
3451
|
+
...at,
|
|
3452
|
+
child
|
|
3453
|
+
]));
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
const match = toRegExp(segment);
|
|
3457
|
+
await eachInSequence(children.filter((c)=>match.test(c)), (child)=>expand(walk, rest, [
|
|
3458
|
+
...at,
|
|
3459
|
+
child
|
|
3460
|
+
]));
|
|
3461
|
+
}
|
|
3462
|
+
function stringList(value) {
|
|
3463
|
+
return Array.isArray(value) && value.every((v)=>'string' == typeof v) ? value : void 0;
|
|
3464
|
+
}
|
|
3465
|
+
async function safeRead(read, file, problems) {
|
|
3466
|
+
try {
|
|
3467
|
+
return await read(file);
|
|
3468
|
+
} catch (error) {
|
|
3469
|
+
problems.push({
|
|
3470
|
+
file: node_path.basename(file),
|
|
3471
|
+
reason: `could not be read (${messageOf(error)})`
|
|
3472
|
+
});
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
async function workspacePatterns(root, read, problems) {
|
|
3477
|
+
const patterns = [];
|
|
3478
|
+
const yamlText = await safeRead(read, node_path.join(root, 'pnpm-workspace.yaml'), problems);
|
|
3479
|
+
if (void 0 !== yamlText) try {
|
|
3480
|
+
const doc = external_yaml_parse(yamlText);
|
|
3481
|
+
const list = 'object' == typeof doc && null !== doc && 'packages' in doc ? stringList(doc.packages) : [];
|
|
3482
|
+
if (list) patterns.push(...list);
|
|
3483
|
+
else problems.push({
|
|
3484
|
+
file: 'pnpm-workspace.yaml',
|
|
3485
|
+
reason: 'packages is not a list'
|
|
3486
|
+
});
|
|
3487
|
+
} catch {
|
|
3488
|
+
problems.push({
|
|
3489
|
+
file: 'pnpm-workspace.yaml',
|
|
3490
|
+
reason: 'could not be parsed'
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
const pkgText = await safeRead(read, node_path.join(root, 'package.json'), problems);
|
|
3494
|
+
if (void 0 !== pkgText) try {
|
|
3495
|
+
const pkg = JSON.parse(pkgText);
|
|
3496
|
+
const workspaces = 'object' == typeof pkg && null !== pkg && 'workspaces' in pkg ? pkg.workspaces : void 0;
|
|
3497
|
+
const list = void 0 === workspaces ? [] : stringList(workspaces) ?? ('object' == typeof workspaces && null !== workspaces && 'packages' in workspaces ? stringList(workspaces.packages) : void 0);
|
|
3498
|
+
if (list) patterns.push(...list);
|
|
3499
|
+
else problems.push({
|
|
3500
|
+
file: 'package.json',
|
|
3501
|
+
reason: 'workspaces is not a list'
|
|
3502
|
+
});
|
|
3503
|
+
} catch {
|
|
3504
|
+
problems.push({
|
|
3505
|
+
file: 'package.json',
|
|
3506
|
+
reason: 'could not be parsed'
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
return patterns;
|
|
3510
|
+
}
|
|
3511
|
+
async function findAppDirs(root, read, listDirs, problems = []) {
|
|
3512
|
+
const patterns = await workspacePatterns(root, read, problems);
|
|
3513
|
+
const include = patterns.filter((p)=>!p.startsWith('!')).map(normalize);
|
|
3514
|
+
const exclude = patterns.filter((p)=>p.startsWith('!')).map((p)=>normalize(p.slice(1))).filter((p)=>void 0 !== p).map(globToRegExp);
|
|
3515
|
+
const walk = {
|
|
3516
|
+
root,
|
|
3517
|
+
listDirs,
|
|
3518
|
+
problems,
|
|
3519
|
+
visited: 0,
|
|
3520
|
+
seen: new Set(),
|
|
3521
|
+
found: new Set()
|
|
3522
|
+
};
|
|
3523
|
+
await eachInSequence(include, async (pattern)=>{
|
|
3524
|
+
if (void 0 !== pattern) await expand(walk, collapse(pattern.split('/')));
|
|
3525
|
+
});
|
|
3526
|
+
if (walk.visited > MAX_VISITED) problems.push({
|
|
3527
|
+
file: '.',
|
|
3528
|
+
reason: `has more than ${String(MAX_VISITED)} folders to search, so the workspace globs were cut short`
|
|
3529
|
+
});
|
|
3530
|
+
const candidates = [
|
|
3531
|
+
...walk.found
|
|
3532
|
+
].filter((dir)=>'' !== dir && !exclude.some((re)=>re.test(dir)));
|
|
3533
|
+
const withPackage = [];
|
|
3534
|
+
await eachInSequence(candidates, async (dir)=>{
|
|
3535
|
+
const file = node_path.join(root, ...dir.split('/'), 'package.json');
|
|
3536
|
+
withPackage.push(await safeRead(read, file, problems) === void 0 ? void 0 : dir);
|
|
3537
|
+
});
|
|
3538
|
+
const dirs = withPackage.filter((d)=>void 0 !== d);
|
|
3539
|
+
return [
|
|
3540
|
+
'.',
|
|
3541
|
+
...dirs.toSorted((a, b)=>a.localeCompare(b))
|
|
3542
|
+
];
|
|
3543
|
+
}
|
|
3544
|
+
const EXTENSIONS = [
|
|
3545
|
+
'.ts',
|
|
3546
|
+
'.mts',
|
|
3547
|
+
'.cts',
|
|
3548
|
+
'.js',
|
|
3549
|
+
'.mjs',
|
|
3550
|
+
'.cjs'
|
|
3551
|
+
];
|
|
3552
|
+
const BUILD_TOOLS = [
|
|
3553
|
+
'rsbuild',
|
|
3554
|
+
'rspack',
|
|
3555
|
+
'webpack'
|
|
3556
|
+
];
|
|
3557
|
+
const FEDERATION_CONFIG = 'module-federation.config';
|
|
3558
|
+
const MAX_IMPORT_HOPS = 3;
|
|
3559
|
+
const detect_relative = (root, file)=>node_path.relative(root, file).split(node_path.sep).join('/') || '.';
|
|
3560
|
+
function detect_report(ctx, file, field, reason) {
|
|
3561
|
+
ctx.notAdopted.push({
|
|
3562
|
+
dir: ctx.dir,
|
|
3563
|
+
file: detect_relative(ctx.root, file),
|
|
3564
|
+
field,
|
|
3565
|
+
reason
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
async function firstExisting(read, candidates) {
|
|
3569
|
+
const sources = await Promise.all(candidates.map((file)=>read(file)));
|
|
3570
|
+
const index = sources.findIndex((s)=>void 0 !== s);
|
|
3571
|
+
const source = sources[index];
|
|
3572
|
+
const file = candidates[index];
|
|
3573
|
+
return void 0 === source || void 0 === file ? void 0 : {
|
|
3574
|
+
file,
|
|
3575
|
+
source
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
function load(ctx, file, source) {
|
|
3579
|
+
try {
|
|
3580
|
+
const ast = parseSource(source, file);
|
|
3581
|
+
return {
|
|
3582
|
+
ast,
|
|
3583
|
+
scope: scopeOf(ast, file)
|
|
3584
|
+
};
|
|
3585
|
+
} catch {
|
|
3586
|
+
detect_report(ctx, file, 'file', 'could not be parsed');
|
|
3587
|
+
return;
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
function namesFor(scope, canonical) {
|
|
3591
|
+
const names = new Set([
|
|
3592
|
+
canonical
|
|
3593
|
+
]);
|
|
3594
|
+
for (const [local, binding] of scope.bindings)if ('import' === binding.kind && binding.imported === canonical) names.add(local);
|
|
3595
|
+
return names;
|
|
3596
|
+
}
|
|
3597
|
+
function patternNames(node, out) {
|
|
3598
|
+
if (!node) return;
|
|
3599
|
+
if (isIdentifier(node)) out.add(node.name);
|
|
3600
|
+
else if ('ObjectPattern' === node.type) for (const prop of node.properties)patternNames('RestElement' === prop.type ? prop.argument : prop.value, out);
|
|
3601
|
+
else if ('ArrayPattern' === node.type) for (const element of node.elements)patternNames(element, out);
|
|
3602
|
+
else if ('RestElement' === node.type) patternNames(node.argument, out);
|
|
3603
|
+
else if ('AssignmentPattern' === node.type) patternNames(node.left, out);
|
|
3604
|
+
}
|
|
3605
|
+
const isFunction = (n)=>'FunctionDeclaration' === n.type || 'FunctionExpression' === n.type || 'ArrowFunctionExpression' === n.type || 'ObjectMethod' === n.type || 'ClassMethod' === n.type;
|
|
3606
|
+
const isLocalScope = (n)=>isFunction(n) || 'BlockStatement' === n.type || 'CatchClause' === n.type || 'ForStatement' === n.type || 'ForInStatement' === n.type || 'ForOfStatement' === n.type || 'SwitchStatement' === n.type;
|
|
3607
|
+
function declaredInside(scope) {
|
|
3608
|
+
const names = new Set();
|
|
3609
|
+
if ('params' in scope) for (const param of scope.params)patternNames(param, names);
|
|
3610
|
+
if ('CatchClause' === scope.type) patternNames(scope.param, names);
|
|
3611
|
+
for (const node of findAll(scope, (n)=>'VariableDeclarator' === n.type || 'FunctionDeclaration' === n.type || 'ClassDeclaration' === n.type))if ('VariableDeclarator' === node.type) patternNames(node.id, names);
|
|
3612
|
+
else if ('id' in node) patternNames(node.id, names);
|
|
3613
|
+
return names;
|
|
3614
|
+
}
|
|
3615
|
+
function federationCalls(ast, scope) {
|
|
3616
|
+
const plugin = namesFor(scope, 'pluginModuleFederation');
|
|
3617
|
+
const webpack = namesFor(scope, 'ModuleFederationPlugin');
|
|
3618
|
+
const found = [];
|
|
3619
|
+
const matches = (n, shadowed)=>{
|
|
3620
|
+
if (isCallExpression(n)) {
|
|
3621
|
+
const callee = n.callee;
|
|
3622
|
+
if (isIdentifier(callee)) return plugin.has(callee.name) && !shadowed.has(callee.name);
|
|
3623
|
+
return isMemberExpression(callee) && isIdentifier(callee.property, {
|
|
3624
|
+
name: 'pluginModuleFederation'
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
if (!isNewExpression(n)) return false;
|
|
3628
|
+
const callee = n.callee;
|
|
3629
|
+
if (isIdentifier(callee)) return webpack.has(callee.name) && !shadowed.has(callee.name);
|
|
3630
|
+
return isMemberExpression(callee) && isIdentifier(callee.property, {
|
|
3631
|
+
name: 'ModuleFederationPlugin'
|
|
3632
|
+
});
|
|
3633
|
+
};
|
|
3634
|
+
const visit = (value, shadowed)=>{
|
|
3635
|
+
if (Array.isArray(value)) {
|
|
3636
|
+
for (const item of value)visit(item, shadowed);
|
|
3637
|
+
return;
|
|
3638
|
+
}
|
|
3639
|
+
if (!isNode(value)) return;
|
|
3640
|
+
if (matches(value, shadowed) && (isCallExpression(value) || isNewExpression(value))) found.push({
|
|
3641
|
+
node: value,
|
|
3642
|
+
shadowed
|
|
3643
|
+
});
|
|
3644
|
+
const inner = 'Program' !== value.type && isLocalScope(value) ? new Set([
|
|
3645
|
+
...shadowed,
|
|
3646
|
+
...declaredInside(value)
|
|
3647
|
+
]) : shadowed;
|
|
3648
|
+
for (const [key, child] of Object.entries(value))if (!SKIP_KEYS.has(key)) visit(child, inner);
|
|
3649
|
+
};
|
|
3650
|
+
visit(ast.program, new Set());
|
|
3651
|
+
return found;
|
|
3652
|
+
}
|
|
3653
|
+
function scopeAt(scope, shadowed) {
|
|
3654
|
+
if (![
|
|
3655
|
+
...shadowed
|
|
3656
|
+
].some((name)=>scope.bindings.has(name))) return scope;
|
|
3657
|
+
const bindings = new Map(scope.bindings);
|
|
3658
|
+
for (const name of shadowed)if (bindings.has(name)) bindings.set(name, {
|
|
3659
|
+
kind: 'other',
|
|
3660
|
+
what: 'declared again inside a function around the call'
|
|
3661
|
+
});
|
|
3662
|
+
return {
|
|
3663
|
+
...scope,
|
|
3664
|
+
bindings
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
const isRelative = (source)=>'.' === source || '..' === source || source.startsWith('./') || source.startsWith('../');
|
|
3668
|
+
function candidatesFor(target) {
|
|
3669
|
+
const ext = node_path.extname(target);
|
|
3670
|
+
if (EXTENSIONS.includes(ext)) {
|
|
3671
|
+
const stem = target.slice(0, -ext.length);
|
|
3672
|
+
return [
|
|
3673
|
+
target,
|
|
3674
|
+
...'.js' === ext ? [
|
|
3675
|
+
`${stem}.ts`
|
|
3676
|
+
] : '.mjs' === ext ? [
|
|
3677
|
+
`${stem}.mts`
|
|
3678
|
+
] : []
|
|
3679
|
+
];
|
|
3680
|
+
}
|
|
3681
|
+
return [
|
|
3682
|
+
...EXTENSIONS.map((e)=>`${target}${e}`),
|
|
3683
|
+
...EXTENSIONS.map((e)=>node_path.join(target, `index${e}`))
|
|
3684
|
+
];
|
|
3685
|
+
}
|
|
3686
|
+
async function detect_resolveOptions(ctx, node, scope, hops = 0, seen = new Set()) {
|
|
3687
|
+
const n = evaluate_unwrap(node);
|
|
3688
|
+
if (!n) return evaluate_fail('are missing');
|
|
3689
|
+
if ('ObjectExpression' === n.type) return ok({
|
|
3690
|
+
object: n,
|
|
3691
|
+
scope
|
|
3692
|
+
});
|
|
3693
|
+
if (isCallExpression(n)) {
|
|
3694
|
+
const wrapper = namesFor(scope, 'createModuleFederationConfig');
|
|
3695
|
+
if (isIdentifier(n.callee) && wrapper.has(n.callee.name)) return detect_resolveOptions(ctx, n.arguments[0], scope, hops, seen);
|
|
3696
|
+
return evaluate_fail(reasonFor(n));
|
|
3697
|
+
}
|
|
3698
|
+
if (!isIdentifier(n)) {
|
|
3699
|
+
const evaluated = evaluate(n, scope);
|
|
3700
|
+
return evaluate_fail(evaluated.ok ? 'are not an object' : evaluated.reason);
|
|
3701
|
+
}
|
|
3702
|
+
const binding = scope.bindings.get(n.name);
|
|
3703
|
+
if (binding?.kind === 'const' && !seen.has(n.name)) {
|
|
3704
|
+
if (scope.mutated.has(n.name)) return evaluate_fail(`refers to ${n.name}, which is changed later in the file`);
|
|
3705
|
+
return detect_resolveOptions(ctx, binding.init, scope, hops, new Set([
|
|
3706
|
+
...seen,
|
|
3707
|
+
n.name
|
|
3708
|
+
]));
|
|
3709
|
+
}
|
|
3710
|
+
if (binding?.kind !== 'import') {
|
|
3711
|
+
const evaluated = evaluate(n, scope);
|
|
3712
|
+
return evaluate_fail(evaluated.ok ? 'are not an object' : evaluated.reason);
|
|
3713
|
+
}
|
|
3714
|
+
if (!isRelative(binding.source)) return evaluate_fail(`are imported from a package (${n.name} from '${binding.source}')`);
|
|
3715
|
+
if ((binding.depth ?? 0) > 1) return evaluate_fail(`are read from deep inside what '${binding.source}' exports`);
|
|
3716
|
+
if (hops >= MAX_IMPORT_HOPS) return evaluate_fail(`are imported through more than ${String(MAX_IMPORT_HOPS)} files`);
|
|
3717
|
+
const target = node_path.resolve(node_path.dirname(scope.file), binding.source);
|
|
3718
|
+
if (!isInside(ctx.root, target)) return evaluate_fail(`are imported from outside the project ('${binding.source}')`);
|
|
3719
|
+
if (node_path.relative(ctx.root, target).split(node_path.sep).includes('node_modules')) return evaluate_fail(`are imported from node_modules ('${binding.source}')`);
|
|
3720
|
+
const found = await firstExisting(ctx.read, candidatesFor(target));
|
|
3721
|
+
if (!found) return evaluate_fail(`are imported from '${binding.source}', which was not found`);
|
|
3722
|
+
const imported = load(ctx, found.file, found.source)?.scope;
|
|
3723
|
+
if (!imported) return evaluate_fail(`are imported from '${binding.source}', which could not be parsed`);
|
|
3724
|
+
const exported = '*' === binding.imported ? void 0 : imported.exports.get(binding.imported);
|
|
3725
|
+
if (!exported) return evaluate_fail('default' === binding.imported ? `are imported from '${binding.source}', which has no default export` : `are imported from '${binding.source}', which has no export ${binding.imported}`);
|
|
3726
|
+
const local = imported.exportedFrom.get(binding.imported);
|
|
3727
|
+
if (local && imported.mutated.has(local)) return evaluate_fail(`are imported from '${binding.source}', where ${local} is changed later in the file`);
|
|
3728
|
+
return detect_resolveOptions(ctx, exported, imported, hops + 1);
|
|
3729
|
+
}
|
|
3730
|
+
function readPort(ctx, ast, scope, tool) {
|
|
3731
|
+
const key = 'rsbuild' === tool ? 'server' : 'devServer';
|
|
3732
|
+
const ports = [];
|
|
3733
|
+
for (const prop of findAll(ast, (n)=>isObjectProperty(n))){
|
|
3734
|
+
if (!isObjectProperty(prop) || prop.computed || !isIdentifier(prop.key, {
|
|
3735
|
+
name: key
|
|
3736
|
+
})) continue;
|
|
3737
|
+
const value = evaluate_unwrap(prop.value);
|
|
3738
|
+
if (value?.type !== 'ObjectExpression') continue;
|
|
3739
|
+
const port = evaluate_entriesOf(value, scope).entries.findLast((e)=>'port' === e.key);
|
|
3740
|
+
if (port) ports.push(port.value);
|
|
3741
|
+
}
|
|
3742
|
+
const [first, ...others] = ports;
|
|
3743
|
+
if (!first) return;
|
|
3744
|
+
if (others.length > 0) return void detect_report(ctx, scope.file, 'port', 'is set in more than one place');
|
|
3745
|
+
const evaluated = evaluate(first, scope);
|
|
3746
|
+
if (evaluated.ok && 'number' == typeof evaluated.value) return evaluated.value;
|
|
3747
|
+
detect_report(ctx, scope.file, 'port', evaluated.ok ? 'is not a number' : evaluated.reason);
|
|
3748
|
+
}
|
|
3749
|
+
async function packageName(read, dir) {
|
|
3750
|
+
const text = await read(node_path.join(dir, 'package.json'));
|
|
3751
|
+
if (void 0 === text) return;
|
|
3752
|
+
try {
|
|
3753
|
+
const pkg = JSON.parse(text);
|
|
3754
|
+
return 'object' == typeof pkg && null !== pkg && 'name' in pkg && 'string' == typeof pkg.name ? pkg.name : void 0;
|
|
3755
|
+
} catch {
|
|
3756
|
+
return;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
async function detectApp(ctx) {
|
|
3760
|
+
const abs = node_path.join(ctx.root, ...ctx.dir.split('/'));
|
|
3761
|
+
const builds = BUILD_TOOLS.flatMap((tool)=>EXTENSIONS.map((ext)=>({
|
|
3762
|
+
tool,
|
|
3763
|
+
file: node_path.join(abs, `${tool}.config${ext}`)
|
|
3764
|
+
})));
|
|
3765
|
+
const [build, federation] = await Promise.all([
|
|
3766
|
+
firstExisting(ctx.read, builds.map((b)=>b.file)),
|
|
3767
|
+
firstExisting(ctx.read, EXTENSIONS.map((ext)=>node_path.join(abs, `${FEDERATION_CONFIG}${ext}`)))
|
|
3768
|
+
]);
|
|
3769
|
+
if (!build) {
|
|
3770
|
+
if (federation) detect_report(ctx, federation.file, 'buildTool', 'has no rsbuild, rspack or webpack config next to it');
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
const tool = builds.find((b)=>b.file === build.file)?.tool ?? 'rsbuild';
|
|
3774
|
+
const parsed = load(ctx, build.file, build.source);
|
|
3775
|
+
if (!parsed) return;
|
|
3776
|
+
const { ast, scope } = parsed;
|
|
3777
|
+
const calls = federationCalls(ast, scope);
|
|
3778
|
+
if (calls.length > 1) return void detect_report(ctx, build.file, 'options', `has ${String(calls.length)} Module Federation plugins in one config, so none of them is read`);
|
|
3779
|
+
let options;
|
|
3780
|
+
const [call] = calls;
|
|
3781
|
+
if (call) options = await detect_resolveOptions(ctx, call.node.arguments[0], scopeAt(scope, call.shadowed));
|
|
3782
|
+
else {
|
|
3783
|
+
if (!federation) return;
|
|
3784
|
+
const mfScope = load(ctx, federation.file, federation.source)?.scope;
|
|
3785
|
+
if (!mfScope) return;
|
|
3786
|
+
options = await detect_resolveOptions(ctx, mfScope.exports.get('default'), mfScope);
|
|
3787
|
+
}
|
|
3788
|
+
if (!options.ok) return void detect_report(ctx, build.file, 'options', options.reason);
|
|
3789
|
+
const optionsFile = options.value.scope.file;
|
|
3790
|
+
const read = readFederationOptions(options.value.object, options.value.scope, (field, reason)=>{
|
|
3791
|
+
detect_report(ctx, optionsFile, field, reason);
|
|
3792
|
+
});
|
|
3793
|
+
const port = readPort(ctx, ast, scope, tool);
|
|
3794
|
+
const pkgName = await packageName(ctx.read, abs);
|
|
3795
|
+
return {
|
|
3796
|
+
dir: ctx.dir,
|
|
3797
|
+
...void 0 === read.name ? {} : {
|
|
3798
|
+
name: read.name
|
|
3799
|
+
},
|
|
3800
|
+
...void 0 === pkgName ? {} : {
|
|
3801
|
+
packageName: pkgName
|
|
3802
|
+
},
|
|
3803
|
+
role: read.exposing ? 'remote' : 'shell',
|
|
3804
|
+
buildTool: tool,
|
|
3805
|
+
configFile: detect_relative(ctx.root, build.file),
|
|
3806
|
+
...optionsFile === build.file ? {} : {
|
|
3807
|
+
federationFile: detect_relative(ctx.root, optionsFile)
|
|
3808
|
+
},
|
|
3809
|
+
remotes: read.remotes,
|
|
3810
|
+
exposes: read.exposes,
|
|
3811
|
+
shared: read.shared,
|
|
3812
|
+
...void 0 === port ? {} : {
|
|
3813
|
+
port
|
|
3814
|
+
}
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
async function detectFederation(dir, options = {}) {
|
|
3818
|
+
const root = node_path.resolve(dir);
|
|
3819
|
+
const refused = [];
|
|
3820
|
+
const disk = guardedDisk(root, refused);
|
|
3821
|
+
const read = options.read ?? disk.read;
|
|
3822
|
+
const listDirs = options.listDirs ?? disk.listDirs;
|
|
3823
|
+
const notAdopted = [];
|
|
3824
|
+
const problems = [];
|
|
3825
|
+
const dirs = await findAppDirs(root, read, listDirs, problems);
|
|
3826
|
+
for (const problem of problems)notAdopted.push({
|
|
3827
|
+
dir: '.',
|
|
3828
|
+
file: problem.file,
|
|
3829
|
+
field: 'workspaces',
|
|
3830
|
+
reason: problem.reason
|
|
3831
|
+
});
|
|
3832
|
+
const apps = [];
|
|
3833
|
+
await eachInSequence(dirs, async (appDir)=>{
|
|
3834
|
+
const ctx = {
|
|
3835
|
+
root,
|
|
3836
|
+
read,
|
|
3837
|
+
dir: appDir,
|
|
3838
|
+
notAdopted: []
|
|
3839
|
+
};
|
|
3840
|
+
try {
|
|
3841
|
+
const app = await detectApp(ctx);
|
|
3842
|
+
if (app) apps.push(app);
|
|
3843
|
+
} catch (error) {
|
|
3844
|
+
detect_report(ctx, node_path.join(root, ...appDir.split('/')), 'file', `could not be read (${messageOf(error)})`);
|
|
3845
|
+
}
|
|
3846
|
+
notAdopted.push(...ctx.notAdopted);
|
|
3847
|
+
});
|
|
3848
|
+
for (const refusal of refused)notAdopted.push({
|
|
3849
|
+
dir: '.',
|
|
3850
|
+
file: refusal.file,
|
|
3851
|
+
field: 'file',
|
|
3852
|
+
reason: refusal.reason
|
|
3853
|
+
});
|
|
3854
|
+
return {
|
|
3855
|
+
apps,
|
|
3856
|
+
notAdopted
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
const RESERVED = new Set([
|
|
3860
|
+
'root',
|
|
3861
|
+
'remotes'
|
|
3862
|
+
]);
|
|
3863
|
+
const DEV_PORT_START = 4311;
|
|
3864
|
+
const OWNER_PLACEHOLDER = 'unassigned';
|
|
3865
|
+
const CONTROL = new RegExp(String.raw`[\u0000-\u001f\u007f-\u009f]`, 'g');
|
|
3866
|
+
const printable = (text)=>text.replaceAll(CONTROL, '?');
|
|
3867
|
+
const join = (dir, file)=>'.' === dir ? file : `${dir}/${file}`;
|
|
3868
|
+
function localPort(entry) {
|
|
3869
|
+
try {
|
|
3870
|
+
const url = new URL(entry);
|
|
3871
|
+
const local = 'localhost' === url.hostname || '127.0.0.1' === url.hostname;
|
|
3872
|
+
const port = Number(url.port);
|
|
3873
|
+
return local && Number.isInteger(port) && port > 0 ? port : void 0;
|
|
3874
|
+
} catch {
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
function skip(app, field, reason) {
|
|
3879
|
+
return {
|
|
3880
|
+
dir: app.dir,
|
|
3881
|
+
file: app.federationFile ?? app.configFile,
|
|
3882
|
+
field,
|
|
3883
|
+
reason
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
function nameApps(apps, notAdopted) {
|
|
3887
|
+
const taken = new Set();
|
|
3888
|
+
const named = [];
|
|
3889
|
+
for (const app of apps){
|
|
3890
|
+
const { name } = app;
|
|
3891
|
+
let problem;
|
|
3892
|
+
if (void 0 === name) problem = 'has no literal name, so there is nothing to call this app';
|
|
3893
|
+
else if (MFE_NAME.test(name)) {
|
|
3894
|
+
if (RESERVED.has(name) || 'remote' === app.role && 'shell' === name) problem = `"${name}" is a name mfeflow keeps for itself`;
|
|
3895
|
+
else if (taken.has(name)) problem = `"${name}" is already used by another app`;
|
|
3896
|
+
} else problem = `"${name}" is not a name mfeflow accepts (lowercase letters, digits and single _)`;
|
|
3897
|
+
if (void 0 === name || void 0 !== problem) {
|
|
3898
|
+
notAdopted.push(skip(app, 'name', `${problem ?? ''}; the app was left out`));
|
|
3899
|
+
continue;
|
|
3900
|
+
}
|
|
3901
|
+
taken.add(name);
|
|
3902
|
+
named.push({
|
|
3903
|
+
app,
|
|
3904
|
+
name
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
return named;
|
|
3908
|
+
}
|
|
3909
|
+
function exposesOf(app, notAdopted) {
|
|
3910
|
+
const kept = [];
|
|
3911
|
+
for (const key of Object.keys(app.exposes))if (EXPOSE.test(key)) kept.push(key);
|
|
3912
|
+
else notAdopted.push(skip(app, `exposes['${key}']`, 'is not a ./Name path, which mfeflow.app.json requires'));
|
|
3913
|
+
return kept;
|
|
3914
|
+
}
|
|
3915
|
+
function platformOf(app) {
|
|
3916
|
+
const platform = {};
|
|
3917
|
+
for (const [pkg, shared] of Object.entries(app.shared))if ('string' == typeof shared.requiredVersion) platform[pkg] = shared.requiredVersion;
|
|
3918
|
+
return platform;
|
|
3919
|
+
}
|
|
3920
|
+
function shellsLoading(name, shells) {
|
|
3921
|
+
return shells.filter(({ app })=>Object.entries(app.remotes).some(([alias, remote])=>(remote.name ?? alias) === name)).map((shell)=>shell.name);
|
|
3922
|
+
}
|
|
3923
|
+
function portFromShells(name, shells) {
|
|
3924
|
+
return shells.flatMap((shell)=>Object.entries(shell.app.remotes)).filter(([alias, r])=>(r.name ?? alias) === name).map(([, r])=>localPort(r.entry)).find((port)=>void 0 !== port);
|
|
3925
|
+
}
|
|
3926
|
+
function assignPorts(named, shells, warnings) {
|
|
3927
|
+
const claims = new Map();
|
|
3928
|
+
const claim = (port, name)=>{
|
|
3929
|
+
claims.set(port, [
|
|
3930
|
+
...claims.get(port) ?? [],
|
|
3931
|
+
name
|
|
3932
|
+
]);
|
|
3933
|
+
};
|
|
3934
|
+
const ports = new Map();
|
|
3935
|
+
const portless = [];
|
|
3936
|
+
for (const { app, name } of named){
|
|
3937
|
+
const port = app.port ?? ('remote' === app.role ? portFromShells(name, shells) : void 0);
|
|
3938
|
+
if (void 0 === port) {
|
|
3939
|
+
if ('remote' === app.role) portless.push(name);
|
|
3940
|
+
continue;
|
|
3941
|
+
}
|
|
3942
|
+
claim(port, name);
|
|
3943
|
+
if ('remote' === app.role) ports.set(name, port);
|
|
3944
|
+
}
|
|
3945
|
+
for (const [port, names] of claims)if (names.length > 1) warnings.push(`${names.join(', ')} are all on port ${String(port)}; give each app its own`);
|
|
3946
|
+
let next = DEV_PORT_START;
|
|
3947
|
+
for (const name of portless){
|
|
3948
|
+
while(claims.has(next))next += 1;
|
|
3949
|
+
claim(next, name);
|
|
3950
|
+
ports.set(name, next);
|
|
3951
|
+
warnings.push(`${name}: no dev port found in its config, so its dev registry entry uses ${String(next)}; change it if the app runs elsewhere`);
|
|
3952
|
+
}
|
|
3953
|
+
return ports;
|
|
3954
|
+
}
|
|
3955
|
+
function planAdoption(input) {
|
|
3956
|
+
const notAdopted = [
|
|
3957
|
+
...input.report.notAdopted
|
|
3958
|
+
];
|
|
3959
|
+
const warnings = [];
|
|
3960
|
+
const named = nameApps(input.report.apps, notAdopted);
|
|
3961
|
+
const shells = named.filter(({ app })=>'shell' === app.role);
|
|
3962
|
+
const remotes = named.filter(({ app })=>'remote' === app.role);
|
|
3963
|
+
if (0 === shells.length) {
|
|
3964
|
+
const found = 0 === input.report.apps.length ? 'no Module Federation config was found' : remotes.length > 0 ? `only remotes were found (${remotes.map((r)=>r.name).join(', ')}), and no shell that loads them` : 'no app had a usable name';
|
|
3965
|
+
throw new MfeflowError('E124', found);
|
|
3966
|
+
}
|
|
3967
|
+
const apps = {};
|
|
3968
|
+
const ports = assignPorts(named, shells, warnings);
|
|
3969
|
+
const appFiles = [];
|
|
3970
|
+
const adoptedRemotes = [];
|
|
3971
|
+
for (const { app, name } of named){
|
|
3972
|
+
apps[name] = {
|
|
3973
|
+
kind: app.role,
|
|
3974
|
+
path: app.dir,
|
|
3975
|
+
...void 0 === app.port ? {} : {
|
|
3976
|
+
port: app.port
|
|
3977
|
+
}
|
|
3978
|
+
};
|
|
3979
|
+
const platform = platformOf(app);
|
|
3980
|
+
appFiles.push({
|
|
3981
|
+
name,
|
|
3982
|
+
dir: app.dir,
|
|
3983
|
+
role: app.role,
|
|
3984
|
+
content: {
|
|
3985
|
+
$schema: SCHEMA_URLS.mfe,
|
|
3986
|
+
name,
|
|
3987
|
+
owner: OWNER_PLACEHOLDER,
|
|
3988
|
+
shells: 'remote' === app.role ? shellsLoading(name, shells) : [],
|
|
3989
|
+
routes: [],
|
|
3990
|
+
exposes: exposesOf(app, notAdopted),
|
|
3991
|
+
...Object.keys(platform).length > 0 ? {
|
|
3992
|
+
platform
|
|
3993
|
+
} : {}
|
|
3994
|
+
}
|
|
3995
|
+
});
|
|
3996
|
+
const port = ports.get(name);
|
|
3997
|
+
if ('remote' === app.role && void 0 !== port) adoptedRemotes.push({
|
|
3998
|
+
name,
|
|
3999
|
+
port
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
warnings.unshift(`routes are empty (mfeflow cannot read them from a build config); add the paths each app serves to ${appFiles.map((f)=>printable(join(f.dir, 'mfeflow.app.json'))).join(', ')}`);
|
|
4003
|
+
const scriptOnly = remotes.flatMap(({ name })=>shells.flatMap(({ app })=>Object.entries(app.remotes)).filter(([alias, r])=>(r.name ?? alias) === name && "script" === r.type)).map(([alias])=>printable(alias));
|
|
4004
|
+
if (scriptOnly.length > 0) warnings.push(`${[
|
|
4005
|
+
...new Set(scriptOnly)
|
|
4006
|
+
].join(', ')}: loaded from a remoteEntry.js, but the registry entries point at mf-manifest.json; check that the remote builds one`);
|
|
4007
|
+
const fromElsewhere = shells.flatMap(({ app })=>Object.entries(app.remotes)).filter(([alias, r])=>!named.some((n)=>n.name === (r.name ?? alias))).map(([alias])=>printable(alias));
|
|
4008
|
+
if (fromElsewhere.length > 0) warnings.push(`${[
|
|
4009
|
+
...new Set(fromElsewhere)
|
|
4010
|
+
].join(', ')}: loaded by a shell but not found in this folder, so no app or registry entry was written`);
|
|
4011
|
+
if (adoptedRemotes.length > 0) warnings.push('registry entries for staging and prod point at example.com; set the real addresses before you deploy');
|
|
4012
|
+
return {
|
|
4013
|
+
project: {
|
|
4014
|
+
version: 1,
|
|
4015
|
+
topology: 'monorepo',
|
|
4016
|
+
packageManager: input.packageManager,
|
|
4017
|
+
scope: input.scope,
|
|
4018
|
+
apps,
|
|
4019
|
+
features: {}
|
|
4020
|
+
},
|
|
4021
|
+
appFiles,
|
|
4022
|
+
remotes: adoptedRemotes,
|
|
4023
|
+
shellDirs: shells.map(({ app })=>app.dir),
|
|
4024
|
+
notAdopted: notAdopted.map((item)=>({
|
|
4025
|
+
dir: printable(item.dir),
|
|
4026
|
+
file: printable(item.file),
|
|
4027
|
+
field: printable(item.field),
|
|
4028
|
+
reason: printable(item.reason)
|
|
4029
|
+
})),
|
|
4030
|
+
warnings
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
4033
|
+
const MAX_BYTES = 1000000;
|
|
4034
|
+
const TIMEOUT_MS = 10000;
|
|
4035
|
+
const MAX_REDIRECTS = 5;
|
|
4036
|
+
const realFetch = (url, init)=>fetch(url, init);
|
|
4037
|
+
function registryUrl(name, registries, version) {
|
|
4038
|
+
const slash = name.indexOf('/');
|
|
4039
|
+
const namespace = name.slice(0, slash);
|
|
4040
|
+
const bare = name.slice(slash + 1);
|
|
4041
|
+
const template = registries[namespace];
|
|
4042
|
+
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
4043
|
+
const url = template.replaceAll('{name}', encodeURIComponent(bare)).replaceAll('{version}', encodeURIComponent(version ?? 'latest'));
|
|
4044
|
+
let parsed;
|
|
4045
|
+
try {
|
|
4046
|
+
parsed = new URL(url);
|
|
4047
|
+
} catch (error) {
|
|
4048
|
+
throw new MfeflowError('E401', `${namespace} has an invalid URL (${redactUrl(url)})`, {
|
|
4049
|
+
cause: error
|
|
4050
|
+
});
|
|
4051
|
+
}
|
|
4052
|
+
if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${redactUrl(url)} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
|
|
4053
|
+
return parsed.href;
|
|
4054
|
+
}
|
|
4055
|
+
async function getHttps(url, fetchImpl, code, options = {}) {
|
|
4056
|
+
const fail = (at, why, cause)=>{
|
|
4057
|
+
throw new MfeflowError(code, redactUrlsIn(`${at}: ${why}`), void 0 === cause ? {} : {
|
|
4058
|
+
cause
|
|
4059
|
+
});
|
|
4060
|
+
};
|
|
4061
|
+
const startsPrivate = isPrivateHost(new URL(url).hostname);
|
|
4062
|
+
const refusePrivate = (target)=>{
|
|
4063
|
+
if (void 0 === options.blockPrivate || !isPrivateHost(target.hostname)) return;
|
|
4064
|
+
if (!options.blockPrivate && startsPrivate) return;
|
|
4065
|
+
const why = options.blockPrivate ? 'those are only requested from a trusted registry' : 'a public registry may not send you to one';
|
|
4066
|
+
throw new MfeflowError('E405', `${redactUrl(target.href)} is a loopback or private address; ${why}`);
|
|
4067
|
+
};
|
|
4068
|
+
refusePrivate(new URL(url));
|
|
4069
|
+
const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
|
|
4070
|
+
const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
|
|
4071
|
+
const addressOk = (address)=>{
|
|
4072
|
+
try {
|
|
4073
|
+
return permitted(new URL(address));
|
|
4074
|
+
} catch {
|
|
4075
|
+
return false;
|
|
4076
|
+
}
|
|
4077
|
+
};
|
|
4078
|
+
let current = url;
|
|
4079
|
+
for(let hop = 0; hop <= MAX_REDIRECTS; hop += 1){
|
|
4080
|
+
let response;
|
|
4081
|
+
try {
|
|
4082
|
+
response = await fetchImpl(current, {
|
|
4083
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
4084
|
+
redirect: 'manual'
|
|
4085
|
+
});
|
|
4086
|
+
} catch (error) {
|
|
4087
|
+
return fail(current, messageOf(error), error);
|
|
4088
|
+
}
|
|
4089
|
+
if (void 0 !== response.url && '' !== response.url && !addressOk(response.url)) fail(url, `ended up at ${response.url}, which is not https`);
|
|
4090
|
+
if (void 0 !== response.url && '' !== response.url) refusePrivate(new URL(response.url));
|
|
4091
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
4092
|
+
const location = response.headers?.get('location');
|
|
4093
|
+
await response.body?.cancel().catch(()=>void 0);
|
|
4094
|
+
if (null == location || '' === location) fail(current, `redirected (${String(response.status)}) with no address`);
|
|
4095
|
+
let next;
|
|
4096
|
+
try {
|
|
4097
|
+
next = new URL(location ?? '', current);
|
|
4098
|
+
} catch (error) {
|
|
4099
|
+
return fail(current, 'redirected to a bad address', error);
|
|
4100
|
+
}
|
|
4101
|
+
if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
|
|
4102
|
+
refusePrivate(next);
|
|
4103
|
+
current = next.href;
|
|
4104
|
+
}
|
|
4105
|
+
return fail(url, 'redirected too many times');
|
|
4106
|
+
}
|
|
4107
|
+
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version, trusted = false) {
|
|
4108
|
+
const url = registryUrl(name, registries, version);
|
|
4109
|
+
const shown = redactUrl(url);
|
|
4110
|
+
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
4111
|
+
loopback: true,
|
|
4112
|
+
blockPrivate: !trusted
|
|
4113
|
+
});
|
|
4114
|
+
if (!response.ok) {
|
|
4115
|
+
const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
|
|
4116
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
|
|
4117
|
+
}
|
|
4118
|
+
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${shown} is larger than 1 MB`));
|
|
4119
|
+
}
|
|
4120
|
+
function checkIntegrity(name, recorded, served, accept) {
|
|
4121
|
+
if (recorded === served) return false;
|
|
4122
|
+
if (!accept) throw new MfeflowError('E406', `${name} was ${recorded} when you added it and is now ${served}`);
|
|
4123
|
+
return true;
|
|
4124
|
+
}
|
|
4125
|
+
function integrityOf(text) {
|
|
4126
|
+
return `sha256-${createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
4127
|
+
}
|
|
4128
|
+
async function readCappedText(response, maxBytes, tooBig) {
|
|
4129
|
+
const declared = Number(response.headers?.get('content-length') ?? 0 / 0);
|
|
4130
|
+
if (declared > maxBytes) {
|
|
4131
|
+
await response.body?.cancel().catch(()=>void 0);
|
|
4132
|
+
throw tooBig();
|
|
4133
|
+
}
|
|
4134
|
+
if (!response.body) {
|
|
4135
|
+
const text = await response.text();
|
|
4136
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) throw tooBig();
|
|
4137
|
+
return text;
|
|
4138
|
+
}
|
|
4139
|
+
const chunks = [];
|
|
4140
|
+
let total = 0;
|
|
4141
|
+
for await (const chunk of response.body){
|
|
4142
|
+
total += chunk.byteLength;
|
|
4143
|
+
if (total > maxBytes) throw tooBig();
|
|
4144
|
+
chunks.push(chunk);
|
|
4145
|
+
}
|
|
4146
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
4147
|
+
}
|
|
4148
|
+
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch, trusted = false) {
|
|
4149
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, false);
|
|
4150
|
+
return items ?? [];
|
|
4151
|
+
}
|
|
4152
|
+
async function indexedVersion(namespace, name, registries, fetchImpl = realFetch, trusted = false) {
|
|
4153
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, true);
|
|
4154
|
+
return items?.find((item)=>item.name === name)?.version;
|
|
4155
|
+
}
|
|
4156
|
+
async function readIndex(namespace, registries, fetchImpl, trusted, missingOk) {
|
|
4157
|
+
const template = registries[namespace];
|
|
4158
|
+
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
4159
|
+
const problem = registryTemplateProblem(template);
|
|
4160
|
+
if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
|
|
4161
|
+
const url = registryIndexUrl(template);
|
|
4162
|
+
const shown = redactUrl(url);
|
|
4163
|
+
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
4164
|
+
loopback: true,
|
|
4165
|
+
blockPrivate: !trusted
|
|
4166
|
+
});
|
|
4167
|
+
if (!response.ok) {
|
|
4168
|
+
if (missingOk && 404 === response.status) return;
|
|
4169
|
+
const hint = 404 === response.status ? ': this registry has no index.json' : '';
|
|
4170
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint}`);
|
|
4171
|
+
}
|
|
4172
|
+
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${shown} is larger than 1 MB`));
|
|
4173
|
+
let value;
|
|
4174
|
+
try {
|
|
4175
|
+
value = JSON.parse(text);
|
|
4176
|
+
} catch (error) {
|
|
4177
|
+
const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
|
|
4178
|
+
throw new MfeflowError('E403', `${shown} is not JSON (${reason})`, {
|
|
4179
|
+
cause: error
|
|
4180
|
+
});
|
|
4181
|
+
}
|
|
4182
|
+
const issues = validateFeatureRegistryIndex(value);
|
|
4183
|
+
if (issues.length > 0) {
|
|
4184
|
+
const first = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
|
|
4185
|
+
const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
|
|
4186
|
+
throw new MfeflowError('E403', `${shown}: ${first.join('; ')}${more}`);
|
|
4187
|
+
}
|
|
4188
|
+
return itemsOf(value);
|
|
4189
|
+
}
|
|
4190
|
+
function itemsOf(value) {
|
|
4191
|
+
const holder = {
|
|
4192
|
+
items: []
|
|
4193
|
+
};
|
|
4194
|
+
Object.assign(holder, value);
|
|
4195
|
+
return holder.items;
|
|
4196
|
+
}
|
|
4197
|
+
function parseFeature(text, trust, label) {
|
|
4198
|
+
let value;
|
|
4199
|
+
try {
|
|
4200
|
+
value = JSON.parse(text);
|
|
4201
|
+
} catch (error) {
|
|
4202
|
+
throw new MfeflowError('E202', `${label}: ${messageOf(error)}`, {
|
|
4203
|
+
cause: error
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
if ('object' != typeof value || null === value || Array.isArray(value)) throw new MfeflowError('E202', `${label}: must be a JSON object`);
|
|
4207
|
+
const record = Object.fromEntries(Object.entries(value));
|
|
4208
|
+
if ('third-party' === trust) assertAllowedKeys(record, label);
|
|
4209
|
+
assertKnownOps(record, label);
|
|
4210
|
+
const issues = validateFeatureManifest(record);
|
|
4211
|
+
if (issues.length > 0) {
|
|
4212
|
+
const detail = issues.map((i)=>`${i.path || '(root)'} ${i.message}`).join('; ');
|
|
4213
|
+
throw new MfeflowError('E202', `${label}: ${detail}`);
|
|
4214
|
+
}
|
|
4215
|
+
const manifest = toManifest(record, label);
|
|
4216
|
+
if ('third-party' === trust) assertThirdPartyContent(manifest, label);
|
|
4217
|
+
return manifest;
|
|
4218
|
+
}
|
|
4219
|
+
function toManifest(record, label) {
|
|
4220
|
+
const manifest = {
|
|
4221
|
+
name: '',
|
|
4222
|
+
version: ''
|
|
4223
|
+
};
|
|
4224
|
+
Object.assign(manifest, record);
|
|
4225
|
+
if ('' === manifest.name) throw new MfeflowError('E202', `${label}: name is required`);
|
|
4226
|
+
return manifest;
|
|
4227
|
+
}
|
|
4228
|
+
async function loadFeature(name, options) {
|
|
4229
|
+
if (!FEATURE_NAME.test(name)) throw new MfeflowError('E201', `"${name}" is not a feature name`);
|
|
4230
|
+
if (name.startsWith('@')) return loadRemote(name, options);
|
|
4231
|
+
return loadLocal(name, options);
|
|
4232
|
+
}
|
|
4233
|
+
async function loadLocal(name, options) {
|
|
4234
|
+
const ops = options.fs ?? realFs;
|
|
4235
|
+
if (!options.featuresDir) throw new MfeflowError('E201', `"${name}" (no features folder configured)`);
|
|
4236
|
+
const dir = node_path.join(options.featuresDir, name);
|
|
4237
|
+
const text = await readIfExists(ops, node_path.join(dir, 'feature.json'));
|
|
4238
|
+
if (void 0 === text) throw new MfeflowError('E201', `"${name}" (looked in ${options.featuresDir})`);
|
|
4239
|
+
const manifest = parseFeature(text, 'first-party', `${name}/feature.json`);
|
|
4240
|
+
if (manifest.name !== name) throw new MfeflowError('E202', `${name}/feature.json: name is "${manifest.name}", expected "${name}"`);
|
|
4241
|
+
return {
|
|
4242
|
+
manifest,
|
|
4243
|
+
trust: 'first-party',
|
|
4244
|
+
dir,
|
|
4245
|
+
readAsset: async (relative)=>{
|
|
4246
|
+
if (!isSafeRelativePath(relative)) throw new MfeflowError('E105', `"${relative}"`);
|
|
4247
|
+
const content = await readIfExists(ops, node_path.join(dir, ...relative.split('/')));
|
|
4248
|
+
if (void 0 === content) throw new MfeflowError('E201', `${name}: file "${relative}" is missing`);
|
|
4249
|
+
return content;
|
|
4250
|
+
}
|
|
4251
|
+
};
|
|
4252
|
+
}
|
|
4253
|
+
async function loadRemote(name, options) {
|
|
4254
|
+
const registries = options.registries ?? {};
|
|
4255
|
+
const text = await fetchRemoteText(name, registries, options.fetch, options.version, true === options.trusted);
|
|
4256
|
+
const manifest = parseFeature(text, 'third-party', name);
|
|
4257
|
+
if (manifest.name !== name) throw new MfeflowError('E402', `asked for ${name}, got ${manifest.name}`);
|
|
4258
|
+
if (true === options.checkIndex) await assertIndexAgrees(name, manifest.version, registries, options);
|
|
4259
|
+
return {
|
|
4260
|
+
manifest,
|
|
4261
|
+
trust: 'third-party',
|
|
4262
|
+
integrity: integrityOf(text)
|
|
4263
|
+
};
|
|
4264
|
+
}
|
|
4265
|
+
async function assertIndexAgrees(name, version, registries, options) {
|
|
4266
|
+
const slash = name.indexOf('/');
|
|
4267
|
+
let listed;
|
|
4268
|
+
try {
|
|
4269
|
+
listed = await indexedVersion(name.slice(0, slash), name.slice(slash + 1), registries, options.fetch, true === options.trusted);
|
|
4270
|
+
} catch (error) {
|
|
4271
|
+
if (error instanceof MfeflowError && 'MFEFLOW_E401' === error.code) return;
|
|
4272
|
+
throw error;
|
|
4273
|
+
}
|
|
4274
|
+
if (void 0 !== listed && listed !== version) throw new MfeflowError('E402', `the index lists ${name} at ${listed} but its feature.json is ${version}`);
|
|
4275
|
+
}
|
|
4276
|
+
async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
4277
|
+
let dirs;
|
|
4278
|
+
try {
|
|
4279
|
+
dirs = await ops.listDirs(featuresDir);
|
|
4280
|
+
} catch {
|
|
4281
|
+
return [];
|
|
4282
|
+
}
|
|
4283
|
+
const checked = await Promise.all(dirs.map(async (dir)=>await readIfExists(ops, node_path.join(featuresDir, dir, 'feature.json')) === void 0 ? void 0 : dir));
|
|
4284
|
+
return checked.filter((d)=>void 0 !== d).toSorted((a, b)=>a.localeCompare(b));
|
|
4285
|
+
}
|
|
4286
|
+
const TRUST_FILE = 'trusted-registries.json';
|
|
4287
|
+
const TRUST_ENV = 'MFEFLOW_TRUST_REGISTRIES';
|
|
4288
|
+
const trust_set = (value)=>void 0 === value || '' === value ? void 0 : value;
|
|
4289
|
+
function configDirOf(env, platform = process.platform, home = node_os.homedir()) {
|
|
4290
|
+
const override = trust_set(env.MFEFLOW_CONFIG_DIR);
|
|
4291
|
+
if (void 0 !== override) return override;
|
|
4292
|
+
const xdg = trust_set(env.XDG_CONFIG_HOME);
|
|
4293
|
+
if (void 0 !== xdg) return node_path.join(xdg, 'mfeflow');
|
|
4294
|
+
const appData = trust_set(env.APPDATA);
|
|
4295
|
+
if ('win32' === platform && void 0 !== appData) return node_path.join(appData, 'mfeflow');
|
|
4296
|
+
return node_path.join(home, '.config', 'mfeflow');
|
|
4297
|
+
}
|
|
4298
|
+
function trustFileIn(configDir) {
|
|
4299
|
+
return node_path.join(configDir, TRUST_FILE);
|
|
4300
|
+
}
|
|
4301
|
+
function isEntry(value) {
|
|
4302
|
+
if ('object' != typeof value || null === value) return false;
|
|
4303
|
+
const { namespace, template } = Object.fromEntries(Object.entries(value));
|
|
4304
|
+
return 'string' == typeof namespace && REGISTRY_NAMESPACE.test(namespace) && 'string' == typeof template && void 0 === registryTemplateProblem(template);
|
|
4305
|
+
}
|
|
4306
|
+
async function readTrusted(configDir, ops = realFs) {
|
|
4307
|
+
let text;
|
|
4308
|
+
try {
|
|
4309
|
+
text = await readIfExists(ops, trustFileIn(configDir));
|
|
4310
|
+
} catch {
|
|
4311
|
+
return [];
|
|
4312
|
+
}
|
|
4313
|
+
if (void 0 === text) return [];
|
|
4314
|
+
try {
|
|
4315
|
+
const value = JSON.parse(text);
|
|
4316
|
+
if ('object' != typeof value || null === value) return [];
|
|
4317
|
+
const { registries } = Object.fromEntries(Object.entries(value));
|
|
4318
|
+
return Array.isArray(registries) ? registries.filter(isEntry) : [];
|
|
4319
|
+
} catch {
|
|
4320
|
+
return [];
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
async function save(configDir, entries, ops) {
|
|
4324
|
+
const sorted = entries.toSorted((a, b)=>a.namespace.localeCompare(b.namespace) || a.template.localeCompare(b.template));
|
|
4325
|
+
const body = `${JSON.stringify({
|
|
4326
|
+
version: 1,
|
|
4327
|
+
registries: sorted
|
|
4328
|
+
}, null, 2)}\n`;
|
|
4329
|
+
try {
|
|
4330
|
+
await writeFileAtomic(ops, trustFileIn(configDir), body);
|
|
4331
|
+
} catch (error) {
|
|
4332
|
+
throw new MfeflowError('E106', trustFileIn(configDir), {
|
|
4333
|
+
cause: error
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
async function addTrusted(configDir, entry, ops = realFs) {
|
|
4338
|
+
const current = await readTrusted(configDir, ops);
|
|
4339
|
+
if (current.some((e)=>e.namespace === entry.namespace && e.template === entry.template)) return false;
|
|
4340
|
+
await save(configDir, [
|
|
4341
|
+
...current,
|
|
4342
|
+
entry
|
|
4343
|
+
], ops);
|
|
4344
|
+
return true;
|
|
4345
|
+
}
|
|
4346
|
+
async function removeTrusted(configDir, namespace, ops = realFs) {
|
|
4347
|
+
const current = await readTrusted(configDir, ops);
|
|
4348
|
+
const kept = current.filter((e)=>e.namespace !== namespace);
|
|
4349
|
+
if (kept.length === current.length) return 0;
|
|
4350
|
+
await save(configDir, kept, ops);
|
|
4351
|
+
return current.length - kept.length;
|
|
4352
|
+
}
|
|
4353
|
+
function parseTrustEnv(value) {
|
|
4354
|
+
return (value ?? '').split(',').map((part)=>part.trim()).flatMap((part)=>{
|
|
4355
|
+
const eq = part.indexOf('=');
|
|
4356
|
+
const namespace = -1 === eq ? part : part.slice(0, eq).trim();
|
|
4357
|
+
if (!REGISTRY_NAMESPACE.test(namespace)) return [];
|
|
4358
|
+
const template = -1 === eq ? '' : part.slice(eq + 1).trim();
|
|
4359
|
+
return [
|
|
4360
|
+
'' === template ? {
|
|
4361
|
+
namespace
|
|
4362
|
+
} : {
|
|
4363
|
+
namespace,
|
|
4364
|
+
template
|
|
4365
|
+
}
|
|
4366
|
+
];
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
function trustedNamespaces(registries, recorded, fromEnv = []) {
|
|
4370
|
+
const fetch1 = new Set();
|
|
4371
|
+
const privateOk = new Set();
|
|
4372
|
+
for (const [namespace, template] of Object.entries(registries)){
|
|
4373
|
+
const inFile = recorded.some((e)=>e.namespace === namespace && e.template === template);
|
|
4374
|
+
const items = fromEnv.filter((e)=>e.namespace === namespace);
|
|
4375
|
+
const pinned = items.some((e)=>e.template === template);
|
|
4376
|
+
if (inFile || pinned) privateOk.add(namespace);
|
|
4377
|
+
if (inFile || items.some((e)=>void 0 === e.template || e.template === template)) fetch1.add(namespace);
|
|
4378
|
+
}
|
|
4379
|
+
return {
|
|
4380
|
+
fetch: fetch1,
|
|
4381
|
+
privateOk
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
2562
4384
|
const registry_build_text = (value)=>`${JSON.stringify(value, null, 2)}\n`;
|
|
2563
4385
|
async function buildRegistry(dir, ops = realFs) {
|
|
2564
4386
|
let folders;
|
|
@@ -2672,4 +4494,4 @@ function packageInfo() {
|
|
|
2672
4494
|
version
|
|
2673
4495
|
};
|
|
2674
4496
|
}
|
|
2675
|
-
export { ERROR_CATALOGUE, MfeflowError, PROJECT_FILE, PlanBuilder, RUNTIME_CONFIG_FILE, TRUST_ENV, addTrusted, applyPlan, applyTransform, buildRegistry, checkIntegrity, configDirOf, dotenvKeys, emptyPlan, fetchFeatureIndex, fetchRemoteText, findDiverged, findProjectRoot, getHttps, hasErrno, hashContent, inOrder, indexedVersion, integrityOf, isMfeflowError, isProtectedPath, listLocalFeatures, loadFeature, loadProject, messageOf, packageInfo, parseFeature, parseProject, parseTrustEnv, readCappedText, readTrusted, realFs, recordPlan, registryUrl, removeTrusted, renderDiff, renderTemplate, resolveFeature, resolveOptions, runtimeConfigFiles, saveProject, schemaKeys, serializeProject, substitute, summarize, trustFileIn, trustedNamespaces, writeFileAtomic };
|
|
4497
|
+
export { APP_FILE, ERROR_CATALOGUE, LEGACY_APP_FILE, MfeflowError, PROJECT_FILE, PlanBuilder, RUNTIME_CONFIG_FILE, TRUST_ENV, addTrusted, applyPlan, applyTransform, buildRegistry, checkIntegrity, configDirOf, detectBaseline, detectFederation, dotenvKeys, emptyPlan, fetchFeatureIndex, fetchRemoteText, findDiverged, findProjectRoot, getHttps, hasErrno, hashContent, inOrder, indexedVersion, integrityOf, isMfeflowError, isProtectedPath, legacyAppFileNotice, listLocalFeatures, loadFeature, loadProject, messageOf, packageInfo, parseFeature, parseProject, parseTrustEnv, planAdoption, planUpgrade, readAppFile, readCappedText, readTrusted, realFs, recordPlan, registryUrl, removeTrusted, renderDiff, renderTemplate, resolveFeature, resolveOptions, runtimeConfigFiles, saveProject, schemaKeys, serializeProject, substitute, summarize, symlinkedPrefix, trustFileIn, trustedNamespaces, writeFileAtomic };
|