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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ja-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Clowder はモデル非依存です。各エージェント CLI / adapter は統

### オプション B:ソースセットアップ

**前提条件:** [Node.js 20+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(オプション — スキップする場合は `--memory`)* · Git
**前提条件:** [Node.js 24+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(オプション — スキップする場合は `--memory`)* · Git

```bash
# 1. クローン
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ The desktop installer bundles the app runtime, portable Node.js, and Redis, so r

### Option B: Source Setup

**Prerequisites:** [Node.js 20+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(optional — use `--memory` to skip)* · Git
**Prerequisites:** [Node.js 24+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(optional — use `--memory` to skip)* · Git

```bash
# 1. Clone
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Clowder 不绑定模型。当前支持的 Agent CLI / adapter:

### 方式 B:源码安装

**前置要求:** [Node.js 20+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(可选 — 用 `--memory` 跳过)* · Git
**前置要求:** [Node.js 24+](https://nodejs.org/) · [pnpm 9+](https://pnpm.io/) · [Redis 7+](https://redis.io/) *(可选 — 用 `--memory` 跳过)* · Git

```bash
# 1. 克隆
Expand Down
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

| Tool | Version | Install |
|------|---------|---------|
| **Node.js** | >= 20.0.0 | [nodejs.org](https://nodejs.org/) |
| **Node.js** | >= 24.0.0 | [nodejs.org](https://nodejs.org/) |
| **pnpm** | >= 9.0.0 | `npm install -g pnpm` |
| **Redis** | >= 7.0 | `brew install redis` (macOS) or [redis.io](https://redis.io/download/) — *optional: use `--memory` flag to skip* |
| **Git** | any recent | Comes with most systems |
Expand Down
2 changes: 1 addition & 1 deletion SETUP.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

| 工具 | 版本 | 安装方式 |
|------|------|---------|
| **Node.js** | >= 20.0.0 | [nodejs.org](https://nodejs.org/) |
| **Node.js** | >= 24.0.0 | [nodejs.org](https://nodejs.org/) |
| **pnpm** | >= 9.0.0 | `npm install -g pnpm` |
| **Redis** | >= 7.0 | `brew install redis`(macOS)或 [redis.io](https://redis.io/download/) — *可选:用 `--memory` 标志跳过* |
| **Git** | 任意近期版本 | 大多数系统自带 |
Expand Down
4 changes: 2 additions & 2 deletions desktop/scripts/build-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ bold "Step 3/6 — Bundle Node.js portable (arm64 + x64)"
# matches the bundled runtime. Same rationale as the Windows build.
BUILD_NODE_VERSION="$(node --version 2>/dev/null || echo '')"
if [[ -z "$BUILD_NODE_VERSION" ]]; then
warn "node not on PATH; defaulting to v22.12.0"
BUILD_NODE_VERSION="v22.12.0"
warn "node not on PATH; defaulting to v24.16.0"
BUILD_NODE_VERSION="v24.16.0"
fi
BUILD_NODE_MAJOR="${BUILD_NODE_VERSION#v}"
BUILD_NODE_MAJOR="${BUILD_NODE_MAJOR%%.*}"
Expand Down
2 changes: 1 addition & 1 deletion desktop/service-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class ServiceManager {
// ---- Pre-flight checks ----
const nodeExe = resolveNode(this.root);
if (!nodeExe) {
throw new Error('Node.js not found. Please install Node.js >= 20 from https://nodejs.org/');
throw new Error('Node.js not found. Please install Node.js >= 24 from https://nodejs.org/');
}
log(`Node.js found: ${nodeExe}`);

Expand Down
4 changes: 2 additions & 2 deletions docs/features/F113-multi-platform-one-click-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ community_issue: https://github.com/zts212653/clowder-ai/issues/14
`scripts/install.sh` — 自动检测 Debian/Ubuntu、CentOS/RHEL/Fedora,安装依赖、配置环境变量、构建并启动服务。

- 系统依赖检测 + 自动安装(git、curl)
- fnm/Node.js 20 + pnpm + Redis
- fnm/Node.js 24 + pnpm + Redis
- AI CLI 工具安装(Claude Code、Codex、Gemini CLI)
- TTY 安全读取(非交互环境兼容)
- npm registry fallback(中国镜像)
Expand All @@ -41,7 +41,7 @@ community_issue: https://github.com/zts212653/clowder-ai/issues/14
核心能力:
- **Homebrew 自动安装**:Apple Silicon(`/opt/homebrew`)+ Intel(`/usr/local`)双路径检测;非 login shell 下也能发现已有 brew(`shellenv` 注入)
- **Xcode CLT**:缺失时自动 `xcode-select --install`,30min 非致命超时(不阻塞安装)
- **Node.js**:优先 fnm,fallback `brew install node@20`;keg-only formula 的 bin 路径显式注入 PATH
- **Node.js**:优先 fnm,fallback `brew install node@24`;keg-only formula 的 bin 路径显式注入 PATH
- **pnpm**:corepack → npm fallback;安装后无条件 `persist_user_bin` + 当前 session PATH 注入
- **Redis**:`brew install redis` + `brew services start redis`(启动失败非致命,install 是关键)
- **AI CLI 工具**:
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/utils/cli-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ function defaultSpawn(
// managers (nvm/fnm/Volta). CLI shims use `#!/usr/bin/env node`, so the
// child process must be able to find `node` in its PATH. Prepend the
// directory containing the resolved CLI binary — it typically sits next
// to the `node` binary that installed it (e.g. ~/.nvm/versions/node/v20/bin/).
// to the `node` binary that installed it (e.g. ~/.nvm/versions/node/v24/bin/).
const env = { ...options.env };
if (isAbsolute(command)) {
const binDir = dirname(command);
Expand Down
53 changes: 37 additions & 16 deletions packages/api/test/install-script-error-classification.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,26 @@ test('Step 5 fails fast on non-lockfile errors instead of swapping to plain pnpm

test('Invoke-PnpmInstallWithCapturedOutput trusts $LASTEXITCODE over pipeline exceptions (DEP0169 tolerance)', () => {
// Node 24 emits DEP0169 deprecation warnings to stderr. With $ErrorActionPreference=Stop,
// the 2>&1 | Tee-Object pipeline can throw even when pnpm itself exited 0.
// the 2>&1 pipeline can throw even when pnpm itself exited 0.
// The catch path must check $LASTEXITCODE and treat exit 0 as success, not failure.
const body = getInvokePnpmInstallFunctionBody();
// The catch block must reference $LASTEXITCODE so it can distinguish a real
// process failure from a benign pipeline throw on stderr.
const catchBlock = body.match(/} catch \{[\s\S]*?\}/);
assert.ok(catchBlock, 'must have catch block');
// Anchor to the actual pnpm pipeline catch block by locating the
// "Two distinct scenarios reach this catch:" comment, which uniquely
// identifies the DEP0169 tolerance catch (not the bare spinner catch {}).
const scenariosIdx = body.indexOf('Two distinct scenarios');
assert.ok(scenariosIdx >= 0, 'must have DEP0169 tolerance catch block with "Two distinct scenarios" comment');
// Walk backward from the comment to find its enclosing '} catch {'
const beforeComment = body.substring(0, scenariosIdx);
const catchIdx = beforeComment.lastIndexOf('} catch {');
assert.ok(catchIdx >= 0, 'must have catch block before "Two distinct scenarios" comment');
// Slice from catch start to the following '} finally {' to capture the
// full pnpm pipeline catch block (excluding later code outside it).
const fromCatch = body.substring(catchIdx);
const finallyIdx = fromCatch.indexOf('} finally {');
assert.ok(finallyIdx >= 0, 'catch block must be followed by } finally {');
const catchBlock = fromCatch.substring(0, finallyIdx);
assert.match(
catchBlock[0],
catchBlock,
/\$(?:global:)?LASTEXITCODE\s*-eq\s*0/,
'catch block must check $LASTEXITCODE (optionally with $global: scope) -eq 0 to avoid DEP0169 false failures',
);
Expand Down Expand Up @@ -204,22 +215,23 @@ test('Invoke-PnpmInstallWithCapturedOutput calls resolved pnpm directly inside t
// (Invoke-Pnpm -> Invoke-ToolCommand -> & $toolCommand) hides the native exit
// code from the captured pipeline. Fix: resolve pnpm up-front and invoke it
// directly inside the captured pipeline (`& $pnpmCommand @CommandArgs 2>&1
// | Tee-Object ...`) so the native command is the only producer of
// | ForEach-Object ...`) so the native command is the only producer of
// $LASTEXITCODE in scope.
const body = getInvokePnpmInstallFunctionBody();
assert.match(body, /Resolve-PnpmCommand/, 'must resolve pnpm command upfront (not via Invoke-Pnpm wrapper)');
// The captured pipeline must invoke the resolved pnpm command directly with
// `& $pnpmCommand`, NOT through the Invoke-Pnpm wrapper, so PowerShell can
// record the native exit code in $LASTEXITCODE reliably.
// record the native exit code in $LASTEXITCODE reliably. The pipeline may
// use either Tee-Object or ForEach-Object — both preserve $LASTEXITCODE.
assert.match(
body,
/&\s*\$pnpmCommand\s+@CommandArgs\s+2>&1\s*\|\s*Tee-Object/,
'captured pipeline must call & $pnpmCommand @CommandArgs 2>&1 | Tee-Object directly',
/&\s*\$pnpmCommand\s+@CommandArgs\s+2>&1\s*\|\s*(?:Tee-Object|ForEach-Object)/,
'captured pipeline must call & $pnpmCommand @CommandArgs 2>&1 | Tee-Object or ForEach-Object directly',
);
assert.doesNotMatch(
body,
/Invoke-Pnpm\s+-CommandArgs\s+\$CommandArgs\s+2>&1\s*\|\s*Tee-Object/,
'must NOT pipe Invoke-Pnpm into Tee-Object (hides native exit code from caller scope)',
/Invoke-Pnpm\s+-CommandArgs\s+\$CommandArgs\s+2>&1\s*\|\s*(?:Tee-Object|ForEach-Object)/,
'must NOT pipe Invoke-Pnpm into Tee-Object/ForEach-Object (hides native exit code from caller scope)',
);
});

Expand Down Expand Up @@ -247,10 +259,19 @@ test('Invoke-PnpmInstallWithCapturedOutput reads $global:LASTEXITCODE explicitly
assert.ok(successCheck, 'must have an Ok = $... -eq 0 check on the success path');
assert.equal(successCheck[1], '$global:LASTEXITCODE', 'success path must read $global:LASTEXITCODE explicitly');
// The catch path must also read $global:LASTEXITCODE for the same reason.
const catchBlock = body.match(/} catch \{[\s\S]*?\}\s*\}\s*finally/);
assert.ok(catchBlock, 'must have catch block');
// Anchor to the pnpm pipeline catch block using the "Two distinct scenarios"
// comment, then extract the block between the catch and its following finally.
const scenariosIdx = body.indexOf('Two distinct scenarios');
assert.ok(scenariosIdx >= 0, 'must have DEP0169 tolerance catch block with "Two distinct scenarios" comment');
const beforeComment = body.substring(0, scenariosIdx);
const catchIdx = beforeComment.lastIndexOf('} catch {');
assert.ok(catchIdx >= 0, 'must have catch block before "Two distinct scenarios" comment');
const fromCatch = body.substring(catchIdx);
const finallyIdx = fromCatch.indexOf('} finally {');
assert.ok(finallyIdx >= 0, 'catch block must be followed by } finally {');
const catchBlock = fromCatch.substring(0, finallyIdx);
assert.match(
catchBlock[0],
catchBlock,
/\$global:LASTEXITCODE\s*-eq\s*0/,
'catch path must read $global:LASTEXITCODE explicitly to detect pnpm success-with-pipeline-throw',
);
Expand All @@ -260,7 +281,7 @@ test('Invoke-PnpmInstallWithCapturedOutput temporarily downgrades ErrorActionPre
const body = getInvokePnpmInstallFunctionBody();
const snapshotIdx = body.indexOf('$previousErrorActionPreference = $ErrorActionPreference');
const downgradeIdx = body.indexOf('$ErrorActionPreference = "SilentlyContinue"');
const invokeMatch = body.match(/&\s*\$pnpmCommand\s+@CommandArgs\s+2>&1\s*\|\s*Tee-Object/);
const invokeMatch = body.match(/&\s*\$pnpmCommand\s+@CommandArgs\s+2>&1\s*\|\s*(?:Tee-Object|ForEach-Object)/);
const invokeIdx = invokeMatch ? invokeMatch.index : -1;
const finallyIdx = body.indexOf('} finally {');
const restoreIdx = body.indexOf('$ErrorActionPreference = $previousErrorActionPreference');
Expand Down
119 changes: 107 additions & 12 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ function Refresh-Path {

function Resolve-PnpmCommand { Resolve-ToolCommand -Name "pnpm" }
function Invoke-Pnpm { param([string[]]$CommandArgs) Invoke-ToolCommand -Name "pnpm" -CommandArgs $CommandArgs }
function Get-CommandOutputText {
param([object[]]$OutputLines)
return (@($OutputLines) | ForEach-Object { "$_" }) -join "`n"
}
function Test-PuppeteerBrowserDownloadFailure {
param([string]$OutputText)
return $OutputText -match "puppeteer" -and
Expand Down Expand Up @@ -90,7 +86,7 @@ function Invoke-PnpmInstallWithCapturedOutput {
[switch]$SkipPuppeteerDownload
)

$capturedOutput = @()
$capturedOutput = [System.Collections.Generic.List[string]]::new()
$hadPreviousSkip = Test-Path Env:PUPPETEER_SKIP_DOWNLOAD
$previousSkipValue = if ($hadPreviousSkip) { $env:PUPPETEER_SKIP_DOWNLOAD } else { $null }
$previousErrorActionPreference = $ErrorActionPreference
Expand All @@ -110,6 +106,23 @@ function Invoke-PnpmInstallWithCapturedOutput {
}
}

# Spinner state for live progress display.
# Use a hashtable so ForEach-Object scriptblock mutations are visible
# in the outer scope (PowerShell 5.1 closes over reference types but
# not value types — $script:scope would also work, but a hashtable
# is cleaner inside a function that may be dot-sourced).
$spin = @{
Chars = @('-', '\', '|', '/')
Index = 0
Last = ""
Line = ""
CanUse = $false
}
try {
[void][Console]::CursorVisible
$spin.CanUse = $true
} catch {}

try {
if ($SkipPuppeteerDownload) {
$env:PUPPETEER_SKIP_DOWNLOAD = "1"
Expand All @@ -134,16 +147,77 @@ function Invoke-PnpmInstallWithCapturedOutput {
# Stop, PowerShell 5.1 can promote benign Node 24 stderr (DEP0169)
# into a RemoteException before we can read pnpm's exit code.
$ErrorActionPreference = "SilentlyContinue"
& $pnpmCommand @CommandArgs 2>&1 | Tee-Object -Variable capturedOutput

# Instead of Tee-Object, process each line through a custom handler
# that captures output AND shows a live spinner for progress lines.
# Non-progress lines (errors, warnings, etc.) are printed normally
# so they remain visible on screen.
& $pnpmCommand @CommandArgs 2>&1 | ForEach-Object {
$line = "$_"
$capturedOutput.Add($line)

# Classify the line: is it a progress line that the spinner
# should display, or a real output line to print normally?
$isProgressLine = $line -match '^\.\.\./' -or
$line -match '^packages/' -or
$line -match '(?:Progress|Already|Downloaded|Resolved|Reused):'

if ($spin.CanUse -and $isProgressLine) {
# Update spinner with progress info
if ($line -match '^\.\.\./(.+?)(?:\s+|\$)') {
$spin.Last = $Matches[1]
} elseif ($line -match '^packages/(.+?)(?:\s|$)') {
$spin.Last = $Matches[1]
} elseif ($line -match '(?:Progress|Already|Downloaded|Resolved|Reused):\s*(.+)') {
$spin.Last = $Matches[1].Trim()
}

$ch = $spin.Chars[$spin.Index % $spin.Chars.Count]
$spin.Index++
$display = if ($spin.Last) { $spin.Last } else { "resolving..." }
if ($display.Length -gt 50) { $display = $display.Substring(0, 47) + "..." }
$spin.Line = " $ch $display"
try {
[Console]::SetCursorPosition(0, [Console]::CursorTop)
[Console]::Write("`r$($spin.Line)" + " " * ([Math]::Max(0, 60 - $spin.Line.Length)))
} catch {}
} else {
# Non-progress line: clear spinner, print on its own line
if ($spin.CanUse -and $spin.Line) {
try {
[Console]::SetCursorPosition(0, [Console]::CursorTop)
[Console]::Write((" " * 70) + "`r")
} catch {}
$spin.Line = ""
}
Write-Host " $line"
}
}

# Clear the progress line after pnpm finishes
if ($spin.CanUse -and $spin.Line) {
try {
[Console]::SetCursorPosition(0, [Console]::CursorTop)
[Console]::Write((" " * 70) + "`r")
} catch {}
}

return [pscustomobject]@{
Ok = $global:LASTEXITCODE -eq 0
ErrorRecord = $null
OutputText = Get-CommandOutputText -OutputLines $capturedOutput
OutputText = $capturedOutput -join "`n"
}
} catch {
# Clear progress line on error too
if ($spin.CanUse -and $spin.Line) {
try {
[Console]::SetCursorPosition(0, [Console]::CursorTop)
[Console]::Write((" " * 70) + "`r")
} catch {}
}
# Two distinct scenarios reach this catch:
# (a) pnpm actually ran, exited 0, and only the 2>&1 | Tee-Object
# pipeline threw (e.g. Node 24 DEP0169 deprecation on stderr
# (a) pnpm actually ran, exited 0, and only the 2>&1 pipeline
# threw (e.g. Node 24 DEP0169 deprecation on stderr
# under $ErrorActionPreference=Stop). $global:LASTEXITCODE is
# now 0 and we should treat this as success.
# (b) pnpm itself failed before producing an exit code, or the
Expand All @@ -154,13 +228,13 @@ function Invoke-PnpmInstallWithCapturedOutput {
return [pscustomobject]@{
Ok = $true
ErrorRecord = $null
OutputText = Get-CommandOutputText -OutputLines ($capturedOutput + @($_))
OutputText = ($capturedOutput + @("$_")) -join "`n"
}
}
return [pscustomobject]@{
Ok = $false
ErrorRecord = $_
OutputText = Get-CommandOutputText -OutputLines ($capturedOutput + @($_))
OutputText = ($capturedOutput + @("$_")) -join "`n"
}
}
} finally {
Expand Down Expand Up @@ -494,6 +568,16 @@ if (-not $frozenInstallResult.Ok) {
}
if (-not $plainInstallResult.Ok) {
Exit-InstallerIfCancelled -ErrorRecord $plainInstallResult.ErrorRecord -Context "pnpm install"
# Re-emit captured pnpm output so the user can see the real error
if ($plainInstallResult.OutputText) {
Write-Host ""
Write-Host " --- pnpm output ---" -ForegroundColor DarkGray
$plainInstallResult.OutputText -split "`n" | ForEach-Object {
Write-Host " $_" -ForegroundColor DarkGray
}
Write-Host " --- end pnpm output ---" -ForegroundColor DarkGray
Write-Host ""
}
Write-Err "pnpm install failed"
exit 1
}
Expand All @@ -504,8 +588,19 @@ if (-not $frozenInstallResult.Ok) {
if (Test-WindowsEpermFailure -OutputText $frozenInstallResult.OutputText) {
Write-WindowsEpermHint
}
# The spinner suppresses pnpm output during the run. Re-emit it now
# so the user can see the actual error pnpm reported.
if ($frozenInstallResult.OutputText) {
Write-Host ""
Write-Host " --- pnpm output ---" -ForegroundColor DarkGray
$frozenInstallResult.OutputText -split "`n" | ForEach-Object {
Write-Host " $_" -ForegroundColor DarkGray
}
Write-Host " --- end pnpm output ---" -ForegroundColor DarkGray
Write-Host ""
}
Write-Err "pnpm install --frozen-lockfile failed"
Write-Err "The real error is above. This is NOT a lockfile drift issue."
Write-Err "See pnpm output above for the real error. This is NOT a lockfile drift issue."
exit 1
}
}
Expand Down
Loading