@cosmicdrift/kumiko-cli 0.2.367 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-cli",
3
- "version": "0.2.367",
4
- "description": "Standalone CLI for scaffolding Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` produces a runnable workspace; `add feature <name>` mounts a feature automatically.",
3
+ "version": "0.3.0",
4
+ "description": "CLI for Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` scaffolds a runnable workspace; `add feature <name>` mounts a feature; `agent lint`, `project` and `consumer` operate on an existing app.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
@@ -24,10 +24,16 @@
24
24
  ".": {
25
25
  "types": "./src/index.ts",
26
26
  "default": "./src/index.ts"
27
+ },
28
+ "./commands": {
29
+ "types": "./src/commands/index.ts",
30
+ "default": "./src/commands/index.ts"
27
31
  }
28
32
  },
29
33
  "dependencies": {
30
- "@cosmicdrift/kumiko-dev-server": "0.243.4"
34
+ "@cosmicdrift/kumiko-bundled-features": "0.245.0",
35
+ "@cosmicdrift/kumiko-framework": "0.245.0",
36
+ "@cosmicdrift/kumiko-dev-server": "0.245.0"
31
37
  },
32
38
  "publishConfig": {
33
39
  "registry": "https://registry.npmjs.org",
@@ -36,6 +42,7 @@
36
42
  "files": [
37
43
  "bin",
38
44
  "src",
45
+ "!src/**/__tests__/**",
39
46
  "README.md",
40
47
  "LICENSE"
41
48
  ]
@@ -0,0 +1,57 @@
1
+ import { join } from "node:path";
2
+ import type { CliCommand } from "./types";
3
+
4
+ const LINT_SUBCOMMAND = "lint";
5
+
6
+ export const agentCommand: CliCommand = {
7
+ id: "agent",
8
+ description: "AI-agent tooling (lint)",
9
+ help: "Reads kumiko.config.ts in cwd and reports handler definitions the AI\nagent can't describe (exposed to the agent without a usable description).\n\nSubcommands:\n lint Report AI-agent-doc gaps in the current config\n",
10
+ run: async (ctx) => {
11
+ const sub = ctx.argv[0];
12
+ if (sub !== LINT_SUBCOMMAND) {
13
+ ctx.out.err("");
14
+ ctx.out.err(` Usage: kumiko agent <${LINT_SUBCOMMAND}>`);
15
+ ctx.out.err("");
16
+ return 1;
17
+ }
18
+
19
+ const configPath = join(ctx.cwd, "kumiko.config.ts");
20
+ if (!(await Bun.file(configPath).exists())) {
21
+ ctx.out.err("");
22
+ ctx.out.err(` kumiko.config.ts not found at: ${configPath}`);
23
+ ctx.out.err("");
24
+ ctx.out.err(" Create a file that exports your features:");
25
+ ctx.out.err(" // kumiko.config.ts");
26
+ ctx.out.err(' import { myFeature } from "./src/features/my-feature";');
27
+ ctx.out.err(" export default { features: [myFeature] };");
28
+ ctx.out.err("");
29
+ return 1;
30
+ }
31
+
32
+ const config = (await import(configPath)).default as {
33
+ features: readonly import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition[];
34
+ };
35
+ const { findAgentDocGaps, formatAgentDocGap } = await import(
36
+ "@cosmicdrift/kumiko-bundled-features/agent-tools"
37
+ );
38
+
39
+ const gaps = findAgentDocGaps(config.features);
40
+
41
+ if (gaps.length === 0) {
42
+ ctx.out.log("");
43
+ ctx.out.log(" ✓ No AI-agent doc gaps found.");
44
+ ctx.out.log("");
45
+ return 0;
46
+ }
47
+
48
+ ctx.out.log("");
49
+ ctx.out.log(` ${gaps.length} AI-agent doc gap${gaps.length === 1 ? "" : "s"} found:`);
50
+ ctx.out.log("");
51
+ for (const gap of gaps) {
52
+ ctx.out.log(` ${formatAgentDocGap(gap)}`);
53
+ }
54
+ ctx.out.log("");
55
+ return 1;
56
+ },
57
+ };
@@ -0,0 +1,222 @@
1
+ import { join } from "node:path";
2
+ import type { CliCommand, CliCommandContext } from "./types";
3
+
4
+ export const consumerCommand: CliCommand = {
5
+ id: "consumer",
6
+ description: "Manage event consumers (list | status | restart | disable | enable | skip)",
7
+ help: "Subcommands:\n list List all consumers and their state\n status <name> Show detail state for one consumer\n restart <name> Release lock and retry\n disable <name> Pause the consumer\n enable <name> Reactivate\n skip <name> Skip the currently-stuck event\n",
8
+ run: async (ctx) => {
9
+ const sub = ctx.argv[0];
10
+ const arg = ctx.argv[1];
11
+
12
+ const configPath = join(ctx.cwd, "kumiko.config.ts");
13
+ if (!(await Bun.file(configPath).exists())) {
14
+ ctx.out.err("");
15
+ ctx.out.err(` kumiko.config.ts not found at: ${configPath}`);
16
+ ctx.out.err("");
17
+ return 1;
18
+ }
19
+
20
+ const databaseUrl = process.env["DATABASE_URL"];
21
+ if (!databaseUrl) {
22
+ ctx.out.err("");
23
+ ctx.out.err(" DATABASE_URL not set.");
24
+ ctx.out.err("");
25
+ return 1;
26
+ }
27
+
28
+ const config = (await import(configPath)).default as {
29
+ features: readonly import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition[];
30
+ };
31
+ const { createRegistry } = await import("@cosmicdrift/kumiko-framework/engine");
32
+ const { createDbConnection } = await import("@cosmicdrift/kumiko-framework/db");
33
+ const {
34
+ createEventConsumerStateTable,
35
+ disableConsumer,
36
+ enableConsumer,
37
+ getConsumerState,
38
+ listConsumersWithState,
39
+ restartConsumer,
40
+ skipPoisonEvent,
41
+ SEARCH_CONSUMER_NAME,
42
+ SSE_BROADCAST_CONSUMER_NAME,
43
+ } = await import("@cosmicdrift/kumiko-framework/pipeline");
44
+
45
+ const registry = createRegistry(config.features);
46
+ const { db, close } = createDbConnection(databaseUrl);
47
+ await createEventConsumerStateTable(db);
48
+
49
+ const registeredConsumerNames = [
50
+ SSE_BROADCAST_CONSUMER_NAME,
51
+ SEARCH_CONSUMER_NAME,
52
+ ...registry.getAllMultiStreamProjections().keys(),
53
+ ];
54
+
55
+ const printOutcome = (prefix: string, state: { name: string; status: string }): void => {
56
+ ctx.out.log("");
57
+ ctx.out.log(` ✓ ${prefix} ${state.name} → ${state.status}`);
58
+ ctx.out.log("");
59
+ };
60
+
61
+ try {
62
+ switch (sub) {
63
+ case "list":
64
+ return await listConsumers(ctx, db, registeredConsumerNames, listConsumersWithState);
65
+ case "status":
66
+ return await showConsumerStatus(ctx, db, arg, registeredConsumerNames, getConsumerState);
67
+ case "restart":
68
+ return await restartOne(ctx, db, arg, restartConsumer, printOutcome);
69
+ case "disable":
70
+ return await disableOne(ctx, db, arg, disableConsumer, printOutcome);
71
+ case "enable":
72
+ return await enableOne(ctx, db, arg, enableConsumer, printOutcome);
73
+ case "skip":
74
+ return await skipOne(ctx, db, arg, skipPoisonEvent, printOutcome);
75
+ default:
76
+ ctx.out.log("");
77
+ ctx.out.log(
78
+ " Usage: kumiko consumer <list | status | restart | disable | enable | skip> <name>",
79
+ );
80
+ ctx.out.log("");
81
+ return 1;
82
+ }
83
+ } catch (e) {
84
+ ctx.out.err("");
85
+ ctx.out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
86
+ ctx.out.err("");
87
+ return 1;
88
+ } finally {
89
+ await close();
90
+ }
91
+ },
92
+ };
93
+
94
+ function usage(ctx: CliCommandContext, sub: string): number {
95
+ ctx.out.err("");
96
+ ctx.out.err(` Usage: kumiko consumer ${sub} <consumer-name>`);
97
+ ctx.out.err("");
98
+ return 1;
99
+ }
100
+
101
+ async function listConsumers(
102
+ ctx: CliCommandContext,
103
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
104
+ registeredConsumerNames: readonly string[],
105
+ listConsumersWithState: typeof import("@cosmicdrift/kumiko-framework/pipeline").listConsumersWithState,
106
+ ): Promise<number> {
107
+ const entries = await listConsumersWithState(db, registeredConsumerNames);
108
+ if (entries.length === 0) {
109
+ ctx.out.log("");
110
+ ctx.out.log(" No event consumers registered.");
111
+ ctx.out.log("");
112
+ return 0;
113
+ }
114
+ ctx.out.log("");
115
+ ctx.out.log(" Registered event consumers:");
116
+ ctx.out.log("");
117
+ for (const e of entries) {
118
+ const errHint = e.lastError ? ` error=${e.lastError.slice(0, 60)}` : "";
119
+ ctx.out.log(
120
+ ` ${e.name.padEnd(44)} ${e.status.padEnd(15)} cursor=${e.lastProcessedEventId} attempts=${e.attempts}${errHint}`,
121
+ );
122
+ }
123
+ ctx.out.log("");
124
+ return 0;
125
+ }
126
+
127
+ async function showConsumerStatus(
128
+ ctx: CliCommandContext,
129
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
130
+ arg: string | undefined,
131
+ registeredConsumerNames: readonly string[],
132
+ getConsumerState: typeof import("@cosmicdrift/kumiko-framework/pipeline").getConsumerState,
133
+ ): Promise<number> {
134
+ if (!arg) {
135
+ ctx.out.err("");
136
+ ctx.out.err(" Usage: kumiko consumer status <consumer-name>");
137
+ ctx.out.err("");
138
+ return 1;
139
+ }
140
+ const state = await getConsumerState(db, arg);
141
+ if (!state) {
142
+ if (!registeredConsumerNames.includes(arg)) {
143
+ ctx.out.err("");
144
+ ctx.out.err(` Consumer "${arg}" is not registered.`);
145
+ ctx.out.err("");
146
+ return 1;
147
+ }
148
+ ctx.out.log("");
149
+ ctx.out.log(` ${arg}: never-run`);
150
+ ctx.out.log("");
151
+ return 0;
152
+ }
153
+ ctx.out.log("");
154
+ ctx.out.log(` ${state.name}`);
155
+ ctx.out.log(` status: ${state.status}`);
156
+ ctx.out.log(` last event id: ${state.lastProcessedEventId}`);
157
+ ctx.out.log(` attempts: ${state.attempts}`);
158
+ ctx.out.log(` updated at: ${String(state.updatedAt)}`);
159
+ if (state.lastError) {
160
+ ctx.out.log(` last error: ${state.lastError}`);
161
+ }
162
+ ctx.out.log("");
163
+ return 0;
164
+ }
165
+
166
+ async function restartOne(
167
+ ctx: CliCommandContext,
168
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
169
+ arg: string | undefined,
170
+ restartConsumer: typeof import("@cosmicdrift/kumiko-framework/pipeline").restartConsumer,
171
+ printOutcome: (prefix: string, state: { name: string; status: string }) => void,
172
+ ): Promise<number> {
173
+ if (!arg) return usage(ctx, "restart");
174
+ const state = await restartConsumer(db, arg);
175
+ printOutcome("restarted", state);
176
+ return 0;
177
+ }
178
+
179
+ async function disableOne(
180
+ ctx: CliCommandContext,
181
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
182
+ arg: string | undefined,
183
+ disableConsumer: typeof import("@cosmicdrift/kumiko-framework/pipeline").disableConsumer,
184
+ printOutcome: (prefix: string, state: { name: string; status: string }) => void,
185
+ ): Promise<number> {
186
+ if (!arg) return usage(ctx, "disable");
187
+ const state = await disableConsumer(db, arg);
188
+ printOutcome("disabled", state);
189
+ return 0;
190
+ }
191
+
192
+ async function enableOne(
193
+ ctx: CliCommandContext,
194
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
195
+ arg: string | undefined,
196
+ enableConsumer: typeof import("@cosmicdrift/kumiko-framework/pipeline").enableConsumer,
197
+ printOutcome: (prefix: string, state: { name: string; status: string }) => void,
198
+ ): Promise<number> {
199
+ if (!arg) return usage(ctx, "enable");
200
+ const state = await enableConsumer(db, arg);
201
+ printOutcome("enabled", state);
202
+ return 0;
203
+ }
204
+
205
+ async function skipOne(
206
+ ctx: CliCommandContext,
207
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
208
+ arg: string | undefined,
209
+ skipPoisonEvent: typeof import("@cosmicdrift/kumiko-framework/pipeline").skipPoisonEvent,
210
+ printOutcome: (prefix: string, state: { name: string; status: string }) => void,
211
+ ): Promise<number> {
212
+ if (!arg) return usage(ctx, "skip");
213
+ const state = await skipPoisonEvent(db, arg);
214
+ if (state.skippedEventId === null) {
215
+ ctx.out.log("");
216
+ ctx.out.log(` ~ ${state.name}: cursor already at head — nothing to skip.`);
217
+ ctx.out.log("");
218
+ } else {
219
+ printOutcome(`skipped event ${state.skippedEventId},`, state);
220
+ }
221
+ return 0;
222
+ }
@@ -0,0 +1,17 @@
1
+ import { agentCommand } from "./agent";
2
+ import { consumerCommand } from "./consumer";
3
+ import { projectCommand } from "./project";
4
+ import type { CliCommand } from "./types";
5
+
6
+ export const APP_COMMANDS: ReadonlyArray<CliCommand> = [
7
+ agentCommand,
8
+ projectCommand,
9
+ consumerCommand,
10
+ ];
11
+
12
+ export function findAppCommand(id: string): CliCommand | undefined {
13
+ return APP_COMMANDS.find((c) => c.id === id);
14
+ }
15
+
16
+ export type { CliCommand, CliCommandContext } from "./types";
17
+ export { agentCommand, consumerCommand, projectCommand };
@@ -0,0 +1,164 @@
1
+ import { join } from "node:path";
2
+ import type { CliCommand, CliCommandContext } from "./types";
3
+
4
+ export const projectCommand: CliCommand = {
5
+ id: "project",
6
+ description: "Manage projections (list | status <name> | rebuild <name>)",
7
+ help: "Reads kumiko.config.ts in cwd, builds the registry, dispatches against the\nprojection-state table in DATABASE_URL.\n\nSubcommands:\n list List all projections and their state\n status <name> Show detail state for one projection\n rebuild <name> Full rebuild + report",
8
+ run: async (ctx) => {
9
+ const sub = ctx.argv[0];
10
+ const arg = ctx.argv[1];
11
+
12
+ const configPath = join(ctx.cwd, "kumiko.config.ts");
13
+ if (!(await Bun.file(configPath).exists())) {
14
+ ctx.out.err("");
15
+ ctx.out.err(` kumiko.config.ts not found at: ${configPath}`);
16
+ ctx.out.err("");
17
+ ctx.out.err(" Create a file that exports your features:");
18
+ ctx.out.err(" // kumiko.config.ts");
19
+ ctx.out.err(' import { myFeature } from "./src/features/my-feature";');
20
+ ctx.out.err(" export default { features: [myFeature] };");
21
+ ctx.out.err("");
22
+ return 1;
23
+ }
24
+
25
+ const databaseUrl = process.env["DATABASE_URL"];
26
+ if (!databaseUrl) {
27
+ ctx.out.err("");
28
+ ctx.out.err(" DATABASE_URL not set. Run against a configured env.");
29
+ ctx.out.err("");
30
+ return 1;
31
+ }
32
+
33
+ const config = (await import(configPath)).default as {
34
+ features: readonly import("@cosmicdrift/kumiko-framework/engine").FeatureDefinition[];
35
+ };
36
+ const { createRegistry } = await import("@cosmicdrift/kumiko-framework/engine");
37
+ const { createDbConnection } = await import("@cosmicdrift/kumiko-framework/db");
38
+ const {
39
+ listProjectionsWithState,
40
+ getProjectionState,
41
+ rebuildProjection,
42
+ createProjectionStateTable,
43
+ } = await import("@cosmicdrift/kumiko-framework/pipeline");
44
+
45
+ const registry = createRegistry(config.features);
46
+ const { db, close } = createDbConnection(databaseUrl);
47
+ await createProjectionStateTable(db);
48
+
49
+ try {
50
+ switch (sub) {
51
+ case "list":
52
+ return await listProjections(ctx, db, registry, listProjectionsWithState);
53
+ case "status":
54
+ return await showProjectionStatus(ctx, db, arg, registry, getProjectionState);
55
+ case "rebuild":
56
+ return await rebuildOne(ctx, db, arg, registry, rebuildProjection);
57
+ default:
58
+ ctx.out.log("");
59
+ ctx.out.log(" Usage: kumiko project <list | status <name> | rebuild <name>>");
60
+ ctx.out.log("");
61
+ return 1;
62
+ }
63
+ } finally {
64
+ await close();
65
+ }
66
+ },
67
+ };
68
+
69
+ async function listProjections(
70
+ ctx: CliCommandContext,
71
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
72
+ registry: ReturnType<typeof import("@cosmicdrift/kumiko-framework/engine").createRegistry>,
73
+ listProjectionsWithState: typeof import("@cosmicdrift/kumiko-framework/pipeline").listProjectionsWithState,
74
+ ): Promise<number> {
75
+ const entries = await listProjectionsWithState(db, registry);
76
+ if (entries.length === 0) {
77
+ ctx.out.log("");
78
+ ctx.out.log(" No projections registered.");
79
+ ctx.out.log("");
80
+ return 0;
81
+ }
82
+ ctx.out.log("");
83
+ ctx.out.log(" Registered projections:");
84
+ ctx.out.log("");
85
+ for (const e of entries) {
86
+ const when = e.lastRebuildAt ? e.lastRebuildAt.toString() : "never";
87
+ ctx.out.log(
88
+ ` ${e.name.padEnd(40)} ${e.status.padEnd(15)} source=${e.sources.join(",")} last=${when}`,
89
+ );
90
+ }
91
+ ctx.out.log("");
92
+ return 0;
93
+ }
94
+
95
+ async function showProjectionStatus(
96
+ ctx: CliCommandContext,
97
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
98
+ arg: string | undefined,
99
+ registry: ReturnType<typeof import("@cosmicdrift/kumiko-framework/engine").createRegistry>,
100
+ getProjectionState: typeof import("@cosmicdrift/kumiko-framework/pipeline").getProjectionState,
101
+ ): Promise<number> {
102
+ if (!arg) {
103
+ ctx.out.err("");
104
+ ctx.out.err(" Usage: kumiko project status <projection-name>");
105
+ ctx.out.err("");
106
+ return 1;
107
+ }
108
+ const state = await getProjectionState(db, arg);
109
+ if (!state) {
110
+ const registered = registry.getAllProjections().has(arg);
111
+ if (!registered) {
112
+ ctx.out.err("");
113
+ ctx.out.err(` Projection "${arg}" is not registered.`);
114
+ ctx.out.err("");
115
+ return 1;
116
+ }
117
+ ctx.out.log("");
118
+ ctx.out.log(` ${arg}: never-rebuilt`);
119
+ ctx.out.log("");
120
+ return 0;
121
+ }
122
+ ctx.out.log("");
123
+ ctx.out.log(` ${state.name}`);
124
+ ctx.out.log(` status: ${state.status}`);
125
+ ctx.out.log(` last event id: ${state.lastProcessedEventId}`);
126
+ ctx.out.log(` last rebuild: ${state.lastRebuildAt?.toString() ?? "never"}`);
127
+ ctx.out.log(` updated at: ${state.updatedAt.toString()}`);
128
+ if (state.lastError) {
129
+ ctx.out.log(` last error: ${state.lastError}`);
130
+ }
131
+ ctx.out.log("");
132
+ return 0;
133
+ }
134
+
135
+ async function rebuildOne(
136
+ ctx: CliCommandContext,
137
+ db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
138
+ arg: string | undefined,
139
+ registry: ReturnType<typeof import("@cosmicdrift/kumiko-framework/engine").createRegistry>,
140
+ rebuildProjection: typeof import("@cosmicdrift/kumiko-framework/pipeline").rebuildProjection,
141
+ ): Promise<number> {
142
+ if (!arg) {
143
+ ctx.out.err("");
144
+ ctx.out.err(" Usage: kumiko project rebuild <projection-name>");
145
+ ctx.out.err("");
146
+ return 1;
147
+ }
148
+ ctx.out.log("");
149
+ ctx.out.log(` Rebuilding ${arg} ...`);
150
+ try {
151
+ const result = await rebuildProjection(arg, { db, registry });
152
+ ctx.out.log("");
153
+ ctx.out.log(
154
+ ` ✓ ${result.projection}: ${result.eventsProcessed} events, ${result.durationMs}ms`,
155
+ );
156
+ ctx.out.log("");
157
+ return 0;
158
+ } catch (e) {
159
+ ctx.out.err("");
160
+ ctx.out.err(` ✗ Rebuild failed: ${e instanceof Error ? e.message : e}`);
161
+ ctx.out.err("");
162
+ return 1;
163
+ }
164
+ }
@@ -0,0 +1,14 @@
1
+ import type { Output } from "../output";
2
+
3
+ export type CliCommandContext = {
4
+ readonly argv: ReadonlyArray<string>;
5
+ readonly cwd: string;
6
+ readonly out: Output;
7
+ };
8
+
9
+ export type CliCommand = {
10
+ readonly id: string;
11
+ readonly description: string;
12
+ readonly help: string;
13
+ readonly run: (ctx: CliCommandContext) => Promise<number>;
14
+ };
package/src/index.ts CHANGED
@@ -4,17 +4,17 @@
4
4
  // (../bin/cli.ts) is a 3-line wrapper that forwards process.argv. Tests
5
5
  // drive runCli directly with a captured Output so no subprocess is needed.
6
6
  //
7
- // Scope (DX-1.2 minimum-viable): `new app <name>` + `add feature <name>`.
8
- // Other commands (dev, build, check, …) stay in the in-repo bin/kumiko.ts
9
- // for now — that one operates on the framework workspace, not on a
10
- // user-app workspace.
7
+ // Scope: scaffolding (`new app <name>`, `add feature <name>`) plus the
8
+ // app-facing ops commands (`agent`, `project`, `consumer`). Framework-
9
+ // maintainer commands (check, ci:guards, dev, build, …) stay in the
10
+ // framework repo's bin/kumiko.ts, which operates on that workspace only.
11
11
 
12
12
  import { scaffoldApp, scaffoldAppFeature } from "@cosmicdrift/kumiko-dev-server";
13
+ import type { CliCommand } from "./commands";
14
+ import { APP_COMMANDS, findAppCommand } from "./commands";
15
+ import type { Output } from "./output";
13
16
 
14
- export type Output = {
15
- readonly log: (line: string) => void;
16
- readonly err: (line: string) => void;
17
- };
17
+ export type { Output } from "./output";
18
18
 
19
19
  export type RunCliOptions = {
20
20
  readonly argv: readonly string[];
@@ -48,6 +48,16 @@ export async function runCli(options: RunCliOptions): Promise<number> {
48
48
  if (first === "new") return await runNew(argv.slice(1), out, cwd);
49
49
  if (first === "add") return runAdd(argv.slice(1), out, cwd);
50
50
 
51
+ const appCommand = findAppCommand(first);
52
+ if (appCommand) {
53
+ const rest = argv.slice(1);
54
+ if (rest.includes("--help") || rest.includes("-h")) {
55
+ printCommandHelp(appCommand, out);
56
+ return 0;
57
+ }
58
+ return await appCommand.run({ argv: rest, cwd, out });
59
+ }
60
+
51
61
  out.err(`kumiko: unknown command "${first}". Run \`kumiko --help\` for usage.`);
52
62
  return 1;
53
63
  }
@@ -111,8 +121,19 @@ function printHelp(out: Output): void {
111
121
  out.log("Commands:");
112
122
  out.log(" kumiko new app <name> Scaffold a new app workspace");
113
123
  out.log(" kumiko add feature <name> Add + auto-mount a feature");
124
+ for (const cmd of APP_COMMANDS) {
125
+ out.log(` kumiko ${cmd.id.padEnd(22)}${cmd.description}`);
126
+ }
114
127
  out.log(" kumiko --version Print version");
115
128
  out.log(" kumiko --help This help");
116
129
  out.log("");
117
130
  out.log("Docs: https://docs.kumiko.rocks");
118
131
  }
132
+
133
+ function printCommandHelp(cmd: CliCommand, out: Output): void {
134
+ out.log("");
135
+ out.log(` kumiko ${cmd.id} — ${cmd.description}`);
136
+ out.log("");
137
+ for (const line of cmd.help.split("\n")) out.log(` ${line}`);
138
+ out.log("");
139
+ }
package/src/output.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type Output = {
2
+ readonly log: (line: string) => void;
3
+ readonly err: (line: string) => void;
4
+ };
@@ -1,122 +0,0 @@
1
- // Drives runCli in-process — captures stdout/stderr via the injected
2
- // Output, exercises the 3-command-walkthrough end-to-end (new app +
3
- // add feature) into a tmp directory.
4
-
5
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join } from "node:path";
9
- import { runCli } from "../index";
10
-
11
- type Captured = { logs: string[]; errs: string[] };
12
-
13
- function capture(): {
14
- out: { log: (s: string) => void; err: (s: string) => void };
15
- captured: Captured;
16
- } {
17
- const captured: Captured = { logs: [], errs: [] };
18
- return {
19
- captured,
20
- out: {
21
- log: (s) => captured.logs.push(s),
22
- err: (s) => captured.errs.push(s),
23
- },
24
- };
25
- }
26
-
27
- describe("kumiko cli", () => {
28
- let tmp: string;
29
-
30
- beforeEach(() => {
31
- tmp = mkdtempSync(join(tmpdir(), "kumiko-cli-"));
32
- });
33
- afterEach(() => {
34
- rmSync(tmp, { recursive: true, force: true });
35
- });
36
-
37
- test("--help prints commands + docs link", async () => {
38
- const { out, captured } = capture();
39
- const code = await runCli({ argv: ["--help"], out });
40
- expect(code).toBe(0);
41
- const joined = captured.logs.join("\n");
42
- expect(joined).toContain("kumiko new app");
43
- expect(joined).toContain("kumiko add feature");
44
- expect(joined).toContain("docs.kumiko.rocks");
45
- });
46
-
47
- test("no args prints help", async () => {
48
- const { out, captured } = capture();
49
- const code = await runCli({ argv: [], out });
50
- expect(code).toBe(0);
51
- expect(captured.logs.join("\n")).toContain("kumiko new app");
52
- });
53
-
54
- test("--version prints semver", async () => {
55
- const { out, captured } = capture();
56
- const code = await runCli({ argv: ["--version"], out });
57
- expect(code).toBe(0);
58
- expect(captured.logs[0]).toMatch(/^\d+\.\d+\.\d+$/);
59
- });
60
-
61
- test("unknown command exits 1", async () => {
62
- const { out, captured } = capture();
63
- const code = await runCli({ argv: ["bogus"], out });
64
- expect(code).toBe(1);
65
- expect(captured.errs.join("\n")).toContain("unknown command");
66
- });
67
-
68
- test("new app <name> scaffolds the walkthrough files", async () => {
69
- const { out, captured } = capture();
70
- const code = await runCli({ argv: ["new", "app", "my-notes"], cwd: tmp, out });
71
- expect(code).toBe(0);
72
- const appRoot = join(tmp, "my-notes");
73
- for (const f of [
74
- "package.json",
75
- "tsconfig.json",
76
- "src/run-config.ts",
77
- "bin/main.ts",
78
- "bin/dev.ts",
79
- ".env.example",
80
- "README.md",
81
- ]) {
82
- expect(() => readFileSync(join(appRoot, f), "utf-8")).not.toThrow();
83
- }
84
- expect(captured.logs.join("\n")).toContain("Scaffolded my-notes");
85
- });
86
-
87
- test("new app rejects bad name", async () => {
88
- const { out, captured } = capture();
89
- const code = await runCli({ argv: ["new", "app", "My Notes"], cwd: tmp, out });
90
- expect(code).toBe(1);
91
- expect(captured.errs.join("\n")).toContain("kebab-case");
92
- });
93
-
94
- test("new app without name exits 1 with usage hint", async () => {
95
- const { out, captured } = capture();
96
- const code = await runCli({ argv: ["new", "app"], cwd: tmp, out });
97
- expect(code).toBe(1);
98
- expect(captured.errs.join("\n")).toContain("missing <name>");
99
- });
100
-
101
- test("add feature mounts into scaffolded app", async () => {
102
- const { out: out1 } = capture();
103
- await runCli({ argv: ["new", "app", "my-notes"], cwd: tmp, out: out1 });
104
- const appRoot = join(tmp, "my-notes");
105
-
106
- const { out: out2, captured } = capture();
107
- const code = await runCli({ argv: ["add", "feature", "notes"], cwd: appRoot, out: out2 });
108
- expect(code).toBe(0);
109
-
110
- const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
111
- expect(runConfig).toContain(`import { notesFeature } from "./features/notes";`);
112
- expect(runConfig).toContain("notesFeature");
113
- expect(captured.logs.join("\n")).toContain("auto-mounted");
114
- });
115
-
116
- test("add feature without name exits 1", async () => {
117
- const { out, captured } = capture();
118
- const code = await runCli({ argv: ["add", "feature"], cwd: tmp, out });
119
- expect(code).toBe(1);
120
- expect(captured.errs.join("\n")).toContain("missing <name>");
121
- });
122
- });