@fougere/cli 0.5.0-alpha.1 → 0.7.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/app/commands/BuildCommand.ts +1 -1
- package/app/commands/CallCommand.ts +3 -3
- package/app/commands/CheckCommand.ts +1 -1
- package/app/commands/DevtoolsCommand.ts +1 -1
- package/app/commands/ExplainCommand.ts +1 -1
- package/app/commands/FreezeCommand.ts +2 -2
- package/app/commands/GraphCommand.ts +1 -1
- package/app/commands/MigrateCommand.ts +1 -1
- package/dist/bin.js +3 -14
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts +1 -6
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +38 -39
- package/dist/bridge.js.map +1 -1
- package/dist/completion.d.ts +1 -8
- package/dist/completion.d.ts.map +1 -1
- package/dist/completion.js.map +1 -1
- package/dist/loader.d.ts +1 -8
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +1 -8
- package/dist/loader.js.map +1 -1
- package/dist/machine.d.ts +4 -10
- package/dist/machine.d.ts.map +1 -1
- package/dist/machine.js +4 -10
- package/dist/machine.js.map +1 -1
- package/dist/runner.d.ts +2 -7
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +4 -4
- package/dist/runner.js.map +1 -1
- package/dist/typescript/EntityTypes.d.ts +12 -0
- package/dist/typescript/EntityTypes.d.ts.map +1 -0
- package/dist/typescript/EntityTypes.js +76 -0
- package/dist/typescript/EntityTypes.js.map +1 -0
- package/dist/typescript/FacadeTypes.d.ts +19 -0
- package/dist/typescript/FacadeTypes.d.ts.map +1 -0
- package/dist/typescript/FacadeTypes.js +34 -0
- package/dist/typescript/FacadeTypes.js.map +1 -0
- package/dist/typescript/syntax.d.ts +5 -0
- package/dist/typescript/syntax.d.ts.map +1 -0
- package/dist/typescript/syntax.js +11 -0
- package/dist/typescript/syntax.js.map +1 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +1 -11
- package/dist/ui.js.map +1 -1
- package/fronds/analysis/handlers/CheckHandler.ts +31 -2
- package/fronds/analysis/handlers/DevtoolsHandler.ts +1 -1
- package/fronds/analysis/handlers/ExplainHandler.ts +2 -2
- package/fronds/scaffold/handlers/SyncHandler.ts +9 -7
- package/package.json +7 -7
- package/src/bin.ts +3 -14
- package/src/bridge.ts +52 -41
- package/src/completion.ts +1 -8
- package/src/loader.ts +1 -8
- package/src/machine.ts +4 -10
- package/src/runner.ts +7 -12
- package/src/typescript/EntityTypes.ts +86 -0
- package/src/typescript/FacadeTypes.ts +48 -0
- package/src/typescript/syntax.ts +11 -0
- package/src/ui.ts +1 -11
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +4 -4
- package/templates/api/fronds/api/handlers/TaskHandler.ts +4 -4
- package/templates/blog/app/pages/index.vue +1 -1
- package/templates/blog/app/pages/posts/manage.vue +2 -2
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +5 -5
- package/templates/flat/CLAUDE.md +7 -7
- package/templates/frond/CLAUDE.md +7 -7
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -3
- package/templates/fronds/blank/handlers/ItemHandler.ts +1 -1
- package/templates/fronds/blog/handlers/PostHandler.ts +4 -4
- package/templates/workspace/CLAUDE.md +7 -7
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { docCommentOf, propertyKey } from './syntax.js';
|
|
2
|
+
/** So a consumer sees the cardinality in the type, not in a doc line. */
|
|
3
|
+
function returnTypeOf(op, rowType) {
|
|
4
|
+
switch (op.cardinality) {
|
|
5
|
+
case 'many': return `${rowType}[]`;
|
|
6
|
+
case 'page': return `${rowType}[] & { total?: number; endCursor?: string; hasMore?: boolean }`;
|
|
7
|
+
case 'maybe': return `${rowType} | undefined`;
|
|
8
|
+
case 'one': return rowType;
|
|
9
|
+
case 'none': return 'unknown';
|
|
10
|
+
default: return 'unknown';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class FacadeTypes {
|
|
14
|
+
operations;
|
|
15
|
+
constructor(operations) {
|
|
16
|
+
this.operations = operations;
|
|
17
|
+
}
|
|
18
|
+
static of(operations) {
|
|
19
|
+
return new FacadeTypes(operations);
|
|
20
|
+
}
|
|
21
|
+
render(options = {}) {
|
|
22
|
+
const name = options.name ?? 'Facade';
|
|
23
|
+
const exported = options.exported === false ? '' : 'export ';
|
|
24
|
+
const rowType = options.rowType ?? 'unknown';
|
|
25
|
+
const members = this.operations.map((operation) => {
|
|
26
|
+
const doc = docCommentOf(operation.description, ' ');
|
|
27
|
+
return `${doc} ${propertyKey(operation.name)}(invocation?: Invocation): Promise<${returnTypeOf(operation, rowType)}>;`;
|
|
28
|
+
});
|
|
29
|
+
if (members.length === 0)
|
|
30
|
+
return `${exported}interface ${name} {}`;
|
|
31
|
+
return `${exported}interface ${name} {\n${members.join('\n')}\n}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=FacadeTypes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FacadeTypes.js","sourceRoot":"","sources":["../../src/typescript/FacadeTypes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAexD,yEAAyE;AACzE,SAAS,YAAY,CAAC,EAAgB,EAAE,OAAe;IACrD,QAAQ,EAAE,CAAC,WAAW,EAAE,CAAC;QACvB,KAAK,MAAM,EAAE,OAAO,GAAG,OAAO,IAAI,CAAC;QACnC,KAAK,MAAM,EAAE,OAAO,GAAG,OAAO,gEAAgE,CAAC;QAC/F,KAAK,OAAO,EAAE,OAAO,GAAG,OAAO,cAAc,CAAC;QAC9C,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;QAC3B,KAAK,MAAM,EAAE,OAAO,SAAS,CAAC;QAC9B,SAAS,OAAO,SAAS,CAAC;IAC5B,CAAC;AACH,CAAC;AAED,MAAM,OAAO,WAAW;IACe,UAAU;IAA/C,YAAqC,UAAmC;0BAAnC,UAAU;IAA4B,CAAC;IAE5E,MAAM,CAAC,EAAE,CAAC,UAAmC;QAC3C,OAAO,IAAI,WAAW,CAAC,UAAU,CAAC,CAAC;IACrC,CAAC;IAED,MAAM,CAAC,OAAO,GAAuB,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,QAAQ,CAAC;QACtC,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QAC7D,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,SAAS,CAAC;QAC7C,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;YAChD,MAAM,GAAG,GAAG,YAAY,CAAC,SAAS,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;YACtD,OAAO,GAAG,GAAG,KAAK,WAAW,CAAC,SAAS,CAAC,IAAI,CAAC,sCAAsC,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC;QAC1H,CAAC,CAAC,CAAC;QAEH,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,GAAG,QAAQ,aAAa,IAAI,KAAK,CAAC;QACnE,OAAO,GAAG,QAAQ,aAAa,IAAI,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;IACpE,CAAC;CACF"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** So a name that is not an identifier is still written as a key. */
|
|
2
|
+
export declare function propertyKey(name: string): string;
|
|
3
|
+
/** So a sentence shows on hover — and cannot close the comment it sits in. */
|
|
4
|
+
export declare function docCommentOf(text: string | undefined, indent: string): string;
|
|
5
|
+
//# sourceMappingURL=syntax.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syntax.d.ts","sourceRoot":"","sources":["../../src/typescript/syntax.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI7E"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** So a name that is not an identifier is still written as a key. */
|
|
2
|
+
export function propertyKey(name) {
|
|
3
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
4
|
+
}
|
|
5
|
+
/** So a sentence shows on hover — and cannot close the comment it sits in. */
|
|
6
|
+
export function docCommentOf(text, indent) {
|
|
7
|
+
if (!text)
|
|
8
|
+
return '';
|
|
9
|
+
return `${indent}/** ${text.replace(/\*\//g, '*\\/')} */\n`;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=syntax.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syntax.js","sourceRoot":"","sources":["../../src/typescript/syntax.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,4BAA4B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;AAC/E,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,YAAY,CAAC,IAAwB,EAAE,MAAc;IACnE,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAC;IAErB,OAAO,GAAG,MAAM,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC;AAC9D,CAAC"}
|
package/dist/ui.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAIA,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,WAAW,OAAO;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;CAC/B;AAED,wBAAgB,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO;IAMhC,qDAAqD;;IAKrD,sCAAsC;mBACvB,MAAM;IAIrB,uBAAuB;;IAQvB,kBAAkB;eACD;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;KAAE;IAMnI,2BAA2B;kBACP;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE;IAM/D,8BAA8B;iBACX;QACjB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB;IAMD,gCAAgC;sBACR;QACtB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAC;QAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB;IAQD,sDAAsD;sBACpC,MAAM;QAIpB,MAAM,QAAQ,MAAM;QACpB,IAAI,SAAS,MAAM;;IAMvB,6BAA6B;kBACf,MAAM;IAIpB,uBAAuB;qBACN,MAAM;IAIvB,uBAAuB;kBACT,MAAM;IAIpB,qBAAqB;mBACN,MAAM;IAIrB,sBAAsB;kBACR,MAAM;IAIpB,6CAA6C;kBAC/B,MAAM,UAAU,MAAM;;;;;;;;;;;;;EAUvC"}
|
package/dist/ui.js
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fougere CLI UI — beautiful terminal interface.
|
|
3
|
-
*
|
|
4
|
-
* Wraps @clack/prompts + picocolors + consola into a cohesive API.
|
|
5
|
-
*
|
|
6
|
-
* This was `@fougere/cli-ui`, a published package with exactly one consumer —
|
|
7
|
-
* the CLI it is named after. A second name in the registry that nobody would
|
|
8
|
-
* ever install on purpose is a name, not a boundary. Same dependency profile,
|
|
9
|
-
* so it folds in as a module; a subpath export is one line the day something
|
|
10
|
-
* outside the CLI wants it.
|
|
11
|
-
*/
|
|
1
|
+
/** Fougere CLI UI — beautiful terminal interface. */
|
|
12
2
|
import * as clack from '@clack/prompts';
|
|
13
3
|
import pc from 'picocolors';
|
|
14
4
|
import { consola } from 'consola';
|
package/dist/ui.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
resolveEffectiveOperations,
|
|
5
5
|
type ScanDiagnostic,
|
|
6
6
|
} from '@fougere/core';
|
|
7
|
-
import { crossFrondImports } from '@fougere/core/node';
|
|
7
|
+
import { adaptersOf, crossFrondImports } from '@fougere/core/node';
|
|
8
8
|
import ProjectScan from '../services/ProjectScan.js';
|
|
9
9
|
|
|
10
10
|
/** One thing that does not hold, in the terms of whoever has to fix it. */
|
|
@@ -52,7 +52,7 @@ export default class CheckHandler {
|
|
|
52
52
|
|
|
53
53
|
/** Report what does not hold in a Fougere app, without booting it. */
|
|
54
54
|
async execute(input: { root?: string }): Promise<CheckResult> {
|
|
55
|
-
const { fronds, diagnostics, config } = await this.projectScan.at(input.root);
|
|
55
|
+
const { fronds, diagnostics, config, root } = await this.projectScan.at(input.root);
|
|
56
56
|
// This is the same pure resolution the boot consumes. No app lifecycle, database,
|
|
57
57
|
// migration, seed or adapter mount is needed for a global semantic check.
|
|
58
58
|
const model = resolveEffectiveOperations(fronds, {
|
|
@@ -87,6 +87,35 @@ export default class CheckHandler {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* An entity addressing an adapter this project does not depend on. Judged HERE and
|
|
92
|
+
* not at boot: a process only knows the adapters it loaded, so an entity stating a
|
|
93
|
+
* Postgres column type in an app running on `adapter/memory` is indistinguishable
|
|
94
|
+
* from a typo. A project's dependencies name every adapter it could ever load.
|
|
95
|
+
*
|
|
96
|
+
* Silent when the project depends on none — there would be nothing to compare against,
|
|
97
|
+
* and reporting every key as unknown is worse than saying nothing.
|
|
98
|
+
*/
|
|
99
|
+
const known = await adaptersOf(root);
|
|
100
|
+
if (known.length > 0) {
|
|
101
|
+
for (const frond of fronds) {
|
|
102
|
+
for (const entity of frond.entities) {
|
|
103
|
+
for (const name of Object.keys(entity.entityClass.getAdapters() ?? {})) {
|
|
104
|
+
if (known.includes(name)) continue;
|
|
105
|
+
findings.push({
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
code: 'unknown-adapter',
|
|
108
|
+
filePath: entity.filePath,
|
|
109
|
+
subject: entity.name,
|
|
110
|
+
message: `states \`adapters: { ${name} }\`, and this project depends on no adapter `
|
|
111
|
+
+ `that answers to it. It depends on ${known.join(', ')}. Nothing reads the entry, `
|
|
112
|
+
+ 'so it is inert — a typo, or a dependency that was never added.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* A warning, not a refusal: it resolves today and the app runs. What it costs is
|
|
92
121
|
* paid once, late — the day the frond it reaches into is deployed on its own.
|
|
@@ -42,7 +42,7 @@ export default class DevtoolsHandler {
|
|
|
42
42
|
try {
|
|
43
43
|
const page = await createHttpTransport(one.url)(
|
|
44
44
|
{ entity: 'rpc', op: 'calls' },
|
|
45
|
-
{ params: {}, query: {},
|
|
45
|
+
{ params: {}, query: {}, input: { since: since[one.url] ?? 0 }, state: {} },
|
|
46
46
|
) as CallPage;
|
|
47
47
|
|
|
48
48
|
return { ...one, cursor: page.cursor, inFlight: page.inFlight, dropped: page.dropped, calls: page.calls };
|
|
@@ -14,7 +14,7 @@ export type ExplainedBinding =
|
|
|
14
14
|
| { kind: 'collector'; typeName: string }
|
|
15
15
|
| { kind: 'fact'; factName: string }
|
|
16
16
|
| { kind: 'param'; name: string; coerce?: 'number' | 'boolean' }
|
|
17
|
-
| { kind: '
|
|
17
|
+
| { kind: 'input' }
|
|
18
18
|
| { kind: 'context' }
|
|
19
19
|
| { kind: 'query' };
|
|
20
20
|
|
|
@@ -229,7 +229,7 @@ function addressOf(value: string): string {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
function inputTypeOf(operation: EffectiveOperation): string | null {
|
|
232
|
-
const body = operation.parameters.find((parameter) => parameter.binding.source.kind === '
|
|
232
|
+
const body = operation.parameters.find((parameter) => parameter.binding.source.kind === 'input');
|
|
233
233
|
return body?.type ?? schemaName(operation.input) ?? null;
|
|
234
234
|
}
|
|
235
235
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { upperFirst,
|
|
3
|
+
import { upperFirst, type SchemaDescriptor } from '@fougere/schema';
|
|
4
|
+
import { EntityTypes } from '../../../src/typescript/EntityTypes.js';
|
|
5
|
+
import { FacadeTypes } from '../../../src/typescript/FacadeTypes.js';
|
|
4
6
|
// The card's shape is declared once, in core, and imported here. A private copy of it
|
|
5
7
|
// lived in this file and went stale the day an op stopped being a bare name: nothing
|
|
6
8
|
// compared the copy to the original, so the drift cost nothing until someone read it.
|
|
@@ -35,7 +37,7 @@ export function entityClassName(name: string): string {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* One entry — a door or a fact —
|
|
40
|
+
* One entry — a door or a fact — validated the same way, because sync consumes the same two
|
|
39
41
|
* values from both: a name it can turn into a class, and a descriptor it can rebuild.
|
|
40
42
|
*
|
|
41
43
|
* A missing descriptor is legal on either side and means different things: a door that
|
|
@@ -52,7 +54,7 @@ function assertEntry(kind: string, frondName: string, entry: { name: string; sch
|
|
|
52
54
|
// `ops` is not checked because it is not used: sync writes entities, and the name
|
|
53
55
|
// and the descriptor below are the only two values that reach a file. The clause
|
|
54
56
|
// that stood here demanded strings — the shape ops had before they carried their
|
|
55
|
-
// kind and their views — and so refused every real host.
|
|
57
|
+
// kind and their views — and so refused every real host. Validate what you consume.
|
|
56
58
|
const descriptor = entry.schema as unknown;
|
|
57
59
|
if (
|
|
58
60
|
!descriptor
|
|
@@ -70,7 +72,7 @@ function assertEntry(kind: string, frondName: string, entry: { name: string; sch
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function identityCardOf(value: unknown): IdentityCard {
|
|
73
|
-
// The card's own shape is
|
|
75
|
+
// The card's own shape is validated by the package that declares it — `fronds`, and each
|
|
74
76
|
// frond's `doors`. What stays here is what only a writer of files needs: a name safe to
|
|
75
77
|
// become one, and the descriptor a class is generated from.
|
|
76
78
|
const card = assertIdentityCard(value, 'Remote rpc.discover');
|
|
@@ -170,7 +172,7 @@ export default class SyncHandler {
|
|
|
170
172
|
`import { Card } from '@fougere/schema';`,
|
|
171
173
|
``,
|
|
172
174
|
`// Generated by \`fougere sync\` from ${baseUrl} — do not edit.`,
|
|
173
|
-
|
|
175
|
+
EntityTypes.of(descriptor).render({ name: className }),
|
|
174
176
|
``,
|
|
175
177
|
`export default ${className};`,
|
|
176
178
|
``,
|
|
@@ -203,7 +205,7 @@ export default class SyncHandler {
|
|
|
203
205
|
`// contract, and a contract that drags a runtime dependency is not one.`,
|
|
204
206
|
`type Invocation = { params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown; state?: Record<string, unknown> };`,
|
|
205
207
|
``,
|
|
206
|
-
|
|
208
|
+
FacadeTypes.of(ops ?? []).render({
|
|
207
209
|
name: `${className}Handler`,
|
|
208
210
|
...(descriptor !== undefined ? { rowType: className } : {}),
|
|
209
211
|
}),
|
|
@@ -263,7 +265,7 @@ export default class SyncHandler {
|
|
|
263
265
|
* The barrel is rewritten every run, so a dropped entity loses its export on its own
|
|
264
266
|
* — but the FILE stayed, and the generated `package.json` exports `'./entities/*'` as
|
|
265
267
|
* a wildcard, so `@fronds/blog/entities/Ticket.js` kept resolving to a class nothing
|
|
266
|
-
* behind it answers for. The consumer compiles, its local
|
|
268
|
+
* behind it answers for. The consumer compiles, its local validator accepts, and the call
|
|
267
269
|
* comes back NOT_FOUND at the door — or never leaves, because the page dropped the
|
|
268
270
|
* call and kept the type.
|
|
269
271
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-alpha.0",
|
|
4
4
|
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"consola": "^3.4.2",
|
|
42
42
|
"jiti": "^2.4.2",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@fougere/
|
|
45
|
-
"@fougere/
|
|
46
|
-
"@fougere/
|
|
47
|
-
"@fougere/
|
|
48
|
-
"@fougere/
|
|
49
|
-
"@fougere/defaults": "0.
|
|
44
|
+
"@fougere/container": "0.7.0-alpha.0",
|
|
45
|
+
"@fougere/core": "0.7.0-alpha.0",
|
|
46
|
+
"@fougere/adapter-sql": "0.7.0-alpha.0",
|
|
47
|
+
"@fougere/transport-http": "0.7.0-alpha.0",
|
|
48
|
+
"@fougere/schema": "0.7.0-alpha.0",
|
|
49
|
+
"@fougere/defaults": "0.7.0-alpha.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"vitest": "^4.1.0"
|
package/src/bin.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
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
|
-
*/
|
|
2
|
+
/** fougere CLI — a Fougere app powered by citty. */
|
|
9
3
|
import { createApp, setLogLevel, envLevel, type ScanResult } from '@fougere/core';
|
|
10
4
|
import { scanProject, getModuleLoader, frondDirsOf, DEFAULT_CONVENTIONS } from '@fougere/core/node';
|
|
11
5
|
import { readdir, stat } from 'node:fs/promises';
|
|
@@ -50,13 +44,8 @@ async function newestDeclaration(root: string): Promise<number> {
|
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
/**
|
|
53
|
-
* The CLI is a Fougere app, so it reads its own written-down scan like any deployment —
|
|
54
|
-
*
|
|
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.
|
|
47
|
+
* The CLI is a Fougere app, so it reads its own written-down scan like any deployment — producing
|
|
48
|
+
* the description reads the project, consuming it does not.
|
|
60
49
|
*/
|
|
61
50
|
async function scanOf(root: string): Promise<ScanResult> {
|
|
62
51
|
const written = join(root, '.fougere/scan.generated.ts');
|
package/src/bridge.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
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
|
-
*/
|
|
2
|
+
/** Entity → citty bridge. */
|
|
8
3
|
import type { Fields } from '@fougere/schema';
|
|
9
|
-
import {
|
|
4
|
+
import { Shapes, Visibility } from '@fougere/schema';
|
|
10
5
|
import type { ArgsDef, ArgDef } from 'citty';
|
|
11
6
|
|
|
12
7
|
function toKebab(name: string): string {
|
|
@@ -25,46 +20,62 @@ export function entityToArgs(fields: Fields): ArgsDef {
|
|
|
25
20
|
|
|
26
21
|
// A `default(v)` travels as the create rule `{ value }` — citty shows it.
|
|
27
22
|
const defaultValue = Lifecycle.of(field).literal?.value;
|
|
28
|
-
const { base: shape, nullable } =
|
|
29
|
-
|
|
30
|
-
const kebab = toKebab(key);
|
|
31
|
-
const def: ArgDef = {
|
|
23
|
+
const { base: shape, nullable } = Shapes.of(field.shape);
|
|
24
|
+
const common = {
|
|
32
25
|
description: field.meta?.description,
|
|
33
26
|
required: !nullable && Lifecycle.of(field).requiredAtCreate,
|
|
34
27
|
};
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
29
|
+
// A closed set is citty's `enum`: the shape already names the legal values, so the
|
|
30
|
+
// refusal and the `--help` listing come from the declaration rather than a check
|
|
31
|
+
// written beside it.
|
|
32
|
+
const options = shape?.type === 'string' && shape.enum?.length
|
|
33
|
+
? shape.enum.filter((value): value is string => value !== null)
|
|
34
|
+
: undefined;
|
|
35
|
+
// First required field becomes positional — but never a boolean, a closed set, or a
|
|
36
|
+
// date-time, which stay named flags.
|
|
37
|
+
const positional = positionalIndex === 0
|
|
38
|
+
&& common.required
|
|
39
|
+
&& key !== 'force'
|
|
40
|
+
&& options === undefined
|
|
41
|
+
&& shape?.type !== 'boolean'
|
|
42
|
+
&& !(shape?.type === 'string' && shape.format === 'date-time');
|
|
43
|
+
if (positional) positionalIndex++;
|
|
65
44
|
|
|
66
|
-
args[
|
|
45
|
+
args[toKebab(key) === key ? key : toKebab(key)] = argFor(
|
|
46
|
+
shape?.type,
|
|
47
|
+
common,
|
|
48
|
+
defaultValue,
|
|
49
|
+
options,
|
|
50
|
+
positional,
|
|
51
|
+
);
|
|
67
52
|
}
|
|
68
53
|
|
|
69
54
|
return args;
|
|
70
55
|
}
|
|
56
|
+
|
|
57
|
+
/** One arg, built WITH its `type`. */
|
|
58
|
+
function argFor(
|
|
59
|
+
type: string | undefined,
|
|
60
|
+
common: { description?: string; required: boolean },
|
|
61
|
+
defaultValue: unknown,
|
|
62
|
+
options: string[] | undefined,
|
|
63
|
+
positional: boolean,
|
|
64
|
+
): ArgDef {
|
|
65
|
+
if (type === 'boolean') {
|
|
66
|
+
return defaultValue === undefined
|
|
67
|
+
? { ...common, type: 'boolean' }
|
|
68
|
+
: { ...common, type: 'boolean', default: defaultValue as boolean };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Only these three shapes carry a default onto the CLI; anything else is named and bare.
|
|
72
|
+
const carriesDefault = type === 'number' || type === 'integer' || type === 'string';
|
|
73
|
+
const withDefault = carriesDefault && defaultValue !== undefined
|
|
74
|
+
? { default: String(defaultValue) }
|
|
75
|
+
: {};
|
|
76
|
+
|
|
77
|
+
if (options) return { ...common, ...withDefault, type: 'enum', options };
|
|
78
|
+
if (positional) return { ...common, ...withDefault, type: 'positional' };
|
|
79
|
+
|
|
80
|
+
return { ...common, ...withDefault, type: 'string' };
|
|
81
|
+
}
|
package/src/completion.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Shell completion generator — outputs a script for bash/zsh. */
|
|
9
2
|
import type { App } from '@fougere/core';
|
|
10
3
|
import { entityToArgs } from './bridge.js';
|
|
11
4
|
|
package/src/loader.ts
CHANGED
|
@@ -3,14 +3,7 @@ import {
|
|
|
3
3
|
} from '@fougere/core/node';
|
|
4
4
|
import type { Conventions } from '@fougere/core/node';
|
|
5
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
|
-
*/
|
|
6
|
+
/** The loader every command needs. */
|
|
14
7
|
export async function installLoader(root: string, reread = false): Promise<Conventions> {
|
|
15
8
|
const { createJiti } = await import('jiti');
|
|
16
9
|
const bare = createJiti(import.meta.url, { interopDefault: true });
|
package/src/machine.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** A command's machine output — the one shape a pipe reads. */
|
|
8
2
|
export function machineWanted(raw: Record<string, unknown>): boolean {
|
|
9
3
|
return raw.json === true || typeof raw.names === 'string';
|
|
10
4
|
}
|
|
11
5
|
|
|
12
6
|
/**
|
|
13
|
-
* A `Map` serializes to `{}`, so the door converts it rather than each command flattening
|
|
14
|
-
*
|
|
15
|
-
*
|
|
7
|
+
* A `Map` serializes to `{}`, so the door converts it rather than each command flattening its own
|
|
8
|
+
* result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a report with an
|
|
9
|
+
* empty graph in it.
|
|
16
10
|
*/
|
|
17
11
|
export function machineText(value: unknown): string {
|
|
18
12
|
return JSON.stringify(value, (_key, held) => (held instanceof Map ? Object.fromEntries(held) : held), 2);
|
package/src/runner.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI runner — scans frond entities for flags, looks for app commands
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Architecture:
|
|
6
|
-
* - fronds/ → entities (flags) + handlers (domain logic)
|
|
7
|
-
* - app/ → commands (prompts, TUI, presentation)
|
|
8
|
-
* - src/ → runner + bridge (framework)
|
|
2
|
+
* CLI runner — scans frond entities for flags, looks for app commands for presentation, dispatches
|
|
3
|
+
* via citty.
|
|
9
4
|
*/
|
|
10
5
|
import type { App } from '@fougere/core';
|
|
11
6
|
import { createAppRunner } from '@fougere/core';
|
|
@@ -83,7 +78,7 @@ export async function run(app: App): Promise<void> {
|
|
|
83
78
|
// App commands handle their own prompting — don't let citty reject missing args
|
|
84
79
|
if (AppCommand) {
|
|
85
80
|
for (const def of Object.values(args)) {
|
|
86
|
-
if (typeof def === 'object' && def)
|
|
81
|
+
if (typeof def === 'object' && def) def.required = false;
|
|
87
82
|
}
|
|
88
83
|
}
|
|
89
84
|
|
|
@@ -121,15 +116,15 @@ export async function run(app: App): Promise<void> {
|
|
|
121
116
|
// Ride the call contract — the same envelope every consumer uses.
|
|
122
117
|
await createAppRunner(app)(
|
|
123
118
|
{ entity: lowerFirst(entity.name), op: 'execute' },
|
|
124
|
-
{ params: {}, query: {},
|
|
119
|
+
{ params: {}, query: {}, input: input, state: {} },
|
|
125
120
|
);
|
|
126
121
|
}
|
|
127
122
|
} catch (err) {
|
|
128
|
-
const
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
129
124
|
// A machine reader parses stdout: a refusal printed there is a refusal that
|
|
130
125
|
// breaks the parse instead of being read. stderr is where it belongs.
|
|
131
|
-
if (machineOutput) process.stderr.write(
|
|
132
|
-
else terminal.error(
|
|
126
|
+
if (machineOutput) process.stderr.write(message + '\n');
|
|
127
|
+
else terminal.error(message);
|
|
133
128
|
process.exit(1);
|
|
134
129
|
}
|
|
135
130
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { upperFirst, type FieldDescriptor, type SchemaDescriptor } from '@fougere/schema';
|
|
2
|
+
import { docCommentOf, propertyKey } from './syntax.js';
|
|
3
|
+
|
|
4
|
+
/** So a nullable field lands as a union. */
|
|
5
|
+
function typeOf(field: FieldDescriptor): string {
|
|
6
|
+
const types = Array.isArray(field.type) ? field.type : field.type ? [field.type] : [];
|
|
7
|
+
const nullable = types.includes('null');
|
|
8
|
+
const base = types.find((t) => t !== 'null');
|
|
9
|
+
const inner = baseTypeOf(base, field);
|
|
10
|
+
return nullable ? `${inner} | null` : inner;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** So `date-time` becomes a `Date`, the same thing the boundary decodes to. */
|
|
14
|
+
function baseTypeOf(base: string | undefined, field: FieldDescriptor): string {
|
|
15
|
+
if (field.enum?.length) {
|
|
16
|
+
return field.enum.map((v) => (v === null ? 'null' : JSON.stringify(v))).join(' | ');
|
|
17
|
+
}
|
|
18
|
+
switch (base) {
|
|
19
|
+
case 'string':
|
|
20
|
+
return field.format === 'date-time' ? 'Date' : 'string';
|
|
21
|
+
case 'number':
|
|
22
|
+
case 'integer':
|
|
23
|
+
return 'number';
|
|
24
|
+
case 'boolean':
|
|
25
|
+
return 'boolean';
|
|
26
|
+
case 'array':
|
|
27
|
+
return field.items ? `${typeOf(field.items)}[]` : 'string[]';
|
|
28
|
+
case 'object':
|
|
29
|
+
return field.properties ? objectTypeOf(field.properties, field.required ?? []) : 'Record<string, unknown>';
|
|
30
|
+
default:
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** So a nested object keeps its optionality. */
|
|
36
|
+
function objectTypeOf(properties: Record<string, FieldDescriptor>, required: readonly string[]): string {
|
|
37
|
+
const members = Object.entries(properties).map(([name, field]) => {
|
|
38
|
+
const optional = required.includes(name) ? '' : '?';
|
|
39
|
+
return `${propertyKey(name)}${optional}: ${typeOf(field)}`;
|
|
40
|
+
});
|
|
41
|
+
return `{ ${members.join('; ')} }`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntityTypesOptions {
|
|
45
|
+
name?: string;
|
|
46
|
+
exported?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** So the generated class carries its row type. */
|
|
50
|
+
function shapeTypeOf(descriptor: SchemaDescriptor, indent = ''): string {
|
|
51
|
+
const entries = Object.entries(descriptor.properties ?? {});
|
|
52
|
+
if (entries.length === 0) return '{}';
|
|
53
|
+
|
|
54
|
+
const lines = entries.map(([key, field]) => {
|
|
55
|
+
const doc = docCommentOf(field.description, `${indent} `);
|
|
56
|
+
return `${doc}${indent} ${propertyKey(key)}: ${typeOf(field)};`;
|
|
57
|
+
});
|
|
58
|
+
return `{\n${lines.join('\n')}\n${indent}}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class EntityTypes {
|
|
62
|
+
private constructor(private readonly descriptor: SchemaDescriptor) {}
|
|
63
|
+
|
|
64
|
+
static of(descriptor: SchemaDescriptor): EntityTypes {
|
|
65
|
+
return new EntityTypes(descriptor);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render(options: EntityTypesOptions = {}): string {
|
|
69
|
+
const name = identifierOf(options.name ?? upperFirst(this.descriptor.title ?? 'Schema'));
|
|
70
|
+
const exported = options.exported === false ? '' : 'export ';
|
|
71
|
+
const card = JSON.stringify(this.descriptor, null, 2)
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line, i) => (i === 0 ? line : ` ${line}`))
|
|
74
|
+
.join('\n');
|
|
75
|
+
|
|
76
|
+
return `${exported}class ${name} extends Card.fromDescriptor<${shapeTypeOf(this.descriptor)}>(${card}).toSchema() {}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** So a name that cannot declare a class is refused before it reaches a file. */
|
|
81
|
+
function identifierOf(name: string): string {
|
|
82
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
|
|
83
|
+
throw new Error(`'${name}' is not a TypeScript identifier — it cannot name a generated declaration`);
|
|
84
|
+
}
|
|
85
|
+
return name;
|
|
86
|
+
}
|