From a35e36a608c6916a50a2877485f5e3df1b9cc272 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Sun, 12 Jul 2026 01:44:01 +0900 Subject: [PATCH 1/4] Fix setup safety issues found in review --- public/run-mac.sh | 6 ++- public/run-windows.ps1 | 17 ++++-- src/App.jsx | 47 ++++++++++++---- src/builder.js | 92 +++++++++++++++++++------------- src/styles.css | 22 ++++++++ tests/e2e/app.spec.js | 100 ++++++++++++++++++++++++++++------- tests/mac.test.js | 12 ++++- tests/public-runners.test.js | 56 +++++++++++++++++++- tests/windows.test.js | 92 ++++++++++++++++++++++++++++++++ 9 files changed, 369 insertions(+), 75 deletions(-) diff --git a/public/run-mac.sh b/public/run-mac.sh index 056e4ef..e7169f1 100644 --- a/public/run-mac.sh +++ b/public/run-mac.sh @@ -1,12 +1,16 @@ #!/bin/bash set -euo pipefail +umask 077 if [ -z "${DEV_SETUP_SCRIPT_B64:-}" ]; then printf "DEV_SETUP_SCRIPT_B64 is missing.\n" >&2 exit 1 fi -tmp="${TMPDIR:-/tmp}/dev-setup-builder-$$.command" +tmp="$(mktemp "${TMPDIR:-/tmp}/dev-setup-builder.XXXXXX")" +cleanup() { rm -f "$tmp"; } +trap cleanup EXIT + if ! printf "%s" "$DEV_SETUP_SCRIPT_B64" | base64 -D > "$tmp" 2>/dev/null; then printf "%s" "$DEV_SETUP_SCRIPT_B64" | base64 --decode > "$tmp" fi diff --git a/public/run-windows.ps1 b/public/run-windows.ps1 index cfe2f82..907ec42 100644 --- a/public/run-windows.ps1 +++ b/public/run-windows.ps1 @@ -4,9 +4,16 @@ if (-not $env:DEV_SETUP_SCRIPT_B64) { throw 'DEV_SETUP_SCRIPT_B64 is missing.' } -$path = Join-Path $env:TEMP "dev-setup-builder-$PID.bat" -$bytes = [Convert]::FromBase64String($env:DEV_SETUP_SCRIPT_B64) -[IO.File]::WriteAllBytes($path, $bytes) +$path = Join-Path $env:TEMP ("dev-setup-builder-{0}.bat" -f [Guid]::NewGuid().ToString('N')) +try { + $bytes = [Convert]::FromBase64String($env:DEV_SETUP_SCRIPT_B64) + [IO.File]::WriteAllBytes($path, $bytes) -cmd.exe /c $path -exit $LASTEXITCODE + cmd.exe /c $path + $exitCode = $LASTEXITCODE +} +finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue +} + +exit $exitCode diff --git a/src/App.jsx b/src/App.jsx index 36b265e..12493b5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,8 +36,8 @@ import { } from "./builder.js"; const DEFAULT_SETTINGS = { - gitName: "Claude Code", - gitEmail: "noreply@anthropic.com", + gitName: "", + gitEmail: "", otelEndpoint: "http://localhost:4317", otelProtocol: "grpc", otelHeaderName: "", @@ -105,7 +105,7 @@ const RAW_BODY_OPTIONS = [ const STATUS_TEXT = { ready: "준비됨", - noTools: "선택된 도구 없음", + noTools: "도구를 하나 이상 선택하세요.", copied: "복사 완료", copyFailed: "복사 실패" }; @@ -169,9 +169,19 @@ const PACKAGE_ICONS = { const ADVANCED_PACKAGE_IDS = new Set(["claude-code-telemetry", "codex-telemetry"]); const PACKAGE_IDS = new Set(PACKAGES.map((item) => item.id)); -// Keep secrets (the OTLP header value is typically an API token) out of the shareable URL. -const URL_SECRET_KEYS = new Set(["otelHeaderValue"]); -const URL_SETTING_KEYS = Object.keys(DEFAULT_SETTINGS).filter((key) => !URL_SECRET_KEYS.has(key)); +// Share only non-identifying configuration. New settings stay private unless explicitly added here. +const URL_SETTING_KEYS = [ + "otelProtocol", + "otelMetricInterval", + "otelLogsInterval", + "claudeMetricsExporter", + "claudeLogsExporter", + "claudeTracesExporter", + "codexLogExporter", + "codexTraceExporter", + "codexMetricsExporter" +]; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PERMISSION_HELP = { mac: [ @@ -273,7 +283,7 @@ function SettingTextInput(props) { } function allSelection() { - return new Set(PACKAGES.filter((item) => !ADVANCED_PACKAGE_IDS.has(item.id)).map((item) => item.id)); + return new Set(PACKAGES.filter((item) => !ADVANCED_PACKAGE_IDS.has(item.id) && item.id !== "git-config").map((item) => item.id)); } function parseUrlState() { @@ -374,7 +384,9 @@ function App() { const [status, setStatus] = useState(STATUS_TEXT.ready); const [lastAction, setLastAction] = useState(""); const [copiedCommand, setCopiedCommand] = useState(""); - const [collapsedGroups, setCollapsedGroups] = useState(() => new Set(["Advanced"])); + const [collapsedGroups, setCollapsedGroups] = useState(() => ( + [...ADVANCED_PACKAGE_IDS].some((id) => initialUrlState.current.selected.has(id)) ? new Set() : new Set(["Advanced"]) + )); const statusTimer = useRef(); const commandTimer = useRef(); @@ -390,6 +402,13 @@ function App() { const tools = visibleResolved(resolved); const currentFileName = fileName(os); const showTelemetryDefaults = resolved.has("claude-code-telemetry") || resolved.has("codex-telemetry"); + const gitIdentityValid = Boolean(settings.gitName.trim() && EMAIL_PATTERN.test(settings.gitEmail.trim())); + const outputBlockReason = tools.length === 0 + ? STATUS_TEXT.noTools + : resolved.has("git-config") && !gitIdentityValid + ? "Git 이름과 올바른 이메일을 입력하세요." + : ""; + const canExport = !outputBlockReason; useEffect(() => { window.history.replaceState(null, "", `${window.location.pathname}?${urlSearch(os, selected, settings)}`); @@ -533,6 +552,7 @@ function App() { } async function copyCurrentScript() { + if (!canExport) return; await copyText(script, STATUS_TEXT.copied, "copy"); } @@ -556,6 +576,7 @@ function App() { } function downloadCurrentScript() { + if (!canExport) return; const blob = new Blob([script], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -568,6 +589,7 @@ function App() { return (
+ 미리보기로 건너뛰기

개발 환경 설치 도우미

@@ -586,6 +608,7 @@ function App() { label={lastAction === "copy" ? "복사됨" : "복사"} icon={lastAction === "copy" ? : } onClick={copyCurrentScript} + isDisabled={!canExport} variant="secondary" />
@@ -710,6 +734,8 @@ function App() { label="헤더 값" value={settings.otelHeaderValue} onChange={(value) => updateSetting("otelHeaderValue", value)} + type="password" + autoComplete="off" /> - +
@@ -803,6 +829,7 @@ function App() { className="command-copy terminal-command" aria-label={`${os === "mac" ? "macOS" : "Windows"} 터미널 설치 명령어 복사`} onClick={() => copyCommand(installCommand)} + disabled={!canExport} > {installCommand} @@ -822,7 +849,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..1dfe371 100644 --- a/src/builder.js +++ b/src/builder.js @@ -73,7 +73,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"] }, { @@ -213,12 +213,14 @@ export const PACKAGES = [ note: "Writes name and email only when both are missing.", presets: [], deps: { mac: ["git"], win: ["git"] }, - mac: (settings) => [ - `configure_git ${sh(settings.gitName)} ${sh(settings.gitEmail)}` - ], - win: (settings) => [ - `Set-GitDefaults -Name ${ps(settings.gitName)} -Email ${ps(settings.gitEmail)}` - ] + mac: (settings) => { + const identity = gitIdentity(settings); + return identity ? [`configure_git ${sh(identity.name)} ${sh(identity.email)}`] : []; + }, + win: (settings) => { + const identity = gitIdentity(settings); + return identity ? [`Set-GitDefaults -Name ${ps(identity.name)} -Email ${ps(identity.email)}`] : []; + } } ]; @@ -261,6 +263,12 @@ function ps(value) { return `'${String(value).replace(/'/g, "''")}'`; } +function gitIdentity(settings) { + const name = String(settings?.gitName || "").trim(); + const email = String(settings?.gitEmail || "").trim(); + return name && /^[^\s@]+@[^\s@]+$/.test(email) ? { name, email } : null; +} + function boolSetting(settings, key) { return settings?.[key] ? "1" : "0"; } @@ -420,16 +428,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 +695,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 $command.Source -like '*\\WindowsApps\\*' -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')", @@ -814,12 +834,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 +966,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..92c96eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -38,6 +38,22 @@ body { grid-template-rows: auto 1fr auto; } +.skip-link { + position: fixed; + top: 10px; + left: 10px; + z-index: 30; + transform: translateY(-200%); + padding: 8px 12px; + border-radius: 6px; + background: var(--ink); + color: #ffffff; +} + +.skip-link:focus { + transform: none; +} + .topbar { display: flex; align-items: center; @@ -773,4 +789,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..81dc0d0 100644 --- a/tests/e2e/app.spec.js +++ b/tests/e2e/app.spec.js @@ -13,6 +13,8 @@ test("exposes Korean, read-only preview, and announced status", async ({ page }) await page.goto("./"); await expect.soft(page.locator("html")).toHaveAttribute("lang", "ko"); + await expect.soft(page.getByRole("link", { name: "미리보기로 건너뛰기" })).toHaveAttribute("href", "#script-preview"); + await expect.soft(page.locator("#script-preview")).toHaveAttribute("aria-label", "생성된 설치 스크립트 미리보기"); await expect.soft(page.getByRole("textbox", { name: "생성된 설치 스크립트" })).toHaveAttribute("readonly", ""); await expect.soft(page.locator(".status")).toHaveAttribute("role", "status"); await expect.soft(page.locator(".status")).toHaveAttribute("aria-live", "polite"); @@ -29,7 +31,7 @@ test("builds scripts and captures primary states", async ({ page }) => { await expect(toolList.getByRole("checkbox")).toHaveCount(18); await expect(toolList.getByRole("checkbox", { name: /Bun/ })).toBeChecked(); await expect(toolList.getByRole("checkbox", { name: /Codex App/ })).toBeChecked(); - await expect(toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ })).toBeChecked(); + await expect(toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ })).not.toBeChecked(); await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "false"); const pageOrigin = new URL(page.url()).origin; await page.context().grantPermissions(["clipboard-write"], { origin: pageOrigin }); @@ -48,6 +50,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 사용자 정보 기본값/ })).not.toBeChecked(); await toolList.getByRole("checkbox", { name: /Claude Code 관측 로그/ }).click(); await toolList.getByRole("checkbox", { name: /Codex 관측 로그/ }).click(); await page.getByText("수집 서버 연결").click(); @@ -104,33 +107,92 @@ 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 }) => { +test("keeps sensitive settings out of shared URLs and exposes shared telemetry", async ({ page }) => { + const sensitive = { + 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", + ...sensitive + }); + + await page.goto(`./?${params}`); + + await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "true"); + await page.getByText("Git 기본값 수정").click(); + await expect(page.getByRole("textbox", { name: "이름", exact: true })).toHaveValue(""); + await expect(page.getByRole("textbox", { name: "이메일", exact: true })).toHaveValue(""); + + await page.getByText("수집 서버 연결").click(); + await expect(page.getByRole("textbox", { name: "수집 서버 주소" })).toHaveValue("http://localhost:4317"); + await expect(page.getByRole("textbox", { name: "리소스 속성" })).toHaveValue(""); + const headerValue = page.getByRole("textbox", { name: "헤더 값" }); + await expect(headerValue).toHaveAttribute("type", "password"); + await expect(headerValue).toHaveAttribute("autocomplete", "off"); + + 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()).not.toBeChecked(); + await expect(promptBodyToggles.last()).not.toBeChecked(); + await promptBodyToggles.first().click(); + + await expect.poll(() => { + const current = new URL(page.url()).searchParams; + return Object.keys(sensitive).filter((key) => current.has(key)); + }).toEqual([]); +}); + +test("blocks generated output when selection or Git identity is invalid", async ({ page }) => { await page.goto("./"); const toolList = page.getByLabel("설치할 도구"); - await toolList.getByRole("checkbox", { name: /Bun/ }).click(); + 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 page.getByText("Git 기본값 수정").click(); + await expect(copyButton).toBeDisabled(); + await expect(downloadButton).toBeDisabled(); + await expect(terminalButton).toBeDisabled(); + await expect(page.getByText("Git 이름과 올바른 이메일을 입력하세요.")).toBeVisible(); + await expect(scriptPreview).not.toHaveValue(/configure_git '' ''/); + await page.getByRole("textbox", { name: "이름", exact: true }).fill("Aiden Student"); + await page.getByRole("textbox", { name: "이메일", exact: true }).fill("not-an-email"); + await expect(copyButton).toBeDisabled(); await page.getByRole("textbox", { name: "이메일", exact: true }).fill("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(","); - - 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.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(copyButton).toBeEnabled(); + await expect(downloadButton).toBeEnabled(); + await expect(terminalButton).toBeEnabled(); + await expect(scriptPreview).toHaveValue(/configure_git 'Aiden Student' 'aiden@example.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..4ec5d87 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, { @@ -109,6 +110,15 @@ const gitScript = buildMacScript(resolveSelection(new Set(["git-config"]), "mac" assert.match(gitScript, /\[ -z "\$existing_name" \] && git config --global user\.name/); assert.match(gitScript, /\[ -z "\$existing_email" \] && git config --global user\.email/); +// Invalid or incomplete identity must not generate a global git config call. +for (const invalidSettings of [ + { gitName: "", gitEmail: "a@example.com" }, + { gitName: "A", gitEmail: "not-an-email" } +]) { + const invalidGitScript = buildMacScript(resolveSelection(new Set(["git-config"]), "mac"), invalidSettings); + assert.doesNotMatch(invalidGitScript, /^configure_git /m); +} + // CR/LF is stripped from telemetry values (TOML/script-line breakout guard). const crlfScript = buildMacScript(resolveSelection(new Set(["claude-code-telemetry"]), "mac"), { ...settings, diff --git a/tests/public-runners.test.js b/tests/public-runners.test.js index 17548a8..4677ec3 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.match(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,53 @@ 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 macFixture = runFixture("bash", [macRunner], '#!/bin/bash\nprintf "runner fixture ok\\n"\nexit 7\n'); +assert.equal(macFixture.result.status, 7, macFixture.result.stderr || macFixture.result.stdout); +assert.match(macFixture.result.stdout, /runner fixture ok/); +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..45204da 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,22 @@ 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") +); +assert.match(commandDetector, /Get-Command \$Name -All/); +assert.match(commandDetector, /WindowsApps/); +assert.match(commandDetector, /Test-Path \$command\.Source -PathType Leaf/); +assert.match(commandDetector, /--version/); const claudeTelemetry = resolveSelection(new Set(["claude-code-telemetry"]), "win"); const claudeTelemetryScript = buildWindowsScript(claudeTelemetry, { @@ -80,6 +100,69 @@ 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 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 --- @@ -118,5 +201,14 @@ const gitWinScript = buildWindowsScript(resolveSelection(new Set(["git-config"]) assert.match(gitWinScript, /if \(-not \$existingName\) \{ & git config --global user\.name/); assert.match(gitWinScript, /if \(-not \$existingEmail\) \{ & git config --global user\.email/); +// Invalid or incomplete identity must not generate a global git config call. +for (const invalidSettings of [ + { gitName: "", gitEmail: "a@example.com" }, + { gitName: "A", gitEmail: "not-an-email" } +]) { + const invalidGitWinScript = buildWindowsScript(resolveSelection(new Set(["git-config"]), "win"), invalidSettings); + assert.doesNotMatch(invalidGitWinScript, /^Set-GitDefaults -Name /m); +} + assert.equal(selfTest().ok, true); console.log("windows tests pass"); From b12643647eee18b468e83580ab7befc26474502b Mon Sep 17 00:00:00 2001 From: seungwonme Date: Sun, 12 Jul 2026 02:36:41 +0900 Subject: [PATCH 2/4] Align setup defaults with beginner workflow --- src/App.jsx | 35 ++++++------------------------ src/builder.js | 20 ++++++----------- tests/e2e/app.spec.js | 50 ++++++++++++++++--------------------------- tests/mac.test.js | 9 -------- tests/windows.test.js | 9 -------- 5 files changed, 32 insertions(+), 91 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 12493b5..e931965 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,8 +36,8 @@ import { } from "./builder.js"; const DEFAULT_SETTINGS = { - gitName: "", - gitEmail: "", + gitName: "Claude Code", + gitEmail: "noreply@anthropic.com", otelEndpoint: "http://localhost:4317", otelProtocol: "grpc", otelHeaderName: "", @@ -140,7 +140,7 @@ const PACKAGE_TEXT = { gh: { note: "GitHub 작업용 gh 명령을 설치합니다." }, "github-auth": { label: "GitHub CLI 로그인", note: "GitHub CLI 로그인 상태를 확인하고 필요한 명령을 안내합니다." }, glab: { note: "GitLab 작업용 glab 명령을 설치합니다." }, - "git-config": { label: "Git 사용자 정보 기본값", note: "Git 이름과 이메일이 없을 때만 기본값을 설정합니다." } + "git-config": { label: "Git 사용자 정보 기본값", note: "Git 정보가 없을 때 Claude Code / noreply@anthropic.com을 사용합니다." } }; const PACKAGE_ICONS = { @@ -169,19 +169,7 @@ const PACKAGE_ICONS = { const ADVANCED_PACKAGE_IDS = new Set(["claude-code-telemetry", "codex-telemetry"]); const PACKAGE_IDS = new Set(PACKAGES.map((item) => item.id)); -// Share only non-identifying configuration. New settings stay private unless explicitly added here. -const URL_SETTING_KEYS = [ - "otelProtocol", - "otelMetricInterval", - "otelLogsInterval", - "claudeMetricsExporter", - "claudeLogsExporter", - "claudeTracesExporter", - "codexLogExporter", - "codexTraceExporter", - "codexMetricsExporter" -]; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const URL_SETTING_KEYS = Object.keys(DEFAULT_SETTINGS); const PERMISSION_HELP = { mac: [ @@ -283,7 +271,7 @@ function SettingTextInput(props) { } function allSelection() { - return new Set(PACKAGES.filter((item) => !ADVANCED_PACKAGE_IDS.has(item.id) && item.id !== "git-config").map((item) => item.id)); + return new Set(PACKAGES.filter((item) => !ADVANCED_PACKAGE_IDS.has(item.id)).map((item) => item.id)); } function parseUrlState() { @@ -384,9 +372,7 @@ function App() { const [status, setStatus] = useState(STATUS_TEXT.ready); const [lastAction, setLastAction] = useState(""); const [copiedCommand, setCopiedCommand] = useState(""); - const [collapsedGroups, setCollapsedGroups] = useState(() => ( - [...ADVANCED_PACKAGE_IDS].some((id) => initialUrlState.current.selected.has(id)) ? new Set() : new Set(["Advanced"]) - )); + const [collapsedGroups, setCollapsedGroups] = useState(() => new Set(["Advanced"])); const statusTimer = useRef(); const commandTimer = useRef(); @@ -402,12 +388,7 @@ function App() { const tools = visibleResolved(resolved); const currentFileName = fileName(os); const showTelemetryDefaults = resolved.has("claude-code-telemetry") || resolved.has("codex-telemetry"); - const gitIdentityValid = Boolean(settings.gitName.trim() && EMAIL_PATTERN.test(settings.gitEmail.trim())); - const outputBlockReason = tools.length === 0 - ? STATUS_TEXT.noTools - : resolved.has("git-config") && !gitIdentityValid - ? "Git 이름과 올바른 이메일을 입력하세요." - : ""; + const outputBlockReason = tools.length === 0 ? STATUS_TEXT.noTools : ""; const canExport = !outputBlockReason; useEffect(() => { @@ -734,8 +715,6 @@ function App() { label="헤더 값" value={settings.otelHeaderValue} onChange={(value) => updateSetting("otelHeaderValue", value)} - type="password" - autoComplete="off" /> { - const identity = gitIdentity(settings); - return identity ? [`configure_git ${sh(identity.name)} ${sh(identity.email)}`] : []; - }, - win: (settings) => { - const identity = gitIdentity(settings); - return identity ? [`Set-GitDefaults -Name ${ps(identity.name)} -Email ${ps(identity.email)}`] : []; - } + mac: (settings) => [ + `configure_git ${sh(settings.gitName)} ${sh(settings.gitEmail)}` + ], + win: (settings) => [ + `Set-GitDefaults -Name ${ps(settings.gitName)} -Email ${ps(settings.gitEmail)}` + ] } ]; @@ -263,12 +261,6 @@ function ps(value) { return `'${String(value).replace(/'/g, "''")}'`; } -function gitIdentity(settings) { - const name = String(settings?.gitName || "").trim(); - const email = String(settings?.gitEmail || "").trim(); - return name && /^[^\s@]+@[^\s@]+$/.test(email) ? { name, email } : null; -} - function boolSetting(settings, key) { return settings?.[key] ? "1" : "0"; } diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js index 81dc0d0..269d19c 100644 --- a/tests/e2e/app.spec.js +++ b/tests/e2e/app.spec.js @@ -31,7 +31,7 @@ test("builds scripts and captures primary states", async ({ page }) => { await expect(toolList.getByRole("checkbox")).toHaveCount(18); await expect(toolList.getByRole("checkbox", { name: /Bun/ })).toBeChecked(); await expect(toolList.getByRole("checkbox", { name: /Codex App/ })).toBeChecked(); - await expect(toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ })).not.toBeChecked(); + await expect(toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ })).toBeChecked(); await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "false"); const pageOrigin = new URL(page.url()).origin; await page.context().grantPermissions(["clipboard-write"], { origin: pageOrigin }); @@ -50,7 +50,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 사용자 정보 기본값/ })).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(); @@ -111,8 +111,8 @@ test("builds scripts and captures primary states", async ({ page }) => { await page.screenshot({ path: join(screenshotDir, "mobile-mac.png"), fullPage: true }); }); -test("keeps sensitive settings out of shared URLs and exposes shared telemetry", async ({ page }) => { - const sensitive = { +test("shares configured settings and keeps Advanced collapsed", async ({ page }) => { + const shared = { gitName: "Aiden Student", gitEmail: "aiden@example.com", otelEndpoint: "https://collector.example.com", @@ -131,38 +131,36 @@ test("keeps sensitive settings out of shared URLs and exposes shared telemetry", const params = new URLSearchParams({ os: "mac", tools: "git,git-config,claude-code-telemetry,codex-telemetry", - ...sensitive + ...shared }); await page.goto(`./?${params}`); - await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByRole("button", { name: /고급 설정/ })).toHaveAttribute("aria-expanded", "false"); await page.getByText("Git 기본값 수정").click(); - await expect(page.getByRole("textbox", { name: "이름", exact: true })).toHaveValue(""); - await expect(page.getByRole("textbox", { name: "이메일", exact: true })).toHaveValue(""); + await expect(page.getByRole("textbox", { name: "이름", exact: true })).toHaveValue("Aiden Student"); + await expect(page.getByRole("textbox", { name: "이메일", exact: true })).toHaveValue("aiden@example.com"); + await page.getByRole("button", { name: /고급 설정/ }).click(); await page.getByText("수집 서버 연결").click(); - await expect(page.getByRole("textbox", { name: "수집 서버 주소" })).toHaveValue("http://localhost:4317"); - await expect(page.getByRole("textbox", { name: "리소스 속성" })).toHaveValue(""); - const headerValue = page.getByRole("textbox", { name: "헤더 값" }); - await expect(headerValue).toHaveAttribute("type", "password"); - await expect(headerValue).toHaveAttribute("autocomplete", "off"); + 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"); 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()).not.toBeChecked(); - await expect(promptBodyToggles.last()).not.toBeChecked(); - await promptBodyToggles.first().click(); + await expect(promptBodyToggles.first()).toBeChecked(); + await expect(promptBodyToggles.last()).toBeChecked(); await expect.poll(() => { const current = new URL(page.url()).searchParams; - return Object.keys(sensitive).filter((key) => current.has(key)); - }).toEqual([]); + return Object.fromEntries(Object.keys(shared).map((key) => [key, current.get(key)])); + }).toEqual(shared); }); -test("blocks generated output when selection or Git identity is invalid", async ({ page }) => { +test("blocks empty output and keeps beginner Git defaults", async ({ page }) => { await page.goto("./"); const toolList = page.getByLabel("설치할 도구"); @@ -178,21 +176,11 @@ test("blocks generated output when selection or Git identity is invalid", async await expect(page.getByText("도구를 하나 이상 선택하세요.")).toBeVisible(); await toolList.getByRole("checkbox", { name: /Git 사용자 정보 기본값/ }).click(); - await page.getByText("Git 기본값 수정").click(); - await expect(copyButton).toBeDisabled(); - await expect(downloadButton).toBeDisabled(); - await expect(terminalButton).toBeDisabled(); - await expect(page.getByText("Git 이름과 올바른 이메일을 입력하세요.")).toBeVisible(); - await expect(scriptPreview).not.toHaveValue(/configure_git '' ''/); - - await page.getByRole("textbox", { name: "이름", exact: true }).fill("Aiden Student"); - await page.getByRole("textbox", { name: "이메일", exact: true }).fill("not-an-email"); - await expect(copyButton).toBeDisabled(); - await page.getByRole("textbox", { name: "이메일", exact: true }).fill("aiden@example.com"); await expect(copyButton).toBeEnabled(); await expect(downloadButton).toBeEnabled(); await expect(terminalButton).toBeEnabled(); - await expect(scriptPreview).toHaveValue(/configure_git 'Aiden Student' 'aiden@example.com'/); + 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 4ec5d87..1cd0207 100644 --- a/tests/mac.test.js +++ b/tests/mac.test.js @@ -110,15 +110,6 @@ const gitScript = buildMacScript(resolveSelection(new Set(["git-config"]), "mac" assert.match(gitScript, /\[ -z "\$existing_name" \] && git config --global user\.name/); assert.match(gitScript, /\[ -z "\$existing_email" \] && git config --global user\.email/); -// Invalid or incomplete identity must not generate a global git config call. -for (const invalidSettings of [ - { gitName: "", gitEmail: "a@example.com" }, - { gitName: "A", gitEmail: "not-an-email" } -]) { - const invalidGitScript = buildMacScript(resolveSelection(new Set(["git-config"]), "mac"), invalidSettings); - assert.doesNotMatch(invalidGitScript, /^configure_git /m); -} - // CR/LF is stripped from telemetry values (TOML/script-line breakout guard). const crlfScript = buildMacScript(resolveSelection(new Set(["claude-code-telemetry"]), "mac"), { ...settings, diff --git a/tests/windows.test.js b/tests/windows.test.js index 45204da..c0b065e 100644 --- a/tests/windows.test.js +++ b/tests/windows.test.js @@ -201,14 +201,5 @@ const gitWinScript = buildWindowsScript(resolveSelection(new Set(["git-config"]) assert.match(gitWinScript, /if \(-not \$existingName\) \{ & git config --global user\.name/); assert.match(gitWinScript, /if \(-not \$existingEmail\) \{ & git config --global user\.email/); -// Invalid or incomplete identity must not generate a global git config call. -for (const invalidSettings of [ - { gitName: "", gitEmail: "a@example.com" }, - { gitName: "A", gitEmail: "not-an-email" } -]) { - const invalidGitWinScript = buildWindowsScript(resolveSelection(new Set(["git-config"]), "win"), invalidSettings); - assert.doesNotMatch(invalidGitWinScript, /^Set-GitDefaults -Name /m); -} - assert.equal(selfTest().ok, true); console.log("windows tests pass"); From 63e36dd42f749b449f4007049d7d6d03f24ffeae Mon Sep 17 00:00:00 2001 From: seungwonme Date: Sun, 12 Jul 2026 03:14:29 +0900 Subject: [PATCH 3/4] Fix Python fallback and runner permissions --- public/run-mac.sh | 1 - src/builder.js | 15 ++++++++------- tests/public-runners.test.js | 6 ++++-- tests/windows.test.js | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/public/run-mac.sh b/public/run-mac.sh index e7169f1..641d263 100644 --- a/public/run-mac.sh +++ b/public/run-mac.sh @@ -1,6 +1,5 @@ #!/bin/bash set -euo pipefail -umask 077 if [ -z "${DEV_SETUP_SCRIPT_B64:-}" ]; then printf "DEV_SETUP_SCRIPT_B64 is missing.\n" >&2 diff --git a/src/builder.js b/src/builder.js index ab14478..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' }" ] }, { @@ -691,7 +692,7 @@ export function buildWindowsScript(resolved, settings) { " $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 $command.Source -like '*\\WindowsApps\\*' -or -not (Test-Path $command.Source -PathType Leaf)) { continue }", + " 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", @@ -718,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", " }", "}", diff --git a/tests/public-runners.test.js b/tests/public-runners.test.js index 4677ec3..bb253d6 100644 --- a/tests/public-runners.test.js +++ b/tests/public-runners.test.js @@ -17,7 +17,7 @@ const macContent = readFileSync(macRunner, "utf8"); assert.match(macContent, /DEV_SETUP_SCRIPT_B64/); assert.match(macContent, /base64 -D/); assert.match(macContent, /base64 --decode/); -assert.match(macContent, /^umask 077$/m); +assert.doesNotMatch(macContent, /^umask 077$/m); assert.match(macContent, /mktemp/); assert.match(macContent, /trap cleanup EXIT/); assert.doesNotMatch(macContent, /\$\$/); @@ -54,9 +54,11 @@ function runFixture(command, args, fixture) { return { files, result }; } -const macFixture = runFixture("bash", [macRunner], '#!/bin/bash\nprintf "runner fixture ok\\n"\nexit 7\n'); +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"; diff --git a/tests/windows.test.js b/tests/windows.test.js index c0b065e..d4344d8 100644 --- a/tests/windows.test.js +++ b/tests/windows.test.js @@ -70,10 +70,21 @@ 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.match(commandDetector, /WindowsApps/); +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, { @@ -121,6 +132,29 @@ if (!pwsh.error && pwsh.status === 0) { }); 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") From aa25cd66addc0e5d3e71abab4c3136d9fe5439a1 Mon Sep 17 00:00:00 2001 From: seungwonme Date: Mon, 13 Jul 2026 11:19:58 +0900 Subject: [PATCH 4/4] Remove preview skip link --- src/App.jsx | 3 +-- src/styles.css | 16 ---------------- tests/e2e/app.spec.js | 2 -- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e931965..d1dfdc6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -570,7 +570,6 @@ function App() { return (
- 미리보기로 건너뛰기

개발 환경 설치 도우미

@@ -780,7 +779,7 @@ function App() {
- +
diff --git a/src/styles.css b/src/styles.css index 92c96eb..e697ec8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -38,22 +38,6 @@ body { grid-template-rows: auto 1fr auto; } -.skip-link { - position: fixed; - top: 10px; - left: 10px; - z-index: 30; - transform: translateY(-200%); - padding: 8px 12px; - border-radius: 6px; - background: var(--ink); - color: #ffffff; -} - -.skip-link:focus { - transform: none; -} - .topbar { display: flex; align-items: center; diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js index 269d19c..d36aebb 100644 --- a/tests/e2e/app.spec.js +++ b/tests/e2e/app.spec.js @@ -13,8 +13,6 @@ test("exposes Korean, read-only preview, and announced status", async ({ page }) await page.goto("./"); await expect.soft(page.locator("html")).toHaveAttribute("lang", "ko"); - await expect.soft(page.getByRole("link", { name: "미리보기로 건너뛰기" })).toHaveAttribute("href", "#script-preview"); - await expect.soft(page.locator("#script-preview")).toHaveAttribute("aria-label", "생성된 설치 스크립트 미리보기"); await expect.soft(page.getByRole("textbox", { name: "생성된 설치 스크립트" })).toHaveAttribute("readonly", ""); await expect.soft(page.locator(".status")).toHaveAttribute("role", "status"); await expect.soft(page.locator(".status")).toHaveAttribute("aria-live", "polite");