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.
- package/README.md +17 -14
- package/assets/features/analytics-clarity/feature.json +64 -0
- package/assets/features/analytics-clarity/files/clarity-test.ts +36 -0
- package/assets/features/analytics-clarity/files/clarity.ts +26 -0
- package/assets/features/analytics-gtm/files/init.ts +2 -2
- package/assets/features/commit-prompt/feature.json +27 -0
- package/assets/features/commit-prompt/files/cz.config.js +27 -0
- package/assets/features/data-react-query/feature.json +89 -0
- package/assets/features/data-react-query/files/keys.ts.eta +8 -0
- package/assets/features/data-react-query/files/remote-Query-test.tsx.eta +39 -0
- package/assets/features/data-react-query/files/remote-Query.tsx +15 -0
- package/assets/features/data-react-query/files/shell-Query-test.tsx +21 -0
- package/assets/features/data-react-query/files/shell-Query.tsx +14 -0
- package/assets/features/data-react-query/files/useHealth.fetch.ts.eta +15 -0
- package/assets/features/data-react-query/files/useHealth.http.ts.eta +11 -0
- package/assets/features/env-t3/feature.json +21 -0
- package/assets/features/forms/feature.json +90 -0
- package/assets/features/forms/files/ContactForm-test.tsx +35 -0
- package/assets/features/forms/files/ContactForm.tsx +50 -0
- package/assets/features/forms/files/schema.arktype.ts +9 -0
- package/assets/features/forms/files/schema.valibot.ts +9 -0
- package/assets/features/forms/files/schema.zod.ts +9 -0
- package/assets/features/i18n/feature.json +139 -0
- package/assets/features/i18n/files/LanguageSwitcher.tsx +28 -0
- package/assets/features/i18n/files/Languages.tsx +12 -0
- package/assets/features/i18n/files/language-switcher-test.tsx +19 -0
- package/assets/features/i18n/files/remote-I18n-test.tsx.eta +43 -0
- package/assets/features/i18n/files/remote-I18n.tsx.eta +36 -0
- package/assets/features/i18n/files/remote-ar.json.eta +3 -0
- package/assets/features/i18n/files/remote-config.ts +9 -0
- package/assets/features/i18n/files/remote-en.json.eta +3 -0
- package/assets/features/i18n/files/remote-load.ts +4 -0
- package/assets/features/i18n/files/shell-config.ts +20 -0
- package/assets/features/i18n/files/shell-init-test.ts +32 -0
- package/assets/features/i18n/files/shell-init.ts +7 -0
- package/assets/features/lint-oxlint/files/oxlintrc.json +1 -1
- package/assets/features/lint-oxlint/files/prettierignore +1 -0
- package/assets/features/storybook/feature.json +119 -0
- package/assets/features/storybook/files/App.stories.tsx.eta +8 -0
- package/assets/features/storybook/files/Home.stories.tsx +9 -0
- package/assets/features/storybook/files/button.stories.tsx +14 -0
- package/assets/features/storybook/files/remote-main.ts +13 -0
- package/assets/features/storybook/files/rsbuild.config.ts +10 -0
- package/assets/features/storybook/files/shell-main.ts +39 -0
- package/assets/features/tailwind/feature.json +87 -0
- package/assets/features/tailwind/files/Scope-test.tsx.eta +17 -0
- package/assets/features/tailwind/files/Scope.tsx.eta +15 -0
- package/assets/features/tailwind/files/postcss.config.mjs +1 -0
- package/assets/features/tailwind/files/remote-styles.css.eta +21 -0
- package/assets/features/tailwind/files/shell-styles.css.eta +22 -0
- package/assets/features/testing/files/vitest.config.ts +11 -2
- package/assets/features/theming/feature.json +103 -0
- package/assets/features/theming/files/Theme.tsx +15 -0
- package/assets/features/theming/files/ThemeToggle.tsx +23 -0
- package/assets/features/theming/files/apply-stored-test.ts +37 -0
- package/assets/features/theming/files/apply-stored.ts +13 -0
- package/assets/features/theming/files/head-script.ts +8 -0
- package/assets/features/theming/files/theme-toggle-test.tsx +18 -0
- package/assets/features/theming/files/theme.css +14 -0
- package/assets/features/theming/files/themes.ts +30 -0
- package/assets/features/theming/files/tokens-test.ts +9 -0
- package/assets/features/theming/files/tokens.ts +9 -0
- package/assets/features/ui-shadcn/feature.json +61 -0
- package/assets/features/ui-shadcn/files/button-test.tsx.eta +32 -0
- package/assets/features/ui-shadcn/files/button.tsx.eta +36 -0
- package/assets/features/ui-shadcn/files/components.json.eta +22 -0
- package/assets/features/ui-shadcn/files/utils-test.ts.eta +17 -0
- package/assets/features/ui-shadcn/files/utils.ts.eta +11 -0
- package/assets/templates/monorepo/files/root/gitignore.eta +1 -0
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -1
- package/dist/commands/features.d.ts +2 -2
- package/dist/commands/registry.d.ts +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +368 -33
- package/dist/output.d.ts +43 -1
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
3532
|
+
description: "Only features whose name, description or keywords contain this"
|
|
3442
3533
|
},
|
|
3443
3534
|
...GLOBAL_ARGS
|
|
3444
3535
|
};
|
|
3445
|
-
async function
|
|
3536
|
+
async function projectAt(cwd) {
|
|
3446
3537
|
try {
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
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(
|
|
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
|
|
3471
|
-
const
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
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
|
|
3488
|
-
|
|
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',
|