mfeflow 0.4.0 → 0.5.0

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