mfeflow 0.2.0 → 0.4.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.
Files changed (76) hide show
  1. package/README.md +17 -14
  2. package/assets/features/analytics-clarity/feature.json +64 -0
  3. package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
  4. package/assets/features/analytics-clarity/files/clarity.ts +26 -0
  5. package/assets/features/analytics-gtm/files/init.ts +2 -2
  6. package/assets/features/commit-prompt/feature.json +27 -0
  7. package/assets/features/commit-prompt/files/cz.config.js +27 -0
  8. package/assets/features/data-react-query/feature.json +89 -0
  9. package/assets/features/data-react-query/files/keys.ts.eta +8 -0
  10. package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
  11. package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
  12. package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
  13. package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
  14. package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
  15. package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
  16. package/assets/features/env-t3/feature.json +21 -0
  17. package/assets/features/forms/feature.json +90 -0
  18. package/assets/features/forms/files/ContactForm-test.tsx +35 -0
  19. package/assets/features/forms/files/ContactForm.tsx +50 -0
  20. package/assets/features/forms/files/schema.arktype.ts +9 -0
  21. package/assets/features/forms/files/schema.valibot.ts +9 -0
  22. package/assets/features/forms/files/schema.zod.ts +9 -0
  23. package/assets/features/i18n/feature.json +139 -0
  24. package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
  25. package/assets/features/i18n/files/Languages.tsx +12 -0
  26. package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
  27. package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
  28. package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
  29. package/assets/features/i18n/files/remote-ar.json.eta +3 -0
  30. package/assets/features/i18n/files/remote-config.ts +9 -0
  31. package/assets/features/i18n/files/remote-en.json.eta +3 -0
  32. package/assets/features/i18n/files/remote-load.ts +4 -0
  33. package/assets/features/i18n/files/shell-config.ts +20 -0
  34. package/assets/features/i18n/files/shell-init-test.ts +32 -0
  35. package/assets/features/i18n/files/shell-init.ts +7 -0
  36. package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
  37. package/assets/features/lint-oxlint/files/prettierignore +1 -0
  38. package/assets/features/storybook/feature.json +119 -0
  39. package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
  40. package/assets/features/storybook/files/Home.stories.tsx +9 -0
  41. package/assets/features/storybook/files/button.stories.tsx +14 -0
  42. package/assets/features/storybook/files/remote-main.ts +13 -0
  43. package/assets/features/storybook/files/rsbuild.config.ts +10 -0
  44. package/assets/features/storybook/files/shell-main.ts +39 -0
  45. package/assets/features/tailwind/feature.json +87 -0
  46. package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
  47. package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
  48. package/assets/features/tailwind/files/postcss.config.mjs +1 -0
  49. package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
  50. package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
  51. package/assets/features/testing/files/vitest.config.ts +11 -2
  52. package/assets/features/theming/feature.json +103 -0
  53. package/assets/features/theming/files/Theme.tsx +15 -0
  54. package/assets/features/theming/files/ThemeToggle.tsx +23 -0
  55. package/assets/features/theming/files/apply-stored-test.ts +37 -0
  56. package/assets/features/theming/files/apply-stored.ts +13 -0
  57. package/assets/features/theming/files/head-script.ts +8 -0
  58. package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
  59. package/assets/features/theming/files/theme.css +14 -0
  60. package/assets/features/theming/files/themes.ts +30 -0
  61. package/assets/features/theming/files/tokens-test.ts +9 -0
  62. package/assets/features/theming/files/tokens.ts +9 -0
  63. package/assets/features/ui-shadcn/feature.json +61 -0
  64. package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
  65. package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
  66. package/assets/features/ui-shadcn/files/components.json.eta +22 -0
  67. package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
  68. package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
  69. package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
  70. package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
  71. package/dist/commands/features.d.ts +2 -2
  72. package/dist/commands/registry.d.ts +52 -0
  73. package/dist/index.d.ts +1 -1
  74. package/dist/index.js +368 -33
  75. package/dist/output.d.ts +43 -1
  76. package/package.json +4 -4
@@ -0,0 +1,22 @@
1
+ <%_ const prefix = it.app.tailwindPrefix _%>
2
+ {
3
+ "$schema": "https://ui.shadcn.com/schema.json",
4
+ "style": "new-york",
5
+ "rsc": false,
6
+ "tsx": true,
7
+ "tailwind": {
8
+ "config": "",
9
+ "css": "src/styles.css",
10
+ "baseColor": "neutral",
11
+ "cssVariables": false,
12
+ "prefix": "<%= prefix %>"
13
+ },
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "utils": "@/lib/utils",
19
+ "hooks": "@/hooks"
20
+ },
21
+ "iconLibrary": "lucide"
22
+ }
@@ -0,0 +1,17 @@
1
+ <%_ const prefix = it.app.tailwindPrefix _%>
2
+ import { describe, expect, it } from 'vitest';
3
+ import { cn } from './utils';
4
+
5
+ const prefix = '<%= prefix %>';
6
+
7
+ describe('cn', () => {
8
+ it("keeps the last of two conflicting classes with this remote's prefix", () => {
9
+ expect(cn(`${prefix}:p-2`, `${prefix}:p-4`)).toBe(`${prefix}:p-4`);
10
+ });
11
+
12
+ it('skips false values and keeps classes that do not conflict', () => {
13
+ expect(cn(`${prefix}:flex`, false, undefined, `${prefix}:gap-2`)).toBe(
14
+ `${prefix}:flex ${prefix}:gap-2`,
15
+ );
16
+ });
17
+ });
@@ -0,0 +1,11 @@
1
+ <%_ const prefix = it.app.tailwindPrefix _%>
2
+ import { clsx, type ClassValue } from 'clsx';
3
+ import { extendTailwindMerge } from 'tailwind-merge';
4
+
5
+ // Knows this remote's prefix, so cn('<%= prefix %>:p-2', '<%= prefix %>:p-4') keeps only the last one.
6
+ const merge = extendTailwindMerge({ prefix: '<%= prefix %>' });
7
+
8
+ /** Joins class names and drops the ones a later class overrides. */
9
+ export function cn(...inputs: ClassValue[]): string {
10
+ return merge(clsx(inputs));
11
+ }
@@ -1,5 +1,6 @@
1
1
  node_modules/
2
2
  dist/
3
+ storybook-static/
3
4
  .turbo/
4
5
  coverage/
5
6
  *.tsbuildinfo
@@ -2,6 +2,8 @@ packages:
2
2
  - apps/*
3
3
  - packages/*
4
4
 
5
- # Dependencies allowed to run install scripts.
5
+ # Dependencies allowed to run install scripts. esbuild (pulled in by Storybook) works without its
6
+ # script; listing it as false keeps pnpm from stopping the install to ask.
6
7
  allowBuilds:
7
8
  lefthook: true
9
+ esbuild: false
@@ -46,8 +46,8 @@ export declare const FEATURES_ARGS: {
46
46
  readonly search: {
47
47
  readonly type: 'positional';
48
48
  readonly required: false;
49
- readonly description: 'Only features whose name or description contains this';
49
+ readonly description: 'Only features whose name, description or keywords contain this';
50
50
  };
51
51
  };
52
- export declare function matches(feature: FeatureInfo, search: string): boolean;
52
+ export declare function matches(feature: FeatureInfo, search: string, keywords?: string[]): boolean;
53
53
  export declare function runFeatures(rt: Runtime, args: Record<string, unknown>, raw: readonly string[]): Promise<FeaturesResult>;
@@ -0,0 +1,52 @@
1
+ import type { RegistryResult } from '../output.js';
2
+ import type { Runtime } from '../runtime.js';
3
+ export declare const REGISTRY_ARGS: {
4
+ readonly yes: {
5
+ readonly type: 'boolean';
6
+ readonly alias: 'y';
7
+ readonly description: 'Take the defaults and never prompt';
8
+ };
9
+ readonly preset: {
10
+ readonly type: 'string';
11
+ readonly description: 'Replay options from a JSON file or an https URL';
12
+ readonly valueHint: 'file|url';
13
+ };
14
+ readonly json: {
15
+ readonly type: 'boolean';
16
+ readonly description: 'Print one JSON document on stdout, nothing else';
17
+ };
18
+ readonly 'dry-run': {
19
+ readonly type: 'boolean';
20
+ readonly description: 'Print the summary and diff, write nothing';
21
+ };
22
+ readonly cwd: {
23
+ readonly type: 'string';
24
+ readonly description: 'Run as if started in this folder';
25
+ readonly valueHint: 'dir';
26
+ };
27
+ readonly install: {
28
+ readonly type: 'boolean';
29
+ readonly default: true;
30
+ readonly description: 'Run the package manager after writing (--no-install to skip)';
31
+ };
32
+ readonly verbose: {
33
+ readonly type: 'boolean';
34
+ readonly description: 'Show package manager output and stack traces';
35
+ };
36
+ readonly local: {
37
+ readonly type: 'string';
38
+ readonly description: 'Link @mfeflow/* from a built mfeflow checkout (env MFEFLOW_LOCAL)';
39
+ readonly valueHint: 'path';
40
+ };
41
+ readonly help: {
42
+ readonly type: 'boolean';
43
+ readonly alias: 'h';
44
+ readonly description: 'Show this help';
45
+ };
46
+ readonly out: {
47
+ readonly type: 'string';
48
+ readonly description: 'registry build: the folder to write (default: <dir>/dist)';
49
+ readonly valueHint: 'dir';
50
+ };
51
+ };
52
+ export declare function runRegistry(rt: Runtime, args: Record<string, unknown>, raw: readonly string[]): Promise<RegistryResult>;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { main } from './cli.js';
2
2
  export { COMMANDS } from './cli.js';
3
3
  export { EXIT, exitCodeFor } from './exit.js';
4
4
  export { validateResult } from './output.js';
5
- export type { AddResult, BudgetResult, DevResult, CliResult, FailureResult, FeatureInfo, FeaturesResult, InitResult, PlanReport, ReportResult, } from './output.js';
5
+ export type { AddResult, BudgetResult, DevResult, CliResult, FailureResult, FeatureInfo, FeaturesResult, InitResult, PlanReport, RegistryEntryInfo, RegistryResult, RegistryStatus, ReportResult, } from './output.js';
6
6
  export { realRuntime, type Runtime } from './runtime.js';
7
7
  export type { Io } from './io.js';
8
8
  export type { Prompter } from './prompter.js';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
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 { 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";
3
3
  import { defineCommand, runCommand } from "citty";
4
4
  import node_path from "node:path";
5
- import { COST_MODEL, DOCS_URL, MFE_NAME, assetsFromManifest, isSafeRelativePath, judgeCost, round1, validateMfeManifest, validateRegistryEntry } from "@mfeflow/contracts";
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
8
  import { createRequire } from "node:module";
@@ -171,6 +171,46 @@ const FIRST_PARTY = [
171
171
  always: false,
172
172
  default: false
173
173
  },
174
+ {
175
+ name: 'theming',
176
+ always: false,
177
+ default: false
178
+ },
179
+ {
180
+ name: 'i18n',
181
+ always: false,
182
+ default: false
183
+ },
184
+ {
185
+ name: 'tailwind',
186
+ always: false,
187
+ default: false
188
+ },
189
+ {
190
+ name: 'ui-shadcn',
191
+ always: false,
192
+ default: false
193
+ },
194
+ {
195
+ name: 'forms',
196
+ always: false,
197
+ default: false
198
+ },
199
+ {
200
+ name: 'data-react-query',
201
+ always: false,
202
+ default: false
203
+ },
204
+ {
205
+ name: 'analytics-clarity',
206
+ always: false,
207
+ default: false
208
+ },
209
+ {
210
+ name: 'storybook',
211
+ always: false,
212
+ default: false
213
+ },
174
214
  {
175
215
  name: 'ci-github',
176
216
  always: false,
@@ -180,6 +220,11 @@ const FIRST_PARTY = [
180
220
  name: 'git-hooks',
181
221
  always: false,
182
222
  default: false
223
+ },
224
+ {
225
+ name: 'commit-prompt',
226
+ always: false,
227
+ default: false
183
228
  }
184
229
  ];
185
230
  function isFirstParty(name) {
@@ -720,6 +765,7 @@ function validateResult(value) {
720
765
  if (null !== value.search && 'string' != typeof value.search) c.add('search', 'must be text or null');
721
766
  c.objects(value, 'features', '', (f, p)=>{
722
767
  for (const key of [
768
+ 'source',
723
769
  'name',
724
770
  'version',
725
771
  "description"
@@ -730,6 +776,51 @@ function validateResult(value) {
730
776
  'installed'
731
777
  ])c.boolean(f, key, p);
732
778
  });
779
+ c.objects(value, 'registries', '', (r, p)=>{
780
+ for (const key of [
781
+ 'namespace',
782
+ 'index'
783
+ ])c.string(r, key, p);
784
+ if ('number' != typeof r.items) c.add(`${p}items`, 'must be a number');
785
+ if (null !== r.error) if (isObj(r.error)) for (const key of [
786
+ 'code',
787
+ 'message',
788
+ 'fix',
789
+ 'docs'
790
+ ])c.string(r.error, key, `${p}error.`);
791
+ else c.add(`${p}error`, 'must be an object or null');
792
+ });
793
+ break;
794
+ case 'registry':
795
+ if (![
796
+ 'add',
797
+ 'remove',
798
+ 'list',
799
+ 'build'
800
+ ].includes(String(value.action))) c.add('action', 'must be add, remove, list or build');
801
+ c.boolean(value, 'dryRun', '');
802
+ c.boolean(value, 'changed', '');
803
+ if (null !== value.root) c.string(value, 'root', '');
804
+ c.objects(value, 'registries', '', (r, p)=>{
805
+ for (const key of [
806
+ 'namespace',
807
+ 'url',
808
+ 'index'
809
+ ])c.string(r, key, p);
810
+ });
811
+ if (null !== value.build) if (isObj(value.build)) {
812
+ for (const key of [
813
+ 'namespace',
814
+ 'out'
815
+ ])c.string(value.build, key, 'build.');
816
+ c.texts(value.build, 'files', 'build.');
817
+ c.objects(value.build, 'features', 'build.', (f, p)=>{
818
+ for (const key of [
819
+ 'name',
820
+ 'version'
821
+ ])c.string(f, key, p);
822
+ });
823
+ } else c.add('build', 'must be an object or null');
733
824
  break;
734
825
  case 'doctor':
735
826
  case 'validate':
@@ -752,7 +843,7 @@ function validateResult(value) {
752
843
  if (!isObj(value.overrides)) c.add('overrides', 'must be an object');
753
844
  break;
754
845
  default:
755
- c.add('command', 'must be init, add, features, doctor, validate, check-compat, budget or dev');
846
+ c.add('command', 'must be init, add, features, registry, doctor, validate, check-compat, budget or dev');
756
847
  }
757
848
  return c.issues;
758
849
  }
@@ -3438,60 +3529,121 @@ const FEATURES_ARGS = {
3438
3529
  search: {
3439
3530
  type: 'positional',
3440
3531
  required: false,
3441
- description: "Only features whose name or description contains this"
3532
+ description: "Only features whose name, description or keywords contain this"
3442
3533
  },
3443
3534
  ...GLOBAL_ARGS
3444
3535
  };
3445
- async function installedFeatures(cwd) {
3536
+ async function projectAt(cwd) {
3446
3537
  try {
3447
- const project = await loadProject(await findProjectRoot(cwd));
3448
- return new Set(Object.keys(project.features));
3449
- } catch {
3450
- return new Set();
3538
+ return await loadProject(await findProjectRoot(cwd));
3539
+ } catch (error) {
3540
+ if (isMfeflowError(error) && 'MFEFLOW_E102' === error.code) return;
3541
+ throw error;
3451
3542
  }
3452
3543
  }
3453
- function matches(feature, search) {
3544
+ function matches(feature, search, keywords = []) {
3454
3545
  const needle = search.toLowerCase();
3455
- return feature.name.toLowerCase().includes(needle) || feature.description.toLowerCase().includes(needle);
3546
+ return feature.name.toLowerCase().includes(needle) || feature.description.toLowerCase().includes(needle) || keywords.some((keyword)=>keyword.toLowerCase().includes(needle));
3456
3547
  }
3457
- function features_line(feature) {
3548
+ function features_line(feature, widths) {
3458
3549
  const tags = [
3459
- feature.always ? 'always' : feature.default ? 'default' : 'opt-in',
3550
+ feature.always ? 'always' : 'mfeflow' === feature.source ? feature.default ? 'default' : 'opt-in' : 'registry',
3460
3551
  ...feature.installed ? [
3461
3552
  'installed'
3462
3553
  ] : []
3463
3554
  ];
3464
- return ` ${feature.name.padEnd(14)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
3555
+ return ` ${feature.source.padEnd(widths.source)} ${feature.name.padEnd(widths.name)} ${feature.version.padEnd(7)} ${feature.description} [${tags.join(', ')}]`;
3556
+ }
3557
+ function fetchIndexes(rt, registries) {
3558
+ return Promise.all(Object.entries(registries).toSorted(([a], [b])=>a.localeCompare(b)).map(async ([namespace, template])=>{
3559
+ const base = {
3560
+ namespace,
3561
+ index: registryIndexUrl(template)
3562
+ };
3563
+ try {
3564
+ const items = await fetchFeatureIndex(namespace, registries, rt.fetch);
3565
+ return {
3566
+ status: {
3567
+ ...base,
3568
+ items: items.length,
3569
+ error: null
3570
+ },
3571
+ items
3572
+ };
3573
+ } catch (error) {
3574
+ const report = isMfeflowError(error) ? reportOf(error) : unexpectedReport(error);
3575
+ return {
3576
+ status: {
3577
+ ...base,
3578
+ items: 0,
3579
+ error: report
3580
+ },
3581
+ items: []
3582
+ };
3583
+ }
3584
+ }));
3465
3585
  }
3466
3586
  async function runFeatures(rt, args, raw) {
3467
3587
  assertKnownFlags(raw, FEATURES_ARGS);
3468
3588
  const globals = readGlobals(args, rt.io.env, rt.io.cwd);
3469
3589
  const search = 'string' == typeof args.search && '' !== args.search ? args.search : null;
3470
- const installed = await installedFeatures(globals.cwd);
3471
- const all = await Promise.all(FIRST_PARTY.map(async (first)=>{
3472
- const { manifest } = await loadAny(first.name, {
3473
- roots: rt.roots
3474
- });
3475
- return {
3476
- name: first.name,
3477
- version: manifest.version,
3478
- description: manifest.description ?? '',
3479
- always: first.always,
3480
- default: first.default,
3481
- installed: installed.has(first.name)
3482
- };
3483
- }));
3484
- const features = null === search ? all : all.filter((f)=>matches(f, search));
3590
+ const project = await projectAt(globals.cwd);
3591
+ const installed = new Set(Object.keys(project?.features ?? {}));
3592
+ const [firstParty, fetched] = await Promise.all([
3593
+ Promise.all(FIRST_PARTY.map(async (first)=>{
3594
+ const { manifest } = await loadAny(first.name, {
3595
+ roots: rt.roots
3596
+ });
3597
+ return {
3598
+ source: 'mfeflow',
3599
+ name: first.name,
3600
+ version: manifest.version,
3601
+ description: manifest.description ?? '',
3602
+ always: first.always,
3603
+ default: first.default,
3604
+ installed: installed.has(first.name)
3605
+ };
3606
+ })),
3607
+ fetchIndexes(rt, project?.registries ?? {})
3608
+ ]);
3609
+ const fromRegistries = fetched.flatMap(({ status, items })=>items.toSorted((a, b)=>a.name.localeCompare(b.name)).map((item)=>{
3610
+ const name = `${status.namespace}/${item.name}`;
3611
+ return [
3612
+ {
3613
+ source: status.namespace,
3614
+ name,
3615
+ version: item.version,
3616
+ description: item.description,
3617
+ always: false,
3618
+ default: false,
3619
+ installed: installed.has(name)
3620
+ },
3621
+ item.keywords ?? []
3622
+ ];
3623
+ }));
3624
+ const features = [
3625
+ ...firstParty.filter((f)=>null === search || matches(f, search)),
3626
+ ...fromRegistries.filter(([f, keywords])=>null === search || matches(f, search, keywords)).map(([f])=>f)
3627
+ ];
3628
+ const registries = fetched.map((f)=>f.status);
3485
3629
  if (!globals.json) {
3486
3630
  if (0 === features.length) rt.io.stdout(`No features match "${search ?? ''}".`);
3487
- else rt.io.stdout(features.map(features_line).join('\n'));
3488
- rt.io.stdout('\nFrom a registry: mfeflow add @namespace/name (configure it under "registries" in mfeflow.json).');
3631
+ else {
3632
+ const widths = {
3633
+ source: Math.max(...features.map((f)=>f.source.length)),
3634
+ name: Math.max(14, ...features.map((f)=>f.name.length))
3635
+ };
3636
+ rt.io.stdout(features.map((f)=>features_line(f, widths)).join('\n'));
3637
+ }
3638
+ for (const { namespace, error } of registries)if (error) rt.io.stderr(`Could not read ${namespace}: ${error.message}\n ${error.fix}`);
3639
+ 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.');
3489
3640
  }
3490
3641
  return {
3491
3642
  ok: true,
3492
3643
  command: 'features',
3493
3644
  search,
3494
- features
3645
+ features,
3646
+ registries
3495
3647
  };
3496
3648
  }
3497
3649
  const INIT_ARGS = {
@@ -3744,6 +3896,175 @@ function printInit(rt, result) {
3744
3896
  rt.io.stdout(`\nCreated ${result.root} with a shell and ${String(remotes.length)} remote${1 === remotes.length ? '' : 's'} (${remotes.join(', ')}).`);
3745
3897
  rt.io.stdout(`Next:\n${result.next.map((line)=>` ${line}`).join('\n')}`);
3746
3898
  }
3899
+ const REGISTRY_ARGS = {
3900
+ out: {
3901
+ type: 'string',
3902
+ description: 'registry build: the folder to write (default: <dir>/dist)',
3903
+ valueHint: 'dir'
3904
+ },
3905
+ ...GLOBAL_ARGS
3906
+ };
3907
+ const USAGE = 'mfeflow registry add @namespace <url-template> | list | remove @namespace | build <dir>';
3908
+ function registry_entries(project) {
3909
+ return Object.entries(project.registries ?? {}).toSorted(([a], [b])=>a.localeCompare(b)).map(([namespace, url])=>({
3910
+ namespace,
3911
+ url,
3912
+ index: registryIndexUrl(url)
3913
+ }));
3914
+ }
3915
+ function registry_result(action, globals, extra) {
3916
+ return {
3917
+ ok: true,
3918
+ command: 'registry',
3919
+ action,
3920
+ dryRun: globals.dryRun,
3921
+ root: null,
3922
+ changed: false,
3923
+ build: null,
3924
+ ...extra
3925
+ };
3926
+ }
3927
+ function assertNamespace(namespace, verb) {
3928
+ if (void 0 === namespace) throw new MfeflowError('E107', `registry ${verb} needs a namespace like @someone`);
3929
+ if ('@mfeflow' === namespace) throw new MfeflowError('E107', '@mfeflow is reserved for the features that ship with mfeflow');
3930
+ 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)`);
3931
+ return namespace;
3932
+ }
3933
+ async function projectOf(globals) {
3934
+ const root = await findProjectRoot(globals.cwd);
3935
+ return {
3936
+ root,
3937
+ project: await loadProject(root)
3938
+ };
3939
+ }
3940
+ async function save(globals, root, project, registries) {
3941
+ const { registries: _old, ...rest } = project;
3942
+ const next = Object.keys(registries).length > 0 ? {
3943
+ ...rest,
3944
+ registries
3945
+ } : rest;
3946
+ if (!globals.dryRun) await saveProject(root, next);
3947
+ return next;
3948
+ }
3949
+ async function addRegistry(rt, globals, namespaceArg, url) {
3950
+ const namespace = assertNamespace(namespaceArg, 'add');
3951
+ if (void 0 === url) throw new MfeflowError('E107', `registry add needs a URL: mfeflow registry add ${namespace} https://example.com/{name}.json`);
3952
+ const problem = registryTemplateProblem(url);
3953
+ if (void 0 !== problem) throw new MfeflowError('E107', `the URL ${problem}`);
3954
+ const { root, project } = await projectOf(globals);
3955
+ const current = project.registries ?? {};
3956
+ const existing = current[namespace];
3957
+ if (existing === url) {
3958
+ if (!globals.json) rt.io.stdout(`${namespace} already points at ${url}; nothing to do.`);
3959
+ return registry_result('add', globals, {
3960
+ root,
3961
+ registries: registry_entries(project)
3962
+ });
3963
+ }
3964
+ if (void 0 !== existing) throw new MfeflowError('E107', `${namespace} already points at ${existing}`, {
3965
+ fix: `Run \`mfeflow registry remove ${namespace}\` first, then add it again.`
3966
+ });
3967
+ const next = await save(globals, root, project, {
3968
+ ...current,
3969
+ [namespace]: url
3970
+ });
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`);
3972
+ return registry_result('add', globals, {
3973
+ root,
3974
+ changed: true,
3975
+ registries: registry_entries(next)
3976
+ });
3977
+ }
3978
+ async function removeRegistry(rt, globals, namespaceArg) {
3979
+ const namespace = assertNamespace(namespaceArg, 'remove');
3980
+ const { root, project } = await projectOf(globals);
3981
+ const { [namespace]: url, ...rest } = project.registries ?? {};
3982
+ if (void 0 === url) throw new MfeflowError('E107', `${namespace} is not in this project's registries`, {
3983
+ fix: 'List them with `mfeflow registry list`.'
3984
+ });
3985
+ const using = Object.keys(project.features).filter((name)=>name.startsWith(`${namespace}/`));
3986
+ if (using.length > 0) throw new MfeflowError('E107', `${using.join(', ')} installed from ${namespace}`, {
3987
+ fix: `Keep ${namespace} while they are installed: \`mfeflow add remote\` and \`add shell\` fetch them again and fail without it.`
3988
+ });
3989
+ 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}).`);
3991
+ return registry_result('remove', globals, {
3992
+ root,
3993
+ changed: true,
3994
+ registries: registry_entries(next)
3995
+ });
3996
+ }
3997
+ async function listRegistries(rt, globals) {
3998
+ const { root, project } = await projectOf(globals);
3999
+ const registries = registry_entries(project);
4000
+ if (!globals.json) if (0 === registries.length) rt.io.stdout('No registries. Add one: mfeflow registry add @namespace https://example.com/{name}.json');
4001
+ else {
4002
+ 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'));
4004
+ }
4005
+ return registry_result('list', globals, {
4006
+ root,
4007
+ registries
4008
+ });
4009
+ }
4010
+ async function buildFolder(rt, globals, args, dirArg) {
4011
+ if (void 0 === dirArg) throw new MfeflowError('E107', 'registry build needs the folder that holds your features: mfeflow registry build ./my-registry');
4012
+ const dir = node_path.resolve(globals.cwd, dirArg);
4013
+ const out = node_path.resolve(globals.cwd, args_text(args, 'out') ?? node_path.join(dirArg, 'dist'));
4014
+ const built = await buildRegistry(dir);
4015
+ if (!globals.dryRun) await inOrder(built.files, (file)=>writeFileAtomic(realFs, node_path.join(out, ...file.path.split('/')), file.content));
4016
+ if (!globals.json) {
4017
+ const lines = built.features.map((f)=>` ${f.name} ${f.version}`);
4018
+ rt.io.stdout([
4019
+ `${globals.dryRun ? 'Dry run: would build' : 'Built'} ${String(built.features.length)} feature(s) for ${built.namespace}:`,
4020
+ ...lines,
4021
+ '',
4022
+ `${globals.dryRun ? 'Would write' : 'Wrote'} ${String(built.files.length)} files to ${out}`,
4023
+ '',
4024
+ "Host that folder on any static host (GitHub Pages works). Users then run:",
4025
+ ` mfeflow registry add ${built.namespace} https://<your-host>/{name}.json`
4026
+ ].join('\n'));
4027
+ }
4028
+ return registry_result('build', globals, {
4029
+ registries: [],
4030
+ build: {
4031
+ namespace: built.namespace,
4032
+ out,
4033
+ features: built.features.map(({ name, version })=>({
4034
+ name,
4035
+ version
4036
+ })),
4037
+ files: built.files.map((f)=>f.path)
4038
+ }
4039
+ });
4040
+ }
4041
+ async function runRegistry(rt, args, raw) {
4042
+ assertKnownFlags(raw, REGISTRY_ARGS);
4043
+ const globals = readGlobals(args, rt.io.env, rt.io.cwd);
4044
+ const positional = Array.isArray(args._) ? args._.map(String) : [];
4045
+ const [action, first, second] = positional;
4046
+ const takes = {
4047
+ add: 2,
4048
+ remove: 1,
4049
+ list: 0,
4050
+ build: 1
4051
+ };
4052
+ const surplus = positional[(takes[action ?? ''] ?? 1 / 0) + 1];
4053
+ if (void 0 !== surplus) throw new MfeflowError('E107', `unexpected "${surplus}" (${USAGE})`);
4054
+ switch(action){
4055
+ case 'add':
4056
+ return addRegistry(rt, globals, first, second);
4057
+ case 'remove':
4058
+ return removeRegistry(rt, globals, first);
4059
+ case 'list':
4060
+ return listRegistries(rt, globals);
4061
+ case 'build':
4062
+ return buildFolder(rt, globals, args, first);
4063
+ case void 0:
4064
+ default:
4065
+ throw new MfeflowError('E107', void 0 === action ? `registry needs an action (${USAGE})` : `unknown action "${action}" (${USAGE})`);
4066
+ }
4067
+ }
3747
4068
  const REPO_URL = 'https://github.com/mfeflow/mfeflow';
3748
4069
  function flagLabel(name, def) {
3749
4070
  const alias = 'alias' in def ? [
@@ -4017,7 +4338,7 @@ const COMMANDS = [
4017
4338
  },
4018
4339
  {
4019
4340
  name: 'features',
4020
- summary: 'list the features you can add',
4341
+ summary: 'list and search the features you can add, from mfeflow and your registries',
4021
4342
  usage: 'mfeflow features [search] [options]',
4022
4343
  args: FEATURES_ARGS,
4023
4344
  examples: [
@@ -4027,6 +4348,20 @@ const COMMANDS = [
4027
4348
  ],
4028
4349
  run: runFeatures
4029
4350
  },
4351
+ {
4352
+ name: 'registry',
4353
+ 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]',
4355
+ args: REGISTRY_ARGS,
4356
+ examples: [
4357
+ 'mfeflow registry add @acme https://acme.github.io/features/{name}.json',
4358
+ 'mfeflow registry add @test http://127.0.0.1:4010/{name}.json # plain http: loopback hosts only',
4359
+ 'mfeflow registry list --json',
4360
+ 'mfeflow registry remove @acme --dry-run',
4361
+ 'mfeflow registry build ./my-registry --out ./site'
4362
+ ],
4363
+ run: runRegistry
4364
+ },
4030
4365
  {
4031
4366
  name: 'doctor',
4032
4367
  summary: 'check singletons, compatibility, config and drift; --fix repairs the safe ones',