Jelajahi Sumber

fix(studio): repair service config flow and default CLI entry

Ma 6 bulan lalu
induk
melakukan
8b2d32f8e8

+ 4 - 4
packages/cli/src/__tests__/studio.test.ts

@@ -37,8 +37,8 @@ describe("studio command", () => {
       throw new Error(`missing: ${path}`);
     });
 
-    const { studioCommand } = await import("../commands/studio.js");
-    await studioCommand.parseAsync(["node", "studio", "--port", "9001"]);
+    const { createStudioCommand } = await import("../commands/studio.js");
+    await createStudioCommand().parseAsync(["node", "studio", "--port", "9001"]);
 
     expect(spawnMock).toHaveBeenCalledWith(
       "npx",
@@ -68,8 +68,8 @@ describe("studio command", () => {
       throw new Error(`missing: ${path}`);
     });
 
-    const { studioCommand } = await import("../commands/studio.js");
-    await studioCommand.parseAsync(["node", "studio", "--port", "4567"]);
+    const { createStudioCommand } = await import("../commands/studio.js");
+    await createStudioCommand().parseAsync(["node", "studio", "--port", "4567"]);
 
     expect(spawnMock).toHaveBeenCalledWith(
       "node",

+ 8 - 5
packages/cli/src/__tests__/tui-command.test.ts

@@ -9,21 +9,24 @@ describe("tui command", () => {
     vi.restoreAllMocks();
   });
 
-  it("launches the TUI when no subcommand is provided", async () => {
-    const launchTui = vi.fn(async () => {});
-    const program = createProgram({ launchTui });
+  it("launches Studio when no subcommand is provided", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
 
     await program.parseAsync([], { from: "user" });
 
-    expect(launchTui).toHaveBeenCalledTimes(1);
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    expect(launchStudio).toHaveBeenCalledWith(process.cwd(), "4567");
   });
 
   it("launches the TUI when the explicit tui command is used", async () => {
     const launchTui = vi.fn(async () => {});
-    const program = createProgram({ launchTui });
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchTui, launchStudio });
 
     await program.parseAsync(["tui"], { from: "user" });
 
     expect(launchTui).toHaveBeenCalledTimes(1);
+    expect(launchStudio).not.toHaveBeenCalled();
   });
 });

+ 57 - 41
packages/cli/src/commands/studio.ts

@@ -16,6 +16,10 @@ export interface BrowserLaunchSpec {
   readonly args: string[];
 }
 
+export interface StudioCommandHooks {
+  readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
+}
+
 async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
   for (const path of paths) {
     try {
@@ -98,51 +102,63 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
   return null;
 }
 
-export const studioCommand = new Command("studio")
+export async function launchStudioWorkbench(root: string, port: string): Promise<void> {
+  const url = `http://localhost:${port}`;
+  const launch = await resolveStudioLaunch(root);
+
+  if (!launch) {
+    logError(
+      "InkOS Studio not found. If you cloned the repo, run:\n" +
+      "  cd packages/studio && pnpm install && pnpm build\n" +
+      "Then run 'inkos studio' from the project root.",
+    );
+    process.exit(1);
+  }
+
+  log(`Starting InkOS Studio on ${url}`);
+
+  const child = spawn(launch.command, launch.args, {
+    cwd: root,
+    stdio: "inherit",
+    env: { ...process.env, INKOS_STUDIO_PORT: port },
+  });
+
+  child.on("error", (e) => {
+    logError(`Failed to start studio: ${e.message}`);
+    process.exit(1);
+  });
+
+  const browserLaunch = resolveBrowserLaunch(process.platform, url);
+  const browser = spawn(browserLaunch.command, browserLaunch.args, {
+    cwd: root,
+    stdio: "ignore",
+    detached: true,
+  });
+  browser.on("error", () => {
+    // Best effort only — server startup should not fail just because browser open failed.
+  });
+  if (typeof browser.unref === "function") {
+    browser.unref();
+  }
+
+  child.on("exit", (code) => {
+    process.exit(code ?? 0);
+  });
+}
+
+export function createStudioCommand(hooks: StudioCommandHooks = {}): Command {
+  return new Command("studio")
   .description("Start InkOS Studio web workbench")
   .option("-p, --port <port>", "Server port", "4567")
   .action(async (opts) => {
     const root = findProjectRoot();
     const port = opts.port;
-    const url = `http://localhost:${port}`;
-    const launch = await resolveStudioLaunch(root);
-
-    if (!launch) {
-      logError(
-        "InkOS Studio not found. If you cloned the repo, run:\n" +
-        "  cd packages/studio && pnpm install && pnpm build\n" +
-        "Then run 'inkos studio' from the project root.",
-      );
-      process.exit(1);
+    if (hooks.launchStudio) {
+      await hooks.launchStudio(root, port);
+      return;
     }
-
-    log(`Starting InkOS Studio on ${url}`);
-
-    const child = spawn(launch.command, launch.args, {
-      cwd: root,
-      stdio: "inherit",
-      env: { ...process.env, INKOS_STUDIO_PORT: port },
-    });
-
-    child.on("error", (e) => {
-      logError(`Failed to start studio: ${e.message}`);
-      process.exit(1);
-    });
-
-    const browserLaunch = resolveBrowserLaunch(process.platform, url);
-    const browser = spawn(browserLaunch.command, browserLaunch.args, {
-      cwd: root,
-      stdio: "ignore",
-      detached: true,
-    });
-    browser.on("error", () => {
-      // Best effort only — server startup should not fail just because browser open failed.
-    });
-    if (typeof browser.unref === "function") {
-      browser.unref();
-    }
-
-    child.on("exit", (code) => {
-      process.exit(code ?? 0);
-    });
+    await launchStudioWorkbench(root, port);
   });
+}
+
+export const studioCommand = createStudioCommand();

+ 6 - 5
packages/cli/src/program.ts

@@ -24,7 +24,7 @@ import { analyticsCommand } from "./commands/analytics.js";
 import { evalCommand } from "./commands/eval.js";
 import { importCommand } from "./commands/import.js";
 import { fanficCommand } from "./commands/fanfic.js";
-import { studioCommand } from "./commands/studio.js";
+import { createStudioCommand, launchStudioWorkbench } from "./commands/studio.js";
 import { consolidateCommand } from "./commands/consolidate.js";
 import { createInteractCommand, type InteractCommandHooks } from "./commands/interact.js";
 import { createTuiCommand } from "./commands/tui.js";
@@ -35,6 +35,7 @@ const { version } = require("../package.json") as { version: string };
 
 export interface ProgramHooks {
   readonly launchTui?: (projectRoot: string) => Promise<void> | void;
+  readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
   readonly runInteraction?: InteractCommandHooks["runInteraction"];
   readonly readInteractionInput?: InteractCommandHooks["readInput"];
 }
@@ -47,11 +48,11 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
     .description("InkOS — Multi-agent novel production system")
     .version(version)
     .action(async () => {
-      if (hooks.launchTui) {
-        await hooks.launchTui(process.cwd());
+      if (hooks.launchStudio) {
+        await hooks.launchStudio(process.cwd(), "4567");
         return;
       }
-      await launchTui(process.cwd());
+      await launchStudioWorkbench(process.cwd(), "4567");
     });
 
   program.addCommand(initCommand);
@@ -79,7 +80,7 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
   program.addCommand(evalCommand);
   program.addCommand(importCommand);
   program.addCommand(fanficCommand);
-  program.addCommand(studioCommand);
+  program.addCommand(createStudioCommand({ launchStudio: hooks.launchStudio }));
   program.addCommand(consolidateCommand);
   program.addCommand(createInteractCommand({
     runInteraction: hooks.runInteraction,