studio.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { Command } from "commander";
  2. import { findProjectRoot, log, logError } from "../utils.js";
  3. import { spawn } from "node:child_process";
  4. import { dirname, join } from "node:path";
  5. import { access } from "node:fs/promises";
  6. import { fileURLToPath } from "node:url";
  7. import { ensureProjectDirectoryInitialized } from "../project-bootstrap.js";
  8. export interface StudioLaunchSpec {
  9. readonly studioEntry: string;
  10. readonly command: string;
  11. readonly args: string[];
  12. }
  13. export interface BrowserLaunchSpec {
  14. readonly command: string;
  15. readonly args: string[];
  16. }
  17. export interface StudioCommandHooks {
  18. readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
  19. }
  20. async function prepareStudioRoot(root: string): Promise<{ readonly root: string; readonly initialized: boolean }> {
  21. const initialized = await ensureProjectDirectoryInitialized(root, { language: "zh" });
  22. return { root, initialized };
  23. }
  24. async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
  25. for (const path of paths) {
  26. try {
  27. await access(path);
  28. return path;
  29. } catch {
  30. // continue
  31. }
  32. }
  33. return undefined;
  34. }
  35. const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
  36. export function resolveBrowserLaunch(
  37. platform: NodeJS.Platform,
  38. url: string,
  39. ): BrowserLaunchSpec {
  40. if (platform === "darwin") {
  41. return { command: "open", args: [url] };
  42. }
  43. if (platform === "win32") {
  44. return { command: "cmd", args: ["/c", "start", "", url] };
  45. }
  46. return { command: "xdg-open", args: [url] };
  47. }
  48. export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
  49. const sourceEntry = await firstAccessiblePath([
  50. join(root, "packages", "studio", "src", "api", "index.ts"),
  51. join(root, "..", "packages", "studio", "src", "api", "index.ts"),
  52. join(root, "..", "studio", "src", "api", "index.ts"),
  53. ]);
  54. if (sourceEntry) {
  55. const studioPackageRoot = dirname(dirname(dirname(sourceEntry)));
  56. const localTsxLoader = await firstAccessiblePath([
  57. join(studioPackageRoot, "node_modules", "tsx", "dist", "loader.mjs"),
  58. ]);
  59. if (localTsxLoader) {
  60. return {
  61. studioEntry: sourceEntry,
  62. command: "node",
  63. args: ["--import", localTsxLoader, sourceEntry, root],
  64. };
  65. }
  66. const localTsx = await firstAccessiblePath([
  67. join(studioPackageRoot, "node_modules", ".bin", "tsx"),
  68. ]);
  69. if (localTsx) {
  70. return {
  71. studioEntry: sourceEntry,
  72. command: localTsx,
  73. args: [sourceEntry, root],
  74. };
  75. }
  76. return {
  77. studioEntry: sourceEntry,
  78. command: "npx",
  79. args: ["tsx", sourceEntry, root],
  80. };
  81. }
  82. const builtEntry = await firstAccessiblePath([
  83. join(root, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
  84. join(root, "node_modules", "@actalk", "inkos-studio", "server.cjs"),
  85. join(cliPackageRoot, "node_modules", "@actalk", "inkos-studio", "dist", "api", "index.js"),
  86. join(cliPackageRoot, "node_modules", "@actalk", "inkos-studio", "server.cjs"),
  87. join(cliPackageRoot, "..", "inkos-studio", "dist", "api", "index.js"),
  88. join(cliPackageRoot, "..", "inkos-studio", "server.cjs"),
  89. ]);
  90. if (builtEntry) {
  91. return {
  92. studioEntry: builtEntry,
  93. command: "node",
  94. args: [builtEntry, root],
  95. };
  96. }
  97. return null;
  98. }
  99. export async function launchStudioWorkbench(root: string, port: string): Promise<void> {
  100. const prepared = await prepareStudioRoot(root);
  101. const url = prepared.initialized
  102. ? `http://localhost:${port}#/services`
  103. : `http://localhost:${port}`;
  104. const launch = await resolveStudioLaunch(root);
  105. if (!launch) {
  106. logError(
  107. "InkOS Studio not found. If you cloned the repo, run:\n" +
  108. " cd packages/studio && pnpm install && pnpm build\n" +
  109. "Then run 'inkos studio' from the project root.",
  110. );
  111. process.exit(1);
  112. }
  113. log(`Starting InkOS Studio on ${url}`);
  114. const child = spawn(launch.command, launch.args, {
  115. cwd: root,
  116. stdio: "inherit",
  117. env: { ...process.env, INKOS_STUDIO_PORT: port },
  118. });
  119. child.on("error", (e) => {
  120. logError(`Failed to start studio: ${e.message}`);
  121. process.exit(1);
  122. });
  123. const browserLaunch = resolveBrowserLaunch(process.platform, url);
  124. const browser = spawn(browserLaunch.command, browserLaunch.args, {
  125. cwd: root,
  126. stdio: "ignore",
  127. detached: true,
  128. });
  129. browser.on("error", () => {
  130. // Best effort only — server startup should not fail just because browser open failed.
  131. });
  132. if (typeof browser.unref === "function") {
  133. browser.unref();
  134. }
  135. child.on("exit", (code) => {
  136. process.exit(code ?? 0);
  137. });
  138. }
  139. export async function launchStudioEntry(
  140. root: string,
  141. port: string,
  142. hooks: StudioCommandHooks = {},
  143. ): Promise<void> {
  144. const prepared = await prepareStudioRoot(root);
  145. if (prepared.initialized) {
  146. log(`No inkos.json found in ${root}. Initialized a minimal InkOS project for Studio.`);
  147. }
  148. if (hooks.launchStudio) {
  149. await hooks.launchStudio(prepared.root, port);
  150. return;
  151. }
  152. await launchStudioWorkbench(prepared.root, port);
  153. }
  154. export function createStudioCommand(hooks: StudioCommandHooks = {}): Command {
  155. return new Command("studio")
  156. .description("Start InkOS Studio web workbench")
  157. .option("-p, --port <port>", "Server port", "4567")
  158. .action(async (opts) => {
  159. const root = findProjectRoot();
  160. const port = opts.port;
  161. await launchStudioEntry(root, port, hooks);
  162. });
  163. }
  164. export const studioCommand = createStudioCommand();