@cosmicdrift/kumiko-dev-server 0.410.0 → 0.412.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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/scaffold-agent-docs.d.ts +2 -1
- package/dist/scaffold-agent-docs.js +3 -3
- package/dist/scaffold-app.d.ts +6 -0
- package/dist/scaffold-app.js +102 -173
- package/dist/scaffold-blank-starter.d.ts +21 -0
- package/dist/scaffold-blank-starter.js +142 -0
- package/dist/scaffold-render-helpers.d.ts +11 -0
- package/dist/scaffold-render-helpers.js +108 -0
- package/dist/welcome-banner.d.ts +1 -1
- package/dist/welcome-banner.js +3 -1
- package/package.json +10 -10
- package/src/changes.json +6 -0
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type { ScaffoldAppOptions, ScaffoldAppResult, ScaffoldFeatureEntry, } fro
|
|
|
16
16
|
export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.ts";
|
|
17
17
|
export type { ScaffoldAppFeatureOptions, ScaffoldAppFeatureResult, } from "./scaffold-app-feature.ts";
|
|
18
18
|
export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.ts";
|
|
19
|
+
export { SCAFFOLD_STARTERS, type ScaffoldStarter } from "./scaffold-blank-starter.ts";
|
|
19
20
|
export type { CheckDeployDriftResult, DeployDriftEntry, RenderDeployFilesOptions, RenderDeployFilesResult, RenderedDeployFile, ScaffoldDeployDetected, ScaffoldDeployOptions, ScaffoldDeployResult, ScaffoldedFile, } from "./scaffold-deploy.ts";
|
|
20
21
|
export { checkDeployDrift, renderDeployFiles, scaffoldDeploy } from "./scaffold-deploy.ts";
|
|
21
22
|
export type { ScaffoldFeatureOptions, ScaffoldFeatureResult } from "./scaffold-feature.ts";
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export { runInitDeployCli } from "./init-deploy-cli.js";
|
|
|
29
29
|
export { runDevApp } from "./run-dev-app.js";
|
|
30
30
|
export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.js";
|
|
31
31
|
export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.js";
|
|
32
|
+
export { SCAFFOLD_STARTERS } from "./scaffold-blank-starter.js";
|
|
32
33
|
export { checkDeployDrift, renderDeployFiles, scaffoldDeploy } from "./scaffold-deploy.js";
|
|
33
34
|
export { scaffoldFeature } from "./scaffold-feature.js";
|
|
34
35
|
export { setupTestStackFromFeatures, } from "./setup-test-stack-from-features.js";
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export declare const FRAMEWORK_AGENTS_IMPORT = "@node_modules/@cosmicdrift/kumiko-framework/AGENTS.md";
|
|
2
2
|
export declare function renderMountedFeatureList(features: ReadonlyArray<{
|
|
3
3
|
readonly name: string;
|
|
4
|
-
}> | undefined): string;
|
|
4
|
+
}> | undefined, starter?: "demo" | "blank"): string;
|
|
5
5
|
export type AgentsMdOptions = {
|
|
6
6
|
readonly productName: string;
|
|
7
7
|
readonly features: ReadonlyArray<{
|
|
8
8
|
readonly name: string;
|
|
9
9
|
}> | undefined;
|
|
10
10
|
readonly scriptNames: readonly string[];
|
|
11
|
+
readonly starter?: "demo" | "blank";
|
|
11
12
|
};
|
|
12
13
|
export declare function renderAgentsMd(options: AgentsMdOptions): string;
|
|
13
14
|
export declare function renderClaudeMd(): string;
|
|
@@ -20,11 +20,11 @@ const SCRIPT_DESCRIPTIONS = {
|
|
|
20
20
|
};
|
|
21
21
|
const DEMO_TASKS_FEATURE_LINE = "- `tasks` (demo — list + edit screens)";
|
|
22
22
|
const FOUNDATION_FEATURE_LINES = ["- `secrets` (foundation)", "- `sessions` (foundation)"];
|
|
23
|
-
export function renderMountedFeatureList(features) {
|
|
23
|
+
export function renderMountedFeatureList(features, starter = "demo") {
|
|
24
24
|
const lines = features && features.length > 0
|
|
25
25
|
? features.map((f) => `- \`${f.name}\``)
|
|
26
26
|
: FOUNDATION_FEATURE_LINES;
|
|
27
|
-
return [...lines, DEMO_TASKS_FEATURE_LINE].join("\n");
|
|
27
|
+
return (starter === "demo" ? [...lines, DEMO_TASKS_FEATURE_LINE] : lines).join("\n");
|
|
28
28
|
}
|
|
29
29
|
function renderCommandList(scriptNames) {
|
|
30
30
|
return scriptNames
|
|
@@ -42,7 +42,7 @@ function agentsTemplatePath() {
|
|
|
42
42
|
export function renderAgentsMd(options) {
|
|
43
43
|
return render(readFileSync(agentsTemplatePath(), "utf-8"), {
|
|
44
44
|
productName: options.productName,
|
|
45
|
-
featureList: renderMountedFeatureList(options.features),
|
|
45
|
+
featureList: renderMountedFeatureList(options.features, options.starter),
|
|
46
46
|
commands: renderCommandList(options.scriptNames),
|
|
47
47
|
}, {});
|
|
48
48
|
}
|
package/dist/scaffold-app.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ScaffoldStarter } from "./scaffold-blank-starter.ts";
|
|
2
|
+
export { SCAFFOLD_STARTERS, type ScaffoldStarter } from "./scaffold-blank-starter.ts";
|
|
1
3
|
export type ScaffoldFeatureEntry = {
|
|
2
4
|
readonly name: string;
|
|
3
5
|
readonly importPath: string;
|
|
@@ -15,6 +17,7 @@ export type ScaffoldTestSetup = {
|
|
|
15
17
|
export type ScaffoldTestSetupFactory = (input: {
|
|
16
18
|
readonly appName: string;
|
|
17
19
|
readonly frameworkVersion: string;
|
|
20
|
+
readonly starter?: ScaffoldStarter;
|
|
18
21
|
}) => ScaffoldTestSetup;
|
|
19
22
|
export declare const SCAFFOLD_BIOME_VERSION = "2.6.4";
|
|
20
23
|
export type ScaffoldAppOptions = {
|
|
@@ -40,6 +43,9 @@ export type ScaffoldAppOptions = {
|
|
|
40
43
|
readonly testSetup?: ScaffoldTestSetupFactory;
|
|
41
44
|
/** Infra tag written to `package.json#kumiko.deploy.infraRef`. Default: CURRENT_INFRA_REF. */
|
|
42
45
|
readonly infraRef?: string;
|
|
46
|
+
/** "demo" (default): demo tasks feature, admin seeded from DEMO_ADMIN_PASSWORD.
|
|
47
|
+
* "blank": no demo; the creator is seeded from KUMIKO_BOOTSTRAP_ADMIN_EMAIL without a password. */
|
|
48
|
+
readonly starter?: ScaffoldStarter;
|
|
43
49
|
};
|
|
44
50
|
export type ScaffoldAppResult = {
|
|
45
51
|
readonly destination: string;
|
package/dist/scaffold-app.js
CHANGED
|
@@ -28,7 +28,10 @@ import { resolveFrameworkVersion } from "./framework-version.js";
|
|
|
28
28
|
import { isKebabSegment } from "./kebab.js";
|
|
29
29
|
import { CURRENT_INFRA_REF, PREPARE_SCRIPT, resolveManagedAppFacts, syncManagedFiles, } from "./managed-files/index.js";
|
|
30
30
|
import { renderAgentsMd, renderClaudeMd, renderMountedFeatureList } from "./scaffold-agent-docs.js";
|
|
31
|
+
import { BLANK_ENV_EXAMPLE_ADMIN_BLOCK, BLANK_STARTER_DELIVERY_FEATURE, blankReadmeCopy, renderBlankEnvSchema, renderBlankMain, renderBlankServer, } from "./scaffold-blank-starter.js";
|
|
31
32
|
import { createDemoTasksFeature, renderDemoSeedFile, renderDemoTasksFeatureFile, renderDemoTasksI18n, renderDemoTasksIndex, renderDemoTasksWebIndex, } from "./scaffold-demo-tasks.js";
|
|
33
|
+
import { deriveTenantId, MAX_LINE_WIDTH, renderAdminAuthBlock, renderDefaultAppShellElement, renderSortedImports, } from "./scaffold-render-helpers.js";
|
|
34
|
+
export { SCAFFOLD_STARTERS } from "./scaffold-blank-starter.js";
|
|
32
35
|
// Exact pin (no caret): the generated files are formatted to this Biome's output,
|
|
33
36
|
// and a scaffold-lint test keeps it equal to the monorepo's own Biome version.
|
|
34
37
|
export const SCAFFOLD_BIOME_VERSION = "2.6.4";
|
|
@@ -39,7 +42,30 @@ dist-server/
|
|
|
39
42
|
test-results/
|
|
40
43
|
playwright-report/
|
|
41
44
|
`;
|
|
42
|
-
|
|
45
|
+
function writeDemoTasksFiles(destination, files) {
|
|
46
|
+
mkdirSync(join(destination, "src", "features", "tasks", "web"), { recursive: true });
|
|
47
|
+
write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
|
|
48
|
+
files.push("src/features/tasks/feature.ts");
|
|
49
|
+
write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
|
|
50
|
+
files.push("src/features/tasks/i18n.ts");
|
|
51
|
+
write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
|
|
52
|
+
files.push("src/features/tasks/index.ts");
|
|
53
|
+
write(join(destination, "src", "features", "tasks", "web", "index.ts"), renderDemoTasksWebIndex());
|
|
54
|
+
files.push("src/features/tasks/web/index.ts");
|
|
55
|
+
write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
|
|
56
|
+
files.push("src/seed.ts");
|
|
57
|
+
}
|
|
58
|
+
function writeBinFiles(destination, appName, isBlank, files) {
|
|
59
|
+
write(join(destination, "bin", "main.ts"), isBlank ? renderBlankMain(appName) : renderMain(appName));
|
|
60
|
+
files.push("bin/main.ts");
|
|
61
|
+
write(join(destination, "bin", "server.ts"), isBlank ? renderBlankServer(appName) : renderServer(appName));
|
|
62
|
+
files.push("bin/server.ts");
|
|
63
|
+
write(join(destination, "bin", "env-schema.ts"), isBlank ? renderBlankEnvSchema() : renderEnvSchema(appName));
|
|
64
|
+
files.push("bin/env-schema.ts");
|
|
65
|
+
write(join(destination, "bin", "kumiko.ts"), renderBinKumiko());
|
|
66
|
+
files.push("bin/kumiko.ts");
|
|
67
|
+
}
|
|
68
|
+
function resolveScaffoldTarget(options) {
|
|
43
69
|
if (!isKebabSegment(options.name)) {
|
|
44
70
|
throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
|
|
45
71
|
}
|
|
@@ -52,7 +78,17 @@ export async function scaffoldApp(options) {
|
|
|
52
78
|
if (version === undefined) {
|
|
53
79
|
throw new Error("scaffoldApp: cannot resolve the framework version; pass frameworkVersion explicitly");
|
|
54
80
|
}
|
|
55
|
-
|
|
81
|
+
return { destination, version };
|
|
82
|
+
}
|
|
83
|
+
export async function scaffoldApp(options) {
|
|
84
|
+
const { destination, version } = resolveScaffoldTarget(options);
|
|
85
|
+
const starter = options.starter ?? "demo";
|
|
86
|
+
const isBlank = starter === "blank";
|
|
87
|
+
const testSetup = options.testSetup?.({
|
|
88
|
+
appName: options.name,
|
|
89
|
+
frameworkVersion: version,
|
|
90
|
+
...(isBlank && { starter }),
|
|
91
|
+
});
|
|
56
92
|
mkdirSync(join(destination, "bin"), { recursive: true });
|
|
57
93
|
mkdirSync(join(destination, "src"), { recursive: true });
|
|
58
94
|
mkdirSync(join(destination, "kumiko"), { recursive: true });
|
|
@@ -63,48 +99,30 @@ export async function scaffoldApp(options) {
|
|
|
63
99
|
files.push("kumiko.json");
|
|
64
100
|
write(join(destination, ".gitignore"), SCAFFOLD_GITIGNORE);
|
|
65
101
|
files.push(".gitignore");
|
|
66
|
-
write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
|
|
102
|
+
write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features, starter));
|
|
67
103
|
files.push("src/run-config.ts");
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
files.push("src/features/tasks/feature.ts");
|
|
71
|
-
write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
|
|
72
|
-
files.push("src/features/tasks/i18n.ts");
|
|
73
|
-
write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
|
|
74
|
-
files.push("src/features/tasks/index.ts");
|
|
75
|
-
write(join(destination, "src", "features", "tasks", "web", "index.ts"), renderDemoTasksWebIndex());
|
|
76
|
-
files.push("src/features/tasks/web/index.ts");
|
|
77
|
-
write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
|
|
78
|
-
files.push("src/seed.ts");
|
|
104
|
+
if (!isBlank)
|
|
105
|
+
writeDemoTasksFiles(destination, files);
|
|
79
106
|
write(join(destination, APP_CONFIG_FILE), renderAppConfig());
|
|
80
107
|
files.push(APP_CONFIG_FILE);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
write(join(destination, "bin", "server.ts"), renderServer(options.name));
|
|
84
|
-
files.push("bin/server.ts");
|
|
85
|
-
write(join(destination, "bin", "env-schema.ts"), renderEnvSchema(options.name));
|
|
86
|
-
files.push("bin/env-schema.ts");
|
|
87
|
-
write(join(destination, "bin", "kumiko.ts"), renderBinKumiko());
|
|
88
|
-
files.push("bin/kumiko.ts");
|
|
89
|
-
write(join(destination, "src", "client.tsx"), renderClient(options.name));
|
|
108
|
+
writeBinFiles(destination, options.name, isBlank, files);
|
|
109
|
+
write(join(destination, "src", "client.tsx"), renderClient(options.name, starter));
|
|
90
110
|
files.push("src/client.tsx");
|
|
91
111
|
write(join(destination, "src", "styles.css"), renderStylesCss());
|
|
92
112
|
files.push("src/styles.css");
|
|
93
113
|
mkdirSync(join(destination, "public"), { recursive: true });
|
|
94
114
|
write(join(destination, "public", "index.html"), renderIndexHtml(options.name));
|
|
95
115
|
files.push("public/index.html");
|
|
96
|
-
write(join(destination, ".env.example"), renderEnvExample(options.name));
|
|
116
|
+
write(join(destination, ".env.example"), renderEnvExample(options.name, starter));
|
|
97
117
|
files.push(".env.example");
|
|
98
118
|
write(join(destination, "docker-compose.yml"), renderDockerCompose());
|
|
99
119
|
files.push("docker-compose.yml");
|
|
100
|
-
await writeInitMigration(destination, options.features);
|
|
120
|
+
await writeInitMigration(destination, options.features, starter);
|
|
101
121
|
files.push("kumiko/migrations/0001_init.sql", "kumiko/migrations/.snapshot.json");
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
files.push(rel);
|
|
107
|
-
}
|
|
122
|
+
for (const [rel, content] of Object.entries(testSetup?.files ?? {})) {
|
|
123
|
+
mkdirSync(dirname(join(destination, rel)), { recursive: true });
|
|
124
|
+
write(join(destination, rel), content);
|
|
125
|
+
files.push(rel);
|
|
108
126
|
}
|
|
109
127
|
const managedFacts = resolveManagedAppFacts({
|
|
110
128
|
appRoot: destination,
|
|
@@ -115,11 +133,12 @@ export async function scaffoldApp(options) {
|
|
|
115
133
|
if (action.action === "create")
|
|
116
134
|
files.push(action.path);
|
|
117
135
|
}
|
|
118
|
-
write(join(destination, "README.md"), `${renderReadme(options.name, options.features)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`);
|
|
136
|
+
write(join(destination, "README.md"), `${renderReadme(options.name, options.features, starter)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`);
|
|
119
137
|
files.push("README.md");
|
|
120
138
|
write(join(destination, "AGENTS.md"), renderAgentsMd({
|
|
121
139
|
productName: options.name,
|
|
122
140
|
features: options.features,
|
|
141
|
+
starter,
|
|
123
142
|
scriptNames: Object.keys(appScripts(testSetup)),
|
|
124
143
|
}));
|
|
125
144
|
files.push("AGENTS.md");
|
|
@@ -207,42 +226,7 @@ const FOUNDATION_FEATURES = [
|
|
|
207
226
|
// scaffolded `bun dev`; filtering also protects direct scaffoldApp() calls that
|
|
208
227
|
// pass all bundled-feature entries, keeping these four out of run-config.ts.
|
|
209
228
|
const COMPOSE_AUTO_MOUNTED_NAMES = new Set(["config", "user", "tenant", "auth-email-password"]);
|
|
210
|
-
|
|
211
|
-
// Mirrors Biome's import sort so generated files pass `biome check` untouched:
|
|
212
|
-
// packages alphabetically by specifier, relative paths last.
|
|
213
|
-
function compareImportKeys(a, b) {
|
|
214
|
-
const lowerA = a.replace(/^type /, "").toLowerCase();
|
|
215
|
-
const lowerB = b.replace(/^type /, "").toLowerCase();
|
|
216
|
-
if (lowerA !== lowerB)
|
|
217
|
-
return lowerA < lowerB ? -1 : 1;
|
|
218
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
219
|
-
}
|
|
220
|
-
function isRelativeSpecifier(specifier) {
|
|
221
|
-
return specifier.startsWith("./") || specifier.startsWith("../");
|
|
222
|
-
}
|
|
223
|
-
function renderImportLine({ specifier, names, typeOnly, defaultName }) {
|
|
224
|
-
if (defaultName !== undefined)
|
|
225
|
-
return `import ${defaultName} from "${specifier}";`;
|
|
226
|
-
const sorted = [...names].sort(compareImportKeys);
|
|
227
|
-
const keyword = typeOnly === true ? "import type" : "import";
|
|
228
|
-
const oneLine = `${keyword} { ${sorted.join(", ")} } from "${specifier}";`;
|
|
229
|
-
if (sorted.length === 1 || oneLine.length <= MAX_LINE_WIDTH)
|
|
230
|
-
return oneLine;
|
|
231
|
-
return `${keyword} {\n${sorted.map((n) => ` ${n},`).join("\n")}\n} from "${specifier}";`;
|
|
232
|
-
}
|
|
233
|
-
function renderSortedImports(imports) {
|
|
234
|
-
return [...imports]
|
|
235
|
-
.sort((a, b) => {
|
|
236
|
-
const relativeA = isRelativeSpecifier(a.specifier);
|
|
237
|
-
const relativeB = isRelativeSpecifier(b.specifier);
|
|
238
|
-
if (relativeA !== relativeB)
|
|
239
|
-
return relativeA ? 1 : -1;
|
|
240
|
-
return compareImportKeys(a.specifier, b.specifier);
|
|
241
|
-
})
|
|
242
|
-
.map(renderImportLine)
|
|
243
|
-
.join("\n");
|
|
244
|
-
}
|
|
245
|
-
function renderRunConfig(features) {
|
|
229
|
+
function effectiveScaffoldFeatures(features, starter) {
|
|
246
230
|
// Fallback decided BEFORE filtering: if the caller passed features (even
|
|
247
231
|
// if every one of them is an auto-mounted name), an all-filtered-out empty
|
|
248
232
|
// result is the correct outcome — falling back to FOUNDATION_FEATURES here
|
|
@@ -250,6 +234,12 @@ function renderRunConfig(features) {
|
|
|
250
234
|
// for.
|
|
251
235
|
const base = features?.length ? features : FOUNDATION_FEATURES;
|
|
252
236
|
const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
|
|
237
|
+
const needsDelivery = starter === "blank" && !effective.some((f) => f.name === BLANK_STARTER_DELIVERY_FEATURE.name);
|
|
238
|
+
return needsDelivery ? [...effective, BLANK_STARTER_DELIVERY_FEATURE] : effective;
|
|
239
|
+
}
|
|
240
|
+
function renderRunConfig(features, starter) {
|
|
241
|
+
const effective = effectiveScaffoldFeatures(features, starter);
|
|
242
|
+
const hasTasks = starter === "demo";
|
|
253
243
|
const grouped = new Map();
|
|
254
244
|
for (const entry of effective) {
|
|
255
245
|
const existing = grouped.get(entry.importPath) ?? [];
|
|
@@ -260,9 +250,13 @@ function renderRunConfig(features) {
|
|
|
260
250
|
const imports = [
|
|
261
251
|
...[...grouped].map(([specifier, names]) => ({ specifier, names })),
|
|
262
252
|
{ specifier: "@cosmicdrift/kumiko-locale-de", names: ["localeDe"] },
|
|
263
|
-
{ specifier: "./features/tasks", names: ["tasksFeature"] },
|
|
253
|
+
...(hasTasks ? [{ specifier: "./features/tasks", names: ["tasksFeature"] }] : []),
|
|
254
|
+
];
|
|
255
|
+
const callExprs = [
|
|
256
|
+
...effective.map((f) => f.callExpression),
|
|
257
|
+
"localeDe()",
|
|
258
|
+
...(hasTasks ? ["tasksFeature"] : []),
|
|
264
259
|
];
|
|
265
|
-
const callExprs = [...effective.map((f) => f.callExpression), "localeDe()", "tasksFeature"];
|
|
266
260
|
const oneLineFeatures = `export const APP_FEATURES = [${callExprs.join(", ")}] as const;`;
|
|
267
261
|
const appFeatures = oneLineFeatures.length <= MAX_LINE_WIDTH
|
|
268
262
|
? oneLineFeatures
|
|
@@ -283,23 +277,6 @@ ${appFeatures}
|
|
|
283
277
|
export const HAS_AUTH = true;
|
|
284
278
|
`;
|
|
285
279
|
}
|
|
286
|
-
function renderAdminAuthBlock(appName, extraAdminLines) {
|
|
287
|
-
const passwordAndExtras = extraAdminLines.map((l) => ` ${l}`).join("\n");
|
|
288
|
-
return ` auth: {
|
|
289
|
-
admin: {
|
|
290
|
-
email: "admin@${appName}.local",
|
|
291
|
-
${passwordAndExtras}
|
|
292
|
-
memberships: [
|
|
293
|
-
{
|
|
294
|
-
tenantId: DEFAULT_TENANT_ID,
|
|
295
|
-
tenantKey: "${appName}",
|
|
296
|
-
tenantName: "${appName}",
|
|
297
|
-
roles: ["TenantAdmin"],
|
|
298
|
-
},
|
|
299
|
-
],
|
|
300
|
-
},
|
|
301
|
-
},`;
|
|
302
|
-
}
|
|
303
280
|
// Ports match runDevApp's default and kumiko-testing's SCAFFOLD_E2E_PORT.
|
|
304
281
|
const SCAFFOLD_DEV_PORT = 4173;
|
|
305
282
|
const SCAFFOLD_E2E_PORT = 4174;
|
|
@@ -442,42 +419,15 @@ ${authBlock}
|
|
|
442
419
|
});
|
|
443
420
|
`;
|
|
444
421
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
function renderDefaultAppShellElement(appName) {
|
|
448
|
-
const spanOpen = '<span className="font-semibold tracking-tight">';
|
|
449
|
-
const userSpread = "{...(user !== undefined && { user })}";
|
|
450
|
-
// html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
|
|
451
|
-
const brandInline = `${spanOpen}${appName}</span>`;
|
|
452
|
-
const singleLine = ` <DefaultAppShell brand={${brandInline}} schema={schema} ${userSpread}>`;
|
|
453
|
-
if (singleLine.length <= MAX_LINE_WIDTH)
|
|
454
|
-
return singleLine;
|
|
455
|
-
const brandAttrInline = ` brand={${brandInline}}`;
|
|
456
|
-
const brandAttribute = brandAttrInline.length <= MAX_LINE_WIDTH
|
|
457
|
-
? brandAttrInline
|
|
458
|
-
: `${[
|
|
459
|
-
" brand={",
|
|
460
|
-
...(` ${brandInline}`.length <= MAX_LINE_WIDTH
|
|
461
|
-
? [` ${brandInline}`]
|
|
462
|
-
: [` ${spanOpen}`, ` ${appName}`, " </span>"]),
|
|
463
|
-
" }",
|
|
464
|
-
].join("\n")}`;
|
|
465
|
-
return [
|
|
466
|
-
" <DefaultAppShell",
|
|
467
|
-
brandAttribute,
|
|
468
|
-
" schema={schema}",
|
|
469
|
-
` ${userSpread}`,
|
|
470
|
-
" >",
|
|
471
|
-
].join("\n");
|
|
472
|
-
}
|
|
473
|
-
function renderClient(appName) {
|
|
422
|
+
function renderClient(appName, starter) {
|
|
423
|
+
const hasTasks = starter === "demo";
|
|
474
424
|
const imports = renderSortedImports([
|
|
475
425
|
{
|
|
476
426
|
specifier: "@cosmicdrift/kumiko-bundled-features/auth-email-password/web",
|
|
477
427
|
names: ["emailPasswordClient", "useShellUser"],
|
|
478
428
|
},
|
|
479
429
|
{ specifier: "@cosmicdrift/kumiko-locale-de/web", names: ["localeDeClient"] },
|
|
480
|
-
{ specifier: "./features/tasks/web", names: ["tasksClient"] },
|
|
430
|
+
...(hasTasks ? [{ specifier: "./features/tasks/web", names: ["tasksClient"] }] : []),
|
|
481
431
|
{
|
|
482
432
|
specifier: "@cosmicdrift/kumiko-renderer/web",
|
|
483
433
|
names: ["type AppSchema", "createKumikoApp", "DefaultAppShell"],
|
|
@@ -511,12 +461,19 @@ ${renderDefaultAppShellElement(appName)}
|
|
|
511
461
|
|
|
512
462
|
createKumikoApp({
|
|
513
463
|
shell: AppShell,
|
|
514
|
-
clientFeatures: [localeDeClient(), emailPasswordClient(), tasksClient],
|
|
464
|
+
clientFeatures: [localeDeClient(), emailPasswordClient()${hasTasks ? ", tasksClient" : ""}],
|
|
515
465
|
});
|
|
516
466
|
`;
|
|
517
467
|
}
|
|
518
|
-
function renderEnvExample(appName) {
|
|
468
|
+
function renderEnvExample(appName, starter) {
|
|
519
469
|
const devDb = `${appName.replace(/-/g, "_")}_dev`;
|
|
470
|
+
const adminBlock = starter === "blank"
|
|
471
|
+
? BLANK_ENV_EXAMPLE_ADMIN_BLOCK
|
|
472
|
+
: `# DEMO_ADMIN_PASSWORD: password of the admin seeded on first prod boot
|
|
473
|
+
# (admin@${appName}.local). Required by \`bun run boot\` and \`bun run start\`,
|
|
474
|
+
# min 12 chars. Generate with: openssl rand -hex 24
|
|
475
|
+
DEMO_ADMIN_PASSWORD=
|
|
476
|
+
`;
|
|
520
477
|
return `# bun dev (runDevApp → setupTestStack) needs TEST_DATABASE_URL.
|
|
521
478
|
# Production (bun bin/main.ts → runProdApp) needs DATABASE_URL.
|
|
522
479
|
# Both default to the same local Postgres — runDevApp creates its own
|
|
@@ -536,11 +493,7 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
|
|
|
536
493
|
# Generate with: openssl rand -base64 32
|
|
537
494
|
KUMIKO_SECRETS_MASTER_KEY_V1=
|
|
538
495
|
|
|
539
|
-
|
|
540
|
-
# (admin@${appName}.local). Required by \`bun run boot\` and \`bun run start\`,
|
|
541
|
-
# min 12 chars. Generate with: openssl rand -hex 24
|
|
542
|
-
DEMO_ADMIN_PASSWORD=
|
|
543
|
-
|
|
496
|
+
${adminBlock}
|
|
544
497
|
# Subject-keys KMS for PII fields (user, tenant-invitation, fileRef). All-or-none.
|
|
545
498
|
# Unset → local boot with plaintext PII + warning; NODE_ENV=production requires
|
|
546
499
|
# the trio (requireKmsWiring). Generate PLATFORM_KEK / KUMIKO_BLIND_INDEX_KEY
|
|
@@ -590,20 +543,28 @@ volumes:
|
|
|
590
543
|
kumiko-pg:
|
|
591
544
|
`;
|
|
592
545
|
}
|
|
593
|
-
function
|
|
594
|
-
|
|
546
|
+
function demoReadmeCopy(appName) {
|
|
547
|
+
return {
|
|
548
|
+
intro: "Scaffolded by `kumiko new app`. Includes a demo **tasks** feature with list +\nedit screens, sidebar nav, and seeded rows — `bun dev` shows a working admin UI\nafter login. Add more features via `bunx @cosmicdrift/kumiko-cli add feature <name>`.",
|
|
549
|
+
runConfigNote: "Edit `src/run-config.ts` to add bundled features. The demo lives in\n`src/features/tasks/`.",
|
|
550
|
+
devLogin: `The welcome banner prints the URL (default \`http://localhost:4173\`) and admin\nlogin. Sign in as \`admin@${appName}.local\` / \`changeme\`, then open **Tasks**\nin the sidebar — demo rows are pre-seeded.`,
|
|
551
|
+
bootEnvNote: "a missing one exits 0 without validating the wiring, so set\n`DEMO_ADMIN_PASSWORD` (min 12 chars, `openssl rand -hex 24`) in `.env`.",
|
|
552
|
+
prodSeed: `The first prod boot seeds \`admin@${appName}.local\` with the password from\n\`DEMO_ADMIN_PASSWORD\` (required, min 12 chars). Log in with that.`,
|
|
553
|
+
demoArchitectureLines: "- `src/features/tasks/` — demo feature (entity + handlers + screens + nav).\n- `src/seed.ts` — dev seed for demo tasks (`bun dev` only).\n",
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function renderReadme(appName, features, starter) {
|
|
557
|
+
const copy = starter === "blank" ? blankReadmeCopy(appName) : demoReadmeCopy(appName);
|
|
558
|
+
const featureList = renderMountedFeatureList(features, starter);
|
|
595
559
|
return `# ${appName}
|
|
596
560
|
|
|
597
|
-
|
|
598
|
-
edit screens, sidebar nav, and seeded rows — \`bun dev\` shows a working admin UI
|
|
599
|
-
after login. Add more features via \`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
|
|
561
|
+
${copy.intro}
|
|
600
562
|
|
|
601
563
|
## Mounted features
|
|
602
564
|
|
|
603
565
|
${featureList}
|
|
604
566
|
|
|
605
|
-
|
|
606
|
-
\`src/features/tasks/\`.
|
|
567
|
+
${copy.runConfigNote}
|
|
607
568
|
|
|
608
569
|
## First run (browser)
|
|
609
570
|
|
|
@@ -615,9 +576,7 @@ docker compose up -d # local Postgres + Redis (skip if you already have them)
|
|
|
615
576
|
bun dev
|
|
616
577
|
\`\`\`
|
|
617
578
|
|
|
618
|
-
|
|
619
|
-
login. Sign in as \`admin@${appName}.local\` / \`changeme\`, then open **Tasks**
|
|
620
|
-
in the sidebar — demo rows are pre-seeded.
|
|
579
|
+
${copy.devLogin}
|
|
621
580
|
|
|
622
581
|
## Boot-only smoke (no DB needed)
|
|
623
582
|
|
|
@@ -628,8 +587,7 @@ bun run boot
|
|
|
628
587
|
Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
|
|
629
588
|
+ env schema, exits 0 without touching DB/Redis. Useful in CI. It prints every
|
|
630
589
|
variable with its feature and status, never the value. An invalid value exits 1;
|
|
631
|
-
|
|
632
|
-
\`DEMO_ADMIN_PASSWORD\` (min 12 chars, \`openssl rand -hex 24\`) in \`.env\`.
|
|
590
|
+
${copy.bootEnvNote}
|
|
633
591
|
|
|
634
592
|
Without \`PLATFORM_KEK\` / \`SUBJECT_KEYS_DATABASE_URL\` / \`KUMIKO_BLIND_INDEX_KEY\`
|
|
635
593
|
set (see \`.env.example\`), both \`bun run boot\` and \`bun run start\` boot with
|
|
@@ -644,8 +602,7 @@ bun run schema:apply # apply checked-in kumiko/migrations (needs DATABASE_URL)
|
|
|
644
602
|
bun run start # runProdApp against dist/
|
|
645
603
|
\`\`\`
|
|
646
604
|
|
|
647
|
-
|
|
648
|
-
\`DEMO_ADMIN_PASSWORD\` (required, min 12 chars). Log in with that.
|
|
605
|
+
${copy.prodSeed}
|
|
649
606
|
|
|
650
607
|
After adding entities/features, regenerate migrations:
|
|
651
608
|
|
|
@@ -669,9 +626,7 @@ Files whose first line reads \`kumiko-managed\` (deploy/, .github/workflows/,
|
|
|
669
626
|
## Architecture
|
|
670
627
|
|
|
671
628
|
- \`src/run-config.ts\` — single source of truth: which features your app mounts (\`APP_FEATURES\`, \`HAS_AUTH\`).
|
|
672
|
-
- \`
|
|
673
|
-
- \`src/seed.ts\` — dev seed for demo tasks (\`bun dev\` only).
|
|
674
|
-
- \`kumiko.config.ts\` — the composed feature list, client entries and ports the tooling reads.
|
|
629
|
+
${copy.demoArchitectureLines}- \`kumiko.config.ts\` — the composed feature list, client entries and ports the tooling reads.
|
|
675
630
|
- \`kumiko/schema.ts\` — generated (\`kumiko-managed\`): \`ENTITY_METAS\` from kumiko.config.ts for \`kumiko schema\`.
|
|
676
631
|
- \`bin/server.ts\` — dev-server entry (\`bun dev\`).
|
|
677
632
|
- \`bin/env-schema.ts\` — env contract of the production processes.
|
|
@@ -770,9 +725,8 @@ function renderBinKumiko() {
|
|
|
770
725
|
"",
|
|
771
726
|
].join("\n");
|
|
772
727
|
}
|
|
773
|
-
async function instantiateScaffoldFeatures(features) {
|
|
774
|
-
const
|
|
775
|
-
const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
|
|
728
|
+
async function instantiateScaffoldFeatures(features, starter) {
|
|
729
|
+
const effective = effectiveScaffoldFeatures(features, starter);
|
|
776
730
|
const instances = [];
|
|
777
731
|
for (const entry of effective) {
|
|
778
732
|
const mod = (await import(__rewriteRelativeImportExtension(entry.importPath)));
|
|
@@ -791,11 +745,12 @@ async function instantiateScaffoldFeatures(features) {
|
|
|
791
745
|
instances.push(exp);
|
|
792
746
|
}
|
|
793
747
|
}
|
|
794
|
-
|
|
748
|
+
if (starter === "demo")
|
|
749
|
+
instances.push(createDemoTasksFeature());
|
|
795
750
|
return instances;
|
|
796
751
|
}
|
|
797
|
-
async function writeInitMigration(destination, features) {
|
|
798
|
-
const instances = await instantiateScaffoldFeatures(features);
|
|
752
|
+
async function writeInitMigration(destination, features, starter) {
|
|
753
|
+
const instances = await instantiateScaffoldFeatures(features, starter);
|
|
799
754
|
const composed = composeFeatures(instances, { includeBundled: true });
|
|
800
755
|
const metas = collectTableMetas(composed);
|
|
801
756
|
const result = generateMigration({
|
|
@@ -810,29 +765,3 @@ async function writeInitMigration(destination, features) {
|
|
|
810
765
|
writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
|
|
811
766
|
writeSnapshotJson(join(migrationsDir, ".snapshot.json"), result.snapshot);
|
|
812
767
|
}
|
|
813
|
-
// Deterministic tenant-ID from app-name. Format: UUID-v4 with the
|
|
814
|
-
// version-marker at the right spot. NOT cryptographically random —
|
|
815
|
-
// just a stable per-app default the user can change later.
|
|
816
|
-
function deriveTenantId(name) {
|
|
817
|
-
let state = 2166136261;
|
|
818
|
-
for (const ch of name) {
|
|
819
|
-
state ^= ch.charCodeAt(0);
|
|
820
|
-
state = Math.imul(state, 16777619) >>> 0;
|
|
821
|
-
}
|
|
822
|
-
// @wrapper-known semantic-alias
|
|
823
|
-
const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
|
|
824
|
-
const a = hex(state, 8);
|
|
825
|
-
state ^= state << 13;
|
|
826
|
-
state >>>= 0;
|
|
827
|
-
const b = hex(state, 4);
|
|
828
|
-
state ^= state >>> 17;
|
|
829
|
-
state >>>= 0;
|
|
830
|
-
const c = `4${hex(state, 3)}`;
|
|
831
|
-
state ^= state << 5;
|
|
832
|
-
state >>>= 0;
|
|
833
|
-
const d4 = (0x8 | (state & 0x3)).toString(16);
|
|
834
|
-
const d = `${d4}${hex(state >>> 4, 3)}`;
|
|
835
|
-
state = Math.imul(state, 16777619) >>> 0;
|
|
836
|
-
const e = hex(state, 12);
|
|
837
|
-
return `${a}-${b}-${c}-${d}-${e}`;
|
|
838
|
-
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const SCAFFOLD_STARTERS: readonly ["demo", "blank"];
|
|
2
|
+
export type ScaffoldStarter = (typeof SCAFFOLD_STARTERS)[number];
|
|
3
|
+
export declare const BLANK_STARTER_DELIVERY_FEATURE: {
|
|
4
|
+
readonly name: "delivery";
|
|
5
|
+
readonly importPath: "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
6
|
+
readonly exportName: "createDeliveryFeature";
|
|
7
|
+
readonly callExpression: "createDeliveryFeature()";
|
|
8
|
+
};
|
|
9
|
+
export declare function renderBlankEnvSchema(): string;
|
|
10
|
+
export declare function renderBlankMain(appName: string): string;
|
|
11
|
+
export declare function renderBlankServer(appName: string): string;
|
|
12
|
+
export declare const BLANK_ENV_EXAMPLE_ADMIN_BLOCK = "# KUMIKO_BOOTSTRAP_ADMIN_EMAIL: login of the person who created the app. The first prod\n# boot makes it the admin WITHOUT a password; set one via \"forgot password\". That needs\n# SMTP_HOST (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM) and APP_BASE_URL (e.g.\n# https://app.example.com); without them the reset mail flow stays off.\n# KUMIKO_BOOTSTRAP_ADMIN_EMAIL=\n# SMTP_HOST=\n# APP_BASE_URL=\n";
|
|
13
|
+
export type ReadmeCopy = {
|
|
14
|
+
readonly intro: string;
|
|
15
|
+
readonly runConfigNote: string;
|
|
16
|
+
readonly devLogin: string;
|
|
17
|
+
readonly bootEnvNote: string;
|
|
18
|
+
readonly prodSeed: string;
|
|
19
|
+
readonly demoArchitectureLines: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function blankReadmeCopy(appName: string): ReadmeCopy;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The `blank` starter of scaffoldApp: no demo feature and no admin password in the env. The
|
|
2
|
+
// person who creates the app is seeded from KUMIKO_BOOTSTRAP_ADMIN_EMAIL and sets a password
|
|
3
|
+
// through "forgot password".
|
|
4
|
+
import { deriveTenantId, renderAdminAuthBlock, renderSortedImports, } from "./scaffold-render-helpers.js";
|
|
5
|
+
export const SCAFFOLD_STARTERS = ["demo", "blank"];
|
|
6
|
+
// Delivery is what the `mail` boot option sends through.
|
|
7
|
+
export const BLANK_STARTER_DELIVERY_FEATURE = {
|
|
8
|
+
name: "delivery",
|
|
9
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/delivery",
|
|
10
|
+
exportName: "createDeliveryFeature",
|
|
11
|
+
callExpression: "createDeliveryFeature()",
|
|
12
|
+
};
|
|
13
|
+
export function renderBlankEnvSchema() {
|
|
14
|
+
const imports = renderSortedImports([
|
|
15
|
+
{ specifier: "@cosmicdrift/kumiko-dev-server/env-schema", names: ["frameworkCoreEnvSchema"] },
|
|
16
|
+
{ specifier: "@cosmicdrift/kumiko-framework/config", names: ["allAppFeatures"] },
|
|
17
|
+
{ specifier: "@cosmicdrift/kumiko-framework/env", names: ["composeEnvSchema"] },
|
|
18
|
+
]);
|
|
19
|
+
return `// Env contract of the production processes (bin/main.ts, bin/kumiko.ts).
|
|
20
|
+
// Importing it must not read process.env; validation happens in runProdApp.
|
|
21
|
+
// KUMIKO_BOOTSTRAP_ADMIN_EMAIL joins the schema through the seeds option in bin/main.ts.
|
|
22
|
+
|
|
23
|
+
${imports}
|
|
24
|
+
import appConfig from "../kumiko.config";
|
|
25
|
+
|
|
26
|
+
export const envSchema = composeEnvSchema({
|
|
27
|
+
core: frameworkCoreEnvSchema,
|
|
28
|
+
features: allAppFeatures(appConfig),
|
|
29
|
+
});
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
export function renderBlankMain(appName) {
|
|
33
|
+
const imports = renderSortedImports([
|
|
34
|
+
{ specifier: "@cosmicdrift/kumiko-server-runtime", names: ["runProdApp"] },
|
|
35
|
+
{ specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
|
|
36
|
+
{
|
|
37
|
+
specifier: "@cosmicdrift/kumiko-framework/crypto",
|
|
38
|
+
names: ["requireKmsWiring", "resolveKmsWiring"],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
specifier: "@cosmicdrift/kumiko-bundled-features/auth-email-password",
|
|
42
|
+
names: ["DEFAULT_AUTH_PATHS"],
|
|
43
|
+
},
|
|
44
|
+
{ specifier: "../src/run-config", names: ["APP_FEATURES"] },
|
|
45
|
+
{ specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
|
|
46
|
+
{ specifier: "./env-schema", names: ["envSchema"] },
|
|
47
|
+
]);
|
|
48
|
+
return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
|
|
49
|
+
// composeFeatures + validateBoot + createRegistry without DB/Redis-connect
|
|
50
|
+
// (see @cosmicdrift/kumiko-server-runtime runProdApp).
|
|
51
|
+
|
|
52
|
+
${imports}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_TENANT_ID = "${deriveTenantId(appName)}" as TenantId;
|
|
55
|
+
|
|
56
|
+
const kmsOpts = {
|
|
57
|
+
logPrefix: "[${appName}]",
|
|
58
|
+
plaintextReason:
|
|
59
|
+
"no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
const kmsWiring =
|
|
63
|
+
process.env["NODE_ENV"] === "production"
|
|
64
|
+
? requireKmsWiring(process.env, kmsOpts)
|
|
65
|
+
: resolveKmsWiring(process.env, kmsOpts);
|
|
66
|
+
|
|
67
|
+
// The bootstrap admin has no password; password reset is the only way it gets one. Without SMTP
|
|
68
|
+
// and APP_BASE_URL (local boot) the mail flow stays off and the app still boots.
|
|
69
|
+
const appBaseUrl = process.env["APP_BASE_URL"];
|
|
70
|
+
const mailAppUrl = process.env["SMTP_HOST"] && appBaseUrl ? appBaseUrl : undefined;
|
|
71
|
+
|
|
72
|
+
await runProdApp({
|
|
73
|
+
features: APP_FEATURES,
|
|
74
|
+
...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions }),
|
|
75
|
+
...(appConfig.rowLevelSecurity && { rowLevelSecurity: appConfig.rowLevelSecurity }),
|
|
76
|
+
envSchema,
|
|
77
|
+
staticDir: "./dist",
|
|
78
|
+
...kmsWiring,
|
|
79
|
+
...(mailAppUrl !== undefined && { mail: { transport: "smtp-env" } }),
|
|
80
|
+
auth: {
|
|
81
|
+
...(mailAppUrl !== undefined && {
|
|
82
|
+
passwordReset: { appUrl: \`\${mailAppUrl}\${DEFAULT_AUTH_PATHS.resetPassword}\` },
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
seeds: {
|
|
86
|
+
bootstrapAdminFromEnv: {
|
|
87
|
+
tenant: { id: DEFAULT_TENANT_ID, key: "${appName}", name: "${appName}" },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
export function renderBlankServer(appName) {
|
|
94
|
+
const imports = renderSortedImports([
|
|
95
|
+
{ specifier: "@cosmicdrift/kumiko-dev-server", names: ["runDevApp"] },
|
|
96
|
+
{ specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
|
|
97
|
+
{ specifier: "../src/run-config", names: ["APP_FEATURES"] },
|
|
98
|
+
{ specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
|
|
99
|
+
]);
|
|
100
|
+
const authBlock = renderAdminAuthBlock(appName, [
|
|
101
|
+
'password: "changeme",',
|
|
102
|
+
'displayName: "Admin",',
|
|
103
|
+
]);
|
|
104
|
+
return `// Dev bootstrap. \`bun --watch bin/server.ts\` (see package.json scripts.dev)
|
|
105
|
+
// starts a full-featured dev server with auto-reload on code changes.
|
|
106
|
+
// setupTestStack creates missing entity tables automatically — a new
|
|
107
|
+
// r.entity(...) in a feature becomes a CREATE TABLE on the next reboot,
|
|
108
|
+
// no manual \`kumiko schema apply\` needed (that's prod-only).
|
|
109
|
+
// Persistent DB via KUMIKO_DEV_DB_NAME (.env) so admin + data survive reboots.
|
|
110
|
+
// The local admin below is dev-only: no mail runs locally, so there is no password reset.
|
|
111
|
+
|
|
112
|
+
${imports}
|
|
113
|
+
|
|
114
|
+
const DEFAULT_TENANT_ID = "${deriveTenantId(appName)}" as TenantId;
|
|
115
|
+
|
|
116
|
+
await runDevApp({
|
|
117
|
+
features: APP_FEATURES,
|
|
118
|
+
...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions }),
|
|
119
|
+
welcomeBanner: true,
|
|
120
|
+
clientEntry: "./src/client.tsx",
|
|
121
|
+
${authBlock}
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
export const BLANK_ENV_EXAMPLE_ADMIN_BLOCK = `# KUMIKO_BOOTSTRAP_ADMIN_EMAIL: login of the person who created the app. The first prod
|
|
126
|
+
# boot makes it the admin WITHOUT a password; set one via "forgot password". That needs
|
|
127
|
+
# SMTP_HOST (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM) and APP_BASE_URL (e.g.
|
|
128
|
+
# https://app.example.com); without them the reset mail flow stays off.
|
|
129
|
+
# KUMIKO_BOOTSTRAP_ADMIN_EMAIL=
|
|
130
|
+
# SMTP_HOST=
|
|
131
|
+
# APP_BASE_URL=
|
|
132
|
+
`;
|
|
133
|
+
export function blankReadmeCopy(appName) {
|
|
134
|
+
return {
|
|
135
|
+
intro: "Scaffolded by `kumiko new app --blank`. An empty app with login, tenants and sessions.\nAdd features via `bunx @cosmicdrift/kumiko-cli add feature <name>`.",
|
|
136
|
+
runConfigNote: "Edit `src/run-config.ts` to add bundled features.",
|
|
137
|
+
devLogin: `The welcome banner prints the URL (default \`http://localhost:4173\`) and the local\ndev login \`admin@${appName}.local\` / \`changeme\` (dev server only).`,
|
|
138
|
+
bootEnvNote: "a missing one exits 0 without validating the wiring.",
|
|
139
|
+
prodSeed: 'The first prod boot makes `KUMIKO_BOOTSTRAP_ADMIN_EMAIL` the admin of the app, without a\npassword. Open the app, choose "forgot password" and set one through the mail link. That\nneeds `SMTP_HOST` (+ `SMTP_*`) and `APP_BASE_URL`; without them nobody can set a password.\nWhile the variable is unset, nobody is seeded.',
|
|
140
|
+
demoArchitectureLines: "",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const MAX_LINE_WIDTH = 100;
|
|
2
|
+
export type ImportSpec = {
|
|
3
|
+
readonly specifier: string;
|
|
4
|
+
readonly names: readonly string[];
|
|
5
|
+
readonly typeOnly?: boolean;
|
|
6
|
+
readonly defaultName?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function renderSortedImports(imports: readonly ImportSpec[]): string;
|
|
9
|
+
export declare function renderDefaultAppShellElement(appName: string): string;
|
|
10
|
+
export declare function deriveTenantId(name: string): string;
|
|
11
|
+
export declare function renderAdminAuthBlock(appName: string, extraAdminLines: readonly string[]): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Text-rendering helpers shared by the scaffold renderers: import blocks formatted like Biome's
|
|
2
|
+
// output and the per-app derived tenant id.
|
|
3
|
+
export const MAX_LINE_WIDTH = 100;
|
|
4
|
+
// Mirrors Biome's import sort so generated files pass `biome check` untouched:
|
|
5
|
+
// packages alphabetically by specifier, relative paths last.
|
|
6
|
+
function compareImportKeys(a, b) {
|
|
7
|
+
const lowerA = a.replace(/^type /, "").toLowerCase();
|
|
8
|
+
const lowerB = b.replace(/^type /, "").toLowerCase();
|
|
9
|
+
if (lowerA !== lowerB)
|
|
10
|
+
return lowerA < lowerB ? -1 : 1;
|
|
11
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
12
|
+
}
|
|
13
|
+
function isRelativeSpecifier(specifier) {
|
|
14
|
+
return specifier.startsWith("./") || specifier.startsWith("../");
|
|
15
|
+
}
|
|
16
|
+
function renderImportLine({ specifier, names, typeOnly, defaultName }) {
|
|
17
|
+
if (defaultName !== undefined)
|
|
18
|
+
return `import ${defaultName} from "${specifier}";`;
|
|
19
|
+
const sorted = [...names].sort(compareImportKeys);
|
|
20
|
+
const keyword = typeOnly === true ? "import type" : "import";
|
|
21
|
+
const oneLine = `${keyword} { ${sorted.join(", ")} } from "${specifier}";`;
|
|
22
|
+
if (sorted.length === 1 || oneLine.length <= MAX_LINE_WIDTH)
|
|
23
|
+
return oneLine;
|
|
24
|
+
return `${keyword} {\n${sorted.map((n) => ` ${n},`).join("\n")}\n} from "${specifier}";`;
|
|
25
|
+
}
|
|
26
|
+
export function renderSortedImports(imports) {
|
|
27
|
+
return [...imports]
|
|
28
|
+
.sort((a, b) => {
|
|
29
|
+
const relativeA = isRelativeSpecifier(a.specifier);
|
|
30
|
+
const relativeB = isRelativeSpecifier(b.specifier);
|
|
31
|
+
if (relativeA !== relativeB)
|
|
32
|
+
return relativeA ? 1 : -1;
|
|
33
|
+
return compareImportKeys(a.specifier, b.specifier);
|
|
34
|
+
})
|
|
35
|
+
.map(renderImportLine)
|
|
36
|
+
.join("\n");
|
|
37
|
+
}
|
|
38
|
+
// Tiers mirror how Biome wraps the JSX as the name grows. The user spread
|
|
39
|
+
// is part of every tier so the line-width math covers it.
|
|
40
|
+
export function renderDefaultAppShellElement(appName) {
|
|
41
|
+
const spanOpen = '<span className="font-semibold tracking-tight">';
|
|
42
|
+
const userSpread = "{...(user !== undefined && { user })}";
|
|
43
|
+
// html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
|
|
44
|
+
const brandInline = `${spanOpen}${appName}</span>`;
|
|
45
|
+
const singleLine = ` <DefaultAppShell brand={${brandInline}} schema={schema} ${userSpread}>`;
|
|
46
|
+
if (singleLine.length <= MAX_LINE_WIDTH)
|
|
47
|
+
return singleLine;
|
|
48
|
+
const brandAttrInline = ` brand={${brandInline}}`;
|
|
49
|
+
const brandAttribute = brandAttrInline.length <= MAX_LINE_WIDTH
|
|
50
|
+
? brandAttrInline
|
|
51
|
+
: `${[
|
|
52
|
+
" brand={",
|
|
53
|
+
...(` ${brandInline}`.length <= MAX_LINE_WIDTH
|
|
54
|
+
? [` ${brandInline}`]
|
|
55
|
+
: [` ${spanOpen}`, ` ${appName}`, " </span>"]),
|
|
56
|
+
" }",
|
|
57
|
+
].join("\n")}`;
|
|
58
|
+
return [
|
|
59
|
+
" <DefaultAppShell",
|
|
60
|
+
brandAttribute,
|
|
61
|
+
" schema={schema}",
|
|
62
|
+
` ${userSpread}`,
|
|
63
|
+
" >",
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
// Deterministic tenant-ID from app-name. Format: UUID-v4 with the
|
|
67
|
+
// version-marker at the right spot. NOT cryptographically random —
|
|
68
|
+
// just a stable per-app default the user can change later.
|
|
69
|
+
export function deriveTenantId(name) {
|
|
70
|
+
let state = 2166136261;
|
|
71
|
+
for (const ch of name) {
|
|
72
|
+
state ^= ch.charCodeAt(0);
|
|
73
|
+
state = Math.imul(state, 16777619) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
// @wrapper-known semantic-alias
|
|
76
|
+
const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
|
|
77
|
+
const a = hex(state, 8);
|
|
78
|
+
state ^= state << 13;
|
|
79
|
+
state >>>= 0;
|
|
80
|
+
const b = hex(state, 4);
|
|
81
|
+
state ^= state >>> 17;
|
|
82
|
+
state >>>= 0;
|
|
83
|
+
const c = `4${hex(state, 3)}`;
|
|
84
|
+
state ^= state << 5;
|
|
85
|
+
state >>>= 0;
|
|
86
|
+
const d4 = (0x8 | (state & 0x3)).toString(16);
|
|
87
|
+
const d = `${d4}${hex(state >>> 4, 3)}`;
|
|
88
|
+
state = Math.imul(state, 16777619) >>> 0;
|
|
89
|
+
const e = hex(state, 12);
|
|
90
|
+
return `${a}-${b}-${c}-${d}-${e}`;
|
|
91
|
+
}
|
|
92
|
+
export function renderAdminAuthBlock(appName, extraAdminLines) {
|
|
93
|
+
const passwordAndExtras = extraAdminLines.map((l) => ` ${l}`).join("\n");
|
|
94
|
+
return ` auth: {
|
|
95
|
+
admin: {
|
|
96
|
+
email: "admin@${appName}.local",
|
|
97
|
+
${passwordAndExtras}
|
|
98
|
+
memberships: [
|
|
99
|
+
{
|
|
100
|
+
tenantId: DEFAULT_TENANT_ID,
|
|
101
|
+
tenantKey: "${appName}",
|
|
102
|
+
tenantName: "${appName}",
|
|
103
|
+
roles: ["TenantAdmin"],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},`;
|
|
108
|
+
}
|
package/dist/welcome-banner.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type WelcomeBannerInput = {
|
|
|
4
4
|
/** Login the seeded admin uses (from runDevApp options.auth.admin). */
|
|
5
5
|
readonly admin?: {
|
|
6
6
|
readonly email: string;
|
|
7
|
-
readonly password
|
|
7
|
+
readonly password?: string | undefined;
|
|
8
8
|
};
|
|
9
9
|
/** Hot-reload hint shown in the "add a feature" line. Defaults to
|
|
10
10
|
* `src/features/`. */
|
package/dist/welcome-banner.js
CHANGED
|
@@ -10,7 +10,9 @@ export function renderWelcomeBanner(input) {
|
|
|
10
10
|
lines.push(`✓ kumiko-app läuft`);
|
|
11
11
|
lines.push(`→ Browser: ${input.url}`);
|
|
12
12
|
if (input.admin) {
|
|
13
|
-
lines.push(
|
|
13
|
+
lines.push(input.admin.password === undefined
|
|
14
|
+
? `→ Login als: ${input.admin.email}`
|
|
15
|
+
: `→ Login als: ${input.admin.email} / ${input.admin.password}`);
|
|
14
16
|
}
|
|
15
17
|
lines.push(`→ Feature add: edit ${featuresDir}`);
|
|
16
18
|
lines.push(`→ Docs: ${docsUrl}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.412.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -77,15 +77,15 @@
|
|
|
77
77
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-feature-ast": "0.
|
|
82
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
86
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
87
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
80
|
+
"@cosmicdrift/kumiko-bundled-features": "0.412.0",
|
|
81
|
+
"@cosmicdrift/kumiko-feature-ast": "0.412.0",
|
|
82
|
+
"@cosmicdrift/kumiko-framework": "0.412.0",
|
|
83
|
+
"@cosmicdrift/kumiko-renderer": "0.412.0",
|
|
84
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.412.0",
|
|
85
|
+
"@cosmicdrift/kumiko-guards": "0.412.0",
|
|
86
|
+
"@cosmicdrift/kumiko-server-runtime": "0.412.0",
|
|
87
|
+
"@cosmicdrift/kumiko-testing": "0.412.0",
|
|
88
|
+
"@cosmicdrift/kumiko-types": "0.412.0",
|
|
89
89
|
"ts-morph": "^28.0.0",
|
|
90
90
|
"zod": "^4.6.7"
|
|
91
91
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.411.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "scaffoldApp starter \"blank\"",
|
|
6
|
+
"detail": "`scaffoldApp({ starter: \"blank\" })` (`SCAFFOLD_STARTERS`, `ScaffoldStarter`) writes an app without the tasks demo, `src/seed.ts` and `DEMO_ADMIN_PASSWORD`. `bin/main.ts` seeds the creator through `seeds.bootstrapAdminFromEnv` and wires SMTP mail and password reset when `SMTP_HOST` and `APP_BASE_URL` are set; `delivery` is mounted. The welcome banner prints only the email when the dev admin has no password. The default starter `demo` is unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.408.0",
|
|
4
10
|
"type": "fix",
|