kitfly 0.1.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -0
  4. package/VERSION +1 -0
  5. package/package.json +63 -0
  6. package/schemas/README.md +32 -0
  7. package/schemas/site.schema.json +5 -0
  8. package/schemas/theme.schema.json +5 -0
  9. package/schemas/v0/site.schema.json +172 -0
  10. package/schemas/v0/theme.schema.json +210 -0
  11. package/scripts/build-all.ts +121 -0
  12. package/scripts/build.ts +601 -0
  13. package/scripts/bundle.ts +781 -0
  14. package/scripts/dev.ts +777 -0
  15. package/scripts/generate-checksums.sh +78 -0
  16. package/scripts/release/export-release-key.sh +28 -0
  17. package/scripts/release/release-guard-tag-version.sh +79 -0
  18. package/scripts/release/sign-release-assets.sh +123 -0
  19. package/scripts/release/upload-release-assets.sh +76 -0
  20. package/scripts/release/upload-release-provenance.sh +52 -0
  21. package/scripts/release/verify-public-key.sh +48 -0
  22. package/scripts/release/verify-signatures.sh +117 -0
  23. package/scripts/version-sync.ts +82 -0
  24. package/src/__tests__/build.test.ts +240 -0
  25. package/src/__tests__/bundle.test.ts +786 -0
  26. package/src/__tests__/cli.test.ts +706 -0
  27. package/src/__tests__/crucible.test.ts +1043 -0
  28. package/src/__tests__/engine.test.ts +157 -0
  29. package/src/__tests__/init.test.ts +450 -0
  30. package/src/__tests__/pipeline.test.ts +1087 -0
  31. package/src/__tests__/productbook.test.ts +1206 -0
  32. package/src/__tests__/runbook.test.ts +974 -0
  33. package/src/__tests__/server-registry.test.ts +1251 -0
  34. package/src/__tests__/servicebook.test.ts +1248 -0
  35. package/src/__tests__/shared.test.ts +2005 -0
  36. package/src/__tests__/styles.test.ts +14 -0
  37. package/src/__tests__/theme-schema.test.ts +47 -0
  38. package/src/__tests__/theme.test.ts +554 -0
  39. package/src/cli.ts +582 -0
  40. package/src/commands/init.ts +92 -0
  41. package/src/commands/update.ts +444 -0
  42. package/src/engine.ts +20 -0
  43. package/src/logger.ts +15 -0
  44. package/src/migrations/0000_schema_versioning.ts +67 -0
  45. package/src/migrations/0001_server_port.ts +52 -0
  46. package/src/migrations/0002_brand_logo.ts +49 -0
  47. package/src/migrations/index.ts +26 -0
  48. package/src/migrations/schema.ts +24 -0
  49. package/src/server-registry.ts +405 -0
  50. package/src/shared.ts +1239 -0
  51. package/src/site/styles.css +931 -0
  52. package/src/site/template.html +193 -0
  53. package/src/templates/crucible.ts +1163 -0
  54. package/src/templates/driver.ts +876 -0
  55. package/src/templates/handbook.ts +339 -0
  56. package/src/templates/minimal.ts +139 -0
  57. package/src/templates/pipeline.ts +966 -0
  58. package/src/templates/productbook.ts +1032 -0
  59. package/src/templates/runbook.ts +829 -0
  60. package/src/templates/schema.ts +119 -0
  61. package/src/templates/servicebook.ts +1242 -0
  62. package/src/theme.ts +245 -0
@@ -0,0 +1,444 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getMigrationsForVersion } from "../migrations/index.ts";
5
+ import type { SiteManifest, StandaloneProvenance } from "../templates/schema.ts";
6
+
7
+ const KITFLY_REPO = "3leaps/kitfly";
8
+ const GITHUB_RAW = "https://raw.githubusercontent.com";
9
+
10
+ export interface UpdateFlags {
11
+ check?: boolean;
12
+ dryRun?: boolean;
13
+ force?: boolean;
14
+ yes?: boolean;
15
+ migrationsOnly?: boolean;
16
+ // Dev/offline aid: update from local kitfly source tree.
17
+ local?: boolean;
18
+ }
19
+
20
+ class UpdateError extends Error {
21
+ name = "UpdateError";
22
+ }
23
+
24
+ function toArrayBuffer(data: Uint8Array): ArrayBuffer {
25
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
26
+ }
27
+
28
+ async function sha256Hex(data: Uint8Array): Promise<string> {
29
+ const hash = await crypto.subtle.digest("SHA-256", toArrayBuffer(data));
30
+ return Array.from(new Uint8Array(hash))
31
+ .map((b) => b.toString(16).padStart(2, "0"))
32
+ .join("");
33
+ }
34
+
35
+ async function readJson<T>(path: string): Promise<T> {
36
+ const raw = await readFile(path, "utf-8");
37
+ return JSON.parse(raw) as T;
38
+ }
39
+
40
+ async function writeJsonAtomic(path: string, obj: unknown): Promise<void> {
41
+ await ensureDir(dirname(path));
42
+ const tmp = `${path}.tmp.${Date.now()}`;
43
+ await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8");
44
+ await rename(tmp, path);
45
+ }
46
+
47
+ async function writeBytesAtomic(path: string, bytes: Uint8Array): Promise<void> {
48
+ await ensureDir(dirname(path));
49
+ const tmp = `${path}.tmp.${Date.now()}`;
50
+ await writeFile(tmp, bytes);
51
+ await rename(tmp, path);
52
+ }
53
+
54
+ async function ensureDir(path: string): Promise<void> {
55
+ await mkdir(path, { recursive: true });
56
+ }
57
+
58
+ const __dirname = dirname(fileURLToPath(import.meta.url));
59
+ const KITFLY_ROOT = resolve(join(__dirname, "../.."));
60
+
61
+ async function readLocalKitflyFileBytes(relPath: string): Promise<Uint8Array> {
62
+ const path = join(KITFLY_ROOT, relPath);
63
+ const buf = await readFile(path);
64
+ return new Uint8Array(buf);
65
+ }
66
+
67
+ async function fetchKitflyFileBytes(relPath: string, version: string): Promise<Uint8Array> {
68
+ if (process.env.KITFLY_UPDATE_SOURCE === "local") {
69
+ return readLocalKitflyFileBytes(relPath);
70
+ }
71
+
72
+ const ref = version === "latest" ? "main" : `v${version}`;
73
+ const url = `${GITHUB_RAW}/${KITFLY_REPO}/${ref}/${relPath}`;
74
+ const res = await fetch(url);
75
+ if (!res.ok) {
76
+ throw new UpdateError(`Failed to fetch ${relPath}: ${res.status} (${url})`);
77
+ }
78
+ const ab = await res.arrayBuffer();
79
+ return new Uint8Array(ab);
80
+ }
81
+
82
+ async function getLatestVersion(): Promise<string> {
83
+ const bytes = await fetchKitflyFileBytes("VERSION", "latest");
84
+ return new TextDecoder().decode(bytes).trim();
85
+ }
86
+
87
+ function parseSemver(v: string): [number, number, number] {
88
+ const [maj, min, pat] = v.split(".").map((n) => parseInt(n, 10));
89
+ return [maj || 0, min || 0, pat || 0];
90
+ }
91
+
92
+ function cmp(a: string, b: string): number {
93
+ const aa = parseSemver(a);
94
+ const bb = parseSemver(b);
95
+ for (let i = 0; i < 3; i++) {
96
+ if (aa[i] !== bb[i]) return aa[i] < bb[i] ? -1 : 1;
97
+ }
98
+ return 0;
99
+ }
100
+
101
+ async function promptConfirm(message: string): Promise<boolean> {
102
+ if (!process.stdin.isTTY) return false;
103
+ process.stdout.write(`${message} [y/N] `);
104
+ const chunks: Buffer[] = [];
105
+ return new Promise((resolve) => {
106
+ process.stdin.resume();
107
+ process.stdin.once("data", (d) => {
108
+ chunks.push(d as Buffer);
109
+ process.stdin.pause();
110
+ const ans = Buffer.concat(chunks).toString("utf-8").trim().toLowerCase();
111
+ resolve(ans === "y" || ans === "yes");
112
+ });
113
+ });
114
+ }
115
+
116
+ type FileDecision =
117
+ | { kind: "update"; path: string }
118
+ | { kind: "add"; path: string }
119
+ | { kind: "skip_modified"; path: string }
120
+ | { kind: "skip_missing"; path: string }
121
+ | { kind: "error"; path: string; error: string };
122
+
123
+ function uniq(paths: string[]): string[] {
124
+ return Array.from(new Set(paths));
125
+ }
126
+
127
+ function getSchemaVersionFromSiteYaml(content: string): string | null {
128
+ const m = content.match(/^schemaVersion\s*:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?/m);
129
+ return m ? m[1] : null;
130
+ }
131
+
132
+ async function readSiteYamlSchemaVersion(root: string): Promise<string | null> {
133
+ try {
134
+ const content = await readFile(join(root, "site.yaml"), "utf-8");
135
+ return getSchemaVersionFromSiteYaml(content);
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function formatPlan(from: string, to: string, decisions: FileDecision[], migs: string[]): string {
142
+ const updates = decisions.filter((d) => d.kind === "update");
143
+ const adds = decisions.filter((d) => d.kind === "add");
144
+ const modified = decisions.filter((d) => d.kind === "skip_modified");
145
+ const missing = decisions.filter((d) => d.kind === "skip_missing");
146
+ const errors = decisions.filter((d) => d.kind === "error");
147
+
148
+ const lines: string[] = [];
149
+ lines.push(`Kitfly Update: ${from} -> ${to}`);
150
+ lines.push("");
151
+
152
+ if (updates.length) {
153
+ lines.push("Files to update (unmodified):");
154
+ for (const d of updates) lines.push(` - ${d.path}`);
155
+ lines.push("");
156
+ }
157
+ if (adds.length) {
158
+ lines.push("New files to add:");
159
+ for (const d of adds) lines.push(` - ${d.path}`);
160
+ lines.push("");
161
+ }
162
+ if (modified.length) {
163
+ lines.push("Files with local changes:");
164
+ for (const d of modified) lines.push(` - ${d.path} (modified; will skip)`);
165
+ lines.push("");
166
+ }
167
+ if (missing.length) {
168
+ lines.push("Files missing locally:");
169
+ for (const d of missing) lines.push(` - ${d.path} (missing; will skip)`);
170
+ lines.push("");
171
+ }
172
+ if (errors.length) {
173
+ lines.push("Fetch errors:");
174
+ for (const d of errors) lines.push(` - ${d.path}: ${d.error}`);
175
+ lines.push("");
176
+ }
177
+ if (migs.length) {
178
+ lines.push("Config migrations:");
179
+ for (const m of migs) lines.push(` - ${m}`);
180
+ lines.push("");
181
+ }
182
+ if (!updates.length && !adds.length && !modified.length && !missing.length && !errors.length) {
183
+ lines.push("No managed files found in provenance.");
184
+ lines.push("");
185
+ }
186
+
187
+ return lines.join("\n");
188
+ }
189
+
190
+ export async function update(
191
+ versionArg: string | undefined,
192
+ flags: UpdateFlags = {},
193
+ ): Promise<void> {
194
+ const root = resolve(process.cwd());
195
+
196
+ if (flags.local) {
197
+ process.env.KITFLY_UPDATE_SOURCE = "local";
198
+ }
199
+ const manifestPath = join(root, ".kitfly/manifest.json");
200
+ const provenancePath = join(root, ".kitfly/provenance.json");
201
+
202
+ let manifest: SiteManifest;
203
+ try {
204
+ manifest = await readJson<SiteManifest>(manifestPath);
205
+ } catch {
206
+ throw new UpdateError(
207
+ `No .kitfly/manifest.json found.\nThis doesn't appear to be a kitfly site, or it was created before metadata tracking.`,
208
+ );
209
+ }
210
+
211
+ if (!manifest.standalone) {
212
+ throw new UpdateError(
213
+ `This site is not in standalone mode.\n\nUpdate kitfly globally instead:\n bun update -g kitfly`,
214
+ );
215
+ }
216
+
217
+ let provenance: StandaloneProvenance;
218
+ try {
219
+ provenance = await readJson<StandaloneProvenance>(provenancePath);
220
+ } catch {
221
+ throw new UpdateError(
222
+ `No .kitfly/provenance.json found.\nThis site may have been created before provenance tracking.`,
223
+ );
224
+ }
225
+
226
+ const currentVersion = manifest.kitflyVersion || provenance.kitflyVersion || "0.0.0";
227
+ let targetVersion: string;
228
+ try {
229
+ targetVersion = versionArg ? versionArg : await getLatestVersion();
230
+ } catch (e) {
231
+ const msg = e instanceof Error ? e.message : String(e);
232
+ throw new UpdateError(
233
+ `Failed to determine latest kitfly version from GitHub.\n${msg}\n\nIf you're developing locally (no remote yet), try:\n kitfly update --check --local`,
234
+ );
235
+ }
236
+
237
+ if (flags.check) {
238
+ const latest = versionArg ? targetVersion : targetVersion;
239
+ console.log(`Current: ${currentVersion}`);
240
+ console.log(`Latest: ${latest}`);
241
+ if (cmp(currentVersion, latest) === 0) {
242
+ console.log("\nUp to date.");
243
+ } else {
244
+ console.log("\nRun 'kitfly update' to upgrade.");
245
+ }
246
+ return;
247
+ }
248
+
249
+ if (cmp(currentVersion, targetVersion) === 0) {
250
+ console.log(`Already on kitfly ${currentVersion}`);
251
+ return;
252
+ }
253
+
254
+ const baselinePaths = provenance.files.map((f) => f.path);
255
+ const managedPaths = uniq([
256
+ ...baselinePaths,
257
+ // Ensure schema files can be introduced on update.
258
+ "schemas/README.md",
259
+ "schemas/site.schema.json",
260
+ "schemas/theme.schema.json",
261
+ "schemas/v0/site.schema.json",
262
+ "schemas/v0/theme.schema.json",
263
+ ]);
264
+
265
+ const baselineByPath = new Map(provenance.files.map((f) => [f.path, f] as const));
266
+ const decisions: FileDecision[] = [];
267
+ const remoteCache = new Map<string, { bytes: Uint8Array; hash: string }>();
268
+
269
+ for (const relPath of managedPaths) {
270
+ const localPath = join(root, relPath);
271
+ const baseline = baselineByPath.get(relPath);
272
+
273
+ // Determine local hash/state
274
+ let localBytes: Uint8Array | null = null;
275
+ let localHash: string | null = null;
276
+ try {
277
+ const buf = await readFile(localPath);
278
+ localBytes = new Uint8Array(buf);
279
+ localHash = await sha256Hex(localBytes);
280
+ } catch {
281
+ localBytes = null;
282
+ localHash = null;
283
+ }
284
+
285
+ if (baseline) {
286
+ // Existing managed file
287
+ if (!localBytes || !localHash) {
288
+ decisions.push({ kind: "skip_missing", path: relPath });
289
+ continue;
290
+ }
291
+ if (localHash !== baseline.sourceHash) {
292
+ decisions.push({ kind: "skip_modified", path: relPath });
293
+ continue;
294
+ }
295
+ decisions.push({ kind: "update", path: relPath });
296
+ } else {
297
+ // New file introduced in newer kitfly
298
+ if (localBytes) {
299
+ decisions.push({ kind: "skip_modified", path: relPath });
300
+ } else {
301
+ decisions.push({ kind: "add", path: relPath });
302
+ }
303
+ }
304
+ }
305
+
306
+ // Pre-fetch remote content for files we intend to write.
307
+ for (const d of decisions) {
308
+ if (d.kind !== "update" && d.kind !== "add") continue;
309
+ try {
310
+ const bytes = await fetchKitflyFileBytes(d.path, targetVersion);
311
+ const hash = await sha256Hex(bytes);
312
+ remoteCache.set(d.path, { bytes, hash });
313
+ } catch (e) {
314
+ const msg = e instanceof Error ? e.message : String(e);
315
+ remoteCache.delete(d.path);
316
+ // Replace decision with error
317
+ const idx = decisions.findIndex((x) => x.kind === d.kind && x.path === d.path);
318
+ if (idx >= 0) decisions[idx] = { kind: "error", path: d.path, error: msg };
319
+ }
320
+ }
321
+
322
+ const fromSchema = (await readSiteYamlSchemaVersion(root)) || manifest.schemaVersion || "0.1.0";
323
+ const toSchema = targetVersion; // schema $version tracks release in v0.x
324
+ const migs = getMigrationsForVersion(fromSchema, toSchema);
325
+
326
+ const planText = formatPlan(
327
+ currentVersion,
328
+ targetVersion,
329
+ decisions,
330
+ migs.map((m) => `${m.id}: ${m.description}`),
331
+ );
332
+ console.log(planText);
333
+
334
+ if (flags.dryRun) return;
335
+
336
+ if (!flags.yes) {
337
+ const ok = await promptConfirm(`Proceed?`);
338
+ if (!ok) {
339
+ console.log("Cancelled.");
340
+ return;
341
+ }
342
+ }
343
+
344
+ if (flags.migrationsOnly) {
345
+ for (const m of migs) {
346
+ const ctx = { root, manifest, fromSchemaVersion: fromSchema, toSchemaVersion: toSchema };
347
+ if (await m.applies(ctx)) {
348
+ const res = await m.apply(ctx);
349
+ console.log(`${m.id}: ${res.message}`);
350
+ }
351
+ }
352
+ return;
353
+ }
354
+
355
+ // Apply file updates
356
+ const now = new Date().toISOString();
357
+ let changed = 0;
358
+ let skipped = 0;
359
+
360
+ for (const d of decisions) {
361
+ if (d.kind === "skip_modified" || d.kind === "skip_missing") {
362
+ skipped++;
363
+ continue;
364
+ }
365
+ if (d.kind === "error") {
366
+ skipped++;
367
+ continue;
368
+ }
369
+
370
+ const entry = remoteCache.get(d.path);
371
+ if (!entry) {
372
+ skipped++;
373
+ continue;
374
+ }
375
+
376
+ // If baseline existed and file is modified, require --force.
377
+ const baseline = baselineByPath.get(d.path);
378
+ if (baseline) {
379
+ const localPath = join(root, d.path);
380
+ let localHash: string | null = null;
381
+ try {
382
+ const buf = await readFile(localPath);
383
+ localHash = await sha256Hex(new Uint8Array(buf));
384
+ } catch {
385
+ localHash = null;
386
+ }
387
+ if (!localHash || localHash !== baseline.sourceHash) {
388
+ if (!flags.force) {
389
+ skipped++;
390
+ continue;
391
+ }
392
+ }
393
+ }
394
+
395
+ await writeBytesAtomic(join(root, d.path), entry.bytes);
396
+ changed++;
397
+
398
+ // Update provenance entry (create if new)
399
+ const localHash = await sha256Hex(entry.bytes);
400
+ const existing = baselineByPath.get(d.path);
401
+ if (existing) {
402
+ existing.localHash = localHash;
403
+ existing.modified = false;
404
+ existing.sourceHash = entry.hash;
405
+ } else {
406
+ provenance.files.push({ path: d.path, sourceHash: entry.hash, localHash, modified: false });
407
+ baselineByPath.set(d.path, provenance.files[provenance.files.length - 1]);
408
+ }
409
+ }
410
+
411
+ // Mark skipped baseline files as modified (best-effort)
412
+ for (const d of decisions) {
413
+ if (d.kind !== "skip_modified" && d.kind !== "skip_missing") continue;
414
+ const entry = baselineByPath.get(d.path);
415
+ if (!entry) continue;
416
+ entry.modified = true;
417
+ // localHash is unknown/irrelevant if missing
418
+ if (d.kind === "skip_missing") entry.localHash = undefined;
419
+ }
420
+
421
+ // Apply migrations after file updates
422
+ for (const m of migs) {
423
+ const ctx = { root, manifest, fromSchemaVersion: fromSchema, toSchemaVersion: toSchema };
424
+ if (await m.applies(ctx)) {
425
+ await m.apply(ctx);
426
+ }
427
+ }
428
+
429
+ // Update metadata
430
+ const prev = manifest.kitflyVersion;
431
+ manifest.kitflyVersion = targetVersion;
432
+ manifest.lastUpdated = now;
433
+ manifest.updateHistory = manifest.updateHistory || [];
434
+ manifest.updateHistory.push({ from: prev, to: targetVersion, date: now });
435
+ manifest.schemaVersion = toSchema;
436
+
437
+ provenance.kitflyVersion = targetVersion;
438
+ provenance.updatedAt = now;
439
+
440
+ await writeJsonAtomic(manifestPath, manifest);
441
+ await writeJsonAtomic(provenancePath, provenance);
442
+
443
+ console.log(`\nUpdated ${changed} file(s). Skipped ${skipped} file(s).`);
444
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Paths for Kitfly's built-in engine assets.
3
+ *
4
+ * The "engine" lives next to the CLI code (template, styles, default assets).
5
+ * A user's "site" lives in the folder they point Kitfly at.
6
+ */
7
+
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ export const ENGINE_ROOT = resolve(join(dirname(fileURLToPath(import.meta.url)), ".."));
12
+
13
+ export const ENGINE_SITE_DIR = join(ENGINE_ROOT, "src/site");
14
+ export const ENGINE_ASSETS_DIR = join(ENGINE_ROOT, "assets");
15
+
16
+ export const SITE_OVERRIDE_DIRNAME = "kitfly";
17
+
18
+ export function siteOverridePath(siteRoot: string, relPathFromKitflyDir: string): string {
19
+ return join(siteRoot, SITE_OVERRIDE_DIRNAME, relPathFromKitflyDir);
20
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * CLI Logger - Structured logging for kitfly CLI operations
3
+ *
4
+ * Uses tsfulmen's simple logger. This is CLI-only code
5
+ * and is NOT copied to standalone sites via `kitfly init`.
6
+ *
7
+ * Note: scripts/dev.ts uses dynamic import of tsfulmen/logging
8
+ * directly (with try/catch) since it's site code that must work
9
+ * without tsfulmen in standalone mode.
10
+ */
11
+
12
+ import { createSimpleLogger } from "@fulmenhq/tsfulmen/logging";
13
+
14
+ /** CLI logger for kitfly operations */
15
+ export const log = createSimpleLogger("kitfly");
@@ -0,0 +1,67 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Migration, MigrationContext } from "./schema.ts";
4
+
5
+ function ensureYamlSchemaComment(content: string, schemaPath: string): string {
6
+ const lines = content.split("\n");
7
+ const desired = `# yaml-language-server: $schema=${schemaPath}`;
8
+ if (lines[0]?.startsWith("# yaml-language-server:")) {
9
+ lines[0] = desired;
10
+ return lines.join("\n");
11
+ }
12
+ return `${desired}\n${content}`;
13
+ }
14
+
15
+ function ensureSiteSchemaVersion(content: string, version: string): string {
16
+ if (/^schemaVersion\s*:/m.test(content)) return content;
17
+ const lines = content.split("\n");
18
+ const insertLine = `schemaVersion: "${version}"`;
19
+ if (lines[0]?.startsWith("# yaml-language-server:")) {
20
+ lines.splice(1, 0, insertLine);
21
+ return lines.join("\n");
22
+ }
23
+ return `${insertLine}\n${content}`;
24
+ }
25
+
26
+ export const migration: Migration = {
27
+ id: "0000_schema_versioning",
28
+ version: "0.1.0",
29
+ description: "Add schemaVersion and update $schema comment paths",
30
+ breaking: false,
31
+ applies: async (ctx: MigrationContext) => {
32
+ const sitePath = join(ctx.root, "site.yaml");
33
+ try {
34
+ const content = await readFile(sitePath, "utf-8");
35
+ return (
36
+ !content.startsWith("# yaml-language-server: $schema=./schemas/v0/site.schema.json") ||
37
+ !/^schemaVersion\s*:/m.test(content)
38
+ );
39
+ } catch {
40
+ return false;
41
+ }
42
+ },
43
+ describe: () =>
44
+ [
45
+ "Ensures site.yaml includes:",
46
+ "",
47
+ "# yaml-language-server: $schema=./schemas/v0/site.schema.json",
48
+ 'schemaVersion: "0.1.0"',
49
+ "",
50
+ "This is additive; it does not change runtime behavior.",
51
+ ].join("\n"),
52
+ apply: async (ctx: MigrationContext) => {
53
+ const sitePath = join(ctx.root, "site.yaml");
54
+ let content: string;
55
+ try {
56
+ content = await readFile(sitePath, "utf-8");
57
+ } catch {
58
+ return { applied: false, message: "site.yaml not found" };
59
+ }
60
+
61
+ let next = ensureYamlSchemaComment(content, "./schemas/v0/site.schema.json");
62
+ next = ensureSiteSchemaVersion(next, ctx.toSchemaVersion);
63
+ if (next === content) return { applied: false, message: "site.yaml already up to date" };
64
+ await writeFile(sitePath, next, "utf-8");
65
+ return { applied: true, message: "Updated site.yaml schema header" };
66
+ },
67
+ };
@@ -0,0 +1,52 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Migration, MigrationContext } from "./schema.ts";
4
+
5
+ const SNIPPET = [
6
+ "",
7
+ "# Server configuration (optional)",
8
+ "# server:",
9
+ "# port: 3333",
10
+ '# host: "localhost"',
11
+ "",
12
+ ].join("\n");
13
+
14
+ export const migration: Migration = {
15
+ id: "0001_server_port",
16
+ version: "0.1.0",
17
+ description: "Add server.port configuration option (commented)",
18
+ breaking: false,
19
+ applies: async (ctx: MigrationContext) => {
20
+ const sitePath = join(ctx.root, "site.yaml");
21
+ try {
22
+ const content = await readFile(sitePath, "utf-8");
23
+ return !/^server\s*:/m.test(content);
24
+ } catch {
25
+ return false;
26
+ }
27
+ },
28
+ describe: () =>
29
+ [
30
+ "Adds an optional server section you can enable:",
31
+ "",
32
+ "server:",
33
+ " port: 3333",
34
+ ' host: "localhost"',
35
+ "",
36
+ "Kitfly defaults remain unchanged if you skip this.",
37
+ ].join("\n"),
38
+ apply: async (ctx: MigrationContext) => {
39
+ const sitePath = join(ctx.root, "site.yaml");
40
+ let content: string;
41
+ try {
42
+ content = await readFile(sitePath, "utf-8");
43
+ } catch {
44
+ return { applied: false, message: "site.yaml not found" };
45
+ }
46
+ if (/^server\s*:/m.test(content)) {
47
+ return { applied: false, message: "server config already exists" };
48
+ }
49
+ await writeFile(sitePath, `${content.replace(/\s+$/, "")}\n${SNIPPET}`, "utf-8");
50
+ return { applied: true, message: "Appended server config snippet" };
51
+ },
52
+ };
@@ -0,0 +1,49 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Migration, MigrationContext } from "./schema.ts";
4
+
5
+ const SNIPPET = [
6
+ "",
7
+ "# Brand assets (optional)",
8
+ "# brand:",
9
+ '# logo: "assets/brand/logo.png"',
10
+ '# favicon: "assets/brand/favicon.png"',
11
+ '# logoType: "icon" # icon | wordmark',
12
+ "",
13
+ ].join("\n");
14
+
15
+ export const migration: Migration = {
16
+ id: "0002_brand_logo",
17
+ version: "0.1.0",
18
+ description: "Add brand.logo and brand.favicon options (commented)",
19
+ breaking: false,
20
+ applies: async (ctx: MigrationContext) => {
21
+ const sitePath = join(ctx.root, "site.yaml");
22
+ try {
23
+ const content = await readFile(sitePath, "utf-8");
24
+ return !/\n\s*logo\s*:/m.test(content) && !/\n\s*favicon\s*:/m.test(content);
25
+ } catch {
26
+ return false;
27
+ }
28
+ },
29
+ describe: () =>
30
+ [
31
+ "Adds optional brand asset fields (logo + favicon).",
32
+ "",
33
+ "These are already supported by Kitfly defaults; this just documents them in site.yaml.",
34
+ ].join("\n"),
35
+ apply: async (ctx: MigrationContext) => {
36
+ const sitePath = join(ctx.root, "site.yaml");
37
+ let content: string;
38
+ try {
39
+ content = await readFile(sitePath, "utf-8");
40
+ } catch {
41
+ return { applied: false, message: "site.yaml not found" };
42
+ }
43
+ if (/\n\s*logo\s*:/m.test(content) || /\n\s*favicon\s*:/m.test(content)) {
44
+ return { applied: false, message: "brand asset fields already present" };
45
+ }
46
+ await writeFile(sitePath, `${content.replace(/\s+$/, "")}\n${SNIPPET}`, "utf-8");
47
+ return { applied: true, message: "Appended brand asset snippet" };
48
+ },
49
+ };
@@ -0,0 +1,26 @@
1
+ import { migration as schemaVersioning } from "./0000_schema_versioning.ts";
2
+ import { migration as serverPort } from "./0001_server_port.ts";
3
+ import { migration as brandLogo } from "./0002_brand_logo.ts";
4
+ import type { Migration } from "./schema.ts";
5
+
6
+ export const migrations: Migration[] = [schemaVersioning, serverPort, brandLogo];
7
+
8
+ function parseSemver(v: string): [number, number, number] {
9
+ const [maj, min, pat] = v.split(".").map((n) => parseInt(n, 10));
10
+ return [maj || 0, min || 0, pat || 0];
11
+ }
12
+
13
+ function cmp(a: string, b: string): number {
14
+ const aa = parseSemver(a);
15
+ const bb = parseSemver(b);
16
+ for (let i = 0; i < 3; i++) {
17
+ if (aa[i] !== bb[i]) return aa[i] < bb[i] ? -1 : 1;
18
+ }
19
+ return 0;
20
+ }
21
+
22
+ export function getMigrationsForVersion(fromVersion: string, toVersion: string): Migration[] {
23
+ return migrations.filter(
24
+ (m) => cmp(m.version, fromVersion) === 1 && cmp(m.version, toVersion) <= 0,
25
+ );
26
+ }
@@ -0,0 +1,24 @@
1
+ import type { SiteManifest } from "../templates/schema.ts";
2
+
3
+ export interface MigrationContext {
4
+ root: string;
5
+ manifest: SiteManifest;
6
+ fromSchemaVersion: string;
7
+ toSchemaVersion: string;
8
+ }
9
+
10
+ export interface MigrationResult {
11
+ applied: boolean;
12
+ message: string;
13
+ }
14
+
15
+ export interface Migration {
16
+ id: string;
17
+ version: string; // Schema version that introduced this change
18
+ description: string;
19
+ breaking: boolean;
20
+
21
+ applies: (ctx: MigrationContext) => Promise<boolean> | boolean;
22
+ describe: () => string;
23
+ apply: (ctx: MigrationContext) => Promise<MigrationResult>;
24
+ }