create-kumiko-app 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "create-kumiko-app",
3
+ "version": "0.3.2",
4
+ "description": "`bun create kumiko-app <name>` — scaffold a new Kumiko app with an interactive feature picker.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
10
+ "directory": "packages/create-kumiko-app"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
+ },
15
+ "homepage": "https://kumiko.rocks",
16
+ "type": "module",
17
+ "kumiko": {
18
+ "runtime": "dev"
19
+ },
20
+ "bin": {
21
+ "create-kumiko-app": "./bin/cli.ts"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ }
28
+ },
29
+ "scripts": {
30
+ "vendor:manifest": "bun run scripts/vendor-manifest.ts"
31
+ },
32
+ "dependencies": {
33
+ "@cosmicdrift/kumiko-dev-server": "0.76.0",
34
+ "@cosmicdrift/kumiko-framework": "0.76.0",
35
+ "@inquirer/prompts": "^7.4.0"
36
+ },
37
+ "publishConfig": {
38
+ "registry": "https://registry.npmjs.org",
39
+ "access": "public"
40
+ },
41
+ "files": [
42
+ "bin",
43
+ "src",
44
+ "feature-manifest.json",
45
+ "README.md",
46
+ "LICENSE"
47
+ ]
48
+ }
@@ -0,0 +1,78 @@
1
+ // CLI smoke: `bun create kumiko-app demo --yes` scaffolds an app whose
2
+ // run-config.ts imports the recommended features. Full boot is out of
3
+ // scope here (needs DB/Redis) — the gate is "scaffold succeeds, files
4
+ // look right".
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { parseArgv, runCreate } from "../index";
11
+
12
+ describe("create-kumiko-app CLI", () => {
13
+ let tmp: string;
14
+ let logs: string[];
15
+ beforeEach(() => {
16
+ tmp = mkdtempSync(join(tmpdir(), "create-kumiko-app-"));
17
+ logs = [];
18
+ });
19
+ afterEach(() => rmSync(tmp, { recursive: true, force: true }));
20
+
21
+ test("--yes scaffolds the recommended stack", async () => {
22
+ const code = await runCreate({
23
+ name: "demo-app",
24
+ yes: true,
25
+ cwd: tmp,
26
+ log: (line) => logs.push(line),
27
+ });
28
+ expect(code).toBe(0);
29
+
30
+ const dest = join(tmp, "demo-app");
31
+ expect(existsSync(dest)).toBe(true);
32
+ expect(existsSync(join(dest, "package.json"))).toBe(true);
33
+ expect(existsSync(join(dest, "bin/main.ts"))).toBe(true);
34
+
35
+ const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
36
+ // Picker-MVP recommended features land in run-config.
37
+ expect(cfg).toContain("createAuthEmailPasswordFeature");
38
+ expect(cfg).toContain("createUserFeature");
39
+ expect(cfg).toContain("createTenantFeature");
40
+ expect(cfg).toContain("createDeliveryFeature");
41
+ // mail-transport-smtp is opt-in (not recommended, no transitive require) — should NOT auto-mount.
42
+ expect(cfg).not.toContain("mailTransportSmtpFeature");
43
+ });
44
+
45
+ test("--print-manifest emits JSON, no name needed", async () => {
46
+ const code = await runCreate({
47
+ printManifest: true,
48
+ log: (line) => logs.push(line),
49
+ });
50
+ expect(code).toBe(0);
51
+ const json = JSON.parse(logs.join(""));
52
+ expect(Array.isArray(json)).toBe(true);
53
+ expect(json.length).toBeGreaterThan(0);
54
+ });
55
+
56
+ test("missing name prints usage + exits 1", async () => {
57
+ const code = await runCreate({ log: (line) => logs.push(line) });
58
+ expect(code).toBe(1);
59
+ expect(logs.join("\n")).toContain("Usage:");
60
+ });
61
+ });
62
+
63
+ describe("parseArgv", () => {
64
+ test("first positional is the app name", () => {
65
+ expect(parseArgv(["my-app"]).name).toBe("my-app");
66
+ });
67
+
68
+ test("--yes / -y flips yes flag", () => {
69
+ expect(parseArgv(["--yes"]).yes).toBe(true);
70
+ expect(parseArgv(["-y"]).yes).toBe(true);
71
+ });
72
+
73
+ test("--print-manifest works alone (no name)", () => {
74
+ const args = parseArgv(["--print-manifest"]);
75
+ expect(args.printManifest).toBe(true);
76
+ expect(args.name).toBeUndefined();
77
+ });
78
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveDeps } from "../dep-resolver";
3
+ import type { Manifest } from "../manifest";
4
+
5
+ const MANIFEST: Manifest = {
6
+ source: "test",
7
+ featureCount: 5,
8
+ features: [
9
+ {
10
+ name: "auth-email-password",
11
+ description: null,
12
+ requires: ["user", "tenant"],
13
+ optionalRequires: [],
14
+ },
15
+ { name: "user", description: null, requires: [], optionalRequires: [] },
16
+ { name: "tenant", description: null, requires: ["config"], optionalRequires: [] },
17
+ { name: "config", description: null, requires: [], optionalRequires: [] },
18
+ { name: "billing", description: null, requires: ["tenant"], optionalRequires: [] },
19
+ ],
20
+ };
21
+
22
+ describe("resolveDeps", () => {
23
+ test("transitive requires close (auth → user + tenant + config)", () => {
24
+ const result = resolveDeps(["auth-email-password"], MANIFEST);
25
+ expect(new Set(result.featureNames)).toEqual(
26
+ new Set(["auth-email-password", "user", "tenant", "config"]),
27
+ );
28
+ expect(new Set(result.autoAdded)).toEqual(new Set(["user", "tenant", "config"]));
29
+ });
30
+
31
+ test("dedupes overlapping deps", () => {
32
+ const result = resolveDeps(["auth-email-password", "billing"], MANIFEST);
33
+ expect(result.featureNames.length).toBe(new Set(result.featureNames).size);
34
+ expect(new Set(result.featureNames)).toContain("tenant");
35
+ });
36
+
37
+ test("explicit selection NOT counted as autoAdded", () => {
38
+ const result = resolveDeps(["user", "auth-email-password"], MANIFEST);
39
+ expect(result.autoAdded).not.toContain("user");
40
+ expect(result.autoAdded).toContain("tenant");
41
+ });
42
+
43
+ test("unknown feature throws (manifest bug)", () => {
44
+ expect(() => resolveDeps(["does-not-exist"], MANIFEST)).toThrow(/not in manifest/);
45
+ });
46
+ });
@@ -0,0 +1,31 @@
1
+ // Vendored manifest.json must match the source-of-truth in
2
+ // samples/apps/use-all-bundled/feature-manifest.json. The picker reads
3
+ // the vendored copy at runtime; a stale copy lets the picker show old
4
+ // features or miss new ones. Refresh with `bun run vendor:manifest`.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const VENDOR = resolve(HERE, "..", "..", "feature-manifest.json");
13
+ const SOURCE = resolve(
14
+ HERE,
15
+ "..",
16
+ "..",
17
+ "..",
18
+ "..",
19
+ "samples",
20
+ "apps",
21
+ "use-all-bundled",
22
+ "feature-manifest.json",
23
+ );
24
+
25
+ describe("vendored manifest", () => {
26
+ test("byte-identical to samples/apps/use-all-bundled/feature-manifest.json", () => {
27
+ const vendor = readFileSync(VENDOR, "utf-8");
28
+ const source = readFileSync(SOURCE, "utf-8");
29
+ expect(vendor).toBe(source);
30
+ });
31
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { FEATURE_CONSTRUCTORS } from "../feature-constructors";
3
+ import { loadManifest } from "../manifest";
4
+ import { buildChoices } from "../picker";
5
+
6
+ describe("buildChoices", () => {
7
+ const manifest = loadManifest();
8
+ const choices = buildChoices(manifest);
9
+
10
+ test("only emits features that have a constructor entry", () => {
11
+ for (const c of choices) {
12
+ expect(Object.hasOwn(FEATURE_CONSTRUCTORS, c.name)).toBe(true);
13
+ }
14
+ });
15
+
16
+ test("every MVP feature is represented (no silent drops)", () => {
17
+ const choiceNames = new Set(choices.map((c) => c.name));
18
+ for (const name of Object.keys(FEATURE_CONSTRUCTORS)) {
19
+ expect(choiceNames.has(name)).toBe(true);
20
+ }
21
+ });
22
+
23
+ test("recommended features land checked by default", () => {
24
+ const recommended = choices.filter((c) => c.recommended);
25
+ expect(recommended.length).toBeGreaterThan(0);
26
+ expect(recommended.map((c) => c.name)).toContain("auth-email-password");
27
+ });
28
+
29
+ test("category falls back to 'other' when uiHints absent", () => {
30
+ for (const c of choices) {
31
+ expect(typeof c.category).toBe("string");
32
+ expect(c.category.length).toBeGreaterThan(0);
33
+ }
34
+ });
35
+ });
@@ -0,0 +1,44 @@
1
+ // Closes a feature set under `requires` (transitive hard dependencies).
2
+ // The picker only asks about explicit selections; this resolver folds in
3
+ // every dep so the scaffolded run-config.ts is bootable as-is.
4
+ //
5
+ // Output is the deterministic set of feature names, in the order the
6
+ // caller can hand to scaffoldApp. Cycles (none in practice) self-terminate
7
+ // via the visited-set; unknown deps (a manifest feature requires X but X
8
+ // isn't in the manifest) throw — that's a manifest bug worth surfacing.
9
+
10
+ import type { Manifest, ManifestFeatureEntry } from "./manifest";
11
+
12
+ export type DepResolutionResult = {
13
+ /** Selected + transitively required features, in stable scaffold order. */
14
+ readonly featureNames: readonly string[];
15
+ /** Features added implicitly via requires (not in the original selection). */
16
+ readonly autoAdded: readonly string[];
17
+ };
18
+
19
+ export function resolveDeps(selected: readonly string[], manifest: Manifest): DepResolutionResult {
20
+ const byName = new Map<string, ManifestFeatureEntry>(manifest.features.map((f) => [f.name, f]));
21
+ const visited = new Set<string>();
22
+ const autoAdded = new Set<string>();
23
+ const selectedSet = new Set(selected);
24
+
25
+ function visit(name: string): void {
26
+ if (visited.has(name)) return;
27
+ visited.add(name);
28
+ const entry = byName.get(name);
29
+ if (!entry) {
30
+ throw new Error(
31
+ `resolveDeps: feature "${name}" not in manifest (referenced as require/selection)`,
32
+ );
33
+ }
34
+ if (!selectedSet.has(name)) autoAdded.add(name);
35
+ for (const dep of entry.requires) visit(dep);
36
+ }
37
+
38
+ for (const name of selected) visit(name);
39
+
40
+ return {
41
+ featureNames: [...visited],
42
+ autoAdded: [...autoAdded],
43
+ };
44
+ }
@@ -0,0 +1,238 @@
1
+ // Static map: feature.name (from the manifest) → ScaffoldFeatureEntry that
2
+ // scaffoldApp consumes to render run-config.ts imports + APP_FEATURES.
3
+ //
4
+ // One entry per bundled feature whose constructor takes zero required args
5
+ // (or only opts with defaults). Features requiring caller-supplied
6
+ // transports/providers (channel-email, channel-push, subscription-stripe,
7
+ // subscription-mollie, file-provider-s3, managed-pages, tier-engine) are
8
+ // intentionally absent — the picker hides them, the user wires them by
9
+ // hand after scaffold.
10
+ //
11
+ // Naming inconsistency is intentional and historical: most features expose
12
+ // `create<Pascal>Feature` factories, a handful export the FeatureDefinition
13
+ // object directly. The callExpression captures that: `()` for factories,
14
+ // bare name for objects.
15
+
16
+ import type { ScaffoldFeatureEntry } from "@cosmicdrift/kumiko-dev-server";
17
+
18
+ export const FEATURE_CONSTRUCTORS: Readonly<Record<string, ScaffoldFeatureEntry>> = {
19
+ // --- Identity ---
20
+ tenant: {
21
+ name: "tenant",
22
+ importPath: "@cosmicdrift/kumiko-bundled-features/tenant",
23
+ exportName: "createTenantFeature",
24
+ callExpression: "createTenantFeature()",
25
+ },
26
+ user: {
27
+ name: "user",
28
+ importPath: "@cosmicdrift/kumiko-bundled-features/user",
29
+ exportName: "createUserFeature",
30
+ callExpression: "createUserFeature()",
31
+ },
32
+ sessions: {
33
+ name: "sessions",
34
+ importPath: "@cosmicdrift/kumiko-bundled-features/sessions",
35
+ exportName: "createSessionsFeature",
36
+ callExpression: "createSessionsFeature()",
37
+ },
38
+ "auth-email-password": {
39
+ name: "auth-email-password",
40
+ importPath: "@cosmicdrift/kumiko-bundled-features/auth-email-password",
41
+ exportName: "createAuthEmailPasswordFeature",
42
+ callExpression: "createAuthEmailPasswordFeature()",
43
+ },
44
+ "user-profile": {
45
+ name: "user-profile",
46
+ importPath: "@cosmicdrift/kumiko-bundled-features/user-profile",
47
+ exportName: "createUserProfileFeature",
48
+ callExpression: "createUserProfileFeature()",
49
+ },
50
+
51
+ // --- Infrastructure ---
52
+ config: {
53
+ name: "config",
54
+ importPath: "@cosmicdrift/kumiko-bundled-features/config",
55
+ exportName: "createConfigFeature",
56
+ callExpression: "createConfigFeature()",
57
+ },
58
+ secrets: {
59
+ name: "secrets",
60
+ importPath: "@cosmicdrift/kumiko-bundled-features/secrets",
61
+ exportName: "createSecretsFeature",
62
+ callExpression: "createSecretsFeature()",
63
+ },
64
+ "cap-counter": {
65
+ name: "cap-counter",
66
+ importPath: "@cosmicdrift/kumiko-bundled-features/cap-counter",
67
+ exportName: "capCounterFeature",
68
+ callExpression: "capCounterFeature",
69
+ },
70
+ "step-dispatcher": {
71
+ name: "step-dispatcher",
72
+ importPath: "@cosmicdrift/kumiko-bundled-features/step-dispatcher",
73
+ exportName: "createStepDispatcherFeature",
74
+ callExpression: "createStepDispatcherFeature()",
75
+ },
76
+
77
+ // --- Storage ---
78
+ files: {
79
+ name: "files",
80
+ importPath: "@cosmicdrift/kumiko-bundled-features/files",
81
+ exportName: "createFilesFeature",
82
+ callExpression: "createFilesFeature()",
83
+ },
84
+ "file-foundation": {
85
+ name: "file-foundation",
86
+ importPath: "@cosmicdrift/kumiko-bundled-features/file-foundation",
87
+ exportName: "fileFoundationFeature",
88
+ callExpression: "fileFoundationFeature",
89
+ },
90
+ "file-provider-inmemory": {
91
+ name: "file-provider-inmemory",
92
+ importPath: "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory",
93
+ exportName: "fileProviderInMemoryFeature",
94
+ callExpression: "fileProviderInMemoryFeature",
95
+ },
96
+
97
+ // --- Notifications ---
98
+ delivery: {
99
+ name: "delivery",
100
+ importPath: "@cosmicdrift/kumiko-bundled-features/delivery",
101
+ exportName: "createDeliveryFeature",
102
+ callExpression: "createDeliveryFeature()",
103
+ },
104
+ "mail-foundation": {
105
+ name: "mail-foundation",
106
+ importPath: "@cosmicdrift/kumiko-bundled-features/mail-foundation",
107
+ exportName: "mailFoundationFeature",
108
+ callExpression: "mailFoundationFeature",
109
+ },
110
+ "mail-transport-inmemory": {
111
+ name: "mail-transport-inmemory",
112
+ importPath: "@cosmicdrift/kumiko-bundled-features/mail-transport-inmemory",
113
+ exportName: "mailTransportInMemoryFeature",
114
+ callExpression: "mailTransportInMemoryFeature",
115
+ },
116
+ "mail-transport-smtp": {
117
+ name: "mail-transport-smtp",
118
+ importPath: "@cosmicdrift/kumiko-bundled-features/mail-transport-smtp",
119
+ exportName: "mailTransportSmtpFeature",
120
+ callExpression: "mailTransportSmtpFeature",
121
+ },
122
+ "channel-in-app": {
123
+ name: "channel-in-app",
124
+ importPath: "@cosmicdrift/kumiko-bundled-features/channel-in-app",
125
+ exportName: "createChannelInAppFeature",
126
+ callExpression: "createChannelInAppFeature()",
127
+ },
128
+ "renderer-foundation": {
129
+ name: "renderer-foundation",
130
+ importPath: "@cosmicdrift/kumiko-bundled-features/renderer-foundation",
131
+ exportName: "createRendererFoundationFeature",
132
+ callExpression: "createRendererFoundationFeature()",
133
+ },
134
+ "renderer-simple": {
135
+ name: "renderer-simple",
136
+ importPath: "@cosmicdrift/kumiko-bundled-features/renderer-simple",
137
+ exportName: "createRendererSimpleFeature",
138
+ callExpression: "createRendererSimpleFeature()",
139
+ },
140
+ "template-resolver": {
141
+ name: "template-resolver",
142
+ importPath: "@cosmicdrift/kumiko-bundled-features/template-resolver",
143
+ exportName: "createTemplateResolverFeature",
144
+ callExpression: "createTemplateResolverFeature()",
145
+ },
146
+
147
+ // --- Billing ---
148
+ "billing-foundation": {
149
+ name: "billing-foundation",
150
+ importPath: "@cosmicdrift/kumiko-bundled-features/billing-foundation",
151
+ exportName: "billingFoundationFeature",
152
+ callExpression: "billingFoundationFeature",
153
+ },
154
+
155
+ // --- Compliance ---
156
+ audit: {
157
+ name: "audit",
158
+ importPath: "@cosmicdrift/kumiko-bundled-features/audit",
159
+ exportName: "createAuditFeature",
160
+ callExpression: "createAuditFeature()",
161
+ },
162
+ "compliance-profiles": {
163
+ name: "compliance-profiles",
164
+ importPath: "@cosmicdrift/kumiko-bundled-features/compliance-profiles",
165
+ exportName: "createComplianceProfilesFeature",
166
+ callExpression: "createComplianceProfilesFeature()",
167
+ },
168
+ "data-retention": {
169
+ name: "data-retention",
170
+ importPath: "@cosmicdrift/kumiko-bundled-features/data-retention",
171
+ exportName: "createDataRetentionFeature",
172
+ callExpression: "createDataRetentionFeature()",
173
+ },
174
+ "user-data-rights": {
175
+ name: "user-data-rights",
176
+ importPath: "@cosmicdrift/kumiko-bundled-features/user-data-rights",
177
+ exportName: "createUserDataRightsFeature",
178
+ callExpression: "createUserDataRightsFeature()",
179
+ },
180
+
181
+ // --- Operations ---
182
+ "feature-toggles": {
183
+ name: "feature-toggles",
184
+ importPath: "@cosmicdrift/kumiko-bundled-features/feature-toggles",
185
+ exportName: "createFeatureTogglesFeature",
186
+ callExpression: "createFeatureTogglesFeature()",
187
+ },
188
+ jobs: {
189
+ name: "jobs",
190
+ importPath: "@cosmicdrift/kumiko-bundled-features/jobs",
191
+ exportName: "createJobsFeature",
192
+ callExpression: "createJobsFeature()",
193
+ },
194
+ "rate-limiting": {
195
+ name: "rate-limiting",
196
+ importPath: "@cosmicdrift/kumiko-bundled-features/rate-limiting",
197
+ exportName: "createRateLimitingFeature",
198
+ callExpression: "createRateLimitingFeature()",
199
+ },
200
+ readiness: {
201
+ name: "readiness",
202
+ importPath: "@cosmicdrift/kumiko-bundled-features/readiness",
203
+ exportName: "readinessFeature",
204
+ callExpression: "readinessFeature",
205
+ },
206
+
207
+ // --- Content ---
208
+ "text-content": {
209
+ name: "text-content",
210
+ importPath: "@cosmicdrift/kumiko-bundled-features/text-content",
211
+ exportName: "createTextContentFeature",
212
+ callExpression: "createTextContentFeature()",
213
+ },
214
+ "legal-pages": {
215
+ name: "legal-pages",
216
+ importPath: "@cosmicdrift/kumiko-bundled-features/legal-pages",
217
+ exportName: "createLegalPagesFeature",
218
+ callExpression: "createLegalPagesFeature()",
219
+ },
220
+
221
+ // --- Data ---
222
+ tags: {
223
+ name: "tags",
224
+ importPath: "@cosmicdrift/kumiko-bundled-features/tags",
225
+ exportName: "tagsFeature",
226
+ callExpression: "tagsFeature",
227
+ },
228
+ "custom-fields": {
229
+ name: "custom-fields",
230
+ importPath: "@cosmicdrift/kumiko-bundled-features/custom-fields",
231
+ exportName: "customFieldsFeature",
232
+ callExpression: "customFieldsFeature",
233
+ },
234
+ };
235
+
236
+ export function isPickable(featureName: string): boolean {
237
+ return Object.hasOwn(FEATURE_CONSTRUCTORS, featureName);
238
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ // `bun create kumiko-app <name>` → bunx create-kumiko-app <name>.
2
+ //
3
+ // Flow: parse args → load vendored manifest → run picker (unless
4
+ // --print-manifest / --yes) → resolve deps → map to scaffold entries →
5
+ // scaffoldApp() → print next-steps.
6
+
7
+ import { type ScaffoldFeatureEntry, scaffoldApp } from "@cosmicdrift/kumiko-dev-server";
8
+ import { resolveDeps } from "./dep-resolver";
9
+ import { FEATURE_CONSTRUCTORS } from "./feature-constructors";
10
+ import { loadManifest, type Manifest } from "./manifest";
11
+ import { buildChoices, runPicker } from "./picker";
12
+
13
+ export type CliArgs = {
14
+ /** App name (kebab-case). Required for `scaffold` mode. */
15
+ readonly name?: string;
16
+ /** Print the picker choices as JSON and exit (CI snapshot test). */
17
+ readonly printManifest?: boolean;
18
+ /** Skip the interactive picker, take every `recommended:true` feature. */
19
+ readonly yes?: boolean;
20
+ /** Override cwd for scaffoldApp (mostly for the smoke test). */
21
+ readonly cwd?: string;
22
+ /** Override stdout sink (default: console.log). */
23
+ readonly log?: (line: string) => void;
24
+ };
25
+
26
+ export async function runCreate(args: CliArgs): Promise<number> {
27
+ const log = args.log ?? ((line) => console.log(line));
28
+ const manifest = loadManifest();
29
+
30
+ if (args.printManifest) {
31
+ log(JSON.stringify(buildChoices(manifest), null, 2));
32
+ return 0;
33
+ }
34
+
35
+ if (!args.name) {
36
+ log("Usage: bun create kumiko-app <name> [--yes] [--print-manifest]");
37
+ return 1;
38
+ }
39
+
40
+ const selected = args.yes ? defaultSelection(manifest) : await runPicker(manifest);
41
+ if (selected.length === 0) {
42
+ log("Keine Features gewählt — Abbruch.");
43
+ return 1;
44
+ }
45
+
46
+ const resolved = resolveDeps(selected, manifest);
47
+ const features = resolved.featureNames
48
+ .map((name) => FEATURE_CONSTRUCTORS[name])
49
+ .filter((entry): entry is ScaffoldFeatureEntry => entry !== undefined);
50
+
51
+ // config/user/tenant/auth-email-password are auto-mounted by
52
+ // composeFeatures(includeBundled:true) at boot — only log auto-adds
53
+ // that actually land in the explicit APP_FEATURES list.
54
+ const reportableAutoAdds = resolved.autoAdded.filter((n) =>
55
+ Object.hasOwn(FEATURE_CONSTRUCTORS, n),
56
+ );
57
+ if (reportableAutoAdds.length > 0) {
58
+ log(`Auto-included via requires: ${reportableAutoAdds.join(", ")}`);
59
+ }
60
+
61
+ const result = scaffoldApp({
62
+ name: args.name,
63
+ cwd: args.cwd,
64
+ features,
65
+ });
66
+
67
+ log("");
68
+ log(`✓ ${result.appName} scaffolded → ${result.destination}`);
69
+ log("");
70
+ log("Nächste Schritte:");
71
+ log(` cd ${args.name}`);
72
+ log(" bun install");
73
+ log(" cp .env.example .env # edit JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1");
74
+ log(" bun run boot");
75
+ return 0;
76
+ }
77
+
78
+ function defaultSelection(manifest: Manifest): readonly string[] {
79
+ return buildChoices(manifest)
80
+ .filter((c) => c.recommended)
81
+ .map((c) => c.name);
82
+ }
83
+
84
+ export function parseArgv(argv: readonly string[]): CliArgs {
85
+ const out: { -readonly [K in keyof CliArgs]?: CliArgs[K] } = {};
86
+ for (const arg of argv) {
87
+ if (arg === "--print-manifest") out.printManifest = true;
88
+ else if (arg === "--yes" || arg === "-y") out.yes = true;
89
+ else if (!arg.startsWith("-") && out.name === undefined) out.name = arg;
90
+ }
91
+ return out;
92
+ }
@@ -0,0 +1,59 @@
1
+ // Loads the vendored feature-manifest.json (copied from
2
+ // samples/apps/use-all-bundled by scripts/vendor-manifest.ts) at runtime.
3
+ // The published package ships the JSON next to package.json so the picker
4
+ // works without network access — the source-of-truth at build time is the
5
+ // sample-app's manifest, kept in sync via a CI drift-test.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ export type ManifestUiHintOption =
12
+ | {
13
+ readonly key: string;
14
+ readonly label: string;
15
+ readonly type: "boolean";
16
+ readonly default: boolean;
17
+ }
18
+ | {
19
+ readonly key: string;
20
+ readonly label: string;
21
+ readonly type: "select";
22
+ readonly options: readonly string[];
23
+ readonly default: string;
24
+ }
25
+ | {
26
+ readonly key: string;
27
+ readonly label: string;
28
+ readonly type: "text";
29
+ readonly default?: string;
30
+ };
31
+
32
+ export type ManifestUiHints = {
33
+ readonly displayLabel?: string;
34
+ readonly category?: string;
35
+ readonly recommended?: boolean;
36
+ readonly configurableOptions?: readonly ManifestUiHintOption[];
37
+ };
38
+
39
+ export type ManifestFeatureEntry = {
40
+ readonly name: string;
41
+ readonly description: string | null;
42
+ readonly requires: readonly string[];
43
+ readonly optionalRequires: readonly string[];
44
+ readonly uiHints?: ManifestUiHints;
45
+ };
46
+
47
+ export type Manifest = {
48
+ readonly source: string;
49
+ readonly featureCount: number;
50
+ readonly features: readonly ManifestFeatureEntry[];
51
+ };
52
+
53
+ const HERE = dirname(fileURLToPath(import.meta.url));
54
+
55
+ export function loadManifest(): Manifest {
56
+ const path = resolve(HERE, "..", "feature-manifest.json");
57
+ const raw = readFileSync(path, "utf-8");
58
+ return JSON.parse(raw) as Manifest;
59
+ }