@@ -822,7 +827,7 @@ function App() {
/>
- {tools.length ? status : STATUS_TEXT.noTools}
+ {outputBlockReason || status}
{added.length ? `자동 추가: ${added.join(", ")}` : ""}
diff --git a/src/builder.js b/src/builder.js
index 1d3ad9f..335d8af 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -41,9 +41,10 @@ export const PACKAGES = [
"$pythonOk = $false",
"foreach ($candidate in @('Python.Python.3.14','Python.Python.3.13','Python.Python.3.12')) {",
" if ($pythonOk) { break }",
- " Install-Winget -Label 'Python' -PackageId $candidate -Command 'python' -ExtraArgs @('--scope','user')",
+ " Install-Winget -Label 'Python' -PackageId $candidate -Command 'python' -ExtraArgs @('--scope','user') -DeferFailure",
" if (Has-Command 'python') { $pythonOk = $true }",
- "}"
+ "}",
+ "if (-not $pythonOk) { Fail 'Python' }"
]
},
{
@@ -73,7 +74,7 @@ export const PACKAGES = [
note: "Container runtime. May require first launch or restart after install.",
presets: [],
deps: { mac: ["homebrew"], win: [] },
- mac: () => ['brew_cask "Docker Desktop" "docker" "Docker.app" "docker"'],
+ mac: () => ['brew_cask "Docker Desktop" "docker" "Docker.app" ""'],
win: () => ["Install-DockerDesktop"]
},
{
@@ -420,16 +421,20 @@ export function buildMacScript(resolved, settings) {
' [ -n "$1" ] && { [ -d "/Applications/$1" ] || [ -d "$HOME/Applications/$1" ]; }',
"}",
"",
+ "cask_installed() {",
+ ' [ -n "$1" ] && has brew && brew list --cask "$1" >/dev/null 2>&1',
+ "}",
+ "",
"brew_cask() {",
' label="$1"; cask="$2"; app_name="$3"; command_name="$4"',
' info "$label"',
' [ -n "$command_name" ] && has "$command_name" && { ok "$label installed"; return 0; }',
- ' app_exists "$app_name" && { ok "$label installed"; return 0; }',
+ ' { app_exists "$app_name" || cask_installed "$cask"; } && { ok "$label installed"; return 0; }',
' if ! has brew; then fail "$label (Homebrew missing)"; return 1; fi',
" # Homebrew cask docs: https://docs.brew.sh/Cask-Cookbook",
' brew install --cask "$cask" >> "$LOG" 2>&1',
" refresh_path",
- ' if { [ -n "$command_name" ] && has "$command_name"; } || app_exists "$app_name"; then ok "$label installed"; else fail "$label"; fi',
+ ' if { [ -n "$command_name" ] && has "$command_name"; } || app_exists "$app_name" || cask_installed "$cask"; then ok "$label installed"; else fail "$label"; fi',
"}",
"",
"npm_global() {",
@@ -683,7 +688,15 @@ export function buildWindowsScript(resolved, settings) {
"function Warn([string]$Text) { Write-Host \" WARN: $Text\" }",
"function Fail([string]$Text) { Write-Host \" FAIL: $Text\"; $script:Failed += $Text }",
"function Step([string]$Text) { Write-Host \"\"; Write-Host \"[$Text]\" }",
- "function Has-Command([string]$Name) { return [bool](Get-Command $Name -ErrorAction SilentlyContinue) }",
+ "function Has-Command([string]$Name) {",
+ " $commands = @(Get-Command $Name -All -ErrorAction SilentlyContinue)",
+ " if ($Name -ne 'python') { return $commands.Count -gt 0 }",
+ " foreach ($command in $commands) {",
+ " if (-not $command.Source -or -not (Test-Path $command.Source -PathType Leaf)) { continue }",
+ " try { & $command.Source --version *> $null; if ($LASTEXITCODE -eq 0) { return $true } } catch {}",
+ " }",
+ " return $false",
+ "}",
"",
"function Refresh-Path {",
" $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')",
@@ -706,20 +719,20 @@ export function buildWindowsScript(resolved, settings) {
"}",
"",
"function Install-Winget {",
- " param([string]$Label, [string]$PackageId, [string]$Command, [string[]]$ExtraArgs = @())",
+ " param([string]$Label, [string]$PackageId, [string]$Command, [string[]]$ExtraArgs = @(), [switch]$DeferFailure)",
" Step $Label",
" if ($Command -and (Has-Command $Command)) { Ok \"$Label installed\"; return }",
- " if (-not (Has-Command 'winget')) { Fail \"$Label (winget missing)\"; return }",
+ " if (-not (Has-Command 'winget')) { if (-not $DeferFailure) { Fail \"$Label (winget missing)\" }; return }",
" # Official winget docs: https://learn.microsoft.com/windows/package-manager/winget/",
" $args = @('install','--id',$PackageId,'--exact','--source','winget','--accept-source-agreements','--accept-package-agreements','--silent','--disable-interactivity') + $ExtraArgs",
" & winget @args *>> $LogFile",
" $exitCode = $LASTEXITCODE",
" Refresh-Path",
" if ($Command) {",
- " if (Has-Command $Command) { Ok \"$Label installed\" } else { Fail $Label }",
+ " if (Has-Command $Command) { Ok \"$Label installed\" } elseif (-not $DeferFailure) { Fail $Label }",
" } elseif ($exitCode -eq 0) {",
" Ok \"$Label installer completed\"",
- " } else {",
+ " } elseif (-not $DeferFailure) {",
" Fail $Label",
" }",
"}",
@@ -814,12 +827,7 @@ export function buildWindowsScript(resolved, settings) {
"}",
"",
"function Test-DockerDesktop {",
- " if (Has-Command 'docker') { return $true }",
- " foreach ($p in @(",
- " (Join-Path $env:ProgramFiles 'Docker\\Docker\\Docker Desktop.exe'),",
- " (Join-Path $env:ProgramFiles 'Docker\\Docker\\resources\\bin\\docker.exe')",
- " )) { if ($p -and (Test-Path $p)) { return $true } }",
- " return $false",
+ " return [bool](Test-Path (Join-Path $env:ProgramFiles 'Docker\\Docker\\Docker Desktop.exe'))",
"}",
"",
"function Install-DockerDesktop {",
@@ -951,30 +959,31 @@ export function buildWindowsScript(resolved, settings) {
" Step 'Codex CLI telemetry'",
" $dir = Join-Path $env:USERPROFILE '.codex'",
" $config = Join-Path $dir 'config.toml'",
- " New-Item -ItemType Directory -Path $dir -Force | Out-Null",
- " if (-not (Test-Path $config)) { New-Item -ItemType File -Path $config -Force | Out-Null }",
" $start = '# Dev Setup Builder - Codex telemetry start'",
" $end = '# Dev Setup Builder - Codex telemetry end'",
- " $content = Get-Content -Path $config -Raw -ErrorAction SilentlyContinue",
- " $clean = [regex]::Replace($content, \"(?ms)^$([regex]::Escape($start))\\r?\\n.*?^$([regex]::Escape($end))\\r?\\n?\", '')",
- " if ($clean -match '(?m)^\\[otel\\]') { Warn 'Codex [otel] already exists outside Dev Setup Builder block; leaving it unchanged'; return }",
- " $safeEnvironment = ConvertTo-TomlString $Environment",
- " $logPrompt = if ($CodexLogUserPrompt -eq '1') { 'true' } else { 'false' }",
- " $logExporterToml = ConvertTo-CodexExporterToml $CodexLogExporter $Endpoint $Protocol $HeaderName $HeaderValue",
- " $traceExporterToml = ConvertTo-CodexExporterToml $CodexTraceExporter $Endpoint $Protocol $HeaderName $HeaderValue",
- " $metricsExporterToml = ConvertTo-CodexExporterToml $CodexMetricsExporter $Endpoint $Protocol $HeaderName $HeaderValue",
- " $block = @(",
- " $start",
- " '[otel]'",
- " \"environment = `\"$safeEnvironment`\"\"",
- " \"log_user_prompt = $logPrompt\"",
- " \"exporter = $logExporterToml\"",
- " \"trace_exporter = $traceExporterToml\"",
- " \"metrics_exporter = $metricsExporterToml\"",
- " $end",
- " ''",
- " )",
- " [IO.File]::WriteAllText($config, ($clean.TrimEnd() + \"`r`n`r`n\" + ($block -join \"`r`n\")), [Text.UTF8Encoding]::new($false))",
+ " try {",
+ " New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null",
+ " $content = if (Test-Path $config) { [IO.File]::ReadAllText($config, [Text.UTF8Encoding]::new($false)) } else { '' }",
+ " $clean = [regex]::Replace($content, \"(?ms)^$([regex]::Escape($start))\\r?\\n.*?^$([regex]::Escape($end))\\r?\\n?\", '')",
+ " if ($clean -match '(?m)^\\[otel\\]') { Warn 'Codex [otel] already exists outside Dev Setup Builder block; leaving it unchanged'; return }",
+ " $safeEnvironment = ConvertTo-TomlString $Environment",
+ " $logPrompt = if ($CodexLogUserPrompt -eq '1') { 'true' } else { 'false' }",
+ " $logExporterToml = ConvertTo-CodexExporterToml $CodexLogExporter $Endpoint $Protocol $HeaderName $HeaderValue",
+ " $traceExporterToml = ConvertTo-CodexExporterToml $CodexTraceExporter $Endpoint $Protocol $HeaderName $HeaderValue",
+ " $metricsExporterToml = ConvertTo-CodexExporterToml $CodexMetricsExporter $Endpoint $Protocol $HeaderName $HeaderValue",
+ " $block = @(",
+ " $start",
+ " '[otel]'",
+ " \"environment = `\"$safeEnvironment`\"\"",
+ " \"log_user_prompt = $logPrompt\"",
+ " \"exporter = $logExporterToml\"",
+ " \"trace_exporter = $traceExporterToml\"",
+ " \"metrics_exporter = $metricsExporterToml\"",
+ " $end",
+ " ''",
+ " )",
+ " [IO.File]::WriteAllText($config, ($clean.TrimEnd() + \"`r`n`r`n\" + ($block -join \"`r`n\")), [Text.UTF8Encoding]::new($false))",
+ " } catch { Fail \"Codex telemetry ($($_.Exception.Message))\"; return }",
" Ok 'Codex telemetry configured'",
"}",
"",
diff --git a/src/styles.css b/src/styles.css
index b4e6e03..e697ec8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -773,4 +773,10 @@ body {
align-items: flex-start;
flex-direction: column;
}
+
+ .site-footer {
+ position: static;
+ justify-self: end;
+ margin: 0 14px 14px;
+ }
}
diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js
index 23af932..d36aebb 100644
--- a/tests/e2e/app.spec.js
+++ b/tests/e2e/app.spec.js
@@ -48,6 +48,7 @@ test("builds scripts and captures primary states", async ({ page }) => {
await page.getByRole("button", { name: "전체 선택" }).click();
await expect(toolList.getByRole("checkbox", { name: /Claude Code 관측 로그/ })).not.toBeChecked();
await expect(toolList.getByRole("checkbox", { name: /Codex 관측 로그/ })).not.toBeChecked();
+ await expect(toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ })).toBeChecked();
await toolList.getByRole("checkbox", { name: /Claude Code 관측 로그/ }).click();
await toolList.getByRole("checkbox", { name: /Codex 관측 로그/ }).click();
await page.getByText("수집 서버 연결").click();
@@ -104,33 +105,80 @@ test("builds scripts and captures primary states", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("./");
await expect(page.getByRole("heading", { name: "개발 환경 설치 도우미" })).toBeVisible();
+ await expect(page.locator(".site-footer")).toHaveCSS("position", "static");
await page.screenshot({ path: join(screenshotDir, "mobile-mac.png"), fullPage: true });
});
-test("syncs selected options and settings through query params", async ({ page }) => {
- await page.goto("./");
+test("shares configured settings and keeps Advanced collapsed", async ({ page }) => {
+ const shared = {
+ gitName: "Aiden Student",
+ gitEmail: "aiden@example.com",
+ otelEndpoint: "https://collector.example.com",
+ otelEnvironment: "customer-prod",
+ otelHeaderName: "Authorization",
+ otelHeaderValue: "secret-token",
+ otelResourceAttributes: "team=secret",
+ claudeLogUserPrompts: "1",
+ claudeLogAssistantResponses: "1",
+ claudeLogToolDetails: "1",
+ claudeLogToolContent: "1",
+ claudeRawApiBodiesMode: "inline",
+ claudeRawApiBodiesDir: "/private/bodies",
+ codexLogUserPrompt: "1"
+ };
+ const params = new URLSearchParams({
+ os: "mac",
+ tools: "git,git-config,claude-code-telemetry,codex-telemetry",
+ ...shared
+ });
+
+ await page.goto(`./?${params}`);
- const toolList = page.getByLabel("설치할 도구");
- await toolList.getByRole("checkbox", { name: /Bun/ }).click();
+ await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "false");
await page.getByText("Git 기본값 수정").click();
- await page.getByRole("textbox", { name: "이름", exact: true }).fill("Aiden Student");
- await page.getByRole("textbox", { name: "이메일", exact: true }).fill("aiden@example.com");
+ await expect(page.getByRole("textbox", { name: "이름", exact: true })).toHaveValue("Aiden Student");
+ await expect(page.getByRole("textbox", { name: "이메일", exact: true })).toHaveValue("aiden@example.com");
- await expect.poll(() => new URL(page.url()).searchParams.get("gitName")).toBe("Aiden Student");
- const currentUrl = new URL(page.url());
- const tools = currentUrl.searchParams.get("tools").split(",");
+ await page.getByRole("button", { name: /고급 설정/ }).click();
+ await page.getByText("수집 서버 연결").click();
+ await expect(page.getByRole("textbox", { name: "수집 서버 주소" })).toHaveValue("https://collector.example.com");
+ await expect(page.getByRole("textbox", { name: "리소스 속성" })).toHaveValue("team=secret");
+ await expect(page.getByRole("textbox", { name: "헤더 값" })).toHaveValue("secret-token");
- expect(currentUrl.searchParams.get("os")).toBe("mac");
- expect(tools).toContain("git-config");
- expect(tools).not.toContain("bun");
- expect(currentUrl.searchParams.get("gitEmail")).toBe("aiden@example.com");
+ await page.getByText("Claude Code 세부 설정").click();
+ await page.getByText("Codex 세부 설정").click();
+ const promptBodyToggles = page.getByRole("switch", { name: "프롬프트 본문 수집" });
+ await expect(promptBodyToggles).toHaveCount(2);
+ await expect(promptBodyToggles.first()).toBeChecked();
+ await expect(promptBodyToggles.last()).toBeChecked();
- await page.goto(`./${currentUrl.search}`);
- await expect(page.getByRole("radio", { name: "macOS" })).toHaveAttribute("aria-checked", "true");
- await expect(toolList.getByRole("checkbox", { name: /Bun/ })).not.toBeChecked();
- await page.getByText("Git 기본값 수정").click();
- await expect(page.getByRole("textbox", { name: "이름", exact: true })).toHaveValue("Aiden Student");
- await expect(page.getByRole("textbox", { name: "이메일", exact: true })).toHaveValue("aiden@example.com");
+ await expect.poll(() => {
+ const current = new URL(page.url()).searchParams;
+ return Object.fromEntries(Object.keys(shared).map((key) => [key, current.get(key)]));
+ }).toEqual(shared);
+});
+
+test("blocks empty output and keeps beginner Git defaults", async ({ page }) => {
+ await page.goto("./");
+
+ const toolList = page.getByLabel("설치할 도구");
+ const copyButton = page.getByRole("button", { name: "복사", exact: true });
+ const downloadButton = page.getByRole("button", { name: "다운로드", exact: true });
+ const terminalButton = page.getByRole("button", { name: "macOS 터미널 설치 명령어 복사" });
+ const scriptPreview = page.getByRole("textbox", { name: "생성된 설치 스크립트" });
+
+ await page.getByRole("button", { name: "전체 해제" }).click();
+ await expect(copyButton).toBeDisabled();
+ await expect(downloadButton).toBeDisabled();
+ await expect(terminalButton).toBeDisabled();
+ await expect(page.getByText("도구를 하나 이상 선택하세요.")).toBeVisible();
+
+ await toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ }).click();
+ await expect(copyButton).toBeEnabled();
+ await expect(downloadButton).toBeEnabled();
+ await expect(terminalButton).toBeEnabled();
+ await expect(page.getByText("Git 정보가 없을 때 Claude Code / noreply@anthropic.com을 사용합니다.")).toBeVisible();
+ await expect(scriptPreview).toHaveValue(/configure_git 'Claude Code' 'noreply@anthropic.com'/);
});
test("enables WSL2 by default when switching from macOS to Windows", async ({ page }) => {
diff --git a/tests/mac.test.js b/tests/mac.test.js
index 628c5e3..1cd0207 100644
--- a/tests/mac.test.js
+++ b/tests/mac.test.js
@@ -54,7 +54,8 @@ const macPackages = PACKAGES.filter((item) => supportsOs(item, "mac"));
assert.equal(macPackages.some((item) => item.id === "wsl2"), false);
const dockerScript = buildMacScript(new Set(["homebrew", "docker"]), settings);
-assert.match(dockerScript, /brew_cask "Docker Desktop" "docker"/);
+assert.match(dockerScript, /brew_cask "Docker Desktop" "docker" "Docker\.app" ""/);
+assert.match(dockerScript, /brew list --cask "\$1"/);
const claudeTelemetry = resolveSelection(new Set(["claude-code-telemetry"]), "mac");
const claudeTelemetryScript = buildMacScript(claudeTelemetry, {
diff --git a/tests/public-runners.test.js b/tests/public-runners.test.js
index 17548a8..bb253d6 100644
--- a/tests/public-runners.test.js
+++ b/tests/public-runners.test.js
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
-import { existsSync, readFileSync } from "node:fs";
+import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
const macRunner = "public/run-mac.sh";
const windowsRunner = "public/run-windows.ps1";
@@ -15,6 +17,10 @@ const macContent = readFileSync(macRunner, "utf8");
assert.match(macContent, /DEV_SETUP_SCRIPT_B64/);
assert.match(macContent, /base64 -D/);
assert.match(macContent, /base64 --decode/);
+assert.doesNotMatch(macContent, /^umask 077$/m);
+assert.match(macContent, /mktemp/);
+assert.match(macContent, /trap cleanup EXIT/);
+assert.doesNotMatch(macContent, /\$\$/);
// Reconnect stdin to the terminal so `curl | bash` can drive interactive installers (sudo prompt).
assert.match(macContent, /bash "\$tmp" < \/dev\/tty/);
// Probe openability (ENXIO in CI/cron) instead of a fragile existence check that aborts under set -e.
@@ -25,5 +31,55 @@ const windowsContent = readFileSync(windowsRunner, "utf8");
assert.match(windowsContent, /DEV_SETUP_SCRIPT_B64/);
assert.match(windowsContent, /FromBase64String/);
assert.match(windowsContent, /cmd\.exe \/c/);
+assert.match(windowsContent, /\[Guid\]::NewGuid/);
+assert.match(windowsContent, /try\s*{/);
+assert.match(windowsContent, /finally\s*{/);
+assert.match(windowsContent, /Remove-Item -LiteralPath \$path/);
+assert.doesNotMatch(windowsContent, /\$PID/);
+
+function runFixture(command, args, fixture) {
+ const tempDir = mkdtempSync(join(tmpdir(), "dev-setup-builder-runner-"));
+ const result = spawnSync(command, args, {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ DEV_SETUP_SCRIPT_B64: Buffer.from(fixture).toString("base64"),
+ TEMP: tempDir,
+ TMP: tempDir,
+ TMPDIR: tempDir
+ }
+ });
+ const files = readdirSync(tempDir);
+ rmSync(tempDir, { recursive: true, force: true });
+ return { files, result };
+}
+
+const expectedUmask = process.umask().toString(8).padStart(4, "0");
+const macFixture = runFixture("bash", [macRunner], '#!/bin/bash\nprintf "runner fixture ok\\n"\numask\nexit 7\n');
+assert.equal(macFixture.result.status, 7, macFixture.result.stderr || macFixture.result.stdout);
+assert.match(macFixture.result.stdout, /runner fixture ok/);
+assert.match(macFixture.result.stdout, new RegExp(`^${expectedUmask}$`, "m"));
+assert.deepEqual(macFixture.files, []);
+
+const powerShell = process.platform === "win32" ? "powershell.exe" : "pwsh";
+const powerShellProbe = spawnSync(powerShell, ["-NoProfile", "-Command", "exit 0"]);
+if (!powerShellProbe.error && powerShellProbe.status === 0) {
+ const windowsFixture = runFixture(
+ powerShell,
+ process.platform === "win32"
+ ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", windowsRunner]
+ : [
+ "-NoProfile",
+ "-Command",
+ `function global:cmd.exe { & /bin/bash $args[1] }; & '${windowsRunner}'`
+ ],
+ process.platform === "win32"
+ ? "@echo off\r\necho runner fixture ok\r\nexit /b 7\r\n"
+ : '#!/bin/bash\nprintf "runner fixture ok\\n"\n'
+ );
+ assert.equal(windowsFixture.result.status, process.platform === "win32" ? 7 : 0, windowsFixture.result.stderr || windowsFixture.result.stdout);
+ assert.match(windowsFixture.result.stdout, /runner fixture ok/);
+ assert.deepEqual(windowsFixture.files, []);
+}
console.log("public runner tests pass");
diff --git a/tests/windows.test.js b/tests/windows.test.js
index 9f6db0b..d4344d8 100644
--- a/tests/windows.test.js
+++ b/tests/windows.test.js
@@ -1,4 +1,8 @@
import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import {
buildWindowsScript,
resolveSelection,
@@ -54,6 +58,33 @@ assert.match(setupScript, /wsl --install --no-launch/);
const dockerScript = buildWindowsScript(new Set(["docker"]), settings);
assert.match(dockerScript, /Docker\.DockerDesktop/);
+const dockerDetector = dockerScript.slice(
+ dockerScript.indexOf("function Test-DockerDesktop"),
+ dockerScript.indexOf("function Install-DockerDesktop")
+);
+assert.doesNotMatch(dockerDetector, /Has-Command 'docker'/);
+assert.doesNotMatch(dockerDetector, /resources\\\\bin\\\\docker\.exe/);
+
+const pythonScript = buildWindowsScript(resolveSelection(new Set(["python"]), "win"), settings);
+const commandDetector = pythonScript.slice(
+ pythonScript.indexOf("function Has-Command"),
+ pythonScript.indexOf("function Refresh-Path")
+);
+const wingetInstaller = pythonScript.slice(
+ pythonScript.indexOf("function Install-Winget"),
+ pythonScript.indexOf("function Install-NpmGlobal")
+);
+const pythonBodyStart = pythonScript.lastIndexOf("$pythonOk = $false");
+const pythonBody = pythonScript.slice(
+ pythonBodyStart,
+ pythonScript.indexOf('\r\n\r\nWrite-Host ""', pythonBodyStart)
+);
+assert.match(commandDetector, /Get-Command \$Name -All/);
+assert.doesNotMatch(commandDetector, /WindowsApps/);
+assert.match(commandDetector, /Test-Path \$command\.Source -PathType Leaf/);
+assert.match(commandDetector, /--version/);
+assert.match(pythonBody, /-DeferFailure/);
+assert.match(pythonBody, /if \(-not \$pythonOk\) \{ Fail 'Python' \}/);
const claudeTelemetry = resolveSelection(new Set(["claude-code-telemetry"]), "win");
const claudeTelemetryScript = buildWindowsScript(claudeTelemetry, {
@@ -80,6 +111,92 @@ assert.match(codexTelemetryScript, /Set-CodexTelemetry/);
assert.match(codexTelemetryScript, /\[otel\]/);
assert.match(codexTelemetryScript, /metrics_exporter = \$metricsExporterToml/);
assert.match(codexTelemetryScript, /Set-CodexTelemetry 'http:\/\/localhost:4317' 'grpc' '' '' 'dev' '' '60000' '5000' 'otlp' 'otlp' 'none' '0' '0' '0' '0' 'off' '' 'otlp' 'none' 'otlp' '1'/);
+assert.match(codexTelemetryScript, /\[IO\.File\]::ReadAllText\(\$config/);
+assert.doesNotMatch(codexTelemetryScript, /\$content = Get-Content -Path \$config -Raw/);
+assert.match(codexTelemetryScript, /catch \{ Fail "Codex telemetry/);
+
+const pwsh = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "$null"], { encoding: "utf8" });
+if (!pwsh.error && pwsh.status === 0) {
+ const pythonCheck = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
+ input: [
+ commandDetector,
+ "function Get-Command { param($Name, [switch]$All, $ErrorAction); return $script:Commands }",
+ "$script:Commands = @([pscustomobject]@{ Source = 'C:\\Users\\test\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe' })",
+ "if (Has-Command 'python') { exit 1 }",
+ "$script:Commands = @([pscustomobject]@{ Source = 'C:\\Users\\test\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe' }, [pscustomobject]@{ Source = [Environment]::ProcessPath })",
+ "if (-not (Has-Command 'python')) { exit 2 }",
+ "$script:Commands = @([pscustomobject]@{ Source = 'Z:\\missing\\python.exe' })",
+ "if (Has-Command 'python') { exit 3 }"
+ ].join("\n"),
+ encoding: "utf8"
+ });
+ assert.equal(pythonCheck.status, 0, pythonCheck.stderr || pythonCheck.stdout);
+
+ const pythonFallbackCheck = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
+ input: [
+ "$script:Failed = @()",
+ "$script:Attempts = 0",
+ "$script:PythonInstalled = $false",
+ "$LogFile = [IO.Path]::GetTempFileName()",
+ "function Step([string]$Text) {}",
+ "function Ok([string]$Text) {}",
+ "function Fail([string]$Text) { $script:Failed += $Text }",
+ "function Refresh-Path {}",
+ "function Has-Command([string]$Name) { if ($Name -eq 'winget') { return $true }; if ($Name -eq 'python') { return $script:PythonInstalled }; return $false }",
+ "function winget { $script:Attempts += 1; if ($script:Attempts -eq 2) { $script:PythonInstalled = $true } }",
+ wingetInstaller,
+ pythonBody,
+ "Remove-Item $LogFile -Force",
+ "if (-not $pythonOk) { exit 1 }",
+ "if ($script:Failed.Count -ne 0) { exit 2 }",
+ "if ($script:Attempts -ne 2) { exit 3 }"
+ ].join("\n"),
+ encoding: "utf8"
+ });
+ assert.equal(pythonFallbackCheck.status, 0, pythonFallbackCheck.stderr || pythonFallbackCheck.stdout);
+
+ const helpers = codexTelemetryScript.slice(
+ codexTelemetryScript.indexOf("function ConvertTo-TomlString"),
+ codexTelemetryScript.indexOf("function Check-GitHubAuth")
+ );
+ const call = codexTelemetryScript.split(/\r?\n/).find((line) => line.startsWith("Set-CodexTelemetry "));
+ const harness = [
+ "$script:Failed = @()",
+ "function Step([string]$Text) {}",
+ "function Ok([string]$Text) { Write-Output \"OK: $Text\" }",
+ "function Warn([string]$Text) { Write-Output \"WARN: $Text\" }",
+ "function Fail([string]$Text) { Write-Output \"FAIL: $Text\"; $script:Failed += $Text }",
+ helpers,
+ call,
+ "if ($script:Failed.Count -gt 0) { exit 1 }"
+ ].join("\n");
+ const home = mkdtempSync(join(tmpdir(), "dev-setup-builder-"));
+ try {
+ mkdirSync(join(home, ".codex"));
+ writeFileSync(join(home, ".codex", "config.toml"), "");
+ const success = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
+ input: harness,
+ env: { ...process.env, USERPROFILE: home },
+ encoding: "utf8"
+ });
+ assert.equal(success.status, 0, success.stderr || success.stdout);
+ assert.match(success.stdout, /OK: Codex telemetry configured/);
+ assert.match(readFileSync(join(home, ".codex", "config.toml"), "utf8"), /\[otel\]/);
+
+ rmSync(join(home, ".codex", "config.toml"));
+ mkdirSync(join(home, ".codex", "config.toml"));
+ const failure = spawnSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "-"], {
+ input: harness,
+ env: { ...process.env, USERPROFILE: home },
+ encoding: "utf8"
+ });
+ assert.equal(failure.status, 1, failure.stderr || failure.stdout);
+ assert.match(failure.stdout, /FAIL: Codex telemetry/);
+ assert.doesNotMatch(failure.stdout, /OK: Codex telemetry configured/);
+ } finally {
+ rmSync(home, { recursive: true, force: true });
+ }
+}
// --- Regression coverage for the script-defect fixes ---