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/dist/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
- import { MfeflowError, PlanBuilder, applyPlan, dotenvKeys, findDiverged, findProjectRoot, getHttps, hasErrno, inOrder, isMfeflowError, loadFeature, loadProject, messageOf, parseFeature, readCappedText, realFs, recordPlan, renderDiff, renderTemplate, resolveFeature, runtimeConfigFiles, schemaKeys, serializeProject, summarize } from "@mfeflow/core";
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 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)) {
@@ -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 dev');
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, project } = request;
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 bad(source, why) {
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)) 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`);
1237
1387
  return value;
1238
1388
  }
1239
1389
  function optionsOf(source, value) {
1240
- 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');
1241
1391
  const out = {};
1242
1392
  for (const [feature, given] of Object.entries(value)){
1243
- if (!isRecord(given)) bad(source, `options.${feature} must be an object`);
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)) bad(source, 'must be a JSON object');
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)) bad(source, `"${key}" is not a preset option`);
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) bad(source, `${key} must be text`);
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) bad(url, `answered ${String(response.status)}`);
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://')) bad(source, 'must be 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 done = (findings)=>Promise.resolve({
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 (!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));
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 (!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}`, {
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 })=>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)`, {
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 })=>done(apps.filter((app)=>app.present).flatMap((app)=>{
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 done([
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 the same route',
2210
+ title: 'no two remotes claim overlapping routes',
2039
2211
  run: ({ apps })=>{
2040
- const claimed = new Map();
2212
+ const claimed = [];
2041
2213
  const out = [];
2042
- for (const app of apps)if ('remote' === app.kind && 'ok' === app.mfe.state) for (const route of app.mfe.value.routes){
2043
- const owner = claimed.get(route);
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
- else claimed.set(route, app.name);
2219
+ for (const route of app.mfe.value.routes)claimed.push({
2220
+ app: app.name,
2221
+ route
2222
+ });
2049
2223
  }
2050
- return done(out);
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 done(out);
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 done(0 === unbuilt.length ? [] : [
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 done([
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 drift = {
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 done((await Promise.all(places(view).map(check))).flat());
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 })=>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)`, {
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
- drift,
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
- if (!sizes_isObj(manifest) || !sizes_isObj(manifest.metaData) || !sizes_isObj(manifest.metaData.remoteEntry)) return;
2672
- const local = {
2673
- ...manifest,
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, urls] of Object.entries(assets.exposes)){
2697
- const files = urls.flatMap((url)=>distPath(url) ?? []).filter((f)=>SCRIPT.test(f));
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 exposeNames = Object.keys(size.exposes);
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 total = round1((size.entryBytes + (void 0 === expose ? 0 : size.exposes[expose] ?? 0)) / 1024);
2769
- 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;
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 description contains this"
4408
+ description: "Only features whose name, description or keywords contain this"
3487
4409
  },
3488
4410
  ...GLOBAL_ARGS
3489
4411
  };
3490
- async function installedFeatures(cwd) {
4412
+ async function projectAt(cwd) {
3491
4413
  try {
3492
- const project = await loadProject(await findProjectRoot(cwd));
3493
- return new Set(Object.keys(project.features));
3494
- } catch {
3495
- return new Set();
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(14)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
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 installed = await installedFeatures(globals.cwd);
3516
- const all = await Promise.all(FIRST_PARTY.map(async (first)=>{
3517
- const { manifest } = await loadAny(first.name, {
3518
- roots: rt.roots
3519
- });
3520
- return {
3521
- name: first.name,
3522
- version: manifest.version,
3523
- description: manifest.description ?? '',
3524
- always: first.always,
3525
- default: first.default,
3526
- installed: installed.has(first.name)
3527
- };
3528
- }));
3529
- const features = null === search ? all : all.filter((f)=>matches(f, search));
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 rt.io.stdout(features.map(features_line).join('\n'));
3533
- rt.io.stdout('\nFrom a registry: mfeflow add @namespace/name (configure it under "registries" in mfeflow.json).');
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.upgrade)} upgrade problem (MFEFLOW_E5xx)`,
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) {