dshm-compat 0.1.3 → 0.1.4
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 +33 -9
- package/INSTALL.md +6 -6
- package/README.md +27 -11
- 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 +14 -8
- package/docs/RECIPES.md +50 -81
- package/index.js +46 -1
- package/lib/analyze.js +6 -1
- package/lib/convert.js +4 -3
- package/lib/elf.js +41 -0
- package/lib/guest.js +3 -2
- package/lib/plan.js +10 -0
- package/lib/release.js +118 -0
- package/lib/rules.js +7 -22
- package/lib/s2-native.js +254 -0
- package/lib/sidecar.js +174 -0
- package/lib/zip.js +59 -0
- package/package.json +3 -1
- package/lib/recipes/dsh-ops.js +0 -209
package/lib/release.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic GitHub Release worker acquisition.
|
|
3
|
+
*
|
|
4
|
+
* Some native plugins do not ship a runtime in their npm tarball but publish
|
|
5
|
+
* per-platform workers as release assets. This module finds a Linux asset,
|
|
6
|
+
* extracts it, and returns the first AArch64/x86_64 ELF. It is repository
|
|
7
|
+
* agnostic; no package name is special-cased.
|
|
8
|
+
*
|
|
9
|
+
* @module dshm-compat/release
|
|
10
|
+
*/
|
|
11
|
+
import { basename } from 'node:path'
|
|
12
|
+
import { elfArch } from './elf.js'
|
|
13
|
+
import { downloadBuffer } from './fetch.js'
|
|
14
|
+
import { extractTar } from './tar.js'
|
|
15
|
+
import { extractZip } from './zip.js'
|
|
16
|
+
|
|
17
|
+
const API = 'https://api.github.com'
|
|
18
|
+
|
|
19
|
+
/** @param {any} report */
|
|
20
|
+
export function githubRepo(report) {
|
|
21
|
+
if (report?.resolved?.kind === 'github' && typeof report.resolved.name === 'string') {
|
|
22
|
+
const [owner, repo] = report.resolved.name.split('/')
|
|
23
|
+
if (owner && repo) return { owner, repo }
|
|
24
|
+
}
|
|
25
|
+
const repository = report?.package?.repository
|
|
26
|
+
const url = typeof repository === 'string' ? repository : repository?.url
|
|
27
|
+
if (typeof url === 'string') {
|
|
28
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/#?]+)/i)
|
|
29
|
+
if (match) return { owner: match[1], repo: match[2].replace(/\.git$/i, '') }
|
|
30
|
+
}
|
|
31
|
+
const tarball = report?.resolved?.tarballUrl
|
|
32
|
+
if (typeof tarball === 'string') {
|
|
33
|
+
const match = tarball.match(/api\.github\.com\/repos\/([^/]+)\/([^/]+)/i)
|
|
34
|
+
if (match) return { owner: match[1], repo: match[2] }
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string} url @param {AbortSignal} [signal] */
|
|
40
|
+
async function fetchJson(url, signal) {
|
|
41
|
+
const timeout = AbortSignal.timeout(20000)
|
|
42
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
|
|
43
|
+
const response = await fetch(url, {
|
|
44
|
+
signal: combined,
|
|
45
|
+
headers: { accept: 'application/vnd.github+json', 'user-agent': 'dshm-compat' },
|
|
46
|
+
})
|
|
47
|
+
if (!response.ok) throw new Error(`GitHub API ${response.status} for ${url}`)
|
|
48
|
+
return await response.json()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} owner @param {string} repo @param {AbortSignal} [signal] */
|
|
52
|
+
async function releaseAssets(owner, repo, signal) {
|
|
53
|
+
try {
|
|
54
|
+
const latest = await fetchJson(`${API}/repos/${owner}/${repo}/releases/latest`, signal)
|
|
55
|
+
if (Array.isArray(latest?.assets)) return latest.assets
|
|
56
|
+
} catch { /* fall through to the list endpoint */ }
|
|
57
|
+
const releases = await fetchJson(`${API}/repos/${owner}/${repo}/releases?per_page=5`, signal)
|
|
58
|
+
/** @type {any[]} */
|
|
59
|
+
const assets = []
|
|
60
|
+
for (const release of Array.isArray(releases) ? releases : []) {
|
|
61
|
+
if (Array.isArray(release?.assets)) assets.push(...release.assets)
|
|
62
|
+
}
|
|
63
|
+
return assets
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {any} asset */
|
|
67
|
+
function assetRank(asset) {
|
|
68
|
+
const name = String(asset?.name ?? '').toLowerCase()
|
|
69
|
+
if (!/\.(?:zip|tgz|tar\.gz)$/.test(name)) return undefined
|
|
70
|
+
if (/win32|windows|darwin|macos|source|checksum|sha256/.test(name)) return undefined
|
|
71
|
+
if (!/linux|musl/.test(name)) return undefined
|
|
72
|
+
if (/aarch64|arm64/.test(name)) return 0
|
|
73
|
+
if (/x86_64|amd64|x64/.test(name)) return 1
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {Buffer} archive @param {string} name */
|
|
78
|
+
function archiveEntries(archive, name) {
|
|
79
|
+
if (/\.zip$/i.test(name)) return extractZip(archive)
|
|
80
|
+
return extractTar(archive).entries
|
|
81
|
+
.filter(entry => entry.type === 'file')
|
|
82
|
+
.map(entry => ({ path: entry.path, data: entry.data }))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {any} report
|
|
87
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
88
|
+
* @returns {Promise<{ buffer: Buffer, name: string, arch: 'arm64'|'x64', asset: string } | undefined>}
|
|
89
|
+
*/
|
|
90
|
+
export async function findReleaseWorker(report, options = {}) {
|
|
91
|
+
const repo = githubRepo(report)
|
|
92
|
+
if (repo === undefined) return undefined
|
|
93
|
+
let assets
|
|
94
|
+
try { assets = await releaseAssets(repo.owner, repo.repo, options.signal) } catch { return undefined }
|
|
95
|
+
const ranked = assets
|
|
96
|
+
.map(asset => ({ asset, rank: assetRank(asset) }))
|
|
97
|
+
.filter(item => item.rank !== undefined)
|
|
98
|
+
.sort((a, b) => a.rank - b.rank)
|
|
99
|
+
for (const { asset } of ranked) {
|
|
100
|
+
if (typeof asset.browser_download_url !== 'string' || Number(asset.size ?? 0) > 64 * 1024 * 1024) continue
|
|
101
|
+
let archive
|
|
102
|
+
try { archive = await downloadBuffer(asset.browser_download_url, { maxBytes: 64 * 1024 * 1024, signal: options.signal }) } catch { continue }
|
|
103
|
+
let entries
|
|
104
|
+
try { entries = archiveEntries(archive, asset.name) } catch { continue }
|
|
105
|
+
const candidates = entries
|
|
106
|
+
.filter(entry => elfArch(entry.data) !== undefined)
|
|
107
|
+
.sort((a, b) => b.data.length - a.data.length)
|
|
108
|
+
if (candidates.length === 0) continue
|
|
109
|
+
const worker = candidates[0]
|
|
110
|
+
return {
|
|
111
|
+
buffer: worker.data,
|
|
112
|
+
name: basename(worker.path),
|
|
113
|
+
arch: elfArch(worker.data),
|
|
114
|
+
asset: asset.name,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
package/lib/rules.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Custom compatibility rules.
|
|
3
3
|
*
|
|
4
|
-
* Heuristics classify unknown packages. A rule is
|
|
5
|
-
* blocker and conversion recipe are already known; it wins
|
|
6
|
-
* and carries
|
|
4
|
+
* Heuristics classify unknown packages. A rule is an operator-supplied hint for
|
|
5
|
+
* a package whose exact blocker and conversion recipe are already known; it wins
|
|
6
|
+
* over the heuristic and carries build/run instructions the converter cannot
|
|
7
|
+
* infer. Built-in per-package rules are intentionally absent: adaptation lives
|
|
8
|
+
* in the generic engines, not in a package-name branch.
|
|
7
9
|
*
|
|
8
10
|
* A deployment can add rules in
|
|
9
11
|
* `$DSH_HOME/dshm-compat/registry.json`:
|
|
@@ -28,24 +30,7 @@ import { compatDir } from './state.js'
|
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
/** @type {Record<string, PackageRule>} */
|
|
31
|
-
export const PACKAGE_RULES = {
|
|
32
|
-
'dsh-ops': {
|
|
33
|
-
strategy: 'S2',
|
|
34
|
-
reason: 'the plugin is a Rust worker executable; the published package carries only win32-x64, linux-x64 and darwin-arm64 payloads',
|
|
35
|
-
next: [
|
|
36
|
-
'cross-build native/ for aarch64-unknown-linux-musl',
|
|
37
|
-
'run dsh-ops-worker in the Alpine guest and proxy the ops_* tools over RPC',
|
|
38
|
-
'rewrite PLATFORMS, package.json os/cpu and dsh-plugin.json targets for linux-arm64',
|
|
39
|
-
],
|
|
40
|
-
nativeBuild: {
|
|
41
|
-
language: 'rust',
|
|
42
|
-
target: 'aarch64-unknown-linux-musl',
|
|
43
|
-
crate: 'native/Cargo.toml',
|
|
44
|
-
binary: 'dsh-ops-worker',
|
|
45
|
-
notes: 'the crate is portable; windows/darwin metadata crates are target-gated',
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
}
|
|
33
|
+
export const PACKAGE_RULES = {}
|
|
49
34
|
|
|
50
35
|
const STRATEGIES = new Set(['S0', 'S1', 'S2', 'S3', 'S4'])
|
|
51
36
|
|
package/lib/s2-native.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
let workerBinary = options.workerBinary
|
|
61
|
+
let workerName = options.workerName
|
|
62
|
+
if (workerBinary === undefined && typeof options.workerPath === 'string') {
|
|
63
|
+
try {
|
|
64
|
+
workerBinary = readFileSync(options.workerPath)
|
|
65
|
+
workerName ??= basename(options.workerPath)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { status: 'needs-native-build', target: TARGET, command: BUILD_COMMAND, message: `cannot read worker: ${String(error?.message ?? error)}` }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (workerBinary === undefined) {
|
|
71
|
+
const candidate = files.find(file => file.type === 'file'
|
|
72
|
+
&& /^runtime\/linux-(?:arm64|x64)\//.test(file.path)
|
|
73
|
+
&& !file.path.endsWith('/provenance.json')
|
|
74
|
+
&& elfArch(file.data) !== undefined)
|
|
75
|
+
if (candidate !== undefined) {
|
|
76
|
+
workerBinary = candidate.data
|
|
77
|
+
workerName = basename(candidate.path)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let acquiredAsset
|
|
81
|
+
if (workerBinary === undefined) {
|
|
82
|
+
const releaseWorker = await findReleaseWorker(report, { signal: options.signal })
|
|
83
|
+
if (releaseWorker !== undefined) {
|
|
84
|
+
workerBinary = releaseWorker.buffer
|
|
85
|
+
workerName = releaseWorker.name
|
|
86
|
+
acquiredAsset = releaseWorker.asset
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (workerBinary === undefined) {
|
|
90
|
+
return {
|
|
91
|
+
status: 'needs-native-build',
|
|
92
|
+
target: TARGET,
|
|
93
|
+
command: BUILD_COMMAND,
|
|
94
|
+
message: `${packageName} needs a Linux arm64 or x64 worker; build one or pass workerPath to compat_convert`,
|
|
95
|
+
notes: [
|
|
96
|
+
'install a Rust toolchain and a musl aarch64 cross linker, or use cross/cargo-zigbuild',
|
|
97
|
+
'stage the result at runtime/linux-arm64/<worker> or runtime/linux-x64/<worker>, or pass it as workerPath',
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const workerArch = elfArch(workerBinary)
|
|
102
|
+
if (workerArch === undefined) {
|
|
103
|
+
return { status: 'invalid-worker', message: 'worker is not an AArch64 or x86_64 ELF image' }
|
|
104
|
+
}
|
|
105
|
+
const runtimeTarget = workerArch === 'arm64' ? 'linux-arm64' : 'linux-x64'
|
|
106
|
+
workerName ??= 'worker'
|
|
107
|
+
|
|
108
|
+
const runtimeEntry = files.find(file => file.type === 'file'
|
|
109
|
+
&& file.path.endsWith('.js')
|
|
110
|
+
&& file.data?.toString('utf8').includes('export const PLATFORMS = Object.freeze(['))
|
|
111
|
+
if (runtimeEntry === undefined) {
|
|
112
|
+
return { status: 'recipe-mismatch', message: `${packageName} has no JS file exposing the PLATFORMS matrix` }
|
|
113
|
+
}
|
|
114
|
+
const runtime = runtimeEntry.data.toString('utf8')
|
|
115
|
+
const platformsMatch = runtime.match(/export const PLATFORMS = Object\.freeze\(\[([^\]]*)\]\)/)
|
|
116
|
+
const platforms = platformsMatch[1]
|
|
117
|
+
.split(',')
|
|
118
|
+
.map(item => item.trim().replace(/^['"]|['"]$/g, ''))
|
|
119
|
+
.filter(item => item !== '')
|
|
120
|
+
if (!platforms.includes(runtimeTarget)) platforms.push(runtimeTarget)
|
|
121
|
+
const platformLine = `export const PLATFORMS = Object.freeze([${platforms.map(item => `'${item}'`).join(', ')}])`
|
|
122
|
+
const targetHelper = "\nconst resolveTarget = (platform, arch) => (platform === 'openharmony' || (platform === 'linux' && arch === 'arm64')) ? '" + runtimeTarget + "' : `${platform}-${arch}`\n"
|
|
123
|
+
let patchedRuntime = runtime.replaceAll('${platform}-${arch}', '${resolveTarget(platform, arch)}')
|
|
124
|
+
patchedRuntime = patchedRuntime.replace(platformsMatch[0], `${platformLine}${targetHelper}`)
|
|
125
|
+
const signature = 'export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch } = {}) {'
|
|
126
|
+
if (!patchedRuntime.includes(signature)) return { status: 'recipe-mismatch', message: 'resolveWorker signature changed' }
|
|
127
|
+
|
|
128
|
+
const patchedSources = new Map()
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
if (file.type !== 'file' || !file.path.endsWith('.js') || file.data === undefined) continue
|
|
131
|
+
let text = file.data.toString('utf8')
|
|
132
|
+
const before = text
|
|
133
|
+
text = text
|
|
134
|
+
.replaceAll('const argv = [file]', "const argv = ['bash', '-c', 'exec \"$0\" \"$@\"', file]")
|
|
135
|
+
.replaceAll('let argv = [file]', "let argv = ['bash', '-c', 'exec \"$0\" \"$@\"', file]")
|
|
136
|
+
if (text !== before) patchedSources.set(file.path, text)
|
|
137
|
+
}
|
|
138
|
+
if (patchedSources.size === 0) {
|
|
139
|
+
return { status: 'recipe-mismatch', message: `${packageName} has no standard native worker spawn (const/let argv = [file])` }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const pkgFile = byPath.get('package.json')
|
|
143
|
+
if (pkgFile?.data === undefined) return { status: 'invalid-package', message: 'package.json is missing' }
|
|
144
|
+
const pkg = JSON.parse(pkgFile.data.toString('utf8'))
|
|
145
|
+
delete pkg.os
|
|
146
|
+
delete pkg.cpu
|
|
147
|
+
// DSHM core is a prerelease (e.g. 0.2.1-alpha.4+dshm.56). The upstream
|
|
148
|
+
// ^0.2.0-rc.2 range does not match 0.2.1-*, and the bundle loader would throw
|
|
149
|
+
// on the next boot. "workspace:*" follows the running core instead.
|
|
150
|
+
if (pkg.peerDependencies !== null && typeof pkg.peerDependencies === 'object' && !Array.isArray(pkg.peerDependencies)) {
|
|
151
|
+
if (typeof pkg.peerDependencies['@deepseek-ai/dsh'] === 'string') {
|
|
152
|
+
pkg.peerDependencies['@deepseek-ai/dsh'] = 'workspace:*'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (pkg.optionalDependencies !== null && typeof pkg.optionalDependencies === 'object') {
|
|
156
|
+
for (const name of Object.keys(pkg.optionalDependencies)) {
|
|
157
|
+
if (/(?:^|[-/])(?:win32|darwin)(?:[-/]|$)/i.test(name)) delete pkg.optionalDependencies[name]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pluginFile = byPath.get('dsh-plugin.json')
|
|
162
|
+
if (pluginFile?.data !== undefined) {
|
|
163
|
+
const manifest = JSON.parse(pluginFile.data.toString('utf8'))
|
|
164
|
+
const components = manifest?.contributes?.['x-components']
|
|
165
|
+
if (Array.isArray(components)) {
|
|
166
|
+
for (const component of components) {
|
|
167
|
+
if (component === null || typeof component !== 'object') continue
|
|
168
|
+
if (Array.isArray(component.platforms) && !component.platforms.includes('openharmony')) component.platforms.push('openharmony')
|
|
169
|
+
if (Array.isArray(component.targets) && !component.targets.includes(runtimeTarget)) component.targets.push(runtimeTarget)
|
|
170
|
+
if (Array.isArray(component.architectures) && !component.architectures.includes('arm64')) component.architectures.push('arm64')
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
pluginFile.data = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const runtimeDir = `runtime/${runtimeTarget}`
|
|
177
|
+
const workerPath = `${runtimeDir}/${workerName}`
|
|
178
|
+
const provenancePath = `${runtimeDir}/provenance.json`
|
|
179
|
+
/** @type {{ path: string, data: Buffer, mode?: number }[]} */
|
|
180
|
+
const extraEntries = []
|
|
181
|
+
let stagedWorker = workerBinary
|
|
182
|
+
let workerSource = 'external aarch64-unknown-linux-musl build supplied by the caller'
|
|
183
|
+
if (workerArch === 'x64') {
|
|
184
|
+
try {
|
|
185
|
+
const wrapper = Buffer.from([
|
|
186
|
+
'#!/bin/sh',
|
|
187
|
+
'set -e',
|
|
188
|
+
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
189
|
+
`exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${workerName}-x64" "$@"`,
|
|
190
|
+
'',
|
|
191
|
+
].join('\n'))
|
|
192
|
+
stagedWorker = wrapper
|
|
193
|
+
workerSource = 'x86_64 worker executed through the bundled qemu-x86_64 and glibc rootfs'
|
|
194
|
+
extraEntries.push({ path: `${runtimeDir}/${workerName}-x64`, data: workerBinary, mode: 0o755 })
|
|
195
|
+
extraEntries.push({ path: `${runtimeDir}/qemu-x86_64`, data: readFileSync(join(QEMU_ROOT, 'qemu-x86_64')), mode: 0o755 })
|
|
196
|
+
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 })
|
|
197
|
+
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 })
|
|
198
|
+
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 })
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { status: 'invalid-output', error: `cannot stage the bundled qemu runtime: ${String(error?.message ?? error)}` }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const sha256 = createHash('sha256').update(stagedWorker).digest('hex')
|
|
204
|
+
const provenance = {
|
|
205
|
+
version: typeof pkg.version === 'string' ? pkg.version : '0.0.0',
|
|
206
|
+
target: runtimeTarget,
|
|
207
|
+
executable: workerName,
|
|
208
|
+
sha256,
|
|
209
|
+
source: workerSource,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @type {string[]} */
|
|
213
|
+
const changed = [
|
|
214
|
+
`added ${runtimeTarget} to PLATFORMS and mapped openharmony/arm64 to it`,
|
|
215
|
+
`wrapped ${patchedSources.size} native worker spawn(s) in bash -c so dshm-vm-bash routes them to the guest`,
|
|
216
|
+
'removed package.json os/cpu gates and non-Linux optional payloads',
|
|
217
|
+
'relaxed @deepseek-ai/dsh peer range to workspace:* for the DSHM core',
|
|
218
|
+
`added dsh-plugin.json openharmony/${runtimeTarget} targets`,
|
|
219
|
+
...(acquiredAsset === undefined ? [] : [`acquired ${workerName} from GitHub release asset ${acquiredAsset}`]),
|
|
220
|
+
workerArch === 'x64'
|
|
221
|
+
? `staged ${workerName} as a shell wrapper over qemu-x86_64 + glibc rootfs`
|
|
222
|
+
: `staged ${workerPath}`,
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
const output = files.filter(file => file.path !== workerPath).map(file => {
|
|
226
|
+
if (file.path === runtimeEntry.path) return { path: file.path, data: Buffer.from(patchedRuntime, 'utf8'), mode: file.mode }
|
|
227
|
+
if (patchedSources.has(file.path)) return { path: file.path, data: Buffer.from(patchedSources.get(file.path), 'utf8'), mode: file.mode }
|
|
228
|
+
if (file.path === 'package.json') return { path: file.path, data: Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8'), mode: file.mode }
|
|
229
|
+
return { path: file.path, data: file.data, mode: file.mode }
|
|
230
|
+
})
|
|
231
|
+
output.push({ path: workerPath, data: stagedWorker, mode: 0o755 })
|
|
232
|
+
output.push(...extraEntries)
|
|
233
|
+
output.push({ path: provenancePath, data: Buffer.from(`${JSON.stringify(provenance, null, 2)}\n`, 'utf8'), mode: 0o644 })
|
|
234
|
+
|
|
235
|
+
const packed = packTarGz(output)
|
|
236
|
+
const outputDir = join(compatDir(process.env), 'builds')
|
|
237
|
+
mkdirSync(outputDir, { recursive: true })
|
|
238
|
+
const safeName = String(packageName).replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
|
|
239
|
+
const tarball = join(outputDir, `${safeName}-${pkg.version ?? '0.0.0'}-dshm.tgz`)
|
|
240
|
+
writeFileSync(tarball, packed)
|
|
241
|
+
const validation = validateTarball(tarball)
|
|
242
|
+
if (!validation.ok) return { status: 'invalid-output', tarball, errors: validation.errors }
|
|
243
|
+
return {
|
|
244
|
+
status: 'ok',
|
|
245
|
+
tarball,
|
|
246
|
+
bytes: packed.length,
|
|
247
|
+
sha256: createHash('sha256').update(packed).digest('hex'),
|
|
248
|
+
changed,
|
|
249
|
+
warnings: validation.warnings,
|
|
250
|
+
validation,
|
|
251
|
+
worker: { target: workerArch === 'arm64' ? TARGET : 'x86_64-linux-gnu', arch: workerArch, sha256 },
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
package/lib/sidecar.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
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 sha256 = createHash('sha256').update(entry.data).digest('hex')
|
|
102
|
+
const meta = {
|
|
103
|
+
id,
|
|
104
|
+
worker: join(target, 'worker'),
|
|
105
|
+
workerPath: relative,
|
|
106
|
+
sha256,
|
|
107
|
+
bytes: entry.data.length,
|
|
108
|
+
elf,
|
|
109
|
+
source: tarball,
|
|
110
|
+
stagedAt: new Date().toISOString(),
|
|
111
|
+
}
|
|
112
|
+
writeFileSync(join(staging, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
|
113
|
+
rmSync(target, { recursive: true, force: true })
|
|
114
|
+
renameSync(staging, target)
|
|
115
|
+
return { ok: true, ...meta }
|
|
116
|
+
} catch (error) {
|
|
117
|
+
try { rmSync(staging, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
118
|
+
return { ok: false, error: `cannot stage sidecar: ${String(error?.message ?? error)}` }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Call a staged worker with one JSON request.
|
|
124
|
+
*
|
|
125
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
126
|
+
* @param {string} id
|
|
127
|
+
* @param {unknown} input
|
|
128
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
|
|
129
|
+
*/
|
|
130
|
+
export async function callSidecar(ctx, id, input, options = {}) {
|
|
131
|
+
const env = options.env ?? process.env
|
|
132
|
+
const meta = readSidecar(id, env)
|
|
133
|
+
if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
|
|
134
|
+
return { ok: false, error: `sidecar not staged: ${String(id)}` }
|
|
135
|
+
}
|
|
136
|
+
const result = await runGuestFile(ctx, meta.worker, [], JSON.stringify(input ?? {}), {
|
|
137
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
138
|
+
signal: options.signal,
|
|
139
|
+
})
|
|
140
|
+
if (result.status !== 'ok') {
|
|
141
|
+
return { ok: false, status: result.status, error: result.reason ?? `worker exited ${result.exitCode}`, stdout: result.stdout, stderr: result.stderr }
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return { ok: true, value: JSON.parse(String(result.stdout).trim()), stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
|
|
145
|
+
} catch {
|
|
146
|
+
return { ok: false, error: 'worker returned invalid JSON', stdout: result.stdout, stderr: result.stderr }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ask a staged worker for its version.
|
|
152
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
153
|
+
* @param {string} id
|
|
154
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
|
|
155
|
+
*/
|
|
156
|
+
export async function probeSidecar(ctx, id, options = {}) {
|
|
157
|
+
const env = options.env ?? process.env
|
|
158
|
+
const meta = readSidecar(id, env)
|
|
159
|
+
if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
|
|
160
|
+
return { ok: false, error: `sidecar not staged: ${String(id)}` }
|
|
161
|
+
}
|
|
162
|
+
const result = await runGuestFile(ctx, meta.worker, ['--version'], '', {
|
|
163
|
+
timeoutMs: options.timeoutMs ?? 30000,
|
|
164
|
+
signal: options.signal,
|
|
165
|
+
})
|
|
166
|
+
return {
|
|
167
|
+
ok: result.status === 'ok',
|
|
168
|
+
status: result.status,
|
|
169
|
+
exitCode: result.exitCode,
|
|
170
|
+
stdout: result.stdout,
|
|
171
|
+
stderr: result.stderr,
|
|
172
|
+
error: result.reason,
|
|
173
|
+
}
|
|
174
|
+
}
|
package/lib/zip.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ZIP reader for GitHub release assets.
|
|
3
|
+
*
|
|
4
|
+
* Supports stored (0) and deflate (8) entries and the classic EOCD layout.
|
|
5
|
+
* It intentionally rejects ZIP64 and encrypted archives: release worker
|
|
6
|
+
* payloads are small, and a missing worker is better than guessing.
|
|
7
|
+
*
|
|
8
|
+
* @module dshm-compat/zip
|
|
9
|
+
*/
|
|
10
|
+
import { inflateRawSync } from 'node:zlib'
|
|
11
|
+
|
|
12
|
+
const EOCD = 0x06054b50
|
|
13
|
+
const CENTRAL = 0x02014b50
|
|
14
|
+
const LOCAL = 0x04034b50
|
|
15
|
+
|
|
16
|
+
/** @param {Buffer} buffer */
|
|
17
|
+
export function extractZip(buffer) {
|
|
18
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('not a ZIP archive')
|
|
19
|
+
let eocd = -1
|
|
20
|
+
const start = Math.max(0, buffer.length - 65557)
|
|
21
|
+
for (let i = buffer.length - 22; i >= start; i--) {
|
|
22
|
+
if (buffer.readUInt32LE(i) === EOCD) { eocd = i; break }
|
|
23
|
+
}
|
|
24
|
+
if (eocd < 0) throw new Error('ZIP end-of-central-directory not found')
|
|
25
|
+
const count = buffer.readUInt16LE(eocd + 10)
|
|
26
|
+
const centralOffset = buffer.readUInt32LE(eocd + 16)
|
|
27
|
+
if (centralOffset === 0xffffffff || count === 0xffff) throw new Error('ZIP64 is not supported')
|
|
28
|
+
|
|
29
|
+
/** @type {{ path: string, data: Buffer }[]} */
|
|
30
|
+
const entries = []
|
|
31
|
+
let offset = centralOffset
|
|
32
|
+
for (let index = 0; index < count; index++) {
|
|
33
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) throw new Error('invalid ZIP central directory')
|
|
34
|
+
const method = buffer.readUInt16LE(offset + 10)
|
|
35
|
+
const compressedSize = buffer.readUInt32LE(offset + 20)
|
|
36
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24)
|
|
37
|
+
const nameLength = buffer.readUInt16LE(offset + 28)
|
|
38
|
+
const extraLength = buffer.readUInt16LE(offset + 30)
|
|
39
|
+
const commentLength = buffer.readUInt16LE(offset + 32)
|
|
40
|
+
const localOffset = buffer.readUInt32LE(offset + 42)
|
|
41
|
+
const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
|
|
42
|
+
offset += 46 + nameLength + extraLength + commentLength
|
|
43
|
+
if (name.endsWith('/')) continue
|
|
44
|
+
const normalized = name.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
45
|
+
if (normalized.startsWith('/') || normalized.split('/').includes('..')) throw new Error(`unsafe ZIP path: ${name}`)
|
|
46
|
+
if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error(`invalid ZIP local header: ${name}`)
|
|
47
|
+
const localNameLength = buffer.readUInt16LE(localOffset + 26)
|
|
48
|
+
const localExtraLength = buffer.readUInt16LE(localOffset + 28)
|
|
49
|
+
const dataOffset = localOffset + 30 + localNameLength + localExtraLength
|
|
50
|
+
const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize)
|
|
51
|
+
let data
|
|
52
|
+
if (method === 0) data = compressed
|
|
53
|
+
else if (method === 8) data = inflateRawSync(compressed)
|
|
54
|
+
else throw new Error(`unsupported ZIP compression method ${method} for ${name}`)
|
|
55
|
+
if (data.length !== uncompressedSize) throw new Error(`ZIP size mismatch for ${name}`)
|
|
56
|
+
entries.push({ path: normalized, data })
|
|
57
|
+
}
|
|
58
|
+
return entries
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshm-compat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Analyze and convert DSH plugins that cannot be installed or run on HarmonyOS DSHM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -24,12 +24,14 @@
|
|
|
24
24
|
"index.js",
|
|
25
25
|
"bin",
|
|
26
26
|
"lib",
|
|
27
|
+
"assets",
|
|
27
28
|
"cordis.patch.yml",
|
|
28
29
|
"locale/*.json",
|
|
29
30
|
"icon.svg",
|
|
30
31
|
"README.md",
|
|
31
32
|
"INSTALL.md",
|
|
32
33
|
"CHANGELOG.md",
|
|
34
|
+
"AGENTS.md",
|
|
33
35
|
"docs/*.md"
|
|
34
36
|
],
|
|
35
37
|
"engines": {
|