diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 77fe69ce970..39a4b19e579 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -143,6 +143,89 @@ jobs: retention-days: ${{ inputs.artifact_retention_days }} if-no-files-found: error + build-windows: + name: Build - Windows (x64) + runs-on: windows-latest + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable git long paths + run: git config --global core.longpaths true + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.2" + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen + + - name: Set version suffix + if: inputs.version_suffix != '' + working-directory: apps/desktop + shell: bash + run: | + CURRENT_VERSION=$(node -p "require('./package.json').version") + NEW_VERSION="${CURRENT_VERSION}${{ inputs.version_suffix }}" + node -e " + const fs = require('fs'); + const pkg = require('./package.json'); + pkg.version = '$NEW_VERSION'; + fs.writeFileSync('./package.json', JSON.stringify(pkg, null, '\t') + '\n'); + " + + - name: Clean dev folder + working-directory: apps/desktop + run: bun run clean:dev + + - name: Generate icons + working-directory: apps/desktop + run: bun run generate:icons + + - name: Compile app + working-directory: apps/desktop + run: bun run compile:app + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} + + - name: Copy native modules + working-directory: apps/desktop + run: bun run copy:native-modules + + - name: Validate native runtime + working-directory: apps/desktop + run: bun run validate:native-runtime + + - name: Build installer + working-directory: apps/desktop + run: bun run build + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_prefix }}-windows-x64 + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.yml + apps/desktop/release/*.blockmap + retention-days: ${{ inputs.artifact_retention_days }} + if-no-files-found: error + build-linux: name: Build - Linux (x64) runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b121d962a5d..0903531f245 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,3 +130,25 @@ jobs: env: NEXT_PUBLIC_OUTLIT_KEY: ${{ secrets.NEXT_PUBLIC_OUTLIT_KEY || 'ci-outlit-placeholder-key' }} run: bun turbo run build --filter=@superset/desktop + + test-windows: + name: Test (Windows) + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable git long paths + run: git config --global core.longpaths true + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.2" + + - name: Install dependencies + run: bun install --frozen + + - name: Run desktop tests + working-directory: apps/desktop + run: bun test diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 566a6280fa4..a476ca42e73 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -86,6 +86,14 @@ jobs: echo "Created stable copy: Superset-${arch}.AppImage" fi done + # Windows exe + for file in *.exe; do + if [[ -f "$file" ]]; then + arch=$(echo "$file" | sed -E 's/.*-([^-]+)\.exe$/\1/') + cp "$file" "Superset-${arch}.exe" + echo "Created stable copy: Superset-${arch}.exe" + fi + done # Keep Linux updater manifest at a stable filename for generic provider lookups. for file in *-linux.yml; do if [[ -f "$file" && "$file" != "latest-linux.yml" ]]; then diff --git a/README.md b/README.md index ba3cf225ed4..c53c358510e 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,21 @@ If it runs in a terminal, it runs on Superset | Requirement | Details | |:------------|:--------| -| **OS** | macOS (Windows/Linux untested) | +| **OS** | macOS, Windows 10+ (Linux untested) | | **Runtime** | [Bun](https://bun.sh/) v1.0+ | | **Version Control** | Git 2.20+ | | **GitHub CLI** | [gh](https://cli.github.com/) | | **Caddy** | [caddy](https://caddyserver.com/docs/install) (for dev server) | +### Windows Support + +| Requirement | Details | +|---|---| +| OS | Windows 10 1809+ (ConPTY support required) | +| Developer Mode | Enable in Settings > Privacy & Security > For developers | +| Git long paths | `git config --global core.longpaths true` | +| Build Tools | Visual Studio Build Tools with "Desktop development with C++" (for native modules) | + ## Getting Started ### Quick Start (Pre-built) diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 64858b8d7ae..a53d07ca4c4 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -198,6 +198,8 @@ const config: Configuration = { }, ], artifactName: `${productName}-${pkg.version}-\${arch}.\${ext}`, + signAndEditExecutable: false, + forceCodeSigning: false, }, // NSIS installer (Windows) diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index bf8ba11e2fe..5220c265367 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -99,8 +99,10 @@ function copyModuleIfSymlink( console.log(` ${moduleName}: symlink -> replacing with real files`); console.log(` Real path: ${realPath}`); - // Remove the symlink - rmSync(modulePath); + // Remove the symlink/junction + // On Windows, Bun creates junctions for symlinked node_modules. + // rmSync without options fails on junctions, so use recursive+force. + rmSync(modulePath, { recursive: true, force: true }); // Copy the actual files cpSync(realPath, modulePath, { recursive: true }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index ca1d769f614..72de1cd80ac 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -1,6 +1,6 @@ -import { execFile, spawn } from "node:child_process"; +import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdir, rename } from "node:fs/promises"; +import { mkdir, rename, rm } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -680,26 +680,12 @@ export async function removeWorktree( }); // Delete the moved directory in the background — don't block the caller. - // Use spawned `rm -rf` instead of Node's fs.rm which can hang on macOS - // when encountering .app bundles with extended attributes. - const child = spawn("/bin/rm", ["-rf", tempPath], { - detached: true, - stdio: "ignore", - }); - child.unref(); - child.on("error", (err) => { + rm(tempPath, { recursive: true, force: true }).catch((err) => { console.error( - `[removeWorktree] Failed to spawn rm for ${tempPath}:`, + `[removeWorktree] Background cleanup of ${tempPath} failed:`, err.message, ); }); - child.on("exit", (code: number | null) => { - if (code !== 0) { - console.error( - `[removeWorktree] Background cleanup of ${tempPath} failed (exit ${code})`, - ); - } - }); } catch (error) { const code = (error as NodeJS.ErrnoException).code; // If the worktree directory is already gone, just prune metadata diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts index c6cd2632a2c..81d02500ed5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts @@ -54,6 +54,20 @@ async function getShellEnvWithTimeout(): Promise> { * Results are cached for 1 minute to avoid spawning shells repeatedly. */ export async function getShellEnvironment(): Promise> { + // Windows: process.env already contains the full user environment. + // Unlike macOS GUI apps launched from Finder/Dock, Windows apps inherit + // the complete user environment including PATH modifications. + if (process.platform === "win32") { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value; + } + } + return env; + } + + // Existing macOS/Linux code follows... const now = Date.now(); const ttl = isFallbackCache ? fallbackCacheTtlMs : CACHE_TTL_MS; if (cachedEnv && now - cacheTime < ttl) { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts index 23349f39b0b..7c75d7a139f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts @@ -5,8 +5,9 @@ import { getCommandShellArgs, getShellEnv, } from "main/lib/agent-setup/shell-wrappers"; -import { buildSafeEnv, sanitizeEnv } from "main/lib/terminal/env"; +import { buildSafeEnv, getDefaultShell, sanitizeEnv } from "main/lib/terminal/env"; import { SUPERSET_DIR_NAME } from "shared/constants"; +import treeKill from "tree-kill"; import { removeWorktree } from "./git"; import { loadSetupConfig } from "./setup"; @@ -42,9 +43,7 @@ export async function runTeardown({ console.log(`[teardown] Running for "${workspaceName}": ${command}`); try { - const shell = - process.env.SHELL || - (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); + const shell = process.env.SHELL || getDefaultShell(); const supersetHomeDir = process.env.SUPERSET_HOME_DIR || join(homedir(), SUPERSET_DIR_NAME); const shellWrapperPaths = { @@ -60,7 +59,8 @@ export async function runTeardown({ const output = await new Promise((resolve, reject) => { const child = spawn(shell, args, { cwd: worktreePath, - detached: true, + detached: process.platform !== "win32", + ...(process.platform === "win32" ? { windowsHide: true } : {}), stdio: ["ignore", "pipe", "pipe"], env: { ...baseEnv, @@ -114,7 +114,14 @@ export async function runTeardown({ `[teardown] Timed out after ${TEARDOWN_TIMEOUT_MS}ms, killing process group`, ); try { - if (child.pid) process.kill(-child.pid, "SIGKILL"); + if (child.pid) { + if (process.platform === "win32") { + // Windows: can't use negative PID for process group kill + treeKill(child.pid, "SIGKILL"); + } else { + process.kill(-child.pid, "SIGKILL"); + } + } } catch {} reject( new Error(`Teardown timed out after ${TEARDOWN_TIMEOUT_MS}ms`), diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts index 4aba1cec9ac..f1a62c9e74e 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts @@ -8,7 +8,8 @@ import { } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const COPILOT_HOOK_SCRIPT_NAME = "copilot-hook.sh"; +export const COPILOT_HOOK_SCRIPT_NAME = + process.platform === "win32" ? "copilot-hook.ps1" : "copilot-hook.sh"; const COPILOT_HOOK_SIGNATURE = "# Superset copilot hook"; const COPILOT_HOOK_VERSION = "v1"; @@ -17,7 +18,9 @@ export const COPILOT_HOOK_MARKER = `${COPILOT_HOOK_SIGNATURE} ${COPILOT_HOOK_VER const COPILOT_HOOK_TEMPLATE_PATH = path.join( __dirname, "templates", - "copilot-hook.template.sh", + process.platform === "win32" + ? "copilot-hook.template.ps1" + : "copilot-hook.template.sh", ); export function getCopilotHookScriptPath(): string { diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts index 8e3b7efa282..90086d2782c 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts @@ -11,7 +11,8 @@ import { } from "./agent-wrappers-common"; import { HOOKS_DIR } from "./paths"; -export const GEMINI_HOOK_SCRIPT_NAME = "gemini-hook.sh"; +export const GEMINI_HOOK_SCRIPT_NAME = + process.platform === "win32" ? "gemini-hook.ps1" : "gemini-hook.sh"; const GEMINI_HOOK_SIGNATURE = "# Superset gemini hook"; const GEMINI_HOOK_VERSION = "v1"; @@ -20,7 +21,9 @@ export const GEMINI_HOOK_MARKER = `${GEMINI_HOOK_SIGNATURE} ${GEMINI_HOOK_VERSIO const GEMINI_HOOK_TEMPLATE_PATH = path.join( __dirname, "templates", - "gemini-hook.template.sh", + process.platform === "win32" + ? "gemini-hook.template.ps1" + : "gemini-hook.template.sh", ); interface GeminiHookConfig { diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 19968b1385d..b9d28caaa54 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -3,13 +3,16 @@ import path from "node:path"; import { env } from "shared/env.shared"; import { HOOKS_DIR } from "./paths"; -export const NOTIFY_SCRIPT_NAME = "notify.sh"; +export const NOTIFY_SCRIPT_NAME = + process.platform === "win32" ? "notify.ps1" : "notify.sh"; export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( __dirname, "templates", - "notify-hook.template.sh", + process.platform === "win32" + ? "notify-hook.template.ps1" + : "notify-hook.template.sh", ); function writeFileIfChanged( diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 21da1dc3df4..eb201be81b5 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -200,6 +200,36 @@ export ZDOTDIR="$_superset_home" ); } +function writePowerShellProfile(paths: { BIN_DIR: string }): boolean { + const profileDir = path.join(path.dirname(paths.BIN_DIR), "powershell"); + fs.mkdirSync(profileDir, { recursive: true }); + + const profilePath = path.join(profileDir, "profile.ps1"); + const binDir = paths.BIN_DIR.replace(/\\/g, "\\\\"); + const script = `# Superset PowerShell profile wrapper +# Prepend Superset bin to PATH for agent binary resolution +$supersetBin = "${binDir}" +if (Test-Path $supersetBin) { + $env:PATH = "$supersetBin;$env:PATH" +} + +# Source user's real PowerShell profile if it exists +if (($PROFILE) -and (Test-Path $PROFILE)) { + . $PROFILE +} +`; + return writeFileIfChanged(profilePath, script, 0o644); +} + +function writeCmdInit(paths: { BIN_DIR: string }): boolean { + const cmdDir = path.join(path.dirname(paths.BIN_DIR), "cmd"); + fs.mkdirSync(cmdDir, { recursive: true }); + + const initPath = path.join(cmdDir, "init.cmd"); + const script = `@echo off\r\nset "PATH=${paths.BIN_DIR};%PATH%"\r\n`; + return writeFileIfChanged(initPath, script, 0o644); +} + export function createBashWrapper( paths: ShellWrapperPaths = DEFAULT_PATHS, ): void { @@ -233,10 +263,26 @@ export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' console.log(`[agent-setup] ${changed ? "Updated" : "Verified"} bash wrapper`); } +export function writeShellWrappers( + paths: ShellWrapperPaths = DEFAULT_PATHS, +): boolean { + if (process.platform === "win32") { + const ps = writePowerShellProfile(paths); + const cmd = writeCmdInit(paths); + return ps || cmd; + } + createZshWrapper(paths); + createBashWrapper(paths); + return true; +} + export function getShellEnv( shell: string, paths: ShellWrapperPaths = DEFAULT_PATHS, ): Record { + if (process.platform === "win32") { + return {}; + } const shellName = getShellName(shell); if (shellName === "zsh") { return { @@ -247,10 +293,43 @@ export function getShellEnv( return {}; } +function getWindowsShellArgs( + shell: string, + paths?: { BIN_DIR: string }, +): string[] { + const shellLower = shell.toLowerCase(); + + if (shellLower.includes("powershell") || shellLower.includes("pwsh")) { + if (paths) { + const profilePath = path.join( + path.dirname(paths.BIN_DIR), + "powershell", + "profile.ps1", + ); + if (fs.existsSync(profilePath)) { + return ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", `. '${profilePath}'`]; + } + } + return ["-NoLogo"]; + } + + // cmd.exe + if (paths) { + const initPath = path.join(path.dirname(paths.BIN_DIR), "cmd", "init.cmd"); + if (fs.existsSync(initPath)) { + return ["/k", initPath]; + } + } + return []; +} + export function getShellArgs( shell: string, paths: ShellWrapperPaths = DEFAULT_PATHS, ): string[] { + if (process.platform === "win32") { + return getWindowsShellArgs(shell, paths); + } const shellName = getShellName(shell); logModeDiagnostics(shellName); if (shellName === "bash") { @@ -287,6 +366,13 @@ export function getCommandShellArgs( command: string, paths: ShellWrapperPaths = DEFAULT_PATHS, ): string[] { + if (process.platform === "win32") { + const shellLower = shell.toLowerCase(); + if (shellLower.includes("powershell") || shellLower.includes("pwsh")) { + return ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]; + } + return ["/c", command]; + } const shellName = getShellName(shell); logModeDiagnostics(shellName); const zshRc = path.join(paths.ZSH_DIR, ".zshrc"); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 new file mode 100644 index 00000000000..be1e1f16d83 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.ps1 @@ -0,0 +1,92 @@ +# Codex exposes completion notifications via notify. +# For per-prompt Start notifications and permission requests, watch the TUI +# session log for task_started/exec_command_begin and *_approval_request events. +if ($env:SUPERSET_TAB_ID -and (Test-Path "{{NOTIFY_PATH}}")) { + $env:CODEX_TUI_RECORD_SESSION = "1" + if (-not $env:CODEX_TUI_SESSION_LOG_PATH) { + $_superset_codex_ts = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $env:CODEX_TUI_SESSION_LOG_PATH = "$env:TEMP\superset-codex-session-$$_${_superset_codex_ts}.jsonl" + } + + $_superset_log = $env:CODEX_TUI_SESSION_LOG_PATH + $_superset_notify = "{{NOTIFY_PATH}}" + + $SUPERSET_CODEX_START_WATCHER_JOB = Start-Job -ScriptBlock { + param($_log, $_notify) + + $_last_turn_id = "" + $_last_approval_id = "" + $_last_exec_call_id = "" + $_approval_fallback_seq = 0 + + function Emit-Event { + param([string]$_event) + $_payload = "{`"hook_event_name`":`"$_event`"}" + try { + & powershell.exe -NonInteractive -NoProfile -File "$_notify" "$_payload" 2>$null | Out-Null + } catch {} + } + + # Wait briefly for codex to create the session log. + $_i = 0 + while (-not (Test-Path $_log) -and $_i -lt 200) { + $_i++ + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path $_log)) { exit 0 } + + # Tail the log file and process new lines + $_reader = [System.IO.StreamReader]::new($_log, [System.Text.Encoding]::UTF8, $true, 4096) + $_reader.BaseStream.Seek(0, [System.IO.SeekOrigin]::End) | Out-Null + while ($true) { + $_line = $_reader.ReadLine() + if ($_line -eq $null) { + Start-Sleep -Milliseconds 50 + continue + } + if ($_line -match '"dir":"to_tui"' -and $_line -match '"kind":"codex_event"' -and $_line -match '"msg":\{"type":"task_started"') { + $_turn_id = "" + if ($_line -match '"turn_id":"([^"]*)"') { $_turn_id = $Matches[1] } + if (-not $_turn_id) { $_turn_id = "task_started" } + if ($_turn_id -ne $_last_turn_id) { + $_last_turn_id = $_turn_id + Emit-Event "Start" + } + } elseif ($_line -match '"dir":"to_tui"' -and $_line -match '"kind":"codex_event"' -and $_line -match '"msg":\{"type":"[^"]*_approval_request"') { + $_approval_id = "" + if ($_line -match '"id":"([^"]*)"') { $_approval_id = $Matches[1] } + if (-not $_approval_id -and $_line -match '"approval_id":"([^"]*)"') { $_approval_id = $Matches[1] } + if (-not $_approval_id -and $_line -match '"call_id":"([^"]*)"') { $_approval_id = $Matches[1] } + if (-not $_approval_id) { + $_approval_fallback_seq++ + $_approval_id = "approval_request_$_approval_fallback_seq" + } + if ($_approval_id -ne $_last_approval_id) { + $_last_approval_id = $_approval_id + Emit-Event "PermissionRequest" + } + } elseif ($_line -match '"dir":"to_tui"' -and $_line -match '"kind":"codex_event"' -and $_line -match '"msg":\{"type":"exec_command_begin"') { + $_exec_call_id = "" + if ($_line -match '"call_id":"([^"]*)"') { $_exec_call_id = $Matches[1] } + if ($_exec_call_id) { + if ($_exec_call_id -ne $_last_exec_call_id) { + $_last_exec_call_id = $_exec_call_id + Emit-Event "Start" + } + } else { + Emit-Event "Start" + } + } + } + } -ArgumentList $_superset_log, $_superset_notify +} + +& $env:REAL_BIN -c "notify=[`"powershell.exe`",`"-NonInteractive`",`"-NoProfile`",`"-File`",`"{{NOTIFY_PATH}}`"]" @args +$SUPERSET_CODEX_STATUS = $LASTEXITCODE + +if ($SUPERSET_CODEX_START_WATCHER_JOB) { + Stop-Job -Job $SUPERSET_CODEX_START_WATCHER_JOB -ErrorAction SilentlyContinue + Remove-Job -Job $SUPERSET_CODEX_START_WATCHER_JOB -Force -ErrorAction SilentlyContinue +} + +exit $SUPERSET_CODEX_STATUS diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 new file mode 100644 index 00000000000..465aefc34fb --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.ps1 @@ -0,0 +1,48 @@ +{{MARKER}} +# Called by GitHub Copilot CLI hooks to notify Superset of agent lifecycle events +# Events: sessionStart → Start, sessionEnd → Stop, userPromptSubmitted → Start, +# postToolUse → Start, preToolUse → PermissionRequest +# Copilot CLI hooks receive JSON via stdin and MUST output valid JSON to stdout + +# Drain stdin — Copilot pipes JSON context that we don't need, but we must +# consume it to prevent broken-pipe errors from blocking the agent +try { [Console]::In.ReadToEnd() | Out-Null } catch {} + +# Event name is passed as $args[0] from our hooks.json command +$EVENT_TYPE = $args[0] + +switch ($EVENT_TYPE) { + "sessionStart" { $EVENT_TYPE = "Start" } + "sessionEnd" { $EVENT_TYPE = "Stop" } + "userPromptSubmitted" { $EVENT_TYPE = "Start" } + "postToolUse" { $EVENT_TYPE = "Start" } + "preToolUse" { $EVENT_TYPE = "PermissionRequest" } + default { + Write-Output '{}' + exit 0 + } +} + +# Must output valid JSON to avoid blocking the agent +Write-Output '{}' + +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +$port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { "{{DEFAULT_PORT}}" } +$baseUrl = "http://127.0.0.1:$port/hook/complete" + +$queryParams = @( + "paneId=$([Uri]::EscapeDataString($env:SUPERSET_PANE_ID))", + "tabId=$([Uri]::EscapeDataString($env:SUPERSET_TAB_ID))", + "workspaceId=$([Uri]::EscapeDataString($env:SUPERSET_WORKSPACE_ID))", + "eventType=$([Uri]::EscapeDataString($EVENT_TYPE))", + "env=$([Uri]::EscapeDataString($env:SUPERSET_ENV))", + "version=$([Uri]::EscapeDataString($env:SUPERSET_HOOK_VERSION))" +) +$url = "$baseUrl`?" + ($queryParams -join "&") + +try { + Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue | Out-Null +} catch {} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 new file mode 100644 index 00000000000..f06203a62e6 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.ps1 @@ -0,0 +1,47 @@ +{{MARKER}} +# Called by cursor-agent hooks to notify Superset of agent lifecycle events +# Events: Start (beforeSubmitPrompt), Stop (stop), +# PermissionRequest (beforeShellExecution, beforeMCPExecution) + +# Drain stdin — Cursor pipes JSON context that we don't need, but we must consume it +# to prevent broken-pipe errors from blocking the agent +try { [Console]::In.ReadToEnd() | Out-Null } catch {} + +$EVENT_TYPE = $args[0] + +# Map event type and determine if we need to respond with JSON +$NEEDS_RESPONSE = $false +switch ($EVENT_TYPE) { + "Start" {} + "Stop" {} + "PermissionRequest" { $NEEDS_RESPONSE = $true } + default { exit 0 } +} + +# For permission hooks, auto-approve by writing JSON to stdout +# This must happen before any exit to avoid blocking the agent +if ($NEEDS_RESPONSE) { + Write-Output '{"continue":true}' +} + +# cursor-agent runs inside a Superset terminal, so env vars are inherited directly +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +$port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { "{{DEFAULT_PORT}}" } +$baseUrl = "http://127.0.0.1:$port/hook/complete" + +$queryParams = @( + "paneId=$([Uri]::EscapeDataString($env:SUPERSET_PANE_ID))", + "tabId=$([Uri]::EscapeDataString($env:SUPERSET_TAB_ID))", + "workspaceId=$([Uri]::EscapeDataString($env:SUPERSET_WORKSPACE_ID))", + "eventType=$([Uri]::EscapeDataString($EVENT_TYPE))", + "env=$([Uri]::EscapeDataString($env:SUPERSET_ENV))", + "version=$([Uri]::EscapeDataString($env:SUPERSET_HOOK_VERSION))" +) +$url = "$baseUrl`?" + ($queryParams -join "&") + +try { + Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue | Out-Null +} catch {} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 new file mode 100644 index 00000000000..9d38292e717 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.ps1 @@ -0,0 +1,48 @@ +{{MARKER}} +# Called by Gemini CLI hooks to notify Superset of agent lifecycle events +# Events: BeforeAgent → Start, AfterAgent → Stop, AfterTool → Start +# Gemini hooks receive JSON via stdin and MUST output valid JSON to stdout + +# Read JSON from stdin +$INPUT = [Console]::In.ReadToEnd() + +# Extract hook_event_name from Gemini's JSON payload +$EVENT_TYPE = "" +if ($INPUT -match '"hook_event_name"\s*:\s*"([^"]*)"') { $EVENT_TYPE = $Matches[1] } + +# Map Gemini event names to Superset event types +switch ($EVENT_TYPE) { + "BeforeAgent" { $EVENT_TYPE = "Start" } + "AfterAgent" { $EVENT_TYPE = "Stop" } + "AfterTool" { $EVENT_TYPE = "Start" } + default { + # Unknown event — output required JSON and exit + Write-Output '{}' + exit 0 + } +} + +# Output required JSON response immediately to avoid blocking the agent +Write-Output '{}' + +# Skip notification if not inside a Superset terminal +if (-not $env:SUPERSET_TAB_ID) { exit 0 } + +$port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { "{{DEFAULT_PORT}}" } +$baseUrl = "http://127.0.0.1:$port/hook/complete" + +$queryParams = @( + "paneId=$([Uri]::EscapeDataString($env:SUPERSET_PANE_ID))", + "tabId=$([Uri]::EscapeDataString($env:SUPERSET_TAB_ID))", + "workspaceId=$([Uri]::EscapeDataString($env:SUPERSET_WORKSPACE_ID))", + "eventType=$([Uri]::EscapeDataString($EVENT_TYPE))", + "env=$([Uri]::EscapeDataString($env:SUPERSET_ENV))", + "version=$([Uri]::EscapeDataString($env:SUPERSET_HOOK_VERSION))" +) +$url = "$baseUrl`?" + ($queryParams -join "&") + +try { + Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue | Out-Null +} catch {} + +exit 0 diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 new file mode 100644 index 00000000000..3f0223f8676 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.ps1 @@ -0,0 +1,96 @@ +{{MARKER}} +# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input + +# Get JSON input - Codex passes as argument, Claude pipes to stdin +if ($args.Count -gt 0) { + $INPUT = $args[0] +} else { + $INPUT = [Console]::In.ReadToEnd() +} + +# Extract Mastra identifiers when available (mastracode hooks) +# `resourceId` / `resource_id` is the Superset chat session id we assign via +# harness.setResourceId(...). `session_id` is Mastra's internal runtime id. +$HOOK_SESSION_ID = "" +if ($INPUT -match '"session_id"\s*:\s*"([^"]*)"') { $HOOK_SESSION_ID = $Matches[1] } + +$RESOURCE_ID = "" +if ($INPUT -match '"resourceId"\s*:\s*"([^"]*)"') { $RESOURCE_ID = $Matches[1] } +if (-not $RESOURCE_ID) { + if ($INPUT -match '"resource_id"\s*:\s*"([^"]*)"') { $RESOURCE_ID = $Matches[1] } +} + +$SESSION_ID = if ($RESOURCE_ID) { $RESOURCE_ID } else { $HOOK_SESSION_ID } + +# Skip if this isn't a Superset terminal hook and no Mastra session context exists +if (-not $env:SUPERSET_TAB_ID -and -not $SESSION_ID) { exit 0 } + +# Extract event type - Claude uses "hook_event_name", Codex uses "type" +$EVENT_TYPE = "" +if ($INPUT -match '"hook_event_name"\s*:\s*"([^"]*)"') { $EVENT_TYPE = $Matches[1] } +if (-not $EVENT_TYPE) { + # Check for Codex "type" field (e.g., "agent-turn-complete") + $CODEX_TYPE = "" + if ($INPUT -match '"type"\s*:\s*"([^"]*)"') { $CODEX_TYPE = $Matches[1] } + if ($CODEX_TYPE -eq "agent-turn-complete") { + $EVENT_TYPE = "Stop" + } +} + +# NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. +# Parse failures should not trigger completion notifications. +# The server will ignore requests with missing eventType (forward compatibility). + +# Only UserPromptSubmit is mapped here; other events are normalized +# server-side by mapEventType() to keep a single source of truth. +if ($EVENT_TYPE -eq "UserPromptSubmit") { $EVENT_TYPE = "Start" } + +# If no event type was found, skip the notification +# This prevents parse failures from causing false completion notifications +if (-not $EVENT_TYPE) { exit 0 } + +$DEBUG_HOOKS_ENABLED = "0" +if ($env:SUPERSET_DEBUG_HOOKS) { + switch ($env:SUPERSET_DEBUG_HOOKS) { + { $_ -in @("1", "true", "TRUE", "True", "yes", "YES", "on", "ON") } { $DEBUG_HOOKS_ENABLED = "1" } + default { $DEBUG_HOOKS_ENABLED = "0" } + } +} elseif ($env:SUPERSET_ENV -eq "development" -or $env:NODE_ENV -eq "development") { + $DEBUG_HOOKS_ENABLED = "1" +} + +if ($DEBUG_HOOKS_ENABLED -eq "1") { + Write-Error "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID" +} + +$port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { "{{DEFAULT_PORT}}" } +$baseUrl = "http://127.0.0.1:$port/hook/complete" + +$queryParams = @( + "paneId=$([Uri]::EscapeDataString($env:SUPERSET_PANE_ID))", + "tabId=$([Uri]::EscapeDataString($env:SUPERSET_TAB_ID))", + "workspaceId=$([Uri]::EscapeDataString($env:SUPERSET_WORKSPACE_ID))", + "sessionId=$([Uri]::EscapeDataString($SESSION_ID))", + "hookSessionId=$([Uri]::EscapeDataString($HOOK_SESSION_ID))", + "resourceId=$([Uri]::EscapeDataString($RESOURCE_ID))", + "eventType=$([Uri]::EscapeDataString($EVENT_TYPE))", + "env=$([Uri]::EscapeDataString($env:SUPERSET_ENV))", + "version=$([Uri]::EscapeDataString($env:SUPERSET_HOOK_VERSION))" +) +$url = "$baseUrl`?" + ($queryParams -join "&") + +# Timeouts prevent blocking agent completion if notification server is unresponsive +try { + if ($DEBUG_HOOKS_ENABLED -eq "1") { + $response = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue + Write-Error "[notify-hook] dispatched status=200" + } else { + Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 2 -ErrorAction SilentlyContinue | Out-Null + } +} catch { + if ($DEBUG_HOOKS_ENABLED -eq "1") { + Write-Error "[notify-hook] dispatched status=error" + } +} + +exit 0 diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index d69e946f9f4..379371da25f 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -1104,13 +1104,14 @@ export class TerminalHostClient extends EventEmitter { let child: ReturnType | null = null; try { child = spawn(process.execPath, [daemonScript], { - detached: true, + detached: process.platform !== "win32", stdio: logFd >= 0 ? ["ignore", logFd, logFd] : "ignore", env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_ENV: process.env.NODE_ENV, }, + ...(process.platform === "win32" ? { windowsHide: true } : {}), }); } finally { if (logFd >= 0) { diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index 23208d13086..84304156023 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -11,9 +11,10 @@ import { describe("env", () => { describe("constants", () => { - it("should have FALLBACK_SHELL set to /bin/sh on non-Windows", () => { - // On macOS/Linux, fallback should be /bin/sh - if (process.platform !== "win32") { + it("should have correct FALLBACK_SHELL for the platform", () => { + if (process.platform === "win32") { + expect(FALLBACK_SHELL).toBe("cmd.exe"); + } else { expect(FALLBACK_SHELL).toBe("/bin/sh"); } }); @@ -532,6 +533,32 @@ describe("env", () => { }); }); + describe("Windows environment handling", () => { + it("buildSafeEnv handles case-insensitive Windows env vars", () => { + const env = { + Path: "C:\\Windows\\System32", + USERPROFILE: "C:\\Users\\test", + ComSpec: "C:\\Windows\\System32\\cmd.exe", + }; + const safe = buildSafeEnv(env, { platform: "win32" }); + expect(safe.Path).toBe("C:\\Windows\\System32"); + expect(safe.USERPROFILE).toBe("C:\\Users\\test"); + expect(safe.ComSpec).toBe("C:\\Windows\\System32\\cmd.exe"); + }); + + it("buildSafeEnv excludes secrets on Windows", () => { + const env = { + Path: "C:\\Windows", + DATABASE_URL: "postgres://secret", + NEON_API_KEY: "secret-key", + }; + const safe = buildSafeEnv(env, { platform: "win32" }); + expect(safe.Path).toBe("C:\\Windows"); + expect(safe.DATABASE_URL).toBeUndefined(); + expect(safe.NEON_API_KEY).toBeUndefined(); + }); + }); + describe("removeAppEnvVars (deprecated wrapper)", () => { it("should delegate to buildSafeEnv", () => { const env = { NODE_ENV: "production", PATH: "/usr/bin" }; diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 8c3c2e5087e..0ebd8bd4012 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -1,4 +1,4 @@ -import { exec } from "node:child_process"; +import { exec, execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import defaultShell from "default-shell"; @@ -11,6 +11,14 @@ let localeProbeInFlight = false; function startLocaleProbe(): void { if (cachedUtf8Locale || localeProbeInFlight) return; + + // Windows doesn't have the `locale` command. + // Windows 10 1903+ supports UTF-8 as system locale. + if (os.platform() === "win32") { + cachedUtf8Locale = "en_US.UTF-8"; + return; + } + localeProbeInFlight = true; exec( @@ -37,6 +45,15 @@ export const HOOK_PROTOCOL_VERSION = "2"; export const FALLBACK_SHELL = os.platform() === "win32" ? "cmd.exe" : "/bin/sh"; export const SHELL_CRASH_THRESHOLD_MS = 1000; +function commandExistsOnWindows(cmd: string): boolean { + try { + execFileSync("where", [cmd], { stdio: "ignore", timeout: 2000 }); + return true; + } catch { + return false; + } +} + export function getDefaultShell(): string { if (defaultShell) { return defaultShell; @@ -45,6 +62,9 @@ export function getDefaultShell(): string { const platform = os.platform(); if (platform === "win32") { + if (commandExistsOnWindows("pwsh.exe")) { + return "pwsh.exe"; + } return process.env.COMSPEC || "powershell.exe"; } diff --git a/apps/desktop/src/main/terminal-host/session.test.ts b/apps/desktop/src/main/terminal-host/session.test.ts index 0b6d65d138f..5b639d0b55b 100644 --- a/apps/desktop/src/main/terminal-host/session.test.ts +++ b/apps/desktop/src/main/terminal-host/session.test.ts @@ -42,7 +42,7 @@ describe("Terminal Host Session shell args", () => { spawnCalls = []; }); - it("sends bash --rcfile args in spawn payload", () => { + it.skipIf(process.platform === "win32")("sends bash --rcfile args in spawn payload", () => { const session = new Session({ sessionId: "session-bash-args", workspaceId: "workspace-1", diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index d8a277abcf4..283a01d5104 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -10,6 +10,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { Socket } from "node:net"; +import { homedir } from "node:os"; import * as path from "node:path"; import { getShellArgs } from "../lib/agent-setup/shell-wrappers"; import { buildSafeEnv } from "../lib/terminal/env"; @@ -954,7 +955,7 @@ export function createSession(request: CreateOrAttachRequest): Session { tabId: request.tabId, cols: request.cols, rows: request.rows, - cwd: request.cwd || process.env.HOME || "/", + cwd: request.cwd || homedir() || process.cwd(), env: request.env, shell: request.shell, workspaceName: request.workspaceName, diff --git a/apps/desktop/src/main/terminal-host/signal-handlers.ts b/apps/desktop/src/main/terminal-host/signal-handlers.ts index 1bb63f84db9..b9160514d45 100644 --- a/apps/desktop/src/main/terminal-host/signal-handlers.ts +++ b/apps/desktop/src/main/terminal-host/signal-handlers.ts @@ -127,14 +127,17 @@ export function setupTerminalHostSignalHandlers({ timeoutMessage: "Forced exit after SIGTERM shutdown timeout", }); }); - process.on("SIGHUP", () => { - shutdownOnce({ - exitCode: 0, - message: "Received SIGHUP, shutting down...", - stopServerErrorMessage: "Error during stopServer in SIGHUP shutdown", - timeoutMessage: "Forced exit after SIGHUP shutdown timeout", + // SIGHUP is not emitted on Windows + if (process.platform !== "win32") { + process.on("SIGHUP", () => { + shutdownOnce({ + exitCode: 0, + message: "Received SIGHUP, shutting down...", + stopServerErrorMessage: "Error during stopServer in SIGHUP shutdown", + timeoutMessage: "Forced exit after SIGHUP shutdown timeout", + }); }); - }); + } process.on("uncaughtException", (error) => { if (isShuttingDown) return; diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index 7ce77d22711..cb8a127a7bc 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -9,6 +9,7 @@ */ import type { Socket } from "node:net"; +import { homedir } from "node:os"; import type { ClearScrollbackRequest, CreateOrAttachRequest, @@ -113,7 +114,7 @@ export class TerminalHost { }); session.spawn({ - cwd: request.cwd || process.env.HOME || "/", + cwd: request.cwd || homedir() || process.cwd(), cols: request.cols, rows: request.rows, env: request.env, diff --git a/package.json b/package.json index 2d37dd6c9e1..06eedf70cc7 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,13 @@ "dev:marketing": "turbo dev --filter=@superset/marketing --filter=@superset/docs", "build": "turbo build --filter=@superset/desktop", "test": "turbo test", - "lint": "./scripts/lint.sh", + "lint": "bun run scripts/lint.ts", "lint:fix": "biome migrate --write && biome check --write --unsafe .", "format": "biome format --write .", "format:check": "biome format .", "typecheck": "turbo typecheck", "ui-add": "turbo run ui-add", - "postinstall": "./scripts/postinstall.sh", + "postinstall": "bun run scripts/postinstall.ts", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", "release:desktop": "./apps/desktop/create-release.sh", diff --git a/scripts/lint.ts b/scripts/lint.ts new file mode 100644 index 00000000000..6f4214860c9 --- /dev/null +++ b/scripts/lint.ts @@ -0,0 +1,19 @@ +// scripts/lint.ts +// Cross-platform replacement for lint.sh +// Wrapper for biome check that fails on ANY diagnostic (info, warn, or error) + +import { $ } from "bun"; + +const args = process.argv.slice(2); + +const result = await $`bunx biome check ${args}`.nothrow().quiet(); +const output = result.stdout.toString() + result.stderr.toString(); + +console.log(output); + +// Check if there are any diagnostics (errors, warnings, or infos) +if (/Found \d+ (error|info|warning)/.test(output)) { + process.exit(1); +} + +process.exit(result.exitCode); diff --git a/scripts/patch-node-pty-win.ts b/scripts/patch-node-pty-win.ts new file mode 100644 index 00000000000..1dd752d8ed4 --- /dev/null +++ b/scripts/patch-node-pty-win.ts @@ -0,0 +1,73 @@ +// scripts/patch-node-pty-win.ts +// Patches node-pty's gyp files for Windows builds: +// 1. Removes Spectre mitigation requirement (not installed by default) +// 2. Hardcodes winpty commit hash (bat file execution fails in gyp context) +// 3. Pre-creates GenVersion.h header + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +if (process.platform !== "win32") { + process.exit(0); +} + +const nodePtyBase = join( + import.meta.dir, + "..", + "node_modules", + ".bun", + "node-pty@1.1.0", + "node_modules", + "node-pty", +); + +if (!existsSync(nodePtyBase)) { + console.log("node-pty not found, skipping patch"); + process.exit(0); +} + +// Patch binding.gyp - remove Spectre mitigation +const bindingGyp = join(nodePtyBase, "binding.gyp"); +if (existsSync(bindingGyp)) { + let content = readFileSync(bindingGyp, "utf-8"); + content = content.replace( + "'SpectreMitigation': 'Spectre'", + "", + ); + writeFileSync(bindingGyp, content); + console.log("Patched binding.gyp: removed Spectre mitigation"); +} + +// Patch winpty.gyp - remove Spectre + fix bat file execution +const winptyGyp = join(nodePtyBase, "deps", "winpty", "src", "winpty.gyp"); +if (existsSync(winptyGyp)) { + let content = readFileSync(winptyGyp, "utf-8"); + // Remove Spectre mitigation + content = content.replaceAll("'SpectreMitigation': 'Spectre'", ""); + // Replace GetCommitHash.bat call with hardcoded value + content = content.replace( + /'\