dshm-compat 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * dshm-compat host half.
3
+ *
4
+ * Phase 1 exposes the read-only compatibility analyzer and the DSHM install
5
+ * queue transport. The converter and guest sidecar are added behind the same
6
+ * service/tool surface in later phases.
7
+ *
8
+ * @module dshm-compat
9
+ */
10
+ import { analyze } from './lib/analyze.js'
11
+ import { scan } from './lib/batch.js'
12
+ import { convert } from './lib/convert.js'
13
+ import { probeGuest, provisionGuest } from './lib/guest.js'
14
+ import { enqueueInstall } from './lib/install.js'
15
+ import { buildPlan } from './lib/plan.js'
16
+ import { recordInstall, recordReport, status } from './lib/state.js'
17
+
18
+ export const name = 'dshm-compat'
19
+ export const inject = ['tools']
20
+
21
+ const OUTPUT = { type: 'object', additionalProperties: true }
22
+ const RESULT_NOTE = ' Returns a JSON result; failures are values with status:"error", not thrown.'
23
+
24
+ /** @param {unknown} value */
25
+ function errorResult(error) {
26
+ return {
27
+ status: 'error',
28
+ error: {
29
+ code: typeof error?.code === 'string' ? error.code : 'COMPAT_ERROR',
30
+ message: String(error?.message ?? error),
31
+ },
32
+ }
33
+ }
34
+
35
+ /** @param {unknown} _args @param {unknown} value */
36
+ function renderJson(_args, value) {
37
+ let text
38
+ try { text = JSON.stringify(value, null, 2) } catch { text = String(value) }
39
+ if (text.length > 60000) text = `${text.slice(0, 60000)}\n... [truncated]`
40
+ return [{ type: 'text', text }]
41
+ }
42
+
43
+ /** @param {string} name @param {string} description @param {Record<string, unknown>} parameters @param {(args: any, exec: any) => Promise<unknown>} execute */
44
+ function definition(name, description, parameters, execute) {
45
+ return {
46
+ name,
47
+ description: description + RESULT_NOTE,
48
+ parameters,
49
+ output: { schema: OUTPUT, render: renderJson },
50
+ execute,
51
+ }
52
+ }
53
+
54
+ export function apply(ctx) {
55
+ const register = (tool, label) => {
56
+ ctx.effect(() => ctx.tools.register(tool), `dshm-compat: ${label}`)
57
+ }
58
+
59
+ register(definition(
60
+ 'compat_analyze',
61
+ 'Download one DSH plugin package (npm name[@version], a GitHub repository URL, or a local .tgz) and classify why it may not install or run on HarmonyOS DSHM. '
62
+ + 'Read-only: it does not execute install scripts and does not modify any profile. '
63
+ + 'The report names a strategy (S0 pure JS/TS, S1 known native substitute, S2 guest Alpine sidecar, S3 WASM/remote, S4 unsupported), the evidence, and the next action.',
64
+ {
65
+ type: 'object',
66
+ properties: {
67
+ spec: { type: 'string', description: 'npm package name with optional @version, a GitHub repository URL, or an absolute/relative .tgz path.' },
68
+ textScan: { type: 'boolean', description: 'Scan JavaScript text for platform/Electron/child_process references. Default true; set false for a fast structural report.' },
69
+ },
70
+ required: ['spec'],
71
+ additionalProperties: false,
72
+ },
73
+ async (args, exec) => {
74
+ try {
75
+ if (typeof args?.spec !== 'string' || args.spec.trim() === '') throw Object.assign(new Error('spec is required'), { code: 'INVALID_SPEC' })
76
+ const report = await analyze(args.spec.trim(), { textScan: args.textScan !== false, signal: exec?.signal })
77
+ const entry = recordReport(report)
78
+ return { status: 'ok', entry, report }
79
+ } catch (error) {
80
+ return errorResult(error)
81
+ }
82
+ },
83
+ ), 'compat_analyze')
84
+
85
+ register(definition(
86
+ 'compat_plan',
87
+ 'Analyze one plugin and return a concrete conversion plan without changing anything: manifest rewrite steps for S0, dependency substitutions for S1, guest provisioning and native build targets for S2, or an explicit unsupported result for S4.',
88
+ {
89
+ type: 'object',
90
+ properties: {
91
+ spec: { type: 'string', description: 'npm package name with optional @version, a GitHub repository URL, or an absolute/relative .tgz path.' },
92
+ },
93
+ required: ['spec'],
94
+ additionalProperties: false,
95
+ },
96
+ async (args, exec) => {
97
+ try {
98
+ if (typeof args?.spec !== 'string' || args.spec.trim() === '') throw Object.assign(new Error('spec is required'), { code: 'INVALID_SPEC' })
99
+ const report = await analyze(args.spec.trim(), { textScan: true, signal: exec?.signal })
100
+ recordReport(report)
101
+ return { status: 'ok', plan: buildPlan(report), report }
102
+ } catch (error) {
103
+ return errorResult(error)
104
+ }
105
+ },
106
+ ), 'compat_plan')
107
+
108
+ register(definition(
109
+ 'compat_scan',
110
+ 'Analyze up to 50 plugin specs in parallel (3 at a time) and return a per-strategy compatibility table. Use this to triage a market list before converting individual packages; full reports are stored under $DSH_HOME/dshm-compat/reports and summarized here.',
111
+ {
112
+ type: 'object',
113
+ properties: {
114
+ specs: { type: 'array', items: { type: 'string' }, description: 'npm names, GitHub URLs, or local .tgz paths. At most 50; duplicates are removed.' },
115
+ textScan: { type: 'boolean', description: 'Scan JavaScript text too. Default false for batch speed.' },
116
+ },
117
+ required: ['specs'],
118
+ additionalProperties: false,
119
+ },
120
+ async (args, exec) => {
121
+ try {
122
+ const result = await scan(args?.specs, { textScan: args?.textScan === true, signal: exec?.signal })
123
+ for (const item of result.items) if (item.ok && item.report !== undefined) recordReport(item.report)
124
+ return {
125
+ status: 'ok',
126
+ counts: result.counts,
127
+ items: result.items.map(({ report: _report, ...item }) => item),
128
+ }
129
+ } catch (error) {
130
+ return errorResult(error)
131
+ }
132
+ },
133
+ ), 'compat_scan')
134
+
135
+ register(definition(
136
+ 'compat_convert',
137
+ 'Convert one S0 (pure JS/TS) plugin into a DSHM-installable tarball: remove foreign os/cpu gates and install lifecycle scripts, normalize the client platform to web, and repack. '
138
+ + 'It does not execute install scripts and does not bundle runtime dependencies yet. S1/S2/S3/S4 reports are returned with the next action instead of pretending to convert.',
139
+ {
140
+ type: 'object',
141
+ properties: {
142
+ spec: { type: 'string', description: 'npm package name with optional @version, a GitHub repository URL, or an absolute/relative .tgz path.' },
143
+ install: { type: 'boolean', description: 'When true, submit the converted tarball to the DSHM install queue after a successful conversion.' },
144
+ profileDir: { type: 'string', description: 'Absolute DSHM profile directory for install:true. Omit to let the host choose.' },
145
+ confirm: { type: 'boolean', description: 'Required when install is true. Confirms that installing the converted package runs third-party code.' },
146
+ vendor: { type: 'boolean', description: 'Vendor missing pure-JS runtime dependencies into the tarball. Default true.' },
147
+ 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.' },
148
+ },
149
+ required: ['spec'],
150
+ additionalProperties: false,
151
+ },
152
+ async (args, exec) => {
153
+ try {
154
+ if (typeof args?.spec !== 'string' || args.spec.trim() === '') throw Object.assign(new Error('spec is required'), { code: 'INVALID_SPEC' })
155
+ const report = await analyze(args.spec.trim(), { textScan: false, signal: exec?.signal })
156
+ const result = await convert(args.spec.trim(), { report, signal: exec?.signal, vendor: args.vendor !== false, workerPath: args.workerPath })
157
+ if (result.status !== 'ok') return result
158
+ recordReport({ ...report, convertedAt: new Date().toISOString(), tarball: result.tarball })
159
+ if (args.install !== true) return result
160
+ if (args.confirm !== true) {
161
+ return { ...result, install: { status: 'confirmation-required', message: 'Re-run with install:true and confirm:true to submit the tarball.' } }
162
+ }
163
+ const installed = await enqueueInstall(`file:${result.tarball}`, { profileDir: args.profileDir, signal: exec?.signal })
164
+ recordInstall({ spec: args.spec, tarball: result.tarball, at: new Date().toISOString(), ...installed })
165
+ return { ...result, install: installed }
166
+ } catch (error) {
167
+ return errorResult(error)
168
+ }
169
+ },
170
+ ), 'compat_convert')
171
+
172
+ register(definition(
173
+ 'compat_status',
174
+ 'Show the compatibility analyzer reports and install attempts stored under $DSH_HOME/dshm-compat, plus whether the current host looks like DSHM.',
175
+ { type: 'object', properties: {}, additionalProperties: false },
176
+ async () => {
177
+ try { return { status: 'ok', ...status() } } catch (error) { return errorResult(error) }
178
+ },
179
+ ), 'compat_status')
180
+
181
+ register(definition(
182
+ 'compat_guest_probe',
183
+ 'Probe the DSHM guest Linux runtime through the host subprocess service: reports the guest kernel, Alpine version, and whether bash/apk/node/npm/python3 are present. '
184
+ + 'Read-only; it does not install packages or start a VM explicitly (DSHM may route bash -c to its guest).',
185
+ { type: 'object', properties: {}, additionalProperties: false },
186
+ async (_args, exec) => {
187
+ try { return { status: 'ok', ...(await probeGuest(ctx, { signal: exec?.signal })) } } catch (error) { return errorResult(error) }
188
+ },
189
+ ), 'compat_guest_probe')
190
+
191
+ register(definition(
192
+ 'compat_guest_provision',
193
+ 'Install a reviewed allowlist of Alpine packages in the DSHM guest (bash, nodejs, npm, python3, build-base, linux-headers, git, ffmpeg). '
194
+ + 'Requires confirm:true because it modifies the guest root filesystem. It reports the exact apk output; no success is assumed when the guest has no network.',
195
+ {
196
+ type: 'object',
197
+ properties: {
198
+ packages: {
199
+ type: 'array',
200
+ items: { type: 'string' },
201
+ description: 'Subset of the allowlist. Omit for bash, nodejs, npm, python3, build-base, linux-headers.',
202
+ },
203
+ confirm: { type: 'boolean', description: 'Must be true to modify the guest root filesystem.' },
204
+ },
205
+ required: ['confirm'],
206
+ additionalProperties: false,
207
+ },
208
+ async (args, exec) => {
209
+ if (args?.confirm !== true) return { status: 'confirmation-required', message: 'Re-run with confirm:true after reviewing the package list.' }
210
+ try { return { status: 'ok', ...(await provisionGuest(ctx, args.packages, { signal: exec?.signal })) } } catch (error) { return errorResult(error) }
211
+ },
212
+ ), 'compat_guest_provision')
213
+
214
+ register(definition(
215
+ 'compat_install',
216
+ 'Submit an already-built plugin tarball or spec to the DSHM host install queue and wait for the host installer result. '
217
+ + 'This does NOT convert or build anything; use compat_convert first when it exists. Requires confirm:true because installing a plugin runs third-party code.',
218
+ {
219
+ type: 'object',
220
+ properties: {
221
+ spec: { type: 'string', description: 'A file:/absolute-path .tgz produced by the converter, or an npm/GitHub spec.' },
222
+ profileDir: { type: 'string', description: 'Absolute DSHM profile directory. Omit to let the host choose its profile.' },
223
+ confirm: { type: 'boolean', description: 'Must be true. The caller confirms that installing this package runs third-party code.' },
224
+ },
225
+ required: ['spec', 'confirm'],
226
+ additionalProperties: false,
227
+ },
228
+ async (args, exec) => {
229
+ if (args?.confirm !== true) {
230
+ return { status: 'confirmation-required', message: 'Re-run with confirm:true after reviewing the analyzer report.' }
231
+ }
232
+ try {
233
+ const result = await enqueueInstall(args.spec, { profileDir: args.profileDir, signal: exec?.signal })
234
+ recordInstall({ spec: args.spec, at: new Date().toISOString(), ...result })
235
+ return result
236
+ } catch (error) {
237
+ return errorResult(error)
238
+ }
239
+ },
240
+ ), 'compat_install')
241
+
242
+ ctx.logger?.info?.('dshm-compat: analyzer ready (S0-S4); conversion and guest sidecar are next')
243
+ }
package/lib/analyze.js ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Compatibility analyzer for DSH plugins aimed at HarmonyOS DSHM.
3
+ *
4
+ * The analyzer is deliberately read-only: it downloads a package archive,
5
+ * inspects its files and manifests, and returns a classification plus the next
6
+ * action. No install script is executed and nothing is written into a profile.
7
+ *
8
+ * Strategy codes:
9
+ * S0 - pure JS/TS: code-level conversion on the host
10
+ * S1 - known native dependencies with an OHOS/JS substitute
11
+ * S2 - native binaries or shell commands: guest Alpine sidecar
12
+ * S3 - source-available native: WASM or remote build
13
+ * S4 - unsupported: e.g. a win32-only binary with no source
14
+ *
15
+ * @module dshm-compat/analyze
16
+ */
17
+ import { createHash } from 'node:crypto'
18
+ import { checkDependencies } from './core.js'
19
+ import { lookupPackageRule } from './rules.js'
20
+ import { findRootPrefix, extractTar } from './tar.js'
21
+ import { loadArchive, resolveSpec } from './fetch.js'
22
+
23
+ /** Runtime dependencies that are native or install a native payload. */
24
+ const KNOWN_NATIVE = {
25
+ 'sharp': { ohos: '@ohos-ports/sharp', note: 'DSHM already ships an OpenHarmony port' },
26
+ 'node-pty': { ohos: '@ohos-ports/node-pty', note: 'DSHM already ships an OpenHarmony port' },
27
+ 'koffi': { ohos: '@ohos-ports/koffi', note: 'DSHM already ships an OpenHarmony port' },
28
+ 'better-sqlite3': { js: 'node:sqlite', note: 'Node 24 ships node:sqlite; behavior differs' },
29
+ 'sqlite3': { js: 'node:sqlite', note: 'Node 24 ships node:sqlite; behavior differs' },
30
+ 'canvas': { js: '@napi-rs/canvas / pure JS fallback', note: 'no OHOS build in the package' },
31
+ 'bcrypt': { js: 'bcryptjs', note: 'pure-JS replacement' },
32
+ 'argon2': { js: 'hash-wasm', note: 'WASM replacement' },
33
+ 'esbuild': { js: 'esbuild-wasm', note: 'WASM replacement' },
34
+ 'node-gyp': { build: true, note: 'requires a C/C++ toolchain' },
35
+ 'prebuild-install': { build: true, note: 'downloads a prebuilt native binary' },
36
+ 'node-pre-gyp': { build: true, note: 'downloads or builds a native binary' },
37
+ '@napi-rs/canvas': { js: 'WASM/JS fallback', note: 'native N-API package' },
38
+ }
39
+
40
+ const NATIVE_EXTENSIONS = new Set(['.node', '.so', '.dll', '.dylib', '.exe', '.wasm'])
41
+ const TEXT_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx', '.json'])
42
+ const SCAN_FILE_LIMIT = 512 * 1024
43
+ const SCAN_TOTAL_LIMIT = 32 * 1024 * 1024
44
+ const SCAN_SAMPLE_LIMIT = 5
45
+
46
+ /** @param {string} path */
47
+ function extname(path) {
48
+ const base = path.slice(path.lastIndexOf('/') + 1)
49
+ const dot = base.lastIndexOf('.')
50
+ return dot <= 0 ? '' : base.slice(dot).toLowerCase()
51
+ }
52
+
53
+ /** @param {unknown} value */
54
+ function record(value) {
55
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
56
+ }
57
+
58
+ /** @param {unknown} value */
59
+ function stringArray(value) {
60
+ return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []
61
+ }
62
+
63
+ /** @param {string} text @param {RegExp} pattern */
64
+ function countMatches(text, pattern) {
65
+ const matches = text.match(pattern)
66
+ return matches === null ? 0 : matches.length
67
+ }
68
+
69
+ /** @param {{ path: string, data?: Buffer, size: number }} file */
70
+ function isProbablyText(file) {
71
+ if (file.data === undefined) return false
72
+ const probe = file.data.subarray(0, Math.min(file.data.length, 4096))
73
+ return !probe.includes(0)
74
+ }
75
+
76
+ /**
77
+ * Pick the package root: an archive-level top directory is stripped by the tar
78
+ * reader, then the shallowest package.json wins (handles monorepo subpackages).
79
+ *
80
+ * @param {import('./tar.js').TarEntry[]} entries
81
+ */
82
+ function selectPackageRoot(entries) {
83
+ const files = entries.filter(entry => entry.type === 'file')
84
+ const candidates = files
85
+ .filter(entry => entry.path === 'package.json' || entry.path.endsWith('/package.json'))
86
+ .map(entry => ({ dir: entry.path === 'package.json' ? '' : entry.path.slice(0, -'/package.json'.length), depth: entry.path.split('/').length }))
87
+ .sort((a, b) => a.depth - b.depth)
88
+ if (candidates.length === 0) return { dir: '', packageFile: undefined }
89
+ const root = candidates[0]
90
+ return { dir: root.dir === '' ? '' : `${root.dir}/`, packageFile: `${root.dir}/package.json` }
91
+ }
92
+
93
+ /**
94
+ * @param {string} spec
95
+ * @param {{ registry?: string, signal?: AbortSignal, maxBytes?: number, textScan?: boolean }} [options]
96
+ */
97
+ export async function analyze(spec, options = {}) {
98
+ const resolved = await resolveSpec(spec, { registry: options.registry, signal: options.signal })
99
+ const archive = await loadArchive(resolved, { maxBytes: options.maxBytes, signal: options.signal })
100
+ const { entries, totalBytes, truncated } = extractTar(archive)
101
+ const archivePrefix = findRootPrefix(entries)
102
+ const topEntries = entries.map(entry => ({
103
+ ...entry,
104
+ path: archivePrefix !== '' && entry.path.startsWith(archivePrefix) ? entry.path.slice(archivePrefix.length) : entry.path,
105
+ }))
106
+ const { dir, packageFile } = selectPackageRoot(topEntries)
107
+ const files = topEntries
108
+ .filter(entry => entry.type === 'file' && entry.path.startsWith(dir))
109
+ .map(entry => ({ ...entry, path: entry.path.slice(dir.length) }))
110
+
111
+ /** @type {Buffer | undefined} */
112
+ let packageText
113
+ const packageEntry = packageFile === undefined ? undefined : files.find(file => file.path === 'package.json')
114
+ if (packageEntry?.data !== undefined) packageText = packageEntry.data
115
+ let packageJson
116
+ let packageError
117
+ if (packageText !== undefined) {
118
+ try { packageJson = JSON.parse(packageText.toString('utf8')) } catch (error) { packageError = String(error?.message ?? error) }
119
+ }
120
+
121
+ const manifestEntry = files.find(file => file.path === 'dsh-plugin.json')
122
+ let manifest
123
+ let manifestError
124
+ if (manifestEntry?.data !== undefined) {
125
+ try { manifest = JSON.parse(manifestEntry.data.toString('utf8')) } catch (error) { manifestError = String(error?.message ?? error) }
126
+ }
127
+
128
+ const pkg = record(packageJson)
129
+ const dsh = pkg === undefined ? undefined : record(pkg.dsh)
130
+ const scripts = pkg === undefined ? undefined : record(pkg.scripts)
131
+ const dependencies = new Set([
132
+ ...Object.keys(record(pkg?.dependencies) ?? {}),
133
+ ...Object.keys(record(pkg?.optionalDependencies) ?? {}),
134
+ ...Object.keys(record(pkg?.peerDependencies) ?? {}),
135
+ ])
136
+ const installScripts = {}
137
+ for (const name of ['preinstall', 'install', 'postinstall', 'prepare']) {
138
+ if (typeof scripts?.[name] === 'string') installScripts[name] = scripts[name]
139
+ }
140
+
141
+ const nativeFiles = []
142
+ const packageFiles = []
143
+ let scannedBytes = 0
144
+ const scan = {
145
+ files: 0,
146
+ truncated: false,
147
+ processPlatform: 0,
148
+ win32: 0,
149
+ darwin: 0,
150
+ linux: 0,
151
+ childProcess: 0,
152
+ spawn: 0,
153
+ exec: 0,
154
+ electron: 0,
155
+ browserWindow: 0,
156
+ dlopen: 0,
157
+ samples: { processPlatform: [], win32: [], childProcess: [], electron: [] },
158
+ }
159
+
160
+ for (const file of files) {
161
+ const extension = extname(file.path)
162
+ if (NATIVE_EXTENSIONS.has(extension) || file.path.endsWith('/binding.gyp') || file.path.startsWith('prebuilds/')) {
163
+ nativeFiles.push({ path: file.path, size: file.size, extension: extension || 'gyp' })
164
+ }
165
+ if (extension === '.json' && file.path !== 'package.json' && file.path !== 'dsh-plugin.json') {
166
+ packageFiles.push(file.path)
167
+ }
168
+ if (options.textScan === false) continue
169
+ if (!TEXT_EXTENSIONS.has(extension) || file.data === undefined) continue
170
+ if (file.size > SCAN_FILE_LIMIT || scannedBytes + file.size > SCAN_TOTAL_LIMIT) { scan.truncated = true; continue }
171
+ if (!isProbablyText(file)) continue
172
+ const text = file.data.toString('utf8')
173
+ scannedBytes += file.size
174
+ scan.files += 1
175
+ for (const [key, pattern] of [
176
+ ['processPlatform', /process\.platform/g],
177
+ ['win32', /\bwin32\b/g],
178
+ ['darwin', /\bdarwin\b/g],
179
+ ['linux', /\blinux\b/g],
180
+ ['childProcess', /child_process/g],
181
+ ['spawn', /\.spawn\s*\(/g],
182
+ ['exec', /\.exec\s*\(/g],
183
+ ['electron', /\belectron\b/g],
184
+ ['browserWindow', /BrowserWindow/g],
185
+ ['dlopen', /process\.dlopen|\.node['"]/g],
186
+ ]) {
187
+ const count = countMatches(text, pattern)
188
+ if (count === 0) continue
189
+ scan[key] += count
190
+ if (scan.samples[key] !== undefined && scan.samples[key].length < SCAN_SAMPLE_LIMIT) scan.samples[key].push(file.path)
191
+ }
192
+ }
193
+
194
+ const nativeDeps = [...dependencies]
195
+ .filter(name => KNOWN_NATIVE[name] !== undefined)
196
+ .map(name => ({ name, ...KNOWN_NATIVE[name] }))
197
+ const dependencyCheck = checkDependencies(pkg, options.env ?? process.env)
198
+ const unknownNativeDeps = nativeDeps.filter(dep => dep.ohos === undefined && dep.js === undefined && dep.build !== true)
199
+ const platformBlocked = pkg !== undefined && (
200
+ (Array.isArray(pkg.os) && !pkg.os.includes('linux') && !pkg.os.includes('openharmony'))
201
+ || (Array.isArray(pkg.cpu) && !pkg.cpu.includes('arm64'))
202
+ )
203
+ const hasDsh = dsh !== undefined && (record(dsh.bundle) !== undefined || record(dsh.client) !== undefined)
204
+ const client = record(dsh?.client)
205
+ const targets = []
206
+ if (manifest !== undefined && manifest !== null && typeof manifest === 'object' && Array.isArray(manifest.contributes?.['x-components'])) {
207
+ for (const component of manifest.contributes['x-components']) {
208
+ if (record(component) === undefined) continue
209
+ targets.push({
210
+ id: typeof component.id === 'string' ? component.id : undefined,
211
+ platforms: stringArray(component.platforms),
212
+ architectures: stringArray(component.architectures),
213
+ targets: stringArray(component.targets),
214
+ })
215
+ }
216
+ }
217
+
218
+ const hasWin32OnlyPayload = nativeFiles.length > 0
219
+ && nativeFiles.every(file => /(?:^|\/)win32-|\.exe$|\.dll$/i.test(file.path))
220
+ const hasNativeSource = files.some(file => (
221
+ file.path === 'native/Cargo.toml'
222
+ || file.path.endsWith('/Cargo.toml')
223
+ || file.path === 'binding.gyp'
224
+ || /(?:^|\/)native\/src\//.test(file.path)
225
+ || /(?:^|\/)src\/.+\.(?:c|cc|cpp|h|hpp|rs)$/.test(file.path)
226
+ ))
227
+ const hasLifecycle = ['preinstall', 'install', 'postinstall'].some(name => typeof scripts?.[name] === 'string')
228
+ const entryCandidates = [
229
+ typeof pkg?.main === 'string' ? pkg.main : undefined,
230
+ typeof dsh?.bundle?.patch === 'string' ? dsh.bundle.patch : undefined,
231
+ typeof pkg?.exports === 'object' && pkg.exports !== null ? pkg.exports['./client'] : undefined,
232
+ ].filter(value => typeof value === 'string').map(value => value.replace(/^\.\//, ''))
233
+ const missingEntries = entryCandidates.filter(entry => !files.some(file => file.path === entry))
234
+ const prepareNeedsBuild = typeof scripts?.prepare === 'string' && missingEntries.length > 0
235
+ const needsBuild = hasLifecycle || prepareNeedsBuild
236
+ const electronSurface = scan.electron > 0 || scan.browserWindow > 0
237
+
238
+ let strategy
239
+ let confidence = 'medium'
240
+ const reasons = []
241
+ const next = []
242
+
243
+ if (hasWin32OnlyPayload && !hasNativeSource) {
244
+ strategy = 'S4'
245
+ confidence = 'high'
246
+ reasons.push('the archive carries only win32 payloads and no C/C++/Rust source to rebuild')
247
+ next.push('ask the plugin author for source or a linux-arm64 build')
248
+ } else if (nativeFiles.length > 0 || unknownNativeDeps.length > 0 || electronSurface) {
249
+ strategy = 'S2'
250
+ confidence = 'medium'
251
+ if (nativeFiles.length > 0) reasons.push(`${nativeFiles.length} native file(s) need a linux-arm64 runtime outside the DSHM host process`)
252
+ if (unknownNativeDeps.length > 0) reasons.push(`unrecognized native dependencies: ${unknownNativeDeps.map(dep => dep.name).join(', ')}`)
253
+ if (Object.keys(installScripts).length > 0) reasons.push(`install scripts: ${Object.keys(installScripts).join(', ')}`)
254
+ if (electronSurface) reasons.push(`Electron API surface detected (${scan.electron} reference(s)); needs a shim or a sidecar UI`)
255
+ next.push('run the plugin in the Alpine guest sidecar and expose its tools through RPC')
256
+ if (electronSurface) next.push('apply the Electron API shim for app/shell/dialog/clipboard/ipc')
257
+ } else if (nativeDeps.length > 0) {
258
+ strategy = 'S1'
259
+ confidence = 'medium'
260
+ reasons.push(`native dependencies have known substitutes: ${nativeDeps.map(dep => dep.name).join(', ')}`)
261
+ next.push(`substitute ${nativeDeps.map(dep => `${dep.name} -> ${dep.ohos ?? dep.js}`).join(', ')}`)
262
+ } else if (needsBuild) {
263
+ strategy = 'S0'
264
+ confidence = 'medium'
265
+ reasons.push(prepareNeedsBuild
266
+ ? `prepare script is required because built entry files are missing: ${missingEntries.join(', ')}`
267
+ : `install lifecycle script(s): ${Object.keys(installScripts).join(', ')}`)
268
+ next.push('run the pure-JS build recipe in a controlled Node worker, then bundle and pack')
269
+ } else {
270
+ strategy = 'S0'
271
+ confidence = 'high'
272
+ reasons.push('no blocking native files, native dependencies, or install lifecycle scripts found')
273
+ if (typeof scripts?.prepare === 'string') reasons.push('prepare script is present but its built entry files already exist; a registry tarball does not need to run it')
274
+ next.push('rewrite package.json, bundle host/client code, pack and install')
275
+ }
276
+
277
+ if (platformBlocked) {
278
+ reasons.push('package.json os/cpu excludes a Linux/arm64 target; the manifest must be rewritten')
279
+ if (strategy === 'S0') confidence = 'medium'
280
+ }
281
+ if (dependencyCheck.missing.length > 0) {
282
+ reasons.push(`runtime dependencies not present in the DSHM core: ${dependencyCheck.missing.slice(0, 30).join(', ')}; vendor or bundle them before install`)
283
+ if (strategy === 'S0') confidence = 'medium'
284
+ }
285
+ if (dependencyCheck.core.available === false && Object.keys(dependencyCheck.dependencies).length > 0) {
286
+ reasons.push(`DSHM core inventory unavailable (${dependencyCheck.core.reason}); dependency availability is unverified`)
287
+ }
288
+ if (unknownNativeDeps.length > 0) {
289
+ reasons.push(`unrecognized native dependency: ${unknownNativeDeps.map(dep => dep.name).join(', ')}`)
290
+ confidence = 'low'
291
+ }
292
+ if (truncated) {
293
+ reasons.push('archive was truncated by the safety limit; the report may be incomplete')
294
+ confidence = 'low'
295
+ }
296
+ if (packageError !== undefined) {
297
+ reasons.push(`package.json could not be parsed: ${packageError}`)
298
+ confidence = 'low'
299
+ }
300
+
301
+ const rule = lookupPackageRule(typeof pkg?.name === 'string' ? pkg.name : undefined, options.env ?? process.env)
302
+ if (rule !== undefined) {
303
+ strategy = rule.strategy
304
+ confidence = 'high'
305
+ reasons.push(`package rule: ${rule.reason}`)
306
+ for (const step of rule.next) if (!next.includes(step)) next.push(step)
307
+ }
308
+
309
+ const fingerprint = createHash('sha256')
310
+ .update(`${resolved.kind}\0${resolved.name}\0${resolved.version}\0${archive.length}`)
311
+ .digest('hex')
312
+
313
+ return {
314
+ spec: String(spec),
315
+ fingerprint,
316
+ resolved: {
317
+ kind: resolved.kind,
318
+ name: resolved.name,
319
+ requested: resolved.requested,
320
+ version: resolved.version,
321
+ approximated: resolved.approximated ?? false,
322
+ tarball: resolved.tarballUrl,
323
+ archiveBytes: archive.length,
324
+ },
325
+ package: pkg === undefined ? undefined : {
326
+ name: pkg.name,
327
+ version: pkg.version,
328
+ description: pkg.description,
329
+ type: pkg.type,
330
+ main: pkg.main,
331
+ os: pkg.os,
332
+ cpu: pkg.cpu,
333
+ scripts,
334
+ dependencies: pkg.dependencies,
335
+ optionalDependencies: pkg.optionalDependencies,
336
+ peerDependencies: pkg.peerDependencies,
337
+ dsh,
338
+ bin: pkg.bin,
339
+ },
340
+ manifest: manifest === undefined ? undefined : {
341
+ id: manifest.id,
342
+ name: manifest.name,
343
+ version: manifest.version,
344
+ targets,
345
+ },
346
+ files: {
347
+ count: files.length,
348
+ totalBytes: files.reduce((sum, file) => sum + file.size, 0),
349
+ native: nativeFiles.slice(0, 100),
350
+ json: packageFiles.slice(0, 50),
351
+ truncated,
352
+ },
353
+ signals: {
354
+ hasDsh,
355
+ hostEntry: hasDsh ? (typeof dsh?.bundle?.patch === 'string' ? dsh.bundle.patch : 'inferred from main') : undefined,
356
+ client: client === undefined ? undefined : {
357
+ platform: client.platform,
358
+ inject: stringArray(client.inject),
359
+ external: stringArray(client.external),
360
+ },
361
+ nativeDeps,
362
+ unknownNativeDeps: unknownNativeDeps.map(dep => dep.name),
363
+ dependencies: {
364
+ core: dependencyCheck.core,
365
+ present: dependencyCheck.present.slice(0, 100),
366
+ missing: dependencyCheck.missing.slice(0, 100),
367
+ optionalMissing: (dependencyCheck.optionalMissing ?? []).slice(0, 100),
368
+ unknown: dependencyCheck.unknown,
369
+ },
370
+ installScripts,
371
+ platformBlocked,
372
+ scan,
373
+ electronSurface,
374
+ },
375
+ strategy,
376
+ confidence,
377
+ rule: rule === undefined ? undefined : { strategy: rule.strategy, reason: rule.reason, nativeBuild: rule.nativeBuild },
378
+ reasons,
379
+ next,
380
+ }
381
+ }
package/lib/batch.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Bounded batch analysis.
3
+ *
4
+ * Scanning a market list is the only practical way to answer "which of these
5
+ * can be converted"; doing it serially would be too slow and unbounded
6
+ * concurrency would exhaust the host. This module runs at most three analyses
7
+ * at a time and returns summaries, not full reports.
8
+ *
9
+ * @module dshm-compat/batch
10
+ */
11
+ import { analyze } from './analyze.js'
12
+
13
+ const DEFAULT_CONCURRENCY = 3
14
+ const MAX_SPECS = 50
15
+
16
+ /**
17
+ * @param {string[]} specs
18
+ * @param {{ concurrency?: number, textScan?: boolean, signal?: AbortSignal, registry?: string }} [options]
19
+ */
20
+ export async function scan(specs, options = {}) {
21
+ if (!Array.isArray(specs)) throw Object.assign(new Error('specs must be an array'), { code: 'INVALID_SPEC' })
22
+ const unique = [...new Set(specs.filter(spec => typeof spec === 'string' && spec.trim() !== '').map(spec => spec.trim()))]
23
+ if (unique.length > MAX_SPECS) throw Object.assign(new Error(`at most ${MAX_SPECS} specs per scan`), { code: 'TOO_MANY_SPECS' })
24
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, 5, unique.length || 1))
25
+ /** @type {Array<{ spec: string, ok: boolean, strategy?: string, confidence?: string, name?: string, version?: string, error?: string, fingerprint?: string }>} */
26
+ const items = new Array(unique.length)
27
+ let next = 0
28
+ const worker = async () => {
29
+ for (;;) {
30
+ const index = next
31
+ next += 1
32
+ if (index >= unique.length) return
33
+ const spec = unique[index]
34
+ try {
35
+ const report = await analyze(spec, { textScan: options.textScan === true, signal: options.signal, registry: options.registry })
36
+ items[index] = {
37
+ spec,
38
+ ok: true,
39
+ strategy: report.strategy,
40
+ confidence: report.confidence,
41
+ name: report.resolved?.name,
42
+ version: report.resolved?.version,
43
+ fingerprint: report.fingerprint,
44
+ report,
45
+ }
46
+ } catch (error) {
47
+ items[index] = { spec, ok: false, error: String(error?.message ?? error) }
48
+ }
49
+ }
50
+ }
51
+ await Promise.all(Array.from({ length: concurrency }, worker))
52
+
53
+ const counts = { total: unique.length, ok: 0, error: 0, S0: 0, S1: 0, S2: 0, S3: 0, S4: 0 }
54
+ for (const item of items) {
55
+ if (!item.ok) { counts.error += 1; continue }
56
+ counts.ok += 1
57
+ counts[item.strategy] += 1
58
+ }
59
+ return { counts, items }
60
+ }