Jelajahi Sumber

feat(cli): auto-init Studio projects on first launch

Ma 6 bulan lalu
induk
melakukan
35320fbcf3

+ 54 - 0
packages/cli/src/__tests__/project-bootstrap.test.ts

@@ -0,0 +1,54 @@
+import { beforeEach, afterEach, describe, expect, it } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+describe("project bootstrap", () => {
+  const originalHome = process.env.HOME;
+  let tempDir: string;
+
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-bootstrap-"));
+    process.env.HOME = tempDir;
+  });
+
+  afterEach(async () => {
+    process.env.HOME = originalHome;
+    await rm(tempDir, { recursive: true, force: true });
+  });
+
+  it("creates a minimal Studio-first project when none exists", async () => {
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+
+    const initialized = await ensureProjectDirectoryInitialized(tempDir, { language: "zh" });
+
+    expect(initialized).toBe(true);
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.name).toMatch(/^inkos-bootstrap-/);
+    expect(config.version).toBe("0.1.0");
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.service).toBe("custom");
+    expect(config.llm.model).toBe("");
+    expect(config.llm.baseUrl).toBe("");
+    await expect(readFile(join(tempDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
+    await expect(readFile(join(tempDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
+  });
+
+  it("does not overwrite support files when auto-initializing", async () => {
+    await writeFile(join(tempDir, ".env"), "EXISTING=1\n", "utf-8");
+    await writeFile(join(tempDir, ".gitignore"), "CUSTOM\n", "utf-8");
+
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+    await ensureProjectDirectoryInitialized(tempDir, { language: "zh" });
+
+    await expect(readFile(join(tempDir, ".env"), "utf-8")).resolves.toBe("EXISTING=1\n");
+    await expect(readFile(join(tempDir, ".gitignore"), "utf-8")).resolves.toBe("CUSTOM\n");
+  });
+
+  it("returns false when the directory is already an InkOS project", async () => {
+    await writeFile(join(tempDir, "inkos.json"), "{}\n", "utf-8");
+    const { ensureProjectDirectoryInitialized } = await import("../project-bootstrap.js");
+
+    await expect(ensureProjectDirectoryInitialized(tempDir, { language: "zh" })).resolves.toBe(false);
+  });
+});

+ 3 - 0
packages/cli/src/__tests__/studio.test.ts

@@ -10,6 +10,9 @@ const logErrorMock = vi.fn();
 
 vi.mock("node:fs/promises", () => ({
   access: accessMock,
+  mkdir: vi.fn(),
+  readFile: vi.fn(async () => ""),
+  writeFile: vi.fn(),
 }));
 
 vi.mock("node:child_process", () => ({

+ 56 - 2
packages/cli/src/__tests__/tui-command.test.ts

@@ -1,12 +1,25 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { realpathSync } from "node:fs";
 import { createProgram } from "../program.js";
 
 describe("tui command", () => {
   const originalArgv = process.argv;
+  const originalCwd = process.cwd();
+  let tempDir: string;
 
-  afterEach(() => {
+  beforeEach(async () => {
+    tempDir = await mkdtemp(join(tmpdir(), "inkos-default-studio-"));
+    process.chdir(tempDir);
+  });
+
+  afterEach(async () => {
     process.argv = originalArgv;
+    process.chdir(originalCwd);
     vi.restoreAllMocks();
+    await rm(tempDir, { recursive: true, force: true });
   });
 
   it("launches Studio when no subcommand is provided", async () => {
@@ -19,6 +32,33 @@ describe("tui command", () => {
     expect(launchStudio).toHaveBeenCalledWith(process.cwd(), "4567");
   });
 
+  it("auto-initializes a minimal project before launching Studio by default", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync([], { from: "user" });
+
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.name).toMatch(/^inkos-default-studio-/);
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.model).toBe("");
+    expect(config.llm.baseUrl).toBe("");
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    const [calledRoot, calledPort] = launchStudio.mock.calls[0] as unknown as [string, string];
+    expect(realpathSync(calledRoot)).toBe(realpathSync(tempDir));
+    expect(calledPort).toBe("4567");
+  });
+
+  it("does not overwrite an existing .env during automatic Studio init", async () => {
+    await writeFile(join(tempDir, ".env"), "EXISTING_ENV=1\n", "utf-8");
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync([], { from: "user" });
+
+    await expect(readFile(join(tempDir, ".env"), "utf-8")).resolves.toBe("EXISTING_ENV=1\n");
+  });
+
   it("launches the TUI when the explicit tui command is used", async () => {
     const launchTui = vi.fn(async () => {});
     const launchStudio = vi.fn(async () => {});
@@ -29,4 +69,18 @@ describe("tui command", () => {
     expect(launchTui).toHaveBeenCalledTimes(1);
     expect(launchStudio).not.toHaveBeenCalled();
   });
+
+  it("auto-initializes a minimal project before launching the explicit studio command", async () => {
+    const launchStudio = vi.fn(async () => {});
+    const program = createProgram({ launchStudio });
+
+    await program.parseAsync(["studio"], { from: "user" });
+
+    const config = JSON.parse(await readFile(join(tempDir, "inkos.json"), "utf-8"));
+    expect(config.llm.configSource).toBe("studio");
+    expect(launchStudio).toHaveBeenCalledTimes(1);
+    const [calledRoot, calledPort] = launchStudio.mock.calls[0] as unknown as [string, string];
+    expect(realpathSync(calledRoot)).toBe(realpathSync(tempDir));
+    expect(calledPort).toBe("4567");
+  });
 });

+ 8 - 111
packages/cli/src/commands/init.ts

@@ -1,16 +1,8 @@
 import { Command } from "commander";
-import { access, readFile, writeFile, mkdir } from "node:fs/promises";
-import { join, basename, resolve } from "node:path";
-import { log, logError, GLOBAL_ENV_PATH } from "../utils.js";
-
-async function hasGlobalConfig(): Promise<boolean> {
-  try {
-    const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
-    return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here");
-  } catch {
-    return false;
-  }
-}
+import { mkdir } from "node:fs/promises";
+import { resolve } from "node:path";
+import { log, logError } from "../utils.js";
+import { initializeProjectDirectory } from "../project-bootstrap.js";
 
 export const initCommand = new Command("init")
   .description("Initialize an InkOS project (current directory by default)")
@@ -18,108 +10,13 @@ export const initCommand = new Command("init")
   .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)", "zh")
   .action(async (name: string | undefined, opts: { lang?: string }) => {
     const projectDir = name ? resolve(process.cwd(), name) : process.cwd();
-    const projectName = basename(projectDir);
 
     try {
       await mkdir(projectDir, { recursive: true });
-
-      // Check if inkos.json already exists
-      const configPath = join(projectDir, "inkos.json");
-      try {
-        await access(configPath);
-        throw new Error(`inkos.json already exists in ${projectDir}. Use a different directory or delete the existing project.`);
-      } catch (e) {
-        if (e instanceof Error && e.message.includes("already exists")) throw e;
-        // File doesn't exist, good
-      }
-
-      await mkdir(join(projectDir, "books"), { recursive: true });
-      await mkdir(join(projectDir, "radar"), { recursive: true });
-
-      const config = {
-        name: projectName,
-        version: "0.1.0",
-        language: opts.lang ?? "zh",
-        llm: {
-          provider: process.env.INKOS_LLM_PROVIDER ?? "openai",
-          baseUrl: process.env.INKOS_LLM_BASE_URL ?? "",
-          model: process.env.INKOS_LLM_MODEL ?? "",
-        },
-        notify: [],
-        daemon: {
-          schedule: {
-            radarCron: "0 */6 * * *",
-            writeCron: "*/15 * * * *",
-          },
-          maxConcurrentBooks: 3,
-        },
-      };
-
-      await writeFile(
-        join(projectDir, "inkos.json"),
-        JSON.stringify(config, null, 2),
-        "utf-8",
-      );
-      await Promise.all([
-        writeFile(join(projectDir, ".nvmrc"), "22\n", "utf-8"),
-        writeFile(join(projectDir, ".node-version"), "22\n", "utf-8"),
-      ]);
-
-      const global = await hasGlobalConfig();
-
-      if (global) {
-        await writeFile(
-          join(projectDir, ".env"),
-          [
-            "# Project-level LLM overrides (optional)",
-            "# Global config at ~/.inkos/.env will be used by default.",
-            "# Uncomment below to override for this project only:",
-            "# INKOS_LLM_PROVIDER=openai",
-            "# INKOS_LLM_BASE_URL=",
-            "# INKOS_LLM_API_KEY=",
-            "# INKOS_LLM_MODEL=",
-            "",
-            "# Web search (optional):",
-            "# TAVILY_API_KEY=tvly-xxxxx",
-          ].join("\n"),
-          "utf-8",
-        );
-      } else {
-        await writeFile(
-          join(projectDir, ".env"),
-          [
-            "# LLM Configuration",
-            "# Tip: Run 'inkos config set-global' to set once for all projects.",
-            "# Provider: openai (OpenAI / compatible proxy), anthropic (Anthropic native)",
-            "INKOS_LLM_PROVIDER=openai",
-            "INKOS_LLM_BASE_URL=",
-            "INKOS_LLM_API_KEY=",
-            "INKOS_LLM_MODEL=",
-            "",
-            "# Optional parameters (defaults shown):",
-            "# INKOS_LLM_TEMPERATURE=0.7",
-            "# INKOS_LLM_MAX_TOKENS=8192",
-            "# INKOS_LLM_THINKING_BUDGET=0          # Anthropic extended thinking budget",
-            "# INKOS_LLM_API_FORMAT=chat             # chat (default) or responses (OpenAI Responses API)",
-            "",
-            "# Web search (optional, for auditor era-research):",
-            "# TAVILY_API_KEY=tvly-xxxxx              # Free at tavily.com (1000 searches/month)",
-            "",
-            "# Anthropic example:",
-            "# INKOS_LLM_PROVIDER=anthropic",
-            "# INKOS_LLM_PROVIDER=anthropic",
-            "# INKOS_LLM_BASE_URL=",
-            "# INKOS_LLM_MODEL=",
-          ].join("\n"),
-          "utf-8",
-        );
-      }
-
-      await writeFile(
-        join(projectDir, ".gitignore"),
-        [".env", "node_modules/", ".DS_Store"].join("\n"),
-        "utf-8",
-      );
+      await initializeProjectDirectory(projectDir, {
+        language: (opts.lang === "en" ? "en" : "zh"),
+        overwriteSupportFiles: true,
+      });
 
       log(`Project initialized at ${projectDir}`);
       log("");

+ 29 - 6
packages/cli/src/commands/studio.ts

@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
 import { dirname, join } from "node:path";
 import { access } from "node:fs/promises";
 import { fileURLToPath } from "node:url";
+import { ensureProjectDirectoryInitialized } from "../project-bootstrap.js";
 
 export interface StudioLaunchSpec {
   readonly studioEntry: string;
@@ -20,6 +21,11 @@ export interface StudioCommandHooks {
   readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
 }
 
+async function prepareStudioRoot(root: string): Promise<{ readonly root: string; readonly initialized: boolean }> {
+  const initialized = await ensureProjectDirectoryInitialized(root, { language: "zh" });
+  return { root, initialized };
+}
+
 async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
   for (const path of paths) {
     try {
@@ -103,7 +109,10 @@ export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpe
 }
 
 export async function launchStudioWorkbench(root: string, port: string): Promise<void> {
-  const url = `http://localhost:${port}`;
+  const prepared = await prepareStudioRoot(root);
+  const url = prepared.initialized
+    ? `http://localhost:${port}#/services`
+    : `http://localhost:${port}`;
   const launch = await resolveStudioLaunch(root);
 
   if (!launch) {
@@ -146,6 +155,24 @@ export async function launchStudioWorkbench(root: string, port: string): Promise
   });
 }
 
+export async function launchStudioEntry(
+  root: string,
+  port: string,
+  hooks: StudioCommandHooks = {},
+): Promise<void> {
+  const prepared = await prepareStudioRoot(root);
+  if (prepared.initialized) {
+    log(`No inkos.json found in ${root}. Initialized a minimal InkOS project for Studio.`);
+  }
+
+  if (hooks.launchStudio) {
+    await hooks.launchStudio(prepared.root, port);
+    return;
+  }
+
+  await launchStudioWorkbench(prepared.root, port);
+}
+
 export function createStudioCommand(hooks: StudioCommandHooks = {}): Command {
   return new Command("studio")
   .description("Start InkOS Studio web workbench")
@@ -153,11 +180,7 @@ export function createStudioCommand(hooks: StudioCommandHooks = {}): Command {
   .action(async (opts) => {
     const root = findProjectRoot();
     const port = opts.port;
-    if (hooks.launchStudio) {
-      await hooks.launchStudio(root, port);
-      return;
-    }
-    await launchStudioWorkbench(root, port);
+    await launchStudioEntry(root, port, hooks);
   });
 }
 

+ 2 - 6
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 { createStudioCommand, launchStudioWorkbench } from "./commands/studio.js";
+import { createStudioCommand, launchStudioEntry } from "./commands/studio.js";
 import { consolidateCommand } from "./commands/consolidate.js";
 import { createInteractCommand, type InteractCommandHooks } from "./commands/interact.js";
 import { createTuiCommand } from "./commands/tui.js";
@@ -48,11 +48,7 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
     .description("InkOS — Multi-agent novel production system")
     .version(version)
     .action(async () => {
-      if (hooks.launchStudio) {
-        await hooks.launchStudio(process.cwd(), "4567");
-        return;
-      }
-      await launchStudioWorkbench(process.cwd(), "4567");
+      await launchStudioEntry(process.cwd(), "4567", { launchStudio: hooks.launchStudio });
     });
 
   program.addCommand(initCommand);

+ 142 - 0
packages/cli/src/project-bootstrap.ts

@@ -0,0 +1,142 @@
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { basename, join } from "node:path";
+import { GLOBAL_ENV_PATH } from "./utils.js";
+
+export interface ProjectBootstrapOptions {
+  readonly language?: "zh" | "en";
+  readonly overwriteSupportFiles?: boolean;
+}
+
+async function hasGlobalConfig(): Promise<boolean> {
+  try {
+    const content = await readFile(GLOBAL_ENV_PATH, "utf-8");
+    return content.includes("INKOS_LLM_API_KEY=") && !content.includes("your-api-key-here");
+  } catch {
+    return false;
+  }
+}
+
+async function exists(path: string): Promise<boolean> {
+  try {
+    await access(path);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function writeMaybe(path: string, content: string, overwrite: boolean): Promise<void> {
+  if (!overwrite && await exists(path)) {
+    return;
+  }
+  await writeFile(path, content, "utf-8");
+}
+
+function buildProjectConfig(projectDir: string, language: "zh" | "en") {
+  return {
+    name: basename(projectDir),
+    version: "0.1.0" as const,
+    language,
+    llm: {
+      provider: "openai" as const,
+      service: "custom",
+      configSource: "studio" as const,
+      baseUrl: "",
+      model: "",
+      apiFormat: "chat" as const,
+      stream: true,
+    },
+    notify: [],
+    inputGovernanceMode: "v2" as const,
+    daemon: {
+      schedule: {
+        radarCron: "0 */6 * * *",
+        writeCron: "*/15 * * * *",
+      },
+      maxConcurrentBooks: 3,
+    },
+  };
+}
+
+function buildProjectEnvTemplate(globalConfigured: boolean): string {
+  if (globalConfigured) {
+    return [
+      "# Project-level LLM overrides (optional)",
+      "# Global config at ~/.inkos/.env will be used by default.",
+      "# Switch Studio to '使用 Studio 配置' if you want per-project service settings.",
+      "# Uncomment below to override for this project only:",
+      "# INKOS_LLM_PROVIDER=openai",
+      "# INKOS_LLM_BASE_URL=",
+      "# INKOS_LLM_API_KEY=",
+      "# INKOS_LLM_MODEL=",
+      "",
+      "# Web search (optional):",
+      "# TAVILY_API_KEY=tvly-xxxxx",
+      "",
+    ].join("\n");
+  }
+
+  return [
+    "# Optional project-level LLM overrides",
+    "# Studio can manage provider / model / key without editing this file.",
+    "# Uncomment only if you want this directory to force env-based config:",
+    "# INKOS_LLM_PROVIDER=openai",
+    "# INKOS_LLM_BASE_URL=",
+    "# INKOS_LLM_API_KEY=",
+    "# INKOS_LLM_MODEL=",
+    "# INKOS_LLM_API_FORMAT=chat",
+    "# INKOS_LLM_STREAM=true",
+    "",
+    "# Web search (optional):",
+    "# TAVILY_API_KEY=tvly-xxxxx",
+    "",
+  ].join("\n");
+}
+
+export async function initializeProjectDirectory(
+  projectDir: string,
+  options: ProjectBootstrapOptions = {},
+): Promise<void> {
+  const language = options.language ?? "zh";
+  const overwriteSupportFiles = options.overwriteSupportFiles ?? true;
+  const configPath = join(projectDir, "inkos.json");
+
+  if (await exists(configPath)) {
+    throw new Error(`inkos.json already exists in ${projectDir}. Use a different directory or delete the existing project.`);
+  }
+
+  await mkdir(projectDir, { recursive: true });
+  await mkdir(join(projectDir, "books"), { recursive: true });
+  await mkdir(join(projectDir, "radar"), { recursive: true });
+
+  await writeFile(
+    configPath,
+    JSON.stringify(buildProjectConfig(projectDir, language), null, 2),
+    "utf-8",
+  );
+
+  const globalConfigured = await hasGlobalConfig();
+
+  await Promise.all([
+    writeMaybe(join(projectDir, ".env"), buildProjectEnvTemplate(globalConfigured), overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".gitignore"), [".env", "node_modules/", ".DS_Store"].join("\n"), overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".nvmrc"), "22\n", overwriteSupportFiles),
+    writeMaybe(join(projectDir, ".node-version"), "22\n", overwriteSupportFiles),
+  ]);
+}
+
+export async function ensureProjectDirectoryInitialized(
+  projectDir: string,
+  options: Omit<ProjectBootstrapOptions, "overwriteSupportFiles"> = {},
+): Promise<boolean> {
+  const configPath = join(projectDir, "inkos.json");
+  if (await exists(configPath)) {
+    return false;
+  }
+
+  await initializeProjectDirectory(projectDir, {
+    language: options.language,
+    overwriteSupportFiles: false,
+  });
+  return true;
+}