mfeflow 0.3.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 +23 -16
- 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/features.d.ts +2 -2
- package/dist/commands/registry.d.ts +52 -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 +1584 -126
- package/dist/output.d.ts +116 -2
- 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, assetsFromManifest, isSafeRelativePath, judgeCost, 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)) {
|
|
@@ -765,6 +844,7 @@ function validateResult(value) {
|
|
|
765
844
|
if (null !== value.search && 'string' != typeof value.search) c.add('search', 'must be text or null');
|
|
766
845
|
c.objects(value, 'features', '', (f, p)=>{
|
|
767
846
|
for (const key of [
|
|
847
|
+
'source',
|
|
768
848
|
'name',
|
|
769
849
|
'version',
|
|
770
850
|
"description"
|
|
@@ -775,6 +855,55 @@ function validateResult(value) {
|
|
|
775
855
|
'installed'
|
|
776
856
|
])c.boolean(f, key, p);
|
|
777
857
|
});
|
|
858
|
+
c.objects(value, 'registries', '', (r, p)=>{
|
|
859
|
+
for (const key of [
|
|
860
|
+
'namespace',
|
|
861
|
+
'index'
|
|
862
|
+
])c.string(r, key, p);
|
|
863
|
+
c.boolean(r, 'trusted', p);
|
|
864
|
+
if ('number' != typeof r.items) c.add(`${p}items`, 'must be a number');
|
|
865
|
+
if (null !== r.error) if (isObj(r.error)) for (const key of [
|
|
866
|
+
'code',
|
|
867
|
+
'message',
|
|
868
|
+
'fix',
|
|
869
|
+
'docs'
|
|
870
|
+
])c.string(r.error, key, `${p}error.`);
|
|
871
|
+
else c.add(`${p}error`, 'must be an object or null');
|
|
872
|
+
});
|
|
873
|
+
break;
|
|
874
|
+
case 'registry':
|
|
875
|
+
if (![
|
|
876
|
+
'add',
|
|
877
|
+
'remove',
|
|
878
|
+
'list',
|
|
879
|
+
'build',
|
|
880
|
+
'trust',
|
|
881
|
+
'untrust'
|
|
882
|
+
].includes(String(value.action))) c.add('action', 'must be add, remove, list, build, trust or untrust');
|
|
883
|
+
c.boolean(value, 'dryRun', '');
|
|
884
|
+
c.boolean(value, 'changed', '');
|
|
885
|
+
if (null !== value.root) c.string(value, 'root', '');
|
|
886
|
+
c.objects(value, 'registries', '', (r, p)=>{
|
|
887
|
+
for (const key of [
|
|
888
|
+
'namespace',
|
|
889
|
+
'url',
|
|
890
|
+
'index'
|
|
891
|
+
])c.string(r, key, p);
|
|
892
|
+
c.boolean(r, 'trusted', p);
|
|
893
|
+
});
|
|
894
|
+
if (null !== value.build) if (isObj(value.build)) {
|
|
895
|
+
for (const key of [
|
|
896
|
+
'namespace',
|
|
897
|
+
'out'
|
|
898
|
+
])c.string(value.build, key, 'build.');
|
|
899
|
+
c.texts(value.build, 'files', 'build.');
|
|
900
|
+
c.objects(value.build, 'features', 'build.', (f, p)=>{
|
|
901
|
+
for (const key of [
|
|
902
|
+
'name',
|
|
903
|
+
'version'
|
|
904
|
+
])c.string(f, key, p);
|
|
905
|
+
});
|
|
906
|
+
} else c.add('build', 'must be an object or null');
|
|
778
907
|
break;
|
|
779
908
|
case 'doctor':
|
|
780
909
|
case 'validate':
|
|
@@ -796,8 +925,16 @@ function validateResult(value) {
|
|
|
796
925
|
});
|
|
797
926
|
if (!isObj(value.overrides)) c.add('overrides', 'must be an object');
|
|
798
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;
|
|
799
936
|
default:
|
|
800
|
-
c.add('command', 'must be init, add, features, 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');
|
|
801
938
|
}
|
|
802
939
|
return c.issues;
|
|
803
940
|
}
|
|
@@ -933,6 +1070,18 @@ function withProjectChange(plan, project) {
|
|
|
933
1070
|
]
|
|
934
1071
|
};
|
|
935
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
|
+
}
|
|
936
1085
|
async function missingFolders(root) {
|
|
937
1086
|
const missing = [];
|
|
938
1087
|
let dir = root;
|
|
@@ -999,7 +1148,8 @@ async function write_exists(dir) {
|
|
|
999
1148
|
}
|
|
1000
1149
|
}
|
|
1001
1150
|
async function writePlan(request) {
|
|
1002
|
-
const { rt, globals, root, plan
|
|
1151
|
+
const { rt, globals, root, plan } = request;
|
|
1152
|
+
const project = stampCli(request.project, plan);
|
|
1003
1153
|
const shown = withProjectChange(plan, project);
|
|
1004
1154
|
if (!globals.json) {
|
|
1005
1155
|
rt.io.stdout(summarize(shown));
|
|
@@ -1226,32 +1376,32 @@ const KEYS = new Set([
|
|
|
1226
1376
|
'topology',
|
|
1227
1377
|
'options'
|
|
1228
1378
|
]);
|
|
1229
|
-
function
|
|
1379
|
+
function preset_bad(source, why) {
|
|
1230
1380
|
throw new MfeflowError('E109', `${source}: ${why}`);
|
|
1231
1381
|
}
|
|
1232
1382
|
function isRecord(value) {
|
|
1233
1383
|
return 'object' == typeof value && null !== value && !Array.isArray(value);
|
|
1234
1384
|
}
|
|
1235
1385
|
function stringList(source, key, value) {
|
|
1236
|
-
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`);
|
|
1237
1387
|
return value;
|
|
1238
1388
|
}
|
|
1239
1389
|
function optionsOf(source, value) {
|
|
1240
|
-
if (!isRecord(value))
|
|
1390
|
+
if (!isRecord(value)) preset_bad(source, 'options must be an object of feature names');
|
|
1241
1391
|
const out = {};
|
|
1242
1392
|
for (const [feature, given] of Object.entries(value)){
|
|
1243
|
-
if (!isRecord(given))
|
|
1393
|
+
if (!isRecord(given)) preset_bad(source, `options.${feature} must be an object`);
|
|
1244
1394
|
out[feature] = given;
|
|
1245
1395
|
}
|
|
1246
1396
|
return out;
|
|
1247
1397
|
}
|
|
1248
1398
|
function parsePreset(source, value) {
|
|
1249
|
-
if (!isRecord(value))
|
|
1399
|
+
if (!isRecord(value)) preset_bad(source, 'must be a JSON object');
|
|
1250
1400
|
const preset = {};
|
|
1251
1401
|
for (const [key, item] of Object.entries(value)){
|
|
1252
|
-
if (!KEYS.has(key))
|
|
1402
|
+
if (!KEYS.has(key)) preset_bad(source, `"${key}" is not a preset option`);
|
|
1253
1403
|
if ('scope' === key || 'packageManager' === key || 'topology' === key) {
|
|
1254
|
-
if ('string' != typeof item)
|
|
1404
|
+
if ('string' != typeof item) preset_bad(source, `${key} must be text`);
|
|
1255
1405
|
preset[key] = item;
|
|
1256
1406
|
} else if ('remotes' === key || 'features' === key) preset[key] = stringList(source, key, item);
|
|
1257
1407
|
else if ('options' === key) preset.options = optionsOf(source, item);
|
|
@@ -1260,13 +1410,13 @@ function parsePreset(source, value) {
|
|
|
1260
1410
|
}
|
|
1261
1411
|
async function readUrl(url, fetchImpl) {
|
|
1262
1412
|
const response = await getHttps(url, fetchImpl, 'E109');
|
|
1263
|
-
if (!response.ok)
|
|
1413
|
+
if (!response.ok) preset_bad(url, `answered ${String(response.status)}`);
|
|
1264
1414
|
return readCappedText(response, MAX_BYTES, ()=>new MfeflowError('E109', `${url}: is larger than 100 KB`));
|
|
1265
1415
|
}
|
|
1266
1416
|
async function loadPreset(source, sources) {
|
|
1267
1417
|
let text;
|
|
1268
1418
|
if (/^https?:\/\//i.test(source)) {
|
|
1269
|
-
if (!source.toLowerCase().startsWith('https://'))
|
|
1419
|
+
if (!source.toLowerCase().startsWith('https://')) preset_bad(source, 'must be https');
|
|
1270
1420
|
text = await readUrl(source, sources.fetch);
|
|
1271
1421
|
} else {
|
|
1272
1422
|
const file = node_path.resolve(sources.cwd, source);
|
|
@@ -1288,6 +1438,9 @@ async function loadPreset(source, sources) {
|
|
|
1288
1438
|
}
|
|
1289
1439
|
return parsePreset(source, value);
|
|
1290
1440
|
}
|
|
1441
|
+
async function trustedIn(rt, registries) {
|
|
1442
|
+
return trustedNamespaces(registries ?? {}, await readTrusted(rt.configDir), parseTrustEnv(rt.io.env[TRUST_ENV]));
|
|
1443
|
+
}
|
|
1291
1444
|
const ADD_ARGS = {
|
|
1292
1445
|
port: {
|
|
1293
1446
|
type: 'string',
|
|
@@ -1324,6 +1477,10 @@ const ADD_ARGS = {
|
|
|
1324
1477
|
description: 'add env-key: root, every shell or every remote, instead of --app',
|
|
1325
1478
|
valueHint: 'root|shell|remotes'
|
|
1326
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
|
+
},
|
|
1327
1484
|
...GLOBAL_ARGS
|
|
1328
1485
|
};
|
|
1329
1486
|
function portFor(args, project, start) {
|
|
@@ -1342,17 +1499,23 @@ async function nameFrom(rt, globals, given, what) {
|
|
|
1342
1499
|
});
|
|
1343
1500
|
throw new MfeflowError('E107', `add ${what} needs a name: mfeflow add ${what} <name>`);
|
|
1344
1501
|
}
|
|
1345
|
-
async function addAppParts(rt, globals, builder, project, next, kind, name) {
|
|
1502
|
+
async function addAppParts(rt, globals, builder, project, next, kind, name, acceptChanged) {
|
|
1346
1503
|
const names = [
|
|
1347
1504
|
'core',
|
|
1348
1505
|
...Object.keys(project.features).filter((n)=>'core' !== n)
|
|
1349
1506
|
];
|
|
1350
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`);
|
|
1351
1513
|
const loadedAll = await Promise.all(names.map((feature)=>loadAny(feature, {
|
|
1352
1514
|
roots: rt.roots,
|
|
1353
1515
|
registries: project.registries,
|
|
1354
1516
|
fetch: rt.fetch,
|
|
1355
|
-
version: project.features[feature]?.version
|
|
1517
|
+
version: project.features[feature]?.version,
|
|
1518
|
+
trusted: trusted.privateOk
|
|
1356
1519
|
})));
|
|
1357
1520
|
let core;
|
|
1358
1521
|
const fromRegistry = [];
|
|
@@ -1369,6 +1532,20 @@ async function addAppParts(rt, globals, builder, project, next, kind, name) {
|
|
|
1369
1532
|
fix: `Nothing was written. Use a registry URL with {version} so ${recorded} can be fetched, or add the app by hand.`
|
|
1370
1533
|
});
|
|
1371
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
|
+
}
|
|
1372
1549
|
}
|
|
1373
1550
|
planned.push({
|
|
1374
1551
|
feature,
|
|
@@ -1401,10 +1578,13 @@ async function addApp(rt, globals, args, kind, given) {
|
|
|
1401
1578
|
path: `apps/${name}`,
|
|
1402
1579
|
port
|
|
1403
1580
|
}
|
|
1581
|
+
},
|
|
1582
|
+
features: {
|
|
1583
|
+
...project.features
|
|
1404
1584
|
}
|
|
1405
1585
|
};
|
|
1406
1586
|
const builder = createBuilder(root, chosen.checkout);
|
|
1407
|
-
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']);
|
|
1408
1588
|
const version = core.manifest.version;
|
|
1409
1589
|
if ('remote' === kind) await resolveFeature({
|
|
1410
1590
|
feature: await remoteGlue({
|
|
@@ -1566,10 +1746,13 @@ async function addFeatures(rt, globals, local, requested, options) {
|
|
|
1566
1746
|
};
|
|
1567
1747
|
}
|
|
1568
1748
|
assertOptionsAreForFeatures(options, adding);
|
|
1749
|
+
const trusted = await trustedIn(rt, project.registries);
|
|
1569
1750
|
const loaded = await Promise.all(adding.map((name)=>loadAny(name, {
|
|
1570
1751
|
roots: rt.roots,
|
|
1571
1752
|
registries: project.registries,
|
|
1572
|
-
fetch: rt.fetch
|
|
1753
|
+
fetch: rt.fetch,
|
|
1754
|
+
trusted: trusted.privateOk,
|
|
1755
|
+
checkIndex: true
|
|
1573
1756
|
})));
|
|
1574
1757
|
const thirdParty = loaded.filter((f)=>'third-party' === f.trust).map((f)=>f.manifest.name);
|
|
1575
1758
|
assertThirdPartyAllowed(thirdParty, globals, canPrompt);
|
|
@@ -1626,7 +1809,7 @@ function finding(check, code, detail, options = {}) {
|
|
|
1626
1809
|
if (void 0 !== options.path) out.path = options.path;
|
|
1627
1810
|
return out;
|
|
1628
1811
|
}
|
|
1629
|
-
const
|
|
1812
|
+
const findings_done = (findings)=>Promise.resolve({
|
|
1630
1813
|
findings
|
|
1631
1814
|
});
|
|
1632
1815
|
const SINGLETON_SPECIFIERS = [
|
|
@@ -1884,11 +2067,6 @@ async function versionIn(app, name, root) {
|
|
|
1884
2067
|
}
|
|
1885
2068
|
return declared;
|
|
1886
2069
|
}
|
|
1887
|
-
function comparable(version) {
|
|
1888
|
-
const exact = semver.valid(version);
|
|
1889
|
-
if (null !== exact) return exact;
|
|
1890
|
-
return null === semver.validRange(version) ? void 0 : semver.minVersion(version)?.version ?? void 0;
|
|
1891
|
-
}
|
|
1892
2070
|
function unresolved(check, code, name, specs) {
|
|
1893
2071
|
return finding(check, code, `${name} is ${specs.join(', ')}: the version cannot be compared until install, so it is not checked`, {
|
|
1894
2072
|
severity: 'warning'
|
|
@@ -1929,12 +2107,6 @@ const singletonVersions = {
|
|
|
1929
2107
|
};
|
|
1930
2108
|
}
|
|
1931
2109
|
};
|
|
1932
|
-
function fits(range, shellVersion) {
|
|
1933
|
-
if (void 0 === shellVersion) return true;
|
|
1934
|
-
const have = comparable(shellVersion);
|
|
1935
|
-
const valid = semver.validRange(range);
|
|
1936
|
-
return void 0 === have || null === valid || semver.satisfies(have, valid);
|
|
1937
|
-
}
|
|
1938
2110
|
async function compatFor(shell, remote, root) {
|
|
1939
2111
|
const out = [];
|
|
1940
2112
|
const at = {
|
|
@@ -1947,7 +2119,7 @@ async function compatFor(shell, remote, root) {
|
|
|
1947
2119
|
if (void 0 !== have && void 0 === comparable(have)) out.push(unresolved('compat', 'E302', name, [
|
|
1948
2120
|
`${shell.name} ${have}`
|
|
1949
2121
|
]));
|
|
1950
|
-
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));
|
|
1951
2123
|
}
|
|
1952
2124
|
for (const name of SINGLETON_PACKAGES){
|
|
1953
2125
|
const [theirs, mine] = await Promise.all([
|
|
@@ -1965,7 +2137,7 @@ async function compatFor(shell, remote, root) {
|
|
|
1965
2137
|
];
|
|
1966
2138
|
if (open.length > 0) out.push(unresolved('compat', 'E302', name, open));
|
|
1967
2139
|
if (void 0 !== want && void 0 !== mine) {
|
|
1968
|
-
if (!
|
|
2140
|
+
if (!satisfiesRange(`^${want}`, mine) && !(name in platform)) out.push(finding('compat', 'E302', `${remote.name} uses ${name} ${want}, ${shell.name} provides ${mine}`, {
|
|
1969
2141
|
app: remote.name
|
|
1970
2142
|
}));
|
|
1971
2143
|
}
|
|
@@ -1994,7 +2166,7 @@ function issueText(read) {
|
|
|
1994
2166
|
const appsOnDisk = {
|
|
1995
2167
|
id: 'config.apps',
|
|
1996
2168
|
title: 'every app in mfeflow.json is on disk',
|
|
1997
|
-
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)`, {
|
|
1998
2170
|
app: app.name,
|
|
1999
2171
|
path: `${app.rel}/package.json`
|
|
2000
2172
|
})))
|
|
@@ -2002,7 +2174,7 @@ const appsOnDisk = {
|
|
|
2002
2174
|
const mfeJson = {
|
|
2003
2175
|
id: 'config.mfe-json',
|
|
2004
2176
|
title: 'every app has a valid mfe.json that matches mfeflow.json',
|
|
2005
|
-
run: ({ apps })=>
|
|
2177
|
+
run: ({ apps })=>findings_done(apps.filter((app)=>app.present).flatMap((app)=>{
|
|
2006
2178
|
const at = {
|
|
2007
2179
|
app: app.name,
|
|
2008
2180
|
path: `${app.rel}/mfe.json`
|
|
@@ -2028,26 +2200,28 @@ const config_ports = {
|
|
|
2028
2200
|
...byPort.get(app.port) ?? [],
|
|
2029
2201
|
app.name
|
|
2030
2202
|
]);
|
|
2031
|
-
return
|
|
2203
|
+
return findings_done([
|
|
2032
2204
|
...byPort
|
|
2033
2205
|
].filter(([, names])=>names.length > 1).map(([port, names])=>finding('config.ports', 'E115', `${names.join(' and ')} both use ${String(port)}`)));
|
|
2034
2206
|
}
|
|
2035
2207
|
};
|
|
2036
2208
|
const config_routes = {
|
|
2037
2209
|
id: 'config.routes',
|
|
2038
|
-
title: 'no two remotes claim
|
|
2210
|
+
title: 'no two remotes claim overlapping routes',
|
|
2039
2211
|
run: ({ apps })=>{
|
|
2040
|
-
const claimed =
|
|
2212
|
+
const claimed = [];
|
|
2041
2213
|
const out = [];
|
|
2042
|
-
for (const app of apps)if ('remote' === app.kind && 'ok' === app.mfe.state)
|
|
2043
|
-
const
|
|
2044
|
-
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}`, {
|
|
2045
2216
|
app: app.name,
|
|
2046
2217
|
path: `${app.rel}/mfe.json`
|
|
2047
2218
|
}));
|
|
2048
|
-
|
|
2219
|
+
for (const route of app.mfe.value.routes)claimed.push({
|
|
2220
|
+
app: app.name,
|
|
2221
|
+
route
|
|
2222
|
+
});
|
|
2049
2223
|
}
|
|
2050
|
-
return
|
|
2224
|
+
return findings_done(out);
|
|
2051
2225
|
}
|
|
2052
2226
|
};
|
|
2053
2227
|
const config_registry = {
|
|
@@ -2078,7 +2252,7 @@ const config_registry = {
|
|
|
2078
2252
|
path: `registry/envs/${env}/${name}.json`
|
|
2079
2253
|
}));
|
|
2080
2254
|
}
|
|
2081
|
-
return
|
|
2255
|
+
return findings_done(out);
|
|
2082
2256
|
}
|
|
2083
2257
|
};
|
|
2084
2258
|
const localCheckout = {
|
|
@@ -2093,17 +2267,17 @@ const localCheckout = {
|
|
|
2093
2267
|
const unbuilt = [
|
|
2094
2268
|
...scanCheckout(node_path.resolve(root, project.local))
|
|
2095
2269
|
].filter(([, p])=>!p.built).map(([name])=>name);
|
|
2096
|
-
return
|
|
2270
|
+
return findings_done(0 === unbuilt.length ? [] : [
|
|
2097
2271
|
finding('config.local', 'E110', `${unbuilt.join(', ')} not built in ${project.local}`)
|
|
2098
2272
|
]);
|
|
2099
2273
|
} catch (error) {
|
|
2100
|
-
return
|
|
2274
|
+
return findings_done([
|
|
2101
2275
|
finding('config.local', 'E110', error instanceof Error ? error.message : String(error))
|
|
2102
2276
|
]);
|
|
2103
2277
|
}
|
|
2104
2278
|
}
|
|
2105
2279
|
};
|
|
2106
|
-
const
|
|
2280
|
+
const config_drift = {
|
|
2107
2281
|
id: 'files.drift',
|
|
2108
2282
|
title: 'copied files match the hashes in mfeflow.json',
|
|
2109
2283
|
run: async ({ root, project })=>({
|
|
@@ -2150,7 +2324,7 @@ async function eachPlace(view, check) {
|
|
|
2150
2324
|
findings: [],
|
|
2151
2325
|
skipped: NOT_INSTALLED
|
|
2152
2326
|
};
|
|
2153
|
-
return
|
|
2327
|
+
return findings_done((await Promise.all(places(view).map(check))).flat());
|
|
2154
2328
|
}
|
|
2155
2329
|
async function serverKeysOf(place) {
|
|
2156
2330
|
const source = await readText(node_path.join(place.dir, 'env.ts'));
|
|
@@ -2462,7 +2636,7 @@ const runtimePlugins = {
|
|
|
2462
2636
|
const workspaceVersions = {
|
|
2463
2637
|
id: 'singletons.workspace-version',
|
|
2464
2638
|
title: 'every workspace package has a version',
|
|
2465
|
-
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)`, {
|
|
2466
2640
|
path: `${p.rel}/package.json`,
|
|
2467
2641
|
fixable: true
|
|
2468
2642
|
})))
|
|
@@ -2486,7 +2660,7 @@ const DOCTOR_CHECKS = [
|
|
|
2486
2660
|
singletonVersions,
|
|
2487
2661
|
workspaceVersions,
|
|
2488
2662
|
compat,
|
|
2489
|
-
|
|
2663
|
+
config_drift,
|
|
2490
2664
|
runtimePlugins,
|
|
2491
2665
|
...envChecks
|
|
2492
2666
|
];
|
|
@@ -2649,16 +2823,6 @@ async function shellInitial(dist) {
|
|
|
2649
2823
|
bytes
|
|
2650
2824
|
};
|
|
2651
2825
|
}
|
|
2652
|
-
const sizes_isObj = (v)=>'object' == typeof v && null !== v && !Array.isArray(v);
|
|
2653
|
-
const ORIGIN = 'https://dist.invalid';
|
|
2654
|
-
const SCRIPT = /\.[cm]?js$/;
|
|
2655
|
-
function distPath(url) {
|
|
2656
|
-
try {
|
|
2657
|
-
return decodeURIComponent(new URL(url).pathname).replace(/^\//, '');
|
|
2658
|
-
} catch {
|
|
2659
|
-
return;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
2826
|
async function remoteSizes(dist) {
|
|
2663
2827
|
const text = await readText(node_path.join(dist, 'mf-manifest.json'));
|
|
2664
2828
|
if (void 0 === text) return;
|
|
@@ -2668,34 +2832,13 @@ async function remoteSizes(dist) {
|
|
|
2668
2832
|
} catch {
|
|
2669
2833
|
return;
|
|
2670
2834
|
}
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
metaData: {
|
|
2675
|
-
...manifest.metaData,
|
|
2676
|
-
publicPath: 'auto',
|
|
2677
|
-
getPublicPath: void 0,
|
|
2678
|
-
globalName: void 0,
|
|
2679
|
-
remoteEntry: {
|
|
2680
|
-
...manifest.metaData.remoteEntry,
|
|
2681
|
-
type: void 0
|
|
2682
|
-
}
|
|
2683
|
-
}
|
|
2684
|
-
};
|
|
2685
|
-
let assets;
|
|
2686
|
-
try {
|
|
2687
|
-
assets = assetsFromManifest(local, `${ORIGIN}/mf-manifest.json`);
|
|
2688
|
-
} catch {
|
|
2689
|
-
return;
|
|
2690
|
-
}
|
|
2691
|
-
const entry = void 0 === assets.remoteEntry ? void 0 : distPath(assets.remoteEntry);
|
|
2692
|
-
if (void 0 === entry) return;
|
|
2693
|
-
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('/')));
|
|
2694
2838
|
if (void 0 === entryBytes) return;
|
|
2695
2839
|
const exposes = {};
|
|
2696
|
-
for (const [name,
|
|
2697
|
-
const
|
|
2698
|
-
const bytes = await sumGzip(dist, files);
|
|
2840
|
+
for (const [name, list] of Object.entries(files.exposes)){
|
|
2841
|
+
const bytes = await sumGzip(dist, list);
|
|
2699
2842
|
if (void 0 !== bytes) exposes[name] = bytes;
|
|
2700
2843
|
}
|
|
2701
2844
|
return {
|
|
@@ -2762,11 +2905,14 @@ async function remoteBudget(app, dist, model, out) {
|
|
|
2762
2905
|
}));
|
|
2763
2906
|
const own = 'ok' === app.mfe.state ? app.mfe.value : void 0;
|
|
2764
2907
|
const budgetKb = own?.budgets?.initialJsKb ?? null;
|
|
2765
|
-
const
|
|
2766
|
-
const expose = exposeNames.includes('./App') ? './App' : exposeNames[0];
|
|
2908
|
+
const expose = primaryExpose(Object.keys(size.exposes));
|
|
2767
2909
|
const routes = (own?.routes ?? []).map((route)=>{
|
|
2768
|
-
const
|
|
2769
|
-
|
|
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;
|
|
2770
2916
|
if (false === routePass) out.push(finding('budget', 'E304', `${app.name} on ${route} loads ${String(total)} KB; mfe.json budgets.initialJsKb is ${String(budgetKb)}`, {
|
|
2771
2917
|
app: app.name,
|
|
2772
2918
|
path: `${app.rel}/mfe.json`
|
|
@@ -2829,6 +2975,782 @@ function formatBudget(result) {
|
|
|
2829
2975
|
for (const f of result.findings)lines.push('', `error ${f.code} ${f.message}`, ` Fix: ${f.fix}`, ` ${f.docs}`);
|
|
2830
2976
|
return lines.join('\n');
|
|
2831
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
|
+
}
|
|
2832
3754
|
const LOCAL_HOSTS = new Set([
|
|
2833
3755
|
'localhost',
|
|
2834
3756
|
'127.0.0.1',
|
|
@@ -3483,60 +4405,137 @@ const FEATURES_ARGS = {
|
|
|
3483
4405
|
search: {
|
|
3484
4406
|
type: 'positional',
|
|
3485
4407
|
required: false,
|
|
3486
|
-
description: "Only features whose name or
|
|
4408
|
+
description: "Only features whose name, description or keywords contain this"
|
|
3487
4409
|
},
|
|
3488
4410
|
...GLOBAL_ARGS
|
|
3489
4411
|
};
|
|
3490
|
-
async function
|
|
4412
|
+
async function projectAt(cwd) {
|
|
3491
4413
|
try {
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
4414
|
+
return await loadProject(await findProjectRoot(cwd));
|
|
4415
|
+
} catch (error) {
|
|
4416
|
+
if (isMfeflowError(error) && 'MFEFLOW_E102' === error.code) return;
|
|
4417
|
+
throw error;
|
|
3496
4418
|
}
|
|
3497
4419
|
}
|
|
3498
|
-
function matches(feature, search) {
|
|
4420
|
+
function matches(feature, search, keywords = []) {
|
|
3499
4421
|
const needle = search.toLowerCase();
|
|
3500
|
-
return feature.name.toLowerCase().includes(needle) || feature.description.toLowerCase().includes(needle);
|
|
4422
|
+
return feature.name.toLowerCase().includes(needle) || feature.description.toLowerCase().includes(needle) || keywords.some((keyword)=>keyword.toLowerCase().includes(needle));
|
|
3501
4423
|
}
|
|
3502
|
-
function features_line(feature) {
|
|
4424
|
+
function features_line(feature, widths) {
|
|
3503
4425
|
const tags = [
|
|
3504
|
-
feature.always ? 'always' : feature.default ? 'default' : 'opt-in',
|
|
4426
|
+
feature.always ? 'always' : 'mfeflow' === feature.source ? feature.default ? 'default' : 'opt-in' : 'registry',
|
|
3505
4427
|
...feature.installed ? [
|
|
3506
4428
|
'installed'
|
|
3507
4429
|
] : []
|
|
3508
4430
|
];
|
|
3509
|
-
return ` ${feature.name.padEnd(
|
|
4431
|
+
return ` ${feature.source.padEnd(widths.source)} ${feature.name.padEnd(widths.name)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
|
|
4432
|
+
}
|
|
4433
|
+
function fetchIndexes(rt, registries, trusted) {
|
|
4434
|
+
return Promise.all(Object.entries(registries).toSorted(([a], [b])=>a.localeCompare(b)).map(async ([namespace, template])=>{
|
|
4435
|
+
const base = {
|
|
4436
|
+
namespace,
|
|
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: []
|
|
4447
|
+
};
|
|
4448
|
+
try {
|
|
4449
|
+
const items = await fetchFeatureIndex(namespace, registries, rt.fetch, trusted.privateOk.has(namespace));
|
|
4450
|
+
return {
|
|
4451
|
+
status: {
|
|
4452
|
+
...base,
|
|
4453
|
+
trusted: true,
|
|
4454
|
+
items: items.length,
|
|
4455
|
+
error: null
|
|
4456
|
+
},
|
|
4457
|
+
items
|
|
4458
|
+
};
|
|
4459
|
+
} catch (error) {
|
|
4460
|
+
const report = isMfeflowError(error) ? reportOf(error) : unexpectedReport(error);
|
|
4461
|
+
return {
|
|
4462
|
+
status: {
|
|
4463
|
+
...base,
|
|
4464
|
+
trusted: true,
|
|
4465
|
+
items: 0,
|
|
4466
|
+
error: report
|
|
4467
|
+
},
|
|
4468
|
+
items: []
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
}));
|
|
3510
4472
|
}
|
|
3511
4473
|
async function runFeatures(rt, args, raw) {
|
|
3512
4474
|
assertKnownFlags(raw, FEATURES_ARGS);
|
|
3513
4475
|
const globals = readGlobals(args, rt.io.env, rt.io.cwd);
|
|
3514
4476
|
const search = 'string' == typeof args.search && '' !== args.search ? args.search : null;
|
|
3515
|
-
const
|
|
3516
|
-
const
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
4477
|
+
const project = await projectAt(globals.cwd);
|
|
4478
|
+
const installed = new Set(Object.keys(project?.features ?? {}));
|
|
4479
|
+
const [firstParty, fetched] = await Promise.all([
|
|
4480
|
+
Promise.all(FIRST_PARTY.map(async (first)=>{
|
|
4481
|
+
const { manifest } = await loadAny(first.name, {
|
|
4482
|
+
roots: rt.roots
|
|
4483
|
+
});
|
|
4484
|
+
return {
|
|
4485
|
+
source: 'mfeflow',
|
|
4486
|
+
name: first.name,
|
|
4487
|
+
version: manifest.version,
|
|
4488
|
+
description: manifest.description ?? '',
|
|
4489
|
+
always: first.always,
|
|
4490
|
+
default: first.default,
|
|
4491
|
+
installed: installed.has(first.name)
|
|
4492
|
+
};
|
|
4493
|
+
})),
|
|
4494
|
+
trustedIn(rt, project?.registries).then((trusted)=>fetchIndexes(rt, project?.registries ?? {}, trusted))
|
|
4495
|
+
]);
|
|
4496
|
+
const fromRegistries = fetched.flatMap(({ status, items })=>items.toSorted((a, b)=>a.name.localeCompare(b.name)).map((item)=>{
|
|
4497
|
+
const name = `${status.namespace}/${item.name}`;
|
|
4498
|
+
return [
|
|
4499
|
+
{
|
|
4500
|
+
source: status.namespace,
|
|
4501
|
+
name,
|
|
4502
|
+
version: item.version,
|
|
4503
|
+
description: item.description,
|
|
4504
|
+
always: false,
|
|
4505
|
+
default: false,
|
|
4506
|
+
installed: installed.has(name)
|
|
4507
|
+
},
|
|
4508
|
+
item.keywords ?? []
|
|
4509
|
+
];
|
|
4510
|
+
}));
|
|
4511
|
+
const features = [
|
|
4512
|
+
...firstParty.filter((f)=>null === search || matches(f, search)),
|
|
4513
|
+
...fromRegistries.filter(([f, keywords])=>null === search || matches(f, search, keywords)).map(([f])=>f)
|
|
4514
|
+
];
|
|
4515
|
+
const registries = fetched.map((f)=>f.status);
|
|
3530
4516
|
if (!globals.json) {
|
|
3531
4517
|
if (0 === features.length) rt.io.stdout(`No features match "${search ?? ''}".`);
|
|
3532
|
-
else
|
|
3533
|
-
|
|
4518
|
+
else {
|
|
4519
|
+
const widths = {
|
|
4520
|
+
source: Math.max(...features.map((f)=>f.source.length)),
|
|
4521
|
+
name: Math.max(14, ...features.map((f)=>f.name.length))
|
|
4522
|
+
};
|
|
4523
|
+
rt.io.stdout(features.map((f)=>features_line(f, widths)).join('\n'));
|
|
4524
|
+
}
|
|
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'));
|
|
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.');
|
|
3534
4532
|
}
|
|
3535
4533
|
return {
|
|
3536
4534
|
ok: true,
|
|
3537
4535
|
command: 'features',
|
|
3538
4536
|
search,
|
|
3539
|
-
features
|
|
4537
|
+
features,
|
|
4538
|
+
registries
|
|
3540
4539
|
};
|
|
3541
4540
|
}
|
|
3542
4541
|
const INIT_ARGS = {
|
|
@@ -3789,6 +4788,401 @@ function printInit(rt, result) {
|
|
|
3789
4788
|
rt.io.stdout(`\nCreated ${result.root} with a shell and ${String(remotes.length)} remote${1 === remotes.length ? '' : 's'} (${remotes.join(', ')}).`);
|
|
3790
4789
|
rt.io.stdout(`Next:\n${result.next.map((line)=>` ${line}`).join('\n')}`);
|
|
3791
4790
|
}
|
|
4791
|
+
const REGISTRY_ARGS = {
|
|
4792
|
+
out: {
|
|
4793
|
+
type: 'string',
|
|
4794
|
+
description: 'registry build: the folder to write (default: <dir>/dist)',
|
|
4795
|
+
valueHint: 'dir'
|
|
4796
|
+
},
|
|
4797
|
+
...GLOBAL_ARGS
|
|
4798
|
+
};
|
|
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);
|
|
4802
|
+
return Object.entries(project.registries ?? {}).toSorted(([a], [b])=>a.localeCompare(b)).map(([namespace, url])=>({
|
|
4803
|
+
namespace,
|
|
4804
|
+
url: redactUrl(url),
|
|
4805
|
+
index: redactUrl(registryIndexUrl(url)),
|
|
4806
|
+
trusted: trusted.fetch.has(namespace)
|
|
4807
|
+
}));
|
|
4808
|
+
}
|
|
4809
|
+
function registry_result(action, globals, extra) {
|
|
4810
|
+
return {
|
|
4811
|
+
ok: true,
|
|
4812
|
+
command: 'registry',
|
|
4813
|
+
action,
|
|
4814
|
+
dryRun: globals.dryRun,
|
|
4815
|
+
root: null,
|
|
4816
|
+
changed: false,
|
|
4817
|
+
build: null,
|
|
4818
|
+
...extra
|
|
4819
|
+
};
|
|
4820
|
+
}
|
|
4821
|
+
function assertNamespace(namespace, verb) {
|
|
4822
|
+
if (void 0 === namespace) throw new MfeflowError('E107', `registry ${verb} needs a namespace like @someone`);
|
|
4823
|
+
if ('@mfeflow' === namespace) throw new MfeflowError('E107', '@mfeflow is reserved for the features that ship with mfeflow');
|
|
4824
|
+
if (!REGISTRY_NAMESPACE.test(namespace)) throw new MfeflowError('E107', `"${namespace}" is not a namespace; they start with @ and use lower-case letters, digits and "-" (for example @someone)`);
|
|
4825
|
+
return namespace;
|
|
4826
|
+
}
|
|
4827
|
+
async function projectOf(globals) {
|
|
4828
|
+
const root = await findProjectRoot(globals.cwd);
|
|
4829
|
+
return {
|
|
4830
|
+
root,
|
|
4831
|
+
project: await loadProject(root)
|
|
4832
|
+
};
|
|
4833
|
+
}
|
|
4834
|
+
async function save(globals, root, project, registries) {
|
|
4835
|
+
const { registries: old, ...rest } = project;
|
|
4836
|
+
const changed = JSON.stringify(old ?? {}) !== JSON.stringify(registries);
|
|
4837
|
+
const base = changed ? raiseCli(rest) : rest;
|
|
4838
|
+
const next = Object.keys(registries).length > 0 ? {
|
|
4839
|
+
...base,
|
|
4840
|
+
registries
|
|
4841
|
+
} : base;
|
|
4842
|
+
if (!globals.dryRun) await saveProject(root, next);
|
|
4843
|
+
return next;
|
|
4844
|
+
}
|
|
4845
|
+
async function addRegistry(rt, globals, namespaceArg, url) {
|
|
4846
|
+
const namespace = assertNamespace(namespaceArg, 'add');
|
|
4847
|
+
if (void 0 === url) throw new MfeflowError('E107', `registry add needs a URL: mfeflow registry add ${namespace} https://example.com/{name}.json`);
|
|
4848
|
+
const problem = registryTemplateProblem(url);
|
|
4849
|
+
if (void 0 !== problem) throw new MfeflowError('E107', `the URL ${problem}`);
|
|
4850
|
+
const { root, project } = await projectOf(globals);
|
|
4851
|
+
const current = project.registries ?? {};
|
|
4852
|
+
const existing = current[namespace];
|
|
4853
|
+
const shown = redactUrl(url);
|
|
4854
|
+
if (existing === url) {
|
|
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' : ''}.`);
|
|
4860
|
+
return registry_result('add', globals, {
|
|
4861
|
+
root,
|
|
4862
|
+
registries: await registry_entries(rt, project)
|
|
4863
|
+
});
|
|
4864
|
+
}
|
|
4865
|
+
if (void 0 !== existing) throw new MfeflowError('E107', `${namespace} already points at ${redactUrl(existing)}`, {
|
|
4866
|
+
fix: `Run \`mfeflow registry remove ${namespace}\` first, then add it again.`
|
|
4867
|
+
});
|
|
4868
|
+
const next = await save(globals, root, project, {
|
|
4869
|
+
...current,
|
|
4870
|
+
[namespace]: url
|
|
4871
|
+
});
|
|
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`);
|
|
4877
|
+
return registry_result('add', globals, {
|
|
4878
|
+
root,
|
|
4879
|
+
changed: true,
|
|
4880
|
+
registries: await registry_entries(rt, next)
|
|
4881
|
+
});
|
|
4882
|
+
}
|
|
4883
|
+
async function removeRegistry(rt, globals, namespaceArg) {
|
|
4884
|
+
const namespace = assertNamespace(namespaceArg, 'remove');
|
|
4885
|
+
const { root, project } = await projectOf(globals);
|
|
4886
|
+
const { [namespace]: url, ...rest } = project.registries ?? {};
|
|
4887
|
+
if (void 0 === url) throw new MfeflowError('E107', `${namespace} is not in this project's registries`, {
|
|
4888
|
+
fix: 'List them with `mfeflow registry list`.'
|
|
4889
|
+
});
|
|
4890
|
+
const using = Object.keys(project.features).filter((name)=>name.startsWith(`${namespace}/`));
|
|
4891
|
+
if (using.length > 0) throw new MfeflowError('E107', `${using.join(', ')} installed from ${namespace}`, {
|
|
4892
|
+
fix: `Keep ${namespace} while they are installed: \`mfeflow add remote\` and \`add shell\` fetch them again and fail without it.`
|
|
4893
|
+
});
|
|
4894
|
+
const next = await save(globals, root, project, rest);
|
|
4895
|
+
if (!globals.json) rt.io.stdout(globals.dryRun ? `Dry run: would remove ${namespace}. Nothing was written.` : `Removed ${namespace} (${redactUrl(url)}).`);
|
|
4896
|
+
return registry_result('remove', globals, {
|
|
4897
|
+
root,
|
|
4898
|
+
changed: true,
|
|
4899
|
+
registries: await registry_entries(rt, next)
|
|
4900
|
+
});
|
|
4901
|
+
}
|
|
4902
|
+
async function listRegistries(rt, globals) {
|
|
4903
|
+
const { root, project } = await projectOf(globals);
|
|
4904
|
+
const registries = await registry_entries(rt, project);
|
|
4905
|
+
if (!globals.json) if (0 === registries.length) rt.io.stdout('No registries. Add one: mfeflow registry add @namespace https://example.com/{name}.json');
|
|
4906
|
+
else {
|
|
4907
|
+
const width = Math.max(...registries.map((r)=>r.namespace.length));
|
|
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');
|
|
4910
|
+
}
|
|
4911
|
+
return registry_result('list', globals, {
|
|
4912
|
+
root,
|
|
4913
|
+
registries
|
|
4914
|
+
});
|
|
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
|
+
}
|
|
4962
|
+
async function buildFolder(rt, globals, args, dirArg) {
|
|
4963
|
+
if (void 0 === dirArg) throw new MfeflowError('E107', 'registry build needs the folder that holds your features: mfeflow registry build ./my-registry');
|
|
4964
|
+
const dir = node_path.resolve(globals.cwd, dirArg);
|
|
4965
|
+
const out = node_path.resolve(globals.cwd, args_text(args, 'out') ?? node_path.join(dirArg, 'dist'));
|
|
4966
|
+
const built = await buildRegistry(dir);
|
|
4967
|
+
if (!globals.dryRun) await inOrder(built.files, (file)=>writeFileAtomic(realFs, node_path.join(out, ...file.path.split('/')), file.content));
|
|
4968
|
+
if (!globals.json) {
|
|
4969
|
+
const lines = built.features.map((f)=>` ${f.name} ${f.version}`);
|
|
4970
|
+
rt.io.stdout([
|
|
4971
|
+
`${globals.dryRun ? 'Dry run: would build' : 'Built'} ${String(built.features.length)} feature(s) for ${built.namespace}:`,
|
|
4972
|
+
...lines,
|
|
4973
|
+
'',
|
|
4974
|
+
`${globals.dryRun ? 'Would write' : 'Wrote'} ${String(built.files.length)} files to ${out}`,
|
|
4975
|
+
'',
|
|
4976
|
+
"Host that folder on any static host (GitHub Pages works). Users then run:",
|
|
4977
|
+
` mfeflow registry add ${built.namespace} https://<your-host>/{name}.json`
|
|
4978
|
+
].join('\n'));
|
|
4979
|
+
}
|
|
4980
|
+
return registry_result('build', globals, {
|
|
4981
|
+
registries: [],
|
|
4982
|
+
build: {
|
|
4983
|
+
namespace: built.namespace,
|
|
4984
|
+
out,
|
|
4985
|
+
features: built.features.map(({ name, version })=>({
|
|
4986
|
+
name,
|
|
4987
|
+
version
|
|
4988
|
+
})),
|
|
4989
|
+
files: built.files.map((f)=>f.path)
|
|
4990
|
+
}
|
|
4991
|
+
});
|
|
4992
|
+
}
|
|
4993
|
+
async function runRegistry(rt, args, raw) {
|
|
4994
|
+
assertKnownFlags(raw, REGISTRY_ARGS);
|
|
4995
|
+
const globals = readGlobals(args, rt.io.env, rt.io.cwd, rt.io.interactive);
|
|
4996
|
+
const positional = Array.isArray(args._) ? args._.map(String) : [];
|
|
4997
|
+
const [action, first, second] = positional;
|
|
4998
|
+
const takes = {
|
|
4999
|
+
add: 2,
|
|
5000
|
+
remove: 1,
|
|
5001
|
+
list: 0,
|
|
5002
|
+
build: 1,
|
|
5003
|
+
trust: 1,
|
|
5004
|
+
untrust: 1
|
|
5005
|
+
};
|
|
5006
|
+
const surplus = positional[(takes[action ?? ''] ?? 1 / 0) + 1];
|
|
5007
|
+
if (void 0 !== surplus) throw new MfeflowError('E107', `unexpected "${surplus}" (${registry_USAGE})`);
|
|
5008
|
+
switch(action){
|
|
5009
|
+
case 'add':
|
|
5010
|
+
return addRegistry(rt, globals, first, second);
|
|
5011
|
+
case 'remove':
|
|
5012
|
+
return removeRegistry(rt, globals, first);
|
|
5013
|
+
case 'list':
|
|
5014
|
+
return listRegistries(rt, globals);
|
|
5015
|
+
case 'trust':
|
|
5016
|
+
return trustRegistry(rt, globals, first);
|
|
5017
|
+
case 'untrust':
|
|
5018
|
+
return untrustRegistry(rt, globals, first);
|
|
5019
|
+
case 'build':
|
|
5020
|
+
return buildFolder(rt, globals, args, first);
|
|
5021
|
+
case void 0:
|
|
5022
|
+
default:
|
|
5023
|
+
throw new MfeflowError('E107', void 0 === action ? `registry needs an action (${registry_USAGE})` : `unknown action "${action}" (${registry_USAGE})`);
|
|
5024
|
+
}
|
|
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
|
+
}
|
|
3792
5186
|
const REPO_URL = 'https://github.com/mfeflow/mfeflow';
|
|
3793
5187
|
function flagLabel(name, def) {
|
|
3794
5188
|
const alias = 'alias' in def ? [
|
|
@@ -3827,8 +5221,8 @@ function exitCodesText() {
|
|
|
3827
5221
|
` ${String(EXIT.project)} project or config problem (MFEFLOW_E1xx)`,
|
|
3828
5222
|
` ${String(EXIT.feature)} feature problem (MFEFLOW_E2xx)`,
|
|
3829
5223
|
` ${String(EXIT.build)} build or runtime problem (MFEFLOW_E3xx)`,
|
|
3830
|
-
` ${String(EXIT.registry)} registry problem (MFEFLOW_E4xx)`,
|
|
3831
|
-
` ${String(EXIT.
|
|
5224
|
+
` ${String(EXIT.registry)} feature registry problem (MFEFLOW_E4xx)`,
|
|
5225
|
+
` ${String(EXIT.deploy)} deploy problem (MFEFLOW_E5xx)`,
|
|
3832
5226
|
` ${String(EXIT.cancelled)} cancelled at a prompt, or Ctrl+C in mfeflow dev`
|
|
3833
5227
|
].join('\n');
|
|
3834
5228
|
}
|
|
@@ -4024,6 +5418,7 @@ function realRuntime(io = processIo()) {
|
|
|
4024
5418
|
system: createSystem(io),
|
|
4025
5419
|
fetch: (url, init)=>fetch(url, init),
|
|
4026
5420
|
roots: assetRoots(),
|
|
5421
|
+
configDir: configDirOf(io.env),
|
|
4027
5422
|
dev: realDevHost()
|
|
4028
5423
|
};
|
|
4029
5424
|
}
|
|
@@ -4062,7 +5457,7 @@ const COMMANDS = [
|
|
|
4062
5457
|
},
|
|
4063
5458
|
{
|
|
4064
5459
|
name: 'features',
|
|
4065
|
-
summary: 'list the features you can add',
|
|
5460
|
+
summary: 'list and search the features you can add, from mfeflow and your registries',
|
|
4066
5461
|
usage: 'mfeflow features [search] [options]',
|
|
4067
5462
|
args: FEATURES_ARGS,
|
|
4068
5463
|
examples: [
|
|
@@ -4072,6 +5467,21 @@ const COMMANDS = [
|
|
|
4072
5467
|
],
|
|
4073
5468
|
run: runFeatures
|
|
4074
5469
|
},
|
|
5470
|
+
{
|
|
5471
|
+
name: 'registry',
|
|
5472
|
+
summary: 'configure third-party feature registries, or build one to host',
|
|
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]',
|
|
5474
|
+
args: REGISTRY_ARGS,
|
|
5475
|
+
examples: [
|
|
5476
|
+
'mfeflow registry add @acme https://acme.github.io/features/{name}.json',
|
|
5477
|
+
'mfeflow registry add @test http://127.0.0.1:4010/{name}.json # plain http: loopback hosts only',
|
|
5478
|
+
'mfeflow registry list --json',
|
|
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',
|
|
5481
|
+
'mfeflow registry build ./my-registry --out ./site'
|
|
5482
|
+
],
|
|
5483
|
+
run: runRegistry
|
|
5484
|
+
},
|
|
4075
5485
|
{
|
|
4076
5486
|
name: 'doctor',
|
|
4077
5487
|
summary: 'check singletons, compatibility, config and drift; --fix repairs the safe ones',
|
|
@@ -4119,6 +5529,54 @@ const COMMANDS = [
|
|
|
4119
5529
|
],
|
|
4120
5530
|
run: runBudget
|
|
4121
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
|
+
},
|
|
4122
5580
|
{
|
|
4123
5581
|
name: 'dev',
|
|
4124
5582
|
summary: 'run the shell and every remote together, with a dev registry that follows the project',
|
|
@@ -4165,7 +5623,7 @@ function fail(error, rt, json, verbose) {
|
|
|
4165
5623
|
if (isMfeflowError(mfe)) {
|
|
4166
5624
|
if (json) rt.io.stdout(JSON.stringify(failure(mfe), null, 2));
|
|
4167
5625
|
else rt.io.stderr(mfe.format());
|
|
4168
|
-
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)));
|
|
4169
5627
|
return exitCodeFor(mfe);
|
|
4170
5628
|
}
|
|
4171
5629
|
if (json) {
|