diff --git a/.gitignore b/.gitignore index 9bbb224..3f2d642 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ playwright-report/ test-results/ +.DS_Store +prompts/ diff --git a/public/run-mac.sh b/public/run-mac.sh index 0838061..056e4ef 100644 --- a/public/run-mac.sh +++ b/public/run-mac.sh @@ -12,4 +12,12 @@ if ! printf "%s" "$DEV_SETUP_SCRIPT_B64" | base64 -D > "$tmp" 2>/dev/null; then fi chmod +x "$tmp" -bash "$tmp" +# Reconnect stdin to the terminal: when invoked via `curl | bash`, stdin is the +# consumed pipe, so Homebrew's installer (sudo password, prompts) could not read input. +# /dev/tty exists even without a controlling terminal (CI, cron), where opening it +# fails with ENXIO; probe that it is actually openable before redirecting. +if { : < /dev/tty; } 2>/dev/null; then + bash "$tmp" < /dev/tty +else + bash "$tmp" +fi diff --git a/src/App.jsx b/src/App.jsx index 3ead0b6..1d5b2cd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -170,7 +170,9 @@ const PACKAGE_ICONS = { const ADVANCED_PACKAGE_IDS = new Set(["claude-code-telemetry", "codex-telemetry"]); const PACKAGE_IDS = new Set(PACKAGES.map((item) => item.id)); -const URL_SETTING_KEYS = Object.keys(DEFAULT_SETTINGS); +// 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)); const PERMISSION_HELP = { mac: [ @@ -341,7 +343,8 @@ function terminalInstallCommand(os, script) { if (os === "mac") { return `curl -fsSL '${publicAssetUrl("run-mac.sh")}' | DEV_SETUP_SCRIPT_B64='${encodedScript}' bash`; } - return `powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:DEV_SETUP_SCRIPT_B64='${encodedScript}'; iex (Invoke-WebRequest -UseBasicParsing '${publicAssetUrl("run-windows.ps1")}').Content"`; + // No `$` in the outer command: pasting into PowerShell would otherwise expand $env:DEV_SETUP_SCRIPT_B64 before the child runs. Base64 has no single quotes to escape. + return `powershell -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::SetEnvironmentVariable('DEV_SETUP_SCRIPT_B64','${encodedScript}','Process'); iex (Invoke-WebRequest -UseBasicParsing '${publicAssetUrl("run-windows.ps1")}').Content"`; } function groupDomId(group) { diff --git a/src/builder.js b/src/builder.js index cc2b6d8..196f877 100644 --- a/src/builder.js +++ b/src/builder.js @@ -36,7 +36,7 @@ export const PACKAGES = [ note: "Latest package-manager stable Python.", presets: ["minimal", "agent"], deps: { mac: ["homebrew"], win: [] }, - mac: () => ['brew_formula "Python" "python" "python3"'], + mac: () => ["install_python"], win: () => [ "$pythonOk = $false", "foreach ($candidate in @('Python.Python.3.14','Python.Python.3.13','Python.Python.3.12')) {", @@ -267,7 +267,11 @@ function boolSetting(settings, key) { function setting(settings, key, fallback) { const value = settings?.[key]; - return value === undefined || value === null || value === "" ? fallback : value; + if (value === undefined || value === null || value === "") { + return fallback; + } + // Strip CR/LF: illegal in TOML basic strings and a script-line breakout vector. + return typeof value === "string" ? value.replace(/[\r\n]/g, "") : value; } function telemetryArgs(settings, quote = sh) { @@ -385,7 +389,8 @@ export function buildMacScript(resolved, settings) { ' info "Homebrew"', " if has brew; then ok \"Homebrew installed\"; refresh_path; return 0; fi", " # Official install docs: https://brew.sh/", - ` /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" >> "$LOG" 2>&1 || { fail "Homebrew"; return 1; }`, + " # NONINTERACTIVE=1 skips the RETURN prompt; output stays on the terminal so the sudo password prompt is visible.", + ` NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || { fail "Homebrew"; return 1; }`, " refresh_path", ' if has brew; then ok "Homebrew installed"; else fail "Homebrew"; fi', "}", @@ -401,6 +406,16 @@ export function buildMacScript(resolved, settings) { ' if has "$command_name"; then ok "$label installed"; else fail "$label"; fi', "}", "", + "install_python() {", + ' info "Python"', + " # /usr/bin/python3 is an Apple CommandLineTools stub, so check for a real Homebrew Python instead of `has python3`.", + ' if [ -x /opt/homebrew/bin/python3 ] || [ -x /usr/local/bin/python3 ]; then ok "Python installed"; return 0; fi', + ' if ! has brew; then fail "Python (Homebrew missing)"; return 1; fi', + ' brew install python >> "$LOG" 2>&1', + " refresh_path", + ' if [ -x /opt/homebrew/bin/python3 ] || [ -x /usr/local/bin/python3 ]; then ok "Python installed"; else fail "Python"; fi', + "}", + "", "app_exists() {", ' [ -n "$1" ] && { [ -d "/Applications/$1" ] || [ -d "$HOME/Applications/$1" ]; }', "}", @@ -534,6 +549,7 @@ export function buildMacScript(resolved, settings) { ' [ "$raw_mode" = "inline" ] && printf "%s\\n" "export OTEL_LOG_RAW_API_BODIES=1"', ' [ "$raw_mode" = "file" ] && [ -n "$raw_dir" ] && printf "export OTEL_LOG_RAW_API_BODIES=%s\\n" "$(shell_escape "file:$raw_dir")"', ' } > "$telemetry_file"', + ' chmod 600 "$telemetry_file"', ' append_once "$HOME/.zshrc" "# Dev Setup Builder - Claude Code telemetry" "source \\"$telemetry_file\\""', ' ok "Claude Code telemetry configured"', "}", @@ -587,7 +603,7 @@ export function buildMacScript(resolved, settings) { ' printf "trace_exporter = %s\\n" "$(codex_exporter_toml "$trace_exporter" "$endpoint" "$protocol" "$header_name" "$header_value")"', ' printf "metrics_exporter = %s\\n" "$(codex_exporter_toml "$metrics_exporter" "$endpoint" "$protocol" "$header_name" "$header_value")"', ' printf "%s\\n" "$end"', - ' } >> "$config"', + ' } > "$config"', ' rm -f "$clean"', ' ok "Codex telemetry configured"', "}", @@ -614,8 +630,8 @@ export function buildMacScript(resolved, settings) { ' existing_name="$(git config --global user.name 2>/dev/null || true)"', ' existing_email="$(git config --global user.email 2>/dev/null || true)"', ' if [ -n "$existing_name" ] && [ -n "$existing_email" ]; then ok "Git identity already set"; return 0; fi', - ' git config --global user.name "$name" >> "$LOG" 2>&1', - ' git config --global user.email "$email" >> "$LOG" 2>&1', + ' [ -z "$existing_name" ] && git config --global user.name "$name" >> "$LOG" 2>&1', + ' [ -z "$existing_email" ] && git config --global user.email "$email" >> "$LOG" 2>&1', ' ok "Git identity defaults set"', "}", "", @@ -649,7 +665,7 @@ export function buildWindowsScript(resolved, settings) { "rem For SmartScreen: More info > Run anyway.", "setlocal", "set \"BAT_FILE=%~f0\"", - "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$raw=[IO.File]::ReadAllText($env:BAT_FILE,[Text.UTF8Encoding]::new($false)); $m='#__PS_SCRIPT_BELOW__'; $i=$raw.LastIndexOf($m); if($i -lt 0){ Write-Host 'ERROR: PS marker not found'; exit 1 }; $ps=$raw.Substring($i+$m.Length); $sb=[scriptblock]::Create($ps); & $sb; exit $LASTEXITCODE\"", + "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$raw=[IO.File]::ReadAllText($env:BAT_FILE,[Text.UTF8Encoding]::new($false)); $m='#__PS_SCRIPT'+'_BELOW__'; $i=$raw.IndexOf($m); if($i -lt 0){ Write-Host 'ERROR: PS marker not found'; exit 1 }; $ps=$raw.Substring($i+$m.Length); $sb=[scriptblock]::Create($ps); & $sb; exit $LASTEXITCODE\"", "set \"EC=%ERRORLEVEL%\"", "pause", "exit /b %EC%", @@ -661,7 +677,7 @@ export function buildWindowsScript(resolved, settings) { "try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}", "$LogFile = Join-Path $env:TEMP (\"dev-setup-builder-{0}.log\" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))", "[IO.File]::WriteAllText($LogFile, \"=== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===`r`n\", [Text.UTF8Encoding]::new($false))", - "$Failed = @()", + "$script:Failed = @()", "", "function Ok([string]$Text) { Write-Host \" OK: $Text\" }", "function Warn([string]$Text) { Write-Host \" WARN: $Text\" }", @@ -841,7 +857,9 @@ export function buildWindowsScript(resolved, settings) { " Step 'Codex CLI'", " if (Has-Command 'codex') { Ok 'Codex CLI installed'; return }", " # Official install docs: https://developers.openai.com/codex/quickstart", - " try { & powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex\" *>> $LogFile } catch { $_ | Out-String | Add-Content -Path $LogFile }", + " # Set the env var in this scope (inherited by the child) so it is not interpolated away inside the child -Command string.", + " $env:CODEX_NON_INTERACTIVE = '1'", + " try { & powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\" *>> $LogFile } catch { $_ | Out-String | Add-Content -Path $LogFile }", " Refresh-Path", " if (Has-Command 'codex') { Ok 'Codex CLI installed'; return }", " if (Has-Command 'npm') { Install-NpmGlobal -Label 'Codex CLI' -Package '@openai/codex' -Command 'codex' } else { Fail 'Codex CLI' }", @@ -985,8 +1003,8 @@ export function buildWindowsScript(resolved, settings) { " $existingName = & git config --global user.name 2>$null", " $existingEmail = & git config --global user.email 2>$null", " if ($existingName -and $existingEmail) { Ok 'Git identity already set'; return }", - " & git config --global user.name $Name *>> $LogFile", - " & git config --global user.email $Email *>> $LogFile", + " if (-not $existingName) { & git config --global user.name $Name *>> $LogFile }", + " if (-not $existingEmail) { & git config --global user.email $Email *>> $LogFile }", " Ok 'Git identity defaults set'", "}", "", @@ -996,9 +1014,9 @@ export function buildWindowsScript(resolved, settings) { "", "Write-Host \"\"", "Write-Host \"Log: $LogFile\"", - "if ($Failed.Count -gt 0) {", + "if ($script:Failed.Count -gt 0) {", " Write-Host 'Failed items:'", - " foreach ($item in $Failed) { Write-Host \" - $item\" }", + " foreach ($item in $script:Failed) { Write-Host \" - $item\" }", " exit 1", "}", "Write-Host 'Done. Open a new terminal before using newly installed commands.'", diff --git a/tests/mac.test.js b/tests/mac.test.js index 5213bcb..628c5e3 100644 --- a/tests/mac.test.js +++ b/tests/mac.test.js @@ -85,5 +85,37 @@ assert.match(codexTelemetryScript, /\[otel\]/); assert.match(codexTelemetryScript, /metrics_exporter = %s/); assert.match(codexTelemetryScript, /configure_codex_telemetry 'http:\/\/localhost:4317' 'grpc' '' '' 'dev' '' '60000' '5000' 'otlp' 'otlp' 'none' '0' '0' '0' '0' 'off' '' 'otlp' 'none' 'otlp' '1'/); +// --- Regression coverage for the script-defect fixes --- + +// Codex telemetry must REPLACE config.toml (>), not append (>>), or re-runs stack [otel] tables. +assert.match(codexTelemetryScript, /\}\s*>\s*"\$config"/); +assert.doesNotMatch(codexTelemetryScript, /\}\s*>>\s*"\$config"/); + +// Homebrew installer runs non-interactively (no silent RETURN hang). +const brewScript = buildMacScript(new Set(["homebrew"]), settings); +assert.match(brewScript, /NONINTERACTIVE=1 \/bin\/bash -c/); + +// Python must not trust the /usr/bin/python3 CommandLineTools stub. +const pythonScript = buildMacScript(resolveSelection(new Set(["python"]), "mac"), settings); +assert.match(pythonScript, /install_python\(\) \{/); +assert.match(pythonScript, /\/opt\/homebrew\/bin\/python3/); +assert.doesNotMatch(pythonScript, /brew_formula "Python"/); + +// Telemetry file may hold the OTLP header secret -> owner-only. +assert.match(claudeTelemetryScript, /chmod 600 "\$telemetry_file"/); + +// Git identity writes each field only when that specific field is missing. +const gitScript = buildMacScript(resolveSelection(new Set(["git-config"]), "mac"), settings); +assert.match(gitScript, /\[ -z "\$existing_name" \] && git config --global user\.name/); +assert.match(gitScript, /\[ -z "\$existing_email" \] && git config --global user\.email/); + +// CR/LF is stripped from telemetry values (TOML/script-line breakout guard). +const crlfScript = buildMacScript(resolveSelection(new Set(["claude-code-telemetry"]), "mac"), { + ...settings, + otelEndpoint: "http://x\nEVIL" +}); +assert.doesNotMatch(crlfScript, /http:\/\/x\nEVIL/); +assert.match(crlfScript, /http:\/\/xEVIL/); + assert.equal(selfTest().ok, true); console.log("mac tests pass"); diff --git a/tests/public-runners.test.js b/tests/public-runners.test.js index 6a28b71..17548a8 100644 --- a/tests/public-runners.test.js +++ b/tests/public-runners.test.js @@ -15,6 +15,11 @@ const macContent = readFileSync(macRunner, "utf8"); assert.match(macContent, /DEV_SETUP_SCRIPT_B64/); assert.match(macContent, /base64 -D/); assert.match(macContent, /base64 --decode/); +// 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. +assert.match(macContent, /\{ : < \/dev\/tty; \} 2>\/dev\/null/); +assert.doesNotMatch(macContent, /\[ -e \/dev\/tty \]/); const windowsContent = readFileSync(windowsRunner, "utf8"); assert.match(windowsContent, /DEV_SETUP_SCRIPT_B64/); diff --git a/tests/windows.test.js b/tests/windows.test.js index 8c795c3..827b1f7 100644 --- a/tests/windows.test.js +++ b/tests/windows.test.js @@ -81,5 +81,36 @@ 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'/); +// --- Regression coverage for the script-defect fixes --- + +// Failure tracking must be $script:-scoped everywhere, or a FAIL is lost and the run reports exit 0. +assert.match(codexScript, /\$script:Failed = @\(\)/); +assert.match(codexScript, /if \(\$script:Failed\.Count -gt 0\)/); +assert.doesNotMatch(codexScript, /\n\$Failed = @\(\)/); + +// Codex CLI env var is set in the parent scope, not interpolated away inside the child -Command. +assert.match(codexScript, /\$env:CODEX_NON_INTERACTIVE = '1'/); +assert.doesNotMatch(codexScript, /-Command "\$env:CODEX_NON_INTERACTIVE=1;/); + +// Polyglot marker is split in the header and matched with IndexOf so a setting value cannot hijack extraction. +assert.match(codexScript, /\$m='#__PS_SCRIPT'\+'_BELOW__'/); +assert.match(codexScript, /\$raw\.IndexOf\(\$m\)/); +assert.doesNotMatch(codexScript, /\.LastIndexOf\(\$m\)/); + +// A hostile setting containing the literal marker must not become the first match. +const marker = "#__PS_SCRIPT_BELOW__"; +const hostileScript = buildWindowsScript(resolveSelection(new Set(["git-config"]), "win"), { + gitName: `x${marker}Stop-Computer`, + gitEmail: "a@b.c" +}); +const firstMarker = hostileScript.indexOf(marker); +assert.equal(hostileScript.slice(0, firstMarker).includes(marker), false); +assert.match(hostileScript.slice(firstMarker), /^#__PS_SCRIPT_BELOW__\r?\n/); + +// Git identity writes each field only when that specific field is missing. +const gitWinScript = buildWindowsScript(resolveSelection(new Set(["git-config"]), "win"), settings); +assert.match(gitWinScript, /if \(-not \$existingName\) \{ & git config --global user\.name/); +assert.match(gitWinScript, /if \(-not \$existingEmail\) \{ & git config --global user\.email/); + assert.equal(selfTest().ok, true); console.log("windows tests pass");