mfeflow 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/assets/features/analytics-gtm/feature.json +1 -1
- package/assets/features/ci-github/feature.json +1 -1
- package/assets/features/env-t3/feature.json +1 -1
- package/assets/features/lint-oxlint/feature.json +1 -1
- package/assets/features/testing/feature.json +1 -1
- package/assets/features/tsconfig/feature.json +1 -1
- package/assets/features/url-factory/feature.json +1 -1
- package/assets/templates/monorepo/feature.json +7 -1
- package/assets/templates/monorepo/files/root/README.md.eta +15 -0
- package/assets/templates/monorepo/files/shell/src/assert.ts.eta +37 -0
- package/assets/templates/monorepo/files/shell/src/bootstrap.tsx.eta +12 -2
- package/dist/catalog.d.ts +4 -0
- package/dist/checks/singletons.d.ts +2 -5
- package/dist/commands/add.d.ts +4 -0
- package/dist/commands/deploy.d.ts +227 -0
- package/dist/commands/status.d.ts +58 -0
- package/dist/deploy-testing.d.ts +41 -0
- package/dist/devregistry.d.ts +4 -5
- package/dist/exit.d.ts +1 -1
- package/dist/hosts.d.ts +25 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1291 -123
- package/dist/output.d.ts +77 -5
- package/dist/pipeline.d.ts +8 -0
- package/dist/publish.d.ts +89 -0
- package/dist/runtime.d.ts +8 -1
- package/dist/testing.d.ts +66 -0
- package/dist/trust.d.ts +4 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
|
-
import {
|
|
2
|
+
import { COST_MODEL, DOCS_URL, MFE_NAME, REGISTRY_NAMESPACE, ROUTE_OVERLAP, isLoopbackHttp, isSafeRelativePath, judgeCost, judgeRemoteInitial, primaryExpose, redactUrl, redactUrlsIn, registryIndexUrl, registryTemplateProblem, remoteDistFiles, round1, routesOverlap, validateMfeManifest, validateRegistryEntry, validateRegistryIndex } from "@mfeflow/contracts";
|
|
3
|
+
import { MfeflowError, PlanBuilder, TRUST_ENV, addTrusted, applyPlan, buildRegistry, checkIntegrity, configDirOf, dotenvKeys, fetchFeatureIndex, findDiverged, findProjectRoot, getHttps, hasErrno, inOrder, isMfeflowError, loadFeature, loadProject, messageOf, parseFeature, parseTrustEnv, readCappedText, readTrusted, realFs, recordPlan, removeTrusted, renderDiff, renderTemplate, resolveFeature, runtimeConfigFiles, saveProject, schemaKeys, serializeProject, summarize, trustedNamespaces, writeFileAtomic } from "@mfeflow/core";
|
|
3
4
|
import { defineCommand, runCommand } from "citty";
|
|
4
5
|
import node_path from "node:path";
|
|
5
|
-
import { COST_MODEL, DOCS_URL, MFE_NAME, REGISTRY_NAMESPACE, assetsFromManifest, isSafeRelativePath, judgeCost, registryIndexUrl, registryTemplateProblem, round1, validateMfeManifest, validateRegistryEntry } from "@mfeflow/contracts";
|
|
6
6
|
import promises from "node:fs/promises";
|
|
7
7
|
import node_fs from "node:fs";
|
|
8
|
+
import semver, { gt } from "semver";
|
|
8
9
|
import { createRequire } from "node:module";
|
|
9
|
-
import
|
|
10
|
+
import { HostError, INDEX_CACHE_CONTROL, PreconditionFailedError, applyPlan as static_applyPlan, artifactPrefix, comparable, indexKey, localHost, planDeploy, readIndex, satisfiesRange } from "@mfeflow/registry/static";
|
|
10
11
|
import { parse } from "yaml";
|
|
11
12
|
import node_zlib from "node:zlib";
|
|
13
|
+
import node_os from "node:os";
|
|
12
14
|
import { spawn, spawnSync } from "node:child_process";
|
|
13
15
|
import node_net from "node:net";
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -266,7 +268,11 @@ function loadAny(name, source) {
|
|
|
266
268
|
} : {},
|
|
267
269
|
...void 0 === source.version ? {} : {
|
|
268
270
|
version: source.version
|
|
269
|
-
}
|
|
271
|
+
},
|
|
272
|
+
trusted: source.trusted?.has(name.slice(0, name.indexOf('/'))) === true,
|
|
273
|
+
...true === source.checkIndex ? {
|
|
274
|
+
checkIndex: true
|
|
275
|
+
} : {}
|
|
270
276
|
});
|
|
271
277
|
}
|
|
272
278
|
const RESERVED = new Set([
|
|
@@ -444,7 +450,7 @@ const EXIT = {
|
|
|
444
450
|
feature: 4,
|
|
445
451
|
build: 5,
|
|
446
452
|
registry: 6,
|
|
447
|
-
|
|
453
|
+
deploy: 7,
|
|
448
454
|
cancelled: 130
|
|
449
455
|
};
|
|
450
456
|
class Cancelled extends Error {
|
|
@@ -462,7 +468,7 @@ const BY_RANGE = {
|
|
|
462
468
|
2: EXIT.feature,
|
|
463
469
|
3: EXIT.build,
|
|
464
470
|
4: EXIT.registry,
|
|
465
|
-
5: EXIT.
|
|
471
|
+
5: EXIT.deploy
|
|
466
472
|
};
|
|
467
473
|
function exitCodeOfCode(code) {
|
|
468
474
|
if (USAGE_ERRORS.has(code)) return EXIT.usage;
|
|
@@ -557,7 +563,13 @@ function planReport(plan) {
|
|
|
557
563
|
action: c.action
|
|
558
564
|
})),
|
|
559
565
|
dependencies: plan.dependencies.map((d)=>({
|
|
560
|
-
|
|
566
|
+
file: d.file,
|
|
567
|
+
name: d.name,
|
|
568
|
+
range: d.range,
|
|
569
|
+
dev: d.dev,
|
|
570
|
+
...true === d.thirdParty ? {
|
|
571
|
+
thirdParty: true
|
|
572
|
+
} : {}
|
|
561
573
|
})),
|
|
562
574
|
scripts: plan.scripts.map((s)=>({
|
|
563
575
|
file: s.file,
|
|
@@ -625,6 +637,7 @@ function checkPlan(c, plan) {
|
|
|
625
637
|
'range'
|
|
626
638
|
])c.string(d, key, p);
|
|
627
639
|
c.boolean(d, 'dev', p);
|
|
640
|
+
if (void 0 !== d.thirdParty) c.boolean(d, 'thirdParty', p);
|
|
628
641
|
});
|
|
629
642
|
c.objects(plan, "scripts", at, (s, p)=>{
|
|
630
643
|
for (const key of [
|
|
@@ -720,6 +733,72 @@ function checkBudget(c, value) {
|
|
|
720
733
|
])c.string(f, key, p);
|
|
721
734
|
});
|
|
722
735
|
}
|
|
736
|
+
function checkFindings(c, value) {
|
|
737
|
+
c.objects(value, 'findings', '', (f, p)=>{
|
|
738
|
+
for (const key of [
|
|
739
|
+
'code',
|
|
740
|
+
'message',
|
|
741
|
+
'fix',
|
|
742
|
+
'docs'
|
|
743
|
+
])c.string(f, key, p);
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
function checkDeploy(c, value) {
|
|
747
|
+
for (const key of [
|
|
748
|
+
'root',
|
|
749
|
+
'env',
|
|
750
|
+
'host',
|
|
751
|
+
'remote',
|
|
752
|
+
'version'
|
|
753
|
+
])c.string(value, key, '');
|
|
754
|
+
for (const key of [
|
|
755
|
+
'dryRun',
|
|
756
|
+
'published'
|
|
757
|
+
])c.boolean(value, key, '');
|
|
758
|
+
for (const key of [
|
|
759
|
+
'fromEnv',
|
|
760
|
+
'servedBefore',
|
|
761
|
+
'previous',
|
|
762
|
+
'rolledBackFrom',
|
|
763
|
+
'localEntry'
|
|
764
|
+
])if (null !== value[key] && 'string' != typeof value[key]) c.add(key, 'must be text or null');
|
|
765
|
+
c.texts(value, 'uploaded', '');
|
|
766
|
+
c.texts(value, 'skipped', '');
|
|
767
|
+
c.texts(value, 'warnings', '');
|
|
768
|
+
c.objects(value, 'uploads', '', (u, p)=>{
|
|
769
|
+
for (const key of [
|
|
770
|
+
'key',
|
|
771
|
+
'contentType',
|
|
772
|
+
'cacheControl'
|
|
773
|
+
])c.string(u, key, p);
|
|
774
|
+
if ('number' != typeof u.bytes) c.add(`${p}bytes`, 'must be a number');
|
|
775
|
+
if (![
|
|
776
|
+
'create-only',
|
|
777
|
+
'if-match',
|
|
778
|
+
'none'
|
|
779
|
+
].includes(String(u.guard))) c.add(`${p}guard`, 'must be create-only, if-match or none');
|
|
780
|
+
});
|
|
781
|
+
if (true === value.dryRun && Array.isArray(value.uploaded) && value.uploaded.length > 0) c.add('uploaded', 'must be empty for a dry run');
|
|
782
|
+
if (void 0 !== value.index && !isObj(value.index)) c.add('index', 'must be an object');
|
|
783
|
+
checkFindings(c, value);
|
|
784
|
+
}
|
|
785
|
+
function checkStatus(c, value) {
|
|
786
|
+
c.string(value, 'root', '');
|
|
787
|
+
c.string(value, 'host', '');
|
|
788
|
+
c.objects(value, 'envs', '', (e, p)=>{
|
|
789
|
+
c.string(e, 'env', p);
|
|
790
|
+
c.boolean(e, 'published', p);
|
|
791
|
+
if (null !== e.generatedAt && 'string' != typeof e.generatedAt) c.add(`${p}generatedAt`, 'must be text or null');
|
|
792
|
+
c.objects(e, 'remotes', p, (r, q)=>{
|
|
793
|
+
c.string(r, 'name', q);
|
|
794
|
+
c.string(r, 'version', q);
|
|
795
|
+
c.texts(r, 'history', q);
|
|
796
|
+
c.texts(r, 'drift', q);
|
|
797
|
+
if (null !== r.unreferenced) c.texts(r, 'unreferenced', q);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
checkFindings(c, value);
|
|
801
|
+
}
|
|
723
802
|
function validateResult(value) {
|
|
724
803
|
const c = new Check();
|
|
725
804
|
if (!isObj(value)) {
|
|
@@ -781,6 +860,7 @@ function validateResult(value) {
|
|
|
781
860
|
'namespace',
|
|
782
861
|
'index'
|
|
783
862
|
])c.string(r, key, p);
|
|
863
|
+
c.boolean(r, 'trusted', p);
|
|
784
864
|
if ('number' != typeof r.items) c.add(`${p}items`, 'must be a number');
|
|
785
865
|
if (null !== r.error) if (isObj(r.error)) for (const key of [
|
|
786
866
|
'code',
|
|
@@ -796,8 +876,10 @@ function validateResult(value) {
|
|
|
796
876
|
'add',
|
|
797
877
|
'remove',
|
|
798
878
|
'list',
|
|
799
|
-
'build'
|
|
800
|
-
|
|
879
|
+
'build',
|
|
880
|
+
'trust',
|
|
881
|
+
'untrust'
|
|
882
|
+
].includes(String(value.action))) c.add('action', 'must be add, remove, list, build, trust or untrust');
|
|
801
883
|
c.boolean(value, 'dryRun', '');
|
|
802
884
|
c.boolean(value, 'changed', '');
|
|
803
885
|
if (null !== value.root) c.string(value, 'root', '');
|
|
@@ -807,6 +889,7 @@ function validateResult(value) {
|
|
|
807
889
|
'url',
|
|
808
890
|
'index'
|
|
809
891
|
])c.string(r, key, p);
|
|
892
|
+
c.boolean(r, 'trusted', p);
|
|
810
893
|
});
|
|
811
894
|
if (null !== value.build) if (isObj(value.build)) {
|
|
812
895
|
for (const key of [
|
|
@@ -842,8 +925,16 @@ function validateResult(value) {
|
|
|
842
925
|
});
|
|
843
926
|
if (!isObj(value.overrides)) c.add('overrides', 'must be an object');
|
|
844
927
|
break;
|
|
928
|
+
case 'deploy':
|
|
929
|
+
case 'promote':
|
|
930
|
+
case 'rollback':
|
|
931
|
+
checkDeploy(c, value);
|
|
932
|
+
break;
|
|
933
|
+
case 'status':
|
|
934
|
+
checkStatus(c, value);
|
|
935
|
+
break;
|
|
845
936
|
default:
|
|
846
|
-
c.add('command', 'must be init, add, features, registry, doctor, validate, check-compat, budget or
|
|
937
|
+
c.add('command', 'must be init, add, features, registry, doctor, validate, check-compat, budget, dev, deploy, promote, rollback or status');
|
|
847
938
|
}
|
|
848
939
|
return c.issues;
|
|
849
940
|
}
|
|
@@ -979,6 +1070,18 @@ function withProjectChange(plan, project) {
|
|
|
979
1070
|
]
|
|
980
1071
|
};
|
|
981
1072
|
}
|
|
1073
|
+
function stampCli(project, plan) {
|
|
1074
|
+
if (0 === withProjectChange(plan, project).changes.length) return project;
|
|
1075
|
+
return raiseCli(project);
|
|
1076
|
+
}
|
|
1077
|
+
function raiseCli(project) {
|
|
1078
|
+
const running = readPackageJson().version;
|
|
1079
|
+
if (void 0 !== project.cli && !gt(running, project.cli)) return project;
|
|
1080
|
+
return {
|
|
1081
|
+
...project,
|
|
1082
|
+
cli: running
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
982
1085
|
async function missingFolders(root) {
|
|
983
1086
|
const missing = [];
|
|
984
1087
|
let dir = root;
|
|
@@ -1045,7 +1148,8 @@ async function write_exists(dir) {
|
|
|
1045
1148
|
}
|
|
1046
1149
|
}
|
|
1047
1150
|
async function writePlan(request) {
|
|
1048
|
-
const { rt, globals, root, plan
|
|
1151
|
+
const { rt, globals, root, plan } = request;
|
|
1152
|
+
const project = stampCli(request.project, plan);
|
|
1049
1153
|
const shown = withProjectChange(plan, project);
|
|
1050
1154
|
if (!globals.json) {
|
|
1051
1155
|
rt.io.stdout(summarize(shown));
|
|
@@ -1272,32 +1376,32 @@ const KEYS = new Set([
|
|
|
1272
1376
|
'topology',
|
|
1273
1377
|
'options'
|
|
1274
1378
|
]);
|
|
1275
|
-
function
|
|
1379
|
+
function preset_bad(source, why) {
|
|
1276
1380
|
throw new MfeflowError('E109', `${source}: ${why}`);
|
|
1277
1381
|
}
|
|
1278
1382
|
function isRecord(value) {
|
|
1279
1383
|
return 'object' == typeof value && null !== value && !Array.isArray(value);
|
|
1280
1384
|
}
|
|
1281
1385
|
function stringList(source, key, value) {
|
|
1282
|
-
if (!Array.isArray(value) || !value.every((item)=>'string' == typeof item))
|
|
1386
|
+
if (!Array.isArray(value) || !value.every((item)=>'string' == typeof item)) preset_bad(source, `${key} must be a list of text`);
|
|
1283
1387
|
return value;
|
|
1284
1388
|
}
|
|
1285
1389
|
function optionsOf(source, value) {
|
|
1286
|
-
if (!isRecord(value))
|
|
1390
|
+
if (!isRecord(value)) preset_bad(source, 'options must be an object of feature names');
|
|
1287
1391
|
const out = {};
|
|
1288
1392
|
for (const [feature, given] of Object.entries(value)){
|
|
1289
|
-
if (!isRecord(given))
|
|
1393
|
+
if (!isRecord(given)) preset_bad(source, `options.${feature} must be an object`);
|
|
1290
1394
|
out[feature] = given;
|
|
1291
1395
|
}
|
|
1292
1396
|
return out;
|
|
1293
1397
|
}
|
|
1294
1398
|
function parsePreset(source, value) {
|
|
1295
|
-
if (!isRecord(value))
|
|
1399
|
+
if (!isRecord(value)) preset_bad(source, 'must be a JSON object');
|
|
1296
1400
|
const preset = {};
|
|
1297
1401
|
for (const [key, item] of Object.entries(value)){
|
|
1298
|
-
if (!KEYS.has(key))
|
|
1402
|
+
if (!KEYS.has(key)) preset_bad(source, `"${key}" is not a preset option`);
|
|
1299
1403
|
if ('scope' === key || 'packageManager' === key || 'topology' === key) {
|
|
1300
|
-
if ('string' != typeof item)
|
|
1404
|
+
if ('string' != typeof item) preset_bad(source, `${key} must be text`);
|
|
1301
1405
|
preset[key] = item;
|
|
1302
1406
|
} else if ('remotes' === key || 'features' === key) preset[key] = stringList(source, key, item);
|
|
1303
1407
|
else if ('options' === key) preset.options = optionsOf(source, item);
|
|
@@ -1306,13 +1410,13 @@ function parsePreset(source, value) {
|
|
|
1306
1410
|
}
|
|
1307
1411
|
async function readUrl(url, fetchImpl) {
|
|
1308
1412
|
const response = await getHttps(url, fetchImpl, 'E109');
|
|
1309
|
-
if (!response.ok)
|
|
1413
|
+
if (!response.ok) preset_bad(url, `answered ${String(response.status)}`);
|
|
1310
1414
|
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E109', `${url}: is larger than 100 KB`));
|
|
1311
1415
|
}
|
|
1312
1416
|
async function loadPreset(source, sources) {
|
|
1313
1417
|
let text;
|
|
1314
1418
|
if (/^https?:\/\//i.test(source)) {
|
|
1315
|
-
if (!source.toLowerCase().startsWith('https://'))
|
|
1419
|
+
if (!source.toLowerCase().startsWith('https://')) preset_bad(source, 'must be https');
|
|
1316
1420
|
text = await readUrl(source, sources.fetch);
|
|
1317
1421
|
} else {
|
|
1318
1422
|
const file = node_path.resolve(sources.cwd, source);
|
|
@@ -1334,6 +1438,9 @@ async function loadPreset(source, sources) {
|
|
|
1334
1438
|
}
|
|
1335
1439
|
return parsePreset(source, value);
|
|
1336
1440
|
}
|
|
1441
|
+
async function trustedIn(rt, registries) {
|
|
1442
|
+
return trustedNamespaces(registries ?? {}, await readTrusted(rt.configDir), parseTrustEnv(rt.io.env[TRUST_ENV]));
|
|
1443
|
+
}
|
|
1337
1444
|
const ADD_ARGS = {
|
|
1338
1445
|
port: {
|
|
1339
1446
|
type: 'string',
|
|
@@ -1370,6 +1477,10 @@ const ADD_ARGS = {
|
|
|
1370
1477
|
description: 'add env-key: root, every shell or every remote, instead of --app',
|
|
1371
1478
|
valueHint: 'root|shell|remotes'
|
|
1372
1479
|
},
|
|
1480
|
+
'accept-changed-content': {
|
|
1481
|
+
type: 'boolean',
|
|
1482
|
+
description: 'Take a registry feature whose content differs from the hash recorded in mfeflow.json'
|
|
1483
|
+
},
|
|
1373
1484
|
...GLOBAL_ARGS
|
|
1374
1485
|
};
|
|
1375
1486
|
function portFor(args, project, start) {
|
|
@@ -1388,17 +1499,23 @@ async function nameFrom(rt, globals, given, what) {
|
|
|
1388
1499
|
});
|
|
1389
1500
|
throw new MfeflowError('E107', `add ${what} needs a name: mfeflow add ${what} <name>`);
|
|
1390
1501
|
}
|
|
1391
|
-
async function addAppParts(rt, globals, builder, project, next, kind, name) {
|
|
1502
|
+
async function addAppParts(rt, globals, builder, project, next, kind, name, acceptChanged) {
|
|
1392
1503
|
const names = [
|
|
1393
1504
|
'core',
|
|
1394
1505
|
...Object.keys(project.features).filter((n)=>'core' !== n)
|
|
1395
1506
|
];
|
|
1396
1507
|
const { canPrompt } = globals;
|
|
1508
|
+
const trusted = await trustedIn(rt, project.registries);
|
|
1509
|
+
const untrusted = [
|
|
1510
|
+
...new Set(names.filter((n)=>n.startsWith('@')).map((n)=>n.slice(0, n.indexOf('/'))).filter((ns)=>ns in (project.registries ?? {}) && !trusted.fetch.has(ns)))
|
|
1511
|
+
];
|
|
1512
|
+
if (untrusted.length > 0) throw new MfeflowError('E405', `${untrusted.join(', ')} is installed here but not trusted on this machine, so it was not requested`);
|
|
1397
1513
|
const loadedAll = await Promise.all(names.map((feature)=>loadAny(feature, {
|
|
1398
1514
|
roots: rt.roots,
|
|
1399
1515
|
registries: project.registries,
|
|
1400
1516
|
fetch: rt.fetch,
|
|
1401
|
-
version: project.features[feature]?.version
|
|
1517
|
+
version: project.features[feature]?.version,
|
|
1518
|
+
trusted: trusted.privateOk
|
|
1402
1519
|
})));
|
|
1403
1520
|
let core;
|
|
1404
1521
|
const fromRegistry = [];
|
|
@@ -1415,6 +1532,20 @@ async function addAppParts(rt, globals, builder, project, next, kind, name) {
|
|
|
1415
1532
|
fix: `Nothing was written. Use a registry URL with {version} so ${recorded} can be fetched, or add the app by hand.`
|
|
1416
1533
|
});
|
|
1417
1534
|
fromRegistry.push(feature);
|
|
1535
|
+
const entry = next.features[feature];
|
|
1536
|
+
const now = loaded.integrity;
|
|
1537
|
+
if (void 0 !== entry && void 0 !== now) {
|
|
1538
|
+
if (void 0 === entry.integrity) {
|
|
1539
|
+
if (!globals.json) rt.io.stdout(`${feature} has no integrity hash in mfeflow.json; recording one now.`);
|
|
1540
|
+
next.features[feature] = {
|
|
1541
|
+
...entry,
|
|
1542
|
+
integrity: now
|
|
1543
|
+
};
|
|
1544
|
+
} else if (checkIntegrity(feature, entry.integrity, now, acceptChanged)) next.features[feature] = {
|
|
1545
|
+
...entry,
|
|
1546
|
+
integrity: now
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1418
1549
|
}
|
|
1419
1550
|
planned.push({
|
|
1420
1551
|
feature,
|
|
@@ -1447,10 +1578,13 @@ async function addApp(rt, globals, args, kind, given) {
|
|
|
1447
1578
|
path: `apps/${name}`,
|
|
1448
1579
|
port
|
|
1449
1580
|
}
|
|
1581
|
+
},
|
|
1582
|
+
features: {
|
|
1583
|
+
...project.features
|
|
1450
1584
|
}
|
|
1451
1585
|
};
|
|
1452
1586
|
const builder = createBuilder(root, chosen.checkout);
|
|
1453
|
-
const core = await addAppParts(rt, globals, builder, project, next, 'remote' === kind ? 'remotes' : 'shell', name);
|
|
1587
|
+
const core = await addAppParts(rt, globals, builder, project, next, 'remote' === kind ? 'remotes' : 'shell', name, true === args['accept-changed-content']);
|
|
1454
1588
|
const version = core.manifest.version;
|
|
1455
1589
|
if ('remote' === kind) await resolveFeature({
|
|
1456
1590
|
feature: await remoteGlue({
|
|
@@ -1612,10 +1746,13 @@ async function addFeatures(rt, globals, local, requested, options) {
|
|
|
1612
1746
|
};
|
|
1613
1747
|
}
|
|
1614
1748
|
assertOptionsAreForFeatures(options, adding);
|
|
1749
|
+
const trusted = await trustedIn(rt, project.registries);
|
|
1615
1750
|
const loaded = await Promise.all(adding.map((name)=>loadAny(name, {
|
|
1616
1751
|
roots: rt.roots,
|
|
1617
1752
|
registries: project.registries,
|
|
1618
|
-
fetch: rt.fetch
|
|
1753
|
+
fetch: rt.fetch,
|
|
1754
|
+
trusted: trusted.privateOk,
|
|
1755
|
+
checkIndex: true
|
|
1619
1756
|
})));
|
|
1620
1757
|
const thirdParty = loaded.filter((f)=>'third-party' === f.trust).map((f)=>f.manifest.name);
|
|
1621
1758
|
assertThirdPartyAllowed(thirdParty, globals, canPrompt);
|
|
@@ -1672,7 +1809,7 @@ function finding(check, code, detail, options = {}) {
|
|
|
1672
1809
|
if (void 0 !== options.path) out.path = options.path;
|
|
1673
1810
|
return out;
|
|
1674
1811
|
}
|
|
1675
|
-
const
|
|
1812
|
+
const findings_done = (findings)=>Promise.resolve({
|
|
1676
1813
|
findings
|
|
1677
1814
|
});
|
|
1678
1815
|
const SINGLETON_SPECIFIERS = [
|
|
@@ -1930,11 +2067,6 @@ async function versionIn(app, name, root) {
|
|
|
1930
2067
|
}
|
|
1931
2068
|
return declared;
|
|
1932
2069
|
}
|
|
1933
|
-
function comparable(version) {
|
|
1934
|
-
const exact = semver.valid(version);
|
|
1935
|
-
if (null !== exact) return exact;
|
|
1936
|
-
return null === semver.validRange(version) ? void 0 : semver.minVersion(version)?.version ?? void 0;
|
|
1937
|
-
}
|
|
1938
2070
|
function unresolved(check, code, name, specs) {
|
|
1939
2071
|
return finding(check, code, `${name} is ${specs.join(', ')}: the version cannot be compared until install, so it is not checked`, {
|
|
1940
2072
|
severity: 'warning'
|
|
@@ -1975,12 +2107,6 @@ const singletonVersions = {
|
|
|
1975
2107
|
};
|
|
1976
2108
|
}
|
|
1977
2109
|
};
|
|
1978
|
-
function fits(range, shellVersion) {
|
|
1979
|
-
if (void 0 === shellVersion) return true;
|
|
1980
|
-
const have = comparable(shellVersion);
|
|
1981
|
-
const valid = semver.validRange(range);
|
|
1982
|
-
return void 0 === have || null === valid || semver.satisfies(have, valid);
|
|
1983
|
-
}
|
|
1984
2110
|
async function compatFor(shell, remote, root) {
|
|
1985
2111
|
const out = [];
|
|
1986
2112
|
const at = {
|
|
@@ -1993,7 +2119,7 @@ async function compatFor(shell, remote, root) {
|
|
|
1993
2119
|
if (void 0 !== have && void 0 === comparable(have)) out.push(unresolved('compat', 'E302', name, [
|
|
1994
2120
|
`${shell.name} ${have}`
|
|
1995
2121
|
]));
|
|
1996
|
-
else if (!
|
|
2122
|
+
else if (!satisfiesRange(range, have)) out.push(finding('compat', 'E302', `${remote.name} needs ${name} ${range} (mfe.json platform) but ${shell.name} has ${String(have)}`, at));
|
|
1997
2123
|
}
|
|
1998
2124
|
for (const name of SINGLETON_PACKAGES){
|
|
1999
2125
|
const [theirs, mine] = await Promise.all([
|
|
@@ -2011,7 +2137,7 @@ async function compatFor(shell, remote, root) {
|
|
|
2011
2137
|
];
|
|
2012
2138
|
if (open.length > 0) out.push(unresolved('compat', 'E302', name, open));
|
|
2013
2139
|
if (void 0 !== want && void 0 !== mine) {
|
|
2014
|
-
if (!
|
|
2140
|
+
if (!satisfiesRange(`^${want}`, mine) && !(name in platform)) out.push(finding('compat', 'E302', `${remote.name} uses ${name} ${want}, ${shell.name} provides ${mine}`, {
|
|
2015
2141
|
app: remote.name
|
|
2016
2142
|
}));
|
|
2017
2143
|
}
|
|
@@ -2040,7 +2166,7 @@ function issueText(read) {
|
|
|
2040
2166
|
const appsOnDisk = {
|
|
2041
2167
|
id: 'config.apps',
|
|
2042
2168
|
title: 'every app in mfeflow.json is on disk',
|
|
2043
|
-
run: ({ apps })=>
|
|
2169
|
+
run: ({ apps })=>findings_done(apps.filter((app)=>!app.present).map((app)=>finding('config.apps', 'E112', `${app.name} (${app.rel}/package.json not found)`, {
|
|
2044
2170
|
app: app.name,
|
|
2045
2171
|
path: `${app.rel}/package.json`
|
|
2046
2172
|
})))
|
|
@@ -2048,7 +2174,7 @@ const appsOnDisk = {
|
|
|
2048
2174
|
const mfeJson = {
|
|
2049
2175
|
id: 'config.mfe-json',
|
|
2050
2176
|
title: 'every app has a valid mfe.json that matches mfeflow.json',
|
|
2051
|
-
run: ({ apps })=>
|
|
2177
|
+
run: ({ apps })=>findings_done(apps.filter((app)=>app.present).flatMap((app)=>{
|
|
2052
2178
|
const at = {
|
|
2053
2179
|
app: app.name,
|
|
2054
2180
|
path: `${app.rel}/mfe.json`
|
|
@@ -2074,26 +2200,28 @@ const config_ports = {
|
|
|
2074
2200
|
...byPort.get(app.port) ?? [],
|
|
2075
2201
|
app.name
|
|
2076
2202
|
]);
|
|
2077
|
-
return
|
|
2203
|
+
return findings_done([
|
|
2078
2204
|
...byPort
|
|
2079
2205
|
].filter(([, names])=>names.length > 1).map(([port, names])=>finding('config.ports', 'E115', `${names.join(' and ')} both use ${String(port)}`)));
|
|
2080
2206
|
}
|
|
2081
2207
|
};
|
|
2082
2208
|
const config_routes = {
|
|
2083
2209
|
id: 'config.routes',
|
|
2084
|
-
title: 'no two remotes claim
|
|
2210
|
+
title: 'no two remotes claim overlapping routes',
|
|
2085
2211
|
run: ({ apps })=>{
|
|
2086
|
-
const claimed =
|
|
2212
|
+
const claimed = [];
|
|
2087
2213
|
const out = [];
|
|
2088
|
-
for (const app of apps)if ('remote' === app.kind && 'ok' === app.mfe.state)
|
|
2089
|
-
const
|
|
2090
|
-
if (void 0 !== owner && owner !== app.name) out.push(finding('config.routes', 'E116', `${route} is claimed by ${owner} and ${app.name}`, {
|
|
2214
|
+
for (const app of apps)if ('remote' === app.kind && 'ok' === app.mfe.state) {
|
|
2215
|
+
for (const route of app.mfe.value.routes)for (const other of claimed)if (other.app !== app.name && routesOverlap(route, other.route)) out.push(finding('config.routes', 'E116', `${app.name} route ${route} overlaps ${other.app} route ${other.route}`, {
|
|
2091
2216
|
app: app.name,
|
|
2092
2217
|
path: `${app.rel}/mfe.json`
|
|
2093
2218
|
}));
|
|
2094
|
-
|
|
2219
|
+
for (const route of app.mfe.value.routes)claimed.push({
|
|
2220
|
+
app: app.name,
|
|
2221
|
+
route
|
|
2222
|
+
});
|
|
2095
2223
|
}
|
|
2096
|
-
return
|
|
2224
|
+
return findings_done(out);
|
|
2097
2225
|
}
|
|
2098
2226
|
};
|
|
2099
2227
|
const config_registry = {
|
|
@@ -2124,7 +2252,7 @@ const config_registry = {
|
|
|
2124
2252
|
path: `registry/envs/${env}/${name}.json`
|
|
2125
2253
|
}));
|
|
2126
2254
|
}
|
|
2127
|
-
return
|
|
2255
|
+
return findings_done(out);
|
|
2128
2256
|
}
|
|
2129
2257
|
};
|
|
2130
2258
|
const localCheckout = {
|
|
@@ -2139,17 +2267,17 @@ const localCheckout = {
|
|
|
2139
2267
|
const unbuilt = [
|
|
2140
2268
|
...scanCheckout(node_path.resolve(root, project.local))
|
|
2141
2269
|
].filter(([, p])=>!p.built).map(([name])=>name);
|
|
2142
|
-
return
|
|
2270
|
+
return findings_done(0 === unbuilt.length ? [] : [
|
|
2143
2271
|
finding('config.local', 'E110', `${unbuilt.join(', ')} not built in ${project.local}`)
|
|
2144
2272
|
]);
|
|
2145
2273
|
} catch (error) {
|
|
2146
|
-
return
|
|
2274
|
+
return findings_done([
|
|
2147
2275
|
finding('config.local', 'E110', error instanceof Error ? error.message : String(error))
|
|
2148
2276
|
]);
|
|
2149
2277
|
}
|
|
2150
2278
|
}
|
|
2151
2279
|
};
|
|
2152
|
-
const
|
|
2280
|
+
const config_drift = {
|
|
2153
2281
|
id: 'files.drift',
|
|
2154
2282
|
title: 'copied files match the hashes in mfeflow.json',
|
|
2155
2283
|
run: async ({ root, project })=>({
|
|
@@ -2196,7 +2324,7 @@ async function eachPlace(view, check) {
|
|
|
2196
2324
|
findings: [],
|
|
2197
2325
|
skipped: NOT_INSTALLED
|
|
2198
2326
|
};
|
|
2199
|
-
return
|
|
2327
|
+
return findings_done((await Promise.all(places(view).map(check))).flat());
|
|
2200
2328
|
}
|
|
2201
2329
|
async function serverKeysOf(place) {
|
|
2202
2330
|
const source = await readText(node_path.join(place.dir, 'env.ts'));
|
|
@@ -2508,7 +2636,7 @@ const runtimePlugins = {
|
|
|
2508
2636
|
const workspaceVersions = {
|
|
2509
2637
|
id: 'singletons.workspace-version',
|
|
2510
2638
|
title: 'every workspace package has a version',
|
|
2511
|
-
run: ({ workspace })=>
|
|
2639
|
+
run: ({ workspace })=>findings_done(workspace.filter((p)=>!p.hasVersion).map((p)=>finding('singletons.workspace-version', 'E111', `${p.pkg.name ?? p.rel} (${p.rel}/package.json)`, {
|
|
2512
2640
|
path: `${p.rel}/package.json`,
|
|
2513
2641
|
fixable: true
|
|
2514
2642
|
})))
|
|
@@ -2532,7 +2660,7 @@ const DOCTOR_CHECKS = [
|
|
|
2532
2660
|
singletonVersions,
|
|
2533
2661
|
workspaceVersions,
|
|
2534
2662
|
compat,
|
|
2535
|
-
|
|
2663
|
+
config_drift,
|
|
2536
2664
|
runtimePlugins,
|
|
2537
2665
|
...envChecks
|
|
2538
2666
|
];
|
|
@@ -2695,16 +2823,6 @@ async function shellInitial(dist) {
|
|
|
2695
2823
|
bytes
|
|
2696
2824
|
};
|
|
2697
2825
|
}
|
|
2698
|
-
const sizes_isObj = (v)=>'object' == typeof v && null !== v && !Array.isArray(v);
|
|
2699
|
-
const ORIGIN = 'https://dist.invalid';
|
|
2700
|
-
const SCRIPT = /\.[cm]?js$/;
|
|
2701
|
-
function distPath(url) {
|
|
2702
|
-
try {
|
|
2703
|
-
return decodeURIComponent(new URL(url).pathname).replace(/^\//, '');
|
|
2704
|
-
} catch {
|
|
2705
|
-
return;
|
|
2706
|
-
}
|
|
2707
|
-
}
|
|
2708
2826
|
async function remoteSizes(dist) {
|
|
2709
2827
|
const text = await readText(node_path.join(dist, 'mf-manifest.json'));
|
|
2710
2828
|
if (void 0 === text) return;
|
|
@@ -2714,34 +2832,13 @@ async function remoteSizes(dist) {
|
|
|
2714
2832
|
} catch {
|
|
2715
2833
|
return;
|
|
2716
2834
|
}
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
metaData: {
|
|
2721
|
-
...manifest.metaData,
|
|
2722
|
-
publicPath: 'auto',
|
|
2723
|
-
getPublicPath: void 0,
|
|
2724
|
-
globalName: void 0,
|
|
2725
|
-
remoteEntry: {
|
|
2726
|
-
...manifest.metaData.remoteEntry,
|
|
2727
|
-
type: void 0
|
|
2728
|
-
}
|
|
2729
|
-
}
|
|
2730
|
-
};
|
|
2731
|
-
let assets;
|
|
2732
|
-
try {
|
|
2733
|
-
assets = assetsFromManifest(local, `${ORIGIN}/mf-manifest.json`);
|
|
2734
|
-
} catch {
|
|
2735
|
-
return;
|
|
2736
|
-
}
|
|
2737
|
-
const entry = void 0 === assets.remoteEntry ? void 0 : distPath(assets.remoteEntry);
|
|
2738
|
-
if (void 0 === entry) return;
|
|
2739
|
-
const entryBytes = await gzipBytes(node_path.join(dist, ...entry.split('/')));
|
|
2835
|
+
const files = remoteDistFiles(manifest);
|
|
2836
|
+
if (void 0 === files) return;
|
|
2837
|
+
const entryBytes = await gzipBytes(node_path.join(dist, ...files.entry.split('/')));
|
|
2740
2838
|
if (void 0 === entryBytes) return;
|
|
2741
2839
|
const exposes = {};
|
|
2742
|
-
for (const [name,
|
|
2743
|
-
const
|
|
2744
|
-
const bytes = await sumGzip(dist, files);
|
|
2840
|
+
for (const [name, list] of Object.entries(files.exposes)){
|
|
2841
|
+
const bytes = await sumGzip(dist, list);
|
|
2745
2842
|
if (void 0 !== bytes) exposes[name] = bytes;
|
|
2746
2843
|
}
|
|
2747
2844
|
return {
|
|
@@ -2808,11 +2905,14 @@ async function remoteBudget(app, dist, model, out) {
|
|
|
2808
2905
|
}));
|
|
2809
2906
|
const own = 'ok' === app.mfe.state ? app.mfe.value : void 0;
|
|
2810
2907
|
const budgetKb = own?.budgets?.initialJsKb ?? null;
|
|
2811
|
-
const
|
|
2812
|
-
const expose = exposeNames.includes('./App') ? './App' : exposeNames[0];
|
|
2908
|
+
const expose = primaryExpose(Object.keys(size.exposes));
|
|
2813
2909
|
const routes = (own?.routes ?? []).map((route)=>{
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2910
|
+
const verdict = judgeRemoteInitial({
|
|
2911
|
+
entryBytes: size.entryBytes,
|
|
2912
|
+
exposeBytes: void 0 === expose ? 0 : size.exposes[expose] ?? 0
|
|
2913
|
+
}, budgetKb ?? void 0, model);
|
|
2914
|
+
const total = verdict.totalKb;
|
|
2915
|
+
const routePass = null === budgetKb ? null : verdict.pass;
|
|
2816
2916
|
if (false === routePass) out.push(finding('budget', 'E304', `${app.name} on ${route} loads ${String(total)} KB; mfe.json budgets.initialJsKb is ${String(budgetKb)}`, {
|
|
2817
2917
|
app: app.name,
|
|
2818
2918
|
path: `${app.rel}/mfe.json`
|
|
@@ -2875,6 +2975,782 @@ function formatBudget(result) {
|
|
|
2875
2975
|
for (const f of result.findings)lines.push('', `error ${f.code} ${f.message}`, ` Fix: ${f.fix}`, ` ${f.docs}`);
|
|
2876
2976
|
return lines.join('\n');
|
|
2877
2977
|
}
|
|
2978
|
+
const USAGE = '--host must be file:<dir> or s3://<bucket>[/prefix]';
|
|
2979
|
+
function parseHostSpec(spec, cwd) {
|
|
2980
|
+
if (spec.startsWith('file:')) {
|
|
2981
|
+
const dir = spec.slice(5);
|
|
2982
|
+
if ('' === dir) throw new MfeflowError('E506', `${USAGE}; "${spec}" names no folder`);
|
|
2983
|
+
const home = /^~(?:[/\\](.*))?$/.exec(dir);
|
|
2984
|
+
if (home) return {
|
|
2985
|
+
kind: 'file',
|
|
2986
|
+
dir: node_path.resolve(node_os.homedir(), home[1] ?? '')
|
|
2987
|
+
};
|
|
2988
|
+
return {
|
|
2989
|
+
kind: 'file',
|
|
2990
|
+
dir: node_path.resolve(cwd, dir)
|
|
2991
|
+
};
|
|
2992
|
+
}
|
|
2993
|
+
if (spec.startsWith('s3://')) {
|
|
2994
|
+
const [bucket = '', ...rest] = spec.slice(5).split('/');
|
|
2995
|
+
const parts = rest.filter((part)=>'' !== part);
|
|
2996
|
+
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)) throw new MfeflowError('E506', `${USAGE}; the bucket name is not valid`);
|
|
2997
|
+
if (parts.some((part)=>'.' === part || '..' === part)) throw new MfeflowError('E506', `${USAGE}; the prefix cannot contain . or ..`);
|
|
2998
|
+
return {
|
|
2999
|
+
kind: 's3',
|
|
3000
|
+
bucket,
|
|
3001
|
+
prefix: parts.join('/')
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
3004
|
+
throw new MfeflowError('E506', USAGE);
|
|
3005
|
+
}
|
|
3006
|
+
function describeHost(host) {
|
|
3007
|
+
if ('file' === host.kind) return `file:${host.dir}`;
|
|
3008
|
+
return '' === host.prefix ? `s3://${host.bucket}` : `s3://${host.bucket}/${host.prefix}`;
|
|
3009
|
+
}
|
|
3010
|
+
const hosts_present = (env, name)=>{
|
|
3011
|
+
const value = env[name];
|
|
3012
|
+
return void 0 === value || '' === value ? void 0 : value;
|
|
3013
|
+
};
|
|
3014
|
+
function secretsOf(env) {
|
|
3015
|
+
return [
|
|
3016
|
+
'AWS_ACCESS_KEY_ID',
|
|
3017
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
3018
|
+
'AWS_SESSION_TOKEN'
|
|
3019
|
+
].map((name)=>hosts_present(env, name)).filter((value)=>void 0 !== value);
|
|
3020
|
+
}
|
|
3021
|
+
function redact(text, secrets) {
|
|
3022
|
+
return secrets.reduce((out, secret)=>out.replaceAll(secret, '***'), text);
|
|
3023
|
+
}
|
|
3024
|
+
function withPrefix(inner, prefix) {
|
|
3025
|
+
if ('' === prefix) return inner;
|
|
3026
|
+
const at = (key)=>`${prefix}/${key}`;
|
|
3027
|
+
return {
|
|
3028
|
+
put: (key, body, options)=>inner.put(at(key), body, options),
|
|
3029
|
+
get: (key)=>inner.get(at(key)),
|
|
3030
|
+
head: (key)=>inner.head(at(key)),
|
|
3031
|
+
...void 0 === inner.list ? {} : {
|
|
3032
|
+
list: async (keyPrefix)=>(await inner.list?.(at(keyPrefix)))?.filter((key)=>key.startsWith(`${prefix}/`)).map((key)=>key.slice(prefix.length + 1)) ?? []
|
|
3033
|
+
}
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
async function openHost(spec, options) {
|
|
3037
|
+
if ('file' === spec.kind) return localHost(spec.dir);
|
|
3038
|
+
const { s3Host } = await import("@mfeflow/registry/s3");
|
|
3039
|
+
const { env } = options;
|
|
3040
|
+
const accessKeyId = hosts_present(env, 'AWS_ACCESS_KEY_ID');
|
|
3041
|
+
const secretAccessKey = hosts_present(env, 'AWS_SECRET_ACCESS_KEY');
|
|
3042
|
+
const region = hosts_present(env, 'MFEFLOW_S3_REGION') ?? hosts_present(env, 'AWS_REGION');
|
|
3043
|
+
const missing = [
|
|
3044
|
+
...void 0 === accessKeyId ? [
|
|
3045
|
+
'AWS_ACCESS_KEY_ID'
|
|
3046
|
+
] : [],
|
|
3047
|
+
...void 0 === secretAccessKey ? [
|
|
3048
|
+
'AWS_SECRET_ACCESS_KEY'
|
|
3049
|
+
] : [],
|
|
3050
|
+
...void 0 === region ? [
|
|
3051
|
+
'MFEFLOW_S3_REGION'
|
|
3052
|
+
] : []
|
|
3053
|
+
];
|
|
3054
|
+
if (void 0 === accessKeyId || void 0 === secretAccessKey || void 0 === region) throw new MfeflowError('E506', `set ${missing.join(', ')} to use ${describeHost(spec)}`);
|
|
3055
|
+
const endpoint = hosts_present(env, 'MFEFLOW_S3_ENDPOINT');
|
|
3056
|
+
const sessionToken = hosts_present(env, 'AWS_SESSION_TOKEN');
|
|
3057
|
+
return withPrefix(s3Host({
|
|
3058
|
+
region,
|
|
3059
|
+
bucket: spec.bucket,
|
|
3060
|
+
accessKeyId,
|
|
3061
|
+
secretAccessKey,
|
|
3062
|
+
...void 0 === endpoint ? {} : {
|
|
3063
|
+
endpoint
|
|
3064
|
+
},
|
|
3065
|
+
...void 0 === sessionToken ? {} : {
|
|
3066
|
+
sessionToken
|
|
3067
|
+
},
|
|
3068
|
+
...void 0 === options.fetch ? {} : {
|
|
3069
|
+
fetch: options.fetch
|
|
3070
|
+
}
|
|
3071
|
+
}), spec.prefix);
|
|
3072
|
+
}
|
|
3073
|
+
const GATE_CODES = {
|
|
3074
|
+
schema: 'E501',
|
|
3075
|
+
'route-overlap': 'E502',
|
|
3076
|
+
compat: 'E503',
|
|
3077
|
+
budget: 'E504',
|
|
3078
|
+
rollout: 'E505'
|
|
3079
|
+
};
|
|
3080
|
+
const ENV_NAME = /^[a-z][a-z0-9-]*$/;
|
|
3081
|
+
function envOf(args, key = 'env') {
|
|
3082
|
+
const env = args_text(args, key);
|
|
3083
|
+
if (void 0 === env) throw new MfeflowError('E107', `--${key} <env> is required`);
|
|
3084
|
+
if (!ENV_NAME.test(env)) throw new MfeflowError('E107', `--${key} "${env}" must be lowercase letters, digits and -`);
|
|
3085
|
+
return env;
|
|
3086
|
+
}
|
|
3087
|
+
function positionals(args, max, usage) {
|
|
3088
|
+
const given = Array.isArray(args._) ? args._.map(String) : [];
|
|
3089
|
+
const surplus = given[max];
|
|
3090
|
+
if (void 0 !== surplus) throw new MfeflowError('E107', `unexpected "${surplus}" (${usage})`);
|
|
3091
|
+
return given;
|
|
3092
|
+
}
|
|
3093
|
+
async function openSession(rt, args, raw = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive)) {
|
|
3094
|
+
const view = await inspectProject(await findProjectRoot(raw.cwd));
|
|
3095
|
+
const flag = args_text(args, 'host');
|
|
3096
|
+
const spec = flag ?? view.project.registry?.host;
|
|
3097
|
+
if (void 0 === spec) throw new MfeflowError('E506', 'no host: pass --host or set registry.host in mfeflow.json');
|
|
3098
|
+
const parsed = parseHostSpec(spec, void 0 === flag ? view.root : raw.cwd);
|
|
3099
|
+
const secrets = secretsOf(rt.io.env);
|
|
3100
|
+
let host;
|
|
3101
|
+
try {
|
|
3102
|
+
host = await openHost(parsed, {
|
|
3103
|
+
env: rt.io.env,
|
|
3104
|
+
...void 0 === rt.hostFetch ? {} : {
|
|
3105
|
+
fetch: rt.hostFetch
|
|
3106
|
+
}
|
|
3107
|
+
});
|
|
3108
|
+
} catch (error) {
|
|
3109
|
+
throw hostFailure(error, secrets);
|
|
3110
|
+
}
|
|
3111
|
+
const flagUrl = args_text(args, 'base-url');
|
|
3112
|
+
const baseUrl = void 0 === flagUrl ? view.project.registry?.baseUrl === void 0 ? void 0 : normalizeBaseUrl(view.project.registry.baseUrl, 'registry.baseUrl') : normalizeBaseUrl(flagUrl, '--base-url');
|
|
3113
|
+
return {
|
|
3114
|
+
rt,
|
|
3115
|
+
globals: raw,
|
|
3116
|
+
view,
|
|
3117
|
+
host: void 0 === rt.wrapHost ? host : rt.wrapHost(host),
|
|
3118
|
+
hostLabel: describeHost(parsed),
|
|
3119
|
+
secrets,
|
|
3120
|
+
baseUrl
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
function normalizeBaseUrl(value, label) {
|
|
3124
|
+
const bad = (why)=>new MfeflowError('E506', `${label} ${why}`);
|
|
3125
|
+
if (/[?#]/.test(value)) throw bad('must not contain a query or a fragment');
|
|
3126
|
+
let url;
|
|
3127
|
+
try {
|
|
3128
|
+
url = new URL(value);
|
|
3129
|
+
} catch {
|
|
3130
|
+
throw bad('must be a URL such as https://cdn.example.com');
|
|
3131
|
+
}
|
|
3132
|
+
if ('' !== url.username || '' !== url.password) throw bad('must not contain credentials');
|
|
3133
|
+
if ('http:' === url.protocol && !isLoopbackHttp(url)) throw bad('uses http, which is allowed only on localhost; use https');
|
|
3134
|
+
if ('https:' !== url.protocol && 'http:' !== url.protocol) throw bad('must be an https URL');
|
|
3135
|
+
let end = url.href.length;
|
|
3136
|
+
while(end > 0 && 47 === url.href.charCodeAt(end - 1))end -= 1;
|
|
3137
|
+
return url.href.slice(0, end);
|
|
3138
|
+
}
|
|
3139
|
+
function hostFailure(error, secrets) {
|
|
3140
|
+
if (error instanceof MfeflowError || error instanceof PreconditionFailedError) return error;
|
|
3141
|
+
const message = redact(messageOf(error), secrets);
|
|
3142
|
+
if (error instanceof HostError) return new MfeflowError(message.includes('already published') ? 'E511' : 'E506', message);
|
|
3143
|
+
if (error instanceof SyntaxError) return new MfeflowError('E506', `the index is ${message}`);
|
|
3144
|
+
return error instanceof Error ? error : new Error(message);
|
|
3145
|
+
}
|
|
3146
|
+
async function onHost(s, work) {
|
|
3147
|
+
try {
|
|
3148
|
+
return await work();
|
|
3149
|
+
} catch (error) {
|
|
3150
|
+
throw hostFailure(error, s.secrets);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
function remoteApp(view, name) {
|
|
3154
|
+
const remotes = view.apps.filter((a)=>'remote' === a.kind);
|
|
3155
|
+
if (void 0 === name) throw new MfeflowError('E107', `name the remote (remotes: ${remotes.map((a)=>a.name).join(', ')})`);
|
|
3156
|
+
const app = remotes.find((a)=>a.name === name);
|
|
3157
|
+
if (void 0 === app) throw new MfeflowError('E107', `"${name}" is not a remote of this project (remotes: ${remotes.map((a)=>a.name).join(', ')})`);
|
|
3158
|
+
if ('ok' !== app.mfe.state) throw new MfeflowError('E113', `${app.rel}/mfe.json is ${app.mfe.state}`);
|
|
3159
|
+
return app;
|
|
3160
|
+
}
|
|
3161
|
+
async function shellVersions(view, mfe) {
|
|
3162
|
+
const shells = view.apps.filter((a)=>'shell' === a.kind && a.present);
|
|
3163
|
+
const shell = shells.find((a)=>mfe.shells.includes(a.name)) ?? shells[0];
|
|
3164
|
+
if (void 0 === shell) return {};
|
|
3165
|
+
const out = {};
|
|
3166
|
+
for (const name of new Set([
|
|
3167
|
+
...Object.keys(mfe.platform ?? {}),
|
|
3168
|
+
...SINGLETON_PACKAGES
|
|
3169
|
+
])){
|
|
3170
|
+
const version = await versionIn(shell, name, view.root);
|
|
3171
|
+
if (void 0 !== version) out[name] = version;
|
|
3172
|
+
}
|
|
3173
|
+
return out;
|
|
3174
|
+
}
|
|
3175
|
+
const shellManifestKey = (env)=>`envs/${env}/shell/mf-manifest.json`;
|
|
3176
|
+
async function readShellManifest(s, env, file) {
|
|
3177
|
+
try {
|
|
3178
|
+
if (void 0 !== file) {
|
|
3179
|
+
const body = await promises.readFile(node_path.resolve(s.globals.cwd, file), 'utf8');
|
|
3180
|
+
return {
|
|
3181
|
+
manifest: JSON.parse(body),
|
|
3182
|
+
warnings: []
|
|
3183
|
+
};
|
|
3184
|
+
}
|
|
3185
|
+
const stored = await onHost(s, ()=>s.host.get(shellManifestKey(env)));
|
|
3186
|
+
if (void 0 === stored) return {
|
|
3187
|
+
warnings: [
|
|
3188
|
+
`no shell manifest at ${shellManifestKey(env)}; compat used workspace versions`
|
|
3189
|
+
]
|
|
3190
|
+
};
|
|
3191
|
+
return {
|
|
3192
|
+
manifest: JSON.parse(new TextDecoder().decode(stored.body)),
|
|
3193
|
+
warnings: []
|
|
3194
|
+
};
|
|
3195
|
+
} catch (error) {
|
|
3196
|
+
if (error instanceof MfeflowError) throw error;
|
|
3197
|
+
return {
|
|
3198
|
+
warnings: [
|
|
3199
|
+
'the shell manifest could not be read or is not valid JSON; compat used workspace versions'
|
|
3200
|
+
]
|
|
3201
|
+
};
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
async function hostEnvs(s) {
|
|
3205
|
+
if (void 0 === s.host.list) return [];
|
|
3206
|
+
const keys = await onHost(s, ()=>s.host.list?.('envs/') ?? Promise.resolve([]));
|
|
3207
|
+
const envs = keys.flatMap((key)=>{
|
|
3208
|
+
const match = /^envs\/([a-z][a-z0-9-]*)\/index\.json$/.exec(key);
|
|
3209
|
+
return match?.[1] === void 0 ? [] : [
|
|
3210
|
+
match[1]
|
|
3211
|
+
];
|
|
3212
|
+
});
|
|
3213
|
+
return [
|
|
3214
|
+
...new Set(envs)
|
|
3215
|
+
].toSorted((a, b)=>a.localeCompare(b));
|
|
3216
|
+
}
|
|
3217
|
+
async function currentIndex(s, env) {
|
|
3218
|
+
return onHost(s, ()=>readIndex(s.host, env));
|
|
3219
|
+
}
|
|
3220
|
+
function baseUrlFor(s, index) {
|
|
3221
|
+
if (void 0 !== s.baseUrl) return s.baseUrl;
|
|
3222
|
+
for (const remote of index?.remotes ?? []){
|
|
3223
|
+
const marker = `/${remote.name}/`;
|
|
3224
|
+
const at = remote.entry.lastIndexOf(marker);
|
|
3225
|
+
if (at > 0) return remote.entry.slice(0, at);
|
|
3226
|
+
}
|
|
3227
|
+
throw new MfeflowError('E506', "no base URL: pass --base-url or set registry.baseUrl in mfeflow.json (where the host's files are served from)");
|
|
3228
|
+
}
|
|
3229
|
+
function describeUploads(plan) {
|
|
3230
|
+
return plan.uploads.map((u)=>({
|
|
3231
|
+
key: u.key,
|
|
3232
|
+
bytes: 'string' == typeof u.body ? new TextEncoder().encode(u.body).length : u.body.length,
|
|
3233
|
+
contentType: u.contentType,
|
|
3234
|
+
cacheControl: u.cacheControl,
|
|
3235
|
+
guard: void 0 !== u.ifMatch ? 'if-match' : void 0 !== u.ifNoneMatch ? 'create-only' : 'none'
|
|
3236
|
+
}));
|
|
3237
|
+
}
|
|
3238
|
+
function gateFindings(plan, app) {
|
|
3239
|
+
return plan.failures.map((f)=>finding('deploy.gates', GATE_CODES[f.code], f.message, {
|
|
3240
|
+
app
|
|
3241
|
+
}));
|
|
3242
|
+
}
|
|
3243
|
+
function otherWriter(index, env, name) {
|
|
3244
|
+
if (void 0 === index) return `${indexKey(env)} was created by someone else`;
|
|
3245
|
+
const served = index.remotes.find((r)=>r.name === name)?.version;
|
|
3246
|
+
const at = void 0 === index.generatedAt ? '' : ` at ${index.generatedAt}`;
|
|
3247
|
+
return `${indexKey(env)} was rewritten${at} by another deploy${void 0 === served ? '' : `; it now serves ${name}@${served}`}`;
|
|
3248
|
+
}
|
|
3249
|
+
async function commit(s, options) {
|
|
3250
|
+
let attempt = 0;
|
|
3251
|
+
for(;;){
|
|
3252
|
+
attempt += 1;
|
|
3253
|
+
const planned = await onHost(s, options.plan);
|
|
3254
|
+
const { plan } = planned;
|
|
3255
|
+
if (plan.failures.length > 0 || options.dryRun || true === planned.noop) return {
|
|
3256
|
+
planned,
|
|
3257
|
+
applied: void 0,
|
|
3258
|
+
published: false
|
|
3259
|
+
};
|
|
3260
|
+
const toApply = true === options.noPublish ? {
|
|
3261
|
+
...plan,
|
|
3262
|
+
uploads: plan.uploads.filter((u)=>u.key !== plan.indexKey)
|
|
3263
|
+
} : plan;
|
|
3264
|
+
try {
|
|
3265
|
+
const applied = await onHost(s, ()=>static_applyPlan(s.host, toApply));
|
|
3266
|
+
return {
|
|
3267
|
+
planned,
|
|
3268
|
+
applied,
|
|
3269
|
+
published: true !== options.noPublish
|
|
3270
|
+
};
|
|
3271
|
+
} catch (error) {
|
|
3272
|
+
if (!(error instanceof PreconditionFailedError)) throw error;
|
|
3273
|
+
if (attempt < 2) continue;
|
|
3274
|
+
const now = await currentIndex(s, options.env).catch(()=>void 0);
|
|
3275
|
+
throw new MfeflowError('E510', otherWriter(now?.index, options.env, options.name));
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
function localEntryOf(remote) {
|
|
3280
|
+
return {
|
|
3281
|
+
version: remote.version ?? '',
|
|
3282
|
+
manifestUrl: remote.entry,
|
|
3283
|
+
...void 0 === remote.remoteEntry ? {} : {
|
|
3284
|
+
remoteEntry: remote.remoteEntry
|
|
3285
|
+
},
|
|
3286
|
+
...void 0 === remote.type ? {} : {
|
|
3287
|
+
type: remote.type
|
|
3288
|
+
},
|
|
3289
|
+
...void 0 === remote.entryGlobalName ? {} : {
|
|
3290
|
+
entryGlobalName: remote.entryGlobalName
|
|
3291
|
+
},
|
|
3292
|
+
...void 0 === remote.exposes ? {} : {
|
|
3293
|
+
exposes: remote.exposes
|
|
3294
|
+
},
|
|
3295
|
+
...void 0 === remote.previous ? {} : {
|
|
3296
|
+
previous: remote.previous
|
|
3297
|
+
},
|
|
3298
|
+
...void 0 === remote.owner ? {} : {
|
|
3299
|
+
owner: remote.owner
|
|
3300
|
+
}
|
|
3301
|
+
};
|
|
3302
|
+
}
|
|
3303
|
+
async function writeLocalEntry(root, env, remote) {
|
|
3304
|
+
const file = node_path.join(root, 'registry', 'envs', env, `${remote.name}.json`);
|
|
3305
|
+
await promises.mkdir(node_path.dirname(file), {
|
|
3306
|
+
recursive: true
|
|
3307
|
+
});
|
|
3308
|
+
await promises.writeFile(file, `${JSON.stringify(localEntryOf(remote), null, 2)}\n`);
|
|
3309
|
+
return node_path.relative(root, file).split(node_path.sep).join('/');
|
|
3310
|
+
}
|
|
3311
|
+
const HOST_ARGS = {
|
|
3312
|
+
host: {
|
|
3313
|
+
type: 'string',
|
|
3314
|
+
description: 'Where the registry lives: file:<dir> or s3://<bucket>[/prefix] (default registry.host)',
|
|
3315
|
+
valueHint: 'spec'
|
|
3316
|
+
},
|
|
3317
|
+
'base-url': {
|
|
3318
|
+
type: 'string',
|
|
3319
|
+
description: "Where the host's files are served from (default registry.baseUrl)",
|
|
3320
|
+
valueHint: 'url'
|
|
3321
|
+
},
|
|
3322
|
+
'shell-manifest': {
|
|
3323
|
+
type: 'string',
|
|
3324
|
+
description: "The shell's deployed mf-manifest.json (default envs/<env>/shell/ on the host)",
|
|
3325
|
+
valueHint: 'file'
|
|
3326
|
+
}
|
|
3327
|
+
};
|
|
3328
|
+
const DEPLOY_ARGS = {
|
|
3329
|
+
env: {
|
|
3330
|
+
type: 'string',
|
|
3331
|
+
description: 'Environment to deploy to',
|
|
3332
|
+
valueHint: 'env'
|
|
3333
|
+
},
|
|
3334
|
+
version: {
|
|
3335
|
+
type: 'string',
|
|
3336
|
+
description: "Version to publish (default: the remote's package.json version)",
|
|
3337
|
+
valueHint: 'v'
|
|
3338
|
+
},
|
|
3339
|
+
dist: {
|
|
3340
|
+
type: 'string',
|
|
3341
|
+
description: 'Build output folder inside the remote (default dist)',
|
|
3342
|
+
valueHint: 'dir'
|
|
3343
|
+
},
|
|
3344
|
+
publish: {
|
|
3345
|
+
type: 'boolean',
|
|
3346
|
+
default: true,
|
|
3347
|
+
description: 'Move the index to the new version (--no-publish uploads the files and stops)'
|
|
3348
|
+
},
|
|
3349
|
+
...HOST_ARGS,
|
|
3350
|
+
...GLOBAL_ARGS
|
|
3351
|
+
};
|
|
3352
|
+
const PROMOTE_ARGS = {
|
|
3353
|
+
from: {
|
|
3354
|
+
type: 'string',
|
|
3355
|
+
description: 'Environment to copy the version from',
|
|
3356
|
+
valueHint: 'env'
|
|
3357
|
+
},
|
|
3358
|
+
to: {
|
|
3359
|
+
type: 'string',
|
|
3360
|
+
description: 'Environment to point at it',
|
|
3361
|
+
valueHint: 'env'
|
|
3362
|
+
},
|
|
3363
|
+
...HOST_ARGS,
|
|
3364
|
+
...GLOBAL_ARGS
|
|
3365
|
+
};
|
|
3366
|
+
const ROLLBACK_ARGS = {
|
|
3367
|
+
env: {
|
|
3368
|
+
type: 'string',
|
|
3369
|
+
description: 'Environment to roll back',
|
|
3370
|
+
valueHint: 'env'
|
|
3371
|
+
},
|
|
3372
|
+
to: {
|
|
3373
|
+
type: 'string',
|
|
3374
|
+
description: 'Version to go back to (default: the previous one)',
|
|
3375
|
+
valueHint: 'v'
|
|
3376
|
+
},
|
|
3377
|
+
force: {
|
|
3378
|
+
type: 'boolean',
|
|
3379
|
+
description: 'Roll back even if the schema, route or compatibility checks fail (the files must still exist)'
|
|
3380
|
+
},
|
|
3381
|
+
...HOST_ARGS,
|
|
3382
|
+
...GLOBAL_ARGS
|
|
3383
|
+
};
|
|
3384
|
+
async function deploy_walk(root, rel, out) {
|
|
3385
|
+
const entries = await promises.readdir(node_path.join(root, rel), {
|
|
3386
|
+
withFileTypes: true
|
|
3387
|
+
});
|
|
3388
|
+
for (const entry of entries){
|
|
3389
|
+
const next = '' === rel ? entry.name : `${rel}/${entry.name}`;
|
|
3390
|
+
if (entry.isDirectory()) await deploy_walk(root, next, out);
|
|
3391
|
+
else if (entry.isFile()) out[next] = new Uint8Array(await promises.readFile(node_path.join(root, next)));
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
async function readBuild(dir, shown) {
|
|
3395
|
+
const files = {};
|
|
3396
|
+
try {
|
|
3397
|
+
await deploy_walk(dir, '', files);
|
|
3398
|
+
} catch (error) {
|
|
3399
|
+
if (hasErrno(error, 'ENOENT') || hasErrno(error, 'ENOTDIR')) throw new MfeflowError('E507', `${shown} does not exist`);
|
|
3400
|
+
throw error;
|
|
3401
|
+
}
|
|
3402
|
+
const raw = files['mf-manifest.json'];
|
|
3403
|
+
if (void 0 === raw) throw new MfeflowError('E507', `${shown}/mf-manifest.json is not there`);
|
|
3404
|
+
let manifest;
|
|
3405
|
+
try {
|
|
3406
|
+
manifest = JSON.parse(new TextDecoder().decode(raw));
|
|
3407
|
+
} catch {
|
|
3408
|
+
throw new MfeflowError('E507', `${shown}/mf-manifest.json is not valid JSON`);
|
|
3409
|
+
}
|
|
3410
|
+
return {
|
|
3411
|
+
manifest,
|
|
3412
|
+
files
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
async function deploy_confirm(s, message, dryRun) {
|
|
3416
|
+
if (dryRun || !s.globals.canPrompt) return;
|
|
3417
|
+
if (!await s.rt.prompter.confirm({
|
|
3418
|
+
message,
|
|
3419
|
+
initialValue: false
|
|
3420
|
+
})) throw new Cancelled();
|
|
3421
|
+
}
|
|
3422
|
+
async function deploy_finish(s, facts, done) {
|
|
3423
|
+
const { plan, before, warnings, noop } = done.planned;
|
|
3424
|
+
const after = plan.index.remotes.find((r)=>r.name === facts.remote);
|
|
3425
|
+
const shown = true === noop ? before : after;
|
|
3426
|
+
const failed = plan.failures.length > 0;
|
|
3427
|
+
const dryRun = s.globals.dryRun;
|
|
3428
|
+
const moved = done.published && !failed;
|
|
3429
|
+
let localEntry = null;
|
|
3430
|
+
const local = 'dev' !== facts.env;
|
|
3431
|
+
if (moved && local && void 0 !== after) localEntry = await writeLocalEntry(s.view.root, facts.env, after);
|
|
3432
|
+
const notes = moved && !local ? [
|
|
3433
|
+
'registry/envs/dev was not written: `mfeflow dev` reads it'
|
|
3434
|
+
] : [];
|
|
3435
|
+
const result = {
|
|
3436
|
+
ok: !failed,
|
|
3437
|
+
command: facts.command,
|
|
3438
|
+
dryRun,
|
|
3439
|
+
root: s.view.root,
|
|
3440
|
+
env: facts.env,
|
|
3441
|
+
fromEnv: facts.fromEnv ?? null,
|
|
3442
|
+
host: s.hostLabel,
|
|
3443
|
+
remote: facts.remote,
|
|
3444
|
+
version: facts.version,
|
|
3445
|
+
servedBefore: before?.version ?? null,
|
|
3446
|
+
published: moved,
|
|
3447
|
+
previous: shown?.previous ?? null,
|
|
3448
|
+
rolledBackFrom: shown?.rolledBackFrom ?? null,
|
|
3449
|
+
uploads: failed ? [] : describeUploads(plan),
|
|
3450
|
+
uploaded: done.applied?.uploaded ?? [],
|
|
3451
|
+
skipped: done.applied?.skipped ?? [],
|
|
3452
|
+
localEntry,
|
|
3453
|
+
warnings: [
|
|
3454
|
+
...warnings,
|
|
3455
|
+
...notes
|
|
3456
|
+
],
|
|
3457
|
+
findings: gateFindings(plan, facts.remote)
|
|
3458
|
+
};
|
|
3459
|
+
if (dryRun && !failed) result.index = plan.index;
|
|
3460
|
+
return result;
|
|
3461
|
+
}
|
|
3462
|
+
function formatDeploy(result) {
|
|
3463
|
+
const lines = [];
|
|
3464
|
+
const target = `${result.remote}@${result.version}`;
|
|
3465
|
+
const route = null === result.fromEnv ? `to ${result.env}` : `from ${result.fromEnv} to ${result.env}`;
|
|
3466
|
+
const verb = result.dryRun ? `Plan for ${result.command}` : result.command;
|
|
3467
|
+
lines.push(`${verb} ${target} ${route} (${result.host})`);
|
|
3468
|
+
for (const f of result.findings)lines.push(`error ${f.code} ${f.message}`, ` Fix: ${f.fix}`, ` ${f.docs}`);
|
|
3469
|
+
for (const w of result.warnings)lines.push(`warning ${w}`);
|
|
3470
|
+
if (!result.ok) {
|
|
3471
|
+
lines.push('Nothing was uploaded.');
|
|
3472
|
+
return lines.join('\n');
|
|
3473
|
+
}
|
|
3474
|
+
if (result.dryRun) {
|
|
3475
|
+
lines.push('Would upload, in order:');
|
|
3476
|
+
for (const u of result.uploads)lines.push(` ${u.key} (${String(u.bytes)} bytes, ${u.guard})`);
|
|
3477
|
+
lines.push('Dry run: nothing was written.');
|
|
3478
|
+
return lines.join('\n');
|
|
3479
|
+
}
|
|
3480
|
+
const files = `${String(result.uploaded.length)} uploaded, ${String(result.skipped.length)} already there`;
|
|
3481
|
+
if (result.published) {
|
|
3482
|
+
const was = null === result.servedBefore ? 'first deploy' : `was ${result.servedBefore}`;
|
|
3483
|
+
lines.push(`Files: ${files}`, `${result.env} now serves ${target} (${was}).`);
|
|
3484
|
+
if (null !== result.rolledBackFrom) lines.push(`Rolled back from ${result.rolledBackFrom}.`);
|
|
3485
|
+
if (null !== result.localEntry) lines.push(`Wrote ${result.localEntry}.`);
|
|
3486
|
+
} else if (result.uploaded.length + result.skipped.length > 0) lines.push(`Files: ${files}`, `The index was not changed; ${result.env} still serves ${result.servedBefore ?? 'nothing'}.`);
|
|
3487
|
+
else lines.push(`${result.env} already serves ${target}. Nothing to do.`);
|
|
3488
|
+
return lines.join('\n');
|
|
3489
|
+
}
|
|
3490
|
+
async function deployPlan(s, mfe, build, version, env, shellFile) {
|
|
3491
|
+
const { index, etag } = await currentIndex(s, env);
|
|
3492
|
+
const shell = await readShellManifest(s, env, shellFile);
|
|
3493
|
+
const plan = planDeploy({
|
|
3494
|
+
remote: mfe,
|
|
3495
|
+
version,
|
|
3496
|
+
manifest: build.manifest,
|
|
3497
|
+
files: build.files,
|
|
3498
|
+
env,
|
|
3499
|
+
baseUrl: baseUrlFor(s, index),
|
|
3500
|
+
...void 0 === index || void 0 === etag ? {} : {
|
|
3501
|
+
currentIndex: index,
|
|
3502
|
+
currentIndexEtag: etag
|
|
3503
|
+
},
|
|
3504
|
+
...void 0 === shell.manifest ? {} : {
|
|
3505
|
+
shellManifest: shell.manifest
|
|
3506
|
+
},
|
|
3507
|
+
workspaceVersions: await shellVersions(s.view, mfe)
|
|
3508
|
+
});
|
|
3509
|
+
return {
|
|
3510
|
+
plan,
|
|
3511
|
+
before: index?.remotes.find((r)=>r.name === mfe.name),
|
|
3512
|
+
warnings: [
|
|
3513
|
+
...shell.warnings,
|
|
3514
|
+
...plan.warnings
|
|
3515
|
+
]
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
async function runDeploy(rt, args, raw) {
|
|
3519
|
+
assertKnownFlags(raw, DEPLOY_ARGS);
|
|
3520
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
3521
|
+
const [name] = positionals(args, 1, 'mfeflow deploy <remote> --env <env>');
|
|
3522
|
+
const env = envOf(args);
|
|
3523
|
+
const s = await openSession(rt, args, globals);
|
|
3524
|
+
const app = remoteApp(s.view, name);
|
|
3525
|
+
const version = args_text(args, 'version') ?? app.pkg?.version;
|
|
3526
|
+
if (void 0 === version) throw new MfeflowError('E507', `${app.name} has no version: pass --version or set one in its package.json`);
|
|
3527
|
+
const dist = args_text(args, 'dist') ?? 'dist';
|
|
3528
|
+
const build = await readBuild(node_path.join(app.dir, dist), `${app.rel}/${dist}`);
|
|
3529
|
+
const noPublish = false === args.publish;
|
|
3530
|
+
await deploy_confirm(s, `Deploy ${app.name}@${version} to ${env} on ${s.hostLabel}?`, globals.dryRun);
|
|
3531
|
+
const done = await commit(s, {
|
|
3532
|
+
env,
|
|
3533
|
+
name: app.name,
|
|
3534
|
+
dryRun: globals.dryRun,
|
|
3535
|
+
noPublish,
|
|
3536
|
+
plan: ()=>deployPlan(s, app.mfe.value, build, version, env, args_text(args, 'shell-manifest'))
|
|
3537
|
+
});
|
|
3538
|
+
const result = await deploy_finish(s, {
|
|
3539
|
+
command: 'deploy',
|
|
3540
|
+
env,
|
|
3541
|
+
remote: app.name,
|
|
3542
|
+
version
|
|
3543
|
+
}, done);
|
|
3544
|
+
if (!globals.json) rt.io.stdout(formatDeploy(result));
|
|
3545
|
+
return result;
|
|
3546
|
+
}
|
|
3547
|
+
async function versionManifest(s, name, version) {
|
|
3548
|
+
const prefix = artifactPrefix(name, version);
|
|
3549
|
+
const missing = (what)=>new MfeflowError('E509', `${name}@${version}: ${what} (${prefix}/)`);
|
|
3550
|
+
const stored = await s.host.get(`${prefix}/mf-manifest.json`);
|
|
3551
|
+
if (void 0 === stored) throw missing('mf-manifest.json is not on the host');
|
|
3552
|
+
let manifest;
|
|
3553
|
+
try {
|
|
3554
|
+
manifest = JSON.parse(new TextDecoder().decode(stored.body));
|
|
3555
|
+
} catch {
|
|
3556
|
+
throw missing('mf-manifest.json on the host is not valid JSON');
|
|
3557
|
+
}
|
|
3558
|
+
const dist = remoteDistFiles(manifest);
|
|
3559
|
+
if (void 0 === dist) throw missing('mf-manifest.json has no usable remote entry');
|
|
3560
|
+
const wanted = [
|
|
3561
|
+
...new Set([
|
|
3562
|
+
dist.entry,
|
|
3563
|
+
...Object.values(dist.exposes).flat()
|
|
3564
|
+
])
|
|
3565
|
+
];
|
|
3566
|
+
const gone = [];
|
|
3567
|
+
for (const file of wanted)if (await s.host.head(`${prefix}/${file}`) === void 0) gone.push(file);
|
|
3568
|
+
if (gone.length > 0) throw missing(`${gone.slice(0, 5).join(', ')}${gone.length > 5 ? ' and more' : ''} not on the host`);
|
|
3569
|
+
return manifest;
|
|
3570
|
+
}
|
|
3571
|
+
async function movePlan(s, app, version, env, source, shellFile, options = {}) {
|
|
3572
|
+
const { index, etag } = await currentIndex(s, env);
|
|
3573
|
+
const before = index?.remotes.find((r)=>r.name === app.name);
|
|
3574
|
+
const origin = options.guard?.(before) ?? source;
|
|
3575
|
+
if (before?.version === version) return {
|
|
3576
|
+
plan: {
|
|
3577
|
+
uploads: [],
|
|
3578
|
+
index: index ?? {
|
|
3579
|
+
remotes: []
|
|
3580
|
+
},
|
|
3581
|
+
indexKey: '',
|
|
3582
|
+
failures: [],
|
|
3583
|
+
warnings: []
|
|
3584
|
+
},
|
|
3585
|
+
before,
|
|
3586
|
+
warnings: [],
|
|
3587
|
+
noop: true
|
|
3588
|
+
};
|
|
3589
|
+
const manifest = await versionManifest(s, app.name, version);
|
|
3590
|
+
const shell = await readShellManifest(s, env, shellFile);
|
|
3591
|
+
const mfe = {
|
|
3592
|
+
...app.mfe,
|
|
3593
|
+
routes: origin.routes ?? app.mfe.routes,
|
|
3594
|
+
owner: origin.owner ?? app.mfe.owner
|
|
3595
|
+
};
|
|
3596
|
+
const plan = planDeploy({
|
|
3597
|
+
remote: mfe,
|
|
3598
|
+
version,
|
|
3599
|
+
manifest,
|
|
3600
|
+
files: {},
|
|
3601
|
+
env,
|
|
3602
|
+
baseUrl: baseUrlFor(s, {
|
|
3603
|
+
remotes: [
|
|
3604
|
+
origin,
|
|
3605
|
+
...index?.remotes ?? []
|
|
3606
|
+
]
|
|
3607
|
+
}),
|
|
3608
|
+
...void 0 === index || void 0 === etag ? {} : {
|
|
3609
|
+
currentIndex: index,
|
|
3610
|
+
currentIndexEtag: etag
|
|
3611
|
+
},
|
|
3612
|
+
...void 0 === shell.manifest ? {} : {
|
|
3613
|
+
shellManifest: shell.manifest
|
|
3614
|
+
},
|
|
3615
|
+
workspaceVersions: await shellVersions(s.view, mfe)
|
|
3616
|
+
});
|
|
3617
|
+
const warnings = [
|
|
3618
|
+
...shell.warnings,
|
|
3619
|
+
...plan.warnings
|
|
3620
|
+
];
|
|
3621
|
+
const gates = plan.failures.filter((f)=>'budget' !== f.code);
|
|
3622
|
+
const forced = true === options.force ? gates : [];
|
|
3623
|
+
const blocking = gates.filter((f)=>!forced.includes(f));
|
|
3624
|
+
if (blocking.length > 0) return {
|
|
3625
|
+
plan: {
|
|
3626
|
+
...plan,
|
|
3627
|
+
failures: blocking
|
|
3628
|
+
},
|
|
3629
|
+
before,
|
|
3630
|
+
warnings
|
|
3631
|
+
};
|
|
3632
|
+
if (forced.length > 0) {
|
|
3633
|
+
const invalid = validateRegistryIndex(plan.index).filter((i)=>i.code !== ROUTE_OVERLAP);
|
|
3634
|
+
if (invalid.length > 0) throw new MfeflowError('E501', `even --force cannot write an invalid index: ${invalid.map((i)=>`${i.path} ${i.message}`).join('; ')}`);
|
|
3635
|
+
warnings.push(`FORCED: --force skipped ${String(forced.length)} failed check(s); ${version} may not work with the shell that is deployed: ${forced.map((f)=>f.message).join(' | ')}`);
|
|
3636
|
+
}
|
|
3637
|
+
const indexUpload = {
|
|
3638
|
+
key: plan.indexKey,
|
|
3639
|
+
body: `${JSON.stringify(plan.index, null, 2)}\n`,
|
|
3640
|
+
contentType: 'application/json',
|
|
3641
|
+
cacheControl: INDEX_CACHE_CONTROL,
|
|
3642
|
+
...void 0 === etag ? {
|
|
3643
|
+
ifNoneMatch: '*'
|
|
3644
|
+
} : {
|
|
3645
|
+
ifMatch: etag
|
|
3646
|
+
}
|
|
3647
|
+
};
|
|
3648
|
+
return {
|
|
3649
|
+
plan: {
|
|
3650
|
+
...plan,
|
|
3651
|
+
failures: [],
|
|
3652
|
+
uploads: [
|
|
3653
|
+
indexUpload
|
|
3654
|
+
]
|
|
3655
|
+
},
|
|
3656
|
+
before,
|
|
3657
|
+
warnings
|
|
3658
|
+
};
|
|
3659
|
+
}
|
|
3660
|
+
function inEnv(index, name, env) {
|
|
3661
|
+
const remote = index?.remotes.find((r)=>r.name === name);
|
|
3662
|
+
if (remote?.version === void 0) throw new MfeflowError('E508', `${name} is not deployed to ${env}`);
|
|
3663
|
+
return remote;
|
|
3664
|
+
}
|
|
3665
|
+
async function runPromote(rt, args, raw) {
|
|
3666
|
+
assertKnownFlags(raw, PROMOTE_ARGS);
|
|
3667
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
3668
|
+
const [name] = positionals(args, 1, 'mfeflow promote <remote> --from <env> --to <env>');
|
|
3669
|
+
const from = envOf(args, 'from');
|
|
3670
|
+
const to = envOf(args, 'to');
|
|
3671
|
+
if (from === to) throw new MfeflowError('E107', `--from and --to are both "${from}"`);
|
|
3672
|
+
const s = await openSession(rt, args, globals);
|
|
3673
|
+
const app = remoteApp(s.view, name);
|
|
3674
|
+
const source = inEnv((await currentIndex(s, from)).index, app.name, from);
|
|
3675
|
+
const version = source.version ?? '';
|
|
3676
|
+
await deploy_confirm(s, `Point ${to} at ${app.name}@${version} (now in ${from}) on ${s.hostLabel}?`, globals.dryRun);
|
|
3677
|
+
const done = await commit(s, {
|
|
3678
|
+
env: to,
|
|
3679
|
+
name: app.name,
|
|
3680
|
+
dryRun: globals.dryRun,
|
|
3681
|
+
plan: async ()=>{
|
|
3682
|
+
const nowSource = inEnv((await currentIndex(s, from)).index, app.name, from);
|
|
3683
|
+
if (nowSource.version !== version) throw new MfeflowError('E510', `${from} now serves ${app.name}@${String(nowSource.version)}, not ${version} as when this promote started; check \`mfeflow status\` and run it again`);
|
|
3684
|
+
return movePlan(s, {
|
|
3685
|
+
name: app.name,
|
|
3686
|
+
mfe: app.mfe.value
|
|
3687
|
+
}, version, to, nowSource, args_text(args, 'shell-manifest'));
|
|
3688
|
+
}
|
|
3689
|
+
});
|
|
3690
|
+
const result = await deploy_finish(s, {
|
|
3691
|
+
command: 'promote',
|
|
3692
|
+
env: to,
|
|
3693
|
+
fromEnv: from,
|
|
3694
|
+
remote: app.name,
|
|
3695
|
+
version
|
|
3696
|
+
}, done);
|
|
3697
|
+
if (!globals.json) rt.io.stdout(formatDeploy(result));
|
|
3698
|
+
return result;
|
|
3699
|
+
}
|
|
3700
|
+
async function runRollback(rt, args, raw) {
|
|
3701
|
+
assertKnownFlags(raw, ROLLBACK_ARGS);
|
|
3702
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
3703
|
+
const [name] = positionals(args, 1, 'mfeflow rollback <remote> --env <env>');
|
|
3704
|
+
const env = envOf(args);
|
|
3705
|
+
const s = await openSession(rt, args, globals);
|
|
3706
|
+
const app = remoteApp(s.view, name);
|
|
3707
|
+
const explicit = args_text(args, 'to');
|
|
3708
|
+
const target = (remote)=>{
|
|
3709
|
+
const known = [
|
|
3710
|
+
...new Set([
|
|
3711
|
+
remote.previous,
|
|
3712
|
+
...remote.history ?? []
|
|
3713
|
+
])
|
|
3714
|
+
].filter((v)=>void 0 !== v);
|
|
3715
|
+
const to = explicit ?? remote.previous;
|
|
3716
|
+
if (void 0 === to) throw new MfeflowError('E508', `${app.name} in ${env} has no previous version to go back to`);
|
|
3717
|
+
if (to === remote.version) throw new MfeflowError('E508', `${env} already serves ${app.name}@${to}`);
|
|
3718
|
+
if (!known.includes(to)) throw new MfeflowError('E508', `${app.name}@${to} is not in the history of ${env} (${0 === known.length ? 'empty' : known.join(', ')})`);
|
|
3719
|
+
return to;
|
|
3720
|
+
};
|
|
3721
|
+
const current = inEnv((await currentIndex(s, env)).index, app.name, env);
|
|
3722
|
+
const to = target(current);
|
|
3723
|
+
await deploy_confirm(s, `Roll ${env} back from ${app.name}@${current.version ?? ''} to ${to} on ${s.hostLabel}?`, globals.dryRun);
|
|
3724
|
+
const done = await commit(s, {
|
|
3725
|
+
env,
|
|
3726
|
+
name: app.name,
|
|
3727
|
+
dryRun: globals.dryRun,
|
|
3728
|
+
plan: ()=>movePlan(s, {
|
|
3729
|
+
name: app.name,
|
|
3730
|
+
mfe: app.mfe.value
|
|
3731
|
+
}, to, env, current, args_text(args, 'shell-manifest'), {
|
|
3732
|
+
force: true === args.force,
|
|
3733
|
+
guard: (fresh)=>{
|
|
3734
|
+
const now = inEnv(void 0 === fresh ? void 0 : {
|
|
3735
|
+
remotes: [
|
|
3736
|
+
fresh
|
|
3737
|
+
]
|
|
3738
|
+
}, app.name, env);
|
|
3739
|
+
if (void 0 === explicit && now.version !== current.version) throw new MfeflowError('E510', `${env} now serves ${app.name}@${String(now.version)}, not ${String(current.version)} as when this rollback started; check \`mfeflow status\` and run it again`);
|
|
3740
|
+
target(now);
|
|
3741
|
+
return now;
|
|
3742
|
+
}
|
|
3743
|
+
})
|
|
3744
|
+
});
|
|
3745
|
+
const result = await deploy_finish(s, {
|
|
3746
|
+
command: 'rollback',
|
|
3747
|
+
env,
|
|
3748
|
+
remote: app.name,
|
|
3749
|
+
version: to
|
|
3750
|
+
}, done);
|
|
3751
|
+
if (!globals.json) rt.io.stdout(formatDeploy(result));
|
|
3752
|
+
return result;
|
|
3753
|
+
}
|
|
2878
3754
|
const LOCAL_HOSTS = new Set([
|
|
2879
3755
|
'localhost',
|
|
2880
3756
|
'127.0.0.1',
|
|
@@ -3554,17 +4430,27 @@ function features_line(feature, widths) {
|
|
|
3554
4430
|
];
|
|
3555
4431
|
return ` ${feature.source.padEnd(widths.source)} ${feature.name.padEnd(widths.name)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
|
|
3556
4432
|
}
|
|
3557
|
-
function fetchIndexes(rt, registries) {
|
|
4433
|
+
function fetchIndexes(rt, registries, trusted) {
|
|
3558
4434
|
return Promise.all(Object.entries(registries).toSorted(([a], [b])=>a.localeCompare(b)).map(async ([namespace, template])=>{
|
|
3559
4435
|
const base = {
|
|
3560
4436
|
namespace,
|
|
3561
|
-
index: registryIndexUrl(template)
|
|
4437
|
+
index: redactUrl(registryIndexUrl(template))
|
|
4438
|
+
};
|
|
4439
|
+
if (!trusted.fetch.has(namespace)) return {
|
|
4440
|
+
status: {
|
|
4441
|
+
...base,
|
|
4442
|
+
trusted: false,
|
|
4443
|
+
items: 0,
|
|
4444
|
+
error: null
|
|
4445
|
+
},
|
|
4446
|
+
items: []
|
|
3562
4447
|
};
|
|
3563
4448
|
try {
|
|
3564
|
-
const items = await fetchFeatureIndex(namespace, registries, rt.fetch);
|
|
4449
|
+
const items = await fetchFeatureIndex(namespace, registries, rt.fetch, trusted.privateOk.has(namespace));
|
|
3565
4450
|
return {
|
|
3566
4451
|
status: {
|
|
3567
4452
|
...base,
|
|
4453
|
+
trusted: true,
|
|
3568
4454
|
items: items.length,
|
|
3569
4455
|
error: null
|
|
3570
4456
|
},
|
|
@@ -3575,6 +4461,7 @@ function fetchIndexes(rt, registries) {
|
|
|
3575
4461
|
return {
|
|
3576
4462
|
status: {
|
|
3577
4463
|
...base,
|
|
4464
|
+
trusted: true,
|
|
3578
4465
|
items: 0,
|
|
3579
4466
|
error: report
|
|
3580
4467
|
},
|
|
@@ -3604,7 +4491,7 @@ async function runFeatures(rt, args, raw) {
|
|
|
3604
4491
|
installed: installed.has(first.name)
|
|
3605
4492
|
};
|
|
3606
4493
|
})),
|
|
3607
|
-
fetchIndexes(rt, project?.registries ?? {})
|
|
4494
|
+
trustedIn(rt, project?.registries).then((trusted)=>fetchIndexes(rt, project?.registries ?? {}, trusted))
|
|
3608
4495
|
]);
|
|
3609
4496
|
const fromRegistries = fetched.flatMap(({ status, items })=>items.toSorted((a, b)=>a.name.localeCompare(b.name)).map((item)=>{
|
|
3610
4497
|
const name = `${status.namespace}/${item.name}`;
|
|
@@ -3636,6 +4523,11 @@ async function runFeatures(rt, args, raw) {
|
|
|
3636
4523
|
rt.io.stdout(features.map((f)=>features_line(f, widths)).join('\n'));
|
|
3637
4524
|
}
|
|
3638
4525
|
for (const { namespace, error } of registries)if (error) rt.io.stderr(`Could not read ${namespace}: ${error.message}\n ${error.fix}`);
|
|
4526
|
+
const untrusted = registries.filter((r)=>!r.trusted).map((r)=>r.namespace);
|
|
4527
|
+
if (untrusted.length > 0) rt.io.stdout([
|
|
4528
|
+
'',
|
|
4529
|
+
...untrusted.map((namespace)=>`${namespace}: not trusted yet, nothing was requested. Run \`mfeflow registry trust ${namespace}\` to list its features.`)
|
|
4530
|
+
].join('\n'));
|
|
3639
4531
|
rt.io.stdout(0 === registries.length ? '\nFrom a registry: mfeflow registry add @namespace <url>, then mfeflow add @namespace/name.' : '\nAdd one with: mfeflow add @namespace/name.');
|
|
3640
4532
|
}
|
|
3641
4533
|
return {
|
|
@@ -3904,12 +4796,14 @@ const REGISTRY_ARGS = {
|
|
|
3904
4796
|
},
|
|
3905
4797
|
...GLOBAL_ARGS
|
|
3906
4798
|
};
|
|
3907
|
-
const
|
|
3908
|
-
function registry_entries(project) {
|
|
4799
|
+
const registry_USAGE = 'mfeflow registry add @namespace <url-template> | list | remove @namespace | trust @namespace | untrust @namespace | build <dir>';
|
|
4800
|
+
async function registry_entries(rt, project) {
|
|
4801
|
+
const trusted = await trustedIn(rt, project.registries);
|
|
3909
4802
|
return Object.entries(project.registries ?? {}).toSorted(([a], [b])=>a.localeCompare(b)).map(([namespace, url])=>({
|
|
3910
4803
|
namespace,
|
|
3911
|
-
url,
|
|
3912
|
-
index: registryIndexUrl(url)
|
|
4804
|
+
url: redactUrl(url),
|
|
4805
|
+
index: redactUrl(registryIndexUrl(url)),
|
|
4806
|
+
trusted: trusted.fetch.has(namespace)
|
|
3913
4807
|
}));
|
|
3914
4808
|
}
|
|
3915
4809
|
function registry_result(action, globals, extra) {
|
|
@@ -3938,11 +4832,13 @@ async function projectOf(globals) {
|
|
|
3938
4832
|
};
|
|
3939
4833
|
}
|
|
3940
4834
|
async function save(globals, root, project, registries) {
|
|
3941
|
-
const { registries:
|
|
4835
|
+
const { registries: old, ...rest } = project;
|
|
4836
|
+
const changed = JSON.stringify(old ?? {}) !== JSON.stringify(registries);
|
|
4837
|
+
const base = changed ? raiseCli(rest) : rest;
|
|
3942
4838
|
const next = Object.keys(registries).length > 0 ? {
|
|
3943
|
-
...
|
|
4839
|
+
...base,
|
|
3944
4840
|
registries
|
|
3945
|
-
} :
|
|
4841
|
+
} : base;
|
|
3946
4842
|
if (!globals.dryRun) await saveProject(root, next);
|
|
3947
4843
|
return next;
|
|
3948
4844
|
}
|
|
@@ -3954,25 +4850,34 @@ async function addRegistry(rt, globals, namespaceArg, url) {
|
|
|
3954
4850
|
const { root, project } = await projectOf(globals);
|
|
3955
4851
|
const current = project.registries ?? {};
|
|
3956
4852
|
const existing = current[namespace];
|
|
4853
|
+
const shown = redactUrl(url);
|
|
3957
4854
|
if (existing === url) {
|
|
3958
|
-
|
|
4855
|
+
const newlyTrusted = !globals.dryRun && await addTrusted(rt.configDir, {
|
|
4856
|
+
namespace,
|
|
4857
|
+
template: url
|
|
4858
|
+
});
|
|
4859
|
+
if (!globals.json) rt.io.stdout(`${namespace} already points at ${shown}; nothing to do${newlyTrusted ? ' except trust it' : ''}.`);
|
|
3959
4860
|
return registry_result('add', globals, {
|
|
3960
4861
|
root,
|
|
3961
|
-
registries: registry_entries(project)
|
|
4862
|
+
registries: await registry_entries(rt, project)
|
|
3962
4863
|
});
|
|
3963
4864
|
}
|
|
3964
|
-
if (void 0 !== existing) throw new MfeflowError('E107', `${namespace} already points at ${existing}`, {
|
|
4865
|
+
if (void 0 !== existing) throw new MfeflowError('E107', `${namespace} already points at ${redactUrl(existing)}`, {
|
|
3965
4866
|
fix: `Run \`mfeflow registry remove ${namespace}\` first, then add it again.`
|
|
3966
4867
|
});
|
|
3967
4868
|
const next = await save(globals, root, project, {
|
|
3968
4869
|
...current,
|
|
3969
4870
|
[namespace]: url
|
|
3970
4871
|
});
|
|
3971
|
-
if (!globals.
|
|
4872
|
+
if (!globals.dryRun) await addTrusted(rt.configDir, {
|
|
4873
|
+
namespace,
|
|
4874
|
+
template: url
|
|
4875
|
+
});
|
|
4876
|
+
if (!globals.json) rt.io.stdout(globals.dryRun ? `Dry run: would add and trust ${namespace} -> ${shown}. Nothing was written.` : `Added ${namespace} -> ${shown} and trusted it on this machine.\nSee what it offers: mfeflow features`);
|
|
3972
4877
|
return registry_result('add', globals, {
|
|
3973
4878
|
root,
|
|
3974
4879
|
changed: true,
|
|
3975
|
-
registries: registry_entries(next)
|
|
4880
|
+
registries: await registry_entries(rt, next)
|
|
3976
4881
|
});
|
|
3977
4882
|
}
|
|
3978
4883
|
async function removeRegistry(rt, globals, namespaceArg) {
|
|
@@ -3987,26 +4892,73 @@ async function removeRegistry(rt, globals, namespaceArg) {
|
|
|
3987
4892
|
fix: `Keep ${namespace} while they are installed: \`mfeflow add remote\` and \`add shell\` fetch them again and fail without it.`
|
|
3988
4893
|
});
|
|
3989
4894
|
const next = await save(globals, root, project, rest);
|
|
3990
|
-
if (!globals.json) rt.io.stdout(globals.dryRun ? `Dry run: would remove ${namespace}. Nothing was written.` : `Removed ${namespace} (${url}).`);
|
|
4895
|
+
if (!globals.json) rt.io.stdout(globals.dryRun ? `Dry run: would remove ${namespace}. Nothing was written.` : `Removed ${namespace} (${redactUrl(url)}).`);
|
|
3991
4896
|
return registry_result('remove', globals, {
|
|
3992
4897
|
root,
|
|
3993
4898
|
changed: true,
|
|
3994
|
-
registries: registry_entries(next)
|
|
4899
|
+
registries: await registry_entries(rt, next)
|
|
3995
4900
|
});
|
|
3996
4901
|
}
|
|
3997
4902
|
async function listRegistries(rt, globals) {
|
|
3998
4903
|
const { root, project } = await projectOf(globals);
|
|
3999
|
-
const registries = registry_entries(project);
|
|
4904
|
+
const registries = await registry_entries(rt, project);
|
|
4000
4905
|
if (!globals.json) if (0 === registries.length) rt.io.stdout('No registries. Add one: mfeflow registry add @namespace https://example.com/{name}.json');
|
|
4001
4906
|
else {
|
|
4002
4907
|
const width = Math.max(...registries.map((r)=>r.namespace.length));
|
|
4003
|
-
rt.io.stdout(registries.map((r)=>` ${r.namespace.padEnd(width)} ${r.url}`).join('\n'));
|
|
4908
|
+
rt.io.stdout(registries.map((r)=>` ${r.namespace.padEnd(width)} ${r.url} [${r.trusted ? 'trusted' : 'not trusted yet'}]`).join('\n'));
|
|
4909
|
+
if (registries.some((r)=>!r.trusted)) rt.io.stdout('\nTrust one with: mfeflow registry trust @namespace');
|
|
4004
4910
|
}
|
|
4005
4911
|
return registry_result('list', globals, {
|
|
4006
4912
|
root,
|
|
4007
4913
|
registries
|
|
4008
4914
|
});
|
|
4009
4915
|
}
|
|
4916
|
+
async function trustRegistry(rt, globals, namespaceArg) {
|
|
4917
|
+
const namespace = assertNamespace(namespaceArg, 'trust');
|
|
4918
|
+
const { root, project } = await projectOf(globals);
|
|
4919
|
+
const template = project.registries?.[namespace];
|
|
4920
|
+
if (void 0 === template) throw new MfeflowError('E107', `${namespace} is not in this project's registries`, {
|
|
4921
|
+
fix: 'List them with `mfeflow registry list`, or add it with `mfeflow registry add`.'
|
|
4922
|
+
});
|
|
4923
|
+
const shown = redactUrl(template);
|
|
4924
|
+
const recorded = await readTrusted(rt.configDir);
|
|
4925
|
+
const already = recorded.some((e)=>e.namespace === namespace && e.template === template);
|
|
4926
|
+
if (!already && !globals.dryRun) {
|
|
4927
|
+
if (globals.canPrompt) {
|
|
4928
|
+
const yes = await rt.prompter.confirm({
|
|
4929
|
+
message: `Trust ${namespace} (${shown})? mfeflow will request its index and features from that address.`
|
|
4930
|
+
});
|
|
4931
|
+
if (!yes) throw new Cancelled();
|
|
4932
|
+
} else if (!globals.yes) throw new MfeflowError('E107', `trusting ${namespace} needs a person to confirm it; pass --yes to trust it without a terminal (or set ${TRUST_ENV}=${namespace} for one run)`);
|
|
4933
|
+
await addTrusted(rt.configDir, {
|
|
4934
|
+
namespace,
|
|
4935
|
+
template
|
|
4936
|
+
});
|
|
4937
|
+
}
|
|
4938
|
+
if (!globals.json) rt.io.stdout(globals.dryRun ? `Dry run: would trust ${namespace} (${shown}). Nothing was written.` : already ? `${namespace} is already trusted.` : `Trusted ${namespace} (${shown}) on this machine.`);
|
|
4939
|
+
return registry_result('trust', globals, {
|
|
4940
|
+
root,
|
|
4941
|
+
changed: !already,
|
|
4942
|
+
registries: await registry_entries(rt, project)
|
|
4943
|
+
});
|
|
4944
|
+
}
|
|
4945
|
+
async function untrustRegistry(rt, globals, namespaceArg) {
|
|
4946
|
+
const namespace = assertNamespace(namespaceArg, 'untrust');
|
|
4947
|
+
let found;
|
|
4948
|
+
try {
|
|
4949
|
+
found = await projectOf(globals);
|
|
4950
|
+
} catch (error) {
|
|
4951
|
+
if (!isMfeflowError(error) || 'MFEFLOW_E102' !== error.code) throw error;
|
|
4952
|
+
}
|
|
4953
|
+
const recorded = (await readTrusted(rt.configDir)).some((e)=>e.namespace === namespace);
|
|
4954
|
+
if (recorded && !globals.dryRun) await removeTrusted(rt.configDir, namespace);
|
|
4955
|
+
if (!globals.json) rt.io.stdout(recorded ? globals.dryRun ? `Dry run: would stop trusting ${namespace}. Nothing was written.` : `No longer trusting ${namespace}. mfeflow features will not request it.` : `${namespace} was not trusted; nothing to do.`);
|
|
4956
|
+
return registry_result('untrust', globals, {
|
|
4957
|
+
root: found?.root ?? null,
|
|
4958
|
+
changed: recorded,
|
|
4959
|
+
registries: void 0 === found ? [] : await registry_entries(rt, found.project)
|
|
4960
|
+
});
|
|
4961
|
+
}
|
|
4010
4962
|
async function buildFolder(rt, globals, args, dirArg) {
|
|
4011
4963
|
if (void 0 === dirArg) throw new MfeflowError('E107', 'registry build needs the folder that holds your features: mfeflow registry build ./my-registry');
|
|
4012
4964
|
const dir = node_path.resolve(globals.cwd, dirArg);
|
|
@@ -4040,17 +4992,19 @@ async function buildFolder(rt, globals, args, dirArg) {
|
|
|
4040
4992
|
}
|
|
4041
4993
|
async function runRegistry(rt, args, raw) {
|
|
4042
4994
|
assertKnownFlags(raw, REGISTRY_ARGS);
|
|
4043
|
-
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
4995
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
4044
4996
|
const positional = Array.isArray(args._) ? args._.map(String) : [];
|
|
4045
4997
|
const [action, first, second] = positional;
|
|
4046
4998
|
const takes = {
|
|
4047
4999
|
add: 2,
|
|
4048
5000
|
remove: 1,
|
|
4049
5001
|
list: 0,
|
|
4050
|
-
build: 1
|
|
5002
|
+
build: 1,
|
|
5003
|
+
trust: 1,
|
|
5004
|
+
untrust: 1
|
|
4051
5005
|
};
|
|
4052
5006
|
const surplus = positional[(takes[action ?? ''] ?? 1 / 0) + 1];
|
|
4053
|
-
if (void 0 !== surplus) throw new MfeflowError('E107', `unexpected "${surplus}" (${
|
|
5007
|
+
if (void 0 !== surplus) throw new MfeflowError('E107', `unexpected "${surplus}" (${registry_USAGE})`);
|
|
4054
5008
|
switch(action){
|
|
4055
5009
|
case 'add':
|
|
4056
5010
|
return addRegistry(rt, globals, first, second);
|
|
@@ -4058,13 +5012,177 @@ async function runRegistry(rt, args, raw) {
|
|
|
4058
5012
|
return removeRegistry(rt, globals, first);
|
|
4059
5013
|
case 'list':
|
|
4060
5014
|
return listRegistries(rt, globals);
|
|
5015
|
+
case 'trust':
|
|
5016
|
+
return trustRegistry(rt, globals, first);
|
|
5017
|
+
case 'untrust':
|
|
5018
|
+
return untrustRegistry(rt, globals, first);
|
|
4061
5019
|
case 'build':
|
|
4062
5020
|
return buildFolder(rt, globals, args, first);
|
|
4063
5021
|
case void 0:
|
|
4064
5022
|
default:
|
|
4065
|
-
throw new MfeflowError('E107', void 0 === action ? `registry needs an action (${
|
|
5023
|
+
throw new MfeflowError('E107', void 0 === action ? `registry needs an action (${registry_USAGE})` : `unknown action "${action}" (${registry_USAGE})`);
|
|
4066
5024
|
}
|
|
4067
5025
|
}
|
|
5026
|
+
const STATUS_ARGS = {
|
|
5027
|
+
env: {
|
|
5028
|
+
type: 'string',
|
|
5029
|
+
description: 'Only this environment (default: every env on the host, and every folder in registry/envs but dev)',
|
|
5030
|
+
valueHint: 'env'
|
|
5031
|
+
},
|
|
5032
|
+
host: {
|
|
5033
|
+
type: 'string',
|
|
5034
|
+
description: 'Where the registry lives: file:<dir> or s3://<bucket>[/prefix] (default registry.host)',
|
|
5035
|
+
valueHint: 'spec'
|
|
5036
|
+
},
|
|
5037
|
+
...GLOBAL_ARGS
|
|
5038
|
+
};
|
|
5039
|
+
const decodeVersion = (segment)=>segment.replaceAll('_', '+');
|
|
5040
|
+
function mentioned(indexes, name) {
|
|
5041
|
+
const out = new Set();
|
|
5042
|
+
for (const index of indexes)for (const remote of index.remotes.filter((r)=>r.name === name)){
|
|
5043
|
+
const all = [
|
|
5044
|
+
remote.version,
|
|
5045
|
+
remote.previous,
|
|
5046
|
+
remote.rolledBackFrom,
|
|
5047
|
+
...remote.history ?? []
|
|
5048
|
+
];
|
|
5049
|
+
for (const v of all)if (void 0 !== v) out.add(v);
|
|
5050
|
+
}
|
|
5051
|
+
return out;
|
|
5052
|
+
}
|
|
5053
|
+
function compareVersions(a, b) {
|
|
5054
|
+
if (null !== semver.valid(a) && null !== semver.valid(b)) return semver.compareBuild(a, b);
|
|
5055
|
+
return a.localeCompare(b);
|
|
5056
|
+
}
|
|
5057
|
+
function pendingOver(served, version) {
|
|
5058
|
+
if (void 0 === served || null === semver.valid(version) || null === semver.valid(served)) return true;
|
|
5059
|
+
return semver.compareBuild(version, served) > 0;
|
|
5060
|
+
}
|
|
5061
|
+
async function unreferenced(s, name, served, indexes) {
|
|
5062
|
+
if (void 0 === s.host.list) return null;
|
|
5063
|
+
const keys = await onHost(s, ()=>s.host.list?.(`${name}/`) ?? Promise.resolve([]));
|
|
5064
|
+
const tracked = mentioned(indexes, name);
|
|
5065
|
+
const found = new Set();
|
|
5066
|
+
for (const key of keys){
|
|
5067
|
+
const segment = key.split('/')[1];
|
|
5068
|
+
if (void 0 !== segment && '' !== segment) found.add(decodeVersion(segment));
|
|
5069
|
+
}
|
|
5070
|
+
return [
|
|
5071
|
+
...found
|
|
5072
|
+
].filter((v)=>!tracked.has(v) && pendingOver(served, v)).toSorted(compareVersions);
|
|
5073
|
+
}
|
|
5074
|
+
function driftOf(view, env, remote, extra) {
|
|
5075
|
+
const file = `registry/envs/${env}/${remote.name}.json`;
|
|
5076
|
+
const entry = view.registry[env]?.[remote.name];
|
|
5077
|
+
const drift = [];
|
|
5078
|
+
let local = null;
|
|
5079
|
+
if (void 0 === entry) drift.push(`${file} is missing; the host serves ${String(remote.version)}`);
|
|
5080
|
+
else if ('ok' !== entry.state) drift.push(`${file} is ${entry.state}`);
|
|
5081
|
+
else {
|
|
5082
|
+
local = entry.value.version;
|
|
5083
|
+
if (local !== remote.version) drift.push(`${file} says ${local}, the host serves ${String(remote.version)}`);
|
|
5084
|
+
else if (entry.value.manifestUrl !== remote.entry) drift.push(`${file} points at ${entry.value.manifestUrl}, the host at ${remote.entry}`);
|
|
5085
|
+
}
|
|
5086
|
+
if (null !== extra && extra.length > 0) drift.push(`uploaded but not pointed at: ${extra.join(', ')} (deploy it, or it was uploaded with --no-publish)`);
|
|
5087
|
+
return {
|
|
5088
|
+
local,
|
|
5089
|
+
drift
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
async function envStatus(s, env, all, findings) {
|
|
5093
|
+
const index = all.get(env);
|
|
5094
|
+
if (void 0 === index) return {
|
|
5095
|
+
env,
|
|
5096
|
+
published: false,
|
|
5097
|
+
generatedAt: null,
|
|
5098
|
+
remotes: []
|
|
5099
|
+
};
|
|
5100
|
+
const remotes = [];
|
|
5101
|
+
for (const remote of index.remotes){
|
|
5102
|
+
const extra = await unreferenced(s, remote.name, remote.version, [
|
|
5103
|
+
...all.values()
|
|
5104
|
+
]);
|
|
5105
|
+
const { local, drift } = driftOf(s.view, env, remote, extra);
|
|
5106
|
+
for (const message of drift)findings.push(finding('status.drift', 'E512', message, {
|
|
5107
|
+
severity: 'warning',
|
|
5108
|
+
app: remote.name
|
|
5109
|
+
}));
|
|
5110
|
+
remotes.push({
|
|
5111
|
+
name: remote.name,
|
|
5112
|
+
version: remote.version ?? '',
|
|
5113
|
+
previous: remote.previous ?? null,
|
|
5114
|
+
history: remote.history ?? [],
|
|
5115
|
+
rolledBackFrom: remote.rolledBackFrom ?? null,
|
|
5116
|
+
local,
|
|
5117
|
+
unreferenced: extra,
|
|
5118
|
+
drift
|
|
5119
|
+
});
|
|
5120
|
+
}
|
|
5121
|
+
return {
|
|
5122
|
+
env,
|
|
5123
|
+
published: true,
|
|
5124
|
+
generatedAt: index.generatedAt ?? null,
|
|
5125
|
+
remotes
|
|
5126
|
+
};
|
|
5127
|
+
}
|
|
5128
|
+
function formatStatus(result) {
|
|
5129
|
+
const lines = [
|
|
5130
|
+
`Registry ${result.host}`
|
|
5131
|
+
];
|
|
5132
|
+
for (const e of result.envs){
|
|
5133
|
+
lines.push('', e.env);
|
|
5134
|
+
if (!e.published) {
|
|
5135
|
+
lines.push(' nothing deployed yet');
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
lines.push(` index written ${e.generatedAt ?? 'at an unknown time'}`);
|
|
5139
|
+
for (const r of e.remotes){
|
|
5140
|
+
const back = null === r.previous ? 'no previous' : `previous ${r.previous}`;
|
|
5141
|
+
const rolled = null === r.rolledBackFrom ? '' : `, rolled back from ${r.rolledBackFrom}`;
|
|
5142
|
+
lines.push(` ${r.name} ${r.version} (${back}${rolled})${r.drift.length > 0 ? ' DRIFT' : ''}`);
|
|
5143
|
+
for (const d of r.drift)lines.push(` drift: ${d}`);
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
if (result.findings.length > 0) lines.push('', 'Drift: see the lines marked above; fix it with deploy, promote or rollback.');
|
|
5147
|
+
return lines.join('\n');
|
|
5148
|
+
}
|
|
5149
|
+
async function runStatus(rt, args, raw) {
|
|
5150
|
+
assertKnownFlags(raw, STATUS_ARGS);
|
|
5151
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
5152
|
+
positionals(args, 0, 'mfeflow status [--env <env>]');
|
|
5153
|
+
const s = await openSession(rt, args, globals);
|
|
5154
|
+
const onTheHost = await hostEnvs(s);
|
|
5155
|
+
const envs = void 0 === args.env ? [
|
|
5156
|
+
...new Set([
|
|
5157
|
+
...onTheHost,
|
|
5158
|
+
...s.view.envDirs.filter((e)=>'dev' !== e)
|
|
5159
|
+
])
|
|
5160
|
+
].toSorted((a, b)=>a.localeCompare(b)) : [
|
|
5161
|
+
envOf(args)
|
|
5162
|
+
];
|
|
5163
|
+
const findings = [];
|
|
5164
|
+
const out = [];
|
|
5165
|
+
const all = new Map();
|
|
5166
|
+
for (const env of new Set([
|
|
5167
|
+
...envs,
|
|
5168
|
+
...onTheHost,
|
|
5169
|
+
...s.view.envDirs
|
|
5170
|
+
])){
|
|
5171
|
+
const { index } = await currentIndex(s, env);
|
|
5172
|
+
if (void 0 !== index) all.set(env, index);
|
|
5173
|
+
}
|
|
5174
|
+
for (const env of envs)out.push(await envStatus(s, env, all, findings));
|
|
5175
|
+
const result = {
|
|
5176
|
+
ok: true,
|
|
5177
|
+
command: 'status',
|
|
5178
|
+
root: s.view.root,
|
|
5179
|
+
host: s.hostLabel,
|
|
5180
|
+
envs: out,
|
|
5181
|
+
findings
|
|
5182
|
+
};
|
|
5183
|
+
if (!globals.json) rt.io.stdout(formatStatus(result));
|
|
5184
|
+
return result;
|
|
5185
|
+
}
|
|
4068
5186
|
const REPO_URL = 'https://github.com/mfeflow/mfeflow';
|
|
4069
5187
|
function flagLabel(name, def) {
|
|
4070
5188
|
const alias = 'alias' in def ? [
|
|
@@ -4103,8 +5221,8 @@ function exitCodesText() {
|
|
|
4103
5221
|
` ${String(EXIT.project)} project or config problem (MFEFLOW_E1xx)`,
|
|
4104
5222
|
` ${String(EXIT.feature)} feature problem (MFEFLOW_E2xx)`,
|
|
4105
5223
|
` ${String(EXIT.build)} build or runtime problem (MFEFLOW_E3xx)`,
|
|
4106
|
-
` ${String(EXIT.registry)} registry problem (MFEFLOW_E4xx)`,
|
|
4107
|
-
` ${String(EXIT.
|
|
5224
|
+
` ${String(EXIT.registry)} feature registry problem (MFEFLOW_E4xx)`,
|
|
5225
|
+
` ${String(EXIT.deploy)} deploy problem (MFEFLOW_E5xx)`,
|
|
4108
5226
|
` ${String(EXIT.cancelled)} cancelled at a prompt, or Ctrl+C in mfeflow dev`
|
|
4109
5227
|
].join('\n');
|
|
4110
5228
|
}
|
|
@@ -4300,6 +5418,7 @@ function realRuntime(io = processIo()) {
|
|
|
4300
5418
|
system: createSystem(io),
|
|
4301
5419
|
fetch: (url, init)=>fetch(url, init),
|
|
4302
5420
|
roots: assetRoots(),
|
|
5421
|
+
configDir: configDirOf(io.env),
|
|
4303
5422
|
dev: realDevHost()
|
|
4304
5423
|
};
|
|
4305
5424
|
}
|
|
@@ -4351,13 +5470,14 @@ const COMMANDS = [
|
|
|
4351
5470
|
{
|
|
4352
5471
|
name: 'registry',
|
|
4353
5472
|
summary: 'configure third-party feature registries, or build one to host',
|
|
4354
|
-
usage: 'mfeflow registry add @namespace <url-template> | mfeflow registry list | mfeflow registry remove @namespace | mfeflow registry build <dir> [--out dir]',
|
|
5473
|
+
usage: 'mfeflow registry add @namespace <url-template> | mfeflow registry list | mfeflow registry remove @namespace | mfeflow registry trust @namespace | mfeflow registry untrust @namespace | mfeflow registry build <dir> [--out dir]',
|
|
4355
5474
|
args: REGISTRY_ARGS,
|
|
4356
5475
|
examples: [
|
|
4357
5476
|
'mfeflow registry add @acme https://acme.github.io/features/{name}.json',
|
|
4358
5477
|
'mfeflow registry add @test http://127.0.0.1:4010/{name}.json # plain http: loopback hosts only',
|
|
4359
5478
|
'mfeflow registry list --json',
|
|
4360
5479
|
'mfeflow registry remove @acme --dry-run',
|
|
5480
|
+
'mfeflow registry trust @acme # a registry from a cloned repo is not asked until you trust it',
|
|
4361
5481
|
'mfeflow registry build ./my-registry --out ./site'
|
|
4362
5482
|
],
|
|
4363
5483
|
run: runRegistry
|
|
@@ -4409,6 +5529,54 @@ const COMMANDS = [
|
|
|
4409
5529
|
],
|
|
4410
5530
|
run: runBudget
|
|
4411
5531
|
},
|
|
5532
|
+
{
|
|
5533
|
+
name: 'deploy',
|
|
5534
|
+
summary: "upload a remote's build to the registry host and point an environment at it",
|
|
5535
|
+
usage: 'mfeflow deploy <remote> --env <env> [--version <v>] [--host <spec>] [--no-publish]',
|
|
5536
|
+
args: DEPLOY_ARGS,
|
|
5537
|
+
examples: [
|
|
5538
|
+
'mfeflow deploy catalog --env staging --host file:./site --base-url https://cdn.example.com',
|
|
5539
|
+
'mfeflow deploy catalog --env prod',
|
|
5540
|
+
'mfeflow deploy catalog --env prod --version 1.4.2 --dry-run',
|
|
5541
|
+
'mfeflow deploy catalog --env prod --no-publish # upload, leave the index alone',
|
|
5542
|
+
'mfeflow deploy catalog --env prod --host s3://my-registry/mfe --json'
|
|
5543
|
+
],
|
|
5544
|
+
run: runDeploy
|
|
5545
|
+
},
|
|
5546
|
+
{
|
|
5547
|
+
name: 'promote',
|
|
5548
|
+
summary: 'point one environment at the version another environment serves; no upload',
|
|
5549
|
+
usage: 'mfeflow promote <remote> --from <env> --to <env> [--host <spec>]',
|
|
5550
|
+
args: PROMOTE_ARGS,
|
|
5551
|
+
examples: [
|
|
5552
|
+
'mfeflow promote catalog --from staging --to prod',
|
|
5553
|
+
'mfeflow promote catalog --from staging --to prod --dry-run'
|
|
5554
|
+
],
|
|
5555
|
+
run: runPromote
|
|
5556
|
+
},
|
|
5557
|
+
{
|
|
5558
|
+
name: 'rollback',
|
|
5559
|
+
summary: 'point an environment back at an earlier version; no rebuild, no upload',
|
|
5560
|
+
usage: 'mfeflow rollback <remote> --env <env> [--to <version>] [--host <spec>]',
|
|
5561
|
+
args: ROLLBACK_ARGS,
|
|
5562
|
+
examples: [
|
|
5563
|
+
'mfeflow rollback catalog --env prod',
|
|
5564
|
+
'mfeflow rollback catalog --env prod --to 1.3.0',
|
|
5565
|
+
'mfeflow rollback catalog --env prod --dry-run --json'
|
|
5566
|
+
],
|
|
5567
|
+
run: runRollback
|
|
5568
|
+
},
|
|
5569
|
+
{
|
|
5570
|
+
name: 'status',
|
|
5571
|
+
summary: 'show what each environment serves, the version before it, and drift',
|
|
5572
|
+
usage: 'mfeflow status [--env <env>] [--host <spec>]',
|
|
5573
|
+
args: STATUS_ARGS,
|
|
5574
|
+
examples: [
|
|
5575
|
+
'mfeflow status',
|
|
5576
|
+
'mfeflow status --env prod --json'
|
|
5577
|
+
],
|
|
5578
|
+
run: runStatus
|
|
5579
|
+
},
|
|
4412
5580
|
{
|
|
4413
5581
|
name: 'dev',
|
|
4414
5582
|
summary: 'run the shell and every remote together, with a dev registry that follows the project',
|
|
@@ -4455,7 +5623,7 @@ function fail(error, rt, json, verbose) {
|
|
|
4455
5623
|
if (isMfeflowError(mfe)) {
|
|
4456
5624
|
if (json) rt.io.stdout(JSON.stringify(failure(mfe), null, 2));
|
|
4457
5625
|
else rt.io.stderr(mfe.format());
|
|
4458
|
-
if (verbose && void 0 !== mfe.cause) rt.io.stderr(unexpectedText(mfe.cause, true));
|
|
5626
|
+
if (verbose && void 0 !== mfe.cause) rt.io.stderr(redactUrlsIn(unexpectedText(mfe.cause, true)));
|
|
4459
5627
|
return exitCodeFor(mfe);
|
|
4460
5628
|
}
|
|
4461
5629
|
if (json) {
|