@mfeflow/core 0.4.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 +96 -7
- package/dist/features/index.d.ts +2 -1
- package/dist/features/load.d.ts +8 -0
- package/dist/features/remote.d.ts +19 -3
- package/dist/features/trust.d.ts +44 -0
- package/dist/fs.d.ts +5 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2389 -281
- package/dist/plan.d.ts +21 -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,14 +1,16 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { FEATURE_CONDITION, FEATURE_NAME, REGISTRY_NAMESPACE, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isLoopbackHttp, isMfeflowProject, isRegistryProtocol, isSafeRelativePath, 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";
|
|
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";
|
|
8
10
|
import { Eta } from "eta";
|
|
9
|
-
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";
|
|
10
12
|
import { parse, parseExpression } from "@babel/parser";
|
|
11
|
-
import
|
|
13
|
+
import node_os from "node:os";
|
|
12
14
|
const ERROR_CATALOGUE = {
|
|
13
15
|
E101: {
|
|
14
16
|
title: 'A shared singleton is missing from the shared list',
|
|
@@ -59,8 +61,8 @@ const ERROR_CATALOGUE = {
|
|
|
59
61
|
fix: 'Restore the folder, or remove the app from "apps" in mfeflow.json.'
|
|
60
62
|
},
|
|
61
63
|
E113: {
|
|
62
|
-
title: '
|
|
63
|
-
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}.`
|
|
64
66
|
},
|
|
65
67
|
E114: {
|
|
66
68
|
title: 'A registry entry is missing or invalid',
|
|
@@ -71,8 +73,8 @@ const ERROR_CATALOGUE = {
|
|
|
71
73
|
fix: 'Give one of them another port in its rsbuild config and in "apps" in mfeflow.json.'
|
|
72
74
|
},
|
|
73
75
|
E116: {
|
|
74
|
-
title: 'Two remotes claim
|
|
75
|
-
fix: 'Give each route to one remote, 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.'
|
|
76
78
|
},
|
|
77
79
|
E117: {
|
|
78
80
|
title: 'A copied file changed since mfeflow wrote it',
|
|
@@ -94,6 +96,18 @@ const ERROR_CATALOGUE = {
|
|
|
94
96
|
title: 'A server env key is not listed in turbo.json',
|
|
95
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.'
|
|
96
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
|
+
},
|
|
97
111
|
E201: {
|
|
98
112
|
title: 'Feature not found',
|
|
99
113
|
fix: 'List what is available with `mfeflow features`, or check the spelling.'
|
|
@@ -160,7 +174,7 @@ const ERROR_CATALOGUE = {
|
|
|
160
174
|
},
|
|
161
175
|
E302: {
|
|
162
176
|
title: 'A remote is not compatible with its shell',
|
|
163
|
-
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.'
|
|
164
178
|
},
|
|
165
179
|
E303: {
|
|
166
180
|
title: 'A runtime plugin imports a shared module',
|
|
@@ -168,7 +182,7 @@ const ERROR_CATALOGUE = {
|
|
|
168
182
|
},
|
|
169
183
|
E304: {
|
|
170
184
|
title: 'A bundle is over its budget',
|
|
171
|
-
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.'
|
|
172
186
|
},
|
|
173
187
|
E305: {
|
|
174
188
|
title: 'There is no build output to measure',
|
|
@@ -178,6 +192,10 @@ const ERROR_CATALOGUE = {
|
|
|
178
192
|
title: 'A dev server could not start',
|
|
179
193
|
fix: "Free the port or change it in mfeflow.json and the app's rsbuild config, then run `mfeflow dev` again."
|
|
180
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
|
+
},
|
|
181
199
|
E401: {
|
|
182
200
|
title: 'Could not fetch from the registry',
|
|
183
201
|
fix: 'Check the URL in "registries" and your network, then try again.'
|
|
@@ -193,6 +211,78 @@ const ERROR_CATALOGUE = {
|
|
|
193
211
|
E404: {
|
|
194
212
|
title: 'A feature in the registry folder cannot be published',
|
|
195
213
|
fix: 'Fix the features listed above. They are checked with the same rules `mfeflow add @namespace/name` applies, so a feature that passes here installs for users.'
|
|
214
|
+
},
|
|
215
|
+
E501: {
|
|
216
|
+
title: 'A deploy failed the schema gate',
|
|
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.`
|
|
218
|
+
},
|
|
219
|
+
E502: {
|
|
220
|
+
title: 'A route of this remote overlaps a route of another remote',
|
|
221
|
+
fix: `Give each URL to one remote (/checkout/* also covers /checkout/pay), then deploy again. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
222
|
+
},
|
|
223
|
+
E503: {
|
|
224
|
+
title: "The remote's shared ranges do not fit the shell that is deployed",
|
|
225
|
+
fix: `Align the remote with the shell on the package named above, or deploy the shell first. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
226
|
+
},
|
|
227
|
+
E504: {
|
|
228
|
+
title: 'The remote is over its first-load JS budget',
|
|
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.`
|
|
230
|
+
},
|
|
231
|
+
E505: {
|
|
232
|
+
title: 'A rollout was asked for, and a static host cannot split traffic',
|
|
233
|
+
fix: `Deploy to one version at a time, or use the registry server for percentage rollouts. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
234
|
+
},
|
|
235
|
+
E506: {
|
|
236
|
+
title: 'The registry host is not set up or cannot be used',
|
|
237
|
+
fix: `Pass --host file:<dir> or s3://<bucket>[/prefix], or set registry.host in mfeflow.json. Deploys also need --base-url or registry.baseUrl. S3 settings come from MFEFLOW_S3_ENDPOINT, MFEFLOW_S3_REGION, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
238
|
+
},
|
|
239
|
+
E507: {
|
|
240
|
+
title: 'There is no build output to deploy',
|
|
241
|
+
fix: `Run \`pnpm build\` in the remote first. The build folder needs an mf-manifest.json, and the remote needs a version in package.json or --version. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
242
|
+
},
|
|
243
|
+
E508: {
|
|
244
|
+
title: 'There is no such version to move to',
|
|
245
|
+
fix: `Run \`mfeflow status\` to see what each environment serves and the versions you can go back to. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
246
|
+
},
|
|
247
|
+
E509: {
|
|
248
|
+
title: 'Files of the target version are missing on the host',
|
|
249
|
+
fix: `Deploy that version again from its build, or pick another version with --to. The index was not changed. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
250
|
+
},
|
|
251
|
+
E510: {
|
|
252
|
+
title: 'Another deploy changed the index at the same time',
|
|
253
|
+
fix: `Run \`mfeflow status\` to see what is served now, then run the command again. The index was not changed by this run. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
254
|
+
},
|
|
255
|
+
E511: {
|
|
256
|
+
title: 'This version is already published with different content',
|
|
257
|
+
fix: `A published version never changes. Bump the version and deploy again. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
258
|
+
},
|
|
259
|
+
E512: {
|
|
260
|
+
title: 'Local registry files and the host disagree',
|
|
261
|
+
fix: `Run the deploy, promote or rollback that was meant to move the environment, or fix registry/envs by hand. Artifacts uploaded with --no-publish are not served until you deploy them. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
262
|
+
},
|
|
263
|
+
E405: {
|
|
264
|
+
title: 'The registry is not trusted yet',
|
|
265
|
+
fix: 'Run `mfeflow registry trust @namespace` once to allow requests to it, or remove it from "registries".'
|
|
266
|
+
},
|
|
267
|
+
E406: {
|
|
268
|
+
title: 'The registry changed a feature since you added it',
|
|
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.'
|
|
196
286
|
}
|
|
197
287
|
};
|
|
198
288
|
class MfeflowError extends Error {
|
|
@@ -231,14 +321,15 @@ function readPackageJson() {
|
|
|
231
321
|
if (!isPackageJson(pkg)) throw new MfeflowError('E106', 'package.json is missing a name or version');
|
|
232
322
|
return pkg;
|
|
233
323
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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;
|
|
239
330
|
}
|
|
240
|
-
|
|
241
|
-
|
|
331
|
+
function joinPosix(...parts) {
|
|
332
|
+
return node_path.posix.join(...parts.filter((p)=>'' !== p));
|
|
242
333
|
}
|
|
243
334
|
const realFs = {
|
|
244
335
|
readFile: (file)=>promises.readFile(file, 'utf8'),
|
|
@@ -294,15 +385,46 @@ async function writeFileAtomic(ops, file, content) {
|
|
|
294
385
|
});
|
|
295
386
|
}
|
|
296
387
|
}
|
|
297
|
-
function
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
};
|
|
303
415
|
}
|
|
304
|
-
function
|
|
305
|
-
|
|
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);
|
|
306
428
|
}
|
|
307
429
|
const PROJECT_FILE = 'mfeflow.json';
|
|
308
430
|
function describeIssues(issues) {
|
|
@@ -325,11 +447,15 @@ function serializeProject(project) {
|
|
|
325
447
|
if (issues.length > 0) throw new MfeflowError('E104', describeIssues(issues));
|
|
326
448
|
const { $schema, ...rest } = project;
|
|
327
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));
|
|
328
451
|
const ordered = {
|
|
329
452
|
$schema: $schema ?? SCHEMA_URLS.project,
|
|
330
453
|
...rest,
|
|
331
454
|
...files ? {
|
|
332
455
|
files
|
|
456
|
+
} : {},
|
|
457
|
+
...kept ? {
|
|
458
|
+
kept
|
|
333
459
|
} : {}
|
|
334
460
|
};
|
|
335
461
|
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
@@ -367,11 +493,14 @@ function recordPlan(project, plan) {
|
|
|
367
493
|
const features = {
|
|
368
494
|
...project.features
|
|
369
495
|
};
|
|
370
|
-
for (const f of plan.features)features[f.name] =
|
|
496
|
+
for (const f of plan.features)features[f.name] = {
|
|
371
497
|
version: f.version,
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
498
|
+
...f.options ? {
|
|
499
|
+
options: f.options
|
|
500
|
+
} : {},
|
|
501
|
+
...void 0 === f.integrity ? {} : {
|
|
502
|
+
integrity: f.integrity
|
|
503
|
+
}
|
|
375
504
|
};
|
|
376
505
|
const next = {
|
|
377
506
|
...project,
|
|
@@ -431,6 +560,11 @@ function emptyPlan() {
|
|
|
431
560
|
};
|
|
432
561
|
}
|
|
433
562
|
const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
563
|
+
const DEPENDENCY_SECTIONS = [
|
|
564
|
+
'dependencies',
|
|
565
|
+
'devDependencies',
|
|
566
|
+
'optionalDependencies'
|
|
567
|
+
];
|
|
434
568
|
class PlanBuilder {
|
|
435
569
|
read;
|
|
436
570
|
options;
|
|
@@ -477,6 +611,9 @@ class PlanBuilder {
|
|
|
477
611
|
async content(file) {
|
|
478
612
|
return (await this.open(file)).current ?? void 0;
|
|
479
613
|
}
|
|
614
|
+
async existed(file) {
|
|
615
|
+
return null !== (await this.open(file)).original;
|
|
616
|
+
}
|
|
480
617
|
async captureProject() {
|
|
481
618
|
this.state.projectBefore = await this.read(PROJECT_FILE) ?? null;
|
|
482
619
|
}
|
|
@@ -499,6 +636,13 @@ class PlanBuilder {
|
|
|
499
636
|
this.touch(entry);
|
|
500
637
|
entry.current = edit(entry.current);
|
|
501
638
|
}
|
|
639
|
+
async replace(file, content) {
|
|
640
|
+
const entry = await this.open(file);
|
|
641
|
+
if (null === entry.current) throw new MfeflowError('E208', `${file} does not exist`);
|
|
642
|
+
if ('third-party' === this.trust && entry.current !== content) throw new MfeflowError('E207', file);
|
|
643
|
+
this.touch(entry);
|
|
644
|
+
entry.current = content;
|
|
645
|
+
}
|
|
502
646
|
async delete(file) {
|
|
503
647
|
const entry = await this.open(file);
|
|
504
648
|
this.touch(entry);
|
|
@@ -527,8 +671,73 @@ class PlanBuilder {
|
|
|
527
671
|
file,
|
|
528
672
|
name,
|
|
529
673
|
range: wanted,
|
|
530
|
-
dev
|
|
674
|
+
dev,
|
|
675
|
+
...'third-party' === this.trust ? {
|
|
676
|
+
thirdParty: true
|
|
677
|
+
} : {}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
async setRange(file, name, range) {
|
|
681
|
+
if (!isRegistryRange(range)) throw new MfeflowError('E202', `${name}: "${range}" is not a semver range or a dist-tag`);
|
|
682
|
+
const source = await this.content(file);
|
|
683
|
+
if (void 0 === source) throw new MfeflowError('E208', `${file} does not exist`);
|
|
684
|
+
const existing = this.declaredRange(file, source, name, range);
|
|
685
|
+
if (existing === range || existing.startsWith('workspace:')) return;
|
|
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}`);
|
|
687
|
+
if ('third-party' === this.trust) throw new MfeflowError('E207', `${file}: ${name} is already ${JSON.stringify(existing)}; the feature wants ${range}`);
|
|
688
|
+
if (existing.startsWith('catalog:')) return void await this.setCatalogEntry(name, existing.slice(8), range);
|
|
689
|
+
await this.patch(file, (text)=>editJsonObject(file, text, (pkg)=>{
|
|
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;
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
declaredRange(file, source, name, wanted) {
|
|
705
|
+
const pkg = parseJsonObject(file, source);
|
|
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;
|
|
709
|
+
throw new MfeflowError('E208', `${file} does not depend on ${name}`);
|
|
710
|
+
}
|
|
711
|
+
async setCatalogEntry(name, catalogName, range) {
|
|
712
|
+
const path = '' === catalogName || 'default' === catalogName ? [
|
|
713
|
+
'catalog'
|
|
714
|
+
] : [
|
|
715
|
+
'catalogs',
|
|
716
|
+
catalogName
|
|
717
|
+
];
|
|
718
|
+
const source = await this.content(WORKSPACE_FILE);
|
|
719
|
+
const doc = void 0 === source ? void 0 : parseDocument(source);
|
|
720
|
+
const problem = doc?.errors[0];
|
|
721
|
+
if (problem) throw new MfeflowError('E208', `${WORKSPACE_FILE} is not valid YAML (${problem.message})`, {
|
|
722
|
+
cause: problem
|
|
531
723
|
});
|
|
724
|
+
if (void 0 === doc || !isMap(doc.getIn(path)) || !doc.hasIn([
|
|
725
|
+
...path,
|
|
726
|
+
name
|
|
727
|
+
])) throw new MfeflowError('E208', `${WORKSPACE_FILE} has no catalog entry for ${name}`);
|
|
728
|
+
doc.setIn([
|
|
729
|
+
...path,
|
|
730
|
+
name
|
|
731
|
+
], range);
|
|
732
|
+
let text;
|
|
733
|
+
try {
|
|
734
|
+
text = doc.toString();
|
|
735
|
+
} catch (error) {
|
|
736
|
+
throw new MfeflowError('E208', `${WORKSPACE_FILE} could not be written back (${messageOf(error)})`, {
|
|
737
|
+
cause: error
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
await this.patch(WORKSPACE_FILE, ()=>text);
|
|
532
741
|
}
|
|
533
742
|
async addScript(file, name, command) {
|
|
534
743
|
let added = true;
|
|
@@ -600,6 +809,10 @@ class PlanBuilder {
|
|
|
600
809
|
};
|
|
601
810
|
}
|
|
602
811
|
}
|
|
812
|
+
function isRegistryRange(range) {
|
|
813
|
+
if ('' === range || range !== range.trim() || range.includes('/')) return false;
|
|
814
|
+
return /^[a-z][a-z0-9-]*$/.test(range) || null !== validRange(range);
|
|
815
|
+
}
|
|
603
816
|
function isPinnedElsewhere(range) {
|
|
604
817
|
return range.startsWith('catalog:') || range.startsWith('workspace:');
|
|
605
818
|
}
|
|
@@ -625,16 +838,20 @@ const VERB = {
|
|
|
625
838
|
patch: 'patch ',
|
|
626
839
|
delete: 'delete'
|
|
627
840
|
};
|
|
841
|
+
const dependencyLine = (d)=>` ${d.file} ${d.name}@${d.range}${d.dev ? ' (dev)' : ''}`;
|
|
628
842
|
function summarize(plan) {
|
|
629
843
|
const count = (action)=>plan.changes.filter((c)=>c.action === action).length;
|
|
630
844
|
const lines = [
|
|
631
845
|
`${String(count('create'))} to create, ${String(count('patch'))} to patch, ${String(count('delete'))} to delete`
|
|
632
846
|
];
|
|
633
847
|
for (const c of plan.changes)lines.push(` ${VERB[c.action]} ${c.path}`);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
848
|
+
const dependencies = plan.dependencies.filter((d)=>true !== d.thirdParty);
|
|
849
|
+
const newPackages = plan.dependencies.filter((d)=>true === d.thirdParty);
|
|
850
|
+
if (dependencies.length > 0) {
|
|
851
|
+
lines.push(`Dependencies (${String(dependencies.length)}):`);
|
|
852
|
+
for (const d of dependencies)lines.push(dependencyLine(d));
|
|
637
853
|
}
|
|
854
|
+
if (newPackages.length > 0) lines.push('', `NEW PACKAGES from a third-party feature (${String(newPackages.length)}):`, ...newPackages.map(dependencyLine), " Check each one before you install. npm and yarn run a package's install scripts;", ' pnpm 10 and later blocks them until you allow them.', '');
|
|
638
855
|
if (plan.scripts.length > 0) {
|
|
639
856
|
lines.push(`Scripts added to package.json, not run (${String(plan.scripts.length)}):`);
|
|
640
857
|
for (const s of plan.scripts)lines.push(` ${s.file} ${s.name}: ${s.command}`);
|
|
@@ -687,19 +904,19 @@ const DEVICE_NAMES = new Set([
|
|
|
687
904
|
'lpt³'
|
|
688
905
|
]);
|
|
689
906
|
const INVISIBLE = /[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Cs}]/u;
|
|
690
|
-
function
|
|
907
|
+
function write_policy_refuse(file, why) {
|
|
691
908
|
throw new MfeflowError('E105', `"${file}" ${why}`);
|
|
692
909
|
}
|
|
693
910
|
function assertWritable(file) {
|
|
694
|
-
if (INVISIBLE.test(file))
|
|
911
|
+
if (INVISIBLE.test(file)) write_policy_refuse(file, 'has control or invisible characters');
|
|
695
912
|
const key = policyKey(file);
|
|
696
|
-
if ('mfeflow.json' === key)
|
|
913
|
+
if ('mfeflow.json' === key) write_policy_refuse(file, 'is not a place features write to');
|
|
697
914
|
for (const segment of key.split('/')){
|
|
698
|
-
if ('.git' === segment || 'node_modules' === segment)
|
|
699
|
-
if (segment.includes(':'))
|
|
700
|
-
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');
|
|
701
918
|
const base = (segment.split('.')[0] ?? '').trimEnd();
|
|
702
|
-
if (DEVICE_NAMES.has(base))
|
|
919
|
+
if (DEVICE_NAMES.has(base)) write_policy_refuse(file, 'is a Windows device name');
|
|
703
920
|
}
|
|
704
921
|
}
|
|
705
922
|
const THIRD_PARTY_FOLDERS = new Set([
|
|
@@ -745,7 +962,7 @@ async function applyPlan(plan, options) {
|
|
|
745
962
|
const realRoot = await ops.realpath(options.root);
|
|
746
963
|
await inOrder(targets, async ({ change, file })=>{
|
|
747
964
|
await assertContained(ops, realRoot, file, change.path);
|
|
748
|
-
await assertTrustedRoute(ops, options.root, change);
|
|
965
|
+
await assertTrustedRoute(ops, options.root, change, true === options.refuseSymlinks);
|
|
749
966
|
});
|
|
750
967
|
const current = await verifyUnchanged(targets, ops);
|
|
751
968
|
if (options.dryRun) return {
|
|
@@ -762,7 +979,7 @@ async function applyPlan(plan, options) {
|
|
|
762
979
|
const dir = await ops.mkdir(node_path.dirname(file));
|
|
763
980
|
if (dir) createdDirs.push(dir);
|
|
764
981
|
await assertContained(ops, realRoot, node_path.dirname(file), change.path);
|
|
765
|
-
await assertTrustedRoute(ops, options.root, change);
|
|
982
|
+
await assertTrustedRoute(ops, options.root, change, true === options.refuseSymlinks);
|
|
766
983
|
const temp = tempSibling(file);
|
|
767
984
|
staged.set(file, temp);
|
|
768
985
|
await ops.writeFile(temp, change.after);
|
|
@@ -905,17 +1122,13 @@ async function assertContained(ops, realRoot, target, shown) {
|
|
|
905
1122
|
const inside = node_path.relative(realRoot, real);
|
|
906
1123
|
if (inside.startsWith('..') || node_path.isAbsolute(inside)) throw new MfeflowError('E105', `"${shown}" resolves through a symlink to ${real}`);
|
|
907
1124
|
}
|
|
908
|
-
async function assertTrustedRoute(ops, root, change) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
link: await ops.isSymlink(node_path.join(root, ...prefix.split('/')))
|
|
916
|
-
})));
|
|
917
|
-
const hit = linked.find((entry)=>entry.link);
|
|
918
|
-
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`);
|
|
919
1132
|
}
|
|
920
1133
|
async function deepestRealpath(ops, target) {
|
|
921
1134
|
try {
|
|
@@ -1004,11 +1217,6 @@ function substitute(source, data, name = 'template') {
|
|
|
1004
1217
|
return String(value);
|
|
1005
1218
|
});
|
|
1006
1219
|
}
|
|
1007
|
-
const RUNTIME_CONFIG_FILE = /^runtime-config(?:\.([a-z0-9-]+))?\.json$/;
|
|
1008
|
-
async function runtimeConfigFiles(folder) {
|
|
1009
|
-
const names = await promises.readdir(folder).catch(()=>[]);
|
|
1010
|
-
return names.filter((name)=>RUNTIME_CONFIG_FILE.test(name)).toSorted((a, b)=>a.localeCompare(b));
|
|
1011
|
-
}
|
|
1012
1220
|
function applyEdits(source, edits) {
|
|
1013
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);
|
|
1014
1222
|
}
|
|
@@ -1477,7 +1685,8 @@ function schemaKeys(source, file, scope, object = 'runtimeConfigSchema') {
|
|
|
1477
1685
|
return [
|
|
1478
1686
|
{
|
|
1479
1687
|
name,
|
|
1480
|
-
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)
|
|
1481
1690
|
}
|
|
1482
1691
|
];
|
|
1483
1692
|
});
|
|
@@ -1697,139 +1906,6 @@ function applyTransform(source, file, op) {
|
|
|
1697
1906
|
throw new MfeflowError('E204', JSON.stringify(Reflect.get(op, 'op')));
|
|
1698
1907
|
}
|
|
1699
1908
|
}
|
|
1700
|
-
const MAX_BYTES = 1000000;
|
|
1701
|
-
const TIMEOUT_MS = 10000;
|
|
1702
|
-
const MAX_REDIRECTS = 5;
|
|
1703
|
-
const realFetch = (url, init)=>fetch(url, init);
|
|
1704
|
-
function registryUrl(name, registries, version) {
|
|
1705
|
-
const slash = name.indexOf('/');
|
|
1706
|
-
const namespace = name.slice(0, slash);
|
|
1707
|
-
const bare = name.slice(slash + 1);
|
|
1708
|
-
const template = registries[namespace];
|
|
1709
|
-
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1710
|
-
const url = template.replaceAll('{name}', encodeURIComponent(bare)).replaceAll('{version}', encodeURIComponent(version ?? 'latest'));
|
|
1711
|
-
let parsed;
|
|
1712
|
-
try {
|
|
1713
|
-
parsed = new URL(url);
|
|
1714
|
-
} catch (error) {
|
|
1715
|
-
throw new MfeflowError('E401', `${namespace} has an invalid URL (${url})`, {
|
|
1716
|
-
cause: error
|
|
1717
|
-
});
|
|
1718
|
-
}
|
|
1719
|
-
if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${url} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
|
|
1720
|
-
return parsed.href;
|
|
1721
|
-
}
|
|
1722
|
-
async function getHttps(url, fetchImpl, code, options = {}) {
|
|
1723
|
-
const fail = (at, why, cause)=>{
|
|
1724
|
-
throw new MfeflowError(code, `${at}: ${why}`, void 0 === cause ? {} : {
|
|
1725
|
-
cause
|
|
1726
|
-
});
|
|
1727
|
-
};
|
|
1728
|
-
const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
|
|
1729
|
-
const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
|
|
1730
|
-
const addressOk = (address)=>{
|
|
1731
|
-
try {
|
|
1732
|
-
return permitted(new URL(address));
|
|
1733
|
-
} catch {
|
|
1734
|
-
return false;
|
|
1735
|
-
}
|
|
1736
|
-
};
|
|
1737
|
-
let current = url;
|
|
1738
|
-
for(let hop = 0; hop <= MAX_REDIRECTS; hop += 1){
|
|
1739
|
-
let response;
|
|
1740
|
-
try {
|
|
1741
|
-
response = await fetchImpl(current, {
|
|
1742
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
1743
|
-
redirect: 'manual'
|
|
1744
|
-
});
|
|
1745
|
-
} catch (error) {
|
|
1746
|
-
return fail(current, messageOf(error), error);
|
|
1747
|
-
}
|
|
1748
|
-
if (void 0 !== response.url && '' !== response.url && !addressOk(response.url)) fail(url, `ended up at ${response.url}, which is not https`);
|
|
1749
|
-
if (response.status < 300 || response.status >= 400) return response;
|
|
1750
|
-
const location = response.headers?.get('location');
|
|
1751
|
-
await response.body?.cancel().catch(()=>void 0);
|
|
1752
|
-
if (null == location || '' === location) fail(current, `redirected (${String(response.status)}) with no address`);
|
|
1753
|
-
let next;
|
|
1754
|
-
try {
|
|
1755
|
-
next = new URL(location ?? '', current);
|
|
1756
|
-
} catch (error) {
|
|
1757
|
-
return fail(current, 'redirected to a bad address', error);
|
|
1758
|
-
}
|
|
1759
|
-
if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
|
|
1760
|
-
current = next.href;
|
|
1761
|
-
}
|
|
1762
|
-
return fail(url, 'redirected too many times');
|
|
1763
|
-
}
|
|
1764
|
-
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version) {
|
|
1765
|
-
const url = registryUrl(name, registries, version);
|
|
1766
|
-
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1767
|
-
loopback: true
|
|
1768
|
-
});
|
|
1769
|
-
if (!response.ok) {
|
|
1770
|
-
const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
|
|
1771
|
-
throw new MfeflowError('E401', `${url} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
|
|
1772
|
-
}
|
|
1773
|
-
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${url} is larger than 1 MB`));
|
|
1774
|
-
}
|
|
1775
|
-
async function readCappedText(response, maxBytes, tooBig) {
|
|
1776
|
-
const declared = Number(response.headers?.get('content-length') ?? 0 / 0);
|
|
1777
|
-
if (declared > maxBytes) {
|
|
1778
|
-
await response.body?.cancel().catch(()=>void 0);
|
|
1779
|
-
throw tooBig();
|
|
1780
|
-
}
|
|
1781
|
-
if (!response.body) {
|
|
1782
|
-
const text = await response.text();
|
|
1783
|
-
if (Buffer.byteLength(text, 'utf8') > maxBytes) throw tooBig();
|
|
1784
|
-
return text;
|
|
1785
|
-
}
|
|
1786
|
-
const chunks = [];
|
|
1787
|
-
let total = 0;
|
|
1788
|
-
for await (const chunk of response.body){
|
|
1789
|
-
total += chunk.byteLength;
|
|
1790
|
-
if (total > maxBytes) throw tooBig();
|
|
1791
|
-
chunks.push(chunk);
|
|
1792
|
-
}
|
|
1793
|
-
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
1794
|
-
}
|
|
1795
|
-
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch) {
|
|
1796
|
-
const template = registries[namespace];
|
|
1797
|
-
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1798
|
-
const problem = registryTemplateProblem(template);
|
|
1799
|
-
if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
|
|
1800
|
-
const url = registryIndexUrl(template);
|
|
1801
|
-
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1802
|
-
loopback: true
|
|
1803
|
-
});
|
|
1804
|
-
if (!response.ok) {
|
|
1805
|
-
const hint = 404 === response.status ? ': this registry has no index.json' : '';
|
|
1806
|
-
throw new MfeflowError('E401', `${url} answered ${String(response.status)}${hint}`);
|
|
1807
|
-
}
|
|
1808
|
-
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${url} is larger than 1 MB`));
|
|
1809
|
-
let value;
|
|
1810
|
-
try {
|
|
1811
|
-
value = JSON.parse(text);
|
|
1812
|
-
} catch (error) {
|
|
1813
|
-
const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
|
|
1814
|
-
throw new MfeflowError('E403', `${url} is not JSON (${reason})`, {
|
|
1815
|
-
cause: error
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1818
|
-
const issues = validateFeatureRegistryIndex(value);
|
|
1819
|
-
if (issues.length > 0) {
|
|
1820
|
-
const shown = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
|
|
1821
|
-
const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
|
|
1822
|
-
throw new MfeflowError('E403', `${url}: ${shown.join('; ')}${more}`);
|
|
1823
|
-
}
|
|
1824
|
-
return itemsOf(value);
|
|
1825
|
-
}
|
|
1826
|
-
function itemsOf(value) {
|
|
1827
|
-
const holder = {
|
|
1828
|
-
items: []
|
|
1829
|
-
};
|
|
1830
|
-
Object.assign(holder, value);
|
|
1831
|
-
return holder.items;
|
|
1832
|
-
}
|
|
1833
1909
|
const ROOTS = new Set([
|
|
1834
1910
|
'z',
|
|
1835
1911
|
'v',
|
|
@@ -1948,7 +2024,7 @@ function isModuleSpecifier(value) {
|
|
|
1948
2024
|
}
|
|
1949
2025
|
const DIST_TAG = /^[a-z][a-z0-9-]*$/;
|
|
1950
2026
|
const TARBALL_NAME = /[.](?:tgz|tar\.gz|tar)$/i;
|
|
1951
|
-
function
|
|
2027
|
+
function policy_isRegistryRange(range) {
|
|
1952
2028
|
if ('' === range || range !== range.trim()) return false;
|
|
1953
2029
|
if (range.includes('/') || range.includes('\\')) return false;
|
|
1954
2030
|
if (range.split(/\s+|\|\|/).some((part)=>TARBALL_NAME.test(part))) return false;
|
|
@@ -1992,7 +2068,7 @@ function assertThirdPartyContent(manifest, label) {
|
|
|
1992
2068
|
}
|
|
1993
2069
|
for (const dep of manifest.dependencies ?? []){
|
|
1994
2070
|
if (dep.name.includes('{{')) throw new MfeflowError('E203', `${label}: ${dep.name} names a package in the project's scope`);
|
|
1995
|
-
if (!
|
|
2071
|
+
if (!policy_isRegistryRange(dep.range)) throw new MfeflowError('E203', `${label}: ${dep.name}@${dep.range} is not a registry version range`);
|
|
1996
2072
|
}
|
|
1997
2073
|
for (const t of manifest.transforms ?? [])assertThirdPartyTransform(t, label);
|
|
1998
2074
|
for (const script of manifest.scripts ?? [])assertPlainScript(script.name, manifest.name, label);
|
|
@@ -2008,90 +2084,16 @@ function assertThirdPartyTransform(t, label) {
|
|
|
2008
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`);
|
|
2009
2085
|
}
|
|
2010
2086
|
}
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
}
|
|
2020
|
-
if ('object' != typeof value || null === value || Array.isArray(value)) throw new MfeflowError('E202', `${label}: must be a JSON object`);
|
|
2021
|
-
const record = Object.fromEntries(Object.entries(value));
|
|
2022
|
-
if ('third-party' === trust) assertAllowedKeys(record, label);
|
|
2023
|
-
assertKnownOps(record, label);
|
|
2024
|
-
const issues = validateFeatureManifest(record);
|
|
2025
|
-
if (issues.length > 0) {
|
|
2026
|
-
const detail = issues.map((i)=>`${i.path || '(root)'} ${i.message}`).join('; ');
|
|
2027
|
-
throw new MfeflowError('E202', `${label}: ${detail}`);
|
|
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);
|
|
2028
2095
|
}
|
|
2029
|
-
|
|
2030
|
-
if ('third-party' === trust) assertThirdPartyContent(manifest, label);
|
|
2031
|
-
return manifest;
|
|
2032
|
-
}
|
|
2033
|
-
function toManifest(record, label) {
|
|
2034
|
-
const manifest = {
|
|
2035
|
-
name: '',
|
|
2036
|
-
version: ''
|
|
2037
|
-
};
|
|
2038
|
-
Object.assign(manifest, record);
|
|
2039
|
-
if ('' === manifest.name) throw new MfeflowError('E202', `${label}: name is required`);
|
|
2040
|
-
return manifest;
|
|
2041
|
-
}
|
|
2042
|
-
async function loadFeature(name, options) {
|
|
2043
|
-
if (!FEATURE_NAME.test(name)) throw new MfeflowError('E201', `"${name}" is not a feature name`);
|
|
2044
|
-
if (name.startsWith('@')) return loadRemote(name, options);
|
|
2045
|
-
return loadLocal(name, options);
|
|
2046
|
-
}
|
|
2047
|
-
async function loadLocal(name, options) {
|
|
2048
|
-
const ops = options.fs ?? realFs;
|
|
2049
|
-
if (!options.featuresDir) throw new MfeflowError('E201', `"${name}" (no features folder configured)`);
|
|
2050
|
-
const dir = node_path.join(options.featuresDir, name);
|
|
2051
|
-
const text = await readIfExists(ops, node_path.join(dir, 'feature.json'));
|
|
2052
|
-
if (void 0 === text) throw new MfeflowError('E201', `"${name}" (looked in ${options.featuresDir})`);
|
|
2053
|
-
const manifest = parseFeature(text, 'first-party', `${name}/feature.json`);
|
|
2054
|
-
if (manifest.name !== name) throw new MfeflowError('E202', `${name}/feature.json: name is "${manifest.name}", expected "${name}"`);
|
|
2055
|
-
return {
|
|
2056
|
-
manifest,
|
|
2057
|
-
trust: 'first-party',
|
|
2058
|
-
readAsset: async (relative)=>{
|
|
2059
|
-
if (!isSafeRelativePath(relative)) throw new MfeflowError('E105', `"${relative}"`);
|
|
2060
|
-
const content = await readIfExists(ops, node_path.join(dir, ...relative.split('/')));
|
|
2061
|
-
if (void 0 === content) throw new MfeflowError('E201', `${name}: file "${relative}" is missing`);
|
|
2062
|
-
return content;
|
|
2063
|
-
}
|
|
2064
|
-
};
|
|
2065
|
-
}
|
|
2066
|
-
async function loadRemote(name, options) {
|
|
2067
|
-
const text = await fetchRemoteText(name, options.registries ?? {}, options.fetch, options.version);
|
|
2068
|
-
const manifest = parseFeature(text, 'third-party', name);
|
|
2069
|
-
if (manifest.name !== name) throw new MfeflowError('E402', `asked for ${name}, got ${manifest.name}`);
|
|
2070
|
-
return {
|
|
2071
|
-
manifest,
|
|
2072
|
-
trust: 'third-party'
|
|
2073
|
-
};
|
|
2074
|
-
}
|
|
2075
|
-
async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
2076
|
-
let dirs;
|
|
2077
|
-
try {
|
|
2078
|
-
dirs = await ops.listDirs(featuresDir);
|
|
2079
|
-
} catch {
|
|
2080
|
-
return [];
|
|
2081
|
-
}
|
|
2082
|
-
const checked = await Promise.all(dirs.map(async (dir)=>await readIfExists(ops, node_path.join(featuresDir, dir, 'feature.json')) === void 0 ? void 0 : dir));
|
|
2083
|
-
return checked.filter((d)=>void 0 !== d).toSorted((a, b)=>a.localeCompare(b));
|
|
2084
|
-
}
|
|
2085
|
-
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
2086
|
-
function shortHash(text) {
|
|
2087
|
-
let hash = 0x811c9dc5;
|
|
2088
|
-
for (const char of text)hash = Math.imul(hash ^ char.codePointAt(0), 0x01000193) >>> 0;
|
|
2089
|
-
let out = '';
|
|
2090
|
-
for(let i = 0; i < 4; i++){
|
|
2091
|
-
out += LETTERS[hash % 26];
|
|
2092
|
-
hash = Math.floor(hash / 26);
|
|
2093
|
-
}
|
|
2094
|
-
return out;
|
|
2096
|
+
return out;
|
|
2095
2097
|
}
|
|
2096
2098
|
function tailwindPrefix(name) {
|
|
2097
2099
|
if (/^[a-z]+$/.test(name)) return name;
|
|
@@ -2263,7 +2265,10 @@ async function resolveFeature(input) {
|
|
|
2263
2265
|
version: manifest.version,
|
|
2264
2266
|
...Object.keys(options).length > 0 ? {
|
|
2265
2267
|
options
|
|
2266
|
-
} : {}
|
|
2268
|
+
} : {},
|
|
2269
|
+
...void 0 === feature.integrity ? {} : {
|
|
2270
|
+
integrity: feature.integrity
|
|
2271
|
+
}
|
|
2267
2272
|
});
|
|
2268
2273
|
}
|
|
2269
2274
|
async function editFile(builder, file, edit, op) {
|
|
@@ -2273,6 +2278,2109 @@ async function editFile(builder, file, edit, op) {
|
|
|
2273
2278
|
if (isConfig && !await builder.exists(file)) return void await builder.create(file, edit('{}\n'));
|
|
2274
2279
|
await builder.patch(file, edit);
|
|
2275
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
|
+
}
|
|
2276
4384
|
const registry_build_text = (value)=>`${JSON.stringify(value, null, 2)}\n`;
|
|
2277
4385
|
async function buildRegistry(dir, ops = realFs) {
|
|
2278
4386
|
let folders;
|
|
@@ -2386,4 +4494,4 @@ function packageInfo() {
|
|
|
2386
4494
|
version
|
|
2387
4495
|
};
|
|
2388
4496
|
}
|
|
2389
|
-
export { ERROR_CATALOGUE, MfeflowError, PROJECT_FILE, PlanBuilder, RUNTIME_CONFIG_FILE, applyPlan, applyTransform, buildRegistry, dotenvKeys, emptyPlan, fetchFeatureIndex, fetchRemoteText, findDiverged, findProjectRoot, getHttps, hasErrno, hashContent, inOrder, isMfeflowError, isProtectedPath, listLocalFeatures, loadFeature, loadProject, messageOf, packageInfo, parseFeature, parseProject, readCappedText, realFs, recordPlan, registryUrl, renderDiff, renderTemplate, resolveFeature, resolveOptions, runtimeConfigFiles, saveProject, schemaKeys, serializeProject, substitute, summarize, 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 };
|