Skip to content

fix(core): resolve spawn completion on exit, not only close (Windows detached-child hang) - #29831

Open
Hona wants to merge 13 commits into
anomalyco:devfrom
Hona:fix/shell-detached-child-hang
Open

fix(core): resolve spawn completion on exit, not only close (Windows detached-child hang)#29831
Hona wants to merge 13 commits into
anomalyco:devfrom
Hona:fix/shell-detached-child-hang

Conversation

@Hona

@Hona Hona commented May 29, 2026

Copy link
Copy Markdown
Member

What

Fix shell commands that hang after starting a background process.

Why

The command can finish while its child still keeps output open, leaving the agent waiting forever.

How

Return when the command exits, keep reading final output until it stays quiet across a 500ms check, and preserve cleanup for stuck children.

Tests cover detached children and delayed output.

Closes #24731
Closes #24784
Closes #20902
Closes #29822
Closes #25038
Closes #28697
Closes #25938

The shell tool hangs on Windows when a command spawns a detached child that
inherits stdio (e.g. a backgrounded web server or playwright-cli). The
cross-spawn spawner resolved process completion only on the child's `close`
event, which fires after *all* stdio streams close. A detached child keeps the
inherited pipe's write end open, so `close` is delayed indefinitely even
though the main process already exited -- and exitCode (and thus the shell
tool) never resolves.

Resolve on whichever of `exit`/`close` fires first. `exit` returns
promptly once the main process is gone (fixing the hang); `close` is kept as
the required backstop for failed spawns, where cross-spawn emits
spawn->error->close but never exit, so exit-only would hang that path forever.

Closes anomalyco#24731
Copilot AI review requested due to automatic review settings May 29, 2026 04:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a Windows hang in the shell tool when a command spawns a detached child that inherits stdio (e.g. playwright-cli). The CrossSpawnSpawner previously completed only on the child's "close" event, which can be delayed indefinitely if a detached child keeps the parent's stdio pipes open. The fix completes the exit Deferred on whichever of "exit" or "close" fires first, while preserving the "close" listener as a backstop for the ENOENT path where cross-spawn never emits "exit".

Changes:

  • Resolve the spawn completion Deferred in the "exit" handler in addition to the existing "close" handler, with end/exit guards so the value is consistent regardless of order.
  • Added an explanatory comment documenting why both events are needed.
  • Added a regression test (using fx.live) that spawns a detached daemon inheriting stdio and asserts exitCode resolves within 5s, reaping the daemon afterward.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/core/src/cross-spawn-spawner.ts Complete on first of exit/close; keep close as ENOENT backstop.
packages/core/test/effect/cross-spawn-spawner.test.ts New regression test for detached child stdio inheritance (#24731); trailing blank lines added.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/test/effect/cross-spawn-spawner.test.ts Outdated
Hona added 6 commits May 29, 2026 14:58
…ed pipes

Replace the fixed 100ms post-exit drain (which truncated output when a slow
per-chunk consumer was still working through a backlog) with joining the output
reader fiber. For a normal command this returns the instant the stdio streams
reach EOF -- full output, no fixed delay, matching upstream's backpressure-synced
behavior. A detached child can hold the pipe open forever with no further output,
so an idle watcher that resets on every chunk bounds only that case; a command
still producing output is never cut off.
@jliu666

jliu666 commented May 30, 2026

Copy link
Copy Markdown

Is it ready to merge? Will it be included in the next release?

@gnahz77

gnahz77 commented Jun 1, 2026

Copy link
Copy Markdown

I tested this PR on Windows with PowerShell and confirmed that the issue has been fixed. Thank you for submitting this PR, and I hope it can be merged soon.

@NuDheeraj

NuDheeraj commented Jun 4, 2026

Copy link
Copy Markdown

hi hit the same issue shell is hanging forever when a command spawns a background process, can this be merged?

dannyisbad added a commit to dannyisbad/opencode that referenced this pull request Jun 12, 2026
@JustSidus

Copy link
Copy Markdown

This fix addresses a critical issue that affects agent-browser and any tool that spawns detached processes on Windows. Multiple users have confirmed it works. The approach of completing on exit instead of close is the right solution. Ready to merge?

@asdasd070511

Copy link
Copy Markdown

same lol this is annoying shit

@asdasd070511

Copy link
Copy Markdown

Is this ready to be merged? Really looking forward to this fix.

@myavkat

myavkat commented Jun 21, 2026

Copy link
Copy Markdown

gradlew processes also hangs frequently on windows with powershell, looking forward to the merge!

@frodrd00

frodrd00 commented Jul 1, 2026

Copy link
Copy Markdown

?

@frasdav

frasdav commented Jul 6, 2026

Copy link
Copy Markdown

Would also love to see this merged ASAP!

@will-santiago

Copy link
Copy Markdown

Looking forward to this fix ASAP please !

@asdasd070511

Copy link
Copy Markdown
images (5) When merge?

@mcxinyu

mcxinyu commented Jul 27, 2026

Copy link
Copy Markdown

Hi, @Hona some merge checked fail, Could you please take a look?

@asdasd070511

Copy link
Copy Markdown

For anyone on Windows who is tired of waiting for this to land: here is a PowerShell script that builds opencode locally with this PR applied on top of latest dev.

Why not just build the PR branch directly? Its head is based on an older dev, so building it as-is downgrades you and drops everything merged since. The script instead checks out latest origin/dev and 3-way merges only the files this PR touches onto it, then builds and installs the CLI and/or the Desktop app locally.

Usage

git clone https://github.com/anomalyco/opencode.git
pwsh -File .\update-opencode.ps1                 # CLI + Desktop
pwsh -File .\update-opencode.ps1 -Target cli     # CLI only
pwsh -File .\update-opencode.ps1 -DryRun         # port the PR, stop before building

Requirements: PowerShell 7, git, bun. The Desktop target additionally needs Node (npx) and ripgrep (rg). Because the script overwrites the binary behind the global npm shim, run npm i -g opencode-ai once first; for the Desktop target, install the official Desktop app once.

Notes

  • Paths auto-resolve (repo next to the script or under ~, %APPDATA%\npm\node_modules\opencode-ai or npm root -g, %LOCALAPPDATA%\Programs\...). Override with -RepoPath / -CliNpmDir / -DesktopInstall, or set $env:OPENCODE_SOURCE.
  • Reversible: the previous CLI exe is kept next to the new one as opencode.exe.old-running-<stamp>, and the previous app.asar is backed up under %TEMP%\opencode.
  • Build is stamped <dev version>-pr29831 on channel prod, so CLI and Desktop keep sharing ~/.local/share/opencode/opencode.db.
  • Install renames the running exe instead of killing it, so the session you launch it from survives; open a new terminal to pick up the new binary. Use -ForceKillCli if you would rather it kill the running CLI.
  • -SkipPr builds plain latest dev if you just want a local build without the patch.

Tested on Windows 11 / PowerShell 7 against dev 1.18.7 (CLI and Desktop). I am not the PR author, just sharing what I have been using until this gets merged.

update-opencode.ps1
#Requires -Version 7
<#
.SYNOPSIS
  Windows: rebuild + install OpenCode CLI and/or Desktop from latest origin/dev with PR #29831
  (detached-child shell hang fix) ported on top. channel=prod by default.

.DESCRIPTION
  Why this exists: PR #29831 fixes the Windows hang where a shell tool call never returns
  because a detached child keeps the pipe open. The PR branch is based on an OLD dev, so
  building the PR head directly downgrades you. This script instead checks out latest
  origin/dev and 3-way merges only the PR's files onto it, then builds and installs locally.

  Pipeline (same source / channel / PR for both targets):
    1) fetch origin/dev (+ the PR ref)
    2) port the PR onto dev via per-file 3-way merge (git merge-file) and commit
    3) bun install
    4) build + install the selected targets

  Channel "prod" makes both targets use ~/.local/share/opencode/opencode.db (shared sessions).
  Non-prod channels get opencode-<channel>.db and Desktop userData ai.opencode.desktop.<channel>.

  CLI install uses Windows rename-while-running so the current session can stay up;
  new terminals pick up the new binary. Desktop is stopped briefly to replace app.asar.

  REQUIREMENTS
    - PowerShell 7 (pwsh), git, bun
    - Desktop target only: npx (Node) and rg (ripgrep, for the build-marker check)
    - A clone of the repo:   git clone https://github.com/anomalyco/opencode.git
    - CLI target only: opencode installed once via npm, so the launcher shim exists:
        npm i -g opencode-ai
    - Desktop target only: the official OpenCode Desktop installed once.

  PATHS
    All paths auto-resolve for the current user; nothing is hardcoded to one machine.
      repo    : $env:OPENCODE_SOURCE, else <script dir>\opencode[-source], else ~\opencode[-source]
      cli     : %APPDATA%\npm\node_modules\opencode-ai, else `npm root -g`\opencode-ai
      desktop : %LOCALAPPDATA%\Programs\@opencode-aidesktop (else any *opencode* app dir there)
      work    : %TEMP%\opencode
    Override any of them with -RepoPath / -CliNpmDir / -DesktopInstall / -WorkRoot.

  Everything it touches is reversible: the previous CLI exe is kept next to the new one as
  opencode.exe.old-running-<stamp>, and the previous app.asar is backed up under %TEMP%\opencode.

.EXAMPLE
  # Both CLI + Desktop (default)
  pwsh -File .\update-opencode.ps1

.EXAMPLE
  # CLI only
  pwsh -File .\update-opencode.ps1 -Target cli

.EXAMPLE
  # See what it would do (resolves paths, ports the PR, stops before build/install)
  pwsh -File .\update-opencode.ps1 -DryRun

.EXAMPLE
  # Repo lives somewhere else
  pwsh -File .\update-opencode.ps1 -RepoPath D:\src\opencode

.EXAMPLE
  # Plain latest dev, no PR port
  pwsh -File .\update-opencode.ps1 -SkipPr

.EXAMPLE
  # The current CLI process holds a lock on the exe; kill it and install anyway
  pwsh -File .\update-opencode.ps1 -Target cli -ForceKillCli
#>


[CmdletBinding()]
param(
  [ValidateSet("cli", "desktop", "both")]
  [string]$Target = "both",

  # Empty = auto-resolve for the current user (see Resolve-* below). Pass a path to override.
  [string]$RepoPath = "",
  [string]$DesktopInstall = "",
  [string]$CliNpmDir = "",
  [string]$WorkRoot = "",

  [ValidateSet("prod", "beta", "dev")]
  [string]$Channel = "prod",

  # Empty = auto from packages/opencode/package.json on the branch (+ -prN if porting PR)
  [string]$Version = "",
  [int]$PrNumber = 29831,
  [string]$BranchName = "",

  [switch]$SkipPr,
  [switch]$SkipFetch,
  [switch]$SkipInstall,
  [switch]$DryRun,
  [switch]$KeepBranch,
  [switch]$ForceKillCli
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$DoCli = $Target -in @("cli", "both")
$DoDesktop = $Target -in @("desktop", "both")

function Write-Step([string]$Message) {
  Write-Host ""
  Write-Host "==> $Message" -ForegroundColor Cyan
}

function Fail([string]$Message) {
  # throw would collapse a multi-line message into one wrapped line; keep setup hints readable.
  Write-Host ""
  Write-Host $Message -ForegroundColor Red
  Write-Host ""
  exit 1
}

function Assert-Command {
  param([string]$Name, [string]$Hint = "")
  if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
    $msg = "Required command not found: $Name"
    if ($Hint) { $msg += "`n  $Hint" }
    Fail $msg
  }
}

# ---- path resolution (no machine-specific paths baked into this script) ----

function Resolve-RepoPath {
  if ($env:OPENCODE_SOURCE) { return $env:OPENCODE_SOURCE }
  $marker = "packages\opencode\package.json"
  $candidates = @()
  if ($PSScriptRoot) {
    $candidates += (Join-Path $PSScriptRoot "opencode")
    $candidates += (Join-Path $PSScriptRoot "opencode-source")
  }
  $candidates += (Join-Path $env:USERPROFILE "opencode")
  $candidates += (Join-Path $env:USERPROFILE "opencode-source")
  foreach ($c in $candidates) {
    if (Test-Path -LiteralPath (Join-Path $c $marker)) { return $c }
  }
  # Not found: return the conventional location so Preflight can print a useful error.
  return (Join-Path $env:USERPROFILE "opencode-source")
}

function Resolve-CliNpmDir {
  $direct = Join-Path $env:APPDATA "npm\node_modules\opencode-ai"
  if (Test-Path -LiteralPath $direct) { return $direct }
  # nvm / fnm / volta put the global root elsewhere; ask npm.
  $root = $null
  try { $root = (& npm root -g 2>$null | Select-Object -First 1) } catch { $root = $null }
  if ($root) {
    $viaNpm = Join-Path $root.Trim() "opencode-ai"
    if (Test-Path -LiteralPath $viaNpm) { return $viaNpm }
  }
  return $direct
}

function Resolve-DesktopInstall {
  $direct = Join-Path $env:LOCALAPPDATA "Programs\@opencode-aidesktop"
  if (Test-Path -LiteralPath (Join-Path $direct "resources\app.asar")) { return $direct }
  $programs = Join-Path $env:LOCALAPPDATA "Programs"
  if (Test-Path -LiteralPath $programs) {
    $hit = Get-ChildItem -LiteralPath $programs -Directory -ErrorAction SilentlyContinue |
      Where-Object {
        $_.Name -match 'opencode' -and
        (Test-Path -LiteralPath (Join-Path $_.FullName "resources\app.asar"))
      } |
      Select-Object -First 1
    if ($hit) { return $hit.FullName }
  }
  return $direct
}

function Invoke-Bun {
  param(
    [string[]]$BunArgs,
    [string]$Cwd,
    [hashtable]$EnvExtra = @{}
  )
  Push-Location -LiteralPath $Cwd
  $old = @{}
  try {
    foreach ($k in $EnvExtra.Keys) {
      $old[$k] = [Environment]::GetEnvironmentVariable($k, "Process")
      Set-Item -Path "Env:$k" -Value $EnvExtra[$k]
    }
    & bun @BunArgs
    if ($LASTEXITCODE -ne 0) { throw "bun $($BunArgs -join ' ') failed (exit $LASTEXITCODE)" }
  } finally {
    foreach ($k in $old.Keys) {
      if ($null -eq $old[$k]) { Remove-Item -Path "Env:$k" -ErrorAction SilentlyContinue }
      else { Set-Item -Path "Env:$k" -Value $old[$k] }
    }
    Pop-Location
  }
}

function Stop-DesktopOnly {
  $killed = @()
  Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
    Where-Object {
      $_.ExecutablePath -and (
        $_.ExecutablePath -like "*\@opencode-aidesktop\*" -or
        $_.ExecutablePath -like "*\Programs\@opencode-aidesktop\*"
      )
    } |
    ForEach-Object {
      Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
      $killed += $_.ProcessId
    }
  if ($killed.Count) {
    Write-Host "Stopped desktop PIDs: $($killed -join ', ')"
    Start-Sleep -Seconds 1
  } else {
    Write-Host "Desktop not running."
  }
}

function Stop-NpmCliOnly {
  $killed = @()
  Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
    Where-Object {
      $_.ExecutablePath -and $_.ExecutablePath -like "*\npm\node_modules\opencode-ai\bin\opencode.exe"
    } |
    ForEach-Object {
      Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
      $killed += $_.ProcessId
    }
  if ($killed.Count) {
    Write-Host "Stopped npm CLI PIDs: $($killed -join ', ')"
    Start-Sleep -Seconds 1
  }
}

function Resolve-ConflictsPreferOursDescription {
  param([string]$Path)
  if (-not (Test-Path -LiteralPath $Path)) { return $false }
  $text = Get-Content -LiteralPath $Path -Raw -Encoding utf8
  if ($text -notmatch '<<<<<<<') { return $false }

  $pattern = '(?s)<<<<<<<[^\n]*\r?\n(.*?)=======\r?\n(.*?)>>>>>>>[^\n]*\r?\n'
  $resolved = [regex]::Replace($text, $pattern, {
    param($m)
    $ours = $m.Groups[1].Value
    $theirs = $m.Groups[2].Value
    if ($theirs -match 'Effect\.ensuring' -and $ours -notmatch 'Effect\.ensuring') {
      return ($theirs -replace "(?m)^\s*description:\s*input\.description,?\r?\n", "")
    }
    return $ours
  })

  if ($resolved -match '<<<<<<<') {
    throw "Unresolved conflict markers remain in $Path"
  }
  Set-Content -LiteralPath $Path -Value $resolved -Encoding utf8NoBOM -NoNewline
  return $true
}

function Fix-ShellTestForLatest {
  param([string]$TestPath)
  if (-not (Test-Path -LiteralPath $TestPath)) { return }

  $c = Get-Content -LiteralPath $TestPath -Raw -Encoding utf8

  if ($c -notmatch 'from "effect/unstable/process"') {
    if ($c -match 'import \{ FSUtil \} from "@opencode-ai/core/fs-util"') {
      $c = $c -replace '(import \{ FSUtil \} from "@opencode-ai/core/fs-util"\r?\n)',
        "`$1import { ChildProcessSpawner } from `"effect/unstable/process`"`r`n"
    } else {
      $c = $c -replace '(import \{ CrossSpawnSpawner \} from "@opencode-ai/core/cross-spawn-spawner"\r?\n)',
        "`$1import { FSUtil } from `"@opencode-ai/core/fs-util`"`r`nimport { ChildProcessSpawner } from `"effect/unstable/process`"`r`n"
    }
  }
  $c = $c -replace 'import \{ Cause, Effect, Exit, Layer \} from "effect"',
    'import { Cause, Effect, Exit, Layer, Stream } from "effect"'
  if ($c -notmatch 'import \{ LayerNode \}') {
    $c = $c -replace '(import \{ describe, expect \} from "bun:test"\r?\n)',
      "`$1import { LayerNode } from `"@opencode-ai/core/effect/layer-node`"`r`n"
  }
  $c = $c -replace 'import \{ AppFileSystem \} from "@opencode-ai/core/filesystem"\r?\n', ""

  $c = $c -replace '(?s)const fsvc = yield\* AppFileSystem\.Service\s*const pid = Number\(yield\* fsvc\.readFileString\(',
    'const pid = Number(yield* FSUtil.use.readFileString('
  $c = $c -replace 'yield\* \(yield\* AppFileSystem\.Service\)\.readFileString',
    'yield* FSUtil.use.readFileString'
  $c = $c -replace 'AppFileSystem\.Service', 'FSUtil.Service'

  $layerNodeStack = @(
    'LayerNode.compile(',
    '    LayerNode.group([FSUtil.node, Plugin.node, Truncate.node, Config.node, Agent.node, RuntimeFlags.node]),',
    '  ),',
    '  testInstanceStoreLayer'
  ) -join "`n  "
  $c = $c -replace 'AppFileSystem\.defaultLayer,\r?\n\s*Plugin\.defaultLayer,\r?\n\s*Truncate\.defaultLayer,\r?\n\s*Config\.defaultLayer,\r?\n\s*Agent\.defaultLayer,\r?\n\s*RuntimeFlags\.defaultLayer',
    $layerNodeStack
  $c = $c -replace 'AppFileSystem\.defaultLayer', $layerNodeStack

  if ($c -match '<<<<<<<|AppFileSystem') {
    throw "shell.test.ts still has conflict markers or AppFileSystem after fixup: $TestPath"
  }
  Set-Content -LiteralPath $TestPath -Value $c -Encoding utf8NoBOM -NoNewline
}

function Sync-RepoAndPortPr {
  Write-Step "Sync repo"
  Push-Location -LiteralPath $RepoPath
  try {
    $dirty = git status --porcelain
    if ($dirty) {
      Write-Host "Working tree dirty; stashing..."
      git stash push -u -m "update-opencode $script:Stamp"
      if ($LASTEXITCODE -ne 0) { throw "git stash failed" }
      Write-Host "  your local changes are safe in the stash: git stash list / git stash pop" -ForegroundColor Yellow
    }

    if (-not $SkipFetch) {
      git fetch origin dev
      if ($LASTEXITCODE -ne 0) { throw "git fetch origin dev failed" }
      if (-not $SkipPr) {
        git fetch origin "pull/$PrNumber/head:refs/pr/$PrNumber"
        if ($LASTEXITCODE -ne 0) { throw "git fetch PR #$PrNumber failed" }
      }
    }

    git checkout -B $BranchName origin/dev
    if ($LASTEXITCODE -ne 0) { throw "git checkout $BranchName failed" }
    $head = (git rev-parse --short HEAD).Trim()
    Write-Host "HEAD: $head (origin/dev)"
  } finally {
    Pop-Location
  }

  $prFiles = @(
    "packages/core/src/cross-spawn-spawner.ts",
    "packages/core/test/effect/cross-spawn-spawner.test.ts",
    "packages/opencode/src/tool/shell.ts",
    "packages/opencode/test/tool/shell.test.ts"
  )

  if ($SkipPr) {
    Write-Step "Skip PR port (-SkipPr)"
    return
  }

  Write-Step "Port PR #$PrNumber onto latest dev"
  $shellPath = Join-Path $RepoPath "packages/opencode/src/tool/shell.ts"
  $spawnPath = Join-Path $RepoPath "packages/core/src/cross-spawn-spawner.ts"
  $already = (Select-String -LiteralPath $shellPath -Pattern "POST_EXIT_OUTPUT_IDLE_TIMEOUT" -Quiet) -and
    (Select-String -LiteralPath $spawnPath -Pattern "const exited = Deferred" -Quiet)

  if ($already) {
    Write-Host "origin/dev already contains PR markers; skip port."
    return
  }

  $mergeDir = Join-Path $WorkRoot "merge3-$script:Stamp"
  New-Item -ItemType Directory -Force -Path $mergeDir | Out-Null

  Push-Location -LiteralPath $RepoPath
  try {
    $prRef = "refs/pr/$PrNumber"
    $prTip = (git rev-parse $prRef).Trim()
    $ancestor = (git merge-base origin/dev $prTip).Trim()
    Write-Host "PR tip:    $prTip"
    Write-Host "Ancestor:  $ancestor"

    foreach ($rel in $prFiles) {
      $name = Split-Path $rel -Leaf
      $anc = Join-Path $mergeDir "anc-$name"
      $ours = Join-Path $mergeDir "ours-$name"
      $theirs = Join-Path $mergeDir "theirs-$name"
      $out = Join-Path $RepoPath $rel

      git show "${ancestor}:$rel" | Set-Content -LiteralPath $anc -Encoding utf8
      git show "HEAD:$rel" | Set-Content -LiteralPath $ours -Encoding utf8
      git show "${prTip}:$rel" | Set-Content -LiteralPath $theirs -Encoding utf8

      & git merge-file $ours $anc $theirs
      $code = $LASTEXITCODE
      if ($code -lt 0) { throw "git merge-file hard-failed on $rel (exit $code)" }
      Copy-Item -LiteralPath $ours -Destination $out -Force
      Write-Host ("  {0}: merge-file exit={1}" -f $rel, $code)

      if ($code -gt 0) {
        $null = Resolve-ConflictsPreferOursDescription -Path $out
        Write-Host "  resolved conflict markers in $rel"
      }
    }

    Fix-ShellTestForLatest -TestPath (Join-Path $RepoPath "packages/opencode/test/tool/shell.test.ts")

    if (-not (Select-String -LiteralPath $shellPath -Pattern "POST_EXIT_OUTPUT_IDLE_TIMEOUT" -Quiet)) {
      throw "PR marker missing in shell.ts after merge"
    }
    if (-not (Select-String -LiteralPath $spawnPath -Pattern "const exited = Deferred" -Quiet)) {
      throw "PR marker missing in cross-spawn-spawner.ts after merge"
    }

    git add -- $prFiles
    $pending = git diff --cached --name-only
    if (-not $pending) {
      Write-Host "No staged changes after port."
    } else {
      git commit -m "fix(core): port PR #$PrNumber detached-child hang fix onto latest dev"
      if ($LASTEXITCODE -ne 0) { throw "git commit failed" }
    }
  } finally {
    Pop-Location
  }
}

function Install-CliBinary {
  param([string]$BuiltExe)

  Write-Step "Install CLI binary"
  $binDir = Join-Path $CliNpmDir "bin"
  $live = Join-Path $binDir "opencode.exe"
  if (-not (Test-Path -LiteralPath $binDir)) {
    throw "CLI npm bin dir missing: $binDir (install opencode-ai once via npm first)"
  }
  if (-not (Test-Path -LiteralPath $BuiltExe)) {
    throw "Built CLI missing: $BuiltExe"
  }

  $sideDir = Join-Path $WorkRoot "cli-$Version"
  New-Item -ItemType Directory -Force -Path $sideDir | Out-Null
  $sideExe = Join-Path $sideDir "opencode.exe"
  Copy-Item -LiteralPath $BuiltExe -Destination $sideExe -Force

  Write-Host "Smoke side binary..."
  $ver = & $sideExe --version
  $db = & $sideExe db path
  Write-Host "  version: $ver"
  Write-Host "  db path: $db"
  if ("$ver".Trim() -ne $Version) {
    throw "CLI version mismatch: expected $Version, got $ver"
  }
  if ($Channel -eq "prod" -and "$db" -notmatch 'opencode\.db$') {
    throw "Expected prod db opencode.db, got: $db"
  }

  if ($ForceKillCli) {
    Stop-NpmCliOnly
  }

  # Windows: rename locked live exe, then drop new one in place
  $oldRunning = Join-Path $binDir ("opencode.exe.old-running-" + $script:Stamp)
  if (Test-Path -LiteralPath $live) {
    if (Test-Path -LiteralPath $oldRunning) { Remove-Item -LiteralPath $oldRunning -Force -ErrorAction SilentlyContinue }
    try {
      Rename-Item -LiteralPath $live -NewName (Split-Path $oldRunning -Leaf) -Force
      Write-Host "Renamed live -> $(Split-Path $oldRunning -Leaf)"
    } catch {
      if ($ForceKillCli) { throw }
      Write-Host "Rename failed (CLI locked). Staged for next restart: $sideExe" -ForegroundColor Yellow
      $pending = Join-Path $binDir ("opencode.exe.new-" + $Version)
      Copy-Item -LiteralPath $BuiltExe -Destination $pending -Force
      $script:CliPending = $pending
      $script:CliSide = $sideExe
      return
    }
  }

  Copy-Item -LiteralPath $BuiltExe -Destination $live -Force

  $pkgPath = Join-Path $CliNpmDir "package.json"
  if (Test-Path -LiteralPath $pkgPath) {
    $pkg = Get-Content -LiteralPath $pkgPath -Raw | ConvertFrom-Json
    $pkg.version = $Version
    ($pkg | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $pkgPath -Encoding utf8
  }

  $check = & $live --version
  $checkDb = & $live db path
  Write-Host "Installed CLI: $check"
  Write-Host "CLI db path:   $checkDb"
  $script:CliLive = $live
  $script:CliOldRunning = $oldRunning
  $script:CliSide = $sideExe
}

function Install-DesktopAsar {
  param([string]$OutDir)

  Write-Step "Pack and replace Desktop app.asar"
  $asarPath = Join-Path $DesktopInstall "resources\app.asar"
  if (-not (Test-Path -LiteralPath $asarPath)) { throw "Missing app.asar: $asarPath" }
  if (-not (Test-Path -LiteralPath (Join-Path $OutDir "main\index.js"))) {
    throw "Desktop out missing main/index.js"
  }

  Stop-DesktopOnly

  $extract = Join-Path $WorkRoot "desktop-pack-$script:Stamp"
  $origExtract = Join-Path $WorkRoot "desktop-orig-$script:Stamp"
  if (Test-Path $extract) { Remove-Item $extract -Recurse -Force }
  if (Test-Path $origExtract) { Remove-Item $origExtract -Recurse -Force }

  Write-Host "Extracting current asar for package.json / native deps..."
  & npx --yes asar extract $asarPath $origExtract
  if ($LASTEXITCODE -ne 0) { throw "asar extract failed" }

  New-Item -ItemType Directory -Force -Path $extract | Out-Null
  $appPackagePath = Join-Path $extract "package.json"
  Copy-Item -LiteralPath (Join-Path $origExtract "package.json") -Destination $appPackagePath -Force
  $appPackage = Get-Content -LiteralPath $appPackagePath -Raw | ConvertFrom-Json
  $appPackage.version = $script:DesktopAppVersion
  ($appPackage | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $appPackagePath -Encoding utf8
  Write-Host "Desktop updater version: $($script:DesktopAppVersion)"
  foreach ($name in @("resources", "node_modules")) {
    $src = Join-Path $origExtract $name
    if (Test-Path -LiteralPath $src) {
      Copy-Item -LiteralPath $src -Destination (Join-Path $extract $name) -Recurse -Force
    }
  }
  Copy-Item -LiteralPath $OutDir -Destination (Join-Path $extract "out") -Recurse -Force

  $meta = @(
    "target=desktop"
    "channel=$Channel"
    "version=$Version"
    "branch=$BranchName"
    "head=$(git -C $RepoPath rev-parse --short HEAD)"
    "pr=$(if ($SkipPr) { 'none' } else { $PrNumber })"
    "built=$(Get-Date -Format o)"
  ) -join "`n"
  Set-Content -LiteralPath (Join-Path $extract "DESKTOP-BUILD.txt") -Value $meta -Encoding utf8

  $backup = Join-Path $WorkRoot "app.asar.bak-$script:Stamp"
  Copy-Item -LiteralPath $asarPath -Destination $backup -Force
  Write-Host "Backup: $backup ($((Get-Item $backup).Length) bytes)"

  & npx --yes asar pack $extract $asarPath
  if ($LASTEXITCODE -ne 0) { throw "asar pack failed" }
  Write-Host "Installed: $asarPath ($((Get-Item $asarPath).Length) bytes)"

  $verify = Join-Path $WorkRoot "desktop-verify-$script:Stamp"
  if (Test-Path $verify) { Remove-Item $verify -Recurse -Force }
  & npx --yes asar extract $asarPath $verify
  if ($LASTEXITCODE -ne 0) { throw "asar verify extract failed" }

  $vIdx = Join-Path $verify "out\main\index.js"
  $vRaw = Select-String -LiteralPath $vIdx -Pattern 'const raw = ' | Select-Object -First 1
  Write-Host "Installed channel: $($vRaw.Line.Trim())"
  if ($vRaw.Line -notmatch [regex]::Escape("`"$Channel`"")) {
    throw "Installed asar channel mismatch"
  }
  if (-not $SkipPr) {
    $vHit = rg -n "POST_EXIT_OUTPUT_IDLE_TIMEOUT" (Join-Path $verify "out\main\chunks") 2>$null
    if (-not $vHit) { throw "Installed asar missing PR marker" }
  }
  $installedAppVersion = (Get-Content -LiteralPath (Join-Path $verify "package.json") -Raw | ConvertFrom-Json).version
  Write-Host "Installed Desktop updater version: $installedAppVersion"
  if ($installedAppVersion -ne $script:DesktopAppVersion) {
    throw "Installed Desktop version mismatch: expected $($script:DesktopAppVersion), got $installedAppVersion"
  }

  $script:DesktopAsar = $asarPath
  $script:DesktopBackup = $backup
}

# ---------- main ----------
# StrictMode: these are assigned only on some paths, but the final summary reads them all.
$script:CliLive = $null
$script:CliOldRunning = $null
$script:CliSide = $null
$script:CliPending = $null
$script:DesktopAsar = $null
$script:DesktopBackup = $null
$script:DesktopAppVersion = $null

Write-Step "Preflight"
Assert-Command git -Hint "https://git-scm.com/download/win"
Assert-Command bun -Hint "https://bun.sh  (winget install Oven-sh.Bun)"
if ($DoDesktop) {
  Assert-Command npx -Hint "Install Node.js  (winget install OpenJS.NodeJS.LTS)"
  Assert-Command rg  -Hint "Install ripgrep  (winget install BurntSushi.ripgrep.MSVC)"
}

if ($Channel -ne "prod") {
  Write-Host "WARNING: channel=$Channel -> separate DB / Desktop userData; sessions won't match prod." -ForegroundColor Yellow
}

if (-not $RepoPath) { $RepoPath = Resolve-RepoPath }
if (-not $WorkRoot) { $WorkRoot = Join-Path ([IO.Path]::GetTempPath()) "opencode" }
if ($DoDesktop -and -not $DesktopInstall) { $DesktopInstall = Resolve-DesktopInstall }
if ($DoCli -and -not $CliNpmDir) { $CliNpmDir = Resolve-CliNpmDir }

# [IO.Path]::Combine, not Join-Path: a user-supplied path on a non-existent drive makes
# Join-Path throw before these explanatory messages ever print.
if (-not (Test-Path -LiteralPath ([IO.Path]::Combine($RepoPath, "packages\opencode\package.json")))) {
  Fail @"
OpenCode source repo not found at: $RepoPath
Clone it first:
    git clone https://github.com/anomalyco/opencode.git "$RepoPath"
Or point this script at an existing clone:
    -RepoPath <path>        (or set `$env:OPENCODE_SOURCE)
"@
}
if ($DoDesktop -and -not (Test-Path -LiteralPath ([IO.Path]::Combine($DesktopInstall, "resources\app.asar")))) {
  Fail @"
OpenCode Desktop install not found at: $DesktopInstall
This script re-packs the app.asar of an existing Desktop install, so install the official
Desktop app once first, or pass -DesktopInstall <path>.
Use -Target cli to skip Desktop entirely.
"@
}
if ($DoCli -and -not (Test-Path -LiteralPath ([IO.Path]::Combine($CliNpmDir, "bin")))) {
  Fail @"
opencode-ai npm package not found at: $CliNpmDir
The script overwrites the binary that the npm launcher shim runs, so install it once first:
    npm i -g opencode-ai
Or pass -CliNpmDir <path>.  Use -Target desktop to skip the CLI entirely.
"@
}

New-Item -ItemType Directory -Force -Path $WorkRoot | Out-Null
$script:Stamp = Get-Date -Format "yyyyMMdd-HHmmss"

if (-not $BranchName) {
  $suffix = if ($SkipPr) { "latest" } else { "pr$PrNumber" }
  $BranchName = "update-$Target-$suffix-$script:Stamp"
}

$versionOverride = $Version

Write-Host "Target:    $Target"
Write-Host "Repo:      $RepoPath"
Write-Host "Channel:   $Channel"
Write-Host "Version:   $(if ($versionOverride) { $versionOverride } else { '(auto from package.json after sync)' })"
Write-Host "PR:        $(if ($SkipPr) { '(skipped)' } else { $PrNumber })"
Write-Host "Branch:    $BranchName"
Write-Host "DryRun:    $DryRun"
if ($DoDesktop) { Write-Host "Desktop:   $DesktopInstall" }
if ($DoCli) { Write-Host "CLI npm:   $CliNpmDir" }

Sync-RepoAndPortPr

# Version follows the tree we just checked out (origin/dev ± PR port).
if (-not $versionOverride) {
  $pkgJson = Join-Path $RepoPath "packages\opencode\package.json"
  if (-not (Test-Path -LiteralPath $pkgJson)) {
    throw "Cannot auto-detect version: missing $pkgJson"
  }
  $baseVer = (Get-Content -LiteralPath $pkgJson -Raw | ConvertFrom-Json).version
  if (-not $baseVer) { throw "packages/opencode/package.json has no version" }
  # Strip any previous local suffix so re-runs stay clean: 1.19.0-pr29831 -> 1.19.0
  $baseVer = ($baseVer -replace '-pr\d+$', '' -replace '-local.*$', '')
  if ($SkipPr) {
    $Version = $baseVer
  } else {
    $Version = "$baseVer-pr$PrNumber"
  }
  Write-Host "Auto version: $Version (from package.json $baseVer)"
} else {
  $Version = $versionOverride
  Write-Host "Version override: $Version"
}

if ($DoDesktop) {
  # A local -pr prerelease sorts below the same official stable release, so it
  # must not be used as Electron's app.getVersion() value. Keep the custom
  # build identity in OPENCODE_VERSION / DESKTOP-BUILD.txt instead.
  $script:DesktopAppVersion = if (-not $versionOverride -and -not $SkipPr) { $baseVer } else { $Version }
  Write-Host "Desktop updater version: $($script:DesktopAppVersion)"
}

if ($DryRun) {
  Write-Step "DryRun: stop before build/install"
  Write-Host "Branch ready: $BranchName"
  Write-Host "Resolved version: $Version"
  Write-Host "Re-run without -DryRun to build and install."
  return
}

if (-not $SkipInstall) {
  Write-Step "bun install"
  Invoke-Bun -BunArgs @("install") -Cwd $RepoPath
}

$envExtra = @{
  OPENCODE_CHANNEL = $Channel
  OPENCODE_VERSION = $Version
}

if ($DoDesktop) {
  Write-Step "Build opencode node server (for desktop sidecar)"
  Invoke-Bun -BunArgs @("script/build-node.ts") -Cwd (Join-Path $RepoPath "packages/opencode") -EnvExtra $envExtra

  Write-Step "Build desktop (prebuild + electron-vite)"
  $desktopPkg = Join-Path $RepoPath "packages/desktop"
  Invoke-Bun -BunArgs @("run", "prebuild") -Cwd $desktopPkg -EnvExtra $envExtra
  Invoke-Bun -BunArgs @("run", "build") -Cwd $desktopPkg -EnvExtra $envExtra

  $outDir = Join-Path $desktopPkg "out"
  $idx = Join-Path $outDir "main\index.js"
  $rawLine = Select-String -LiteralPath $idx -Pattern 'const raw = ' | Select-Object -First 1
  Write-Host "Desktop baked channel: $($rawLine.Line.Trim())"
  if ($rawLine.Line -notmatch [regex]::Escape("`"$Channel`"")) {
    throw "Expected OPENCODE_CHANNEL=$Channel in desktop index.js"
  }
  if (-not $SkipPr) {
    $hit = rg -n "POST_EXIT_OUTPUT_IDLE_TIMEOUT" (Join-Path $outDir "main\chunks") 2>$null
    if (-not $hit) { throw "Desktop chunks missing POST_EXIT_OUTPUT_IDLE_TIMEOUT" }
  }

  Install-DesktopAsar -OutDir $outDir
}

if ($DoCli) {
  Write-Step "Build CLI binary (single platform, channel=$Channel)"
  Invoke-Bun -BunArgs @("run", "script/build.ts", "--single", "--skip-install") `
    -Cwd (Join-Path $RepoPath "packages/opencode") `
    -EnvExtra $envExtra

  $built = Join-Path $RepoPath "packages\opencode\dist\opencode-windows-x64\bin\opencode.exe"
  if (-not (Test-Path -LiteralPath $built)) {
    # fallback name patterns
    $cand = Get-ChildItem (Join-Path $RepoPath "packages\opencode\dist") -Recurse -Filter "opencode.exe" |
      Select-Object -First 1
    if ($cand) { $built = $cand.FullName }
  }
  Install-CliBinary -BuiltExe $built
}

Write-Step "Done"
$head = (git -C $RepoPath rev-parse --short HEAD).Trim()
$lines = @(
  "OpenCode update complete."
  "  target  : $Target"
  "  channel : $Channel"
  "  version : $Version"
  "  branch  : $BranchName"
  "  head    : $head"
)
if ($DoDesktop -and $script:DesktopAsar) {
  $lines += "  desktop : $($script:DesktopAsar)"
  $lines += "  app ver : $($script:DesktopAppVersion)  (Electron updater identity)"
  $lines += "  asar bak: $($script:DesktopBackup)"
  $lines += "  -> Fully quit and relaunch OpenCode Desktop."
}
if ($DoCli) {
  if ($script:CliLive) {
    $lines += "  cli     : $($script:CliLive)"
    $lines += "  cli old : $($script:CliOldRunning)"
  }
  if ($script:CliPending) {
    $lines += "  cli pend: $($script:CliPending)  (rename-install failed; run with -ForceKillCli or close CLI then re-run)"
  }
  if ($script:CliSide) {
    $lines += "  cli side: $($script:CliSide)"
  }
  $lines += "  -> Open a NEW terminal/session to use the new CLI (current session stays on old process)."
  $lines += "  -> With channel=prod, CLI uses opencode.db (same as Desktop)."
}
$lines += ""
$lines += "DB backups (manual): copy ~/.local/share/opencode/opencode*.db* first if unsure."
Write-Host ($lines -join "`n") -ForegroundColor Green

@nimrodmoshe05-glitch

nimrodmoshe05-glitch commented Jul 28, 2026

Copy link
Copy Markdown

This should be merged ASAP as its a deal breaker!!!!! @thdxr @rekram1-node

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment