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.
@@ -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
+ }