@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 +4 -2
- package/dist/errors.d.ts +9 -1
- package/dist/features/index.d.ts +2 -1
- package/dist/features/policy.d.ts +1 -1
- package/dist/features/registry-build.d.ts +25 -0
- package/dist/features/remote.d.ts +15 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +177 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,8 +26,10 @@ install, commit or the next `mfeflow` command.
|
|
|
26
26
|
files and the local `features/` folder are refused. Paths are compared after
|
|
27
27
|
Unicode (NFC) and case folding, and names with trailing dots or spaces, `:`,
|
|
28
28
|
invisible characters or Windows device names are refused for every feature.
|
|
29
|
-
- **Scripts** are lower-case words joined by
|
|
30
|
-
|
|
29
|
+
- **Scripts** are lower-case words joined by `:` and start with the feature's
|
|
30
|
+
own name (`segment:check` for `@acme/segment`), so `build`, `lint:*` and
|
|
31
|
+
other runners never pick them up. Lifecycle events, `pnpm:`, `npm:`, `yarn:`
|
|
32
|
+
and `bun:` prefixes and every `pre` or `post` name are refused.
|
|
31
33
|
- **Dependencies** take a semver range or a dist-tag. `file:`, `link:`, git,
|
|
32
34
|
URLs, `npm:` aliases, `workspace:` and `catalog:` are refused.
|
|
33
35
|
- **Imports** name a package, a relative path or an `@/` alias, never a URL.
|
package/dist/errors.d.ts
CHANGED
|
@@ -125,7 +125,7 @@ export declare const ERROR_CATALOGUE: {
|
|
|
125
125
|
};
|
|
126
126
|
readonly E210: {
|
|
127
127
|
readonly title: 'Registry namespace is not configured';
|
|
128
|
-
readonly fix: 'Add it
|
|
128
|
+
readonly fix: 'Add it with `mfeflow registry add @someone https://example.com/{name}.json` (or under "registries" in mfeflow.json).';
|
|
129
129
|
};
|
|
130
130
|
readonly E211: {
|
|
131
131
|
readonly title: 'Files changed since the plan was made';
|
|
@@ -179,6 +179,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 {
|
package/dist/features/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { listLocalFeatures, loadFeature, parseFeature, type LoadedFeature, type LoadOptions, } from './load.js';
|
|
2
|
-
export { 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,
|
|
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'
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
+
"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.
|
|
48
|
+
"@mfeflow/contracts": "0.4.0",
|
|
49
49
|
"diff": "9.0.0",
|
|
50
50
|
"eta": "4.6.0",
|
|
51
51
|
"semver": "7.8.5",
|