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/index.js CHANGED
@@ -14,7 +14,10 @@ import { runDoctor } from './lib/doctor.js'
14
14
  import { probeGuest, provisionGuest } from './lib/guest.js'
15
15
  import { enqueueInstall } from './lib/install.js'
16
16
  import { installLocalTarball, isLocalSpec } from './lib/local-install.js'
17
+ import { uninstallLocalPackage } from './lib/uninstall.js'
17
18
  import { buildPlan } from './lib/plan.js'
19
+ import { callSidecar, listSidecars, probeSidecar, stageSidecar } from './lib/sidecar.js'
20
+ import { buildSource } from './lib/source-build.js'
18
21
  import { recordInstall, recordReport, status } from './lib/state.js'
19
22
 
20
23
  export const name = 'dshm-compat'
@@ -152,7 +155,7 @@ export function apply(ctx) {
152
155
  profileDir: { type: 'string', description: 'Absolute DSHM profile directory for install:true. Omit to let the host choose.' },
153
156
  confirm: { type: 'boolean', description: 'Required when install is true. Confirms that installing the converted package runs third-party code.' },
154
157
  vendor: { type: 'boolean', description: 'Vendor missing pure-JS runtime dependencies into the tarball. Default true.' },
155
- workerPath: { type: 'string', description: 'Absolute path to a prebuilt AArch64 Linux worker for recipes that need one (currently dsh-ops). Omit to receive the build command instead.' },
158
+ workerPath: { type: 'string', description: 'Absolute path to a prebuilt AArch64 Linux worker for an S2 plugin. Omit to let the generic adapter return the build command.' },
156
159
  allowLocal: { type: 'boolean', description: 'When install is true, unpack the converted tarball into the profile instead of the host queue. Default true; set false to force the queue.' },
157
160
  },
158
161
  required: ['spec'],
@@ -232,6 +235,106 @@ export function apply(ctx) {
232
235
  },
233
236
  ), 'compat_guest_provision')
234
237
 
238
+ register(definition(
239
+ 'compat_sidecar',
240
+ 'Stage and call a native Linux/AArch64 worker in the DSHM Alpine guest. Actions: stage (extract a worker from a converted tarball into $DSH_HOME/dshm-compat/sidecars/<id>), list, probe (run --version), call (one JSON request on stdin, one JSON value on stdout; requires confirm:true). '
241
+ + 'This is the generic S2 bridge; the worker runs through bash -c and is routed to the guest by dshm-vm-bash.',
242
+ {
243
+ type: 'object',
244
+ properties: {
245
+ action: { type: 'string', enum: ['stage', 'list', 'probe', 'call'] },
246
+ id: { type: 'string', description: 'Sidecar id (letters, digits, dot, underscore, dash).' },
247
+ tarball: { type: 'string', description: 'Converted package tarball containing the worker.' },
248
+ workerPath: { type: 'string', description: 'Worker path inside the tarball, e.g. runtime/linux-arm64/<worker>.' },
249
+ input: { type: 'object', additionalProperties: true, description: 'JSON request for action=call. Default {}.' },
250
+ timeoutMs: { type: 'integer', description: 'Per-call timeout. Default 120000.' },
251
+ confirm: { type: 'boolean', description: 'Required for action=call: confirms that the worker runs native code in the guest.' },
252
+ },
253
+ required: ['action'],
254
+ additionalProperties: false,
255
+ },
256
+ async (args, exec) => {
257
+ try {
258
+ if (args?.action === 'stage') {
259
+ if (typeof args.tarball !== 'string' || typeof args.workerPath !== 'string') {
260
+ return { status: 'error', error: { code: 'INVALID_ARGUMENT', message: 'stage requires tarball and workerPath' } }
261
+ }
262
+ const result = stageSidecar(args.tarball, args.workerPath, { id: args.id })
263
+ return result.ok ? { status: 'ok', ...result } : { status: 'error', ...result }
264
+ }
265
+ if (args?.action === 'list') return { status: 'ok', sidecars: listSidecars() }
266
+ if (args?.action === 'probe') {
267
+ if (typeof args.id !== 'string') return { status: 'error', error: { code: 'INVALID_ARGUMENT', message: 'probe requires id' } }
268
+ return { status: 'ok', ...(await probeSidecar(ctx, args.id, { timeoutMs: args.timeoutMs, signal: exec?.signal })) }
269
+ }
270
+ if (args?.action === 'call') {
271
+ if (typeof args.id !== 'string') return { status: 'error', error: { code: 'INVALID_ARGUMENT', message: 'call requires id' } }
272
+ if (args.confirm !== true) return { status: 'confirmation-required', message: 'Re-run with confirm:true to execute the worker in the guest.' }
273
+ return { status: 'ok', ...(await callSidecar(ctx, args.id, args.input ?? {}, { timeoutMs: args.timeoutMs, signal: exec?.signal })) }
274
+ }
275
+ return { status: 'error', error: { code: 'INVALID_ACTION', message: `unknown sidecar action: ${String(args?.action)}` } }
276
+ } catch (error) {
277
+ return errorResult(error)
278
+ }
279
+ },
280
+ ), 'compat_sidecar')
281
+
282
+ register(definition(
283
+ 'compat_build',
284
+ 'Plan or attempt a source build. backend:"plan" returns the exact PC/CI commands; backend:"guest" provisions Node/Rust in the Alpine guest and tries the build there (slow, needs guest network/disk; requires confirm:true). Returns the artifact path on success.',
285
+ {
286
+ type: 'object',
287
+ properties: {
288
+ spec: { type: 'string', description: 'Source package: npm name, GitHub URL, or local .tgz.' },
289
+ backend: { type: 'string', enum: ['plan', 'guest'], description: 'plan (default) or guest.' },
290
+ timeoutMs: { type: 'integer', description: 'Guest build timeout. Default 30 minutes.' },
291
+ confirm: { type: 'boolean', description: 'Required for backend:"guest". Confirms provisioning a toolchain and running build scripts in the guest.' },
292
+ },
293
+ required: ['spec'],
294
+ additionalProperties: false,
295
+ },
296
+ async (args, exec) => {
297
+ try {
298
+ if (args?.backend === 'guest' && args.confirm !== true) {
299
+ return { status: 'confirmation-required', message: 'Re-run with confirm:true to provision a toolchain and run source build scripts in the guest.' }
300
+ }
301
+ return await buildSource(ctx, args.spec, {
302
+ backend: args.backend === 'guest' ? 'guest' : 'plan',
303
+ timeoutMs: args.timeoutMs,
304
+ signal: exec?.signal,
305
+ })
306
+ } catch (error) {
307
+ return errorResult(error)
308
+ }
309
+ },
310
+ ), 'compat_build')
311
+
312
+ register(definition(
313
+ 'compat_uninstall',
314
+ 'Remove a profile-local plugin: delete profiles/<profile>/node_modules/<name>, remove its dependency entry, and remove it from dsh.profile.bundles. Requires confirm:true and a DSHM restart.',
315
+ {
316
+ type: 'object',
317
+ properties: {
318
+ name: { type: 'string', description: 'Installed package name, e.g. @scope/plugin.' },
319
+ profileDir: { type: 'string', description: 'Absolute DSHM profile directory. Omit to infer it.' },
320
+ confirm: { type: 'boolean', description: 'Must be true. Confirms removing the package from the profile.' },
321
+ },
322
+ required: ['name', 'confirm'],
323
+ additionalProperties: false,
324
+ },
325
+ async args => {
326
+ if (args?.confirm !== true) {
327
+ return { status: 'confirmation-required', message: 'Re-run with confirm:true to remove the package from the profile.' }
328
+ }
329
+ try {
330
+ const result = uninstallLocalPackage(args.name, { profileDir: args.profileDir })
331
+ return result.ok ? { status: 'ok', ...result } : { status: 'error', ...result }
332
+ } catch (error) {
333
+ return errorResult(error)
334
+ }
335
+ },
336
+ ), 'compat_uninstall')
337
+
235
338
  register(definition(
236
339
  'compat_install',
237
340
  'Install an already-built plugin artifact. Local .tgz paths (or file:/link: paths) are unpacked into the owning DSH profile and its bundle list, because DSHM\'s host queue rejects local specs; npm names and GitHub repositories still go through the host install queue. '
package/lib/analyze.js CHANGED
@@ -245,10 +245,11 @@ export async function analyze(spec, options = {}) {
245
245
  confidence = 'high'
246
246
  reasons.push('the archive carries only win32 payloads and no C/C++/Rust source to rebuild')
247
247
  next.push('ask the plugin author for source or a linux-arm64 build')
248
- } else if (nativeFiles.length > 0 || unknownNativeDeps.length > 0 || electronSurface) {
248
+ } else if (nativeFiles.length > 0 || unknownNativeDeps.length > 0 || electronSurface || hasNativeSource) {
249
249
  strategy = 'S2'
250
250
  confidence = 'medium'
251
251
  if (nativeFiles.length > 0) reasons.push(`${nativeFiles.length} native file(s) need a linux-arm64 runtime outside the DSHM host process`)
252
+ if (hasNativeSource && nativeFiles.length === 0) reasons.push('native source needs a Linux/guest build; no prebuilt runtime is present')
252
253
  if (unknownNativeDeps.length > 0) reasons.push(`unrecognized native dependencies: ${unknownNativeDeps.map(dep => dep.name).join(', ')}`)
253
254
  if (Object.keys(installScripts).length > 0) reasons.push(`install scripts: ${Object.keys(installScripts).join(', ')}`)
254
255
  if (electronSurface) reasons.push(`Electron API surface detected (${scan.electron} reference(s)); needs a shim or a sidecar UI`)
@@ -328,6 +329,9 @@ export async function analyze(spec, options = {}) {
328
329
  description: pkg.description,
329
330
  type: pkg.type,
330
331
  main: pkg.main,
332
+ repository: pkg.repository,
333
+ homepage: pkg.homepage,
334
+ bugs: pkg.bugs,
331
335
  os: pkg.os,
332
336
  cpu: pkg.cpu,
333
337
  scripts,
@@ -360,6 +364,10 @@ export async function analyze(spec, options = {}) {
360
364
  },
361
365
  nativeDeps,
362
366
  unknownNativeDeps: unknownNativeDeps.map(dep => dep.name),
367
+ nativeSource: hasNativeSource,
368
+ rustSource: files.some(file => /(?:^|\/)Cargo\.toml$/.test(file.path) || /\.rs$/.test(file.path)),
369
+ cSource: files.some(file => /\.(?:c|cc|cpp)$/.test(file.path)),
370
+ nodeGyp: files.some(file => /(?:^|\/)binding\.gyp$/.test(file.path)),
363
371
  dependencies: {
364
372
  core: dependencyCheck.core,
365
373
  present: dependencyCheck.present.slice(0, 100),
package/lib/convert.js CHANGED
@@ -41,20 +41,69 @@ function safeName(name) {
41
41
  return String(name).replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
42
42
  }
43
43
 
44
+ /**
45
+ * A GitHub source checkout may have no built `main`. Prefer the package's
46
+ * published npm build instead of stopping at needs-build; this is generic and
47
+ * keeps the source version only when the registry has nothing newer/built.
48
+ *
49
+ * @param {any} report
50
+ * @param {any} options
51
+ */
52
+ async function registryFallback(report, options) {
53
+ const name = report.package?.name
54
+ if (typeof name !== 'string' || name === '' || options.registryFallback === true) return undefined
55
+ let resolved
56
+ try {
57
+ resolved = await resolveSpec(`${name}@latest`, { registry: options.registry, signal: options.signal })
58
+ } catch {
59
+ return undefined
60
+ }
61
+ const version = typeof resolved.version === 'string' ? resolved.version : undefined
62
+ if (version === undefined || version === report.package.version) return undefined
63
+ let fallbackReport
64
+ try {
65
+ fallbackReport = await analyze(`${name}@latest`, { textScan: false, signal: options.signal, registry: options.registry })
66
+ } catch {
67
+ return undefined
68
+ }
69
+ if (fallbackReport.strategy !== 'S2') return undefined
70
+ const { convertS2Native } = await import('./s2-native.js')
71
+ const converted = await convertS2Native(`${name}@latest`, {
72
+ report: fallbackReport,
73
+ workerPath: options.workerPath,
74
+ workerBinary: options.workerBinary,
75
+ workerName: options.workerName,
76
+ signal: options.signal,
77
+ registryFallback: true,
78
+ })
79
+ if (converted.status !== 'ok') return undefined
80
+ return {
81
+ ...converted,
82
+ fallback: { from: report.spec, to: `${name}@${version}`, version },
83
+ changed: [...(converted.changed ?? []), `replaced source checkout with published ${name}@${version}`],
84
+ }
85
+ }
86
+
44
87
  /**
45
88
  * @param {string} spec
46
- * @param {{ report?: any, registry?: string, signal?: AbortSignal, maxBytes?: number }} [options]
89
+ * @param {{ report?: any, registry?: string, signal?: AbortSignal, maxBytes?: number, workerPath?: string, workerBinary?: Buffer, workerName?: string, registryFallback?: boolean }} [options]
47
90
  */
48
91
  export async function convert(spec, options = {}) {
49
92
  const report = options.report ?? await analyze(spec, { textScan: false, signal: options.signal, registry: options.registry })
50
- if (report.package?.name === 'dsh-ops' && report.rule?.nativeBuild?.language === 'rust') {
51
- const { convertDshOps } = await import('./recipes/dsh-ops.js')
52
- return await convertDshOps(spec, {
93
+ if (report.strategy === 'S2') {
94
+ const { convertS2Native } = await import('./s2-native.js')
95
+ const result = await convertS2Native(spec, {
53
96
  report,
54
97
  workerPath: options.workerPath,
55
98
  workerBinary: options.workerBinary,
99
+ workerName: options.workerName,
56
100
  signal: options.signal,
57
101
  })
102
+ if (result.status === 'needs-build' && result.sourcePackage === true) {
103
+ const fallback = await registryFallback(report, options)
104
+ if (fallback !== undefined) return fallback
105
+ }
106
+ return result
58
107
  }
59
108
  if (report.strategy !== 'S0' && report.strategy !== 'S1') {
60
109
  return {
@@ -96,6 +145,11 @@ export async function convert(spec, options = {}) {
96
145
 
97
146
  if (pkg.os !== undefined) { delete pkg.os; changed.push('removed package.json os gate') }
98
147
  if (pkg.cpu !== undefined) { delete pkg.cpu; changed.push('removed package.json cpu gate') }
148
+ if (pkg.engines !== null && typeof pkg.engines === 'object' && !Array.isArray(pkg.engines) && pkg.engines.dsh !== undefined) {
149
+ delete pkg.engines.dsh
150
+ if (Object.keys(pkg.engines).length === 0) delete pkg.engines
151
+ changed.push('removed engines.dsh gate')
152
+ }
99
153
  if (pkg.scripts !== null && typeof pkg.scripts === 'object' && !Array.isArray(pkg.scripts)) {
100
154
  for (const name of ['preinstall', 'install', 'postinstall', 'prepare']) {
101
155
  if (typeof pkg.scripts[name] === 'string') { delete pkg.scripts[name]; changed.push(`removed scripts.${name}`) }
package/lib/elf.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Minimal AArch64 ELF checks shared by the generic S2 adapter and the generic
3
+ * sidecar staging path.
4
+ *
5
+ * @module dshm-compat/elf
6
+ */
7
+
8
+ /** @param {Buffer} buffer */
9
+ export function isAarch64Elf(buffer) {
10
+ return buffer.length >= 64
11
+ && buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46
12
+ && buffer[4] === 2 && buffer[5] === 1
13
+ && buffer.readUInt16LE(18) === 183
14
+ }
15
+
16
+ /** @param {Buffer} buffer */
17
+ export function isX64Elf(buffer) {
18
+ return buffer.length >= 64
19
+ && buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46
20
+ && buffer[4] === 2 && buffer[5] === 1
21
+ && buffer.readUInt16LE(18) === 62
22
+ }
23
+
24
+ /** @param {Buffer} buffer @returns {'arm64'|'x64'|undefined} */
25
+ export function elfArch(buffer) {
26
+ if (isAarch64Elf(buffer)) return 'arm64'
27
+ if (isX64Elf(buffer)) return 'x64'
28
+ return undefined
29
+ }
30
+
31
+ /** @param {Buffer} buffer */
32
+ export function elfSummary(buffer) {
33
+ if (!isAarch64Elf(buffer)) return undefined
34
+ return {
35
+ class: 64,
36
+ endian: 'little',
37
+ machine: 'AArch64',
38
+ type: buffer.readUInt16LE(16) === 3 ? 'DYN' : buffer.readUInt16LE(16) === 2 ? 'EXEC' : String(buffer.readUInt16LE(16)),
39
+ bytes: buffer.length,
40
+ }
41
+ }
package/lib/guest.js CHANGED
@@ -8,6 +8,8 @@
8
8
  *
9
9
  * @module dshm-compat/guest
10
10
  */
11
+ import { isAbsolute } from 'node:path'
12
+
11
13
  const PROBE_SCRIPT = [
12
14
  'echo DSHM_COMPAT_PROBE_BEGIN',
13
15
  'uname -srm 2>/dev/null || true',
@@ -19,7 +21,6 @@ const PROBE_SCRIPT = [
19
21
  'command -v python3 || true',
20
22
  'echo DSHM_COMPAT_PROBE_END',
21
23
  ].join('\n')
22
-
23
24
  /** Packages the provisioning tool may install. */
24
25
  export const PROVISION_PACKAGES = Object.freeze([
25
26
  'bash',
@@ -29,6 +30,8 @@ export const PROVISION_PACKAGES = Object.freeze([
29
30
  'python3',
30
31
  'build-base',
31
32
  'linux-headers',
33
+ 'rust',
34
+ 'cargo',
32
35
  'git',
33
36
  'ffmpeg',
34
37
  ])
@@ -98,7 +101,7 @@ export async function runGuest(ctx, script, options = {}) {
98
101
  * @param {{ timeoutMs?: number, signal?: AbortSignal, cwd?: string }} [options]
99
102
  */
100
103
  export async function runGuestFile(ctx, file, args = [], input = '', options = {}) {
101
- if (typeof file !== 'string' || !file.startsWith('/')) {
104
+ if (typeof file !== 'string' || !isAbsolute(file)) {
102
105
  return { status: 'invalid', reason: 'worker path must be absolute and visible inside the guest' }
103
106
  }
104
107
  const subprocess = ctx.get('subprocess')
@@ -112,7 +115,7 @@ export async function runGuestFile(ctx, file, args = [], input = '', options = {
112
115
  if (options.signal) signals.push(options.signal)
113
116
  try {
114
117
  const handle = subprocess.spawn({
115
- argv: ['bash', '-c', 'exec "$0" "$@"', file, ...args.map(String)],
118
+ argv: ['bash', file, ...args.map(String)],
116
119
  cwd: options.cwd ?? '/',
117
120
  stdio: {
118
121
  ...(input !== '' ? { stdin: { data: input } } : {}),
package/lib/plan.js CHANGED
@@ -18,6 +18,16 @@ function record(value) {
18
18
  function nativeBuildOf(report) {
19
19
  const rule = record(report.rule)
20
20
  if (record(rule?.nativeBuild) !== undefined) return rule.nativeBuild
21
+ const signals = record(report.signals) ?? {}
22
+ if (signals.nodeGyp === true) {
23
+ return { language: 'node-gyp', target: 'aarch64-unknown-linux-musl', notes: 'build in the Alpine guest with build-base and python3' }
24
+ }
25
+ if (signals.rustSource === true) {
26
+ return { language: 'rust', target: 'aarch64-unknown-linux-musl' }
27
+ }
28
+ if (signals.cSource === true) {
29
+ return { language: 'c/c++', target: 'aarch64-unknown-linux-musl', notes: 'build in the Alpine guest' }
30
+ }
21
31
  const native = Array.isArray(report.files?.native) ? report.files.native : []
22
32
  if (native.some(file => typeof file.path === 'string' && file.path.endsWith('binding.gyp'))) {
23
33
  return { language: 'node-gyp', target: 'aarch64-unknown-linux-musl', notes: 'build in the Alpine guest with build-base and python3' }
package/lib/release.js ADDED
@@ -0,0 +1,185 @@
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 { createHash } from 'node:crypto'
12
+ import { basename } from 'node:path'
13
+ import { elfArch } from './elf.js'
14
+ import { downloadBuffer } from './fetch.js'
15
+ import { extractTar } from './tar.js'
16
+ import { extractZip } from './zip.js'
17
+
18
+ const API = 'https://api.github.com'
19
+
20
+ /** @param {any} report */
21
+ export function githubRepo(report) {
22
+ if (report?.resolved?.kind === 'github' && typeof report.resolved.name === 'string') {
23
+ const [owner, repo] = report.resolved.name.split('/')
24
+ if (owner && repo) return { owner, repo }
25
+ }
26
+ const repository = report?.package?.repository
27
+ const candidates = [
28
+ typeof repository === 'string' ? repository : repository?.url,
29
+ typeof report?.package?.homepage === 'string' ? report.package.homepage : undefined,
30
+ typeof report?.package?.bugs === 'string' ? report.package.bugs : report?.package?.bugs?.url,
31
+ ].filter(value => typeof value === 'string')
32
+ for (const url of candidates) {
33
+ const match = url.match(/github\.com[/:]([^/]+)\/([^/#?]+)/i)
34
+ if (match) return { owner: match[1], repo: match[2].replace(/\.git$/i, '') }
35
+ }
36
+ const tarball = report?.resolved?.tarballUrl
37
+ if (typeof tarball === 'string') {
38
+ const match = tarball.match(/api\.github\.com\/repos\/([^/]+)\/([^/]+)/i)
39
+ if (match) return { owner: match[1], repo: match[2] }
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /** @param {string} url @param {AbortSignal} [signal] */
45
+ async function fetchJson(url, signal) {
46
+ const timeout = AbortSignal.timeout(20000)
47
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
48
+ const response = await fetch(url, {
49
+ signal: combined,
50
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'dshm-compat' },
51
+ })
52
+ if (!response.ok) throw new Error(`GitHub API ${response.status} for ${url}`)
53
+ return await response.json()
54
+ }
55
+
56
+ /** @param {string} owner @param {string} repo @param {string|undefined} version @param {AbortSignal} [signal] */
57
+ async function releaseAssets(owner, repo, version, signal) {
58
+ if (typeof version === 'string' && version !== '') {
59
+ for (const tag of [`v${version}`, version]) {
60
+ try {
61
+ const tagged = await fetchJson(`${API}/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, signal)
62
+ if (Array.isArray(tagged?.assets) && tagged.assets.length > 0) return tagged.assets
63
+ } catch { /* try the next tag, then the latest release */ }
64
+ }
65
+ }
66
+ try {
67
+ const latest = await fetchJson(`${API}/repos/${owner}/${repo}/releases/latest`, signal)
68
+ if (Array.isArray(latest?.assets) && latest.assets.length > 0) return latest.assets
69
+ } catch { /* fall through to the list endpoint */ }
70
+ const releases = await fetchJson(`${API}/repos/${owner}/${repo}/releases?per_page=5`, signal)
71
+ /** @type {any[]} */
72
+ const assets = []
73
+ for (const release of Array.isArray(releases) ? releases : []) {
74
+ if (Array.isArray(release?.assets)) assets.push(...release.assets)
75
+ }
76
+ return assets
77
+ }
78
+
79
+ /** @param {any} asset */
80
+ function assetRank(asset) {
81
+ const name = String(asset?.name ?? '').toLowerCase()
82
+ if (!/\.(?:zip|tgz|tar\.gz)$/.test(name)) return undefined
83
+ if (/win32|windows|darwin|macos|source|checksum|sha256/.test(name)) return undefined
84
+ if (!/linux|musl/.test(name)) return undefined
85
+ if (/aarch64|arm64/.test(name)) return 0
86
+ if (/x86_64|amd64|x64/.test(name)) return 1
87
+ return undefined
88
+ }
89
+
90
+ /** @param {Buffer} archive @param {string} name */
91
+ function archiveEntries(archive, name) {
92
+ if (/\.zip$/i.test(name)) return extractZip(archive)
93
+ return extractTar(archive).entries
94
+ .filter(entry => entry.type === 'file')
95
+ .map(entry => ({ path: entry.path, data: entry.data }))
96
+ }
97
+
98
+ /**
99
+ * @param {any} report
100
+ * @param {{ signal?: AbortSignal }} [options]
101
+ * @returns {Promise<
102
+ * | { ok: true, buffer: Buffer, name: string, arch: 'arm64'|'x64', asset: string, sha256: string, attempts: any[] }
103
+ * | { ok: false, reason: string, attempts: any[] }
104
+ * >}
105
+ */
106
+ export async function findReleaseWorker(report, options = {}) {
107
+ const attempts = []
108
+ const repo = githubRepo(report)
109
+ if (repo === undefined) return { ok: false, reason: 'no GitHub repository in the package metadata', attempts }
110
+
111
+ let assets
112
+ try {
113
+ assets = await releaseAssets(repo.owner, repo.repo, report?.package?.version ?? report?.resolved?.version, options.signal)
114
+ } catch (error) {
115
+ return { ok: false, reason: `GitHub release lookup failed: ${String(error?.message ?? error)}`, attempts }
116
+ }
117
+ const ranked = assets
118
+ .map(asset => ({ asset, rank: assetRank(asset) }))
119
+ .filter(item => item.rank !== undefined)
120
+ .sort((a, b) => a.rank - b.rank)
121
+ if (ranked.length === 0) return { ok: false, reason: 'no Linux worker asset in the latest releases', attempts }
122
+
123
+ for (const { asset } of ranked) {
124
+ if (Number(asset.size ?? 0) > 64 * 1024 * 1024) {
125
+ attempts.push({ asset: asset.name, stage: 'size', error: `asset is larger than 64 MiB` })
126
+ continue
127
+ }
128
+ const urls = [asset.browser_download_url]
129
+ if (asset.id !== undefined) urls.push(`${API}/repos/${repo.owner}/${repo.repo}/releases/assets/${asset.id}`)
130
+ let archive
131
+ let downloadedUrl
132
+ for (const url of urls) {
133
+ if (typeof url !== 'string') continue
134
+ try {
135
+ archive = await downloadBuffer(url, {
136
+ maxBytes: 64 * 1024 * 1024,
137
+ signal: options.signal,
138
+ headers: url.includes('/releases/assets/') ? { accept: 'application/octet-stream' } : {},
139
+ })
140
+ downloadedUrl = url
141
+ attempts.push({ asset: asset.name, url, stage: 'download', ok: true })
142
+ break
143
+ } catch (error) {
144
+ attempts.push({ asset: asset.name, url, stage: 'download', error: String(error?.message ?? error) })
145
+ }
146
+ }
147
+ if (archive === undefined || downloadedUrl === undefined) continue
148
+
149
+ const digestMatch = typeof asset.digest === 'string' ? asset.digest.match(/^sha256:([a-f0-9]{64})$/i) : null
150
+ if (digestMatch !== null) {
151
+ const actual = createHash('sha256').update(archive).digest('hex')
152
+ if (actual !== digestMatch[1].toLowerCase()) {
153
+ attempts.push({ asset: asset.name, stage: 'sha256', error: `expected ${digestMatch[1]}, got ${actual}` })
154
+ continue
155
+ }
156
+ }
157
+
158
+ let entries
159
+ try {
160
+ entries = archiveEntries(archive, asset.name)
161
+ } catch (error) {
162
+ attempts.push({ asset: asset.name, stage: 'extract', error: String(error?.message ?? error) })
163
+ continue
164
+ }
165
+ const candidates = entries
166
+ .filter(entry => elfArch(entry.data) !== undefined)
167
+ .sort((a, b) => b.data.length - a.data.length)
168
+ if (candidates.length === 0) {
169
+ attempts.push({ asset: asset.name, stage: 'worker', error: 'archive contains no AArch64/x86_64 ELF' })
170
+ continue
171
+ }
172
+ const worker = candidates[0]
173
+ attempts.push({ asset: asset.name, url: downloadedUrl, stage: 'worker', ok: true })
174
+ return {
175
+ ok: true,
176
+ buffer: worker.data,
177
+ name: basename(worker.path),
178
+ arch: elfArch(worker.data),
179
+ asset: asset.name,
180
+ sha256: createHash('sha256').update(worker.data).digest('hex'),
181
+ attempts,
182
+ }
183
+ }
184
+ return { ok: false, reason: 'no usable Linux worker asset could be downloaded', attempts }
185
+ }
package/lib/rules.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Compatibility rules for known packages.
2
+ * Custom compatibility rules.
3
3
  *
4
- * Heuristics classify unknown packages. A rule is for a package whose exact
5
- * blocker and conversion recipe are already known; it wins over the heuristic
6
- * and carries the build/run instructions the converter cannot infer.
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