From 0354236170c9fc9b7af0373820ebaa69b4b05a28 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 7 May 2026 00:43:58 +0800 Subject: [PATCH 1/2] refactor(cli): split executable entry from cli logic --- packages/cli/src/bin.test.ts | 2 +- packages/cli/src/bin.ts | 385 +---------------------------------- packages/cli/src/cli.ts | 355 ++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+), 380 deletions(-) create mode 100644 packages/cli/src/cli.ts diff --git a/packages/cli/src/bin.test.ts b/packages/cli/src/bin.test.ts index 2ae679345..5db2ff8dc 100644 --- a/packages/cli/src/bin.test.ts +++ b/packages/cli/src/bin.test.ts @@ -61,7 +61,7 @@ vi.mock("./browser.js", () => ({ openBrowser, })); -import { main } from "./bin"; +import { main } from "./cli"; import { parseArgs, RUNTIME_CONFIG_ERROR } from "./parse-args"; beforeEach(() => { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 91980d92a..6dc762c86 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,380 +1,7 @@ -import { existsSync, readFileSync } from "fs"; -import { dirname, join, resolve } from "path"; -import { fileURLToPath } from "url"; -import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js"; -import { openBrowser } from "./browser.js"; -import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js"; -import { readLogExcerpt } from "./log-excerpt.js"; -import { assertSupportedNodeVersion } from "./node-version.js"; -import { parseArgs } from "./parse-args.js"; -import { startManagedServer } from "./pm2-control.js"; -import { confirmYesNo, isInteractiveSession } from "./prompts.js"; -import { getServerStatus, type ServerStatus, stopRunningServer } from "./server-control.js"; -import { startServer } from "./server-runner.js"; -import { getBrowserUrl, getListenIp, getListenUrl } from "./server-url.js"; +import { main } from "./cli.js"; -const MANAGED_SERVER_WAIT_MS = 5000; -const DEFAULT_LOG_TAIL_LINES = 40; - -function formatConfig(config: CliConfig | null): string { - return JSON.stringify(config ?? {}, null, 2); -} - -function formatStatus(status: ServerStatus): string { - const listenUrl = getListenUrl(status) ?? "n/a"; - const browserUrl = getBrowserUrl(status) ?? "n/a"; - const startedAt = status.startedAt === null ? "n/a" : new Date(status.startedAt).toISOString(); - - return [ - `Status: ${status.status}`, - `Listen host: ${status.host ?? "n/a"}`, - `Listen IP: ${getListenIp(status) ?? "n/a"}`, - `Port: ${status.port ?? "n/a"}`, - `Listen URL: ${listenUrl}`, - `Local URL: ${browserUrl}`, - `PID: ${status.pid ?? "n/a"}`, - `Started: ${startedAt}`, - `Restarts: ${status.restartCount}`, - `Out log: ${status.outFile}`, - `Error log: ${status.errFile}`, - ].join("\n"); -} - -function showLogs( - status: ServerStatus, - { - tail = DEFAULT_LOG_TAIL_LINES, - errorsOnly = false, - }: { tail?: number; errorsOnly?: boolean } = {} -): void { - const paths = errorsOnly ? [status.errFile] : [status.outFile, status.errFile]; - const contents = paths - .filter((path, index, paths) => paths.indexOf(path) === index) - .flatMap((path) => { - const content = readLogExcerpt(path, { maxLines: tail, maxChars: null }); - return content ? [content] : []; - }); - - console.log(contents.length === 0 ? "No logs available." : contents.join("\n")); -} - -function showHelp(): void { - console.log(` -@spencer-kit/coder-studio - Coder Studio CLI - -USAGE: - coder-studio [COMMAND] - -COMMANDS: - serve Start the Coder Studio server in background (default) - server Alias for serve - open Start the server if needed and open Coder Studio in a browser - auth Manage auth login blocks in local server storage - config Persist CLI host/port/data-dir/password settings - stop Stop the managed Coder Studio server - status Show the managed server status - logs Show the managed server logs - help Show this help message - version Show version - -OPTIONS: - --host Save server host for future runs - --port, -p Save server port for future runs - --data-dir, -d Save data directory for future runs - --password Save auth password for future runs - --restart Restart an already running managed server for serve/open - --help Show help - --version, -v Show version - -EXAMPLES: - coder-studio - coder-studio serve - coder-studio server - coder-studio auth ban-list - coder-studio auth unblock --ip 198.51.100.24 - coder-studio serve --foreground - coder-studio serve --restart - coder-studio open - coder-studio open --restart - coder-studio status - coder-studio logs - coder-studio stop - coder-studio config --host 0.0.0.0 --port 8080 -`); -} - -function showConfigHelp(): void { - console.log(` -@spencer-kit/coder-studio - config - -USAGE: - coder-studio config [OPTIONS] - coder-studio config help - -BEHAVIOR: - Without options, prints the current saved config. - Bare serve reads this saved config for future runs. - -OPTIONS: - --host Save server host for future runs - --port, -p Save server port for future runs - --data-dir, -d Save data directory for future runs - --password Save auth password for future runs - --help Show config help - -EXAMPLES: - coder-studio config - coder-studio config --host 0.0.0.0 - coder-studio config --port 8080 - coder-studio config --data-dir /tmp/cs-data - coder-studio config --password sekrit - coder-studio config --host 0.0.0.0 --port 8080 -`); -} - -function showVersion(): void { - const manifestPath = [ - new URL("../package.json", import.meta.url), - new URL("../../package.json", import.meta.url), - ].find((candidate) => existsSync(candidate)); - if (!manifestPath) { - throw new Error("Unable to locate CLI package.json"); - } - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { version?: string }; - const version = manifest.version ?? "0.0.0"; - console.log(`@spencer-kit/coder-studio v${version}`); -} - -function formatAuthBlocks(blocks: Awaited>): string { - if (blocks.length === 0) { - return "No blocked IPs."; - } - - return JSON.stringify(blocks, null, 2); -} - -function resolveManagedScriptPath(): string { - const currentFile = fileURLToPath(import.meta.url); - const currentDir = dirname(currentFile); - const candidates = [ - join(currentDir, "server-runner.js"), - join(currentDir, "server-runner.mjs"), - join(currentDir, "../src/server-runner.ts"), - ]; - - const scriptPath = candidates.find((candidate) => existsSync(candidate)); - if (!scriptPath) { - throw new Error("Unable to locate the managed server entry script"); - } - - return scriptPath; -} - -function isCliEntrypoint(): boolean { - if (process.argv[1] === undefined) { - return false; - } - - const currentFile = fileURLToPath(import.meta.url); - const currentDir = dirname(currentFile); - const entryScript = resolve(process.argv[1]); - const entryCandidates = new Set([ - currentFile, - join(currentDir, "../bin.js"), - join(currentDir, "../dist/bin.js"), - ]); - - return entryCandidates.has(entryScript); -} - -function isRunningStatus(status: ServerStatus): boolean { - return status.status === "running" || status.status === "starting"; -} - -interface ManagedStartupDecision { - existingStatus: ServerStatus | null; - restartRequested: boolean; -} - -async function shouldRestartRunningServer(status: ServerStatus): Promise { - const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "the existing server"; - - if (!isInteractiveSession()) { - return false; - } - - return confirmYesNo(`Coder Studio is already running at ${currentUrl}. Restart it? [y/N] `); -} - -async function prepareManagedStartup(forceRestart = false): Promise { - const status = await getServerStatus(); - if (!isRunningStatus(status)) { - return { - existingStatus: null, - restartRequested: false, - }; - } - - const restart = forceRestart ? true : await shouldRestartRunningServer(status); - if (!restart) { - const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "n/a"; - if (!isInteractiveSession()) { - console.log( - `Coder Studio is already running at ${currentUrl}. Service already exists and was not restarted.` - ); - } else { - console.log(`Leaving the existing Coder Studio server running at ${currentUrl}.`); - } - return { - existingStatus: status, - restartRequested: false, - }; - } - - console.log("Restarting the managed Coder Studio server..."); - return { - existingStatus: null, - restartRequested: true, - }; -} - -async function startManagedServerFlow(): Promise { - await startManagedServer({ - script: resolveManagedScriptPath(), - cwd: process.cwd(), - waitMs: MANAGED_SERVER_WAIT_MS, - }); -} - -async function openManagedServerInBrowser(existingStatus?: ServerStatus | null): Promise { - const status = existingStatus ?? (await getServerStatus()); - const browserUrl = getBrowserUrl(status); - - if (browserUrl === null) { - throw new Error("Unable to determine the running Coder Studio URL."); - } - - console.log(`Opening Coder Studio in your browser: ${browserUrl}`); - await openBrowser(browserUrl); -} - -export async function main(argv = process.argv.slice(2)): Promise { - assertSupportedNodeVersion(); - const args = parseArgs(argv); - - if (args.command === "config") { - if (args.configHelp) { - showConfigHelp(); - return; - } - - if ( - args.host === undefined && - args.port === undefined && - args.dataDir === undefined && - args.password === undefined - ) { - console.log(formatConfig(readCliConfig())); - return; - } - - const savedConfig = readCliConfig(); - const nextConfig: CliConfig = { - ...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}), - ...(savedConfig?.port !== undefined && savedConfig.port > 0 - ? { port: savedConfig.port } - : {}), - ...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}), - ...(savedConfig?.password !== undefined ? { password: savedConfig.password } : {}), - ...(args.host !== undefined ? { host: args.host } : {}), - ...(args.port !== undefined ? { port: args.port } : {}), - ...(args.dataDir !== undefined ? { dataDir: args.dataDir } : {}), - ...(args.password !== undefined ? { password: args.password } : {}), - }; - writeCliConfig(nextConfig); - console.log(formatConfig(nextConfig)); - return; - } - - if (args.command === "stop") { - const stopped = await stopRunningServer(); - console.log(stopped ? "Stopped Coder Studio server." : "No running Coder Studio server found."); - return; - } - - if (args.command === "status") { - console.log(formatStatus(await getServerStatus())); - return; - } - - if (args.command === "logs") { - showLogs(await getServerStatus(), { tail: args.tail, errorsOnly: args.errorsOnly }); - return; - } - - if (args.command === "help") { - showHelp(); - return; - } - - if (args.command === "version") { - showVersion(); - return; - } - - if (args.command === "auth") { - if (args.authCommand === "ban-list") { - console.log(formatAuthBlocks(await listAuthBlocks())); - return; - } - - if (args.authCommand === "unblock") { - const cleared = await clearAuthBlockByIp(args.ip!); - console.log(cleared ? `Unblocked IP: ${args.ip}` : `No block found for IP: ${args.ip}`); - return; - } - } - - if (args.command === "open") { - const startup = await prepareManagedStartup(args.restart); - if (startup.existingStatus === null) { - await startManagedServerFlow(); - } - - await openManagedServerInBrowser(startup.existingStatus); - return; - } - - if (args.foreground) { - const startup = await prepareManagedStartup(args.restart); - if (startup.existingStatus !== null) { - return; - } - - if (startup.restartRequested) { - await stopRunningServer(); - } - - console.log("Starting Coder Studio Server in foreground..."); - await startServer(); - return; - } - - const startup = await prepareManagedStartup(args.restart); - if (startup.existingStatus !== null) { - return; - } - - await startManagedServerFlow(); - - console.log("Coder Studio server started in background."); - console.log("Run `coder-studio status` to inspect the server."); -} - -if (isCliEntrypoint()) { - main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.error("CLI error:", message); - process.exit(1); - }); -} +void main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error("CLI error:", message); + process.exit(1); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 000000000..649374b8e --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,355 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js"; +import { openBrowser } from "./browser.js"; +import { type CliConfig, readCliConfig, writeCliConfig } from "./config-store.js"; +import { readLogExcerpt } from "./log-excerpt.js"; +import { assertSupportedNodeVersion } from "./node-version.js"; +import { parseArgs } from "./parse-args.js"; +import { startManagedServer } from "./pm2-control.js"; +import { confirmYesNo, isInteractiveSession } from "./prompts.js"; +import { getServerStatus, type ServerStatus, stopRunningServer } from "./server-control.js"; +import { startServer } from "./server-runner.js"; +import { getBrowserUrl, getListenIp, getListenUrl } from "./server-url.js"; + +const MANAGED_SERVER_WAIT_MS = 5000; +const DEFAULT_LOG_TAIL_LINES = 40; + +function formatConfig(config: CliConfig | null): string { + return JSON.stringify(config ?? {}, null, 2); +} + +function formatStatus(status: ServerStatus): string { + const listenUrl = getListenUrl(status) ?? "n/a"; + const browserUrl = getBrowserUrl(status) ?? "n/a"; + const startedAt = status.startedAt === null ? "n/a" : new Date(status.startedAt).toISOString(); + + return [ + `Status: ${status.status}`, + `Listen host: ${status.host ?? "n/a"}`, + `Listen IP: ${getListenIp(status) ?? "n/a"}`, + `Port: ${status.port ?? "n/a"}`, + `Listen URL: ${listenUrl}`, + `Local URL: ${browserUrl}`, + `PID: ${status.pid ?? "n/a"}`, + `Started: ${startedAt}`, + `Restarts: ${status.restartCount}`, + `Out log: ${status.outFile}`, + `Error log: ${status.errFile}`, + ].join("\n"); +} + +function showLogs( + status: ServerStatus, + { + tail = DEFAULT_LOG_TAIL_LINES, + errorsOnly = false, + }: { tail?: number; errorsOnly?: boolean } = {} +): void { + const paths = errorsOnly ? [status.errFile] : [status.outFile, status.errFile]; + const contents = paths + .filter((path, index, paths) => paths.indexOf(path) === index) + .flatMap((path) => { + const content = readLogExcerpt(path, { maxLines: tail, maxChars: null }); + return content ? [content] : []; + }); + + console.log(contents.length === 0 ? "No logs available." : contents.join("\n")); +} + +function showHelp(): void { + console.log(` +@spencer-kit/coder-studio - Coder Studio CLI + +USAGE: + coder-studio [COMMAND] + +COMMANDS: + serve Start the Coder Studio server in background (default) + server Alias for serve + open Start the server if needed and open Coder Studio in a browser + auth Manage auth login blocks in local server storage + config Persist CLI host/port/data-dir/password settings + stop Stop the managed Coder Studio server + status Show the managed server status + logs Show the managed server logs + help Show this help message + version Show version + +OPTIONS: + --host Save server host for future runs + --port, -p Save server port for future runs + --data-dir, -d Save data directory for future runs + --password Save auth password for future runs + --restart Restart an already running managed server for serve/open + --help Show help + --version, -v Show version + +EXAMPLES: + coder-studio + coder-studio serve + coder-studio server + coder-studio auth ban-list + coder-studio auth unblock --ip 198.51.100.24 + coder-studio serve --foreground + coder-studio serve --restart + coder-studio open + coder-studio open --restart + coder-studio status + coder-studio logs + coder-studio stop + coder-studio config --host 0.0.0.0 --port 8080 +`); +} + +function showConfigHelp(): void { + console.log(` +@spencer-kit/coder-studio - config + +USAGE: + coder-studio config [OPTIONS] + coder-studio config help + +BEHAVIOR: + Without options, prints the current saved config. + Bare serve reads this saved config for future runs. + +OPTIONS: + --host Save server host for future runs + --port, -p Save server port for future runs + --data-dir, -d Save data directory for future runs + --password Save auth password for future runs + --help Show config help + +EXAMPLES: + coder-studio config + coder-studio config --host 0.0.0.0 + coder-studio config --port 8080 + coder-studio config --data-dir /tmp/cs-data + coder-studio config --password sekrit + coder-studio config --host 0.0.0.0 --port 8080 +`); +} + +function showVersion(): void { + const manifestPath = [ + new URL("../package.json", import.meta.url), + new URL("../../package.json", import.meta.url), + ].find((candidate) => existsSync(candidate)); + if (!manifestPath) { + throw new Error("Unable to locate CLI package.json"); + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { version?: string }; + const version = manifest.version ?? "0.0.0"; + console.log(`@spencer-kit/coder-studio v${version}`); +} + +function formatAuthBlocks(blocks: Awaited>): string { + if (blocks.length === 0) { + return "No blocked IPs."; + } + + return JSON.stringify(blocks, null, 2); +} + +function resolveManagedScriptPath(): string { + const currentFile = fileURLToPath(import.meta.url); + const currentDir = dirname(currentFile); + const candidates = [ + join(currentDir, "server-runner.js"), + join(currentDir, "server-runner.mjs"), + join(currentDir, "../src/server-runner.ts"), + ]; + + const scriptPath = candidates.find((candidate) => existsSync(candidate)); + if (!scriptPath) { + throw new Error("Unable to locate the managed server entry script"); + } + + return scriptPath; +} + +function isRunningStatus(status: ServerStatus): boolean { + return status.status === "running" || status.status === "starting"; +} + +interface ManagedStartupDecision { + existingStatus: ServerStatus | null; + restartRequested: boolean; +} + +async function shouldRestartRunningServer(status: ServerStatus): Promise { + const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "the existing server"; + + if (!isInteractiveSession()) { + return false; + } + + return confirmYesNo(`Coder Studio is already running at ${currentUrl}. Restart it? [y/N] `); +} + +async function prepareManagedStartup(forceRestart = false): Promise { + const status = await getServerStatus(); + if (!isRunningStatus(status)) { + return { + existingStatus: null, + restartRequested: false, + }; + } + + const restart = forceRestart ? true : await shouldRestartRunningServer(status); + if (!restart) { + const currentUrl = getBrowserUrl(status) ?? getListenUrl(status) ?? "n/a"; + if (!isInteractiveSession()) { + console.log( + `Coder Studio is already running at ${currentUrl}. Service already exists and was not restarted.` + ); + } else { + console.log(`Leaving the existing Coder Studio server running at ${currentUrl}.`); + } + return { + existingStatus: status, + restartRequested: false, + }; + } + + console.log("Restarting the managed Coder Studio server..."); + return { + existingStatus: null, + restartRequested: true, + }; +} + +async function startManagedServerFlow(): Promise { + await startManagedServer({ + script: resolveManagedScriptPath(), + cwd: process.cwd(), + waitMs: MANAGED_SERVER_WAIT_MS, + }); +} + +async function openManagedServerInBrowser(existingStatus?: ServerStatus | null): Promise { + const status = existingStatus ?? (await getServerStatus()); + const browserUrl = getBrowserUrl(status); + + if (browserUrl === null) { + throw new Error("Unable to determine the running Coder Studio URL."); + } + + console.log(`Opening Coder Studio in your browser: ${browserUrl}`); + await openBrowser(browserUrl); +} + +export async function main(argv = process.argv.slice(2)): Promise { + assertSupportedNodeVersion(); + const args = parseArgs(argv); + + if (args.command === "config") { + if (args.configHelp) { + showConfigHelp(); + return; + } + + if ( + args.host === undefined && + args.port === undefined && + args.dataDir === undefined && + args.password === undefined + ) { + console.log(formatConfig(readCliConfig())); + return; + } + + const savedConfig = readCliConfig(); + const nextConfig: CliConfig = { + ...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}), + ...(savedConfig?.port !== undefined && savedConfig.port > 0 + ? { port: savedConfig.port } + : {}), + ...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}), + ...(savedConfig?.password !== undefined ? { password: savedConfig.password } : {}), + ...(args.host !== undefined ? { host: args.host } : {}), + ...(args.port !== undefined ? { port: args.port } : {}), + ...(args.dataDir !== undefined ? { dataDir: args.dataDir } : {}), + ...(args.password !== undefined ? { password: args.password } : {}), + }; + writeCliConfig(nextConfig); + console.log(formatConfig(nextConfig)); + return; + } + + if (args.command === "stop") { + const stopped = await stopRunningServer(); + console.log(stopped ? "Stopped Coder Studio server." : "No running Coder Studio server found."); + return; + } + + if (args.command === "status") { + console.log(formatStatus(await getServerStatus())); + return; + } + + if (args.command === "logs") { + showLogs(await getServerStatus(), { tail: args.tail, errorsOnly: args.errorsOnly }); + return; + } + + if (args.command === "help") { + showHelp(); + return; + } + + if (args.command === "version") { + showVersion(); + return; + } + + if (args.command === "auth") { + if (args.authCommand === "ban-list") { + console.log(formatAuthBlocks(await listAuthBlocks())); + return; + } + + if (args.authCommand === "unblock") { + const cleared = await clearAuthBlockByIp(args.ip!); + console.log(cleared ? `Unblocked IP: ${args.ip}` : `No block found for IP: ${args.ip}`); + return; + } + } + + if (args.command === "open") { + const startup = await prepareManagedStartup(args.restart); + if (startup.existingStatus === null) { + await startManagedServerFlow(); + } + + await openManagedServerInBrowser(startup.existingStatus); + return; + } + + if (args.foreground) { + const startup = await prepareManagedStartup(args.restart); + if (startup.existingStatus !== null) { + return; + } + + if (startup.restartRequested) { + await stopRunningServer(); + } + + console.log("Starting Coder Studio Server in foreground..."); + await startServer(); + return; + } + + const startup = await prepareManagedStartup(args.restart); + if (startup.existingStatus !== null) { + return; + } + + await startManagedServerFlow(); + + console.log("Coder Studio server started in background."); + console.log("Run `coder-studio status` to inspect the server."); +} From ce9607daba95019a4a5cdf2a2df8b78fbbf38b53 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 7 May 2026 00:46:41 +0800 Subject: [PATCH 2/2] chore(release): add patch changeset for cli entry split --- .changeset/late-seals-travel.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/late-seals-travel.md diff --git a/.changeset/late-seals-travel.md b/.changeset/late-seals-travel.md new file mode 100644 index 000000000..09b3d6c20 --- /dev/null +++ b/.changeset/late-seals-travel.md @@ -0,0 +1,4 @@ +"@spencer-kit/coder-studio": patch +--- + +Refactor the CLI entry structure so the executable wrapper only launches the command entrypoint, while the reusable CLI logic lives in a separate module. This fixes cases where globally installed `coder-studio` commands could fail to print output because the entry module was misdetected.