@fougere/cli 0.3.0-alpha.0 → 0.5.0-alpha.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 +1 -1
- package/app/commands/BuildCommand.ts +3 -0
- package/app/commands/CallCommand.ts +3 -3
- package/app/commands/CheckCommand.ts +11 -8
- package/app/commands/DevtoolsCommand.ts +92 -0
- package/app/commands/ExplainCommand.ts +51 -8
- package/app/commands/FreezeCommand.ts +6 -1
- package/app/commands/GraphCommand.ts +3 -0
- package/app/commands/KeysCommand.ts +1 -1
- package/app/commands/MigrateCommand.ts +3 -0
- package/app/commands/ServeCommand.ts +7 -16
- package/dist/bin.js +4 -5
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +11 -4
- package/dist/bridge.js.map +1 -1
- package/dist/completion.d.ts +4 -1
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js +54 -20
- package/dist/completion.js.map +1 -1
- package/dist/loader.d.ts +11 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +23 -0
- package/dist/loader.js.map +1 -0
- package/dist/machine.d.ts +16 -0
- package/dist/machine.d.ts.map +1 -0
- package/dist/machine.js +22 -0
- package/dist/machine.js.map +1 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +16 -7
- package/dist/runner.js.map +1 -1
- package/fronds/analysis/entities/Build.ts +2 -1
- package/fronds/analysis/entities/Check.ts +2 -1
- package/fronds/analysis/entities/Devtools.ts +8 -0
- package/fronds/analysis/entities/Explain.ts +2 -1
- package/fronds/analysis/entities/Freeze.ts +2 -1
- package/fronds/analysis/entities/Graph.ts +2 -1
- package/fronds/analysis/entities/Migrate.ts +1 -0
- package/fronds/analysis/handlers/BuildHandler.ts +2 -1
- package/fronds/analysis/handlers/DevtoolsHandler.ts +86 -0
- package/fronds/analysis/handlers/ExplainHandler.ts +52 -12
- package/fronds/analysis/handlers/FreezeHandler.ts +18 -13
- package/fronds/analysis/handlers/MigrateHandler.ts +4 -3
- package/fronds/scaffold/entities/BuildFrond.ts +1 -1
- package/fronds/scaffold/entities/Call.ts +1 -1
- package/fronds/scaffold/entities/Sync.ts +1 -1
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +5 -5
- package/fronds/scaffold/handlers/SyncHandler.ts +17 -17
- package/package.json +9 -8
- package/src/bin.ts +83 -0
- package/src/bridge.ts +70 -0
- package/src/completion.ts +152 -0
- package/src/index.ts +3 -0
- package/src/loader.ts +28 -0
- package/src/machine.ts +23 -0
- package/src/runner.ts +146 -0
- package/src/theme.ts +19 -0
- package/src/ui.ts +131 -0
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +1 -1
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +1 -1
- package/templates/fronds/blog/handlers/PostHandler.ts +1 -1
|
@@ -8,17 +8,17 @@ export default class BuildFrondHandler {
|
|
|
8
8
|
private cwd = process.cwd();
|
|
9
9
|
|
|
10
10
|
/** Build a frond into a standalone deployable package. */
|
|
11
|
-
async execute(input: {
|
|
11
|
+
async execute(input: { frond: string }): Promise<{ path: string; entities: string[] }> {
|
|
12
12
|
const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
|
|
13
|
-
const frondDir = join(this.cwd, conventions.fronds, input.
|
|
13
|
+
const frondDir = join(this.cwd, conventions.fronds, input.frond);
|
|
14
14
|
|
|
15
15
|
if (!existsSync(frondDir)) {
|
|
16
|
-
throw new Error(`Frond '${input.
|
|
16
|
+
throw new Error(`Frond '${input.frond}' not found at ${frondDir}`);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const entitiesDir = join(frondDir, conventions.dirs.entities);
|
|
20
20
|
if (!existsSync(entitiesDir)) {
|
|
21
|
-
throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.
|
|
21
|
+
throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.frond}'`);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// Discover entity files
|
|
@@ -67,7 +67,7 @@ export default class BuildFrondHandler {
|
|
|
67
67
|
const pkgPath = join(frondDir, 'package.json');
|
|
68
68
|
const pkg = existsSync(pkgPath)
|
|
69
69
|
? JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
70
|
-
: { name: frondPackage(input.
|
|
70
|
+
: { name: frondPackage(input.frond, conventions), version: '0.0.1', type: 'module' };
|
|
71
71
|
|
|
72
72
|
pkg.exports = {
|
|
73
73
|
'.': {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { upperFirst, EntityTypeSource, FacadeTypeSource, type SchemaDescriptor } from '@fougere/schema';
|
|
4
4
|
// The card's shape is declared once, in core, and imported here. A private copy of it
|
|
5
5
|
// lived in this file and went stale the day an op stopped being a bare name: nothing
|
|
6
6
|
// compared the copy to the original, so the drift cost nothing until someone read it.
|
|
@@ -26,7 +26,7 @@ export function entityClassName(name: string): string {
|
|
|
26
26
|
const identifier = name
|
|
27
27
|
.split('-')
|
|
28
28
|
.filter(Boolean)
|
|
29
|
-
.map(
|
|
29
|
+
.map(upperFirst)
|
|
30
30
|
.join('');
|
|
31
31
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
|
|
32
32
|
throw new Error(`Entity name '${name}' cannot be represented as a TypeScript identifier`);
|
|
@@ -93,8 +93,8 @@ export default class SyncHandler {
|
|
|
93
93
|
private cwd = process.cwd();
|
|
94
94
|
|
|
95
95
|
/** Mirror a remote frond's contract into local entities. */
|
|
96
|
-
async execute(input: {
|
|
97
|
-
assertSafeName('frond', input.
|
|
96
|
+
async execute(input: { frond: string; from: string }): Promise<{ path: string; entities: string[]; removed: string[] }> {
|
|
97
|
+
assertSafeName('frond', input.frond);
|
|
98
98
|
let remoteUrl: URL;
|
|
99
99
|
try {
|
|
100
100
|
remoteUrl = new URL(input.from);
|
|
@@ -121,9 +121,9 @@ export default class SyncHandler {
|
|
|
121
121
|
if (rpc.error) throw new Error(`Remote error: ${rpc.error.message}`);
|
|
122
122
|
const card = identityCardOf(rpc.result);
|
|
123
123
|
|
|
124
|
-
const target = card.fronds.find((f) => f.name === input.
|
|
124
|
+
const target = card.fronds.find((f) => f.name === input.frond);
|
|
125
125
|
if (!target) {
|
|
126
|
-
throw new Error(`Frond '${input.
|
|
126
|
+
throw new Error(`Frond '${input.frond}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// The consumer's own convention: a synced frond is laid out like the ones they wrote,
|
|
@@ -132,7 +132,7 @@ export default class SyncHandler {
|
|
|
132
132
|
const entities = conventions.dirs.entities;
|
|
133
133
|
const handlers = conventions.dirs.handlers;
|
|
134
134
|
|
|
135
|
-
const frondDir = join(this.cwd, '.fougere', 'remotes', input.
|
|
135
|
+
const frondDir = join(this.cwd, '.fougere', 'remotes', input.frond);
|
|
136
136
|
const entitiesDir = join(frondDir, entities);
|
|
137
137
|
const handlersDir = join(frondDir, handlers);
|
|
138
138
|
mkdirSync(entitiesDir, { recursive: true });
|
|
@@ -158,7 +158,7 @@ export default class SyncHandler {
|
|
|
158
158
|
/**
|
|
159
159
|
* One card, one class.
|
|
160
160
|
*
|
|
161
|
-
* `
|
|
161
|
+
* `Card.toSchema` gives the JUDGE — validate, from, getFields — and now takes the
|
|
162
162
|
* row shape as a type argument, so the same declaration gives the TYPE. Both
|
|
163
163
|
* come off the same card: nothing to keep in step, and the file a consumer reads
|
|
164
164
|
* has the shape of the one they would have written by hand
|
|
@@ -167,10 +167,10 @@ export default class SyncHandler {
|
|
|
167
167
|
const writeRow = (className: string, descriptor: SchemaDescriptor): void => {
|
|
168
168
|
written.add(join(entitiesDir, `${className}.ts`));
|
|
169
169
|
writeFileSync(join(entitiesDir, `${className}.ts`), [
|
|
170
|
-
`import {
|
|
170
|
+
`import { Card } from '@fougere/schema';`,
|
|
171
171
|
``,
|
|
172
172
|
`// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
|
|
173
|
-
|
|
173
|
+
EntityTypeSource.of(descriptor).render({ name: className }),
|
|
174
174
|
``,
|
|
175
175
|
`export default ${className};`,
|
|
176
176
|
``,
|
|
@@ -203,7 +203,7 @@ export default class SyncHandler {
|
|
|
203
203
|
`// contract, and a contract that drags a runtime dependency is not one.`,
|
|
204
204
|
`type Invocation = { params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown; state?: Record<string, unknown> };`,
|
|
205
205
|
``,
|
|
206
|
-
|
|
206
|
+
FacadeTypeSource.of(ops ?? []).render({
|
|
207
207
|
name: `${className}Handler`,
|
|
208
208
|
...(descriptor !== undefined ? { rowType: className } : {}),
|
|
209
209
|
}),
|
|
@@ -239,10 +239,10 @@ export default class SyncHandler {
|
|
|
239
239
|
|
|
240
240
|
// Package.json
|
|
241
241
|
writeFileSync(join(frondDir, 'package.json'), JSON.stringify({
|
|
242
|
-
name: frondPackage(input.
|
|
242
|
+
name: frondPackage(input.frond, conventions),
|
|
243
243
|
version: '0.0.0-synced',
|
|
244
244
|
type: 'module',
|
|
245
|
-
fougere: { frond: input.
|
|
245
|
+
fougere: { frond: input.frond, synced: true, source: baseUrl },
|
|
246
246
|
exports: {
|
|
247
247
|
'.': './index.ts',
|
|
248
248
|
[`./${entities}/*`]: `./${entities}/*.ts`,
|
|
@@ -251,11 +251,11 @@ export default class SyncHandler {
|
|
|
251
251
|
},
|
|
252
252
|
}, null, 2) + '\n');
|
|
253
253
|
|
|
254
|
-
//
|
|
255
|
-
this.updateRemotesRegistry(input.
|
|
254
|
+
// .fougere/remotes.json is the central registry of synced remotes.
|
|
255
|
+
this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
|
|
256
256
|
|
|
257
|
-
//
|
|
258
|
-
this.updateTsconfigPaths(input.
|
|
257
|
+
// Non-Nuxt projects only: Nuxt writes its own paths.
|
|
258
|
+
this.updateTsconfigPaths(input.frond, frondDir, conventions);
|
|
259
259
|
|
|
260
260
|
/**
|
|
261
261
|
* What the host no longer serves stops being importable here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-alpha.0",
|
|
4
4
|
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"dist",
|
|
33
33
|
"app",
|
|
34
34
|
"fronds",
|
|
35
|
-
"templates"
|
|
35
|
+
"templates",
|
|
36
|
+
"src"
|
|
36
37
|
],
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@clack/prompts": "^0.10.0",
|
|
@@ -40,12 +41,12 @@
|
|
|
40
41
|
"consola": "^3.4.2",
|
|
41
42
|
"jiti": "^2.4.2",
|
|
42
43
|
"picocolors": "^1.1.1",
|
|
43
|
-
"@fougere/
|
|
44
|
-
"@fougere/
|
|
45
|
-
"@fougere/
|
|
46
|
-
"@fougere/
|
|
47
|
-
"@fougere/
|
|
48
|
-
"@fougere/
|
|
44
|
+
"@fougere/adapter-sql": "0.5.0-alpha.0",
|
|
45
|
+
"@fougere/core": "0.5.0-alpha.0",
|
|
46
|
+
"@fougere/container": "0.5.0-alpha.0",
|
|
47
|
+
"@fougere/schema": "0.5.0-alpha.0",
|
|
48
|
+
"@fougere/defaults": "0.5.0-alpha.0",
|
|
49
|
+
"@fougere/transport-http": "0.5.0-alpha.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"vitest": "^4.1.0"
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fougere CLI — a Fougere app powered by citty.
|
|
4
|
+
*
|
|
5
|
+
* src/ → compiled (tsc → dist/)
|
|
6
|
+
* fronds/ → loaded at runtime by jiti (domain)
|
|
7
|
+
* app/ → loaded at runtime by jiti (presentation)
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, setLogLevel, envLevel, type ScanResult } from '@fougere/core';
|
|
10
|
+
import { scanProject, getModuleLoader, frondDirsOf, DEFAULT_CONVENTIONS } from '@fougere/core/node';
|
|
11
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createContainer } from '@fougere/container';
|
|
14
|
+
import { ui } from './ui.js';
|
|
15
|
+
import { run } from './runner.js';
|
|
16
|
+
import { installLoader } from './loader.js';
|
|
17
|
+
|
|
18
|
+
await installLoader(process.cwd());
|
|
19
|
+
|
|
20
|
+
const cliRoot = new URL('..', import.meta.url).pathname;
|
|
21
|
+
const container = createContainer();
|
|
22
|
+
const terminal = ui();
|
|
23
|
+
container.registerValue('ui', terminal);
|
|
24
|
+
container.registerValue('cwd', process.cwd());
|
|
25
|
+
|
|
26
|
+
// The CLI is a Fougere app — silence its boot chatter unless explicitly asked. The
|
|
27
|
+
// threshold is SET, not only announced: a static import evaluates the logger module,
|
|
28
|
+
// its env read included, before this line runs.
|
|
29
|
+
process.env.FOUGERE_LOG_LEVEL ??= 'warn';
|
|
30
|
+
setLogLevel(envLevel() ?? 'warn');
|
|
31
|
+
|
|
32
|
+
/** The newest declaration under `fronds/`, or 0 when there is none to compare against. */
|
|
33
|
+
async function newestDeclaration(root: string): Promise<number> {
|
|
34
|
+
const frondsDir = join(root, DEFAULT_CONVENTIONS.fronds);
|
|
35
|
+
const names = await readdir(frondsDir, { withFileTypes: true }).catch(() => []);
|
|
36
|
+
const dirs = names.filter((entry) => entry.isDirectory())
|
|
37
|
+
.flatMap((entry) => [
|
|
38
|
+
join(frondsDir, entry.name),
|
|
39
|
+
...frondDirsOf(DEFAULT_CONVENTIONS).map((dir) => join(frondsDir, entry.name, dir)),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const times = await Promise.all(dirs.map(async (dir) => {
|
|
43
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
44
|
+
const stats = await Promise.all(entries
|
|
45
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
|
|
46
|
+
.map((entry) => stat(join(dir, entry.name)).then((s) => s.mtimeMs).catch(() => 0)));
|
|
47
|
+
return Math.max(0, ...stats);
|
|
48
|
+
}));
|
|
49
|
+
return Math.max(0, ...times);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The CLI is a Fougere app, so it reads its own written-down scan like any deployment —
|
|
54
|
+
* producing the description reads the project, consuming it does not.
|
|
55
|
+
*
|
|
56
|
+
* Reading its own 44 declarations through the compiler cost 617 ms on every invocation,
|
|
57
|
+
* for a domain fixed at publish time. Staleness is decided by mtime rather than by a flag:
|
|
58
|
+
* editing a frond must not need a command, and a published package has nothing newer than
|
|
59
|
+
* its artefact.
|
|
60
|
+
*/
|
|
61
|
+
async function scanOf(root: string): Promise<ScanResult> {
|
|
62
|
+
const written = join(root, '.fougere/scan.generated.ts');
|
|
63
|
+
const writtenAt = await stat(written).then((s) => s.mtimeMs).catch(() => 0);
|
|
64
|
+
if (writtenAt > 0 && writtenAt >= await newestDeclaration(root)) {
|
|
65
|
+
return ((await getModuleLoader()(written)) as unknown as { scan: ScanResult }).scan;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The only slow phase, and it announced nothing: the boot states it at `info`, which the
|
|
69
|
+
// threshold above lowers to `warn`. The terminal says it instead, and a pipe keeps its
|
|
70
|
+
// output parsable.
|
|
71
|
+
const spin = process.stdout.isTTY ? terminal.spinner('reading fronds') : undefined;
|
|
72
|
+
const scan = await scanProject(root);
|
|
73
|
+
spin?.stop(`${scan.fronds.length} frond(s)`);
|
|
74
|
+
return scan;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const scan = await scanOf(cliRoot);
|
|
78
|
+
|
|
79
|
+
const app = await createApp({ scan, createContainer: () => container });
|
|
80
|
+
|
|
81
|
+
container.registerValue('app', app);
|
|
82
|
+
|
|
83
|
+
await run(app);
|
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Lifecycle, Role } from '@fougere/schema';
|
|
2
|
+
/**
|
|
3
|
+
* Entity → citty bridge.
|
|
4
|
+
*
|
|
5
|
+
* Converts Entity fields into citty ArgsDef.
|
|
6
|
+
* The Entity IS the CLI definition — no duplicate schema.
|
|
7
|
+
*/
|
|
8
|
+
import type { Fields } from '@fougere/schema';
|
|
9
|
+
import { Anatomy, Visibility } from '@fougere/schema';
|
|
10
|
+
import type { ArgsDef, ArgDef } from 'citty';
|
|
11
|
+
|
|
12
|
+
function toKebab(name: string): string {
|
|
13
|
+
return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Convert an Entity's fields into citty args definition. */
|
|
17
|
+
export function entityToArgs(fields: Fields): ArgsDef {
|
|
18
|
+
const args: ArgsDef = {};
|
|
19
|
+
let positionalIndex = 0;
|
|
20
|
+
|
|
21
|
+
// Axes-derived ingress membership; the CLI additionally skips ALL relations
|
|
22
|
+
// (a ref is not a flag — supplying related rows is not a CLI gesture).
|
|
23
|
+
for (const [key, field] of Object.entries(Visibility.of(fields).input)) {
|
|
24
|
+
if (Role.of(field).relation) continue;
|
|
25
|
+
|
|
26
|
+
// A `default(v)` travels as the create rule `{ value }` — citty shows it.
|
|
27
|
+
const defaultValue = Lifecycle.of(field).literal?.value;
|
|
28
|
+
const { base: shape, nullable } = Anatomy.of(field.shape);
|
|
29
|
+
|
|
30
|
+
const kebab = toKebab(key);
|
|
31
|
+
const def: ArgDef = {
|
|
32
|
+
description: field.meta?.description,
|
|
33
|
+
required: !nullable && Lifecycle.of(field).requiredAtCreate,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
switch (shape?.type) {
|
|
37
|
+
case 'boolean':
|
|
38
|
+
(def as Record<string, unknown>).type = 'boolean';
|
|
39
|
+
if (defaultValue !== undefined) def.default = defaultValue as boolean;
|
|
40
|
+
break;
|
|
41
|
+
case 'number':
|
|
42
|
+
case 'integer':
|
|
43
|
+
case 'string':
|
|
44
|
+
// A closed set is citty's `enum`: the shape already names the legal values, so the
|
|
45
|
+
// refusal and the `--help` listing come from the declaration rather than a check
|
|
46
|
+
// written beside it.
|
|
47
|
+
if (shape.type === 'string' && shape.enum?.length) {
|
|
48
|
+
(def as Record<string, unknown>).type = 'enum';
|
|
49
|
+
(def as Record<string, unknown>).options = shape.enum.filter((v) => v !== null);
|
|
50
|
+
// A date-time string stays a named string — never a positional arg.
|
|
51
|
+
} else if (shape.type === 'string' && shape.format === 'date-time') {
|
|
52
|
+
(def as Record<string, unknown>).type = 'string';
|
|
53
|
+
} else if (positionalIndex === 0 && def.required && key !== 'force') {
|
|
54
|
+
// First non-bool required field becomes positional
|
|
55
|
+
(def as Record<string, unknown>).type = 'positional';
|
|
56
|
+
positionalIndex++;
|
|
57
|
+
} else {
|
|
58
|
+
(def as Record<string, unknown>).type = 'string';
|
|
59
|
+
}
|
|
60
|
+
if (defaultValue !== undefined) def.default = String(defaultValue);
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
(def as Record<string, unknown>).type = 'string';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
args[kebab === key ? key : kebab] = def;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return args;
|
|
70
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completion generator — outputs a script for bash/zsh.
|
|
3
|
+
*
|
|
4
|
+
* Commands and flags are the CLI's own entities, read once at generation. The VALUES a
|
|
5
|
+
* positional accepts belong to the project the shell sits in, so they are not written
|
|
6
|
+
* down here: the script asks `fougere explain --names` at the moment of the TAB. A list
|
|
7
|
+
* frozen into a script is stale the first time a handler is added.
|
|
8
|
+
*/
|
|
9
|
+
import type { App } from '@fougere/core';
|
|
10
|
+
import { entityToArgs } from './bridge.js';
|
|
11
|
+
|
|
12
|
+
function toKebab(name: string): string {
|
|
13
|
+
return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()).replace(/^-/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What a positional names, said by the field's own name — no table to keep in step. */
|
|
17
|
+
const NAMES: Record<string, 'operations' | 'fronds' | undefined> = {
|
|
18
|
+
operation: 'operations',
|
|
19
|
+
frond: 'fronds',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface CommandMeta {
|
|
23
|
+
name: string;
|
|
24
|
+
flags: string[];
|
|
25
|
+
names?: 'operations' | 'fronds';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractCommandMeta(app: App): CommandMeta[] {
|
|
29
|
+
const commands: CommandMeta[] = [];
|
|
30
|
+
|
|
31
|
+
for (const frond of app.fronds) {
|
|
32
|
+
const handlerMap = new Map(frond.handlers.map((h) => [h.address, h]));
|
|
33
|
+
|
|
34
|
+
for (const entity of frond.entities) {
|
|
35
|
+
const handler = handlerMap.get(entity.name);
|
|
36
|
+
if (!handler) continue;
|
|
37
|
+
|
|
38
|
+
let facade: Record<string, Function>;
|
|
39
|
+
try { facade = app.resolve(handler.name); } catch { continue; }
|
|
40
|
+
if (typeof facade.execute !== 'function') continue;
|
|
41
|
+
|
|
42
|
+
// The same function the runner builds citty's args from, so the positional and the
|
|
43
|
+
// flags are the ones the command actually has.
|
|
44
|
+
const args = Object.entries(entityToArgs(entity.entityClass.getFields())) as
|
|
45
|
+
[string, { type?: string }][];
|
|
46
|
+
const positional = args.find(([, def]) => def.type === 'positional')?.[0];
|
|
47
|
+
|
|
48
|
+
commands.push({
|
|
49
|
+
name: toKebab(entity.name),
|
|
50
|
+
flags: args.filter(([, def]) => def.type !== 'positional').map(([key]) => `--${key}`),
|
|
51
|
+
...(positional && NAMES[positional] ? { names: NAMES[positional]! } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return commands;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generateZshCompletion(app: App, binName = 'fougere'): string {
|
|
60
|
+
const commands = extractCommandMeta(app);
|
|
61
|
+
|
|
62
|
+
const valueCases = commands
|
|
63
|
+
.filter((cmd) => cmd.names)
|
|
64
|
+
.map((cmd) => ` ${cmd.name}) _${binName}_names ${cmd.names} ;;`)
|
|
65
|
+
.join('\n');
|
|
66
|
+
|
|
67
|
+
const flagCases = commands
|
|
68
|
+
.map((cmd) => ` ${cmd.name}) flags="${cmd.flags.join(' ')}" ;;`)
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
return `# Auto-generated by @fougere/cli
|
|
72
|
+
|
|
73
|
+
# The project answers; a stale list in this file would not.
|
|
74
|
+
_${binName}_names() {
|
|
75
|
+
local -a values
|
|
76
|
+
values=(\${(f)"$(${binName} explain --names $1 2>/dev/null)"})
|
|
77
|
+
(( \${#values} )) && compadd -- \${values}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_${binName}() {
|
|
81
|
+
local -a commands flags
|
|
82
|
+
commands=(${commands.map((c) => `'${c.name}'`).join(' ')})
|
|
83
|
+
|
|
84
|
+
if (( CURRENT == 2 )); then
|
|
85
|
+
_describe 'command' commands
|
|
86
|
+
return
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
local cmd=\${words[2]}
|
|
90
|
+
|
|
91
|
+
if [[ "\${words[CURRENT]}" != --* ]]; then
|
|
92
|
+
case "$cmd" in
|
|
93
|
+
${valueCases}
|
|
94
|
+
esac
|
|
95
|
+
return
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
case "$cmd" in
|
|
99
|
+
${flagCases}
|
|
100
|
+
*) flags="" ;;
|
|
101
|
+
esac
|
|
102
|
+
_values 'flags' \${(s: :)flags}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
compdef _${binName} ${binName}
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function generateBashCompletion(app: App, binName = 'fougere'): string {
|
|
110
|
+
const commands = extractCommandMeta(app);
|
|
111
|
+
const cmdNames = commands.map((c) => c.name).join(' ');
|
|
112
|
+
|
|
113
|
+
const valueCases = commands
|
|
114
|
+
.filter((cmd) => cmd.names)
|
|
115
|
+
.map((cmd) => ` ${cmd.name}) names="${cmd.names}" ;;`)
|
|
116
|
+
.join('\n');
|
|
117
|
+
|
|
118
|
+
const flagCases = commands
|
|
119
|
+
.map((cmd) => ` ${cmd.name}) COMPREPLY=( $(compgen -W "${cmd.flags.join(' ')}" -- "\${cur}") ) ;;`)
|
|
120
|
+
.join('\n');
|
|
121
|
+
|
|
122
|
+
return `# Auto-generated by @fougere/cli
|
|
123
|
+
_${binName}_completions() {
|
|
124
|
+
local cur cmd names
|
|
125
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
126
|
+
cmd="\${COMP_WORDS[1]}"
|
|
127
|
+
|
|
128
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
129
|
+
COMPREPLY=( $(compgen -W "${cmdNames}" -- "\${cur}") )
|
|
130
|
+
return
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
if [[ "\${cur}" != --* ]]; then
|
|
134
|
+
names=""
|
|
135
|
+
case "$cmd" in
|
|
136
|
+
${valueCases}
|
|
137
|
+
esac
|
|
138
|
+
if [[ -n "\${names}" ]]; then
|
|
139
|
+
# The project answers; a stale list in this file would not.
|
|
140
|
+
COMPREPLY=( $(compgen -W "$(${binName} explain --names "\${names}" 2>/dev/null | tr '\\n' ' ')" -- "\${cur}") )
|
|
141
|
+
return
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
case "$cmd" in
|
|
146
|
+
${flagCases}
|
|
147
|
+
esac
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
complete -F _${binName}_completions ${binName}
|
|
151
|
+
`;
|
|
152
|
+
}
|
package/src/index.ts
ADDED
package/src/loader.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
setModuleLoader, loadConfig, resolveConventions, frondAliases,
|
|
3
|
+
} from '@fougere/core/node';
|
|
4
|
+
import type { Conventions } from '@fougere/core/node';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The loader every command needs: `alias` is what makes `@fronds/user/entities/User.js`
|
|
8
|
+
* resolve when one frond names its neighbour, in any command that loads user code.
|
|
9
|
+
*
|
|
10
|
+
* Two jitis, because the config is read BEFORE the aliases — it names the scope they are
|
|
11
|
+
* built from. `reread` drops the module cache: every loader caches by specifier, so a
|
|
12
|
+
* second boot in one process would be handed what the first one read.
|
|
13
|
+
*/
|
|
14
|
+
export async function installLoader(root: string, reread = false): Promise<Conventions> {
|
|
15
|
+
const { createJiti } = await import('jiti');
|
|
16
|
+
const bare = createJiti(import.meta.url, { interopDefault: true });
|
|
17
|
+
setModuleLoader((filePath) => bare.import(filePath) as Promise<Record<string, unknown>>);
|
|
18
|
+
|
|
19
|
+
const conventions = resolveConventions((await loadConfig(root)).conventions);
|
|
20
|
+
const jiti = createJiti(import.meta.url, {
|
|
21
|
+
interopDefault: true,
|
|
22
|
+
alias: await frondAliases(root, conventions),
|
|
23
|
+
...(reread ? { moduleCache: false } : {}),
|
|
24
|
+
});
|
|
25
|
+
setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
|
|
26
|
+
|
|
27
|
+
return conventions;
|
|
28
|
+
}
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A command's machine output — the one shape a pipe reads.
|
|
3
|
+
*
|
|
4
|
+
* There is no list of commands here: `json` is declared by an entity like any other
|
|
5
|
+
* field, so a command that declares it gets the door. The runner used to name `explain`
|
|
6
|
+
* in an `if`, and `graph --json` announced a flag it then ignored.
|
|
7
|
+
*/
|
|
8
|
+
export function machineWanted(raw: Record<string, unknown>): boolean {
|
|
9
|
+
return raw.json === true || typeof raw.names === 'string';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `Map` serializes to `{}`, so the door converts it rather than each command flattening
|
|
14
|
+
* its own result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a
|
|
15
|
+
* report with an empty graph in it.
|
|
16
|
+
*/
|
|
17
|
+
export function machineText(value: unknown): string {
|
|
18
|
+
return JSON.stringify(value, (_key, held) => (held instanceof Map ? Object.fromEntries(held) : held), 2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function printMachine(value: unknown): void {
|
|
22
|
+
process.stdout.write(machineText(value) + '\n');
|
|
23
|
+
}
|