@fougere/cli 0.2.0-alpha.2 → 0.3.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 +10 -1
- package/app/commands/BuildCommand.ts +39 -0
- package/app/commands/CallCommand.ts +2 -2
- package/app/commands/CheckCommand.ts +2 -1
- package/app/commands/ExplainCommand.ts +77 -0
- package/app/commands/FreezeCommand.ts +107 -0
- package/app/commands/GrantCommand.ts +44 -0
- package/app/commands/KeysCommand.ts +56 -0
- package/app/commands/MigrateCommand.ts +54 -0
- package/app/commands/NewCommand.ts +5 -5
- package/app/commands/ServeCommand.ts +83 -7
- package/app/commands/grant-material.ts +5 -0
- package/dist/bin.js +53 -7
- package/dist/bin.js.map +1 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +4 -4
- package/dist/bridge.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +6 -6
- package/dist/runner.js.map +1 -1
- package/fronds/analysis/entities/Build.ts +7 -0
- package/fronds/analysis/entities/Explain.ts +8 -0
- package/fronds/analysis/entities/Freeze.ts +7 -0
- package/fronds/analysis/entities/Migrate.ts +7 -0
- package/fronds/analysis/handlers/BuildHandler.ts +60 -0
- package/fronds/analysis/handlers/CheckHandler.ts +40 -41
- package/fronds/analysis/handlers/ExplainHandler.ts +214 -0
- package/fronds/analysis/handlers/FreezeHandler.ts +172 -0
- package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
- package/fronds/analysis/services/ProjectScan.ts +23 -7
- package/fronds/analysis/versions.ts +58 -0
- package/fronds/scaffold/entities/Grant.ts +6 -0
- package/fronds/scaffold/entities/Keys.ts +4 -0
- package/fronds/scaffold/entities/Serve.ts +2 -1
- package/fronds/scaffold/handlers/BuildFrondHandler.ts +11 -13
- package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
- package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
- package/fronds/scaffold/handlers/SyncHandler.ts +27 -24
- package/fronds/scaffold/services/ProjectWriter.ts +9 -8
- package/package.json +8 -7
- package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
- package/templates/admin/fronds/admin/package.json +1 -1
- package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
- package/templates/api/fronds/api/package.json +1 -1
- package/templates/apps/nuxt/app/pages/index.vue +1 -1
- package/templates/blog/app/pages/posts/index.vue +1 -1
- package/templates/blog/app/pages/posts/manage.vue +1 -1
- package/templates/blog/app/pages/posts/new.vue +1 -1
- package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
- package/templates/blog/fronds/blog/package.json +1 -1
- package/templates/flat/AGENTS.md +14 -0
- package/templates/flat/CLAUDE.md +25 -3
- package/templates/frond/AGENTS.md +14 -0
- package/templates/frond/CLAUDE.md +25 -3
- package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
- package/templates/frond/fronds/__name__/package.json +1 -1
- package/templates/frond/serve.mjs +3 -2
- package/templates/fronds/blank/package.json +1 -1
- package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
- package/templates/fronds/blog/package.json +1 -1
- package/templates/workspace/AGENTS.md +14 -0
- package/templates/workspace/CLAUDE.md +25 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describeSet, diffSet, registrationKeyOf, type SchemaBundle, type SetDiff } from '@fougere/schema';
|
|
4
|
+
import ProjectScan from '../services/ProjectScan.js';
|
|
5
|
+
import { VERSIONS, chainOf } from '../versions.js';
|
|
6
|
+
import type Freeze from '../entities/Freeze.js';
|
|
7
|
+
|
|
8
|
+
export interface FreezeInspection {
|
|
9
|
+
version: string;
|
|
10
|
+
/** The version this one follows, or absent when it is the first. */
|
|
11
|
+
previous?: string;
|
|
12
|
+
entities: string[];
|
|
13
|
+
/** What the step contains — absent when there is nothing before to step from. */
|
|
14
|
+
step?: SetDiff;
|
|
15
|
+
/** Per entity, the pairs the calculation refuses to decide. Empty means it was written. */
|
|
16
|
+
ambiguous: Record<string, Array<{ removed: string; added: string }>>;
|
|
17
|
+
/** Whether anything reached the disk — false while a question stands. */
|
|
18
|
+
written: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Freezing a version — the artefact three readers share.
|
|
23
|
+
*
|
|
24
|
+
* A snapshot alone loses the INTENT (a field gone plus a field appeared cannot be told
|
|
25
|
+
* from a rename), and a step alone corrupts in silence. Both are written, and replaying
|
|
26
|
+
* the step over the previous snapshot must reproduce this one — which is what catches a
|
|
27
|
+
* missing step or a hand-edited file.
|
|
28
|
+
*
|
|
29
|
+
* Nothing is written while an ambiguity stands: the only information the code does not
|
|
30
|
+
* hold is what the person who made the change meant, and this is the one place asking
|
|
31
|
+
* for it is justified.
|
|
32
|
+
*/
|
|
33
|
+
export default class FreezeHandler {
|
|
34
|
+
constructor(private projectScan: ProjectScan) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Record this version — or report what stops it, having written nothing.
|
|
38
|
+
*
|
|
39
|
+
* One op and not two: it is idempotent while it refuses, so a caller settles the
|
|
40
|
+
* ambiguities and calls again with `renamed`. Splitting it would let a caller write
|
|
41
|
+
* a version it never inspected.
|
|
42
|
+
*/
|
|
43
|
+
async execute(input: Freeze & { renamed?: Record<string, Record<string, string>> }): Promise<FreezeInspection> {
|
|
44
|
+
const fronds = await this.read(input);
|
|
45
|
+
const version = input.version;
|
|
46
|
+
const entities = fronds.flatMap(({ bundle }) => Object.keys(bundle.$defs ?? {}));
|
|
47
|
+
// Two entrances, one map: what the entities declare, and what a caller answered.
|
|
48
|
+
// The answer wins — it is the later word on a question the declaration left open.
|
|
49
|
+
const renamed = settled(fronds.map(({ declared }) => declared), input.renamed ?? {});
|
|
50
|
+
|
|
51
|
+
// Every frond is inspected before ANY of them writes: a question standing in one
|
|
52
|
+
// frond must not leave the others recorded, or a second run cuts half a version.
|
|
53
|
+
const inspected = fronds.map(({ path, bundle, previous }) => ({
|
|
54
|
+
path,
|
|
55
|
+
bundle,
|
|
56
|
+
previous,
|
|
57
|
+
step: previous ? diffSet(previous.bundle, bundle, { renamed }) : undefined,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const ambiguous: FreezeInspection['ambiguous'] = {};
|
|
61
|
+
for (const { step } of inspected) {
|
|
62
|
+
for (const [name, answer] of Object.entries(step?.entities ?? {})) {
|
|
63
|
+
if (answer.ambiguous.length > 0) ambiguous[name] = answer.ambiguous;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Nothing on disk while a question stands. The only information the code does not
|
|
67
|
+
// hold is what the person who made the change meant.
|
|
68
|
+
if (Object.keys(ambiguous).length > 0) {
|
|
69
|
+
return { version, previous: previousName(inspected), entities, step: merge(inspected), ambiguous, written: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const { path, bundle, previous, step } of inspected) {
|
|
73
|
+
await this.record(path, version, bundle);
|
|
74
|
+
if (!previous || !step) continue;
|
|
75
|
+
// `previous` is recorded rather than re-derived: the chain is a fact of the moment
|
|
76
|
+
// this version was cut, and a later sort of directory names is not that fact.
|
|
77
|
+
await writeFile(
|
|
78
|
+
join(path, VERSIONS, version, 'from.json'),
|
|
79
|
+
`${JSON.stringify({ previous: previous.name, renamed, ...step }, null, 2)}\n`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { version, previous: previousName(inspected), entities, step: merge(inspected), ambiguous: {}, written: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async record(root: string, version: string, bundle: SchemaBundle): Promise<void> {
|
|
87
|
+
const directory = join(root, VERSIONS, version);
|
|
88
|
+
await mkdir(directory, { recursive: true });
|
|
89
|
+
await writeFile(join(directory, 'shape.json'), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Today's shapes and the version before them, one entry per frond.
|
|
94
|
+
*
|
|
95
|
+
* `Fronds.schemas()` is deliberately flat — a fact heard in one frond is declared in
|
|
96
|
+
* another — so the per-frond map is built here, where the question IS per frond.
|
|
97
|
+
*/
|
|
98
|
+
private async read(input: Freeze) {
|
|
99
|
+
const scan = await this.projectScan.at(input.root ?? undefined);
|
|
100
|
+
return Promise.all(
|
|
101
|
+
scan.fronds
|
|
102
|
+
.filter((frond) => frond.entities.length > 0)
|
|
103
|
+
.map(async (frond) => ({
|
|
104
|
+
path: frond.source.path,
|
|
105
|
+
bundle: describeSet(Object.fromEntries(frond.entities.map((e) => [e.name, e.entityClass]))),
|
|
106
|
+
declared: declaredRenames(frond.entities),
|
|
107
|
+
previous: await previousOf(frond.source.path, input.version),
|
|
108
|
+
})),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Inspected = { previous?: { name: string }; step?: SetDiff };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What the entities state about themselves — `previous` says what a field WAS, while
|
|
117
|
+
* `diff` reads old → new, so the pair is turned around here and nowhere else.
|
|
118
|
+
*/
|
|
119
|
+
function declaredRenames(
|
|
120
|
+
entities: ReadonlyArray<{ name: string; entityClass: unknown }>,
|
|
121
|
+
): Record<string, Record<string, string>> {
|
|
122
|
+
const out: Record<string, Record<string, string>> = {};
|
|
123
|
+
for (const { name, entityClass } of entities) {
|
|
124
|
+
const previous = (entityClass as { previous?: Record<string, string> }).previous;
|
|
125
|
+
// Keyed as `describeSet` keys `$defs`, which is what `diffSet` reads. Spelling the
|
|
126
|
+
// convention a second way here is the defect this repo has already recorded twice.
|
|
127
|
+
const key = registrationKeyOf(name);
|
|
128
|
+
if (previous) out[key] = Object.fromEntries(Object.entries(previous).map(([now, was]) => [was, now]));
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every source of an answer, folded per entity — later sources win field by field. */
|
|
134
|
+
function settled(
|
|
135
|
+
sources: ReadonlyArray<Record<string, Record<string, string>>>,
|
|
136
|
+
answers: Record<string, Record<string, string>>,
|
|
137
|
+
): Record<string, Record<string, string>> {
|
|
138
|
+
const out: Record<string, Record<string, string>> = {};
|
|
139
|
+
for (const source of [...sources, answers]) {
|
|
140
|
+
for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...(out[entity] ?? {}), ...pairs };
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The version every frond steps from. They are cut together, so they agree. */
|
|
146
|
+
function previousName(inspected: readonly Inspected[]): string | undefined {
|
|
147
|
+
return inspected.find(({ previous }) => previous)?.previous?.name;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One report out of several fronds — entity names are unique across a scan, so the
|
|
152
|
+
* union loses nothing. Writing stays per frond; only the telling is gathered.
|
|
153
|
+
*/
|
|
154
|
+
function merge(inspected: readonly Inspected[]): SetDiff | undefined {
|
|
155
|
+
const steps = inspected.map(({ step }) => step).filter((step): step is SetDiff => Boolean(step));
|
|
156
|
+
if (steps.length === 0) return undefined;
|
|
157
|
+
return {
|
|
158
|
+
entities: Object.assign({}, ...steps.map((step) => step.entities)),
|
|
159
|
+
entitiesAdded: steps.flatMap((step) => step.entitiesAdded),
|
|
160
|
+
entitiesRemoved: steps.flatMap((step) => step.entitiesRemoved),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The version this one steps from: the tip of the chain, the links read rather than sorted. */
|
|
165
|
+
async function previousOf(root: string, version: string): Promise<{ name: string; bundle: SchemaBundle } | undefined> {
|
|
166
|
+
const chain = (await chainOf(root)).filter((cut) => cut.name !== version);
|
|
167
|
+
const last = chain.at(-1)?.name;
|
|
168
|
+
if (!last) return undefined;
|
|
169
|
+
|
|
170
|
+
const raw = await readFile(join(root, VERSIONS, last, 'shape.json'), 'utf8').catch(() => undefined);
|
|
171
|
+
return raw ? { name: last, bundle: JSON.parse(raw) as SchemaBundle } : undefined;
|
|
172
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { loadConfig } from '@fougere/core/node';
|
|
2
|
+
import { resolveStorage } from '@fougere/defaults';
|
|
3
|
+
import { actualState, desiredTables, planStep, collapseChain, applyStep, type Plan, type StepChange } from '@fougere/adapter-sql';
|
|
4
|
+
import type { SetDiff } from '@fougere/schema';
|
|
5
|
+
import ProjectScan from '../services/ProjectScan.js';
|
|
6
|
+
import { chainOf } from '../versions.js';
|
|
7
|
+
import type Migrate from '../entities/Migrate.js';
|
|
8
|
+
|
|
9
|
+
export interface MigrationPlan {
|
|
10
|
+
/** Versions whose step was read, oldest first. */
|
|
11
|
+
chain: string[];
|
|
12
|
+
changes: StepChange[];
|
|
13
|
+
refusals: Plan['refusals'];
|
|
14
|
+
/** The statements actually run — empty unless `apply` was asked for. */
|
|
15
|
+
ran: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Catching the database up with the frozen chain.
|
|
20
|
+
*
|
|
21
|
+
* Every step is replayed in order and what has already happened is SKIPPED, read off the
|
|
22
|
+
* columns themselves rather than a ledger of applied migrations. That is what makes the
|
|
23
|
+
* chain safe to replay whole: nothing here has to know which version the database sits
|
|
24
|
+
* at, and a column renamed by hand is seen rather than contradicted.
|
|
25
|
+
*
|
|
26
|
+
* The additive pass is not repeated here — a boot already creates missing tables and
|
|
27
|
+
* columns. What this adds is the half that touches live data, and only what a human
|
|
28
|
+
* declared at `fougere freeze`.
|
|
29
|
+
*/
|
|
30
|
+
export default class MigrateHandler {
|
|
31
|
+
constructor(private projectScan: ProjectScan) {}
|
|
32
|
+
|
|
33
|
+
/** Realise the frozen steps this database has not caught up with. */
|
|
34
|
+
async execute(input: Migrate): Promise<MigrationPlan> {
|
|
35
|
+
const scan = await this.projectScan.at(input.root ?? undefined);
|
|
36
|
+
const perFrond = await Promise.all(scan.fronds.map((frond) => stepsOf(frond.source.path)));
|
|
37
|
+
const steps = perFrond.flat();
|
|
38
|
+
if (steps.length === 0) return { chain: [], changes: [], refusals: [], ran: [] };
|
|
39
|
+
|
|
40
|
+
const config = await loadConfig(scan.root);
|
|
41
|
+
const storage = resolveStorage(config.db ?? {});
|
|
42
|
+
if (!storage.db) {
|
|
43
|
+
return {
|
|
44
|
+
chain: versionsOf(steps),
|
|
45
|
+
changes: [],
|
|
46
|
+
refusals: [{ entity: '*', field: '*', reason: 'no `db` in fougere.config.ts — nothing to migrate' }],
|
|
47
|
+
ran: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tables = desiredTables(scan as never);
|
|
52
|
+
// Each frond's chain is composed on its own — its versions are its own line — and the
|
|
53
|
+
// results are gathered by SOURCE, because an engine is what a statement runs against.
|
|
54
|
+
const composed = collapseChain(perFrond.map((chain) => collapseChain(chain.map(({ step }) => step))));
|
|
55
|
+
const sourceOf = storage.sourceOf ?? (() => 'db');
|
|
56
|
+
|
|
57
|
+
const changes: StepChange[] = [];
|
|
58
|
+
const refusals: Plan['refusals'] = [];
|
|
59
|
+
const ran: string[] = [];
|
|
60
|
+
for (const source of storage.sources?.() ?? ['db']) {
|
|
61
|
+
const db = (source === 'db' ? storage.db : storage.dbOf?.(source)) as Parameters<typeof actualState>[0];
|
|
62
|
+
if (!db) continue;
|
|
63
|
+
|
|
64
|
+
const mine = onSource(composed, source, sourceOf);
|
|
65
|
+
if (Object.keys(mine.entities).length === 0) continue;
|
|
66
|
+
|
|
67
|
+
const plan = planStep(mine, tables, { actual: await actualState(db) });
|
|
68
|
+
changes.push(...plan.changes);
|
|
69
|
+
refusals.push(...plan.refusals);
|
|
70
|
+
// Held back: a refusal anywhere stops every engine, for the reason it stops every
|
|
71
|
+
// statement — half a chain is worse across two engines than within one.
|
|
72
|
+
if (input.apply && refusals.length === 0) ran.push(...(await applyStep(plan, db)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { chain: versionsOf(steps), changes, refusals, ran: refusals.length > 0 ? [] : ran };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The versions read, oldest first and each named once however many fronds cut it. */
|
|
80
|
+
function versionsOf(steps: ReadonlyArray<{ version: string }>): string[] {
|
|
81
|
+
return [...new Set(steps.map(({ version }) => version))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The part of a step whose entities live on one engine. */
|
|
85
|
+
function onSource(step: SetDiff, source: string, sourceOf: (entity: string) => string): SetDiff {
|
|
86
|
+
return {
|
|
87
|
+
...step,
|
|
88
|
+
entities: Object.fromEntries(Object.entries(step.entities).filter(([entity]) => sourceOf(entity) === source)),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every recorded step, oldest first — the chain composes, so it is replayed whole. */
|
|
93
|
+
async function stepsOf(frondPath: string): Promise<Array<{ version: string; step: SetDiff }>> {
|
|
94
|
+
const chain = await chainOf(frondPath);
|
|
95
|
+
// The first version has a shape and no step — there was nothing before it to move from.
|
|
96
|
+
return chain.flatMap(({ name, step }) => (step ? [{ version: name, step }] : []));
|
|
97
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type FougereConfig, type ScanResult } from '@fougere/core';
|
|
2
|
+
import {
|
|
3
|
+
scanProject, frondAliases, setModuleLoader, loadConfig, resolveConventions,
|
|
4
|
+
} from '@fougere/core/node';
|
|
2
5
|
import { resolve } from 'node:path';
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -18,18 +21,31 @@ export default class ProjectScan {
|
|
|
18
21
|
private cwd = process.cwd();
|
|
19
22
|
|
|
20
23
|
/** Scan the project at `root`, relative to where the command was invoked. */
|
|
21
|
-
async at(root?: string): Promise<ScanResult & { root: string }> {
|
|
24
|
+
async at(root?: string): Promise<ScanResult & { root: string; config: FougereConfig }> {
|
|
22
25
|
const target = resolve(this.cwd, root || '.');
|
|
23
26
|
|
|
24
27
|
// The scan reads `.ts` sources; the default loader is a plain `import`, which
|
|
25
28
|
// cannot. Installed once per call because the loader is module-global — the
|
|
26
29
|
// CLI's own app was loaded with its own, and this replaces it for the target.
|
|
27
30
|
const { createJiti } = await import('jiti');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const install = (alias?: Record<string, string>): void => {
|
|
32
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true, ...(alias ? { alias } : {}) });
|
|
33
|
+
setModuleLoader((filePath) => jiti.import(filePath) as Promise<Record<string, unknown>>);
|
|
34
|
+
};
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The config is read BEFORE the aliases, because it names the scope they are built
|
|
38
|
+
* from. Safe because nothing in `fougere.config.ts` may import `@fronds/*` — the one
|
|
39
|
+
* import that would need the name to read the file that declares it.
|
|
40
|
+
*/
|
|
41
|
+
install();
|
|
42
|
+
const config = await loadConfig(target);
|
|
43
|
+
const conventions = resolveConventions(config.conventions);
|
|
44
|
+
|
|
45
|
+
// The scope is the framework's own convention; the loader has to know it, or a frond
|
|
46
|
+
// naming its neighbour is unreadable to the very tool that checks it.
|
|
47
|
+
install(await frondAliases(target, conventions));
|
|
48
|
+
|
|
49
|
+
return { root: target, config, ...(await scanProject(target, undefined, conventions)) };
|
|
34
50
|
}
|
|
35
51
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { SetDiff } from '@fougere/schema';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a FROND keeps what its shapes used to be — beside `entities/`, not under a dot.
|
|
7
|
+
* Written by `fougere freeze`, replayed by `fougere migrate`.
|
|
8
|
+
*/
|
|
9
|
+
export const VERSIONS = 'versions';
|
|
10
|
+
|
|
11
|
+
/** One cut version: its name, and the step that reached it — absent on the first. */
|
|
12
|
+
export interface Version {
|
|
13
|
+
name: string;
|
|
14
|
+
step?: SetDiff & { previous?: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The versions a frond has cut, oldest first — read by FOLLOWING each step's `previous`.
|
|
19
|
+
*
|
|
20
|
+
* That link is the fact recorded the day the version was cut. Sorting directory names is
|
|
21
|
+
* a guess about the same fact, and a hotfix cut after a later version orders it wrong.
|
|
22
|
+
*/
|
|
23
|
+
export async function chainOf(frondPath: string): Promise<Version[]> {
|
|
24
|
+
const directory = join(frondPath, VERSIONS);
|
|
25
|
+
const found = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
26
|
+
const names = found.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
27
|
+
if (names.length === 0) return [];
|
|
28
|
+
|
|
29
|
+
const steps = new Map<string, Version['step']>();
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const raw = await readFile(join(directory, name, 'from.json'), 'utf8').catch(() => undefined);
|
|
32
|
+
steps.set(name, raw ? (JSON.parse(raw) as Version['step']) : undefined);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const roots = names.filter((name) => steps.get(name)?.previous === undefined);
|
|
36
|
+
if (roots.length !== 1) {
|
|
37
|
+
const said = roots.length === 0 ? 'none starts it' : `${roots.join(', ')} each start one`;
|
|
38
|
+
throw new Error(`${directory}: a frond's versions are ONE line and ${said}.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const next = new Map<string, string>();
|
|
42
|
+
for (const [name, step] of steps) if (step?.previous !== undefined) next.set(step.previous, name);
|
|
43
|
+
|
|
44
|
+
const chain: Version[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (let at: string | undefined = roots[0]; at !== undefined && !seen.has(at); at = next.get(at)) {
|
|
47
|
+
seen.add(at);
|
|
48
|
+
chain.push({ name: at, step: steps.get(at) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One check for every way the links fail to be a line: a fork (two versions claiming
|
|
52
|
+
// the same `previous`), a cycle, or a step naming a version that is not there.
|
|
53
|
+
const adrift = names.filter((name) => !seen.has(name));
|
|
54
|
+
if (adrift.length > 0) {
|
|
55
|
+
throw new Error(`${directory}: ${adrift.join(', ')} follow no version in the chain that starts at ${roots[0]}.`);
|
|
56
|
+
}
|
|
57
|
+
return chain;
|
|
58
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { entity, text, number, optional } from "@fougere/schema";
|
|
1
|
+
import { entity, text, number, bool, optional } from "@fougere/schema";
|
|
2
2
|
|
|
3
3
|
/** `fougere serve <frond>` — run one frond alone in its own process (JSON-RPC over HTTP). */
|
|
4
4
|
export default class Serve extends entity({
|
|
5
5
|
frond: text({ min: 1, description: "Frond to host in its own process" }),
|
|
6
6
|
port: optional(number({ description: "Port to listen on (default 4100)" })),
|
|
7
|
+
watch: optional(bool({ description: "Rebuild the app when the frond changes" })),
|
|
7
8
|
}) {}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, basename } from 'node:path';
|
|
4
|
-
|
|
5
|
-
function capitalize(s: string): string {
|
|
6
|
-
return s[0].toUpperCase() + s.slice(1);
|
|
7
|
-
}
|
|
4
|
+
import { loadConfig, resolveConventions, frondPackage } from '@fougere/core/node';
|
|
8
5
|
|
|
9
6
|
export default class BuildFrondHandler {
|
|
10
7
|
// cwd is ambient in a CLI — not a DI service (the container resolves by type).
|
|
@@ -12,15 +9,16 @@ export default class BuildFrondHandler {
|
|
|
12
9
|
|
|
13
10
|
/** Build a frond into a standalone deployable package. */
|
|
14
11
|
async execute(input: { name: string }): Promise<{ path: string; entities: string[] }> {
|
|
15
|
-
const
|
|
12
|
+
const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
|
|
13
|
+
const frondDir = join(this.cwd, conventions.fronds, input.name);
|
|
16
14
|
|
|
17
15
|
if (!existsSync(frondDir)) {
|
|
18
16
|
throw new Error(`Frond '${input.name}' not found at ${frondDir}`);
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
const entitiesDir = join(frondDir,
|
|
19
|
+
const entitiesDir = join(frondDir, conventions.dirs.entities);
|
|
22
20
|
if (!existsSync(entitiesDir)) {
|
|
23
|
-
throw new Error(`No entities/ directory in frond '${input.name}'`);
|
|
21
|
+
throw new Error(`No ${conventions.dirs.entities}/ directory in frond '${input.name}'`);
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
// Discover entity files
|
|
@@ -36,7 +34,7 @@ export default class BuildFrondHandler {
|
|
|
36
34
|
|
|
37
35
|
// Generate barrel index.ts
|
|
38
36
|
const indexLines = entityNames.map(
|
|
39
|
-
(name) => `export { default as ${name} } from '
|
|
37
|
+
(name) => `export { default as ${name} } from './${conventions.dirs.entities}/${name}.js';`,
|
|
40
38
|
);
|
|
41
39
|
writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
|
|
42
40
|
|
|
@@ -53,7 +51,7 @@ export default class BuildFrondHandler {
|
|
|
53
51
|
esModuleInterop: true,
|
|
54
52
|
skipLibCheck: true,
|
|
55
53
|
},
|
|
56
|
-
include: ['index.ts',
|
|
54
|
+
include: ['index.ts', `${conventions.dirs.entities}/**/*.ts`],
|
|
57
55
|
};
|
|
58
56
|
|
|
59
57
|
const tsconfigPath = join(frondDir, 'tsconfig.build.json');
|
|
@@ -69,16 +67,16 @@ export default class BuildFrondHandler {
|
|
|
69
67
|
const pkgPath = join(frondDir, 'package.json');
|
|
70
68
|
const pkg = existsSync(pkgPath)
|
|
71
69
|
? JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
72
|
-
: { name:
|
|
70
|
+
: { name: frondPackage(input.name, conventions), version: '0.0.1', type: 'module' };
|
|
73
71
|
|
|
74
72
|
pkg.exports = {
|
|
75
73
|
'.': {
|
|
76
74
|
types: './dist/index.d.ts',
|
|
77
75
|
default: './dist/index.js',
|
|
78
76
|
},
|
|
79
|
-
|
|
80
|
-
types:
|
|
81
|
-
default:
|
|
77
|
+
[`./${conventions.dirs.entities}/*`]: {
|
|
78
|
+
types: `./dist/${conventions.dirs.entities}/*.d.ts`,
|
|
79
|
+
default: `./dist/${conventions.dirs.entities}/*.js`,
|
|
82
80
|
},
|
|
83
81
|
'./package.json': './package.json',
|
|
84
82
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work lives in app/commands/GrantCommand (it prints secrets). This handler
|
|
3
|
+
* exists only so the runner registers the `grant` subcommand.
|
|
4
|
+
*/
|
|
5
|
+
export default class GrantHandler {
|
|
6
|
+
/** Bind a frond's name to a fresh key, signed by the root. */
|
|
7
|
+
async execute(): Promise<void> {}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work lives in app/commands/KeysCommand (it writes the root key). This
|
|
3
|
+
* handler exists only so the runner registers the `keys` subcommand.
|
|
4
|
+
*/
|
|
5
|
+
export default class KeysHandler {
|
|
6
|
+
/** Create the root key a split deployment's grants are signed by. */
|
|
7
|
+
async execute(): Promise<void> {}
|
|
8
|
+
}
|
|
@@ -4,7 +4,10 @@ import { entitySourceOf, facadeTypeSourceOf, type SchemaDescriptor } from '@foug
|
|
|
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.
|
|
7
|
-
import type
|
|
7
|
+
import { assertIdentityCard, type IdentityCard } from '@fougere/core';
|
|
8
|
+
import {
|
|
9
|
+
type Conventions, loadConfig, resolveConventions, frondPackage,
|
|
10
|
+
} from '@fougere/core/node';
|
|
8
11
|
|
|
9
12
|
function assertSafeName(kind: string, name: string): void {
|
|
10
13
|
if (typeof name !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(name)) {
|
|
@@ -67,18 +70,12 @@ function assertEntry(kind: string, frondName: string, entry: { name: string; sch
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
function identityCardOf(value: unknown): IdentityCard {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const card = value
|
|
73
|
+
// The card's own shape is judged by the package that declares it — `fronds`, and each
|
|
74
|
+
// frond's `doors`. What stays here is what only a writer of files needs: a name safe to
|
|
75
|
+
// become one, and the descriptor a class is generated from.
|
|
76
|
+
const card = assertIdentityCard(value, 'Remote rpc.discover');
|
|
74
77
|
for (const frond of card.fronds) {
|
|
75
|
-
if (!frond || typeof frond !== 'object') {
|
|
76
|
-
throw new Error('Remote rpc.discover returned an invalid frond entry');
|
|
77
|
-
}
|
|
78
78
|
assertSafeName('frond', frond.name);
|
|
79
|
-
if (!Array.isArray(frond.doors)) {
|
|
80
|
-
throw new Error(`Remote frond '${frond.name}' has no valid doors array`);
|
|
81
|
-
}
|
|
82
79
|
// Absent rather than empty is tolerated: a host older than the fact list says nothing
|
|
83
80
|
// about facts, and refusing it would break sync against every previous version for a
|
|
84
81
|
// feature the consumer may not use.
|
|
@@ -129,9 +126,15 @@ export default class SyncHandler {
|
|
|
129
126
|
throw new Error(`Frond '${input.name}' not found on ${baseUrl}. Available: ${card.fronds.map((f) => f.name).join(', ')}`);
|
|
130
127
|
}
|
|
131
128
|
|
|
129
|
+
// The consumer's own convention: a synced frond is laid out like the ones they wrote,
|
|
130
|
+
// and the export map below is what makes `@fronds/<name>/<dir>/X.js` resolve.
|
|
131
|
+
const conventions = resolveConventions((await loadConfig(this.cwd)).conventions);
|
|
132
|
+
const entities = conventions.dirs.entities;
|
|
133
|
+
const handlers = conventions.dirs.handlers;
|
|
134
|
+
|
|
132
135
|
const frondDir = join(this.cwd, '.fougere', 'remotes', input.name);
|
|
133
|
-
const entitiesDir = join(frondDir,
|
|
134
|
-
const handlersDir = join(frondDir,
|
|
136
|
+
const entitiesDir = join(frondDir, entities);
|
|
137
|
+
const handlersDir = join(frondDir, handlers);
|
|
135
138
|
mkdirSync(entitiesDir, { recursive: true });
|
|
136
139
|
mkdirSync(handlersDir, { recursive: true });
|
|
137
140
|
|
|
@@ -229,21 +232,21 @@ export default class SyncHandler {
|
|
|
229
232
|
// One binding carries the value AND the type, because a class is both — the pair of
|
|
230
233
|
// re-exports that stood here was the price of declaring them separately.
|
|
231
234
|
const indexLines = [...generated].flatMap(([name, { row, door }]) => [
|
|
232
|
-
...(row ? [`export { default as ${name} } from '
|
|
233
|
-
...(door ? [`export type { ${name}Handler } from '
|
|
235
|
+
...(row ? [`export { default as ${name} } from './${entities}/${name}.js';`] : []),
|
|
236
|
+
...(door ? [`export type { ${name}Handler } from './${handlers}/${name}Handler.js';`] : []),
|
|
234
237
|
]);
|
|
235
238
|
writeFileSync(join(frondDir, 'index.ts'), indexLines.join('\n') + '\n');
|
|
236
239
|
|
|
237
240
|
// Package.json
|
|
238
241
|
writeFileSync(join(frondDir, 'package.json'), JSON.stringify({
|
|
239
|
-
name:
|
|
242
|
+
name: frondPackage(input.name, conventions),
|
|
240
243
|
version: '0.0.0-synced',
|
|
241
244
|
type: 'module',
|
|
242
245
|
fougere: { frond: input.name, synced: true, source: baseUrl },
|
|
243
246
|
exports: {
|
|
244
247
|
'.': './index.ts',
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
[`./${entities}/*`]: `./${entities}/*.ts`,
|
|
249
|
+
[`./${handlers}/*`]: `./${handlers}/*.ts`,
|
|
247
250
|
'./package.json': './package.json',
|
|
248
251
|
},
|
|
249
252
|
}, null, 2) + '\n');
|
|
@@ -252,14 +255,14 @@ export default class SyncHandler {
|
|
|
252
255
|
this.updateRemotesRegistry(input.name, baseUrl, frondDir);
|
|
253
256
|
|
|
254
257
|
// Update tsconfig paths if tsconfig.json exists (non-Nuxt projects)
|
|
255
|
-
this.updateTsconfigPaths(input.name, frondDir);
|
|
258
|
+
this.updateTsconfigPaths(input.name, frondDir, conventions);
|
|
256
259
|
|
|
257
260
|
/**
|
|
258
261
|
* What the host no longer serves stops being importable here.
|
|
259
262
|
*
|
|
260
263
|
* The barrel is rewritten every run, so a dropped entity loses its export on its own
|
|
261
264
|
* — but the FILE stayed, and the generated `package.json` exports `'./entities/*'` as
|
|
262
|
-
* a wildcard, so `@
|
|
265
|
+
* a wildcard, so `@fronds/blog/entities/Ticket.js` kept resolving to a class nothing
|
|
263
266
|
* behind it answers for. The consumer compiles, its local judge accepts, and the call
|
|
264
267
|
* comes back NOT_FOUND at the door — or never leaves, because the page dropped the
|
|
265
268
|
* call and kept the type.
|
|
@@ -304,8 +307,8 @@ export default class SyncHandler {
|
|
|
304
307
|
writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
|
|
305
308
|
}
|
|
306
309
|
|
|
307
|
-
/** Add
|
|
308
|
-
private updateTsconfigPaths(name: string, localPath: string): void {
|
|
310
|
+
/** Add the frond's scoped name to tsconfig paths if tsconfig.json exists. */
|
|
311
|
+
private updateTsconfigPaths(name: string, localPath: string, conventions: Conventions): void {
|
|
309
312
|
const tsconfigPath = join(this.cwd, 'tsconfig.json');
|
|
310
313
|
if (!existsSync(tsconfigPath)) return;
|
|
311
314
|
|
|
@@ -320,8 +323,8 @@ export default class SyncHandler {
|
|
|
320
323
|
tsconfig.compilerOptions.paths ??= {};
|
|
321
324
|
|
|
322
325
|
const relative = localPath.replace(this.cwd, '.').replace(/\\/g, '/');
|
|
323
|
-
tsconfig.compilerOptions.paths[
|
|
324
|
-
tsconfig.compilerOptions.paths[
|
|
326
|
+
tsconfig.compilerOptions.paths[frondPackage(name, conventions)] = [`${relative}/index.ts`];
|
|
327
|
+
tsconfig.compilerOptions.paths[`${frondPackage(name, conventions)}/*`] = [`${relative}/*`];
|
|
325
328
|
|
|
326
329
|
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
|
|
327
330
|
} catch { /* tsconfig parse error — skip */ }
|