dshm-compat 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/CHANGELOG.md +75 -9
- package/INSTALL.md +6 -6
- package/README.md +39 -15
- package/assets/qemu-x86_64/NOTICE.md +17 -0
- package/assets/qemu-x86_64/qemu-x86_64 +0 -0
- package/assets/qemu-x86_64/rootfs/lib/x86_64-linux-gnu/libc.so.6 +0 -0
- package/assets/qemu-x86_64/rootfs/lib/x86_64-linux-gnu/libgcc_s.so.1 +0 -0
- package/assets/qemu-x86_64/rootfs/lib64/ld-linux-x86-64.so.2 +0 -0
- package/docs/ARCHITECTURE.md +18 -8
- package/docs/RECIPES.md +79 -91
- package/index.js +104 -1
- package/lib/analyze.js +9 -1
- package/lib/convert.js +58 -4
- package/lib/elf.js +41 -0
- package/lib/guest.js +6 -3
- package/lib/plan.js +10 -0
- package/lib/release.js +185 -0
- package/lib/rules.js +7 -22
- package/lib/s2-native.js +316 -0
- package/lib/sidecar.js +185 -0
- package/lib/source-build.js +165 -0
- package/lib/uninstall.js +94 -0
- package/lib/zip.js +59 -0
- package/package.json +3 -1
- package/lib/recipes/dsh-ops.js +0 -209
package/lib/s2-native.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic S2 native adapter (dsh-ops is the first fixture).
|
|
3
|
+
*
|
|
4
|
+
* A native plugin usually ships a worker plus JS that (a) gates a PLATFORMS
|
|
5
|
+
* matrix, (b) interpolates `${platform}-${arch}` into runtime paths, and
|
|
6
|
+
* (c) spawns a worker argv array. This adapter normalizes those three patterns
|
|
7
|
+
* and stages an externally built AArch64 musl worker. It is package-name
|
|
8
|
+
* agnostic; dsh-ops remains the reference sample and test fixture.
|
|
9
|
+
*
|
|
10
|
+
* @module dshm-compat/s2-native
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { basename, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { analyze } from './analyze.js'
|
|
17
|
+
import { elfArch } from './elf.js'
|
|
18
|
+
import { loadArchive, resolveSpec } from './fetch.js'
|
|
19
|
+
import { packTarGz } from './pack.js'
|
|
20
|
+
import { findReleaseWorker } from './release.js'
|
|
21
|
+
import { compatDir } from './state.js'
|
|
22
|
+
import { extractTar, findRootPrefix } from './tar.js'
|
|
23
|
+
import { validateTarball } from './validate.js'
|
|
24
|
+
|
|
25
|
+
const BUILD_COMMAND = 'cargo build --release --locked --manifest-path native/Cargo.toml --target aarch64-unknown-linux-musl'
|
|
26
|
+
const TARGET = 'aarch64-unknown-linux-musl'
|
|
27
|
+
const QEMU_ROOT = fileURLToPath(new URL('../assets/qemu-x86_64/', import.meta.url))
|
|
28
|
+
|
|
29
|
+
/** @param {import('../tar.js').TarEntry[]} entries */
|
|
30
|
+
function selectRoot(entries) {
|
|
31
|
+
const candidates = entries
|
|
32
|
+
.filter(entry => entry.type === 'file' && (entry.path === 'package.json' || entry.path.endsWith('/package.json')))
|
|
33
|
+
.map(entry => ({ dir: entry.path === 'package.json' ? '' : entry.path.slice(0, -'/package.json'.length), depth: entry.path.split('/').length }))
|
|
34
|
+
.sort((a, b) => a.depth - b.depth)
|
|
35
|
+
return candidates.length === 0 ? '' : (candidates[0].dir === '' ? '' : `${candidates[0].dir}/`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} spec
|
|
40
|
+
* @param {{ report?: any, workerPath?: string, workerBinary?: Buffer, signal?: AbortSignal }} [options]
|
|
41
|
+
*/
|
|
42
|
+
export async function convertS2Native(spec, options = {}) {
|
|
43
|
+
const report = options.report ?? await analyze(spec, { textScan: false, signal: options.signal })
|
|
44
|
+
const packageName = report.package?.name ?? report.resolved?.name ?? 'native-plugin'
|
|
45
|
+
const packageVersion = report.package?.version ?? report.resolved?.version ?? '0.0.0'
|
|
46
|
+
const resolved = await resolveSpec(spec, { signal: options.signal })
|
|
47
|
+
const archive = await loadArchive(resolved, { signal: options.signal })
|
|
48
|
+
const { entries } = extractTar(archive)
|
|
49
|
+
const prefix = findRootPrefix(entries)
|
|
50
|
+
const top = entries
|
|
51
|
+
.filter(entry => entry.type === 'file')
|
|
52
|
+
.map(entry => ({ ...entry, path: prefix !== '' && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path }))
|
|
53
|
+
const root = selectRoot(top)
|
|
54
|
+
const files = top
|
|
55
|
+
.filter(entry => entry.path.startsWith(root))
|
|
56
|
+
.map(entry => ({ ...entry, path: entry.path.slice(root.length) }))
|
|
57
|
+
const byPath = new Map(files.map(file => [file.path, file]))
|
|
58
|
+
const getText = path => byPath.get(path)?.data?.toString('utf8')
|
|
59
|
+
|
|
60
|
+
const manifestEntry = byPath.get('package.json')
|
|
61
|
+
let manifest
|
|
62
|
+
try { manifest = manifestEntry?.data === undefined ? undefined : JSON.parse(manifestEntry.data.toString('utf8')) } catch { manifest = undefined }
|
|
63
|
+
const main = typeof manifest?.main === 'string' ? manifest.main.replace(/^\.\//, '') : undefined
|
|
64
|
+
const hasMain = main !== undefined && files.some(file => file.path === main)
|
|
65
|
+
const buildScript = typeof manifest?.scripts?.build === 'string'
|
|
66
|
+
? manifest.scripts.build
|
|
67
|
+
: typeof manifest?.scripts?.prepack === 'string' ? manifest.scripts.prepack : undefined
|
|
68
|
+
if (!hasMain && buildScript !== undefined) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'needs-build',
|
|
71
|
+
sourcePackage: true,
|
|
72
|
+
message: `${packageName} is a source package (main ${main ?? '<missing>'} is absent, build script ${JSON.stringify(buildScript)}); build it outside DSHM, then pass the produced tarball to compat_convert`,
|
|
73
|
+
build: {
|
|
74
|
+
command: 'npm ci && npm run build && npm pack',
|
|
75
|
+
output: 'the produced .tgz',
|
|
76
|
+
then: 'compat_convert { spec: "<produced.tgz>" }',
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let workerBinary = options.workerBinary
|
|
82
|
+
let workerName = options.workerName
|
|
83
|
+
if (workerBinary === undefined && typeof options.workerPath === 'string') {
|
|
84
|
+
try {
|
|
85
|
+
workerBinary = readFileSync(options.workerPath)
|
|
86
|
+
workerName ??= basename(options.workerPath)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { status: 'needs-native-build', target: TARGET, command: BUILD_COMMAND, message: `cannot read worker: ${String(error?.message ?? error)}` }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (workerBinary === undefined) {
|
|
92
|
+
const candidate = files.find(file => file.type === 'file'
|
|
93
|
+
&& /^runtime\/linux-(?:arm64|x64)\//.test(file.path)
|
|
94
|
+
&& !file.path.endsWith('/provenance.json')
|
|
95
|
+
&& elfArch(file.data) !== undefined)
|
|
96
|
+
if (candidate !== undefined) {
|
|
97
|
+
workerBinary = candidate.data
|
|
98
|
+
workerName = basename(candidate.path)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let acquiredAsset
|
|
102
|
+
let acquisition
|
|
103
|
+
if (workerBinary === undefined) {
|
|
104
|
+
acquisition = await findReleaseWorker(report, { signal: options.signal })
|
|
105
|
+
if (acquisition.ok === true) {
|
|
106
|
+
workerBinary = acquisition.buffer
|
|
107
|
+
workerName = acquisition.name
|
|
108
|
+
acquiredAsset = acquisition.asset
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (workerBinary === undefined) {
|
|
112
|
+
return {
|
|
113
|
+
status: 'needs-native-build',
|
|
114
|
+
target: TARGET,
|
|
115
|
+
command: BUILD_COMMAND,
|
|
116
|
+
message: `${packageName} needs a Linux arm64 or x64 worker; build one or pass workerPath to compat_convert`,
|
|
117
|
+
notes: [
|
|
118
|
+
'install a Rust toolchain and a musl aarch64 cross linker, or use cross/cargo-zigbuild',
|
|
119
|
+
'stage the result at runtime/linux-arm64/<worker> or runtime/linux-x64/<worker>, or pass it as workerPath',
|
|
120
|
+
],
|
|
121
|
+
...(acquisition === undefined ? {} : { acquisition }),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const workerArch = elfArch(workerBinary)
|
|
125
|
+
if (workerArch === undefined) {
|
|
126
|
+
return { status: 'invalid-worker', message: 'worker is not an AArch64 or x86_64 ELF image' }
|
|
127
|
+
}
|
|
128
|
+
const runtimeTarget = workerArch === 'arm64' ? 'linux-arm64' : 'linux-x64'
|
|
129
|
+
workerName ??= 'worker'
|
|
130
|
+
|
|
131
|
+
const runtimeEntry = files.find(file => file.type === 'file'
|
|
132
|
+
&& file.path.endsWith('.js')
|
|
133
|
+
&& file.data?.toString('utf8').includes('export const PLATFORMS = Object.freeze(['))
|
|
134
|
+
if (runtimeEntry === undefined) {
|
|
135
|
+
const manifestEntry = byPath.get('package.json')
|
|
136
|
+
let manifest
|
|
137
|
+
try { manifest = manifestEntry?.data === undefined ? undefined : JSON.parse(manifestEntry.data.toString('utf8')) } catch { manifest = undefined }
|
|
138
|
+
const main = typeof manifest?.main === 'string' ? manifest.main.replace(/^\.\//, '') : undefined
|
|
139
|
+
const hasMain = main !== undefined && files.some(file => file.path === main)
|
|
140
|
+
const buildScript = typeof manifest?.scripts?.build === 'string'
|
|
141
|
+
? manifest.scripts.build
|
|
142
|
+
: typeof manifest?.scripts?.prepack === 'string' ? manifest.scripts.prepack : undefined
|
|
143
|
+
if (!hasMain && buildScript !== undefined) {
|
|
144
|
+
return {
|
|
145
|
+
status: 'needs-build',
|
|
146
|
+
sourcePackage: true,
|
|
147
|
+
message: `${packageName} is a source package (main ${main ?? '<missing>'} is absent, build script ${JSON.stringify(buildScript)}); build it outside DSHM, then pass the produced tarball to compat_convert`,
|
|
148
|
+
build: {
|
|
149
|
+
command: 'npm ci && npm run build && npm pack',
|
|
150
|
+
output: 'the produced .tgz',
|
|
151
|
+
then: 'compat_convert { spec: "<produced.tgz>" }',
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { status: 'recipe-mismatch', message: `${packageName} has no JS file exposing the PLATFORMS matrix` }
|
|
156
|
+
}
|
|
157
|
+
const runtime = runtimeEntry.data.toString('utf8')
|
|
158
|
+
const platformsMatch = runtime.match(/export const PLATFORMS = Object\.freeze\(\[([^\]]*)\]\)/)
|
|
159
|
+
const platforms = platformsMatch[1]
|
|
160
|
+
.split(',')
|
|
161
|
+
.map(item => item.trim().replace(/^['"]|['"]$/g, ''))
|
|
162
|
+
.filter(item => item !== '')
|
|
163
|
+
if (!platforms.includes(runtimeTarget)) platforms.push(runtimeTarget)
|
|
164
|
+
const platformLine = `export const PLATFORMS = Object.freeze([${platforms.map(item => `'${item}'`).join(', ')}])`
|
|
165
|
+
const targetHelper = "\nconst resolveTarget = (platform, arch) => (platform === 'openharmony' || (platform === 'linux' && arch === 'arm64')) ? '" + runtimeTarget + "' : `${platform}-${arch}`\n"
|
|
166
|
+
let patchedRuntime = runtime.replaceAll('${platform}-${arch}', '${resolveTarget(platform, arch)}')
|
|
167
|
+
patchedRuntime = patchedRuntime.replace(platformsMatch[0], `${platformLine}${targetHelper}`)
|
|
168
|
+
const signature = 'export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch } = {}) {'
|
|
169
|
+
if (!patchedRuntime.includes(signature)) return { status: 'recipe-mismatch', message: 'resolveWorker signature changed' }
|
|
170
|
+
|
|
171
|
+
const patchedSources = new Map()
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
if (file.type !== 'file' || !file.path.endsWith('.js') || file.data === undefined) continue
|
|
174
|
+
let text = file.data.toString('utf8')
|
|
175
|
+
const before = text
|
|
176
|
+
text = text
|
|
177
|
+
.replaceAll('const argv = [file]', "const argv = ['bash', file]")
|
|
178
|
+
.replaceAll('let argv = [file]', "let argv = ['bash', file]")
|
|
179
|
+
.replaceAll("[file, '-c', command]", "['bash', '-c', command]")
|
|
180
|
+
.replaceAll('[file, scriptPath]', "['bash', scriptPath]")
|
|
181
|
+
if (text !== before) patchedSources.set(file.path, text)
|
|
182
|
+
}
|
|
183
|
+
if (patchedSources.size === 0) {
|
|
184
|
+
return { status: 'recipe-mismatch', message: `${packageName} has no standard native worker spawn (const/let argv = [file])` }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pkgFile = byPath.get('package.json')
|
|
188
|
+
if (pkgFile?.data === undefined) return { status: 'invalid-package', message: 'package.json is missing' }
|
|
189
|
+
const pkg = JSON.parse(pkgFile.data.toString('utf8'))
|
|
190
|
+
delete pkg.os
|
|
191
|
+
delete pkg.cpu
|
|
192
|
+
if (pkg.engines !== null && typeof pkg.engines === 'object' && !Array.isArray(pkg.engines)) {
|
|
193
|
+
delete pkg.engines.dsh
|
|
194
|
+
if (Object.keys(pkg.engines).length === 0) delete pkg.engines
|
|
195
|
+
}
|
|
196
|
+
// DSHM core is a prerelease (e.g. 0.2.1-alpha.4+dshm.56). The upstream
|
|
197
|
+
// ^0.2.0-rc.2 range does not match 0.2.1-*, and the bundle loader would throw
|
|
198
|
+
// on the next boot. "workspace:*" follows the running core instead.
|
|
199
|
+
if (pkg.peerDependencies !== null && typeof pkg.peerDependencies === 'object' && !Array.isArray(pkg.peerDependencies)) {
|
|
200
|
+
if (typeof pkg.peerDependencies['@deepseek-ai/dsh'] === 'string') {
|
|
201
|
+
pkg.peerDependencies['@deepseek-ai/dsh'] = 'workspace:*'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (pkg.optionalDependencies !== null && typeof pkg.optionalDependencies === 'object') {
|
|
205
|
+
for (const name of Object.keys(pkg.optionalDependencies)) {
|
|
206
|
+
if (/(?:^|[-/])(?:win32|darwin)(?:[-/]|$)/i.test(name)) delete pkg.optionalDependencies[name]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const pluginFile = byPath.get('dsh-plugin.json')
|
|
211
|
+
if (pluginFile?.data !== undefined) {
|
|
212
|
+
const manifest = JSON.parse(pluginFile.data.toString('utf8'))
|
|
213
|
+
const components = manifest?.contributes?.['x-components']
|
|
214
|
+
if (Array.isArray(components)) {
|
|
215
|
+
for (const component of components) {
|
|
216
|
+
if (component === null || typeof component !== 'object') continue
|
|
217
|
+
if (Array.isArray(component.platforms) && !component.platforms.includes('openharmony')) component.platforms.push('openharmony')
|
|
218
|
+
if (Array.isArray(component.targets) && !component.targets.includes(runtimeTarget)) component.targets.push(runtimeTarget)
|
|
219
|
+
if (Array.isArray(component.architectures) && !component.architectures.includes('arm64')) component.architectures.push('arm64')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
pluginFile.data = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const runtimeDir = `runtime/${runtimeTarget}`
|
|
226
|
+
const workerPath = `${runtimeDir}/${workerName}`
|
|
227
|
+
const provenancePath = `${runtimeDir}/provenance.json`
|
|
228
|
+
/** @type {{ path: string, data: Buffer, mode?: number }[]} */
|
|
229
|
+
const extraEntries = []
|
|
230
|
+
let stagedWorker
|
|
231
|
+
let workerSource
|
|
232
|
+
try {
|
|
233
|
+
if (workerArch === 'x64') {
|
|
234
|
+
const realName = `${workerName}-x64`
|
|
235
|
+
stagedWorker = Buffer.from([
|
|
236
|
+
'#!/bin/sh',
|
|
237
|
+
'set -e',
|
|
238
|
+
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
239
|
+
`exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${realName}" "$@"`,
|
|
240
|
+
'',
|
|
241
|
+
].join('\n'))
|
|
242
|
+
workerSource = 'x86_64 worker executed through the bundled qemu-x86_64 and glibc rootfs'
|
|
243
|
+
extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
|
|
244
|
+
extraEntries.push({ path: `${runtimeDir}/qemu-x86_64`, data: readFileSync(join(QEMU_ROOT, 'qemu-x86_64')), mode: 0o755 })
|
|
245
|
+
extraEntries.push({ path: `${runtimeDir}/rootfs/lib64/ld-linux-x86-64.so.2`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib64', 'ld-linux-x86-64.so.2')), mode: 0o644 })
|
|
246
|
+
extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libc.so.6`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libc.so.6')), mode: 0o644 })
|
|
247
|
+
extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libgcc_s.so.1`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libgcc_s.so.1')), mode: 0o644 })
|
|
248
|
+
} else {
|
|
249
|
+
const realName = `${workerName}-bin`
|
|
250
|
+
stagedWorker = Buffer.from([
|
|
251
|
+
'#!/bin/sh',
|
|
252
|
+
'set -e',
|
|
253
|
+
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
254
|
+
`exec "$DIR/${realName}" "$@"`,
|
|
255
|
+
'',
|
|
256
|
+
].join('\n'))
|
|
257
|
+
workerSource = 'external aarch64-unknown-linux-musl build supplied by the caller'
|
|
258
|
+
extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { status: 'invalid-output', error: `cannot stage the native worker wrapper: ${String(error?.message ?? error)}` }
|
|
262
|
+
}
|
|
263
|
+
const sha256 = createHash('sha256').update(stagedWorker).digest('hex')
|
|
264
|
+
const provenance = {
|
|
265
|
+
version: typeof pkg.version === 'string' ? pkg.version : '0.0.0',
|
|
266
|
+
target: runtimeTarget,
|
|
267
|
+
executable: workerName,
|
|
268
|
+
sha256,
|
|
269
|
+
source: workerSource,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @type {string[]} */
|
|
273
|
+
const changed = [
|
|
274
|
+
`added ${runtimeTarget} to PLATFORMS and mapped openharmony/arm64 to it`,
|
|
275
|
+
`rewrote ${patchedSources.size} native worker spawn(s) to ['bash', file] so dshm-vm-bash preserves argv`,
|
|
276
|
+
'removed package.json os/cpu gates and non-Linux optional payloads',
|
|
277
|
+
'removed engines.dsh when present so the DSHM bundle loader does not reject the prerelease core',
|
|
278
|
+
'relaxed @deepseek-ai/dsh peer range to workspace:* for the DSHM core',
|
|
279
|
+
`added dsh-plugin.json openharmony/${runtimeTarget} targets`,
|
|
280
|
+
...(acquiredAsset === undefined ? [] : [`acquired ${workerName} from GitHub release asset ${acquiredAsset}`]),
|
|
281
|
+
workerArch === 'x64'
|
|
282
|
+
? `staged ${workerName} as a shell wrapper over qemu-x86_64 + glibc rootfs`
|
|
283
|
+
: `staged ${workerName} as a shell wrapper over ${workerName}-bin`,
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
const output = files.filter(file => file.path !== workerPath).map(file => {
|
|
287
|
+
if (file.path === runtimeEntry.path) return { path: file.path, data: Buffer.from(patchedRuntime, 'utf8'), mode: file.mode }
|
|
288
|
+
if (patchedSources.has(file.path)) return { path: file.path, data: Buffer.from(patchedSources.get(file.path), 'utf8'), mode: file.mode }
|
|
289
|
+
if (file.path === 'package.json') return { path: file.path, data: Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8'), mode: file.mode }
|
|
290
|
+
return { path: file.path, data: file.data, mode: file.mode }
|
|
291
|
+
})
|
|
292
|
+
output.push({ path: workerPath, data: stagedWorker, mode: 0o755 })
|
|
293
|
+
output.push(...extraEntries)
|
|
294
|
+
output.push({ path: provenancePath, data: Buffer.from(`${JSON.stringify(provenance, null, 2)}\n`, 'utf8'), mode: 0o644 })
|
|
295
|
+
|
|
296
|
+
const packed = packTarGz(output)
|
|
297
|
+
const outputDir = join(compatDir(process.env), 'builds')
|
|
298
|
+
mkdirSync(outputDir, { recursive: true })
|
|
299
|
+
const safeName = String(packageName).replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
300
|
+
const tarball = join(outputDir, `${safeName}-${pkg.version ?? '0.0.0'}-dshm.tgz`)
|
|
301
|
+
writeFileSync(tarball, packed)
|
|
302
|
+
const validation = validateTarball(tarball)
|
|
303
|
+
if (!validation.ok) return { status: 'invalid-output', tarball, errors: validation.errors }
|
|
304
|
+
return {
|
|
305
|
+
status: 'ok',
|
|
306
|
+
tarball,
|
|
307
|
+
bytes: packed.length,
|
|
308
|
+
sha256: createHash('sha256').update(packed).digest('hex'),
|
|
309
|
+
changed,
|
|
310
|
+
warnings: validation.warnings,
|
|
311
|
+
validation,
|
|
312
|
+
worker: { target: workerArch === 'arm64' ? TARGET : 'x86_64-linux-gnu', arch: workerArch, sha256 },
|
|
313
|
+
...(acquisition?.ok === true ? { acquisition: { asset: acquisition.asset, sha256: acquisition.sha256 } } : {}),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
package/lib/sidecar.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic guest sidecar staging and JSON-stdio calls.
|
|
3
|
+
*
|
|
4
|
+
* An S2 plugin's native worker is a Linux/AArch64 executable. This module
|
|
5
|
+
* stages such a worker from a converted tarball into
|
|
6
|
+
* `$DSH_HOME/dshm-compat/sidecars/<id>/` and invokes it inside the Alpine guest
|
|
7
|
+
* through the public subprocess service (which `dshm-vm-bash` routes).
|
|
8
|
+
*
|
|
9
|
+
* The worker protocol assumed here is one JSON request on stdin and one JSON
|
|
10
|
+
* value on stdout per process. That is the protocol most native DSH plugins use
|
|
11
|
+
* and the one the generic S2 adapter's `bash -c` wrapper preserves.
|
|
12
|
+
*
|
|
13
|
+
* @module dshm-compat/sidecar
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { join, posix } from 'node:path'
|
|
18
|
+
import { elfSummary } from './elf.js'
|
|
19
|
+
import { runGuestFile } from './guest.js'
|
|
20
|
+
import { compatDir } from './state.js'
|
|
21
|
+
import { extractTar } from './tar.js'
|
|
22
|
+
|
|
23
|
+
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i
|
|
24
|
+
|
|
25
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
26
|
+
export function sidecarRoot(env = process.env) {
|
|
27
|
+
return join(compatDir(env), 'sidecars')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} id */
|
|
31
|
+
export function validSidecarId(id) {
|
|
32
|
+
return typeof id === 'string' && ID_PATTERN.test(id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {string} path */
|
|
36
|
+
function safeRelative(path) {
|
|
37
|
+
if (typeof path !== 'string' || path === '' || path.startsWith('/') || path.includes('\\')) return undefined
|
|
38
|
+
const normalized = posix.normalize(path).replace(/^\.\//, '')
|
|
39
|
+
if (normalized.startsWith('../') || normalized === '..') return undefined
|
|
40
|
+
return normalized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} id */
|
|
44
|
+
export function sidecarDir(id, env = process.env) {
|
|
45
|
+
if (!validSidecarId(id)) throw Object.assign(new Error('invalid sidecar id'), { code: 'INVALID_ID' })
|
|
46
|
+
return join(sidecarRoot(env), id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} id */
|
|
50
|
+
export function readSidecar(id, env = process.env) {
|
|
51
|
+
try {
|
|
52
|
+
const meta = JSON.parse(readFileSync(join(sidecarDir(id, env), 'meta.json'), 'utf8'))
|
|
53
|
+
return meta !== null && typeof meta === 'object' ? meta : undefined
|
|
54
|
+
} catch { return undefined }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
58
|
+
export function listSidecars(env = process.env) {
|
|
59
|
+
const root = sidecarRoot(env)
|
|
60
|
+
let entries
|
|
61
|
+
try { entries = readdirSync(root, { withFileTypes: true }) } catch { return [] }
|
|
62
|
+
const result = []
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.isDirectory()) continue
|
|
65
|
+
const meta = readSidecar(entry.name, env)
|
|
66
|
+
if (meta !== undefined) result.push(meta)
|
|
67
|
+
}
|
|
68
|
+
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stage a worker file from a tarball.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} tarball
|
|
75
|
+
* @param {string} workerPath - path inside the package, e.g. `runtime/linux-arm64/<worker>`.
|
|
76
|
+
* @param {{ id?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
77
|
+
*/
|
|
78
|
+
export function stageSidecar(tarball, workerPath, options = {}) {
|
|
79
|
+
const env = options.env ?? process.env
|
|
80
|
+
const relative = safeRelative(workerPath)
|
|
81
|
+
if (relative === undefined) return { ok: false, error: 'workerPath must be a relative path inside the package' }
|
|
82
|
+
if (typeof tarball !== 'string' || !existsSync(tarball)) return { ok: false, error: `tarball not found: ${String(tarball)}` }
|
|
83
|
+
|
|
84
|
+
let entries
|
|
85
|
+
try { entries = extractTar(readFileSync(tarball)).entries } catch (error) {
|
|
86
|
+
return { ok: false, error: `cannot read tarball: ${String(error?.message ?? error)}` }
|
|
87
|
+
}
|
|
88
|
+
const entry = entries.find(item => item.type === 'file' && item.path === `package/${relative}`)
|
|
89
|
+
if (entry?.data === undefined) return { ok: false, error: `worker not found in tarball: package/${relative}` }
|
|
90
|
+
const elf = elfSummary(entry.data)
|
|
91
|
+
if (elf === undefined) return { ok: false, error: 'worker is not an AArch64 ELF image' }
|
|
92
|
+
|
|
93
|
+
const id = options.id ?? `worker-${elf.type.toLowerCase()}`
|
|
94
|
+
if (!validSidecarId(id)) return { ok: false, error: `invalid sidecar id: ${String(id)}` }
|
|
95
|
+
const target = sidecarDir(id, env)
|
|
96
|
+
const staging = `${target}.staging-${process.pid}-${Date.now()}`
|
|
97
|
+
try {
|
|
98
|
+
rmSync(staging, { recursive: true, force: true })
|
|
99
|
+
mkdirSync(staging, { recursive: true })
|
|
100
|
+
writeFileSync(join(staging, 'worker'), entry.data, { mode: 0o755 })
|
|
101
|
+
const script = [
|
|
102
|
+
'#!/bin/sh',
|
|
103
|
+
'set -e',
|
|
104
|
+
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
105
|
+
'exec "$DIR/worker" "$@"',
|
|
106
|
+
'',
|
|
107
|
+
].join('\n')
|
|
108
|
+
writeFileSync(join(staging, 'worker.sh'), script, { mode: 0o755 })
|
|
109
|
+
const sha256 = createHash('sha256').update(entry.data).digest('hex')
|
|
110
|
+
const meta = {
|
|
111
|
+
id,
|
|
112
|
+
worker: join(target, 'worker'),
|
|
113
|
+
script: join(target, 'worker.sh'),
|
|
114
|
+
workerPath: relative,
|
|
115
|
+
sha256,
|
|
116
|
+
bytes: entry.data.length,
|
|
117
|
+
elf,
|
|
118
|
+
source: tarball,
|
|
119
|
+
stagedAt: new Date().toISOString(),
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(join(staging, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
|
122
|
+
rmSync(target, { recursive: true, force: true })
|
|
123
|
+
renameSync(staging, target)
|
|
124
|
+
return { ok: true, ...meta }
|
|
125
|
+
} catch (error) {
|
|
126
|
+
try { rmSync(staging, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
127
|
+
return { ok: false, error: `cannot stage sidecar: ${String(error?.message ?? error)}` }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Call a staged worker with one JSON request.
|
|
133
|
+
*
|
|
134
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
135
|
+
* @param {string} id
|
|
136
|
+
* @param {unknown} input
|
|
137
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
|
|
138
|
+
*/
|
|
139
|
+
export async function callSidecar(ctx, id, input, options = {}) {
|
|
140
|
+
const env = options.env ?? process.env
|
|
141
|
+
const meta = readSidecar(id, env)
|
|
142
|
+
if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
|
|
143
|
+
return { ok: false, error: `sidecar not staged: ${String(id)}` }
|
|
144
|
+
}
|
|
145
|
+
const file = typeof meta.script === 'string' ? meta.script : meta.worker
|
|
146
|
+
const result = await runGuestFile(ctx, file, [], JSON.stringify(input ?? {}), {
|
|
147
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
148
|
+
signal: options.signal,
|
|
149
|
+
})
|
|
150
|
+
if (result.status !== 'ok') {
|
|
151
|
+
return { ok: false, status: result.status, error: result.reason ?? `worker exited ${result.exitCode}`, stdout: result.stdout, stderr: result.stderr }
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return { ok: true, value: JSON.parse(String(result.stdout).trim()), stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, error: 'worker returned invalid JSON', stdout: result.stdout, stderr: result.stderr }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ask a staged worker for its version.
|
|
162
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
163
|
+
* @param {string} id
|
|
164
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
|
|
165
|
+
*/
|
|
166
|
+
export async function probeSidecar(ctx, id, options = {}) {
|
|
167
|
+
const env = options.env ?? process.env
|
|
168
|
+
const meta = readSidecar(id, env)
|
|
169
|
+
if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
|
|
170
|
+
return { ok: false, error: `sidecar not staged: ${String(id)}` }
|
|
171
|
+
}
|
|
172
|
+
const file = typeof meta.script === 'string' ? meta.script : meta.worker
|
|
173
|
+
const result = await runGuestFile(ctx, file, ['--version'], '', {
|
|
174
|
+
timeoutMs: options.timeoutMs ?? 30000,
|
|
175
|
+
signal: options.signal,
|
|
176
|
+
})
|
|
177
|
+
return {
|
|
178
|
+
ok: result.status === 'ok',
|
|
179
|
+
status: result.status,
|
|
180
|
+
exitCode: result.exitCode,
|
|
181
|
+
stdout: result.stdout,
|
|
182
|
+
stderr: result.stderr,
|
|
183
|
+
error: result.reason,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort source build orchestration.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately opt-in. The reliable route for a source-only plugin is
|
|
5
|
+
* an external build (PC/CI); this module only attempts the same commands inside
|
|
6
|
+
* the Alpine guest after provisioning a toolchain, and reports the exact
|
|
7
|
+
* failure when the guest cannot do it.
|
|
8
|
+
*
|
|
9
|
+
* @module dshm-compat/source-build
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { loadArchive, resolveSpec } from './fetch.js'
|
|
15
|
+
import { provisionGuest, runGuest } from './guest.js'
|
|
16
|
+
import { compatDir } from './state.js'
|
|
17
|
+
import { extractTar, findRootPrefix } from './tar.js'
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} value */
|
|
20
|
+
function record(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {import('./tar.js').TarEntry[]} entries */
|
|
25
|
+
function selectRoot(entries) {
|
|
26
|
+
const candidates = entries
|
|
27
|
+
.filter(entry => entry.type === 'file' && (entry.path === 'package.json' || entry.path.endsWith('/package.json')))
|
|
28
|
+
.map(entry => ({ dir: entry.path === 'package.json' ? '' : entry.path.slice(0, -'/package.json'.length), depth: entry.path.split('/').length }))
|
|
29
|
+
.sort((a, b) => a.depth - b.depth)
|
|
30
|
+
return candidates.length === 0 ? '' : (candidates[0].dir === '' ? '' : `${candidates[0].dir}/`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} value */
|
|
34
|
+
function shellQuote(value) {
|
|
35
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect a generic build plan from package files.
|
|
40
|
+
* @param {{ path: string, data?: Buffer }[]} files
|
|
41
|
+
*/
|
|
42
|
+
export function detectSourceBuild(files) {
|
|
43
|
+
const byPath = new Map(files.map(file => [file.path, file]))
|
|
44
|
+
const text = path => byPath.get(path)?.data?.toString('utf8')
|
|
45
|
+
const cargoPath = ['native/Cargo.toml', 'Cargo.toml'].find(path => byPath.has(path))
|
|
46
|
+
if (cargoPath !== undefined) {
|
|
47
|
+
const cargo = text(cargoPath) ?? ''
|
|
48
|
+
const packageName = cargo.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1]
|
|
49
|
+
const binName = cargo.match(/\[\[bin\]\][\s\S]*?^\s*name\s*=\s*"([^"]+)"/m)?.[1]
|
|
50
|
+
const binary = binName ?? packageName ?? 'worker'
|
|
51
|
+
return {
|
|
52
|
+
kind: 'rust',
|
|
53
|
+
manifest: cargoPath,
|
|
54
|
+
binary,
|
|
55
|
+
command: 'cargo build --release',
|
|
56
|
+
output: `target/release/${binary}`,
|
|
57
|
+
packages: ['rust', 'cargo', 'build-base', 'linux-headers'],
|
|
58
|
+
artifact: 'worker',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const packageText = text('package.json')
|
|
62
|
+
if (packageText !== undefined) {
|
|
63
|
+
let pkg
|
|
64
|
+
try { pkg = JSON.parse(packageText) } catch { pkg = undefined }
|
|
65
|
+
const build = typeof pkg?.scripts?.build === 'string' ? pkg.scripts.build : typeof pkg?.scripts?.prepack === 'string' ? pkg.scripts.prepack : undefined
|
|
66
|
+
if (build !== undefined) {
|
|
67
|
+
const install = byPath.has('package-lock.json') || byPath.has('npm-shrinkwrap.json') ? 'npm ci' : 'npm install'
|
|
68
|
+
return {
|
|
69
|
+
kind: 'node',
|
|
70
|
+
manifest: 'package.json',
|
|
71
|
+
command: `${install} && npm run build && npm pack`,
|
|
72
|
+
output: '*.tgz',
|
|
73
|
+
packages: ['nodejs', 'npm', 'python3', 'build-base', 'linux-headers', 'git'],
|
|
74
|
+
artifact: 'tarball',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} spec
|
|
83
|
+
* @param {{ env?: NodeJS.ProcessEnv, signal?: AbortSignal }} [options]
|
|
84
|
+
*/
|
|
85
|
+
export async function prepareSource(spec, options = {}) {
|
|
86
|
+
const env = options.env ?? process.env
|
|
87
|
+
const resolved = await resolveSpec(spec, { signal: options.signal })
|
|
88
|
+
const archive = await loadArchive(resolved, { signal: options.signal })
|
|
89
|
+
const { entries } = extractTar(archive)
|
|
90
|
+
const prefix = findRootPrefix(entries)
|
|
91
|
+
const top = entries
|
|
92
|
+
.filter(entry => entry.type === 'file')
|
|
93
|
+
.map(entry => ({ ...entry, path: prefix !== '' && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path }))
|
|
94
|
+
const root = selectRoot(top)
|
|
95
|
+
const files = top
|
|
96
|
+
.filter(entry => entry.path.startsWith(root))
|
|
97
|
+
.map(entry => ({ ...entry, path: entry.path.slice(root.length) }))
|
|
98
|
+
const plan = detectSourceBuild(files)
|
|
99
|
+
if (plan === undefined) return { ok: false, error: 'no generic build plan (Cargo.toml or package.json build/prepack)' }
|
|
100
|
+
|
|
101
|
+
const safe = String(resolved.name ?? 'source').replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
102
|
+
const fingerprint = createHash('sha256').update(`${resolved.name ?? ''}@${resolved.version ?? ''}`).digest('hex').slice(0, 12)
|
|
103
|
+
const dir = join(compatDir(env), 'builds', 'source', `${safe}-${fingerprint}`)
|
|
104
|
+
rmSync(dir, { recursive: true, force: true })
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
if (file.data === undefined) continue
|
|
107
|
+
const target = join(dir, ...file.path.split('/'))
|
|
108
|
+
mkdirSync(join(target, '..'), { recursive: true })
|
|
109
|
+
writeFileSync(target, file.data, { mode: file.mode & 0o777 })
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, dir, plan, packageName: resolved.name, version: resolved.version }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
116
|
+
* @param {string} spec
|
|
117
|
+
* @param {{ backend?: 'plan'|'guest', env?: NodeJS.ProcessEnv, signal?: AbortSignal, timeoutMs?: number }} [options]
|
|
118
|
+
*/
|
|
119
|
+
export async function buildSource(ctx, spec, options = {}) {
|
|
120
|
+
const prepared = await prepareSource(spec, options)
|
|
121
|
+
if (prepared.ok !== true) return { status: 'unsupported', ...prepared }
|
|
122
|
+
const { dir, plan } = prepared
|
|
123
|
+
const base = {
|
|
124
|
+
status: 'planned',
|
|
125
|
+
source: { name: prepared.packageName, version: prepared.version },
|
|
126
|
+
directory: dir,
|
|
127
|
+
plan: {
|
|
128
|
+
kind: plan.kind,
|
|
129
|
+
commands: plan.command,
|
|
130
|
+
output: plan.output,
|
|
131
|
+
packages: plan.packages,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
if (options.backend !== 'guest') {
|
|
135
|
+
return { ...base, message: 'run this plan on a PC/CI, or pass backend:"guest" to attempt it in the Alpine guest' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const provision = await provisionGuest(ctx, plan.packages, { timeoutMs: options.timeoutMs ?? 15 * 60 * 1000, signal: options.signal })
|
|
139
|
+
if (provision.status !== 'ok') {
|
|
140
|
+
return { ...base, status: 'provision-failed', provision }
|
|
141
|
+
}
|
|
142
|
+
const command = `set -e\ncd ${shellQuote(dir)}\n${plan.command}`
|
|
143
|
+
const result = await runGuest(ctx, command, { timeoutMs: options.timeoutMs ?? 30 * 60 * 1000, signal: options.signal })
|
|
144
|
+
if (result.status !== 'ok') {
|
|
145
|
+
return { ...base, status: 'build-failed', provision, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let artifact
|
|
149
|
+
if (plan.kind === 'rust') artifact = join(dir, plan.output)
|
|
150
|
+
else {
|
|
151
|
+
const packed = result.stdout?.match(/[^\s]+\.tgz/)?.[0]
|
|
152
|
+
artifact = packed !== undefined ? join(dir, packed.replace(/^\.\//, '')) : join(dir, 'package.tgz')
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
status: 'ok',
|
|
157
|
+
artifact,
|
|
158
|
+
provision,
|
|
159
|
+
stdout: result.stdout,
|
|
160
|
+
stderr: result.stderr,
|
|
161
|
+
next: plan.kind === 'rust'
|
|
162
|
+
? `compat_convert { spec: ${JSON.stringify(spec)}, workerPath: ${JSON.stringify(artifact)} }`
|
|
163
|
+
: `compat_convert { spec: ${JSON.stringify(artifact)} }`,
|
|
164
|
+
}
|
|
165
|
+
}
|