diff --git a/README.md b/README.md index 47f99b73..2fa77689 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Notes: - Auto mode summarizes on navigation (incl. SPAs); otherwise use the button. - Daemon is localhost-only and requires a shared token; rerunning `summarize daemon install --token ` adds another paired browser token instead of invalidating the old one. - Autostart: macOS (launchd), Linux (systemd user), Windows (Scheduled Task). +- Windows containers: `summarize daemon install` starts the daemon for the current container session but does not register a Scheduled Task. Run it each time the container starts or add that command to your container startup, and publish port `8787` so the host browser can reach the daemon. - Tip: configure `free` via `summarize refresh-free` (needs `OPENROUTER_API_KEY`). Add `--set-default` to set model=`free`. More: diff --git a/docs/chrome-extension.md b/docs/chrome-extension.md index 1676ba97..e6be6d29 100644 --- a/docs/chrome-extension.md +++ b/docs/chrome-extension.md @@ -44,6 +44,12 @@ Dev (repo checkout): - `summarize daemon install` now tries both launchd domains (`gui/` then `user/`). - Install as your normal user (not root) so HOME + launchd domain match. - Re-run: `summarize daemon install --token `. +- Windows containers: + - `summarize daemon install --token ` starts the daemon for the current container session but does not create a Scheduled Task. + - Run that command manually each time the container starts, or add it to your container startup. Also publish the daemon port in `docker-compose.yml`: + `ports: ['8787:8787']` + `command: ['cmd', '/c', 'summarize daemon install --token ']` + - Then restart the container and verify `http://127.0.0.1:8787/health`. - “Need extension-side traces”: - Options → Logs → `extension.log` (panel/background events). - Enable “Extended logging” in Advanced settings for full pipeline traces. diff --git a/src/daemon/cli.ts b/src/daemon/cli.ts index 4bfb1243..7d1a5fd2 100644 --- a/src/daemon/cli.ts +++ b/src/daemon/cli.ts @@ -1,5 +1,7 @@ +import { closeSync, openSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { spawn } from "node:child_process"; import { buildDaemonHelp } from "../run/help.js"; import { resolveCliEntrypointPathForService } from "./cli-entrypoint.js"; import { @@ -16,6 +18,7 @@ import { isLaunchAgentLoaded, readLaunchAgentProgramArguments, restartLaunchAgent, + resolveDaemonLogPaths, uninstallLaunchAgent, } from "./launchd.js"; import { @@ -33,6 +36,7 @@ import { restartSystemdService, uninstallSystemdService, } from "./systemd.js"; +import { isWindowsContainerEnvironment } from "./windows-container.js"; type DaemonCliContext = { normalizedArgv: string[]; @@ -309,6 +313,61 @@ async function readInstalledDaemonCommand( return null; } +function writeWindowsContainerInstallInstructions({ + stdout, + port, + configPath, + programArguments, + workingDirectory, +}: { + stdout: NodeJS.WritableStream; + port: number; + configPath: string; + programArguments: string[]; + workingDirectory?: string; +}) { + stdout.write("Windows container detected: skipped Scheduled Task registration.\n"); + stdout.write(`Daemon config: ${configPath}\n`); + stdout.write(`Daemon command: ${formatProgramArguments(programArguments)}\n`); + if (workingDirectory) { + stdout.write(`Daemon cwd: ${workingDirectory}\n`); + } + stdout.write("Daemon autostart is not available in Windows container mode.\n"); + stdout.write( + "Run `summarize daemon install --token ` each time the container starts, or add that command to your container startup.\n", + ); + stdout.write(`Publish port ${port}:${port} so the host browser can reach the daemon.\n`); +} + +async function startDetachedContainerDaemon({ + env, + programArguments, + workingDirectory, +}: { + env: Record; + programArguments: string[]; + workingDirectory?: string; +}): Promise { + const { logDir, stdoutPath, stderrPath } = resolveDaemonLogPaths(env); + await fs.mkdir(logDir, { recursive: true }); + + const stdoutFd = openSync(stdoutPath, "a"); + const stderrFd = openSync(stderrPath, "a"); + try { + const child = spawn(programArguments[0] ?? process.execPath, programArguments.slice(1), { + cwd: workingDirectory, + detached: true, + env: { ...process.env, ...env }, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true, + }); + child.unref(); + } finally { + closeSync(stdoutFd); + closeSync(stderrFd); + } +} + export async function handleDaemonRequest({ normalizedArgv, envForRun, @@ -325,7 +384,6 @@ export async function handleDaemonRequest({ } if (sub === "install") { - const service = resolveDaemonService(); const token = readArgValue(normalizedArgv, "--token"); if (!token) throw new Error("Missing --token"); const portRaw = readArgValue(normalizedArgv, "--port"); @@ -348,8 +406,44 @@ export async function handleDaemonRequest({ }, }); - const { programArguments, workingDirectory } = await resolveDaemonProgramArguments({ dev }); + const windowsContainerMode = + process.platform === "win32" && isWindowsContainerEnvironment(envForRun); + if (windowsContainerMode) { + const { programArguments, workingDirectory } = await resolveDaemonProgramArguments({ dev }); + await startDetachedContainerDaemon({ + env: envForRun, + programArguments, + workingDirectory, + }); + await waitForHealthWithRetries({ + fetchImpl, + port, + attempts: 5, + timeoutMs: 5000, + delayMs: 500, + }); + const authed = await checkAuthWithRetries({ + fetchImpl, + token: token.trim(), + port, + attempts: 5, + delayMs: 400, + }); + if (!authed) throw new Error("Daemon is up but auth failed (token mismatch?)"); + writeWindowsContainerInstallInstructions({ + stdout, + port, + configPath, + programArguments, + workingDirectory, + }); + stdout.write("OK: daemon is running in this container session and authenticated.\n"); + return true; + } + + const { programArguments, workingDirectory } = await resolveDaemonProgramArguments({ dev }); + const service = resolveDaemonService(); await service.install({ env: envForRun, stdout, programArguments, workingDirectory }); await waitForHealthWithRetries({ fetchImpl, port, attempts: 5, timeoutMs: 5000, delayMs: 500 }); const authed = await checkAuthWithRetries({ @@ -376,13 +470,30 @@ export async function handleDaemonRequest({ } if (sub === "status") { - const service = resolveDaemonService(); const cfg = await readDaemonConfig({ env: envForRun }); if (!cfg) { stdout.write("Daemon not installed (missing ~/.summarize/daemon.json)\n"); stdout.write("Run: summarize daemon install --token \n"); return true; } + if (process.platform === "win32" && isWindowsContainerEnvironment(envForRun)) { + const healthy = await (async () => { + try { + await waitForHealth({ fetchImpl, port: cfg.port, timeoutMs: 1000 }); + return true; + } catch { + return false; + } + })(); + const authed = healthy + ? await checkAuth({ fetchImpl, token: daemonConfigPrimaryToken(cfg), port: cfg.port }) + : false; + stdout.write("Autostart: manual (Windows container mode; no Scheduled Task)\n"); + stdout.write(`Daemon: ${healthy ? `up on ${DAEMON_HOST}:${cfg.port}` : "down"}\n`); + stdout.write(`Auth: ${authed ? "ok" : "failed"}\n`); + return true; + } + const service = resolveDaemonService(); const loaded = await service.isLoaded({ env: envForRun }); const healthy = await (async () => { try { @@ -403,13 +514,20 @@ export async function handleDaemonRequest({ } if (sub === "restart") { - const service = resolveDaemonService(); const cfg = await readDaemonConfig({ env: envForRun }); if (!cfg) { stdout.write("Daemon not installed (missing ~/.summarize/daemon.json)\n"); stdout.write("Run: summarize daemon install --token \n"); return true; } + if (process.platform === "win32" && isWindowsContainerEnvironment(envForRun)) { + stdout.write("Autostart is manual in Windows container mode; no Scheduled Task is registered.\n"); + stdout.write( + "Restart the container or rerun `summarize daemon install --token ` to start the daemon again.\n", + ); + return true; + } + const service = resolveDaemonService(); const loaded = await service.isLoaded({ env: envForRun }); if (!loaded) { stdout.write( @@ -462,6 +580,12 @@ export async function handleDaemonRequest({ } if (sub === "uninstall") { + if (process.platform === "win32" && isWindowsContainerEnvironment(envForRun)) { + stdout.write( + "Uninstalled (Windows container mode does not register Scheduled Task autostart). Config left in ~/.summarize/daemon.json\n", + ); + return true; + } const service = resolveDaemonService(); await service.uninstall({ env: envForRun, stdout }); stdout.write( diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 80c5f503..3aa53870 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { DAEMON_WINDOWS_TASK_NAME } from "./constants.js"; +import { isWindowsContainerEnvironment } from "./windows-container.js"; const execFileAsync = promisify(execFile); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index ac6e6b47..a12e019a 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -49,9 +49,14 @@ import { toExtractOnlySlidesPayload, } from "./server-summarize-execution.js"; import { parseSummarizeRequest } from "./server-summarize-request.js"; +import { isWindowsContainerEnvironment } from "./windows-container.js"; export { corsHeaders, isTrustedOrigin } from "./server-http.js"; +export function resolveDaemonListenHost(env: Record): string { + return isWindowsContainerEnvironment(env) ? "0.0.0.0" : DAEMON_HOST; +} + function createLineWriter(onLine: (line: string) => void) { let buffer = ""; return new Writable({ @@ -126,6 +131,7 @@ export async function runDaemonServer({ const processRegistry = new ProcessRegistry(); setProcessObserver(processRegistry.createObserver()); + const listenHost = resolveDaemonListenHost(env); const sessions = new Map(); const refreshSessions = new Map(); @@ -395,7 +401,7 @@ export async function runDaemonServer({ try { await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(port, DAEMON_HOST, () => { + server.listen(port, listenHost, () => { const address = server.address(); const actualPort = address && typeof address === "object" && typeof address.port === "number" diff --git a/src/daemon/windows-container.ts b/src/daemon/windows-container.ts new file mode 100644 index 00000000..37fe846a --- /dev/null +++ b/src/daemon/windows-container.ts @@ -0,0 +1,21 @@ +const WINDOWS_CONTAINER_INSTALL_MODE_ENV = "SUMMARIZE_WINDOWS_CONTAINER_MODE"; +const WINDOWS_CONTAINER_MARKERS = [ + "CONTAINER_SANDBOX_MOUNT_POINT", + "DOTNET_RUNNING_IN_CONTAINER", + "RUNNING_IN_CONTAINER", +] as const; + +function isTruthyEnvValue(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no"; +} + +export function isWindowsContainerEnvironment( + env: Record, +): boolean { + const override = env[WINDOWS_CONTAINER_INSTALL_MODE_ENV]?.trim().toLowerCase(); + if (override === "container") return true; + if (override === "desktop") return false; + return WINDOWS_CONTAINER_MARKERS.some((key) => isTruthyEnvValue(env[key])); +} diff --git a/src/run/env.ts b/src/run/env.ts index d9720e2f..712c2bb2 100644 --- a/src/run/env.ts +++ b/src/run/env.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import { accessSync, constants as fsConstants } from "node:fs"; import path from "node:path"; import type { CliProvider, SummarizeConfig } from "../config.js"; @@ -31,6 +32,27 @@ export function resolveExecutableInPath( return null; } +export async function canSpawnCommand({ + command, + args = ["--help"], + env, +}: { + command: string; + args?: string[]; + env: Record; +}): Promise { + if (!command.trim()) return false; + return new Promise((resolve) => { + const proc = spawn(command, args, { + stdio: ["ignore", "ignore", "ignore"], + env, + windowsHide: true, + }); + proc.on("error", () => resolve(false)); + proc.on("close", (code) => resolve(code === 0)); + }); +} + export function hasBirdCli(env: Record): boolean { return resolveExecutableInPath("bird", env) !== null; } diff --git a/src/slides/extract.ts b/src/slides/extract.ts index 7df351e5..9725e190 100644 --- a/src/slides/extract.ts +++ b/src/slides/extract.ts @@ -2,7 +2,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import type { ExtractedLinkContent, MediaCache } from "../content/index.js"; import { extractYouTubeVideoId, isDirectMediaUrl, isYouTubeUrl } from "../content/index.js"; -import { resolveExecutableInPath } from "../run/env.js"; +import { canSpawnCommand, resolveExecutableInPath } from "../run/env.js"; import { buildSlidesMediaCacheKey, downloadRemoteVideo, @@ -118,6 +118,27 @@ function resolveToolPath( return resolveExecutableInPath(binary, env); } +async function resolveRunnableTool({ + binary, + env, + explicitEnvKey, + probeArgs, +}: { + binary: string; + env: Record; + explicitEnvKey?: string; + probeArgs: string[]; +}): Promise { + const explicit = + explicitEnvKey && typeof env[explicitEnvKey] === "string" ? env[explicitEnvKey]?.trim() : ""; + if (explicit) { + return (await canSpawnCommand({ command: explicit, args: probeArgs, env })) ? explicit : null; + } + const resolved = resolveToolPath(binary, env, explicitEnvKey); + if (resolved) return resolved; + return (await canSpawnCommand({ command: binary, args: probeArgs, env })) ? binary : null; +} + type ExtractSlidesArgs = { source: SlideSource; settings: SlideSettings; @@ -271,14 +292,31 @@ export async function extractSlidesForSource({ `pipeline=ingest(sequential)->scene-detect(parallel:${workers})->extract-frames(parallel:${workers})->ocr(parallel:${workers})`, ); - const ffmpegBinary = ffmpegPath ?? resolveToolPath("ffmpeg", env, "FFMPEG_PATH"); + const ffmpegBinary = + ffmpegPath ?? + (await resolveRunnableTool({ + binary: "ffmpeg", + env, + explicitEnvKey: "FFMPEG_PATH", + probeArgs: ["-version"], + })); if (!ffmpegBinary) { throw new Error("Missing ffmpeg (install ffmpeg or add it to PATH)."); } - const ffprobeBinary = resolveToolPath("ffprobe", env, "FFPROBE_PATH"); + const ffprobeBinary = await resolveRunnableTool({ + binary: "ffprobe", + env, + explicitEnvKey: "FFPROBE_PATH", + probeArgs: ["-version"], + }); if (settings.ocr && !tesseractPath) { - const resolved = resolveToolPath("tesseract", env, "TESSERACT_PATH"); + const resolved = await resolveRunnableTool({ + binary: "tesseract", + env, + explicitEnvKey: "TESSERACT_PATH", + probeArgs: ["--version"], + }); if (!resolved) { throw new Error("Missing tesseract OCR (install tesseract or skip --slides-ocr)."); } @@ -286,7 +324,13 @@ export async function extractSlidesForSource({ } const ocrEnabled = Boolean(settings.ocr && tesseractPath); const ocrAvailable = Boolean( - tesseractPath ?? resolveToolPath("tesseract", env, "TESSERACT_PATH"), + tesseractPath ?? + (await resolveRunnableTool({ + binary: "tesseract", + env, + explicitEnvKey: "TESSERACT_PATH", + probeArgs: ["--version"], + })), ); { @@ -296,6 +340,15 @@ export async function extractSlidesForSource({ } reportSlidesProgress?.("preparing source", SLIDES_PROGRESS.PREPARE); + const ytDlpBinary = + ytDlpPath ?? + (await resolveRunnableTool({ + binary: "yt-dlp", + env, + explicitEnvKey: "YT_DLP_PATH", + probeArgs: ["--version"], + })); + const { inputPath, inputCleanup, @@ -304,7 +357,7 @@ export async function extractSlidesForSource({ source, mediaCache, timeoutMs, - ytDlpPath, + ytDlpPath: ytDlpBinary, ytDlpCookiesFromBrowser, resolveSlidesYtDlpExtractFormat: () => resolveSlidesYtDlpExtractFormat(env), resolveSlidesStreamFallback: () => resolveSlidesStreamFallback(env), diff --git a/tests/daemon.cli.test.ts b/tests/daemon.cli.test.ts index a7af7e60..cea2849a 100644 --- a/tests/daemon.cli.test.ts +++ b/tests/daemon.cli.test.ts @@ -2,6 +2,7 @@ import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), readDaemonConfig: vi.fn(), writeDaemonConfig: vi.fn(), runDaemonServer: vi.fn(), @@ -17,12 +18,17 @@ const mocks = vi.hoisted(() => ({ restartSystemdService: vi.fn(), uninstallSystemdService: vi.fn(), installScheduledTask: vi.fn(), + isWindowsContainerEnvironment: vi.fn(), isScheduledTaskInstalled: vi.fn(), readScheduledTaskCommand: vi.fn(), restartScheduledTask: vi.fn(), uninstallScheduledTask: vi.fn(), })); +vi.mock("node:child_process", () => ({ + spawn: mocks.spawn, +})); + vi.mock("../src/daemon/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -47,8 +53,9 @@ vi.mock("../src/daemon/launchd.js", () => ({ restartLaunchAgent: mocks.restartLaunchAgent, uninstallLaunchAgent: mocks.uninstallLaunchAgent, resolveDaemonLogPaths: () => ({ - daemonOutLog: "/tmp/daemon.out.log", - daemonErrLog: "/tmp/daemon.err.log", + logDir: "/tmp/.summarize/logs", + stdoutPath: "/tmp/.summarize/logs/daemon.log", + stderrPath: "/tmp/.summarize/logs/daemon.err.log", }), })); @@ -68,12 +75,17 @@ vi.mock("../src/daemon/schtasks.js", () => ({ uninstallScheduledTask: mocks.uninstallScheduledTask, })); +vi.mock("../src/daemon/windows-container.js", () => ({ + isWindowsContainerEnvironment: mocks.isWindowsContainerEnvironment, +})); + import { handleDaemonRequest } from "../src/daemon/cli.js"; describe("daemon cli", () => { const originalPath = process.env.PATH; const originalOpenAiKey = process.env.OPENAI_API_KEY; const originalHome = process.env.HOME; + const originalPlatform = process.platform; beforeEach(() => { vi.clearAllMocks(); @@ -84,9 +96,11 @@ describe("daemon cli", () => { mocks.readLaunchAgentProgramArguments.mockResolvedValue(null); mocks.readSystemdServiceExecStart.mockResolvedValue(null); mocks.readScheduledTaskCommand.mockResolvedValue(null); + mocks.isWindowsContainerEnvironment.mockReturnValue(false); mocks.installLaunchAgent.mockResolvedValue(undefined); mocks.installSystemdService.mockResolvedValue(undefined); mocks.installScheduledTask.mockResolvedValue(undefined); + mocks.spawn.mockReturnValue({ unref: vi.fn() }); }); afterEach(() => { @@ -96,6 +110,7 @@ describe("daemon cli", () => { else process.env.OPENAI_API_KEY = originalOpenAiKey; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + Object.defineProperty(process, "platform", { value: originalPlatform }); }); it("applies daemon snapshot env to process.env for child processes on run (#99)", async () => { @@ -184,4 +199,56 @@ describe("daemon cli", () => { }), }); }); + + it("starts the daemon and prints container autostart instructions for Windows containers", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + mocks.isWindowsContainerEnvironment.mockReturnValue(true); + mocks.readDaemonConfig.mockResolvedValueOnce(null); + mocks.writeDaemonConfig.mockResolvedValueOnce( + "C:\\Users\\ContainerAdministrator\\.summarize\\daemon.json", + ); + + const stdout = new PassThrough(); + let text = ""; + stdout.on("data", (chunk) => { + text += chunk.toString(); + }); + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/health")) + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + if (url.endsWith("/v1/ping")) + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + const handled = await handleDaemonRequest({ + normalizedArgv: ["daemon", "install", "--token", "new-token-123456"], + envForRun: { + USERPROFILE: "C:\\Users\\ContainerAdministrator", + CONTAINER_SANDBOX_MOUNT_POINT: "C:\\ContainerMappedDirectories", + }, + fetchImpl: fetchMock as unknown as typeof fetch, + stdout, + stderr: new PassThrough(), + }); + + expect(handled).toBe(true); + expect(mocks.installScheduledTask).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(["daemon", "run"]), + expect.objectContaining({ + detached: true, + windowsHide: true, + }), + ); + expect(text).toContain("Windows container detected: skipped Scheduled Task registration."); + expect(text).toContain("Daemon autostart is not available in Windows container mode."); + expect(text).toContain("Run `summarize daemon install --token ` each time the container starts"); + expect(text).toContain("Publish port 8787:8787 so the host browser can reach the daemon."); + expect(text).toContain("OK: daemon is running in this container session and authenticated."); + }); }); diff --git a/tests/daemon.server.test.ts b/tests/daemon.server.test.ts index b014d97a..8750b493 100644 --- a/tests/daemon.server.test.ts +++ b/tests/daemon.server.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildHealthPayload, corsHeaders, isTrustedOrigin } from "../src/daemon/server.js"; +import { + buildHealthPayload, + corsHeaders, + isTrustedOrigin, + resolveDaemonListenHost, +} from "../src/daemon/server.js"; import { resolvePackageVersion } from "../src/version.js"; describe("daemon/server health payload", () => { @@ -38,3 +43,17 @@ describe("daemon/server CORS allowlist", () => { expect(corsHeaders(null)).toEqual({}); }); }); + +describe("daemon/server listen host", () => { + it("binds to loopback by default", () => { + expect(resolveDaemonListenHost({})).toBe("127.0.0.1"); + }); + + it("binds to all interfaces in Windows container mode", () => { + expect( + resolveDaemonListenHost({ + CONTAINER_SANDBOX_MOUNT_POINT: "C:\\ContainerMappedDirectories", + }), + ).toBe("0.0.0.0"); + }); +}); diff --git a/tests/daemon.windows-container.test.ts b/tests/daemon.windows-container.test.ts new file mode 100644 index 00000000..dcf5cd08 --- /dev/null +++ b/tests/daemon.windows-container.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { isWindowsContainerEnvironment } from "../src/daemon/windows-container.js"; + +describe("daemon/windows-container", () => { + it("defaults to desktop mode when no container markers are present", () => { + expect(isWindowsContainerEnvironment({})).toBe(false); + }); + + it("supports explicit container mode override", () => { + expect( + isWindowsContainerEnvironment({ + SUMMARIZE_WINDOWS_CONTAINER_MODE: "container", + }), + ).toBe(true); + }); + + it("supports explicit desktop mode override", () => { + expect( + isWindowsContainerEnvironment({ + SUMMARIZE_WINDOWS_CONTAINER_MODE: "desktop", + CONTAINER_SANDBOX_MOUNT_POINT: "C:\\ContainerMappedDirectories", + }), + ).toBe(false); + }); + + it("auto-detects common container environment markers", () => { + expect( + isWindowsContainerEnvironment({ + CONTAINER_SANDBOX_MOUNT_POINT: "C:\\ContainerMappedDirectories", + }), + ).toBe(true); + expect( + isWindowsContainerEnvironment({ + DOTNET_RUNNING_IN_CONTAINER: "true", + }), + ).toBe(true); + expect( + isWindowsContainerEnvironment({ + RUNNING_IN_CONTAINER: "1", + }), + ).toBe(true); + }); +}); diff --git a/tests/run.env.test.ts b/tests/run.env.test.ts index 206a200c..fdd6bb44 100644 --- a/tests/run.env.test.ts +++ b/tests/run.env.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { + canSpawnCommand, hasUvxCli, parseBooleanEnv, parseCliProviderArg, @@ -35,6 +36,23 @@ describe("run/env", () => { expect(hasUvxCli({ PATH: "" })).toBe(false); }); + it("probes runnable commands by spawning them", async () => { + await expect( + canSpawnCommand({ + command: process.execPath, + args: ["--version"], + env: process.env as Record, + }), + ).resolves.toBe(true); + await expect( + canSpawnCommand({ + command: "definitely-missing-summarize-binary", + args: ["--help"], + env: process.env as Record, + }), + ).resolves.toBe(false); + }); + it("parses cli model ids and provider args", () => { expect(parseCliUserModelId("cli/codex/gpt-5.2")).toEqual({ provider: "codex",