@mfeflow/core 0.3.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/README.md +4 -2
- package/dist/errors.d.ts +68 -4
- package/dist/features/index.d.ts +3 -1
- package/dist/features/load.d.ts +6 -0
- package/dist/features/policy.d.ts +1 -1
- package/dist/features/registry-build.d.ts +25 -0
- package/dist/features/remote.d.ts +32 -3
- package/dist/features/trust.d.ts +44 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +483 -31
- package/dist/plan.d.ts +18 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,8 +26,10 @@ install, commit or the next `mfeflow` command.
|
|
|
26
26
|
files and the local `features/` folder are refused. Paths are compared after
|
|
27
27
|
Unicode (NFC) and case folding, and names with trailing dots or spaces, `:`,
|
|
28
28
|
invisible characters or Windows device names are refused for every feature.
|
|
29
|
-
- **Scripts** are lower-case words joined by
|
|
30
|
-
|
|
29
|
+
- **Scripts** are lower-case words joined by `:` and start with the feature's
|
|
30
|
+
own name (`segment:check` for `@acme/segment`), so `build`, `lint:*` and
|
|
31
|
+
other runners never pick them up. Lifecycle events, `pnpm:`, `npm:`, `yarn:`
|
|
32
|
+
and `bun:` prefixes and every `pre` or `post` name are refused.
|
|
31
33
|
- **Dependencies** take a semver range or a dist-tag. `file:`, `link:`, git,
|
|
32
34
|
URLs, `npm:` aliases, `workspace:` and `catalog:` are refused.
|
|
33
35
|
- **Imports** name a package, a relative path or an `@/` alias, never a URL.
|
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';
|
|
@@ -125,7 +125,7 @@ export declare const ERROR_CATALOGUE: {
|
|
|
125
125
|
};
|
|
126
126
|
readonly E210: {
|
|
127
127
|
readonly title: 'Registry namespace is not configured';
|
|
128
|
-
readonly fix: 'Add it
|
|
128
|
+
readonly fix: 'Add it with `mfeflow registry add @someone https://example.com/{name}.json` (or under "registries" in mfeflow.json).';
|
|
129
129
|
};
|
|
130
130
|
readonly E211: {
|
|
131
131
|
readonly title: 'Files changed since the plan was made';
|
|
@@ -179,6 +179,70 @@ export declare const ERROR_CATALOGUE: {
|
|
|
179
179
|
readonly title: 'The registry returned something that is not a feature';
|
|
180
180
|
readonly fix: 'Ask the registry owner to check the entry. It must be a feature.json document.';
|
|
181
181
|
};
|
|
182
|
+
readonly E403: {
|
|
183
|
+
readonly title: "The registry's index.json is not valid";
|
|
184
|
+
readonly fix: 'Ask the registry owner to rebuild it with `mfeflow registry build`. It lists items with a name, version and description.';
|
|
185
|
+
};
|
|
186
|
+
readonly E404: {
|
|
187
|
+
readonly title: 'A feature in the registry folder cannot be published';
|
|
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
|
+
};
|
|
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
|
+
};
|
|
182
246
|
};
|
|
183
247
|
export type ErrorCode = keyof typeof ERROR_CATALOGUE;
|
|
184
248
|
export interface MfeflowErrorOptions {
|
package/dist/features/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { listLocalFeatures, loadFeature, parseFeature, type LoadedFeature, type LoadOptions, } from './load.js';
|
|
2
|
-
export { 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';
|
|
7
|
+
export { buildRegistry, type RegistryBuild, type RegistryFile } from './registry-build.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
|
|
@@ -16,7 +16,7 @@ export declare function isRegistryRange(range: string): boolean;
|
|
|
16
16
|
* lifecycle events, `pnpm:`/`npm:`/`yarn:`/`bun:` prefixes and every `pre` or
|
|
17
17
|
* `post` name, since what it wraps may be added to package.json later.
|
|
18
18
|
*/
|
|
19
|
-
export declare function assertPlainScript(name: string,
|
|
19
|
+
export declare function assertPlainScript(name: string, feature: string, label?: string): void;
|
|
20
20
|
/** Keys check: runs on the raw JSON, before the schema, so "hooks" is named rather than ignored. */
|
|
21
21
|
export declare function assertAllowedKeys(value: Record<string, unknown>, label: string): void;
|
|
22
22
|
/** Unknown op names are their own error, so authors learn what the built-ins are. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FeatureRegistryIndex } from '@mfeflow/contracts';
|
|
2
|
+
import { type FsOps } from '../fs.js';
|
|
3
|
+
export interface RegistryFile {
|
|
4
|
+
/** Relative to the output folder, with forward slashes. */
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RegistryBuild {
|
|
9
|
+
/** The namespace every feature in the folder uses, like `@test`. */
|
|
10
|
+
namespace: string;
|
|
11
|
+
index: FeatureRegistryIndex;
|
|
12
|
+
features: {
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
files: string[];
|
|
16
|
+
}[];
|
|
17
|
+
/** `index.json` and, per feature, `<name>.json`, `<name>/<version>.json` and `<name>/latest.json`. */
|
|
18
|
+
files: RegistryFile[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Checks every `<dir>/<folder>/feature.json` the way `mfeflow add @namespace/name` will when a user
|
|
22
|
+
* installs it, and returns the files to host: one registry folder serves any static host. Every
|
|
23
|
+
* problem is collected, so an author fixes them in one pass. Writes nothing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildRegistry(dir: string, ops?: FsOps): Promise<RegistryBuild>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FeatureRegistryItem } from '@mfeflow/contracts';
|
|
1
2
|
/** The part of `Response` the loader uses, so tests can pass a plain object. */
|
|
2
3
|
export interface FetchResponse {
|
|
3
4
|
ok: boolean;
|
|
@@ -14,6 +15,8 @@ export type FetchLike = (url: string, init?: {
|
|
|
14
15
|
signal?: AbortSignal;
|
|
15
16
|
redirect?: 'follow' | 'manual';
|
|
16
17
|
}) => Promise<FetchResponse>;
|
|
18
|
+
/** Largest feature.json or index.json a registry may serve; `registry build` refuses to write more. */
|
|
19
|
+
export declare const MAX_BYTES = 1000000;
|
|
17
20
|
/**
|
|
18
21
|
* The URL a namespaced feature lives at. `{name}` is the part after the slash and
|
|
19
22
|
* `{version}` the version asked for (`latest` when none), both URL-encoded.
|
|
@@ -21,14 +24,40 @@ export type FetchLike = (url: string, init?: {
|
|
|
21
24
|
export declare function registryUrl(name: string, registries: Record<string, string>, version?: string): string;
|
|
22
25
|
/**
|
|
23
26
|
* GETs `url`, following redirects by hand so one that leaves https is refused before it is
|
|
24
|
-
* requested. `code` is the error every failure here throws.
|
|
27
|
+
* requested. `code` is the error every failure here throws. With `loopback`, a URL that starts
|
|
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. 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.
|
|
25
33
|
*/
|
|
26
|
-
export declare function getHttps(url: string, fetchImpl: FetchLike, code: 'E109' | 'E401'
|
|
34
|
+
export declare function getHttps(url: string, fetchImpl: FetchLike, code: 'E109' | 'E401', options?: {
|
|
35
|
+
loopback?: boolean;
|
|
36
|
+
blockPrivate?: boolean;
|
|
37
|
+
}): Promise<FetchResponse>;
|
|
27
38
|
/** Downloads the feature.json text for `@ns/name`. */
|
|
28
|
-
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;
|
|
29
47
|
/**
|
|
30
48
|
* The body of `response` as text, refusing more than `maxBytes`: from the declared
|
|
31
49
|
* length first, then on the bytes actually streamed, so an oversized answer is
|
|
32
50
|
* cancelled instead of downloaded. `tooBig` builds the error to throw.
|
|
33
51
|
*/
|
|
34
52
|
export declare function readCappedText(response: FetchResponse, maxBytes: number, tooBig: () => Error): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* The items `<namespace>`'s `index.json` lists. The index sits in the folder the registry's URL
|
|
55
|
+
* template starts in (see `registryIndexUrl`). Fails with E401 when it cannot be fetched and E403
|
|
56
|
+
* when it is not an index, so a caller can show one registry's problem and carry on with the rest.
|
|
57
|
+
*/
|
|
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
|
@@ -6,11 +6,11 @@ export declare function packageInfo(): {
|
|
|
6
6
|
export { ERROR_CATALOGUE, hasErrno, isMfeflowError, messageOf, MfeflowError, type ErrorCode, type MfeflowErrorOptions, } from './errors.js';
|
|
7
7
|
export { hashContent } from './hash.js';
|
|
8
8
|
export { inOrder } from './sequence.js';
|
|
9
|
-
export { realFs, type FsOps } from './fs.js';
|
|
9
|
+
export { realFs, writeFileAtomic, type FsOps } from './fs.js';
|
|
10
10
|
export { findDiverged, findProjectRoot, loadProject, parseProject, PROJECT_FILE, recordPlan, saveProject, serializeProject, type DivergedFile, type MfeflowProject, } from './project.js';
|
|
11
11
|
export { emptyPlan, PlanBuilder, renderDiff, summarize, type ChangeAction, type DependencyAdd, type EnvKeyAdd, type FileChange, type Plan, type PlanBuilderOptions, type PlanFeature, type ReadFile, type ScriptAdd, } from './plan.js';
|
|
12
12
|
export { applyPlan, type ApplyOptions, type ApplyResult, type InstallContext, type InstallStep, } from './apply.js';
|
|
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 { fetchRemoteText, isProtectedPath, listLocalFeatures, loadFeature, parseFeature, getHttps, readCappedText, registryUrl, resolveFeature, resolveOptions, type FetchLike, type FetchResponse, type LoadedFeature, type LoadOptions, 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, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isMfeflowProject, isSafeRelativePath, validateFeatureManifest, 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',
|
|
@@ -132,7 +133,7 @@ const ERROR_CATALOGUE = {
|
|
|
132
133
|
},
|
|
133
134
|
E210: {
|
|
134
135
|
title: 'Registry namespace is not configured',
|
|
135
|
-
fix: 'Add it
|
|
136
|
+
fix: 'Add it with `mfeflow registry add @someone https://example.com/{name}.json` (or under "registries" in mfeflow.json).'
|
|
136
137
|
},
|
|
137
138
|
E211: {
|
|
138
139
|
title: 'Files changed since the plan was made',
|
|
@@ -185,6 +186,70 @@ const ERROR_CATALOGUE = {
|
|
|
185
186
|
E402: {
|
|
186
187
|
title: 'The registry returned something that is not a feature',
|
|
187
188
|
fix: 'Ask the registry owner to check the entry. It must be a feature.json document.'
|
|
189
|
+
},
|
|
190
|
+
E403: {
|
|
191
|
+
title: "The registry's index.json is not valid",
|
|
192
|
+
fix: "Ask the registry owner to rebuild it with `mfeflow registry build`. It lists items with a name, version and description."
|
|
193
|
+
},
|
|
194
|
+
E404: {
|
|
195
|
+
title: 'A feature in the registry folder cannot be published',
|
|
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.'
|
|
188
253
|
}
|
|
189
254
|
};
|
|
190
255
|
class MfeflowError extends Error {
|
|
@@ -359,11 +424,14 @@ function recordPlan(project, plan) {
|
|
|
359
424
|
const features = {
|
|
360
425
|
...project.features
|
|
361
426
|
};
|
|
362
|
-
for (const f of plan.features)features[f.name] =
|
|
427
|
+
for (const f of plan.features)features[f.name] = {
|
|
363
428
|
version: f.version,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
429
|
+
...f.options ? {
|
|
430
|
+
options: f.options
|
|
431
|
+
} : {},
|
|
432
|
+
...void 0 === f.integrity ? {} : {
|
|
433
|
+
integrity: f.integrity
|
|
434
|
+
}
|
|
367
435
|
};
|
|
368
436
|
const next = {
|
|
369
437
|
...project,
|
|
@@ -423,6 +491,11 @@ function emptyPlan() {
|
|
|
423
491
|
};
|
|
424
492
|
}
|
|
425
493
|
const WORKSPACE_FILE = 'pnpm-workspace.yaml';
|
|
494
|
+
const DEPENDENCY_SECTIONS = [
|
|
495
|
+
'dependencies',
|
|
496
|
+
'devDependencies',
|
|
497
|
+
'optionalDependencies'
|
|
498
|
+
];
|
|
426
499
|
class PlanBuilder {
|
|
427
500
|
read;
|
|
428
501
|
options;
|
|
@@ -491,6 +564,13 @@ class PlanBuilder {
|
|
|
491
564
|
this.touch(entry);
|
|
492
565
|
entry.current = edit(entry.current);
|
|
493
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
|
+
}
|
|
494
574
|
async delete(file) {
|
|
495
575
|
const entry = await this.open(file);
|
|
496
576
|
this.touch(entry);
|
|
@@ -519,9 +599,69 @@ class PlanBuilder {
|
|
|
519
599
|
file,
|
|
520
600
|
name,
|
|
521
601
|
range: wanted,
|
|
522
|
-
dev
|
|
602
|
+
dev,
|
|
603
|
+
...'third-party' === this.trust ? {
|
|
604
|
+
thirdParty: true
|
|
605
|
+
} : {}
|
|
523
606
|
});
|
|
524
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
|
+
}
|
|
525
665
|
async addScript(file, name, command) {
|
|
526
666
|
let added = true;
|
|
527
667
|
await this.patch(file, (source)=>editJsonObject(file, source, (pkg)=>{
|
|
@@ -592,6 +732,10 @@ class PlanBuilder {
|
|
|
592
732
|
};
|
|
593
733
|
}
|
|
594
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
|
+
}
|
|
595
739
|
function isPinnedElsewhere(range) {
|
|
596
740
|
return range.startsWith('catalog:') || range.startsWith('workspace:');
|
|
597
741
|
}
|
|
@@ -617,16 +761,20 @@ const VERB = {
|
|
|
617
761
|
patch: 'patch ',
|
|
618
762
|
delete: 'delete'
|
|
619
763
|
};
|
|
764
|
+
const dependencyLine = (d)=>` ${d.file} ${d.name}@${d.range}${d.dev ? ' (dev)' : ''}`;
|
|
620
765
|
function summarize(plan) {
|
|
621
766
|
const count = (action)=>plan.changes.filter((c)=>c.action === action).length;
|
|
622
767
|
const lines = [
|
|
623
768
|
`${String(count('create'))} to create, ${String(count('patch'))} to patch, ${String(count('delete'))} to delete`
|
|
624
769
|
];
|
|
625
770
|
for (const c of plan.changes)lines.push(` ${VERB[c.action]} ${c.path}`);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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));
|
|
629
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.', '');
|
|
630
778
|
if (plan.scripts.length > 0) {
|
|
631
779
|
lines.push(`Scripts added to package.json, not run (${String(plan.scripts.length)}):`);
|
|
632
780
|
for (const s of plan.scripts)lines.push(` ${s.file} ${s.name}: ${s.command}`);
|
|
@@ -1704,19 +1852,36 @@ function registryUrl(name, registries, version) {
|
|
|
1704
1852
|
try {
|
|
1705
1853
|
parsed = new URL(url);
|
|
1706
1854
|
} catch (error) {
|
|
1707
|
-
throw new MfeflowError('E401', `${namespace} has an invalid URL (${url})`, {
|
|
1855
|
+
throw new MfeflowError('E401', `${namespace} has an invalid URL (${redactUrl(url)})`, {
|
|
1708
1856
|
cause: error
|
|
1709
1857
|
});
|
|
1710
1858
|
}
|
|
1711
|
-
if (
|
|
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)`);
|
|
1712
1860
|
return parsed.href;
|
|
1713
1861
|
}
|
|
1714
|
-
async function getHttps(url, fetchImpl, code) {
|
|
1862
|
+
async function getHttps(url, fetchImpl, code, options = {}) {
|
|
1715
1863
|
const fail = (at, why, cause)=>{
|
|
1716
|
-
throw new MfeflowError(code, `${at}: ${why}
|
|
1864
|
+
throw new MfeflowError(code, redactUrlsIn(`${at}: ${why}`), void 0 === cause ? {} : {
|
|
1717
1865
|
cause
|
|
1718
1866
|
});
|
|
1719
1867
|
};
|
|
1868
|
+
const startsPrivate = isPrivateHost(new URL(url).hostname);
|
|
1869
|
+
const refusePrivate = (target)=>{
|
|
1870
|
+
if (void 0 === options.blockPrivate || !isPrivateHost(target.hostname)) return;
|
|
1871
|
+
if (!options.blockPrivate && startsPrivate) return;
|
|
1872
|
+
const why = options.blockPrivate ? 'those are only requested from a trusted registry' : 'a public registry may not send you to one';
|
|
1873
|
+
throw new MfeflowError('E405', `${redactUrl(target.href)} is a loopback or private address; ${why}`);
|
|
1874
|
+
};
|
|
1875
|
+
refusePrivate(new URL(url));
|
|
1876
|
+
const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
|
|
1877
|
+
const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
|
|
1878
|
+
const addressOk = (address)=>{
|
|
1879
|
+
try {
|
|
1880
|
+
return permitted(new URL(address));
|
|
1881
|
+
} catch {
|
|
1882
|
+
return false;
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1720
1885
|
let current = url;
|
|
1721
1886
|
for(let hop = 0; hop <= MAX_REDIRECTS; hop += 1){
|
|
1722
1887
|
let response;
|
|
@@ -1728,7 +1893,8 @@ async function getHttps(url, fetchImpl, code) {
|
|
|
1728
1893
|
} catch (error) {
|
|
1729
1894
|
return fail(current, messageOf(error), error);
|
|
1730
1895
|
}
|
|
1731
|
-
if (void 0 !== response.url && '' !== response.url && !response.url
|
|
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));
|
|
1732
1898
|
if (response.status < 300 || response.status >= 400) return response;
|
|
1733
1899
|
const location = response.headers?.get('location');
|
|
1734
1900
|
await response.body?.cancel().catch(()=>void 0);
|
|
@@ -1739,19 +1905,32 @@ async function getHttps(url, fetchImpl, code) {
|
|
|
1739
1905
|
} catch (error) {
|
|
1740
1906
|
return fail(current, 'redirected to a bad address', error);
|
|
1741
1907
|
}
|
|
1742
|
-
if (
|
|
1908
|
+
if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
|
|
1909
|
+
refusePrivate(next);
|
|
1743
1910
|
current = next.href;
|
|
1744
1911
|
}
|
|
1745
1912
|
return fail(url, 'redirected too many times');
|
|
1746
1913
|
}
|
|
1747
|
-
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version) {
|
|
1914
|
+
async function fetchRemoteText(name, registries, fetchImpl = realFetch, version, trusted = false) {
|
|
1748
1915
|
const url = registryUrl(name, registries, version);
|
|
1749
|
-
const
|
|
1916
|
+
const shown = redactUrl(url);
|
|
1917
|
+
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1918
|
+
loopback: true,
|
|
1919
|
+
blockPrivate: !trusted
|
|
1920
|
+
});
|
|
1750
1921
|
if (!response.ok) {
|
|
1751
1922
|
const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
|
|
1752
|
-
throw new MfeflowError('E401', `${
|
|
1923
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
|
|
1753
1924
|
}
|
|
1754
|
-
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')}`;
|
|
1755
1934
|
}
|
|
1756
1935
|
async function readCappedText(response, maxBytes, tooBig) {
|
|
1757
1936
|
const declared = Number(response.headers?.get('content-length') ?? 0 / 0);
|
|
@@ -1773,6 +1952,55 @@ async function readCappedText(response, maxBytes, tooBig) {
|
|
|
1773
1952
|
}
|
|
1774
1953
|
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
1775
1954
|
}
|
|
1955
|
+
async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch, trusted = false) {
|
|
1956
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, false);
|
|
1957
|
+
return items ?? [];
|
|
1958
|
+
}
|
|
1959
|
+
async function indexedVersion(namespace, name, registries, fetchImpl = realFetch, trusted = false) {
|
|
1960
|
+
const items = await readIndex(namespace, registries, fetchImpl, trusted, true);
|
|
1961
|
+
return items?.find((item)=>item.name === name)?.version;
|
|
1962
|
+
}
|
|
1963
|
+
async function readIndex(namespace, registries, fetchImpl, trusted, missingOk) {
|
|
1964
|
+
const template = registries[namespace];
|
|
1965
|
+
if (void 0 === template) throw new MfeflowError('E210', namespace);
|
|
1966
|
+
const problem = registryTemplateProblem(template);
|
|
1967
|
+
if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
|
|
1968
|
+
const url = registryIndexUrl(template);
|
|
1969
|
+
const shown = redactUrl(url);
|
|
1970
|
+
const response = await getHttps(url, fetchImpl, 'E401', {
|
|
1971
|
+
loopback: true,
|
|
1972
|
+
blockPrivate: !trusted
|
|
1973
|
+
});
|
|
1974
|
+
if (!response.ok) {
|
|
1975
|
+
if (missingOk && 404 === response.status) return;
|
|
1976
|
+
const hint = 404 === response.status ? ': this registry has no index.json' : '';
|
|
1977
|
+
throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint}`);
|
|
1978
|
+
}
|
|
1979
|
+
const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${shown} is larger than 1 MB`));
|
|
1980
|
+
let value;
|
|
1981
|
+
try {
|
|
1982
|
+
value = JSON.parse(text);
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
|
|
1985
|
+
throw new MfeflowError('E403', `${shown} is not JSON (${reason})`, {
|
|
1986
|
+
cause: error
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
const issues = validateFeatureRegistryIndex(value);
|
|
1990
|
+
if (issues.length > 0) {
|
|
1991
|
+
const first = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
|
|
1992
|
+
const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
|
|
1993
|
+
throw new MfeflowError('E403', `${shown}: ${first.join('; ')}${more}`);
|
|
1994
|
+
}
|
|
1995
|
+
return itemsOf(value);
|
|
1996
|
+
}
|
|
1997
|
+
function itemsOf(value) {
|
|
1998
|
+
const holder = {
|
|
1999
|
+
items: []
|
|
2000
|
+
};
|
|
2001
|
+
Object.assign(holder, value);
|
|
2002
|
+
return holder.items;
|
|
2003
|
+
}
|
|
1776
2004
|
const ROOTS = new Set([
|
|
1777
2005
|
'z',
|
|
1778
2006
|
'v',
|
|
@@ -1852,6 +2080,7 @@ const THIRD_PARTY_KEYS = new Set([
|
|
|
1852
2080
|
'name',
|
|
1853
2081
|
'version',
|
|
1854
2082
|
"description",
|
|
2083
|
+
'keywords',
|
|
1855
2084
|
'prompts',
|
|
1856
2085
|
'requires',
|
|
1857
2086
|
'conflicts',
|
|
@@ -1890,13 +2119,13 @@ function isModuleSpecifier(value) {
|
|
|
1890
2119
|
}
|
|
1891
2120
|
const DIST_TAG = /^[a-z][a-z0-9-]*$/;
|
|
1892
2121
|
const TARBALL_NAME = /[.](?:tgz|tar\.gz|tar)$/i;
|
|
1893
|
-
function
|
|
2122
|
+
function policy_isRegistryRange(range) {
|
|
1894
2123
|
if ('' === range || range !== range.trim()) return false;
|
|
1895
2124
|
if (range.includes('/') || range.includes('\\')) return false;
|
|
1896
2125
|
if (range.split(/\s+|\|\|/).some((part)=>TARBALL_NAME.test(part))) return false;
|
|
1897
2126
|
return DIST_TAG.test(range) || null !== validRange(range);
|
|
1898
2127
|
}
|
|
1899
|
-
function assertPlainScript(name, label) {
|
|
2128
|
+
function assertPlainScript(name, feature, label = feature) {
|
|
1900
2129
|
const reject = (why)=>{
|
|
1901
2130
|
throw new MfeflowError('E203', `${label}: script "${name}" ${why}`);
|
|
1902
2131
|
};
|
|
@@ -1905,6 +2134,8 @@ function assertPlainScript(name, label) {
|
|
|
1905
2134
|
if (MANAGER_PREFIXES.has(prefix)) reject('uses a package manager hook prefix');
|
|
1906
2135
|
if (LIFECYCLE_SCRIPTS.has(name)) reject('would run on its own (npm lifecycle)');
|
|
1907
2136
|
if (/^(pre|post)/.test(name)) reject("starts with pre or post, which wraps another script; namespace it, e.g. feature:name");
|
|
2137
|
+
const own = feature.slice(feature.indexOf('/') + 1);
|
|
2138
|
+
if (prefix !== own) reject(`must start with the feature's own name, like ${own}:check, so no other runner picks it up`);
|
|
1908
2139
|
}
|
|
1909
2140
|
function assertAllowedKeys(value, label) {
|
|
1910
2141
|
for (const key of Object.keys(value))if (!THIRD_PARTY_KEYS.has(key)) throw new MfeflowError('E203', `${label}: "${key}" is not allowed in a third-party feature`);
|
|
@@ -1932,10 +2163,10 @@ function assertThirdPartyContent(manifest, label) {
|
|
|
1932
2163
|
}
|
|
1933
2164
|
for (const dep of manifest.dependencies ?? []){
|
|
1934
2165
|
if (dep.name.includes('{{')) throw new MfeflowError('E203', `${label}: ${dep.name} names a package in the project's scope`);
|
|
1935
|
-
if (!
|
|
2166
|
+
if (!policy_isRegistryRange(dep.range)) throw new MfeflowError('E203', `${label}: ${dep.name}@${dep.range} is not a registry version range`);
|
|
1936
2167
|
}
|
|
1937
2168
|
for (const t of manifest.transforms ?? [])assertThirdPartyTransform(t, label);
|
|
1938
|
-
for (const script of manifest.scripts ?? [])assertPlainScript(script.name, label);
|
|
2169
|
+
for (const script of manifest.scripts ?? [])assertPlainScript(script.name, manifest.name, label);
|
|
1939
2170
|
}
|
|
1940
2171
|
function assertThirdPartyTransform(t, label) {
|
|
1941
2172
|
const deny = (why)=>{
|
|
@@ -2004,14 +2235,28 @@ async function loadLocal(name, options) {
|
|
|
2004
2235
|
};
|
|
2005
2236
|
}
|
|
2006
2237
|
async function loadRemote(name, options) {
|
|
2007
|
-
const
|
|
2238
|
+
const registries = options.registries ?? {};
|
|
2239
|
+
const text = await fetchRemoteText(name, registries, options.fetch, options.version, true === options.trusted);
|
|
2008
2240
|
const manifest = parseFeature(text, 'third-party', name);
|
|
2009
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);
|
|
2010
2243
|
return {
|
|
2011
2244
|
manifest,
|
|
2012
|
-
trust: 'third-party'
|
|
2245
|
+
trust: 'third-party',
|
|
2246
|
+
integrity: integrityOf(text)
|
|
2013
2247
|
};
|
|
2014
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
|
+
}
|
|
2015
2260
|
async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
2016
2261
|
let dirs;
|
|
2017
2262
|
try {
|
|
@@ -2022,6 +2267,104 @@ async function listLocalFeatures(featuresDir, ops = realFs) {
|
|
|
2022
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));
|
|
2023
2268
|
return checked.filter((d)=>void 0 !== d).toSorted((a, b)=>a.localeCompare(b));
|
|
2024
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
|
+
}
|
|
2025
2368
|
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
|
2026
2369
|
function shortHash(text) {
|
|
2027
2370
|
let hash = 0x811c9dc5;
|
|
@@ -2203,7 +2546,10 @@ async function resolveFeature(input) {
|
|
|
2203
2546
|
version: manifest.version,
|
|
2204
2547
|
...Object.keys(options).length > 0 ? {
|
|
2205
2548
|
options
|
|
2206
|
-
} : {}
|
|
2549
|
+
} : {},
|
|
2550
|
+
...void 0 === feature.integrity ? {} : {
|
|
2551
|
+
integrity: feature.integrity
|
|
2552
|
+
}
|
|
2207
2553
|
});
|
|
2208
2554
|
}
|
|
2209
2555
|
async function editFile(builder, file, edit, op) {
|
|
@@ -2213,6 +2559,112 @@ async function editFile(builder, file, edit, op) {
|
|
|
2213
2559
|
if (isConfig && !await builder.exists(file)) return void await builder.create(file, edit('{}\n'));
|
|
2214
2560
|
await builder.patch(file, edit);
|
|
2215
2561
|
}
|
|
2562
|
+
const registry_build_text = (value)=>`${JSON.stringify(value, null, 2)}\n`;
|
|
2563
|
+
async function buildRegistry(dir, ops = realFs) {
|
|
2564
|
+
let folders;
|
|
2565
|
+
try {
|
|
2566
|
+
folders = (await ops.listDirs(dir)).toSorted((a, b)=>a.localeCompare(b));
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
throw new MfeflowError('E404', `${dir} is not a folder (${messageOf(error)})`, {
|
|
2569
|
+
cause: error
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
const problems = [];
|
|
2573
|
+
const found = [];
|
|
2574
|
+
let looked = 0;
|
|
2575
|
+
for (const folder of folders){
|
|
2576
|
+
const file = node_path.join(dir, folder, 'feature.json');
|
|
2577
|
+
const source = await readIfExists(ops, file);
|
|
2578
|
+
if (void 0 === source) continue;
|
|
2579
|
+
looked += 1;
|
|
2580
|
+
const label = `${folder}/feature.json`;
|
|
2581
|
+
try {
|
|
2582
|
+
const manifest = parseFeature(source, 'third-party', label);
|
|
2583
|
+
const slash = manifest.name.indexOf('/');
|
|
2584
|
+
const bare = manifest.name.slice(slash + 1);
|
|
2585
|
+
const own = (problem)=>problems.push(`${label}: ${problem}`);
|
|
2586
|
+
const namespace = manifest.name.slice(0, slash);
|
|
2587
|
+
if (!REGISTRY_NAMESPACE.test(namespace)) own(`namespace "${namespace}" must start with a letter or digit, like @acme`);
|
|
2588
|
+
if ('index' === bare) own('"index" is taken by the registry index; pick another name');
|
|
2589
|
+
if (valid(manifest.version) !== manifest.version) own(`version "${manifest.version}" must be a semver version like 1.0.0`);
|
|
2590
|
+
if ((manifest.description ?? '') === '') own("description is required; `mfeflow features` shows it next to the name");
|
|
2591
|
+
found.push({
|
|
2592
|
+
folder,
|
|
2593
|
+
namespace,
|
|
2594
|
+
bare,
|
|
2595
|
+
version: manifest.version,
|
|
2596
|
+
description: manifest.description ?? '',
|
|
2597
|
+
keywords: manifest.keywords,
|
|
2598
|
+
json: registry_build_text(manifest)
|
|
2599
|
+
});
|
|
2600
|
+
} catch (error) {
|
|
2601
|
+
if (!isMfeflowError(error)) throw error;
|
|
2602
|
+
problems.push(`${label}: ${error.message.replace(`${label}: `, '')}`);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
if (0 === looked) throw new MfeflowError('E404', `no <folder>/feature.json found in ${dir}`, {
|
|
2606
|
+
fix: 'Put each feature in its own folder next to the others: my-registry/hello/feature.json.'
|
|
2607
|
+
});
|
|
2608
|
+
const namespaces = new Set(found.map((f)=>f.namespace));
|
|
2609
|
+
if (namespaces.size > 1) problems.push(`one registry serves one namespace, but this folder has ${[
|
|
2610
|
+
...namespaces
|
|
2611
|
+
].toSorted((a, b)=>a.localeCompare(b)).join(', ')}`);
|
|
2612
|
+
const names = new Map();
|
|
2613
|
+
for (const f of found){
|
|
2614
|
+
const earlier = names.get(f.bare);
|
|
2615
|
+
if (void 0 !== earlier) problems.push(`${f.folder}/feature.json: ${f.bare} is already defined by ${earlier}`);
|
|
2616
|
+
names.set(f.bare, `${f.folder}/feature.json`);
|
|
2617
|
+
}
|
|
2618
|
+
const files = [];
|
|
2619
|
+
const features = [];
|
|
2620
|
+
const items = [];
|
|
2621
|
+
for (const f of found.toSorted((a, b)=>a.bare.localeCompare(b.bare))){
|
|
2622
|
+
const paths = [
|
|
2623
|
+
`${f.bare}.json`,
|
|
2624
|
+
`${f.bare}/${f.version}.json`,
|
|
2625
|
+
`${f.bare}/latest.json`
|
|
2626
|
+
];
|
|
2627
|
+
if (Buffer.byteLength(f.json, 'utf8') > MAX_BYTES) problems.push(`${f.folder}/feature.json: is larger than 1 MB, which users' installs refuse`);
|
|
2628
|
+
for (const p of paths)files.push({
|
|
2629
|
+
path: p,
|
|
2630
|
+
content: f.json
|
|
2631
|
+
});
|
|
2632
|
+
features.push({
|
|
2633
|
+
name: `${f.namespace}/${f.bare}`,
|
|
2634
|
+
version: f.version,
|
|
2635
|
+
files: paths
|
|
2636
|
+
});
|
|
2637
|
+
items.push({
|
|
2638
|
+
name: f.bare,
|
|
2639
|
+
version: f.version,
|
|
2640
|
+
description: f.description,
|
|
2641
|
+
...void 0 === f.keywords || 0 === f.keywords.length ? {} : {
|
|
2642
|
+
keywords: f.keywords
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
const index = {
|
|
2647
|
+
$schema: SCHEMA_URLS.featureRegistryIndex,
|
|
2648
|
+
items
|
|
2649
|
+
};
|
|
2650
|
+
for (const issue of validateFeatureRegistryIndex(index))problems.push(`index.json: ${issue.path} ${issue.message}`);
|
|
2651
|
+
if (problems.length > 0) {
|
|
2652
|
+
const list = problems.map((p)=>`\n - ${p}`).join('');
|
|
2653
|
+
throw new MfeflowError('E404', `${String(problems.length)} problem(s)${list}`);
|
|
2654
|
+
}
|
|
2655
|
+
files.push({
|
|
2656
|
+
path: 'index.json',
|
|
2657
|
+
content: registry_build_text(index)
|
|
2658
|
+
});
|
|
2659
|
+
return {
|
|
2660
|
+
namespace: [
|
|
2661
|
+
...namespaces
|
|
2662
|
+
][0] ?? '',
|
|
2663
|
+
index,
|
|
2664
|
+
features,
|
|
2665
|
+
files
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2216
2668
|
function packageInfo() {
|
|
2217
2669
|
const { name, version } = readPackageJson();
|
|
2218
2670
|
return {
|
|
@@ -2220,4 +2672,4 @@ function packageInfo() {
|
|
|
2220
2672
|
version
|
|
2221
2673
|
};
|
|
2222
2674
|
}
|
|
2223
|
-
export { ERROR_CATALOGUE, MfeflowError, PROJECT_FILE, PlanBuilder, RUNTIME_CONFIG_FILE, applyPlan, applyTransform, dotenvKeys, emptyPlan, 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 };
|
|
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",
|