@mfeflow/core 0.4.0 → 0.5.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/errors.d.ts +59 -3
- package/dist/features/index.d.ts +2 -1
- package/dist/features/load.d.ts +6 -0
- package/dist/features/remote.d.ts +19 -3
- package/dist/features/trust.d.ts +44 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +318 -32
- package/dist/plan.d.ts +18 -0
- package/package.json +2 -2
package/dist/errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every error the engine throws. Ranges: E1xx project and config, E2xx
|
|
3
|
-
* features, E3xx build and runtime, E4xx
|
|
3
|
+
* features, E3xx build and runtime, E4xx feature registries, E5xx deploy and the static registry.
|
|
4
4
|
*/
|
|
5
5
|
export declare const ERROR_CATALOGUE: {
|
|
6
6
|
readonly E101: {
|
|
@@ -64,8 +64,8 @@ export declare const ERROR_CATALOGUE: {
|
|
|
64
64
|
readonly fix: 'Give one of them another port in its rsbuild config and in "apps" in mfeflow.json.';
|
|
65
65
|
};
|
|
66
66
|
readonly E116: {
|
|
67
|
-
readonly title: 'Two remotes claim
|
|
68
|
-
readonly fix: 'Give each route to one remote, and keep the name in mfe.json equal to the app name.';
|
|
67
|
+
readonly title: 'Two remotes claim routes that overlap, or an mfe.json disagrees with mfeflow.json';
|
|
68
|
+
readonly fix: 'Give each route to one remote (a wildcard such as /checkout/* also covers /checkout/pay), and keep the name in mfe.json equal to the app name.';
|
|
69
69
|
};
|
|
70
70
|
readonly E117: {
|
|
71
71
|
readonly title: 'A copied file changed since mfeflow wrote it';
|
|
@@ -187,6 +187,62 @@ export declare const ERROR_CATALOGUE: {
|
|
|
187
187
|
readonly title: 'A feature in the registry folder cannot be published';
|
|
188
188
|
readonly 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.';
|
|
189
189
|
};
|
|
190
|
+
readonly E501: {
|
|
191
|
+
readonly title: 'A deploy failed the schema gate';
|
|
192
|
+
readonly fix: "Fix what the message names: the remote's mfe.json, its mf-manifest.json or the version. Nothing was uploaded. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
193
|
+
};
|
|
194
|
+
readonly E502: {
|
|
195
|
+
readonly title: 'A route of this remote overlaps a route of another remote';
|
|
196
|
+
readonly fix: "Give each URL to one remote (/checkout/* also covers /checkout/pay), then deploy again. Nothing was uploaded. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
197
|
+
};
|
|
198
|
+
readonly E503: {
|
|
199
|
+
readonly title: "The remote's shared ranges do not fit the shell that is deployed";
|
|
200
|
+
readonly fix: "Align the remote with the shell on the package named above, or deploy the shell first. Nothing was uploaded. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
201
|
+
};
|
|
202
|
+
readonly E504: {
|
|
203
|
+
readonly title: 'The remote is over its first-load JS budget';
|
|
204
|
+
readonly fix: "Cut what the remote loads first, or raise budgets.initialJsKb in its mfe.json on purpose. Nothing was uploaded. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
205
|
+
};
|
|
206
|
+
readonly E505: {
|
|
207
|
+
readonly title: 'A rollout was asked for, and a static host cannot split traffic';
|
|
208
|
+
readonly fix: "Deploy to one version at a time, or use the registry server for percentage rollouts. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
209
|
+
};
|
|
210
|
+
readonly E506: {
|
|
211
|
+
readonly title: 'The registry host is not set up or cannot be used';
|
|
212
|
+
readonly 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 https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
213
|
+
};
|
|
214
|
+
readonly E507: {
|
|
215
|
+
readonly title: 'There is no build output to deploy';
|
|
216
|
+
readonly 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 https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
217
|
+
};
|
|
218
|
+
readonly E508: {
|
|
219
|
+
readonly title: 'There is no such version to move to';
|
|
220
|
+
readonly fix: "Run `mfeflow status` to see what each environment serves and the versions you can go back to. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
221
|
+
};
|
|
222
|
+
readonly E509: {
|
|
223
|
+
readonly title: 'Files of the target version are missing on the host';
|
|
224
|
+
readonly fix: "Deploy that version again from its build, or pick another version with --to. The index was not changed. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
225
|
+
};
|
|
226
|
+
readonly E510: {
|
|
227
|
+
readonly title: 'Another deploy changed the index at the same time';
|
|
228
|
+
readonly fix: "Run `mfeflow status` to see what is served now, then run the command again. The index was not changed by this run. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
229
|
+
};
|
|
230
|
+
readonly E511: {
|
|
231
|
+
readonly title: 'This version is already published with different content';
|
|
232
|
+
readonly fix: "A published version never changes. Bump the version and deploy again. See https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
233
|
+
};
|
|
234
|
+
readonly E512: {
|
|
235
|
+
readonly title: 'Local registry files and the host disagree';
|
|
236
|
+
readonly 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 https://mfeflow.github.io/mfeflow/guide/deploy-and-rollback.";
|
|
237
|
+
};
|
|
238
|
+
readonly E405: {
|
|
239
|
+
readonly title: 'The registry is not trusted yet';
|
|
240
|
+
readonly fix: 'Run `mfeflow registry trust @namespace` once to allow requests to it, or remove it from "registries".';
|
|
241
|
+
};
|
|
242
|
+
readonly E406: {
|
|
243
|
+
readonly title: 'The registry changed a feature since you added it';
|
|
244
|
+
readonly fix: 'Nothing was written. Look at what changed; if you accept the new content, run the command again with --accept-changed-content.';
|
|
245
|
+
};
|
|
190
246
|
};
|
|
191
247
|
export type ErrorCode = keyof typeof ERROR_CATALOGUE;
|
|
192
248
|
export interface MfeflowErrorOptions {
|
package/dist/features/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { listLocalFeatures, loadFeature, parseFeature, type LoadedFeature, type LoadOptions, } from './load.js';
|
|
2
|
-
export { fetchFeatureIndex, fetchRemoteText, getHttps, readCappedText, registryUrl, type FetchLike, type FetchResponse, } from './remote.js';
|
|
2
|
+
export { fetchFeatureIndex, fetchRemoteText, getHttps, checkIntegrity, indexedVersion, integrityOf, readCappedText, registryUrl, type FetchLike, type FetchResponse, } from './remote.js';
|
|
3
|
+
export { addTrusted, configDirOf, parseTrustEnv, readTrusted, removeTrusted, TRUST_ENV, trustedNamespaces, trustFileIn, type EnvTrust, type Trusted, type TrustedRegistry, } from './trust.js';
|
|
3
4
|
export { resolveFeature, resolveOptions, type ResolveInput } from './resolve.js';
|
|
4
5
|
export { type Trust } from './policy.js';
|
|
5
6
|
export { isProtectedPath } from './write-policy.js';
|
package/dist/features/load.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface LoadedFeature {
|
|
|
7
7
|
trust: Trust;
|
|
8
8
|
/** Reads a `from` file of a local feature. Absent for remote features. */
|
|
9
9
|
readAsset?: (relative: string) => Promise<string>;
|
|
10
|
+
/** `sha256-<hex>` of the feature.json a registry served. Absent for local features. */
|
|
11
|
+
integrity?: string;
|
|
10
12
|
}
|
|
11
13
|
/** Parses and checks the text of a `feature.json`. Third-party manifests also pass the declarative-only policy. */
|
|
12
14
|
export declare function parseFeature(text: string, trust: Trust, label: string): FeatureManifest;
|
|
@@ -19,6 +21,10 @@ export interface LoadOptions {
|
|
|
19
21
|
/** The version to ask a registry for, when its URL template has `{version}`. */
|
|
20
22
|
version?: string;
|
|
21
23
|
fs?: FsOps;
|
|
24
|
+
/** The user trusts this feature's registry, so it may be on a loopback or private address. */
|
|
25
|
+
trusted?: boolean;
|
|
26
|
+
/** Also compare the version the registry's index lists with the one in the feature.json (E402 on a difference). */
|
|
27
|
+
checkIndex?: boolean;
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Finds a feature by name. `@ns/name` comes from that namespace's registry and
|
|
@@ -26,13 +26,24 @@ export declare function registryUrl(name: string, registries: Record<string, str
|
|
|
26
26
|
* GETs `url`, following redirects by hand so one that leaves https is refused before it is
|
|
27
27
|
* requested. `code` is the error every failure here throws. With `loopback`, a URL that starts
|
|
28
28
|
* on plain http at 127.0.0.1, localhost or [::1] may stay on such a host; nothing else may
|
|
29
|
-
* step down to http, and an https URL never ends up on one.
|
|
29
|
+
* step down to http, and an https URL never ends up on one. With `blockPrivate`, no request goes
|
|
30
|
+
* to a loopback or private address (`isPrivateHost`), not even by redirect; that is E405. With
|
|
31
|
+
* `blockPrivate: false` (a trusted registry) a private address is fine only when `url` itself is
|
|
32
|
+
* private, so a public server can never redirect you into a private network.
|
|
30
33
|
*/
|
|
31
34
|
export declare function getHttps(url: string, fetchImpl: FetchLike, code: 'E109' | 'E401', options?: {
|
|
32
35
|
loopback?: boolean;
|
|
36
|
+
blockPrivate?: boolean;
|
|
33
37
|
}): Promise<FetchResponse>;
|
|
34
38
|
/** Downloads the feature.json text for `@ns/name`. */
|
|
35
|
-
export declare function fetchRemoteText(name: string, registries: Record<string, string>, fetchImpl?: FetchLike, version?: string): Promise<string>;
|
|
39
|
+
export declare function fetchRemoteText(name: string, registries: Record<string, string>, fetchImpl?: FetchLike, version?: string, trusted?: boolean): Promise<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Compares the hash recorded when `name` was added with the one of what the registry serves now.
|
|
42
|
+
* A difference is an E406 unless `accept`; returns whether the content changed.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkIntegrity(name: string, recorded: string, served: string, accept: boolean): boolean;
|
|
45
|
+
/** `sha256-<hex>` of the exact text a registry served. */
|
|
46
|
+
export declare function integrityOf(text: string): string;
|
|
36
47
|
/**
|
|
37
48
|
* The body of `response` as text, refusing more than `maxBytes`: from the declared
|
|
38
49
|
* length first, then on the bytes actually streamed, so an oversized answer is
|
|
@@ -44,4 +55,9 @@ export declare function readCappedText(response: FetchResponse, maxBytes: number
|
|
|
44
55
|
* template starts in (see `registryIndexUrl`). Fails with E401 when it cannot be fetched and E403
|
|
45
56
|
* when it is not an index, so a caller can show one registry's problem and carry on with the rest.
|
|
46
57
|
*/
|
|
47
|
-
export declare function fetchFeatureIndex(namespace: string, registries: Record<string, string>, fetchImpl?: FetchLike): Promise<FeatureRegistryItem[]>;
|
|
58
|
+
export declare function fetchFeatureIndex(namespace: string, registries: Record<string, string>, fetchImpl?: FetchLike, trusted?: boolean): Promise<FeatureRegistryItem[]>;
|
|
59
|
+
/**
|
|
60
|
+
* The version `<namespace>`'s index lists for the bare feature `name`, undefined when the index
|
|
61
|
+
* does not list it or the registry has none (404). Any other failure to read it is thrown.
|
|
62
|
+
*/
|
|
63
|
+
export declare function indexedVersion(namespace: string, name: string, registries: Record<string, string>, fetchImpl?: FetchLike, trusted?: boolean): Promise<string | undefined>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type FsOps } from '../fs.js';
|
|
2
|
+
/** A registry the user has accepted: this namespace at exactly this URL template. */
|
|
3
|
+
export interface TrustedRegistry {
|
|
4
|
+
namespace: string;
|
|
5
|
+
template: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const TRUST_FILE = "trusted-registries.json";
|
|
8
|
+
/** Names the namespaces to trust for one run without touching the trust file, like `@a,@b`. For CI. */
|
|
9
|
+
export declare const TRUST_ENV = "MFEFLOW_TRUST_REGISTRIES";
|
|
10
|
+
/**
|
|
11
|
+
* Where mfeflow keeps per-user settings: `MFEFLOW_CONFIG_DIR`, else `$XDG_CONFIG_HOME/mfeflow`,
|
|
12
|
+
* else `%APPDATA%\mfeflow` on Windows, else `~/.config/mfeflow`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function configDirOf(env: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, home?: string): string;
|
|
15
|
+
export declare function trustFileIn(configDir: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* What the user has trusted. A missing, unreadable or malformed file trusts nothing, so a damaged
|
|
18
|
+
* file fails closed and the next `registry trust` replaces it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readTrusted(configDir: string, ops?: FsOps): Promise<TrustedRegistry[]>;
|
|
21
|
+
/** Records `entry`. Returns false when it was already trusted. */
|
|
22
|
+
export declare function addTrusted(configDir: string, entry: TrustedRegistry, ops?: FsOps): Promise<boolean>;
|
|
23
|
+
/** Forgets everything trusted under `namespace`. Returns how many entries went. */
|
|
24
|
+
export declare function removeTrusted(configDir: string, namespace: string, ops?: FsOps): Promise<number>;
|
|
25
|
+
/** One item of `TRUST_ENV`: a namespace, optionally pinned to the URL template it must have. */
|
|
26
|
+
export interface EnvTrust {
|
|
27
|
+
namespace: string;
|
|
28
|
+
template?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Reads `@a,@b=https://host/{name}.json`: items split on commas, anything that is not a namespace dropped. */
|
|
31
|
+
export declare function parseTrustEnv(value: string | undefined): EnvTrust[];
|
|
32
|
+
export interface Trusted {
|
|
33
|
+
/** Registries that may be asked at all. */
|
|
34
|
+
fetch: Set<string>;
|
|
35
|
+
/** Registries that may also be on a loopback or private address. */
|
|
36
|
+
privateOk: Set<string>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What the user trusts among `registries`. The trust file and a pinned env item (`@ns=<template>`
|
|
40
|
+
* with the template the project has) allow everything; an env item with only a namespace allows
|
|
41
|
+
* asking a public registry but never a loopback or private address, so a pull request cannot aim
|
|
42
|
+
* a CI job at an internal host.
|
|
43
|
+
*/
|
|
44
|
+
export declare function trustedNamespaces(registries: Record<string, string>, recorded: readonly TrustedRegistry[], fromEnv?: readonly EnvTrust[]): Trusted;
|
package/dist/index.d.ts
CHANGED
|
@@ -13,4 +13,4 @@ export { applyPlan, type ApplyOptions, type ApplyResult, type InstallContext, ty
|
|
|
13
13
|
export { renderTemplate, substitute, type TemplateData } from './templates.js';
|
|
14
14
|
export { RUNTIME_CONFIG_FILE, runtimeConfigFiles } from './runtime-config.js';
|
|
15
15
|
export { applyTransform, dotenvKeys, schemaKeys, type SchemaKey } from './transforms/index.js';
|
|
16
|
-
export { buildRegistry, fetchFeatureIndex, fetchRemoteText, isProtectedPath, listLocalFeatures, loadFeature, parseFeature, getHttps, readCappedText, registryUrl, resolveFeature, resolveOptions, type FetchLike, type FetchResponse, type LoadedFeature, type LoadOptions, type RegistryBuild, type RegistryFile, type ResolveInput, type Trust, } from './features/index.js';
|
|
16
|
+
export { buildRegistry, fetchFeatureIndex, fetchRemoteText, isProtectedPath, listLocalFeatures, loadFeature, parseFeature, addTrusted, configDirOf, parseTrustEnv, readTrusted, removeTrusted, TRUST_ENV, trustedNamespaces, trustFileIn, type EnvTrust, type Trusted, type TrustedRegistry, getHttps, checkIntegrity, indexedVersion, integrityOf, readCappedText, registryUrl, resolveFeature, resolveOptions, type FetchLike, type FetchResponse, type LoadedFeature, type LoadOptions, type RegistryBuild, type RegistryFile, type ResolveInput, type Trust, } from './features/index.js';
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
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";
|
|
2
|
+
import { DOCS_URL, FEATURE_CONDITION, FEATURE_NAME, REGISTRY_NAMESPACE, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isLoopbackHttp, isMfeflowProject, isPrivateHost, isRegistryProtocol, isSafeRelativePath, redactUrl, redactUrlsIn, registryIndexUrl, registryTemplateProblem, validateFeatureManifest, validateFeatureRegistryIndex, validateMfeflowProject } from "@mfeflow/contracts";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import promises from "node:fs/promises";
|
|
5
5
|
import node_path from "node:path";
|
|
6
6
|
import { formatPatch, structuredPatch } from "diff";
|
|
7
7
|
import { isMap, isSeq, parseDocument, stringify } from "yaml";
|
|
8
|
+
import { valid, validRange } from "semver";
|
|
8
9
|
import { Eta } from "eta";
|
|
9
10
|
import { isArrayExpression, isArrowFunctionExpression, isBooleanLiteral, isCallExpression, isFunctionDeclaration, isFunctionExpression, isIdentifier, isImportDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isNode, isNullLiteral, isNumericLiteral, isObjectExpression, isObjectProperty, isParenthesizedExpression, isReturnStatement, isStringLiteral, isTSAsExpression, isTSNonNullExpression, isTSSatisfiesExpression, isUnaryExpression, isValidIdentifier, isVariableDeclaration } from "@babel/types";
|
|
10
11
|
import { parse, parseExpression } from "@babel/parser";
|
|
11
|
-
import
|
|
12
|
+
import node_os from "node:os";
|
|
12
13
|
const ERROR_CATALOGUE = {
|
|
13
14
|
E101: {
|
|
14
15
|
title: 'A shared singleton is missing from the shared list',
|
|
@@ -71,8 +72,8 @@ const ERROR_CATALOGUE = {
|
|
|
71
72
|
fix: 'Give one of them another port in its rsbuild config and in "apps" in mfeflow.json.'
|
|
72
73
|
},
|
|
73
74
|
E116: {
|
|
74
|
-
title: 'Two remotes claim
|
|
75
|
-
fix: 'Give each route to one remote, and keep the name in mfe.json equal to the app name.'
|
|
75
|
+
title: 'Two remotes claim routes that overlap, or an mfe.json disagrees with mfeflow.json',
|
|
76
|
+
fix: 'Give each route to one remote (a wildcard such as /checkout/* also covers /checkout/pay), and keep the name in mfe.json equal to the app name.'
|
|
76
77
|
},
|
|
77
78
|
E117: {
|
|
78
79
|
title: 'A copied file changed since mfeflow wrote it',
|
|
@@ -193,6 +194,62 @@ const ERROR_CATALOGUE = {
|
|
|
193
194
|
E404: {
|
|
194
195
|
title: 'A feature in the registry folder cannot be published',
|
|
195
196
|
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.'
|
|
197
|
+
},
|
|
198
|
+
E501: {
|
|
199
|
+
title: 'A deploy failed the schema gate',
|
|
200
|
+
fix: `Fix what the message names: the remote's mfe.json, its mf-manifest.json or the version. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
201
|
+
},
|
|
202
|
+
E502: {
|
|
203
|
+
title: 'A route of this remote overlaps a route of another remote',
|
|
204
|
+
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.`
|
|
205
|
+
},
|
|
206
|
+
E503: {
|
|
207
|
+
title: "The remote's shared ranges do not fit the shell that is deployed",
|
|
208
|
+
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.`
|
|
209
|
+
},
|
|
210
|
+
E504: {
|
|
211
|
+
title: 'The remote is over its first-load JS budget',
|
|
212
|
+
fix: `Cut what the remote loads first, or raise budgets.initialJsKb in its mfe.json on purpose. Nothing was uploaded. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
213
|
+
},
|
|
214
|
+
E505: {
|
|
215
|
+
title: 'A rollout was asked for, and a static host cannot split traffic',
|
|
216
|
+
fix: `Deploy to one version at a time, or use the registry server for percentage rollouts. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
217
|
+
},
|
|
218
|
+
E506: {
|
|
219
|
+
title: 'The registry host is not set up or cannot be used',
|
|
220
|
+
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.`
|
|
221
|
+
},
|
|
222
|
+
E507: {
|
|
223
|
+
title: 'There is no build output to deploy',
|
|
224
|
+
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.`
|
|
225
|
+
},
|
|
226
|
+
E508: {
|
|
227
|
+
title: 'There is no such version to move to',
|
|
228
|
+
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.`
|
|
229
|
+
},
|
|
230
|
+
E509: {
|
|
231
|
+
title: 'Files of the target version are missing on the host',
|
|
232
|
+
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.`
|
|
233
|
+
},
|
|
234
|
+
E510: {
|
|
235
|
+
title: 'Another deploy changed the index at the same time',
|
|
236
|
+
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.`
|
|
237
|
+
},
|
|
238
|
+
E511: {
|
|
239
|
+
title: 'This version is already published with different content',
|
|
240
|
+
fix: `A published version never changes. Bump the version and deploy again. See ${DOCS_URL}/guide/deploy-and-rollback.`
|
|
241
|
+
},
|
|
242
|
+
E512: {
|
|
243
|
+
title: 'Local registry files and the host disagree',
|
|
244
|
+
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.`
|
|
245
|
+
},
|
|
246
|
+
E405: {
|
|
247
|
+
title: 'The registry is not trusted yet',
|
|
248
|
+
fix: 'Run `mfeflow registry trust @namespace` once to allow requests to it, or remove it from "registries".'
|
|
249
|
+
},
|
|
250
|
+
E406: {
|
|
251
|
+
title: 'The registry changed a feature since you added it',
|
|
252
|
+
fix: 'Nothing was written. Look at what changed; if you accept the new content, run the command again with --accept-changed-content.'
|
|
196
253
|
}
|
|
197
254
|
};
|
|
198
255
|
class MfeflowError extends Error {
|
|
@@ -367,11 +424,14 @@ function recordPlan(project, plan) {
|
|
|
367
424
|
const features = {
|
|
368
425
|
...project.features
|
|
369
426
|
};
|
|
370
|
-
for (const f of plan.features)features[f.name] =
|
|
427
|
+
for (const f of plan.features)features[f.name] = {
|
|
371
428
|
version: f.version,
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
429
|
+
...f.options ? {
|
|
430
|
+
options: f.options
|
|
431
|
+
} : {},
|
|
432
|
+
...void 0 === f.integrity ? {} : {
|
|
433
|
+
integrity: f.integrity
|
|
434
|
+
}
|
|
375
435
|
};
|
|
376
436
|
const next = {
|
|
377
437
|
...project,
|
|
@@ -431,6 +491,11 @@ function emptyPlan() {
|
|
|
431
491
|
};
|
|
432
492
|
}
|
|
433
493
|
const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
494
|
+
const DEPENDENCY_SECTIONS = [
|
|
495
|
+
'dependencies',
|
|
496
|
+
'devDependencies',
|
|
497
|
+
'optionalDependencies'
|
|
498
|
+
];
|
|
434
499
|
class PlanBuilder {
|
|
435
500
|
read;
|
|
436
501
|
options;
|
|
@@ -499,6 +564,13 @@ class PlanBuilder {
|
|
|
499
564
|
this.touch(entry);
|
|
500
565
|
entry.current = edit(entry.current);
|
|
501
566
|
}
|
|
567
|
+
async replace(file, content) {
|
|
568
|
+
const entry = await this.open(file);
|
|
569
|
+
if (null === entry.current) throw new MfeflowError('E208', `${file} does not exist`);
|
|
570
|
+
if ('third-party' === this.trust && entry.current !== content) throw new MfeflowError('E207', file);
|
|
571
|
+
this.touch(entry);
|
|
572
|
+
entry.current = content;
|
|
573
|
+
}
|
|
502
574
|
async delete(file) {
|
|
503
575
|
const entry = await this.open(file);
|
|
504
576
|
this.touch(entry);
|
|
@@ -527,9 +599,69 @@ class PlanBuilder {
|
|
|
527
599
|
file,
|
|
528
600
|
name,
|
|
529
601
|
range: wanted,
|
|
530
|
-
dev
|
|
602
|
+
dev,
|
|
603
|
+
...'third-party' === this.trust ? {
|
|
604
|
+
thirdParty: true
|
|
605
|
+
} : {}
|
|
531
606
|
});
|
|
532
607
|
}
|
|
608
|
+
async setRange(file, name, range) {
|
|
609
|
+
if (!isRegistryRange(range)) throw new MfeflowError('E202', `${name}: "${range}" is not a semver range or a dist-tag`);
|
|
610
|
+
const source = await this.content(file);
|
|
611
|
+
if (void 0 === source) throw new MfeflowError('E208', `${file} does not exist`);
|
|
612
|
+
const existing = this.declaredRange(file, source, name);
|
|
613
|
+
if (existing === range || existing.startsWith('workspace:')) return;
|
|
614
|
+
if (!existing.startsWith('catalog:') && !isRegistryRange(existing)) return void this.warn(`${file}: ${name} is ${JSON.stringify(existing)}; left as it is, not moved to ${range}`);
|
|
615
|
+
if ('third-party' === this.trust) throw new MfeflowError('E207', `${file}: ${name} is already ${JSON.stringify(existing)}; the feature wants ${range}`);
|
|
616
|
+
if (existing.startsWith('catalog:')) return void await this.setCatalogEntry(name, existing.slice(8), range);
|
|
617
|
+
await this.patch(file, (text)=>editJsonObject(file, text, (pkg)=>{
|
|
618
|
+
const section = DEPENDENCY_SECTIONS.find((key)=>name in asRecord(pkg[key])) ?? '';
|
|
619
|
+
pkg[section] = {
|
|
620
|
+
...asRecord(pkg[section]),
|
|
621
|
+
[name]: range
|
|
622
|
+
};
|
|
623
|
+
return true;
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
declaredRange(file, source, name) {
|
|
627
|
+
const pkg = parseJsonObject(file, source);
|
|
628
|
+
for (const section of DEPENDENCY_SECTIONS){
|
|
629
|
+
const value = asRecord(pkg[section])[name];
|
|
630
|
+
if ('string' == typeof value) return value;
|
|
631
|
+
}
|
|
632
|
+
throw new MfeflowError('E208', `${file} does not depend on ${name}`);
|
|
633
|
+
}
|
|
634
|
+
async setCatalogEntry(name, catalogName, range) {
|
|
635
|
+
const path = '' === catalogName || 'default' === catalogName ? [
|
|
636
|
+
'catalog'
|
|
637
|
+
] : [
|
|
638
|
+
'catalogs',
|
|
639
|
+
catalogName
|
|
640
|
+
];
|
|
641
|
+
const source = await this.content(WORKSPACE_FILE);
|
|
642
|
+
const doc = void 0 === source ? void 0 : parseDocument(source);
|
|
643
|
+
const problem = doc?.errors[0];
|
|
644
|
+
if (problem) throw new MfeflowError('E208', `${WORKSPACE_FILE} is not valid YAML (${problem.message})`, {
|
|
645
|
+
cause: problem
|
|
646
|
+
});
|
|
647
|
+
if (void 0 === doc || !isMap(doc.getIn(path)) || !doc.hasIn([
|
|
648
|
+
...path,
|
|
649
|
+
name
|
|
650
|
+
])) throw new MfeflowError('E208', `${WORKSPACE_FILE} has no catalog entry for ${name}`);
|
|
651
|
+
doc.setIn([
|
|
652
|
+
...path,
|
|
653
|
+
name
|
|
654
|
+
], range);
|
|
655
|
+
let text;
|
|
656
|
+
try {
|
|
657
|
+
text = doc.toString();
|
|
658
|
+
} catch (error) {
|
|
659
|
+
throw new MfeflowError('E208', `${WORKSPACE_FILE} could not be written back (${messageOf(error)})`, {
|
|
660
|
+
cause: error
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
await this.patch(WORKSPACE_FILE, ()=>text);
|
|
664
|
+
}
|
|
533
665
|
async addScript(file, name, command) {
|
|
534
666
|
let added = true;
|
|
535
667
|
await this.patch(file, (source)=>editJsonObject(file, source, (pkg)=>{
|
|
@@ -600,6 +732,10 @@ class PlanBuilder {
|
|
|
600
732
|
};
|
|
601
733
|
}
|
|
602
734
|
}
|
|
735
|
+
function isRegistryRange(range) {
|
|
736
|
+
if ('' === range || range !== range.trim() || range.includes('/')) return false;
|
|
737
|
+
return /^[a-z][a-z0-9-]*$/.test(range) || null !== validRange(range);
|
|
738
|
+
}
|
|
603
739
|
function isPinnedElsewhere(range) {
|
|
604
740
|
return range.startsWith('catalog:') || range.startsWith('workspace:');
|
|
605
741
|
}
|
|
@@ -625,16 +761,20 @@ const VERB = {
|
|
|
625
761
|
patch: 'patch ',
|
|
626
762
|
delete: 'delete'
|
|
627
763
|
};
|
|
764
|
+
const dependencyLine = (d)=>` ${d.file} ${d.name}@${d.range}${d.dev ? ' (dev)' : ''}`;
|
|
628
765
|
function summarize(plan) {
|
|
629
766
|
const count = (action)=>plan.changes.filter((c)=>c.action === action).length;
|
|
630
767
|
const lines = [
|
|
631
768
|
`${String(count('create'))} to create, ${String(count('patch'))} to patch, ${String(count('delete'))} to delete`
|
|
632
769
|
];
|
|
633
770
|
for (const c of plan.changes)lines.push(` ${VERB[c.action]} ${c.path}`);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
771
|
+
const dependencies = plan.dependencies.filter((d)=>true !== d.thirdParty);
|
|
772
|
+
const newPackages = plan.dependencies.filter((d)=>true === d.thirdParty);
|
|
773
|
+
if (dependencies.length > 0) {
|
|
774
|
+
lines.push(`Dependencies (${String(dependencies.length)}):`);
|
|
775
|
+
for (const d of dependencies)lines.push(dependencyLine(d));
|
|
637
776
|
}
|
|
777
|
+
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
778
|
if (plan.scripts.length > 0) {
|
|
639
779
|
lines.push(`Scripts added to package.json, not run (${String(plan.scripts.length)}):`);
|
|
640
780
|
for (const s of plan.scripts)lines.push(` ${s.file} ${s.name}: ${s.command}`);
|
|
@@ -1712,19 +1852,27 @@ function registryUrl(name, registries, version) {
|
|
|
1712
1852
|
try {
|
|
1713
1853
|
parsed = new URL(url);
|
|
1714
1854
|
} catch (error) {
|
|
1715
|
-
throw new MfeflowError('E401', `${namespace} has an invalid URL (${url})`, {
|
|
1855
|
+
throw new MfeflowError('E401', `${namespace} has an invalid URL (${redactUrl(url)})`, {
|
|
1716
1856
|
cause: error
|
|
1717
1857
|
});
|
|
1718
1858
|
}
|
|
1719
|
-
if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${url} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
|
|
1859
|
+
if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${redactUrl(url)} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
|
|
1720
1860
|
return parsed.href;
|
|
1721
1861
|
}
|
|
1722
1862
|
async function getHttps(url, fetchImpl, code, options = {}) {
|
|
1723
1863
|
const fail = (at, why, cause)=>{
|
|
1724
|
-
throw new MfeflowError(code, `${at}: ${why}
|
|
1864
|
+
throw new MfeflowError(code, redactUrlsIn(`${at}: ${why}`), void 0 === cause ? {} : {
|
|
1725
1865
|
cause
|
|
1726
1866
|
});
|
|
1727
1867
|
};
|
|
1868
|
+
const startsPrivate = isPrivateHost(new URL(url).hostname);
|
|
1869
|
+
const refusePrivate = (target)=>{
|
|
1870
|
+
if (void 0 === options.blockPrivate || !isPrivateHost(target.hostname)) return;
|
|
1871
|
+
if (!options.blockPrivate && startsPrivate) return;
|
|
1872
|
+
const why = options.blockPrivate ? 'those are only requested from a trusted registry' : 'a public registry may not send you to one';
|
|
1873
|
+
throw new MfeflowError('E405', `${redactUrl(target.href)} is a loopback or private address; ${why}`);
|
|
1874
|
+
};
|
|
1875
|
+
refusePrivate(new URL(url));
|
|
1728
1876
|
const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
|
|
1729
1877
|
const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
|
|
1730
1878
|
const addressOk = (address)=>{
|
|
@@ -1746,6 +1894,7 @@ async function getHttps(url, fetchImpl, code, options = {}) {
|
|
|
1746
1894
|
return fail(current, messageOf(error), error);
|
|
1747
1895
|
}
|
|
1748
1896
|
if (void 0 !== response.url && '' !== response.url && !addressOk(response.url)) fail(url, `ended up at ${response.url}, which is not https`);
|
|
1897
|
+
if (void 0 !== response.url && '' !== response.url) refusePrivate(new URL(response.url));
|
|
1749
1898
|
if (response.status < 300 || response.status >= 400) return response;
|
|
1750
1899
|
const location = response.headers?.get('location');
|
|
1751
1900
|
await response.body?.cancel().catch(()=>void 0);
|
|
@@ -1757,20 +1906,31 @@ async function getHttps(url, fetchImpl, code, options = {}) {
|
|
|
1757
1906
|
return fail(current, 'redirected to a bad address', error);
|
|
1758
1907
|
}
|
|
1759
1908
|
if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
|
|
1909
|
+
refusePrivate(next);
|
|
1760
1910
|
current = next.href;
|
|
1761
1911
|
}
|
|
1762
1912
|
return fail(url, 'redirected too many times');
|
|
1763
1913
|
}
|
|
1764
|
-
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version) {
|
|
1914
|
+
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version, trusted = false) {
|
|
1765
1915
|
const url = registryUrl(name, registries, version);
|
|
1916
|
+
const shown = redactUrl(url);
|
|
1766
1917
|
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1767
|
-
loopback: true
|
|
1918
|
+
loopback: true,
|
|
1919
|
+
blockPrivate: !trusted
|
|
1768
1920
|
});
|
|
1769
1921
|
if (!response.ok) {
|
|
1770
1922
|
const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
|
|
1771
|
-
throw new MfeflowError('E401', `${
|
|
1923
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
|
|
1772
1924
|
}
|
|
1773
|
-
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${
|
|
1925
|
+
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${shown} is larger than 1 MB`));
|
|
1926
|
+
}
|
|
1927
|
+
function checkIntegrity(name, recorded, served, accept) {
|
|
1928
|
+
if (recorded === served) return false;
|
|
1929
|
+
if (!accept) throw new MfeflowError('E406', `${name} was ${recorded} when you added it and is now ${served}`);
|
|
1930
|
+
return true;
|
|
1931
|
+
}
|
|
1932
|
+
function integrityOf(text) {
|
|
1933
|
+
return `sha256-${createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
1774
1934
|
}
|
|
1775
1935
|
async function readCappedText(response, maxBytes, tooBig) {
|
|
1776
1936
|
const declared = Number(response.headers?.get('content-length') ?? 0 / 0);
|
|
@@ -1792,34 +1952,45 @@ async function readCappedText(response, maxBytes, tooBig) {
|
|
|
1792
1952
|
}
|
|
1793
1953
|
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
1794
1954
|
}
|
|
1795
|
-
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch) {
|
|
1955
|
+
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch, trusted = false) {
|
|
1956
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, false);
|
|
1957
|
+
return items ?? [];
|
|
1958
|
+
}
|
|
1959
|
+
async function indexedVersion(namespace, name, registries, fetchImpl = realFetch, trusted = false) {
|
|
1960
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, true);
|
|
1961
|
+
return items?.find((item)=>item.name === name)?.version;
|
|
1962
|
+
}
|
|
1963
|
+
async function readIndex(namespace, registries, fetchImpl, trusted, missingOk) {
|
|
1796
1964
|
const template = registries[namespace];
|
|
1797
1965
|
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1798
1966
|
const problem = registryTemplateProblem(template);
|
|
1799
1967
|
if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
|
|
1800
1968
|
const url = registryIndexUrl(template);
|
|
1969
|
+
const shown = redactUrl(url);
|
|
1801
1970
|
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1802
|
-
loopback: true
|
|
1971
|
+
loopback: true,
|
|
1972
|
+
blockPrivate: !trusted
|
|
1803
1973
|
});
|
|
1804
1974
|
if (!response.ok) {
|
|
1975
|
+
if (missingOk && 404 === response.status) return;
|
|
1805
1976
|
const hint = 404 === response.status ? ': this registry has no index.json' : '';
|
|
1806
|
-
throw new MfeflowError('E401', `${
|
|
1977
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint}`);
|
|
1807
1978
|
}
|
|
1808
|
-
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${
|
|
1979
|
+
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${shown} is larger than 1 MB`));
|
|
1809
1980
|
let value;
|
|
1810
1981
|
try {
|
|
1811
1982
|
value = JSON.parse(text);
|
|
1812
1983
|
} catch (error) {
|
|
1813
1984
|
const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
|
|
1814
|
-
throw new MfeflowError('E403', `${
|
|
1985
|
+
throw new MfeflowError('E403', `${shown} is not JSON (${reason})`, {
|
|
1815
1986
|
cause: error
|
|
1816
1987
|
});
|
|
1817
1988
|
}
|
|
1818
1989
|
const issues = validateFeatureRegistryIndex(value);
|
|
1819
1990
|
if (issues.length > 0) {
|
|
1820
|
-
const
|
|
1991
|
+
const first = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
|
|
1821
1992
|
const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
|
|
1822
|
-
throw new MfeflowError('E403', `${
|
|
1993
|
+
throw new MfeflowError('E403', `${shown}: ${first.join('; ')}${more}`);
|
|
1823
1994
|
}
|
|
1824
1995
|
return itemsOf(value);
|
|
1825
1996
|
}
|
|
@@ -1948,7 +2119,7 @@ function isModuleSpecifier(value) {
|
|
|
1948
2119
|
}
|
|
1949
2120
|
const DIST_TAG = /^[a-z][a-z0-9-]*$/;
|
|
1950
2121
|
const TARBALL_NAME = /[.](?:tgz|tar\.gz|tar)$/i;
|
|
1951
|
-
function
|
|
2122
|
+
function policy_isRegistryRange(range) {
|
|
1952
2123
|
if ('' === range || range !== range.trim()) return false;
|
|
1953
2124
|
if (range.includes('/') || range.includes('\\')) return false;
|
|
1954
2125
|
if (range.split(/\s+|\|\|/).some((part)=>TARBALL_NAME.test(part))) return false;
|
|
@@ -1992,7 +2163,7 @@ function assertThirdPartyContent(manifest, label) {
|
|
|
1992
2163
|
}
|
|
1993
2164
|
for (const dep of manifest.dependencies ?? []){
|
|
1994
2165
|
if (dep.name.includes('{{')) throw new MfeflowError('E203', `${label}: ${dep.name} names a package in the project's scope`);
|
|
1995
|
-
if (!
|
|
2166
|
+
if (!policy_isRegistryRange(dep.range)) throw new MfeflowError('E203', `${label}: ${dep.name}@${dep.range} is not a registry version range`);
|
|
1996
2167
|
}
|
|
1997
2168
|
for (const t of manifest.transforms ?? [])assertThirdPartyTransform(t, label);
|
|
1998
2169
|
for (const script of manifest.scripts ?? [])assertPlainScript(script.name, manifest.name, label);
|
|
@@ -2064,14 +2235,28 @@ async function loadLocal(name, options) {
|
|
|
2064
2235
|
};
|
|
2065
2236
|
}
|
|
2066
2237
|
async function loadRemote(name, options) {
|
|
2067
|
-
const
|
|
2238
|
+
const registries = options.registries ?? {};
|
|
2239
|
+
const text = await fetchRemoteText(name, registries, options.fetch, options.version, true === options.trusted);
|
|
2068
2240
|
const manifest = parseFeature(text, 'third-party', name);
|
|
2069
2241
|
if (manifest.name !== name) throw new MfeflowError('E402', `asked for ${name}, got ${manifest.name}`);
|
|
2242
|
+
if (true === options.checkIndex) await assertIndexAgrees(name, manifest.version, registries, options);
|
|
2070
2243
|
return {
|
|
2071
2244
|
manifest,
|
|
2072
|
-
trust: 'third-party'
|
|
2245
|
+
trust: 'third-party',
|
|
2246
|
+
integrity: integrityOf(text)
|
|
2073
2247
|
};
|
|
2074
2248
|
}
|
|
2249
|
+
async function assertIndexAgrees(name, version, registries, options) {
|
|
2250
|
+
const slash = name.indexOf('/');
|
|
2251
|
+
let listed;
|
|
2252
|
+
try {
|
|
2253
|
+
listed = await indexedVersion(name.slice(0, slash), name.slice(slash + 1), registries, options.fetch, true === options.trusted);
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
if (error instanceof MfeflowError && 'MFEFLOW_E401' === error.code) return;
|
|
2256
|
+
throw error;
|
|
2257
|
+
}
|
|
2258
|
+
if (void 0 !== listed && listed !== version) throw new MfeflowError('E402', `the index lists ${name} at ${listed} but its feature.json is ${version}`);
|
|
2259
|
+
}
|
|
2075
2260
|
async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
2076
2261
|
let dirs;
|
|
2077
2262
|
try {
|
|
@@ -2082,6 +2267,104 @@ async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
|
2082
2267
|
const checked = await Promise.all(dirs.map(async (dir)=>await readIfExists(ops, node_path.join(featuresDir, dir, 'feature.json')) === void 0 ? void 0 : dir));
|
|
2083
2268
|
return checked.filter((d)=>void 0 !== d).toSorted((a, b)=>a.localeCompare(b));
|
|
2084
2269
|
}
|
|
2270
|
+
const TRUST_FILE = 'trusted-registries.json';
|
|
2271
|
+
const TRUST_ENV = 'MFEFLOW_TRUST_REGISTRIES';
|
|
2272
|
+
const set = (value)=>void 0 === value || '' === value ? void 0 : value;
|
|
2273
|
+
function configDirOf(env, platform = process.platform, home = node_os.homedir()) {
|
|
2274
|
+
const override = set(env.MFEFLOW_CONFIG_DIR);
|
|
2275
|
+
if (void 0 !== override) return override;
|
|
2276
|
+
const xdg = set(env.XDG_CONFIG_HOME);
|
|
2277
|
+
if (void 0 !== xdg) return node_path.join(xdg, 'mfeflow');
|
|
2278
|
+
const appData = set(env.APPDATA);
|
|
2279
|
+
if ('win32' === platform && void 0 !== appData) return node_path.join(appData, 'mfeflow');
|
|
2280
|
+
return node_path.join(home, '.config', 'mfeflow');
|
|
2281
|
+
}
|
|
2282
|
+
function trustFileIn(configDir) {
|
|
2283
|
+
return node_path.join(configDir, TRUST_FILE);
|
|
2284
|
+
}
|
|
2285
|
+
function isEntry(value) {
|
|
2286
|
+
if ('object' != typeof value || null === value) return false;
|
|
2287
|
+
const { namespace, template } = Object.fromEntries(Object.entries(value));
|
|
2288
|
+
return 'string' == typeof namespace && REGISTRY_NAMESPACE.test(namespace) && 'string' == typeof template && void 0 === registryTemplateProblem(template);
|
|
2289
|
+
}
|
|
2290
|
+
async function readTrusted(configDir, ops = realFs) {
|
|
2291
|
+
let text;
|
|
2292
|
+
try {
|
|
2293
|
+
text = await readIfExists(ops, trustFileIn(configDir));
|
|
2294
|
+
} catch {
|
|
2295
|
+
return [];
|
|
2296
|
+
}
|
|
2297
|
+
if (void 0 === text) return [];
|
|
2298
|
+
try {
|
|
2299
|
+
const value = JSON.parse(text);
|
|
2300
|
+
if ('object' != typeof value || null === value) return [];
|
|
2301
|
+
const { registries } = Object.fromEntries(Object.entries(value));
|
|
2302
|
+
return Array.isArray(registries) ? registries.filter(isEntry) : [];
|
|
2303
|
+
} catch {
|
|
2304
|
+
return [];
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
async function save(configDir, entries, ops) {
|
|
2308
|
+
const sorted = entries.toSorted((a, b)=>a.namespace.localeCompare(b.namespace) || a.template.localeCompare(b.template));
|
|
2309
|
+
const body = `${JSON.stringify({
|
|
2310
|
+
version: 1,
|
|
2311
|
+
registries: sorted
|
|
2312
|
+
}, null, 2)}\n`;
|
|
2313
|
+
try {
|
|
2314
|
+
await writeFileAtomic(ops, trustFileIn(configDir), body);
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
throw new MfeflowError('E106', trustFileIn(configDir), {
|
|
2317
|
+
cause: error
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
async function addTrusted(configDir, entry, ops = realFs) {
|
|
2322
|
+
const current = await readTrusted(configDir, ops);
|
|
2323
|
+
if (current.some((e)=>e.namespace === entry.namespace && e.template === entry.template)) return false;
|
|
2324
|
+
await save(configDir, [
|
|
2325
|
+
...current,
|
|
2326
|
+
entry
|
|
2327
|
+
], ops);
|
|
2328
|
+
return true;
|
|
2329
|
+
}
|
|
2330
|
+
async function removeTrusted(configDir, namespace, ops = realFs) {
|
|
2331
|
+
const current = await readTrusted(configDir, ops);
|
|
2332
|
+
const kept = current.filter((e)=>e.namespace !== namespace);
|
|
2333
|
+
if (kept.length === current.length) return 0;
|
|
2334
|
+
await save(configDir, kept, ops);
|
|
2335
|
+
return current.length - kept.length;
|
|
2336
|
+
}
|
|
2337
|
+
function parseTrustEnv(value) {
|
|
2338
|
+
return (value ?? '').split(',').map((part)=>part.trim()).flatMap((part)=>{
|
|
2339
|
+
const eq = part.indexOf('=');
|
|
2340
|
+
const namespace = -1 === eq ? part : part.slice(0, eq).trim();
|
|
2341
|
+
if (!REGISTRY_NAMESPACE.test(namespace)) return [];
|
|
2342
|
+
const template = -1 === eq ? '' : part.slice(eq + 1).trim();
|
|
2343
|
+
return [
|
|
2344
|
+
'' === template ? {
|
|
2345
|
+
namespace
|
|
2346
|
+
} : {
|
|
2347
|
+
namespace,
|
|
2348
|
+
template
|
|
2349
|
+
}
|
|
2350
|
+
];
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
function trustedNamespaces(registries, recorded, fromEnv = []) {
|
|
2354
|
+
const fetch1 = new Set();
|
|
2355
|
+
const privateOk = new Set();
|
|
2356
|
+
for (const [namespace, template] of Object.entries(registries)){
|
|
2357
|
+
const inFile = recorded.some((e)=>e.namespace === namespace && e.template === template);
|
|
2358
|
+
const items = fromEnv.filter((e)=>e.namespace === namespace);
|
|
2359
|
+
const pinned = items.some((e)=>e.template === template);
|
|
2360
|
+
if (inFile || pinned) privateOk.add(namespace);
|
|
2361
|
+
if (inFile || items.some((e)=>void 0 === e.template || e.template === template)) fetch1.add(namespace);
|
|
2362
|
+
}
|
|
2363
|
+
return {
|
|
2364
|
+
fetch: fetch1,
|
|
2365
|
+
privateOk
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2085
2368
|
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
2086
2369
|
function shortHash(text) {
|
|
2087
2370
|
let hash = 0x811c9dc5;
|
|
@@ -2263,7 +2546,10 @@ async function resolveFeature(input) {
|
|
|
2263
2546
|
version: manifest.version,
|
|
2264
2547
|
...Object.keys(options).length > 0 ? {
|
|
2265
2548
|
options
|
|
2266
|
-
} : {}
|
|
2549
|
+
} : {},
|
|
2550
|
+
...void 0 === feature.integrity ? {} : {
|
|
2551
|
+
integrity: feature.integrity
|
|
2552
|
+
}
|
|
2267
2553
|
});
|
|
2268
2554
|
}
|
|
2269
2555
|
async function editFile(builder, file, edit, op) {
|
|
@@ -2386,4 +2672,4 @@ function packageInfo() {
|
|
|
2386
2672
|
version
|
|
2387
2673
|
};
|
|
2388
2674
|
}
|
|
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 };
|
|
2675
|
+
export { ERROR_CATALOGUE, MfeflowError, PROJECT_FILE, PlanBuilder, RUNTIME_CONFIG_FILE, TRUST_ENV, addTrusted, applyPlan, applyTransform, buildRegistry, checkIntegrity, configDirOf, dotenvKeys, emptyPlan, fetchFeatureIndex, fetchRemoteText, findDiverged, findProjectRoot, getHttps, hasErrno, hashContent, inOrder, indexedVersion, integrityOf, isMfeflowError, isProtectedPath, listLocalFeatures, loadFeature, loadProject, messageOf, packageInfo, parseFeature, parseProject, parseTrustEnv, readCappedText, readTrusted, realFs, recordPlan, registryUrl, removeTrusted, renderDiff, renderTemplate, resolveFeature, resolveOptions, runtimeConfigFiles, saveProject, schemaKeys, serializeProject, substitute, summarize, trustFileIn, trustedNamespaces, writeFileAtomic };
|
package/dist/plan.d.ts
CHANGED
|
@@ -17,12 +17,16 @@ export interface PlanFeature {
|
|
|
17
17
|
name: string;
|
|
18
18
|
version: string;
|
|
19
19
|
options?: Record<string, unknown>;
|
|
20
|
+
/** `sha256-<hex>` of the feature.json a registry served; recorded in mfeflow.json. */
|
|
21
|
+
integrity?: string;
|
|
20
22
|
}
|
|
21
23
|
export interface DependencyAdd {
|
|
22
24
|
file: string;
|
|
23
25
|
name: string;
|
|
24
26
|
range: string;
|
|
25
27
|
dev: boolean;
|
|
28
|
+
/** Added by a third-party feature, so `summarize` lists it in its own block. */
|
|
29
|
+
thirdParty?: boolean;
|
|
26
30
|
}
|
|
27
31
|
/** Added to package.json and shown. The engine never runs it. */
|
|
28
32
|
export interface ScriptAdd {
|
|
@@ -99,8 +103,22 @@ export declare class PlanBuilder {
|
|
|
99
103
|
create(file: string, content: string, track?: boolean): Promise<void>;
|
|
100
104
|
/** Rewrites an existing file. */
|
|
101
105
|
patch(file: string, edit: (source: string) => string): Promise<void>;
|
|
106
|
+
/** Overwrites a file that exists, shown as a modification. First-party only: a third-party feature never replaces content. */
|
|
107
|
+
replace(file: string, content: string): Promise<void>;
|
|
102
108
|
delete(file: string): Promise<void>;
|
|
103
109
|
addDependency(file: string, name: string, range: string, dev?: boolean): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Moves an existing dependency to `range`. A dependency on `catalog:` changes
|
|
112
|
+
* its entry in pnpm-workspace.yaml instead. A `workspace:` link, or any
|
|
113
|
+
* specifier that is not a registry range (`npm:`, `link:`, `file:`, a
|
|
114
|
+
* repository or a URL), is the project's own choice and is left alone with a
|
|
115
|
+
* warning. The new range must be a semver range or a dist-tag (E202). A
|
|
116
|
+
* missing dependency is an E208. A third-party feature can only ask for the
|
|
117
|
+
* range that is already there.
|
|
118
|
+
*/
|
|
119
|
+
setRange(file: string, name: string, range: string): Promise<void>;
|
|
120
|
+
private declaredRange;
|
|
121
|
+
private setCatalogEntry;
|
|
104
122
|
/** Adds a script. An identical one already there is a no-op; a different command under the same name is a conflict. */
|
|
105
123
|
addScript(file: string, name: string, command: string): Promise<void>;
|
|
106
124
|
addEnvKey(file: string, key: string): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mfeflow/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The engine behind the mfeflow CLI: project model, change plans with diffs, atomic apply with rollback, and feature resolution.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"micro-frontend",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@babel/parser": "8.0.10",
|
|
47
47
|
"@babel/types": "8.0.10",
|
|
48
|
-
"@mfeflow/contracts": "0.
|
|
48
|
+
"@mfeflow/contracts": "0.5.0",
|
|
49
49
|
"diff": "9.0.0",
|
|
50
50
|
"eta": "4.6.0",
|
|
51
51
|
"semver": "7.8.5",
|