Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules/
dist/
playwright-report/
test-results/
.DS_Store
prompts/
10 changes: 9 additions & 1 deletion public/run-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 31 additions & 13 deletions src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
"}",
Expand All @@ -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" ]; }',
"}",
Expand Down Expand Up @@ -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"',
"}",
Expand Down Expand Up @@ -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"',
"}",
Expand All @@ -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"',
"}",
"",
Expand Down Expand Up @@ -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%",
Expand All @@ -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\" }",
Expand Down Expand Up @@ -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' }",
Expand Down Expand Up @@ -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'",
"}",
"",
Expand All @@ -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.'",
Expand Down
32 changes: 32 additions & 0 deletions tests/mac.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
5 changes: 5 additions & 0 deletions tests/public-runners.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
31 changes: 31 additions & 0 deletions tests/windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");