@mfeflow/core 0.4.0 → 0.5.0

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