@fougere/cli 0.3.0-alpha.0 → 0.4.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/CallCommand.ts +3 -3
- package/app/commands/ExplainCommand.ts +51 -4
- package/app/commands/FreezeCommand.ts +1 -1
- package/app/commands/KeysCommand.ts +1 -1
- 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/runner.d.ts.map +1 -1
- package/dist/runner.js +4 -3
- package/dist/runner.js.map +1 -1
- package/fronds/analysis/entities/Explain.ts +2 -1
- package/fronds/analysis/handlers/ExplainHandler.ts +52 -12
- package/fronds/analysis/handlers/FreezeHandler.ts +15 -11
- package/fronds/analysis/handlers/MigrateHandler.ts +2 -2
- 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 +15 -15
- 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/runner.ts +139 -0
- package/src/theme.ts +19 -0
- package/src/ui.ts +131 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from '@fougere/core';
|
|
6
6
|
import { relative } from 'node:path';
|
|
7
7
|
import ProjectScan from '../services/ProjectScan.js';
|
|
8
|
+
import { ANONYMOUS_SCHEMA_NAME, lowerFirst, type SchemaView } from '@fougere/schema';
|
|
8
9
|
|
|
9
10
|
type Cardinality = NonNullable<OperationContract['cardinality']>;
|
|
10
11
|
type Binding = EffectiveOperation['binding'][number];
|
|
@@ -60,6 +61,17 @@ export interface ExplainResult {
|
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** What this project serves, when no single operation was named. */
|
|
65
|
+
export interface ExplainListing {
|
|
66
|
+
fronds: {
|
|
67
|
+
name: string;
|
|
68
|
+
runtime: 'local' | 'remote';
|
|
69
|
+
remote: string | null;
|
|
70
|
+
operations: number;
|
|
71
|
+
}[];
|
|
72
|
+
operations: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
interface Selector {
|
|
64
76
|
frond?: string;
|
|
65
77
|
surface?: string;
|
|
@@ -71,17 +83,46 @@ interface Selector {
|
|
|
71
83
|
export default class ExplainHandler {
|
|
72
84
|
constructor(private projectScan: ProjectScan) {}
|
|
73
85
|
|
|
86
|
+
/** The names this project serves — the list `explain` used to spell in a refusal only. */
|
|
87
|
+
async list(input: { root?: string }): Promise<ExplainListing> {
|
|
88
|
+
const { scan, model } = await this.modelOf(input.root);
|
|
89
|
+
const remotes = scan.config.remotes ?? {};
|
|
90
|
+
const counted = new Map<string, number>();
|
|
91
|
+
for (const operation of model.operations) {
|
|
92
|
+
const frond = operation.placement.frond;
|
|
93
|
+
counted.set(frond, (counted.get(frond) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
fronds: scan.fronds.map((frond) => ({
|
|
98
|
+
name: frond.name,
|
|
99
|
+
runtime: remotes[frond.name] ? 'remote' as const : 'local' as const,
|
|
100
|
+
remote: remotes[frond.name] ?? null,
|
|
101
|
+
operations: counted.get(frond.name) ?? 0,
|
|
102
|
+
})).sort((a, b) => a.name.localeCompare(b.name)),
|
|
103
|
+
operations: model.operations.map((operation) => operation.id).sort(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async modelOf(root?: string) {
|
|
108
|
+
const scan = await this.projectScan.at(root);
|
|
109
|
+
return {
|
|
110
|
+
scan,
|
|
111
|
+
model: resolveEffectiveOperations(scan.fronds, {
|
|
112
|
+
diagnostics: scan.diagnostics,
|
|
113
|
+
remotes: scan.config.remotes,
|
|
114
|
+
adapters: scan.config.adapters,
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Print the contract one operation will be served under. */
|
|
74
120
|
async execute(input: { operation?: string; root?: string; json?: boolean }): Promise<ExplainResult> {
|
|
75
121
|
const requested = input.operation?.trim();
|
|
76
122
|
if (!requested) throw new Error('Usage: fougere explain <Operation> [--json] [--root <directory>]');
|
|
77
123
|
|
|
78
124
|
const selector = parseSelector(requested);
|
|
79
|
-
const scan = await this.
|
|
80
|
-
const model = resolveEffectiveOperations(scan.fronds, {
|
|
81
|
-
diagnostics: scan.diagnostics,
|
|
82
|
-
remotes: scan.config.remotes,
|
|
83
|
-
adapters: scan.config.adapters,
|
|
84
|
-
});
|
|
125
|
+
const { scan, model } = await this.modelOf(input.root);
|
|
85
126
|
const candidates = model.operations.filter((operation) => matches(operation, selector));
|
|
86
127
|
|
|
87
128
|
if (candidates.length === 0) {
|
|
@@ -184,7 +225,7 @@ function matches(operation: EffectiveOperation, selector: Selector): boolean {
|
|
|
184
225
|
|
|
185
226
|
function addressOf(value: string): string {
|
|
186
227
|
const base = value.endsWith('Handler') ? value.slice(0, -'Handler'.length) : value;
|
|
187
|
-
return
|
|
228
|
+
return lowerFirst(base);
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
function inputTypeOf(operation: EffectiveOperation): string | null {
|
|
@@ -197,11 +238,10 @@ function outputOf(operation: EffectiveOperation): ExplainResult['output'] {
|
|
|
197
238
|
return type ? { type, cardinality: operation.cardinality ?? null } : null;
|
|
198
239
|
}
|
|
199
240
|
|
|
200
|
-
function schemaName(schema:
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
return view.source?.name;
|
|
241
|
+
function schemaName(schema: SchemaView | undefined): string | undefined {
|
|
242
|
+
if (!schema) return undefined;
|
|
243
|
+
if (schema.name && schema.name !== ANONYMOUS_SCHEMA_NAME) return schema.name;
|
|
244
|
+
return schema.derivation?.sourceName;
|
|
205
245
|
}
|
|
206
246
|
|
|
207
247
|
function parsedOutput(raw: string | undefined): string | undefined {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { Bundle, lowerFirst, type SchemaBundle, type SchemaView, type SetDiff } from '@fougere/schema';
|
|
4
4
|
import ProjectScan from '../services/ProjectScan.js';
|
|
5
5
|
import { VERSIONS, chainOf } from '../versions.js';
|
|
6
6
|
import type Freeze from '../entities/Freeze.js';
|
|
@@ -13,7 +13,7 @@ export interface FreezeInspection {
|
|
|
13
13
|
/** What the step contains — absent when there is nothing before to step from. */
|
|
14
14
|
step?: SetDiff;
|
|
15
15
|
/** Per entity, the pairs the calculation refuses to decide. Empty means it was written. */
|
|
16
|
-
ambiguous: Record<string,
|
|
16
|
+
ambiguous: Record<string, { removed: string; added: string }[]>;
|
|
17
17
|
/** Whether anything reached the disk — false while a question stands. */
|
|
18
18
|
written: boolean;
|
|
19
19
|
}
|
|
@@ -54,7 +54,9 @@ export default class FreezeHandler {
|
|
|
54
54
|
path,
|
|
55
55
|
bundle,
|
|
56
56
|
previous,
|
|
57
|
-
step: previous
|
|
57
|
+
step: previous
|
|
58
|
+
? Bundle.fromDescriptor(previous.bundle).diff(Bundle.fromDescriptor(bundle), { renamed })
|
|
59
|
+
: undefined,
|
|
58
60
|
}));
|
|
59
61
|
|
|
60
62
|
const ambiguous: FreezeInspection['ambiguous'] = {};
|
|
@@ -102,7 +104,9 @@ export default class FreezeHandler {
|
|
|
102
104
|
.filter((frond) => frond.entities.length > 0)
|
|
103
105
|
.map(async (frond) => ({
|
|
104
106
|
path: frond.source.path,
|
|
105
|
-
bundle:
|
|
107
|
+
bundle: Bundle.fromSchemas(
|
|
108
|
+
Object.fromEntries(frond.entities.map((e) => [e.name, e.entityClass])),
|
|
109
|
+
).descriptor,
|
|
106
110
|
declared: declaredRenames(frond.entities),
|
|
107
111
|
previous: await previousOf(frond.source.path, input.version),
|
|
108
112
|
})),
|
|
@@ -114,17 +118,17 @@ type Inspected = { previous?: { name: string }; step?: SetDiff };
|
|
|
114
118
|
|
|
115
119
|
/**
|
|
116
120
|
* What the entities state about themselves — `previous` says what a field WAS, while
|
|
117
|
-
* `diff` reads old
|
|
121
|
+
* `Bundle.diff` reads old to new, so the pair is turned around here and nowhere else.
|
|
118
122
|
*/
|
|
119
123
|
function declaredRenames(
|
|
120
|
-
entities:
|
|
124
|
+
entities: readonly { name: string; entityClass: unknown }[],
|
|
121
125
|
): Record<string, Record<string, string>> {
|
|
122
126
|
const out: Record<string, Record<string, string>> = {};
|
|
123
127
|
for (const { name, entityClass } of entities) {
|
|
124
|
-
const previous = (entityClass as
|
|
125
|
-
// Keyed as `
|
|
128
|
+
const previous = (entityClass as SchemaView).previous;
|
|
129
|
+
// Keyed as `Bundle.fromSchemas` keys `$defs`, which is what `Bundle.diff` reads. Spelling the
|
|
126
130
|
// convention a second way here is the defect this repo has already recorded twice.
|
|
127
|
-
const key =
|
|
131
|
+
const key = lowerFirst(name);
|
|
128
132
|
if (previous) out[key] = Object.fromEntries(Object.entries(previous).map(([now, was]) => [was, now]));
|
|
129
133
|
}
|
|
130
134
|
return out;
|
|
@@ -132,12 +136,12 @@ function declaredRenames(
|
|
|
132
136
|
|
|
133
137
|
/** Every source of an answer, folded per entity — later sources win field by field. */
|
|
134
138
|
function settled(
|
|
135
|
-
sources:
|
|
139
|
+
sources: readonly Record<string, Record<string, string>>[],
|
|
136
140
|
answers: Record<string, Record<string, string>>,
|
|
137
141
|
): Record<string, Record<string, string>> {
|
|
138
142
|
const out: Record<string, Record<string, string>> = {};
|
|
139
143
|
for (const source of [...sources, answers]) {
|
|
140
|
-
for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...
|
|
144
|
+
for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...out[entity], ...pairs };
|
|
141
145
|
}
|
|
142
146
|
return out;
|
|
143
147
|
}
|
|
@@ -77,7 +77,7 @@ export default class MigrateHandler {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** The versions read, oldest first and each named once however many fronds cut it. */
|
|
80
|
-
function versionsOf(steps:
|
|
80
|
+
function versionsOf(steps: readonly { version: string }[]): string[] {
|
|
81
81
|
return [...new Set(steps.map(({ version }) => version))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -90,7 +90,7 @@ function onSource(step: SetDiff, source: string, sourceOf: (entity: string) => s
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/** Every recorded step, oldest first — the chain composes, so it is replayed whole. */
|
|
93
|
-
async function stepsOf(frondPath: string): Promise<
|
|
93
|
+
async function stepsOf(frondPath: string): Promise<{ version: string; step: SetDiff }[]> {
|
|
94
94
|
const chain = await chainOf(frondPath);
|
|
95
95
|
// The first version has a shape and no step — there was nothing before it to move from.
|
|
96
96
|
return chain.flatMap(({ name, step }) => (step ? [{ version: name, step }] : []));
|
|
@@ -2,5 +2,5 @@ import { entity, text } from "@fougere/schema";
|
|
|
2
2
|
|
|
3
3
|
/** `fougere call <entity>.<op> [--field value …]` — invoke one operation, print the result. */
|
|
4
4
|
export default class Call extends entity({
|
|
5
|
-
|
|
5
|
+
operation: text({ min: 1, description: "entity.op to invoke (e.g. post.create)" }),
|
|
6
6
|
}) {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { entity, text } from '@fougere/schema';
|
|
2
2
|
|
|
3
3
|
export default class Sync extends entity({
|
|
4
|
-
|
|
4
|
+
frond: text({ description: 'Frond name to sync (e.g. blog)' }),
|
|
5
5
|
from: text({ description: 'Remote URL (e.g. http://blog-service:3000)' }),
|
|
6
6
|
}) {}
|
|
@@ -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`,
|
|
@@ -252,10 +252,10 @@ export default class SyncHandler {
|
|
|
252
252
|
}, null, 2) + '\n');
|
|
253
253
|
|
|
254
254
|
// Update .fougere/remotes.json — central registry of synced remotes
|
|
255
|
-
this.updateRemotesRegistry(input.
|
|
255
|
+
this.updateRemotesRegistry(input.frond, baseUrl, frondDir);
|
|
256
256
|
|
|
257
257
|
// Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
|
|
258
|
-
this.updateTsconfigPaths(input.
|
|
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.4.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.4.0-alpha.0",
|
|
45
|
+
"@fougere/defaults": "0.4.0-alpha.0",
|
|
46
|
+
"@fougere/transport-http": "0.4.0-alpha.0",
|
|
47
|
+
"@fougere/core": "0.4.0-alpha.0",
|
|
48
|
+
"@fougere/schema": "0.4.0-alpha.0",
|
|
49
|
+
"@fougere/container": "0.4.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
|
+
}
|