@fougere/cli 0.9.2-alpha.0 → 0.10.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/CheckCommand.ts +3 -2
- package/app/commands/ExplainCommand.ts +15 -1
- package/app/commands/GraphCommand.ts +25 -1
- package/app/commands/LoadCommand.ts +44 -0
- package/app/commands/ServeCommand.ts +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +24 -4
- package/dist/bridge.js.map +1 -1
- package/dist/machine.d.ts +1 -1
- package/dist/machine.js +1 -1
- package/dist/runner.d.ts +9 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +13 -6
- package/dist/runner.js.map +1 -1
- package/dist/typescript/{EntityTypes.d.ts → EntityTypes/EntityTypes.d.ts} +1 -4
- package/dist/typescript/EntityTypes/EntityTypes.d.ts.map +1 -0
- package/dist/typescript/{EntityTypes.js → EntityTypes/EntityTypes.js} +1 -1
- package/dist/typescript/EntityTypes/EntityTypes.js.map +1 -0
- package/dist/typescript/EntityTypes/EntityTypesOptions.d.ts +5 -0
- package/dist/typescript/EntityTypes/EntityTypesOptions.d.ts.map +1 -0
- package/dist/typescript/EntityTypes/EntityTypesOptions.js +2 -0
- package/dist/typescript/EntityTypes/EntityTypesOptions.js.map +1 -0
- package/dist/typescript/FacadeTypes/FacadeTypes.d.ts +9 -0
- package/dist/typescript/FacadeTypes/FacadeTypes.d.ts.map +1 -0
- package/dist/typescript/{FacadeTypes.js → FacadeTypes/FacadeTypes.js} +1 -1
- package/dist/typescript/FacadeTypes/FacadeTypes.js.map +1 -0
- package/dist/typescript/FacadeTypes/FacadeTypesOptions.d.ts +6 -0
- package/dist/typescript/FacadeTypes/FacadeTypesOptions.d.ts.map +1 -0
- package/dist/typescript/FacadeTypes/FacadeTypesOptions.js +2 -0
- package/dist/typescript/FacadeTypes/FacadeTypesOptions.js.map +1 -0
- package/dist/typescript/FacadeTypes/OpDescriptor.d.ts +8 -0
- package/dist/typescript/FacadeTypes/OpDescriptor.d.ts.map +1 -0
- package/dist/typescript/FacadeTypes/OpDescriptor.js +2 -0
- package/dist/typescript/FacadeTypes/OpDescriptor.js.map +1 -0
- package/fronds/analysis/entities/Load.ts +8 -0
- package/fronds/analysis/handlers/BuildHandler.ts +25 -2
- package/fronds/analysis/handlers/CheckHandler.ts +39 -30
- package/fronds/analysis/handlers/DevtoolsHandler.ts +12 -2
- package/fronds/analysis/handlers/ExplainHandler.ts +6 -0
- package/fronds/analysis/handlers/GraphHandler.ts +6 -3
- package/fronds/analysis/handlers/LoadHandler.ts +43 -0
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +1 -1
- package/fronds/scaffold/handlers/SyncHandler.ts +57 -18
- package/fronds/scaffold/services/ProjectWriter.ts +61 -3
- package/package.json +15 -8
- package/src/bridge.ts +27 -4
- package/src/machine.ts +1 -1
- package/src/runner.ts +13 -6
- package/src/typescript/{EntityTypes.ts → EntityTypes/EntityTypes.ts} +2 -6
- package/src/typescript/EntityTypes/EntityTypesOptions.ts +4 -0
- package/src/typescript/{FacadeTypes.ts → FacadeTypes/FacadeTypes.ts} +3 -15
- package/src/typescript/FacadeTypes/FacadeTypesOptions.ts +5 -0
- package/src/typescript/FacadeTypes/OpDescriptor.ts +8 -0
- package/templates/blog/fronds/blog/entities/Post.ts +1 -1
- package/templates/flat/CLAUDE.md +3 -3
- package/templates/frond/CLAUDE.md +3 -3
- package/templates/workspace/CLAUDE.md +3 -3
- package/dist/typescript/EntityTypes.d.ts.map +0 -1
- package/dist/typescript/EntityTypes.js.map +0 -1
- package/dist/typescript/FacadeTypes.d.ts +0 -19
- package/dist/typescript/FacadeTypes.d.ts.map +0 -1
- package/dist/typescript/FacadeTypes.js.map +0 -1
- package/templates/apps/nuxt/app/app.vue +0 -25
- package/templates/apps/nuxt/app/pages/index.vue +0 -33
- package/templates/apps/nuxt/nuxt.config.ts +0 -6
- package/templates/apps/nuxt/package.json +0 -19
- package/templates/apps/nuxt/tsconfig.json +0 -3
|
@@ -2,9 +2,18 @@ import { createHttpTransport } from '@fougere/transport-http/client';
|
|
|
2
2
|
import type { CallPage, CallRecord } from '@fougere/core';
|
|
3
3
|
import ProjectScan from '../services/ProjectScan.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Where an app answers in development — Nuxt, Next and the site all sit there, so it is a
|
|
7
|
+
* convention and not a setting. A port belongs to the HOST, though, and this project states
|
|
8
|
+
* none: an app served anywhere else is named the way `FOUGERE_LOG_LEVEL` names a level the
|
|
9
|
+
* file did not, and `--url` still wins over both.
|
|
10
|
+
*/
|
|
6
11
|
const LOCAL = 'http://127.0.0.1:3000';
|
|
7
12
|
|
|
13
|
+
function local(): string {
|
|
14
|
+
return trimmed(process.env.FOUGERE_URL || LOCAL);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** One address that was asked, and what came back from it. */
|
|
9
18
|
export interface CallSource {
|
|
10
19
|
url: string;
|
|
@@ -70,10 +79,11 @@ export default class DevtoolsHandler {
|
|
|
70
79
|
*/
|
|
71
80
|
private async addresses(root?: string): Promise<{ url: string; frond?: string }[]> {
|
|
72
81
|
const { config } = await this.projectScan.at(root);
|
|
82
|
+
const here = local();
|
|
73
83
|
const remotes = Object.entries(config.remotes ?? {})
|
|
74
84
|
.map(([frond, url]) => ({ url: trimmed(url), frond }));
|
|
75
85
|
|
|
76
|
-
return [{ url:
|
|
86
|
+
return [{ url: here }, ...remotes.filter((one) => one.url !== here)];
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -59,6 +59,11 @@ export interface ExplainResult {
|
|
|
59
59
|
runtime: 'local' | 'remote';
|
|
60
60
|
remote: string | null;
|
|
61
61
|
};
|
|
62
|
+
/** Where the work GOES, next to where it answers — and how much of it leaves the process. */
|
|
63
|
+
reach: {
|
|
64
|
+
fronds: { frond: string; runtime: 'local' | 'remote' }[];
|
|
65
|
+
hops: number;
|
|
66
|
+
};
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/** What this project serves, when no single operation was named. */
|
|
@@ -193,6 +198,7 @@ function project(operation: EffectiveOperation, root: string): ExplainResult {
|
|
|
193
198
|
runtime: operation.placement.runtime,
|
|
194
199
|
remote: operation.placement.remote ?? null,
|
|
195
200
|
},
|
|
201
|
+
reach: operation.reach,
|
|
196
202
|
};
|
|
197
203
|
}
|
|
198
204
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
-
buildGraph, suggestSplit,
|
|
3
|
-
type EntityNode, type DomainCluster, type FrondDescriptor,
|
|
2
|
+
buildGraph, declaredTopologyOf, suggestSplit,
|
|
3
|
+
type DeclaredTopology, type EntityNode, type DomainCluster, type FrondDescriptor,
|
|
4
4
|
} from '@fougere/core';
|
|
5
5
|
import ProjectScan from '../services/ProjectScan.js';
|
|
6
6
|
|
|
7
7
|
export interface GraphResult {
|
|
8
8
|
fronds: FrondDescriptor[];
|
|
9
|
+
/** Where the fronds run and which reaches which — the same picture, one altitude up. */
|
|
10
|
+
declared: DeclaredTopology;
|
|
9
11
|
nodes: Map<string, EntityNode>;
|
|
10
12
|
clusters: DomainCluster[];
|
|
11
13
|
totalEntities: number;
|
|
@@ -17,12 +19,13 @@ export default class GraphHandler {
|
|
|
17
19
|
|
|
18
20
|
/** Report how a workspace's fronds and entities reference each other. */
|
|
19
21
|
async execute(input: { root?: string; minEntities?: number }): Promise<GraphResult> {
|
|
20
|
-
const { fronds } = await this.projectScan.at(input.root);
|
|
22
|
+
const { fronds, config } = await this.projectScan.at(input.root);
|
|
21
23
|
const nodes = buildGraph(fronds);
|
|
22
24
|
const clusters = suggestSplit(nodes);
|
|
23
25
|
|
|
24
26
|
return {
|
|
25
27
|
fronds,
|
|
28
|
+
declared: declaredTopologyOf({ fronds, remotes: config.remotes ?? {} }),
|
|
26
29
|
nodes,
|
|
27
30
|
clusters,
|
|
28
31
|
totalEntities: nodes.size,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { loadScript, reachableOps } from '@fougere/testing';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import ProjectScan from '../services/ProjectScan.js';
|
|
5
|
+
|
|
6
|
+
export interface LoadScenario {
|
|
7
|
+
/** Where it was written, or `null` when it was only printed. */
|
|
8
|
+
file: string | null;
|
|
9
|
+
facade: string;
|
|
10
|
+
/** Every operation the scenario calls, in the order it lists them. */
|
|
11
|
+
operations: string[];
|
|
12
|
+
script: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A load scenario, written from what the project serves.
|
|
17
|
+
*
|
|
18
|
+
* Read from the SCAN rather than a boot: an app that boots runs its migrations and plants its
|
|
19
|
+
* seeds, and a command that describes a project has no business writing to its database.
|
|
20
|
+
*/
|
|
21
|
+
export default class LoadHandler {
|
|
22
|
+
constructor(private projectScan: ProjectScan) {}
|
|
23
|
+
|
|
24
|
+
/** Generate a k6 scenario covering every operation the default facade answers. */
|
|
25
|
+
async execute(input: { root?: string; facade?: string; out?: string }): Promise<LoadScenario> {
|
|
26
|
+
const { root, fronds, config } = await this.projectScan.at(input.root);
|
|
27
|
+
const facade = input.facade?.trim() || undefined;
|
|
28
|
+
// The topology statement travels with it: an op that crosses a process is not held to the
|
|
29
|
+
// same figure as one that never leaves, and `remotes:` is what says which is which.
|
|
30
|
+
const remotes = config.remotes ?? {};
|
|
31
|
+
const script = loadScript({ fronds }, { ...(facade ? { facade } : {}), remotes });
|
|
32
|
+
|
|
33
|
+
const file = input.out === undefined ? join(root, 'load.js') : input.out || null;
|
|
34
|
+
if (file) await writeFile(file, script, 'utf8');
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
file,
|
|
38
|
+
facade: facade ?? 'http://127.0.0.1:3000/_fougere/call',
|
|
39
|
+
operations: reachableOps({ fronds }, {}, remotes).map((one) => one.method),
|
|
40
|
+
script,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, basename } from 'node:path';
|
|
4
4
|
import { resolveConventions, frondPackage } from '@fougere/core';
|
|
5
5
|
import { loadConfig } from '@fougere/core/node';
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
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
|
+
import { EntityTypes } from '../../../src/typescript/EntityTypes/EntityTypes.js';
|
|
5
|
+
import { FacadeTypes } from '../../../src/typescript/FacadeTypes/FacadeTypes.js';
|
|
6
6
|
// The card's shape is declared once, in core, and imported here. A private copy of it
|
|
7
7
|
// lived in this file and went stale the day an op stopped being a bare name: nothing
|
|
8
8
|
// compared the copy to the original, so the drift cost nothing until someone read it.
|
|
9
9
|
import { assertIdentityCard, type IdentityCard } from '@fougere/core';
|
|
10
10
|
import { type Conventions, resolveConventions, frondPackage } from '@fougere/core';
|
|
11
11
|
import { loadConfig } from '@fougere/core/node';
|
|
12
|
+
import { facadeModule, type Served } from '@fougere/compiler';
|
|
13
|
+
import { ErrorCode } from '@fougere/core/contract';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A union of enum MEMBERS, from what the card said this operation refuses.
|
|
17
|
+
*
|
|
18
|
+
* `ErrorCode` is a string enum, so `'CONFLICT'` is not assignable to it: a literal reads as the
|
|
19
|
+
* right thing and then refuses to narrow `FougereError<Code>`, which is the whole point. A code
|
|
20
|
+
* this version does not know is dropped rather than written — the far side may be newer, and a
|
|
21
|
+
* name that resolves to nothing would stop the consumer's build.
|
|
22
|
+
*/
|
|
23
|
+
function codesOf(errors: readonly string[] | undefined): string {
|
|
24
|
+
const known = (errors ?? []).filter((code) => code in ErrorCode);
|
|
25
|
+
if (known.length === 0) return 'never';
|
|
26
|
+
|
|
27
|
+
return known.map((code) => `ErrorCode.${code}`).join(' | ');
|
|
28
|
+
}
|
|
12
29
|
|
|
13
30
|
function assertSafeName(kind: string, name: string): void {
|
|
14
31
|
if (typeof name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(name)) {
|
|
@@ -36,10 +53,10 @@ export function entityClassName(name: string): string {
|
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/**
|
|
39
|
-
* One entry — a
|
|
56
|
+
* One entry — a facade or a fact — validated the same way, because sync consumes the same two
|
|
40
57
|
* values from both: a name it can turn into a class, and a descriptor it can rebuild.
|
|
41
58
|
*
|
|
42
|
-
* A missing descriptor is legal on either side and means different things: a
|
|
59
|
+
* A missing descriptor is legal on either side and means different things: a facade that
|
|
43
60
|
* stores nothing (a health check, a search across shapes), or a fact whose announced type
|
|
44
61
|
* is not a declared entity. Neither produces a row class, and demanding one here refused
|
|
45
62
|
* the WHOLE card over a single entry.
|
|
@@ -72,7 +89,7 @@ function assertEntry(kind: string, frondName: string, entry: { name: string; sch
|
|
|
72
89
|
|
|
73
90
|
function identityCardOf(value: unknown): IdentityCard {
|
|
74
91
|
// The card's own shape is validated by the package that declares it — `fronds`, and each
|
|
75
|
-
// frond's `
|
|
92
|
+
// frond's `facades`. What stays here is what only a writer of files needs: a name safe to
|
|
76
93
|
// become one, and the descriptor a class is generated from.
|
|
77
94
|
const card = assertIdentityCard(value, 'Remote rpc.discover');
|
|
78
95
|
for (const frond of card.fronds) {
|
|
@@ -83,7 +100,7 @@ function identityCardOf(value: unknown): IdentityCard {
|
|
|
83
100
|
if (frond.facts !== undefined && !Array.isArray(frond.facts)) {
|
|
84
101
|
throw new Error(`Remote frond '${frond.name}' has no valid facts array`);
|
|
85
102
|
}
|
|
86
|
-
for (const
|
|
103
|
+
for (const facade of frond.facades) assertEntry('facade', frond.name, facade);
|
|
87
104
|
for (const fact of frond.facts ?? []) assertEntry('fact', frond.name, fact);
|
|
88
105
|
}
|
|
89
106
|
return card;
|
|
@@ -142,17 +159,17 @@ export default class SyncHandler {
|
|
|
142
159
|
/**
|
|
143
160
|
* What was written under each name — the barrel below is a projection of exactly this.
|
|
144
161
|
*
|
|
145
|
-
* Three combinations, and all three occur: a
|
|
146
|
-
*
|
|
162
|
+
* Three combinations, and all three occur: a facade with rows behind it (both files), a
|
|
163
|
+
* facade with none (the façade type alone), and a fact (the row class alone, because
|
|
147
164
|
* nothing calls a fact).
|
|
148
165
|
*/
|
|
149
|
-
const generated = new Map<string, { row: boolean;
|
|
166
|
+
const generated = new Map<string, { row: boolean; facade: boolean }>();
|
|
150
167
|
/** Absolute paths written by THIS run — anything else generated here is now stale. */
|
|
151
168
|
const written = new Set<string>();
|
|
152
169
|
const claim = (name: string): string => {
|
|
153
170
|
const className = entityClassName(name);
|
|
154
171
|
if (generated.has(className)) throw new Error(`Remote declares duplicate entity '${className}'`);
|
|
155
|
-
generated.set(className, { row: false,
|
|
172
|
+
generated.set(className, { row: false, facade: false });
|
|
156
173
|
return className;
|
|
157
174
|
};
|
|
158
175
|
|
|
@@ -179,16 +196,24 @@ export default class SyncHandler {
|
|
|
179
196
|
generated.get(className)!.row = true;
|
|
180
197
|
};
|
|
181
198
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
generated.get(className)!.door = true;
|
|
199
|
+
/** What the card says of each address — the same three facts a scan gives. */
|
|
200
|
+
const served: Served[] = [];
|
|
185
201
|
|
|
186
|
-
|
|
202
|
+
for (const { name, schema: descriptor, ops } of target.facades) {
|
|
203
|
+
const className = claim(name);
|
|
204
|
+
generated.get(className)!.facade = true;
|
|
205
|
+
served.push({
|
|
206
|
+
at: name,
|
|
207
|
+
handler: `import('./${handlers}/${className}Handler.js').${className}Handler`,
|
|
208
|
+
ops: (ops ?? []).map((op) => ({ name: op.name, codes: codesOf(op.errors) })),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// No shape behind this facade: its operations still travel, its rows do not exist.
|
|
187
212
|
// `rowType` falls back to `unknown`, which is the truth rather than an empty class.
|
|
188
213
|
if (descriptor !== undefined) writeRow(className, descriptor as SchemaDescriptor);
|
|
189
214
|
|
|
190
215
|
/**
|
|
191
|
-
* The
|
|
216
|
+
* The facade's type, next to the row's — what `Facade<T>` needs and what nothing
|
|
192
217
|
* carried across a repository boundary.
|
|
193
218
|
*
|
|
194
219
|
* Writing `Facade<ArticleHandler>` used to require importing the handler's class.
|
|
@@ -229,12 +254,26 @@ export default class SyncHandler {
|
|
|
229
254
|
writeRow(className, descriptor as SchemaDescriptor);
|
|
230
255
|
}
|
|
231
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The facade module, written from the CARD rather than from a scan.
|
|
259
|
+
*
|
|
260
|
+
* A frond in another repository cannot be scanned — there are no sources here. What the
|
|
261
|
+
* card carries is exactly the three facts the module needs: the addresses, the operations,
|
|
262
|
+
* and what each one refuses. The handler TYPE is the one thing it cannot carry, so the
|
|
263
|
+
* synthetic interface written above stands in its place: it names the same operations with
|
|
264
|
+
* the same answers, which is what a page reads.
|
|
265
|
+
*
|
|
266
|
+
* `facadeModule` is the compiler's own, not a copy: two spellings of one format would
|
|
267
|
+
* drift the day either gained a member.
|
|
268
|
+
*/
|
|
269
|
+
writeFileSync(join(frondDir, 'facade.ts'), facadeModule(served, `${baseUrl} — a card, not a scan`));
|
|
270
|
+
|
|
232
271
|
// Barrel index
|
|
233
272
|
// One binding carries the value AND the type, because a class is both — the pair of
|
|
234
273
|
// re-exports that stood here was the price of declaring them separately.
|
|
235
|
-
const indexLines = [...generated].flatMap(([name, { row,
|
|
274
|
+
const indexLines = [...generated].flatMap(([name, { row, facade }]) => [
|
|
236
275
|
...(row ? [`export { default as ${name} } from './${entities}/${name}.js';`] : []),
|
|
237
|
-
...(
|
|
276
|
+
...(facade ? [`export type { ${name}Handler } from './${handlers}/${name}Handler.js';`] : []),
|
|
238
277
|
]);
|
|
239
278
|
writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
|
|
240
279
|
|
|
@@ -265,7 +304,7 @@ export default class SyncHandler {
|
|
|
265
304
|
* — but the FILE stayed, and the generated `package.json` exports `'./entities/*'` as
|
|
266
305
|
* a wildcard, so `@fronds/blog/entities/Ticket.js` kept resolving to a class nothing
|
|
267
306
|
* behind it answers for. The consumer compiles, its local validator accepts, and the call
|
|
268
|
-
* comes back NOT_FOUND at the
|
|
307
|
+
* comes back NOT_FOUND at the facade — or never leaves, because the page dropped the
|
|
269
308
|
* call and kept the type.
|
|
270
309
|
*/
|
|
271
310
|
const removed = [...this.prune(entitiesDir, written), ...this.prune(handlersDir, written)];
|
|
@@ -30,6 +30,52 @@ function monorepoPackages(): string | undefined {
|
|
|
30
30
|
*/
|
|
31
31
|
const TEMPLATES = fileURLToPath(new URL('../../../templates/', import.meta.url));
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Where a HOST keeps its starter, or nothing when it ships none.
|
|
35
|
+
*
|
|
36
|
+
* The package that owns the wiring owns the files: `@fougere/nuxt` knows what a
|
|
37
|
+
* `nuxt.config.ts` must say, and a copy of it here would be that knowledge written twice —
|
|
38
|
+
* which is how `templates/apps/` came to hold one host while six were published.
|
|
39
|
+
*
|
|
40
|
+
* Resolved from the main entry and walked up to the manifest, because a host does not export
|
|
41
|
+
* its own `package.json` and has no reason to.
|
|
42
|
+
*
|
|
43
|
+
* `import.meta.resolve` and not `createRequire().resolve`: a host's `exports` states only the
|
|
44
|
+
* `import` condition, so the CJS resolver answers `ERR_PACKAGE_PATH_NOT_EXPORTED` for every
|
|
45
|
+
* one of them.
|
|
46
|
+
*/
|
|
47
|
+
function starterOf(pkg: string): string | undefined {
|
|
48
|
+
let dir: string;
|
|
49
|
+
try {
|
|
50
|
+
dir = dirname(fileURLToPath(import.meta.resolve(pkg)));
|
|
51
|
+
} catch { return undefined; }
|
|
52
|
+
|
|
53
|
+
while (!existsSync(join(dir, 'package.json')) && dir !== dirname(dir)) dir = dirname(dir);
|
|
54
|
+
const template = join(dir, 'template');
|
|
55
|
+
|
|
56
|
+
return existsSync(template) ? template : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The hosts this CLI can scaffold: its own `@fougere/*` dependencies that ship a starter.
|
|
61
|
+
*
|
|
62
|
+
* The dependency list IS the registry — nothing to declare, and a host added to the family
|
|
63
|
+
* appears here the day the CLI depends on it.
|
|
64
|
+
*/
|
|
65
|
+
function hosts(): Map<string, string> {
|
|
66
|
+
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')) as
|
|
67
|
+
{ dependencies?: Record<string, string> };
|
|
68
|
+
const found = new Map<string, string>();
|
|
69
|
+
|
|
70
|
+
for (const pkg of Object.keys(manifest.dependencies ?? {})) {
|
|
71
|
+
if (!pkg.startsWith('@fougere/')) continue;
|
|
72
|
+
const starter = starterOf(pkg);
|
|
73
|
+
if (starter) found.set(pkg.slice('@fougere/'.length), starter);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/** The version that scaffolds is the version the templates were written for. */
|
|
34
80
|
const scaffoldVersion = (): string =>
|
|
35
81
|
(JSON.parse(readFileSync(fileURLToPath(new URL('../../../package.json', import.meta.url)), 'utf8')) as
|
|
@@ -102,19 +148,31 @@ export default class ProjectWriter {
|
|
|
102
148
|
return { path: dest };
|
|
103
149
|
}
|
|
104
150
|
|
|
105
|
-
/** Add an app (consumer) under apps/<name
|
|
151
|
+
/** Add an app (consumer) under apps/<name>, from the host package that owns its wiring. */
|
|
106
152
|
addApp(wsDir: string, template: string, name: string): { path: string } {
|
|
107
153
|
const dest = join(wsDir, 'apps', name);
|
|
108
|
-
|
|
154
|
+
const starter = hosts().get(template);
|
|
155
|
+
if (!starter) throw new Error(`No host ships a starter for '${template}'. Served: ${[...hosts().keys()].join(', ')}.`);
|
|
156
|
+
|
|
157
|
+
cpSync(starter, dest, { recursive: true });
|
|
109
158
|
restoreGitignore(dest);
|
|
110
159
|
setPackageName(dest, name);
|
|
111
160
|
return { path: dest };
|
|
112
161
|
}
|
|
113
162
|
|
|
114
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* What can be scaffolded, of a kind.
|
|
165
|
+
*
|
|
166
|
+
* A FROND comes from this package: it is Fougere's own vocabulary, entities and handlers,
|
|
167
|
+
* and no other package owns it. An APP comes from its HOST — the registry is the hosts this
|
|
168
|
+
* CLI depends on, so `fougere new` offers what is published rather than what was copied here.
|
|
169
|
+
*/
|
|
115
170
|
listTemplates(kind: 'fronds' | 'apps'): string[] {
|
|
171
|
+
if (kind === 'apps') return [...hosts().keys()].sort();
|
|
172
|
+
|
|
116
173
|
const dir = join(TEMPLATES, kind);
|
|
117
174
|
if (!existsSync(dir)) return [];
|
|
175
|
+
|
|
118
176
|
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
119
177
|
}
|
|
120
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fougere/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0-alpha.0",
|
|
4
4
|
"description": "The Fougere CLI — compose a workspace, serve a frond, call an operation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fougere",
|
|
@@ -42,13 +42,20 @@
|
|
|
42
42
|
"jiti": "^2.4.2",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
44
|
"safe-regex": "^2.1.1",
|
|
45
|
-
"@fougere/
|
|
46
|
-
"@fougere/core": "0.
|
|
47
|
-
"@fougere/
|
|
48
|
-
"@fougere/
|
|
49
|
-
"@fougere/
|
|
50
|
-
"@fougere/
|
|
51
|
-
"@fougere/
|
|
45
|
+
"@fougere/adapter-sql": "0.10.0-alpha.0",
|
|
46
|
+
"@fougere/core": "0.10.0-alpha.0",
|
|
47
|
+
"@fougere/defaults": "0.10.0-alpha.0",
|
|
48
|
+
"@fougere/nuxt": "0.10.0-alpha.0",
|
|
49
|
+
"@fougere/compiler": "0.10.0-alpha.0",
|
|
50
|
+
"@fougere/container": "0.10.0-alpha.0",
|
|
51
|
+
"@fougere/svelte": "0.10.0-alpha.0",
|
|
52
|
+
"@fougere/testing": "0.10.0-alpha.0",
|
|
53
|
+
"@fougere/react": "0.10.0-alpha.0",
|
|
54
|
+
"@fougere/transport-http": "0.10.0-alpha.0",
|
|
55
|
+
"@fougere/schema": "0.10.0-alpha.0",
|
|
56
|
+
"@fougere/next": "0.10.0-alpha.0",
|
|
57
|
+
"@fougere/admin": "0.10.0-alpha.0",
|
|
58
|
+
"@fougere/oclif": "0.10.0-alpha.0"
|
|
52
59
|
},
|
|
53
60
|
"devDependencies": {
|
|
54
61
|
"vitest": "^4.1.0"
|
package/src/bridge.ts
CHANGED
|
@@ -8,23 +8,46 @@ function toKebab(name: string): string {
|
|
|
8
8
|
return name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* What a caller supplies: the fields the axes admit, or the PRIMARY when they admit nothing.
|
|
13
|
+
*
|
|
14
|
+
* `Visibility.input` answers who may WRITE a field, and a primary is the server's — right for a
|
|
15
|
+
* form, wrong for an operation whose whole input is `Product.pick('id')`. That contract asks the
|
|
16
|
+
* caller to NAME a row, and the axes leave it with no argument at all: `product:archive abc-9`
|
|
17
|
+
* answered `Unexpected argument`.
|
|
18
|
+
*
|
|
19
|
+
* Only when nothing else remains, so `create` over a full entity is untouched — its input holds
|
|
20
|
+
* twelve writable fields and the primary stays the server's.
|
|
21
|
+
*/
|
|
22
|
+
function suppliedIn(fields: Fields): Fields {
|
|
23
|
+
const written = Visibility.of(fields).input;
|
|
24
|
+
if (Object.keys(written).length > 0) return written;
|
|
25
|
+
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.entries(fields).filter(([, field]) => Role.of(field).isPrimary),
|
|
28
|
+
) as Fields;
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
/** Convert an Entity's fields into citty args definition. */
|
|
12
32
|
export function entityToArgs(fields: Fields): ArgsDef {
|
|
13
33
|
const args: ArgsDef = {};
|
|
14
34
|
let positionalIndex = 0;
|
|
15
35
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
for (const [key, field] of Object.entries(
|
|
36
|
+
// The CLI additionally skips ALL relations: a ref is not a flag — supplying related rows is
|
|
37
|
+
// not a CLI gesture.
|
|
38
|
+
for (const [key, field] of Object.entries(suppliedIn(fields))) {
|
|
19
39
|
if (Role.of(field).isRelation) continue;
|
|
20
40
|
|
|
21
41
|
// A `default(v)` travels as the create rule `{ value }` — citty shows it.
|
|
22
42
|
const defaultValue = Lifecycle.of(field).literal?.value;
|
|
23
43
|
const { base: shape, nullable } = Shapes.of(field.shape);
|
|
24
44
|
const type = Shapes.typeOf(field.shape);
|
|
45
|
+
// Naming a row is required by definition: a generator fills a primary the server writes,
|
|
46
|
+
// never one a caller hands in to designate.
|
|
47
|
+
const designates = Role.of(field).isPrimary;
|
|
25
48
|
const common = {
|
|
26
49
|
description: field.meta?.description,
|
|
27
|
-
required: !nullable && Lifecycle.of(field).requiredAtCreate,
|
|
50
|
+
required: designates || (!nullable && Lifecycle.of(field).requiredAtCreate),
|
|
28
51
|
};
|
|
29
52
|
|
|
30
53
|
// A closed set is citty's `enum`: the shape already names the legal values, so the
|
package/src/machine.ts
CHANGED
|
@@ -4,7 +4,7 @@ export function machineWanted(raw: Record<string, unknown>): boolean {
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* A `Map` serializes to `{}`, so the
|
|
7
|
+
* A `Map` serializes to `{}`, so the facade converts it rather than each command flattening its own
|
|
8
8
|
* result: `GraphResult.nodes` is a Map, and `graph --json` would have printed a report with an
|
|
9
9
|
* empty graph in it.
|
|
10
10
|
*/
|
package/src/runner.ts
CHANGED
|
@@ -22,11 +22,11 @@ function toCamel(kebab: string): string {
|
|
|
22
22
|
|
|
23
23
|
/** Scan app/commands/ for command classes. */
|
|
24
24
|
async function loadAppCommands(
|
|
25
|
-
|
|
25
|
+
root: string,
|
|
26
26
|
loader: (path: string) => Promise<Record<string, unknown>>,
|
|
27
27
|
): Promise<Map<string, new (...args: unknown[]) => { run: (raw: Record<string, unknown>) => Promise<void> }>> {
|
|
28
28
|
const map = new Map();
|
|
29
|
-
const dir = join(
|
|
29
|
+
const dir = join(root, 'app', 'commands');
|
|
30
30
|
const files = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
31
31
|
|
|
32
32
|
for (const f of files) {
|
|
@@ -42,14 +42,21 @@ async function loadAppCommands(
|
|
|
42
42
|
return map;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Every operation of an app, as a terminal command.
|
|
47
|
+
*
|
|
48
|
+
* `root` is where the PRESENTATION classes are looked for — `app/commands/`, one per command
|
|
49
|
+
* that wants to print something of its own. It defaults to this package, which is how
|
|
50
|
+
* `npx fougere` finds its fifteen; a project passes its own and gets the same treatment for
|
|
51
|
+
* the operations it declares, with no class at all where a default rendering will do.
|
|
52
|
+
*/
|
|
53
|
+
export async function run(app: App, root = new URL('..', import.meta.url).pathname): Promise<void> {
|
|
46
54
|
const terminal = ui();
|
|
47
|
-
const cliRoot = new URL('..', import.meta.url).pathname;
|
|
48
55
|
|
|
49
56
|
const { createJiti } = await import('jiti');
|
|
50
57
|
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
51
58
|
const loader = (path: string) => jiti.import(path) as Promise<Record<string, unknown>>;
|
|
52
|
-
const appCommands = await loadAppCommands(
|
|
59
|
+
const appCommands = await loadAppCommands(root, loader);
|
|
53
60
|
|
|
54
61
|
const subCommands: Record<string, ReturnType<typeof defineCommand>> = {};
|
|
55
62
|
|
|
@@ -86,7 +93,7 @@ export async function run(app: App): Promise<void> {
|
|
|
86
93
|
meta: {
|
|
87
94
|
name: cmdName,
|
|
88
95
|
// `--help` reads the operation's own doc sentence, which the scan already
|
|
89
|
-
// carries for every
|
|
96
|
+
// carries for every facade (`OperationContract.description`). A table here
|
|
90
97
|
// would be the same fact written twice, and it drifted: it described `add`
|
|
91
98
|
// and `doctor`, which do not exist, and had nothing for `call` or `serve`.
|
|
92
99
|
description: handlerEntry.operations.get('execute')?.description,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { upperFirst, type FieldDescriptor, type SchemaDescriptor } from '@fougere/schema';
|
|
2
|
-
import { docCommentOf, propertyKey } from '
|
|
2
|
+
import { docCommentOf, propertyKey } from '../syntax.js';
|
|
3
|
+
import type { EntityTypesOptions } from './EntityTypesOptions.js';
|
|
3
4
|
|
|
4
5
|
/** So a nullable field lands as a union. */
|
|
5
6
|
function typeOf(field: FieldDescriptor): string {
|
|
@@ -41,11 +42,6 @@ function objectTypeOf(properties: Record<string, FieldDescriptor>, required: rea
|
|
|
41
42
|
return `{ ${members.join('; ')} }`;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export interface EntityTypesOptions {
|
|
45
|
-
name?: string;
|
|
46
|
-
exported?: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
45
|
/** So the generated class carries its row type. */
|
|
50
46
|
function shapeTypeOf(descriptor: SchemaDescriptor, indent = ''): string {
|
|
51
47
|
const entries = Object.entries(descriptor.properties ?? {});
|
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
export interface FacadeTypesOptions {
|
|
5
|
-
name?: string;
|
|
6
|
-
exported?: boolean;
|
|
7
|
-
rowType?: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface OpDescriptor {
|
|
11
|
-
name: string;
|
|
12
|
-
description?: string;
|
|
13
|
-
output?: SchemaDescriptor;
|
|
14
|
-
cardinality?: 'one' | 'maybe' | 'many' | 'page' | 'none';
|
|
15
|
-
}
|
|
1
|
+
import { docCommentOf, propertyKey } from '../syntax.js';
|
|
2
|
+
import type { FacadeTypesOptions } from './FacadeTypesOptions.js';
|
|
3
|
+
import type { OpDescriptor } from './OpDescriptor.js';
|
|
16
4
|
|
|
17
5
|
/** So a consumer sees the cardinality in the type, not in a doc line. */
|
|
18
6
|
function returnTypeOf(op: OpDescriptor, rowType: string): string {
|
|
@@ -7,6 +7,6 @@ export default class Post extends entity({
|
|
|
7
7
|
createdAt: created(),
|
|
8
8
|
// Server-owned: a post is born a draft and flipped by the publish
|
|
9
9
|
// operation, never by a client writing the field. readOnly closes
|
|
10
|
-
// the inbound
|
|
10
|
+
// the inbound facade — the field is projected out, never accepted in.
|
|
11
11
|
status: readOnly(oneOf('draft', 'published', { default: 'draft' })),
|
|
12
12
|
}) {}
|
package/templates/flat/CLAUDE.md
CHANGED
|
@@ -25,11 +25,11 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
|
-
## A surface is a
|
|
28
|
+
## A surface is a facade, never a logic
|
|
29
29
|
|
|
30
|
-
Every
|
|
30
|
+
Every facade goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second
|
|
32
|
+
is a second facade with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|
|
@@ -25,11 +25,11 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
|
-
## A surface is a
|
|
28
|
+
## A surface is a facade, never a logic
|
|
29
29
|
|
|
30
|
-
Every
|
|
30
|
+
Every facade goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second
|
|
32
|
+
is a second facade with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|
|
@@ -25,11 +25,11 @@ Two consequences worth stating, because they are what makes it hold:
|
|
|
25
25
|
|
|
26
26
|
If you are about to write the same constraint in two places, you have missed the derivation.
|
|
27
27
|
|
|
28
|
-
## A surface is a
|
|
28
|
+
## A surface is a facade, never a logic
|
|
29
29
|
|
|
30
|
-
Every
|
|
30
|
+
Every facade goes through the handler **façade**, which is where validation sits: unknown-key refusal,
|
|
31
31
|
collectors. A resolver or route you wire yourself against the storage — or worse, against the database —
|
|
32
|
-
is a second
|
|
32
|
+
is a second facade with no validator behind it, and the rules declared in the entities stop applying there.
|
|
33
33
|
|
|
34
34
|
Before adding a surface, reach for its **projection**:
|
|
35
35
|
|