mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(python-runtime): package the Windows x64 dsh executable
Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build. Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted. The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure.
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import { isAbsolute, join, parse, relative, sep } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-output-retention'
|
||||
@@ -170,7 +170,10 @@ let rgPathPromise: Promise<string> | undefined
|
||||
*/
|
||||
export function resolveRgPath(): Promise<string> {
|
||||
rgPathPromise ??= Promise.resolve().then(async () => {
|
||||
const executableSidecar = `${process.execPath}-rg`
|
||||
const executable = parse(process.execPath)
|
||||
const executableSidecar = process.platform === 'win32'
|
||||
? join(executable.dir, `${executable.name}-rg.exe`)
|
||||
: `${process.execPath}-rg`
|
||||
if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar
|
||||
return (await import('@vscode/ripgrep')).rgPath
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { join, parse } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { dependencyRgPath, existsSync } = vi.hoisted(() => ({
|
||||
dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg',
|
||||
existsSync: vi.fn(),
|
||||
}))
|
||||
const originalPlatform = process.platform
|
||||
const originalExecPath = process.execPath
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
@@ -16,17 +19,35 @@ beforeEach(() => {
|
||||
vi.resetModules()
|
||||
existsSync.mockReset()
|
||||
Reflect.deleteProperty(process, 'pkg')
|
||||
Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform })
|
||||
process.execPath = originalExecPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(process, 'pkg')
|
||||
Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform })
|
||||
process.execPath = originalExecPath
|
||||
})
|
||||
|
||||
describe('ripgrep resolution', () => {
|
||||
it('uses the native sidecar beside the current executable', async () => {
|
||||
Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'linux' })
|
||||
process.execPath = '/runtime/dsh'
|
||||
existsSync.mockReturnValue(true)
|
||||
const sidecar = `${process.execPath}-rg`
|
||||
const sidecar = '/runtime/dsh-rg'
|
||||
const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
|
||||
|
||||
await expect(resolveRgPath()).resolves.toBe(sidecar)
|
||||
expect(existsSync).toHaveBeenCalledWith(sidecar)
|
||||
})
|
||||
|
||||
it('uses a conventional executable name for the Windows ripgrep sidecar', async () => {
|
||||
Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'win32' })
|
||||
process.execPath = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64.exe'
|
||||
existsSync.mockReturnValue(true)
|
||||
const sidecar = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64-rg.exe'
|
||||
const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
|
||||
|
||||
await expect(resolveRgPath()).resolves.toBe(sidecar)
|
||||
@@ -47,6 +68,10 @@ describe('ripgrep resolution', () => {
|
||||
const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search')
|
||||
|
||||
await expect(resolveRgPath()).resolves.toBe(dependencyRgPath)
|
||||
expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`)
|
||||
const executable = parse(process.execPath)
|
||||
const sidecar = process.platform === 'win32'
|
||||
? join(executable.dir, `${executable.name}-rg.exe`)
|
||||
: `${process.execPath}-rg`
|
||||
expect(existsSync).toHaveBeenCalledWith(sidecar)
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+6
@@ -9972,6 +9972,12 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-jobs':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/jobs/tool-jobs
|
||||
'@deepseek-ai/dsh-tool-pwsh':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shell/tool-pwsh
|
||||
'@deepseek-ai/dsh-tool-pwsh-persistent':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shell/tool-pwsh-persistent
|
||||
'@deepseek-ai/dsh-tool-ralph':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/workflow/tool-ralph
|
||||
|
||||
@@ -39,7 +39,15 @@ def _host_platform_tag() -> str:
|
||||
machine = platform.machine().lower()
|
||||
arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine
|
||||
system = platform.system().lower()
|
||||
key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system
|
||||
key = (
|
||||
f"macos-{arch}"
|
||||
if system == "darwin"
|
||||
else f"linux-{arch}"
|
||||
if system == "linux"
|
||||
else f"win-{arch}"
|
||||
if system == "windows"
|
||||
else system
|
||||
)
|
||||
try:
|
||||
return _PLATFORMS[key][0]
|
||||
except KeyError as exc:
|
||||
@@ -69,16 +77,21 @@ class RuntimeBuildHook(BuildHookInterface):
|
||||
runtime_files = sorted(
|
||||
runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else []
|
||||
)
|
||||
expected_files = [expected_executable, f"{expected_executable}-rg"]
|
||||
expected_files = (
|
||||
[expected_executable, f"{expected_executable.removesuffix('.exe')}-rg.exe"]
|
||||
if expected_executable.endswith(".exe")
|
||||
else [expected_executable, f"{expected_executable}-rg"]
|
||||
)
|
||||
if "-macos-" in expected_executable:
|
||||
expected_files.append(f"{expected_executable}-spawn-helper")
|
||||
expected_files.sort()
|
||||
found_files = [path.name for path in runtime_files]
|
||||
if found_files != expected_files:
|
||||
raise RuntimeError(
|
||||
f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}"
|
||||
)
|
||||
for executable in runtime_files:
|
||||
if executable.stat().st_mode & stat.S_IXUSR == 0:
|
||||
if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"runtime executable is not executable: {executable}")
|
||||
build_data["pure_python"] = False
|
||||
build_data["infer_tag"] = False
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-persona": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^",
|
||||
@@ -103,6 +104,7 @@
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
|
||||
@@ -10,5 +10,9 @@
|
||||
"macos-arm64": {
|
||||
"tag": "macosx_14_0_arm64",
|
||||
"executable": "deepseek-harness-sdk-runtime-macos-arm64"
|
||||
},
|
||||
"win-x64": {
|
||||
"tag": "win_amd64",
|
||||
"executable": "deepseek-harness-sdk-runtime-win-x64.exe"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's
|
||||
``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git):
|
||||
|
||||
- **exe (production)**: single-file Node executables named
|
||||
``deepseek-harness-sdk-runtime-<platform>-<arch>`` (platform in {linux, macos}, arch in
|
||||
{x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling
|
||||
``-spawn-helper``. The target machine needs no Node installation.
|
||||
``deepseek-harness-sdk-runtime-<platform>-<arch>`` for Linux/macOS and an
|
||||
``.exe`` counterpart for Windows. Each has a sibling ripgrep executable;
|
||||
macOS also uses a sibling ``-spawn-helper``. The target machine needs no
|
||||
Node installation.
|
||||
- **node (dev-only)**: the full deploy closure under ``runtime/node/``
|
||||
(``package.json`` + ``node_modules/``), executed as ``node
|
||||
runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a
|
||||
@@ -30,7 +31,7 @@ PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json"
|
||||
|
||||
RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE"
|
||||
|
||||
_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"}
|
||||
_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos", "win32": "win"}
|
||||
_ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"}
|
||||
|
||||
_EXE_ACQUISITION_HINT = (
|
||||
@@ -62,13 +63,18 @@ def bundled_runtime_path() -> Path:
|
||||
touching callers).
|
||||
"""
|
||||
tag = _current_platform_tag()
|
||||
path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}"
|
||||
extension = ".exe" if tag.startswith("win-") else ""
|
||||
path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}{extension}"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. "
|
||||
+ _EXE_ACQUISITION_HINT
|
||||
)
|
||||
ripgrep = Path(f"{path}-rg")
|
||||
ripgrep = (
|
||||
path.with_name(f"{path.stem}-rg.exe")
|
||||
if tag.startswith("win-")
|
||||
else Path(f"{path}-rg")
|
||||
)
|
||||
if not ripgrep.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. "
|
||||
@@ -110,11 +116,11 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]:
|
||||
def _current_platform_tag() -> str:
|
||||
plat = _PLATFORM_TAGS.get(sys.platform)
|
||||
arch = _ARCH_TAGS.get(platform.machine().lower())
|
||||
if plat is None or arch is None:
|
||||
if plat is None or arch is None or (plat == "win" and arch != "x64"):
|
||||
raise FileNotFoundError(
|
||||
"no bundled DeepSeek Harness SDK runtime exists for this platform "
|
||||
f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: "
|
||||
"linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT
|
||||
"Linux x64/arm64, macOS arm64, and Windows x64. " + _EXE_ACQUISITION_HINT
|
||||
)
|
||||
return f"{plat}-{arch}"
|
||||
|
||||
|
||||
@@ -93,11 +93,14 @@ class HarnessClient:
|
||||
self._start_stderr_thread()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the runtime after a bounded opportunity to flush durable state."""
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return
|
||||
shutdown_completed = False
|
||||
try:
|
||||
self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
|
||||
shutdown_completed = True
|
||||
except Exception as exc:
|
||||
self._stderr_lines.append(f"shutdown request failed: {exc}")
|
||||
if proc.stdin:
|
||||
@@ -105,11 +108,17 @@ class HarnessClient:
|
||||
proc.stdin.close()
|
||||
except Exception as exc:
|
||||
self._stderr_lines.append(f"stdin close failed: {exc}")
|
||||
if shutdown_completed:
|
||||
try:
|
||||
proc.wait(timeout=self.config.shutdown_timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.wait(timeout=self.config.shutdown_timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -783,6 +783,43 @@ for line in sys.stdin:
|
||||
assert client._proc is None
|
||||
|
||||
|
||||
def test_client_close_allows_eof_quiescence_after_shutdown_response(tmp_path: Path) -> None:
|
||||
script = tmp_path / "fake_runtime.py"
|
||||
marker = tmp_path / "quiesced.txt"
|
||||
script.write_text(
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
|
||||
elif msg.get("method") == "shutdown":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
|
||||
|
||||
time.sleep(0.05)
|
||||
Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced")
|
||||
""".strip()
|
||||
)
|
||||
|
||||
client = HarnessClient(
|
||||
HarnessConfig(
|
||||
_launch_args=(sys.executable, str(script)),
|
||||
env={"QUIESCED_MARKER": str(marker)},
|
||||
shutdown_timeout_seconds=1,
|
||||
)
|
||||
)
|
||||
client.start()
|
||||
client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent")
|
||||
client.close()
|
||||
|
||||
assert marker.read_text() == "quiesced"
|
||||
|
||||
|
||||
def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None:
|
||||
script = tmp_path / "rejecting_runtime.py"
|
||||
script.write_text(
|
||||
|
||||
@@ -62,6 +62,14 @@ def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None:
|
||||
assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64"
|
||||
|
||||
|
||||
def test_windows_wheel_tag_and_payload_are_x64_only() -> None:
|
||||
assert build_python_release.PLATFORMS["win-x64"] == (
|
||||
"win_amd64",
|
||||
"deepseek-harness-sdk-runtime-win-x64.exe",
|
||||
)
|
||||
assert not any(name.startswith("win-") and name != "win-x64" for name in build_python_release.PLATFORMS)
|
||||
|
||||
|
||||
def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None:
|
||||
manifest = tmp_path / "platforms.json"
|
||||
manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n')
|
||||
@@ -85,7 +93,10 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path:
|
||||
assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)])
|
||||
@pytest.mark.parametrize(
|
||||
("target", "with_helper"),
|
||||
[("linux-x64", False), ("macos-arm64", True), ("win-x64.exe", False)],
|
||||
)
|
||||
def test_stage_runtime_copies_platform_payload(
|
||||
tmp_path: Path, target: str, with_helper: bool
|
||||
) -> None:
|
||||
@@ -93,7 +104,11 @@ def test_stage_runtime_copies_platform_payload(
|
||||
executable.write_bytes(b"runtime")
|
||||
executable.chmod(0o755)
|
||||
expected = {executable.name: b"runtime"}
|
||||
ripgrep = Path(f"{executable}-rg")
|
||||
ripgrep = (
|
||||
executable.with_name(f"{executable.stem}-rg.exe")
|
||||
if executable.suffix == ".exe"
|
||||
else Path(f"{executable}-rg")
|
||||
)
|
||||
ripgrep.write_bytes(b"ripgrep")
|
||||
ripgrep.chmod(0o755)
|
||||
expected[ripgrep.name] = b"ripgrep"
|
||||
|
||||
@@ -55,6 +55,30 @@ def test_runtime_requires_spawn_helper_only_on_macos(
|
||||
assert runtime.bundled_runtime_path() == linux
|
||||
|
||||
|
||||
def test_windows_runtime_uses_exe_payload_and_exe_sidecar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
runtime_dir = tmp_path / "runtime"
|
||||
runtime_dir.mkdir()
|
||||
executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe"
|
||||
executable.touch()
|
||||
(runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch()
|
||||
monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64")
|
||||
|
||||
assert runtime.bundled_runtime_path() == executable
|
||||
|
||||
|
||||
def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(runtime.sys, "platform", "win32")
|
||||
monkeypatch.setattr(runtime.platform, "machine", lambda: "AMD64")
|
||||
assert runtime._current_platform_tag() == "win-x64"
|
||||
|
||||
monkeypatch.setattr(runtime.platform, "machine", lambda: "ARM64")
|
||||
with pytest.raises(FileNotFoundError, match="Windows x64"):
|
||||
runtime._current_platform_tag()
|
||||
|
||||
|
||||
def test_runtime_requires_ripgrep_sidecar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -35,6 +35,24 @@ describe('resolveLinuxNodePtyAddon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWindowsNodePtyAddons', () => {
|
||||
it('requires both ConPTY addons from the x64 prebuild', () => {
|
||||
const root = temporaryPackage()
|
||||
const conpty = createAddon(root, 'prebuilds', 'win32-x64', 'conpty.node')
|
||||
const consoleList = createAddon(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')
|
||||
|
||||
expect(resolveWindowsNodePtyAddons(root, 'x64')).toEqual([conpty, consoleList])
|
||||
})
|
||||
|
||||
it('names every missing Windows addon', () => {
|
||||
const root = temporaryPackage()
|
||||
|
||||
expect(() => resolveWindowsNodePtyAddons(root, 'x64')).toThrow(
|
||||
`Windows node-pty addons are missing: ${join(root, 'prebuilds', 'win32-x64', 'conpty.node')}, ${join(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function temporaryPackage(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-'))
|
||||
roots.push(root)
|
||||
|
||||
@@ -21,3 +21,25 @@ export function resolveLinuxNodePtyAddon(
|
||||
`build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Require both node-pty addons used by the Windows ConPTY backend.
|
||||
* @param packageDirectory - staged node-pty package directory.
|
||||
* @param arch - Windows target architecture.
|
||||
* @returns the existing addon paths in load order.
|
||||
*/
|
||||
export function resolveWindowsNodePtyAddons(
|
||||
packageDirectory: string,
|
||||
arch: 'x64',
|
||||
): string[] {
|
||||
const directory = join(packageDirectory, 'prebuilds', `win32-${arch}`)
|
||||
const addons = [
|
||||
join(directory, 'conpty.node'),
|
||||
join(directory, 'conpty_console_list.node'),
|
||||
]
|
||||
const missing = addons.filter(path => !existsSync(path))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`)
|
||||
}
|
||||
return addons
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts')
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function run(env: NodeJS.ProcessEnv, ...args: string[]) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx/esm', script, ...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: isolatedPnpmEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
describe('Python runtime executable builder CLI', () => {
|
||||
it('runs pnpm through its JavaScript entrypoint without a command shell', () => {
|
||||
const result = run(
|
||||
{ npm_execpath: 'C:\\tools\\pnpm.cjs' },
|
||||
'--skip-build',
|
||||
'--dry-run',
|
||||
'--targets=node24-macos-arm64',
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs dlx @yao-pkg/pkg@6.21.0`)
|
||||
expect(result.stdout).not.toMatch(/pnpm\.cmd/i)
|
||||
})
|
||||
|
||||
it('resolves the pnpm package behind a Windows command shim', () => {
|
||||
const setup = mkdtempSync(join(tmpdir(), 'dsh-pnpm-home-'))
|
||||
temporaryDirectories.push(setup)
|
||||
const home = join(setup, 'node_modules', '.bin')
|
||||
const entrypoint = join(setup, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')
|
||||
mkdirSync(home, { recursive: true })
|
||||
mkdirSync(dirname(entrypoint), { recursive: true })
|
||||
writeFileSync(entrypoint, '')
|
||||
|
||||
const result = run(
|
||||
{ npm_execpath: 'C:\\tools\\pnpm.cmd', PNPM_HOME: home },
|
||||
'--skip-build',
|
||||
'--dry-run',
|
||||
'--targets=node24-macos-arm64',
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain(`${process.execPath} ${entrypoint} run verify-runtime-closure`)
|
||||
expect(result.stdout).not.toMatch(/pnpm\.cmd/i)
|
||||
})
|
||||
|
||||
it('rejects a Windows arm64 product before any build step', () => {
|
||||
const result = run(
|
||||
{ npm_execpath: 'C:\\tools\\pnpm.cjs' },
|
||||
'--skip-build',
|
||||
'--dry-run',
|
||||
'--targets=node24-win-arm64',
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('Windows supports x64 only')
|
||||
expect(result.stdout).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
function isolatedPnpmEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !['npm_execpath', 'pnpm_home'].includes(key.toLowerCase())),
|
||||
)
|
||||
return { ...environment, ...overrides }
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -63,7 +63,7 @@ const ASSET_GLOBS = [
|
||||
'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*',
|
||||
]
|
||||
|
||||
const PLATFORMS = ['linux', 'macos'] as const
|
||||
const PLATFORMS = ['linux', 'macos', 'win'] as const
|
||||
const ARCHES = ['x64', 'arm64'] as const
|
||||
type Platform = (typeof PLATFORMS)[number]
|
||||
type Arch = (typeof ARCHES)[number]
|
||||
@@ -83,10 +83,7 @@ class Target {
|
||||
private constructor(
|
||||
/** pkg Node range (`node<major>`). */
|
||||
readonly nodeRange: string,
|
||||
/**
|
||||
* pkg platform tag. Windows is a documented non-goal
|
||||
* (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*/
|
||||
/** pkg platform tag. */
|
||||
readonly platform: Platform,
|
||||
/** pkg CPU tag. */
|
||||
readonly arch: Arch,
|
||||
@@ -117,6 +114,9 @@ class Target {
|
||||
if (!isArch(arch)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
|
||||
}
|
||||
if (platform === 'win' && arch !== 'x64') {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: Windows supports x64 only.`)
|
||||
}
|
||||
return new Target(nodeRange, platform, arch)
|
||||
}
|
||||
|
||||
@@ -125,7 +125,13 @@ class Target {
|
||||
* @returns the host target; throws on an unsupported host platform or arch.
|
||||
*/
|
||||
static host(): Target {
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
|
||||
const platform = process.platform === 'darwin'
|
||||
? 'macos'
|
||||
: process.platform === 'linux'
|
||||
? 'linux'
|
||||
: process.platform === 'win32'
|
||||
? 'win'
|
||||
: undefined
|
||||
if (platform === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
|
||||
}
|
||||
@@ -133,6 +139,9 @@ class Target {
|
||||
if (arch === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
|
||||
}
|
||||
if (platform === 'win' && arch !== 'x64') {
|
||||
throw new Error('build-exe-for-python-sdk: Windows supports x64 only; use an x64 Node process.')
|
||||
}
|
||||
return new Target(DEFAULT_NODE_RANGE, platform, arch)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +209,7 @@ class BuildCli {
|
||||
return [
|
||||
'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
|
||||
'',
|
||||
' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
|
||||
' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64.',
|
||||
' Default: the host platform only (on node24).',
|
||||
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
@@ -212,8 +221,27 @@ class BuildCli {
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
function pnpmInvocation(args: string[]): [command: string, args: string[]] {
|
||||
const entrypoint = process.env.npm_execpath?.trim()
|
||||
if (entrypoint !== undefined && entrypoint !== '') {
|
||||
const extension = extname(entrypoint).toLowerCase()
|
||||
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
||||
return [process.execPath, [entrypoint, ...args]]
|
||||
}
|
||||
if (extension !== '.cmd') return [entrypoint, args]
|
||||
}
|
||||
const home = process.env.PNPM_HOME?.trim()
|
||||
if (home !== undefined && home !== '') {
|
||||
const packageBin = resolve(home, '..', 'pnpm', 'bin')
|
||||
for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
|
||||
const candidate = resolve(packageBin, filename)
|
||||
if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
|
||||
}
|
||||
return ['pnpm', args]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,7 +269,7 @@ class SingleExeBuild {
|
||||
|
||||
/** Verify the closure before compiling or packaging. */
|
||||
async verifyClosure(): Promise<void> {
|
||||
await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
|
||||
await this.runPnpm('runtime dependency closure', ['run', 'verify-runtime-closure'])
|
||||
}
|
||||
|
||||
/** Build all package artifacts unless `--skip-build` was passed. */
|
||||
@@ -250,7 +278,7 @@ class SingleExeBuild {
|
||||
console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
|
||||
return
|
||||
}
|
||||
await this.run('build', pnpmBin(), ['run', 'build'])
|
||||
await this.runPnpm('build', ['run', 'build'])
|
||||
}
|
||||
|
||||
/** Clear and deploy the runtime closure into the node carrier. */
|
||||
@@ -260,7 +288,7 @@ class SingleExeBuild {
|
||||
}
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
|
||||
else await rm(this.staging, { recursive: true, force: true })
|
||||
await this.run('deploy', pnpmBin(), [
|
||||
await this.runPnpm('deploy', [
|
||||
'--filter',
|
||||
DEPLOY_ROOT_PACKAGE,
|
||||
'deploy',
|
||||
@@ -393,10 +421,11 @@ class SingleExeBuild {
|
||||
* @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required.
|
||||
*/
|
||||
async pack(target: Target): Promise<string[]> {
|
||||
const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
const product = target.platform === 'win' ? `${productBase}.exe` : productBase
|
||||
await this.prepareNativePty(target)
|
||||
if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true })
|
||||
await this.run(`pkg ${target.spec}`, pnpmBin(), [
|
||||
await this.runPnpm(`pkg ${target.spec}`, [
|
||||
'dlx',
|
||||
PKG_SPEC,
|
||||
this.staging,
|
||||
@@ -424,16 +453,19 @@ class SingleExeBuild {
|
||||
|
||||
/** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
|
||||
private async copyRipgrepSidecar(target: Target, product: string): Promise<string> {
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
const executable = target.platform === 'win' ? 'rg.exe' : 'rg'
|
||||
const source = join(
|
||||
this.staging,
|
||||
'node_modules',
|
||||
'@vscode',
|
||||
`ripgrep-${platform}-${target.arch}`,
|
||||
'bin',
|
||||
'rg',
|
||||
executable,
|
||||
)
|
||||
const destination = `${product}-rg`
|
||||
const destination = target.platform === 'win'
|
||||
? `${product.slice(0, -'.exe'.length)}-rg.exe`
|
||||
: `${product}-rg`
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
|
||||
return destination
|
||||
@@ -455,7 +487,6 @@ class SingleExeBuild {
|
||||
const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build')
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
|
||||
else await rm(stagedBuild, { recursive: true, force: true })
|
||||
if (target.platform !== 'linux') return
|
||||
const packageDirectory = join(
|
||||
root,
|
||||
'packages',
|
||||
@@ -464,6 +495,21 @@ class SingleExeBuild {
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
)
|
||||
if (target.platform === 'win') {
|
||||
if (target.arch !== 'x64') {
|
||||
throw new Error('build-exe-for-python-sdk: Windows supports x64 only.')
|
||||
}
|
||||
const host = Target.host()
|
||||
if (target.platform !== host.platform || target.arch !== host.arch) {
|
||||
throw new Error(
|
||||
'build-exe-for-python-sdk: build the Windows runtime under x64 Node on its target host; '
|
||||
+ `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`,
|
||||
)
|
||||
}
|
||||
resolveWindowsNodePtyAddons(join(this.staging, 'node_modules', 'node-pty'), target.arch)
|
||||
return
|
||||
}
|
||||
if (target.platform !== 'linux') return
|
||||
const destination = join(stagedBuild, 'Release', 'pty.node')
|
||||
const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch)
|
||||
if (this.cli.dryRun) {
|
||||
@@ -553,6 +599,12 @@ class SingleExeBuild {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Run pnpm through its JavaScript entrypoint when the caller supplies one. */
|
||||
private async runPnpm(label: string, args: string[]): Promise<void> {
|
||||
const [command, invocationArgs] = pnpmInvocation(args)
|
||||
await this.run(label, command, invocationArgs)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -47,9 +47,12 @@ def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]]
|
||||
PLATFORMS = load_platforms()
|
||||
|
||||
|
||||
def runtime_suffixes(executable_name: str) -> tuple[str, ...]:
|
||||
suffixes = ("", "-rg")
|
||||
return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes
|
||||
def runtime_filenames(executable_name: str) -> tuple[str, ...]:
|
||||
"""Return the exact platform payload names for one runtime executable."""
|
||||
if executable_name.endswith(".exe"):
|
||||
return (executable_name, f"{executable_name.removesuffix('.exe')}-rg.exe")
|
||||
names = (executable_name, f"{executable_name}-rg")
|
||||
return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -210,8 +213,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
|
||||
rewrite_version(destination / "pyproject.toml", version)
|
||||
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
for suffix in runtime_suffixes(executable_name):
|
||||
shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}")
|
||||
source_directory = executable.parent
|
||||
for filename in runtime_filenames(executable_name):
|
||||
shutil.copy2(source_directory / filename, runtime_dir / filename)
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
@@ -250,13 +254,13 @@ def verify_wheel(
|
||||
]
|
||||
if package == "runtime":
|
||||
assert platform is not None
|
||||
expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])]
|
||||
expected_files = sorted(runtime_filenames(platform[1]))
|
||||
found_files = sorted(Path(name).name for name in runtime_files)
|
||||
if found_files != expected_files:
|
||||
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
|
||||
for runtime_file in runtime_files:
|
||||
mode = archive.getinfo(runtime_file).external_attr >> 16
|
||||
if mode & stat.S_IXUSR == 0:
|
||||
if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}")
|
||||
elif runtime_files:
|
||||
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
|
||||
|
||||
@@ -21,6 +21,7 @@ const platforms = {
|
||||
'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' },
|
||||
'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' },
|
||||
'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' },
|
||||
'win-x64': { tag: 'win_amd64', executable: 'runtime-win-x64.exe' },
|
||||
}
|
||||
|
||||
function workspace(root: string, name: string, manifest: Record<string, unknown>): void {
|
||||
@@ -35,7 +36,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('verifyRuntimeClosure', () => {
|
||||
it('requires only plugins active for a Linux or macOS target', async () => {
|
||||
it('requires only plugins active for each published target', async () => {
|
||||
const root = fixture({
|
||||
'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } },
|
||||
'python/sdk-runtime/platforms.json': platforms,
|
||||
@@ -52,6 +53,9 @@ describe('verifyRuntimeClosure', () => {
|
||||
- id: macos
|
||||
name: '@scope/macos'
|
||||
disabled: !!js process.platform !== 'darwin'
|
||||
- id: windows
|
||||
name: '@scope/windows'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -61,6 +65,7 @@ describe('verifyRuntimeClosure', () => {
|
||||
expect(result.failures).toEqual([
|
||||
'standard preset -> @scope/linux (linux-arm64, linux-x64)',
|
||||
'standard preset -> @scope/macos (macos-arm64)',
|
||||
'standard preset -> @scope/windows (win-x64)',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -78,7 +83,7 @@ describe('verifyRuntimeClosure', () => {
|
||||
const result = await verifyRuntimeClosure(root)
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)',
|
||||
'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, win-x64)',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -112,7 +117,7 @@ describe('verifyRuntimeClosure', () => {
|
||||
const result = await verifyRuntimeClosure(root)
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)',
|
||||
'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, win-x64)',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ function disabledOnPlatform(value: unknown, processPlatform: string): boolean {
|
||||
function processPlatformForTarget(target: string): string {
|
||||
if (target.startsWith('linux-')) return 'linux'
|
||||
if (target.startsWith('macos-')) return 'darwin'
|
||||
if (target.startsWith('win-')) return 'win32'
|
||||
throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user