mfeflow 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +126 -2
- package/assets/features/analytics-gtm/feature.json +117 -0
- package/assets/features/analytics-gtm/files/Analytics.tsx +13 -0
- package/assets/features/analytics-gtm/files/ConsentBanner.tsx +47 -0
- package/assets/features/analytics-gtm/files/consent-banner-test.tsx +36 -0
- package/assets/features/analytics-gtm/files/init.ts +26 -0
- package/assets/features/analytics-gtm/files/remote-analytics-test.ts.eta +18 -0
- package/assets/features/analytics-gtm/files/remote-analytics.ts.eta +13 -0
- package/assets/features/analytics-gtm/files/shell-init-test.ts +42 -0
- package/assets/features/ci-github/feature.json +26 -0
- package/assets/features/ci-github/files/ci.md +41 -0
- package/assets/features/ci-github/files/ci.yml +63 -0
- package/assets/features/env-t3/feature.json +378 -0
- package/assets/features/env-t3/files/check-runtime-config.ts +105 -0
- package/assets/features/env-t3/files/env.arktype.ts +12 -0
- package/assets/features/env-t3/files/env.example +1 -0
- package/assets/features/env-t3/files/env.valibot.ts +12 -0
- package/assets/features/env-t3/files/env.zod.ts +12 -0
- package/assets/features/env-t3/files/runtime.ts.eta +30 -0
- package/assets/features/env-t3/files/schema.arktype.ts +8 -0
- package/assets/features/env-t3/files/schema.valibot.ts +8 -0
- package/assets/features/env-t3/files/schema.zod.ts +8 -0
- package/assets/features/git-hooks/feature.json +42 -0
- package/assets/features/git-hooks/files/commitlint.config.js +1 -0
- package/assets/features/git-hooks/files/lefthook.yml +9 -0
- package/assets/features/lint-oxlint/feature.json +60 -0
- package/assets/features/lint-oxlint/files/oxlintrc.json +5 -0
- package/assets/features/lint-oxlint/files/prettierignore +5 -0
- package/assets/features/lint-oxlint/files/prettierrc.json +5 -0
- package/assets/features/telemetry-otel/feature.json +159 -0
- package/assets/features/telemetry-otel/files/init.ts.eta +53 -0
- package/assets/features/telemetry-otel/files/remote-telemetry-test.ts.eta +13 -0
- package/assets/features/telemetry-otel/files/remote-telemetry.ts.eta +11 -0
- package/assets/features/telemetry-otel/files/shell-init-test.ts.eta +36 -0
- package/assets/features/testing/feature.json +202 -0
- package/assets/features/testing/files/handlers.ts +5 -0
- package/assets/features/testing/files/playwright.config.ts +30 -0
- package/assets/features/testing/files/root-vitest.config.ts +6 -0
- package/assets/features/testing/files/sample-handlers-test.ts +16 -0
- package/assets/features/testing/files/sample-remote-test.tsx +12 -0
- package/assets/features/testing/files/sample-shell-test.tsx +11 -0
- package/assets/features/testing/files/sample-smoke-e2e.ts +53 -0
- package/assets/features/testing/files/server.ts +4 -0
- package/assets/features/testing/files/setup.ts +18 -0
- package/assets/features/testing/files/vitest.config.ts +11 -0
- package/assets/features/tsconfig/feature.json +78 -0
- package/assets/features/tsconfig/files/root.tsconfig.json +4 -0
- package/assets/features/tsconfig/files/tsconfig.json +5 -0
- package/assets/features/url-factory/feature.json +30 -0
- package/assets/features/url-factory/files/endpoints.ts +12 -0
- package/assets/templates/monorepo/feature.json +295 -0
- package/assets/templates/monorepo/files/remote/mfe.json.eta +7 -0
- package/assets/templates/monorepo/files/remote/package.json.eta +6 -0
- package/assets/templates/monorepo/files/remote/rsbuild.config.ts.eta +7 -0
- package/assets/templates/monorepo/files/remote/src/bootstrap.tsx.eta +6 -0
- package/assets/templates/monorepo/files/remote/src/env.d.ts.eta +1 -0
- package/assets/templates/monorepo/files/remote/src/exposed/App.tsx.eta +18 -0
- package/assets/templates/monorepo/files/remote/src/index.ts.eta +2 -0
- package/assets/templates/monorepo/files/root/AGENTS.md.eta +17 -0
- package/assets/templates/monorepo/files/root/README.md.eta +52 -0
- package/assets/templates/monorepo/files/root/gitignore.eta +11 -0
- package/assets/templates/monorepo/files/root/nvmrc.eta +1 -0
- package/assets/templates/monorepo/files/root/package.json.eta +9 -0
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +7 -0
- package/assets/templates/monorepo/files/root/turbo.json.eta +16 -0
- package/assets/templates/monorepo/files/shell/mfe.json.eta +7 -0
- package/assets/templates/monorepo/files/shell/package.json.eta +6 -0
- package/assets/templates/monorepo/files/shell/rsbuild.config.ts.eta +74 -0
- package/assets/templates/monorepo/files/shell/src/App.tsx.eta +46 -0
- package/assets/templates/monorepo/files/shell/src/bootstrap.tsx.eta +24 -0
- package/assets/templates/monorepo/files/shell/src/env.d.ts.eta +1 -0
- package/assets/templates/monorepo/files/shell/src/index.ts.eta +4 -0
- package/assets/templates/monorepo/files/shell/src/pages/Home.tsx.eta +12 -0
- package/assets/templates/monorepo/files/shell/src/remotes/route-table.tsx.eta +12 -0
- package/assets/templates/monorepo/glue/registry-entry.json.eta +5 -0
- package/assets/templates/monorepo/glue/route.tsx.eta +5 -0
- package/bin/mfeflow.js +4 -0
- package/dist/args.d.ts +68 -0
- package/dist/assets.d.ts +13 -0
- package/dist/catalog.d.ts +24 -0
- package/dist/checks/compat.d.ts +2 -0
- package/dist/checks/config.d.ts +8 -0
- package/dist/checks/env.d.ts +6 -0
- package/dist/checks/index.d.ts +6 -0
- package/dist/checks/plugins.d.ts +4 -0
- package/dist/checks/singletons.d.ts +9 -0
- package/dist/checks/workspace.d.ts +2 -0
- package/dist/cli.d.ts +11 -0
- package/dist/commands/add.d.ts +84 -0
- package/dist/commands/budget.d.ts +56 -0
- package/dist/commands/dev.d.ts +62 -0
- package/dist/commands/doctor.d.ts +141 -0
- package/dist/commands/env-key.d.ts +4 -0
- package/dist/commands/features.d.ts +53 -0
- package/dist/commands/init.d.ts +102 -0
- package/dist/devregistry.d.ts +19 -0
- package/dist/exit.d.ts +31 -0
- package/dist/findings.d.ts +35 -0
- package/dist/glue.d.ts +41 -0
- package/dist/help.d.ts +12 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4154 -0
- package/dist/inspect.d.ts +57 -0
- package/dist/io.d.ts +10 -0
- package/dist/local.d.ts +28 -0
- package/dist/options.d.ts +7 -0
- package/dist/output.d.ts +157 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/preset.d.ts +19 -0
- package/dist/proc.d.ts +79 -0
- package/dist/prompter.d.ts +28 -0
- package/dist/report.d.ts +26 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/singletons.d.ts +11 -0
- package/dist/sizes.d.ts +17 -0
- package/dist/system.d.ts +20 -0
- package/dist/version.d.ts +8 -0
- package/dist/write.d.ts +26 -0
- package/package.json +66 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,4154 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { MfeflowError, PlanBuilder, applyPlan, dotenvKeys, findDiverged, findProjectRoot, getHttps, hasErrno, inOrder, isMfeflowError, loadFeature, loadProject, messageOf, parseFeature, readCappedText, realFs, recordPlan, renderDiff, renderTemplate, resolveFeature, runtimeConfigFiles, schemaKeys, serializeProject, summarize } from "@mfeflow/core";
|
|
3
|
+
import { defineCommand, runCommand } from "citty";
|
|
4
|
+
import node_path from "node:path";
|
|
5
|
+
import { COST_MODEL, DOCS_URL, MFE_NAME, assetsFromManifest, isSafeRelativePath, judgeCost, round1, validateMfeManifest, validateRegistryEntry } from "@mfeflow/contracts";
|
|
6
|
+
import promises from "node:fs/promises";
|
|
7
|
+
import node_fs from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import semver from "semver";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
import node_zlib from "node:zlib";
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import node_net from "node:net";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import * as __rspack_external__clack_prompts_d2d5c36d from "@clack/prompts";
|
|
16
|
+
const GLOBAL_ARGS = {
|
|
17
|
+
yes: {
|
|
18
|
+
type: 'boolean',
|
|
19
|
+
alias: 'y',
|
|
20
|
+
description: 'Take the defaults and never prompt'
|
|
21
|
+
},
|
|
22
|
+
preset: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Replay options from a JSON file or an https URL',
|
|
25
|
+
valueHint: 'file|url'
|
|
26
|
+
},
|
|
27
|
+
json: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'Print one JSON document on stdout, nothing else'
|
|
30
|
+
},
|
|
31
|
+
'dry-run': {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
description: 'Print the summary and diff, write nothing'
|
|
34
|
+
},
|
|
35
|
+
cwd: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Run as if started in this folder',
|
|
38
|
+
valueHint: 'dir'
|
|
39
|
+
},
|
|
40
|
+
install: {
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
default: true,
|
|
43
|
+
description: 'Run the package manager after writing (--no-install to skip)'
|
|
44
|
+
},
|
|
45
|
+
verbose: {
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
description: 'Show package manager output and stack traces'
|
|
48
|
+
},
|
|
49
|
+
local: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Link @mfeflow/* from a built mfeflow checkout (env MFEFLOW_LOCAL)',
|
|
52
|
+
valueHint: 'path'
|
|
53
|
+
},
|
|
54
|
+
help: {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
alias: 'h',
|
|
57
|
+
description: 'Show this help'
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function args_text(args, key) {
|
|
61
|
+
const value = args[key];
|
|
62
|
+
return 'string' == typeof value && '' !== value ? value : void 0;
|
|
63
|
+
}
|
|
64
|
+
function readGlobals(args, env, cwd, interactive = false) {
|
|
65
|
+
const folder = args_text(args, 'cwd');
|
|
66
|
+
const yes = true === args.yes;
|
|
67
|
+
const json = true === args.json;
|
|
68
|
+
return {
|
|
69
|
+
yes,
|
|
70
|
+
preset: args_text(args, 'preset'),
|
|
71
|
+
json,
|
|
72
|
+
dryRun: true === args['dry-run'],
|
|
73
|
+
cwd: void 0 === folder ? cwd : node_path.resolve(cwd, folder),
|
|
74
|
+
install: false !== args.install,
|
|
75
|
+
verbose: true === args.verbose,
|
|
76
|
+
local: args_text(args, 'local') ?? ('' === env.MFEFLOW_LOCAL ? void 0 : env.MFEFLOW_LOCAL),
|
|
77
|
+
canPrompt: interactive && !yes && !json
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function flagNames(raw) {
|
|
81
|
+
const names = [];
|
|
82
|
+
for (const token of raw){
|
|
83
|
+
if ('--' === token) break;
|
|
84
|
+
if (!token.startsWith('-') || '-' === token) continue;
|
|
85
|
+
const body = token.replace(/^--?/, '').split('=')[0] ?? '';
|
|
86
|
+
names.push(body);
|
|
87
|
+
}
|
|
88
|
+
return names;
|
|
89
|
+
}
|
|
90
|
+
function assertKnownFlags(raw, defs) {
|
|
91
|
+
const known = new Set();
|
|
92
|
+
for (const [name, def] of Object.entries(defs))if ('positional' !== def.type) {
|
|
93
|
+
known.add(name);
|
|
94
|
+
if ('alias' in def) for (const alias of [
|
|
95
|
+
def.alias ?? []
|
|
96
|
+
].flat())known.add(alias);
|
|
97
|
+
if ('boolean' === def.type) known.add(`no-${name}`);
|
|
98
|
+
}
|
|
99
|
+
for (const name of flagNames(raw))if (!known.has(name)) throw new MfeflowError('E107', `unknown flag "${1 === name.length ? '-' : '--'}${name}"`);
|
|
100
|
+
}
|
|
101
|
+
function repeated(raw, name) {
|
|
102
|
+
const values = [];
|
|
103
|
+
const flag = `--${name}`;
|
|
104
|
+
for (const [index, token] of raw.entries()){
|
|
105
|
+
if ('--' === token) break;
|
|
106
|
+
if (token === flag) {
|
|
107
|
+
const next = raw[index + 1];
|
|
108
|
+
if (void 0 !== next) values.push(next);
|
|
109
|
+
} else if (token.startsWith(`${flag}=`)) values.push(token.slice(flag.length + 1));
|
|
110
|
+
}
|
|
111
|
+
return values;
|
|
112
|
+
}
|
|
113
|
+
function splitList(value) {
|
|
114
|
+
if (void 0 === value) return;
|
|
115
|
+
const parts = 'string' == typeof value ? value.split(',') : value;
|
|
116
|
+
return parts.map((part)=>part.trim()).filter((part)=>'' !== part);
|
|
117
|
+
}
|
|
118
|
+
const FIRST_PARTY = [
|
|
119
|
+
{
|
|
120
|
+
name: 'core',
|
|
121
|
+
always: true,
|
|
122
|
+
default: true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'tsconfig',
|
|
126
|
+
always: true,
|
|
127
|
+
default: true
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'lint-oxlint',
|
|
131
|
+
always: false,
|
|
132
|
+
default: true
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'url-factory',
|
|
136
|
+
always: false,
|
|
137
|
+
default: true
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'env-t3',
|
|
141
|
+
always: false,
|
|
142
|
+
default: true
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'testing',
|
|
146
|
+
always: false,
|
|
147
|
+
default: true
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'analytics-gtm',
|
|
151
|
+
always: false,
|
|
152
|
+
default: false
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'telemetry-otel',
|
|
156
|
+
always: false,
|
|
157
|
+
default: false
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'ci-github',
|
|
161
|
+
always: false,
|
|
162
|
+
default: false
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'git-hooks',
|
|
166
|
+
always: false,
|
|
167
|
+
default: false
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
function isFirstParty(name) {
|
|
171
|
+
return FIRST_PARTY.some((feature)=>feature.name === name);
|
|
172
|
+
}
|
|
173
|
+
const OPTIONAL_FEATURES = FIRST_PARTY.filter((f)=>!f.always).map((f)=>f.name);
|
|
174
|
+
const DEFAULT_FEATURES = FIRST_PARTY.filter((f)=>!f.always && f.default).map((f)=>f.name);
|
|
175
|
+
async function loadFirstParty(name, roots) {
|
|
176
|
+
if ('core' !== name) return loadFeature(name, {
|
|
177
|
+
featuresDir: roots.features
|
|
178
|
+
});
|
|
179
|
+
const dir = node_path.join(roots.templates, 'monorepo');
|
|
180
|
+
let text;
|
|
181
|
+
try {
|
|
182
|
+
text = await realFs.readFile(node_path.join(dir, 'feature.json'));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new MfeflowError('E201', `core (looked in ${dir})`, {
|
|
185
|
+
cause: error
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
manifest: parseFeature(text, 'first-party', 'core/feature.json'),
|
|
190
|
+
trust: 'first-party',
|
|
191
|
+
readAsset: async (relative)=>{
|
|
192
|
+
if (!isSafeRelativePath(relative)) throw new MfeflowError('E105', `"${relative}"`);
|
|
193
|
+
return realFs.readFile(node_path.join(dir, ...relative.split('/')));
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function loadAny(name, source) {
|
|
198
|
+
if (isFirstParty(name)) return loadFirstParty(name, source.roots);
|
|
199
|
+
return loadFeature(name, {
|
|
200
|
+
featuresDir: source.roots.features,
|
|
201
|
+
...source.registries ? {
|
|
202
|
+
registries: source.registries
|
|
203
|
+
} : {},
|
|
204
|
+
...source.fetch ? {
|
|
205
|
+
fetch: source.fetch
|
|
206
|
+
} : {},
|
|
207
|
+
...void 0 === source.version ? {} : {
|
|
208
|
+
version: source.version
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const RESERVED = new Set([
|
|
213
|
+
'root',
|
|
214
|
+
'shell',
|
|
215
|
+
'remotes'
|
|
216
|
+
]);
|
|
217
|
+
function assertAppName(name, apps = {}) {
|
|
218
|
+
if (!MFE_NAME.test(name)) throw new MfeflowError('E215', `"${name}" is not a Module Federation name`);
|
|
219
|
+
if (RESERVED.has(name)) throw new MfeflowError('E215', `"${name}" is a reserved name`);
|
|
220
|
+
if (name in apps) throw new MfeflowError('E215', `"${name}" is already an app`);
|
|
221
|
+
}
|
|
222
|
+
function usedPorts(project) {
|
|
223
|
+
return new Set(Object.values(project.apps).flatMap((app)=>void 0 === app.port ? [] : [
|
|
224
|
+
app.port
|
|
225
|
+
]));
|
|
226
|
+
}
|
|
227
|
+
function freePort(project, start) {
|
|
228
|
+
const used = usedPorts(project);
|
|
229
|
+
let port = start;
|
|
230
|
+
while(used.has(port))port += 1;
|
|
231
|
+
return port;
|
|
232
|
+
}
|
|
233
|
+
function pascal(name) {
|
|
234
|
+
return name.split('_').map((part)=>part.charAt(0).toUpperCase() + part.slice(1)).join('');
|
|
235
|
+
}
|
|
236
|
+
function routeComponent(remote) {
|
|
237
|
+
return `${pascal(remote)}Route`;
|
|
238
|
+
}
|
|
239
|
+
const REGISTRY_ENVS = [
|
|
240
|
+
'dev',
|
|
241
|
+
'staging',
|
|
242
|
+
'prod'
|
|
243
|
+
];
|
|
244
|
+
const ENV_HOSTS = {
|
|
245
|
+
staging: 'https://staging.example.com',
|
|
246
|
+
prod: 'https://app.example.com'
|
|
247
|
+
};
|
|
248
|
+
async function glueTemplate(roots, file) {
|
|
249
|
+
return promises.readFile(node_path.join(roots.templates, 'monorepo', 'glue', file), 'utf8');
|
|
250
|
+
}
|
|
251
|
+
const asFeatureFile = (file)=>({
|
|
252
|
+
to: file.path,
|
|
253
|
+
content: file.content
|
|
254
|
+
});
|
|
255
|
+
async function registryEntryFiles(roots, remote, envs = REGISTRY_ENVS) {
|
|
256
|
+
const { name, port } = remote;
|
|
257
|
+
const template = await glueTemplate(roots, 'registry-entry.json.eta');
|
|
258
|
+
const urls = {
|
|
259
|
+
dev: {
|
|
260
|
+
version: 'dev',
|
|
261
|
+
manifestUrl: `http://localhost:${String(port)}/mf-manifest.json`
|
|
262
|
+
},
|
|
263
|
+
staging: {
|
|
264
|
+
version: '0.0.0',
|
|
265
|
+
manifestUrl: `${ENV_HOSTS.staging}/mfe/${name}/mf-manifest.json`
|
|
266
|
+
},
|
|
267
|
+
prod: {
|
|
268
|
+
version: '0.0.0',
|
|
269
|
+
manifestUrl: `${ENV_HOSTS.prod}/mfe/${name}/mf-manifest.json`
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return envs.flatMap((env)=>{
|
|
273
|
+
const entry = urls[env];
|
|
274
|
+
if (void 0 === entry) return [];
|
|
275
|
+
return [
|
|
276
|
+
{
|
|
277
|
+
path: `registry/envs/${env}/${name}.json`,
|
|
278
|
+
content: renderTemplate(template, {
|
|
279
|
+
name,
|
|
280
|
+
...entry
|
|
281
|
+
}, `registry/${env}`)
|
|
282
|
+
}
|
|
283
|
+
];
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async function remoteGlue(options) {
|
|
287
|
+
const { name } = options.remote;
|
|
288
|
+
const files = [];
|
|
289
|
+
const transforms = [];
|
|
290
|
+
if (options.registry) files.push(...(await registryEntryFiles(options.roots, options.remote)).map(asFeatureFile));
|
|
291
|
+
if (options.shells.length > 0) {
|
|
292
|
+
const route = await glueTemplate(options.roots, 'route.tsx.eta');
|
|
293
|
+
const component = routeComponent(name);
|
|
294
|
+
for (const target of options.shells){
|
|
295
|
+
files.push({
|
|
296
|
+
to: `src/remotes/${name}.tsx`,
|
|
297
|
+
target,
|
|
298
|
+
content: renderTemplate(route, {
|
|
299
|
+
name,
|
|
300
|
+
component
|
|
301
|
+
}, 'route')
|
|
302
|
+
});
|
|
303
|
+
transforms.push({
|
|
304
|
+
op: 'add-route',
|
|
305
|
+
file: 'src/remotes/route-table.tsx',
|
|
306
|
+
target,
|
|
307
|
+
path: `/${name}`,
|
|
308
|
+
component,
|
|
309
|
+
from: `./${name}`
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
manifest: {
|
|
315
|
+
name: 'core',
|
|
316
|
+
version: options.version,
|
|
317
|
+
files,
|
|
318
|
+
transforms
|
|
319
|
+
},
|
|
320
|
+
trust: 'first-party'
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function partsForApp(feature, from, app) {
|
|
324
|
+
const { manifest } = feature;
|
|
325
|
+
const mine = (items)=>(items ?? []).filter((item)=>item.target === from).map((item)=>Object.assign({}, item, {
|
|
326
|
+
target: app
|
|
327
|
+
}));
|
|
328
|
+
const files = mine(manifest.files);
|
|
329
|
+
const dependencies = mine(manifest.dependencies);
|
|
330
|
+
const scripts = mine(manifest.scripts);
|
|
331
|
+
const transforms = mine(manifest.transforms);
|
|
332
|
+
if (files.length + dependencies.length + scripts.length + transforms.length === 0) return;
|
|
333
|
+
return {
|
|
334
|
+
...feature,
|
|
335
|
+
manifest: {
|
|
336
|
+
name: manifest.name,
|
|
337
|
+
version: manifest.version,
|
|
338
|
+
...manifest.prompts ? {
|
|
339
|
+
prompts: manifest.prompts
|
|
340
|
+
} : {},
|
|
341
|
+
files,
|
|
342
|
+
dependencies,
|
|
343
|
+
scripts,
|
|
344
|
+
transforms
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function options_value(raw) {
|
|
349
|
+
if ('true' === raw) return true;
|
|
350
|
+
if ('false' === raw) return false;
|
|
351
|
+
return raw;
|
|
352
|
+
}
|
|
353
|
+
function parseFeatureOptions(flags) {
|
|
354
|
+
const out = {};
|
|
355
|
+
for (const flag of flags){
|
|
356
|
+
const equals = flag.indexOf('=');
|
|
357
|
+
const dot = flag.indexOf('.');
|
|
358
|
+
if (equals < 1 || dot < 1 || dot > equals - 1) throw new MfeflowError('E107', `--option "${flag}" must look like feature.key=value`);
|
|
359
|
+
const feature = flag.slice(0, dot);
|
|
360
|
+
const key = flag.slice(dot + 1, equals);
|
|
361
|
+
(out[feature] ??= {})[key] = options_value(flag.slice(equals + 1));
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
function mergeOptions(earlier, later) {
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const source of [
|
|
368
|
+
earlier,
|
|
369
|
+
later
|
|
370
|
+
])for (const [feature, given] of Object.entries(source))out[feature] = {
|
|
371
|
+
...out[feature],
|
|
372
|
+
...given
|
|
373
|
+
};
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
function assertOptionsAreForFeatures(options, features) {
|
|
377
|
+
for (const feature of Object.keys(options))if (!features.includes(feature)) throw new MfeflowError('E214', `options given for "${feature}", which is not one of the features in this run (${features.join(', ')})`);
|
|
378
|
+
}
|
|
379
|
+
const EXIT = {
|
|
380
|
+
ok: 0,
|
|
381
|
+
unexpected: 1,
|
|
382
|
+
usage: 2,
|
|
383
|
+
project: 3,
|
|
384
|
+
feature: 4,
|
|
385
|
+
build: 5,
|
|
386
|
+
registry: 6,
|
|
387
|
+
upgrade: 7,
|
|
388
|
+
cancelled: 130
|
|
389
|
+
};
|
|
390
|
+
class Cancelled extends Error {
|
|
391
|
+
constructor(){
|
|
392
|
+
super('Cancelled');
|
|
393
|
+
this.name = 'Cancelled';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const USAGE_ERRORS = new Set([
|
|
397
|
+
'MFEFLOW_E107',
|
|
398
|
+
'MFEFLOW_E215'
|
|
399
|
+
]);
|
|
400
|
+
const BY_RANGE = {
|
|
401
|
+
1: EXIT.project,
|
|
402
|
+
2: EXIT.feature,
|
|
403
|
+
3: EXIT.build,
|
|
404
|
+
4: EXIT.registry,
|
|
405
|
+
5: EXIT.upgrade
|
|
406
|
+
};
|
|
407
|
+
function exitCodeOfCode(code) {
|
|
408
|
+
if (USAGE_ERRORS.has(code)) return EXIT.usage;
|
|
409
|
+
return BY_RANGE[code.charAt(9)] ?? EXIT.unexpected;
|
|
410
|
+
}
|
|
411
|
+
function exitCodeFor(error) {
|
|
412
|
+
if (error instanceof Cancelled) return EXIT.cancelled;
|
|
413
|
+
if (!isMfeflowError(error)) return EXIT.unexpected;
|
|
414
|
+
return exitCodeOfCode(error.code);
|
|
415
|
+
}
|
|
416
|
+
function reportOf(error) {
|
|
417
|
+
return {
|
|
418
|
+
code: error.code,
|
|
419
|
+
message: error.message,
|
|
420
|
+
fix: error.fix,
|
|
421
|
+
docs: error.docs
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function unexpectedReport(error) {
|
|
425
|
+
return {
|
|
426
|
+
code: 'UNEXPECTED',
|
|
427
|
+
message: messageOf(error),
|
|
428
|
+
fix: 'This is a bug in mfeflow. Run again with --verbose for the stack, and report it.',
|
|
429
|
+
docs: 'https://github.com/mfeflow/mfeflow/issues'
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function unexpectedText(error, verbose) {
|
|
433
|
+
const stack = error instanceof Error && void 0 !== error.stack ? error.stack : messageOf(error);
|
|
434
|
+
return verbose ? stack : `Unexpected error: ${messageOf(error)}\n Run again with --verbose for the stack.`;
|
|
435
|
+
}
|
|
436
|
+
async function runChecks(view, checks) {
|
|
437
|
+
const outcomes = await Promise.all(checks.map((check)=>check.run(view)));
|
|
438
|
+
const reports = checks.map((check, i)=>{
|
|
439
|
+
const outcome = outcomes[i] ?? {
|
|
440
|
+
findings: []
|
|
441
|
+
};
|
|
442
|
+
const hasError = outcome.findings.some((f)=>'error' === f.severity);
|
|
443
|
+
const status = void 0 !== outcome.skipped ? 'skipped' : hasError ? 'fail' : outcome.findings.length > 0 ? 'warn' : 'pass';
|
|
444
|
+
return {
|
|
445
|
+
id: check.id,
|
|
446
|
+
title: check.title,
|
|
447
|
+
status,
|
|
448
|
+
...void 0 === outcome.skipped ? {} : {
|
|
449
|
+
skipped: outcome.skipped
|
|
450
|
+
},
|
|
451
|
+
findings: outcome.findings.length
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
checks: reports,
|
|
456
|
+
findings: outcomes.flatMap((o)=>o.findings)
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function summarizeReport(report, fixed) {
|
|
460
|
+
return {
|
|
461
|
+
errors: report.findings.filter((f)=>'error' === f.severity).length,
|
|
462
|
+
warnings: report.findings.filter((f)=>'warning' === f.severity).length,
|
|
463
|
+
skipped: report.checks.filter((c)=>'skipped' === c.status).length,
|
|
464
|
+
fixed
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function exitForFindings(findings) {
|
|
468
|
+
const codes = findings.filter((f)=>'error' === f.severity).map((f)=>exitCodeOfCode(f.code));
|
|
469
|
+
return 0 === codes.length ? EXIT.ok : Math.min(...codes);
|
|
470
|
+
}
|
|
471
|
+
const MARK = {
|
|
472
|
+
pass: 'ok ',
|
|
473
|
+
warn: 'warn',
|
|
474
|
+
fail: 'FAIL',
|
|
475
|
+
skipped: 'skip'
|
|
476
|
+
};
|
|
477
|
+
function formatReport(report, summary) {
|
|
478
|
+
const lines = report.checks.map((c)=>`${MARK[c.status]} ${c.title}${void 0 === c.skipped ? '' : ` (${c.skipped})`}`);
|
|
479
|
+
for (const f of report.findings)lines.push('', `${'error' === f.severity ? 'error' : 'warning'} ${f.code} ${f.message}`, ` Fix: ${f.fix}${f.fixable ? ' (mfeflow doctor --fix)' : ''}`, ` ${f.docs}`);
|
|
480
|
+
lines.push('', `${String(summary.errors)} error(s), ${String(summary.warnings)} warning(s), ${String(summary.skipped)} skipped${summary.fixed > 0 ? `, ${String(summary.fixed)} fixed` : ''}`);
|
|
481
|
+
return lines.join('\n');
|
|
482
|
+
}
|
|
483
|
+
function planReport(plan) {
|
|
484
|
+
const features = new Map(plan.features.map((f)=>[
|
|
485
|
+
f.name,
|
|
486
|
+
f.version
|
|
487
|
+
]));
|
|
488
|
+
return {
|
|
489
|
+
features: [
|
|
490
|
+
...features
|
|
491
|
+
].map(([name, version])=>({
|
|
492
|
+
name,
|
|
493
|
+
version
|
|
494
|
+
})),
|
|
495
|
+
changes: plan.changes.map((c)=>({
|
|
496
|
+
path: c.path,
|
|
497
|
+
action: c.action
|
|
498
|
+
})),
|
|
499
|
+
dependencies: plan.dependencies.map((d)=>({
|
|
500
|
+
...d
|
|
501
|
+
})),
|
|
502
|
+
scripts: plan.scripts.map((s)=>({
|
|
503
|
+
file: s.file,
|
|
504
|
+
name: s.name,
|
|
505
|
+
command: s.command
|
|
506
|
+
})),
|
|
507
|
+
warnings: [
|
|
508
|
+
...plan.warnings
|
|
509
|
+
]
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const isObj = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
513
|
+
class Check {
|
|
514
|
+
issues = [];
|
|
515
|
+
add(path, message) {
|
|
516
|
+
this.issues.push({
|
|
517
|
+
path,
|
|
518
|
+
message
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
string(obj, key, at) {
|
|
522
|
+
if ('string' != typeof obj[key]) this.add(`${at}${key}`, 'must be text');
|
|
523
|
+
}
|
|
524
|
+
boolean(obj, key, at) {
|
|
525
|
+
if ('boolean' != typeof obj[key]) this.add(`${at}${key}`, 'must be true or false');
|
|
526
|
+
}
|
|
527
|
+
list(obj, key, at, each) {
|
|
528
|
+
const value = obj[key];
|
|
529
|
+
if (!Array.isArray(value)) return void this.add(`${at}${key}`, 'must be a list');
|
|
530
|
+
value.forEach((item, i)=>{
|
|
531
|
+
each(item, `${at}${key}[${String(i)}]`);
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
texts(obj, key, at) {
|
|
535
|
+
this.list(obj, key, at, (item, path)=>{
|
|
536
|
+
if ('string' != typeof item) this.add(path, 'must be text');
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
objects(obj, key, at, check) {
|
|
540
|
+
this.list(obj, key, at, (item, path)=>{
|
|
541
|
+
if (isObj(item)) check(item, `${path}.`);
|
|
542
|
+
else this.add(path, 'must be an object');
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function checkPlan(c, plan) {
|
|
547
|
+
if (!isObj(plan)) return void c.add('plan', 'must be an object');
|
|
548
|
+
const at = 'plan.';
|
|
549
|
+
c.objects(plan, 'features', at, (f, p)=>{
|
|
550
|
+
c.string(f, 'name', p);
|
|
551
|
+
c.string(f, 'version', p);
|
|
552
|
+
});
|
|
553
|
+
c.objects(plan, 'changes', at, (f, p)=>{
|
|
554
|
+
c.string(f, 'path', p);
|
|
555
|
+
if (![
|
|
556
|
+
'create',
|
|
557
|
+
'patch',
|
|
558
|
+
'delete'
|
|
559
|
+
].includes(String(f.action))) c.add(`${p}action`, 'must be create, patch or delete');
|
|
560
|
+
});
|
|
561
|
+
c.objects(plan, 'dependencies', at, (d, p)=>{
|
|
562
|
+
for (const key of [
|
|
563
|
+
'file',
|
|
564
|
+
'name',
|
|
565
|
+
'range'
|
|
566
|
+
])c.string(d, key, p);
|
|
567
|
+
c.boolean(d, 'dev', p);
|
|
568
|
+
});
|
|
569
|
+
c.objects(plan, "scripts", at, (s, p)=>{
|
|
570
|
+
for (const key of [
|
|
571
|
+
'file',
|
|
572
|
+
'name',
|
|
573
|
+
'command'
|
|
574
|
+
])c.string(s, key, p);
|
|
575
|
+
});
|
|
576
|
+
c.texts(plan, 'warnings', at);
|
|
577
|
+
}
|
|
578
|
+
function checkWrite(c, value) {
|
|
579
|
+
c.boolean(value, 'dryRun', '');
|
|
580
|
+
c.string(value, 'root', '');
|
|
581
|
+
checkPlan(c, value.plan);
|
|
582
|
+
c.texts(value, 'written', '');
|
|
583
|
+
c.boolean(value, 'installed', '');
|
|
584
|
+
if (void 0 !== value.diff && 'string' != typeof value.diff) c.add('diff', 'must be text');
|
|
585
|
+
if (true === value.dryRun && 'string' != typeof value.diff) c.add('diff', 'is required for a dry run');
|
|
586
|
+
if (true === value.dryRun && Array.isArray(value.written) && value.written.length > 0) c.add('written', 'must be empty for a dry run');
|
|
587
|
+
}
|
|
588
|
+
function checkReport(c, value) {
|
|
589
|
+
c.string(value, 'root', '');
|
|
590
|
+
const summary = value.summary;
|
|
591
|
+
if (isObj(summary)) {
|
|
592
|
+
for (const key of [
|
|
593
|
+
'errors',
|
|
594
|
+
'warnings',
|
|
595
|
+
'skipped',
|
|
596
|
+
'fixed'
|
|
597
|
+
])if ('number' != typeof summary[key]) c.add(`summary.${key}`, 'must be a number');
|
|
598
|
+
} else c.add('summary', 'must be an object');
|
|
599
|
+
c.objects(value, 'checks', '', (check, p)=>{
|
|
600
|
+
for (const key of [
|
|
601
|
+
'id',
|
|
602
|
+
'title'
|
|
603
|
+
])c.string(check, key, p);
|
|
604
|
+
if (![
|
|
605
|
+
'pass',
|
|
606
|
+
'warn',
|
|
607
|
+
'fail',
|
|
608
|
+
'skipped'
|
|
609
|
+
].includes(String(check.status))) c.add(`${p}status`, 'must be pass, warn, fail or skipped');
|
|
610
|
+
if ('number' != typeof check.findings) c.add(`${p}findings`, 'must be a number');
|
|
611
|
+
});
|
|
612
|
+
c.objects(value, 'findings', '', (f, p)=>{
|
|
613
|
+
for (const key of [
|
|
614
|
+
'code',
|
|
615
|
+
'check',
|
|
616
|
+
'message',
|
|
617
|
+
'fix',
|
|
618
|
+
'docs'
|
|
619
|
+
])c.string(f, key, p);
|
|
620
|
+
if ('error' !== f.severity && 'warning' !== f.severity) c.add(`${p}severity`, 'must be error or warning');
|
|
621
|
+
c.boolean(f, 'fixable', p);
|
|
622
|
+
});
|
|
623
|
+
if (value.ok !== (isObj(summary) && 0 === summary.errors)) c.add('ok', 'must be true exactly when there are no errors');
|
|
624
|
+
}
|
|
625
|
+
function checkBudget(c, value) {
|
|
626
|
+
c.string(value, 'root', '');
|
|
627
|
+
const model = value.model;
|
|
628
|
+
if (isObj(model)) {
|
|
629
|
+
for (const key of [
|
|
630
|
+
'lcpDeltaMs',
|
|
631
|
+
'shellKb',
|
|
632
|
+
'remoteKb'
|
|
633
|
+
])if ('number' != typeof model[key]) c.add(`model.${key}`, 'must be a number');
|
|
634
|
+
} else c.add('model', 'must be an object');
|
|
635
|
+
c.objects(value, 'shells', '', (s, p)=>{
|
|
636
|
+
c.string(s, 'name', p);
|
|
637
|
+
for (const key of [
|
|
638
|
+
'initialKb',
|
|
639
|
+
"scripts"
|
|
640
|
+
])if ('number' != typeof s[key]) c.add(`${p}${key}`, 'must be a number');
|
|
641
|
+
});
|
|
642
|
+
c.objects(value, 'remotes', '', (r, p)=>{
|
|
643
|
+
c.string(r, 'name', p);
|
|
644
|
+
c.boolean(r, 'pass', p);
|
|
645
|
+
for (const key of [
|
|
646
|
+
'entryKb',
|
|
647
|
+
'budgetKb'
|
|
648
|
+
])if ('number' != typeof r[key]) c.add(`${p}${key}`, 'must be a number');
|
|
649
|
+
c.objects(r, 'routes', p, (route, q)=>{
|
|
650
|
+
c.string(route, 'route', q);
|
|
651
|
+
if ('number' != typeof route.kb) c.add(`${q}kb`, 'must be a number');
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
c.objects(value, 'findings', '', (f, p)=>{
|
|
655
|
+
for (const key of [
|
|
656
|
+
'code',
|
|
657
|
+
'message',
|
|
658
|
+
'fix',
|
|
659
|
+
'docs'
|
|
660
|
+
])c.string(f, key, p);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
function validateResult(value) {
|
|
664
|
+
const c = new Check();
|
|
665
|
+
if (!isObj(value)) {
|
|
666
|
+
c.add('', 'must be an object');
|
|
667
|
+
return c.issues;
|
|
668
|
+
}
|
|
669
|
+
if (false === value.ok && void 0 === value.command) {
|
|
670
|
+
if (isObj(value.error)) for (const key of [
|
|
671
|
+
'code',
|
|
672
|
+
'message',
|
|
673
|
+
'fix',
|
|
674
|
+
'docs'
|
|
675
|
+
])c.string(value.error, key, 'error.');
|
|
676
|
+
else c.add('error', 'must be an object');
|
|
677
|
+
if ('number' != typeof value.exitCode) c.add('exitCode', 'must be a number');
|
|
678
|
+
return c.issues;
|
|
679
|
+
}
|
|
680
|
+
if ('boolean' != typeof value.ok) c.add('ok', 'must be true or false');
|
|
681
|
+
switch(value.command){
|
|
682
|
+
case 'init':
|
|
683
|
+
checkWrite(c, value);
|
|
684
|
+
c.string(value, 'scope', '');
|
|
685
|
+
c.texts(value, 'next', '');
|
|
686
|
+
c.objects(value, 'apps', '', (app, p)=>{
|
|
687
|
+
c.string(app, 'name', p);
|
|
688
|
+
c.string(app, 'path', p);
|
|
689
|
+
if ('shell' !== app.kind && 'remote' !== app.kind) c.add(`${p}kind`, 'must be shell or remote');
|
|
690
|
+
if (null !== app.port && 'number' != typeof app.port) c.add(`${p}port`, 'must be a number or null');
|
|
691
|
+
});
|
|
692
|
+
break;
|
|
693
|
+
case 'add':
|
|
694
|
+
checkWrite(c, value);
|
|
695
|
+
if (![
|
|
696
|
+
'remote',
|
|
697
|
+
'shell',
|
|
698
|
+
'feature',
|
|
699
|
+
'env-key'
|
|
700
|
+
].includes(String(value.kind))) c.add('kind', 'must be remote, shell, feature or env-key');
|
|
701
|
+
c.texts(value, 'added', '');
|
|
702
|
+
c.texts(value, 'skipped', '');
|
|
703
|
+
break;
|
|
704
|
+
case 'features':
|
|
705
|
+
if (null !== value.search && 'string' != typeof value.search) c.add('search', 'must be text or null');
|
|
706
|
+
c.objects(value, 'features', '', (f, p)=>{
|
|
707
|
+
for (const key of [
|
|
708
|
+
'name',
|
|
709
|
+
'version',
|
|
710
|
+
"description"
|
|
711
|
+
])c.string(f, key, p);
|
|
712
|
+
for (const key of [
|
|
713
|
+
'always',
|
|
714
|
+
'default',
|
|
715
|
+
'installed'
|
|
716
|
+
])c.boolean(f, key, p);
|
|
717
|
+
});
|
|
718
|
+
break;
|
|
719
|
+
case 'doctor':
|
|
720
|
+
case 'validate':
|
|
721
|
+
case 'check-compat':
|
|
722
|
+
checkReport(c, value);
|
|
723
|
+
break;
|
|
724
|
+
case 'budget':
|
|
725
|
+
checkBudget(c, value);
|
|
726
|
+
break;
|
|
727
|
+
case 'dev':
|
|
728
|
+
c.string(value, 'root', '');
|
|
729
|
+
c.string(value, 'registry', '');
|
|
730
|
+
c.boolean(value, 'dryRun', '');
|
|
731
|
+
c.boolean(value, 'interrupted', '');
|
|
732
|
+
c.objects(value, 'apps', '', (app, p)=>{
|
|
733
|
+
c.string(app, 'name', p);
|
|
734
|
+
c.string(app, 'command', p);
|
|
735
|
+
if ('started' !== app.status && 'overridden' !== app.status) c.add(`${p}status`, 'must be started or overridden');
|
|
736
|
+
});
|
|
737
|
+
if (!isObj(value.overrides)) c.add('overrides', 'must be an object');
|
|
738
|
+
break;
|
|
739
|
+
default:
|
|
740
|
+
c.add('command', 'must be init, add, features, doctor, validate, check-compat, budget or dev');
|
|
741
|
+
}
|
|
742
|
+
return c.issues;
|
|
743
|
+
}
|
|
744
|
+
function exitOf(result) {
|
|
745
|
+
if ('findings' in result) return exitForFindings(result.findings);
|
|
746
|
+
if (result.ok && 'interrupted' in result && result.interrupted) return EXIT.cancelled;
|
|
747
|
+
return result.ok ? EXIT.ok : EXIT.unexpected;
|
|
748
|
+
}
|
|
749
|
+
const PLACEHOLDER_RANGE = 'latest';
|
|
750
|
+
const PREFIX = '@mfeflow/';
|
|
751
|
+
const CLI = 'mfeflow';
|
|
752
|
+
const isOwn = (name)=>name === CLI || name.startsWith(PREFIX);
|
|
753
|
+
function posix(file) {
|
|
754
|
+
return file.split(node_path.sep).join('/');
|
|
755
|
+
}
|
|
756
|
+
function realpathLoose(file) {
|
|
757
|
+
try {
|
|
758
|
+
return node_fs.realpathSync.native(file);
|
|
759
|
+
} catch {
|
|
760
|
+
const parent = node_path.dirname(file);
|
|
761
|
+
return parent === file ? file : node_path.join(realpathLoose(parent), node_path.basename(file));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function readName(dir) {
|
|
765
|
+
const file = node_path.join(dir, 'package.json');
|
|
766
|
+
if (!node_fs.existsSync(file)) return;
|
|
767
|
+
const pkg = JSON.parse(node_fs.readFileSync(file, 'utf8'));
|
|
768
|
+
if ('object' != typeof pkg || null === pkg || !('name' in pkg) || 'string' != typeof pkg.name) return;
|
|
769
|
+
const needsBuild = JSON.stringify('exports' in pkg ? pkg.exports : '').includes('./dist/');
|
|
770
|
+
return {
|
|
771
|
+
name: pkg.name,
|
|
772
|
+
built: !needsBuild || node_fs.existsSync(node_path.join(dir, 'dist'))
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function scanCheckout(checkout) {
|
|
776
|
+
const packages = node_path.join(checkout, 'packages');
|
|
777
|
+
if (!node_fs.existsSync(packages)) throw new MfeflowError('E110', `${checkout} has no packages/ folder`);
|
|
778
|
+
const found = new Map();
|
|
779
|
+
for (const entry of node_fs.readdirSync(packages, {
|
|
780
|
+
withFileTypes: true
|
|
781
|
+
})){
|
|
782
|
+
if (!entry.isDirectory()) continue;
|
|
783
|
+
const dir = node_path.join(packages, entry.name);
|
|
784
|
+
const info = readName(dir);
|
|
785
|
+
if (info && isOwn(info.name)) found.set(info.name, {
|
|
786
|
+
dir,
|
|
787
|
+
built: info.built
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
return found;
|
|
791
|
+
}
|
|
792
|
+
function createRangeRewriter(version, local) {
|
|
793
|
+
const packages = void 0 === local ? void 0 : scanCheckout(node_path.resolve(local));
|
|
794
|
+
if (packages) {
|
|
795
|
+
const unbuilt = [
|
|
796
|
+
...packages
|
|
797
|
+
].filter(([, p])=>!p.built).map(([name])=>name);
|
|
798
|
+
if (unbuilt.length > 0) throw new MfeflowError('E110', `${unbuilt.join(', ')} ${unbuilt.length > 1 ? 'have' : 'has'} no dist/ yet`);
|
|
799
|
+
}
|
|
800
|
+
return (name, range)=>{
|
|
801
|
+
if (!isOwn(name) || range !== PLACEHOLDER_RANGE) return;
|
|
802
|
+
if (!packages) return `^${version}`;
|
|
803
|
+
const found = packages.get(name);
|
|
804
|
+
if (!found) throw new MfeflowError('E110', `${name} is not in ${local ?? ''}/packages`);
|
|
805
|
+
return `link:${posix(found.dir)}`;
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const LOCAL_NONE = 'none';
|
|
809
|
+
function chooseLocal(project, root, cwd, flag) {
|
|
810
|
+
const { local: recorded, ...rest } = project;
|
|
811
|
+
const realRoot = realpathLoose(root);
|
|
812
|
+
if (flag === LOCAL_NONE) return {
|
|
813
|
+
checkout: void 0,
|
|
814
|
+
project: rest
|
|
815
|
+
};
|
|
816
|
+
if (void 0 === flag) return {
|
|
817
|
+
checkout: void 0 === recorded ? void 0 : node_path.resolve(realRoot, recorded),
|
|
818
|
+
project
|
|
819
|
+
};
|
|
820
|
+
const checkout = realpathLoose(node_path.resolve(cwd, flag));
|
|
821
|
+
const relative = node_path.relative(realRoot, checkout);
|
|
822
|
+
if ('' === relative) throw new MfeflowError('E107', `--local ${flag} is the project itself`);
|
|
823
|
+
const local = posix(node_path.isAbsolute(relative) ? checkout : relative);
|
|
824
|
+
return {
|
|
825
|
+
checkout,
|
|
826
|
+
project: {
|
|
827
|
+
...rest,
|
|
828
|
+
local
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function isPackageJson(value) {
|
|
833
|
+
return 'object' == typeof value && null !== value && 'name' in value && 'version' in value && 'string' == typeof value.name && 'string' == typeof value.version;
|
|
834
|
+
}
|
|
835
|
+
function readPackageJson() {
|
|
836
|
+
const require = createRequire(import.meta.url);
|
|
837
|
+
const pkg = require('../package.json');
|
|
838
|
+
if (!isPackageJson(pkg)) throw new Error('package.json is missing a name or version');
|
|
839
|
+
return pkg;
|
|
840
|
+
}
|
|
841
|
+
function projectReader(root) {
|
|
842
|
+
return async (file)=>{
|
|
843
|
+
try {
|
|
844
|
+
return await promises.readFile(node_path.join(root, ...file.split('/')), 'utf8');
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (hasErrno(error, 'ENOENT') || hasErrno(error, 'ENOTDIR')) return;
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function createBuilder(root, checkout) {
|
|
852
|
+
return new PlanBuilder(projectReader(root), {
|
|
853
|
+
catalog: true,
|
|
854
|
+
rewriteRange: createRangeRewriter(readPackageJson().version, checkout)
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
function withProjectChange(plan, project) {
|
|
858
|
+
const before = plan.projectBefore ?? null;
|
|
859
|
+
const after = serializeProject(recordPlan(project, plan));
|
|
860
|
+
if (before === after) return plan;
|
|
861
|
+
return {
|
|
862
|
+
...plan,
|
|
863
|
+
changes: [
|
|
864
|
+
...plan.changes,
|
|
865
|
+
{
|
|
866
|
+
path: 'mfeflow.json',
|
|
867
|
+
action: null === before ? 'create' : 'patch',
|
|
868
|
+
before,
|
|
869
|
+
after,
|
|
870
|
+
track: false,
|
|
871
|
+
trust: 'first-party'
|
|
872
|
+
}
|
|
873
|
+
]
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
async function missingFolders(root) {
|
|
877
|
+
const missing = [];
|
|
878
|
+
let dir = root;
|
|
879
|
+
while(!await write_exists(dir)){
|
|
880
|
+
missing.push(dir);
|
|
881
|
+
const parent = node_path.dirname(dir);
|
|
882
|
+
if (parent === dir) break;
|
|
883
|
+
dir = parent;
|
|
884
|
+
}
|
|
885
|
+
return missing;
|
|
886
|
+
}
|
|
887
|
+
async function removeCreated(missing) {
|
|
888
|
+
const [root, ...parents] = missing;
|
|
889
|
+
if (void 0 === root) return;
|
|
890
|
+
try {
|
|
891
|
+
await promises.rm(root, {
|
|
892
|
+
recursive: true,
|
|
893
|
+
force: true
|
|
894
|
+
});
|
|
895
|
+
} catch (error) {
|
|
896
|
+
return messageOf(error);
|
|
897
|
+
}
|
|
898
|
+
for (const dir of parents)try {
|
|
899
|
+
await promises.rmdir(dir);
|
|
900
|
+
} catch {
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
async function removeContents(root) {
|
|
905
|
+
try {
|
|
906
|
+
const entries = await promises.readdir(root);
|
|
907
|
+
await Promise.all(entries.map((entry)=>promises.rm(node_path.join(root, entry), {
|
|
908
|
+
recursive: true,
|
|
909
|
+
force: true
|
|
910
|
+
})));
|
|
911
|
+
} catch (error) {
|
|
912
|
+
return messageOf(error);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function isEmptyFolder(dir) {
|
|
916
|
+
try {
|
|
917
|
+
return 0 === (await promises.readdir(dir)).length;
|
|
918
|
+
} catch {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function installFailed(error, root, stuck, emptied) {
|
|
923
|
+
const reason = messageOf(error.cause ?? error);
|
|
924
|
+
if (void 0 !== stuck) return new MfeflowError('E213', `${reason}; could not clean up ${root} (${stuck})`, {
|
|
925
|
+
cause: error.cause,
|
|
926
|
+
fix: `Delete what is inside ${root} by hand, check your network and package manager, then run the command again.`
|
|
927
|
+
});
|
|
928
|
+
return new MfeflowError('E213', `${reason}; ${emptied ? 'emptied the folder' : 'removed the new folder'} ${root}`, {
|
|
929
|
+
cause: error.cause,
|
|
930
|
+
fix: 'Nothing was left behind. Check your network and package manager, then run the command again.'
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
async function write_exists(dir) {
|
|
934
|
+
try {
|
|
935
|
+
await promises.access(dir);
|
|
936
|
+
return true;
|
|
937
|
+
} catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function writePlan(request) {
|
|
942
|
+
const { rt, globals, root, plan, project } = request;
|
|
943
|
+
const shown = withProjectChange(plan, project);
|
|
944
|
+
if (!globals.json) {
|
|
945
|
+
rt.io.stdout(summarize(shown));
|
|
946
|
+
if (globals.dryRun || globals.verbose) rt.io.stdout(`\n${renderDiff(shown)}`);
|
|
947
|
+
}
|
|
948
|
+
if (globals.dryRun) {
|
|
949
|
+
if (await write_exists(root)) await applyPlan(plan, {
|
|
950
|
+
root,
|
|
951
|
+
project,
|
|
952
|
+
dryRun: true
|
|
953
|
+
});
|
|
954
|
+
return {
|
|
955
|
+
dryRun: true,
|
|
956
|
+
shown,
|
|
957
|
+
written: [],
|
|
958
|
+
installed: false,
|
|
959
|
+
diff: renderDiff(shown)
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
if (globals.canPrompt && !await rt.prompter.confirm({
|
|
963
|
+
message: 'Write these changes?'
|
|
964
|
+
})) throw new Cancelled();
|
|
965
|
+
const installing = globals.install && plan.dependencies.length > 0;
|
|
966
|
+
const missing = await missingFolders(root);
|
|
967
|
+
const wasEmpty = 0 === missing.length && await isEmptyFolder(root);
|
|
968
|
+
const gitDir = node_path.join(root, '.git');
|
|
969
|
+
let madeGit = false;
|
|
970
|
+
await promises.mkdir(root, {
|
|
971
|
+
recursive: true
|
|
972
|
+
});
|
|
973
|
+
let result;
|
|
974
|
+
try {
|
|
975
|
+
result = await applyPlan(plan, {
|
|
976
|
+
root,
|
|
977
|
+
project,
|
|
978
|
+
...installing ? {
|
|
979
|
+
install: async ()=>{
|
|
980
|
+
madeGit = void 0 !== request.afterWrite && !await write_exists(gitDir);
|
|
981
|
+
await request.afterWrite?.();
|
|
982
|
+
await rt.system.install(root, project.packageManager, globals.verbose);
|
|
983
|
+
}
|
|
984
|
+
} : {}
|
|
985
|
+
});
|
|
986
|
+
} catch (error) {
|
|
987
|
+
if (madeGit) await promises.rm(gitDir, {
|
|
988
|
+
recursive: true,
|
|
989
|
+
force: true
|
|
990
|
+
}).catch(()=>void 0);
|
|
991
|
+
const stuck = wasEmpty ? await removeContents(root) : await removeCreated(missing);
|
|
992
|
+
const cleaned = missing.length > 0 || wasEmpty;
|
|
993
|
+
if (cleaned && isMfeflowError(error) && 'MFEFLOW_E213' === error.code) throw installFailed(error, root, stuck, wasEmpty);
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
if (!installing) await request.afterWrite?.();
|
|
997
|
+
return {
|
|
998
|
+
dryRun: false,
|
|
999
|
+
shown,
|
|
1000
|
+
written: result.written,
|
|
1001
|
+
installed: installing
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1005
|
+
const GROUPS = [
|
|
1006
|
+
'root',
|
|
1007
|
+
'shell',
|
|
1008
|
+
'remotes'
|
|
1009
|
+
];
|
|
1010
|
+
function groupOf(value) {
|
|
1011
|
+
if (void 0 === value) return;
|
|
1012
|
+
const group = GROUPS.find((g)=>g === value);
|
|
1013
|
+
if (void 0 === group) throw new MfeflowError('E107', '--group must be root, shell or remotes');
|
|
1014
|
+
return group;
|
|
1015
|
+
}
|
|
1016
|
+
const exactApp = (name)=>`app:${name}`;
|
|
1017
|
+
function serverTargets(project, app, group) {
|
|
1018
|
+
if (void 0 !== app) {
|
|
1019
|
+
if (!(app in project.apps)) throw new MfeflowError('E107', `--app ${app} is not an app (${Object.keys(project.apps).join(', ')})`);
|
|
1020
|
+
return [
|
|
1021
|
+
exactApp(app)
|
|
1022
|
+
];
|
|
1023
|
+
}
|
|
1024
|
+
const kinds = new Set(Object.values(project.apps).map((a)=>a.kind));
|
|
1025
|
+
if (void 0 === group) {
|
|
1026
|
+
const targets = [
|
|
1027
|
+
...kinds.has('shell') ? [
|
|
1028
|
+
'shell'
|
|
1029
|
+
] : [],
|
|
1030
|
+
...kinds.has('remote') ? [
|
|
1031
|
+
'remotes'
|
|
1032
|
+
] : []
|
|
1033
|
+
];
|
|
1034
|
+
if (0 === targets.length) throw new MfeflowError('E107', 'the project has no app to add a key to');
|
|
1035
|
+
return targets;
|
|
1036
|
+
}
|
|
1037
|
+
if ('shell' === group && !kinds.has('shell')) throw new MfeflowError('E107', 'the project has no shell to add a key to');
|
|
1038
|
+
if ('remotes' === group && !kinds.has('remote')) throw new MfeflowError('E107', 'the project has no remotes to add a key to');
|
|
1039
|
+
return [
|
|
1040
|
+
group
|
|
1041
|
+
];
|
|
1042
|
+
}
|
|
1043
|
+
function runtimeTargets(project, app, group) {
|
|
1044
|
+
const shells = Object.entries(project.apps).filter(([, a])=>'shell' === a.kind).map(([name, a])=>({
|
|
1045
|
+
name,
|
|
1046
|
+
path: a.path
|
|
1047
|
+
}));
|
|
1048
|
+
if (void 0 !== app) {
|
|
1049
|
+
const shell = shells.find((s)=>s.name === app);
|
|
1050
|
+
if (void 0 === shell) throw new MfeflowError('E107', `--app ${app} is not a shell; runtime config lives in a shell (${shells.map((s)=>s.name).join(', ')})`);
|
|
1051
|
+
return [
|
|
1052
|
+
{
|
|
1053
|
+
target: exactApp(app),
|
|
1054
|
+
path: shell.path
|
|
1055
|
+
}
|
|
1056
|
+
];
|
|
1057
|
+
}
|
|
1058
|
+
if (void 0 !== group && 'shell' !== group) throw new MfeflowError('E107', `--group ${group} has no runtime config; it lives in a shell`);
|
|
1059
|
+
return shells.map((s)=>({
|
|
1060
|
+
target: exactApp(s.name),
|
|
1061
|
+
path: s.path
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
async function configFilesOf(root, dir) {
|
|
1065
|
+
const folder = node_path.join(root, ...dir.split('/'), 'public', 'config');
|
|
1066
|
+
return (await runtimeConfigFiles(folder)).map((name)=>`public/config/${name}`);
|
|
1067
|
+
}
|
|
1068
|
+
function envKeyOp(input) {
|
|
1069
|
+
const { target, key, scope, schema, value, configFiles } = input;
|
|
1070
|
+
const op = {
|
|
1071
|
+
op: 'add-env-key',
|
|
1072
|
+
file: 'server' === scope ? 'env.ts' : 'src/config/schema.ts',
|
|
1073
|
+
target,
|
|
1074
|
+
key,
|
|
1075
|
+
scope,
|
|
1076
|
+
schema
|
|
1077
|
+
};
|
|
1078
|
+
if (void 0 !== value) op.value = value;
|
|
1079
|
+
if ('runtime' === scope) op.configFiles = configFiles;
|
|
1080
|
+
return op;
|
|
1081
|
+
}
|
|
1082
|
+
async function addEnvKey(rt, globals, args, key) {
|
|
1083
|
+
const scope = args_text(args, 'scope');
|
|
1084
|
+
const schema = args_text(args, 'schema');
|
|
1085
|
+
if (void 0 === key || !KEY.test(key)) throw new MfeflowError('E107', 'add env-key needs a key like API_ORIGIN: mfeflow add env-key <KEY>');
|
|
1086
|
+
if ('server' !== scope && 'runtime' !== scope) throw new MfeflowError('E107', '--scope must be server or runtime');
|
|
1087
|
+
if (void 0 === schema) throw new MfeflowError('E107', '--schema is required, for example --schema "z.url()"');
|
|
1088
|
+
const root = await findProjectRoot(globals.cwd);
|
|
1089
|
+
const chosen = chooseLocal(await loadProject(root), root, globals.cwd, args_text(args, 'local'));
|
|
1090
|
+
const { project } = chosen;
|
|
1091
|
+
const installed = project.features['env-t3'];
|
|
1092
|
+
if (void 0 === installed) throw new MfeflowError('E206', 'add env-key needs env-t3; run `mfeflow add env-t3` first');
|
|
1093
|
+
const value = args_text(args, 'value');
|
|
1094
|
+
const app = args_text(args, 'app');
|
|
1095
|
+
const group = groupOf(args_text(args, 'group'));
|
|
1096
|
+
if (void 0 !== app && void 0 !== group) throw new MfeflowError('E107', '--app and --group cannot be used together');
|
|
1097
|
+
const targets = 'server' === scope ? serverTargets(project, app, group).map((target)=>({
|
|
1098
|
+
target,
|
|
1099
|
+
path: ''
|
|
1100
|
+
})) : runtimeTargets(project, app, group);
|
|
1101
|
+
const transforms = await Promise.all(targets.map(async ({ target, path: dir })=>envKeyOp({
|
|
1102
|
+
target,
|
|
1103
|
+
key,
|
|
1104
|
+
scope,
|
|
1105
|
+
schema,
|
|
1106
|
+
value,
|
|
1107
|
+
configFiles: 'runtime' === scope ? await configFilesOf(root, dir) : []
|
|
1108
|
+
})));
|
|
1109
|
+
const builder = createBuilder(root, chosen.checkout);
|
|
1110
|
+
await resolveFeature({
|
|
1111
|
+
feature: {
|
|
1112
|
+
manifest: {
|
|
1113
|
+
name: 'env-t3',
|
|
1114
|
+
version: installed.version,
|
|
1115
|
+
transforms
|
|
1116
|
+
},
|
|
1117
|
+
trust: 'first-party'
|
|
1118
|
+
},
|
|
1119
|
+
project,
|
|
1120
|
+
builder
|
|
1121
|
+
});
|
|
1122
|
+
const plan = {
|
|
1123
|
+
...builder.build(),
|
|
1124
|
+
features: []
|
|
1125
|
+
};
|
|
1126
|
+
const outcome = await writePlan({
|
|
1127
|
+
rt,
|
|
1128
|
+
globals: {
|
|
1129
|
+
...globals,
|
|
1130
|
+
install: false
|
|
1131
|
+
},
|
|
1132
|
+
root,
|
|
1133
|
+
plan,
|
|
1134
|
+
project
|
|
1135
|
+
});
|
|
1136
|
+
const result = {
|
|
1137
|
+
ok: true,
|
|
1138
|
+
command: 'add',
|
|
1139
|
+
kind: 'env-key',
|
|
1140
|
+
dryRun: outcome.dryRun,
|
|
1141
|
+
root,
|
|
1142
|
+
added: 0 === plan.changes.length ? [] : [
|
|
1143
|
+
key
|
|
1144
|
+
],
|
|
1145
|
+
skipped: 0 === plan.changes.length ? [
|
|
1146
|
+
key
|
|
1147
|
+
] : [],
|
|
1148
|
+
plan: planReport(outcome.shown),
|
|
1149
|
+
written: outcome.written,
|
|
1150
|
+
installed: false,
|
|
1151
|
+
...void 0 === outcome.diff ? {} : {
|
|
1152
|
+
diff: outcome.diff
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
if (!globals.json) if (outcome.dryRun) rt.io.stdout('\nDry run: nothing was written.');
|
|
1156
|
+
else rt.io.stdout(0 === plan.changes.length ? `${key} is already in the ${scope} schema; nothing to do.` : `Added ${key} to the ${scope} schema${'runtime' === scope ? ' and the runtime-config files' : ' and .env.example'}.`);
|
|
1157
|
+
return result;
|
|
1158
|
+
}
|
|
1159
|
+
const MAX_BYTES = 100000;
|
|
1160
|
+
const KEYS = new Set([
|
|
1161
|
+
'$schema',
|
|
1162
|
+
'scope',
|
|
1163
|
+
'remotes',
|
|
1164
|
+
'features',
|
|
1165
|
+
'packageManager',
|
|
1166
|
+
'topology',
|
|
1167
|
+
'options'
|
|
1168
|
+
]);
|
|
1169
|
+
function bad(source, why) {
|
|
1170
|
+
throw new MfeflowError('E109', `${source}: ${why}`);
|
|
1171
|
+
}
|
|
1172
|
+
function isRecord(value) {
|
|
1173
|
+
return 'object' == typeof value && null !== value && !Array.isArray(value);
|
|
1174
|
+
}
|
|
1175
|
+
function stringList(source, key, value) {
|
|
1176
|
+
if (!Array.isArray(value) || !value.every((item)=>'string' == typeof item)) bad(source, `${key} must be a list of text`);
|
|
1177
|
+
return value;
|
|
1178
|
+
}
|
|
1179
|
+
function optionsOf(source, value) {
|
|
1180
|
+
if (!isRecord(value)) bad(source, 'options must be an object of feature names');
|
|
1181
|
+
const out = {};
|
|
1182
|
+
for (const [feature, given] of Object.entries(value)){
|
|
1183
|
+
if (!isRecord(given)) bad(source, `options.${feature} must be an object`);
|
|
1184
|
+
out[feature] = given;
|
|
1185
|
+
}
|
|
1186
|
+
return out;
|
|
1187
|
+
}
|
|
1188
|
+
function parsePreset(source, value) {
|
|
1189
|
+
if (!isRecord(value)) bad(source, 'must be a JSON object');
|
|
1190
|
+
const preset = {};
|
|
1191
|
+
for (const [key, item] of Object.entries(value)){
|
|
1192
|
+
if (!KEYS.has(key)) bad(source, `"${key}" is not a preset option`);
|
|
1193
|
+
if ('scope' === key || 'packageManager' === key || 'topology' === key) {
|
|
1194
|
+
if ('string' != typeof item) bad(source, `${key} must be text`);
|
|
1195
|
+
preset[key] = item;
|
|
1196
|
+
} else if ('remotes' === key || 'features' === key) preset[key] = stringList(source, key, item);
|
|
1197
|
+
else if ('options' === key) preset.options = optionsOf(source, item);
|
|
1198
|
+
}
|
|
1199
|
+
return preset;
|
|
1200
|
+
}
|
|
1201
|
+
async function readUrl(url, fetchImpl) {
|
|
1202
|
+
const response = await getHttps(url, fetchImpl, 'E109');
|
|
1203
|
+
if (!response.ok) bad(url, `answered ${String(response.status)}`);
|
|
1204
|
+
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E109', `${url}: is larger than 100 KB`));
|
|
1205
|
+
}
|
|
1206
|
+
async function loadPreset(source, sources) {
|
|
1207
|
+
let text;
|
|
1208
|
+
if (/^https?:\/\//i.test(source)) {
|
|
1209
|
+
if (!source.toLowerCase().startsWith('https://')) bad(source, 'must be https');
|
|
1210
|
+
text = await readUrl(source, sources.fetch);
|
|
1211
|
+
} else {
|
|
1212
|
+
const file = node_path.resolve(sources.cwd, source);
|
|
1213
|
+
try {
|
|
1214
|
+
text = await (sources.readFile ?? ((f)=>promises.readFile(f, 'utf8')))(file);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
throw new MfeflowError('E109', `${file}: ${messageOf(error)}`, {
|
|
1217
|
+
cause: error
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
let value;
|
|
1222
|
+
try {
|
|
1223
|
+
value = JSON.parse(text);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
throw new MfeflowError('E109', `${source}: not valid JSON (${messageOf(error)})`, {
|
|
1226
|
+
cause: error
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return parsePreset(source, value);
|
|
1230
|
+
}
|
|
1231
|
+
const ADD_ARGS = {
|
|
1232
|
+
port: {
|
|
1233
|
+
type: 'string',
|
|
1234
|
+
description: 'Dev server port for a new app (default: the next free one)',
|
|
1235
|
+
valueHint: 'n'
|
|
1236
|
+
},
|
|
1237
|
+
option: {
|
|
1238
|
+
type: 'string',
|
|
1239
|
+
description: 'A feature option, repeatable: --option feature.key=value',
|
|
1240
|
+
valueHint: 'f.k=v'
|
|
1241
|
+
},
|
|
1242
|
+
scope: {
|
|
1243
|
+
type: 'string',
|
|
1244
|
+
description: 'add env-key: server (env.ts, .env.example) or runtime (runtime-config files)',
|
|
1245
|
+
valueHint: 'server|runtime'
|
|
1246
|
+
},
|
|
1247
|
+
schema: {
|
|
1248
|
+
type: 'string',
|
|
1249
|
+
description: 'add env-key: the validator expression, like "z.url()"',
|
|
1250
|
+
valueHint: 'expr'
|
|
1251
|
+
},
|
|
1252
|
+
value: {
|
|
1253
|
+
type: 'string',
|
|
1254
|
+
description: 'add env-key: the placeholder written to .env.example or the config files',
|
|
1255
|
+
valueHint: 'text'
|
|
1256
|
+
},
|
|
1257
|
+
app: {
|
|
1258
|
+
type: 'string',
|
|
1259
|
+
description: 'add env-key: exactly this app (default: every app; runtime: every shell)',
|
|
1260
|
+
valueHint: 'name'
|
|
1261
|
+
},
|
|
1262
|
+
group: {
|
|
1263
|
+
type: 'string',
|
|
1264
|
+
description: 'add env-key: root, every shell or every remote, instead of --app',
|
|
1265
|
+
valueHint: 'root|shell|remotes'
|
|
1266
|
+
},
|
|
1267
|
+
...GLOBAL_ARGS
|
|
1268
|
+
};
|
|
1269
|
+
function portFor(args, project, start) {
|
|
1270
|
+
const given = args.port;
|
|
1271
|
+
if ('string' != typeof given || '' === given) return freePort(project, start);
|
|
1272
|
+
const port = Number(given);
|
|
1273
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new MfeflowError('E107', `--port "${given}" must be a whole number from 1 to 65535`);
|
|
1274
|
+
const owner = Object.entries(project.apps).find(([, app])=>app.port === port);
|
|
1275
|
+
if (owner) throw new MfeflowError('E107', `--port ${given} is already used by ${owner[0]}`);
|
|
1276
|
+
return port;
|
|
1277
|
+
}
|
|
1278
|
+
async function nameFrom(rt, globals, given, what) {
|
|
1279
|
+
if (void 0 !== given) return given;
|
|
1280
|
+
if (globals.canPrompt) return rt.prompter.text({
|
|
1281
|
+
message: `Name of the new ${what}`
|
|
1282
|
+
});
|
|
1283
|
+
throw new MfeflowError('E107', `add ${what} needs a name: mfeflow add ${what} <name>`);
|
|
1284
|
+
}
|
|
1285
|
+
async function addAppParts(rt, globals, builder, project, next, kind, name) {
|
|
1286
|
+
const names = [
|
|
1287
|
+
'core',
|
|
1288
|
+
...Object.keys(project.features).filter((n)=>'core' !== n)
|
|
1289
|
+
];
|
|
1290
|
+
const { canPrompt } = globals;
|
|
1291
|
+
const loadedAll = await Promise.all(names.map((feature)=>loadAny(feature, {
|
|
1292
|
+
roots: rt.roots,
|
|
1293
|
+
registries: project.registries,
|
|
1294
|
+
fetch: rt.fetch,
|
|
1295
|
+
version: project.features[feature]?.version
|
|
1296
|
+
})));
|
|
1297
|
+
let core;
|
|
1298
|
+
const fromRegistry = [];
|
|
1299
|
+
const planned = [];
|
|
1300
|
+
names.forEach((feature, index)=>{
|
|
1301
|
+
const loaded = loadedAll[index];
|
|
1302
|
+
if (void 0 === loaded) return;
|
|
1303
|
+
if ('core' === feature) core = loaded;
|
|
1304
|
+
const recorded = project.features[feature]?.version;
|
|
1305
|
+
const parts = partsForApp(loaded, kind, name);
|
|
1306
|
+
if (!parts) return;
|
|
1307
|
+
if ('third-party' === loaded.trust) {
|
|
1308
|
+
if (void 0 !== recorded && loaded.manifest.version !== recorded) throw new MfeflowError('E401', `${feature} is installed at ${recorded} but the registry now serves ${loaded.manifest.version}`, {
|
|
1309
|
+
fix: `Nothing was written. Use a registry URL with {version} so ${recorded} can be fetched, or add the app by hand.`
|
|
1310
|
+
});
|
|
1311
|
+
fromRegistry.push(feature);
|
|
1312
|
+
}
|
|
1313
|
+
planned.push({
|
|
1314
|
+
feature,
|
|
1315
|
+
parts
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
assertThirdPartyAllowed(fromRegistry, globals, canPrompt);
|
|
1319
|
+
await inOrder(planned, ({ feature, parts })=>resolveFeature({
|
|
1320
|
+
feature: parts,
|
|
1321
|
+
project: next,
|
|
1322
|
+
builder,
|
|
1323
|
+
options: project.features[feature]?.options ?? {}
|
|
1324
|
+
}));
|
|
1325
|
+
if (void 0 === core) throw new MfeflowError('E201', 'core');
|
|
1326
|
+
return core;
|
|
1327
|
+
}
|
|
1328
|
+
async function addApp(rt, globals, args, kind, given) {
|
|
1329
|
+
const root = await findProjectRoot(globals.cwd);
|
|
1330
|
+
const chosen = chooseLocal(await loadProject(root), root, globals.cwd, args_text(args, 'local'));
|
|
1331
|
+
const { project } = chosen;
|
|
1332
|
+
const name = await nameFrom(rt, globals, given, kind);
|
|
1333
|
+
assertAppName(name, project.apps);
|
|
1334
|
+
const port = portFor(args, project, 'remote' === kind ? 4311 : 4300);
|
|
1335
|
+
const next = {
|
|
1336
|
+
...project,
|
|
1337
|
+
apps: {
|
|
1338
|
+
...project.apps,
|
|
1339
|
+
[name]: {
|
|
1340
|
+
kind,
|
|
1341
|
+
path: `apps/${name}`,
|
|
1342
|
+
port
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
const builder = createBuilder(root, chosen.checkout);
|
|
1347
|
+
const core = await addAppParts(rt, globals, builder, project, next, 'remote' === kind ? 'remotes' : 'shell', name);
|
|
1348
|
+
const version = core.manifest.version;
|
|
1349
|
+
if ('remote' === kind) await resolveFeature({
|
|
1350
|
+
feature: await remoteGlue({
|
|
1351
|
+
roots: rt.roots,
|
|
1352
|
+
version,
|
|
1353
|
+
remote: {
|
|
1354
|
+
name,
|
|
1355
|
+
port
|
|
1356
|
+
},
|
|
1357
|
+
registry: true,
|
|
1358
|
+
shells: [
|
|
1359
|
+
'shell'
|
|
1360
|
+
]
|
|
1361
|
+
}),
|
|
1362
|
+
project: next,
|
|
1363
|
+
builder
|
|
1364
|
+
});
|
|
1365
|
+
else await inOrder(Object.entries(project.apps), async ([remote, app])=>{
|
|
1366
|
+
if ('remote' !== app.kind || void 0 === app.port) return;
|
|
1367
|
+
await resolveFeature({
|
|
1368
|
+
feature: await remoteGlue({
|
|
1369
|
+
roots: rt.roots,
|
|
1370
|
+
version,
|
|
1371
|
+
remote: {
|
|
1372
|
+
name: remote,
|
|
1373
|
+
port: app.port
|
|
1374
|
+
},
|
|
1375
|
+
registry: false,
|
|
1376
|
+
shells: [
|
|
1377
|
+
name
|
|
1378
|
+
]
|
|
1379
|
+
}),
|
|
1380
|
+
project: next,
|
|
1381
|
+
builder
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
const plan = {
|
|
1385
|
+
...builder.build(),
|
|
1386
|
+
features: []
|
|
1387
|
+
};
|
|
1388
|
+
const outcome = await writePlan({
|
|
1389
|
+
rt,
|
|
1390
|
+
globals,
|
|
1391
|
+
root,
|
|
1392
|
+
plan,
|
|
1393
|
+
project: next
|
|
1394
|
+
});
|
|
1395
|
+
return finish(rt, globals, root, kind, [
|
|
1396
|
+
name
|
|
1397
|
+
], [], outcome);
|
|
1398
|
+
}
|
|
1399
|
+
function finish(rt, globals, root, kind, added, skipped, outcome) {
|
|
1400
|
+
const result = {
|
|
1401
|
+
ok: true,
|
|
1402
|
+
command: 'add',
|
|
1403
|
+
kind,
|
|
1404
|
+
dryRun: outcome.dryRun,
|
|
1405
|
+
root,
|
|
1406
|
+
added,
|
|
1407
|
+
skipped,
|
|
1408
|
+
plan: planReport(outcome.shown),
|
|
1409
|
+
written: outcome.written,
|
|
1410
|
+
installed: outcome.installed,
|
|
1411
|
+
...void 0 === outcome.diff ? {} : {
|
|
1412
|
+
diff: outcome.diff
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
if (!globals.json) if (outcome.dryRun) rt.io.stdout('\nDry run: nothing was written.');
|
|
1416
|
+
else rt.io.stdout(`\nAdded ${added.join(', ')}${'feature' === kind ? '' : ` (${kind})`}.`);
|
|
1417
|
+
return result;
|
|
1418
|
+
}
|
|
1419
|
+
async function askOptions(rt, feature, given, canPrompt) {
|
|
1420
|
+
const answers = {
|
|
1421
|
+
...given
|
|
1422
|
+
};
|
|
1423
|
+
if (!canPrompt) return answers;
|
|
1424
|
+
await inOrder(feature.manifest.prompts ?? [], async (prompt)=>{
|
|
1425
|
+
if (prompt.name in answers) return;
|
|
1426
|
+
const message = prompt.message ?? prompt.name;
|
|
1427
|
+
const initial = 'string' == typeof prompt.default ? {
|
|
1428
|
+
initialValue: prompt.default
|
|
1429
|
+
} : {};
|
|
1430
|
+
if ('boolean' === prompt.type) answers[prompt.name] = await rt.prompter.confirm({
|
|
1431
|
+
message,
|
|
1432
|
+
initialValue: true === prompt.default
|
|
1433
|
+
});
|
|
1434
|
+
else if ('select' === prompt.type) answers[prompt.name] = await rt.prompter.select({
|
|
1435
|
+
message,
|
|
1436
|
+
choices: (prompt.choices ?? []).map((c)=>({
|
|
1437
|
+
value: c,
|
|
1438
|
+
label: c
|
|
1439
|
+
})),
|
|
1440
|
+
...initial
|
|
1441
|
+
});
|
|
1442
|
+
else answers[prompt.name] = await rt.prompter.text({
|
|
1443
|
+
message,
|
|
1444
|
+
...initial
|
|
1445
|
+
});
|
|
1446
|
+
});
|
|
1447
|
+
return answers;
|
|
1448
|
+
}
|
|
1449
|
+
function assertThirdPartyAllowed(names, globals, canPrompt) {
|
|
1450
|
+
if (names.length > 0 && !globals.yes && !canPrompt && !globals.dryRun) throw new MfeflowError('E107', `${names.join(', ')} comes from a registry; pass --yes to apply it without a terminal to confirm in`);
|
|
1451
|
+
}
|
|
1452
|
+
const rank = (name)=>OPTIONAL_FEATURES.indexOf(name);
|
|
1453
|
+
function inCatalogueOrder(names) {
|
|
1454
|
+
const known = names.filter((n)=>-1 !== rank(n)).toSorted((a, b)=>rank(a) - rank(b));
|
|
1455
|
+
return [
|
|
1456
|
+
...known,
|
|
1457
|
+
...names.filter((n)=>-1 === rank(n))
|
|
1458
|
+
];
|
|
1459
|
+
}
|
|
1460
|
+
async function addFeatures(rt, globals, local, requested, options) {
|
|
1461
|
+
const root = await findProjectRoot(globals.cwd);
|
|
1462
|
+
const chosen = chooseLocal(await loadProject(root), root, globals.cwd, local);
|
|
1463
|
+
const { project } = chosen;
|
|
1464
|
+
const { canPrompt } = globals;
|
|
1465
|
+
let names = [
|
|
1466
|
+
...new Set(requested)
|
|
1467
|
+
];
|
|
1468
|
+
if (0 === names.length && canPrompt) {
|
|
1469
|
+
const choices = OPTIONAL_FEATURES.filter((n)=>!(n in project.features));
|
|
1470
|
+
names = await rt.prompter.multiselect({
|
|
1471
|
+
message: 'Add which features?',
|
|
1472
|
+
choices: choices.map((value)=>({
|
|
1473
|
+
value,
|
|
1474
|
+
label: value
|
|
1475
|
+
})),
|
|
1476
|
+
initialValues: []
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
if (0 === names.length) throw new MfeflowError('E107', 'add needs a feature: mfeflow add <feature...>, or `mfeflow features` to list them');
|
|
1480
|
+
const skipped = names.filter((n)=>n in project.features);
|
|
1481
|
+
const adding = inCatalogueOrder(names.filter((n)=>!(n in project.features)));
|
|
1482
|
+
if (0 === adding.length) {
|
|
1483
|
+
if (!globals.json) rt.io.stdout(`${skipped.join(', ')} already added; nothing to do.`);
|
|
1484
|
+
return {
|
|
1485
|
+
ok: true,
|
|
1486
|
+
command: 'add',
|
|
1487
|
+
kind: 'feature',
|
|
1488
|
+
dryRun: globals.dryRun,
|
|
1489
|
+
root,
|
|
1490
|
+
added: [],
|
|
1491
|
+
skipped,
|
|
1492
|
+
plan: planReport({
|
|
1493
|
+
version: 1,
|
|
1494
|
+
features: [],
|
|
1495
|
+
changes: [],
|
|
1496
|
+
dependencies: [],
|
|
1497
|
+
scripts: [],
|
|
1498
|
+
env: [],
|
|
1499
|
+
warnings: []
|
|
1500
|
+
}),
|
|
1501
|
+
written: [],
|
|
1502
|
+
installed: false,
|
|
1503
|
+
...globals.dryRun ? {
|
|
1504
|
+
diff: ''
|
|
1505
|
+
} : {}
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
assertOptionsAreForFeatures(options, adding);
|
|
1509
|
+
const loaded = await Promise.all(adding.map((name)=>loadAny(name, {
|
|
1510
|
+
roots: rt.roots,
|
|
1511
|
+
registries: project.registries,
|
|
1512
|
+
fetch: rt.fetch
|
|
1513
|
+
})));
|
|
1514
|
+
const thirdParty = loaded.filter((f)=>'third-party' === f.trust).map((f)=>f.manifest.name);
|
|
1515
|
+
assertThirdPartyAllowed(thirdParty, globals, canPrompt);
|
|
1516
|
+
const builder = createBuilder(root, chosen.checkout);
|
|
1517
|
+
await inOrder(loaded, async (feature)=>{
|
|
1518
|
+
const given = await askOptions(rt, feature, options[feature.manifest.name] ?? {}, canPrompt);
|
|
1519
|
+
await resolveFeature({
|
|
1520
|
+
feature,
|
|
1521
|
+
project,
|
|
1522
|
+
builder,
|
|
1523
|
+
options: given,
|
|
1524
|
+
alsoAdding: adding
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
const plan = builder.build();
|
|
1528
|
+
const outcome = await writePlan({
|
|
1529
|
+
rt,
|
|
1530
|
+
globals,
|
|
1531
|
+
root,
|
|
1532
|
+
plan,
|
|
1533
|
+
project,
|
|
1534
|
+
afterWrite: async ()=>{
|
|
1535
|
+
if (adding.includes('git-hooks')) await rt.system.gitInit(root);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
return finish(rt, globals, root, 'feature', adding, skipped, outcome);
|
|
1539
|
+
}
|
|
1540
|
+
async function runAdd(rt, args, raw) {
|
|
1541
|
+
assertKnownFlags(raw, ADD_ARGS);
|
|
1542
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
1543
|
+
const positional = Array.isArray(args._) ? args._.map(String) : [];
|
|
1544
|
+
const [first, ...rest] = positional;
|
|
1545
|
+
if ('remote' === first || 'shell' === first) return addApp(rt, globals, args, first, rest[0]);
|
|
1546
|
+
if ('env-key' === first) return addEnvKey(rt, globals, args, rest[0]);
|
|
1547
|
+
const preset = globals.preset ? await loadPreset(globals.preset, {
|
|
1548
|
+
cwd: globals.cwd,
|
|
1549
|
+
fetch: rt.fetch
|
|
1550
|
+
}) : {};
|
|
1551
|
+
const options = mergeOptions(preset.options ?? {}, parseFeatureOptions(repeated(raw, 'option')));
|
|
1552
|
+
return addFeatures(rt, globals, args_text(args, 'local'), positional, options);
|
|
1553
|
+
}
|
|
1554
|
+
function finding(check, code, detail, options = {}) {
|
|
1555
|
+
const error = new MfeflowError(code, detail);
|
|
1556
|
+
const out = {
|
|
1557
|
+
code: error.code,
|
|
1558
|
+
severity: options.severity ?? 'error',
|
|
1559
|
+
check,
|
|
1560
|
+
message: error.message,
|
|
1561
|
+
fix: error.fix,
|
|
1562
|
+
docs: error.docs,
|
|
1563
|
+
fixable: options.fixable ?? false
|
|
1564
|
+
};
|
|
1565
|
+
if (void 0 !== options.app) out.app = options.app;
|
|
1566
|
+
if (void 0 !== options.path) out.path = options.path;
|
|
1567
|
+
return out;
|
|
1568
|
+
}
|
|
1569
|
+
const done = (findings)=>Promise.resolve({
|
|
1570
|
+
findings
|
|
1571
|
+
});
|
|
1572
|
+
const SINGLETON_SPECIFIERS = [
|
|
1573
|
+
'react',
|
|
1574
|
+
'react/jsx-runtime',
|
|
1575
|
+
'react/jsx-dev-runtime',
|
|
1576
|
+
'react-dom',
|
|
1577
|
+
'react-dom/client',
|
|
1578
|
+
'react-router',
|
|
1579
|
+
'@tanstack/react-query',
|
|
1580
|
+
'i18next',
|
|
1581
|
+
'react-i18next',
|
|
1582
|
+
'@mfeflow/runtime',
|
|
1583
|
+
'@mfeflow/analytics',
|
|
1584
|
+
'@mfeflow/analytics/react',
|
|
1585
|
+
'@mfeflow/telemetry',
|
|
1586
|
+
'@mfeflow/storage',
|
|
1587
|
+
'@mfeflow/storage/react',
|
|
1588
|
+
'@mfeflow/theme',
|
|
1589
|
+
'@mfeflow/theme/react',
|
|
1590
|
+
'@mfeflow/i18n',
|
|
1591
|
+
'@mfeflow/i18n/react'
|
|
1592
|
+
];
|
|
1593
|
+
function packageNameOf(specifier) {
|
|
1594
|
+
const parts = specifier.split('/');
|
|
1595
|
+
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier;
|
|
1596
|
+
}
|
|
1597
|
+
const SINGLETON_PACKAGES = [
|
|
1598
|
+
...new Set(SINGLETON_SPECIFIERS.map(packageNameOf))
|
|
1599
|
+
];
|
|
1600
|
+
function specifiersOf(name) {
|
|
1601
|
+
return SINGLETON_SPECIFIERS.filter((s)=>packageNameOf(s) === name);
|
|
1602
|
+
}
|
|
1603
|
+
const ENVS = [
|
|
1604
|
+
'dev',
|
|
1605
|
+
'staging',
|
|
1606
|
+
'prod'
|
|
1607
|
+
];
|
|
1608
|
+
const inspect_isObj = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
1609
|
+
async function readText(file) {
|
|
1610
|
+
try {
|
|
1611
|
+
return await promises.readFile(file, 'utf8');
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
if (hasErrno(error, 'ENOENT') || hasErrno(error, 'ENOTDIR') || hasErrno(error, 'EISDIR')) return;
|
|
1614
|
+
throw error;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
async function readJson(file) {
|
|
1618
|
+
const text = await readText(file);
|
|
1619
|
+
if (void 0 === text) return {
|
|
1620
|
+
state: 'missing'
|
|
1621
|
+
};
|
|
1622
|
+
try {
|
|
1623
|
+
return {
|
|
1624
|
+
state: 'ok',
|
|
1625
|
+
value: JSON.parse(text)
|
|
1626
|
+
};
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
return {
|
|
1629
|
+
state: 'invalid',
|
|
1630
|
+
issues: [
|
|
1631
|
+
{
|
|
1632
|
+
path: '',
|
|
1633
|
+
message: `not valid JSON (${messageOf(error)})`
|
|
1634
|
+
}
|
|
1635
|
+
]
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
function strings(value) {
|
|
1640
|
+
if (!inspect_isObj(value)) return {};
|
|
1641
|
+
return Object.fromEntries(Object.entries(value).filter((e)=>'string' == typeof e[1]));
|
|
1642
|
+
}
|
|
1643
|
+
async function readWorkspaceFile(root) {
|
|
1644
|
+
const text = await readText(node_path.join(root, 'pnpm-workspace.yaml'));
|
|
1645
|
+
const empty = {
|
|
1646
|
+
globs: [],
|
|
1647
|
+
catalogs: {
|
|
1648
|
+
default: {},
|
|
1649
|
+
named: {}
|
|
1650
|
+
}
|
|
1651
|
+
};
|
|
1652
|
+
if (void 0 === text) return empty;
|
|
1653
|
+
let doc;
|
|
1654
|
+
try {
|
|
1655
|
+
doc = parse(text);
|
|
1656
|
+
} catch {
|
|
1657
|
+
return empty;
|
|
1658
|
+
}
|
|
1659
|
+
if (!inspect_isObj(doc)) return empty;
|
|
1660
|
+
const globs = Array.isArray(doc.packages) ? doc.packages.filter((g)=>'string' == typeof g) : [];
|
|
1661
|
+
const named = inspect_isObj(doc.catalogs) ? Object.fromEntries(Object.entries(doc.catalogs).map(([k, v])=>[
|
|
1662
|
+
k,
|
|
1663
|
+
strings(v)
|
|
1664
|
+
])) : {};
|
|
1665
|
+
return {
|
|
1666
|
+
globs,
|
|
1667
|
+
catalogs: {
|
|
1668
|
+
default: strings(doc.catalog),
|
|
1669
|
+
named
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
async function subfolders(dir) {
|
|
1674
|
+
try {
|
|
1675
|
+
return (await promises.readdir(dir, {
|
|
1676
|
+
withFileTypes: true
|
|
1677
|
+
})).filter((e)=>e.isDirectory() && 'node_modules' !== e.name).map((e)=>e.name).toSorted((a, b)=>a.localeCompare(b));
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
if (hasErrno(error, 'ENOENT') || hasErrno(error, 'ENOTDIR')) return [];
|
|
1680
|
+
throw error;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
function excluder(pattern) {
|
|
1684
|
+
const body = pattern.replace(/\/$/, '').split('/').map((part)=>'**' === part ? '\u0000' : part.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`).replaceAll('*', '[^/]*')).join('/').replaceAll('\u0000/', '(?:.*/)?').replaceAll('/\u0000', '(?:/.*)?').replaceAll('\u0000', '.*');
|
|
1685
|
+
return new RegExp(`^${body}$`);
|
|
1686
|
+
}
|
|
1687
|
+
async function expandGlobs(root, globs) {
|
|
1688
|
+
const out = [];
|
|
1689
|
+
const excluded = globs.filter((g)=>g.startsWith('!')).map((g)=>excluder(g.slice(1)));
|
|
1690
|
+
for (const glob of globs){
|
|
1691
|
+
if (glob.startsWith('!')) continue;
|
|
1692
|
+
const clean = glob.replace(/\/$/, '');
|
|
1693
|
+
if (clean.endsWith('/*') || clean.endsWith('/**')) {
|
|
1694
|
+
const base = clean.replace(/\/\*{1,2}$/, '');
|
|
1695
|
+
for (const name of (await subfolders(node_path.join(root, base))))out.push(`${base}/${name}`);
|
|
1696
|
+
} else out.push(clean);
|
|
1697
|
+
}
|
|
1698
|
+
return [
|
|
1699
|
+
...new Set(out)
|
|
1700
|
+
].filter((rel)=>!excluded.some((pattern)=>pattern.test(rel)));
|
|
1701
|
+
}
|
|
1702
|
+
function resolveSpec(name, spec, catalogs) {
|
|
1703
|
+
if ('catalog:' === spec || 'catalog:default' === spec) return catalogs.default[name] ?? spec;
|
|
1704
|
+
if (spec.startsWith('catalog:')) return catalogs.named[spec.slice(8)]?.[name] ?? spec;
|
|
1705
|
+
return spec;
|
|
1706
|
+
}
|
|
1707
|
+
async function readPackage(dir, catalogs) {
|
|
1708
|
+
const read = await readJson(node_path.join(dir, 'package.json'));
|
|
1709
|
+
if ('ok' !== read.state || !inspect_isObj(read.value)) return;
|
|
1710
|
+
const deps = {};
|
|
1711
|
+
for (const key of [
|
|
1712
|
+
'peerDependencies',
|
|
1713
|
+
'devDependencies',
|
|
1714
|
+
'dependencies'
|
|
1715
|
+
])for (const [name, spec] of Object.entries(strings(read.value[key])))deps[name] = resolveSpec(name, spec, catalogs);
|
|
1716
|
+
const view = {
|
|
1717
|
+
deps
|
|
1718
|
+
};
|
|
1719
|
+
if ('string' == typeof read.value.name) view.name = read.value.name;
|
|
1720
|
+
if ('string' == typeof read.value.version) view.version = read.value.version;
|
|
1721
|
+
return view;
|
|
1722
|
+
}
|
|
1723
|
+
async function readMfe(dir) {
|
|
1724
|
+
const read = await readJson(node_path.join(dir, 'mfe.json'));
|
|
1725
|
+
if ('ok' !== read.state) return read;
|
|
1726
|
+
const issues = validateMfeManifest(read.value);
|
|
1727
|
+
if (issues.length > 0) return {
|
|
1728
|
+
state: 'invalid',
|
|
1729
|
+
issues
|
|
1730
|
+
};
|
|
1731
|
+
return {
|
|
1732
|
+
state: 'ok',
|
|
1733
|
+
value: read.value
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
async function readRegistry(root) {
|
|
1737
|
+
const envsDir = node_path.join(root, 'registry', 'envs');
|
|
1738
|
+
const envDirs = await subfolders(envsDir);
|
|
1739
|
+
const registry = {};
|
|
1740
|
+
for (const env of envDirs){
|
|
1741
|
+
const entries = {};
|
|
1742
|
+
const dir = node_path.join(envsDir, env);
|
|
1743
|
+
const files = (await promises.readdir(dir)).filter((f)=>f.endsWith('.json')).toSorted((a, b)=>a.localeCompare(b));
|
|
1744
|
+
for (const file of files){
|
|
1745
|
+
const read = await readJson(node_path.join(dir, file));
|
|
1746
|
+
const name = file.slice(0, -5);
|
|
1747
|
+
if ('ok' !== read.state) {
|
|
1748
|
+
entries[name] = read;
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
const issues = validateRegistryEntry(read.value);
|
|
1752
|
+
if (issues.length > 0) {
|
|
1753
|
+
entries[name] = {
|
|
1754
|
+
state: 'invalid',
|
|
1755
|
+
issues
|
|
1756
|
+
};
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
entries[name] = {
|
|
1760
|
+
state: 'ok',
|
|
1761
|
+
value: read.value
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
registry[env] = entries;
|
|
1765
|
+
}
|
|
1766
|
+
return {
|
|
1767
|
+
registry,
|
|
1768
|
+
envDirs
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
async function inspectProject(root) {
|
|
1772
|
+
const project = await loadProject(root);
|
|
1773
|
+
const { globs, catalogs } = await readWorkspaceFile(root);
|
|
1774
|
+
const apps = await Promise.all(Object.entries(project.apps).map(async ([name, app])=>{
|
|
1775
|
+
const dir = node_path.join(root, ...app.path.split('/'));
|
|
1776
|
+
const pkg = await readPackage(dir, catalogs);
|
|
1777
|
+
return {
|
|
1778
|
+
name,
|
|
1779
|
+
kind: app.kind,
|
|
1780
|
+
rel: app.path,
|
|
1781
|
+
dir,
|
|
1782
|
+
port: app.port,
|
|
1783
|
+
present: void 0 !== pkg,
|
|
1784
|
+
pkg,
|
|
1785
|
+
mfe: await readMfe(dir)
|
|
1786
|
+
};
|
|
1787
|
+
}));
|
|
1788
|
+
const folders = await expandGlobs(root, globs);
|
|
1789
|
+
const workspace = (await Promise.all(folders.map(async (rel)=>{
|
|
1790
|
+
const dir = node_path.join(root, ...rel.split('/'));
|
|
1791
|
+
const pkg = await readPackage(dir, catalogs);
|
|
1792
|
+
return pkg && {
|
|
1793
|
+
dir,
|
|
1794
|
+
rel,
|
|
1795
|
+
pkg,
|
|
1796
|
+
hasVersion: void 0 !== pkg.version
|
|
1797
|
+
};
|
|
1798
|
+
}))).filter((p)=>void 0 !== p);
|
|
1799
|
+
return {
|
|
1800
|
+
root,
|
|
1801
|
+
project,
|
|
1802
|
+
apps,
|
|
1803
|
+
workspace,
|
|
1804
|
+
...await readRegistry(root)
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
async function versionIn(app, name, root) {
|
|
1808
|
+
const declared = app.pkg?.deps[name];
|
|
1809
|
+
if (void 0 === declared && !SINGLETON_PACKAGES.includes(name)) return;
|
|
1810
|
+
let dir = app.dir;
|
|
1811
|
+
while(!node_path.relative(root, dir).startsWith('..')){
|
|
1812
|
+
const text = await readText(node_path.join(dir, 'node_modules', ...name.split('/'), 'package.json'));
|
|
1813
|
+
if (void 0 !== text) try {
|
|
1814
|
+
const parsed = JSON.parse(text);
|
|
1815
|
+
if (inspect_isObj(parsed) && 'string' == typeof parsed.version) return parsed.version;
|
|
1816
|
+
} catch {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const parent = node_path.dirname(dir);
|
|
1820
|
+
if (parent === dir || dir === root) break;
|
|
1821
|
+
dir = parent;
|
|
1822
|
+
}
|
|
1823
|
+
return declared;
|
|
1824
|
+
}
|
|
1825
|
+
function comparable(version) {
|
|
1826
|
+
const exact = semver.valid(version);
|
|
1827
|
+
if (null !== exact) return exact;
|
|
1828
|
+
return null === semver.validRange(version) ? void 0 : semver.minVersion(version)?.version ?? void 0;
|
|
1829
|
+
}
|
|
1830
|
+
function unresolved(check, code, name, specs) {
|
|
1831
|
+
return finding(check, code, `${name} is ${specs.join(', ')}: the version cannot be compared until install, so it is not checked`, {
|
|
1832
|
+
severity: 'warning'
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
const singletonVersions = {
|
|
1836
|
+
id: 'singletons.versions',
|
|
1837
|
+
title: 'each singleton (react, react-dom, …) is one version across the apps',
|
|
1838
|
+
async run ({ apps, root }) {
|
|
1839
|
+
const present = apps.filter((app)=>app.present);
|
|
1840
|
+
const out = [];
|
|
1841
|
+
for (const name of SINGLETON_PACKAGES){
|
|
1842
|
+
const found = await Promise.all(present.map(async (app)=>({
|
|
1843
|
+
app: app.name,
|
|
1844
|
+
version: await versionIn(app, name, root)
|
|
1845
|
+
})));
|
|
1846
|
+
const used = found.flatMap((f)=>{
|
|
1847
|
+
const version = void 0 === f.version ? void 0 : comparable(f.version);
|
|
1848
|
+
return void 0 === version ? [] : [
|
|
1849
|
+
{
|
|
1850
|
+
app: f.app,
|
|
1851
|
+
version
|
|
1852
|
+
}
|
|
1853
|
+
];
|
|
1854
|
+
});
|
|
1855
|
+
const open = found.filter((f)=>void 0 !== f.version && void 0 === comparable(f.version));
|
|
1856
|
+
if (open.length > 0) out.push(unresolved('singletons.versions', 'E301', name, open.map((f)=>`${f.app} ${String(f.version)}`)));
|
|
1857
|
+
const versions = new Set(used.map((u)=>u.version));
|
|
1858
|
+
if (versions.size > 1) {
|
|
1859
|
+
const list = used.map((u)=>`${u.app} ${u.version}`).join(', ');
|
|
1860
|
+
out.push(finding('singletons.versions', 'E301', `${name} (${specifiersOf(name).join(', ')}) is ${[
|
|
1861
|
+
...versions
|
|
1862
|
+
].join(' and ')}: ${list}`));
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
return {
|
|
1866
|
+
findings: out
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
function fits(range, shellVersion) {
|
|
1871
|
+
if (void 0 === shellVersion) return true;
|
|
1872
|
+
const have = comparable(shellVersion);
|
|
1873
|
+
const valid = semver.validRange(range);
|
|
1874
|
+
return void 0 === have || null === valid || semver.satisfies(have, valid);
|
|
1875
|
+
}
|
|
1876
|
+
async function compatFor(shell, remote, root) {
|
|
1877
|
+
const out = [];
|
|
1878
|
+
const at = {
|
|
1879
|
+
app: remote.name,
|
|
1880
|
+
path: `${remote.rel}/mfe.json`
|
|
1881
|
+
};
|
|
1882
|
+
const platform = 'ok' === remote.mfe.state ? remote.mfe.value.platform ?? {} : {};
|
|
1883
|
+
for (const [name, range] of Object.entries(platform)){
|
|
1884
|
+
const have = await versionIn(shell, name, root);
|
|
1885
|
+
if (void 0 !== have && void 0 === comparable(have)) out.push(unresolved('compat', 'E302', name, [
|
|
1886
|
+
`${shell.name} ${have}`
|
|
1887
|
+
]));
|
|
1888
|
+
else if (!fits(range, have)) out.push(finding('compat', 'E302', `${remote.name} needs ${name} ${range} (mfe.json platform) but ${shell.name} has ${String(have)}`, at));
|
|
1889
|
+
}
|
|
1890
|
+
for (const name of SINGLETON_PACKAGES){
|
|
1891
|
+
const [theirs, mine] = await Promise.all([
|
|
1892
|
+
versionIn(remote, name, root),
|
|
1893
|
+
versionIn(shell, name, root)
|
|
1894
|
+
]);
|
|
1895
|
+
const want = void 0 === theirs ? void 0 : comparable(theirs);
|
|
1896
|
+
const open = [
|
|
1897
|
+
...void 0 !== theirs && void 0 === want ? [
|
|
1898
|
+
`${remote.name} ${theirs}`
|
|
1899
|
+
] : [],
|
|
1900
|
+
...void 0 !== mine && void 0 === comparable(mine) ? [
|
|
1901
|
+
`${shell.name} ${mine}`
|
|
1902
|
+
] : []
|
|
1903
|
+
];
|
|
1904
|
+
if (open.length > 0) out.push(unresolved('compat', 'E302', name, open));
|
|
1905
|
+
if (void 0 !== want && void 0 !== mine) {
|
|
1906
|
+
if (!fits(`^${want}`, mine) && !(name in platform)) out.push(finding('compat', 'E302', `${remote.name} uses ${name} ${want}, ${shell.name} provides ${mine}`, {
|
|
1907
|
+
app: remote.name
|
|
1908
|
+
}));
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
const compat = {
|
|
1914
|
+
id: 'compat',
|
|
1915
|
+
title: "each remote's shared ranges fit the shells it runs in",
|
|
1916
|
+
async run (view) {
|
|
1917
|
+
const out = [];
|
|
1918
|
+
const remotes = view.apps.filter((a)=>'remote' === a.kind && a.present);
|
|
1919
|
+
for (const remote of remotes){
|
|
1920
|
+
const names = 'ok' === remote.mfe.state ? remote.mfe.value.shells : view.apps.filter((a)=>'shell' === a.kind).map((a)=>a.name);
|
|
1921
|
+
for (const shell of view.apps.filter((a)=>'shell' === a.kind && a.present && names.includes(a.name)))out.push(...await compatFor(shell, remote, view.root));
|
|
1922
|
+
}
|
|
1923
|
+
const seen = new Set();
|
|
1924
|
+
return {
|
|
1925
|
+
findings: out.filter((f)=>!seen.has(f.message) && Boolean(seen.add(f.message)))
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
function issueText(read) {
|
|
1930
|
+
return 'invalid' === read.state ? read.issues.map((i)=>`${i.path || '(root)'} ${i.message}`).join('; ') : '';
|
|
1931
|
+
}
|
|
1932
|
+
const appsOnDisk = {
|
|
1933
|
+
id: 'config.apps',
|
|
1934
|
+
title: 'every app in mfeflow.json is on disk',
|
|
1935
|
+
run: ({ apps })=>done(apps.filter((app)=>!app.present).map((app)=>finding('config.apps', 'E112', `${app.name} (${app.rel}/package.json not found)`, {
|
|
1936
|
+
app: app.name,
|
|
1937
|
+
path: `${app.rel}/package.json`
|
|
1938
|
+
})))
|
|
1939
|
+
};
|
|
1940
|
+
const mfeJson = {
|
|
1941
|
+
id: 'config.mfe-json',
|
|
1942
|
+
title: 'every app has a valid mfe.json that matches mfeflow.json',
|
|
1943
|
+
run: ({ apps })=>done(apps.filter((app)=>app.present).flatMap((app)=>{
|
|
1944
|
+
const at = {
|
|
1945
|
+
app: app.name,
|
|
1946
|
+
path: `${app.rel}/mfe.json`
|
|
1947
|
+
};
|
|
1948
|
+
if ('missing' === app.mfe.state) return [
|
|
1949
|
+
finding('config.mfe-json', 'E113', `${app.name} has no mfe.json`, at)
|
|
1950
|
+
];
|
|
1951
|
+
if ('invalid' === app.mfe.state) return [
|
|
1952
|
+
finding('config.mfe-json', 'E113', `${app.name}: ${issueText(app.mfe)}`, at)
|
|
1953
|
+
];
|
|
1954
|
+
const { name } = app.mfe.value;
|
|
1955
|
+
return name === app.name ? [] : [
|
|
1956
|
+
finding('config.mfe-json', 'E116', `mfe.json names "${name}" but the app is "${app.name}"`, at)
|
|
1957
|
+
];
|
|
1958
|
+
}))
|
|
1959
|
+
};
|
|
1960
|
+
const config_ports = {
|
|
1961
|
+
id: 'config.ports',
|
|
1962
|
+
title: 'no two apps share a port',
|
|
1963
|
+
run: ({ apps })=>{
|
|
1964
|
+
const byPort = new Map();
|
|
1965
|
+
for (const app of apps)if (void 0 !== app.port) byPort.set(app.port, [
|
|
1966
|
+
...byPort.get(app.port) ?? [],
|
|
1967
|
+
app.name
|
|
1968
|
+
]);
|
|
1969
|
+
return done([
|
|
1970
|
+
...byPort
|
|
1971
|
+
].filter(([, names])=>names.length > 1).map(([port, names])=>finding('config.ports', 'E115', `${names.join(' and ')} both use ${String(port)}`)));
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
const config_routes = {
|
|
1975
|
+
id: 'config.routes',
|
|
1976
|
+
title: 'no two remotes claim the same route',
|
|
1977
|
+
run: ({ apps })=>{
|
|
1978
|
+
const claimed = new Map();
|
|
1979
|
+
const out = [];
|
|
1980
|
+
for (const app of apps)if ('remote' === app.kind && 'ok' === app.mfe.state) for (const route of app.mfe.value.routes){
|
|
1981
|
+
const owner = claimed.get(route);
|
|
1982
|
+
if (void 0 !== owner && owner !== app.name) out.push(finding('config.routes', 'E116', `${route} is claimed by ${owner} and ${app.name}`, {
|
|
1983
|
+
app: app.name,
|
|
1984
|
+
path: `${app.rel}/mfe.json`
|
|
1985
|
+
}));
|
|
1986
|
+
else claimed.set(route, app.name);
|
|
1987
|
+
}
|
|
1988
|
+
return done(out);
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
const config_registry = {
|
|
1992
|
+
id: 'config.registry',
|
|
1993
|
+
title: 'every remote has a valid registry entry in dev, staging and prod',
|
|
1994
|
+
run: ({ apps, registry: entries })=>{
|
|
1995
|
+
const out = [];
|
|
1996
|
+
const remotes = apps.filter((app)=>'remote' === app.kind);
|
|
1997
|
+
for (const env of ENVS){
|
|
1998
|
+
for (const app of remotes){
|
|
1999
|
+
const entry = entries[env]?.[app.name] ?? {
|
|
2000
|
+
state: 'missing'
|
|
2001
|
+
};
|
|
2002
|
+
const file = `registry/envs/${env}/${app.name}.json`;
|
|
2003
|
+
if ('missing' === entry.state) out.push(finding('config.registry', 'E114', `${file} is missing`, {
|
|
2004
|
+
app: app.name,
|
|
2005
|
+
path: file,
|
|
2006
|
+
fixable: true
|
|
2007
|
+
}));
|
|
2008
|
+
else if ('invalid' === entry.state) out.push(finding('config.registry', 'E114', `${file}: ${issueText(entry)}`, {
|
|
2009
|
+
app: app.name,
|
|
2010
|
+
path: file
|
|
2011
|
+
}));
|
|
2012
|
+
}
|
|
2013
|
+
const known = new Set(remotes.map((r)=>r.name));
|
|
2014
|
+
for (const name of Object.keys(entries[env] ?? {}))if (!known.has(name)) out.push(finding('config.registry', 'E114', `registry/envs/${env}/${name}.json has no remote in mfeflow.json`, {
|
|
2015
|
+
severity: 'warning',
|
|
2016
|
+
path: `registry/envs/${env}/${name}.json`
|
|
2017
|
+
}));
|
|
2018
|
+
}
|
|
2019
|
+
return done(out);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
const localCheckout = {
|
|
2023
|
+
id: 'config.local',
|
|
2024
|
+
title: 'the linked mfeflow checkout is built',
|
|
2025
|
+
run: ({ project, root })=>{
|
|
2026
|
+
if (void 0 === project.local) return Promise.resolve({
|
|
2027
|
+
findings: [],
|
|
2028
|
+
skipped: 'not linked with --local'
|
|
2029
|
+
});
|
|
2030
|
+
try {
|
|
2031
|
+
const unbuilt = [
|
|
2032
|
+
...scanCheckout(node_path.resolve(root, project.local))
|
|
2033
|
+
].filter(([, p])=>!p.built).map(([name])=>name);
|
|
2034
|
+
return done(0 === unbuilt.length ? [] : [
|
|
2035
|
+
finding('config.local', 'E110', `${unbuilt.join(', ')} not built in ${project.local}`)
|
|
2036
|
+
]);
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
return done([
|
|
2039
|
+
finding('config.local', 'E110', error instanceof Error ? error.message : String(error))
|
|
2040
|
+
]);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
const drift = {
|
|
2045
|
+
id: 'files.drift',
|
|
2046
|
+
title: 'copied files match the hashes in mfeflow.json',
|
|
2047
|
+
run: async ({ root, project })=>({
|
|
2048
|
+
findings: (await findDiverged(root, project)).map((file)=>finding('files.drift', 'E117', `${file.path} is ${file.state}`, {
|
|
2049
|
+
severity: 'warning',
|
|
2050
|
+
path: file.path
|
|
2051
|
+
}))
|
|
2052
|
+
})
|
|
2053
|
+
};
|
|
2054
|
+
const NOT_INSTALLED = 'the env-t3 feature is not installed';
|
|
2055
|
+
const env_installed = ({ project })=>'env-t3' in project.features;
|
|
2056
|
+
const PUBLIC_CONSTANTS = new Set([
|
|
2057
|
+
'PUBLIC_APP_NAME',
|
|
2058
|
+
'PUBLIC_BUILD_VERSION'
|
|
2059
|
+
]);
|
|
2060
|
+
const PUBLIC_VAR = /\bPUBLIC_[A-Z0-9_]+\b/g;
|
|
2061
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
|
2062
|
+
const placeOf = (a)=>{
|
|
2063
|
+
const place = {
|
|
2064
|
+
name: a.name,
|
|
2065
|
+
dir: a.dir,
|
|
2066
|
+
rel: a.rel
|
|
2067
|
+
};
|
|
2068
|
+
const pkg = a.pkg?.name;
|
|
2069
|
+
if (void 0 !== pkg) place.pkg = pkg;
|
|
2070
|
+
return place;
|
|
2071
|
+
};
|
|
2072
|
+
const places = (view)=>[
|
|
2073
|
+
{
|
|
2074
|
+
name: 'root',
|
|
2075
|
+
dir: view.root,
|
|
2076
|
+
rel: ''
|
|
2077
|
+
},
|
|
2078
|
+
...view.apps.filter((a)=>a.present).map(placeOf)
|
|
2079
|
+
];
|
|
2080
|
+
const warning = (app, file)=>({
|
|
2081
|
+
app,
|
|
2082
|
+
path: file,
|
|
2083
|
+
severity: 'warning'
|
|
2084
|
+
});
|
|
2085
|
+
const env_at = (place, file)=>'' === place.rel ? file : `${place.rel}/${file}`;
|
|
2086
|
+
async function eachPlace(view, check) {
|
|
2087
|
+
if (!env_installed(view)) return {
|
|
2088
|
+
findings: [],
|
|
2089
|
+
skipped: NOT_INSTALLED
|
|
2090
|
+
};
|
|
2091
|
+
return done((await Promise.all(places(view).map(check))).flat());
|
|
2092
|
+
}
|
|
2093
|
+
async function serverKeysOf(place) {
|
|
2094
|
+
const source = await readText(node_path.join(place.dir, 'env.ts'));
|
|
2095
|
+
if (void 0 === source) return;
|
|
2096
|
+
try {
|
|
2097
|
+
const keys = schemaKeys(source, 'env.ts', 'server');
|
|
2098
|
+
if (void 0 === keys) return {
|
|
2099
|
+
keys: [],
|
|
2100
|
+
problem: 'it has no createEnv({ server: { … } })'
|
|
2101
|
+
};
|
|
2102
|
+
return {
|
|
2103
|
+
keys
|
|
2104
|
+
};
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
return {
|
|
2107
|
+
keys: [],
|
|
2108
|
+
problem: messageOf(error)
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
const envExample = {
|
|
2113
|
+
id: 'env.example',
|
|
2114
|
+
title: '.env.example lists the required keys of env.ts and no others (decision 0020)',
|
|
2115
|
+
run: (view)=>eachPlace(view, async (place)=>{
|
|
2116
|
+
const server = await serverKeysOf(place);
|
|
2117
|
+
if (void 0 === server) return [];
|
|
2118
|
+
if (void 0 !== server.problem) {
|
|
2119
|
+
const env = env_at(place, 'env.ts');
|
|
2120
|
+
return [
|
|
2121
|
+
finding('env.example', 'E118', `${env} cannot be checked: ${server.problem}`, {
|
|
2122
|
+
app: place.name,
|
|
2123
|
+
path: env,
|
|
2124
|
+
severity: 'warning'
|
|
2125
|
+
})
|
|
2126
|
+
];
|
|
2127
|
+
}
|
|
2128
|
+
const names = new Set(server.keys.map((k)=>k.name));
|
|
2129
|
+
const required = server.keys.filter((k)=>!k.optional).map((k)=>k.name);
|
|
2130
|
+
const file = env_at(place, '.env.example');
|
|
2131
|
+
const example = await readText(node_path.join(place.dir, '.env.example'));
|
|
2132
|
+
const options = {
|
|
2133
|
+
app: place.name,
|
|
2134
|
+
path: file,
|
|
2135
|
+
severity: 'warning'
|
|
2136
|
+
};
|
|
2137
|
+
if (void 0 === example) return 0 === required.length ? [] : [
|
|
2138
|
+
finding('env.example', 'E118', `${file} is missing`, options)
|
|
2139
|
+
];
|
|
2140
|
+
const listed = dotenvKeys(example);
|
|
2141
|
+
return [
|
|
2142
|
+
...required.filter((key)=>!listed.includes(key)).map((key)=>finding('env.example', 'E118', `${key} is in ${env_at(place, 'env.ts')} but not in ${file}`, options)),
|
|
2143
|
+
...listed.filter((key)=>!names.has(key)).map((key)=>finding('env.example', 'E118', `${key} is in ${file} but not in ${env_at(place, 'env.ts')}`, options))
|
|
2144
|
+
];
|
|
2145
|
+
})
|
|
2146
|
+
};
|
|
2147
|
+
async function checkConfigFile(place, folder, name, keys) {
|
|
2148
|
+
const file = env_at(place, `public/config/${name}`);
|
|
2149
|
+
const options = {
|
|
2150
|
+
app: place.name,
|
|
2151
|
+
path: file
|
|
2152
|
+
};
|
|
2153
|
+
const extra = warning(place.name, file);
|
|
2154
|
+
let json;
|
|
2155
|
+
try {
|
|
2156
|
+
json = JSON.parse(await readText(node_path.join(folder, name)) ?? '');
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
return [
|
|
2159
|
+
finding('env.runtime-config', 'E119', `${file} is not valid JSON (${messageOf(error)})`, options)
|
|
2160
|
+
];
|
|
2161
|
+
}
|
|
2162
|
+
if ('object' != typeof json || null === json || Array.isArray(json)) return [
|
|
2163
|
+
finding('env.runtime-config', 'E119', `${file} is not a JSON object`, options)
|
|
2164
|
+
];
|
|
2165
|
+
const present = Object.keys(json);
|
|
2166
|
+
return [
|
|
2167
|
+
...keys.filter((k)=>!k.optional && !present.includes(k.name)).map((k)=>finding('env.runtime-config', 'E119', `${file} is missing ${k.name}`, options)),
|
|
2168
|
+
...present.filter((key)=>!keys.some((k)=>k.name === key)).map((key)=>finding('env.runtime-config', 'E119', `${file} has ${key}, which the schema does not`, extra))
|
|
2169
|
+
];
|
|
2170
|
+
}
|
|
2171
|
+
const runtimeConfig = {
|
|
2172
|
+
id: 'env.runtime-config',
|
|
2173
|
+
title: 'every runtime-config.<env>.json has the keys of the runtime-config schema (decision 0020)',
|
|
2174
|
+
run: (view)=>eachPlace(view, async (place)=>{
|
|
2175
|
+
const source = await readText(node_path.join(place.dir, 'src', 'config', 'schema.ts'));
|
|
2176
|
+
if (void 0 === source) return [];
|
|
2177
|
+
const schemaFile = env_at(place, 'src/config/schema.ts');
|
|
2178
|
+
let keys;
|
|
2179
|
+
try {
|
|
2180
|
+
keys = schemaKeys(source, schemaFile, 'runtime');
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
return [
|
|
2183
|
+
finding('env.runtime-config', 'E119', `${schemaFile}: ${messageOf(error)}`, {
|
|
2184
|
+
app: place.name,
|
|
2185
|
+
path: schemaFile
|
|
2186
|
+
})
|
|
2187
|
+
];
|
|
2188
|
+
}
|
|
2189
|
+
if (void 0 === keys) return [
|
|
2190
|
+
finding('env.runtime-config', 'E119', `${schemaFile} has no runtimeConfigSchema object`, {
|
|
2191
|
+
app: place.name,
|
|
2192
|
+
path: schemaFile
|
|
2193
|
+
})
|
|
2194
|
+
];
|
|
2195
|
+
const folder = node_path.join(place.dir, 'public', 'config');
|
|
2196
|
+
const files = await runtimeConfigFiles(folder);
|
|
2197
|
+
const found = await Promise.all(files.map((name)=>checkConfigFile(place, folder, name, keys)));
|
|
2198
|
+
return found.flat();
|
|
2199
|
+
})
|
|
2200
|
+
};
|
|
2201
|
+
async function sourceFiles(dir) {
|
|
2202
|
+
const entries = await promises.readdir(dir, {
|
|
2203
|
+
withFileTypes: true
|
|
2204
|
+
}).catch(()=>[]);
|
|
2205
|
+
const nested = await Promise.all(entries.map(async (entry)=>{
|
|
2206
|
+
const full = node_path.join(dir, entry.name);
|
|
2207
|
+
if (entry.isDirectory()) return 'node_modules' === entry.name ? [] : sourceFiles(full);
|
|
2208
|
+
return SOURCE_FILE.test(entry.name) ? [
|
|
2209
|
+
full
|
|
2210
|
+
] : [];
|
|
2211
|
+
}));
|
|
2212
|
+
return nested.flat();
|
|
2213
|
+
}
|
|
2214
|
+
const publicPrefix = {
|
|
2215
|
+
id: 'env.public-prefix',
|
|
2216
|
+
title: 'no PUBLIC_ variable is baked into a bundle except constants (decision 0020)',
|
|
2217
|
+
run: (view)=>eachPlace(view, async (place)=>{
|
|
2218
|
+
const files = [
|
|
2219
|
+
node_path.join(place.dir, 'env.ts'),
|
|
2220
|
+
node_path.join(place.dir, '.env.example'),
|
|
2221
|
+
...await sourceFiles(node_path.join(place.dir, 'src'))
|
|
2222
|
+
];
|
|
2223
|
+
const found = await Promise.all(files.map(async (file)=>{
|
|
2224
|
+
const source = await readText(file);
|
|
2225
|
+
if (void 0 === source) return [];
|
|
2226
|
+
const names = [
|
|
2227
|
+
...new Set(source.match(PUBLIC_VAR) ?? [])
|
|
2228
|
+
].filter((name)=>!PUBLIC_CONSTANTS.has(name));
|
|
2229
|
+
const shown = node_path.relative(view.root, file).split(node_path.sep).join('/');
|
|
2230
|
+
return names.map((name)=>finding('env.public-prefix', 'E120', `${name} in ${shown}`, {
|
|
2231
|
+
app: place.name,
|
|
2232
|
+
path: shown,
|
|
2233
|
+
severity: 'warning'
|
|
2234
|
+
}));
|
|
2235
|
+
}));
|
|
2236
|
+
return found.flat();
|
|
2237
|
+
})
|
|
2238
|
+
};
|
|
2239
|
+
const turboEnv = {
|
|
2240
|
+
id: 'env.turbo',
|
|
2241
|
+
title: 'every required server env key is listed in turbo.json (decision 0020)',
|
|
2242
|
+
run: async (view)=>{
|
|
2243
|
+
if (!env_installed(view)) return {
|
|
2244
|
+
findings: [],
|
|
2245
|
+
skipped: NOT_INSTALLED
|
|
2246
|
+
};
|
|
2247
|
+
const source = await readText(node_path.join(view.root, 'turbo.json'));
|
|
2248
|
+
if (void 0 === source) return {
|
|
2249
|
+
findings: [],
|
|
2250
|
+
skipped: 'there is no turbo.json'
|
|
2251
|
+
};
|
|
2252
|
+
let turbo;
|
|
2253
|
+
try {
|
|
2254
|
+
turbo = JSON.parse(source);
|
|
2255
|
+
} catch {
|
|
2256
|
+
return {
|
|
2257
|
+
findings: [],
|
|
2258
|
+
skipped: 'turbo.json is not valid JSON'
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
return eachPlace(view, async (place)=>{
|
|
2262
|
+
const server = await serverKeysOf(place);
|
|
2263
|
+
const listed = turboBuildEnv(turbo, place);
|
|
2264
|
+
return (server?.keys ?? []).filter((key)=>!key.optional && !envListed(listed, key.name)).map((key)=>finding('env.turbo', 'E121', `${key.name} from ${env_at(place, 'env.ts')}`, {
|
|
2265
|
+
app: place.name,
|
|
2266
|
+
path: 'turbo.json',
|
|
2267
|
+
severity: 'warning'
|
|
2268
|
+
}));
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
const env_isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
|
|
2273
|
+
const env_strings = (value)=>Array.isArray(value) ? value.filter((v)=>'string' == typeof v) : [];
|
|
2274
|
+
function turboBuildEnv(turbo, place) {
|
|
2275
|
+
if (!env_isRecord(turbo)) return [];
|
|
2276
|
+
const tasks = env_isRecord(turbo.tasks) ? turbo.tasks : {};
|
|
2277
|
+
const own = void 0 === place.pkg ? void 0 : tasks[`${place.pkg}#build`];
|
|
2278
|
+
return [
|
|
2279
|
+
...env_strings(turbo.globalEnv),
|
|
2280
|
+
...env_isRecord(tasks.build) ? env_strings(tasks.build.env) : [],
|
|
2281
|
+
...env_isRecord(own) ? env_strings(own.env) : []
|
|
2282
|
+
];
|
|
2283
|
+
}
|
|
2284
|
+
function matchesEnv(pattern, name) {
|
|
2285
|
+
const parts = pattern.split('*').map((part)=>part.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`));
|
|
2286
|
+
return new RegExp(`^${parts.join('.*')}$`).test(name);
|
|
2287
|
+
}
|
|
2288
|
+
function envListed(patterns, name) {
|
|
2289
|
+
const included = patterns.some((p)=>!p.startsWith('!') && matchesEnv(p, name));
|
|
2290
|
+
return included && !patterns.some((p)=>p.startsWith('!') && matchesEnv(p.slice(1), name));
|
|
2291
|
+
}
|
|
2292
|
+
const envChecks = [
|
|
2293
|
+
envExample,
|
|
2294
|
+
runtimeConfig,
|
|
2295
|
+
publicPrefix,
|
|
2296
|
+
turboEnv
|
|
2297
|
+
];
|
|
2298
|
+
const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
2299
|
+
const EXTENSIONS = [
|
|
2300
|
+
'',
|
|
2301
|
+
'.ts',
|
|
2302
|
+
'.tsx',
|
|
2303
|
+
'.js',
|
|
2304
|
+
'.mjs',
|
|
2305
|
+
'/index.ts',
|
|
2306
|
+
'/index.js'
|
|
2307
|
+
];
|
|
2308
|
+
function withoutComments(code) {
|
|
2309
|
+
return code.replaceAll(/\/\*[\s\S]*?\*\//g, '').replaceAll(/(^|[^:])\/\/.*$/gm, '$1');
|
|
2310
|
+
}
|
|
2311
|
+
const FROM_CLAUSE = /\b(?:import|export)\s+(type\s+(?!from\b))?([\w\s{},*$]*?)\bfrom\s*['"]([^'"]+)['"]/g;
|
|
2312
|
+
function onlyTypes(clause) {
|
|
2313
|
+
const named = /^\s*\{([^}]*)\}\s*$/.exec(clause)?.[1];
|
|
2314
|
+
if (void 0 === named) return false;
|
|
2315
|
+
const specifiers = named.split(',').filter((part)=>'' !== part.trim());
|
|
2316
|
+
return specifiers.length > 0 && specifiers.every((part)=>/^\s*type\s+\S/.test(part));
|
|
2317
|
+
}
|
|
2318
|
+
function importsOf(code) {
|
|
2319
|
+
const clean = withoutComments(code);
|
|
2320
|
+
const found = new Set();
|
|
2321
|
+
for (const m of clean.matchAll(FROM_CLAUSE))if (void 0 === m[1] && !onlyTypes(m[2] ?? '')) found.add(m[3] ?? '');
|
|
2322
|
+
const patterns = [
|
|
2323
|
+
/\bimport\s*['"]([^'"]+)['"]/g,
|
|
2324
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
2325
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
2326
|
+
];
|
|
2327
|
+
for (const pattern of patterns)for (const m of clean.matchAll(pattern))found.add(m[1] ?? '');
|
|
2328
|
+
return [
|
|
2329
|
+
...found
|
|
2330
|
+
];
|
|
2331
|
+
}
|
|
2332
|
+
async function pluginFiles(app) {
|
|
2333
|
+
const found = new Set();
|
|
2334
|
+
const config = await readText(node_path.join(app.dir, 'rsbuild.config.ts'));
|
|
2335
|
+
const listed = void 0 === config ? null : /runtimePlugins\s*:\s*\[([^\]]*)\]/.exec(withoutComments(config));
|
|
2336
|
+
for (const m of (listed?.[1] ?? '').matchAll(/['"]([^'"]+)['"]/g)){
|
|
2337
|
+
const spec = m[1] ?? '';
|
|
2338
|
+
if (spec.startsWith('.')) found.add(node_path.resolve(app.dir, spec));
|
|
2339
|
+
}
|
|
2340
|
+
const walk = async (dir)=>{
|
|
2341
|
+
let entries;
|
|
2342
|
+
try {
|
|
2343
|
+
entries = await promises.readdir(dir, {
|
|
2344
|
+
withFileTypes: true
|
|
2345
|
+
});
|
|
2346
|
+
} catch {
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
for (const entry of entries){
|
|
2350
|
+
const full = node_path.join(dir, entry.name);
|
|
2351
|
+
if (entry.isDirectory()) await walk(full);
|
|
2352
|
+
else if (SOURCE.test(entry.name) && /runtime-plugin/.test(node_path.relative(app.dir, full))) found.add(full);
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
await walk(node_path.join(app.dir, 'src'));
|
|
2356
|
+
return [
|
|
2357
|
+
...found
|
|
2358
|
+
];
|
|
2359
|
+
}
|
|
2360
|
+
async function resolveFile(base) {
|
|
2361
|
+
for (const ext of EXTENSIONS)if (await readText(base + ext) !== void 0) return base + ext;
|
|
2362
|
+
}
|
|
2363
|
+
const runtimePlugins = {
|
|
2364
|
+
id: 'plugins.imports',
|
|
2365
|
+
title: 'runtime plugins import no shared modules',
|
|
2366
|
+
async run (view) {
|
|
2367
|
+
const shared = new Set([
|
|
2368
|
+
...SINGLETON_PACKAGES,
|
|
2369
|
+
...view.workspace.filter((p)=>!view.apps.some((a)=>a.dir === p.dir)).flatMap((p)=>p.pkg.name ? [
|
|
2370
|
+
p.pkg.name
|
|
2371
|
+
] : [])
|
|
2372
|
+
]);
|
|
2373
|
+
const out = [];
|
|
2374
|
+
for (const app of view.apps.filter((a)=>a.present)){
|
|
2375
|
+
const queue = await pluginFiles(app);
|
|
2376
|
+
const seen = new Set();
|
|
2377
|
+
for(let file = queue.shift(); void 0 !== file; file = queue.shift()){
|
|
2378
|
+
if (seen.has(file)) continue;
|
|
2379
|
+
seen.add(file);
|
|
2380
|
+
const code = await readText(file);
|
|
2381
|
+
if (void 0 !== code) {
|
|
2382
|
+
for (const spec of importsOf(code))if (spec.startsWith('.')) {
|
|
2383
|
+
const next = await resolveFile(node_path.resolve(node_path.dirname(file), spec));
|
|
2384
|
+
if (void 0 !== next) queue.push(next);
|
|
2385
|
+
} else if (!spec.startsWith('node:') && shared.has(packageNameOf(spec))) {
|
|
2386
|
+
const rel = node_path.relative(view.root, file).split(node_path.sep).join('/');
|
|
2387
|
+
out.push(finding('plugins.imports', 'E303', `${rel} imports ${spec}`, {
|
|
2388
|
+
app: app.name,
|
|
2389
|
+
path: rel
|
|
2390
|
+
}));
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
findings: out
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
const workspaceVersions = {
|
|
2401
|
+
id: 'singletons.workspace-version',
|
|
2402
|
+
title: 'every workspace package has a version',
|
|
2403
|
+
run: ({ workspace })=>done(workspace.filter((p)=>!p.hasVersion).map((p)=>finding('singletons.workspace-version', 'E111', `${p.pkg.name ?? p.rel} (${p.rel}/package.json)`, {
|
|
2404
|
+
path: `${p.rel}/package.json`,
|
|
2405
|
+
fixable: true
|
|
2406
|
+
})))
|
|
2407
|
+
};
|
|
2408
|
+
const VALIDATE_CHECKS = [
|
|
2409
|
+
appsOnDisk,
|
|
2410
|
+
mfeJson,
|
|
2411
|
+
config_routes,
|
|
2412
|
+
config_registry
|
|
2413
|
+
];
|
|
2414
|
+
const COMPAT_CHECKS = [
|
|
2415
|
+
compat
|
|
2416
|
+
];
|
|
2417
|
+
const DOCTOR_CHECKS = [
|
|
2418
|
+
appsOnDisk,
|
|
2419
|
+
mfeJson,
|
|
2420
|
+
config_ports,
|
|
2421
|
+
config_routes,
|
|
2422
|
+
config_registry,
|
|
2423
|
+
localCheckout,
|
|
2424
|
+
singletonVersions,
|
|
2425
|
+
workspaceVersions,
|
|
2426
|
+
compat,
|
|
2427
|
+
drift,
|
|
2428
|
+
runtimePlugins,
|
|
2429
|
+
...envChecks
|
|
2430
|
+
];
|
|
2431
|
+
const DOCTOR_ARGS = {
|
|
2432
|
+
fix: {
|
|
2433
|
+
type: 'boolean',
|
|
2434
|
+
description: 'Repair what is safe: missing workspace versions and registry entries'
|
|
2435
|
+
},
|
|
2436
|
+
...GLOBAL_ARGS
|
|
2437
|
+
};
|
|
2438
|
+
const VALIDATE_ARGS = {
|
|
2439
|
+
...GLOBAL_ARGS
|
|
2440
|
+
};
|
|
2441
|
+
const COMPAT_ARGS = {
|
|
2442
|
+
...GLOBAL_ARGS
|
|
2443
|
+
};
|
|
2444
|
+
const DEFAULT_VERSION = '0.0.0';
|
|
2445
|
+
function withVersion(source) {
|
|
2446
|
+
const doc = JSON.parse(source);
|
|
2447
|
+
if ('object' != typeof doc || null === doc || Array.isArray(doc)) return source;
|
|
2448
|
+
const indent = /^(\s+)"/m.exec(source)?.[1] ?? ' ';
|
|
2449
|
+
const entries = Object.entries(doc);
|
|
2450
|
+
const at = Math.max(1, entries.findIndex(([key])=>'name' === key) + 1);
|
|
2451
|
+
const next = Object.fromEntries([
|
|
2452
|
+
...entries.slice(0, at),
|
|
2453
|
+
[
|
|
2454
|
+
'version',
|
|
2455
|
+
DEFAULT_VERSION
|
|
2456
|
+
],
|
|
2457
|
+
...entries.slice(at)
|
|
2458
|
+
]);
|
|
2459
|
+
return `${JSON.stringify(next, null, indent)}\n`;
|
|
2460
|
+
}
|
|
2461
|
+
function withPorts(project, ports) {
|
|
2462
|
+
return Object.fromEntries(Object.entries(project.apps).map(([name, app])=>{
|
|
2463
|
+
const port = ports[name];
|
|
2464
|
+
return [
|
|
2465
|
+
name,
|
|
2466
|
+
void 0 === port ? app : {
|
|
2467
|
+
...app,
|
|
2468
|
+
port
|
|
2469
|
+
}
|
|
2470
|
+
];
|
|
2471
|
+
}));
|
|
2472
|
+
}
|
|
2473
|
+
async function plannedFixes(rt, view, findings, builder) {
|
|
2474
|
+
let count = 0;
|
|
2475
|
+
const assigned = {};
|
|
2476
|
+
const missingEnvs = new Map();
|
|
2477
|
+
for (const f of findings.filter((x)=>x.fixable))if ('MFEFLOW_E111' === f.code && void 0 !== f.path) {
|
|
2478
|
+
await builder.patch(f.path, withVersion);
|
|
2479
|
+
count += 1;
|
|
2480
|
+
} else if ('MFEFLOW_E114' === f.code && void 0 !== f.app && void 0 !== f.path) {
|
|
2481
|
+
const env = f.path.split('/')[2] ?? '';
|
|
2482
|
+
missingEnvs.set(f.app, [
|
|
2483
|
+
...missingEnvs.get(f.app) ?? [],
|
|
2484
|
+
env
|
|
2485
|
+
]);
|
|
2486
|
+
}
|
|
2487
|
+
for (const [name, envs] of missingEnvs){
|
|
2488
|
+
let port = view.project.apps[name]?.port;
|
|
2489
|
+
if (void 0 === port) {
|
|
2490
|
+
port = freePort({
|
|
2491
|
+
apps: withPorts(view.project, assigned)
|
|
2492
|
+
}, 4311);
|
|
2493
|
+
assigned[name] = port;
|
|
2494
|
+
}
|
|
2495
|
+
for (const file of (await registryEntryFiles(rt.roots, {
|
|
2496
|
+
name,
|
|
2497
|
+
port
|
|
2498
|
+
}, envs))){
|
|
2499
|
+
await builder.create(file.path, file.content, false);
|
|
2500
|
+
count += 1;
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
return {
|
|
2504
|
+
count,
|
|
2505
|
+
project: {
|
|
2506
|
+
...view.project,
|
|
2507
|
+
apps: withPorts(view.project, assigned)
|
|
2508
|
+
}
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
async function fixAll(rt, globals, view, findings) {
|
|
2512
|
+
const { checkout } = chooseLocal(view.project, view.root, globals.cwd, void 0);
|
|
2513
|
+
const builder = createBuilder(view.root, checkout);
|
|
2514
|
+
await builder.captureProject();
|
|
2515
|
+
const { count, project } = await plannedFixes(rt, view, findings, builder);
|
|
2516
|
+
if (0 === count) return 0;
|
|
2517
|
+
const outcome = await writePlan({
|
|
2518
|
+
rt,
|
|
2519
|
+
globals: {
|
|
2520
|
+
...globals,
|
|
2521
|
+
install: false
|
|
2522
|
+
},
|
|
2523
|
+
root: view.root,
|
|
2524
|
+
plan: builder.build(),
|
|
2525
|
+
project
|
|
2526
|
+
});
|
|
2527
|
+
return outcome.dryRun ? 0 : count;
|
|
2528
|
+
}
|
|
2529
|
+
async function runGroup(rt, args, raw, defs, command, checks, fix) {
|
|
2530
|
+
assertKnownFlags(raw, defs);
|
|
2531
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
2532
|
+
const root = await findProjectRoot(globals.cwd);
|
|
2533
|
+
let view = await inspectProject(root);
|
|
2534
|
+
let report = await runChecks(view, checks);
|
|
2535
|
+
let fixed = 0;
|
|
2536
|
+
if (fix && report.findings.some((f)=>f.fixable)) {
|
|
2537
|
+
fixed = await fixAll(rt, globals, view, report.findings);
|
|
2538
|
+
if (fixed > 0) {
|
|
2539
|
+
view = await inspectProject(root);
|
|
2540
|
+
report = await runChecks(view, checks);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
const summary = summarizeReport(report, fixed);
|
|
2544
|
+
if (!globals.json) rt.io.stdout(formatReport(report, summary));
|
|
2545
|
+
return {
|
|
2546
|
+
ok: 0 === summary.errors,
|
|
2547
|
+
command,
|
|
2548
|
+
root,
|
|
2549
|
+
summary,
|
|
2550
|
+
checks: report.checks,
|
|
2551
|
+
findings: report.findings
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
function runDoctor(rt, args, raw) {
|
|
2555
|
+
return runGroup(rt, args, raw, DOCTOR_ARGS, 'doctor', DOCTOR_CHECKS, true === args.fix);
|
|
2556
|
+
}
|
|
2557
|
+
function runValidate(rt, args, raw) {
|
|
2558
|
+
return runGroup(rt, args, raw, VALIDATE_ARGS, 'validate', VALIDATE_CHECKS, false);
|
|
2559
|
+
}
|
|
2560
|
+
function runCheckCompat(rt, args, raw) {
|
|
2561
|
+
return runGroup(rt, args, raw, COMPAT_ARGS, 'check-compat', COMPAT_CHECKS, false);
|
|
2562
|
+
}
|
|
2563
|
+
const KB = 1024;
|
|
2564
|
+
const kb = (bytes)=>round1(bytes / KB);
|
|
2565
|
+
async function gzipBytes(file) {
|
|
2566
|
+
try {
|
|
2567
|
+
return node_zlib.gzipSync(await promises.readFile(file), {
|
|
2568
|
+
level: 6
|
|
2569
|
+
}).length;
|
|
2570
|
+
} catch {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
async function sumGzip(dist, files) {
|
|
2575
|
+
const sizes = await Promise.all(files.map((f)=>gzipBytes(node_path.join(dist, ...f.split('/')))));
|
|
2576
|
+
return sizes.some((s)=>void 0 === s) ? void 0 : sizes.reduce((a, s)=>a + (s ?? 0), 0);
|
|
2577
|
+
}
|
|
2578
|
+
async function shellInitial(dist) {
|
|
2579
|
+
const html = await readText(node_path.join(dist, 'index.html'));
|
|
2580
|
+
if (void 0 === html) return;
|
|
2581
|
+
const files = [
|
|
2582
|
+
...html.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/g)
|
|
2583
|
+
].map((m)=>m[1] ?? '').filter((src)=>!/^[a-z]+:\/\//.test(src)).map((src)=>src.replace(/^\.?\//, '').split(/[?#]/)[0] ?? '');
|
|
2584
|
+
const bytes = await sumGzip(dist, files);
|
|
2585
|
+
return void 0 === bytes ? void 0 : {
|
|
2586
|
+
files,
|
|
2587
|
+
bytes
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
const sizes_isObj = (v)=>'object' == typeof v && null !== v && !Array.isArray(v);
|
|
2591
|
+
const ORIGIN = 'https://dist.invalid';
|
|
2592
|
+
const SCRIPT = /\.[cm]?js$/;
|
|
2593
|
+
function distPath(url) {
|
|
2594
|
+
try {
|
|
2595
|
+
return decodeURIComponent(new URL(url).pathname).replace(/^\//, '');
|
|
2596
|
+
} catch {
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
async function remoteSizes(dist) {
|
|
2601
|
+
const text = await readText(node_path.join(dist, 'mf-manifest.json'));
|
|
2602
|
+
if (void 0 === text) return;
|
|
2603
|
+
let manifest;
|
|
2604
|
+
try {
|
|
2605
|
+
manifest = JSON.parse(text);
|
|
2606
|
+
} catch {
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
if (!sizes_isObj(manifest) || !sizes_isObj(manifest.metaData) || !sizes_isObj(manifest.metaData.remoteEntry)) return;
|
|
2610
|
+
const local = {
|
|
2611
|
+
...manifest,
|
|
2612
|
+
metaData: {
|
|
2613
|
+
...manifest.metaData,
|
|
2614
|
+
publicPath: 'auto',
|
|
2615
|
+
getPublicPath: void 0,
|
|
2616
|
+
globalName: void 0,
|
|
2617
|
+
remoteEntry: {
|
|
2618
|
+
...manifest.metaData.remoteEntry,
|
|
2619
|
+
type: void 0
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
};
|
|
2623
|
+
let assets;
|
|
2624
|
+
try {
|
|
2625
|
+
assets = assetsFromManifest(local, `${ORIGIN}/mf-manifest.json`);
|
|
2626
|
+
} catch {
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
const entry = void 0 === assets.remoteEntry ? void 0 : distPath(assets.remoteEntry);
|
|
2630
|
+
if (void 0 === entry) return;
|
|
2631
|
+
const entryBytes = await gzipBytes(node_path.join(dist, ...entry.split('/')));
|
|
2632
|
+
if (void 0 === entryBytes) return;
|
|
2633
|
+
const exposes = {};
|
|
2634
|
+
for (const [name, urls] of Object.entries(assets.exposes)){
|
|
2635
|
+
const files = urls.flatMap((url)=>distPath(url) ?? []).filter((f)=>SCRIPT.test(f));
|
|
2636
|
+
const bytes = await sumGzip(dist, files);
|
|
2637
|
+
if (void 0 !== bytes) exposes[name] = bytes;
|
|
2638
|
+
}
|
|
2639
|
+
return {
|
|
2640
|
+
entryBytes,
|
|
2641
|
+
exposes
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
const BUDGET_ARGS = {
|
|
2645
|
+
dist: {
|
|
2646
|
+
type: 'string',
|
|
2647
|
+
description: 'Build output folder inside each app (default dist)',
|
|
2648
|
+
valueHint: 'dir'
|
|
2649
|
+
},
|
|
2650
|
+
...GLOBAL_ARGS
|
|
2651
|
+
};
|
|
2652
|
+
function modelOf(view) {
|
|
2653
|
+
const given = view.project.budget ?? {};
|
|
2654
|
+
return {
|
|
2655
|
+
lcpDeltaMs: given.lcpDeltaMs ?? COST_MODEL.lcpDeltaMs,
|
|
2656
|
+
shellKb: given.shellKb ?? COST_MODEL.shellKb,
|
|
2657
|
+
remoteKb: given.remoteKb ?? COST_MODEL.remoteKb
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
const notBuilt = (app, what)=>finding('budget', 'E305', `${app.name}: ${what} not found in ${app.rel}`, {
|
|
2661
|
+
app: app.name
|
|
2662
|
+
});
|
|
2663
|
+
async function shellBudget(app, dist, model, baselineKb, out) {
|
|
2664
|
+
const size = await shellInitial(node_path.join(app.dir, dist));
|
|
2665
|
+
if (void 0 === size) return void out.push(notBuilt(app, `${dist}/index.html and its scripts`));
|
|
2666
|
+
const initialKb = kb(size.bytes);
|
|
2667
|
+
const budgetKb = void 0 === baselineKb ? null : baselineKb + model.shellKb;
|
|
2668
|
+
const pass = void 0 === baselineKb ? null : judgeCost({
|
|
2669
|
+
shellBytes: size.bytes,
|
|
2670
|
+
monolithBytes: 1024 * baselineKb,
|
|
2671
|
+
remoteBytes: {},
|
|
2672
|
+
lcpDeltaMs: 0
|
|
2673
|
+
}, model).shell.pass;
|
|
2674
|
+
if (false === pass) out.push(finding('budget', 'E304', `${app.name} loads ${String(initialKb)} KB of JS first; your one-app baseline is ${String(baselineKb)} KB and the shell may add ${String(model.shellKb)} KB`, {
|
|
2675
|
+
app: app.name
|
|
2676
|
+
}));
|
|
2677
|
+
return {
|
|
2678
|
+
name: app.name,
|
|
2679
|
+
initialKb,
|
|
2680
|
+
scripts: size.files.length,
|
|
2681
|
+
baselineKb: baselineKb ?? null,
|
|
2682
|
+
budgetKb,
|
|
2683
|
+
pass
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
async function remoteBudget(app, dist, model, out) {
|
|
2687
|
+
const size = await remoteSizes(node_path.join(app.dir, dist));
|
|
2688
|
+
if (void 0 === size) return void out.push(notBuilt(app, `${dist}/mf-manifest.json and its entry`));
|
|
2689
|
+
const entryKb = kb(size.entryBytes);
|
|
2690
|
+
const pass = judgeCost({
|
|
2691
|
+
shellBytes: 0,
|
|
2692
|
+
monolithBytes: 0,
|
|
2693
|
+
remoteBytes: {
|
|
2694
|
+
[app.name]: size.entryBytes
|
|
2695
|
+
},
|
|
2696
|
+
lcpDeltaMs: 0
|
|
2697
|
+
}, model).remotes.every((r)=>r.pass);
|
|
2698
|
+
if (!pass) out.push(finding('budget', 'E304', `${app.name}'s entry is ${String(entryKb)} KB; a remote may add ${String(model.remoteKb)} KB`, {
|
|
2699
|
+
app: app.name
|
|
2700
|
+
}));
|
|
2701
|
+
const own = 'ok' === app.mfe.state ? app.mfe.value : void 0;
|
|
2702
|
+
const budgetKb = own?.budgets?.initialJsKb ?? null;
|
|
2703
|
+
const exposeNames = Object.keys(size.exposes);
|
|
2704
|
+
const expose = exposeNames.includes('./App') ? './App' : exposeNames[0];
|
|
2705
|
+
const routes = (own?.routes ?? []).map((route)=>{
|
|
2706
|
+
const total = round1((size.entryBytes + (void 0 === expose ? 0 : size.exposes[expose] ?? 0)) / 1024);
|
|
2707
|
+
const routePass = null === budgetKb ? null : total <= budgetKb;
|
|
2708
|
+
if (false === routePass) out.push(finding('budget', 'E304', `${app.name} on ${route} loads ${String(total)} KB; mfe.json budgets.initialJsKb is ${String(budgetKb)}`, {
|
|
2709
|
+
app: app.name,
|
|
2710
|
+
path: `${app.rel}/mfe.json`
|
|
2711
|
+
}));
|
|
2712
|
+
return {
|
|
2713
|
+
route,
|
|
2714
|
+
expose: expose ?? null,
|
|
2715
|
+
kb: total,
|
|
2716
|
+
budgetKb,
|
|
2717
|
+
pass: routePass
|
|
2718
|
+
};
|
|
2719
|
+
});
|
|
2720
|
+
return {
|
|
2721
|
+
name: app.name,
|
|
2722
|
+
entryKb,
|
|
2723
|
+
budgetKb: model.remoteKb,
|
|
2724
|
+
pass,
|
|
2725
|
+
routes
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
async function runBudget(rt, args, raw) {
|
|
2729
|
+
assertKnownFlags(raw, BUDGET_ARGS);
|
|
2730
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
2731
|
+
const dist = 'string' == typeof args.dist && '' !== args.dist ? args.dist : 'dist';
|
|
2732
|
+
const view = await inspectProject(await findProjectRoot(globals.cwd));
|
|
2733
|
+
const model = modelOf(view);
|
|
2734
|
+
const findings = [];
|
|
2735
|
+
const baseline = view.project.budget?.baselineKb;
|
|
2736
|
+
const present = view.apps.filter((a)=>a.present);
|
|
2737
|
+
const shells = (await Promise.all(present.filter((a)=>'shell' === a.kind).map((a)=>shellBudget(a, dist, model, baseline, findings)))).filter((s)=>void 0 !== s);
|
|
2738
|
+
const remotes = (await Promise.all(present.filter((a)=>'remote' === a.kind).map((a)=>remoteBudget(a, dist, model, findings)))).filter((r)=>void 0 !== r);
|
|
2739
|
+
const result = {
|
|
2740
|
+
ok: 0 === exitForFindings(findings),
|
|
2741
|
+
command: 'budget',
|
|
2742
|
+
root: view.root,
|
|
2743
|
+
model,
|
|
2744
|
+
shells,
|
|
2745
|
+
remotes,
|
|
2746
|
+
findings
|
|
2747
|
+
};
|
|
2748
|
+
if (!globals.json) rt.io.stdout(formatBudget(result));
|
|
2749
|
+
return result;
|
|
2750
|
+
}
|
|
2751
|
+
function formatBudget(result) {
|
|
2752
|
+
const lines = [
|
|
2753
|
+
'Initial JS, gzip (1 KB = 1024 bytes):'
|
|
2754
|
+
];
|
|
2755
|
+
for (const s of result.shells){
|
|
2756
|
+
const limit = null === s.budgetKb ? 'no baseline set (budget.baselineKb), not checked' : `limit ${String(s.budgetKb)}`;
|
|
2757
|
+
lines.push(` shell ${s.name}: ${String(s.initialKb)} KB in ${String(s.scripts)} scripts, ${limit}`);
|
|
2758
|
+
}
|
|
2759
|
+
for (const r of result.remotes){
|
|
2760
|
+
lines.push(` remote ${r.name}: entry ${String(r.entryKb)} KB, limit ${String(r.budgetKb)}`);
|
|
2761
|
+
for (const route of r.routes){
|
|
2762
|
+
const limit = null === route.budgetKb ? 'no budget' : `limit ${String(route.budgetKb)}`;
|
|
2763
|
+
lines.push(` ${route.route} (${route.expose ?? 'no expose'}): ${String(route.kb)} KB, ${limit}`);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
lines.push(`LCP (+${String(result.model.lcpDeltaMs)} ms) is measured by the perf job, not here.`);
|
|
2767
|
+
for (const f of result.findings)lines.push('', `error ${f.code} ${f.message}`, ` Fix: ${f.fix}`, ` ${f.docs}`);
|
|
2768
|
+
return lines.join('\n');
|
|
2769
|
+
}
|
|
2770
|
+
const LOCAL_HOSTS = new Set([
|
|
2771
|
+
'localhost',
|
|
2772
|
+
'127.0.0.1',
|
|
2773
|
+
'[::1]'
|
|
2774
|
+
]);
|
|
2775
|
+
function parseOverrides(flags, remotes) {
|
|
2776
|
+
const out = new Map();
|
|
2777
|
+
for (const flag of flags){
|
|
2778
|
+
const equals = flag.indexOf('=');
|
|
2779
|
+
if (equals < 1) throw new MfeflowError('E107', `--remote "${flag}" must look like name=url`);
|
|
2780
|
+
const name = flag.slice(0, equals);
|
|
2781
|
+
const raw = flag.slice(equals + 1);
|
|
2782
|
+
if (!remotes.includes(name)) throw new MfeflowError('E107', `--remote ${name}: no remote called "${name}" (remotes: ${remotes.join(', ')})`);
|
|
2783
|
+
let url;
|
|
2784
|
+
try {
|
|
2785
|
+
url = new URL(raw);
|
|
2786
|
+
} catch {
|
|
2787
|
+
throw new MfeflowError('E107', `--remote ${name}=${raw}: not a URL`);
|
|
2788
|
+
}
|
|
2789
|
+
if ('http:' !== url.protocol && 'https:' !== url.protocol) throw new MfeflowError('E107', `--remote ${name}=${raw}: only http and https URLs`);
|
|
2790
|
+
if ('/' === url.pathname && '' === url.search && '' === url.hash) url = new URL('/mf-manifest.json', url);
|
|
2791
|
+
out.set(name, url.href);
|
|
2792
|
+
}
|
|
2793
|
+
return out;
|
|
2794
|
+
}
|
|
2795
|
+
function assertAllowed(overrides, allowed) {
|
|
2796
|
+
const origins = new Set(allowed.map((o)=>originOf(o)));
|
|
2797
|
+
for (const [name, href] of overrides){
|
|
2798
|
+
const url = new URL(href);
|
|
2799
|
+
if (!LOCAL_HOSTS.has(url.hostname) && !origins.has(url.origin)) throw new MfeflowError('E107', `--remote ${name}=${href} is not on localhost; pass --allow-origin ${url.origin} to allow it`);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
function originOf(value) {
|
|
2803
|
+
try {
|
|
2804
|
+
return new URL(value).origin;
|
|
2805
|
+
} catch {
|
|
2806
|
+
throw new MfeflowError('E107', `--allow-origin "${value}" is not an origin like https://dev.example.com`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
function devIndex(view, overrides = new Map()) {
|
|
2810
|
+
const remotes = view.apps.filter((app)=>'remote' === app.kind).toSorted((a, b)=>a.name < b.name ? -1 : a.name > b.name ? 1 : 0).map((app)=>{
|
|
2811
|
+
const entry = view.registry.dev?.[app.name];
|
|
2812
|
+
const fromRegistry = entry?.state === 'ok' ? entry.value : void 0;
|
|
2813
|
+
const fallback = void 0 === app.port ? '' : `http://localhost:${String(app.port)}/mf-manifest.json`;
|
|
2814
|
+
return {
|
|
2815
|
+
name: app.name,
|
|
2816
|
+
entry: overrides.get(app.name) ?? fromRegistry?.manifestUrl ?? fallback,
|
|
2817
|
+
version: overrides.has(app.name) ? 'override' : fromRegistry?.version ?? 'dev'
|
|
2818
|
+
};
|
|
2819
|
+
}).filter((remote)=>'' !== remote.entry);
|
|
2820
|
+
return {
|
|
2821
|
+
remotes
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
const serializeIndex = (index)=>`${JSON.stringify(index, null, 2)}\n`;
|
|
2825
|
+
const DEFAULT_WAIT = {
|
|
2826
|
+
intervalMs: 2000,
|
|
2827
|
+
timeoutMs: 300000
|
|
2828
|
+
};
|
|
2829
|
+
const GRACE_MS = 5000;
|
|
2830
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
2831
|
+
function resolveCommand(command, env) {
|
|
2832
|
+
const win = 'win32' === env.platform;
|
|
2833
|
+
const flavour = win ? node_path.win32 : node_path.posix;
|
|
2834
|
+
if (command.includes('/') || win && command.includes('\\')) return flavour.isAbsolute(command) ? command : flavour.resolve(command);
|
|
2835
|
+
const extensions = win ? (env.pathext ?? DEFAULT_PATHEXT).split(';').filter((ext)=>'' !== ext) : [];
|
|
2836
|
+
const hasExtension = extensions.some((ext)=>command.toLowerCase().endsWith(ext.toLowerCase()));
|
|
2837
|
+
const names = win ? hasExtension ? [
|
|
2838
|
+
command
|
|
2839
|
+
] : extensions.map((ext)=>command + ext) : [
|
|
2840
|
+
command
|
|
2841
|
+
];
|
|
2842
|
+
for (const dir of (env.path ?? '').split(flavour.delimiter))if ('' !== dir) for (const name of names){
|
|
2843
|
+
const file = flavour.join(dir, name);
|
|
2844
|
+
if (env.isFile(file)) return file;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
function isRunnableFile(file) {
|
|
2848
|
+
try {
|
|
2849
|
+
if (!node_fs.statSync(file).isFile()) return false;
|
|
2850
|
+
if ('win32' !== process.platform) node_fs.accessSync(file, node_fs.constants.X_OK);
|
|
2851
|
+
return true;
|
|
2852
|
+
} catch {
|
|
2853
|
+
return false;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
const resolved = new Map();
|
|
2857
|
+
function absoluteCommand(command) {
|
|
2858
|
+
if (!resolved.has(command)) {
|
|
2859
|
+
const key = Object.keys(process.env).find((k)=>'PATH' === k.toUpperCase());
|
|
2860
|
+
resolved.set(command, resolveCommand(command, {
|
|
2861
|
+
path: void 0 === key ? void 0 : process.env[key],
|
|
2862
|
+
pathext: process.env.PATHEXT,
|
|
2863
|
+
platform: process.platform,
|
|
2864
|
+
isFile: isRunnableFile
|
|
2865
|
+
}));
|
|
2866
|
+
}
|
|
2867
|
+
return resolved.get(command) ?? command;
|
|
2868
|
+
}
|
|
2869
|
+
const taskkillExe = ()=>node_path.win32.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe');
|
|
2870
|
+
const alive = (kill, pid)=>{
|
|
2871
|
+
try {
|
|
2872
|
+
kill(-pid, 0);
|
|
2873
|
+
return true;
|
|
2874
|
+
} catch {
|
|
2875
|
+
return false;
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
2879
|
+
async function killTree(pid, how) {
|
|
2880
|
+
if ('win32' === how.platform) return void await how.taskkill(pid);
|
|
2881
|
+
for (const signal of [
|
|
2882
|
+
'SIGTERM',
|
|
2883
|
+
'SIGKILL'
|
|
2884
|
+
]){
|
|
2885
|
+
try {
|
|
2886
|
+
how.kill(-pid, signal);
|
|
2887
|
+
} catch {
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
const deadline = Date.now() + ('SIGTERM' === signal ? how.graceMs : 2000);
|
|
2891
|
+
while(alive(how.kill, pid) && Date.now() < deadline)await sleep(50);
|
|
2892
|
+
if (!alive(how.kill, pid)) return;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
const realKiller = {
|
|
2896
|
+
platform: process.platform,
|
|
2897
|
+
kill: (pid, signal)=>process.kill(pid, signal),
|
|
2898
|
+
taskkill: (pid)=>new Promise((resolve)=>{
|
|
2899
|
+
const child = spawn(taskkillExe(), [
|
|
2900
|
+
'/pid',
|
|
2901
|
+
String(pid),
|
|
2902
|
+
'/T',
|
|
2903
|
+
'/F'
|
|
2904
|
+
], {
|
|
2905
|
+
stdio: 'ignore'
|
|
2906
|
+
});
|
|
2907
|
+
child.on('error', ()=>{
|
|
2908
|
+
resolve();
|
|
2909
|
+
});
|
|
2910
|
+
child.on('close', ()=>{
|
|
2911
|
+
resolve();
|
|
2912
|
+
});
|
|
2913
|
+
}),
|
|
2914
|
+
graceMs: GRACE_MS
|
|
2915
|
+
};
|
|
2916
|
+
function wrap(child, killer) {
|
|
2917
|
+
const exit = new Promise((resolve)=>{
|
|
2918
|
+
child.once('close', ()=>{
|
|
2919
|
+
resolve();
|
|
2920
|
+
});
|
|
2921
|
+
child.once('error', ()=>{
|
|
2922
|
+
resolve();
|
|
2923
|
+
});
|
|
2924
|
+
});
|
|
2925
|
+
return {
|
|
2926
|
+
pid: child.pid,
|
|
2927
|
+
onOutput (listener) {
|
|
2928
|
+
for (const stream of [
|
|
2929
|
+
child.stdout,
|
|
2930
|
+
child.stderr
|
|
2931
|
+
])stream?.on('data', (chunk)=>{
|
|
2932
|
+
listener(chunk.toString('utf8'));
|
|
2933
|
+
});
|
|
2934
|
+
},
|
|
2935
|
+
onExit (listener) {
|
|
2936
|
+
child.once('close', (code)=>{
|
|
2937
|
+
listener(code);
|
|
2938
|
+
});
|
|
2939
|
+
child.once('error', ()=>{
|
|
2940
|
+
listener(null);
|
|
2941
|
+
});
|
|
2942
|
+
},
|
|
2943
|
+
async stop () {
|
|
2944
|
+
if (void 0 !== child.pid) await killTree(child.pid, killer);
|
|
2945
|
+
await Promise.race([
|
|
2946
|
+
exit,
|
|
2947
|
+
sleep(2000)
|
|
2948
|
+
]);
|
|
2949
|
+
}
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
function createReaper(proc, killNow) {
|
|
2953
|
+
const live = new Set();
|
|
2954
|
+
let listening = false;
|
|
2955
|
+
return {
|
|
2956
|
+
track (pid) {
|
|
2957
|
+
if (!listening) {
|
|
2958
|
+
listening = true;
|
|
2959
|
+
proc.on('exit', ()=>{
|
|
2960
|
+
for (const left of live)killNow(left);
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
live.add(pid);
|
|
2964
|
+
},
|
|
2965
|
+
untrack (pid) {
|
|
2966
|
+
live.delete(pid);
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
}
|
|
2970
|
+
const reaper = createReaper(process, (pid)=>{
|
|
2971
|
+
if ('win32' === process.platform) return void spawnSync(taskkillExe(), [
|
|
2972
|
+
'/pid',
|
|
2973
|
+
String(pid),
|
|
2974
|
+
'/T',
|
|
2975
|
+
'/F'
|
|
2976
|
+
], {
|
|
2977
|
+
stdio: 'ignore'
|
|
2978
|
+
});
|
|
2979
|
+
try {
|
|
2980
|
+
process.kill(-pid, 'SIGKILL');
|
|
2981
|
+
} catch {}
|
|
2982
|
+
});
|
|
2983
|
+
const spawnDev = (spec)=>{
|
|
2984
|
+
const command = absoluteCommand(spec.command);
|
|
2985
|
+
const shell = 'win32' === process.platform && (!node_path.isAbsolute(command) || /\.(?:cmd|bat)$/i.test(command));
|
|
2986
|
+
const child = spawn(shell && /\s/.test(command) ? `"${command}"` : command, [
|
|
2987
|
+
...spec.args
|
|
2988
|
+
], {
|
|
2989
|
+
cwd: spec.cwd,
|
|
2990
|
+
env: {
|
|
2991
|
+
...process.env,
|
|
2992
|
+
...spec.env
|
|
2993
|
+
},
|
|
2994
|
+
stdio: [
|
|
2995
|
+
'ignore',
|
|
2996
|
+
'pipe',
|
|
2997
|
+
'pipe'
|
|
2998
|
+
],
|
|
2999
|
+
detached: 'win32' !== process.platform,
|
|
3000
|
+
shell
|
|
3001
|
+
});
|
|
3002
|
+
const proc = wrap(child, realKiller);
|
|
3003
|
+
if (void 0 === child.pid) return proc;
|
|
3004
|
+
const { pid } = child;
|
|
3005
|
+
reaper.track(pid);
|
|
3006
|
+
child.once('close', ()=>{
|
|
3007
|
+
if (!alive(realKiller.kill, pid)) reaper.untrack(pid);
|
|
3008
|
+
});
|
|
3009
|
+
return {
|
|
3010
|
+
...proc,
|
|
3011
|
+
async stop () {
|
|
3012
|
+
await proc.stop();
|
|
3013
|
+
reaper.untrack(pid);
|
|
3014
|
+
}
|
|
3015
|
+
};
|
|
3016
|
+
};
|
|
3017
|
+
function portFree(port) {
|
|
3018
|
+
return new Promise((resolve)=>{
|
|
3019
|
+
const probe = node_net.createServer();
|
|
3020
|
+
probe.once('error', ()=>{
|
|
3021
|
+
resolve(false);
|
|
3022
|
+
});
|
|
3023
|
+
probe.listen(port, ()=>{
|
|
3024
|
+
probe.close(()=>{
|
|
3025
|
+
resolve(true);
|
|
3026
|
+
});
|
|
3027
|
+
});
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
const DEBOUNCE_MS = 150;
|
|
3031
|
+
function watchPaths(paths, listener) {
|
|
3032
|
+
let timer;
|
|
3033
|
+
const fire = ()=>{
|
|
3034
|
+
clearTimeout(timer);
|
|
3035
|
+
timer = setTimeout(listener, DEBOUNCE_MS);
|
|
3036
|
+
};
|
|
3037
|
+
const watchers = [];
|
|
3038
|
+
for (const target of paths){
|
|
3039
|
+
let folder = target;
|
|
3040
|
+
let only;
|
|
3041
|
+
try {
|
|
3042
|
+
if (!node_fs.statSync(target).isDirectory()) {
|
|
3043
|
+
folder = node_path.dirname(target);
|
|
3044
|
+
only = node_path.basename(target);
|
|
3045
|
+
}
|
|
3046
|
+
} catch {
|
|
3047
|
+
continue;
|
|
3048
|
+
}
|
|
3049
|
+
try {
|
|
3050
|
+
watchers.push(node_fs.watch(folder, (_event, name)=>{
|
|
3051
|
+
if (void 0 === only || null === name || name === only) fire();
|
|
3052
|
+
}));
|
|
3053
|
+
} catch {}
|
|
3054
|
+
}
|
|
3055
|
+
return ()=>{
|
|
3056
|
+
clearTimeout(timer);
|
|
3057
|
+
for (const watcher of watchers)watcher.close();
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
function onInterrupt(listener, source = process) {
|
|
3061
|
+
const signals = [
|
|
3062
|
+
'SIGINT',
|
|
3063
|
+
'SIGTERM',
|
|
3064
|
+
'SIGHUP'
|
|
3065
|
+
];
|
|
3066
|
+
for (const signal of signals)source.on(signal, listener);
|
|
3067
|
+
return ()=>{
|
|
3068
|
+
for (const signal of signals)source.off(signal, listener);
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
function realDevHost() {
|
|
3072
|
+
return {
|
|
3073
|
+
spawn: spawnDev,
|
|
3074
|
+
onInterrupt: onInterrupt,
|
|
3075
|
+
watch: watchPaths,
|
|
3076
|
+
portFree: portFree
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
const DEV_ARGS = {
|
|
3080
|
+
only: {
|
|
3081
|
+
type: 'string',
|
|
3082
|
+
description: 'Run only these apps, comma separated (default: the shell and every remote)',
|
|
3083
|
+
valueHint: 'a,b'
|
|
3084
|
+
},
|
|
3085
|
+
remote: {
|
|
3086
|
+
type: 'string',
|
|
3087
|
+
description: 'Use a server you already run for a remote, repeatable: --remote checkout=http://localhost:3001',
|
|
3088
|
+
valueHint: 'name=url'
|
|
3089
|
+
},
|
|
3090
|
+
'allow-origin': {
|
|
3091
|
+
type: 'string',
|
|
3092
|
+
description: 'Let --remote point at this origin; localhost is always allowed. Repeatable',
|
|
3093
|
+
valueHint: 'origin'
|
|
3094
|
+
},
|
|
3095
|
+
...GLOBAL_ARGS
|
|
3096
|
+
};
|
|
3097
|
+
const PALETTE = [
|
|
3098
|
+
36,
|
|
3099
|
+
35,
|
|
3100
|
+
33,
|
|
3101
|
+
32,
|
|
3102
|
+
34,
|
|
3103
|
+
91
|
|
3104
|
+
];
|
|
3105
|
+
const REGISTRY_DIR = '.mfeflow';
|
|
3106
|
+
const REGISTRY_FILE = 'dev-registry.json';
|
|
3107
|
+
async function writeIfChanged(file, text) {
|
|
3108
|
+
if (await readText(file) === text) return false;
|
|
3109
|
+
await promises.mkdir(node_path.dirname(file), {
|
|
3110
|
+
recursive: true
|
|
3111
|
+
});
|
|
3112
|
+
const temp = `${file}.${String(process.pid)}.tmp`;
|
|
3113
|
+
await promises.writeFile(temp, text, 'utf8');
|
|
3114
|
+
await promises.rename(temp, file);
|
|
3115
|
+
return true;
|
|
3116
|
+
}
|
|
3117
|
+
class Session {
|
|
3118
|
+
host;
|
|
3119
|
+
out;
|
|
3120
|
+
registryFile;
|
|
3121
|
+
names;
|
|
3122
|
+
running = new Map();
|
|
3123
|
+
buffers = new Map();
|
|
3124
|
+
stopping = false;
|
|
3125
|
+
failed = new Set();
|
|
3126
|
+
finish;
|
|
3127
|
+
done = new Promise((resolve)=>{
|
|
3128
|
+
this.finish = resolve;
|
|
3129
|
+
});
|
|
3130
|
+
width;
|
|
3131
|
+
color;
|
|
3132
|
+
constructor(host, io, out, registryFile, names){
|
|
3133
|
+
this.host = host;
|
|
3134
|
+
this.out = out;
|
|
3135
|
+
this.registryFile = registryFile;
|
|
3136
|
+
this.names = names;
|
|
3137
|
+
this.width = Math.max(...names.map((n)=>n.length), 4);
|
|
3138
|
+
this.color = void 0 === io.env.NO_COLOR && (io.interactive || void 0 !== io.env.FORCE_COLOR);
|
|
3139
|
+
}
|
|
3140
|
+
get hasFailed() {
|
|
3141
|
+
return this.failed.size > 0;
|
|
3142
|
+
}
|
|
3143
|
+
has(name) {
|
|
3144
|
+
return this.running.has(name);
|
|
3145
|
+
}
|
|
3146
|
+
runningNames() {
|
|
3147
|
+
return [
|
|
3148
|
+
...this.running.keys()
|
|
3149
|
+
];
|
|
3150
|
+
}
|
|
3151
|
+
prefix(name) {
|
|
3152
|
+
const index = Math.max(0, this.names.indexOf(name));
|
|
3153
|
+
const label = name.padEnd(this.width);
|
|
3154
|
+
const code = PALETTE[index % PALETTE.length] ?? 36;
|
|
3155
|
+
return this.color ? `\u001B[${String(code)}m${label} |\u001B[0m ` : `${label} | `;
|
|
3156
|
+
}
|
|
3157
|
+
line(name, text) {
|
|
3158
|
+
this.out(`${this.prefix(name)}${text}`);
|
|
3159
|
+
}
|
|
3160
|
+
feed(name, chunk) {
|
|
3161
|
+
const text = (this.buffers.get(name) ?? '') + chunk;
|
|
3162
|
+
const lines = text.split(/\r?\n/);
|
|
3163
|
+
this.buffers.set(name, lines.pop() ?? '');
|
|
3164
|
+
for (const line of lines)if ('' !== line.trim()) this.line(name, line);
|
|
3165
|
+
}
|
|
3166
|
+
flush(name) {
|
|
3167
|
+
const rest = this.buffers.get(name) ?? '';
|
|
3168
|
+
this.buffers.delete(name);
|
|
3169
|
+
if ('' !== rest.trim()) this.line(name, rest);
|
|
3170
|
+
}
|
|
3171
|
+
start(app, packageManager) {
|
|
3172
|
+
const child = this.host.spawn({
|
|
3173
|
+
command: packageManager,
|
|
3174
|
+
args: [
|
|
3175
|
+
'run',
|
|
3176
|
+
'dev'
|
|
3177
|
+
],
|
|
3178
|
+
cwd: app.dir,
|
|
3179
|
+
env: {
|
|
3180
|
+
MFEFLOW_DEV_REGISTRY: this.registryFile,
|
|
3181
|
+
...this.color ? {
|
|
3182
|
+
FORCE_COLOR: '1'
|
|
3183
|
+
} : {}
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
this.running.set(app.name, child);
|
|
3187
|
+
this.failed.delete(app.name);
|
|
3188
|
+
child.onOutput((chunk)=>{
|
|
3189
|
+
this.feed(app.name, chunk);
|
|
3190
|
+
});
|
|
3191
|
+
child.onExit((code)=>{
|
|
3192
|
+
this.flush(app.name);
|
|
3193
|
+
if (this.running.get(app.name) !== child) return;
|
|
3194
|
+
this.running.delete(app.name);
|
|
3195
|
+
if (!this.stopping) {
|
|
3196
|
+
this.line(app.name, 0 === code ? 'stopped' : `exited with code ${String(code)}`);
|
|
3197
|
+
if (0 !== code) this.failed.add(app.name);
|
|
3198
|
+
if (0 === this.running.size) this.finish?.();
|
|
3199
|
+
}
|
|
3200
|
+
});
|
|
3201
|
+
}
|
|
3202
|
+
async stopOne(name) {
|
|
3203
|
+
const child = this.running.get(name);
|
|
3204
|
+
if (void 0 === child) return;
|
|
3205
|
+
this.running.delete(name);
|
|
3206
|
+
await child.stop();
|
|
3207
|
+
this.flush(name);
|
|
3208
|
+
}
|
|
3209
|
+
async stopAll() {
|
|
3210
|
+
this.stopping = true;
|
|
3211
|
+
await Promise.all([
|
|
3212
|
+
...this.running.keys()
|
|
3213
|
+
].map((name)=>this.stopOne(name)));
|
|
3214
|
+
this.finish?.();
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
function describe(app, packageManager) {
|
|
3218
|
+
return {
|
|
3219
|
+
name: app.name,
|
|
3220
|
+
kind: app.kind,
|
|
3221
|
+
port: app.port ?? null,
|
|
3222
|
+
url: void 0 === app.port ? null : `http://localhost:${String(app.port)}`,
|
|
3223
|
+
command: `${packageManager} run dev`,
|
|
3224
|
+
status: 'started'
|
|
3225
|
+
};
|
|
3226
|
+
}
|
|
3227
|
+
async function isLinked(app, root, name) {
|
|
3228
|
+
let dir = app.dir;
|
|
3229
|
+
while(!node_path.relative(root, dir).startsWith('..')){
|
|
3230
|
+
try {
|
|
3231
|
+
await promises.access(node_path.join(dir, 'node_modules', ...name.split('/')));
|
|
3232
|
+
return true;
|
|
3233
|
+
} catch {}
|
|
3234
|
+
const parent = node_path.dirname(dir);
|
|
3235
|
+
if (parent === dir || dir === root) break;
|
|
3236
|
+
dir = parent;
|
|
3237
|
+
}
|
|
3238
|
+
return false;
|
|
3239
|
+
}
|
|
3240
|
+
async function hasDependencies(app, root) {
|
|
3241
|
+
const text = await readText(node_path.join(app.dir, 'package.json'));
|
|
3242
|
+
let doc;
|
|
3243
|
+
try {
|
|
3244
|
+
doc = void 0 === text ? void 0 : JSON.parse(text);
|
|
3245
|
+
} catch {
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
if ('object' != typeof doc || null === doc) return false;
|
|
3249
|
+
const names = [
|
|
3250
|
+
'dependencies',
|
|
3251
|
+
'devDependencies'
|
|
3252
|
+
].flatMap((key)=>{
|
|
3253
|
+
const group = Reflect.get(doc, key);
|
|
3254
|
+
return 'object' == typeof group && null !== group ? Object.keys(group) : [];
|
|
3255
|
+
});
|
|
3256
|
+
const linked = await Promise.all(names.map((name)=>isLinked(app, root, name)));
|
|
3257
|
+
return linked.every(Boolean);
|
|
3258
|
+
}
|
|
3259
|
+
async function busyPorts(host, apps) {
|
|
3260
|
+
const checked = await Promise.all(apps.map(async (app)=>void 0 === app.port ? void 0 : await host.portFree(app.port) ? void 0 : app));
|
|
3261
|
+
return checked.filter((a)=>void 0 !== a);
|
|
3262
|
+
}
|
|
3263
|
+
function assertPortsFree(busy) {
|
|
3264
|
+
if (0 === busy.length) return;
|
|
3265
|
+
throw new MfeflowError('E306', busy.map((a)=>`${String(a.port)} is in use (${a.name})`).join('; '), {
|
|
3266
|
+
fix: "Stop whatever listens there (an earlier `mfeflow dev`?), or change the port in mfeflow.json and the app's rsbuild config."
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
async function runDev(rt, args, raw) {
|
|
3270
|
+
assertKnownFlags(raw, DEV_ARGS);
|
|
3271
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
3272
|
+
const host = rt.dev ?? realDevHost();
|
|
3273
|
+
const root = await findProjectRoot(globals.cwd);
|
|
3274
|
+
let view = await inspectProject(root);
|
|
3275
|
+
const { packageManager } = view.project;
|
|
3276
|
+
const known = view.apps.map((a)=>a.name);
|
|
3277
|
+
const only = splitList('string' == typeof args.only ? args.only : void 0);
|
|
3278
|
+
const unknown = (only ?? []).filter((n)=>!known.includes(n));
|
|
3279
|
+
if (unknown.length > 0) throw new MfeflowError('E107', `--only ${unknown.join(', ')}: no such app (apps: ${known.join(', ')})`);
|
|
3280
|
+
const overrides = parseOverrides(repeated(raw, 'remote'), view.apps.filter((a)=>'remote' === a.kind).map((a)=>a.name));
|
|
3281
|
+
assertAllowed(overrides, repeated(raw, 'allow-origin'));
|
|
3282
|
+
const wanted = (v)=>v.apps.filter((a)=>a.present && !overrides.has(a.name) && (void 0 === only || only.includes(a.name)));
|
|
3283
|
+
const first = wanted(view);
|
|
3284
|
+
if (0 === first.length) throw new MfeflowError('E107', 'nothing to run: every app is missing, filtered out by --only, or overridden');
|
|
3285
|
+
const registryFile = node_path.join(root, REGISTRY_DIR, REGISTRY_FILE);
|
|
3286
|
+
const out = (text)=>{
|
|
3287
|
+
if (globals.json) rt.io.stderr(text);
|
|
3288
|
+
else rt.io.stdout(text);
|
|
3289
|
+
};
|
|
3290
|
+
const apps = [
|
|
3291
|
+
...first.map((a)=>describe(a, packageManager)),
|
|
3292
|
+
...[
|
|
3293
|
+
...overrides.keys()
|
|
3294
|
+
].map((name)=>({
|
|
3295
|
+
name,
|
|
3296
|
+
kind: 'remote',
|
|
3297
|
+
port: null,
|
|
3298
|
+
url: overrides.get(name) ?? null,
|
|
3299
|
+
command: '',
|
|
3300
|
+
status: 'overridden'
|
|
3301
|
+
}))
|
|
3302
|
+
];
|
|
3303
|
+
const base = {
|
|
3304
|
+
ok: true,
|
|
3305
|
+
command: 'dev',
|
|
3306
|
+
root,
|
|
3307
|
+
registry: registryFile,
|
|
3308
|
+
apps,
|
|
3309
|
+
overrides: Object.fromEntries(overrides)
|
|
3310
|
+
};
|
|
3311
|
+
if (globals.dryRun) {
|
|
3312
|
+
if (!globals.json) {
|
|
3313
|
+
for (const app of apps)out('overridden' === app.status ? `${app.name}: uses ${app.url ?? ''}` : `${app.name}: ${app.command} in ${view.apps.find((a)=>a.name === app.name)?.rel ?? ''} (port ${String(app.port)})`);
|
|
3314
|
+
out(`dev registry: ${JSON.stringify(devIndex(view, overrides).remotes.map((r)=>`${r.name} -> ${r.entry}`))}`);
|
|
3315
|
+
}
|
|
3316
|
+
return {
|
|
3317
|
+
...base,
|
|
3318
|
+
dryRun: true,
|
|
3319
|
+
interrupted: false
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
3322
|
+
assertPortsFree(await busyPorts(host, first));
|
|
3323
|
+
await writeIfChanged(registryFile, serializeIndex(devIndex(view, overrides)));
|
|
3324
|
+
const session = new Session(host, rt.io, out, registryFile, known);
|
|
3325
|
+
let busy = Promise.resolve();
|
|
3326
|
+
const waiting = new Set();
|
|
3327
|
+
const policy = host.waitPolicy ?? DEFAULT_WAIT;
|
|
3328
|
+
let poll;
|
|
3329
|
+
let waitingSince = 0;
|
|
3330
|
+
let closed = false;
|
|
3331
|
+
const stopPolling = ()=>{
|
|
3332
|
+
clearInterval(poll);
|
|
3333
|
+
poll = void 0;
|
|
3334
|
+
};
|
|
3335
|
+
const keepPolling = ()=>{
|
|
3336
|
+
if (0 === waiting.size || closed) stopPolling();
|
|
3337
|
+
else if (void 0 === poll) {
|
|
3338
|
+
waitingSince = Date.now();
|
|
3339
|
+
poll = setInterval(()=>reload(true), policy.intervalMs);
|
|
3340
|
+
} else if (Date.now() - waitingSince > policy.timeoutMs) {
|
|
3341
|
+
out(`mfeflow: gave up waiting for ${[
|
|
3342
|
+
...waiting
|
|
3343
|
+
].join(', ')} to be installed. Run \`${packageManager} install\`, then save mfeflow.json or run \`mfeflow dev\` again.`);
|
|
3344
|
+
waiting.clear();
|
|
3345
|
+
stopPolling();
|
|
3346
|
+
}
|
|
3347
|
+
};
|
|
3348
|
+
const reload = (fromPoll = false)=>{
|
|
3349
|
+
busy = busy.then(async ()=>{
|
|
3350
|
+
if (fromPoll && void 0 === poll) return;
|
|
3351
|
+
let next;
|
|
3352
|
+
try {
|
|
3353
|
+
next = await inspectProject(root);
|
|
3354
|
+
} catch (error) {
|
|
3355
|
+
out(`mfeflow: ${messageOf(error)} (keeping what is running)`);
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
3358
|
+
view = next;
|
|
3359
|
+
if (await writeIfChanged(registryFile, serializeIndex(devIndex(next, overrides)))) out(`mfeflow: dev registry updated (${devIndex(next, overrides).remotes.map((r)=>r.name).join(', ') || 'no remotes'})`);
|
|
3360
|
+
const now = wanted(next);
|
|
3361
|
+
for (const name of session.runningNames())if (!now.some((a)=>a.name === name)) {
|
|
3362
|
+
out(`mfeflow: ${name} left the project, stopping it`);
|
|
3363
|
+
await session.stopOne(name);
|
|
3364
|
+
}
|
|
3365
|
+
const candidates = now.filter((a)=>!session.has(a.name));
|
|
3366
|
+
const installed = await Promise.all(candidates.map((app)=>hasDependencies(app, root)));
|
|
3367
|
+
const fresh = candidates.filter((app, i)=>{
|
|
3368
|
+
if (true === installed[i]) {
|
|
3369
|
+
waiting.delete(app.name);
|
|
3370
|
+
return true;
|
|
3371
|
+
}
|
|
3372
|
+
if (!waiting.has(app.name)) {
|
|
3373
|
+
out(`mfeflow: ${app.name} is waiting for its install to finish`);
|
|
3374
|
+
waiting.add(app.name);
|
|
3375
|
+
}
|
|
3376
|
+
return false;
|
|
3377
|
+
});
|
|
3378
|
+
const stuck = await busyPorts(host, fresh);
|
|
3379
|
+
for (const app of fresh)if (stuck.includes(app)) out(`mfeflow: not starting ${app.name}: port ${String(app.port)} is in use`);
|
|
3380
|
+
else {
|
|
3381
|
+
out(`mfeflow: starting ${app.name}${void 0 === app.port ? '' : ` on ${String(app.port)}`}`);
|
|
3382
|
+
session.start(app, packageManager);
|
|
3383
|
+
}
|
|
3384
|
+
keepPolling();
|
|
3385
|
+
});
|
|
3386
|
+
};
|
|
3387
|
+
const stopWatching = host.watch([
|
|
3388
|
+
node_path.join(root, 'mfeflow.json'),
|
|
3389
|
+
node_path.join(root, 'registry', 'envs', 'dev'),
|
|
3390
|
+
node_path.join(root, 'pnpm-lock.yaml')
|
|
3391
|
+
], ()=>reload());
|
|
3392
|
+
let interrupted = false;
|
|
3393
|
+
const stopListening = host.onInterrupt(()=>{
|
|
3394
|
+
if (interrupted) return;
|
|
3395
|
+
interrupted = true;
|
|
3396
|
+
out('\nmfeflow: stopping…');
|
|
3397
|
+
session.stopAll();
|
|
3398
|
+
});
|
|
3399
|
+
for (const app of first)session.start(app, packageManager);
|
|
3400
|
+
out(`mfeflow: running ${first.map((a)=>a.name).join(', ')}${overrides.size > 0 ? `; using your ${[
|
|
3401
|
+
...overrides.keys()
|
|
3402
|
+
].join(', ')}` : ''}. Ctrl+C stops everything.`);
|
|
3403
|
+
try {
|
|
3404
|
+
await session.done;
|
|
3405
|
+
} finally{
|
|
3406
|
+
stopWatching();
|
|
3407
|
+
stopListening();
|
|
3408
|
+
closed = true;
|
|
3409
|
+
stopPolling();
|
|
3410
|
+
await busy;
|
|
3411
|
+
await session.stopAll();
|
|
3412
|
+
}
|
|
3413
|
+
return {
|
|
3414
|
+
...base,
|
|
3415
|
+
ok: !session.hasFailed,
|
|
3416
|
+
dryRun: false,
|
|
3417
|
+
interrupted
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
const FEATURES_ARGS = {
|
|
3421
|
+
search: {
|
|
3422
|
+
type: 'positional',
|
|
3423
|
+
required: false,
|
|
3424
|
+
description: "Only features whose name or description contains this"
|
|
3425
|
+
},
|
|
3426
|
+
...GLOBAL_ARGS
|
|
3427
|
+
};
|
|
3428
|
+
async function installedFeatures(cwd) {
|
|
3429
|
+
try {
|
|
3430
|
+
const project = await loadProject(await findProjectRoot(cwd));
|
|
3431
|
+
return new Set(Object.keys(project.features));
|
|
3432
|
+
} catch {
|
|
3433
|
+
return new Set();
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
function matches(feature, search) {
|
|
3437
|
+
const needle = search.toLowerCase();
|
|
3438
|
+
return feature.name.toLowerCase().includes(needle) || feature.description.toLowerCase().includes(needle);
|
|
3439
|
+
}
|
|
3440
|
+
function features_line(feature) {
|
|
3441
|
+
const tags = [
|
|
3442
|
+
feature.always ? 'always' : feature.default ? 'default' : 'opt-in',
|
|
3443
|
+
...feature.installed ? [
|
|
3444
|
+
'installed'
|
|
3445
|
+
] : []
|
|
3446
|
+
];
|
|
3447
|
+
return ` ${feature.name.padEnd(14)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
|
|
3448
|
+
}
|
|
3449
|
+
async function runFeatures(rt, args, raw) {
|
|
3450
|
+
assertKnownFlags(raw, FEATURES_ARGS);
|
|
3451
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
3452
|
+
const search = 'string' == typeof args.search && '' !== args.search ? args.search : null;
|
|
3453
|
+
const installed = await installedFeatures(globals.cwd);
|
|
3454
|
+
const all = await Promise.all(FIRST_PARTY.map(async (first)=>{
|
|
3455
|
+
const { manifest } = await loadAny(first.name, {
|
|
3456
|
+
roots: rt.roots
|
|
3457
|
+
});
|
|
3458
|
+
return {
|
|
3459
|
+
name: first.name,
|
|
3460
|
+
version: manifest.version,
|
|
3461
|
+
description: manifest.description ?? '',
|
|
3462
|
+
always: first.always,
|
|
3463
|
+
default: first.default,
|
|
3464
|
+
installed: installed.has(first.name)
|
|
3465
|
+
};
|
|
3466
|
+
}));
|
|
3467
|
+
const features = null === search ? all : all.filter((f)=>matches(f, search));
|
|
3468
|
+
if (!globals.json) {
|
|
3469
|
+
if (0 === features.length) rt.io.stdout(`No features match "${search ?? ''}".`);
|
|
3470
|
+
else rt.io.stdout(features.map(features_line).join('\n'));
|
|
3471
|
+
rt.io.stdout('\nFrom a registry: mfeflow add @namespace/name (configure it under "registries" in mfeflow.json).');
|
|
3472
|
+
}
|
|
3473
|
+
return {
|
|
3474
|
+
ok: true,
|
|
3475
|
+
command: 'features',
|
|
3476
|
+
search,
|
|
3477
|
+
features
|
|
3478
|
+
};
|
|
3479
|
+
}
|
|
3480
|
+
const INIT_ARGS = {
|
|
3481
|
+
dir: {
|
|
3482
|
+
type: 'positional',
|
|
3483
|
+
required: false,
|
|
3484
|
+
description: 'Folder to create (default mfeflow-app)'
|
|
3485
|
+
},
|
|
3486
|
+
scope: {
|
|
3487
|
+
type: 'string',
|
|
3488
|
+
description: 'npm scope for the project packages, like @my-org',
|
|
3489
|
+
valueHint: '@scope'
|
|
3490
|
+
},
|
|
3491
|
+
remotes: {
|
|
3492
|
+
type: 'string',
|
|
3493
|
+
description: 'Remotes to create, comma separated (default catalog,cart)',
|
|
3494
|
+
valueHint: 'a,b'
|
|
3495
|
+
},
|
|
3496
|
+
features: {
|
|
3497
|
+
type: 'string',
|
|
3498
|
+
description: `Optional features, comma separated (${OPTIONAL_FEATURES.join(', ')})`,
|
|
3499
|
+
valueHint: 'a,b'
|
|
3500
|
+
},
|
|
3501
|
+
'package-manager': {
|
|
3502
|
+
type: 'string',
|
|
3503
|
+
description: 'pnpm (the only one in this release)',
|
|
3504
|
+
valueHint: 'pm'
|
|
3505
|
+
},
|
|
3506
|
+
topology: {
|
|
3507
|
+
type: 'string',
|
|
3508
|
+
description: 'monorepo (the only one in this release)',
|
|
3509
|
+
valueHint: 'name'
|
|
3510
|
+
},
|
|
3511
|
+
option: {
|
|
3512
|
+
type: 'string',
|
|
3513
|
+
description: 'A feature option, repeatable: --option feature.key=value',
|
|
3514
|
+
valueHint: 'f.k=v'
|
|
3515
|
+
},
|
|
3516
|
+
...GLOBAL_ARGS
|
|
3517
|
+
};
|
|
3518
|
+
const DEFAULT_DIR = 'mfeflow-app';
|
|
3519
|
+
const DEFAULT_REMOTES = [
|
|
3520
|
+
'catalog',
|
|
3521
|
+
'cart'
|
|
3522
|
+
];
|
|
3523
|
+
const SCOPE = /^@[a-z0-9][a-z0-9-._]*$/;
|
|
3524
|
+
function scopeFor(dir, cwd = process.cwd()) {
|
|
3525
|
+
const name = node_path.basename(node_path.resolve(cwd, dir)).toLowerCase().replaceAll(/[^a-z0-9-._]+/g, '-').replaceAll(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
|
|
3526
|
+
return `@${'' === name ? 'app' : name}`;
|
|
3527
|
+
}
|
|
3528
|
+
function checkRemotes(remotes) {
|
|
3529
|
+
const seen = new Set();
|
|
3530
|
+
for (const name of remotes){
|
|
3531
|
+
assertAppName(name);
|
|
3532
|
+
if (seen.has(name)) throw new MfeflowError('E215', `"${name}" is listed twice`);
|
|
3533
|
+
seen.add(name);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
function checkFeatures(requested) {
|
|
3537
|
+
const optional = new Set();
|
|
3538
|
+
for (const name of requested)if (!FIRST_PARTY.some((f)=>f.name === name && f.always)) {
|
|
3539
|
+
if (!isFirstParty(name)) throw new MfeflowError('E201', `"${name}" (init takes ${OPTIONAL_FEATURES.join(', ')}; add others later with \`mfeflow add\`)`);
|
|
3540
|
+
optional.add(name);
|
|
3541
|
+
}
|
|
3542
|
+
return OPTIONAL_FEATURES.filter((name)=>optional.has(name));
|
|
3543
|
+
}
|
|
3544
|
+
function init_only(flag, value, allowed) {
|
|
3545
|
+
if (void 0 !== value && value !== allowed) throw new MfeflowError('E107', `--${flag} ${value}: only ${allowed} is available in this release`);
|
|
3546
|
+
}
|
|
3547
|
+
async function resolveInitOptions(rt, globals, args, raw, preset) {
|
|
3548
|
+
init_only('package-manager', args_text(args, 'package-manager') ?? preset.packageManager, 'pnpm');
|
|
3549
|
+
init_only('topology', args_text(args, 'topology') ?? preset.topology, 'monorepo');
|
|
3550
|
+
const { canPrompt } = globals;
|
|
3551
|
+
const { prompter } = rt;
|
|
3552
|
+
const given = 'string' == typeof args.dir && '' !== args.dir ? args.dir : void 0;
|
|
3553
|
+
const dir = given ?? (canPrompt ? await prompter.text({
|
|
3554
|
+
message: 'Where should the project go?',
|
|
3555
|
+
initialValue: DEFAULT_DIR
|
|
3556
|
+
}) : DEFAULT_DIR);
|
|
3557
|
+
const scopeFlag = args_text(args, 'scope') ?? preset.scope;
|
|
3558
|
+
const scope = scopeFlag ?? (canPrompt ? await prompter.text({
|
|
3559
|
+
message: 'npm scope for your packages',
|
|
3560
|
+
initialValue: scopeFor(dir, globals.cwd),
|
|
3561
|
+
validate: (v)=>SCOPE.test(v) ? void 0 : 'Use a scope like @my-org'
|
|
3562
|
+
}) : scopeFor(dir, globals.cwd));
|
|
3563
|
+
if (!SCOPE.test(scope)) throw new MfeflowError('E107', `--scope "${scope}" must look like @my-org`);
|
|
3564
|
+
const remotesFlag = splitList(args_text(args, 'remotes')) ?? preset.remotes;
|
|
3565
|
+
const remotes = remotesFlag ?? (canPrompt ? splitList(await prompter.text({
|
|
3566
|
+
message: 'Remotes, comma separated',
|
|
3567
|
+
initialValue: DEFAULT_REMOTES.join(',')
|
|
3568
|
+
})) ?? [] : DEFAULT_REMOTES);
|
|
3569
|
+
checkRemotes(remotes);
|
|
3570
|
+
const featuresFlag = splitList(args_text(args, 'features')) ?? preset.features;
|
|
3571
|
+
const features = checkFeatures(featuresFlag ?? (canPrompt ? await prompter.multiselect({
|
|
3572
|
+
message: 'Features',
|
|
3573
|
+
choices: OPTIONAL_FEATURES.map((name)=>({
|
|
3574
|
+
value: name,
|
|
3575
|
+
label: name
|
|
3576
|
+
})),
|
|
3577
|
+
initialValues: DEFAULT_FEATURES
|
|
3578
|
+
}) : DEFAULT_FEATURES));
|
|
3579
|
+
const featureOptions = mergeOptions(preset.options ?? {}, parseFeatureOptions(repeated(raw, 'option')));
|
|
3580
|
+
return {
|
|
3581
|
+
dir,
|
|
3582
|
+
scope,
|
|
3583
|
+
remotes,
|
|
3584
|
+
features,
|
|
3585
|
+
featureOptions
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
async function assertEmpty(root, readdir = promises.readdir) {
|
|
3589
|
+
let entries;
|
|
3590
|
+
try {
|
|
3591
|
+
entries = await readdir(root);
|
|
3592
|
+
} catch (error) {
|
|
3593
|
+
if (hasErrno(error, 'ENOENT')) return;
|
|
3594
|
+
if (hasErrno(error, 'ENOTDIR')) throw new MfeflowError('E108', `${root} is not a folder, or part of its path is a file`, {
|
|
3595
|
+
cause: error
|
|
3596
|
+
});
|
|
3597
|
+
throw new MfeflowError('E106', `could not look inside ${root} (${messageOf(error)})`, {
|
|
3598
|
+
cause: error
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
if (entries.length > 0) {
|
|
3602
|
+
const shown = entries.slice(0, 3).join(', ');
|
|
3603
|
+
throw new MfeflowError('E108', `${root} already has ${shown}${entries.length > 3 ? ', …' : ''}`);
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
function skeleton(options) {
|
|
3607
|
+
const apps = {
|
|
3608
|
+
shell: {
|
|
3609
|
+
kind: 'shell',
|
|
3610
|
+
path: 'apps/shell',
|
|
3611
|
+
port: 4300
|
|
3612
|
+
}
|
|
3613
|
+
};
|
|
3614
|
+
options.remotes.forEach((name, index)=>{
|
|
3615
|
+
apps[name] = {
|
|
3616
|
+
kind: 'remote',
|
|
3617
|
+
path: `apps/${name}`,
|
|
3618
|
+
port: 4311 + index
|
|
3619
|
+
};
|
|
3620
|
+
});
|
|
3621
|
+
return {
|
|
3622
|
+
version: 1,
|
|
3623
|
+
topology: 'monorepo',
|
|
3624
|
+
packageManager: 'pnpm',
|
|
3625
|
+
scope: options.scope,
|
|
3626
|
+
apps,
|
|
3627
|
+
features: {}
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
async function runInit(rt, args, raw) {
|
|
3631
|
+
assertKnownFlags(raw, INIT_ARGS);
|
|
3632
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
3633
|
+
const preset = globals.preset ? await loadPreset(globals.preset, {
|
|
3634
|
+
cwd: globals.cwd,
|
|
3635
|
+
fetch: rt.fetch
|
|
3636
|
+
}) : {};
|
|
3637
|
+
const options = await resolveInitOptions(rt, globals, args, raw, preset);
|
|
3638
|
+
const root = node_path.resolve(globals.cwd, options.dir);
|
|
3639
|
+
await assertEmpty(root);
|
|
3640
|
+
const chosen = chooseLocal(skeleton(options), root, globals.cwd, globals.local);
|
|
3641
|
+
const { project } = chosen;
|
|
3642
|
+
const builder = createBuilder(root, chosen.checkout);
|
|
3643
|
+
const names = [
|
|
3644
|
+
'core',
|
|
3645
|
+
'tsconfig',
|
|
3646
|
+
...options.features
|
|
3647
|
+
];
|
|
3648
|
+
assertOptionsAreForFeatures(options.featureOptions, names);
|
|
3649
|
+
const loaded = await Promise.all(names.map((name)=>loadAny(name, {
|
|
3650
|
+
roots: rt.roots
|
|
3651
|
+
})));
|
|
3652
|
+
await inOrder(loaded, (feature)=>resolveFeature({
|
|
3653
|
+
feature,
|
|
3654
|
+
project,
|
|
3655
|
+
builder,
|
|
3656
|
+
options: options.featureOptions[feature.manifest.name] ?? {},
|
|
3657
|
+
alsoAdding: names
|
|
3658
|
+
}));
|
|
3659
|
+
const core = loaded[0];
|
|
3660
|
+
await inOrder(options.remotes, async (remote)=>{
|
|
3661
|
+
const port = project.apps[remote]?.port;
|
|
3662
|
+
if (void 0 === core || void 0 === port) return;
|
|
3663
|
+
await resolveFeature({
|
|
3664
|
+
feature: await remoteGlue({
|
|
3665
|
+
roots: rt.roots,
|
|
3666
|
+
version: core.manifest.version,
|
|
3667
|
+
remote: {
|
|
3668
|
+
name: remote,
|
|
3669
|
+
port
|
|
3670
|
+
},
|
|
3671
|
+
registry: true,
|
|
3672
|
+
shells: [
|
|
3673
|
+
'shell'
|
|
3674
|
+
]
|
|
3675
|
+
}),
|
|
3676
|
+
project,
|
|
3677
|
+
builder,
|
|
3678
|
+
alsoAdding: names
|
|
3679
|
+
});
|
|
3680
|
+
});
|
|
3681
|
+
const plan = builder.build();
|
|
3682
|
+
const outcome = await writePlan({
|
|
3683
|
+
rt,
|
|
3684
|
+
globals,
|
|
3685
|
+
root,
|
|
3686
|
+
plan,
|
|
3687
|
+
project,
|
|
3688
|
+
afterWrite: async ()=>{
|
|
3689
|
+
if (options.features.includes('git-hooks')) await rt.system.gitInit(root);
|
|
3690
|
+
}
|
|
3691
|
+
});
|
|
3692
|
+
const next = [
|
|
3693
|
+
...node_path.resolve(globals.cwd) === root ? [] : [
|
|
3694
|
+
`cd ${options.dir}`
|
|
3695
|
+
],
|
|
3696
|
+
...outcome.installed || outcome.dryRun ? [] : [
|
|
3697
|
+
'pnpm install'
|
|
3698
|
+
],
|
|
3699
|
+
'pnpm dev'
|
|
3700
|
+
];
|
|
3701
|
+
const result = {
|
|
3702
|
+
ok: true,
|
|
3703
|
+
command: 'init',
|
|
3704
|
+
dryRun: outcome.dryRun,
|
|
3705
|
+
root,
|
|
3706
|
+
scope: options.scope,
|
|
3707
|
+
apps: Object.entries(project.apps).map(([name, app])=>({
|
|
3708
|
+
name,
|
|
3709
|
+
kind: app.kind,
|
|
3710
|
+
path: app.path,
|
|
3711
|
+
port: app.port ?? null
|
|
3712
|
+
})),
|
|
3713
|
+
plan: planReport(outcome.shown),
|
|
3714
|
+
written: outcome.written,
|
|
3715
|
+
installed: outcome.installed,
|
|
3716
|
+
next,
|
|
3717
|
+
...void 0 === outcome.diff ? {} : {
|
|
3718
|
+
diff: outcome.diff
|
|
3719
|
+
}
|
|
3720
|
+
};
|
|
3721
|
+
if (!globals.json) printInit(rt, result);
|
|
3722
|
+
return result;
|
|
3723
|
+
}
|
|
3724
|
+
function printInit(rt, result) {
|
|
3725
|
+
if (result.dryRun) return void rt.io.stdout('\nDry run: nothing was written.');
|
|
3726
|
+
const remotes = result.apps.filter((a)=>'remote' === a.kind).map((a)=>a.name);
|
|
3727
|
+
rt.io.stdout(`\nCreated ${result.root} with a shell and ${String(remotes.length)} remote${1 === remotes.length ? '' : 's'} (${remotes.join(', ')}).`);
|
|
3728
|
+
rt.io.stdout(`Next:\n${result.next.map((line)=>` ${line}`).join('\n')}`);
|
|
3729
|
+
}
|
|
3730
|
+
const REPO_URL = 'https://github.com/mfeflow/mfeflow';
|
|
3731
|
+
function flagLabel(name, def) {
|
|
3732
|
+
const alias = 'alias' in def ? [
|
|
3733
|
+
def.alias ?? []
|
|
3734
|
+
].flat().map((a)=>`-${a}, `).join('') : '';
|
|
3735
|
+
if ('boolean' === def.type) return `${alias}--${name}`;
|
|
3736
|
+
return `${alias}--${name} <${'valueHint' in def ? def.valueHint ?? 'value' : 'value'}>`;
|
|
3737
|
+
}
|
|
3738
|
+
function table(args, skip) {
|
|
3739
|
+
const rows = Object.entries(args).filter(([name, def])=>'positional' !== def.type && !skip.has(name)).map(([name, def])=>[
|
|
3740
|
+
flagLabel(name, def),
|
|
3741
|
+
def.description ?? ''
|
|
3742
|
+
]);
|
|
3743
|
+
const width = Math.max(0, ...rows.map(([label])=>label.length));
|
|
3744
|
+
return rows.map(([label, text])=>` ${label.padEnd(width)} ${text}`);
|
|
3745
|
+
}
|
|
3746
|
+
function commandHelp(help) {
|
|
3747
|
+
const globals = new Set(Object.keys(GLOBAL_ARGS));
|
|
3748
|
+
const own = table(help.args, globals);
|
|
3749
|
+
const lines = [
|
|
3750
|
+
`mfeflow ${help.name} - ${help.summary}`,
|
|
3751
|
+
'',
|
|
3752
|
+
`Usage: ${help.usage}`
|
|
3753
|
+
];
|
|
3754
|
+
if (own.length > 0) lines.push('', 'Options:', ...own);
|
|
3755
|
+
lines.push('', 'Global options:', ...table(GLOBAL_ARGS, new Set()));
|
|
3756
|
+
lines.push('', 'Examples:', ...help.examples.map((example)=>` ${example}`));
|
|
3757
|
+
lines.push('', `Docs: ${DOCS_URL}/`);
|
|
3758
|
+
return lines.join('\n');
|
|
3759
|
+
}
|
|
3760
|
+
function exitCodesText() {
|
|
3761
|
+
return [
|
|
3762
|
+
` ${String(EXIT.ok)} success`,
|
|
3763
|
+
` ${String(EXIT.unexpected)} unexpected error`,
|
|
3764
|
+
` ${String(EXIT.usage)} bad command line (MFEFLOW_E107)`,
|
|
3765
|
+
` ${String(EXIT.project)} project or config problem (MFEFLOW_E1xx)`,
|
|
3766
|
+
` ${String(EXIT.feature)} feature problem (MFEFLOW_E2xx)`,
|
|
3767
|
+
` ${String(EXIT.build)} build or runtime problem (MFEFLOW_E3xx)`,
|
|
3768
|
+
` ${String(EXIT.registry)} registry problem (MFEFLOW_E4xx)`,
|
|
3769
|
+
` ${String(EXIT.upgrade)} upgrade problem (MFEFLOW_E5xx)`,
|
|
3770
|
+
` ${String(EXIT.cancelled)} cancelled at a prompt, or Ctrl+C in mfeflow dev`
|
|
3771
|
+
].join('\n');
|
|
3772
|
+
}
|
|
3773
|
+
function rootHelp(commands) {
|
|
3774
|
+
const width = Math.max(...commands.map((c)=>c.name.length));
|
|
3775
|
+
return [
|
|
3776
|
+
`mfeflow ${readPackageJson().version} - React micro-frontend platform kit`,
|
|
3777
|
+
'',
|
|
3778
|
+
'Usage: mfeflow <command> [options]',
|
|
3779
|
+
'',
|
|
3780
|
+
'Commands:',
|
|
3781
|
+
...commands.map((c)=>` ${c.name.padEnd(width)} ${c.summary}`),
|
|
3782
|
+
'',
|
|
3783
|
+
"Run `mfeflow <command> --help` for a command's options. Every command takes",
|
|
3784
|
+
'--yes, --preset, --json, --dry-run, --cwd, --no-install, --verbose.',
|
|
3785
|
+
'',
|
|
3786
|
+
'Examples:',
|
|
3787
|
+
' npm create mfeflow@latest my-app -- --yes',
|
|
3788
|
+
' mfeflow init my-app --remotes catalog,cart,checkout --dry-run',
|
|
3789
|
+
' mfeflow add remote checkout',
|
|
3790
|
+
' mfeflow features lint',
|
|
3791
|
+
'',
|
|
3792
|
+
'Exit codes:',
|
|
3793
|
+
exitCodesText(),
|
|
3794
|
+
'',
|
|
3795
|
+
`Docs: ${DOCS_URL}/ Source: ${REPO_URL}`
|
|
3796
|
+
].join('\n');
|
|
3797
|
+
}
|
|
3798
|
+
function assetRoots(from = node_path.dirname(fileURLToPath(import.meta.url)), exists = node_fs.existsSync) {
|
|
3799
|
+
const pkg = node_path.resolve(from, '..');
|
|
3800
|
+
const bundled = node_path.join(pkg, 'assets');
|
|
3801
|
+
const fromSource = 'src' === node_path.basename(from);
|
|
3802
|
+
const base = !fromSource && exists(node_path.join(bundled, 'features')) ? bundled : node_path.resolve(pkg, '../..');
|
|
3803
|
+
return {
|
|
3804
|
+
features: node_path.join(base, 'features'),
|
|
3805
|
+
templates: node_path.join(base, 'templates')
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3808
|
+
function processIo() {
|
|
3809
|
+
return {
|
|
3810
|
+
stdout: (text)=>process.stdout.write(`${text}\n`),
|
|
3811
|
+
stderr: (text)=>process.stderr.write(`${text}\n`),
|
|
3812
|
+
cwd: process.cwd(),
|
|
3813
|
+
env: process.env,
|
|
3814
|
+
interactive: process.stdin.isTTY && process.stdout.isTTY
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
function answer(value) {
|
|
3818
|
+
if (__rspack_external__clack_prompts_d2d5c36d.isCancel(value)) throw new Cancelled();
|
|
3819
|
+
return value;
|
|
3820
|
+
}
|
|
3821
|
+
const clackPrompter = {
|
|
3822
|
+
text: async ({ message, initialValue, validate })=>answer(await __rspack_external__clack_prompts_d2d5c36d.text({
|
|
3823
|
+
message,
|
|
3824
|
+
...void 0 === initialValue ? {} : {
|
|
3825
|
+
initialValue
|
|
3826
|
+
},
|
|
3827
|
+
...validate ? {
|
|
3828
|
+
validate: (value)=>validate(value ?? '')
|
|
3829
|
+
} : {}
|
|
3830
|
+
})),
|
|
3831
|
+
multiselect: async ({ message, choices, initialValues })=>answer(await __rspack_external__clack_prompts_d2d5c36d.multiselect({
|
|
3832
|
+
message,
|
|
3833
|
+
options: choices.map((c)=>({
|
|
3834
|
+
value: c.value,
|
|
3835
|
+
label: c.label,
|
|
3836
|
+
...void 0 === c.hint ? {} : {
|
|
3837
|
+
hint: c.hint
|
|
3838
|
+
}
|
|
3839
|
+
})),
|
|
3840
|
+
initialValues: [
|
|
3841
|
+
...initialValues
|
|
3842
|
+
],
|
|
3843
|
+
required: false
|
|
3844
|
+
})),
|
|
3845
|
+
select: async ({ message, choices, initialValue })=>answer(await __rspack_external__clack_prompts_d2d5c36d.select({
|
|
3846
|
+
message,
|
|
3847
|
+
options: choices.map((c)=>({
|
|
3848
|
+
value: c.value,
|
|
3849
|
+
label: c.label,
|
|
3850
|
+
...void 0 === c.hint ? {} : {
|
|
3851
|
+
hint: c.hint
|
|
3852
|
+
}
|
|
3853
|
+
})),
|
|
3854
|
+
...void 0 === initialValue ? {} : {
|
|
3855
|
+
initialValue
|
|
3856
|
+
}
|
|
3857
|
+
})),
|
|
3858
|
+
confirm: async ({ message, initialValue })=>answer(await __rspack_external__clack_prompts_d2d5c36d.confirm({
|
|
3859
|
+
message,
|
|
3860
|
+
initialValue: initialValue ?? true
|
|
3861
|
+
}))
|
|
3862
|
+
};
|
|
3863
|
+
const REPO_VARIABLES = [
|
|
3864
|
+
'GIT_DIR',
|
|
3865
|
+
'GIT_WORK_TREE',
|
|
3866
|
+
'GIT_INDEX_FILE',
|
|
3867
|
+
'GIT_COMMON_DIR',
|
|
3868
|
+
'GIT_OBJECT_DIRECTORY',
|
|
3869
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
3870
|
+
'GIT_PREFIX',
|
|
3871
|
+
'GIT_NAMESPACE'
|
|
3872
|
+
];
|
|
3873
|
+
function isolatedEnv(env = process.env) {
|
|
3874
|
+
const copy = {
|
|
3875
|
+
...env
|
|
3876
|
+
};
|
|
3877
|
+
for (const name of REPO_VARIABLES)delete copy[name];
|
|
3878
|
+
return copy;
|
|
3879
|
+
}
|
|
3880
|
+
const run = (command, args, options)=>new Promise((resolve, reject)=>{
|
|
3881
|
+
const child = spawn(command, args, {
|
|
3882
|
+
cwd: options.cwd,
|
|
3883
|
+
env: isolatedEnv(),
|
|
3884
|
+
stdio: [
|
|
3885
|
+
'ignore',
|
|
3886
|
+
'pipe',
|
|
3887
|
+
'pipe'
|
|
3888
|
+
],
|
|
3889
|
+
shell: 'win32' === process.platform && !node_path.isAbsolute(command)
|
|
3890
|
+
});
|
|
3891
|
+
let output = '';
|
|
3892
|
+
const collect = (chunk)=>{
|
|
3893
|
+
const text = chunk.toString('utf8');
|
|
3894
|
+
output += text;
|
|
3895
|
+
options.onOutput?.(text);
|
|
3896
|
+
};
|
|
3897
|
+
child.stdout.on('data', collect);
|
|
3898
|
+
child.stderr.on('data', collect);
|
|
3899
|
+
child.on('error', reject);
|
|
3900
|
+
child.on('close', (code)=>{
|
|
3901
|
+
resolve({
|
|
3902
|
+
code: code ?? 1,
|
|
3903
|
+
output
|
|
3904
|
+
});
|
|
3905
|
+
});
|
|
3906
|
+
});
|
|
3907
|
+
const TAIL_LINES = 15;
|
|
3908
|
+
function installArgs(packageManager) {
|
|
3909
|
+
return 'pnpm' === packageManager ? [
|
|
3910
|
+
'install',
|
|
3911
|
+
'--no-frozen-lockfile'
|
|
3912
|
+
] : [
|
|
3913
|
+
'install'
|
|
3914
|
+
];
|
|
3915
|
+
}
|
|
3916
|
+
function createSystem(io, runner = run) {
|
|
3917
|
+
return {
|
|
3918
|
+
async install (root, packageManager, verbose) {
|
|
3919
|
+
let result;
|
|
3920
|
+
try {
|
|
3921
|
+
result = await runner(packageManager, installArgs(packageManager), {
|
|
3922
|
+
cwd: root,
|
|
3923
|
+
...verbose ? {
|
|
3924
|
+
onOutput: (chunk)=>io.stderr(chunk.trimEnd())
|
|
3925
|
+
} : {}
|
|
3926
|
+
});
|
|
3927
|
+
} catch (error) {
|
|
3928
|
+
throw new Error(`could not start ${packageManager} (${messageOf(error)}); install it, for example with \`corepack enable\``, {
|
|
3929
|
+
cause: error
|
|
3930
|
+
});
|
|
3931
|
+
}
|
|
3932
|
+
if (0 !== result.code) {
|
|
3933
|
+
const tail = result.output.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
|
|
3934
|
+
throw new Error(`${packageManager} install exited with ${String(result.code)}\n${tail}`);
|
|
3935
|
+
}
|
|
3936
|
+
},
|
|
3937
|
+
async gitInit (root) {
|
|
3938
|
+
try {
|
|
3939
|
+
const inside = await runner('git', [
|
|
3940
|
+
'rev-parse',
|
|
3941
|
+
'--is-inside-work-tree'
|
|
3942
|
+
], {
|
|
3943
|
+
cwd: root
|
|
3944
|
+
});
|
|
3945
|
+
if (0 === inside.code) return false;
|
|
3946
|
+
return 0 === (await runner('git', [
|
|
3947
|
+
'init',
|
|
3948
|
+
'--quiet'
|
|
3949
|
+
], {
|
|
3950
|
+
cwd: root
|
|
3951
|
+
})).code;
|
|
3952
|
+
} catch {
|
|
3953
|
+
return false;
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
}
|
|
3958
|
+
function realRuntime(io = processIo()) {
|
|
3959
|
+
return {
|
|
3960
|
+
io,
|
|
3961
|
+
prompter: clackPrompter,
|
|
3962
|
+
system: createSystem(io),
|
|
3963
|
+
fetch: (url, init)=>fetch(url, init),
|
|
3964
|
+
roots: assetRoots(),
|
|
3965
|
+
dev: realDevHost()
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
const COMMANDS = [
|
|
3969
|
+
{
|
|
3970
|
+
name: 'init',
|
|
3971
|
+
summary: 'create a new project: a shell and remotes',
|
|
3972
|
+
usage: 'mfeflow init [dir] [options]',
|
|
3973
|
+
args: INIT_ARGS,
|
|
3974
|
+
examples: [
|
|
3975
|
+
'mfeflow init my-app',
|
|
3976
|
+
'mfeflow init my-app --yes',
|
|
3977
|
+
'mfeflow init my-app --scope @acme --remotes catalog,cart,checkout --features lint-oxlint,git-hooks',
|
|
3978
|
+
'mfeflow init my-app --dry-run # print the diff, write nothing',
|
|
3979
|
+
'mfeflow init my-app --yes --local ~/code/mfeflow # link a local checkout'
|
|
3980
|
+
],
|
|
3981
|
+
run: runInit
|
|
3982
|
+
},
|
|
3983
|
+
{
|
|
3984
|
+
name: 'add',
|
|
3985
|
+
summary: 'add a remote, a shell, features or a typed env key to this project',
|
|
3986
|
+
usage: 'mfeflow add remote <name> | mfeflow add shell <name> | mfeflow add <feature...> | mfeflow add env-key <KEY> --scope server|runtime --schema <expr>',
|
|
3987
|
+
args: ADD_ARGS,
|
|
3988
|
+
examples: [
|
|
3989
|
+
'mfeflow add remote checkout',
|
|
3990
|
+
'mfeflow add remote checkout --port 4320',
|
|
3991
|
+
'mfeflow add shell admin',
|
|
3992
|
+
'mfeflow add git-hooks',
|
|
3993
|
+
'mfeflow add @someone/analytics-segment --yes',
|
|
3994
|
+
'mfeflow add url-factory --dry-run',
|
|
3995
|
+
'mfeflow add env-t3 --option env-t3.validator=valibot',
|
|
3996
|
+
'mfeflow add env-key API_ORIGIN --scope server --schema "z.url()"',
|
|
3997
|
+
'mfeflow add env-key GTM_ID --scope runtime --schema "z.string().min(1)" --app shell'
|
|
3998
|
+
],
|
|
3999
|
+
run: runAdd
|
|
4000
|
+
},
|
|
4001
|
+
{
|
|
4002
|
+
name: 'features',
|
|
4003
|
+
summary: 'list the features you can add',
|
|
4004
|
+
usage: 'mfeflow features [search] [options]',
|
|
4005
|
+
args: FEATURES_ARGS,
|
|
4006
|
+
examples: [
|
|
4007
|
+
'mfeflow features',
|
|
4008
|
+
'mfeflow features lint',
|
|
4009
|
+
'mfeflow features --json'
|
|
4010
|
+
],
|
|
4011
|
+
run: runFeatures
|
|
4012
|
+
},
|
|
4013
|
+
{
|
|
4014
|
+
name: 'doctor',
|
|
4015
|
+
summary: 'check singletons, compatibility, config and drift; --fix repairs the safe ones',
|
|
4016
|
+
usage: 'mfeflow doctor [options]',
|
|
4017
|
+
args: DOCTOR_ARGS,
|
|
4018
|
+
examples: [
|
|
4019
|
+
'mfeflow doctor',
|
|
4020
|
+
'mfeflow doctor --fix',
|
|
4021
|
+
'mfeflow doctor --json',
|
|
4022
|
+
'mfeflow doctor --fix --dry-run'
|
|
4023
|
+
],
|
|
4024
|
+
run: runDoctor
|
|
4025
|
+
},
|
|
4026
|
+
{
|
|
4027
|
+
name: 'validate',
|
|
4028
|
+
summary: 'check mfeflow.json, every mfe.json and every registry entry against their schemas',
|
|
4029
|
+
usage: 'mfeflow validate [options]',
|
|
4030
|
+
args: VALIDATE_ARGS,
|
|
4031
|
+
examples: [
|
|
4032
|
+
'mfeflow validate',
|
|
4033
|
+
'mfeflow validate --json'
|
|
4034
|
+
],
|
|
4035
|
+
run: runValidate
|
|
4036
|
+
},
|
|
4037
|
+
{
|
|
4038
|
+
name: 'check-compat',
|
|
4039
|
+
summary: "check each remote's shared ranges against the shells it runs in",
|
|
4040
|
+
usage: 'mfeflow check-compat [options]',
|
|
4041
|
+
args: COMPAT_ARGS,
|
|
4042
|
+
examples: [
|
|
4043
|
+
'mfeflow check-compat',
|
|
4044
|
+
'mfeflow check-compat --json'
|
|
4045
|
+
],
|
|
4046
|
+
run: runCheckCompat
|
|
4047
|
+
},
|
|
4048
|
+
{
|
|
4049
|
+
name: 'budget',
|
|
4050
|
+
summary: 'measure the built JS of the shell and each remote against the cost-model budget',
|
|
4051
|
+
usage: 'mfeflow budget [options]',
|
|
4052
|
+
args: BUDGET_ARGS,
|
|
4053
|
+
examples: [
|
|
4054
|
+
'mfeflow budget',
|
|
4055
|
+
'mfeflow budget --json',
|
|
4056
|
+
'mfeflow budget --dist build'
|
|
4057
|
+
],
|
|
4058
|
+
run: runBudget
|
|
4059
|
+
},
|
|
4060
|
+
{
|
|
4061
|
+
name: 'dev',
|
|
4062
|
+
summary: 'run the shell and every remote together, with a dev registry that follows the project',
|
|
4063
|
+
usage: 'mfeflow dev [options]',
|
|
4064
|
+
args: DEV_ARGS,
|
|
4065
|
+
examples: [
|
|
4066
|
+
'mfeflow dev',
|
|
4067
|
+
'mfeflow dev --only shell,catalog',
|
|
4068
|
+
'mfeflow dev --remote checkout=http://localhost:3001',
|
|
4069
|
+
'mfeflow dev --remote checkout=https://dev.example.com --allow-origin https://dev.example.com',
|
|
4070
|
+
'mfeflow dev --dry-run'
|
|
4071
|
+
],
|
|
4072
|
+
run: runDev
|
|
4073
|
+
}
|
|
4074
|
+
];
|
|
4075
|
+
function findCommand(name) {
|
|
4076
|
+
return COMMANDS.find((c)=>c.name === name);
|
|
4077
|
+
}
|
|
4078
|
+
function toCitty(command, capture, rt) {
|
|
4079
|
+
return defineCommand({
|
|
4080
|
+
meta: {
|
|
4081
|
+
name: command.name,
|
|
4082
|
+
description: command.summary
|
|
4083
|
+
},
|
|
4084
|
+
args: command.args,
|
|
4085
|
+
async run ({ args, rawArgs }) {
|
|
4086
|
+
capture(await command.run(rt, args, rawArgs));
|
|
4087
|
+
}
|
|
4088
|
+
});
|
|
4089
|
+
}
|
|
4090
|
+
function failure(error) {
|
|
4091
|
+
return {
|
|
4092
|
+
ok: false,
|
|
4093
|
+
error: reportOf(error),
|
|
4094
|
+
exitCode: exitCodeFor(error)
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
function fail(error, rt, json, verbose) {
|
|
4098
|
+
if (error instanceof Cancelled) {
|
|
4099
|
+
rt.io.stderr('Cancelled. Nothing was written.');
|
|
4100
|
+
return EXIT.cancelled;
|
|
4101
|
+
}
|
|
4102
|
+
const mfe = error instanceof Error && 'CLIError' === error.name ? new MfeflowError('E107', stripVTControlCharacters(error.message)) : error;
|
|
4103
|
+
if (isMfeflowError(mfe)) {
|
|
4104
|
+
if (json) rt.io.stdout(JSON.stringify(failure(mfe), null, 2));
|
|
4105
|
+
else rt.io.stderr(mfe.format());
|
|
4106
|
+
if (verbose && void 0 !== mfe.cause) rt.io.stderr(unexpectedText(mfe.cause, true));
|
|
4107
|
+
return exitCodeFor(mfe);
|
|
4108
|
+
}
|
|
4109
|
+
if (json) {
|
|
4110
|
+
const failed = {
|
|
4111
|
+
ok: false,
|
|
4112
|
+
error: unexpectedReport(error),
|
|
4113
|
+
exitCode: EXIT.unexpected
|
|
4114
|
+
};
|
|
4115
|
+
rt.io.stdout(JSON.stringify(failed, null, 2));
|
|
4116
|
+
} else rt.io.stderr(unexpectedText(error, verbose));
|
|
4117
|
+
return EXIT.unexpected;
|
|
4118
|
+
}
|
|
4119
|
+
async function main(argv, rt = realRuntime()) {
|
|
4120
|
+
const json = argv.includes('--json');
|
|
4121
|
+
const verbose = argv.includes('--verbose');
|
|
4122
|
+
try {
|
|
4123
|
+
const [first] = argv;
|
|
4124
|
+
if ('--version' === first || '-v' === first) {
|
|
4125
|
+
rt.io.stdout(`mfeflow ${readPackageJson().version}`);
|
|
4126
|
+
return EXIT.ok;
|
|
4127
|
+
}
|
|
4128
|
+
if (void 0 === first || 'help' === first || '--help' === first || '-h' === first) {
|
|
4129
|
+
rt.io.stdout(rootHelp(COMMANDS));
|
|
4130
|
+
return EXIT.ok;
|
|
4131
|
+
}
|
|
4132
|
+
const command = findCommand(first);
|
|
4133
|
+
if (!command) {
|
|
4134
|
+
const names = COMMANDS.map((c)=>c.name).join(', ');
|
|
4135
|
+
throw new MfeflowError('E107', first.startsWith('-') ? `${first} comes before the command; write mfeflow <command> [options] (commands: ${names})` : `unknown command "${first}" (commands: ${names})`);
|
|
4136
|
+
}
|
|
4137
|
+
const rest = argv.slice(1);
|
|
4138
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
4139
|
+
rt.io.stdout(commandHelp(command));
|
|
4140
|
+
return EXIT.ok;
|
|
4141
|
+
}
|
|
4142
|
+
let result;
|
|
4143
|
+
await runCommand(toCitty(command, (r)=>{
|
|
4144
|
+
result = r;
|
|
4145
|
+
}, rt), {
|
|
4146
|
+
rawArgs: rest
|
|
4147
|
+
});
|
|
4148
|
+
if (json && result) rt.io.stdout(JSON.stringify(result, null, 2));
|
|
4149
|
+
return result ? exitOf(result) : EXIT.ok;
|
|
4150
|
+
} catch (error) {
|
|
4151
|
+
return fail(error, rt, json, verbose);
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
export { COMMANDS, EXIT, exitCodeFor, main, realRuntime, validateResult };
|