@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 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 `:`. Lifecycle events, `pnpm:`,
30
- `npm:`, `yarn:` and `bun:` prefixes and every `pre` or `post` name are refused.
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 registry, E5xx upgrade.
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 the same route, or an mfe.json disagrees with mfeflow.json';
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 to "registries" in mfeflow.json, for example { "@someone": "https://example.com/{name}.json" }.';
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 {
@@ -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';
@@ -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, label: string): void;
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'): Promise<FetchResponse>;
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 { validRange } from "semver";
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 the same route, or an mfe.json disagrees with mfeflow.json',
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 to "registries" in mfeflow.json, for example { "@someone": "https://example.com/{name}.json" }.'
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] = f.options ? {
427
+ for (const f of plan.features)features[f.name] = {
363
428
  version: f.version,
364
- options: f.options
365
- } : {
366
- version: f.version
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
- if (plan.dependencies.length > 0) {
627
- lines.push(`Dependencies (${String(plan.dependencies.length)}):`);
628
- for (const d of plan.dependencies)lines.push(` ${d.file} ${d.name}@${d.range}${d.dev ? ' (dev)' : ''}`);
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 ('https:' !== parsed.protocol) throw new MfeflowError('E401', `${url} is not https`);
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}`, void 0 === cause ? {} : {
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.startsWith('https://')) fail(url, `ended up at ${response.url}, which is not https`);
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 ('https:' !== next.protocol) fail(url, `redirected to ${next.href}, which is not https`);
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 response = await getHttps(url, fetchImpl, 'E401');
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', `${url} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
1923
+ throw new MfeflowError('E401', `${shown} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
1753
1924
  }
1754
- return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E402', `${url} is larger than 1 MB`));
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 isRegistryRange(range) {
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 (!isRegistryRange(dep.range)) throw new MfeflowError('E203', `${label}: ${dep.name}@${dep.range} is not a registry version range`);
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 text = await fetchRemoteText(name, options.registries ?? {}, options.fetch, options.version);
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.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.3.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",