Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4. Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork. Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files. Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
1332 lines
36 KiB
TypeScript
1332 lines
36 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Writable } from 'node:stream'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
interface ReleaseOptions {
|
|
platform: 'windows' | 'macos' | 'linux'
|
|
arch: 'x64' | 'arm64'
|
|
formats: string[]
|
|
skipBuild: boolean
|
|
dryRun: boolean
|
|
unsigned: boolean
|
|
help: boolean
|
|
}
|
|
|
|
interface ReleaseBuilderModule {
|
|
assertReplaceableOutput: (
|
|
directory: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
buildElectronBuilderArguments: (
|
|
options: ReleaseOptions,
|
|
outputDirectory: string
|
|
) => string[]
|
|
electronBuilderEnvironment: (
|
|
options: ReleaseOptions,
|
|
environment?: NodeJS.ProcessEnv
|
|
) => NodeJS.ProcessEnv
|
|
createPortableZip: (
|
|
unpackedDirectory: string,
|
|
zipPath: string,
|
|
dependencies?: {
|
|
openOutput: (filePath: string) => Writable
|
|
}
|
|
) => Promise<void>
|
|
detectBinaryArchitecture: (
|
|
buffer: Buffer
|
|
) => 'x64' | 'arm64' | undefined
|
|
parseArguments: (
|
|
arguments_: string[],
|
|
environment?: { platform: string; arch: string }
|
|
) => ReleaseOptions
|
|
parsePackedPackageMetadata: (
|
|
output: string,
|
|
expected: {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
}
|
|
) => {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
filename: string
|
|
}
|
|
lockedTargetRuntimePackage: (
|
|
packageName: string,
|
|
packageJson?: {
|
|
dependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
},
|
|
packageLock?: {
|
|
packages?: Record<
|
|
string,
|
|
{
|
|
version?: string
|
|
resolved?: string
|
|
integrity?: string
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
>
|
|
}
|
|
) => {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
}
|
|
replaceOutput: (
|
|
stagingDirectory: string,
|
|
destination: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
targetRuntimePackageNames: (
|
|
options: ReleaseOptions
|
|
) => string[]
|
|
targetHarnessPaths: (
|
|
options: ReleaseOptions
|
|
) => {
|
|
nodePtyBinary: string
|
|
nodePtyDirectory: string
|
|
}
|
|
stageTargetRuntimeDependencies: (
|
|
options: ReleaseOptions,
|
|
dependencies?: {
|
|
root: string
|
|
packageJson: {
|
|
dependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
packageLock: {
|
|
packages?: Record<
|
|
string,
|
|
{
|
|
version?: string
|
|
resolved?: string
|
|
integrity?: string
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
>
|
|
}
|
|
npmInvocation: () => {
|
|
command: string
|
|
prefixArgs: string[]
|
|
}
|
|
runCapture: (
|
|
command: string,
|
|
arguments_: string[]
|
|
) => Promise<string>
|
|
extractArchive: (
|
|
archivePath: string,
|
|
destination: string
|
|
) => Promise<void>
|
|
}
|
|
) => Promise<() => void>
|
|
verifyHarnessPackage: (
|
|
resources: string,
|
|
options: ReleaseOptions,
|
|
dependencies?: {
|
|
listPackage: (asarPath: string) => string[]
|
|
statFile: (
|
|
asarPath: string,
|
|
filePath: string
|
|
) => {
|
|
files?: Record<string, unknown>
|
|
link?: string
|
|
size?: number
|
|
}
|
|
extractFile: (
|
|
asarPath: string,
|
|
filePath: string
|
|
) => Buffer
|
|
}
|
|
) => void
|
|
verifyArtifacts: (
|
|
directory: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
verifyPortableZip: (filePath: string) => void
|
|
verifyArchiveIntegrity: (
|
|
filePath: string,
|
|
expectedIntegrity: string
|
|
) => void
|
|
writeManifest: (
|
|
directory: string,
|
|
options: ReleaseOptions
|
|
) => Promise<{
|
|
files: Array<{
|
|
name: string
|
|
size: number
|
|
sha256: string
|
|
}>
|
|
}>
|
|
}
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const packageVersion = (
|
|
require('../package.json') as { version: string }
|
|
).version
|
|
const releaseBuilder = require(
|
|
'../build/build-release.cjs'
|
|
) as ReleaseBuilderModule
|
|
const releaseConfig = require(
|
|
'../build/electron-builder.release.cjs'
|
|
) as {
|
|
extraResources: Array<{
|
|
from: string
|
|
to?: string
|
|
filter?: string[]
|
|
}>
|
|
}
|
|
const prepareBundledRuntimes = require(
|
|
'../build/runtime-hooks.cjs'
|
|
) as (context: {
|
|
electronPlatformName: string
|
|
arch: number
|
|
packager: { projectDir: string }
|
|
}) => Promise<void>
|
|
const windowsOptions: ReleaseOptions = {
|
|
platform: 'windows',
|
|
arch: 'x64',
|
|
formats: ['nsis', 'portable'],
|
|
skipBuild: false,
|
|
dryRun: false,
|
|
unsigned: false,
|
|
help: false
|
|
}
|
|
const electronProcess = process as NodeJS.Process & { noAsar?: boolean }
|
|
const originalNoAsar = electronProcess.noAsar
|
|
|
|
beforeAll(() => {
|
|
electronProcess.noAsar = true
|
|
})
|
|
|
|
afterAll(() => {
|
|
electronProcess.noAsar = originalNoAsar
|
|
})
|
|
|
|
function pe(machine: number): Buffer {
|
|
const buffer = Buffer.alloc(256)
|
|
buffer.write('MZ', 0, 'ascii')
|
|
buffer.writeUInt32LE(128, 0x3c)
|
|
buffer.write('PE\0\0', 128, 'ascii')
|
|
buffer.writeUInt16LE(machine, 132)
|
|
return buffer
|
|
}
|
|
|
|
function elf(machine: number, bigEndian = false): Buffer {
|
|
const buffer = Buffer.alloc(64)
|
|
buffer.set([0x7f, 0x45, 0x4c, 0x46])
|
|
buffer[5] = bigEndian ? 2 : 1
|
|
if (bigEndian) {
|
|
buffer.writeUInt16BE(machine, 18)
|
|
} else {
|
|
buffer.writeUInt16LE(machine, 18)
|
|
}
|
|
return buffer
|
|
}
|
|
|
|
function machO(cpuType: number): Buffer {
|
|
const buffer = Buffer.alloc(8)
|
|
buffer.writeUInt32LE(0xfeedfacf, 0)
|
|
buffer.writeUInt32LE(cpuType, 4)
|
|
return buffer
|
|
}
|
|
|
|
function portableDirectory(parent: string): string {
|
|
const directory = join(parent, 'portable')
|
|
const plugin = join(directory, 'resources', 'runtimes', 'opencode-config', 'node_modules', '@opencode-ai', 'plugin')
|
|
mkdirSync(join(plugin, 'dist'), { recursive: true })
|
|
writeFileSync(join(plugin, 'package.json'), '{"name":"@opencode-ai/plugin","version":"1.18.29"}')
|
|
writeFileSync(join(plugin, 'dist', 'index.js'), 'export const tool = true')
|
|
mkdirSync(
|
|
join(directory, 'resources', 'runtimes', 'opencode'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(join(directory, 'resources', 'licenses'), { recursive: true })
|
|
mkdirSync(
|
|
join(directory, 'resources', 'runtimes', 'continue'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(directory, 'resources', 'tool-environment'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(
|
|
directory,
|
|
'resources',
|
|
'runtimes',
|
|
'npm',
|
|
'node_modules',
|
|
'graceful-fs'
|
|
),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(
|
|
directory,
|
|
'resources',
|
|
'runtimes',
|
|
'npm',
|
|
'bin'
|
|
),
|
|
{ recursive: true }
|
|
)
|
|
for (const [path, content] of [
|
|
['GoodBuddy.exe', 'MZ'],
|
|
['resources/app.asar', 'asar'],
|
|
['resources/release-notes.json', '{}'],
|
|
['resources/icon.ico', 'icon'],
|
|
['resources/tray-icon.png', 'tray'],
|
|
[
|
|
'resources/tool-environment/managed-python-artifacts.json',
|
|
'{"artifacts":[]}'
|
|
],
|
|
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
|
['resources/runtimes/opencode/rg.exe', 'MZ'],
|
|
['resources/licenses/vscode-ripgrep-MIT.txt', 'MIT'],
|
|
['resources/runtimes/continue/package.json', '{}'],
|
|
[
|
|
'resources/runtimes/npm/bin/npm-cli.js',
|
|
'require("../lib/cli.js")'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/bin/npx-cli.js',
|
|
'require("../lib/cli.js")'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/package.json',
|
|
'{"version":"11.19.0"}'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/node_modules/graceful-fs/package.json',
|
|
'{"name":"graceful-fs"}'
|
|
]
|
|
] satisfies Array<[string, string]>) {
|
|
writeFileSync(join(directory, ...path.split('/')), content)
|
|
}
|
|
return directory
|
|
}
|
|
|
|
function endOfCentralDirectory(
|
|
entryCount: number,
|
|
centralSize: number,
|
|
centralOffset = 0
|
|
): Buffer {
|
|
const buffer = Buffer.alloc(22)
|
|
buffer.writeUInt32LE(0x06054b50, 0)
|
|
buffer.writeUInt16LE(entryCount, 8)
|
|
buffer.writeUInt16LE(entryCount, 10)
|
|
buffer.writeUInt32LE(centralSize, 12)
|
|
buffer.writeUInt32LE(centralOffset, 16)
|
|
return buffer
|
|
}
|
|
|
|
describe('release build arguments', () => {
|
|
it.each([
|
|
['win32', 'x64', 'windows', ['nsis', 'portable']],
|
|
['darwin', 'arm64', 'macos', ['dmg']],
|
|
['linux', 'x64', 'linux', ['AppImage', 'deb', 'rpm']]
|
|
])(
|
|
'uses %s host defaults',
|
|
(host, arch, platform, formats) => {
|
|
expect(
|
|
releaseBuilder.parseArguments([], {
|
|
platform: host,
|
|
arch
|
|
})
|
|
).toMatchObject({ platform, arch, formats })
|
|
}
|
|
)
|
|
|
|
it('normalizes aliases, formats, and duplicate formats', () => {
|
|
expect(
|
|
releaseBuilder.parseArguments(
|
|
[
|
|
'--platform',
|
|
'mac',
|
|
'--arch',
|
|
'arm64',
|
|
'--format',
|
|
'DMG,zip,dmg',
|
|
'--skip-build',
|
|
'--dry-run',
|
|
'--unsigned'
|
|
],
|
|
{ platform: 'win32', arch: 'x64' }
|
|
)
|
|
).toEqual({
|
|
platform: 'macos',
|
|
arch: 'arm64',
|
|
formats: ['dmg', 'zip'],
|
|
skipBuild: true,
|
|
dryRun: true,
|
|
unsigned: true,
|
|
help: false
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
[['--platform', 'freebsd'], '不支持当前系统'],
|
|
[['--arch', 'ia32'], '不支持的架构'],
|
|
[['--format', 'zip'], '不支持打包格式'],
|
|
[['--platform', 'linux', '--unsigned'], '仅支持 macOS'],
|
|
[['--unknown'], '未知参数']
|
|
])('rejects invalid arguments', (arguments_, message) => {
|
|
expect(() =>
|
|
releaseBuilder.parseArguments(arguments_, {
|
|
platform: 'linux',
|
|
arch: 'x64'
|
|
})
|
|
).toThrow(message)
|
|
})
|
|
|
|
it('builds target-specific electron-builder arguments', () => {
|
|
const arguments_ =
|
|
releaseBuilder.buildElectronBuilderArguments(
|
|
{
|
|
platform: 'windows',
|
|
arch: 'arm64',
|
|
formats: ['nsis', 'portable'],
|
|
skipBuild: false,
|
|
dryRun: false,
|
|
unsigned: false,
|
|
help: false
|
|
},
|
|
'C:\\release-stage'
|
|
)
|
|
|
|
expect(arguments_).toEqual(
|
|
expect.arrayContaining([
|
|
'--config',
|
|
expect.stringMatching(
|
|
/build[\\/]electron-builder\.release\.cjs$/u
|
|
),
|
|
'--win',
|
|
'nsis',
|
|
'dir',
|
|
'--arm64',
|
|
'--config.directories.output=C:\\release-stage',
|
|
'--publish',
|
|
'never',
|
|
expect.stringContaining('nsis.artifactName=')
|
|
])
|
|
)
|
|
expect(arguments_).not.toContain('portable')
|
|
expect(
|
|
arguments_.some((argument) =>
|
|
argument.includes('portable.artifactName=')
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('explicitly disables notarization for unsigned macOS packages', () => {
|
|
const arguments_ =
|
|
releaseBuilder.buildElectronBuilderArguments(
|
|
{
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
formats: ['dmg', 'zip'],
|
|
unsigned: true
|
|
},
|
|
'/release-stage'
|
|
)
|
|
|
|
expect(arguments_).toContain('--config.mac.notarize=false')
|
|
})
|
|
|
|
it('removes signing credentials from unsigned macOS builds', () => {
|
|
const environment =
|
|
releaseBuilder.electronBuilderEnvironment(
|
|
{
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
formats: ['dmg', 'zip'],
|
|
unsigned: true
|
|
},
|
|
{
|
|
CSC_LINK: 'certificate',
|
|
CSC_KEY_PASSWORD: 'password',
|
|
APPLE_API_KEY: 'api-key',
|
|
APPLE_API_KEY_ID: 'key-id',
|
|
APPLE_API_ISSUER: 'issuer',
|
|
KEEP_ME: 'present'
|
|
}
|
|
)
|
|
|
|
expect(environment).toMatchObject({
|
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false',
|
|
KEEP_ME: 'present'
|
|
})
|
|
expect(environment).not.toHaveProperty('CSC_LINK')
|
|
expect(environment).not.toHaveProperty('CSC_KEY_PASSWORD')
|
|
expect(environment).not.toHaveProperty('APPLE_API_KEY')
|
|
expect(environment).not.toHaveProperty('APPLE_API_KEY_ID')
|
|
expect(environment).not.toHaveProperty('APPLE_API_ISSUER')
|
|
})
|
|
|
|
it('pins and unpacks only the required Harness native packages', () => {
|
|
const packageJson = require('../package.json') as {
|
|
build: {
|
|
asarUnpack: string[]
|
|
npmRebuild: boolean
|
|
extraResources: Array<{
|
|
from: string
|
|
to: string
|
|
}>
|
|
}
|
|
optionalDependencies: Record<string, string>
|
|
}
|
|
expect(packageJson.build.npmRebuild).toBe(false)
|
|
expect(packageJson.build.asarUnpack).toEqual(
|
|
expect.arrayContaining([
|
|
'out/main/package.json',
|
|
'out/main/deepseek-harness-*',
|
|
'out/main/chunks/**/*',
|
|
'node_modules/node-pty/lib/**/*',
|
|
'node_modules/node-pty/package.json',
|
|
'node_modules/node-pty/prebuilds/**/*',
|
|
'node_modules/node-pty/build/Release/**/*',
|
|
'node_modules/koffi/**/*',
|
|
'node_modules/@koromix/koffi-*/**/*',
|
|
'node_modules/@napi-rs/canvas{,/**/*}',
|
|
'node_modules/@napi-rs/canvas-*/**/*'
|
|
])
|
|
)
|
|
expect(packageJson.build.asarUnpack).not.toContain(
|
|
'node_modules/npm/**/*'
|
|
)
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'resources/licenses/onnxruntime-MIT.txt',
|
|
to: 'licenses/onnxruntime-web-MIT.txt'
|
|
})
|
|
expect(packageJson.optionalDependencies).toEqual({
|
|
'@koromix/koffi-darwin-arm64': '3.1.4',
|
|
'@koromix/koffi-darwin-x64': '3.1.4',
|
|
'@koromix/koffi-linux-arm64': '3.1.4',
|
|
'@koromix/koffi-linux-x64': '3.1.4',
|
|
'@koromix/koffi-win32-arm64': '3.1.4',
|
|
'@koromix/koffi-win32-x64': '3.1.4'
|
|
})
|
|
})
|
|
|
|
it('identifies the native packages required by each release target', () => {
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
arch: 'arm64'
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-win32-arm64',
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
])
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
platform: 'linux',
|
|
formats: ['AppImage', 'deb', 'rpm']
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-linux-x64',
|
|
'@napi-rs/canvas-linux-x64-gnu'
|
|
])
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
arch: 'arm64',
|
|
formats: ['dmg', 'zip']
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-darwin-arm64',
|
|
'@napi-rs/canvas-darwin-arm64'
|
|
])
|
|
})
|
|
|
|
it('stages a missing target Canvas package from locked metadata', async () => {
|
|
const runtimeRoot = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-canvas-stage-')
|
|
)
|
|
const canvasPackage =
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
const koffiPackage = '@koromix/koffi-win32-arm64'
|
|
const archiveContents = Buffer.from('locked Canvas archive')
|
|
const integrity = `sha512-${createHash('sha512')
|
|
.update(archiveContents)
|
|
.digest('base64')}`
|
|
const packageJson = {
|
|
dependencies: {
|
|
'@napi-rs/canvas': '1.0.3'
|
|
},
|
|
optionalDependencies: {
|
|
[koffiPackage]: '3.1.4'
|
|
}
|
|
}
|
|
const packageLock = {
|
|
packages: {
|
|
'node_modules/@napi-rs/canvas': {
|
|
version: '1.0.3',
|
|
optionalDependencies: {
|
|
[canvasPackage]: '1.0.3'
|
|
}
|
|
},
|
|
[`node_modules/${canvasPackage}`]: {
|
|
version: '1.0.3',
|
|
resolved: 'https://registry.npmjs.org/locked-canvas.tgz',
|
|
integrity
|
|
},
|
|
[`node_modules/${koffiPackage}`]: {
|
|
version: '3.1.4',
|
|
resolved: 'https://registry.npmjs.org/locked-koffi.tgz',
|
|
integrity: 'sha512-locked'
|
|
}
|
|
}
|
|
}
|
|
const koffiDirectory = join(
|
|
runtimeRoot,
|
|
'node_modules',
|
|
...koffiPackage.split('/')
|
|
)
|
|
const canvasDirectory = join(
|
|
runtimeRoot,
|
|
'node_modules',
|
|
...canvasPackage.split('/')
|
|
)
|
|
const invocations: string[][] = []
|
|
let cleanup: () => void = () => undefined
|
|
|
|
try {
|
|
mkdirSync(koffiDirectory, { recursive: true })
|
|
writeFileSync(
|
|
join(koffiDirectory, 'package.json'),
|
|
JSON.stringify({
|
|
name: koffiPackage,
|
|
version: '3.1.4'
|
|
})
|
|
)
|
|
|
|
cleanup =
|
|
await releaseBuilder.stageTargetRuntimeDependencies(
|
|
{
|
|
...windowsOptions,
|
|
arch: 'arm64'
|
|
},
|
|
{
|
|
root: runtimeRoot,
|
|
packageJson,
|
|
packageLock,
|
|
npmInvocation: () => ({
|
|
command: 'npm-test',
|
|
prefixArgs: []
|
|
}),
|
|
runCapture: async (_command, arguments_) => {
|
|
invocations.push(arguments_)
|
|
const destination = arguments_[
|
|
arguments_.indexOf('--pack-destination') + 1
|
|
]
|
|
if (!destination) {
|
|
throw new Error('pack destination is missing')
|
|
}
|
|
writeFileSync(
|
|
join(destination, 'locked-canvas.tgz'),
|
|
archiveContents
|
|
)
|
|
return JSON.stringify([
|
|
{
|
|
name: canvasPackage,
|
|
version: '1.0.3',
|
|
integrity,
|
|
filename: 'locked-canvas.tgz'
|
|
}
|
|
])
|
|
},
|
|
extractArchive: async (_archive, destination) => {
|
|
writeFileSync(
|
|
join(destination, 'package.json'),
|
|
JSON.stringify({
|
|
name: canvasPackage,
|
|
version: '1.0.3'
|
|
})
|
|
)
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(invocations).toHaveLength(1)
|
|
expect(invocations[0]).toEqual(
|
|
expect.arrayContaining([
|
|
'pack',
|
|
`${canvasPackage}@1.0.3`,
|
|
'--ignore-scripts',
|
|
'--json'
|
|
])
|
|
)
|
|
expect(invocations[0]).not.toContain(
|
|
`${canvasPackage}@latest`
|
|
)
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(canvasDirectory, 'package.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
name: canvasPackage,
|
|
version: '1.0.3'
|
|
})
|
|
|
|
cleanup()
|
|
cleanup = () => undefined
|
|
expect(existsSync(canvasDirectory)).toBe(false)
|
|
expect(existsSync(koffiDirectory)).toBe(true)
|
|
} finally {
|
|
cleanup()
|
|
rmSync(runtimeRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'the target package version differs',
|
|
{
|
|
version: '1.0.4',
|
|
resolved: 'https://registry.npmjs.org/canvas.tgz',
|
|
integrity: 'sha512-locked'
|
|
}
|
|
],
|
|
[
|
|
'target package integrity is missing',
|
|
{
|
|
version: '1.0.3',
|
|
resolved: 'https://registry.npmjs.org/canvas.tgz'
|
|
}
|
|
]
|
|
])('fails closed when %s in the lockfile', (_case, targetLock) => {
|
|
const canvasPackage =
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
expect(() =>
|
|
releaseBuilder.lockedTargetRuntimePackage(
|
|
canvasPackage,
|
|
{
|
|
dependencies: {
|
|
'@napi-rs/canvas': '1.0.3'
|
|
}
|
|
},
|
|
{
|
|
packages: {
|
|
'node_modules/@napi-rs/canvas': {
|
|
version: '1.0.3',
|
|
optionalDependencies: {
|
|
[canvasPackage]: '1.0.3'
|
|
}
|
|
},
|
|
[`node_modules/${canvasPackage}`]: targetLock
|
|
}
|
|
}
|
|
)
|
|
).toThrow('目标 Runtime 依赖未完整锁定')
|
|
})
|
|
|
|
it('validates packed target dependency metadata and archive integrity', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-dependency-test-')
|
|
)
|
|
const archive = join(directory, 'target.tgz')
|
|
const contents = Buffer.from('locked target dependency')
|
|
writeFileSync(archive, contents)
|
|
const integrity = `sha512-${createHash('sha512')
|
|
.update(contents)
|
|
.digest('base64')}`
|
|
const expected = {
|
|
name: '@koromix/koffi-win32-arm64',
|
|
version: '3.1.4',
|
|
integrity
|
|
}
|
|
|
|
try {
|
|
expect(
|
|
releaseBuilder.parsePackedPackageMetadata(
|
|
JSON.stringify([
|
|
{
|
|
...expected,
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
}
|
|
]),
|
|
expected
|
|
)
|
|
).toMatchObject({
|
|
...expected,
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
})
|
|
releaseBuilder.verifyArchiveIntegrity(archive, integrity)
|
|
expect(() =>
|
|
releaseBuilder.verifyArchiveIntegrity(
|
|
archive,
|
|
`sha512-${Buffer.alloc(64).toString('base64')}`
|
|
)
|
|
).toThrow('完整性校验失败')
|
|
expect(() =>
|
|
releaseBuilder.parsePackedPackageMetadata(
|
|
JSON.stringify([
|
|
{
|
|
...expected,
|
|
version: '3.1.5',
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
}
|
|
]),
|
|
expected
|
|
)
|
|
).toThrow('元数据不匹配')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('keeps installable Agent resources out of ordinary desktop packages', () => {
|
|
const packageJson = require('../package.json') as {
|
|
build: {
|
|
files: string[]
|
|
extraResources: Array<{
|
|
from: string
|
|
to?: string
|
|
filter?: string[]
|
|
}>
|
|
}
|
|
}
|
|
const resourceSources = packageJson.build.extraResources.map(
|
|
(entry) => entry.from
|
|
)
|
|
|
|
expect(packageJson.build.files).toEqual([
|
|
'out/**/*',
|
|
'package.json',
|
|
'!node_modules/npm{,/**/*}'
|
|
])
|
|
expect(resourceSources).toContain('resources/skills')
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'resources/tool-environment/managed-python-artifacts.json',
|
|
to: 'tool-environment/managed-python-artifacts.json'
|
|
})
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'node_modules',
|
|
to: 'runtimes',
|
|
filter: ['npm{,/**/*}']
|
|
})
|
|
expect(
|
|
existsSync(join('node_modules', 'npm', 'bin', 'npm-cli.js'))
|
|
).toBe(true)
|
|
expect(
|
|
existsSync(join('node_modules', 'npm', 'bin', 'npx-cli.js'))
|
|
).toBe(true)
|
|
expect(resourceSources).toContain(
|
|
'resources/agent-release-keys.json'
|
|
)
|
|
expect(resourceSources).toContain('agent-runtime-lock.json')
|
|
expect(resourceSources).toContain('remote-runtime-lock.json')
|
|
expect(
|
|
resourceSources.some((source) =>
|
|
source.startsWith('.agent-resources/')
|
|
)
|
|
).toBe(false)
|
|
expect(
|
|
resourceSources.some((source) =>
|
|
source.startsWith('.remote-runtime-resources/')
|
|
)
|
|
).toBe(false)
|
|
expect(
|
|
resourceSources.some((source) => source.startsWith('tests/'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(join('resources', 'skills', 'web-3d-game'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(join('resources', 'web-3d-game-mcp.mjs'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(
|
|
join(
|
|
'tests',
|
|
'fixtures',
|
|
'web-3d-game-skill',
|
|
'SKILL.md'
|
|
)
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('keeps remote Agent and Runtime payloads out of release packages', () => {
|
|
const resourceEntries = releaseConfig.extraResources
|
|
|
|
expect(
|
|
resourceEntries.filter((entry) =>
|
|
entry.from.startsWith('.agent-resources/')
|
|
)
|
|
).toHaveLength(0)
|
|
expect(
|
|
resourceEntries.filter((entry) =>
|
|
entry.from.startsWith('.remote-runtime-resources/')
|
|
)
|
|
).toHaveLength(0)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) => entry.from === 'agent-runtime-lock.json'
|
|
)
|
|
).toHaveLength(1)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) => entry.from === 'remote-runtime-lock.json'
|
|
)
|
|
).toHaveLength(1)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) =>
|
|
entry.from === 'resources/agent-release-keys.json'
|
|
)
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('prepares desktop Runtimes without Agent artifacts', async () => {
|
|
const projectRoot = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-runtime-hook-')
|
|
)
|
|
const packageName = 'opencode-windows-x64-baseline'
|
|
const integrity = 'sha512-test-integrity'
|
|
const executable = Buffer.from('desktop runtime')
|
|
const ripgrepPackageName = '@vscode/ripgrep-win32-x64'
|
|
const ripgrepIntegrity = 'sha512-ripgrep-test-integrity'
|
|
const ripgrepExecutable = Buffer.from('ripgrep runtime')
|
|
try {
|
|
const configPlugin = join(
|
|
projectRoot, '.runtime-resources', 'opencode-config',
|
|
'node_modules', '@opencode-ai', 'plugin'
|
|
)
|
|
mkdirSync(configPlugin, { recursive: true })
|
|
writeFileSync(join(configPlugin, 'package.json'), JSON.stringify({ version: '1.18.29' }))
|
|
const webPackages = {
|
|
'@deepseek-ai/dsh': { version: '0.1.7-rc.2', dependencies: { sharp: '0.35.4', koffi: '3.1.4' } },
|
|
sharp: { version: '0.35.4', optionalDependencies: { '@img/sharp-win32-x64': '0.35.4' } },
|
|
'sharp/node_modules/@img/sharp-win32-x64': { version: '0.35.4' },
|
|
koffi: { version: '3.1.4', optionalDependencies: { '@koromix/koffi-win32-x64': '3.1.4' } },
|
|
'@koromix/koffi-win32-x64': { version: '3.1.4' }
|
|
}
|
|
for (const [name, manifest] of Object.entries(webPackages)) {
|
|
const directory = join(projectRoot, 'node_modules', name)
|
|
mkdirSync(directory, { recursive: true })
|
|
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name, ...manifest }))
|
|
}
|
|
mkdirSync(join(projectRoot, 'out', 'main'), {
|
|
recursive: true
|
|
})
|
|
mkdirSync(
|
|
join(projectRoot, '.runtime-resources', 'x64'),
|
|
{ recursive: true }
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, 'package.json'),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
'@deepseek-ai/dsh-llm': '0.1.7-rc.2',
|
|
'@vscode/ripgrep': '1.18.0'
|
|
}
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, 'package-lock.json'),
|
|
JSON.stringify({
|
|
packages: {
|
|
[`node_modules/${packageName}`]: {
|
|
version: '1.18.29',
|
|
integrity
|
|
},
|
|
[`node_modules/${ripgrepPackageName}`]: {
|
|
version: '1.18.0',
|
|
integrity: ripgrepIntegrity
|
|
}
|
|
}
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(
|
|
projectRoot,
|
|
'.runtime-resources',
|
|
'x64',
|
|
'opencode.exe'
|
|
),
|
|
executable
|
|
)
|
|
writeFileSync(
|
|
join(
|
|
projectRoot,
|
|
'.runtime-resources',
|
|
'x64',
|
|
'.ready.json'
|
|
),
|
|
JSON.stringify({
|
|
packageName,
|
|
version: '1.18.29',
|
|
integrity,
|
|
executableSha256: createHash('sha256')
|
|
.update(executable)
|
|
.digest('hex')
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, '.runtime-resources', 'x64', 'rg.exe'),
|
|
ripgrepExecutable
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, '.runtime-resources', 'x64', '.ripgrep-ready.json'),
|
|
JSON.stringify({
|
|
packageName: ripgrepPackageName,
|
|
version: '1.18.0',
|
|
integrity: ripgrepIntegrity,
|
|
executableSha256: createHash('sha256')
|
|
.update(ripgrepExecutable)
|
|
.digest('hex')
|
|
})
|
|
)
|
|
|
|
await expect(
|
|
prepareBundledRuntimes({
|
|
electronPlatformName: 'win32',
|
|
arch: 1,
|
|
packager: { projectDir: projectRoot }
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(existsSync(join(projectRoot, '.runtime-resources', 'dsh-x64', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))).toBe(true)
|
|
expect(
|
|
existsSync(join(projectRoot, '.agent-resources'))
|
|
).toBe(false)
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(projectRoot, 'out', 'main', 'package.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
name: '@deepseek-ai/dsh-llm',
|
|
version: '0.1.7-rc.2'
|
|
})
|
|
} finally {
|
|
rmSync(projectRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('release binary architecture detection', () => {
|
|
it.each([
|
|
[pe(0x8664), 'x64'],
|
|
[pe(0xaa64), 'arm64'],
|
|
[elf(62), 'x64'],
|
|
[elf(183, true), 'arm64'],
|
|
[machO(0x01000007), 'x64'],
|
|
[machO(0x0100000c), 'arm64'],
|
|
[Buffer.from('not an executable'), undefined]
|
|
])('detects binary headers', (buffer, expected) => {
|
|
expect(
|
|
releaseBuilder.detectBinaryArchitecture(buffer)
|
|
).toBe(expected)
|
|
})
|
|
})
|
|
|
|
describe('release Harness package verification', () => {
|
|
it.each([
|
|
['windows', 'x64', 'prebuilds/win32-x64/conpty.node'],
|
|
['windows', 'arm64', 'prebuilds/win32-arm64/conpty.node'],
|
|
['macos', 'x64', 'prebuilds/darwin-x64/pty.node'],
|
|
['macos', 'arm64', 'prebuilds/darwin-arm64/pty.node'],
|
|
['linux', 'x64', 'prebuilds/linux-x64/pty.node'],
|
|
['linux', 'arm64', 'prebuilds/linux-arm64/pty.node']
|
|
] as const)(
|
|
'verifies the rc.8 node-pty prebuild for %s %s',
|
|
(platform, arch, expected) => {
|
|
expect(
|
|
releaseBuilder.targetHarnessPaths({
|
|
...windowsOptions,
|
|
platform,
|
|
arch
|
|
}).nodePtyBinary
|
|
).toBe(expected)
|
|
}
|
|
)
|
|
|
|
it('fails closed when the packaged Harness Host is missing', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-harness-closure-')
|
|
)
|
|
const resources = join(directory, 'resources')
|
|
try {
|
|
mkdirSync(resources, { recursive: true })
|
|
writeFileSync(join(resources, 'app.asar'), 'asar')
|
|
expect(() =>
|
|
releaseBuilder.verifyHarnessPackage(
|
|
resources,
|
|
windowsOptions,
|
|
{
|
|
listPackage: () => [],
|
|
statFile: () => ({ size: 1 }),
|
|
extractFile: () => Buffer.from('{}')
|
|
}
|
|
)
|
|
).toThrow('DeepSeek Harness Host缺失')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('release output safety', () => {
|
|
it('preserves an existing portable ZIP when exclusive creation fails', async () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-portable-existing-')
|
|
)
|
|
try {
|
|
const unpacked = portableDirectory(directory)
|
|
const zipPath = join(directory, 'portable.zip')
|
|
writeFileSync(zipPath, 'keep-existing-output')
|
|
|
|
await expect(
|
|
releaseBuilder.createPortableZip(unpacked, zipPath)
|
|
).rejects.toMatchObject({ code: 'EEXIST' })
|
|
expect(readFileSync(zipPath, 'utf8')).toBe(
|
|
'keep-existing-output'
|
|
)
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('handles portable ZIP output stream failures without hanging', async () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-portable-write-error-')
|
|
)
|
|
try {
|
|
const unpacked = portableDirectory(directory)
|
|
const failure = new Error('simulated ZIP write failure')
|
|
const output = new Writable({
|
|
write(_chunk, _encoding, callback) {
|
|
callback(failure)
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
releaseBuilder.createPortableZip(
|
|
unpacked,
|
|
join(directory, 'unused.zip'),
|
|
{ openOutput: () => output }
|
|
)
|
|
).rejects.toThrow('simulated ZIP write failure')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'an excessive entry count',
|
|
endOfCentralDirectory(50_001, 46)
|
|
],
|
|
[
|
|
'an oversized central directory',
|
|
endOfCentralDirectory(7, 64 * 1024 * 1024 + 1)
|
|
]
|
|
])('rejects %s before reading ZIP central data', (_case, bytes) => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-portable-invalid-')
|
|
)
|
|
try {
|
|
const zipPath = join(directory, 'portable.zip')
|
|
writeFileSync(zipPath, bytes)
|
|
expect(() =>
|
|
releaseBuilder.verifyPortableZip(zipPath)
|
|
).toThrow('中央目录无效')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('writes a deterministic artifact manifest with streaming hashes', async () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-manifest-')
|
|
)
|
|
try {
|
|
writeFileSync(join(directory, 'artifact.exe'), 'release')
|
|
const manifest = await releaseBuilder.writeManifest(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
|
|
expect(manifest.files).toEqual([
|
|
{
|
|
name: 'artifact.exe',
|
|
size: 7,
|
|
sha256:
|
|
'a4d451ec23463726f72c43d64c710968f6b602cd653b4de8adee1b556240a829'
|
|
}
|
|
])
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(directory, 'release-manifest.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
productName: 'GoodBuddy',
|
|
platform: 'windows',
|
|
arch: 'x64'
|
|
})
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('refuses to replace an unrecognized non-empty directory', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-unsafe-')
|
|
)
|
|
try {
|
|
writeFileSync(join(directory, 'user-file.txt'), 'keep')
|
|
expect(() =>
|
|
releaseBuilder.assertReplaceableOutput(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
).toThrow('拒绝覆盖未识别的发布目录')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('atomically replaces a recognized release directory', () => {
|
|
const parent = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-replace-')
|
|
)
|
|
const destination = join(parent, 'windows-x64')
|
|
const staging = join(parent, 'staging')
|
|
try {
|
|
mkdirSync(destination)
|
|
mkdirSync(staging)
|
|
writeFileSync(
|
|
join(destination, 'release-manifest.json'),
|
|
JSON.stringify({
|
|
formatVersion: 1,
|
|
productName: 'GoodBuddy',
|
|
platform: 'windows',
|
|
arch: 'x64'
|
|
})
|
|
)
|
|
writeFileSync(join(destination, 'old.exe'), 'old')
|
|
writeFileSync(join(staging, 'new.exe'), 'new')
|
|
|
|
releaseBuilder.replaceOutput(
|
|
staging,
|
|
destination,
|
|
windowsOptions
|
|
)
|
|
|
|
expect(existsSync(join(destination, 'new.exe'))).toBe(true)
|
|
expect(existsSync(join(destination, 'old.exe'))).toBe(false)
|
|
expect(existsSync(staging)).toBe(false)
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('requires one artifact for every requested format', async () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-artifacts-')
|
|
)
|
|
try {
|
|
writeFileSync(
|
|
join(
|
|
directory,
|
|
`GoodBuddy-${packageVersion}-windows-x64-setup.exe`
|
|
),
|
|
'MZ'
|
|
)
|
|
const unpacked = portableDirectory(directory)
|
|
const portableZip = join(
|
|
directory,
|
|
`GoodBuddy-${packageVersion}-windows-x64-portable.zip`
|
|
)
|
|
await releaseBuilder.createPortableZip(
|
|
unpacked,
|
|
portableZip
|
|
)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
).not.toThrow()
|
|
rmSync(portableZip)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
).toThrow('portable 产物数量错误')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('validates AppImage, DEB, and RPM package signatures', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-linux-artifacts-')
|
|
)
|
|
const options: ReleaseOptions = {
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
formats: ['AppImage', 'deb', 'rpm'],
|
|
skipBuild: false,
|
|
dryRun: false,
|
|
unsigned: false,
|
|
help: false
|
|
}
|
|
try {
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.AppImage'),
|
|
elf(62)
|
|
)
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-amd64.deb'),
|
|
Buffer.from('!<arch>\n', 'ascii')
|
|
)
|
|
const rpm = Buffer.alloc(96)
|
|
rpm.set([0xed, 0xab, 0xee, 0xdb])
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.rpm'),
|
|
rpm
|
|
)
|
|
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(directory, options)
|
|
).not.toThrow()
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.rpm'),
|
|
'not-rpm'
|
|
)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(directory, options)
|
|
).toThrow('RPM lead magic')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|