@cosmicdrift/kumiko-dev-server 0.75.0 → 0.77.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.77.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.77.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -15,7 +15,7 @@ describe("scaffoldApp", () => {
|
|
|
15
15
|
rmSync(tmp, { recursive: true, force: true });
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test("scaffolds
|
|
18
|
+
test("scaffolds the expected files into <cwd>/<name>", () => {
|
|
19
19
|
const dest = join(tmp, "my-shop");
|
|
20
20
|
const result = scaffoldApp({ name: "my-shop", destination: dest });
|
|
21
21
|
|
|
@@ -26,6 +26,7 @@ describe("scaffoldApp", () => {
|
|
|
26
26
|
"tsconfig.json",
|
|
27
27
|
"src/run-config.ts",
|
|
28
28
|
"bin/main.ts",
|
|
29
|
+
"bin/dev.ts",
|
|
29
30
|
".env.example",
|
|
30
31
|
"README.md",
|
|
31
32
|
]);
|
|
@@ -48,6 +49,53 @@ describe("scaffoldApp", () => {
|
|
|
48
49
|
expect(pkg.dependencies["@cosmicdrift/kumiko-dev-server"]).toBe("^0.13.0");
|
|
49
50
|
expect(pkg.dependencies["@cosmicdrift/kumiko-framework"]).toBe("^0.13.0");
|
|
50
51
|
expect(pkg.scripts["boot"]).toContain("KUMIKO_DRY_RUN_ENV=boot");
|
|
52
|
+
expect(pkg.scripts["dev"]).toBe("bun --watch bin/dev.ts");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("bin/dev.ts contains runDevApp + welcomeBanner + admin login", () => {
|
|
56
|
+
const dest = join(tmp, "my-shop");
|
|
57
|
+
scaffoldApp({ name: "my-shop", destination: dest });
|
|
58
|
+
|
|
59
|
+
const dev = readFileSync(join(dest, "bin/dev.ts"), "utf-8");
|
|
60
|
+
expect(dev).toContain("runDevApp");
|
|
61
|
+
expect(dev).toContain("welcomeBanner: true");
|
|
62
|
+
expect(dev).toContain("admin@my-shop.local");
|
|
63
|
+
expect(dev).toContain(`password: "changeme"`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", () => {
|
|
67
|
+
const dest = join(tmp, "my-shop");
|
|
68
|
+
scaffoldApp({ name: "my-shop", destination: dest });
|
|
69
|
+
|
|
70
|
+
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
71
|
+
expect(env).toContain("KUMIKO_DEV_DB_NAME=my_shop_dev");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("README lists the mounted features dynamically", () => {
|
|
75
|
+
const dest = join(tmp, "my-shop");
|
|
76
|
+
scaffoldApp({
|
|
77
|
+
name: "my-shop",
|
|
78
|
+
destination: dest,
|
|
79
|
+
features: [
|
|
80
|
+
{
|
|
81
|
+
name: "tenant",
|
|
82
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/tenant",
|
|
83
|
+
exportName: "createTenantFeature",
|
|
84
|
+
callExpression: "createTenantFeature()",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "delivery",
|
|
88
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/delivery",
|
|
89
|
+
exportName: "createDeliveryFeature",
|
|
90
|
+
callExpression: "createDeliveryFeature()",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const readme = readFileSync(join(dest, "README.md"), "utf-8");
|
|
96
|
+
expect(readme).toContain("## Mounted features");
|
|
97
|
+
expect(readme).toContain("- `tenant`");
|
|
98
|
+
expect(readme).toContain("- `delivery`");
|
|
51
99
|
});
|
|
52
100
|
|
|
53
101
|
test("bin/main.ts contains runProdApp + auth.admin stub", () => {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Phase-2 Validator (Plan-Doc create-kumiko-app.md, Risk #1): pinnt dass
|
|
2
|
+
// setupTestStack auf persistent DB einen Reboot mit erweitertem Feature-Set
|
|
3
|
+
// überlebt — eine neue r.entity ohne Migration-CLI muss die Tabelle anlegen,
|
|
4
|
+
// und ein bereits existierendes Entity darf nicht crashen (duplicate CREATE).
|
|
5
|
+
//
|
|
6
|
+
// Das ist der Pfad den `bun dev` mit KUMIKO_DEV_DB_NAME triggert: der
|
|
7
|
+
// Dev-User editiert src/features/notes.ts, `bun --watch` rebootet den Process,
|
|
8
|
+
// runDevApp ruft createKumikoServer → setupTestStack mit denselben DB-Name
|
|
9
|
+
// auf, und das neue Notes-Entity muss als Tabelle erscheinen ohne dass der
|
|
10
|
+
// User `kumiko schema apply` aufgerufen hat. Bricht der Filter in
|
|
11
|
+
// push-entity-projection-tables (test-stack.ts:200-209), lügt der Dev-Flow.
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient, createDbConnection, tableExists } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
createEntity,
|
|
17
|
+
createTextField,
|
|
18
|
+
defineFeature,
|
|
19
|
+
type FeatureDefinition,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import {
|
|
22
|
+
pushEntityProjectionTables,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
27
|
+
|
|
28
|
+
const baseEntity = createEntity({
|
|
29
|
+
fields: { title: createTextField({ required: true }) },
|
|
30
|
+
table: "phase2_base_thing",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const noteEntity = createEntity({
|
|
34
|
+
fields: { title: createTextField({ required: true }) },
|
|
35
|
+
table: "phase2_note",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const baseFeature: FeatureDefinition = defineFeature("phase2-base", (r) => {
|
|
39
|
+
r.entity("base-thing", baseEntity);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const notesFeature: FeatureDefinition = defineFeature("phase2-notes", (r) => {
|
|
43
|
+
r.entity("note", noteEntity);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const dbName = `kumiko_phase2_${generateId().slice(-8)}`;
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
const base = process.env["TEST_DATABASE_URL"];
|
|
50
|
+
if (!base) return;
|
|
51
|
+
const adminUrl = base.replace(/\/[^/]+$/, "/postgres");
|
|
52
|
+
const admin = createDbConnection(adminUrl, { maxConnections: 1 });
|
|
53
|
+
try {
|
|
54
|
+
await asRawClient(admin.db).unsafe(`DROP DATABASE IF EXISTS "${dbName}"`);
|
|
55
|
+
} finally {
|
|
56
|
+
await admin.close();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("scaffold dev cycle (Phase 2 Validator)", () => {
|
|
61
|
+
test("persistent reboot with added entity creates only the new table", async () => {
|
|
62
|
+
const base = process.env["TEST_DATABASE_URL"];
|
|
63
|
+
if (!base) throw new Error("TEST_DATABASE_URL required");
|
|
64
|
+
|
|
65
|
+
// Boot 1: only base feature, persistent DB.
|
|
66
|
+
const stack1: TestStack = await setupTestStack({
|
|
67
|
+
features: [baseFeature],
|
|
68
|
+
dbName,
|
|
69
|
+
persistentDb: true,
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await pushEntityProjectionTables(stack1, stack1.registry);
|
|
73
|
+
expect(await tableExists(stack1.db, "public.phase2_base_thing")).toBe(true);
|
|
74
|
+
expect(await tableExists(stack1.db, "public.phase2_note")).toBe(false);
|
|
75
|
+
} finally {
|
|
76
|
+
await stack1.cleanup();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Boot 2: base + notes — simulates the Dev-User edit + bun --watch reboot.
|
|
80
|
+
// pushEntityProjectionTables MUSS phase2_base_thing skippen (existiert)
|
|
81
|
+
// und phase2_note neu anlegen — sonst crash auf duplicate CREATE TABLE
|
|
82
|
+
// oder die Notes-Tabelle fehlt.
|
|
83
|
+
const stack2: TestStack = await setupTestStack({
|
|
84
|
+
features: [baseFeature, notesFeature],
|
|
85
|
+
dbName,
|
|
86
|
+
persistentDb: true,
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
await pushEntityProjectionTables(stack2, stack2.registry);
|
|
90
|
+
expect(await tableExists(stack2.db, "public.phase2_base_thing")).toBe(true);
|
|
91
|
+
expect(await tableExists(stack2.db, "public.phase2_note")).toBe(true);
|
|
92
|
+
} finally {
|
|
93
|
+
await stack2.cleanup();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Boot 3 with same feature-set must remain idempotent (no double-CREATE).
|
|
97
|
+
const stack3: TestStack = await setupTestStack({
|
|
98
|
+
features: [baseFeature, notesFeature],
|
|
99
|
+
dbName,
|
|
100
|
+
persistentDb: true,
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
await pushEntityProjectionTables(stack3, stack3.registry);
|
|
104
|
+
expect(await tableExists(stack3.db, "public.phase2_note")).toBe(true);
|
|
105
|
+
} finally {
|
|
106
|
+
await stack3.cleanup();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// docs without an actual `bunx … && bun install && bun run boot` CI run.
|
|
5
5
|
//
|
|
6
6
|
// What this test pins:
|
|
7
|
-
// - scaffoldApp produces the
|
|
7
|
+
// - scaffoldApp produces the files the walkthrough lists
|
|
8
8
|
// - scaffoldAppFeature scaffolds + auto-mounts (the diff-block shown)
|
|
9
9
|
// - composeFeatures(includeBundled:true) yields the exact feature-count
|
|
10
10
|
// the walkthrough advertises in "Expected output"
|
|
@@ -32,13 +32,14 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
32
32
|
rmSync(tmp, { recursive: true, force: true });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test("Step 1 (kumiko new app) — produces walkthrough's
|
|
35
|
+
test("Step 1 (kumiko new app) — produces walkthrough's scaffold files", () => {
|
|
36
36
|
const result = scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
37
37
|
expect(result.files).toEqual([
|
|
38
38
|
"package.json",
|
|
39
39
|
"tsconfig.json",
|
|
40
40
|
"src/run-config.ts",
|
|
41
41
|
"bin/main.ts",
|
|
42
|
+
"bin/dev.ts",
|
|
42
43
|
".env.example",
|
|
43
44
|
"README.md",
|
|
44
45
|
]);
|
package/src/scaffold-app.ts
CHANGED
|
@@ -80,10 +80,13 @@ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
|
80
80
|
write(join(destination, "bin", "main.ts"), renderMain(options.name));
|
|
81
81
|
files.push("bin/main.ts");
|
|
82
82
|
|
|
83
|
-
write(join(destination, ".
|
|
83
|
+
write(join(destination, "bin", "dev.ts"), renderDev(options.name));
|
|
84
|
+
files.push("bin/dev.ts");
|
|
85
|
+
|
|
86
|
+
write(join(destination, ".env.example"), renderEnvExample(options.name));
|
|
84
87
|
files.push(".env.example");
|
|
85
88
|
|
|
86
|
-
write(join(destination, "README.md"), renderReadme(options.name));
|
|
89
|
+
write(join(destination, "README.md"), renderReadme(options.name, options.features));
|
|
87
90
|
files.push("README.md");
|
|
88
91
|
|
|
89
92
|
return { destination, files, appName: options.name };
|
|
@@ -102,6 +105,7 @@ function renderPackageJson(name: string, version: string): string {
|
|
|
102
105
|
private: true,
|
|
103
106
|
type: "module",
|
|
104
107
|
scripts: {
|
|
108
|
+
dev: "bun --watch bin/dev.ts",
|
|
105
109
|
boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
|
|
106
110
|
check: "tsc --noEmit",
|
|
107
111
|
},
|
|
@@ -313,7 +317,81 @@ function renderMain(appName: string): string {
|
|
|
313
317
|
return sf.getFullText();
|
|
314
318
|
}
|
|
315
319
|
|
|
316
|
-
function
|
|
320
|
+
function renderDev(appName: string): string {
|
|
321
|
+
const tenantId = deriveTenantId(appName);
|
|
322
|
+
const project = newTsProject();
|
|
323
|
+
const sf = project.createSourceFile("dev.ts", "");
|
|
324
|
+
|
|
325
|
+
sf.addImportDeclaration({
|
|
326
|
+
moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
|
|
327
|
+
namedImports: ["runDevApp"],
|
|
328
|
+
});
|
|
329
|
+
sf.addImportDeclaration({
|
|
330
|
+
moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
|
|
331
|
+
isTypeOnly: true,
|
|
332
|
+
namedImports: ["TenantId"],
|
|
333
|
+
});
|
|
334
|
+
sf.addImportDeclaration({
|
|
335
|
+
moduleSpecifier: "../src/run-config",
|
|
336
|
+
namedImports: ["APP_FEATURES"],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
sf.addVariableStatement({
|
|
340
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
341
|
+
declarations: [
|
|
342
|
+
{
|
|
343
|
+
name: "DEFAULT_TENANT_ID",
|
|
344
|
+
initializer: `"${tenantId}" as TenantId`,
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
sf.addStatements((writer) => {
|
|
350
|
+
writer
|
|
351
|
+
.write("await runDevApp(")
|
|
352
|
+
.inlineBlock(() => {
|
|
353
|
+
writer.writeLine("features: APP_FEATURES,");
|
|
354
|
+
writer.writeLine("welcomeBanner: true,");
|
|
355
|
+
writer.write("auth: ").inlineBlock(() => {
|
|
356
|
+
writer.write("admin: ").inlineBlock(() => {
|
|
357
|
+
writer.writeLine(`email: "admin@${appName}.local",`);
|
|
358
|
+
writer.writeLine(`password: "changeme",`);
|
|
359
|
+
writer.writeLine(`displayName: "Admin",`);
|
|
360
|
+
writer.write("memberships: [");
|
|
361
|
+
writer.indent(() => {
|
|
362
|
+
writer.inlineBlock(() => {
|
|
363
|
+
writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
|
|
364
|
+
writer.writeLine(`tenantKey: "${appName}",`);
|
|
365
|
+
writer.writeLine(`tenantName: "${appName}",`);
|
|
366
|
+
writer.writeLine(`roles: ["TenantAdmin"],`);
|
|
367
|
+
});
|
|
368
|
+
writer.write(",");
|
|
369
|
+
});
|
|
370
|
+
writer.write("],");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
})
|
|
374
|
+
.write(");");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
sf.insertText(
|
|
378
|
+
0,
|
|
379
|
+
[
|
|
380
|
+
"// Dev-bootstrap. `bun --watch bin/dev.ts` (siehe package.json scripts.dev)",
|
|
381
|
+
"// startet einen full-featured Dev-Server mit Auto-Reload bei Code-Änderungen.",
|
|
382
|
+
"// setupTestStack legt fehlende Entity-Tabellen automatisch an — neues",
|
|
383
|
+
"// r.entity(...) in einem Feature führt beim nächsten Reboot zu CREATE TABLE,",
|
|
384
|
+
"// kein manuelles `kumiko schema apply` nötig (das gilt nur für Prod).",
|
|
385
|
+
"// Persistent-DB via KUMIKO_DEV_DB_NAME (.env) damit Admin + Daten Reboots überleben.",
|
|
386
|
+
"",
|
|
387
|
+
"",
|
|
388
|
+
].join("\n"),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
return sf.getFullText();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderEnvExample(appName: string): string {
|
|
317
395
|
return `# Required env-vars für boot-mode + dev. Production: über Pulumi/k8s-Secrets.
|
|
318
396
|
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app
|
|
319
397
|
REDIS_URL=redis://127.0.0.1:6379
|
|
@@ -324,37 +402,62 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
|
|
|
324
402
|
# KUMIKO_SECRETS_MASTER_KEY_V1: base64-encoded 32 bytes (AES-256 KEK).
|
|
325
403
|
# Generate with: openssl rand -base64 32
|
|
326
404
|
KUMIKO_SECRETS_MASTER_KEY_V1=
|
|
405
|
+
|
|
406
|
+
# Dev-only: persistente DB für \`bun dev\`. Ohne diesen Var startet jeder Reboot
|
|
407
|
+
# eine frische kumiko_test_<random>-DB → Admin-Login + Daten weg bei jedem Edit.
|
|
408
|
+
# Mit Var bleibt die DB zwischen Reboots erhalten (Schema-Pushes sind idempotent).
|
|
409
|
+
KUMIKO_DEV_DB_NAME=${appName.replace(/-/g, "_")}_dev
|
|
327
410
|
`;
|
|
328
411
|
}
|
|
329
412
|
|
|
330
|
-
function renderReadme(
|
|
413
|
+
function renderReadme(
|
|
414
|
+
appName: string,
|
|
415
|
+
features: ReadonlyArray<ScaffoldFeatureEntry> | undefined,
|
|
416
|
+
): string {
|
|
417
|
+
const featureList =
|
|
418
|
+
features && features.length > 0
|
|
419
|
+
? features.map((f) => `- \`${f.name}\``).join("\n")
|
|
420
|
+
: "- `secrets` (foundation)\n- `sessions` (foundation)";
|
|
331
421
|
return `# ${appName}
|
|
332
422
|
|
|
333
|
-
Scaffolded by \`
|
|
334
|
-
|
|
423
|
+
Scaffolded by \`bun create kumiko-app\`. Boots out-of-the-box with the picked
|
|
424
|
+
feature stack mounted. Add features by editing \`src/run-config.ts\` or via
|
|
425
|
+
\`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
|
|
426
|
+
|
|
427
|
+
## Mounted features
|
|
428
|
+
|
|
429
|
+
${featureList}
|
|
335
430
|
|
|
336
|
-
|
|
431
|
+
Edit \`src/run-config.ts\` to add or remove.
|
|
432
|
+
|
|
433
|
+
## First run
|
|
337
434
|
|
|
338
435
|
\`\`\`sh
|
|
339
436
|
bun install
|
|
340
437
|
cp .env.example .env
|
|
341
|
-
# edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1
|
|
342
|
-
|
|
438
|
+
# edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1, point DATABASE_URL/REDIS_URL at a real PG+Redis
|
|
439
|
+
docker compose up -d # if you don't have PG+Redis running already
|
|
440
|
+
bun dev
|
|
343
441
|
\`\`\`
|
|
344
442
|
|
|
345
|
-
|
|
443
|
+
The dev-server prints a welcome banner with the URL + admin login when ready.
|
|
444
|
+
Edits to \`src/features/**\` trigger a process restart (\`bun --watch\`); new
|
|
445
|
+
\`r.entity(...)\` calls auto-create tables on reboot — no manual migration.
|
|
346
446
|
|
|
347
|
-
##
|
|
447
|
+
## Boot-only smoke (no DB needed)
|
|
348
448
|
|
|
349
449
|
\`\`\`sh
|
|
350
|
-
|
|
351
|
-
# → editiert src/run-config.ts automatisch + scaffolded src/features/my-domain/
|
|
450
|
+
bun run boot
|
|
352
451
|
\`\`\`
|
|
353
452
|
|
|
453
|
+
Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
|
|
454
|
+
+ env schema, exits 0 without touching DB/Redis. Useful in CI.
|
|
455
|
+
|
|
354
456
|
## Architecture
|
|
355
457
|
|
|
356
458
|
- \`src/run-config.ts\` — single source of truth: which features your app mounts.
|
|
357
|
-
- \`bin/
|
|
459
|
+
- \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
|
|
460
|
+
- \`bin/main.ts\` — production-bootstrap (\`bun run boot\` smoke + production deploy).
|
|
358
461
|
|
|
359
462
|
For full docs see https://docs.kumiko.rocks.
|
|
360
463
|
`;
|