@cosmicdrift/kumiko-dev-server 0.24.1 → 0.26.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/bin/kumiko-schema-check.ts +12 -12
- package/package.json +1 -1
- package/src/__tests__/env-schema.integration.test.ts +10 -2
- package/src/__tests__/env-schema.test.ts +46 -0
- package/src/__tests__/kumiko-schema-check.test.ts +57 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +20 -6
- package/src/__tests__/scaffold-app-feature.test.ts +83 -8
- package/src/__tests__/scaffold-app.test.ts +34 -0
- package/src/__tests__/scaffold-deploy.test.ts +32 -1
- package/src/codegen/__tests__/render-codegen.test.ts +22 -0
- package/src/codegen/render.ts +6 -2
- package/src/env-schema.ts +6 -2
- package/src/run-prod-app.ts +13 -13
- package/src/scaffold-app-feature.ts +44 -22
- package/src/scaffold-app.ts +26 -4
- package/src/scaffold-deploy.ts +18 -2
- package/src/schema-check-core.ts +26 -0
- package/templates/deploy/migrate-step.sh.template +11 -2
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { existsSync, readFileSync } from "node:fs";
|
|
27
27
|
import { resolve } from "node:path";
|
|
28
|
+
import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../src/schema-check-core";
|
|
28
29
|
|
|
29
30
|
type Args = {
|
|
30
31
|
readonly runConfigPath: string;
|
|
@@ -34,7 +35,7 @@ type Args = {
|
|
|
34
35
|
function parseArgs(argv: readonly string[]): Args {
|
|
35
36
|
const cwd = process.cwd();
|
|
36
37
|
let runConfigPath = resolve(cwd, "src/run-config.ts");
|
|
37
|
-
let generatePath
|
|
38
|
+
let generatePath: string | undefined;
|
|
38
39
|
for (let i = 0; i < argv.length; i++) {
|
|
39
40
|
const flag = argv[i];
|
|
40
41
|
const value = argv[i + 1];
|
|
@@ -46,7 +47,7 @@ function parseArgs(argv: readonly string[]): Args {
|
|
|
46
47
|
i++;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
return { runConfigPath, generatePath };
|
|
50
|
+
return { runConfigPath, generatePath: generatePath ?? resolveGeneratePath(cwd) };
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
function readRegistryFeatures(generateSrc: string): Set<string> {
|
|
@@ -74,20 +75,15 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
|
|
|
74
75
|
`Convention: 'export const APP_FEATURES = [...] as const'.`,
|
|
75
76
|
);
|
|
76
77
|
}
|
|
77
|
-
// composeFeatures auto-prepends config + user + tenant + auth-email-
|
|
78
|
-
// password im auth-mode. Wenn HAS_AUTH=true (oder default), die 4
|
|
79
|
-
// implicit-mounted features auch in die Set tun damit der Diff nicht
|
|
80
|
-
// false-positive "auth-email-password is mounted but no registry-entry"
|
|
81
|
-
// produziert. Studio/use-all-bundled HAS_AUTH=true ist convention.
|
|
82
78
|
const set = new Set<string>();
|
|
83
79
|
for (const f of mod.APP_FEATURES) {
|
|
84
80
|
set.add(f.name);
|
|
85
81
|
}
|
|
82
|
+
// HAS_AUTH defaults to true (Studio/use-all-bundled convention). When set,
|
|
83
|
+
// include the implicit auth-mode features so the diff doesn't false-positive
|
|
84
|
+
// "auth-email-password is mounted but no registry-entry".
|
|
86
85
|
if (mod.HAS_AUTH ?? true) {
|
|
87
|
-
set.add(
|
|
88
|
-
set.add("user");
|
|
89
|
-
set.add("tenant");
|
|
90
|
-
set.add("auth-email-password");
|
|
86
|
+
for (const name of implicitAuthModeFeatureNames()) set.add(name);
|
|
91
87
|
}
|
|
92
88
|
return set;
|
|
93
89
|
}
|
|
@@ -156,4 +152,8 @@ async function main(): Promise<void> {
|
|
|
156
152
|
}
|
|
157
153
|
}
|
|
158
154
|
|
|
159
|
-
|
|
155
|
+
// Only run when executed as a CLI, not when imported (e.g. from tests that
|
|
156
|
+
// exercise the exported pure helpers).
|
|
157
|
+
if (import.meta.main) {
|
|
158
|
+
await main();
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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>",
|
|
@@ -210,10 +210,17 @@ describe("runProdApp envSchema integration", () => {
|
|
|
210
210
|
}
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
-
it("KUMIKO_DRY_RUN_ENV=boot
|
|
213
|
+
it("KUMIKO_DRY_RUN_ENV=boot does not skip env-validation (no success handle on invalid env)", async () => {
|
|
214
|
+
// Env-error aggregation itself is mode-independent (covered above). The
|
|
215
|
+
// boot-specific guarantee here: the boot dry-run — which returns a handle
|
|
216
|
+
// and exits early on VALID env (see the test above) — must NOT report
|
|
217
|
+
// success on INVALID env. It aborts with the aggregated errors and returns
|
|
218
|
+
// no handle, so a regression that made boot-mode bail out before validation
|
|
219
|
+
// would surface as a falsely-returned handle.
|
|
214
220
|
let captured: KumikoBootError | undefined;
|
|
221
|
+
let handle: unknown;
|
|
215
222
|
try {
|
|
216
|
-
await runProdApp({
|
|
223
|
+
handle = await runProdApp({
|
|
217
224
|
features: [secretsFeature, authFeature],
|
|
218
225
|
envSchema: composed,
|
|
219
226
|
envSource: {
|
|
@@ -229,6 +236,7 @@ describe("runProdApp envSchema integration", () => {
|
|
|
229
236
|
} catch (err) {
|
|
230
237
|
expect(err).toBeInstanceOf(KumikoBootError);
|
|
231
238
|
}
|
|
239
|
+
expect(handle).toBeUndefined();
|
|
232
240
|
expect(captured).toBeDefined();
|
|
233
241
|
expect(captured!.errors.length).toBeGreaterThanOrEqual(3);
|
|
234
242
|
});
|
|
@@ -32,6 +32,51 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it("rejects a non-postgres DATABASE_URL even when it is a valid WHATWG URL", () => {
|
|
36
|
+
try {
|
|
37
|
+
parseEnv(frameworkCoreEnvSchema, {
|
|
38
|
+
DATABASE_URL: "https://example.com/db",
|
|
39
|
+
REDIS_URL: "redis://localhost:6379",
|
|
40
|
+
});
|
|
41
|
+
throw new Error("should have thrown");
|
|
42
|
+
} catch (err) {
|
|
43
|
+
expect(err).toBeInstanceOf(KumikoBootError);
|
|
44
|
+
const db = (err as KumikoBootError).errors.find((e) => e.name === "DATABASE_URL");
|
|
45
|
+
expect(db?.kind).toBe("invalid");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts postgres:// and postgresql:// for DATABASE_URL", () => {
|
|
50
|
+
for (const url of ["postgres://localhost:5432/db", "postgresql://localhost:5432/db"]) {
|
|
51
|
+
const env = parseEnv(frameworkCoreEnvSchema, {
|
|
52
|
+
DATABASE_URL: url,
|
|
53
|
+
REDIS_URL: "redis://localhost:6379",
|
|
54
|
+
});
|
|
55
|
+
expect(env.DATABASE_URL).toBe(url);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects a non-redis REDIS_URL and accepts redis:// + rediss://", () => {
|
|
60
|
+
try {
|
|
61
|
+
parseEnv(frameworkCoreEnvSchema, {
|
|
62
|
+
DATABASE_URL: "postgres://localhost:5432/db",
|
|
63
|
+
REDIS_URL: "https://example.com/cache",
|
|
64
|
+
});
|
|
65
|
+
throw new Error("should have thrown");
|
|
66
|
+
} catch (err) {
|
|
67
|
+
expect(err).toBeInstanceOf(KumikoBootError);
|
|
68
|
+
const redis = (err as KumikoBootError).errors.find((e) => e.name === "REDIS_URL");
|
|
69
|
+
expect(redis?.kind).toBe("invalid");
|
|
70
|
+
}
|
|
71
|
+
for (const url of ["redis://localhost:6379", "rediss://localhost:6379"]) {
|
|
72
|
+
const env = parseEnv(frameworkCoreEnvSchema, {
|
|
73
|
+
DATABASE_URL: "postgres://localhost:5432/db",
|
|
74
|
+
REDIS_URL: url,
|
|
75
|
+
});
|
|
76
|
+
expect(env.REDIS_URL).toBe(url);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
35
80
|
it("rejects an invalid PORT (non-numeric)", () => {
|
|
36
81
|
try {
|
|
37
82
|
parseEnv(frameworkCoreEnvSchema, {
|
|
@@ -70,6 +115,7 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
70
115
|
|
|
71
116
|
try {
|
|
72
117
|
parseEnv(schema, {}, { sources });
|
|
118
|
+
throw new Error("should have thrown");
|
|
73
119
|
} catch (err) {
|
|
74
120
|
const boot = err as KumikoBootError;
|
|
75
121
|
const formatted = boot.format();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Unit-tests for the pure helpers behind the kumiko-schema-check bin.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { composeFeatures } from "../compose-features";
|
|
8
|
+
import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../schema-check-core";
|
|
9
|
+
|
|
10
|
+
describe("resolveGeneratePath", () => {
|
|
11
|
+
test("defaults to drizzle/generate.ts when neither candidate exists", () => {
|
|
12
|
+
const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
|
|
13
|
+
try {
|
|
14
|
+
expect(resolveGeneratePath(cwd)).toBe(join(cwd, "drizzle/generate.ts"));
|
|
15
|
+
} finally {
|
|
16
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("falls back to schema/generate.ts when drizzle/ is absent but schema/ exists", () => {
|
|
21
|
+
const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(join(cwd, "schema"), { recursive: true });
|
|
24
|
+
writeFileSync(join(cwd, "schema/generate.ts"), "// stub", "utf-8");
|
|
25
|
+
expect(resolveGeneratePath(cwd)).toBe(join(cwd, "schema/generate.ts"));
|
|
26
|
+
} finally {
|
|
27
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("prefers drizzle/generate.ts when both exist", () => {
|
|
32
|
+
const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(join(cwd, "drizzle"), { recursive: true });
|
|
35
|
+
mkdirSync(join(cwd, "schema"), { recursive: true });
|
|
36
|
+
writeFileSync(join(cwd, "drizzle/generate.ts"), "// stub", "utf-8");
|
|
37
|
+
writeFileSync(join(cwd, "schema/generate.ts"), "// stub", "utf-8");
|
|
38
|
+
expect(resolveGeneratePath(cwd)).toBe(join(cwd, "drizzle/generate.ts"));
|
|
39
|
+
} finally {
|
|
40
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("implicitAuthModeFeatureNames", () => {
|
|
46
|
+
test("matches composeFeatures' auth-mode prepend exactly (no hardcoded drift)", () => {
|
|
47
|
+
const fromCompose = composeFeatures([], { includeBundled: true }).map((f) => f.name);
|
|
48
|
+
expect(implicitAuthModeFeatureNames()).toEqual(fromCompose);
|
|
49
|
+
// Sanity: the current bundled-foundation set.
|
|
50
|
+
expect([...implicitAuthModeFeatureNames()].sort()).toEqual([
|
|
51
|
+
"auth-email-password",
|
|
52
|
+
"config",
|
|
53
|
+
"tenant",
|
|
54
|
+
"user",
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -65,16 +65,30 @@ describe("runProdApp boot-mode env-source", () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test("boots from injected envSource even when process.env lacks the required vars", async () => {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const logs: string[] = [];
|
|
69
|
+
const originalLog = console.log;
|
|
70
|
+
console.log = (...args: unknown[]) => {
|
|
71
|
+
logs.push(args.map(String).join(" "));
|
|
72
|
+
};
|
|
73
|
+
let handle: Awaited<ReturnType<typeof runProdApp>>;
|
|
74
|
+
try {
|
|
75
|
+
handle = await runProdApp({
|
|
76
|
+
features: [probeFeature],
|
|
77
|
+
autoListen: false,
|
|
78
|
+
migrations: false,
|
|
79
|
+
// REDIS_URL points at an unreachable port — boot-mode must NOT
|
|
80
|
+
// construct the (eager) Redis client, so this never tries to connect.
|
|
81
|
+
envSource: { ...DUMMY_ENV },
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
console.log = originalLog;
|
|
85
|
+
}
|
|
74
86
|
|
|
75
87
|
// Boot-mode with an injected envSource returns an inert dry-run handle.
|
|
76
88
|
expect(handle).toBeDefined();
|
|
77
89
|
expect(typeof handle.stop).toBe("function");
|
|
90
|
+
// The registry was built + validated before any connection was opened.
|
|
91
|
+
expect(logs.some((line) => line.includes("boot validation OK"))).toBe(true);
|
|
78
92
|
await handle.stop();
|
|
79
93
|
});
|
|
80
94
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// scaffoldAppFeature unit-tests (DX-2).
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { scaffoldApp } from "../scaffold-app";
|
|
@@ -50,15 +50,13 @@ describe("scaffoldAppFeature", () => {
|
|
|
50
50
|
expect(runConfig).toMatch(/\]\s*as const;/);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
test("
|
|
53
|
+
test("mounting a second feature keeps the first feature's import exactly once", () => {
|
|
54
|
+
// Cross-feature non-duplication: adding billing must not touch the
|
|
55
|
+
// product-catalog import/entry. Same-feature re-mount idempotency is the
|
|
56
|
+
// next test — the dir-exists guard blocks a direct second scaffold of the
|
|
57
|
+
// same name, so this one cannot reach the short-circuit branches.
|
|
54
58
|
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
55
59
|
const firstRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
56
|
-
// Now re-mount (second feature creates dir-already-exists error;
|
|
57
|
-
// we instead simulate "feature dir exists, only run-config dance").
|
|
58
|
-
// → Real DX-2 flow: scaffold fails on dir-exists; manual remount
|
|
59
|
-
// would call mountInRunConfig directly. Test the mount-side
|
|
60
|
-
// idempotency by triggering a second feature with a different
|
|
61
|
-
// name and asserting the first import stays exactly once.
|
|
62
60
|
scaffoldAppFeature({ name: "billing", appRoot });
|
|
63
61
|
const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
64
62
|
expect(secondRunConfig).toContain("productCatalogFeature");
|
|
@@ -69,11 +67,88 @@ describe("scaffoldAppFeature", () => {
|
|
|
69
67
|
expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
|
|
70
68
|
});
|
|
71
69
|
|
|
70
|
+
test("idempotent: re-scaffolding the same feature is a full no-op on run-config", () => {
|
|
71
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
72
|
+
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
73
|
+
const afterFirst = readFileSync(runConfigPath, "utf-8");
|
|
74
|
+
|
|
75
|
+
// Drop only the feature dir so the dir-exists guard doesn't trip; the
|
|
76
|
+
// run-config import + APP_FEATURES entry both survive. The re-scaffold must
|
|
77
|
+
// hit the full short-circuit in mountInRunConfig (import present AND
|
|
78
|
+
// alreadyListed → changed=false → no save) and duplicate neither half —
|
|
79
|
+
// the branch the cross-feature test above never reaches.
|
|
80
|
+
rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
|
|
81
|
+
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
82
|
+
expect(result.autoMounted).toBe(true);
|
|
83
|
+
|
|
84
|
+
const afterSecond = readFileSync(runConfigPath, "utf-8");
|
|
85
|
+
expect(afterSecond).toBe(afterFirst); // byte-identical: no save, no duplication
|
|
86
|
+
expect((afterSecond.match(/productCatalogFeature/g) ?? []).length).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("self-heals a half-mounted run-config: import present but APP_FEATURES entry removed", () => {
|
|
90
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
91
|
+
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
92
|
+
|
|
93
|
+
// Simulate a half-applied state: the feature dir is gone and the
|
|
94
|
+
// APP_FEATURES entry was hand-removed, but the import line still lingers.
|
|
95
|
+
rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
|
|
96
|
+
const stripped = readFileSync(runConfigPath, "utf-8")
|
|
97
|
+
// Drop the APP_FEATURES array element (appended last, so `, productCatalogFeature`
|
|
98
|
+
// right before the closing `]`). The import `{ productCatalogFeature }` is
|
|
99
|
+
// followed by ` }`, not `]`/`,`, so the lookahead leaves it intact.
|
|
100
|
+
.replace(/,\s*productCatalogFeature(?=\s*\])/, "");
|
|
101
|
+
writeFileSync(runConfigPath, stripped, "utf-8");
|
|
102
|
+
// Guard the simulation itself: the import must remain, the array entry must
|
|
103
|
+
// be gone — exactly one mention left. Without this, a regex that failed to
|
|
104
|
+
// strip would leave the entry in place and the test would false-pass.
|
|
105
|
+
expect(stripped).toContain(
|
|
106
|
+
'import { productCatalogFeature } from "./features/product-catalog";',
|
|
107
|
+
);
|
|
108
|
+
expect((stripped.match(/productCatalogFeature/g) ?? []).length).toBe(1);
|
|
109
|
+
|
|
110
|
+
// Re-scaffold: dir is gone so it proceeds; mountInRunConfig sees the import
|
|
111
|
+
// already present and must still re-add the missing APP_FEATURES entry.
|
|
112
|
+
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
113
|
+
expect(result.autoMounted).toBe(true);
|
|
114
|
+
|
|
115
|
+
const healed = readFileSync(runConfigPath, "utf-8");
|
|
116
|
+
// Import stays exactly once; the array entry is back.
|
|
117
|
+
const occurrences = healed.match(/productCatalogFeature/g) ?? [];
|
|
118
|
+
expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
|
|
119
|
+
expect(healed).toMatch(/\]\s*as const;/);
|
|
120
|
+
});
|
|
121
|
+
|
|
72
122
|
test("rejects non-kebab-case", () => {
|
|
73
123
|
expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
|
|
74
124
|
expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
|
|
75
125
|
});
|
|
76
126
|
|
|
127
|
+
test("rejects trailing- and double-hyphen names (kebabToCamel would break)", () => {
|
|
128
|
+
// `product-` → kebabToCamel leaves a trailing hyphen → `product-Feature`,
|
|
129
|
+
// an invalid identifier. The segment-strict regex must reject these.
|
|
130
|
+
expect(() => scaffoldAppFeature({ name: "product-", appRoot })).toThrow(/kebab-case/);
|
|
131
|
+
expect(() => scaffoldAppFeature({ name: "foo--bar", appRoot })).toThrow(/kebab-case/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("rolls back the scaffolded dir when run-config has no APP_FEATURES (re-run not blocked)", () => {
|
|
135
|
+
// run-config exists but is the wrong shape → mountInRunConfig throws. The
|
|
136
|
+
// feature files were already written; without rollback a re-run would hit
|
|
137
|
+
// the "already exists" guard and the user would be stuck.
|
|
138
|
+
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
139
|
+
writeFileSync(runConfigPath, "export const NOT_APP_FEATURES = [];\n", "utf-8");
|
|
140
|
+
|
|
141
|
+
expect(() => scaffoldAppFeature({ name: "orders", appRoot })).toThrow(/APP_FEATURES/);
|
|
142
|
+
// Rolled back: the half-written feature dir is gone.
|
|
143
|
+
expect(existsSync(join(appRoot, "src/features/orders"))).toBe(false);
|
|
144
|
+
|
|
145
|
+
// Re-run with a valid run-config now succeeds instead of "already exists".
|
|
146
|
+
writeFileSync(runConfigPath, "export const APP_FEATURES = [] as const;\n", "utf-8");
|
|
147
|
+
const result = scaffoldAppFeature({ name: "orders", appRoot });
|
|
148
|
+
expect(result.autoMounted).toBe(true);
|
|
149
|
+
expect(existsSync(join(appRoot, "src/features/orders/feature.ts"))).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
77
152
|
test("refuses to overwrite existing feature dir", () => {
|
|
78
153
|
scaffoldAppFeature({ name: "billing", appRoot });
|
|
79
154
|
expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
|
|
@@ -63,6 +63,21 @@ describe("scaffoldApp", () => {
|
|
|
63
63
|
expect(main).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", () => {
|
|
67
|
+
const dest = join(tmp, "my-shop");
|
|
68
|
+
scaffoldApp({ name: "my-shop", destination: dest });
|
|
69
|
+
|
|
70
|
+
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
71
|
+
// envSchema must cover the same features runProdApp auto-mixes via
|
|
72
|
+
// auth-mode — otherwise auth-email-password's JWT_SECRET (min-32) is
|
|
73
|
+
// absent from the boot-gate and a too-short secret slips through.
|
|
74
|
+
expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: true })");
|
|
75
|
+
expect(main).toContain(
|
|
76
|
+
"composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
|
|
77
|
+
);
|
|
78
|
+
expect(main).toContain("composeFeatures");
|
|
79
|
+
});
|
|
80
|
+
|
|
66
81
|
test("src/run-config.ts mounts secrets + sessions as foundation", () => {
|
|
67
82
|
const dest = join(tmp, "my-shop");
|
|
68
83
|
scaffoldApp({ name: "my-shop", destination: dest });
|
|
@@ -79,6 +94,25 @@ describe("scaffoldApp", () => {
|
|
|
79
94
|
expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
|
|
80
95
|
});
|
|
81
96
|
|
|
97
|
+
test("rejects trailing- and double-hyphen names (invalid package segment)", () => {
|
|
98
|
+
expect(() => scaffoldApp({ name: "my-shop-", destination: tmp })).toThrow(/kebab-case/);
|
|
99
|
+
expect(() => scaffoldApp({ name: "my--shop", destination: tmp })).toThrow(/kebab-case/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("resolves a relative destination against the supplied cwd, not process.cwd()", () => {
|
|
103
|
+
// The CLI passes ctx.cwd; the scaffold must land under it so the
|
|
104
|
+
// displayed path matches the actual write location.
|
|
105
|
+
const result = scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
|
|
106
|
+
expect(result.destination).toBe(join(tmp, "apps/shop"));
|
|
107
|
+
expect(existsSync(join(tmp, "apps/shop", "package.json"))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("resolves the name-default destination against the supplied cwd", () => {
|
|
111
|
+
const result = scaffoldApp({ name: "shop", cwd: tmp });
|
|
112
|
+
expect(result.destination).toBe(join(tmp, "shop"));
|
|
113
|
+
expect(existsSync(join(tmp, "shop", "package.json"))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
82
116
|
test("refuses to overwrite existing destination", () => {
|
|
83
117
|
const dest = join(tmp, "existing");
|
|
84
118
|
scaffoldApp({ name: "existing", destination: dest });
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { scaffoldDeploy } from "../scaffold-deploy";
|
|
5
|
+
import { render, scaffoldDeploy } from "../scaffold-deploy";
|
|
6
6
|
|
|
7
7
|
describe("scaffoldDeploy", () => {
|
|
8
8
|
let tmp: string;
|
|
@@ -41,6 +41,16 @@ describe("scaffoldDeploy", () => {
|
|
|
41
41
|
expect(migrate).toContain("ghcr.io/acme/myapp:latest");
|
|
42
42
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell-substitution literal, not a JS template
|
|
43
43
|
expect(migrate).toContain("postgresql://myapp:${DB_PASSWORD}@db:5432/myapp");
|
|
44
|
+
// The password-bearing URL is composed into the shell env and passed by
|
|
45
|
+
// NAME (`-e DATABASE_URL`, no `=`), so the expanded value never lands in
|
|
46
|
+
// `docker run`'s argv (would be visible in `ps auxe`).
|
|
47
|
+
expect(migrate).toContain("export DATABASE_URL=");
|
|
48
|
+
expect(migrate).toMatch(/-e DATABASE_URL\b(?!=)/);
|
|
49
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell-substitution literal, not a JS template
|
|
50
|
+
expect(migrate).not.toContain('-e DATABASE_URL="postgresql://myapp:${DB_PASSWORD}');
|
|
51
|
+
// grep's no-match exit must not abort the script before the friendly
|
|
52
|
+
// "No _stack network found" branch runs.
|
|
53
|
+
expect(migrate).toContain("| head -1 || true)");
|
|
44
54
|
// Docker template syntax {{.Name}} must pass through verbatim — our
|
|
45
55
|
// placeholder regex only matches lowercase-leading identifiers.
|
|
46
56
|
expect(migrate).toContain('"{{.Name}}"');
|
|
@@ -148,6 +158,27 @@ describe("scaffoldDeploy", () => {
|
|
|
148
158
|
);
|
|
149
159
|
});
|
|
150
160
|
|
|
161
|
+
describe("render placeholder consumption", () => {
|
|
162
|
+
it("passes Docker/Go template syntax {{.X}} through verbatim", () => {
|
|
163
|
+
const out = render('docker network ls --format "{{.Name}}"', {}, {});
|
|
164
|
+
expect(out).toBe('docker network ls --format "{{.Name}}"');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("substitutes known {{key}} placeholders", () => {
|
|
168
|
+
expect(render("hello {{who}}", { who: "world" }, {})).toBe("hello world");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("throws on an orphan close-tag (no opener) instead of leaking it", () => {
|
|
172
|
+
expect(() => render("line\n{{/hasSeeds}}\nmore", {}, {})).toThrow(/unconsumed mustache/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("strips a matched block but does not leave its close-tag behind", () => {
|
|
176
|
+
const out = render("a\n{{#flag}}\nkept\n{{/flag}}\nb", {}, { flag: true });
|
|
177
|
+
expect(out).toContain("kept");
|
|
178
|
+
expect(out).not.toContain("{{/flag}}");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
151
182
|
describe("source-tree detection", () => {
|
|
152
183
|
it("emits seeds-COPY block only when seeds/ exists", () => {
|
|
153
184
|
// Without seeds/: block stripped
|
|
@@ -39,6 +39,28 @@ describe("renderInlineSchemasFile", () => {
|
|
|
39
39
|
expect(out).toContain("// inventory:event:product-archived — from src/feature.ts:92");
|
|
40
40
|
expect(out).not.toContain(appRoot);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
test("falls back to the bare filename when a feature file is outside the app root", () => {
|
|
44
|
+
// A bundled-feature file lives in node_modules, outside the app root —
|
|
45
|
+
// relative() would emit `../../..`. The comment falls back to basename.
|
|
46
|
+
const appRoot = join(tmpdir(), "kumiko-codegen-app");
|
|
47
|
+
const featurePath = join(tmpdir(), "node_modules", "pkg", "dist", "feature.ts");
|
|
48
|
+
const events: ScannedEvent[] = [
|
|
49
|
+
{
|
|
50
|
+
qualifiedName: "bundled:event:thing-happened",
|
|
51
|
+
schemaSource: {
|
|
52
|
+
kind: "inline",
|
|
53
|
+
schemaSource: "z.object({ id: z.string() })",
|
|
54
|
+
generatedConstName: "_kg_bundled__thingHappened",
|
|
55
|
+
},
|
|
56
|
+
featureFilePath: featurePath,
|
|
57
|
+
source: { file: featurePath, line: 7 },
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
const out = renderInlineSchemasFile(events, appRoot);
|
|
61
|
+
expect(out).toContain("// bundled:event:thing-happened — from feature.ts:7");
|
|
62
|
+
expect(out).not.toContain("..");
|
|
63
|
+
});
|
|
42
64
|
});
|
|
43
65
|
|
|
44
66
|
describe("renderDefineFile", () => {
|
package/src/codegen/render.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// actual change, so mtime doesn't tick and the TS language server
|
|
23
23
|
// doesn't reload every 100ms.
|
|
24
24
|
|
|
25
|
-
import { relative } from "node:path";
|
|
25
|
+
import { basename, relative } from "node:path";
|
|
26
26
|
import type { ScannedEvent } from "./scan-events";
|
|
27
27
|
import { rewriteImportPath } from "./scan-events";
|
|
28
28
|
|
|
@@ -143,7 +143,11 @@ export function renderInlineSchemasFile(
|
|
|
143
143
|
});
|
|
144
144
|
for (const ev of sorted) {
|
|
145
145
|
if (ev.schemaSource.kind !== "inline") continue;
|
|
146
|
-
const
|
|
146
|
+
const rel = relative(appRootAbs, ev.featureFilePath).split("\\").join("/");
|
|
147
|
+
// Feature files outside the app root (e.g. a bundled dependency) yield a
|
|
148
|
+
// `../../..`-walk that clutters the source comment; fall back to the bare
|
|
149
|
+
// filename. Comment-only — the generated schema is unaffected.
|
|
150
|
+
const sourcePath = rel.startsWith("..") ? basename(ev.featureFilePath) : rel;
|
|
147
151
|
lines.push(
|
|
148
152
|
`// ${ev.qualifiedName} — from ${sourcePath}:${ev.source.line}`,
|
|
149
153
|
`export const ${ev.schemaSource.generatedConstName} = ${ev.schemaSource.schemaSource};`,
|
package/src/env-schema.ts
CHANGED
|
@@ -27,12 +27,16 @@ export const frameworkCoreEnvSchema = z.object({
|
|
|
27
27
|
.describe("HTTP listen port. runProdApp defaults to 3000 when unset."),
|
|
28
28
|
|
|
29
29
|
DATABASE_URL: z
|
|
30
|
-
|
|
30
|
+
// `protocol` is matched against the URL scheme without the trailing
|
|
31
|
+
// colon — `postgres://`/`postgresql://` pass, `https://`/`ftp://` are
|
|
32
|
+
// rejected so the error message's promise actually holds at boot.
|
|
33
|
+
.url({ protocol: /^postgres(ql)?$/, error: "DATABASE_URL must be a valid postgres:// URL" })
|
|
31
34
|
.describe("Primary Postgres connection string (write + read).")
|
|
32
35
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
33
36
|
|
|
34
37
|
REDIS_URL: z
|
|
35
|
-
|
|
38
|
+
// `redis://` + the TLS variant `rediss://` pass; other schemes reject.
|
|
39
|
+
.url({ protocol: /^rediss?$/, error: "REDIS_URL must be a valid redis:// URL" })
|
|
36
40
|
.describe("Redis connection string for SSE-broker + job-queues.")
|
|
37
41
|
.meta({ kumiko: { pulumi: { secret: true } } }),
|
|
38
42
|
|
package/src/run-prod-app.ts
CHANGED
|
@@ -551,16 +551,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
551
551
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
552
552
|
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
553
553
|
|
|
554
|
-
// 3.
|
|
555
|
-
// idempotency, event-dedup, entity-cache, rate-limit; failing to
|
|
556
|
-
// construct here surfaces the misconfig immediately.
|
|
557
|
-
const { db, close: closeDb } = createDbConnection(databaseUrl);
|
|
558
|
-
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
559
|
-
|
|
560
|
-
// 4. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
554
|
+
// 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
561
555
|
// password via composeFeatures — same source-of-truth as runDevApp
|
|
562
556
|
// AND the per-app drizzle-Schema-Generator, so Migration und Runtime
|
|
563
|
-
// sehen exakt dieselbe Liste.
|
|
557
|
+
// sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
|
|
558
|
+
// can validate wiring and exit without opening a Postgres/Redis socket.
|
|
564
559
|
const composeAuthOptions = buildComposeAuthOptions(options.auth);
|
|
565
560
|
const features = composeFeatures(options.features, {
|
|
566
561
|
includeBundled: !!options.auth,
|
|
@@ -570,22 +565,27 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
570
565
|
validateBoot(features);
|
|
571
566
|
const registry = createRegistry(features);
|
|
572
567
|
|
|
573
|
-
// C1 boot-mode exit: validators ran
|
|
574
|
-
//
|
|
575
|
-
//
|
|
568
|
+
// C1 boot-mode exit: validators ran + registry built; no DB/Redis client
|
|
569
|
+
// is constructed at all in this branch (the eager `new Redis(...)` below
|
|
570
|
+
// would otherwise open a TCP connect just to immediately disconnect it).
|
|
576
571
|
if (runMode === "boot") {
|
|
577
572
|
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
578
573
|
console.log(
|
|
579
574
|
`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
|
|
580
575
|
);
|
|
581
|
-
await closeDb();
|
|
582
|
-
redis.disconnect();
|
|
583
576
|
if (options.envSource === undefined) {
|
|
584
577
|
process.exit(0);
|
|
585
578
|
}
|
|
586
579
|
return makeDryRunHandle();
|
|
587
580
|
}
|
|
588
581
|
|
|
582
|
+
// 4. Connections — Postgres + Redis. The Redis client is shared by
|
|
583
|
+
// idempotency, event-dedup, entity-cache, rate-limit; failing to
|
|
584
|
+
// construct here surfaces the misconfig immediately. `new Redis(...)`
|
|
585
|
+
// connects eagerly, so it must stay AFTER the boot-mode exit above.
|
|
586
|
+
const { db, close: closeDb } = createDbConnection(databaseUrl);
|
|
587
|
+
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
588
|
+
|
|
589
589
|
// Sprint-8a Tier-Composition auto-wire: scan features for a
|
|
590
590
|
// tenantTierResolver-extension. If found AND user didn't supply own
|
|
591
591
|
// effectiveFeatures, build the resolver here (db + registry are
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// for the run-config side. Drizzle FEATURE_IMPORT_REGISTRY is NOT
|
|
12
12
|
// touched here — DX-4 auto-discovery resolves that.
|
|
13
13
|
|
|
14
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { Project, SyntaxKind } from "ts-morph";
|
|
17
17
|
|
|
@@ -31,7 +31,10 @@ export type ScaffoldAppFeatureResult = {
|
|
|
31
31
|
readonly autoMounted: boolean;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// Segment-strict: rejects trailing/double hyphen (`product-`, `foo--bar`),
|
|
35
|
+
// which kebabToCamel would otherwise turn into an invalid identifier
|
|
36
|
+
// (`productFeature` vs the broken `product-Feature`).
|
|
37
|
+
const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
35
38
|
|
|
36
39
|
export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
|
|
37
40
|
if (!KEBAB_RE.test(options.name)) {
|
|
@@ -56,9 +59,19 @@ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): Scaffold
|
|
|
56
59
|
files.push(`src/features/${options.name}/index.ts`);
|
|
57
60
|
|
|
58
61
|
const runConfigPath = join(appRoot, "src", "run-config.ts");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
let autoMounted = false;
|
|
63
|
+
if (existsSync(runConfigPath)) {
|
|
64
|
+
try {
|
|
65
|
+
autoMounted = mountInRunConfig(runConfigPath, options.name);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Roll back the freshly-written feature dir so a re-run isn't blocked
|
|
68
|
+
// by this function's own "already exists" guard. Without this, a
|
|
69
|
+
// shape-mismatch in run-config leaves feature.ts + index.ts on disk and
|
|
70
|
+
// the user is stuck having to hand-delete before retrying.
|
|
71
|
+
rmSync(featureDir, { recursive: true, force: true });
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
62
75
|
|
|
63
76
|
return {
|
|
64
77
|
featureName: options.name,
|
|
@@ -98,8 +111,8 @@ function kebabToCamel(name: string): string {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
// ts-morph: open run-config, prepend import, append APP_FEATURES entry.
|
|
101
|
-
// Returns true on success, throws on shape-mismatch
|
|
102
|
-
// scaffolded
|
|
114
|
+
// Returns true on success, throws on shape-mismatch — the caller rolls back
|
|
115
|
+
// the scaffolded feature dir and re-throws so a re-run isn't blocked.
|
|
103
116
|
function mountInRunConfig(runConfigPath: string, name: string): boolean {
|
|
104
117
|
const camel = kebabToCamel(name);
|
|
105
118
|
const project = new Project({
|
|
@@ -108,20 +121,25 @@ function mountInRunConfig(runConfigPath: string, name: string): boolean {
|
|
|
108
121
|
});
|
|
109
122
|
const sf = project.addSourceFileAtPath(runConfigPath);
|
|
110
123
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
// Import and APP_FEATURES entry are checked independently so a half-applied
|
|
125
|
+
// state self-heals: if the import exists but the entry was hand-removed (or
|
|
126
|
+
// vice versa), re-running adds only the missing half instead of short-
|
|
127
|
+
// circuiting on the import alone and leaving the feature unmounted.
|
|
128
|
+
let changed = false;
|
|
129
|
+
|
|
130
|
+
// 1. Prepend import after the last existing import — only if absent.
|
|
131
|
+
if (!sf.getImportDeclaration(`./features/${name}`)) {
|
|
132
|
+
const imports = sf.getImportDeclarations();
|
|
133
|
+
const insertIndex =
|
|
134
|
+
imports.length > 0 ? (imports[imports.length - 1]?.getChildIndex() ?? 0) + 1 : 0;
|
|
135
|
+
sf.insertImportDeclaration(insertIndex, {
|
|
136
|
+
moduleSpecifier: `./features/${name}`,
|
|
137
|
+
namedImports: [`${camel}Feature`],
|
|
138
|
+
});
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
123
141
|
|
|
124
|
-
// 2. Find `export const APP_FEATURES = [...]` and append the
|
|
142
|
+
// 2. Find `export const APP_FEATURES = [...]` and append the entry — only if absent.
|
|
125
143
|
const appFeaturesDecl = sf.getVariableDeclaration("APP_FEATURES");
|
|
126
144
|
if (!appFeaturesDecl) {
|
|
127
145
|
throw new Error(
|
|
@@ -143,9 +161,13 @@ function mountInRunConfig(runConfigPath: string, name: string): boolean {
|
|
|
143
161
|
if (!arr) {
|
|
144
162
|
throw new Error(`mountInRunConfig: APP_FEATURES is not an array literal — cannot auto-mount.`);
|
|
145
163
|
}
|
|
146
|
-
arr.
|
|
164
|
+
const alreadyListed = arr.getElements().some((el) => el.getText() === `${camel}Feature`);
|
|
165
|
+
if (!alreadyListed) {
|
|
166
|
+
arr.addElement(`${camel}Feature`);
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
147
169
|
|
|
148
|
-
sf.saveSync();
|
|
170
|
+
if (changed) sf.saveSync();
|
|
149
171
|
return true;
|
|
150
172
|
}
|
|
151
173
|
|
package/src/scaffold-app.ts
CHANGED
|
@@ -20,6 +20,11 @@ export type ScaffoldAppOptions = {
|
|
|
20
20
|
readonly name: string;
|
|
21
21
|
/** Absolute or cwd-relative target dir. Default: <cwd>/<name>. */
|
|
22
22
|
readonly destination?: string;
|
|
23
|
+
/** Base dir a relative `destination` (or the name-default) resolves
|
|
24
|
+
* against. Defaults to process.cwd(). Callers with their own cwd-notion
|
|
25
|
+
* (the CLI's ctx.cwd) MUST pass it so the scaffold lands where the
|
|
26
|
+
* command's output claims it does. */
|
|
27
|
+
readonly cwd?: string;
|
|
23
28
|
/** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
|
|
24
29
|
readonly frameworkVersion?: string;
|
|
25
30
|
};
|
|
@@ -30,13 +35,15 @@ export type ScaffoldAppResult = {
|
|
|
30
35
|
readonly appName: string;
|
|
31
36
|
};
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
// Segment-strict: rejects trailing/double hyphen so the name is a valid
|
|
39
|
+
// package-name + folder (`my-shop`, not `my-` or `my--shop`).
|
|
40
|
+
const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
34
41
|
|
|
35
42
|
export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
36
43
|
if (!KEBAB_RE.test(options.name)) {
|
|
37
44
|
throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
|
|
38
45
|
}
|
|
39
|
-
const cwd = process.cwd();
|
|
46
|
+
const cwd = options.cwd ?? process.cwd();
|
|
40
47
|
const destination = resolve(cwd, options.destination ?? options.name);
|
|
41
48
|
if (existsSync(destination)) {
|
|
42
49
|
throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
|
|
@@ -178,7 +185,7 @@ function renderMain(appName: string): string {
|
|
|
178
185
|
|
|
179
186
|
sf.addImportDeclaration({
|
|
180
187
|
moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
|
|
181
|
-
namedImports: ["frameworkCoreEnvSchema", "runProdApp"],
|
|
188
|
+
namedImports: ["composeFeatures", "frameworkCoreEnvSchema", "runProdApp"],
|
|
182
189
|
});
|
|
183
190
|
sf.addImportDeclaration({
|
|
184
191
|
moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
|
|
@@ -204,12 +211,27 @@ function renderMain(appName: string): string {
|
|
|
204
211
|
],
|
|
205
212
|
});
|
|
206
213
|
|
|
214
|
+
// The envSchema must cover the SAME features runProdApp mounts at boot.
|
|
215
|
+
// `auth: { admin: … }` below makes runProdApp auto-mix config/user/tenant/
|
|
216
|
+
// auth-email-password via composeFeatures(includeBundled:true); compose the
|
|
217
|
+
// identical set here so the auth feature's JWT_SECRET (min-32) declaration
|
|
218
|
+
// is part of the boot-gate — otherwise a too-short JWT_SECRET slips through.
|
|
219
|
+
sf.addVariableStatement({
|
|
220
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
221
|
+
declarations: [
|
|
222
|
+
{
|
|
223
|
+
name: "bootFeatures",
|
|
224
|
+
initializer: "composeFeatures(APP_FEATURES, { includeBundled: true })",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
|
|
207
229
|
sf.addVariableStatement({
|
|
208
230
|
declarationKind: VariableDeclarationKind.Const,
|
|
209
231
|
declarations: [
|
|
210
232
|
{
|
|
211
233
|
name: "envSchema",
|
|
212
|
-
initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features:
|
|
234
|
+
initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
|
|
213
235
|
},
|
|
214
236
|
],
|
|
215
237
|
});
|
package/src/scaffold-deploy.ts
CHANGED
|
@@ -64,7 +64,9 @@ const TEMPLATE_FILES = [
|
|
|
64
64
|
{ template: "migrate-step.sh.template", output: "migrate-step.sh" },
|
|
65
65
|
] as const;
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Segment-strict: rejects trailing/double hyphen so the appName is a valid
|
|
68
|
+
// image-tag + folder segment (`kumiko-studio`, not `kumiko-` / `kumiko--x`).
|
|
69
|
+
const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
68
70
|
|
|
69
71
|
export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
|
|
70
72
|
if (!KEBAB_RE.test(options.appName)) {
|
|
@@ -156,7 +158,13 @@ function detectOptionalSurfaces(sourceDir: string): ScaffoldDeployDetected {
|
|
|
156
158
|
return { hasSeeds, hasPrivateGhPackages };
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
|
|
161
|
+
// Unconsumed-mustache guard. After step 1+2 the only legitimate `{{`
|
|
162
|
+
// survivors are Docker/Go template tags (`{{.Name}}`, leading `.`). An
|
|
163
|
+
// orphan close-tag (`{{/foo}}` without an opener) matches neither step and
|
|
164
|
+
// would otherwise leak into the rendered file as a silent failure.
|
|
165
|
+
const ORPHAN_MUSTACHE_RE = /\{\{(?!\.)[^}]*\}\}/;
|
|
166
|
+
|
|
167
|
+
export function render(
|
|
160
168
|
source: string,
|
|
161
169
|
subs: Readonly<Record<string, string>>,
|
|
162
170
|
flags: Readonly<Record<string, boolean>>,
|
|
@@ -188,5 +196,13 @@ function render(
|
|
|
188
196
|
return value;
|
|
189
197
|
});
|
|
190
198
|
|
|
199
|
+
const orphan = ORPHAN_MUSTACHE_RE.exec(result);
|
|
200
|
+
if (orphan) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`scaffoldDeploy.render: unconsumed mustache tag "${orphan[0]}" — ` +
|
|
203
|
+
`likely an orphan close-tag (e.g. a stray "{{/flag}}") in the template.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
return result;
|
|
192
208
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Pure helpers for the `kumiko-schema-check` bin. Kept in src/ (vs. the bin)
|
|
2
|
+
// so they are part of the tsc project + unit-testable without importing the
|
|
3
|
+
// auto-running CLI.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { composeFeatures } from "./compose-features";
|
|
8
|
+
|
|
9
|
+
// The generate-script lives under different roots across apps: publicstatus
|
|
10
|
+
// uses `drizzle/generate.ts`, the framework's own sample-apps use
|
|
11
|
+
// `schema/generate.ts`. Pick whichever exists so a no-arg `bunx
|
|
12
|
+
// kumiko-schema-check` works in both layouts; default to `drizzle/` for the
|
|
13
|
+
// error message when neither is present.
|
|
14
|
+
export function resolveGeneratePath(cwd: string): string {
|
|
15
|
+
const drizzlePath = resolve(cwd, "drizzle/generate.ts");
|
|
16
|
+
const schemaPath = resolve(cwd, "schema/generate.ts");
|
|
17
|
+
if (!existsSync(drizzlePath) && existsSync(schemaPath)) return schemaPath;
|
|
18
|
+
return drizzlePath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The features composeFeatures auto-prepends in auth-mode (config + user +
|
|
22
|
+
// tenant + auth-email-password). Derived from composeFeatures itself so this
|
|
23
|
+
// can't drift from the real prepend-list in compose-features.ts.
|
|
24
|
+
export function implicitAuthModeFeatureNames(): readonly string[] {
|
|
25
|
+
return composeFeatures([], { includeBundled: true }).map((f) => f.name);
|
|
26
|
+
}
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
set -euo pipefail
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# `|| true` keeps `set -e`/`pipefail` from aborting the script when `grep`
|
|
20
|
+
# finds no match (exit 1) — the empty-check below owns that case and prints
|
|
21
|
+
# the actionable hint instead of a bare pipefail abort.
|
|
22
|
+
STACK_NETWORK=$(docker network ls --format "{{.Name}}" | grep -E "_stack$" | head -1 || true)
|
|
20
23
|
if [ -z "$STACK_NETWORK" ]; then
|
|
21
24
|
echo "No _stack network found — compose project not running?" >&2
|
|
22
25
|
exit 1
|
|
@@ -32,8 +35,14 @@ set +a
|
|
|
32
35
|
# DATABASE_URL assumes: db user = appName, db name = appName, host "db"
|
|
33
36
|
# (compose-service-name), port 5432. Adjust to your stack if different.
|
|
34
37
|
# Image tag pinned to :latest — swap to :${BUILD_SHA} for atomic deploys.
|
|
38
|
+
#
|
|
39
|
+
# Compose DATABASE_URL into the shell env and pass it to the container by
|
|
40
|
+
# NAME (`-e DATABASE_URL`, no value) so the expanded password never appears
|
|
41
|
+
# in `docker run`'s argv — otherwise it would be visible in `ps auxe` for
|
|
42
|
+
# the duration of the migrate run.
|
|
43
|
+
export DATABASE_URL="postgresql://{{appName}}:${DB_PASSWORD}@db:5432/{{appName}}"
|
|
35
44
|
docker run --rm \
|
|
36
45
|
--network "$STACK_NETWORK" \
|
|
37
|
-
-e DATABASE_URL
|
|
46
|
+
-e DATABASE_URL \
|
|
38
47
|
ghcr.io/{{githubOrg}}/{{appName}}:latest \
|
|
39
48
|
bun /app/kumiko.js schema apply
|