@mfeflow/core 0.3.0 → 0.4.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
@@ -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,14 @@ 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
+ };
182
190
  };
183
191
  export type ErrorCode = keyof typeof ERROR_CATALOGUE;
184
192
  export interface MfeflowErrorOptions {
@@ -1,5 +1,6 @@
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, readCappedText, registryUrl, type FetchLike, type FetchResponse, } from './remote.js';
3
3
  export { resolveFeature, resolveOptions, type ResolveInput } from './resolve.js';
4
4
  export { type Trust } from './policy.js';
5
5
  export { isProtectedPath } from './write-policy.js';
6
+ export { buildRegistry, type RegistryBuild, type RegistryFile } from './registry-build.js';
@@ -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,9 +24,13 @@ 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.
25
30
  */
26
- export declare function getHttps(url: string, fetchImpl: FetchLike, code: 'E109' | 'E401'): Promise<FetchResponse>;
31
+ export declare function getHttps(url: string, fetchImpl: FetchLike, code: 'E109' | 'E401', options?: {
32
+ loopback?: boolean;
33
+ }): Promise<FetchResponse>;
27
34
  /** Downloads the feature.json text for `@ns/name`. */
28
35
  export declare function fetchRemoteText(name: string, registries: Record<string, string>, fetchImpl?: FetchLike, version?: string): Promise<string>;
29
36
  /**
@@ -32,3 +39,9 @@ export declare function fetchRemoteText(name: string, registries: Record<string,
32
39
  * cancelled instead of downloaded. `tooBig` builds the error to throw.
33
40
  */
34
41
  export declare function readCappedText(response: FetchResponse, maxBytes: number, tooBig: () => Error): Promise<string>;
42
+ /**
43
+ * The items `<namespace>`'s `index.json` lists. The index sits in the folder the registry's URL
44
+ * template starts in (see `registryIndexUrl`). Fails with E401 when it cannot be fetched and E403
45
+ * when it is not an index, so a caller can show one registry's problem and carry on with the rest.
46
+ */
47
+ export declare function fetchFeatureIndex(namespace: string, registries: Record<string, string>, fetchImpl?: FetchLike): Promise<FeatureRegistryItem[]>;
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, 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';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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 { FEATURE_CONDITION, FEATURE_NAME, REGISTRY_NAMESPACE, SCHEMA_URLS, TRANSFORM_OPS, errorDocsUrl, isLoopbackHttp, isMfeflowProject, isRegistryProtocol, isSafeRelativePath, 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";
@@ -8,7 +8,7 @@ import { isMap, isSeq, parseDocument, stringify } from "yaml";
8
8
  import { Eta } from "eta";
9
9
  import { isArrayExpression, isArrowFunctionExpression, isBooleanLiteral, isCallExpression, isFunctionDeclaration, isFunctionExpression, isIdentifier, isImportDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, isJSXElement, isJSXFragment, isJSXIdentifier, isMemberExpression, isNode, isNullLiteral, isNumericLiteral, isObjectExpression, isObjectProperty, isParenthesizedExpression, isReturnStatement, isStringLiteral, isTSAsExpression, isTSNonNullExpression, isTSSatisfiesExpression, isUnaryExpression, isValidIdentifier, isVariableDeclaration } from "@babel/types";
10
10
  import { parse, parseExpression } from "@babel/parser";
11
- import { validRange } from "semver";
11
+ import { valid, validRange } from "semver";
12
12
  const ERROR_CATALOGUE = {
13
13
  E101: {
14
14
  title: 'A shared singleton is missing from the shared list',
@@ -132,7 +132,7 @@ const ERROR_CATALOGUE = {
132
132
  },
133
133
  E210: {
134
134
  title: 'Registry namespace is not configured',
135
- fix: 'Add it to "registries" in mfeflow.json, for example { "@someone": "https://example.com/{name}.json" }.'
135
+ fix: 'Add it with `mfeflow registry add @someone https://example.com/{name}.json` (or under "registries" in mfeflow.json).'
136
136
  },
137
137
  E211: {
138
138
  title: 'Files changed since the plan was made',
@@ -185,6 +185,14 @@ const ERROR_CATALOGUE = {
185
185
  E402: {
186
186
  title: 'The registry returned something that is not a feature',
187
187
  fix: 'Ask the registry owner to check the entry. It must be a feature.json document.'
188
+ },
189
+ E403: {
190
+ title: "The registry's index.json is not valid",
191
+ fix: "Ask the registry owner to rebuild it with `mfeflow registry build`. It lists items with a name, version and description."
192
+ },
193
+ E404: {
194
+ title: 'A feature in the registry folder cannot be published',
195
+ 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.'
188
196
  }
189
197
  };
190
198
  class MfeflowError extends Error {
@@ -1708,15 +1716,24 @@ function registryUrl(name, registries, version) {
1708
1716
  cause: error
1709
1717
  });
1710
1718
  }
1711
- if ('https:' !== parsed.protocol) throw new MfeflowError('E401', `${url} is not https`);
1719
+ if (!isRegistryProtocol(parsed)) throw new MfeflowError('E401', `${url} is not https (plain http is for 127.0.0.1, localhost and [::1] only)`);
1712
1720
  return parsed.href;
1713
1721
  }
1714
- async function getHttps(url, fetchImpl, code) {
1722
+ async function getHttps(url, fetchImpl, code, options = {}) {
1715
1723
  const fail = (at, why, cause)=>{
1716
1724
  throw new MfeflowError(code, `${at}: ${why}`, void 0 === cause ? {} : {
1717
1725
  cause
1718
1726
  });
1719
1727
  };
1728
+ const startsLoopback = true === options.loopback && isLoopbackHttp(new URL(url));
1729
+ const permitted = (target)=>'https:' === target.protocol || startsLoopback && isLoopbackHttp(target);
1730
+ const addressOk = (address)=>{
1731
+ try {
1732
+ return permitted(new URL(address));
1733
+ } catch {
1734
+ return false;
1735
+ }
1736
+ };
1720
1737
  let current = url;
1721
1738
  for(let hop = 0; hop <= MAX_REDIRECTS; hop += 1){
1722
1739
  let response;
@@ -1728,7 +1745,7 @@ async function getHttps(url, fetchImpl, code) {
1728
1745
  } catch (error) {
1729
1746
  return fail(current, messageOf(error), error);
1730
1747
  }
1731
- if (void 0 !== response.url && '' !== response.url && !response.url.startsWith('https://')) fail(url, `ended up at ${response.url}, which is not https`);
1748
+ if (void 0 !== response.url && '' !== response.url && !addressOk(response.url)) fail(url, `ended up at ${response.url}, which is not https`);
1732
1749
  if (response.status < 300 || response.status >= 400) return response;
1733
1750
  const location = response.headers?.get('location');
1734
1751
  await response.body?.cancel().catch(()=>void 0);
@@ -1739,14 +1756,16 @@ async function getHttps(url, fetchImpl, code) {
1739
1756
  } catch (error) {
1740
1757
  return fail(current, 'redirected to a bad address', error);
1741
1758
  }
1742
- if ('https:' !== next.protocol) fail(url, `redirected to ${next.href}, which is not https`);
1759
+ if (!permitted(next)) fail(url, `redirected to ${next.href}, which is not https`);
1743
1760
  current = next.href;
1744
1761
  }
1745
1762
  return fail(url, 'redirected too many times');
1746
1763
  }
1747
1764
  async function fetchRemoteText(name, registries, fetchImpl = realFetch, version) {
1748
1765
  const url = registryUrl(name, registries, version);
1749
- const response = await getHttps(url, fetchImpl, 'E401');
1766
+ const response = await getHttps(url, fetchImpl, 'E401', {
1767
+ loopback: true
1768
+ });
1750
1769
  if (!response.ok) {
1751
1770
  const hint = 404 === response.status ? `${name} is not in that registry` : void 0;
1752
1771
  throw new MfeflowError('E401', `${url} answered ${String(response.status)}${hint ? `: ${hint}` : ''}`);
@@ -1773,6 +1792,44 @@ async function readCappedText(response, maxBytes, tooBig) {
1773
1792
  }
1774
1793
  return new TextDecoder().decode(Buffer.concat(chunks));
1775
1794
  }
1795
+ async function fetchFeatureIndex(namespace, registries, fetchImpl = realFetch) {
1796
+ const template = registries[namespace];
1797
+ if (void 0 === template) throw new MfeflowError('E210', namespace);
1798
+ const problem = registryTemplateProblem(template);
1799
+ if (void 0 !== problem) throw new MfeflowError('E401', `${namespace}: ${problem}`);
1800
+ const url = registryIndexUrl(template);
1801
+ const response = await getHttps(url, fetchImpl, 'E401', {
1802
+ loopback: true
1803
+ });
1804
+ if (!response.ok) {
1805
+ const hint = 404 === response.status ? ': this registry has no index.json' : '';
1806
+ throw new MfeflowError('E401', `${url} answered ${String(response.status)}${hint}`);
1807
+ }
1808
+ const text = await readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E403', `${url} is larger than 1 MB`));
1809
+ let value;
1810
+ try {
1811
+ value = JSON.parse(text);
1812
+ } catch (error) {
1813
+ const reason = messageOf(error).replaceAll(/\p{Cc}/gu, '?');
1814
+ throw new MfeflowError('E403', `${url} is not JSON (${reason})`, {
1815
+ cause: error
1816
+ });
1817
+ }
1818
+ const issues = validateFeatureRegistryIndex(value);
1819
+ if (issues.length > 0) {
1820
+ const shown = issues.slice(0, 3).map((i)=>`${i.path || '(root)'} ${i.message}`);
1821
+ const more = issues.length > 3 ? `; and ${String(issues.length - 3)} more` : '';
1822
+ throw new MfeflowError('E403', `${url}: ${shown.join('; ')}${more}`);
1823
+ }
1824
+ return itemsOf(value);
1825
+ }
1826
+ function itemsOf(value) {
1827
+ const holder = {
1828
+ items: []
1829
+ };
1830
+ Object.assign(holder, value);
1831
+ return holder.items;
1832
+ }
1776
1833
  const ROOTS = new Set([
1777
1834
  'z',
1778
1835
  'v',
@@ -1852,6 +1909,7 @@ const THIRD_PARTY_KEYS = new Set([
1852
1909
  'name',
1853
1910
  'version',
1854
1911
  "description",
1912
+ 'keywords',
1855
1913
  'prompts',
1856
1914
  'requires',
1857
1915
  'conflicts',
@@ -1896,7 +1954,7 @@ function isRegistryRange(range) {
1896
1954
  if (range.split(/\s+|\|\|/).some((part)=>TARBALL_NAME.test(part))) return false;
1897
1955
  return DIST_TAG.test(range) || null !== validRange(range);
1898
1956
  }
1899
- function assertPlainScript(name, label) {
1957
+ function assertPlainScript(name, feature, label = feature) {
1900
1958
  const reject = (why)=>{
1901
1959
  throw new MfeflowError('E203', `${label}: script "${name}" ${why}`);
1902
1960
  };
@@ -1905,6 +1963,8 @@ function assertPlainScript(name, label) {
1905
1963
  if (MANAGER_PREFIXES.has(prefix)) reject('uses a package manager hook prefix');
1906
1964
  if (LIFECYCLE_SCRIPTS.has(name)) reject('would run on its own (npm lifecycle)');
1907
1965
  if (/^(pre|post)/.test(name)) reject("starts with pre or post, which wraps another script; namespace it, e.g. feature:name");
1966
+ const own = feature.slice(feature.indexOf('/') + 1);
1967
+ if (prefix !== own) reject(`must start with the feature's own name, like ${own}:check, so no other runner picks it up`);
1908
1968
  }
1909
1969
  function assertAllowedKeys(value, label) {
1910
1970
  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`);
@@ -1935,7 +1995,7 @@ function assertThirdPartyContent(manifest, label) {
1935
1995
  if (!isRegistryRange(dep.range)) throw new MfeflowError('E203', `${label}: ${dep.name}@${dep.range} is not a registry version range`);
1936
1996
  }
1937
1997
  for (const t of manifest.transforms ?? [])assertThirdPartyTransform(t, label);
1938
- for (const script of manifest.scripts ?? [])assertPlainScript(script.name, label);
1998
+ for (const script of manifest.scripts ?? [])assertPlainScript(script.name, manifest.name, label);
1939
1999
  }
1940
2000
  function assertThirdPartyTransform(t, label) {
1941
2001
  const deny = (why)=>{
@@ -2213,6 +2273,112 @@ async function editFile(builder, file, edit, op) {
2213
2273
  if (isConfig && !await builder.exists(file)) return void await builder.create(file, edit('{}\n'));
2214
2274
  await builder.patch(file, edit);
2215
2275
  }
2276
+ const registry_build_text = (value)=>`${JSON.stringify(value, null, 2)}\n`;
2277
+ async function buildRegistry(dir, ops = realFs) {
2278
+ let folders;
2279
+ try {
2280
+ folders = (await ops.listDirs(dir)).toSorted((a, b)=>a.localeCompare(b));
2281
+ } catch (error) {
2282
+ throw new MfeflowError('E404', `${dir} is not a folder (${messageOf(error)})`, {
2283
+ cause: error
2284
+ });
2285
+ }
2286
+ const problems = [];
2287
+ const found = [];
2288
+ let looked = 0;
2289
+ for (const folder of folders){
2290
+ const file = node_path.join(dir, folder, 'feature.json');
2291
+ const source = await readIfExists(ops, file);
2292
+ if (void 0 === source) continue;
2293
+ looked += 1;
2294
+ const label = `${folder}/feature.json`;
2295
+ try {
2296
+ const manifest = parseFeature(source, 'third-party', label);
2297
+ const slash = manifest.name.indexOf('/');
2298
+ const bare = manifest.name.slice(slash + 1);
2299
+ const own = (problem)=>problems.push(`${label}: ${problem}`);
2300
+ const namespace = manifest.name.slice(0, slash);
2301
+ if (!REGISTRY_NAMESPACE.test(namespace)) own(`namespace "${namespace}" must start with a letter or digit, like @acme`);
2302
+ if ('index' === bare) own('"index" is taken by the registry index; pick another name');
2303
+ if (valid(manifest.version) !== manifest.version) own(`version "${manifest.version}" must be a semver version like 1.0.0`);
2304
+ if ((manifest.description ?? '') === '') own("description is required; `mfeflow features` shows it next to the name");
2305
+ found.push({
2306
+ folder,
2307
+ namespace,
2308
+ bare,
2309
+ version: manifest.version,
2310
+ description: manifest.description ?? '',
2311
+ keywords: manifest.keywords,
2312
+ json: registry_build_text(manifest)
2313
+ });
2314
+ } catch (error) {
2315
+ if (!isMfeflowError(error)) throw error;
2316
+ problems.push(`${label}: ${error.message.replace(`${label}: `, '')}`);
2317
+ }
2318
+ }
2319
+ if (0 === looked) throw new MfeflowError('E404', `no <folder>/feature.json found in ${dir}`, {
2320
+ fix: 'Put each feature in its own folder next to the others: my-registry/hello/feature.json.'
2321
+ });
2322
+ const namespaces = new Set(found.map((f)=>f.namespace));
2323
+ if (namespaces.size > 1) problems.push(`one registry serves one namespace, but this folder has ${[
2324
+ ...namespaces
2325
+ ].toSorted((a, b)=>a.localeCompare(b)).join(', ')}`);
2326
+ const names = new Map();
2327
+ for (const f of found){
2328
+ const earlier = names.get(f.bare);
2329
+ if (void 0 !== earlier) problems.push(`${f.folder}/feature.json: ${f.bare} is already defined by ${earlier}`);
2330
+ names.set(f.bare, `${f.folder}/feature.json`);
2331
+ }
2332
+ const files = [];
2333
+ const features = [];
2334
+ const items = [];
2335
+ for (const f of found.toSorted((a, b)=>a.bare.localeCompare(b.bare))){
2336
+ const paths = [
2337
+ `${f.bare}.json`,
2338
+ `${f.bare}/${f.version}.json`,
2339
+ `${f.bare}/latest.json`
2340
+ ];
2341
+ if (Buffer.byteLength(f.json, 'utf8') > MAX_BYTES) problems.push(`${f.folder}/feature.json: is larger than 1 MB, which users' installs refuse`);
2342
+ for (const p of paths)files.push({
2343
+ path: p,
2344
+ content: f.json
2345
+ });
2346
+ features.push({
2347
+ name: `${f.namespace}/${f.bare}`,
2348
+ version: f.version,
2349
+ files: paths
2350
+ });
2351
+ items.push({
2352
+ name: f.bare,
2353
+ version: f.version,
2354
+ description: f.description,
2355
+ ...void 0 === f.keywords || 0 === f.keywords.length ? {} : {
2356
+ keywords: f.keywords
2357
+ }
2358
+ });
2359
+ }
2360
+ const index = {
2361
+ $schema: SCHEMA_URLS.featureRegistryIndex,
2362
+ items
2363
+ };
2364
+ for (const issue of validateFeatureRegistryIndex(index))problems.push(`index.json: ${issue.path} ${issue.message}`);
2365
+ if (problems.length > 0) {
2366
+ const list = problems.map((p)=>`\n - ${p}`).join('');
2367
+ throw new MfeflowError('E404', `${String(problems.length)} problem(s)${list}`);
2368
+ }
2369
+ files.push({
2370
+ path: 'index.json',
2371
+ content: registry_build_text(index)
2372
+ });
2373
+ return {
2374
+ namespace: [
2375
+ ...namespaces
2376
+ ][0] ?? '',
2377
+ index,
2378
+ features,
2379
+ files
2380
+ };
2381
+ }
2216
2382
  function packageInfo() {
2217
2383
  const { name, version } = readPackageJson();
2218
2384
  return {
@@ -2220,4 +2386,4 @@ function packageInfo() {
2220
2386
  version
2221
2387
  };
2222
2388
  }
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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mfeflow/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.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.4.0",
49
49
  "diff": "9.0.0",
50
50
  "eta": "4.6.0",
51
51
  "semver": "7.8.5",