@ogygie/studio 0.5.0 → 0.5.1
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/dist/server.js +904 -0
- package/package.json +8 -8
- package/src/index.ts +0 -41
- package/src/server/app.ts +0 -84
- package/src/server/routes.test.ts +0 -164
- package/src/server/routes.ts +0 -87
- package/src/server/schemas.ts +0 -92
- package/src/server/service.test.ts +0 -305
- package/src/server/service.ts +0 -426
- package/src/server/start.ts +0 -80
- package/src/server/storage/local-adapter.test.ts +0 -52
- package/src/server/storage/local-adapter.ts +0 -67
- package/src/server/storage/memory-adapter.ts +0 -49
- package/src/server/storage/spec-storage.ts +0 -22
- package/src/ui/app.css +0 -3
- package/src/ui/features/manifest-view/ManifestView.test.tsx +0 -67
- package/src/ui/features/manifest-view/ManifestView.tsx +0 -80
- package/src/ui/features/spec-board/SpecBoard.test.tsx +0 -92
- package/src/ui/features/spec-board/SpecBoard.tsx +0 -198
- package/src/ui/features/spec-editor/MarkdownPreview.tsx +0 -49
- package/src/ui/features/spec-editor/SpecEditor.test.tsx +0 -79
- package/src/ui/features/spec-editor/SpecEditor.tsx +0 -237
- package/src/ui/features/spec-editor/frontmatter-fields.test.ts +0 -54
- package/src/ui/features/spec-editor/frontmatter-fields.ts +0 -85
- package/src/ui/features/spec-editor/markdown-blocks.test.ts +0 -37
- package/src/ui/features/spec-editor/markdown-blocks.ts +0 -82
- package/src/ui/index.html +0 -12
- package/src/ui/lib/fetchContract.ts +0 -40
- package/src/ui/lib/queries.ts +0 -69
- package/src/ui/main.tsx +0 -27
- package/src/ui/patterns/EmptyState.tsx +0 -24
- package/src/ui/patterns/ErrorBanner.tsx +0 -51
- package/src/ui/patterns/Modal.tsx +0 -38
- package/src/ui/router.tsx +0 -76
- package/src/ui/test-utils.tsx +0 -56
package/src/server/service.ts
DELETED
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Business logic of the ogygie-studio feature. Pure functions over the
|
|
3
|
-
* injected SpecStorage (declared-effects principle): list/read/write specs,
|
|
4
|
-
* serialize a conformant frontmatter, enforce the studio transition rules.
|
|
5
|
-
* The studio is a VIEW over the repo: it never repairs a broken spec, never
|
|
6
|
-
* writes `implemented` (that status belongs to the implementation flow —
|
|
7
|
-
* manifest + CI), and preserves the markdown body byte-exact.
|
|
8
|
-
*/
|
|
9
|
-
import {
|
|
10
|
-
SECTIONS_SPEC_OBLIGATOIRES,
|
|
11
|
-
SpecFrontmatterSchema,
|
|
12
|
-
parseSpecFrontmatter,
|
|
13
|
-
type SpecFrontmatter,
|
|
14
|
-
} from "@ogygie/core/spec";
|
|
15
|
-
import { OgygieManifestSchema, type OgygieManifest } from "@ogygie/core/manifest";
|
|
16
|
-
import {
|
|
17
|
-
OgygieFailure,
|
|
18
|
-
ogygieError,
|
|
19
|
-
throwOgygieError,
|
|
20
|
-
type OgygieError,
|
|
21
|
-
} from "@ogygie/core/errors";
|
|
22
|
-
import type { SpecStorage } from "./storage/spec-storage.js";
|
|
23
|
-
import type {
|
|
24
|
-
GetSpecOutput,
|
|
25
|
-
ReadableSpecSummary,
|
|
26
|
-
SpecSummary,
|
|
27
|
-
} from "./schemas.js";
|
|
28
|
-
|
|
29
|
-
export interface StudioDeps {
|
|
30
|
-
storage: SpecStorage;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const SPECS_DIR = "specs";
|
|
34
|
-
const TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
|
|
35
|
-
const MANIFEST_PATH = "ogygie.manifest.json";
|
|
36
|
-
|
|
37
|
-
/** Same block shape as scripts/verify-specs.ts — the studio reads specs the
|
|
38
|
-
* way the gate does, or the two would disagree on what a spec is. */
|
|
39
|
-
const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
|
|
40
|
-
|
|
41
|
-
export interface SplitSpecFile {
|
|
42
|
-
frontmatterText: string;
|
|
43
|
-
/** Everything after the closing `---`, byte-exact (leading newline included). */
|
|
44
|
-
body: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function splitSpecFile(content: string): SplitSpecFile | null {
|
|
48
|
-
const match = FRONTMATTER_BLOCK.exec(content);
|
|
49
|
-
if (match === null || match[1] === undefined) return null;
|
|
50
|
-
return { frontmatterText: match[1], body: content.slice(match[0].length) };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** In-order scan, identical to the gate: a section out of order counts as
|
|
54
|
-
* absent — the studio must refuse exactly what the CI would refuse. */
|
|
55
|
-
export function presentSectionsOf(body: string): string[] {
|
|
56
|
-
const searchable = `\n${body}`;
|
|
57
|
-
const present: string[] = [];
|
|
58
|
-
let cursor = 0;
|
|
59
|
-
for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
|
|
60
|
-
const position = searchable.indexOf(`\n${section}`, cursor);
|
|
61
|
-
if (position === -1) continue;
|
|
62
|
-
present.push(section);
|
|
63
|
-
cursor = position + section.length;
|
|
64
|
-
}
|
|
65
|
-
return present;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function missingSectionsOf(body: string): string[] {
|
|
69
|
-
const present = new Set(presentSectionsOf(body));
|
|
70
|
-
return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Quotes one frontmatter array element so parseSpecFrontmatter reads it back
|
|
74
|
-
* verbatim. Refuses what the restricted format cannot carry (both quote kinds,
|
|
75
|
-
* newlines) instead of writing a file the gate would then refuse. */
|
|
76
|
-
function quoteFrontmatterElement(value: string, specPath: string): string {
|
|
77
|
-
if (value.includes("\n") || (value.includes('"') && value.includes("'"))) {
|
|
78
|
-
throwOgygieError(
|
|
79
|
-
{
|
|
80
|
-
code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
|
|
81
|
-
message:
|
|
82
|
-
"A frontmatter array element cannot be written in the restricted spec format.",
|
|
83
|
-
file: specPath,
|
|
84
|
-
cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
|
|
85
|
-
suggestedFix:
|
|
86
|
-
"Rewrite the element without newlines and with at most one kind of quote character.",
|
|
87
|
-
},
|
|
88
|
-
422,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
return value.includes('"') ? `'${value}'` : `"${value}"`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Serializes a VALID frontmatter into the restricted `key: value` format the
|
|
95
|
-
* parser owns (scalars + inline arrays). Deterministic template order; the
|
|
96
|
-
* `deferred` line is only written when non-empty, like the template. */
|
|
97
|
-
export function serializeSpecFrontmatter(
|
|
98
|
-
frontmatter: SpecFrontmatter,
|
|
99
|
-
specPath: string,
|
|
100
|
-
): string {
|
|
101
|
-
const quote = (value: string): string => quoteFrontmatterElement(value, specPath);
|
|
102
|
-
const lines = [
|
|
103
|
-
`id: ${frontmatter.id}`,
|
|
104
|
-
`status: ${frontmatter.status}`,
|
|
105
|
-
`depends_on: [${frontmatter.depends_on.join(", ")}]`,
|
|
106
|
-
`priority: ${frontmatter.priority}`,
|
|
107
|
-
`refs: [${frontmatter.refs.map(quote).join(", ")}]`,
|
|
108
|
-
];
|
|
109
|
-
if (frontmatter.deferred.length > 0) {
|
|
110
|
-
lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
|
|
111
|
-
}
|
|
112
|
-
return lines.join("\n");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function specPathOf(specId: string): string {
|
|
116
|
-
return `${SPECS_DIR}/${specId}.spec.md`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function frontmatterInvalidError(filePath: string, cause: string): OgygieError {
|
|
120
|
-
return ogygieError({
|
|
121
|
-
code: "SPEC_FRONTMATTER_INVALID",
|
|
122
|
-
message: `Spec ${filePath} has an invalid frontmatter.`,
|
|
123
|
-
file: filePath,
|
|
124
|
-
cause,
|
|
125
|
-
suggestedFix:
|
|
126
|
-
"Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts — the studio never repairs a spec silently.",
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
interface ParsedSpec {
|
|
131
|
-
frontmatter: SpecFrontmatter;
|
|
132
|
-
body: string;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Parses a spec file or explains why it cannot be trusted. */
|
|
136
|
-
function parseSpec(filePath: string, fileName: string, content: string):
|
|
137
|
-
| { ok: true; spec: ParsedSpec }
|
|
138
|
-
| { ok: false; error: OgygieError } {
|
|
139
|
-
const split = splitSpecFile(content);
|
|
140
|
-
if (split === null) {
|
|
141
|
-
return {
|
|
142
|
-
ok: false,
|
|
143
|
-
error: frontmatterInvalidError(
|
|
144
|
-
filePath,
|
|
145
|
-
"file does not start with a --- ... --- frontmatter block",
|
|
146
|
-
),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
const parsed = parseSpecFrontmatter(split.frontmatterText);
|
|
150
|
-
if (parsed.problems.length > 0) {
|
|
151
|
-
return { ok: false, error: frontmatterInvalidError(filePath, parsed.problems.join(" ; ")) };
|
|
152
|
-
}
|
|
153
|
-
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
154
|
-
if (!checked.success) {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
error: frontmatterInvalidError(
|
|
158
|
-
filePath,
|
|
159
|
-
checked.error.issues
|
|
160
|
-
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
161
|
-
.join(" ; "),
|
|
162
|
-
),
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
const expectedId = fileName.replace(/\.spec\.md$/, "");
|
|
166
|
-
if (checked.data.id !== expectedId) {
|
|
167
|
-
return {
|
|
168
|
-
ok: false,
|
|
169
|
-
error: ogygieError({
|
|
170
|
-
code: "SPEC_ID_MISMATCH",
|
|
171
|
-
message: `Spec ${fileName} declares id "${checked.data.id}" — expected "${expectedId}".`,
|
|
172
|
-
file: filePath,
|
|
173
|
-
cause: `id=${checked.data.id} ≠ filename ${expectedId}`,
|
|
174
|
-
suggestedFix: "Make the frontmatter id identical to the file name.",
|
|
175
|
-
}),
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function toReadableSummary(filePath: string, spec: ParsedSpec): ReadableSpecSummary {
|
|
182
|
-
return {
|
|
183
|
-
id: spec.frontmatter.id,
|
|
184
|
-
status: spec.frontmatter.status,
|
|
185
|
-
priority: spec.frontmatter.priority,
|
|
186
|
-
depends_on: spec.frontmatter.depends_on,
|
|
187
|
-
filePath,
|
|
188
|
-
presentSections: presentSectionsOf(spec.body),
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function listSpecFileNames(deps: StudioDeps): Promise<string[]> {
|
|
193
|
-
const entries = await deps.storage.list(SPECS_DIR);
|
|
194
|
-
return entries
|
|
195
|
-
.map((entry) => entry.name)
|
|
196
|
-
.filter((name) => name.endsWith(".spec.md") && !name.startsWith("_"))
|
|
197
|
-
.sort();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ------------------------------------------------------------------ use cases
|
|
201
|
-
|
|
202
|
-
export async function listSpecSummaries(deps: StudioDeps): Promise<SpecSummary[]> {
|
|
203
|
-
const fileNames = await listSpecFileNames(deps);
|
|
204
|
-
const summaries: SpecSummary[] = [];
|
|
205
|
-
for (const fileName of fileNames) {
|
|
206
|
-
const filePath = `${SPECS_DIR}/${fileName}`;
|
|
207
|
-
const content = await deps.storage.read(filePath);
|
|
208
|
-
const parsed = parseSpec(filePath, fileName, content);
|
|
209
|
-
const id = fileName.replace(/\.spec\.md$/, "");
|
|
210
|
-
summaries.push(
|
|
211
|
-
parsed.ok
|
|
212
|
-
? toReadableSummary(filePath, parsed.spec)
|
|
213
|
-
: { id, filePath, error: parsed.error },
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
return summaries;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function readParsedSpec(deps: StudioDeps, specId: string): Promise<ParsedSpec> {
|
|
220
|
-
const fileName = `${specId}.spec.md`;
|
|
221
|
-
const fileNames = await listSpecFileNames(deps);
|
|
222
|
-
if (!fileNames.includes(fileName)) {
|
|
223
|
-
throwOgygieError(
|
|
224
|
-
{
|
|
225
|
-
code: "SPEC_NOT_FOUND",
|
|
226
|
-
message: `No spec file exists for id "${specId}".`,
|
|
227
|
-
file: specPathOf(specId),
|
|
228
|
-
cause: `${fileName} absent from ${SPECS_DIR}/`,
|
|
229
|
-
suggestedFix: "Pick an id from GET /api/specs, or create the spec first.",
|
|
230
|
-
},
|
|
231
|
-
404,
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
const filePath = specPathOf(specId);
|
|
235
|
-
const content = await deps.storage.read(filePath);
|
|
236
|
-
const parsed = parseSpec(filePath, fileName, content);
|
|
237
|
-
if (!parsed.ok) {
|
|
238
|
-
// An unreadable spec cannot be edited from the studio: fixing it in a code
|
|
239
|
-
// editor keeps the "never silently repaired" promise intact.
|
|
240
|
-
throw new OgygieFailure(parsed.error, 422);
|
|
241
|
-
}
|
|
242
|
-
return parsed.spec;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export async function getSpec(deps: StudioDeps, specId: string): Promise<GetSpecOutput> {
|
|
246
|
-
const spec = await readParsedSpec(deps, specId);
|
|
247
|
-
return {
|
|
248
|
-
frontmatter: spec.frontmatter,
|
|
249
|
-
body: spec.body,
|
|
250
|
-
presentSections: presentSectionsOf(spec.body),
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export interface SaveSpecInput {
|
|
255
|
-
frontmatter: SpecFrontmatter;
|
|
256
|
-
body?: string | undefined;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export async function saveSpec(
|
|
260
|
-
deps: StudioDeps,
|
|
261
|
-
specId: string,
|
|
262
|
-
input: SaveSpecInput,
|
|
263
|
-
): Promise<ReadableSpecSummary> {
|
|
264
|
-
const current = await readParsedSpec(deps, specId);
|
|
265
|
-
const filePath = specPathOf(specId);
|
|
266
|
-
|
|
267
|
-
if (input.frontmatter.id !== specId) {
|
|
268
|
-
throwOgygieError(
|
|
269
|
-
{
|
|
270
|
-
code: "SPEC_ID_MISMATCH",
|
|
271
|
-
message: `The submitted frontmatter declares id "${input.frontmatter.id}" — expected "${specId}".`,
|
|
272
|
-
file: filePath,
|
|
273
|
-
cause: `frontmatter.id=${input.frontmatter.id} ≠ route param ${specId}`,
|
|
274
|
-
suggestedFix: "Keep the id identical to the file name — it is not editable.",
|
|
275
|
-
},
|
|
276
|
-
422,
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const from = current.frontmatter.status;
|
|
281
|
-
const target = input.frontmatter.status;
|
|
282
|
-
if (target === "implemented" && from !== "implemented") {
|
|
283
|
-
throwOgygieError(
|
|
284
|
-
{
|
|
285
|
-
code: "STUDIO_TRANSITION_FORBIDDEN",
|
|
286
|
-
message:
|
|
287
|
-
"Status `implemented` belongs to the implementation flow (manifest + CI), never to the studio UI.",
|
|
288
|
-
file: filePath,
|
|
289
|
-
cause: `attempted transition ${from} → implemented from the studio`,
|
|
290
|
-
suggestedFix:
|
|
291
|
-
"Implement the feature, update the manifest to delivered, then flip the spec in the implementation commit.",
|
|
292
|
-
},
|
|
293
|
-
409,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
const isValidation = from === "draft" && target === "validated";
|
|
297
|
-
if (from !== target && !isValidation) {
|
|
298
|
-
throwOgygieError(
|
|
299
|
-
{
|
|
300
|
-
code: "STUDIO_TRANSITION_FORBIDDEN",
|
|
301
|
-
message: "The studio only performs the draft → validated transition.",
|
|
302
|
-
file: filePath,
|
|
303
|
-
cause: `attempted transition ${from} → ${target} from the studio`,
|
|
304
|
-
suggestedFix:
|
|
305
|
-
"Reopening or downgrading a spec happens in the implementation flow (spec amendment commit), not in the studio.",
|
|
306
|
-
},
|
|
307
|
-
409,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// A provided body replaces the markdown; an omitted one is preserved
|
|
312
|
-
// byte-exact. The leading newline keeps `\n# @intent` matchable.
|
|
313
|
-
let body = input.body ?? current.body;
|
|
314
|
-
if (input.body !== undefined && !body.startsWith("\n")) {
|
|
315
|
-
body = `\n${body}`;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (isValidation) {
|
|
319
|
-
const missing = missingSectionsOf(body);
|
|
320
|
-
if (missing.length > 0) {
|
|
321
|
-
throwOgygieError(
|
|
322
|
-
{
|
|
323
|
-
code: "SPEC_SECTION_MISSING",
|
|
324
|
-
message: `Spec ${specId} cannot be validated: ${missing.length} mandatory section(s) missing.`,
|
|
325
|
-
file: filePath,
|
|
326
|
-
cause: `missing or out-of-order: ${missing.join(" ; ")}`,
|
|
327
|
-
suggestedFix:
|
|
328
|
-
"Write the missing sections (in canonical order) before validating — a validated spec is an implementation contract.",
|
|
329
|
-
details: { missingSections: missing },
|
|
330
|
-
},
|
|
331
|
-
409,
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const serialized = serializeSpecFrontmatter(input.frontmatter, filePath);
|
|
337
|
-
const content = `---\n${serialized}\n---${body}`;
|
|
338
|
-
const message = isValidation ? `spec: validate ${specId}` : `spec: save ${specId}`;
|
|
339
|
-
await deps.storage.write(filePath, content, message);
|
|
340
|
-
return toReadableSummary(filePath, { frontmatter: input.frontmatter, body });
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export async function createSpec(
|
|
344
|
-
deps: StudioDeps,
|
|
345
|
-
specId: string,
|
|
346
|
-
): Promise<ReadableSpecSummary> {
|
|
347
|
-
const fileName = `${specId}.spec.md`;
|
|
348
|
-
const fileNames = await listSpecFileNames(deps);
|
|
349
|
-
if (fileNames.includes(fileName)) {
|
|
350
|
-
throwOgygieError(
|
|
351
|
-
{
|
|
352
|
-
code: "SPEC_ALREADY_EXISTS",
|
|
353
|
-
message: `A spec already exists for id "${specId}" — nothing was overwritten.`,
|
|
354
|
-
file: specPathOf(specId),
|
|
355
|
-
cause: `${fileName} already present in ${SPECS_DIR}/`,
|
|
356
|
-
suggestedFix: "Open the existing spec from the board, or pick another id.",
|
|
357
|
-
},
|
|
358
|
-
409,
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
let template: string;
|
|
362
|
-
try {
|
|
363
|
-
template = await deps.storage.read(TEMPLATE_PATH);
|
|
364
|
-
} catch {
|
|
365
|
-
return throwOgygieError({
|
|
366
|
-
code: "SPEC_TEMPLATE_NOT_FOUND",
|
|
367
|
-
message: "specs/_TEMPLATE.spec.md is missing — the studio creates specs from it.",
|
|
368
|
-
file: TEMPLATE_PATH,
|
|
369
|
-
cause: "template file unreadable",
|
|
370
|
-
suggestedFix: "Restore specs/_TEMPLATE.spec.md from the framework skeleton.",
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
const content = template.replace(/^id: .*$/m, `id: ${specId}`);
|
|
374
|
-
const filePath = specPathOf(specId);
|
|
375
|
-
await deps.storage.write(filePath, content, `spec: create ${specId}`);
|
|
376
|
-
|
|
377
|
-
const parsed = parseSpec(filePath, fileName, content);
|
|
378
|
-
if (!parsed.ok) {
|
|
379
|
-
// The template itself must produce a readable draft; anything else is a
|
|
380
|
-
// broken template, not a user mistake.
|
|
381
|
-
return throwOgygieError({
|
|
382
|
-
code: "SPEC_TEMPLATE_NOT_FOUND",
|
|
383
|
-
message: "specs/_TEMPLATE.spec.md does not produce a readable draft spec.",
|
|
384
|
-
file: TEMPLATE_PATH,
|
|
385
|
-
cause: parsed.error.cause,
|
|
386
|
-
suggestedFix: "Fix the template's frontmatter to match SpecFrontmatterSchema.",
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
return toReadableSummary(filePath, parsed.spec);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
export async function getManifest(deps: StudioDeps): Promise<OgygieManifest> {
|
|
393
|
-
const content = await deps.storage.read(MANIFEST_PATH);
|
|
394
|
-
let raw: unknown;
|
|
395
|
-
try {
|
|
396
|
-
raw = JSON.parse(content);
|
|
397
|
-
} catch (parseError) {
|
|
398
|
-
return throwOgygieError(
|
|
399
|
-
{
|
|
400
|
-
code: "MANIFEST_INVALID",
|
|
401
|
-
message: "ogygie.manifest.json is not valid JSON.",
|
|
402
|
-
file: MANIFEST_PATH,
|
|
403
|
-
cause: parseError instanceof Error ? parseError.message : "unparseable JSON",
|
|
404
|
-
suggestedFix: "Fix the manifest JSON — the studio only ever READS it.",
|
|
405
|
-
},
|
|
406
|
-
422,
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
const parsed = OgygieManifestSchema.safeParse(raw);
|
|
410
|
-
if (!parsed.success) {
|
|
411
|
-
throwOgygieError(
|
|
412
|
-
{
|
|
413
|
-
code: "MANIFEST_INVALID",
|
|
414
|
-
message: "ogygie.manifest.json does not satisfy OgygieManifestSchema.",
|
|
415
|
-
file: MANIFEST_PATH,
|
|
416
|
-
cause: parsed.error.issues
|
|
417
|
-
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
418
|
-
.join(" ; "),
|
|
419
|
-
suggestedFix:
|
|
420
|
-
"Fix the listed fields to match packages/ogygie-core/src/manifest.schema.ts.",
|
|
421
|
-
},
|
|
422
|
-
422,
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
return parsed.data;
|
|
426
|
-
}
|
package/src/server/start.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Entry point of `ogygie studio`: local SpecStorage rooted at the
|
|
3
|
-
* launched repo, Fastify bound to 127.0.0.1 ONLY (localhost tool — never
|
|
4
|
-
* exposed), then the browser opens on the board. The repo is the state; this
|
|
5
|
-
* process is just a view over it.
|
|
6
|
-
*
|
|
7
|
-
* The UI is SHIPPED already built in `dist-ui/` (spec v2): a consumer installs
|
|
8
|
-
* the package and runs it, with none of the build-time dependencies. Building
|
|
9
|
-
* on the fly only ever happens inside the framework's own workspace, where
|
|
10
|
-
* vite is present — and when it is not, the refusal says so instead of failing
|
|
11
|
-
* on a module resolution error.
|
|
12
|
-
*/
|
|
13
|
-
import { existsSync } from "node:fs";
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import type { FastifyInstance } from "fastify";
|
|
18
|
-
import { throwOgygieError } from "@ogygie/core/errors";
|
|
19
|
-
import { createLocalSpecStorage } from "./storage/local-adapter.js";
|
|
20
|
-
import { createStudioApp } from "./app.js";
|
|
21
|
-
|
|
22
|
-
const PACKAGE_DIR = fileURLToPath(new URL("../..", import.meta.url));
|
|
23
|
-
|
|
24
|
-
export interface StartStudioOptions {
|
|
25
|
-
/** Root of the Ogygie repo to project (specs/ + ogygie.manifest.json). */
|
|
26
|
-
repoRoot: string;
|
|
27
|
-
port?: number | undefined;
|
|
28
|
-
/** Set false in scripts/tests; defaults to opening the board in a browser. */
|
|
29
|
-
openBrowser?: boolean | undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function buildUiIfMissing(uiDistDir: string): Promise<void> {
|
|
33
|
-
if (existsSync(join(uiDistDir, "index.html"))) return;
|
|
34
|
-
console.log("ogygie studio — UI absente, construction unique…");
|
|
35
|
-
try {
|
|
36
|
-
const { build } = await import("vite");
|
|
37
|
-
await build({
|
|
38
|
-
configFile: join(PACKAGE_DIR, "vite.config.ts"),
|
|
39
|
-
logLevel: "warn",
|
|
40
|
-
});
|
|
41
|
-
} catch (thrown) {
|
|
42
|
-
throwOgygieError({
|
|
43
|
-
code: "OGYGIE_STUDIO_UI_MISSING",
|
|
44
|
-
message: "The studio UI is missing and cannot be built here.",
|
|
45
|
-
file: "packages/ogygie-studio/dist-ui/index.html",
|
|
46
|
-
cause:
|
|
47
|
-
thrown instanceof Error
|
|
48
|
-
? `dist-ui absent and vite unavailable: ${thrown.message}`
|
|
49
|
-
: "dist-ui absent and vite unavailable",
|
|
50
|
-
suggestedFix:
|
|
51
|
-
"A published @ogygie/studio ships its UI already built — reinstall the package. Inside the framework workspace, run `pnpm --filter @ogygie/studio build`.",
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function openInBrowser(url: string): void {
|
|
57
|
-
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
58
|
-
try {
|
|
59
|
-
spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
60
|
-
} catch {
|
|
61
|
-
// Opening the browser is a convenience, never a failure: the URL is logged.
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function startStudio(options: StartStudioOptions): Promise<FastifyInstance> {
|
|
66
|
-
const port = options.port ?? 4001;
|
|
67
|
-
const uiDistDir = join(PACKAGE_DIR, "dist-ui");
|
|
68
|
-
await buildUiIfMissing(uiDistDir);
|
|
69
|
-
|
|
70
|
-
const app = await createStudioApp({
|
|
71
|
-
storage: createLocalSpecStorage(options.repoRoot),
|
|
72
|
-
uiDistDir,
|
|
73
|
-
});
|
|
74
|
-
await app.listen({ port, host: "127.0.0.1" });
|
|
75
|
-
|
|
76
|
-
const url = `http://127.0.0.1:${port}`;
|
|
77
|
-
console.log(`ogygie studio — ${url} (repo: ${options.repoRoot})`);
|
|
78
|
-
if (options.openBrowser !== false) openInBrowser(url);
|
|
79
|
-
return app;
|
|
80
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Proves the v1 local-adapter contract on a scratch directory: list
|
|
3
|
-
* only files, read/write UTF-8 round-trip, STORAGE_FILE_NOT_FOUND on a missing
|
|
4
|
-
* file, and the traversal guard (a path escaping the root is refused).
|
|
5
|
-
*/
|
|
6
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
10
|
-
import { createLocalSpecStorage } from "./local-adapter.js";
|
|
11
|
-
|
|
12
|
-
describe("ogygie-studio — local-adapter", () => {
|
|
13
|
-
let rootDir: string;
|
|
14
|
-
|
|
15
|
-
beforeAll(async () => {
|
|
16
|
-
rootDir = await mkdtemp(join(tmpdir(), "ogygie-studio-"));
|
|
17
|
-
await mkdir(join(rootDir, "specs"));
|
|
18
|
-
await writeFile(join(rootDir, "specs", "alpha.spec.md"), "contenu", "utf8");
|
|
19
|
-
await mkdir(join(rootDir, "specs", "sous-dossier"));
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterAll(async () => {
|
|
23
|
-
await rm(rootDir, { recursive: true, force: true });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("ÉTANT DONNÉ un dossier specs QUAND on liste ALORS seuls les FICHIERS remontent", async () => {
|
|
27
|
-
const storage = createLocalSpecStorage(rootDir);
|
|
28
|
-
const entries = await storage.list("specs");
|
|
29
|
-
expect(entries).toEqual([{ name: "alpha.spec.md" }]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("ÉTANT DONNÉ un fichier écrit QUAND on le relit ALORS le contenu revient octet pour octet", async () => {
|
|
33
|
-
const storage = createLocalSpecStorage(rootDir);
|
|
34
|
-
const content = "---\nid: beta\n---\n\n# @intent\naccentué, émoji ✅\n";
|
|
35
|
-
await storage.write("specs/beta.spec.md", content, "spec: save beta");
|
|
36
|
-
expect(await storage.read("specs/beta.spec.md")).toBe(content);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("ÉTANT DONNÉ un fichier absent QUAND on le lit ALORS STORAGE_FILE_NOT_FOUND", async () => {
|
|
40
|
-
const storage = createLocalSpecStorage(rootDir);
|
|
41
|
-
await expect(storage.read("specs/fantome.spec.md")).rejects.toMatchObject({
|
|
42
|
-
error: expect.objectContaining({ code: "STORAGE_FILE_NOT_FOUND" }),
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("ÉTANT DONNÉ un chemin qui s'échappe de la racine QUAND on le lit ALORS STORAGE_PATH_OUTSIDE_ROOT", async () => {
|
|
47
|
-
const storage = createLocalSpecStorage(rootDir);
|
|
48
|
-
await expect(storage.read("../hors-racine")).rejects.toMatchObject({
|
|
49
|
-
error: expect.objectContaining({ code: "STORAGE_PATH_OUTSIDE_ROOT" }),
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent v1 SpecStorage implementation: the local filesystem, rooted at the
|
|
3
|
-
* repo the studio was launched in. Deliberately dumb — existence policy,
|
|
4
|
-
* parsing and transition rules all live in the service; this adapter only
|
|
5
|
-
* touches the disk. `message` is ignored: in v1 the dev commits by hand.
|
|
6
|
-
*/
|
|
7
|
-
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
-
import { dirname, join, normalize } from "node:path";
|
|
9
|
-
import { throwOgygieError } from "@ogygie/core/errors";
|
|
10
|
-
import type { FileEntry, SpecStorage } from "./spec-storage.js";
|
|
11
|
-
|
|
12
|
-
/** Refuses any path escaping the repo root — the studio serves localhost, but
|
|
13
|
-
* a traversal bug must still be structurally impossible. */
|
|
14
|
-
function resolveInsideRoot(rootDir: string, relativePath: string): string {
|
|
15
|
-
const absolute = normalize(join(rootDir, relativePath));
|
|
16
|
-
if (!absolute.startsWith(normalize(rootDir))) {
|
|
17
|
-
throwOgygieError(
|
|
18
|
-
{
|
|
19
|
-
code: "STORAGE_PATH_OUTSIDE_ROOT",
|
|
20
|
-
message: "The requested path escapes the repo root.",
|
|
21
|
-
file: null,
|
|
22
|
-
cause: `relative path "${relativePath}" resolves outside "${rootDir}"`,
|
|
23
|
-
suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json).",
|
|
24
|
-
},
|
|
25
|
-
400,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
return absolute;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function createLocalSpecStorage(rootDir: string): SpecStorage {
|
|
32
|
-
return {
|
|
33
|
-
async list(dir: string): Promise<FileEntry[]> {
|
|
34
|
-
const absolute = resolveInsideRoot(rootDir, dir);
|
|
35
|
-
try {
|
|
36
|
-
const entries = await readdir(absolute, { withFileTypes: true });
|
|
37
|
-
return entries
|
|
38
|
-
.filter((entry) => entry.isFile())
|
|
39
|
-
.map((entry) => ({ name: entry.name }));
|
|
40
|
-
} catch {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
async read(path: string): Promise<string> {
|
|
46
|
-
const absolute = resolveInsideRoot(rootDir, path);
|
|
47
|
-
try {
|
|
48
|
-
return await readFile(absolute, "utf8");
|
|
49
|
-
} catch {
|
|
50
|
-
return throwOgygieError({
|
|
51
|
-
code: "STORAGE_FILE_NOT_FOUND",
|
|
52
|
-
message: "The requested file cannot be read from the repo.",
|
|
53
|
-
file: path,
|
|
54
|
-
cause: `readFile failed for "${absolute}"`,
|
|
55
|
-
suggestedFix:
|
|
56
|
-
"Check that the studio was launched from an Ogygie repo root (specs/ + ogygie.manifest.json).",
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
async write(path: string, content: string, _message: string): Promise<void> {
|
|
62
|
-
const absolute = resolveInsideRoot(rootDir, path);
|
|
63
|
-
await mkdir(dirname(absolute), { recursive: true });
|
|
64
|
-
await writeFile(absolute, content, "utf8");
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent In-memory SpecStorage for the tests (no real fs, principle 7) and
|
|
3
|
-
* for any future embedded use. Exposes the underlying file map and the write
|
|
4
|
-
* messages so a test can assert BOTH the produced content and the declared
|
|
5
|
-
* intent ("spec: validate <id>") without touching a disk.
|
|
6
|
-
*/
|
|
7
|
-
import { throwOgygieError } from "@ogygie/core/errors";
|
|
8
|
-
import type { FileEntry, SpecStorage } from "./spec-storage.js";
|
|
9
|
-
|
|
10
|
-
export interface MemorySpecStorage extends SpecStorage {
|
|
11
|
-
/** Relative path → content. Mutable on purpose: tests arrange states here. */
|
|
12
|
-
files: Map<string, string>;
|
|
13
|
-
/** Every message passed to write(), in order. */
|
|
14
|
-
messages: string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function createMemorySpecStorage(
|
|
18
|
-
initialFiles: Record<string, string>,
|
|
19
|
-
): MemorySpecStorage {
|
|
20
|
-
const files = new Map(Object.entries(initialFiles));
|
|
21
|
-
const messages: string[] = [];
|
|
22
|
-
return {
|
|
23
|
-
files,
|
|
24
|
-
messages,
|
|
25
|
-
async list(dir: string): Promise<FileEntry[]> {
|
|
26
|
-
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
27
|
-
return [...files.keys()]
|
|
28
|
-
.filter((path) => path.startsWith(prefix) && !path.slice(prefix.length).includes("/"))
|
|
29
|
-
.map((path) => ({ name: path.slice(prefix.length) }));
|
|
30
|
-
},
|
|
31
|
-
async read(path: string): Promise<string> {
|
|
32
|
-
const content = files.get(path);
|
|
33
|
-
if (content === undefined) {
|
|
34
|
-
return throwOgygieError({
|
|
35
|
-
code: "STORAGE_FILE_NOT_FOUND",
|
|
36
|
-
message: "The requested file does not exist in the memory storage.",
|
|
37
|
-
file: path,
|
|
38
|
-
cause: `no entry for "${path}"`,
|
|
39
|
-
suggestedFix: "Arrange the file in the test's initialFiles first.",
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
return content;
|
|
43
|
-
},
|
|
44
|
-
async write(path: string, content: string, message: string): Promise<void> {
|
|
45
|
-
files.set(path, content);
|
|
46
|
-
messages.push(message);
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|