diff --git a/.changeset/tidy-icons-sing.md b/.changeset/tidy-icons-sing.md new file mode 100644 index 000000000..48c2d7ae4 --- /dev/null +++ b/.changeset/tidy-icons-sing.md @@ -0,0 +1,4 @@ +"@spencer-kit/coder-studio": patch +--- + +Fix the bundled web favicon assets by regenerating the PNG and ICO files directly from the SVG source so the icon keeps transparent edges without the visible border artifact. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 606b4b548..508b638a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,51 @@ jobs: - name: Run production build run: pnpm ci:build + + windows-runtime-verify: + name: Windows runtime verification + runs-on: windows-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.2 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Windows CI now proves that our non-PTY subprocess paths pass `windowsHide: true` + # and that provider/session PTY startup still works with the existing cross-platform + # argv contract. It does not visually prove the absence of a transient desktop + # console flash from node-pty itself; that still requires a manual smoke check on + # a real Windows desktop. + - name: Run targeted Windows provider tests + run: pnpm --filter @coder-studio/providers exec vitest run src/claude/definition.test.ts src/codex/definition.test.ts + + - name: Run targeted Windows server tests + run: pnpm --filter @coder-studio/server exec vitest run src/__tests__/provider-runtime/command-check.test.ts src/__tests__/provider-runtime/command-runner.test.ts src/__tests__/provider-runtime/install-manager.test.ts src/__tests__/workspace/runtime-check.test.ts src/git/cli.windows.test.ts src/supervisor/evaluator.windows.test.ts src/__tests__/server-provider-install-wiring.test.ts src/__tests__/session-commands.test.ts src/__tests__/session-integration.test.ts + + - name: Run targeted Windows CLI tests + run: pnpm --filter @spencer-kit/coder-studio exec vitest run src/browser.test.ts src/bin.test.ts src/pm2-control.test.ts src/server-control.test.ts + + - name: Build web assets for CLI packaging + run: pnpm build:web + + - name: Build server package + run: pnpm --filter @coder-studio/server build + + - name: Build CLI package + run: pnpm --filter @spencer-kit/coder-studio build diff --git a/README.md b/README.md index b827b819e..5b955f358 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Coder Studio lets you launch an AI coding workspace on your machine and keep usi Start a task in the office, check progress on your phone during the commute, review changes from a tablet, and continue on a laptop later. Same workspace, same context, no environment handoff. -![Workspace](docs/help/assets/screenshot-workspace.png) +![Workspace](docs/help/assets/screenshot-workspace-overview.png) ## Why Coder Studio diff --git a/README.zh-CN.md b/README.zh-CN.md index 2a492d341..f96a72695 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ Coder Studio 让你把 AI coding workspace 启动在自己的机器上,然后 你可以在办公室发起任务,在通勤路上用手机看进度,在外面用平板审阅改动,回到家再用另一台电脑继续接着做。还是同一个 workspace,还是同一份上下文,不需要重新接管环境。 -![工作区界面](docs/help/assets/screenshot-workspace.png) +![工作区界面](docs/help/assets/screenshot-workspace-overview.png) ## 为什么是 Coder Studio diff --git a/docs/help/assets/screenshot-workspace-overview.png b/docs/help/assets/screenshot-workspace-overview.png new file mode 100644 index 000000000..e0ff5b001 Binary files /dev/null and b/docs/help/assets/screenshot-workspace-overview.png differ diff --git a/docs/help/assets/screenshot-workspace.png b/docs/help/assets/screenshot-workspace.png deleted file mode 100644 index 0fa11e87e..000000000 Binary files a/docs/help/assets/screenshot-workspace.png and /dev/null differ diff --git a/e2e/fixtures/i18n.test.ts b/e2e/fixtures/i18n.test.ts new file mode 100644 index 000000000..1c1c354dd --- /dev/null +++ b/e2e/fixtures/i18n.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { translateForE2E } from "./i18n.js"; + +describe("translateForE2E", () => { + it("defaults to the app default locale for welcome copy", () => { + expect(translateForE2E("welcome.kicker")).toBe("开始使用"); + expect(translateForE2E("action.open_workspace")).toBe("打开工作区"); + }); + + it("can resolve English strings when requested", () => { + expect(translateForE2E("welcome.kicker", "en")).toBe("GET STARTED"); + expect(translateForE2E("workspace.launch.title", "en")).toBe("Open Workspace"); + }); + + it("interpolates variables", () => { + expect(translateForE2E("workspace.launch.items_count", "zh", { count: 3 })).toBe("3 项"); + }); +}); diff --git a/e2e/fixtures/i18n.ts b/e2e/fixtures/i18n.ts new file mode 100644 index 000000000..dbeb7d187 --- /dev/null +++ b/e2e/fixtures/i18n.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; + +function readLocale(path: string) { + return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")) as Record< + string, + unknown + >; +} + +export const E2E_LOCALES = { + en: readLocale("../../packages/web/src/locales/en.json"), + zh: readLocale("../../packages/web/src/locales/zh.json"), +} as const; + +export type E2ELocaleCode = keyof typeof E2E_LOCALES; + +type NestedKeyOf = T extends object + ? { + [K in keyof T]: K extends string + ? T[K] extends object + ? `${K}.${NestedKeyOf}` + : K + : never; + }[keyof T] + : never; + +export type E2ETranslationKey = NestedKeyOf<(typeof E2E_LOCALES)["zh"]>; + +function getNestedValue(obj: unknown, path: string): string | undefined { + const parts = path.split("."); + let current: unknown = obj; + + for (const part of parts) { + if (current === null || current === undefined) return undefined; + if (typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + + return typeof current === "string" ? current : undefined; +} + +export function translateForE2E( + key: E2ETranslationKey, + locale: E2ELocaleCode = "zh", + params?: Record +): string { + let text = getNestedValue(E2E_LOCALES[locale], key); + + if (text === undefined) { + throw new Error(`Missing translation for key: ${key}`); + } + + if (params) { + for (const [name, value] of Object.entries(params)) { + text = text.replace(new RegExp(`\\{${name}\\}`, "g"), String(value)); + } + } + + return text; +} diff --git a/e2e/fixtures/phase1-i18n.ts b/e2e/fixtures/phase1-i18n.ts new file mode 100644 index 000000000..17c291396 --- /dev/null +++ b/e2e/fixtures/phase1-i18n.ts @@ -0,0 +1,18 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { translateForE2E } from "./i18n.js"; + +export async function expectWelcomeCopy(page: Page): Promise { + await expect(page.locator(".welcome-kicker")).toHaveText(translateForE2E("welcome.kicker")); + await expect(page.locator(".welcome-title")).toHaveText(translateForE2E("welcome.title")); + await expect(page.locator(".welcome-body")).toContainText(translateForE2E("welcome.description")); +} + +export async function expectOpenWorkspaceButton(locator: Locator): Promise { + await expect(locator).toBeVisible(); + await expect(locator.locator("span")).toContainText(translateForE2E("action.open_workspace")); +} + +export async function expectSettingsButton(locator: Locator): Promise { + await expect(locator).toBeVisible(); + await expect(locator.locator("span")).toContainText(translateForE2E("action.settings")); +} diff --git a/e2e/fixtures/phase2-i18n.ts b/e2e/fixtures/phase2-i18n.ts new file mode 100644 index 000000000..9b46e04a1 --- /dev/null +++ b/e2e/fixtures/phase2-i18n.ts @@ -0,0 +1,108 @@ +import { type Page } from "@playwright/test"; +import { type E2ELocaleCode, translateForE2E } from "./i18n.js"; + +type SettingsSection = "general" | "appearance" | "providers" | "shortcuts"; +type ProviderSettingLabel = + | "base" + | "config_file" + | "open_config_file_editor" + | "back_to_base" + | "startup_args"; +type SettingsGroupLabel = "notifications" | "theme" | "language"; +type ConfigFileLabel = "claude" | "codex"; + +const SETTINGS_SECTION_KEYS: Record[0]> = { + general: "settings.general", + appearance: "settings.appearance", + providers: "settings.providers", + shortcuts: "settings.shortcuts.title", +}; + +const PROVIDER_SETTING_KEYS: Record[0]> = { + base: "settings.provider.base", + config_file: "settings.provider.config_file", + open_config_file_editor: "settings.provider.open_config_file_editor", + back_to_base: "settings.provider.back_to_base", + startup_args: "settings.provider.startup_args", +}; + +const SETTINGS_GROUP_KEYS: Record[0]> = { + notifications: "settings.notifications", + theme: "settings.theme.title", + language: "settings.language.title", +}; + +const CONFIG_FILE_KEYS: Record[0]> = { + claude: "settings.config_files.claude_config", + codex: "settings.config_files.codex_config", +}; + +export const AUTH_PREVIEW_URL = new URL("../../packages/web/auth-preview.html", import.meta.url) + .href; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function localizedPattern( + key: Parameters[0], + params?: Record +): RegExp { + const en = translateForE2E(key, "en", params); + const zh = translateForE2E(key, "zh", params); + const values = [...new Set([en, zh])].map(escapeRegExp); + return new RegExp(`^(?:${values.join("|")})$`); +} + +export function settingsSectionLabel( + section: SettingsSection, + locale: E2ELocaleCode = "zh" +): string { + return translateForE2E(SETTINGS_SECTION_KEYS[section], locale); +} + +export function settingsSectionPattern(section: SettingsSection): RegExp { + return localizedPattern(SETTINGS_SECTION_KEYS[section]); +} + +export async function openSettingsSection( + page: Page, + section: SettingsSection, + locale?: E2ELocaleCode +): Promise { + await page + .getByRole("button", { + name: locale ? settingsSectionLabel(section, locale) : settingsSectionPattern(section), + }) + .click(); +} + +export function providerSettingLabel( + label: ProviderSettingLabel, + locale: E2ELocaleCode = "zh" +): string { + return translateForE2E(PROVIDER_SETTING_KEYS[label], locale); +} + +export function providerSettingPattern(label: ProviderSettingLabel): RegExp { + return localizedPattern(PROVIDER_SETTING_KEYS[label]); +} + +export function settingsGroupLabel( + label: SettingsGroupLabel, + locale: E2ELocaleCode = "zh" +): string { + return translateForE2E(SETTINGS_GROUP_KEYS[label], locale); +} + +export function settingsGroupPattern(label: SettingsGroupLabel): RegExp { + return localizedPattern(SETTINGS_GROUP_KEYS[label]); +} + +export function configFileLabel(label: ConfigFileLabel, locale: E2ELocaleCode = "zh"): string { + return translateForE2E(CONFIG_FILE_KEYS[label], locale); +} + +export function configFilePattern(label: ConfigFileLabel): RegExp { + return localizedPattern(CONFIG_FILE_KEYS[label]); +} diff --git a/e2e/specs/phase1/agent-session.spec.ts b/e2e/specs/phase1/agent-session.spec.ts index 525d8f27f..65ed68949 100644 --- a/e2e/specs/phase1/agent-session.spec.ts +++ b/e2e/specs/phase1/agent-session.spec.ts @@ -1,20 +1,19 @@ import { expect, test } from "@playwright/test"; +import { expectOpenWorkspaceButton, expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 agent session acceptance", () => { test("F1-06 start session", async ({ page }) => { await page.goto("/"); // Welcome page should render correctly await expect(page.locator(".welcome-container")).toBeVisible(); - await expect(page.locator(".welcome-kicker")).toHaveText("GET STARTED"); - await expect(page.locator(".welcome-title")).toBeVisible(); + await expectWelcomeCopy(page); }); test("F1-07 send prompt", async ({ page }) => { await page.goto("/"); // Check welcome page elements const openBtn = page.locator(".welcome-btn"); - await expect(openBtn).toBeVisible(); - await expect(openBtn.locator("span")).toContainText("Open Workspace"); + await expectOpenWorkspaceButton(openBtn); }); test("F1-08 receive response", async ({ page }) => { diff --git a/e2e/specs/phase1/data-integrity.spec.ts b/e2e/specs/phase1/data-integrity.spec.ts index b7d4b0a3a..d8a343a9d 100644 --- a/e2e/specs/phase1/data-integrity.spec.ts +++ b/e2e/specs/phase1/data-integrity.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 data integrity acceptance", () => { test("F1-37 file persistence", async ({ page }) => { @@ -9,8 +10,8 @@ test.describe("@phase1 data integrity acceptance", () => { test("F1-38 session persistence", async ({ page }) => { await page.goto("/"); - // Check kicker - await expect(page.locator(".welcome-kicker")).toHaveText("GET STARTED"); + // Check translated welcome copy + await expectWelcomeCopy(page); }); test("F1-39 terminal replay", async ({ page }) => { diff --git a/e2e/specs/phase1/editor.spec.ts b/e2e/specs/phase1/editor.spec.ts index fdb7f683d..8461bc4a5 100644 --- a/e2e/specs/phase1/editor.spec.ts +++ b/e2e/specs/phase1/editor.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 editor acceptance", () => { test("F1-11 open file", async ({ page }) => { @@ -9,9 +10,8 @@ test.describe("@phase1 editor acceptance", () => { test("F1-12 edit content", async ({ page }) => { await page.goto("/"); - // Check welcome body text - const body = page.locator(".welcome-body"); - await expect(body).toContainText("A local-first AI coding workbench."); + // Check translated welcome copy + await expectWelcomeCopy(page); }); test("F1-13 save file", async ({ page }) => { diff --git a/e2e/specs/phase1/focus-mode.spec.ts b/e2e/specs/phase1/focus-mode.spec.ts index 7085f7ca7..d56e8ba38 100644 --- a/e2e/specs/phase1/focus-mode.spec.ts +++ b/e2e/specs/phase1/focus-mode.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 focus mode acceptance", () => { test("F1-27 enter focus", async ({ page }) => { @@ -9,7 +10,7 @@ test.describe("@phase1 focus mode acceptance", () => { test("F1-28 exit focus", async ({ page }) => { await page.goto("/"); - // Check kicker text - await expect(page.locator(".welcome-kicker")).toHaveText("GET STARTED"); + // Check translated welcome copy + await expectWelcomeCopy(page); }); }); diff --git a/e2e/specs/phase1/git.spec.ts b/e2e/specs/phase1/git.spec.ts index 2c290c3fb..94ba2738a 100644 --- a/e2e/specs/phase1/git.spec.ts +++ b/e2e/specs/phase1/git.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 git acceptance", () => { test("F1-16 view status", async ({ page }) => { @@ -9,8 +10,8 @@ test.describe("@phase1 git acceptance", () => { test("F1-17 view diff", async ({ page }) => { await page.goto("/"); - // Check welcome elements - await expect(page.locator(".welcome-kicker")).toHaveText("GET STARTED"); + // Check translated welcome copy + await expectWelcomeCopy(page); }); test("F1-18 commit", async ({ page }) => { diff --git a/e2e/specs/phase1/visual-components.spec.ts b/e2e/specs/phase1/visual-components.spec.ts index f9bb595b4..b75ca7721 100644 --- a/e2e/specs/phase1/visual-components.spec.ts +++ b/e2e/specs/phase1/visual-components.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { expectWelcomeCopy } from "../../fixtures/phase1-i18n"; /** * Phase 1 Visual Acceptance Tests: Core Components @@ -16,8 +17,8 @@ test.describe("@phase1 visual acceptance", () => { test("V1-05 workspace panel baseline", async ({ page }) => { await page.goto("/"); - // Welcome kicker should be present - await expect(page.locator(".welcome-kicker")).toHaveText("GET STARTED"); + // Welcome copy should be present in the active locale + await expectWelcomeCopy(page); }); test("V1-06 agent pane baseline", async ({ page }) => { diff --git a/e2e/specs/phase1/workspace.spec.ts b/e2e/specs/phase1/workspace.spec.ts index 0c0bb8cea..66b170edf 100644 --- a/e2e/specs/phase1/workspace.spec.ts +++ b/e2e/specs/phase1/workspace.spec.ts @@ -1,14 +1,18 @@ import { expect, test } from "@playwright/test"; +import { translateForE2E } from "../../fixtures/i18n"; +import { expectOpenWorkspaceButton, expectWelcomeCopy } from "../../fixtures/phase1-i18n"; test.describe("@phase1 workspace acceptance", () => { test("F1-01 open workspace", async ({ page }) => { await page.goto("/"); // Click open workspace button to open the workspace launch modal. const openBtn = page.locator(".welcome-btn"); - await expect(openBtn).toBeVisible(); + await expectOpenWorkspaceButton(openBtn); await openBtn.click(); await expect(page.locator(".launch-overlay")).toBeVisible(); - await expect(page.locator(".launch-title")).toHaveText("Local Folder"); + await expect(page.locator(".launch-title")).toHaveText( + translateForE2E("workspace.launch.title") + ); }); test("F1-02 browse file tree", async ({ page }) => { @@ -26,9 +30,8 @@ test.describe("@phase1 workspace acceptance", () => { test("F1-04 create file", async ({ page }) => { await page.goto("/"); - // Welcome page should have kicker - const kicker = page.locator(".welcome-kicker"); - await expect(kicker).toHaveText("GET STARTED"); + // Welcome page should have translated copy + await expectWelcomeCopy(page); }); test("F1-05 delete file", async ({ page }) => { diff --git a/e2e/specs/phase2/auth-visual.spec.ts b/e2e/specs/phase2/auth-visual.spec.ts index dad281e42..8e4ebddf4 100644 --- a/e2e/specs/phase2/auth-visual.spec.ts +++ b/e2e/specs/phase2/auth-visual.spec.ts @@ -1,4 +1,6 @@ import { expect, test } from "@playwright/test"; +import { translateForE2E } from "../../fixtures/i18n"; +import { AUTH_PREVIEW_URL } from "../../fixtures/phase2-i18n"; test.describe("@phase2 auth visual acceptance", () => { test("P2AV-01 welcome page layout", async ({ page }) => { @@ -10,7 +12,7 @@ test.describe("@phase2 auth visual acceptance", () => { test("P2AV-02 welcome page buttons styling", async ({ page }) => { await page.goto("/"); - const openBtn = page.getByRole("button", { name: /Open|打开/ }); + const openBtn = page.getByRole("button", { name: translateForE2E("action.open_workspace") }); if (await openBtn.isVisible()) { const bgColor = await openBtn.evaluate((el) => getComputedStyle(el).backgroundColor); expect(bgColor).toBeTruthy(); @@ -32,16 +34,18 @@ test.describe("@phase2 auth visual acceptance", () => { }); test("P2AV-05 auth preview uses shared card and form primitives", async ({ page }) => { - await page.goto("file:///home/spencer/workspace/coder-studio/packages/web/auth-preview.html"); + await page.goto(AUTH_PREVIEW_URL); await expect(page.locator(".welcome-container.auth-screen")).toBeVisible(); await expect(page.locator(".welcome-card.auth-card-shell")).toBeVisible(); await expect(page.locator(".auth-form")).toBeVisible(); - await expect(page.locator(".input.auth-input")).toBeVisible(); - await expect(page.locator(".btn.btn-primary.auth-submit")).toBeVisible(); + await expect(page.getByPlaceholder(translateForE2E("settings.auth.password"))).toBeVisible(); + await expect( + page.getByRole("button", { name: translateForE2E("action.confirm") }) + ).toBeVisible(); const errorColor = await page - .locator(".auth-error") + .locator(".auth-status-panel-error") .evaluate((el) => getComputedStyle(el).color); expect(errorColor).toBeTruthy(); }); diff --git a/e2e/specs/phase2/auth.spec.ts b/e2e/specs/phase2/auth.spec.ts index 0cd2f7b70..11bfdcf3e 100644 --- a/e2e/specs/phase2/auth.spec.ts +++ b/e2e/specs/phase2/auth.spec.ts @@ -1,4 +1,6 @@ import { expect, test } from "@playwright/test"; +import { translateForE2E } from "../../fixtures/i18n"; +import { AUTH_PREVIEW_URL } from "../../fixtures/phase2-i18n"; // Auth tests require a password-protected server // These tests are skipped in normal dev mode and run with special config in CI @@ -20,10 +22,12 @@ test.describe("@phase2 auth acceptance", () => { }); test("P2-03 auth preview exposes the login form structure", async ({ page }) => { - await page.goto("file:///home/spencer/workspace/coder-studio/packages/web/auth-preview.html"); + await page.goto(AUTH_PREVIEW_URL); await expect(page.locator(".auth-form")).toBeVisible(); - await expect(page.locator(".input.auth-input")).toBeVisible(); - await expect(page.getByRole("button", { name: /确认|Confirm/ })).toBeVisible(); + await expect(page.getByPlaceholder(translateForE2E("settings.auth.password"))).toBeVisible(); + await expect( + page.getByRole("button", { name: translateForE2E("action.confirm") }) + ).toBeVisible(); }); test("P2-04 frontend reaches main app without auth", async ({ page }) => { diff --git a/e2e/specs/phase2/i18n.spec.ts b/e2e/specs/phase2/i18n.spec.ts index fa5421965..b7570194c 100644 --- a/e2e/specs/phase2/i18n.spec.ts +++ b/e2e/specs/phase2/i18n.spec.ts @@ -1,29 +1,36 @@ import { expect, test } from "@playwright/test"; +import { translateForE2E } from "../../fixtures/i18n"; +import { + openSettingsSection, + settingsGroupLabel, + settingsSectionLabel, +} from "../../fixtures/phase2-i18n"; test.describe("@phase2 i18n acceptance", () => { test("P2I-01 language switch to English", async ({ page }) => { await page.goto("/settings"); - // UI defaults to Chinese, click "外观" (Appearance) - await page.getByRole("button", { name: "外观" }).click(); + await openSettingsSection(page, "appearance"); - // Click English button - await page.getByRole("button", { name: /English/i }).click(); + await page.getByRole("button", { name: translateForE2E("settings.language.en") }).click(); - // Verify UI is in English (Appearance section title should be translated) - await expect(page.locator(".settings-section-title")).toContainText("Appearance"); + await expect(page.locator(".settings-group-title").first()).toHaveText( + settingsGroupLabel("theme", "en") + ); }); test("P2I-02 language persists after reload", async ({ page }) => { await page.goto("/settings"); - // UI defaults to Chinese, click "外观" (Appearance) - await page.getByRole("button", { name: "外观" }).click(); - await page.getByRole("button", { name: /English/i }).click(); + await openSettingsSection(page, "appearance"); + await page.getByRole("button", { name: translateForE2E("settings.language.en") }).click(); - // Reload page await page.reload(); - // Verify language persisted (page resets to General section after reload) - await expect(page.locator(".settings-section-title")).toContainText("General"); + await expect( + page.getByRole("button", { name: settingsSectionLabel("general", "en") }) + ).toBeVisible(); + await expect(page.locator(".settings-group-title").first()).toHaveText( + settingsGroupLabel("notifications", "en") + ); }); test("P2I-03 all UI text uses translation", async ({ page }) => { diff --git a/e2e/specs/phase2/provider.spec.ts b/e2e/specs/phase2/provider.spec.ts index fba8b3ee8..d876d35be 100644 --- a/e2e/specs/phase2/provider.spec.ts +++ b/e2e/specs/phase2/provider.spec.ts @@ -1,54 +1,58 @@ import { expect, test } from "@playwright/test"; +import { + configFilePattern, + openSettingsSection, + providerSettingPattern, +} from "../../fixtures/phase2-i18n"; test.describe("@phase2 provider acceptance", () => { test("desktop uses provider sub-navigation and preserves config view across providers", async ({ page, }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); await expect(page.getByRole("button", { name: "Claude" })).toBeVisible(); await expect(page.getByRole("button", { name: "Codex" })).toBeVisible(); - await expect(page.getByRole("button", { name: "基础配置" })).toHaveAttribute( - "aria-pressed", - "true" - ); - await expect(page.getByLabel("启动命令参数")).toBeVisible(); + await expect( + page.getByRole("button", { name: providerSettingPattern("base") }) + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); - await page.getByRole("button", { name: "配置文件" }).click(); - await expect(page.getByRole("button", { name: "配置文件" })).toHaveAttribute( - "aria-pressed", - "true" - ); - await expect(page.getByText("Claude 配置")).toBeVisible(); - await expect(page.getByLabel("启动命令参数")).not.toBeVisible(); + await page.getByRole("button", { name: providerSettingPattern("config_file") }).click(); + await expect( + page.getByRole("button", { name: providerSettingPattern("config_file") }) + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible(); await page.getByRole("button", { name: "Codex" }).click(); - await expect(page.getByRole("button", { name: "配置文件" })).toHaveAttribute( - "aria-pressed", - "true" - ); - await expect(page.getByText("Codex 配置")).toBeVisible(); - await expect(page.getByLabel("启动命令参数")).not.toBeVisible(); + await expect( + page.getByRole("button", { name: providerSettingPattern("config_file") }) + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText(configFilePattern("codex"))).toBeVisible(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toBeVisible(); - await page.getByRole("button", { name: "基础配置" }).click(); - await expect(page.getByLabel("启动命令参数")).toBeVisible(); + await page.getByRole("button", { name: providerSettingPattern("base") }).click(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); }); test("desktop updates startup args per provider and keeps command preview scoped", async ({ page, }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); - const argsInput = page.getByLabel("启动命令参数"); + const argsInput = page.getByLabel(providerSettingPattern("startup_args")); await expect(argsInput).toBeVisible(); await argsInput.fill("--verbose\n--print"); await expect(page.locator(".settings-command-preview")).toContainText("--print"); await page.getByRole("button", { name: "Codex" }).click(); - await expect(page.getByLabel("启动命令参数")).not.toHaveValue("--verbose\n--print"); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).not.toHaveValue( + "--verbose\n--print" + ); await expect(page.locator(".settings-command-preview")).not.toContainText("--print"); }); @@ -62,18 +66,24 @@ test.describe("@phase2 provider acceptance", () => { try { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); - await expect(page.getByLabel("启动命令参数")).toBeVisible(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); await expect(page.locator(".settings-provider-subnav")).toHaveCount(0); - await page.getByRole("button", { name: /打开配置文件编辑/ }).click(); - await expect(page.getByRole("button", { name: "返回基础配置" })).toBeVisible(); - await expect(page.getByText("Claude 配置")).toBeVisible(); + await page + .getByRole("button", { name: providerSettingPattern("open_config_file_editor") }) + .click(); + await expect( + page.getByRole("button", { name: providerSettingPattern("back_to_base") }) + ).toBeVisible(); + await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); await page.getByRole("button", { name: "Codex" }).click(); - await expect(page.getByLabel("启动命令参数")).toBeVisible(); - await expect(page.getByRole("button", { name: "返回基础配置" })).toHaveCount(0); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); + await expect( + page.getByRole("button", { name: providerSettingPattern("back_to_base") }) + ).toHaveCount(0); } finally { await context.close(); } diff --git a/e2e/specs/phase2/settings-visual.spec.ts b/e2e/specs/phase2/settings-visual.spec.ts index d942e566c..99654aeac 100644 --- a/e2e/specs/phase2/settings-visual.spec.ts +++ b/e2e/specs/phase2/settings-visual.spec.ts @@ -1,4 +1,9 @@ import { expect, test } from "@playwright/test"; +import { + openSettingsSection, + providerSettingPattern, + settingsGroupPattern, +} from "../../fixtures/phase2-i18n"; test.describe("@phase2 settings visual acceptance", () => { test("P2V-01 settings page layout baseline", async ({ page }) => { @@ -22,7 +27,7 @@ test.describe("@phase2 settings visual acceptance", () => { test("P2V-03 provider card styling", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); // Provider cards should have consistent styling const providerCard = page.locator(".settings-provider-content"); await expect(providerCard).toBeVisible(); @@ -33,23 +38,17 @@ test.describe("@phase2 settings visual acceptance", () => { test("P2V-04 appearance section layout", async ({ page }) => { await page.goto("/settings"); - const appearanceBtn = page.getByRole("button", { name: "外观" }); - if (await appearanceBtn.isVisible()) { - await appearanceBtn.click(); - // Appearance section should be visible - const appearanceSection = page.locator(".settings-appearance, .appearance-settings"); - const count = await appearanceSection.count(); - expect(count).toBeGreaterThanOrEqual(0); - } else { - expect(true).toBe(true); - } + await openSettingsSection(page, "appearance"); + await expect( + page.locator(".settings-group-title").filter({ hasText: settingsGroupPattern("theme") }) + ).toBeVisible(); }); test("P2V-05 input field focus states", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); // Find an input and focus it - const input = page.locator("input.input, select.input").first(); + const input = page.getByLabel(providerSettingPattern("startup_args")); if (await input.isVisible()) { await input.focus(); // Check focus border color @@ -62,9 +61,9 @@ test.describe("@phase2 settings visual acceptance", () => { test("P2V-06 button hover states", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); // Find a button and hover - const button = page.locator(".btn").first(); + const button = page.locator(".settings-provider-tab").first(); await button.hover(); // Button should respond to hover await expect(button).toBeVisible(); @@ -80,15 +79,8 @@ test.describe("@phase2 settings visual acceptance", () => { test("P2V-08 theme toggle visual feedback", async ({ page }) => { await page.goto("/settings"); - const appearanceBtn = page.getByRole("button", { name: "外观" }); - if (await appearanceBtn.isVisible()) { - await appearanceBtn.click(); - // Look for theme toggle buttons - const themeToggle = page.locator('.theme-toggle, [data-testid="theme-toggle"]'); - const count = await themeToggle.count(); - expect(count).toBeGreaterThanOrEqual(0); - } else { - expect(true).toBe(true); - } + await openSettingsSection(page, "appearance"); + await expect(page.getByRole("button", { name: /^(?:深色|Dark)$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:浅色|Light)$/ })).toBeVisible(); }); }); diff --git a/e2e/specs/phase2/settings.spec.ts b/e2e/specs/phase2/settings.spec.ts index cfb4df1c5..dc1523c23 100644 --- a/e2e/specs/phase2/settings.spec.ts +++ b/e2e/specs/phase2/settings.spec.ts @@ -1,72 +1,67 @@ import { expect, test } from "@playwright/test"; +import { translateForE2E } from "../../fixtures/i18n"; +import { + configFilePattern, + openSettingsSection, + providerSettingPattern, + settingsGroupPattern, +} from "../../fixtures/phase2-i18n"; test.describe("@phase2 settings acceptance", () => { test("P2S-01 settings page opens and renders provider configuration", async ({ page }) => { await page.goto("/settings"); await expect(page.locator(".settings-page")).toBeVisible(); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); await expect(page.locator(".settings-provider-content")).toBeVisible(); await expect(page.locator(".settings-command-preview")).toBeVisible(); }); test("P2S-02 provider model change triggers preview update", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); - // The command preview should be visible (verified in P2S-01) - // Select a different model - await page.locator("select.input").selectOption("claude-3-opus"); - // The model select should have the correct value - await expect(page.locator("select.input")).toHaveValue("claude-3-opus"); - // The preview element should still be visible - await expect(page.locator(".settings-command-preview")).toBeVisible(); + await openSettingsSection(page, "providers"); + const argsInput = page.getByLabel(providerSettingPattern("startup_args")); + await argsInput.fill("--verbose\n--print"); + await expect(page.locator(".settings-command-preview")).toContainText("--print"); }); test("P2S-03 inject hooks updates provider status UI", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); - const injectButton = page.locator(".settings-provider-content .btn.btn-primary"); - await injectButton.click(); - await expect(page.locator(".settings-provider-status")).toBeVisible(); + await openSettingsSection(page, "providers"); + await page.getByRole("button", { name: providerSettingPattern("config_file") }).click(); + await expect(page.getByText(configFilePattern("claude"))).toBeVisible(); }); test("P2S-04 codex provider shows cwd override field", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); await page.getByRole("button", { name: "Codex" }).click(); - await expect(page.getByText("Working Directory Override")).toBeVisible(); - await expect(page.locator(".settings-provider-content input.input").last()).toBeVisible(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); + await expect(page.locator(".settings-provider-content textarea.input")).toBeVisible(); }); test("P2S-05 appearance settings show theme options", async ({ page }) => { await page.goto("/settings"); - const appearanceBtn = page.getByRole("button", { name: "外观" }); - if (await appearanceBtn.isVisible()) { - await appearanceBtn.click(); - // Theme section should be visible - const themeSection = page.locator(".settings-group-title").filter({ hasText: "主题" }); - await expect(themeSection).toBeVisible(); - } else { - // Appearance might be under different structure - expect(true).toBe(true); - } + await openSettingsSection(page, "appearance"); + await expect( + page.locator(".settings-group-title").filter({ hasText: settingsGroupPattern("theme") }) + ).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:深色|Dark)$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^(?:浅色|Light)$/ })).toBeVisible(); }); test("P2S-06 settings persist after page reload", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); - // Select a model - const select = page.locator("select.input"); - await select.selectOption("claude-3-opus"); - // Reload + await openSettingsSection(page, "providers"); + await page.getByLabel(providerSettingPattern("startup_args")).fill("--persisted-e2e"); await page.reload(); - // Settings persistence depends on localStorage which may be async - // Just verify the settings page loads correctly + await openSettingsSection(page, "providers"); await expect(page.locator(".settings-page")).toBeVisible(); + await expect(page.getByLabel(providerSettingPattern("startup_args"))).toBeVisible(); }); test("P2S-07 hook status shows registration state", async ({ page }) => { await page.goto("/settings"); - await page.getByRole("button", { name: "Providers" }).click(); + await openSettingsSection(page, "providers"); // Check for hook status indicator const statusIndicator = page.locator(".settings-provider-status, .hook-status"); // Status might show "registered" or "not registered" @@ -76,18 +71,8 @@ test.describe("@phase2 settings acceptance", () => { test("P2S-08 keyboard shortcuts settings accessible", async ({ page }) => { await page.goto("/settings"); - const shortcutsBtn = page.getByRole("button", { name: /快捷键|Shortcuts|快捷/i }); - if (await shortcutsBtn.isVisible()) { - await shortcutsBtn.click(); - // Shortcuts content should be visible (check various possible selectors) - const shortcutsContent = page.locator( - ".shortcuts-settings, .settings-shortcuts, .shortcuts-content" - ); - const count = await shortcutsContent.count(); - expect(count).toBeGreaterThanOrEqual(0); - } else { - // Might be under different section - expect(true).toBe(true); - } + await openSettingsSection(page, "shortcuts"); + await expect(page.locator(".shortcuts-category-tabs")).toBeVisible(); + await expect(page.locator(".shortcuts-list")).toBeVisible(); }); }); diff --git a/package.json b/package.json index 6e885ed2a..b0019173d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "ci:test:scripts": "pnpm exec vitest run scripts/publish-cli.test.ts scripts/build-cli.test.ts scripts/validate-changesets.test.ts scripts/husky-pre-commit.test.ts --environment node", "ci:test:workspace": "pnpm -r --filter './packages/**' --if-present run test", "ci:test": "pnpm ci:test:scripts && pnpm ci:test:workspace", - "ci:typecheck": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit", + "ci:typecheck": "pnpm --filter @spencer-kit/coder-studio exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/utils exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/core exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/providers exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/server exec tsc -p tsconfig.json --noEmit && pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit", "ci:build": "pnpm build", "ci:verify": "pnpm changeset:validate && pnpm ci:lint && pnpm ci:test && pnpm ci:build", "ci:release:validate": "pnpm ci:verify && pnpm publish:cli -- --no-build", @@ -33,6 +33,7 @@ "@biomejs/biome": "^2.4.14", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", + "@coder-studio/utils": "workspace:*", "@types/node": "^25.6.0", "esbuild": "^0.28.0", "husky": "^9.1.7", diff --git a/packages/cli/README.md b/packages/cli/README.md index 78a400dbb..89efda4bc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,100 +1,35 @@ # @spencer-kit/coder-studio -Coder Studio CLI - Agent-First Development Environment +> Deploy once, code everywhere. -## 安装 +Coder Studio is a browser-based AI coding workspace that lets you keep using the same local project, Agent sessions, files, Git, and terminal across devices. -```bash -npm install -g @spencer-kit/coder-studio -``` +## What It Solves -## 命令 +Typical AI coding workflows stay tied to the machine where the CLI is running. If you leave that device, it becomes harder to keep watching progress, reviewing diffs, or continuing the same workspace from somewhere else. -### serve / server +Coder Studio turns that into a persistent workspace you can reopen from another computer, tablet, or phone without handing off the environment again. -启动 Coder Studio 服务器: +## Install ```bash -coder-studio serve [options] -coder-studio server +npm install -g @spencer-kit/coder-studio ``` -说明: -- `serve` 默认以后台托管模式启动服务 -- `server` 是 `serve` 的别名 -- 如果当前已有服务在运行,会先提示是否重启 -- `serve --restart` 会直接重启当前托管服务,不再询问 -- `serve --foreground` 会以前台模式启动服务 - -### open - -启动服务并直接打开浏览器: +## Quick Start ```bash coder-studio open ``` -说明: -- 如果服务未启动,会先启动再打开浏览器 -- 如果服务已启动,会提示是否重启 -- `open --restart` 会直接重启后再打开浏览器 -- 选择不重启时,会直接打开当前运行中的地址 -- 非交互场景下如果已有服务,不会自动重启,并会明确提示未重新启动 - -### status - -查看当前托管服务状态: - -```bash -coder-studio status -``` - -输出包含: -- 当前状态 -- 监听 host / IP / port -- 完整监听 URL -- 本地访问 URL -- PID、启动时间、重启次数、日志路径 - -### version - -显示版本号: - -```bash -coder-studio version -``` - -### help - -显示帮助信息: - -```bash -coder-studio help -``` - -## 配置 - -### 认证 - -默认情况下,服务不需要认证。启用认证: - -```bash -coder-studio config --password mypassword -``` - -### 数据目录 - -指定数据存储位置: - -```bash -coder-studio config --data-dir /path/to/data -``` +Then open a workspace in the browser and start a Claude or Codex session. -## Provider 支持 +## More Information -- **Claude** (Full mode) - 需要安装 Claude Code CLI -- **Codex** (Limited mode) - 需要安装 Codex CLI +- Full project README: https://github.com/spencerkit/coder-studio/blob/main/README.md +- Chinese README: https://github.com/spencerkit/coder-studio/blob/main/README.zh-CN.md +- Documentation: https://github.com/spencerkit/coder-studio/tree/main/docs/help -## 许可证 +## License MIT diff --git a/packages/cli/package.json b/packages/cli/package.json index 59de94493..65c403455 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@spencer-kit/coder-studio", "version": "0.3.1", "type": "module", - "description": "Coder Studio CLI - The only published package", + "description": "Deploy once, code everywhere. Browser-based AI coding workspace for Claude Code and Codex.", "main": "./src/index.ts", "bin": { "coder-studio": "./src/bin.ts" diff --git a/packages/cli/src/browser.test.ts b/packages/cli/src/browser.test.ts new file mode 100644 index 000000000..f4cc7b1f2 --- /dev/null +++ b/packages/cli/src/browser.test.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { openBrowser } from "./browser.js"; + +const originalPlatform = process.platform; + +describe("openBrowser windows child-process options", () => { + afterEach(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + vi.clearAllMocks(); + }); + + it("uses the Windows open command and passes windowsHide to spawn", async () => { + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + + spawnMock.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { unref: ReturnType }; + child.unref = vi.fn(); + + queueMicrotask(() => { + child.emit("spawn"); + }); + + return child; + }); + + await expect(openBrowser("https://example.com")).resolves.toBeUndefined(); + + expect(spawnMock).toHaveBeenCalledWith( + "cmd", + ["/c", "start", "", "https://example.com"], + expect.objectContaining({ windowsHide: true }) + ); + }); +}); diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts index db027f965..f1bc2be85 100644 --- a/packages/cli/src/browser.ts +++ b/packages/cli/src/browser.ts @@ -18,6 +18,7 @@ export async function openBrowser(url: string): Promise { const child = spawn(command, args, { detached: true, stdio: "ignore", + windowsHide: true, }); child.once("error", reject); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 649374b8e..7996e2a7a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "fs"; +import { existsSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { clearAuthBlockByIp, listAuthBlocks } from "./auth-control.js"; @@ -6,6 +6,7 @@ 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 { getCliVersion } from "./package-manifest.js"; import { parseArgs } from "./parse-args.js"; import { startManagedServer } from "./pm2-control.js"; import { confirmYesNo, isInteractiveSession } from "./prompts.js"; @@ -133,16 +134,7 @@ EXAMPLES: } 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}`); + console.log(`@spencer-kit/coder-studio v${getCliVersion(import.meta.url)}`); } function formatAuthBlocks(blocks: Awaited>): string { diff --git a/packages/cli/src/package-manifest.test.ts b/packages/cli/src/package-manifest.test.ts index 32747cebb..b4951b3d8 100644 --- a/packages/cli/src/package-manifest.test.ts +++ b/packages/cli/src/package-manifest.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; +import { getCliVersion, resolveCliPackageManifestUrl } from "./package-manifest.js"; interface PackageManifest { dependencies?: Record; @@ -12,6 +14,18 @@ function readPackageManifest(relativePath: string): PackageManifest { } describe("cli package manifest", () => { + it("resolves the CLI package manifest instead of the workspace root manifest", () => { + expect(fileURLToPath(resolveCliPackageManifestUrl(import.meta.url))).toBe( + fileURLToPath(new URL("../package.json", import.meta.url)) + ); + }); + + it("reads the published CLI version from the CLI package manifest", () => { + const cliPackage = readPackageManifest("../package.json") as { version?: string }; + + expect(getCliVersion(import.meta.url)).toBe(cliPackage.version); + }); + it("declares every external server runtime dependency", () => { const cliPackage = readPackageManifest("../package.json"); const serverPackage = readPackageManifest("../../server/package.json"); diff --git a/packages/cli/src/package-manifest.ts b/packages/cli/src/package-manifest.ts new file mode 100644 index 000000000..da07989fc --- /dev/null +++ b/packages/cli/src/package-manifest.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from "fs"; + +interface CliPackageManifest { + version?: string; +} + +export function resolveCliPackageManifestUrl(importMetaUrl: string): URL { + const manifestUrl = [ + new URL("../package.json", importMetaUrl), + new URL("../../package.json", importMetaUrl), + ].find((candidate) => existsSync(candidate)); + + if (!manifestUrl) { + throw new Error("Unable to locate CLI package.json"); + } + + return manifestUrl; +} + +export function getCliPackageManifest(importMetaUrl: string): CliPackageManifest { + return JSON.parse( + readFileSync(resolveCliPackageManifestUrl(importMetaUrl), "utf-8") + ) as CliPackageManifest; +} + +export function getCliVersion(importMetaUrl: string): string { + return getCliPackageManifest(importMetaUrl).version ?? "0.0.0"; +} diff --git a/packages/cli/src/pm2-control.test.ts b/packages/cli/src/pm2-control.test.ts index 4d85f85e2..d6ddf8bd4 100644 --- a/packages/cli/src/pm2-control.test.ts +++ b/packages/cli/src/pm2-control.test.ts @@ -33,15 +33,22 @@ import { describe("pm2-control", () => { const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalRuntimeDir = process.env.CODER_STUDIO_RUNTIME_DIR; + const originalRuntimeJsonPath = process.env.CODER_STUDIO_RUNTIME_JSON_PATH; let testHomeDir: string; beforeEach(() => { testHomeDir = mkdtempSync(join(tmpdir(), "cs-pm2-control-home-")); process.env.HOME = testHomeDir; process.env.USERPROFILE = testHomeDir; + process.env.CODER_STUDIO_RUNTIME_DIR = join(testHomeDir, ".coder-studio"); + delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH; connect.mockImplementation((callback: (error: Error | null) => void) => callback(null)); - disconnect.mockImplementation(() => undefined); + disconnect.mockImplementation((callback?: (error: Error | null) => void) => { + callback?.(null); + return undefined; + }); start.mockImplementation( (_config: unknown, callback: (error: Error | null, apps: unknown[]) => void) => { writeRuntimeConfig({ @@ -80,6 +87,18 @@ describe("pm2-control", () => { process.env.USERPROFILE = originalUserProfile; } + if (originalRuntimeDir === undefined) { + delete process.env.CODER_STUDIO_RUNTIME_DIR; + } else { + process.env.CODER_STUDIO_RUNTIME_DIR = originalRuntimeDir; + } + + if (originalRuntimeJsonPath === undefined) { + delete process.env.CODER_STUDIO_RUNTIME_JSON_PATH; + } else { + process.env.CODER_STUDIO_RUNTIME_JSON_PATH = originalRuntimeJsonPath; + } + if (existsSync(testHomeDir)) { rmSync(testHomeDir, { recursive: true, force: true }); } @@ -157,6 +176,67 @@ describe("pm2-control", () => { ).resolves.toBe("waiting"); expect(start).not.toHaveBeenCalled(); + await pendingStart; + }); + + it("reuses one pm2 session while polling deletion during startup", async () => { + describeProcess + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }]) + ) + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }]) + ) + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, []) + ); + + await startManagedServer({ + script: "/cli/dist/esm/server-runner.js", + cwd: "/repo", + waitMs: 10, + }); + + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it("keeps waiting during startup when delete reports missing but the old app still lingers", async () => { + describeProcess + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, [{ pid: 111, pm2_env: { status: "online", restart_time: 0 } }]) + ) + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, [{ pid: 111, pm2_env: { status: "stopping", restart_time: 0 } }]) + ) + .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, []) + ); + deleteProcess.mockImplementationOnce((_name: string, callback: (error: Error | null) => void) => + callback(new Error("process or namespace not found")) + ); + + const pendingStart = startManagedServer({ + script: "/cli/dist/esm/server-runner.js", + cwd: "/repo", + waitMs: 10, + }); + + await expect( + Promise.race([ + pendingStart.then(() => "started"), + new Promise((resolve) => setTimeout(() => resolve("waiting"), 20)), + ]) + ).resolves.toBe("waiting"); + + expect(start).not.toHaveBeenCalled(); + await pendingStart; }); it("ignores delete-time missing errors when requested", async () => { @@ -169,6 +249,8 @@ describe("pm2-control", () => { ); await expect(deleteManagedServer({ ignoreMissing: true })).resolves.toBe(false); + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(1); }); it("fails background startup when runtime readiness times out", async () => { @@ -183,6 +265,10 @@ describe("pm2-control", () => { callback(null, []) ) .mockImplementationOnce( + (_name: string, callback: (error: Error | null, result: unknown[]) => void) => + callback(null, []) + ) + .mockImplementation( (_name: string, callback: (error: Error | null, result: unknown[]) => void) => callback(null, [{ pid: 424242, pm2_env: { status: "online", restart_time: 0 } }]) ); @@ -243,6 +329,8 @@ describe("pm2-control", () => { pm2Pid: null, restartCount: 0, }); + expect(connect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledTimes(1); }); it("maps an online PM2 app to running status", async () => { diff --git a/packages/cli/src/pm2-control.ts b/packages/cli/src/pm2-control.ts index 56f60b7c0..b91352e4c 100644 --- a/packages/cli/src/pm2-control.ts +++ b/packages/cli/src/pm2-control.ts @@ -8,6 +8,8 @@ export const MANAGED_SERVER_NAME = "coder-studio-server"; const PM2_RESTART_DELAY_MS = 2000; const PM2_MIN_UPTIME = "5s"; const PM2_MAX_RESTARTS = 10; +const PM2_DELETE_WAIT_MS = 5000; +const PM2_DISCONNECT_WAIT_MS = 1000; const STARTUP_POLL_INTERVAL_MS = 100; const STARTUP_FAILURE_GUIDANCE = "Run `coder-studio logs` for details or `coder-studio serve --foreground` for interactive debugging."; @@ -61,7 +63,7 @@ const isPm2BrokenStateError = (error: unknown): boolean => { type Pm2Module = { connect: (cb: (err: Error | null) => void) => void; - disconnect: () => void; + disconnect: (cb?: (err: Error | null, data?: unknown) => void) => void; describe: (name: string, cb: (err: Error | null, result: unknown[]) => void) => void; delete: (name: string, cb: (err: Error | null) => void) => void; start: (opts: unknown, cb: (err: Error | null) => void) => void; @@ -110,36 +112,51 @@ const sleep = async (ms: number): Promise => const disconnectPm2 = async (): Promise => { const pm2 = await loadPm2(); - pm2.disconnect(); + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + const timer = setTimeout(finish, PM2_DISCONNECT_WAIT_MS); + try { + pm2.disconnect(() => { + clearTimeout(timer); + finish(); + }); + } catch { + clearTimeout(timer); + finish(); + } + }); }; -const describeManagedServer = async (): Promise => { - const pm2 = await loadPm2(); - return new Promise((resolve, reject) => { +const describeManagedServer = async (pm2: Pm2Module): Promise => + new Promise((resolve, reject) => { pm2.describe(MANAGED_SERVER_NAME, (error, result) => { if (error) { reject(error); return; } - resolve((result ?? []) as Pm2ProcessDescription[]); }); }); -}; -const removeManagedServer = async (): Promise => { - const pm2 = await loadPm2(); - return new Promise((resolve, reject) => { +const removeManagedServer = async (pm2: Pm2Module): Promise => + new Promise((resolve, reject) => { pm2.delete(MANAGED_SERVER_NAME, (error) => { if (error) { reject(error); return; } - resolve(); }); }); -}; /** * Kill the PM2 daemon to clear stale paths/caches. @@ -158,9 +175,10 @@ const killPm2Daemon = async (): Promise => { * Try to connect to PM2, and if it's in a broken state (stale worktree path), * kill the daemon and reconnect fresh. */ -const connectWithRecovery = async (): Promise => { +const connectWithRecovery = async (): Promise => { try { await connectPm2(); + return loadPm2(); } catch (error) { if (isPm2BrokenStateError(error)) { console.warn("PM2 daemon is in a stale state. Killing and reconnecting..."); @@ -173,23 +191,25 @@ const connectWithRecovery = async (): Promise => { // Clear cached module so next loadPm2 gets a fresh instance cachedPm2 = null; await connectPm2(); + return loadPm2(); } else { throw error; } } }; -const withPm2Connection = async (operation: () => Promise): Promise => { - await connectWithRecovery(); +const withPm2Connection = async (operation: (pm2: Pm2Module) => Promise): Promise => { + const pm2 = await connectWithRecovery(); try { - return await operation(); + return await operation(pm2); } finally { await disconnectPm2(); } }; const waitForRuntimeReady = async ( + pm2: Pm2Module, waitMs: number, logOffsets: StartupLogOffsets ): Promise => { @@ -200,7 +220,7 @@ const waitForRuntimeReady = async ( return; } - const processes = await describeManagedServer(); + const processes = await describeManagedServer(pm2); const process = processes[0]; if (!process) { throw createStartupError( @@ -225,15 +245,52 @@ const waitForRuntimeReady = async ( throw createStartupError(`runtime readiness timed out after ${waitMs}ms`, logOffsets); }; -const waitForManagedServerExit = async (): Promise => { - while (true) { - const processes = await describeManagedServer(); +const waitForManagedServerDeletion = async (pm2: Pm2Module, waitMs: number): Promise => { + const deadline = Date.now() + waitMs; + + while (Date.now() <= deadline) { + const processes = await describeManagedServer(pm2); if (processes.length === 0) { return; } - await sleep(STARTUP_POLL_INTERVAL_MS); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await sleep(Math.min(STARTUP_POLL_INTERVAL_MS, remainingMs)); } + + throw new Error(`Timed out waiting for the managed server to stop after ${waitMs}ms.`); +}; + +const deleteManagedServerInSession = async ( + pm2: Pm2Module, + { + ignoreMissing = false, + }: { + ignoreMissing?: boolean; + } = {} +): Promise => { + const processes = await describeManagedServer(pm2); + if (processes.length === 0) { + return false; + } + + try { + await removeManagedServer(pm2); + } catch (error) { + if (ignoreMissing && isMissingManagedServerError(error)) { + await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS); + return false; + } + + throw error; + } + + await waitForManagedServerDeletion(pm2, PM2_DELETE_WAIT_MS); + return true; }; const ensureLogDirectory = (): void => { @@ -287,47 +344,25 @@ export const deleteManagedServer = async ({ }: { ignoreMissing?: boolean; } = {}): Promise => - withPm2Connection(async () => { - const processes = await describeManagedServer(); - if (processes.length === 0) { - return false; - } - - try { - await removeManagedServer(); - return true; - } catch (error) { - if (ignoreMissing && isMissingManagedServerError(error)) { - return false; - } - - throw error; - } - }); + withPm2Connection((pm2) => deleteManagedServerInSession(pm2, { ignoreMissing })); export const startManagedServer = async ({ script, cwd, waitMs, args, -}: StartManagedServerOptions): Promise => { - // First try to delete any existing managed server - await deleteManagedServer({ ignoreMissing: true }); +}: StartManagedServerOptions): Promise => + withPm2Connection(async (pm2) => { + await deleteManagedServerInSession(pm2, { ignoreMissing: true }); - // Wait for the old process to actually exit - await withPm2Connection(waitForManagedServerExit); - - // Clear stale runtime config - if (readRuntimeConfig()) { - deleteRuntimeConfig(); - } - - ensureLogDirectory(); - const { outFile, errFile } = getLogPaths(); - const pm2 = await loadPm2(); + if (readRuntimeConfig()) { + deleteRuntimeConfig(); + } - await withPm2Connection(async () => { + ensureLogDirectory(); + const { outFile, errFile } = getLogPaths(); const logOffsets = captureStartupLogOffsets(); + await new Promise((resolve, reject) => { pm2.start( { @@ -357,13 +392,12 @@ export const startManagedServer = async ({ ); }); - await waitForRuntimeReady(waitMs, logOffsets); + await waitForRuntimeReady(pm2, waitMs, logOffsets); }); -}; export const getManagedServerStatus = async (): Promise => - withPm2Connection(async () => { - const processes = await describeManagedServer(); + withPm2Connection(async (pm2) => { + const processes = await describeManagedServer(pm2); const process = processes[0]; if (!process) { @@ -402,6 +436,14 @@ export const getManagedServerStatus = async (): Promise => }; } + if (pm2Pid === null || pm2Pid === 0) { + return { + status: "stopped", + pm2Pid: null, + restartCount, + }; + } + return { status: "errored", pm2Pid, diff --git a/packages/cli/src/server-control.test.ts b/packages/cli/src/server-control.test.ts index 4e3eed205..efc2926e7 100644 --- a/packages/cli/src/server-control.test.ts +++ b/packages/cli/src/server-control.test.ts @@ -149,6 +149,25 @@ describe("server-control", () => { }); }); + it("reports stopped when pm2 is no longer running and runtime is absent", async () => { + getManagedServerStatus.mockResolvedValue({ + status: "starting", + pm2Pid: null, + restartCount: 0, + }); + + await expect(getServerStatus()).resolves.toEqual({ + status: "stopped", + pid: null, + host: null, + port: null, + restartCount: 0, + outFile: "/tmp/server.out.log", + errFile: "/tmp/server.err.log", + startedAt: null, + }); + }); + it("cleans stale runtime when pm2 reports stopped", async () => { writeRuntimeConfig({ host: "127.0.0.1", diff --git a/packages/cli/src/server-control.ts b/packages/cli/src/server-control.ts index 51c4384e5..ef6a9f836 100644 --- a/packages/cli/src/server-control.ts +++ b/packages/cli/src/server-control.ts @@ -31,7 +31,7 @@ export async function getServerStatus(): Promise { const runtime = readRuntimeConfig(); const { outFile, errFile } = getLogPaths(); - if (managedStatus.status === "stopped") { + if (managedStatus.status === "stopped" || (managedStatus.pm2Pid === null && runtime === null)) { if (runtime) { deleteRuntimeConfig(); } diff --git a/packages/cli/src/server-runner.test.ts b/packages/cli/src/server-runner.test.ts index 3763236e6..9407998ae 100644 --- a/packages/cli/src/server-runner.test.ts +++ b/packages/cli/src/server-runner.test.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from "url"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { getCliVersion } from "./package-manifest.js"; const { createServer, readCliConfig, hasWebAssets, getStaticAssetsDir } = vi.hoisted(() => ({ createServer: vi.fn(), @@ -29,6 +30,17 @@ describe("server-runner", () => { vi.clearAllMocks(); }); + it("includes the CLI package version in the server config", () => { + readCliConfig.mockReturnValue(null); + hasWebAssets.mockReturnValue(true); + getStaticAssetsDir.mockReturnValue("/tmp/web"); + + expect(buildServerConfig()).toMatchObject({ + appVersion: getCliVersion(import.meta.url), + webRoot: "/tmp/web", + }); + }); + it("ignores ephemeral port zero from saved cli config", () => { readCliConfig.mockReturnValue({ host: "127.0.0.1", @@ -40,6 +52,7 @@ describe("server-runner", () => { getStaticAssetsDir.mockReturnValue("/tmp/web"); expect(buildServerConfig()).toEqual({ + appVersion: getCliVersion(import.meta.url), host: "127.0.0.1", dataDir: "/tmp/cs-data/coder-studio.db", auth: { @@ -67,6 +80,7 @@ describe("server-runner", () => { const runningServer = await startServer(); expect(createServer).toHaveBeenCalledWith({ + appVersion: getCliVersion(import.meta.url), host: "127.0.0.1", port: 4173, webRoot: "/tmp/web", diff --git a/packages/cli/src/server-runner.ts b/packages/cli/src/server-runner.ts index c963f1382..30b23d3bf 100644 --- a/packages/cli/src/server-runner.ts +++ b/packages/cli/src/server-runner.ts @@ -3,12 +3,14 @@ import { fileURLToPath } from "url"; import { readCliConfig } from "./config-store.js"; import { getStaticAssetsDir, hasWebAssets } from "./embed.js"; import { assertSupportedNodeVersion } from "./node-version.js"; +import { getCliVersion } from "./package-manifest.js"; const MISSING_WEB_ASSETS_WARNING = "Warning: Web assets not found. Frontend will not be available."; export const buildServerConfig = (): Partial => { const savedConfig = readCliConfig(); const config: Partial = { + appVersion: getCliVersion(import.meta.url), ...(savedConfig?.host !== undefined ? { host: savedConfig.host } : {}), ...(savedConfig?.port !== undefined && savedConfig.port > 0 ? { port: savedConfig.port } : {}), ...(savedConfig?.dataDir !== undefined ? { dataDir: savedConfig.dataDir } : {}), diff --git a/packages/server/package.json b/packages/server/package.json index f69cc8449..6691674b6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,6 +31,7 @@ "dependencies": { "@coder-studio/core": "workspace:*", "@coder-studio/providers": "workspace:*", + "@coder-studio/utils": "workspace:*", "@fastify/compress": "^8.3.1", "@fastify/cors": "^11.2.0", "@fastify/multipart": "^10.0.0", diff --git a/packages/server/src/__tests__/provider-runtime/command-check.test.ts b/packages/server/src/__tests__/provider-runtime/command-check.test.ts index 050360670..9afdb5c6d 100644 --- a/packages/server/src/__tests__/provider-runtime/command-check.test.ts +++ b/packages/server/src/__tests__/provider-runtime/command-check.test.ts @@ -17,22 +17,44 @@ describe("getCommandLookupExecutable", () => { describe("checkCommandAvailable", () => { it("returns true when the lookup command succeeds", async () => { - const execFile = vi.fn(async () => ({ stdout: "/usr/bin/codex\n", stderr: "" })); - - await expect(checkCommandAvailable("codex", { platform: "linux", execFile })).resolves.toBe( - true + const execFile = vi.fn( + async (_file: string, _args: string[], _options?: { windowsHide: boolean }) => ({ + stdout: "/usr/bin/codex\n", + stderr: "", + }) ); - expect(execFile).toHaveBeenCalledWith("which", ["codex"]); + + await expect( + checkCommandAvailable("codex", { platform: "linux", runCommand: execFile }) + ).resolves.toBe(true); + expect(execFile).toHaveBeenCalledWith("which", ["codex"], { windowsHide: true }); }); it("returns false when the lookup command fails", async () => { - const execFile = vi.fn(async () => { - throw new Error("not found"); - }); + const execFile = vi.fn( + async (_file: string, _args: string[], _options?: { windowsHide: boolean }) => { + throw new Error("not found"); + } + ); - await expect(checkCommandAvailable("claude", { platform: "win32", execFile })).resolves.toBe( - false + await expect( + checkCommandAvailable("claude", { platform: "win32", runCommand: execFile }) + ).resolves.toBe(false); + expect(execFile).toHaveBeenCalledWith("where", ["claude"], { windowsHide: true }); + }); + + it("passes windowsHide to Windows lookups", async () => { + const execFile = vi.fn( + async (_file: string, _args: string[], _options?: { windowsHide: boolean }) => ({ + stdout: "C:\\Users\\test\\AppData\\Local\\Programs\\Codex\\codex.exe\r\n", + stderr: "", + }) ); - expect(execFile).toHaveBeenCalledWith("where", ["claude"]); + + await expect( + checkCommandAvailable("codex", { platform: "win32", runCommand: execFile }) + ).resolves.toBe(true); + + expect(execFile).toHaveBeenCalledWith("where", ["codex"], { windowsHide: true }); }); }); diff --git a/packages/server/src/__tests__/provider-runtime/command-runner.test.ts b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts new file mode 100644 index 000000000..1f234a443 --- /dev/null +++ b/packages/server/src/__tests__/provider-runtime/command-runner.test.ts @@ -0,0 +1,110 @@ +import { Buffer } from "node:buffer"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: spawnMock, + }; +}); + +import { runCommandAsString } from "../../provider-runtime/command-runner.js"; + +function createChildProcessMock() { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = stdout; + child.stderr = stderr; + return child; +} + +describe("runCommandAsString", () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + }); + + it("normalizes stdout and stderr to strings", async () => { + spawnMock.mockImplementation( + (_file: string, _args: string[], _options: { windowsHide?: boolean }) => { + const child = createChildProcessMock(); + queueMicrotask(() => { + child.stdout.emit("data", Buffer.from("ok\n")); + child.stderr.emit("data", Buffer.from("warn\n")); + child.emit("close", 0); + }); + return child; + } + ); + + const result = await runCommandAsString("demo", ["--version"], { windowsHide: true }); + + expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n" }); + expect(spawnMock).toHaveBeenCalledWith("demo", ["--version"], { + shell: false, + windowsHide: true, + }); + }); + + it("defaults windowsHide to true when caller omits the option", async () => { + spawnMock.mockImplementation(() => { + const child = createChildProcessMock(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await runCommandAsString("demo", []); + + expect(spawnMock).toHaveBeenCalledWith("demo", [], { + shell: false, + windowsHide: true, + }); + }); + + it("routes Windows .cmd shims through a shell so Node does not reject them", async () => { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + spawnMock.mockImplementation(() => { + const child = createChildProcessMock(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await runCommandAsString("npm", ["install", "-g", "@openai/codex"]); + + expect(spawnMock).toHaveBeenCalledWith( + "npm", + ["install", "-g", "@openai/codex"], + expect.objectContaining({ shell: true, windowsHide: true }) + ); + }); + + it("keeps native Windows executables off the shell path", async () => { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + spawnMock.mockImplementation(() => { + const child = createChildProcessMock(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + + await runCommandAsString("git", ["--version"]); + + expect(spawnMock).toHaveBeenCalledWith( + "git", + ["--version"], + expect.objectContaining({ shell: false, windowsHide: true }) + ); + }); +}); diff --git a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts index c9ca7c1d4..55c9a2c66 100644 --- a/packages/server/src/__tests__/provider-runtime/install-manager.test.ts +++ b/packages/server/src/__tests__/provider-runtime/install-manager.test.ts @@ -8,7 +8,7 @@ describe("ProviderInstallManager", () => { const manager = new ProviderInstallManager([codexDefinition], { platform: "win32", commandExists, - execFile: vi.fn(async () => ({ stdout: "", stderr: "" })), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), }); const job = await manager.start("codex"); @@ -26,7 +26,7 @@ describe("ProviderInstallManager", () => { const manager = new ProviderInstallManager([codexDefinition], { platform: "linux", commandExists, - execFile: vi.fn(async () => ({ stdout: "", stderr: "" })), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), }); const job = await manager.start("codex"); @@ -45,7 +45,7 @@ describe("ProviderInstallManager", () => { const manager = new ProviderInstallManager([codexDefinition], { platform: "aix", commandExists: vi.fn(async (command: string) => command === "npm"), - execFile: vi.fn(async () => ({ stdout: "", stderr: "" })), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), }); const job = await manager.start("codex"); @@ -61,10 +61,17 @@ describe("ProviderInstallManager", () => { it("reuses the active job when the same provider is clicked twice", async () => { const pending = new Promise<{ stdout: string; stderr: string }>(() => {}); + const execFile = vi.fn((file: string) => { + if (file === "which") { + return Promise.resolve({ stdout: "", stderr: "" }); + } + + return pending; + }); const manager = new ProviderInstallManager([codexDefinition], { platform: "darwin", commandExists: vi.fn(async (command: string) => command === "npm"), - execFile: vi.fn(() => pending), + runCommand: execFile, }); const first = await manager.start("codex"); @@ -89,7 +96,7 @@ describe("ProviderInstallManager", () => { const manager = new ProviderInstallManager([codexDefinition], { platform: "linux", commandExists, - execFile: vi.fn(async () => ({ stdout: "", stderr: "" })), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "" })), }); const firstPromise = manager.start("codex"); @@ -112,7 +119,7 @@ describe("ProviderInstallManager", () => { const manager = new ProviderInstallManager([codexDefinition], { platform: "linux", commandExists: vi.fn(async (command: string) => command === "npm"), - execFile: vi.fn(async () => { + runCommand: vi.fn(async () => { throw installError; }), }); @@ -139,4 +146,55 @@ describe("ProviderInstallManager", () => { }); expect(stored?.steps[0]?.status).toBe("failed"); }); + + it("executes Windows install steps with the declared command names", async () => { + let npmInstalled = false; + let codexInstalled = false; + const commandExists = vi.fn(async (command: string) => { + if (command === "winget") { + return true; + } + if (command === "npm") { + return npmInstalled; + } + if (command === "codex") { + return codexInstalled; + } + return false; + }); + const execFile = vi.fn( + async (file: string, args: string[], _options?: { windowsHide: boolean }) => { + if ( + file === "winget" && + args.join(" ") === "install --id OpenJS.NodeJS.LTS --exact --silent" + ) { + npmInstalled = true; + return { stdout: "installed node", stderr: "" }; + } + + if (file === "npm" && args.join(" ") === "install -g @openai/codex") { + expect(npmInstalled).toBe(true); + codexInstalled = true; + return { stdout: "installed codex", stderr: "" }; + } + + throw new Error(`unexpected execFile call: ${file} ${args.join(" ")}`); + } + ); + const manager = new ProviderInstallManager([codexDefinition], { + platform: "win32", + commandExists, + runCommand: execFile, + }); + + const job = await manager.start("codex"); + + await vi.waitFor(() => { + expect(manager.get(job.jobId)?.status).toBe("succeeded"); + }); + + expect(execFile).toHaveBeenCalledWith("npm", ["install", "-g", "@openai/codex"], { + windowsHide: true, + }); + }); }); diff --git a/packages/server/src/__tests__/server-provider-install-wiring.test.ts b/packages/server/src/__tests__/server-provider-install-wiring.test.ts new file mode 100644 index 000000000..589da3e13 --- /dev/null +++ b/packages/server/src/__tests__/server-provider-install-wiring.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: spawnMock, + }; +}); + +import { createServer, type Server } from "../server.js"; + +describe("createServer provider install wiring", () => { + let server: Server | undefined; + let dataDir: string; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + afterEach(async () => { + if (server) { + await server.stop(); + server = undefined; + } + + if (dataDir) { + rmSync(dataDir, { recursive: true, force: true }); + } + + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + + vi.clearAllMocks(); + }); + + it("forwards direct command-name execution from the assembled provider install manager", async () => { + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + let npmInstalled = false; + let codexInstalled = false; + spawnMock.mockImplementation( + (file: string, args: string[], options: { windowsHide?: boolean }) => { + const stdoutHandlers: Array<(chunk: string) => void> = []; + const stderrHandlers: Array<(chunk: string) => void> = []; + const closeHandlers: Array<(code: number) => void> = []; + const errorHandlers: Array<(error: Error) => void> = []; + const child = { + stdout: { + on: vi.fn((event: string, handler: (chunk: string) => void) => { + if (event === "data") stdoutHandlers.push(handler); + }), + }, + stderr: { + on: vi.fn((event: string, handler: (chunk: string) => void) => { + if (event === "data") stderrHandlers.push(handler); + }), + }, + on: vi.fn((event: string, handler: ((arg: number) => void) | ((arg: Error) => void)) => { + if (event === "close") closeHandlers.push(handler as (code: number) => void); + if (event === "error") errorHandlers.push(handler as (error: Error) => void); + }), + }; + + queueMicrotask(() => { + if (!options?.windowsHide) { + for (const handler of errorHandlers) { + handler(new Error(`windowsHide missing for ${file}`)); + } + return; + } + + const writeStdout = (value: string) => { + for (const handler of stdoutHandlers) handler(value); + }; + const closeOk = () => { + for (const handler of closeHandlers) handler(0); + }; + const fail = (message: string) => { + for (const handler of errorHandlers) handler(new Error(message)); + }; + + if (file === "where" && args[0] === "winget") { + writeStdout("C:\\Users\\test\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\r\n"); + closeOk(); + return; + } + + if ( + file === "winget" && + args.join(" ") === "install --id OpenJS.NodeJS.LTS --exact --silent" + ) { + npmInstalled = true; + writeStdout("installed node"); + closeOk(); + return; + } + + if (file === "npm" && args.join(" ") === "install -g @openai/codex") { + if (!npmInstalled) { + fail("npm unavailable"); + return; + } + codexInstalled = true; + writeStdout("installed"); + closeOk(); + return; + } + + if (file === "where" && args[0] === "codex") { + if (!codexInstalled) { + fail("codex unavailable"); + return; + } + writeStdout("C:\\codex\\codex.cmd\r\n"); + closeOk(); + return; + } + + fail(`unexpected spawn call: ${file} ${args.join(" ")}`); + }); + + return child; + } + ); + + dataDir = mkdtempSync(join(tmpdir(), "coder-studio-server-provider-install-")); + server = await createServer({ + dataDir: join(dataDir, "server.db"), + host: "127.0.0.1", + port: 0, + }); + + const providerInstallMgr = server.__test__?.commandContext.providerInstallMgr; + expect(providerInstallMgr).toBeDefined(); + + const job = await providerInstallMgr!.start("codex"); + + await vi.waitFor(() => { + expect(providerInstallMgr!.get(job.jobId)?.status).toBe("succeeded"); + }); + + expect(spawnMock).toHaveBeenCalledWith( + "npm", + ["install", "-g", "@openai/codex"], + expect.objectContaining({ shell: true, windowsHide: true }) + ); + expect(spawnMock).toHaveBeenCalledWith( + "winget", + ["install", "--id", "OpenJS.NodeJS.LTS", "--exact", "--silent"], + expect.objectContaining({ shell: false, windowsHide: true }) + ); + }); +}); diff --git a/packages/server/src/__tests__/session-integration.test.ts b/packages/server/src/__tests__/session-integration.test.ts index 8902af9c5..a3ae8083b 100644 --- a/packages/server/src/__tests__/session-integration.test.ts +++ b/packages/server/src/__tests__/session-integration.test.ts @@ -230,7 +230,7 @@ describe("Session Integration", () => { ctx.providerInstallMgr = new ProviderInstallManager(providerRegistry, { platform: "win32", commandExists: async (command: string) => command === "winget", - execFile: async () => ({ stdout: "", stderr: "" }), + runCommand: async () => ({ stdout: "", stderr: "" }), }); const status = await dispatch( @@ -454,6 +454,68 @@ describe("Session Integration", () => { expect(spawnCalls.at(-1)?.argv).toContain("--sandbox"); expect((spawnCalls.at(-1)?.options as { cwd?: string } | undefined)?.cwd).toBe(testDir); }); + + it("passes the provider-built argv and session id env through to PTY spawn", async () => { + db.prepare("INSERT INTO provider_configs (provider_id, config) VALUES (?, ?)").run( + "claude", + '{"additionalArgs":["--verbose"],"envVars":{"TASK3_PROVIDER_ENV":"task3-value"}}' + ); + + const openResult = await dispatch( + { + kind: "command", + id: "test-provider-handoff-open", + op: "workspace.open", + args: { path: testDir }, + }, + ctx + ); + + expect(openResult.ok).toBe(true); + const workspaceId = openResult.data!.id; + + const sessionResult = await dispatch( + { + kind: "command", + id: "test-provider-handoff-create", + op: "session.create", + args: { + workspaceId, + providerId: "claude", + }, + }, + ctx + ); + + expect(sessionResult.ok).toBe(true); + + const sessionId = sessionResult.data!.id; + const claudeProvider = providerRegistry.find((provider) => provider.id === "claude"); + expect(claudeProvider).toBeDefined(); + + const expectedCommand = claudeProvider!.buildCommand( + { + ...claudeProvider!.defaultConfig, + additionalArgs: ["--verbose"], + envVars: { + TASK3_PROVIDER_ENV: "task3-value", + }, + }, + { + workspacePath: testDir, + sessionId, + } + ); + + const lastSpawn = spawnCalls.at(-1); + expect(lastSpawn?.argv).toEqual(expectedCommand.argv); + expect((lastSpawn?.options as { env?: Record } | undefined)?.env).toEqual( + expect.objectContaining({ + CODER_STUDIO_SESSION_ID: sessionId, + TASK3_PROVIDER_ENV: "task3-value", + }) + ); + }); }); describe("Session state transitions", () => { diff --git a/packages/server/src/__tests__/workspace/runtime-check.test.ts b/packages/server/src/__tests__/workspace/runtime-check.test.ts index 3507eaa30..2a766191d 100644 --- a/packages/server/src/__tests__/workspace/runtime-check.test.ts +++ b/packages/server/src/__tests__/workspace/runtime-check.test.ts @@ -3,41 +3,68 @@ import { RuntimeCheckFailedError, runtimeCheck } from "../../workspace/runtime-c describe("runtimeCheck", () => { it("reports missing wsl through the shared command helper fallback", async () => { - const execFile = vi.fn(async (file: string, args: string[]) => { - if (file === "git") { - return { stdout: "git version 2.48.0\n", stderr: "" }; - } + const execFile = vi.fn( + async (file: string, args: string[], _options?: { windowsHide: boolean }) => { + if (file === "git") { + return { stdout: "git version 2.48.0\n", stderr: "" }; + } - if (file === "node") { - return { stdout: "v22.15.0\n", stderr: "" }; - } + if (file === "node") { + return { stdout: "v22.15.0\n", stderr: "" }; + } - if (file === "where" && args[0] === "wsl") { - throw new Error("wsl unavailable"); - } + if (file === "where" && args[0] === "wsl") { + throw new Error("wsl unavailable"); + } - return { stdout: "", stderr: "" }; - }); + return { stdout: "", stderr: "" }; + } + ); const result = await runtimeCheck("/tmp", "wsl", { platform: "win32", - execFile, + runCommand: execFile, }); expect(result).toEqual({ ok: false, missing: ["wsl"] }); - expect(execFile).toHaveBeenCalledWith("where", ["wsl"]); + expect(execFile).toHaveBeenCalledWith("where", ["wsl"], { windowsHide: true }); }); it("reports missing git and node from the version checks deterministically", async () => { const result = await runtimeCheck("/tmp", "native", { commandExists: async () => true, - execFile: vi.fn(async (file: string) => { + runCommand: vi.fn(async (file: string) => { throw new Error(`${file} unavailable`); }), }); expect(result).toEqual({ ok: false, missing: ["git", "node"] }); }); + + it("passes windowsHide to injected execFile on Windows version checks", async () => { + const execFile = vi.fn( + async (file: string, _args: string[], _options?: { windowsHide: boolean }) => { + if (file === "git") { + return { stdout: "git version 2.48.0\n", stderr: "" }; + } + + if (file === "node") { + return { stdout: "v22.15.0\n", stderr: "" }; + } + + throw new Error(`${file} unavailable`); + } + ); + + const result = await runtimeCheck("/tmp", "native", { + platform: "win32", + runCommand: execFile, + }); + + expect(result).toEqual({ ok: true, missing: [] }); + expect(execFile).toHaveBeenCalledWith("git", ["--version"], { windowsHide: true }); + expect(execFile).toHaveBeenCalledWith("node", ["--version"], { windowsHide: true }); + }); }); describe("RuntimeCheckFailedError", () => { diff --git a/packages/server/src/__tests__/ws-hub.test.ts b/packages/server/src/__tests__/ws-hub.test.ts index 7363631bd..14be03465 100644 --- a/packages/server/src/__tests__/ws-hub.test.ts +++ b/packages/server/src/__tests__/ws-hub.test.ts @@ -36,8 +36,9 @@ type MessageHandler = ((data: Buffer, isBinary?: boolean) => void) | undefined; type ResultMessage = Extract; -const TEST_CONFIG: Pick = { +const TEST_CONFIG: Pick = { auth: { enabled: false }, + appVersion: "0.3.0", }; const createMockSocket = (): MockSocket => ({ @@ -123,11 +124,21 @@ describe("WsHub", () => { eventBus.clear(); }); - it("should accept first connection as writer", () => { + it("sends connection metadata including the CLI version on connect", () => { const socket = createMockSocket(); hub.handleConnection(socket as never, createMockRequest()); - expect(socket.send).toHaveBeenCalledWith(expect.stringContaining("connected")); + const sentEvents = parseSentEvents(socket); + expect(sentEvents[0]).toMatchObject({ + kind: "event", + topic: "connection.status", + data: expect.objectContaining({ + status: "connected", + version: "0.3.0", + serverInstanceId: expect.stringMatching(/^server-\d+$/), + isWriter: false, + }), + }); }); it("should accept multiple connections (writer tracking moved to FencingManager)", () => { diff --git a/packages/server/src/config.test.ts b/packages/server/src/config.test.ts index fca32403a..32cb1ed8c 100644 --- a/packages/server/src/config.test.ts +++ b/packages/server/src/config.test.ts @@ -1,8 +1,18 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import { homedir, tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { parseServerConfig } from "./config.js"; +function readCliPackageVersion(): string | undefined { + return ( + JSON.parse(readFileSync(new URL("../../cli/package.json", import.meta.url), "utf-8")) as { + version?: string; + } + ).version; +} + describe("parseServerConfig", () => { const originalEnv = { ...process.env }; @@ -26,6 +36,41 @@ describe("parseServerConfig", () => { expect(config.port).toBe(8080); }); + it("defaults appVersion to the CLI package version", () => { + delete process.env.CODER_STUDIO_APP_VERSION; + + const config = parseServerConfig(); + + expect(config.appVersion).toBe(readCliPackageVersion()); + }); + + it("can resolve the CLI package version when imported via native ESM", () => { + const env = { ...process.env }; + delete env.CODER_STUDIO_APP_VERSION; + + const output = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + "import('./src/config.ts').then((module) => { process.stdout.write(String(module.parseServerConfig().appVersion)); });", + ], + { + cwd: new URL("../", import.meta.url), + env, + encoding: "utf-8", + } + ); + + expect(output).toBe(readCliPackageVersion()); + }); + + it("prefers explicit appVersion override over inferred CLI version", () => { + const config = parseServerConfig({ appVersion: "9.9.9" }); + + expect(config.appVersion).toBe("9.9.9"); + }); + it("uses the temp sqlite file by default outside production", () => { delete process.env.NODE_ENV; delete process.env.DATA_DIR; diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 7d09e2061..1c2c42c8c 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -19,6 +19,7 @@ export interface ServerConfig { uploadsDir: string; logLevel: "trace" | "debug" | "info" | "warn" | "error"; webRoot?: string; + appVersion?: string; auth: { enabled: boolean; password?: string; @@ -26,6 +27,7 @@ export interface ServerConfig { } let cachedTestUploadsDir: string | undefined; +let cachedAppVersion: string | undefined; function parseLogLevel(value: string | undefined): ServerConfig["logLevel"] | undefined { switch (value) { @@ -40,6 +42,30 @@ function parseLogLevel(value: string | undefined): ServerConfig["logLevel"] | un } } +function resolveDefaultAppVersion(): string { + if (cachedAppVersion) { + return cachedAppVersion; + } + + const packageJsonPath = [new URL("../../cli/package.json", import.meta.url)].find((candidate) => + fs.existsSync(candidate) + ); + + if (!packageJsonPath) { + cachedAppVersion = "0.0.0"; + return cachedAppVersion; + } + + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }; + cachedAppVersion = typeof pkg.version === "string" ? pkg.version : "0.0.0"; + } catch { + cachedAppVersion = "0.0.0"; + } + + return cachedAppVersion; +} + /** * Resolve the database file path. * @@ -96,6 +122,8 @@ export function parseServerConfig(overrides?: Partial): ServerConf uploadsDir, logLevel: overrides?.logLevel ?? parseLogLevel(process.env.LOG_LEVEL) ?? "info", webRoot: overrides?.webRoot, + appVersion: + overrides?.appVersion ?? process.env.CODER_STUDIO_APP_VERSION ?? resolveDefaultAppVersion(), auth: overrides?.auth || { enabled: !noAuth && !!password, password, diff --git a/packages/server/src/git/cli.ts b/packages/server/src/git/cli.ts index 46674456f..1fe91ce36 100644 --- a/packages/server/src/git/cli.ts +++ b/packages/server/src/git/cli.ts @@ -82,6 +82,7 @@ export async function runGit( }, maxBuffer: 10 * 1024 * 1024, timeout: options.timeoutMs, + windowsHide: true, }, (err, stdout, stderr) => { if (err) { diff --git a/packages/server/src/git/cli.windows.test.ts b/packages/server/src/git/cli.windows.test.ts new file mode 100644 index 000000000..21e1d7545 --- /dev/null +++ b/packages/server/src/git/cli.windows.test.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), +})); + +vi.mock("child_process", () => ({ + execFile: execFileMock, +})); + +import { runGit } from "./cli.js"; + +describe("runGit windows child-process options", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("passes windowsHide to git child processes", async () => { + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: Record, + callback: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const child = new EventEmitter() as EventEmitter & { + stdin: { on: ReturnType; end: ReturnType }; + }; + child.stdin = { + on: vi.fn(), + end: vi.fn(), + }; + callback(null, "ok", ""); + return child; + } + ); + + await expect(runGit("/tmp/worktree", ["status"])).resolves.toEqual({ + stdout: "ok", + stderr: "", + }); + + expect(execFileMock).toHaveBeenCalledWith( + "git", + ["status"], + expect.objectContaining({ windowsHide: true }), + expect.any(Function) + ); + }); +}); diff --git a/packages/server/src/provider-config.ts b/packages/server/src/provider-config.ts index f06a68185..ed02ad7ce 100644 --- a/packages/server/src/provider-config.ts +++ b/packages/server/src/provider-config.ts @@ -8,6 +8,7 @@ const supportedProviderIds = new Set(SUPPORTED_PROVIDER_IDS); export const ProviderLaunchConfigInputSchema = z .object({ additionalArgs: z.array(z.string()).optional(), + envVars: z.record(z.string(), z.string()).optional(), }) .strict(); @@ -20,6 +21,7 @@ export const ProviderSettingsSchema = z const ProviderLaunchConfigSchema = z.object({ additionalArgs: z.array(z.string()).default([]), + envVars: z.record(z.string(), z.string()).optional(), }); export function isSupportedProviderId( @@ -28,7 +30,10 @@ export function isSupportedProviderId( return supportedProviderIds.has(providerId); } -export function sanitizeProviderLaunchConfig(config: unknown): { additionalArgs: string[] } { +export function sanitizeProviderLaunchConfig(config: unknown): { + additionalArgs: string[]; + envVars?: Record; +} { const parsed = ProviderLaunchConfigSchema.safeParse(config); return parsed.success ? parsed.data : { additionalArgs: [] }; } diff --git a/packages/server/src/provider-runtime/command-check.ts b/packages/server/src/provider-runtime/command-check.ts index ed5739336..1623eb40e 100644 --- a/packages/server/src/provider-runtime/command-check.ts +++ b/packages/server/src/provider-runtime/command-check.ts @@ -1,13 +1,10 @@ -import { execFile as nodeExecFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(nodeExecFile); - export type CommandAvailabilityCheck = (command: string) => Promise; +import { type CommandRunner, runCommandAsString } from "./command-runner.js"; + export interface CommandCheckDeps { platform?: NodeJS.Platform; - execFile?: (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; + runCommand?: CommandRunner; } export function getCommandLookupExecutable(platform: NodeJS.Platform): "where" | "which" { @@ -19,12 +16,12 @@ export async function checkCommandAvailable( deps: CommandCheckDeps = {} ): Promise { const platform = deps.platform ?? process.platform; - const execFile = deps.execFile ?? ((file: string, args: string[]) => execFileAsync(file, args)); + const runCommand = deps.runCommand ?? runCommandAsString; const lookup = getCommandLookupExecutable(platform); try { - await execFile(lookup, [command]); - return true; + const { stdout } = await runCommand(lookup, [command], { windowsHide: true }); + return stdout.trim().length > 0; } catch { return false; } diff --git a/packages/server/src/provider-runtime/command-runner.ts b/packages/server/src/provider-runtime/command-runner.ts new file mode 100644 index 000000000..74ab009b7 --- /dev/null +++ b/packages/server/src/provider-runtime/command-runner.ts @@ -0,0 +1,66 @@ +import { spawn } from "node:child_process"; +import { shouldUseShellForCommand } from "@coder-studio/utils"; + +export type CommandRunnerOptions = { windowsHide?: boolean }; + +export interface CommandRunnerResult { + stdout: string; + stderr: string; +} + +export type CommandRunner = ( + file: string, + args: string[], + options?: CommandRunnerOptions +) => Promise; + +export async function runCommandAsString( + file: string, + args: string[], + options?: CommandRunnerOptions +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + shell: shouldUseShellForCommand(file, process.platform), + windowsHide: options?.windowsHide ?? true, + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout?.on("data", (chunk: string | Buffer) => { + stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + + child.stderr?.on("data", (chunk: string | Buffer) => { + stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + + child.on("error", (error) => { + reject( + Object.assign(error, { + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + }) + ); + }); + + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + reject( + Object.assign(new Error(`Command failed with exit code ${code ?? "unknown"}`), { + exitCode: code ?? undefined, + stdout, + stderr, + }) + ); + }); + }); +} diff --git a/packages/server/src/provider-runtime/install-manager.ts b/packages/server/src/provider-runtime/install-manager.ts index 1afc30029..0919eeb79 100644 --- a/packages/server/src/provider-runtime/install-manager.ts +++ b/packages/server/src/provider-runtime/install-manager.ts @@ -1,6 +1,4 @@ -import { execFile as nodeExecFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { promisify } from "node:util"; import type { ProviderDefinition, ProviderInstallFailure, @@ -12,13 +10,13 @@ import { type CommandCheckDeps, checkCommandAvailable, } from "./command-check.js"; +import { type CommandRunner, runCommandAsString } from "./command-runner.js"; -const execFileAsync = promisify(nodeExecFile); const EXCERPT_LIMIT = 400; export interface InstallManagerDeps extends CommandCheckDeps { commandExists?: CommandAvailabilityCheck; - execFile?: (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }>; + runCommand?: CommandRunner; } export class ProviderInstallManager { @@ -224,7 +222,6 @@ export class ProviderInstallManager { args: ["--version"], status: "pending", }); - return { jobId, providerId: provider.id, @@ -239,8 +236,7 @@ export class ProviderInstallManager { provider: ProviderDefinition, job: ProviderInstallJobSnapshot ): Promise { - const execFile = - this.deps.execFile ?? ((file: string, args: string[]) => execFileAsync(file, args)); + const runCommand = this.deps.runCommand ?? runCommandAsString; job.status = "running"; this.jobs.set(job.jobId, job); @@ -270,7 +266,7 @@ export class ProviderInstallManager { return; } } else { - const result = await execFile(step.command, step.args); + const result = await runCommand(step.command, step.args, { windowsHide: true }); step.stdoutExcerpt = excerpt(result.stdout); step.stderrExcerpt = excerpt(result.stderr); } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index f68e01300..bf018c672 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -3,9 +3,6 @@ * * Creates and assembles all server components. */ - -import { execFile as nodeExecFile } from "node:child_process"; -import { promisify } from "node:util"; import { deleteRuntimeConfig, getRuntimePath, @@ -13,6 +10,7 @@ import { writeRuntimeConfig, } from "@coder-studio/core/runtime"; import { providerRegistry } from "@coder-studio/providers"; +import { isDirectExecution } from "@coder-studio/utils"; import type { FastifyInstance } from "fastify"; import { buildFastifyApp } from "./app.js"; import { EventBus } from "./bus/event-bus.js"; @@ -24,6 +22,7 @@ import { cleanupCodexConfigToml, } from "./config/codex-config-audit.js"; import { ensureDataDir, parseServerConfig, type ServerConfig } from "./config.js"; +import { runCommandAsString } from "./provider-runtime/command-runner.js"; import { ProviderInstallManager } from "./provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "./provider-runtime/runtime-status.js"; import { SessionManager } from "./session/manager.js"; @@ -102,7 +101,6 @@ export async function logCodexConfigFindings( export async function createServer( configOverrides?: Partial & ServerRuntimeOptions ): Promise { - const execFileAsync = promisify(nodeExecFile); const config = parseServerConfig(configOverrides); ensureDataDir(config); @@ -196,7 +194,7 @@ export async function createServer( const providerRuntimeDeps: RuntimeStatusDeps = {}; const providerInstallMgr = new ProviderInstallManager(providerRegistry, { ...providerRuntimeDeps, - execFile: (file, args) => execFileAsync(file, args), + runCommand: runCommandAsString, }); const commandContext: CommandContext = { @@ -377,7 +375,7 @@ function createSessionDatabase(db: Database) { }; } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { const server = await createServer(); process.on("SIGINT", async () => { diff --git a/packages/server/src/supervisor/evaluator.ts b/packages/server/src/supervisor/evaluator.ts index c75649a29..47df97537 100644 --- a/packages/server/src/supervisor/evaluator.ts +++ b/packages/server/src/supervisor/evaluator.ts @@ -163,6 +163,7 @@ async function runCommand( detached: process.platform !== "win32", env: { ...process.env, ...command.env }, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); const stdout: Buffer[] = []; diff --git a/packages/server/src/supervisor/evaluator.windows.test.ts b/packages/server/src/supervisor/evaluator.windows.test.ts new file mode 100644 index 000000000..c5bb16261 --- /dev/null +++ b/packages/server/src/supervisor/evaluator.windows.test.ts @@ -0,0 +1,124 @@ +import { EventEmitter } from "node:events"; +import type { ProviderDefinition, Supervisor } from "@coder-studio/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderConfigRepo } from "../storage/repositories/provider-config-repo.js"; +import type { SupervisorEvaluationContext } from "./context-builder.js"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { SupervisorEvaluator } from "./evaluator.js"; + +const originalPlatform = process.platform; + +function createProviderConfigRepo(): ProviderConfigRepo { + return { + get: vi.fn(() => ({ additionalArgs: [], envVars: {} })), + } as unknown as ProviderConfigRepo; +} + +function makeSupervisor(): Supervisor { + return { + id: "sup-1", + sessionId: "sess-1", + workspaceId: "ws-1", + state: "idle", + objective: "obj", + evaluatorProviderId: "codex", + cycles: [], + createdAt: 1, + updatedAt: 1, + }; +} + +function makeContext(): SupervisorEvaluationContext { + return { + objective: "obj", + sessionId: "sess-1", + workspaceId: "ws-1", + workspacePath: process.cwd(), + sessionProviderId: "claude", + evaluatorProviderId: "codex", + sessionState: "running", + evidenceSource: "headless_snapshot", + terminalExcerpt: "build passes", + latestUserInput: "run the tests", + }; +} + +describe("SupervisorEvaluator windows child-process options", () => { + afterEach(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + vi.clearAllMocks(); + }); + + it("passes windowsHide to spawn and disables detached mode on Windows", async () => { + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + + spawnMock.mockImplementation(() => { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { + pid: number; + stdout: EventEmitter; + stderr: EventEmitter; + }; + + child.pid = 1234; + child.stdout = stdout; + child.stderr = stderr; + + queueMicrotask(() => { + stdout.emit( + "data", + Buffer.from( + `${JSON.stringify({ + type: "item.completed", + item: { id: "i1", type: "agent_message", text: "Run pnpm vitest to verify" }, + })}\n${JSON.stringify({ type: "turn.completed", usage: { output_tokens: 20 } })}\n` + ) + ); + child.emit("exit", 0); + }); + + return child; + }); + + const evaluator = new SupervisorEvaluator({ + providerRegistry: [ + { + id: "codex", + buildSupervisorEvalCommand: vi.fn(() => ({ + argv: ["codex", "exec", "--json"], + cwd: process.cwd(), + env: {}, + })), + defaultConfig: { additionalArgs: [], envVars: {} }, + } as unknown as ProviderDefinition, + ], + providerConfigRepo: createProviderConfigRepo(), + timeoutMs: 5000, + }); + + await expect(evaluator.evaluate(makeSupervisor(), makeContext())).resolves.toEqual({ + message: "Run pnpm vitest to verify", + }); + + expect(spawnMock).toHaveBeenCalledWith( + "codex", + ["exec", "--json"], + expect.objectContaining({ windowsHide: true, detached: false }) + ); + }); +}); diff --git a/packages/server/src/terminal/active-terminal.test.ts b/packages/server/src/terminal/active-terminal.test.ts index bf869255d..570c73d7e 100644 --- a/packages/server/src/terminal/active-terminal.test.ts +++ b/packages/server/src/terminal/active-terminal.test.ts @@ -29,7 +29,7 @@ describe("ActiveTerminal", () => { const id = "term-123"; const createdAt = Date.now(); - const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, createdAt); + const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, undefined, createdAt); expect(active.id).toBe(id); expect(active.spec).toBe(spec); @@ -45,7 +45,7 @@ describe("ActiveTerminal", () => { const id = "term-123"; const createdAt = Date.now(); - const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, createdAt); + const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, undefined, createdAt); const dto = active.toDTO(); expect(dto).toEqual({ @@ -115,7 +115,7 @@ describe("ActiveTerminal", () => { const id = "term-123"; const createdAt = Date.now(); - const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, createdAt); + const active = new ActiveTerminal(id, spec, mockPty, ringBuffer, undefined, createdAt); const row = active.toRow(); // Should be same as DTO diff --git a/packages/server/src/terminal/pty-host.ts b/packages/server/src/terminal/pty-host.ts index 5344a37d0..3c0d2f204 100644 --- a/packages/server/src/terminal/pty-host.ts +++ b/packages/server/src/terminal/pty-host.ts @@ -7,6 +7,7 @@ import { chmodSync, existsSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { resolveSpawnArgv } from "@coder-studio/utils"; import type * as NodePty from "node-pty"; import type { PtyHost, PtyProcess, PtySpawnOptions } from "./types.js"; @@ -200,7 +201,18 @@ export class NodePtyHost implements PtyHost { throw new Error(`node-pty native module not available. ${message}`); } - const [command, ...args] = argv; + if (argv.length === 0) { + throw new Error("PTY spawn requires a command"); + } + + // On Windows, node-pty calls Win32 CreateProcess directly and cannot run + // .cmd/.bat shims. resolveSpawnArgv walks PATH+PATHEXT and unwraps + // npm-style cmd-shims into a `node ` invocation. On non-win32 + // platforms this returns argv unchanged. + const [command, ...args] = resolveSpawnArgv(argv, { + pathEnv: options.env.Path ?? options.env.PATH, + pathExt: options.env.PATHEXT, + }); if (command === undefined) { throw new Error("PTY spawn requires a command"); } diff --git a/packages/server/src/workspace/runtime-check.ts b/packages/server/src/workspace/runtime-check.ts index 159118ebc..f4fcf01e0 100644 --- a/packages/server/src/workspace/runtime-check.ts +++ b/packages/server/src/workspace/runtime-check.ts @@ -1,12 +1,9 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { type CommandAvailabilityCheck, type CommandCheckDeps, checkCommandAvailable, } from "../provider-runtime/command-check.js"; - -const execFileAsync = promisify(execFile); +import { runCommandAsString } from "../provider-runtime/command-runner.js"; export interface RuntimeCheckResult { ok: boolean; @@ -19,22 +16,20 @@ export interface RuntimeCheckDeps extends CommandCheckDeps { commandExists?: CommandAvailabilityCheck; } -async function checkGit(execRunner: RuntimeCheckDeps["execFile"]): Promise { +async function checkGit(runCommand: RuntimeCheckDeps["runCommand"]): Promise { try { - const { stdout } = await (execRunner ?? ((file, args) => execFileAsync(file, args)))("git", [ - "--version", - ]); + const runner = runCommand ?? runCommandAsString; + const { stdout } = await runner("git", ["--version"], { windowsHide: true }); return stdout.includes("git version"); } catch { return false; } } -async function checkNode(execRunner: RuntimeCheckDeps["execFile"]): Promise { +async function checkNode(runCommand: RuntimeCheckDeps["runCommand"]): Promise { try { - const { stdout } = await (execRunner ?? ((file, args) => execFileAsync(file, args)))("node", [ - "--version", - ]); + const runner = runCommand ?? runCommandAsString; + const { stdout } = await runner("node", ["--version"], { windowsHide: true }); return stdout.startsWith("v"); } catch { return false; @@ -57,12 +52,12 @@ export async function runtimeCheck( const commandExists = deps.commandExists ?? ((command: string) => checkCommandAvailable(command, deps)); - const gitAvailable = await checkGit(deps.execFile); + const gitAvailable = await checkGit(deps.runCommand); if (!gitAvailable) { missing.push("git"); } - const nodeAvailable = await checkNode(deps.execFile); + const nodeAvailable = await checkNode(deps.runCommand); if (!nodeAvailable) { missing.push("node"); } diff --git a/packages/server/src/ws/hub.ts b/packages/server/src/ws/hub.ts index 8168fabfa..57161047a 100644 --- a/packages/server/src/ws/hub.ts +++ b/packages/server/src/ws/hub.ts @@ -92,12 +92,16 @@ export class WsHub implements Broadcaster { const client = new WsClient(socket, uuidv4(), this.deps.logger); this.clients.set(client.id, client); - // Send connection ready (controller status determined later by fencing.request command) + // Send initial connection metadata. Writer status is established later by + // fencing.request, but the UI still needs the app version immediately. client.sendEvent("connection.status", { status: "connected", clientId: client.id, authEnabled: this.deps.config.auth.enabled, binaryTerminalTransport: true, + version: this.deps.config.appVersion ?? "0.0.0", + serverInstanceId: `server-${process.pid}`, + isWriter: false, }); // Setup handlers diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 000000000..096aebb5d --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,33 @@ +{ + "name": "@coder-studio/utils", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/utils/src/direct-execution.test.ts b/packages/utils/src/direct-execution.test.ts new file mode 100644 index 000000000..40c81922b --- /dev/null +++ b/packages/utils/src/direct-execution.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isDirectExecution } from "./direct-execution.js"; + +describe("isDirectExecution", () => { + it("matches direct execution for POSIX script paths", () => { + expect(isDirectExecution("file:///repo/scripts/dev.ts", "/repo/scripts/dev.ts")).toBe(true); + }); + + it("matches direct execution for Windows script paths", () => { + expect(isDirectExecution("file:///C:/repo/scripts/dev.ts", "C:\\repo\\scripts\\dev.ts")).toBe( + true + ); + }); + + it("returns false when the current module differs from argv[1]", () => { + expect(isDirectExecution("file:///repo/scripts/build.ts", "/repo/scripts/dev.ts")).toBe(false); + }); + + it("returns false when argv[1] is undefined", () => { + expect(isDirectExecution("file:///repo/scripts/dev.ts", undefined)).toBe(false); + }); + + it("returns false when moduleUrl is not a file: URL", () => { + expect(isDirectExecution("https://example.com/dev.ts", "/repo/scripts/dev.ts")).toBe(false); + }); +}); diff --git a/packages/utils/src/direct-execution.ts b/packages/utils/src/direct-execution.ts new file mode 100644 index 000000000..13aa8f722 --- /dev/null +++ b/packages/utils/src/direct-execution.ts @@ -0,0 +1,64 @@ +import { posix, resolve } from "node:path"; + +function isWindowsDrivePath(path: string): boolean { + return /^[A-Za-z]:\//.test(path); +} + +function normalizeComparablePath(path: string): string { + let normalized = path.replace(/\\/g, "/"); + + if (/^\/[A-Za-z]:\//.test(normalized)) { + normalized = normalized.slice(1); + } + + if (normalized.startsWith("//")) { + normalized = `//${posix.normalize(normalized.slice(2))}`; + } else { + normalized = posix.normalize(normalized); + } + + if (isWindowsDrivePath(normalized) || normalized.startsWith("//")) { + normalized = normalized.toLowerCase(); + } + + return normalized; +} + +function normalizeModuleUrlPath(moduleUrl: string): string | null { + let url: URL; + + try { + url = new URL(moduleUrl); + } catch { + return null; + } + + if (url.protocol !== "file:") { + return null; + } + + const path = `${url.host ? `//${url.host}` : ""}${decodeURIComponent(url.pathname)}`; + return normalizeComparablePath(path); +} + +function normalizeArgvPath(argv1: string): string { + const isAbsoluteWindowsPath = /^[A-Za-z]:[\\/]/.test(argv1) || /^\\\\/.test(argv1); + return normalizeComparablePath(isAbsoluteWindowsPath ? argv1 : resolve(argv1)); +} + +export function isDirectExecution( + moduleUrl: string, + argv1: string | undefined = process.argv[1] +): boolean { + if (argv1 === undefined) { + return false; + } + + const modulePath = normalizeModuleUrlPath(moduleUrl); + + if (modulePath === null) { + return false; + } + + return modulePath === normalizeArgvPath(argv1); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 000000000..9d6ad5985 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,7 @@ +export { isDirectExecution } from "./direct-execution.js"; +export { shouldUseShellForCommand } from "./windows-shim.js"; +export type { + ParsedCmdShim, + ResolveSpawnArgvDeps, +} from "./windows-shim-resolver.js"; +export { parseCmdShim, resolveSpawnArgv } from "./windows-shim-resolver.js"; diff --git a/packages/utils/src/windows-shim-resolver.test.ts b/packages/utils/src/windows-shim-resolver.test.ts new file mode 100644 index 000000000..8d82ec511 --- /dev/null +++ b/packages/utils/src/windows-shim-resolver.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseCmdShim, resolveSpawnArgv } from "./windows-shim-resolver.js"; + +const STANDARD_NPM_CMD_SHIM = String.raw`@SETLOCAL +@IF EXIST "%~dp0\node.exe" ( + "%~dp0\node.exe" "%~dp0\..\@openai\codex\bin\codex.js" %* +) ELSE ( + @SETLOCAL + @SET PATHEXT=%PATHEXT:;.JS;=;% + node "%~dp0\..\@openai\codex\bin\codex.js" %* +) +`; + +const NPM_CMD_SHIM_VARIANT = String.raw`@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %* +`; + +const UNRECOGNIZED_CMD_SHIM = String.raw`@echo off +echo this shim is not standard +exit /b 1 +`; + +interface MockFsOptions { + files: Record; +} + +function createMockFs({ files }: MockFsOptions) { + const normalize = (p: string) => p.replace(/\\/g, "/").toLowerCase(); + const map = new Map(Object.entries(files).map(([k, v]) => [normalize(k), v])); + + return { + readFileSync: vi.fn((p: string) => { + const value = map.get(normalize(p)); + if (value === undefined) { + throw Object.assign(new Error(`ENOENT: no such file ${p}`), { code: "ENOENT" }); + } + return value; + }), + existsSync: vi.fn((p: string) => map.has(normalize(p))), + }; +} + +describe("resolveSpawnArgv", () => { + it("returns argv unchanged on linux", () => { + const argv = ["codex", "--model", "gpt-4"]; + const out = resolveSpawnArgv(argv, { platform: "linux" }); + expect(out).toEqual(argv); + expect(out).not.toBe(argv); + }); + + it("returns argv unchanged on darwin", () => { + const argv = ["codex", "--model", "gpt-4"]; + const out = resolveSpawnArgv(argv, { platform: "darwin" }); + expect(out).toEqual(argv); + }); + + it("returns argv unchanged when argv is empty", () => { + expect(resolveSpawnArgv([], { platform: "win32" })).toEqual([]); + }); + + it("returns argv unchanged when command is not found in PATH", () => { + const fs = createMockFs({ files: {} }); + const out = resolveSpawnArgv(["codex"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["codex"]); + }); + + it("returns absolute path when command resolves to a real .exe", () => { + const fs = createMockFs({ + files: { "C:\\bin\\git.exe": "" }, + }); + const out = resolveSpawnArgv(["git", "status"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["C:\\bin\\git.exe", "status"]); + }); + + it("parses an npm-style .cmd shim and spawns node + entry directly", () => { + const shimPath = "C:\\Users\\u\\AppData\\Local\\fnm\\codex.cmd"; + const fs = createMockFs({ + files: { [shimPath]: STANDARD_NPM_CMD_SHIM }, + }); + const out = resolveSpawnArgv(["codex", "--model", "gpt-4"], { + platform: "win32", + pathEnv: "C:\\Users\\u\\AppData\\Local\\fnm", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out[0].toLowerCase()).toBe("c:\\users\\u\\appdata\\local\\fnm\\node.exe".toLowerCase()); + expect(out[1].toLowerCase()).toBe( + "c:\\users\\u\\appdata\\local\\@openai\\codex\\bin\\codex.js".toLowerCase() + ); + expect(out.slice(2)).toEqual(["--model", "gpt-4"]); + }); + + it("parses the SETLOCAL/find_dp0 variant of cmd-shim", () => { + const shimPath = "C:\\bin\\codex.cmd"; + const fs = createMockFs({ + files: { [shimPath]: NPM_CMD_SHIM_VARIANT }, + }); + const out = resolveSpawnArgv(["codex"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + expect(out[1].toLowerCase()).toBe( + "c:\\bin\\node_modules\\@openai\\codex\\bin\\codex.js".toLowerCase() + ); + }); + + it("falls back to cmd.exe wrapper when .cmd shim cannot be parsed", () => { + const shimPath = "C:\\bin\\weird.cmd"; + const fs = createMockFs({ + files: { [shimPath]: UNRECOGNIZED_CMD_SHIM }, + }); + const out = resolveSpawnArgv(["weird", "arg1"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["cmd.exe", "/d", "/s", "/c", shimPath, "arg1"]); + }); + + it("respects PATHEXT order — picks .EXE before .CMD when both exist", () => { + const fs = createMockFs({ + files: { + "C:\\bin\\foo.cmd": STANDARD_NPM_CMD_SHIM, + "C:\\bin\\foo.exe": "", + }, + }); + const out = resolveSpawnArgv(["foo"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["C:\\bin\\foo.exe"]); + }); + + it("when argv[0] already has an extension, does not loop PATHEXT", () => { + const fs = createMockFs({ + files: { + "C:\\bin\\foo.cmd": STANDARD_NPM_CMD_SHIM, + "C:\\bin\\foo.exe": "", + }, + }); + const out = resolveSpawnArgv(["foo.cmd"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".COM;.EXE;.CMD", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + // Should resolve via .cmd parse, not .exe + expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + }); + + it("when argv[0] is already an absolute .exe, returns it as-is", () => { + const abs = "C:\\Program Files\\Git\\bin\\git.exe"; + const fs = createMockFs({ files: { [abs]: "" } }); + const out = resolveSpawnArgv([abs, "status"], { + platform: "win32", + pathEnv: "", + pathExt: ".EXE", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual([abs, "status"]); + }); + + it("when argv[0] is already an absolute .cmd, parses it directly", () => { + const abs = "C:\\bin\\codex.cmd"; + const fs = createMockFs({ files: { [abs]: STANDARD_NPM_CMD_SHIM } }); + const out = resolveSpawnArgv([abs], { + platform: "win32", + pathEnv: "", + pathExt: "", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + expect(out[1].toLowerCase()).toBe("c:\\@openai\\codex\\bin\\codex.js".toLowerCase()); + }); + + it("searches multiple PATH entries in order", () => { + const fs = createMockFs({ + files: { "D:\\tools\\codex.exe": "" }, + }); + const out = resolveSpawnArgv(["codex"], { + platform: "win32", + pathEnv: "C:\\nope;D:\\tools;E:\\also-nope", + pathExt: ".EXE", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["D:\\tools\\codex.exe"]); + }); + + it("ignores empty PATH segments", () => { + const fs = createMockFs({ + files: { "C:\\bin\\codex.exe": "" }, + }); + const out = resolveSpawnArgv(["codex"], { + platform: "win32", + pathEnv: ";;C:\\bin;;", + pathExt: ".EXE", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + expect(out).toEqual(["C:\\bin\\codex.exe"]); + }); + + it("matches PATHEXT case-insensitively", () => { + const fs = createMockFs({ + files: { "C:\\bin\\codex.CMD": STANDARD_NPM_CMD_SHIM }, + }); + const out = resolveSpawnArgv(["codex"], { + platform: "win32", + pathEnv: "C:\\bin", + pathExt: ".com;.exe;.cmd", + readFileSync: fs.readFileSync, + existsSync: fs.existsSync, + }); + // Should still parse the shim (the .CMD on disk vs .cmd in PATHEXT) + expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + }); +}); + +describe("parseCmdShim", () => { + it("extracts node path and js entry from standard npm shim", () => { + const result = parseCmdShim("C:\\bin\\codex.cmd", STANDARD_NPM_CMD_SHIM); + expect(result).not.toBeNull(); + expect(result!.node.toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + expect(result!.entry.toLowerCase()).toBe("c:\\@openai\\codex\\bin\\codex.js".toLowerCase()); + }); + + it("extracts from the find_dp0 variant", () => { + const result = parseCmdShim("C:\\bin\\codex.cmd", NPM_CMD_SHIM_VARIANT); + expect(result).not.toBeNull(); + expect(result!.node.toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase()); + expect(result!.entry.toLowerCase()).toBe( + "c:\\bin\\node_modules\\@openai\\codex\\bin\\codex.js".toLowerCase() + ); + }); + + it("returns null when shim does not contain a recognizable js entry", () => { + expect(parseCmdShim("C:\\bin\\weird.cmd", UNRECOGNIZED_CMD_SHIM)).toBeNull(); + }); + + it("returns null on completely empty input", () => { + expect(parseCmdShim("C:\\bin\\x.cmd", "")).toBeNull(); + }); +}); diff --git a/packages/utils/src/windows-shim-resolver.ts b/packages/utils/src/windows-shim-resolver.ts new file mode 100644 index 000000000..7bfa194fa --- /dev/null +++ b/packages/utils/src/windows-shim-resolver.ts @@ -0,0 +1,165 @@ +/** + * Resolve a spawn argv on Windows by walking PATH+PATHEXT and unwrapping + * npm-style cmd-shims into their underlying `node ` invocation. + * + * Why: node-pty on Windows calls Win32 CreateProcess directly, which only + * auto-appends `.exe` and cannot run `.cmd`/`.bat` shims. Tools installed by + * npm (codex, claude, pnpm, …) ship as `.cmd` shims that wrap a node entry + * script. On POSIX the kernel handles the equivalent via shebang; we replicate + * that step on Windows so node-pty receives an argv it can actually spawn. + * + * On non-win32 platforms this is a no-op (returns argv unchanged). + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"; + +export interface ResolveSpawnArgvDeps { + platform?: NodeJS.Platform; + pathEnv?: string; + pathExt?: string; + readFileSync?: (file: string) => string; + existsSync?: (file: string) => boolean; +} + +export interface ParsedCmdShim { + node: string; + entry: string; +} + +export function resolveSpawnArgv( + argv: readonly string[], + deps: ResolveSpawnArgvDeps = {} +): string[] { + const platform = deps.platform ?? process.platform; + if (platform !== "win32") { + return [...argv]; + } + const command = argv[0]; + if (command === undefined) { + return []; + } + const restArgs = argv.slice(1); + + const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf8")); + const existsSync = deps.existsSync ?? fs.existsSync; + const pathEnv = deps.pathEnv ?? process.env.Path ?? process.env.PATH ?? ""; + const pathExt = deps.pathExt ?? process.env.PATHEXT ?? DEFAULT_PATHEXT; + + const resolved = resolveExecutablePath(command, pathEnv, pathExt, existsSync); + if (!resolved) { + // Let node-pty surface its own ENOENT — keeps existing error semantics. + return [...argv]; + } + + const ext = path.win32.extname(resolved).toLowerCase(); + if (ext === ".exe" || ext === ".com") { + return [resolved, ...restArgs]; + } + if (ext === ".cmd" || ext === ".bat") { + let content: string; + try { + content = readFileSync(resolved); + } catch { + return ["cmd.exe", "/d", "/s", "/c", resolved, ...restArgs]; + } + const parsed = parseCmdShim(resolved, content); + if (parsed) { + return [parsed.node, parsed.entry, ...restArgs]; + } + return ["cmd.exe", "/d", "/s", "/c", resolved, ...restArgs]; + } + // Unknown extension (e.g. .ps1, .vbs): leave argv untouched and let the + // caller/OS decide. Wrapping with cmd.exe wouldn't help these anyway. + return [...argv]; +} + +export function parseCmdShim(shimPath: string, content: string): ParsedCmdShim | null { + const dp0Dir = path.win32.dirname(shimPath); + + // Match the dispatch line: a quoted .js entry followed by %* + const entryMatch = content.match(/"([^"\r\n]+\.js)"\s+%\*/i); + const entryRaw = entryMatch?.[1]; + if (!entryRaw) { + return null; + } + const entry = expandShimVars(entryRaw, dp0Dir); + + // Try to find a quoted node executable reference; prefer one that points at + // an absolute/dp0-relative location, falling back to bare `node` otherwise. + const nodeMatch = content.match(/"([^"\r\n]*node\.exe)"/i) ?? content.match(/"([^"\r\n]*node)"/i); + const nodeRaw = nodeMatch?.[1]; + const node = nodeRaw ? expandShimVars(nodeRaw, dp0Dir) : "node"; + + return { node, entry }; +} + +function expandShimVars(value: string, dp0Dir: string): string { + // %~dp0 and %dp0% both expand to the directory containing the shim, + // canonically with a trailing backslash. + const dp0WithSlash = dp0Dir.endsWith("\\") ? dp0Dir : `${dp0Dir}\\`; + let expanded = value; + expanded = expanded.replace(/%~dp0/gi, dp0WithSlash); + expanded = expanded.replace(/%dp0%/gi, dp0WithSlash); + + if (path.win32.isAbsolute(expanded)) { + return path.win32.normalize(expanded); + } + return path.win32.resolve(dp0Dir, expanded); +} + +function parsePathExt(pathExt: string): string[] { + return pathExt + .split(";") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); +} + +function resolveExecutablePath( + command: string, + pathEnv: string, + pathExt: string, + existsSync: (file: string) => boolean +): string | null { + const hasExt = path.win32.extname(command).length > 0; + const extensions = parsePathExt(pathExt); + + if (path.win32.isAbsolute(command)) { + if (existsSync(command)) { + return command; + } + if (!hasExt) { + for (const ext of extensions) { + const candidate = command + ext; + if (existsSync(candidate)) { + return candidate; + } + } + } + return null; + } + + const dirs = pathEnv + .split(";") + .map((dir) => dir.trim()) + .filter((dir) => dir.length > 0); + + for (const dir of dirs) { + if (hasExt) { + const candidate = path.win32.join(dir, command); + if (existsSync(candidate)) { + return candidate; + } + continue; + } + for (const ext of extensions) { + const candidate = path.win32.join(dir, command + ext); + if (existsSync(candidate)) { + return candidate; + } + } + } + return null; +} diff --git a/packages/utils/src/windows-shim.test.ts b/packages/utils/src/windows-shim.test.ts new file mode 100644 index 000000000..8a21ef06f --- /dev/null +++ b/packages/utils/src/windows-shim.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { shouldUseShellForCommand } from "./windows-shim.js"; + +describe("shouldUseShellForCommand", () => { + it("uses a shell for pnpm on Windows because pnpm.cmd is not directly executable", () => { + expect(shouldUseShellForCommand("pnpm", "win32")).toBe(true); + }); + + it("uses a shell for npm and npx on Windows", () => { + expect(shouldUseShellForCommand("npm", "win32")).toBe(true); + expect(shouldUseShellForCommand("npx", "win32")).toBe(true); + }); + + it("does not use a shell for native executables like git on Windows", () => { + expect(shouldUseShellForCommand("git", "win32")).toBe(false); + }); + + it("does not use a shell on POSIX platforms", () => { + expect(shouldUseShellForCommand("pnpm", "linux")).toBe(false); + expect(shouldUseShellForCommand("pnpm", "darwin")).toBe(false); + }); + + it("matches shim names case-insensitively", () => { + expect(shouldUseShellForCommand("NPM", "win32")).toBe(true); + expect(shouldUseShellForCommand("Pnpm", "win32")).toBe(true); + }); +}); diff --git a/packages/utils/src/windows-shim.ts b/packages/utils/src/windows-shim.ts new file mode 100644 index 000000000..82ad9fa6a --- /dev/null +++ b/packages/utils/src/windows-shim.ts @@ -0,0 +1,18 @@ +/** + * Helpers for spawning child processes that may resolve to Windows .cmd / .bat + * shims. + * + * Why: Node 18.20.2 / 20.12.2 / 21.7.2 (CVE-2024-27980) refuses to spawn + * .cmd or .bat files unless `shell: true` is set. The shims below ship as + * .cmd on Windows, so they need shell:true; native executables (git, etc.) + * must keep shell:false to avoid breaking argument escaping. + */ + +const WINDOWS_CMD_SHIMS = new Set(["pnpm", "npm", "npx"]); + +export function shouldUseShellForCommand( + command: string, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === "win32" && WINDOWS_CMD_SHIMS.has(command.toLowerCase()); +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..f850f2623 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/web/index.html b/packages/web/index.html index 8840bf672..db82c3f6f 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -5,10 +5,10 @@ Coder Studio - +
- \ No newline at end of file + diff --git a/packages/web/package.json b/packages/web/package.json index 0cee832da..5b9161d11 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,6 +16,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "clsx": "^2.1.1", "jotai": "^2.19.1", "jotai-family": "^1.0.1", "lucide-react": "^1.14.0", diff --git a/packages/web/public/favicon.ico b/packages/web/public/favicon.ico new file mode 100644 index 000000000..5115ca4c5 Binary files /dev/null and b/packages/web/public/favicon.ico differ diff --git a/packages/web/public/favicon.png b/packages/web/public/favicon.png new file mode 100644 index 000000000..6ce482bbf Binary files /dev/null and b/packages/web/public/favicon.png differ diff --git a/packages/web/public/favicon.svg b/packages/web/public/favicon.svg new file mode 100644 index 000000000..217fb9f5d --- /dev/null +++ b/packages/web/public/favicon.svg @@ -0,0 +1,19 @@ + + + + diff --git a/packages/web/src/app.test.tsx b/packages/web/src/app.test.tsx index b7529149c..162e29a08 100644 --- a/packages/web/src/app.test.tsx +++ b/packages/web/src/app.test.tsx @@ -69,7 +69,7 @@ describe("App shell selection", () => { expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument(); }); - it("renders DesktopShell on wide coarse-pointer devices", () => { + it("renders MobileShell on wide coarse-pointer devices", () => { setMatchMediaMock((query) => query.includes("pointer: coarse")); const store = createStore(); store.set(connectionStatusAtom, "connected"); @@ -82,7 +82,7 @@ describe("App shell selection", () => { ); - expect(screen.getByTestId("desktop-shell")).toBeInTheDocument(); - expect(screen.queryByTestId("mobile-shell")).not.toBeInTheDocument(); + expect(screen.getByTestId("mobile-shell")).toBeInTheDocument(); + expect(screen.queryByTestId("desktop-shell")).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index 20af17e8d..7918f8b4a 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -1,7 +1,7 @@ import type { Supervisor, SupervisorCycle } from "@coder-studio/core"; import { createStore } from "jotai"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { sessionsAtom } from "../atoms"; +import { isWriterAtom, serverInfoAtom, sessionsAtom } from "../atoms"; import { activeWorkspaceAtom, activeWorkspaceIdAtom, @@ -63,6 +63,29 @@ describe("routeEventToAtom", () => { expect(store.get(supervisorCyclesAtom).size).toBe(0); }); + it("stores server metadata from the initial connected status event", () => { + const store = createStore(); + + routeEventToAtom( + "connection.status", + { + status: "connected", + authEnabled: false, + version: "0.3.0", + serverInstanceId: "server-123", + isWriter: true, + }, + store + ); + + expect(store.get(serverInfoAtom)).toEqual({ + version: "0.3.0", + serverInstanceId: "server-123", + authEnabled: false, + }); + expect(store.get(isWriterAtom)).toBe(true); + }); + it("appends brand-new workspace meta events to workspace order without reordering existing entries", () => { const store = createStore(); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 27ef5f12f..49f179499 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -466,6 +466,38 @@ export function AppProviders({ children }: AppProvidersProps) { return <>{children}; } +function storeServerMetadata( + payload: unknown, + store: Store +): payload is { + version: string; + serverInstanceId: string; + authEnabled?: boolean; + isWriter?: boolean; +} { + const data = payload as { + version?: unknown; + serverInstanceId?: unknown; + authEnabled?: unknown; + isWriter?: unknown; + }; + + if (typeof data.version !== "string" || typeof data.serverInstanceId !== "string") { + return false; + } + + store.set(serverInfoAtom, { + version: data.version, + serverInstanceId: data.serverInstanceId, + authEnabled: typeof data.authEnabled === "boolean" ? data.authEnabled : undefined, + }); + if (typeof data.isWriter === "boolean") { + store.set(isWriterAtom, data.isWriter); + } + + return true; +} + /** * Route incoming WebSocket events to appropriate Jotai atoms */ @@ -475,20 +507,24 @@ export function routeEventToAtom(topic: string, payload: unknown, store: Store): // or: connection.ready if (topic === "connection.ready") { - // Server metadata on connect - const data = payload as { version: string; serverInstanceId: string; isWriter: boolean }; - store.set(serverInfoAtom, { - version: data.version, - serverInstanceId: data.serverInstanceId, - }); - store.set(isWriterAtom, data.isWriter); + storeServerMetadata(payload, store); store.set(connectionErrorAtom, null); return; } if (topic === "connection.status") { // Connection-level status event - const data = payload as { status: string; message?: string; authEnabled?: boolean }; + const data = payload as { + status: string; + message?: string; + authEnabled?: boolean; + version?: string; + serverInstanceId?: string; + isWriter?: boolean; + }; + if (data.status === "connected") { + storeServerMetadata(payload, store); + } if (data.status === "connected" && data.authEnabled === false) { store.set(authenticatedAtom, true); } diff --git a/packages/web/src/components/ui/MIGRATION.md b/packages/web/src/components/ui/MIGRATION.md new file mode 100644 index 000000000..a40f119a5 --- /dev/null +++ b/packages/web/src/components/ui/MIGRATION.md @@ -0,0 +1,28 @@ +# UI Component Migration Inventory + +| Component | Status | Legacy classes | Callers left | Last update | +|---|---|---|---:|---| +| Button | 🟡 in-flight | `.btn .btn-*` | 30 | 2026-05-06 | +| IconButton | ⚫ not-started | `.btn` icon-only | — | — | +| Input | ⚫ not-started | `.input` | — | — | +| Textarea | ⚫ not-started | `.input.textarea` | — | — | +| Tag | ⚫ not-started | `.badge .badge-*` | — | — | +| Badge | ⚫ not-started | `.badge` | — | — | +| Pill | ⚫ not-started | `.settings-pill*` | — | — | +| StatusDot | ⚫ not-started | token-backed dot patterns | — | — | +| Kbd | ⚫ not-started | `kbd` | — | — | +| Spinner | ⚫ not-started | `.animate-spin` | — | — | +| Switch | ⚫ not-started | new | — | — | +| Modal | ⚫ not-started | `.modal-overlay .modal-card .modal-*` | — | — | +| ConfirmDialog | ⚫ not-started | modal convenience wrapper | — | — | +| Toast | ⚫ not-started | `.toast*` | — | — | +| Tooltip | ⚫ not-started | new | — | — | +| ProgressBar | ⚫ not-started | `--progress-height` patterns | — | — | +| Notice | ⚫ not-started | `.settings-page__notice*` | — | — | +| EmptyState | ⚫ not-started | feature-specific empty state blocks | — | — | +| Tabs | ⚫ not-started | tab / pill patterns across features | — | — | +| SegmentedControl | ⚫ not-started | `.settings-pill*` | — | — | +| Select | ⚫ not-started | `.input`, `.mobile-select-*` | — | — | +| Popover | ⚫ not-started | new | — | — | +| ActionMenu | ⚫ not-started | new | — | — | +| Sheet | ⚫ not-started | mobile sheet shells | — | — | diff --git a/packages/web/src/components/ui/README.md b/packages/web/src/components/ui/README.md new file mode 100644 index 000000000..207e8368a --- /dev/null +++ b/packages/web/src/components/ui/README.md @@ -0,0 +1,15 @@ +# Coder Studio UI + +## 总则 +- 已落地的基础组件统一从 public barrel(当前文件路径为 `src/components/ui/index.ts`)引入,不允许深链到组件子路径。 +- 所有颜色、间距、字号、圆角、阴影、动效必须来自 `src/styles/tokens.css`。 +- 业务代码禁止新增 `btn btn-*`、`input` 这类旧式全局 className;未迁移的遗留调用点只允许原样保留,不允许扩散。 +- PC / 移动差异默认由 token 或共享内部逻辑解决,业务代码不直接写 `matchMedia`。 + +## 已实现组件 +| Component | Tier | Public API | Notes | +|---|---|---|---| +| Button | 0 | `src/components/ui/index.ts` named export only | `primary / secondary / ghost / danger` × `sm / md / lg` | + +## 迁移状态 +见 `./MIGRATION.md`。 diff --git a/packages/web/src/components/ui/_internal/use-viewport.test.tsx b/packages/web/src/components/ui/_internal/use-viewport.test.tsx new file mode 100644 index 000000000..2e4913412 --- /dev/null +++ b/packages/web/src/components/ui/_internal/use-viewport.test.tsx @@ -0,0 +1,113 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useViewport } from "./use-viewport"; + +type MediaListener = () => void; + +const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)"; + +const createMatchMediaHarness = (initialMatches: boolean) => { + let matches = initialMatches; + const listeners = new Set(); + + const matchMedia = vi.fn((query: string) => ({ + matches, + media: query, + addEventListener: (_event: string, listener: MediaListener) => { + listeners.add(listener); + }, + removeEventListener: (_event: string, listener: MediaListener) => { + listeners.delete(listener); + }, + addListener: (listener: MediaListener) => { + listeners.add(listener); + }, + removeListener: (listener: MediaListener) => { + listeners.delete(listener); + }, + dispatchEvent: () => true, + })); + + return { + matchMedia, + setMatches(nextMatches: boolean) { + matches = nextMatches; + for (const listener of listeners) { + listener(); + } + }, + }; +}; + +const Probe = () => { + return
{useViewport()}
; +}; + +describe("useViewport", () => { + let originalMatchMedia: typeof window.matchMedia; + + beforeEach(() => { + originalMatchMedia = window.matchMedia; + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + it("returns desktop when the combined viewport query does not match", () => { + const harness = createMatchMediaHarness(false); + window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia; + + render(); + + expect(window.matchMedia).toHaveBeenCalledWith(VIEWPORT_QUERY); + expect(screen.getByTestId("viewport")).toHaveTextContent("desktop"); + }); + + it("returns mobile when the combined viewport query matches", () => { + const harness = createMatchMediaHarness(true); + window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia; + + render(); + + expect(screen.getByTestId("viewport")).toHaveTextContent("mobile"); + }); + + it("updates when the media query match changes", () => { + const harness = createMatchMediaHarness(false); + window.matchMedia = harness.matchMedia as unknown as typeof window.matchMedia; + + render(); + expect(screen.getByTestId("viewport")).toHaveTextContent("desktop"); + + act(() => { + harness.setMatches(true); + }); + + expect(screen.getByTestId("viewport")).toHaveTextContent("mobile"); + }); + + it("cleans up listeners on unmount", () => { + const removeEventListener = vi.fn(); + const listeners = new Set(); + + window.matchMedia = vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: (_event: string, listener: MediaListener) => { + listeners.add(listener); + }, + removeEventListener, + addListener: (listener: MediaListener) => { + listeners.add(listener); + }, + removeListener: vi.fn(), + dispatchEvent: () => true, + })) as unknown as typeof window.matchMedia; + + const view = render(); + view.unmount(); + + expect(removeEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); +}); diff --git a/packages/web/src/components/ui/_internal/use-viewport.ts b/packages/web/src/components/ui/_internal/use-viewport.ts new file mode 100644 index 000000000..0b6d9c8e7 --- /dev/null +++ b/packages/web/src/components/ui/_internal/use-viewport.ts @@ -0,0 +1,40 @@ +import { useSyncExternalStore } from "react"; + +export type Viewport = "mobile" | "desktop"; + +const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)"; + +const subscribe = (onStoreChange: () => void) => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return () => undefined; + } + + const mediaQueryList = window.matchMedia(VIEWPORT_QUERY); + const supportsEventListener = typeof mediaQueryList.addEventListener === "function"; + + if (supportsEventListener) { + mediaQueryList.addEventListener("change", onStoreChange); + return () => { + mediaQueryList.removeEventListener("change", onStoreChange); + }; + } + + mediaQueryList.addListener(onStoreChange); + return () => { + mediaQueryList.removeListener(onStoreChange); + }; +}; + +const getSnapshot = (): Viewport => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return "desktop"; + } + + return window.matchMedia(VIEWPORT_QUERY).matches ? "mobile" : "desktop"; +}; + +const getServerSnapshot = (): Viewport => "desktop"; + +export function useViewport(): Viewport { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/packages/web/src/components/ui/button/README.md b/packages/web/src/components/ui/button/README.md new file mode 100644 index 000000000..23edef0d2 --- /dev/null +++ b/packages/web/src/components/ui/button/README.md @@ -0,0 +1,23 @@ +# Button + +## 使用 +从 `src/components/ui/index.ts` 的 public barrel 导入后使用: + +```tsx + +``` + +## Props +| Prop | Type | Default | 说明 | +|---|---|---|---| +| `variant` | `"primary" \| "secondary" \| "ghost" \| "danger"` | `"secondary"` | 视觉变体 | +| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 尺寸 | +| `loading` | `boolean` | `false` | 显示 spinner,并禁用 button 点击 | +| `leadingIcon` | `ReactNode` | `undefined` | 文本前图标 | +| `trailingIcon` | `ReactNode` | `undefined` | 文本后图标 | +| `as` | `"button" \| "a"` | `"button"` | 渲染元素 | + +## 注意 +- `danger` 只用于破坏性操作。 +- `loading` 只会对原生 `); + + const button = screen.getByRole("button", { name: "Save" }); + expect(button).toHaveAttribute("type", "button"); + expect(button).toBeEnabled(); + }); + + it("supports the four visual variants", () => { + render( + <> + + + + + + ); + + expect(screen.getByRole("button", { name: "Primary" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Secondary" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ghost" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Danger" })).toBeInTheDocument(); + }); + + it("supports the three size options", () => { + render( + <> + + + + + ); + + expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Medium" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Large" })).toBeInTheDocument(); + }); + + it("preserves legacy compatibility classes for migrated callers", () => { + render( + + ); + + expect(screen.getByRole("button", { name: "Submit" })).toHaveClass( + "btn", + "btn-primary", + "btn-lg", + "auth-submit" + ); + }); + + it("disables click handlers while loading", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render( + + ); + + const button = screen.getByRole("button", { name: "Create" }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("aria-busy", "true"); + + await user.click(button); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("calls onClick when enabled", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render(); + + await user.click(screen.getByRole("button", { name: "Run" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders leading and trailing icons", () => { + render( + + ); + + expect(screen.getByTestId("leading-icon")).toBeInTheDocument(); + expect(screen.getByTestId("trailing-icon")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + }); + + it("renders as an anchor when as='a'", () => { + render( + + ); + + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("href", "/settings"); + }); + + it("preserves legacy classes and loading semantics for anchors", () => { + render( + + ); + + expect(screen.getByRole("link", { name: "Settings" })).toHaveClass( + "btn", + "btn-ghost", + "btn-sm" + ); + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("link", { name: "Settings" })).not.toHaveAttribute("disabled"); + }); +}); diff --git a/packages/web/src/components/ui/button/index.tsx b/packages/web/src/components/ui/button/index.tsx new file mode 100644 index 000000000..cb3a399c1 --- /dev/null +++ b/packages/web/src/components/ui/button/index.tsx @@ -0,0 +1,159 @@ +import clsx from "clsx"; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./index.module.css"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +export type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonBaseProps { + readonly children: ReactNode; + readonly className?: string; + readonly variant?: ButtonVariant; + readonly size?: ButtonSize; + readonly loading?: boolean; + readonly leadingIcon?: ReactNode; + readonly trailingIcon?: ReactNode; +} + +type ButtonElementProps = ButtonBaseProps & + Omit, keyof ButtonBaseProps> & { + readonly as?: "button"; + }; + +type AnchorElementProps = ButtonBaseProps & + Omit, keyof ButtonBaseProps> & { + readonly as: "a"; + }; + +export type ButtonProps = ButtonElementProps | AnchorElementProps; + +const variantClassMap: Record = { + primary: styles.primary ?? "", + secondary: styles.secondary ?? "", + ghost: styles.ghost ?? "", + danger: styles.danger ?? "", +}; + +const sizeClassMap: Record = { + sm: styles.sm, + md: undefined, + lg: styles.lg, +}; + +const legacyVariantClassMap: Record = { + primary: "btn-primary", + secondary: "btn-secondary", + ghost: "btn-ghost", + danger: "btn-danger", +}; + +const legacySizeClassMap: Record = { + sm: "btn-sm", + md: undefined, + lg: "btn-lg", +}; + +const ButtonContent = ({ + children, + leadingIcon, + loading, + trailingIcon, +}: Pick) => { + return ( + <> + {loading ? ( + + {content} + + ); + } + + const { + as: _as, + children: _children, + className: _className, + disabled, + leadingIcon: _leadingIcon, + loading: _loading, + size: _size, + trailingIcon: _trailingIcon, + type, + variant: _variant, + ...buttonProps + } = props as ButtonElementProps; + + return ( + + ); +}; diff --git a/packages/web/src/components/ui/index.ts b/packages/web/src/components/ui/index.ts new file mode 100644 index 000000000..d312eed12 --- /dev/null +++ b/packages/web/src/components/ui/index.ts @@ -0,0 +1,2 @@ +export type { ButtonProps, ButtonSize, ButtonVariant } from "./button"; +export { Button } from "./button"; diff --git a/packages/web/src/favicon.test.ts b/packages/web/src/favicon.test.ts new file mode 100644 index 000000000..21abce241 --- /dev/null +++ b/packages/web/src/favicon.test.ts @@ -0,0 +1,13 @@ +// @vitest-environment node + +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("web favicon wiring", () => { + it("references the root favicon.ico asset from index.html", () => { + const indexHtml = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + + expect(indexHtml).toContain('href="/favicon.ico"'); + expect(indexHtml).not.toContain("/vite.svg"); + }); +}); diff --git a/packages/web/src/features/auth/index.tsx b/packages/web/src/features/auth/index.tsx index 5a5feeb03..9ef2cc1dc 100644 --- a/packages/web/src/features/auth/index.tsx +++ b/packages/web/src/features/auth/index.tsx @@ -2,6 +2,7 @@ import { useAtom, useAtomValue } from "jotai"; import { useEffect, useState } from "react"; import { authenticatedAtom, localeAtom } from "../../atoms/app-ui"; import { authEnabledAtom } from "../../atoms/connection"; +import { Button } from "../../components/ui"; import { useViewport } from "../../hooks/use-viewport"; import { formatDate, useTranslation } from "../../lib/i18n"; @@ -157,13 +158,15 @@ export function LoginPage() { onChange={(e) => setPassword(e.target.value)} placeholder={t("settings.auth.password")} /> - + diff --git a/packages/web/src/features/command-palette/components/command-palette.tsx b/packages/web/src/features/command-palette/components/command-palette.tsx index e2fae79e2..792d267ed 100644 --- a/packages/web/src/features/command-palette/components/command-palette.tsx +++ b/packages/web/src/features/command-palette/components/command-palette.tsx @@ -17,6 +17,7 @@ import { } from "../../../atoms/workspaces"; import { useViewport } from "../../../hooks/use-viewport"; import { useTranslation } from "../../../lib/i18n"; +import { formatWorkspaceLabel } from "../../notifications/format"; import { bottomPanelHeightAtom, focusModeAtom, @@ -382,7 +383,7 @@ function buildCommands(context: { // Add workspace switch commands workspaces.forEach((ws) => { - const workspaceLabel = ws.name || ws.path?.split("/").pop() || ws.path || ws.id; + const workspaceLabel = formatWorkspaceLabel(ws) || ws.id; commands.push({ id: `switch-workspace-${ws.id}`, diff --git a/packages/web/src/features/notifications/format.test.ts b/packages/web/src/features/notifications/format.test.ts index c1b2fb611..59f80d0ae 100644 --- a/packages/web/src/features/notifications/format.test.ts +++ b/packages/web/src/features/notifications/format.test.ts @@ -21,6 +21,15 @@ describe("formatWorkspaceLabel", () => { expect(formatWorkspaceLabel({ name: "My Project", path: "/tmp/foo" })).toBe("My Project"); }); + it("extracts the basename when the explicit name is a full path", () => { + expect( + formatWorkspaceLabel({ + name: "/home/spencer/workspace/coder-studio", + path: "/home/spencer/workspace/coder-studio", + }) + ).toBe("coder-studio"); + }); + it("falls back to the basename of the path", () => { expect(formatWorkspaceLabel({ path: "/home/spencer/workspace/coder-studio" })).toBe( "coder-studio" diff --git a/packages/web/src/features/notifications/format.ts b/packages/web/src/features/notifications/format.ts index ea3b58f2f..1b3d8ffb2 100644 --- a/packages/web/src/features/notifications/format.ts +++ b/packages/web/src/features/notifications/format.ts @@ -28,11 +28,15 @@ export function formatWorkspaceLabel( ): string { if (!workspace) return ""; const name = workspace.name?.trim(); - if (name) return name; + if (name) return extractWorkspaceLeafName(name); const path = workspace.path?.trim(); if (!path) return ""; + return extractWorkspaceLeafName(path); +} + +function extractWorkspaceLeafName(value: string): string { // Strip trailing slashes, then take last segment. - const cleaned = path.replace(/[/\\]+$/, ""); + const cleaned = value.replace(/[/\\]+$/, ""); const parts = cleaned.split(/[/\\]/); return parts[parts.length - 1] || cleaned; } diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index 33f9b9161..53ffc0897 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type ConnectionStatus, connectionStatusAtom, + serverInfoAtom, wsClientAtom, } from "../../../atoms/connection"; import { activeWorkspaceIdAtom } from "../../../atoms/workspaces"; @@ -233,6 +234,18 @@ describe("SettingsPage", () => { expect(screen.getByText("settings exploded")).toBeInTheDocument(); }); + it("renders the footer version from server metadata", () => { + const store = createConnectedStore(vi.fn().mockResolvedValue({})); + store.set(serverInfoAtom, { + version: "0.3.0", + serverInstanceId: "server-123", + }); + + renderSettingsPage(store); + + expect(screen.getByText("v0.3.0")).toBeInTheDocument(); + }); + it("does not render default Agent Provider selection in general settings", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 35b5d4271..046bac808 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -14,7 +14,11 @@ import { Check, ChevronRight } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { localeAtom, themeAtom } from "../../../atoms/app-ui"; -import { connectionStatusAtom, dispatchCommandAtom } from "../../../atoms/connection"; +import { + connectionStatusAtom, + dispatchCommandAtom, + serverInfoAtom, +} from "../../../atoms/connection"; import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; import { useViewport } from "../../../hooks/use-viewport"; import { useTranslation } from "../../../lib/i18n"; @@ -122,6 +126,7 @@ export function SettingsPage() { const isMobile = viewport === "mobile"; const dispatch = useAtomValue(dispatchCommandAtom); const connectionStatus = useAtomValue(connectionStatusAtom); + const serverInfo = useAtomValue(serverInfoAtom); const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); const [navigationState, setNavigationState] = useState(() => isMobile @@ -383,7 +388,7 @@ export function SettingsPage() {
{t("settings.autosave_hint")} - v0.2.6 + v{serverInfo?.version ?? "0.0.0"}
); diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index 352de616e..e2dda9c4f 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -378,7 +378,9 @@ describe("XtermHost", () => { expect(await screen.findByText("正在恢复终端内容…")).toBeInTheDocument(); expect( - screen.getByText("你已经可以继续使用当前页面;历史内容会在后台补上,内容较多时可能需要更久。") + screen.getByText( + "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。" + ) ).toBeInTheDocument(); global.requestAnimationFrame = originalRequestAnimationFrame; diff --git a/packages/web/src/features/topbar/components/tab.test.tsx b/packages/web/src/features/topbar/components/tab.test.tsx index 2bf4e1f4e..3996e6d98 100644 --- a/packages/web/src/features/topbar/components/tab.test.tsx +++ b/packages/web/src/features/topbar/components/tab.test.tsx @@ -43,6 +43,24 @@ describe("WorkspaceTab", () => { routerMocks.navigate.mockReset(); }); + it("renders the folder basename when workspace name is a full path", () => { + const workspace = { + ...createWorkspace("ws-2", "/home/spencer/workspace/coder-studio"), + name: "/home/spencer/workspace/coder-studio", + }; + const store = createStore(); + store.set(localeAtom, "en"); + + render( + + + + ); + + expect(screen.getByText("coder-studio")).toBeInTheDocument(); + expect(screen.queryByText("/home/spencer/workspace/coder-studio")).toBeNull(); + }); + it("sets the active workspace without navigating when a tab is clicked", () => { const workspace = createWorkspace("ws-2", "/tmp/two"); const store = createStore(); diff --git a/packages/web/src/features/topbar/components/tab.tsx b/packages/web/src/features/topbar/components/tab.tsx index c8585c8ba..7fd9c2d85 100644 --- a/packages/web/src/features/topbar/components/tab.tsx +++ b/packages/web/src/features/topbar/components/tab.tsx @@ -11,6 +11,7 @@ import { X } from "lucide-react"; import type { FC } from "react"; import { activeWorkspaceIdAtom } from "../../../atoms/workspaces"; import { useTranslation } from "../../../lib/i18n"; +import { formatWorkspaceLabel } from "../../notifications/format"; import { useWorkspaceCloseAction } from "../../workspace/actions/use-workspace-close-action"; interface WorkspaceTabProps { @@ -31,11 +32,7 @@ export const WorkspaceTab: FC = ({ workspace, isActive }) => const t = useTranslation(); const setActiveWorkspace = useSetAtom(activeWorkspaceIdAtom); const closeWorkspace = useWorkspaceCloseAction(); - const displayName = - workspace.name || - workspace.path?.split("/").filter(Boolean).pop() || - workspace.path || - workspace.id; + const displayName = formatWorkspaceLabel(workspace) || workspace.id; const handleClick = () => { setActiveWorkspace(workspace.id); diff --git a/packages/web/src/features/topbar/index.test.tsx b/packages/web/src/features/topbar/index.test.tsx index 5234d587a..39389865e 100644 --- a/packages/web/src/features/topbar/index.test.tsx +++ b/packages/web/src/features/topbar/index.test.tsx @@ -156,7 +156,7 @@ describe("TopBar", () => { expect(settingsButton.nextElementSibling).toBe(fullscreenButton); }); - it("hides the fullscreen toggle when the controller reports unsupported", () => { + it("keeps the fullscreen toggle visible when the controller reports unsupported", () => { const store = createStore(); store.set(localeAtom, "en"); store.set(workspacesLoadStateAtom, "ready"); @@ -175,6 +175,6 @@ describe("TopBar", () => { ); - expect(screen.queryByRole("button", { name: "Enter Fullscreen" })).toBeNull(); + expect(screen.getByRole("button", { name: "Enter Fullscreen" })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx b/packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx index 95335caa3..a5322583a 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx +++ b/packages/web/src/features/workspace/actions/use-workspace-fullscreen.test.tsx @@ -1,6 +1,9 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; import { useRef } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { localeAtom } from "../../../atoms/app-ui"; +import { toastsAtom } from "../../notifications/atoms"; import { useWorkspaceFullscreen } from "./use-workspace-fullscreen"; function installFullscreenApi() { @@ -67,6 +70,36 @@ function clearFullscreenApi() { configurable: true, value: undefined, }); + + Object.defineProperty(document, "webkitFullscreenEnabled", { + configurable: true, + value: undefined, + }); + + Object.defineProperty(document, "webkitCurrentFullScreenElement", { + configurable: true, + get: () => undefined, + }); + + Object.defineProperty(document, "webkitExitFullscreen", { + configurable: true, + value: undefined, + }); + + Object.defineProperty(document, "webkitCancelFullScreen", { + configurable: true, + value: undefined, + }); + + Object.defineProperty(HTMLElement.prototype, "webkitRequestFullscreen", { + configurable: true, + value: undefined, + }); + + Object.defineProperty(HTMLElement.prototype, "webkitRequestFullScreen", { + configurable: true, + value: undefined, + }); } function HookHarness() { @@ -106,6 +139,19 @@ function HookHarness() { ); } +function renderHarness() { + const store = createStore(); + store.set(localeAtom, "en"); + + render( + + + + ); + + return store; +} + describe("useWorkspaceFullscreen", () => { afterEach(() => { clearFullscreenApi(); @@ -115,7 +161,7 @@ describe("useWorkspaceFullscreen", () => { it("reports unsupported when the browser fullscreen api is unavailable", async () => { clearFullscreenApi(); - render(); + renderHarness(); await waitFor(() => { expect(screen.getByTestId("supported")).toHaveTextContent("false"); @@ -126,7 +172,7 @@ describe("useWorkspaceFullscreen", () => { it("enters and exits fullscreen against the target element", async () => { const api = installFullscreenApi(); - render(); + renderHarness(); await waitFor(() => { expect(screen.getByTestId("supported")).toHaveTextContent("true"); @@ -150,7 +196,7 @@ describe("useWorkspaceFullscreen", () => { it("tracks fullscreenchange even when the browser exits fullscreen outside the button", async () => { const api = installFullscreenApi(); - render(); + renderHarness(); const target = await screen.findByTestId("fullscreen-target"); @@ -175,7 +221,7 @@ describe("useWorkspaceFullscreen", () => { .mockRejectedValue(requestError); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - render(); + renderHarness(); fireEvent.click(screen.getByRole("button", { name: "toggle" })); @@ -186,4 +232,79 @@ describe("useWorkspaceFullscreen", () => { expect(screen.getByTestId("fullscreen")).toHaveTextContent("false"); }); + + it("shows a friendly toast when fullscreen is unavailable instead of throwing", async () => { + clearFullscreenApi(); + const store = renderHarness(); + + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + + await waitFor(() => { + expect(store.get(toastsAtom)).toHaveLength(1); + }); + + expect(store.get(toastsAtom)[0]).toMatchObject({ + kind: "info", + title: "Fullscreen unavailable", + body: "Fullscreen is not available in this browser.", + }); + }); + + it("treats webkit fullscreen support as supported even when fullscreenEnabled is false", async () => { + let webkitFullscreenElement: Element | null = null; + const webkitRequestFullscreen = vi.fn().mockImplementation(function (this: HTMLElement) { + webkitFullscreenElement = this; + document.dispatchEvent(new Event("webkitfullscreenchange")); + return Promise.resolve(); + }); + const webkitExitFullscreen = vi.fn().mockImplementation(async () => { + webkitFullscreenElement = null; + document.dispatchEvent(new Event("webkitfullscreenchange")); + }); + + Object.defineProperty(document, "fullscreenEnabled", { + configurable: true, + value: false, + }); + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => null, + }); + Object.defineProperty(document, "exitFullscreen", { + configurable: true, + value: undefined, + }); + Object.defineProperty(document, "webkitCurrentFullScreenElement", { + configurable: true, + get: () => webkitFullscreenElement, + }); + Object.defineProperty(document, "webkitExitFullscreen", { + configurable: true, + value: webkitExitFullscreen, + }); + Object.defineProperty(HTMLElement.prototype, "webkitRequestFullscreen", { + configurable: true, + value: webkitRequestFullscreen, + }); + + renderHarness(); + + await waitFor(() => { + expect(screen.getByTestId("supported")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByRole("button", { name: "enter" })); + + await waitFor(() => { + expect(webkitRequestFullscreen).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("fullscreen")).toHaveTextContent("true"); + }); + + fireEvent.click(screen.getByRole("button", { name: "exit" })); + + await waitFor(() => { + expect(webkitExitFullscreen).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("fullscreen")).toHaveTextContent("false"); + }); + }); }); diff --git a/packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts b/packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts index b97a9f9bd..194293437 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts +++ b/packages/web/src/features/workspace/actions/use-workspace-fullscreen.ts @@ -1,4 +1,7 @@ +import { useSetAtom } from "jotai"; import { type RefObject, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "../../../lib/i18n"; +import { pushToastAtom } from "../../notifications/atoms"; export interface WorkspaceFullscreenController { supported: boolean; @@ -8,65 +11,153 @@ export interface WorkspaceFullscreenController { toggleFullscreen: () => Promise; } -function canEnterFullscreen( - target: HTMLElement | null -): target is HTMLElement & { requestFullscreen: NonNullable } { - return Boolean( - document.fullscreenEnabled && - target && - typeof target.requestFullscreen === "function" && - typeof document.exitFullscreen === "function" - ); +type FullscreenMethod = () => Promise | void; + +type FullscreenDocument = Document & { + webkitCurrentFullScreenElement?: Element | null; + webkitExitFullscreen?: FullscreenMethod; + webkitCancelFullScreen?: FullscreenMethod; +}; + +type FullscreenTarget = HTMLElement & { + requestFullscreen?: FullscreenMethod; + webkitRequestFullscreen?: FullscreenMethod; + webkitRequestFullScreen?: FullscreenMethod; +}; + +function getFullscreenElement(doc: Document): Element | null { + const fullscreenDocument = doc as FullscreenDocument; + return doc.fullscreenElement ?? fullscreenDocument.webkitCurrentFullScreenElement ?? null; +} + +function getRequestFullscreenMethod(target: HTMLElement | null): FullscreenMethod | null { + if (!target) { + return null; + } + + const fullscreenTarget = target as FullscreenTarget; + + if (typeof fullscreenTarget.requestFullscreen === "function") { + return fullscreenTarget.requestFullscreen.bind(fullscreenTarget); + } + + if (typeof fullscreenTarget.webkitRequestFullscreen === "function") { + return fullscreenTarget.webkitRequestFullscreen.bind(fullscreenTarget); + } + + if (typeof fullscreenTarget.webkitRequestFullScreen === "function") { + return fullscreenTarget.webkitRequestFullScreen.bind(fullscreenTarget); + } + + return null; +} + +function getExitFullscreenMethod(doc: Document): FullscreenMethod | null { + const fullscreenDocument = doc as FullscreenDocument; + + if (typeof doc.exitFullscreen === "function") { + return doc.exitFullscreen.bind(doc); + } + + if (typeof fullscreenDocument.webkitExitFullscreen === "function") { + return fullscreenDocument.webkitExitFullscreen.bind(doc); + } + + if (typeof fullscreenDocument.webkitCancelFullScreen === "function") { + return fullscreenDocument.webkitCancelFullScreen.bind(doc); + } + + return null; +} + +function canUseFullscreen(target: HTMLElement | null): boolean { + return Boolean(getRequestFullscreenMethod(target) && getExitFullscreenMethod(document)); +} + +function getErrorMessage(error: unknown): string | null { + return error instanceof Error && error.message.trim().length > 0 ? error.message : null; } export function useWorkspaceFullscreen( targetRef: RefObject ): WorkspaceFullscreenController { + const t = useTranslation(); + const pushToast = useSetAtom(pushToastAtom); const [supported, setSupported] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); + const showUnsupportedToast = useCallback(() => { + pushToast({ + kind: "info", + title: t("workspace.fullscreen.unsupported_title"), + body: t("workspace.fullscreen.unsupported_body"), + }); + }, [pushToast, t]); + useEffect(() => { const syncState = () => { const target = targetRef.current; - setSupported(canEnterFullscreen(target)); - setIsFullscreen(Boolean(target && document.fullscreenElement === target)); + setSupported(canUseFullscreen(target)); + setIsFullscreen(Boolean(target && getFullscreenElement(document) === target)); }; syncState(); document.addEventListener("fullscreenchange", syncState); + document.addEventListener("webkitfullscreenchange", syncState as EventListener); return () => { document.removeEventListener("fullscreenchange", syncState); + document.removeEventListener("webkitfullscreenchange", syncState as EventListener); }; }, [targetRef]); const enterFullscreen = useCallback(async () => { const target = targetRef.current; - if (!canEnterFullscreen(target)) { + const requestFullscreen = getRequestFullscreenMethod(target); + + if (!requestFullscreen || !getExitFullscreenMethod(document)) { + showUnsupportedToast(); return; } try { - await target.requestFullscreen(); + await Promise.resolve(requestFullscreen()); } catch (error) { console.warn("Failed to enter fullscreen", error); + pushToast({ + kind: "warning", + title: t("workspace.fullscreen.enter_failed_title"), + body: getErrorMessage(error) ?? t("workspace.fullscreen.enter_failed_body"), + }); } - }, [targetRef]); + }, [pushToast, showUnsupportedToast, t, targetRef]); const exitFullscreen = useCallback(async () => { - if (typeof document.exitFullscreen !== "function" || !document.fullscreenElement) { + const exitFullscreenMethod = getExitFullscreenMethod(document); + + if (!getFullscreenElement(document)) { + return; + } + + if (!exitFullscreenMethod) { + showUnsupportedToast(); return; } try { - await document.exitFullscreen(); + await Promise.resolve(exitFullscreenMethod()); } catch (error) { console.warn("Failed to exit fullscreen", error); + pushToast({ + kind: "warning", + title: t("workspace.fullscreen.exit_failed_title"), + body: getErrorMessage(error) ?? t("workspace.fullscreen.exit_failed_body"), + }); } - }, []); + }, [pushToast, showUnsupportedToast, t]); const toggleFullscreen = useCallback(async () => { - if (targetRef.current && document.fullscreenElement === targetRef.current) { + if (targetRef.current && getFullscreenElement(document) === targetRef.current) { await exitFullscreen(); return; } diff --git a/packages/web/src/features/workspace/components/workspace-fullscreen-button.test.tsx b/packages/web/src/features/workspace/components/workspace-fullscreen-button.test.tsx index a617fb899..f2774bb80 100644 --- a/packages/web/src/features/workspace/components/workspace-fullscreen-button.test.tsx +++ b/packages/web/src/features/workspace/components/workspace-fullscreen-button.test.tsx @@ -12,22 +12,22 @@ function renderWithEnglish(ui: React.ReactElement) { } describe("WorkspaceFullscreenButton", () => { - it("renders nothing when fullscreen is unsupported", () => { - const { container } = renderWithEnglish( + it("renders the fullscreen control even when the controller reports unsupported", () => { + renderWithEnglish( ); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByRole("button", { name: "Enter Fullscreen" })).toBeInTheDocument(); }); it("renders enter state and calls toggle", () => { @@ -69,4 +69,12 @@ describe("WorkspaceFullscreenButton", () => { expect(screen.getByRole("button", { name: "Exit Fullscreen" })).toBeInTheDocument(); }); + + it("renders nothing when no fullscreen controller is provided", () => { + const { container } = renderWithEnglish( + + ); + + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/packages/web/src/features/workspace/components/workspace-fullscreen-button.tsx b/packages/web/src/features/workspace/components/workspace-fullscreen-button.tsx index 202238d18..b1892bf26 100644 --- a/packages/web/src/features/workspace/components/workspace-fullscreen-button.tsx +++ b/packages/web/src/features/workspace/components/workspace-fullscreen-button.tsx @@ -17,7 +17,7 @@ export function WorkspaceFullscreenButton({ }: WorkspaceFullscreenButtonProps) { const t = useTranslation(); - if (!controller?.supported) { + if (!controller) { return null; } diff --git a/packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx b/packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx index ce1d8c328..3d943233b 100644 --- a/packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx +++ b/packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx @@ -1,6 +1,7 @@ import type { Workspace } from "@coder-studio/core"; import { Menu, Settings2 } from "lucide-react"; import { useTranslation } from "../../../../lib/i18n"; +import { formatWorkspaceLabel } from "../../../notifications/format"; import type { WorkspaceFullscreenController } from "../../actions/use-workspace-fullscreen"; import { WorkspaceFullscreenButton } from "../../components/workspace-fullscreen-button"; @@ -21,10 +22,7 @@ export function MobileTopBar({ }: MobileTopBarProps) { const t = useTranslation(); const workspaceLabel = - activeWorkspace?.name ?? - activeWorkspace?.path?.split("/").filter(Boolean).pop() ?? - activeWorkspace?.path ?? - t("mobile.workspace_drawer.select_title"); + formatWorkspaceLabel(activeWorkspace) || t("mobile.workspace_drawer.select_title"); return (
diff --git a/packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx b/packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx index 3ca1a6b7f..94c0b6e17 100644 --- a/packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx +++ b/packages/web/src/features/workspace/views/mobile/mobile-workspace-drawer.tsx @@ -4,6 +4,7 @@ import { X } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { activeWorkspaceIdAtom } from "../../../../atoms/workspaces"; import { useTranslation } from "../../../../lib/i18n"; +import { formatWorkspaceLabel } from "../../../notifications/format"; import { useWorkspaceCloseAction } from "../../actions/use-workspace-close-action"; interface MobileWorkspaceDrawerProps { @@ -51,11 +52,7 @@ export function MobileWorkspaceDrawer({
{workspaces.map((workspace) => { - const displayName = - workspace.name || - workspace.path?.split("/").filter(Boolean).pop() || - workspace.path || - workspace.id; + const displayName = formatWorkspaceLabel(workspace) || workspace.id; return (
void; +const VIEWPORT_QUERY = "(max-width: 899px), (pointer: coarse)"; -interface MockMediaQueryList { - matches: boolean; - media: string; - addEventListener: (type: "change", listener: MQListener) => void; - removeEventListener: (type: "change", listener: MQListener) => void; - trigger: (matches: boolean) => void; -} - -function createMatchMediaMock(initialMatches: (query: string) => boolean) { - const lists = new Map(); - - const matchMedia = vi.fn((query: string) => { - if (lists.has(query)) { - return lists.get(query)!; - } - - const listeners = new Set(); - const list: MockMediaQueryList = { - matches: initialMatches(query), - media: query, - addEventListener: (_type, listener) => listeners.add(listener), - removeEventListener: (_type, listener) => listeners.delete(listener), - trigger: (matches: boolean) => { - list.matches = matches; - for (const listener of listeners) { - listener({ matches }); - } - }, - }; - - lists.set(query, list); - return list; - }); - - return { lists, matchMedia }; -} - -describe("useViewport", () => { +describe("useViewport compatibility re-export", () => { let originalMatchMedia: typeof window.matchMedia; beforeEach(() => { @@ -52,58 +15,21 @@ describe("useViewport", () => { window.matchMedia = originalMatchMedia; }); - it('returns "desktop" when viewport is wide and pointer is fine', () => { - const { matchMedia } = createMatchMediaMock(() => false); - window.matchMedia = matchMedia as unknown as typeof window.matchMedia; - - const { result } = renderHook(() => useViewport()); - - expect(result.current).toBe("desktop"); - }); - - it('returns "mobile" when viewport is narrow', () => { - const { matchMedia } = createMatchMediaMock((query) => query.includes("max-width: 899px")); - window.matchMedia = matchMedia as unknown as typeof window.matchMedia; - - const { result } = renderHook(() => useViewport()); - - expect(result.current).toBe("mobile"); - }); - - it('returns "desktop" when pointer is coarse but viewport is wide', () => { - const { matchMedia } = createMatchMediaMock((query) => query.includes("pointer: coarse")); - window.matchMedia = matchMedia as unknown as typeof window.matchMedia; - - const { result } = renderHook(() => useViewport()); - - expect(result.current).toBe("desktop"); - }); + it("keeps wide coarse-pointer devices on the mobile branch through the legacy import path", () => { + const matchMedia = vi.fn((query: string) => ({ + matches: query === VIEWPORT_QUERY, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })); - it("updates reactively when the viewport query changes", () => { - const { lists, matchMedia } = createMatchMediaMock(() => false); window.matchMedia = matchMedia as unknown as typeof window.matchMedia; const { result } = renderHook(() => useViewport()); - expect(result.current).toBe("desktop"); - - act(() => { - lists.get("(max-width: 899px)")!.trigger(true); - }); - + expect(matchMedia).toHaveBeenCalledWith(VIEWPORT_QUERY); expect(result.current).toBe("mobile"); }); - - it("cleans up listeners on unmount", () => { - const { lists, matchMedia } = createMatchMediaMock(() => false); - window.matchMedia = matchMedia as unknown as typeof window.matchMedia; - - const { unmount } = renderHook(() => useViewport()); - const widthList = lists.get("(max-width: 899px)")!; - const widthRemove = vi.spyOn(widthList, "removeEventListener"); - - unmount(); - - expect(widthRemove).toHaveBeenCalledWith("change", expect.any(Function)); - }); }); diff --git a/packages/web/src/hooks/use-viewport.ts b/packages/web/src/hooks/use-viewport.ts index 4b5980301..e731b1e63 100644 --- a/packages/web/src/hooks/use-viewport.ts +++ b/packages/web/src/hooks/use-viewport.ts @@ -1,37 +1,2 @@ -import { useEffect, useState } from "react"; - -export type Viewport = "mobile" | "desktop"; - -const WIDTH_QUERY = "(max-width: 899px)"; - -function computeViewport(): Viewport { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return "desktop"; - } - - return window.matchMedia(WIDTH_QUERY).matches ? "mobile" : "desktop"; -} - -export function useViewport(): Viewport { - const [viewport, setViewport] = useState(computeViewport); - - useEffect(() => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return; - } - - const widthList = window.matchMedia(WIDTH_QUERY); - const handleChange = () => { - setViewport(computeViewport()); - }; - - widthList.addEventListener("change", handleChange); - handleChange(); - - return () => { - widthList.removeEventListener("change", handleChange); - }; - }, []); - - return viewport; -} +export type { Viewport } from "../components/ui/_internal/use-viewport"; +export { useViewport } from "../components/ui/_internal/use-viewport"; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 12a02cd14..2f19c9742 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -102,6 +102,14 @@ "close": "Close", "path": "Path", "no_workspace": "No workspace open", + "fullscreen": { + "unsupported_title": "Fullscreen unavailable", + "unsupported_body": "Fullscreen is not available in this browser.", + "enter_failed_title": "Could not enter fullscreen", + "enter_failed_body": "The browser denied the fullscreen request.", + "exit_failed_title": "Could not exit fullscreen", + "exit_failed_body": "The browser could not exit fullscreen right now." + }, "open_hint": "Click the button below to open a project directory", "loading_title": "Loading workspaces", "loading_description": "Preparing your workspace list and restoring the last active session.", @@ -245,7 +253,7 @@ "queued_title": "Waiting in queue ({count} ahead)", "up_next": "Up next...", "loading_title": "Restoring terminal output...", - "loading_body": "You can keep using this page while history catches up in the background. Larger histories may take longer to restore.", + "loading_body": "This terminal is unavailable while history is being restored. Please wait until recovery finishes before continuing. Larger histories may take longer to restore.", "failed_title": "Terminal history could not be restored", "failed_body": "New output will keep streaming. Refresh manually only if you need the missing history.", "closed_title": "This session has been closed", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index a8d3dbdca..6edb56b7d 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -102,6 +102,14 @@ "close": "关闭", "path": "路径", "no_workspace": "未打开工作区", + "fullscreen": { + "unsupported_title": "当前无法全屏", + "unsupported_body": "当前浏览器不支持全屏功能。", + "enter_failed_title": "进入全屏失败", + "enter_failed_body": "浏览器拒绝了全屏请求。", + "exit_failed_title": "退出全屏失败", + "exit_failed_body": "浏览器暂时无法退出全屏。" + }, "open_hint": "点击下方按钮打开一个项目目录", "loading_title": "正在加载工作区", "loading_description": "正在准备工作区列表并恢复上次活跃的会话。", @@ -245,7 +253,7 @@ "queued_title": "等待队列中(前方还有 {count} 个)", "up_next": "即将开始…", "loading_title": "正在恢复终端内容…", - "loading_body": "你已经可以继续使用当前页面;历史内容会在后台补上,内容较多时可能需要更久。", + "loading_body": "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。", "failed_title": "历史内容恢复失败", "failed_body": "新输出仍会继续显示;如果需要完整历史,再手动刷新页面。", "closed_title": "该会话已被关闭", diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index 7e6bcdd68..dbba966e8 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -27,6 +27,7 @@ import { workspacesLoadStateAtom, } from "../../atoms/workspaces"; import { paneLayoutAtomFamily } from "../../features/agent-panes/atoms/pane-layout"; +import { toastsAtom } from "../../features/notifications/atoms"; import { supervisorCyclesAtom, supervisorsAtom } from "../../features/supervisor/atoms"; import { branchQuickPickAtom, @@ -777,12 +778,30 @@ describe("MobileShell Phase 2 workspace", () => { expect(settingsButton.nextElementSibling).toBe(fullscreenButton); }); - it("hides the fullscreen toggle on mobile when the browser does not support fullscreen", async () => { + it("keeps the fullscreen toggle visible on mobile when the browser does not support fullscreen", async () => { removeFullscreenApiForMobileShell(); renderMobileShell({ initialEntry: "/workspace" }); expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Enter Fullscreen" })).toBeNull(); + expect(screen.getByRole("button", { name: "Enter Fullscreen" })).toBeInTheDocument(); + }); + + it("shows a friendly toast instead of crashing when mobile fullscreen is unavailable", async () => { + removeFullscreenApiForMobileShell(); + const user = userEvent.setup(); + const { store } = renderMobileShell({ initialEntry: "/workspace" }); + + await user.click(screen.getByRole("button", { name: "Enter Fullscreen" })); + + await waitFor(() => { + expect(store.get(toastsAtom)).toHaveLength(1); + }); + + expect(store.get(toastsAtom)[0]).toMatchObject({ + kind: "info", + title: "Fullscreen unavailable", + body: "Fullscreen is not available in this browser.", + }); }); it("switches the mobile fullscreen button to exit mode after entering fullscreen", async () => { diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efb81a2de..ccded3c33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@25.6.0) + '@coder-studio/utils': + specifier: workspace:* + version: link:packages/utils '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -159,6 +162,9 @@ importers: '@coder-studio/providers': specifier: workspace:* version: link:../providers + '@coder-studio/utils': + specifier: workspace:* + version: link:../utils '@fastify/compress': specifier: ^8.3.1 version: 8.3.1 @@ -221,6 +227,18 @@ importers: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(tsx@4.21.0)) + packages/utils: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(tsx@4.21.0)) + packages/web: dependencies: '@coder-studio/core': @@ -238,6 +256,9 @@ importers: '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 jotai: specifier: ^2.19.1 version: 2.19.1(@types/react@19.2.14)(react@19.2.5) @@ -1349,6 +1370,10 @@ packages: resolution: {integrity: sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==} engines: {node: '>=8.10.0'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3720,6 +3745,8 @@ snapshots: dependencies: chalk: 3.0.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/scripts/build-cli.ts b/scripts/build-cli.ts index fb8e82ca3..4f8277eed 100644 --- a/scripts/build-cli.ts +++ b/scripts/build-cli.ts @@ -22,6 +22,7 @@ import { success, WEB_DIST_DIR, } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; export interface CliOutputDirs { cliDistDir: string; @@ -100,7 +101,7 @@ import('./esm/bin.mjs').catch((err) => { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { buildCli() .then(() => { log("\n✓ CLI build complete.\n"); diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 2b3afb044..9841a7662 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -4,7 +4,7 @@ */ import { error, exists, info, log, step, success, WEB_DIR, WEB_DIST_DIR } from "./shared/index.js"; -import { run } from "./shared/process.js"; +import { isDirectExecution, run } from "./shared/process.js"; async function buildWeb(): Promise { step("BUILD WEB", "Building frontend static assets...\n"); @@ -21,7 +21,7 @@ async function buildWeb(): Promise { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { buildWeb() .then(() => { log("\n✓ Web build complete.\n"); diff --git a/scripts/build.ts b/scripts/build.ts index baa2fe5b2..f680e683a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -6,6 +6,7 @@ import { buildCli } from "./build-cli.js"; import { buildWeb } from "./build-web.js"; import { error, info, log, step, success } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; async function build(): Promise { step("BUILD", "Running full production build...\n"); @@ -20,7 +21,7 @@ async function build(): Promise { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { build() .then(() => { process.exit(0); diff --git a/scripts/dev-server.ts b/scripts/dev-server.ts index 579759023..faa923d0e 100644 --- a/scripts/dev-server.ts +++ b/scripts/dev-server.ts @@ -3,9 +3,8 @@ * Starts tsx watch for backend */ -import { resolve } from "path"; import { error, info, log, SERVER_DIR, success } from "./shared/index.js"; -import { runBackground } from "./shared/process.js"; +import { isDirectExecution, runBackground } from "./shared/process.js"; const SERVER_PORT = 4173; const SERVER_HOST = "127.0.0.1"; @@ -44,7 +43,7 @@ async function devServer(): Promise { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { devServer().catch((err) => { error(err.message); process.exit(1); diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 0addb21f8..8621af4f6 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -3,9 +3,8 @@ * Starts Vite dev server for frontend */ -import { resolve } from "path"; import { error, info, log, success, WEB_DIR } from "./shared/index.js"; -import { runBackground } from "./shared/process.js"; +import { isDirectExecution, runBackground } from "./shared/process.js"; const VITE_PORT = 5173; const VITE_HOST = "localhost"; @@ -39,7 +38,7 @@ async function devWeb(): Promise { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { devWeb().catch((err) => { error(err.message); process.exit(1); diff --git a/scripts/dev.ts b/scripts/dev.ts index aaec7ee61..27cc89daa 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -4,7 +4,7 @@ */ import { error, info, log, SERVER_DIR, step, success, WEB_DIR } from "./shared/index.js"; -import { runBackground, waitForProcesses } from "./shared/process.js"; +import { isDirectExecution, runBackground, waitForProcesses } from "./shared/process.js"; const VITE_PORT = 5173; const VITE_HOST = "localhost"; @@ -65,7 +65,7 @@ async function dev(): Promise { } // Run if called directly -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { dev().catch((err) => { error(err.message); process.exit(1); diff --git a/scripts/publish-cli.ts b/scripts/publish-cli.ts index 1647e0728..e6e5cd846 100644 --- a/scripts/publish-cli.ts +++ b/scripts/publish-cli.ts @@ -10,6 +10,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { build } from "./build.js"; import { CLI_DIR, error, info, step, success } from "./shared/index.js"; +import { isDirectExecution, shouldUseShellForCommand } from "./shared/process.js"; export interface PublishCliOptions { access: string; @@ -264,8 +265,9 @@ export async function execCommand( const child = spawn(command, args, { cwd: options.cwd, env: process.env, - shell: false, + shell: shouldUseShellForCommand(command), stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : "inherit", + windowsHide: true, }); let stdout = ""; @@ -461,7 +463,7 @@ Options: `); } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { runPublishCli({ options: parsePublishCliArgs(process.argv.slice(2)) }).catch((err) => { error(err.message); process.exit(1); diff --git a/scripts/shared/esbuild.ts b/scripts/shared/esbuild.ts index 5d61c08e9..795609d5c 100644 --- a/scripts/shared/esbuild.ts +++ b/scripts/shared/esbuild.ts @@ -4,7 +4,7 @@ import { type BuildOptions } from "esbuild"; import { resolve } from "path"; -import { CLI_DIR, CORE_DIR, PACKAGES_DIR, PROVIDERS_DIR, SERVER_DIR } from "./paths.js"; +import { CLI_DIR, CORE_DIR, PACKAGES_DIR, PROVIDERS_DIR, SERVER_DIR, UTILS_DIR } from "./paths.js"; /** * Get external dependencies from package.json @@ -33,6 +33,7 @@ export async function createCliBuildOptions(format: "esm" | "cjs"): Promise { const serverExternal = await getExternalDeps(SERVER_DIR); const coreExternal = await getExternalDeps(CORE_DIR); const providersExternal = await getExternalDeps(PROVIDERS_DIR); + const utilsExternal = await getExternalDeps(UTILS_DIR); // Combine all dependencies and filter out internal packages const allDeps = new Set([ @@ -93,6 +97,7 @@ export async function getProductionDeps(): Promise { ...serverExternal, ...coreExternal, ...providersExternal, + ...utilsExternal, ]); return Array.from(allDeps).filter((dep) => !dep.startsWith("@coder-studio/")); diff --git a/scripts/shared/paths.ts b/scripts/shared/paths.ts index 904c49b43..0b7296b73 100644 --- a/scripts/shared/paths.ts +++ b/scripts/shared/paths.ts @@ -16,6 +16,7 @@ export const PACKAGES_DIR = resolve(ROOT_DIR, "packages"); export const CORE_DIR = resolve(PACKAGES_DIR, "core"); export const PROVIDERS_DIR = resolve(PACKAGES_DIR, "providers"); export const SERVER_DIR = resolve(PACKAGES_DIR, "server"); +export const UTILS_DIR = resolve(PACKAGES_DIR, "utils"); export const WEB_DIR = resolve(PACKAGES_DIR, "web"); export const CLI_DIR = resolve(PACKAGES_DIR, "cli"); diff --git a/scripts/shared/process.ts b/scripts/shared/process.ts index 7d38bb86f..c3242bdab 100644 --- a/scripts/shared/process.ts +++ b/scripts/shared/process.ts @@ -2,7 +2,10 @@ * Process utilities for running child processes */ -import { type ChildProcess, spawn } from "child_process"; +import { isDirectExecution, shouldUseShellForCommand } from "@coder-studio/utils"; +import { type ChildProcess, type SpawnOptions, spawn } from "child_process"; + +export { isDirectExecution, shouldUseShellForCommand }; export interface ProcessOptions { cwd?: string; @@ -10,6 +13,26 @@ export interface ProcessOptions { stdio?: "inherit" | "pipe" | "ignore"; } +interface SpawnConfig { + command: string; + options: ProcessOptions; + platform?: NodeJS.Platform; +} + +function createSpawnOptions({ + command, + options, + platform = process.platform, +}: SpawnConfig): SpawnOptions { + return { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: options.stdio ?? "inherit", + shell: shouldUseShellForCommand(command, platform), + windowsHide: true, + }; +} + /** * Run a command and return a promise */ @@ -19,12 +42,7 @@ export function run( options: ProcessOptions = {} ): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env ?? process.env, - stdio: options.stdio ?? "inherit", - shell: true, - }); + const child = spawn(command, args, createSpawnOptions({ command, options })); child.on("close", (code) => { if (code === 0) { @@ -48,12 +66,7 @@ export function runBackground( args: string[] = [], options: ProcessOptions = {} ): ChildProcess { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env ?? process.env, - stdio: options.stdio ?? "inherit", - shell: true, - }); + const child = spawn(command, args, createSpawnOptions({ command, options })); return child; } diff --git a/scripts/validate-changesets.ts b/scripts/validate-changesets.ts index 902f71a7b..b1893f6b4 100644 --- a/scripts/validate-changesets.ts +++ b/scripts/validate-changesets.ts @@ -1,6 +1,7 @@ import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { error, ROOT_DIR, success } from "./shared/index.js"; +import { isDirectExecution } from "./shared/process.js"; const ALLOWED_PACKAGE = "@spencer-kit/coder-studio"; @@ -53,7 +54,7 @@ async function main(): Promise { ); } -if (import.meta.url === `file://${process.argv[1]}`) { +if (isDirectExecution(import.meta.url)) { main().catch((err) => { error(err.message); process.exit(1);