dshm-compat 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ - Native worker wrappers now bridge the DSHM host app-files prefix
6
+ `/data/storage/el2/base/haps/entry/files` to the guest's `/mnt/share` 9p
7
+ mount. `dsh-ops` passes the host `policy.workspaceRoot` as the worker's
8
+ `root`; the worker canonicalizes it before doing anything, so an unmapped
9
+ host path made every `ops_*` call fail with `NOT_FOUND` even when the guest
10
+ path existed. The wrapper creates the symlink only when the guest mount
11
+ exists and the host prefix is absent.
12
+ - Offline regression suite: 30/30.
13
+
14
+ ## 0.1.5
15
+
16
+ - Spawn rewriting now uses `['bash', file]` plus a staged shell wrapper instead
17
+ of `bash -c 'exec "$0" "$@"'`. `dshm-vm-bash` drops positional parameters for
18
+ `-c`, so the old form produced `Permission denied` and lost worker arguments.
19
+ - `runGuestFile` and sidecars use the same non-`-c` path (`bash <wrapper> <args>`).
20
+ - Release acquisition tries the package-version tag first, then the latest
21
+ release, falls back to the API asset endpoint, verifies `sha256` when
22
+ available, and returns attempts instead of a silent failure.
23
+ - Source-only packages return `needs-build` with an external build contract;
24
+ when the same package has a published npm build, `convert` automatically
25
+ retries that build instead. Verified with `dsh-ops` 0.4.0 GitHub source:
26
+ it falls back to `dsh-ops@0.3.0` and produces a validated package.
27
+ - Added `compat_build`: a source build planner for `Cargo.toml`/Node
28
+ `build`/`prepack` packages, plus an opt-in `backend:"guest"` attempt that
29
+ provisions Rust or Node in the Alpine guest and runs the build there. The
30
+ guest path depends on guest network/disk and is explicitly experimental.
31
+ - Added `compat_uninstall` and `engines.dsh` normalization.
32
+ - Offline regression suite: 30/30.
33
+
3
34
  ## 0.1.4
4
35
 
5
36
  - The generic S2 adapter now accepts **x86_64** workers: it bundles
@@ -16,7 +47,29 @@
16
47
  - Verified end-to-end: `compat_convert("https://github.com/T-Auto/dsh-ops")`
17
48
  with **no workerPath** acquires the upstream `linux-x64` release asset, wraps
18
49
  it in QEMU, patches the runtime/spawn/peer/os-cpu gates, and validates.
19
- - Offline regression suite: 27/27.
50
+ - Spawn rewrite now uses `['bash', file]` and stages a shell wrapper around the
51
+ real worker instead of `bash -c 'exec "$0" "$@"'`. `dshm-vm-bash` drops
52
+ `$0`/`$@` for `-c` commands, which caused `Permission denied` and lost
53
+ arguments; the non-`-c` path preserves them.
54
+ - Release selection first tries the tag matching the package version (`vX.Y.Z`),
55
+ then the latest release with assets; missing tags fall through instead of
56
+ returning an empty asset list.
57
+ - Release download falls back from `browser_download_url` to the GitHub API
58
+ asset endpoint (`Accept: application/octet-stream`), records every attempt,
59
+ and verifies the asset `sha256` digest when GitHub provides one. Failures are
60
+ returned in `acquisition.attempts`, not swallowed.
61
+ - The adapter also rewrites resolved-shell spawns (`[file, '-c', command]` and
62
+ `[file, scriptPath]`) to `['bash', '-c', command]` / `['bash', scriptPath]`,
63
+ so the shell component does not exec an ELF from the app sandbox.
64
+ - `runGuestFile` now invokes `bash <script> <args>` instead of
65
+ `bash -c 'exec "$0" "$@"'`; sidecars stage a wrapper script so positional
66
+ arguments survive `dshm-vm-bash`.
67
+ - Source packages with no built `main` and a `build`/`prepack` script return
68
+ `needs-build` with an external `npm ci && npm run build && npm pack` contract
69
+ instead of `recipe-mismatch`.
70
+ - Added `compat_uninstall` for profile-local removal (node_modules + dependency
71
+ + bundle entry), and `engines.dsh` is stripped alongside `os`/`cpu`.
72
+ - Offline regression suite: 28/28.
20
73
 
21
74
  ## 0.1.3
22
75
 
package/INSTALL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Installing dshm-compat
2
2
 
3
- The artifact is a normal DSH bundle package: `dshm-compat-0.1.4.tgz`.
4
- It is host-only, has no runtime dependencies, and adds ten agent tools.
3
+ The artifact is a normal DSH bundle package: `dshm-compat-0.1.6.tgz`.
4
+ It is host-only, has no runtime dependencies, and adds twelve agent tools.
5
5
 
6
6
  ## A. DSHM (the tablet)
7
7
 
@@ -15,7 +15,7 @@ input calls `pnpm view` before installing and fails with
15
15
  `Command failed with EACCES: pnpm view ... spawn pnpm EACCES`. Use one of these
16
16
  instead:
17
17
 
18
- - ask the DSHM Agent to run `pnpm add dshm-compat@0.1.4` in its bash tool (the
18
+ - ask the DSHM Agent to run `pnpm add dshm-compat@0.1.6` in its bash tool (the
19
19
  bundled pnpm shim routes that command to the host install queue); or
20
20
  - install from a GitHub repository URL; or
21
21
  - after any working install, use the plugin's own `compat_install` tool for
@@ -28,7 +28,7 @@ not accept local paths, `file:` or `http://` specs.
28
28
 
29
29
  ### A2. HTTPS tarball
30
30
 
31
- Put `dshm-compat-0.1.4.tgz` on any HTTPS host (GitHub Release, Gitee Release,
31
+ Put `dshm-compat-0.1.6.tgz` on any HTTPS host (GitHub Release, Gitee Release,
32
32
  object storage) and paste the `.tgz` URL into the same Add plugin input. DSHM's
33
33
  installer accepts tarball specs; if the current market UI refuses a plain URL,
34
34
  use A1.
@@ -38,7 +38,7 @@ use A1.
38
38
  If `hdc` is available and the app can read the path:
39
39
 
40
40
  ```sh
41
- hdc file send dshm-compat-0.1.4.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.4.tgz
41
+ hdc file send dshm-compat-0.1.6.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.6.tgz
42
42
  ```
43
43
 
44
44
  Then enter the absolute path (or `file:/...`) in the Add plugin input. This
@@ -47,7 +47,7 @@ depends on the DSHM build's local-file support; A1 is safer.
47
47
  ## B. Desktop DSH (for testing only)
48
48
 
49
49
  ```powershell
50
- & 'D:\安装\dsh-desktop\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'D:\dsh\dsh-26-09-30\dshm-compat\dshm-compat-0.1.4.tgz'
50
+ & 'D:\安装\dsh-desktop\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'D:\dsh\dsh-26-09-30\dshm-compat\dshm-compat-0.1.6.tgz'
51
51
  ```
52
52
 
53
53
  If the profile reports `ERR_PNPM_UNEXPECTED_VIRTUAL_STORE`, its `node_modules`
package/README.md CHANGED
@@ -5,10 +5,11 @@ them through the least invasive strategy that can work.
5
5
 
6
6
  ## Current phase
7
7
 
8
- 0.1.4 adds a generic S2 native adapter (including an x86_64 worker + bundled
9
- QEMU fallback and GitHub Release worker acquisition), `compat_doctor`,
10
- rollback-safe local installs, and codeload fallbacks for GitHub. Phase 1 ships
11
- the analyzer, the S0/S1 converter, the generic S2 engine, guest
8
+ 0.1.6 bridges the host app-files prefix to the guest `/mnt/share` mount for
9
+ native worker roots, fixing `NOT_FOUND` from `ops_*`. 0.1.5 added non-`-c`
10
+ spawn wrappers, release fallbacks, and `compat_uninstall`; 0.1.4 added the
11
+ generic S2 adapter with x86_64 QEMU and GitHub Release acquisition. Phase 1
12
+ ships the analyzer, the S0/S1 converter, the generic S2 engine, guest
12
13
  probing/provisioning, the DSHM install-queue transport, and a profile-local
13
14
  fallback for artifacts the host queue rejects:
14
15
 
@@ -25,8 +26,10 @@ fallback for artifacts the host queue rejects:
25
26
  `workerPath`, the package's `runtime/linux-*`, or the GitHub Release assets
26
27
  (linux-arm64 first, then linux-x64). An x86_64 worker is executed through the
27
28
  bundled `qemu-x86_64` and glibc rootfs, so no AArch64 rebuild is required.
28
- Only when none of these exist does it return `needs-native-build` with the
29
- cross-build command. Package names are not special-cased.
29
+ A source-only checkout is retried against the package's published npm build
30
+ (with a `fallback` field in the result); only when neither a worker nor a
31
+ published build exists does it return `needs-build`/`needs-native-build` with
32
+ the external build command. Package names are not special-cased.
30
33
  - `compat_guest_probe` reports the Alpine guest kernel/packages/tools.
31
34
  - `compat_guest_provision` installs a reviewed allowlist of Alpine packages in
32
35
  the guest (`confirm:true` required).
@@ -39,13 +42,19 @@ fallback for artifacts the host queue rejects:
39
42
  `.tgz` paths (and `file:`/`link:`) are unpacked directly into the owning DSH
40
43
  profile and its bundle list, then need a DSHM restart; this is required
41
44
  because the DSHM host queue rejects local specs.
45
+ - `compat_build` plans a source build (PC/CI commands) or, with
46
+ `backend:"guest"` and `confirm:true`, provisions Node/Rust in the Alpine
47
+ guest and attempts the build there. The guest path is experimental and
48
+ depends on guest network/disk.
49
+ - `compat_uninstall` removes a profile-local package, its dependency entry, and
50
+ its bundle entry; it also needs a restart.
42
51
  - `compat_convert` with `install:true` uses the same local fallback for its
43
52
  converted tarball.
44
53
 
45
- The converter deliberately stops at the manifest/repack/vendor boundary:
46
- source transpilation and native builds are later phases. A report saying
47
- `S2`/`S3`/`S4` is returned as-is rather than being reported as a successful
48
- conversion.
54
+ The converter stops at the manifest/repack/vendor boundary for S0/S1. S2 also
55
+ returns `needs-build` for a source-only package or `needs-native-build` when no
56
+ Linux worker can be obtained. It does not silently claim that an unsupported
57
+ native build succeeded.
49
58
 
50
59
  See [architecture](docs/ARCHITECTURE.md) and [conversion recipes](docs/RECIPES.md).
51
60
 
@@ -61,6 +61,8 @@ spec ──► resolve (npm / GitHub / local .tgz)
61
61
  | `lib/guest.js` | read-only Alpine guest probe through `subprocess` |
62
62
  | `lib/install.js` | DSHM install-queue producer with polling and cancellation |
63
63
  | `lib/local-install.js` | profile-local tarball install with staging, backup, and rollback |
64
+ | `lib/uninstall.js` | profile-local removal of files, dependency, and bundle entry |
65
+ | `lib/source-build.js` | source-build planner and experimental guest build |
64
66
  | `lib/sidecar.js` | generic guest worker staging and JSON-stdio calls |
65
67
  | `lib/doctor.js` | one-shot DSHM/core/profile/queue/guest diagnosis |
66
68
  | `lib/state.js` | bounded report/install index and atomic writes |
@@ -79,6 +81,8 @@ spec ──► resolve (npm / GitHub / local .tgz)
79
81
  | `compat_sidecar` | stages/lists/probes/calls a JSON-stdio native worker in the guest |
80
82
  | `compat_doctor` | read-only DSHM/core/profile/queue/guest diagnosis |
81
83
  | `compat_install` | writes one install-queue request; runs third-party code |
84
+ | `compat_build` | plans a source build, or provisions a guest toolchain and runs it with confirm:true |
85
+ | `compat_uninstall` | removes a profile-local package, dependency, and bundle entry |
82
86
  | `compat_status` | read-only |
83
87
 
84
88
  ## Phase status
package/docs/RECIPES.md CHANGED
@@ -24,14 +24,36 @@ For each match it:
24
24
  resolves to that target in both `resolveWorker` and `tryResolveWorker`;
25
25
  - replaces `${platform}-${arch}` **inside template literals** with
26
26
  `${resolveTarget(platform, arch)}`;
27
- - wraps every standard spawn call in
28
- `['bash', '-c', 'exec "$0" "$@"', file]`, which `dshm-vm-bash` routes to the
29
- Alpine guest;
30
- - removes `os`/`cpu`, drops win32/darwin optional payloads, and relaxes
31
- `@deepseek-ai/dsh` to `workspace:*`;
27
+ - rewrites every standard spawn call to `['bash', file]` and stages a shell
28
+ wrapper at `file` that `exec`s the real worker with `"$@"`. The
29
+ non-`-c` path is required because `dshm-vm-bash` drops positional parameters
30
+ for `bash -c`; the wrapper preserves the worker's argv;
31
+ - removes `os`/`cpu`/`engines.dsh`, drops win32/darwin optional payloads, and
32
+ relaxes `@deepseek-ai/dsh` to `workspace:*`;
32
33
  - adds `openharmony`/target/arch to `dsh-plugin.json` component targets;
33
- - stages the worker at `runtime/<target>/<worker>` with a matching
34
- `provenance.json` (`version`, `target`, `executable`, `sha256`).
34
+ - stages the wrapper at `runtime/<target>/<worker>` and the real binary at
35
+ `<worker>-bin` (AArch64) or `<worker>-x64` (x86_64), with a matching
36
+ `provenance.json` (`version`, `target`, `executable`, `sha256`). The wrapper
37
+ also links the host app-files prefix
38
+ `/data/storage/el2/base/haps/entry/files` to the guest `/mnt/share` mount, so
39
+ a worker that canonicalizes the host `policy.workspaceRoot` sees a real path.
40
+
41
+ A source package whose `main` is absent but which has a `build`/`prepack` script
42
+ returns `needs-build` with `npm ci && npm run build && npm pack`; the produced
43
+ tarball is then passed back to `compat_convert`. Before returning that, the
44
+ converter retries the package's latest published npm build and records a
45
+ `fallback` field when it succeeds. This keeps a source checkout from being
46
+ misreported as a broken recipe while still preferring a runnable published build.
47
+
48
+ ### Guest source build
49
+
50
+ `compat_build { spec, backend: "plan" }` returns the exact PC/CI commands. With
51
+ `backend: "guest"` and `confirm: true`, the plugin provisions Node or Rust in
52
+ the Alpine guest and runs the detected command (`cargo build --release`, or
53
+ `npm ci && npm run build && npm pack`). This is experimental: it needs guest
54
+ network, enough guest disk for the toolchain, and a guest-visible source
55
+ directory. A failure returns the exact `apk`/build stderr; fall back to an
56
+ external build and pass the produced artifact to `compat_convert`.
35
57
 
36
58
  An x86_64 worker is staged as a shell wrapper plus `qemu-x86_64`, a minimal
37
59
  x86_64 glibc rootfs, and the original `<worker>-x64`; the wrapper runs QEMU in
package/index.js CHANGED
@@ -14,8 +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'
18
19
  import { callSidecar, listSidecars, probeSidecar, stageSidecar } from './lib/sidecar.js'
20
+ import { buildSource } from './lib/source-build.js'
19
21
  import { recordInstall, recordReport, status } from './lib/state.js'
20
22
 
21
23
  export const name = 'dshm-compat'
@@ -277,6 +279,62 @@ export function apply(ctx) {
277
279
  },
278
280
  ), 'compat_sidecar')
279
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
+
280
338
  register(definition(
281
339
  'compat_install',
282
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
@@ -329,6 +329,9 @@ export async function analyze(spec, options = {}) {
329
329
  description: pkg.description,
330
330
  type: pkg.type,
331
331
  main: pkg.main,
332
+ repository: pkg.repository,
333
+ homepage: pkg.homepage,
334
+ bugs: pkg.bugs,
332
335
  os: pkg.os,
333
336
  cpu: pkg.cpu,
334
337
  scripts,
package/lib/convert.js CHANGED
@@ -41,21 +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
93
  if (report.strategy === 'S2') {
51
94
  const { convertS2Native } = await import('./s2-native.js')
52
- return await convertS2Native(spec, {
95
+ const result = await convertS2Native(spec, {
53
96
  report,
54
97
  workerPath: options.workerPath,
55
98
  workerBinary: options.workerBinary,
56
99
  workerName: options.workerName,
57
100
  signal: options.signal,
58
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
59
107
  }
60
108
  if (report.strategy !== 'S0' && report.strategy !== 'S1') {
61
109
  return {
@@ -97,6 +145,11 @@ export async function convert(spec, options = {}) {
97
145
 
98
146
  if (pkg.os !== undefined) { delete pkg.os; changed.push('removed package.json os gate') }
99
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
+ }
100
153
  if (pkg.scripts !== null && typeof pkg.scripts === 'object' && !Array.isArray(pkg.scripts)) {
101
154
  for (const name of ['preinstall', 'install', 'postinstall', 'prepare']) {
102
155
  if (typeof pkg.scripts[name] === 'string') { delete pkg.scripts[name]; changed.push(`removed scripts.${name}`) }
package/lib/guest.js CHANGED
@@ -30,6 +30,8 @@ export const PROVISION_PACKAGES = Object.freeze([
30
30
  'python3',
31
31
  'build-base',
32
32
  'linux-headers',
33
+ 'rust',
34
+ 'cargo',
33
35
  'git',
34
36
  'ffmpeg',
35
37
  ])
@@ -113,7 +115,7 @@ export async function runGuestFile(ctx, file, args = [], input = '', options = {
113
115
  if (options.signal) signals.push(options.signal)
114
116
  try {
115
117
  const handle = subprocess.spawn({
116
- argv: ['bash', '-c', 'exec "$0" "$@"', file, ...args.map(String)],
118
+ argv: ['bash', file, ...args.map(String)],
117
119
  cwd: options.cwd ?? '/',
118
120
  stdio: {
119
121
  ...(input !== '' ? { stdin: { data: input } } : {}),
package/lib/release.js CHANGED
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module dshm-compat/release
10
10
  */
11
+ import { createHash } from 'node:crypto'
11
12
  import { basename } from 'node:path'
12
13
  import { elfArch } from './elf.js'
13
14
  import { downloadBuffer } from './fetch.js'
@@ -23,8 +24,12 @@ export function githubRepo(report) {
23
24
  if (owner && repo) return { owner, repo }
24
25
  }
25
26
  const repository = report?.package?.repository
26
- const url = typeof repository === 'string' ? repository : repository?.url
27
- if (typeof url === 'string') {
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) {
28
33
  const match = url.match(/github\.com[/:]([^/]+)\/([^/#?]+)/i)
29
34
  if (match) return { owner: match[1], repo: match[2].replace(/\.git$/i, '') }
30
35
  }
@@ -48,11 +53,19 @@ async function fetchJson(url, signal) {
48
53
  return await response.json()
49
54
  }
50
55
 
51
- /** @param {string} owner @param {string} repo @param {AbortSignal} [signal] */
52
- async function releaseAssets(owner, repo, signal) {
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
+ }
53
66
  try {
54
67
  const latest = await fetchJson(`${API}/repos/${owner}/${repo}/releases/latest`, signal)
55
- if (Array.isArray(latest?.assets)) return latest.assets
68
+ if (Array.isArray(latest?.assets) && latest.assets.length > 0) return latest.assets
56
69
  } catch { /* fall through to the list endpoint */ }
57
70
  const releases = await fetchJson(`${API}/repos/${owner}/${repo}/releases?per_page=5`, signal)
58
71
  /** @type {any[]} */
@@ -85,34 +98,88 @@ function archiveEntries(archive, name) {
85
98
  /**
86
99
  * @param {any} report
87
100
  * @param {{ signal?: AbortSignal }} [options]
88
- * @returns {Promise<{ buffer: Buffer, name: string, arch: 'arm64'|'x64', asset: string } | undefined>}
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
+ * >}
89
105
  */
90
106
  export async function findReleaseWorker(report, options = {}) {
107
+ const attempts = []
91
108
  const repo = githubRepo(report)
92
- if (repo === undefined) return undefined
109
+ if (repo === undefined) return { ok: false, reason: 'no GitHub repository in the package metadata', attempts }
110
+
93
111
  let assets
94
- try { assets = await releaseAssets(repo.owner, repo.repo, options.signal) } catch { return undefined }
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
+ }
95
117
  const ranked = assets
96
118
  .map(asset => ({ asset, rank: assetRank(asset) }))
97
119
  .filter(item => item.rank !== undefined)
98
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
+
99
123
  for (const { asset } of ranked) {
100
- if (typeof asset.browser_download_url !== 'string' || Number(asset.size ?? 0) > 64 * 1024 * 1024) continue
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}`)
101
130
  let archive
102
- try { archive = await downloadBuffer(asset.browser_download_url, { maxBytes: 64 * 1024 * 1024, signal: options.signal }) } catch { continue }
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
+
103
158
  let entries
104
- try { entries = archiveEntries(archive, asset.name) } catch { continue }
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
+ }
105
165
  const candidates = entries
106
166
  .filter(entry => elfArch(entry.data) !== undefined)
107
167
  .sort((a, b) => b.data.length - a.data.length)
108
- if (candidates.length === 0) continue
168
+ if (candidates.length === 0) {
169
+ attempts.push({ asset: asset.name, stage: 'worker', error: 'archive contains no AArch64/x86_64 ELF' })
170
+ continue
171
+ }
109
172
  const worker = candidates[0]
173
+ attempts.push({ asset: asset.name, url: downloadedUrl, stage: 'worker', ok: true })
110
174
  return {
175
+ ok: true,
111
176
  buffer: worker.data,
112
177
  name: basename(worker.path),
113
178
  arch: elfArch(worker.data),
114
179
  asset: asset.name,
180
+ sha256: createHash('sha256').update(worker.data).digest('hex'),
181
+ attempts,
115
182
  }
116
183
  }
117
- return undefined
184
+ return { ok: false, reason: 'no usable Linux worker asset could be downloaded', attempts }
118
185
  }
package/lib/s2-native.js CHANGED
@@ -57,6 +57,27 @@ export async function convertS2Native(spec, options = {}) {
57
57
  const byPath = new Map(files.map(file => [file.path, file]))
58
58
  const getText = path => byPath.get(path)?.data?.toString('utf8')
59
59
 
60
+ const manifestEntry = byPath.get('package.json')
61
+ let manifest
62
+ try { manifest = manifestEntry?.data === undefined ? undefined : JSON.parse(manifestEntry.data.toString('utf8')) } catch { manifest = undefined }
63
+ const main = typeof manifest?.main === 'string' ? manifest.main.replace(/^\.\//, '') : undefined
64
+ const hasMain = main !== undefined && files.some(file => file.path === main)
65
+ const buildScript = typeof manifest?.scripts?.build === 'string'
66
+ ? manifest.scripts.build
67
+ : typeof manifest?.scripts?.prepack === 'string' ? manifest.scripts.prepack : undefined
68
+ if (!hasMain && buildScript !== undefined) {
69
+ return {
70
+ status: 'needs-build',
71
+ sourcePackage: true,
72
+ message: `${packageName} is a source package (main ${main ?? '<missing>'} is absent, build script ${JSON.stringify(buildScript)}); build it outside DSHM, then pass the produced tarball to compat_convert`,
73
+ build: {
74
+ command: 'npm ci && npm run build && npm pack',
75
+ output: 'the produced .tgz',
76
+ then: 'compat_convert { spec: "<produced.tgz>" }',
77
+ },
78
+ }
79
+ }
80
+
60
81
  let workerBinary = options.workerBinary
61
82
  let workerName = options.workerName
62
83
  if (workerBinary === undefined && typeof options.workerPath === 'string') {
@@ -78,12 +99,13 @@ export async function convertS2Native(spec, options = {}) {
78
99
  }
79
100
  }
80
101
  let acquiredAsset
102
+ let acquisition
81
103
  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
104
+ acquisition = await findReleaseWorker(report, { signal: options.signal })
105
+ if (acquisition.ok === true) {
106
+ workerBinary = acquisition.buffer
107
+ workerName = acquisition.name
108
+ acquiredAsset = acquisition.asset
87
109
  }
88
110
  }
89
111
  if (workerBinary === undefined) {
@@ -96,6 +118,7 @@ export async function convertS2Native(spec, options = {}) {
96
118
  'install a Rust toolchain and a musl aarch64 cross linker, or use cross/cargo-zigbuild',
97
119
  'stage the result at runtime/linux-arm64/<worker> or runtime/linux-x64/<worker>, or pass it as workerPath',
98
120
  ],
121
+ ...(acquisition === undefined ? {} : { acquisition }),
99
122
  }
100
123
  }
101
124
  const workerArch = elfArch(workerBinary)
@@ -109,6 +132,26 @@ export async function convertS2Native(spec, options = {}) {
109
132
  && file.path.endsWith('.js')
110
133
  && file.data?.toString('utf8').includes('export const PLATFORMS = Object.freeze(['))
111
134
  if (runtimeEntry === undefined) {
135
+ const manifestEntry = byPath.get('package.json')
136
+ let manifest
137
+ try { manifest = manifestEntry?.data === undefined ? undefined : JSON.parse(manifestEntry.data.toString('utf8')) } catch { manifest = undefined }
138
+ const main = typeof manifest?.main === 'string' ? manifest.main.replace(/^\.\//, '') : undefined
139
+ const hasMain = main !== undefined && files.some(file => file.path === main)
140
+ const buildScript = typeof manifest?.scripts?.build === 'string'
141
+ ? manifest.scripts.build
142
+ : typeof manifest?.scripts?.prepack === 'string' ? manifest.scripts.prepack : undefined
143
+ if (!hasMain && buildScript !== undefined) {
144
+ return {
145
+ status: 'needs-build',
146
+ sourcePackage: true,
147
+ message: `${packageName} is a source package (main ${main ?? '<missing>'} is absent, build script ${JSON.stringify(buildScript)}); build it outside DSHM, then pass the produced tarball to compat_convert`,
148
+ build: {
149
+ command: 'npm ci && npm run build && npm pack',
150
+ output: 'the produced .tgz',
151
+ then: 'compat_convert { spec: "<produced.tgz>" }',
152
+ },
153
+ }
154
+ }
112
155
  return { status: 'recipe-mismatch', message: `${packageName} has no JS file exposing the PLATFORMS matrix` }
113
156
  }
114
157
  const runtime = runtimeEntry.data.toString('utf8')
@@ -131,8 +174,10 @@ export async function convertS2Native(spec, options = {}) {
131
174
  let text = file.data.toString('utf8')
132
175
  const before = text
133
176
  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]")
177
+ .replaceAll('const argv = [file]', "const argv = ['bash', file]")
178
+ .replaceAll('let argv = [file]', "let argv = ['bash', file]")
179
+ .replaceAll("[file, '-c', command]", "['bash', '-c', command]")
180
+ .replaceAll('[file, scriptPath]', "['bash', scriptPath]")
136
181
  if (text !== before) patchedSources.set(file.path, text)
137
182
  }
138
183
  if (patchedSources.size === 0) {
@@ -144,6 +189,10 @@ export async function convertS2Native(spec, options = {}) {
144
189
  const pkg = JSON.parse(pkgFile.data.toString('utf8'))
145
190
  delete pkg.os
146
191
  delete pkg.cpu
192
+ if (pkg.engines !== null && typeof pkg.engines === 'object' && !Array.isArray(pkg.engines)) {
193
+ delete pkg.engines.dsh
194
+ if (Object.keys(pkg.engines).length === 0) delete pkg.engines
195
+ }
147
196
  // DSHM core is a prerelease (e.g. 0.2.1-alpha.4+dshm.56). The upstream
148
197
  // ^0.2.0-rc.2 range does not match 0.2.1-*, and the bundle loader would throw
149
198
  // on the next boot. "workspace:*" follows the running core instead.
@@ -178,27 +227,47 @@ export async function convertS2Native(spec, options = {}) {
178
227
  const provenancePath = `${runtimeDir}/provenance.json`
179
228
  /** @type {{ path: string, data: Buffer, mode?: number }[]} */
180
229
  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([
230
+ let stagedWorker
231
+ let workerSource
232
+ try {
233
+ const hostFilesLink = [
234
+ '# DSHM host app-files prefix -> guest 9p mount',
235
+ 'if [ -d /mnt/share ] && [ ! -e /data/storage/el2/base/haps/entry/files ] && [ ! -L /data/storage/el2/base/haps/entry/files ]; then',
236
+ ' mkdir -p /data/storage/el2/base/haps/entry 2>/dev/null || true',
237
+ ' ln -s /mnt/share /data/storage/el2/base/haps/entry/files 2>/dev/null || true',
238
+ 'fi',
239
+ ]
240
+ if (workerArch === 'x64') {
241
+ const realName = `${workerName}-x64`
242
+ stagedWorker = Buffer.from([
186
243
  '#!/bin/sh',
187
244
  'set -e',
245
+ ...hostFilesLink,
188
246
  'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
189
- `exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${workerName}-x64" "$@"`,
247
+ `exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${realName}" "$@"`,
190
248
  '',
191
249
  ].join('\n'))
192
- stagedWorker = wrapper
193
250
  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 })
251
+ extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
195
252
  extraEntries.push({ path: `${runtimeDir}/qemu-x86_64`, data: readFileSync(join(QEMU_ROOT, 'qemu-x86_64')), mode: 0o755 })
196
253
  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
254
  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
255
  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)}` }
256
+ } else {
257
+ const realName = `${workerName}-bin`
258
+ stagedWorker = Buffer.from([
259
+ '#!/bin/sh',
260
+ 'set -e',
261
+ ...hostFilesLink,
262
+ 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
263
+ `exec "$DIR/${realName}" "$@"`,
264
+ '',
265
+ ].join('\n'))
266
+ workerSource = 'external aarch64-unknown-linux-musl build supplied by the caller'
267
+ extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
201
268
  }
269
+ } catch (error) {
270
+ return { status: 'invalid-output', error: `cannot stage the native worker wrapper: ${String(error?.message ?? error)}` }
202
271
  }
203
272
  const sha256 = createHash('sha256').update(stagedWorker).digest('hex')
204
273
  const provenance = {
@@ -212,14 +281,16 @@ export async function convertS2Native(spec, options = {}) {
212
281
  /** @type {string[]} */
213
282
  const changed = [
214
283
  `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`,
284
+ `rewrote ${patchedSources.size} native worker spawn(s) to ['bash', file] so dshm-vm-bash preserves argv`,
216
285
  'removed package.json os/cpu gates and non-Linux optional payloads',
286
+ 'removed engines.dsh when present so the DSHM bundle loader does not reject the prerelease core',
217
287
  'relaxed @deepseek-ai/dsh peer range to workspace:* for the DSHM core',
218
288
  `added dsh-plugin.json openharmony/${runtimeTarget} targets`,
219
289
  ...(acquiredAsset === undefined ? [] : [`acquired ${workerName} from GitHub release asset ${acquiredAsset}`]),
220
290
  workerArch === 'x64'
221
291
  ? `staged ${workerName} as a shell wrapper over qemu-x86_64 + glibc rootfs`
222
- : `staged ${workerPath}`,
292
+ : `staged ${workerName} as a shell wrapper over ${workerName}-bin`,
293
+ 'mapped /data/storage/el2/base/haps/entry/files to the guest /mnt/share mount',
223
294
  ]
224
295
 
225
296
  const output = files.filter(file => file.path !== workerPath).map(file => {
@@ -249,6 +320,7 @@ export async function convertS2Native(spec, options = {}) {
249
320
  warnings: validation.warnings,
250
321
  validation,
251
322
  worker: { target: workerArch === 'arm64' ? TARGET : 'x86_64-linux-gnu', arch: workerArch, sha256 },
323
+ ...(acquisition?.ok === true ? { acquisition: { asset: acquisition.asset, sha256: acquisition.sha256 } } : {}),
252
324
  }
253
325
  }
254
326
 
package/lib/sidecar.js CHANGED
@@ -98,10 +98,19 @@ export function stageSidecar(tarball, workerPath, options = {}) {
98
98
  rmSync(staging, { recursive: true, force: true })
99
99
  mkdirSync(staging, { recursive: true })
100
100
  writeFileSync(join(staging, 'worker'), entry.data, { mode: 0o755 })
101
+ const script = [
102
+ '#!/bin/sh',
103
+ 'set -e',
104
+ 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
105
+ 'exec "$DIR/worker" "$@"',
106
+ '',
107
+ ].join('\n')
108
+ writeFileSync(join(staging, 'worker.sh'), script, { mode: 0o755 })
101
109
  const sha256 = createHash('sha256').update(entry.data).digest('hex')
102
110
  const meta = {
103
111
  id,
104
112
  worker: join(target, 'worker'),
113
+ script: join(target, 'worker.sh'),
105
114
  workerPath: relative,
106
115
  sha256,
107
116
  bytes: entry.data.length,
@@ -133,7 +142,8 @@ export async function callSidecar(ctx, id, input, options = {}) {
133
142
  if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
134
143
  return { ok: false, error: `sidecar not staged: ${String(id)}` }
135
144
  }
136
- const result = await runGuestFile(ctx, meta.worker, [], JSON.stringify(input ?? {}), {
145
+ const file = typeof meta.script === 'string' ? meta.script : meta.worker
146
+ const result = await runGuestFile(ctx, file, [], JSON.stringify(input ?? {}), {
137
147
  timeoutMs: options.timeoutMs ?? 120000,
138
148
  signal: options.signal,
139
149
  })
@@ -159,7 +169,8 @@ export async function probeSidecar(ctx, id, options = {}) {
159
169
  if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
160
170
  return { ok: false, error: `sidecar not staged: ${String(id)}` }
161
171
  }
162
- const result = await runGuestFile(ctx, meta.worker, ['--version'], '', {
172
+ const file = typeof meta.script === 'string' ? meta.script : meta.worker
173
+ const result = await runGuestFile(ctx, file, ['--version'], '', {
163
174
  timeoutMs: options.timeoutMs ?? 30000,
164
175
  signal: options.signal,
165
176
  })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Best-effort source build orchestration.
3
+ *
4
+ * This is deliberately opt-in. The reliable route for a source-only plugin is
5
+ * an external build (PC/CI); this module only attempts the same commands inside
6
+ * the Alpine guest after provisioning a toolchain, and reports the exact
7
+ * failure when the guest cannot do it.
8
+ *
9
+ * @module dshm-compat/source-build
10
+ */
11
+ import { createHash } from 'node:crypto'
12
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { loadArchive, resolveSpec } from './fetch.js'
15
+ import { provisionGuest, runGuest } from './guest.js'
16
+ import { compatDir } from './state.js'
17
+ import { extractTar, findRootPrefix } from './tar.js'
18
+
19
+ /** @param {unknown} value */
20
+ function record(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
22
+ }
23
+
24
+ /** @param {import('./tar.js').TarEntry[]} entries */
25
+ function selectRoot(entries) {
26
+ const candidates = entries
27
+ .filter(entry => entry.type === 'file' && (entry.path === 'package.json' || entry.path.endsWith('/package.json')))
28
+ .map(entry => ({ dir: entry.path === 'package.json' ? '' : entry.path.slice(0, -'/package.json'.length), depth: entry.path.split('/').length }))
29
+ .sort((a, b) => a.depth - b.depth)
30
+ return candidates.length === 0 ? '' : (candidates[0].dir === '' ? '' : `${candidates[0].dir}/`)
31
+ }
32
+
33
+ /** @param {string} value */
34
+ function shellQuote(value) {
35
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
36
+ }
37
+
38
+ /**
39
+ * Detect a generic build plan from package files.
40
+ * @param {{ path: string, data?: Buffer }[]} files
41
+ */
42
+ export function detectSourceBuild(files) {
43
+ const byPath = new Map(files.map(file => [file.path, file]))
44
+ const text = path => byPath.get(path)?.data?.toString('utf8')
45
+ const cargoPath = ['native/Cargo.toml', 'Cargo.toml'].find(path => byPath.has(path))
46
+ if (cargoPath !== undefined) {
47
+ const cargo = text(cargoPath) ?? ''
48
+ const packageName = cargo.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1]
49
+ const binName = cargo.match(/\[\[bin\]\][\s\S]*?^\s*name\s*=\s*"([^"]+)"/m)?.[1]
50
+ const binary = binName ?? packageName ?? 'worker'
51
+ return {
52
+ kind: 'rust',
53
+ manifest: cargoPath,
54
+ binary,
55
+ command: 'cargo build --release',
56
+ output: `target/release/${binary}`,
57
+ packages: ['rust', 'cargo', 'build-base', 'linux-headers'],
58
+ artifact: 'worker',
59
+ }
60
+ }
61
+ const packageText = text('package.json')
62
+ if (packageText !== undefined) {
63
+ let pkg
64
+ try { pkg = JSON.parse(packageText) } catch { pkg = undefined }
65
+ const build = typeof pkg?.scripts?.build === 'string' ? pkg.scripts.build : typeof pkg?.scripts?.prepack === 'string' ? pkg.scripts.prepack : undefined
66
+ if (build !== undefined) {
67
+ const install = byPath.has('package-lock.json') || byPath.has('npm-shrinkwrap.json') ? 'npm ci' : 'npm install'
68
+ return {
69
+ kind: 'node',
70
+ manifest: 'package.json',
71
+ command: `${install} && npm run build && npm pack`,
72
+ output: '*.tgz',
73
+ packages: ['nodejs', 'npm', 'python3', 'build-base', 'linux-headers', 'git'],
74
+ artifact: 'tarball',
75
+ }
76
+ }
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /**
82
+ * @param {string} spec
83
+ * @param {{ env?: NodeJS.ProcessEnv, signal?: AbortSignal }} [options]
84
+ */
85
+ export async function prepareSource(spec, options = {}) {
86
+ const env = options.env ?? process.env
87
+ const resolved = await resolveSpec(spec, { signal: options.signal })
88
+ const archive = await loadArchive(resolved, { signal: options.signal })
89
+ const { entries } = extractTar(archive)
90
+ const prefix = findRootPrefix(entries)
91
+ const top = entries
92
+ .filter(entry => entry.type === 'file')
93
+ .map(entry => ({ ...entry, path: prefix !== '' && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path }))
94
+ const root = selectRoot(top)
95
+ const files = top
96
+ .filter(entry => entry.path.startsWith(root))
97
+ .map(entry => ({ ...entry, path: entry.path.slice(root.length) }))
98
+ const plan = detectSourceBuild(files)
99
+ if (plan === undefined) return { ok: false, error: 'no generic build plan (Cargo.toml or package.json build/prepack)' }
100
+
101
+ const safe = String(resolved.name ?? 'source').replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
102
+ const fingerprint = createHash('sha256').update(`${resolved.name ?? ''}@${resolved.version ?? ''}`).digest('hex').slice(0, 12)
103
+ const dir = join(compatDir(env), 'builds', 'source', `${safe}-${fingerprint}`)
104
+ rmSync(dir, { recursive: true, force: true })
105
+ for (const file of files) {
106
+ if (file.data === undefined) continue
107
+ const target = join(dir, ...file.path.split('/'))
108
+ mkdirSync(join(target, '..'), { recursive: true })
109
+ writeFileSync(target, file.data, { mode: file.mode & 0o777 })
110
+ }
111
+ return { ok: true, dir, plan, packageName: resolved.name, version: resolved.version }
112
+ }
113
+
114
+ /**
115
+ * @param {import('@deepseek-ai/cordis').Context} ctx
116
+ * @param {string} spec
117
+ * @param {{ backend?: 'plan'|'guest', env?: NodeJS.ProcessEnv, signal?: AbortSignal, timeoutMs?: number }} [options]
118
+ */
119
+ export async function buildSource(ctx, spec, options = {}) {
120
+ const prepared = await prepareSource(spec, options)
121
+ if (prepared.ok !== true) return { status: 'unsupported', ...prepared }
122
+ const { dir, plan } = prepared
123
+ const base = {
124
+ status: 'planned',
125
+ source: { name: prepared.packageName, version: prepared.version },
126
+ directory: dir,
127
+ plan: {
128
+ kind: plan.kind,
129
+ commands: plan.command,
130
+ output: plan.output,
131
+ packages: plan.packages,
132
+ },
133
+ }
134
+ if (options.backend !== 'guest') {
135
+ return { ...base, message: 'run this plan on a PC/CI, or pass backend:"guest" to attempt it in the Alpine guest' }
136
+ }
137
+
138
+ const provision = await provisionGuest(ctx, plan.packages, { timeoutMs: options.timeoutMs ?? 15 * 60 * 1000, signal: options.signal })
139
+ if (provision.status !== 'ok') {
140
+ return { ...base, status: 'provision-failed', provision }
141
+ }
142
+ const command = `set -e\ncd ${shellQuote(dir)}\n${plan.command}`
143
+ const result = await runGuest(ctx, command, { timeoutMs: options.timeoutMs ?? 30 * 60 * 1000, signal: options.signal })
144
+ if (result.status !== 'ok') {
145
+ return { ...base, status: 'build-failed', provision, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
146
+ }
147
+
148
+ let artifact
149
+ if (plan.kind === 'rust') artifact = join(dir, plan.output)
150
+ else {
151
+ const packed = result.stdout?.match(/[^\s]+\.tgz/)?.[0]
152
+ artifact = packed !== undefined ? join(dir, packed.replace(/^\.\//, '')) : join(dir, 'package.tgz')
153
+ }
154
+ return {
155
+ ...base,
156
+ status: 'ok',
157
+ artifact,
158
+ provision,
159
+ stdout: result.stdout,
160
+ stderr: result.stderr,
161
+ next: plan.kind === 'rust'
162
+ ? `compat_convert { spec: ${JSON.stringify(spec)}, workerPath: ${JSON.stringify(artifact)} }`
163
+ : `compat_convert { spec: ${JSON.stringify(artifact)} }`,
164
+ }
165
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Profile-local uninstall.
3
+ *
4
+ * Removes a package from the owning profile: its `node_modules` directory, the
5
+ * `dependencies` entry, and the `dsh.profile.bundles` entry. The profile
6
+ * manifest is only changed after the files are moved aside, so a failed delete
7
+ * can be rolled back.
8
+ *
9
+ * @module dshm-compat/uninstall
10
+ */
11
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { resolveProfileDir } from './local-install.js'
14
+
15
+ /** @param {unknown} value */
16
+ function record(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
18
+ }
19
+
20
+ /** @param {string} file */
21
+ function readJson(file) {
22
+ try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) } catch { return undefined }
23
+ }
24
+
25
+ /** @param {string} file @param {unknown} value */
26
+ function writeJsonAtomic(file, value) {
27
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`
28
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
29
+ renameSync(temporary, file)
30
+ }
31
+
32
+ /**
33
+ * @param {string} name
34
+ * @param {{ profileDir?: string, env?: NodeJS.ProcessEnv }} [options]
35
+ */
36
+ export function uninstallLocalPackage(name, options = {}) {
37
+ const env = options.env ?? process.env
38
+ if (typeof name !== 'string' || !/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(name)) {
39
+ return { ok: false, error: `invalid package name: ${String(name)}` }
40
+ }
41
+ const profileDir = typeof options.profileDir === 'string' && options.profileDir !== ''
42
+ ? options.profileDir
43
+ : resolveProfileDir(env)
44
+ if (profileDir === undefined) return { ok: false, error: 'cannot locate the DSH profile; pass profileDir explicitly' }
45
+ const manifestPath = join(profileDir, 'package.json')
46
+ const manifest = record(readJson(manifestPath))
47
+ if (manifest === undefined) return { ok: false, error: `profile manifest is not an object: ${manifestPath}` }
48
+
49
+ const destination = join(profileDir, 'node_modules', ...name.split('/'))
50
+ const dependencies = record(manifest.dependencies) ?? {}
51
+ const hadDependency = Object.prototype.hasOwnProperty.call(dependencies, name)
52
+ const dsh = record(manifest.dsh) ?? {}
53
+ const profile = record(dsh.profile) ?? {}
54
+ const bundles = Array.isArray(profile.bundles) ? profile.bundles.filter(item => typeof item === 'string') : []
55
+ const hadBundle = bundles.includes(name)
56
+ if (!hadDependency && !hadBundle && !existsSync(destination)) {
57
+ return { ok: true, name, removed: false, profileDir, restartRequired: false, message: 'not installed in this profile' }
58
+ }
59
+
60
+ const backup = `${destination}.uninstall-${process.pid}-${Date.now()}`
61
+ const originalManifest = JSON.parse(JSON.stringify(manifest))
62
+ let moved = false
63
+ try {
64
+ if (existsSync(destination)) {
65
+ renameSync(destination, backup)
66
+ moved = true
67
+ }
68
+ delete dependencies[name]
69
+ manifest.dependencies = dependencies
70
+ profile.bundles = bundles.filter(item => item !== name)
71
+ dsh.profile = profile
72
+ manifest.dsh = dsh
73
+ writeJsonAtomic(manifestPath, manifest)
74
+ if (moved) {
75
+ try { rmSync(backup, { recursive: true, force: true }) } catch { /* keep the backup if it is locked */ }
76
+ }
77
+ return {
78
+ ok: true,
79
+ name,
80
+ removed: true,
81
+ profileDir,
82
+ destination,
83
+ removedDependency: hadDependency,
84
+ removedBundle: hadBundle,
85
+ restartRequired: true,
86
+ }
87
+ } catch (error) {
88
+ try { writeJsonAtomic(manifestPath, originalManifest) } catch { /* best effort */ }
89
+ if (moved && existsSync(backup)) {
90
+ try { renameSync(backup, destination) } catch { /* best effort */ }
91
+ }
92
+ return { ok: false, error: `uninstall failed: ${String(error?.message ?? error)}` }
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshm-compat",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",