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
5 changes: 4 additions & 1 deletion public/run-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ if [ -z "${DEV_SETUP_SCRIPT_B64:-}" ]; then
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
Expand Down
17 changes: 12 additions & 5 deletions public/run-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 11 additions & 6 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const RAW_BODY_OPTIONS = [

const STATUS_TEXT = {
ready: "준비됨",
noTools: "선택된 도구 없음",
noTools: "도구를 하나 이상 선택하세요.",
copied: "복사 완료",
copyFailed: "복사 실패"
};
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -169,9 +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));
// 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 URL_SETTING_KEYS = Object.keys(DEFAULT_SETTINGS);

const PERMISSION_HELP = {
mac: [
Expand Down Expand Up @@ -390,6 +388,8 @@ function App() {
const tools = visibleResolved(resolved);
const currentFileName = fileName(os);
const showTelemetryDefaults = resolved.has("claude-code-telemetry") || resolved.has("codex-telemetry");
const outputBlockReason = tools.length === 0 ? STATUS_TEXT.noTools : "";
const canExport = !outputBlockReason;

useEffect(() => {
window.history.replaceState(null, "", `${window.location.pathname}?${urlSearch(os, selected, settings)}`);
Expand Down Expand Up @@ -533,6 +533,7 @@ function App() {
}

async function copyCurrentScript() {
if (!canExport) return;
await copyText(script, STATUS_TEXT.copied, "copy");
}

Expand All @@ -556,6 +557,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");
Expand Down Expand Up @@ -586,13 +588,15 @@ function App() {
label={lastAction === "copy" ? "복사됨" : "복사"}
icon={lastAction === "copy" ? <Check size={17} /> : <Copy size={17} />}
onClick={copyCurrentScript}
isDisabled={!canExport}
variant="secondary"
/>
<Button
className="action-button"
label={lastAction === "download" ? "다운로드됨" : "다운로드"}
icon={lastAction === "download" ? <Check size={17} /> : <Download size={17} />}
onClick={downloadCurrentScript}
isDisabled={!canExport}
variant="primary"
/>
</div>
Expand Down Expand Up @@ -803,6 +807,7 @@ function App() {
className="command-copy terminal-command"
aria-label={`${os === "mac" ? "macOS" : "Windows"} 터미널 설치 명령어 복사`}
onClick={() => copyCommand(installCommand)}
disabled={!canExport}
>
<code title={installCommand}>{installCommand}</code>
<span className="command-copy-status">
Expand All @@ -822,7 +827,7 @@ function App() {
/>
</div>
<div className="status" role="status" aria-live="polite">
<span className={tools.length ? "" : "warning"}>{tools.length ? status : STATUS_TEXT.noTools}</span>
<span className={outputBlockReason ? "warning" : ""}>{outputBlockReason || status}</span>
<span>{added.length ? `자동 추가: ${added.join(", ")}` : ""}</span>
</div>
</Card>
Expand Down
85 changes: 47 additions & 38 deletions src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }"
]
},
{
Expand Down Expand Up @@ -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"]
},
{
Expand Down Expand Up @@ -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() {",
Expand Down Expand Up @@ -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')",
Expand All @@ -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",
" }",
"}",
Expand Down Expand Up @@ -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 {",
Expand Down Expand Up @@ -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'",
"}",
"",
Expand Down
6 changes: 6 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -773,4 +773,10 @@ body {
align-items: flex-start;
flex-direction: column;
}

.site-footer {
position: static;
justify-self: end;
margin: 0 14px 14px;
}
}
86 changes: 67 additions & 19 deletions tests/e2e/app.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading
Loading