fix(scripts): use project-relative paths and portable toolchains
Remove fixed machine installation paths, prefer project-local Go and Node toolchains, and resolve build verification and browser test binary paths relative to the project. Add isolated discovery/relocation tests and document compatibility and validation boundaries. Validation: 20 tool-discovery assertions, 3 Node path tests, startup isolation and script syntax checks passed. Frontend build passed; full Go compilation was blocked by unrelated concurrent Kubernetes changes.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
data
|
||||
bin
|
||||
artifacts
|
||||
tools/go
|
||||
tools/node
|
||||
web/node_modules
|
||||
web/test-results
|
||||
web/playwright-report
|
||||
|
||||
@@ -21,6 +21,8 @@ Desktop.ini
|
||||
|
||||
# Local dependencies (versions are tracked in go.mod/go.sum and package-lock.json)
|
||||
/vendor/
|
||||
/tools/go/
|
||||
/tools/node/
|
||||
node_modules/
|
||||
|
||||
# Go workspace file
|
||||
|
||||
@@ -65,6 +65,8 @@ Windows PowerShell:
|
||||
.\scripts\build.ps1
|
||||
```
|
||||
|
||||
源码目录的 `start-dev.cmd` 需要 Go、Node.js 和 npm;它不是免编译的发行版启动器。脚本以项目自身位置为基准,优先使用 `tools/go/bin/go.exe` 和 `tools/node/node.exe`(完整工具链含 npm),否则从 PATH 查找,不写死机器安装路径。可将项目和工具目录一起复制到其他电脑;详见 [相对路径与便携构建](docs/build-output.md)。不需要开发环境时直接运行预编译发行版 `serviceport.exe`。
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```sh
|
||||
|
||||
+50
-2
@@ -1,5 +1,53 @@
|
||||
# 本地输出目录
|
||||
|
||||
程序输出在 bin/;构建缓存使用 bin/build-cache,开发候选程序使用 bin/dev-build,Windows 单 EXE 发布目录使用 bin/releases。开发升级脚本的完整数据备份放在 bin/backups。
|
||||
程序输出在 `bin/`;构建缓存使用 `bin/build-cache`,开发候选程序使用 `bin/dev-build`,Windows 单 EXE 发布目录使用 `bin/releases`。开发升级脚本的完整数据备份放在 `bin/backups`。
|
||||
|
||||
bin/backups 包含用户数据,不是可随意删除的编译缓存。旧 artifacts/ 中的测试产物和备份仍保留,不在合并时搬移或清理。验证可使用 artifacts/ 下的独立程序、临时端口和临时数据库,不覆盖运行中的 bin/serviceport.exe。
|
||||
`bin/backups` 包含用户数据,不是可随意删除的编译缓存。旧 `artifacts/` 中的测试产物和备份仍保留,不在合并时搬移或清理。验证可使用 `artifacts/` 下的独立程序、临时端口和临时数据库,不覆盖运行中的 `bin/serviceport.exe`。
|
||||
|
||||
## 相对路径与跨机器使用
|
||||
|
||||
脚本不保存开发者盘符、用户目录或固定系统安装位置。项目根目录从脚本自身位置推导,与从哪个目录调用无关;复制整个项目到另一台电脑或带空格的其他目录后,不需要改脚本中的路径。
|
||||
|
||||
- CMD 入口通过 `%~dp0` 找脚本;PowerShell 通过 `$PSScriptRoot`;Shell 先切换到自身所在项目根目录;Node 通过 `import.meta.url` 定位项目。
|
||||
- 默认输入、输出、缓存和测试二进制都相对项目根目录。`build.ps1 -OutputPath`、验证脚本的文件参数及 `SERVICEPORT_E2E_BINARY` 的相对值也按项目根目录解释,不按调用者的当前目录。
|
||||
- 用户主动传入的外部文件路径仍兼容;脚本不会把它们作为跨机器默认值。已有 `SERVICEPORT_DATA` 自定义数据库路径和 AppData 兼容逻辑不改动。
|
||||
- 运行时仍会把路径规范化用于启动进程、校验删除范围和防止递归备份。这些是动态计算的路径,不是写死在脚本中的绝对路径。Docker fixture 的容器内挂载点和入口路径属于容器契约,不是宿主机器路径,不随本次修改改变。
|
||||
|
||||
## Windows 构建工具查找
|
||||
|
||||
源码中的 `start-dev.cmd` 每次先准备当前源码对应的程序,因此仍需 Go、Node.js 和 npm;缓存不会免除开发工具依赖。只运行软件时,使用预编译发行版的 `serviceport.exe`,无需安装这些工具。
|
||||
|
||||
可以把对应操作系统与架构的完整工具链放进项目:
|
||||
|
||||
```text
|
||||
serviceport/
|
||||
tools/
|
||||
go/bin/go.exe
|
||||
node/node.exe
|
||||
node/npm.cmd
|
||||
node/node_modules/npm/...
|
||||
scripts/
|
||||
start-dev.cmd
|
||||
```
|
||||
|
||||
上面只列关键文件;Go 和 Node 均需复制完整发行目录,不是只复制 EXE。`tools/go`、`tools/node` 不进入 Git 或 Docker 构建上下文。复制项目到另一台电脑时,如果需要便携构建,应自行一起复制这些工具目录;`git clone` 不会下载工具链。
|
||||
|
||||
Windows 查找顺序为:
|
||||
|
||||
1. 可选 `SERVICEPORT_GO` / `SERVICEPORT_NODE`,建议使用项目相对路径;显式路径无效时直接报错,不偷偷降级。
|
||||
2. 项目内 `tools/go/bin/go.exe`、`tools/node/node.exe`,优先于机器 PATH。
|
||||
3. 当前机器 PATH 中的对应应用程序,不再扫描 Program Files、AppData、用户目录或 SDK 安装位置。
|
||||
|
||||
Node 便携目录必须包含配套 npm。选定的 Node/npm 目录只在构建子进程的 PATH 中优先,不修改系统 PATH;直接使用 Node 执行 `build-web.mjs` 时也优先使用该 Node 目录中的 npm。Linux/macOS Shell 入口同样优先使用 `tools/go/bin`、`tools/node/bin`,没有则使用 PATH,工具需是对应系统版本。
|
||||
|
||||
默认工具目录齐全时直接双击 `start-dev.cmd`,无需设置路径。自定义相对位置示例:
|
||||
|
||||
```powershell
|
||||
$env:SERVICEPORT_GO = 'tools/go/bin/go.exe'
|
||||
$env:SERVICEPORT_NODE = 'tools/node/node.exe'
|
||||
.\start-dev.cmd -BuildOnly
|
||||
```
|
||||
|
||||
`-BuildOnly` 只构建候选程序,不启动、不停止或替换现有服务,也不迁移真实数据库。构建脚本固定 `GOTOOLCHAIN=go1.25.0`;本次不自动下载、安装或打包编译器,已有 Go 的工具链获取仍由 Go 自身及网络配置决定。工具缺失时在服务停机和处理数据之前失败,并提示放入项目工具目录或配置 PATH。安装工具或修改用户 PATH 后需重新打开终端。
|
||||
|
||||
验证记录见 [测试记录](testing.md)。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 变更原因与设计记录
|
||||
|
||||
## 脚本相对路径与便携工具链(2026-09-22)
|
||||
|
||||
原因:用户在其他工作副本启动时遇到 `Go is required`,并要求所有脚本跨机器使用、删除固定绝对路径。原脚本包含固定 Go 安装位置,若干 Node 验证入口与浏览器服务还依赖调用者当前目录。
|
||||
|
||||
结果:移除 Go 固定安装目录与用户目录扫描,Go/Node 默认优先使用项目 tools 目录,再从 PATH 获取;可选显式覆盖按项目根目录解析相对值,错误覆盖不降级。构建、打包和矩阵脚本共享工具查找,Node 子进程使用配套运行环境。验证脚本与浏览器测试服务统一按脚本位置定位项目,文件参数和 SERVICEPORT_E2E_BINARY 的相对值不再依赖调用目录;Shell 入口增加项目便携工具链优先级,工具目录不进入 Git/Docker 上下文。
|
||||
|
||||
范围与兼容:涉及开发/打包/验证脚本和测试启动器,不改变业务 API、Go 1.25.0 约束、数据库选择或发行版运行。工具只安装在未加入 PATH 的系统目录时,需要自行加入 PATH 或放入项目 tools;验证脚本的相对参数现在统一以项目根目录为基准。用户显式外部路径仍兼容。运行时规范化、临时目录清理范围检查、停止服务前构建保护与容器内挂载契约保留;不自动安装软件或修改系统 PATH。
|
||||
|
||||
验证:新增 Go/Node 隔离查找和 Node 脚本搬移测试,覆盖项目路径优先级、相对覆盖、空格目录和异地调用。实际构建及协议/浏览器验证边界见 [测试记录](testing.md),操作见 [本地构建说明](build-output.md)。不以本机验证推断另一台机器已具备完整工具链。
|
||||
|
||||
## WebDAV 替代 S3 同步并提供独立备份(2026-09-22)
|
||||
|
||||
原因:用户要求将 S3 同步改为 WebDAV 同步备份,不再保留双入口造成配置歧义,同时需要不影响共享同步状态的归档能力。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 测试与版本矩阵
|
||||
|
||||
## 2026-09-22 脚本相对路径与跨机器调用
|
||||
|
||||
- 从系统临时目录而非项目目录调用 Windows PowerShell 测试:go-toolchain.test.ps1 的 12 项断言、node-toolchain.test.ps1 的 8 项断言通过。覆盖项目便携工具优先于 PATH、项目相对覆盖、错误覆盖不降级、配套 npm、带空格目录与缺失提示。使用临时伪文件,不执行或安装工具,不修改持久环境变量。
|
||||
- Node 内置测试 `node --test scripts/project-paths.test.mjs` 三项通过:验证脚本复制到带空格/方括号的新目录后,路径仍以新项目为基准,并从无关目录启动子进程验证;同时检查三个验证脚本与浏览器启动器使用统一解析,以及维护脚本没有固定盘符/机器安装目录扫描。
|
||||
- 所有 scripts/*.ps1 的 PowerShell 语法解析通过;Git Bash 分别对 build.sh、test-matrix.sh 和容器 start-replica.sh 执行 `bash -n` 通过。未在实际 Linux/macOS 上构建,也未启动 Docker 协议矩阵。`git diff --check` 通过。
|
||||
- 从项目外调用 scripts/start-dev.test.ps1 既有隔离回归通过,覆盖相对入口、调用目录、构建锁、参数/失败隔离、数据路径、备份及错误保护;使用临时程序 fixture,不接触真实服务和数据库。
|
||||
- 实际从项目外调用 `start-dev.ps1 -BuildOnly`:正确定位项目、Go 与 Node,前端构建通过(仅现有大 chunk 警告);随后 Go 编译被工作区并行新增的 internal/outbound/kubernetes.go:184 中 done 重名/类型冲突阻断。本轮没有修改该业务文件,也没有用上次候选程序假装完成新构建。打包和 Playwright 后续步骤因新程序未生成而未执行。
|
||||
- 上一版工具发现实现的 BuildOnly 成功日志保留在 artifacts/go-discovery,但不能替代最终相对路径版本的完整构建验证。本轮日志位于 artifacts/relative-scripts;未重跑全量 Go/go vet/UI,不宣称全量通过。未部署、提交或修改真实数据,其他并行工作区改动保持原样。
|
||||
|
||||
## 2026-09-22 WebDAV 同步备份替代 S3
|
||||
|
||||
- 使用 Go 1.25.0 运行 `go test ./... -count=1 -timeout=120s`:本次变更涉及的 backup、syncjob、store、api、model、MongoDB/HTTP 协议包均通过;全量仅 internal/cluster 的 TestMySQLReplicationAndGroupDiscovery/mysql-group-replication 失败,报 `cluster has no available primary or router`,与本文件此前记录的既有间歇失败一致。本轮未修改该逻辑,不将全量 Go 标记通过。日志 artifacts/webdav-sync/go-test.log。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { delimiter, dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
@@ -11,9 +11,12 @@ const cache = join(repo, 'bin', 'build-cache');
|
||||
mkdirSync(cache, { recursive: true });
|
||||
const hash = value => createHash('sha256').update(value).digest('hex');
|
||||
const readJSON = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
||||
const runtimeEnvironment = { ...process.env };
|
||||
const pathKey = Object.keys(runtimeEnvironment).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||
runtimeEnvironment[pathKey] = dirname(process.execPath) + delimiter + (runtimeEnvironment[pathKey] || '');
|
||||
const npm = args => process.platform === 'win32'
|
||||
? execFileSync('cmd.exe', ['/d', '/s', '/c', `npm.cmd ${args.join(' ')}`], { cwd: web, stdio: 'inherit', windowsHide: true })
|
||||
: execFileSync('npm', args, { cwd: web, stdio: 'inherit' });
|
||||
? execFileSync('cmd.exe', ['/d', '/s', '/c', `npm.cmd ${args.join(' ')}`], { cwd: web, env: runtimeEnvironment, stdio: 'inherit', windowsHide: true })
|
||||
: execFileSync('npm', args, { cwd: web, env: runtimeEnvironment, stdio: 'inherit' });
|
||||
const dependencyKey = hash(JSON.stringify([
|
||||
process.version, process.arch, process.platform,
|
||||
readFileSync(join(web, 'package.json'), 'utf8'),
|
||||
|
||||
+8
-6
@@ -2,6 +2,7 @@ param([string]$Version, [switch]$SkipTests, [switch]$RebuildWeb, [string]$Output
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'build-lock.ps1')
|
||||
$buildLock = Enter-ServiceportBuildLock
|
||||
$previousPath = $env:PATH
|
||||
try {
|
||||
$buildTimer = [Diagnostics.Stopwatch]::StartNew()
|
||||
$phaseTimer = [Diagnostics.Stopwatch]::StartNew()
|
||||
@@ -11,20 +12,20 @@ function Write-BuildPhase([string]$Name) {
|
||||
}
|
||||
. (Join-Path $PSScriptRoot 'version.ps1')
|
||||
$buildInfo = Get-serviceportBuildInfo -Version $Version
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
Set-Location -LiteralPath (Split-Path $PSScriptRoot -Parent)
|
||||
$env:GOTOOLCHAIN = 'go1.25.0'
|
||||
$env:GO111MODULE = 'on'
|
||||
$goCommand = Get-Command go -ErrorAction SilentlyContinue
|
||||
$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' }
|
||||
if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' }
|
||||
if (!(Get-Command node -ErrorAction SilentlyContinue) -or !(Get-Command npm.cmd -ErrorAction SilentlyContinue)) { throw 'Node.js and npm are required' }
|
||||
. (Join-Path $PSScriptRoot 'go-toolchain.ps1')
|
||||
$goExecutable = Get-ServiceportGoExecutable -ProjectRoot (Split-Path $PSScriptRoot -Parent)
|
||||
. (Join-Path $PSScriptRoot 'node-toolchain.ps1')
|
||||
$nodeExecutable = Initialize-ServiceportNodeRuntime -ProjectRoot (Split-Path $PSScriptRoot -Parent)
|
||||
$keepPath = Join-Path $PWD 'web/dist/.gitkeep'
|
||||
$keepBytes = [byte[]]@()
|
||||
if (Test-Path -LiteralPath $keepPath) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) }
|
||||
try {
|
||||
$webArguments = @((Join-Path $PSScriptRoot 'build-web.mjs'))
|
||||
if ($RebuildWeb) { $webArguments += '--force' }
|
||||
node @webArguments
|
||||
& $nodeExecutable @webArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
|
||||
} finally {
|
||||
New-Item -ItemType Directory -Path (Split-Path $keepPath -Parent) -Force | Out-Null
|
||||
@@ -47,5 +48,6 @@ Write-BuildPhase 'Go build'
|
||||
Write-Host ('Build total: {0:N1}s' -f $buildTimer.Elapsed.TotalSeconds)
|
||||
Write-Output $OutputPath
|
||||
} finally {
|
||||
$env:PATH = $previousPath
|
||||
$buildLock.Dispose()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH"
|
||||
export GOTOOLCHAIN=go1.25.0
|
||||
export GO111MODULE=on
|
||||
skip_tests=0
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
function Get-ServiceportGoExecutable {
|
||||
param([Parameter(Mandatory = $true)][string]$ProjectRoot)
|
||||
|
||||
$ProjectRoot = [IO.Path]::GetFullPath($ProjectRoot)
|
||||
if (![string]::IsNullOrWhiteSpace($env:SERVICEPORT_GO)) {
|
||||
$explicit = [Environment]::ExpandEnvironmentVariables($env:SERVICEPORT_GO.Trim().Trim('"'))
|
||||
if (![IO.Path]::IsPathRooted($explicit)) { $explicit = Join-Path $ProjectRoot $explicit }
|
||||
$explicit = [IO.Path]::GetFullPath($explicit)
|
||||
if (!(Test-Path -LiteralPath $explicit -PathType Leaf)) {
|
||||
throw "SERVICEPORT_GO does not point to a file: $explicit. Use a project-relative go.exe path, or clear it to enable automatic discovery."
|
||||
}
|
||||
return $explicit
|
||||
}
|
||||
|
||||
$portable = Join-Path $ProjectRoot 'tools/go/bin/go.exe'
|
||||
if (Test-Path -LiteralPath $portable -PathType Leaf) { return $portable }
|
||||
$command = Get-Command go.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($command -and (Test-Path -LiteralPath $command.Source -PathType Leaf)) { return $command.Source }
|
||||
throw (@(
|
||||
'Go was not found. Running start-dev.cmd from a source checkout requires Go, Node.js and npm.'
|
||||
'Install Go 1.25.0 on PATH and reopen the terminal, or set SERVICEPORT_GO to a project-relative go.exe path.'
|
||||
'A portable Go installation may be placed at tools\go\bin\go.exe relative to the project.'
|
||||
'To run without development tools, use the prebuilt serviceport.exe release instead of start-dev.cmd.'
|
||||
('Checked tools/go/bin/go.exe under project: ' + $ProjectRoot + ', then go.exe on PATH.')
|
||||
) -join [Environment]::NewLine)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'go-toolchain.ps1')
|
||||
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('serviceport-go-test-' + [Guid]::NewGuid().ToString('N') + ' with spaces')
|
||||
$project = Join-Path $fixture 'project'
|
||||
$previous = @{}
|
||||
$names = @('SERVICEPORT_GO')
|
||||
foreach ($name in $names) { $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') }
|
||||
$script:commandSource = $null
|
||||
$script:assertions = 0
|
||||
function Get-Command {
|
||||
[CmdletBinding()]
|
||||
param([string]$Name, [string]$CommandType)
|
||||
if ($Name -ne 'go.exe' -or $CommandType -ne 'Application') { throw 'Go lookup must exclude functions and aliases' }
|
||||
if ($script:commandSource) { [PSCustomObject]@{ Source = $script:commandSource } }
|
||||
}
|
||||
function Assert-GoTest([bool]$Condition, [string]$Message) {
|
||||
if (!$Condition) { throw $Message }
|
||||
$script:assertions++
|
||||
}
|
||||
function New-GoFixture([string]$Path) {
|
||||
New-Item -ItemType Directory -Path (Split-Path $Path -Parent) -Force | Out-Null
|
||||
[IO.File]::WriteAllText($Path, 'resolution fixture, not an executable')
|
||||
return [IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $project -Force | Out-Null
|
||||
foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') }
|
||||
$pathGo = New-GoFixture (Join-Path $fixture 'on path/go.exe')
|
||||
$script:commandSource = $pathGo
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $pathGo) 'PATH lookup failed'
|
||||
$explicit = New-GoFixture (Join-Path $project 'custom Go/bin/go.exe')
|
||||
$env:SERVICEPORT_GO = 'custom Go/bin/go.exe'
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $explicit) 'Project-relative override did not take priority over PATH'
|
||||
$env:SERVICEPORT_GO = '"' + $explicit + '"'
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $explicit) 'Quoted absolute override failed'
|
||||
$env:SERVICEPORT_GO = 'missing/go.exe'
|
||||
$rejected = $false
|
||||
try { Get-ServiceportGoExecutable $project | Out-Null } catch { $rejected = $_.Exception.Message -like 'SERVICEPORT_GO does not point to a file:*' }
|
||||
Assert-GoTest $rejected 'Invalid explicit override silently fell back to PATH'
|
||||
$env:SERVICEPORT_GO = $project
|
||||
$rejected = $false
|
||||
try { Get-ServiceportGoExecutable $project | Out-Null } catch { $rejected = $_.Exception.Message -like 'SERVICEPORT_GO does not point to a file:*' }
|
||||
Assert-GoTest $rejected 'Directory accepted as an executable'
|
||||
$env:SERVICEPORT_GO = $null
|
||||
$script:commandSource = $null
|
||||
$missingMessage = ''
|
||||
try { Get-ServiceportGoExecutable $project | Out-Null } catch { $missingMessage = $_.Exception.Message }
|
||||
Assert-GoTest ($missingMessage.Contains('Go 1.25.0') -and $missingMessage.Contains('SERVICEPORT_GO') -and $missingMessage.Contains('prebuilt serviceport.exe') -and $missingMessage.Contains($project)) 'Missing Go guidance is incomplete'
|
||||
$portable = New-GoFixture (Join-Path $project 'tools/go/bin/go.exe')
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $portable) 'Project portable Go lookup failed'
|
||||
$script:commandSource = $pathGo
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $portable) 'Portable Go did not take priority over machine PATH'
|
||||
$env:SERVICEPORT_GO = 'custom Go/bin/go.exe'
|
||||
Assert-GoTest ((Get-ServiceportGoExecutable $project) -eq $explicit) 'Explicit Go did not take priority over portable Go'
|
||||
foreach ($entry in @('build.ps1', 'package.ps1', 'test-matrix.ps1')) {
|
||||
$content = [IO.File]::ReadAllText((Join-Path $PSScriptRoot $entry))
|
||||
Assert-GoTest ($content.Contains('Get-ServiceportGoExecutable -ProjectRoot') -and !$content.Contains("throw 'Go is required'")) ('Entry point does not use the common resolver: ' + $entry)
|
||||
}
|
||||
Write-Host "Go discovery tests passed: $script:assertions assertions. No toolchain was executed or installed."
|
||||
} finally {
|
||||
foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') }
|
||||
Remove-Item -LiteralPath Function:\Get-Command
|
||||
$resolved = [IO.Path]::GetFullPath($fixture)
|
||||
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
||||
if (!$resolved.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or !(Split-Path $resolved -Leaf).StartsWith('serviceport-go-test-')) { throw 'Unexpected fixture cleanup path' }
|
||||
if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
function Initialize-ServiceportNodeRuntime {
|
||||
param([Parameter(Mandatory = $true)][string]$ProjectRoot)
|
||||
|
||||
$ProjectRoot = [IO.Path]::GetFullPath($ProjectRoot)
|
||||
$nodeExecutable = $null
|
||||
$selected = $false
|
||||
if (![string]::IsNullOrWhiteSpace($env:SERVICEPORT_NODE)) {
|
||||
$nodeExecutable = [Environment]::ExpandEnvironmentVariables($env:SERVICEPORT_NODE.Trim().Trim('"'))
|
||||
if (![IO.Path]::IsPathRooted($nodeExecutable)) { $nodeExecutable = Join-Path $ProjectRoot $nodeExecutable }
|
||||
$selected = $true
|
||||
} elseif (Test-Path -LiteralPath (Join-Path $ProjectRoot 'tools/node/node.exe') -PathType Leaf) {
|
||||
$nodeExecutable = Join-Path $ProjectRoot 'tools/node/node.exe'
|
||||
$selected = $true
|
||||
} else {
|
||||
$command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($command) { $nodeExecutable = $command.Source }
|
||||
}
|
||||
if (!$nodeExecutable -or !(Test-Path -LiteralPath $nodeExecutable -PathType Leaf)) {
|
||||
throw 'Node.js was not found. Put the complete Node.js distribution in tools/node, use SERVICEPORT_NODE with a project-relative node.exe path, or install Node.js and npm on PATH.'
|
||||
}
|
||||
$nodeExecutable = [IO.Path]::GetFullPath($nodeExecutable)
|
||||
$nodeDirectory = Split-Path $nodeExecutable -Parent
|
||||
$npmExecutable = Join-Path $nodeDirectory 'npm.cmd'
|
||||
if (!(Test-Path -LiteralPath $npmExecutable -PathType Leaf)) {
|
||||
$npmCommand = if (!$selected) { Get-Command npm.cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
||||
if (!$npmCommand -or !(Test-Path -LiteralPath $npmCommand.Source -PathType Leaf)) {
|
||||
throw 'npm.cmd was not found beside Node.js. Copy the complete Node.js distribution, including npm.cmd and node_modules/npm, not only node.exe.'
|
||||
}
|
||||
$npmExecutable = $npmCommand.Source
|
||||
}
|
||||
$env:PATH = $nodeDirectory + [IO.Path]::PathSeparator + (Split-Path $npmExecutable -Parent) + [IO.Path]::PathSeparator + $env:PATH
|
||||
return $nodeExecutable
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'node-toolchain.ps1')
|
||||
$fixture = Join-Path ([IO.Path]::GetTempPath()) ('serviceport-node-test-' + [Guid]::NewGuid().ToString('N') + ' with spaces')
|
||||
$project = Join-Path $fixture 'project'
|
||||
$previousPath = $env:PATH
|
||||
$previousNode = $env:SERVICEPORT_NODE
|
||||
$script:commands = @{}
|
||||
$script:assertions = 0
|
||||
function Get-Command {
|
||||
[CmdletBinding()]
|
||||
param([string]$Name, [string]$CommandType)
|
||||
if ($CommandType -ne 'Application') { throw 'Node lookup must exclude aliases and functions' }
|
||||
if ($script:commands[$Name]) { [PSCustomObject]@{ Source = $script:commands[$Name] } }
|
||||
}
|
||||
function Assert-NodeTest([bool]$Condition, [string]$Message) {
|
||||
if (!$Condition) { throw $Message }
|
||||
$script:assertions++
|
||||
}
|
||||
function New-NodeFixture([string]$Directory, [bool]$WithNpm = $true) {
|
||||
New-Item -ItemType Directory -Path $Directory -Force | Out-Null
|
||||
$node = Join-Path $Directory 'node.exe'
|
||||
[IO.File]::WriteAllText($node, 'resolution fixture, not executable')
|
||||
if ($WithNpm) { [IO.File]::WriteAllText((Join-Path $Directory 'npm.cmd'), 'fixture') }
|
||||
return $node
|
||||
}
|
||||
try {
|
||||
$env:SERVICEPORT_NODE = $null
|
||||
$pathNode = New-NodeFixture (Join-Path $fixture 'on path')
|
||||
$script:commands['node.exe'] = $pathNode
|
||||
Assert-NodeTest ((Initialize-ServiceportNodeRuntime $project) -eq $pathNode) 'PATH Node lookup failed'
|
||||
$portable = New-NodeFixture (Join-Path $project 'tools/node')
|
||||
Assert-NodeTest ((Initialize-ServiceportNodeRuntime $project) -eq $portable) 'Portable Node did not take priority over PATH'
|
||||
Assert-NodeTest ($env:PATH.StartsWith((Split-Path $portable -Parent) + ';')) 'Child processes do not use selected Node'
|
||||
$explicit = New-NodeFixture (Join-Path $project 'custom node')
|
||||
$env:SERVICEPORT_NODE = 'custom node/node.exe'
|
||||
Assert-NodeTest ((Initialize-ServiceportNodeRuntime $project) -eq $explicit) 'Project-relative Node override failed'
|
||||
$env:SERVICEPORT_NODE = 'missing/node.exe'
|
||||
$rejected = $false
|
||||
try { Initialize-ServiceportNodeRuntime $project | Out-Null } catch { $rejected = $_.Exception.Message.Contains('Node.js was not found') }
|
||||
Assert-NodeTest $rejected 'Invalid Node override silently fell back'
|
||||
$incomplete = New-NodeFixture (Join-Path $project 'node only') $false
|
||||
$script:commands['npm.cmd'] = Join-Path (Split-Path $pathNode -Parent) 'npm.cmd'
|
||||
$env:SERVICEPORT_NODE = 'node only/node.exe'
|
||||
$rejected = $false
|
||||
try { Initialize-ServiceportNodeRuntime $project | Out-Null } catch { $rejected = $_.Exception.Message.Contains('complete Node.js distribution') }
|
||||
Assert-NodeTest $rejected 'Incomplete portable Node was mixed with unrelated npm'
|
||||
$env:SERVICEPORT_NODE = $null
|
||||
$script:commands['node.exe'] = $incomplete
|
||||
Assert-NodeTest ((Initialize-ServiceportNodeRuntime (Join-Path $fixture 'empty')) -eq $incomplete) 'Existing split PATH Node/npm stopped working'
|
||||
$script:commands.Clear()
|
||||
$rejected = $false
|
||||
try { Initialize-ServiceportNodeRuntime (Join-Path $fixture 'empty') | Out-Null } catch { $rejected = $_.Exception.Message.Contains('tools/node') }
|
||||
Assert-NodeTest $rejected 'Missing Node guidance is incomplete'
|
||||
Write-Host "Node discovery tests passed: $script:assertions assertions. No runtime was executed or installed."
|
||||
} finally {
|
||||
$env:PATH = $previousPath
|
||||
$env:SERVICEPORT_NODE = $previousNode
|
||||
Remove-Item -LiteralPath Function:\Get-Command
|
||||
$resolved = [IO.Path]::GetFullPath($fixture)
|
||||
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
|
||||
if (!$resolved.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -or !(Split-Path $resolved -Leaf).StartsWith('serviceport-node-test-')) { throw 'Unexpected fixture cleanup path' }
|
||||
if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force }
|
||||
}
|
||||
+9
-7
@@ -2,6 +2,7 @@ param([string]$Version, [switch]$RebuildWeb)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot 'build-lock.ps1')
|
||||
$buildLock = Enter-ServiceportBuildLock
|
||||
$previousPath = $env:PATH
|
||||
try {
|
||||
$totalTimer = [Diagnostics.Stopwatch]::StartNew()
|
||||
$phaseTimer = [Diagnostics.Stopwatch]::StartNew()
|
||||
@@ -13,10 +14,10 @@ function Write-PhaseTime([string]$Name) {
|
||||
$buildInfo = Get-serviceportBuildInfo -Version $Version
|
||||
$Version = $buildInfo.Version
|
||||
$repo = Split-Path $PSScriptRoot -Parent
|
||||
$goCommand = Get-Command go -ErrorAction SilentlyContinue
|
||||
$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' }
|
||||
if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' }
|
||||
if (!(Get-Command npm.cmd -ErrorAction SilentlyContinue) -or !(Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js and npm are required' }
|
||||
. (Join-Path $PSScriptRoot 'go-toolchain.ps1')
|
||||
$goExecutable = Get-ServiceportGoExecutable -ProjectRoot $repo
|
||||
. (Join-Path $PSScriptRoot 'node-toolchain.ps1')
|
||||
$nodeExecutable = Initialize-ServiceportNodeRuntime -ProjectRoot $repo
|
||||
$stamp = (Get-Date -Format 'yyyyMMdd-HHmmss-fff') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$releaseRoot = Join-Path $repo "bin/releases/$stamp"
|
||||
$stage = [IO.Path]::GetFullPath((Join-Path $repo "bin/build-cache/package-$stamp"))
|
||||
@@ -28,7 +29,7 @@ $keepPath = Join-Path $repo 'web/dist/.gitkeep'
|
||||
$hadKeep = Test-Path -LiteralPath $keepPath
|
||||
$keepBytes = [byte[]]@()
|
||||
if ($hadKeep) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) }
|
||||
Push-Location $repo
|
||||
Push-Location -LiteralPath $repo
|
||||
try {
|
||||
$env:GOTOOLCHAIN = 'go1.25.0'
|
||||
$env:GO111MODULE = 'on'
|
||||
@@ -38,14 +39,14 @@ try {
|
||||
$env:CGO_ENABLED = '0'
|
||||
$webArguments = @((Join-Path $PSScriptRoot 'build-web.mjs'))
|
||||
if ($RebuildWeb) { $webArguments += '--force' }
|
||||
node @webArguments
|
||||
& $nodeExecutable @webArguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
|
||||
Write-PhaseTime 'Frontend and dependencies'
|
||||
New-Item -ItemType Directory -Path $stage | Out-Null
|
||||
& $goExecutable build -trimpath -ldflags $buildInfo.LDFlags -o $binary ./cmd/serviceport
|
||||
if ($LASTEXITCODE -ne 0) { throw 'serviceport release build failed' }
|
||||
Write-PhaseTime 'Go build'
|
||||
node (Join-Path $PSScriptRoot 'verify-package.mjs') $stage
|
||||
& $nodeExecutable (Join-Path $PSScriptRoot 'verify-package.mjs') $stage
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Package verification failed' }
|
||||
Write-PhaseTime 'Executable verification'
|
||||
# Publish only after verification. The generated paths must stay in this repository.
|
||||
@@ -73,5 +74,6 @@ try {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
$env:PATH = $previousPath
|
||||
$buildLock.Dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const resolveProjectPath = path => resolve(projectRoot, path);
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { projectRoot, resolveProjectPath } from './project-paths.mjs';
|
||||
|
||||
test('relative paths use the relocated script root instead of the caller directory', async () => {
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'serviceport-path-test-'));
|
||||
try {
|
||||
const relocated = join(fixture, 'moved project [spaces]');
|
||||
mkdirSync(join(relocated, 'scripts'), { recursive: true });
|
||||
const module = join(relocated, 'scripts', 'project-paths.mjs');
|
||||
copyFileSync(resolveProjectPath('scripts/project-paths.mjs'), module);
|
||||
const { resolveProjectPath: resolveMoved } = await import(pathToFileURL(module));
|
||||
assert.equal(resolveMoved('bin/serviceport.exe'), join(relocated, 'bin', 'serviceport.exe'));
|
||||
assert.equal(resolveMoved(join(fixture, 'external.exe')), join(fixture, 'external.exe'));
|
||||
const output = execFileSync(process.execPath, ['--input-type=module', '-e',
|
||||
`import { resolveProjectPath } from ${JSON.stringify(pathToFileURL(module).href)}; console.log(resolveProjectPath('bin/serviceport.exe'));`],
|
||||
{ cwd: tmpdir(), encoding: 'utf8', windowsHide: true });
|
||||
assert.equal(output.trim(), join(relocated, 'bin', 'serviceport.exe'));
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(fixture)), resolve(tmpdir()));
|
||||
assert(basename(fixture).startsWith('serviceport-path-test-'));
|
||||
rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('verification and browser launcher resolve binary arguments from the project', () => {
|
||||
assert.equal(resolveProjectPath(''), projectRoot);
|
||||
for (const name of ['verify-package.mjs', 'verify-desktop.mjs', 'verify-random-port.mjs']) {
|
||||
assert.match(readFileSync(resolveProjectPath(`scripts/${name}`), 'utf8'), /resolveProjectPath\(process\.argv\[2\]\)/);
|
||||
}
|
||||
assert.match(readFileSync(resolveProjectPath('web/tests/start-server.mjs'), 'utf8'), /resolveProjectPath\(process\.env\.SERVICEPORT_E2E_BINARY/);
|
||||
});
|
||||
|
||||
test('maintained scripts contain no fixed Windows installation or developer paths', () => {
|
||||
const scripts = readdirSync(resolveProjectPath('scripts')).filter(name => /\.(ps1|mjs|sh)$/.test(name) && !name.includes('.test.'));
|
||||
const paths = [...scripts.map(name => `scripts/${name}`), 'start-dev.cmd', 'package.cmd', 'web/tests/start-server.mjs'];
|
||||
for (const path of paths) {
|
||||
const source = readFileSync(resolveProjectPath(path), 'utf8');
|
||||
assert.doesNotMatch(source, /\b[A-Za-z]:[\\/]/, `Fixed drive path in ${path}`);
|
||||
assert.doesNotMatch(source, /\$env:(?:ProgramFiles|ProgramW6432|LOCALAPPDATA|USERPROFILE)/i, `Machine installation scan in ${path}`);
|
||||
}
|
||||
});
|
||||
@@ -5,15 +5,14 @@ $compose = Join-Path $repo 'deploy/compose.test.yml'
|
||||
$project = 'serviceport-matrix-' + [Guid]::NewGuid().ToString('N').Substring(0, 10)
|
||||
$tempDir = Join-Path ([IO.Path]::GetTempPath()) $project
|
||||
$caFile = Join-Path $tempDir 'ca.pem'
|
||||
$goCommand = Get-Command go -ErrorAction SilentlyContinue
|
||||
$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' }
|
||||
if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' }
|
||||
. (Join-Path $PSScriptRoot 'go-toolchain.ps1')
|
||||
$goExecutable = Get-ServiceportGoExecutable -ProjectRoot $repo
|
||||
Get-Command docker -ErrorAction Stop | Out-Null
|
||||
$names = @('GOTOOLCHAIN', 'SERVICEPORT_MYSQL_TEST_ADDR', 'SERVICEPORT_MYSQL_TEST_USER', 'SERVICEPORT_MYSQL_TEST_PASSWORD', 'SERVICEPORT_MYSQL_TEST_CA', 'SERVICEPORT_MONGO_TEST_ADDR', 'SERVICEPORT_MONGO_TEST_USER', 'SERVICEPORT_MONGO_TEST_PASSWORD', 'SERVICEPORT_MONGO_TEST_CA', 'SERVICEPORT_SSH_TEST_ADDR', 'SERVICEPORT_SSH_TEST_USER', 'SERVICEPORT_SSH_TEST_PASSWORD', 'SERVICEPORT_TEST_SOCKS_ADDR', 'SERVICEPORT_TEST_SOCKS_USER', 'SERVICEPORT_TEST_SOCKS_PASSWORD')
|
||||
$previous = @{}
|
||||
foreach ($name in $names) { $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') }
|
||||
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
||||
Push-Location $repo
|
||||
Push-Location -LiteralPath $repo
|
||||
try {
|
||||
$env:GOTOOLCHAIN = 'go1.25.0'
|
||||
docker compose -f $compose -p $project up -d --build --wait --wait-timeout 300
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH"
|
||||
command -v docker >/dev/null
|
||||
command -v go >/dev/null
|
||||
export GOTOOLCHAIN=go1.25.0
|
||||
|
||||
@@ -5,13 +5,14 @@ import { randomBytes, X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { resolveProjectPath } from './project-paths.mjs'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
const require = createRequire(new URL('../web/package.json', import.meta.url))
|
||||
const { chromium } = require('@playwright/test')
|
||||
assert(process.argv[2], 'Provide the native serviceport executable')
|
||||
const binary = resolve(process.argv[2])
|
||||
const binary = resolveProjectPath(process.argv[2])
|
||||
const temp = mkdtempSync(join(tmpdir(), 'serviceport-desktop-'))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('SERVICEPORT_') && k.toUpperCase() !== 'APPDATA'))
|
||||
Object.assign(env, { APPDATA: join(temp, 'unused-appdata'), SERVICEPORT_DATA: join(temp, 'data', 'serviceport.sqlite'), SERVICEPORT_NO_BROWSER: '1' })
|
||||
|
||||
@@ -6,9 +6,10 @@ import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve, join, dirname, basename, sep } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { resolveProjectPath } from './project-paths.mjs'
|
||||
|
||||
assert(process.argv[2], 'Provide the single-binary release directory or executable')
|
||||
const input = resolve(process.argv[2])
|
||||
const input = resolveProjectPath(process.argv[2])
|
||||
const directory = statSync(input).isDirectory() ? input : dirname(input)
|
||||
const filename = process.platform === 'win32' ? 'serviceport.exe' : 'serviceport'
|
||||
const binary = join(directory, filename)
|
||||
|
||||
@@ -6,9 +6,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join, resolve, sep } from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { resolveProjectPath } from './project-paths.mjs';
|
||||
|
||||
assert(process.argv[2], 'Provide the freshly built serviceport executable');
|
||||
const binary = resolve(process.argv[2]);
|
||||
const binary = resolveProjectPath(process.argv[2]);
|
||||
const temporaryRoot = resolve(tmpdir());
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'serviceport-random-port-'));
|
||||
const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('SERVICEPORT_')));
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { resolveProjectPath } from '../../scripts/project-paths.mjs';
|
||||
const directory = mkdtempSync(join(tmpdir(), "serviceport-e2e-"));
|
||||
const authenticated = process.argv.includes('--auth');
|
||||
const binary =
|
||||
process.env.SERVICEPORT_E2E_BINARY ||
|
||||
resolve(
|
||||
"..",
|
||||
"bin",
|
||||
process.platform === "win32" ? "serviceport.exe" : "serviceport",
|
||||
);
|
||||
const binary = resolveProjectPath(process.env.SERVICEPORT_E2E_BINARY ||
|
||||
join('bin', process.platform === 'win32' ? 'serviceport.exe' : 'serviceport'));
|
||||
const child = spawn(binary, [], {
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
Reference in New Issue
Block a user