dshm-compat 0.1.4 → 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/CHANGELOG.md +43 -1
- package/INSTALL.md +6 -6
- package/README.md +20 -12
- package/docs/ARCHITECTURE.md +4 -0
- package/docs/RECIPES.md +25 -6
- package/index.js +58 -0
- package/lib/analyze.js +3 -0
- package/lib/convert.js +55 -2
- package/lib/guest.js +3 -1
- package/lib/release.js +80 -13
- package/lib/s2-native.js +81 -19
- package/lib/sidecar.js +13 -2
- package/lib/source-build.js +165 -0
- package/lib/uninstall.js +94 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
- Spawn rewriting now uses `['bash', file]` plus a staged shell wrapper instead
|
|
6
|
+
of `bash -c 'exec "$0" "$@"'`. `dshm-vm-bash` drops positional parameters for
|
|
7
|
+
`-c`, so the old form produced `Permission denied` and lost worker arguments.
|
|
8
|
+
- `runGuestFile` and sidecars use the same non-`-c` path (`bash <wrapper> <args>`).
|
|
9
|
+
- Release acquisition tries the package-version tag first, then the latest
|
|
10
|
+
release, falls back to the API asset endpoint, verifies `sha256` when
|
|
11
|
+
available, and returns attempts instead of a silent failure.
|
|
12
|
+
- Source-only packages return `needs-build` with an external build contract;
|
|
13
|
+
when the same package has a published npm build, `convert` automatically
|
|
14
|
+
retries that build instead. Verified with `dsh-ops` 0.4.0 GitHub source:
|
|
15
|
+
it falls back to `dsh-ops@0.3.0` and produces a validated package.
|
|
16
|
+
- Added `compat_build`: a source build planner for `Cargo.toml`/Node
|
|
17
|
+
`build`/`prepack` packages, plus an opt-in `backend:"guest"` attempt that
|
|
18
|
+
provisions Rust or Node in the Alpine guest and runs the build there. The
|
|
19
|
+
guest path depends on guest network/disk and is explicitly experimental.
|
|
20
|
+
- Added `compat_uninstall` and `engines.dsh` normalization.
|
|
21
|
+
- Offline regression suite: 30/30.
|
|
22
|
+
|
|
3
23
|
## 0.1.4
|
|
4
24
|
|
|
5
25
|
- The generic S2 adapter now accepts **x86_64** workers: it bundles
|
|
@@ -16,7 +36,29 @@
|
|
|
16
36
|
- Verified end-to-end: `compat_convert("https://github.com/T-Auto/dsh-ops")`
|
|
17
37
|
with **no workerPath** acquires the upstream `linux-x64` release asset, wraps
|
|
18
38
|
it in QEMU, patches the runtime/spawn/peer/os-cpu gates, and validates.
|
|
19
|
-
-
|
|
39
|
+
- Spawn rewrite now uses `['bash', file]` and stages a shell wrapper around the
|
|
40
|
+
real worker instead of `bash -c 'exec "$0" "$@"'`. `dshm-vm-bash` drops
|
|
41
|
+
`$0`/`$@` for `-c` commands, which caused `Permission denied` and lost
|
|
42
|
+
arguments; the non-`-c` path preserves them.
|
|
43
|
+
- Release selection first tries the tag matching the package version (`vX.Y.Z`),
|
|
44
|
+
then the latest release with assets; missing tags fall through instead of
|
|
45
|
+
returning an empty asset list.
|
|
46
|
+
- Release download falls back from `browser_download_url` to the GitHub API
|
|
47
|
+
asset endpoint (`Accept: application/octet-stream`), records every attempt,
|
|
48
|
+
and verifies the asset `sha256` digest when GitHub provides one. Failures are
|
|
49
|
+
returned in `acquisition.attempts`, not swallowed.
|
|
50
|
+
- The adapter also rewrites resolved-shell spawns (`[file, '-c', command]` and
|
|
51
|
+
`[file, scriptPath]`) to `['bash', '-c', command]` / `['bash', scriptPath]`,
|
|
52
|
+
so the shell component does not exec an ELF from the app sandbox.
|
|
53
|
+
- `runGuestFile` now invokes `bash <script> <args>` instead of
|
|
54
|
+
`bash -c 'exec "$0" "$@"'`; sidecars stage a wrapper script so positional
|
|
55
|
+
arguments survive `dshm-vm-bash`.
|
|
56
|
+
- Source packages with no built `main` and a `build`/`prepack` script return
|
|
57
|
+
`needs-build` with an external `npm ci && npm run build && npm pack` contract
|
|
58
|
+
instead of `recipe-mismatch`.
|
|
59
|
+
- Added `compat_uninstall` for profile-local removal (node_modules + dependency
|
|
60
|
+
+ bundle entry), and `engines.dsh` is stripped alongside `os`/`cpu`.
|
|
61
|
+
- Offline regression suite: 28/28.
|
|
20
62
|
|
|
21
63
|
## 0.1.3
|
|
22
64
|
|
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
|
-
It is host-only, has no runtime dependencies, and adds
|
|
3
|
+
The artifact is a normal DSH bundle package: `dshm-compat-0.1.5.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.
|
|
18
|
+
- ask the DSHM Agent to run `pnpm add dshm-compat@0.1.5` 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.
|
|
31
|
+
Put `dshm-compat-0.1.5.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.
|
|
41
|
+
hdc file send dshm-compat-0.1.5.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.5.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.
|
|
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.5.tgz'
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
If the profile reports `ERR_PNPM_UNEXPECTED_VIRTUAL_STORE`, its `node_modules`
|
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@ them through the least invasive strategy that can work.
|
|
|
5
5
|
|
|
6
6
|
## Current phase
|
|
7
7
|
|
|
8
|
-
0.1.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
0.1.5 adds runtime-bridge fixes (non-`-c` spawn wrappers), release asset
|
|
9
|
+
fallbacks, and `compat_uninstall`; 0.1.4 added the generic S2 adapter with
|
|
10
|
+
x86_64 QEMU and GitHub Release acquisition. Phase 1 ships the analyzer, the
|
|
11
|
+
S0/S1 converter, the generic S2 engine, guest probing/provisioning, the DSHM
|
|
12
|
+
install-queue transport, and a profile-local fallback for artifacts the host
|
|
13
|
+
queue rejects:
|
|
14
14
|
|
|
15
15
|
- `compat_analyze` downloads an npm/GitHub/local package and classifies it.
|
|
16
16
|
- `compat_plan` returns the concrete conversion plan without changing anything.
|
|
@@ -25,8 +25,10 @@ fallback for artifacts the host queue rejects:
|
|
|
25
25
|
`workerPath`, the package's `runtime/linux-*`, or the GitHub Release assets
|
|
26
26
|
(linux-arm64 first, then linux-x64). An x86_64 worker is executed through the
|
|
27
27
|
bundled `qemu-x86_64` and glibc rootfs, so no AArch64 rebuild is required.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
A source-only checkout is retried against the package's published npm build
|
|
29
|
+
(with a `fallback` field in the result); only when neither a worker nor a
|
|
30
|
+
published build exists does it return `needs-build`/`needs-native-build` with
|
|
31
|
+
the external build command. Package names are not special-cased.
|
|
30
32
|
- `compat_guest_probe` reports the Alpine guest kernel/packages/tools.
|
|
31
33
|
- `compat_guest_provision` installs a reviewed allowlist of Alpine packages in
|
|
32
34
|
the guest (`confirm:true` required).
|
|
@@ -39,13 +41,19 @@ fallback for artifacts the host queue rejects:
|
|
|
39
41
|
`.tgz` paths (and `file:`/`link:`) are unpacked directly into the owning DSH
|
|
40
42
|
profile and its bundle list, then need a DSHM restart; this is required
|
|
41
43
|
because the DSHM host queue rejects local specs.
|
|
44
|
+
- `compat_build` plans a source build (PC/CI commands) or, with
|
|
45
|
+
`backend:"guest"` and `confirm:true`, provisions Node/Rust in the Alpine
|
|
46
|
+
guest and attempts the build there. The guest path is experimental and
|
|
47
|
+
depends on guest network/disk.
|
|
48
|
+
- `compat_uninstall` removes a profile-local package, its dependency entry, and
|
|
49
|
+
its bundle entry; it also needs a restart.
|
|
42
50
|
- `compat_convert` with `install:true` uses the same local fallback for its
|
|
43
51
|
converted tarball.
|
|
44
52
|
|
|
45
|
-
The converter
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
The converter stops at the manifest/repack/vendor boundary for S0/S1. S2 also
|
|
54
|
+
returns `needs-build` for a source-only package or `needs-native-build` when no
|
|
55
|
+
Linux worker can be obtained. It does not silently claim that an unsupported
|
|
56
|
+
native build succeeded.
|
|
49
57
|
|
|
50
58
|
See [architecture](docs/ARCHITECTURE.md) and [conversion recipes](docs/RECIPES.md).
|
|
51
59
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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,15 +24,34 @@ 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
|
-
-
|
|
28
|
-
`
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
34
36
|
`provenance.json` (`version`, `target`, `executable`, `sha256`).
|
|
35
37
|
|
|
38
|
+
A source package whose `main` is absent but which has a `build`/`prepack` script
|
|
39
|
+
returns `needs-build` with `npm ci && npm run build && npm pack`; the produced
|
|
40
|
+
tarball is then passed back to `compat_convert`. Before returning that, the
|
|
41
|
+
converter retries the package's latest published npm build and records a
|
|
42
|
+
`fallback` field when it succeeds. This keeps a source checkout from being
|
|
43
|
+
misreported as a broken recipe while still preferring a runnable published build.
|
|
44
|
+
|
|
45
|
+
### Guest source build
|
|
46
|
+
|
|
47
|
+
`compat_build { spec, backend: "plan" }` returns the exact PC/CI commands. With
|
|
48
|
+
`backend: "guest"` and `confirm: true`, the plugin provisions Node or Rust in
|
|
49
|
+
the Alpine guest and runs the detected command (`cargo build --release`, or
|
|
50
|
+
`npm ci && npm run build && npm pack`). This is experimental: it needs guest
|
|
51
|
+
network, enough guest disk for the toolchain, and a guest-visible source
|
|
52
|
+
directory. A failure returns the exact `apk`/build stderr; fall back to an
|
|
53
|
+
external build and pass the produced artifact to `compat_convert`.
|
|
54
|
+
|
|
36
55
|
An x86_64 worker is staged as a shell wrapper plus `qemu-x86_64`, a minimal
|
|
37
56
|
x86_64 glibc rootfs, and the original `<worker>-x64`; the wrapper runs QEMU in
|
|
38
57
|
the guest. This makes a `linux-x64` runtime usable without an AArch64 rebuild.
|
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
|
-
|
|
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',
|
|
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
|
|
27
|
-
|
|
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<
|
|
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
|
|
109
|
+
if (repo === undefined) return { ok: false, reason: 'no GitHub repository in the package metadata', attempts }
|
|
110
|
+
|
|
93
111
|
let assets
|
|
94
|
-
try {
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
-
|
|
83
|
-
if (
|
|
84
|
-
workerBinary =
|
|
85
|
-
workerName =
|
|
86
|
-
acquiredAsset =
|
|
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',
|
|
135
|
-
.replaceAll('let argv = [file]', "let argv = ['bash',
|
|
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,38 @@ 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
|
|
182
|
-
let workerSource
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
230
|
+
let stagedWorker
|
|
231
|
+
let workerSource
|
|
232
|
+
try {
|
|
233
|
+
if (workerArch === 'x64') {
|
|
234
|
+
const realName = `${workerName}-x64`
|
|
235
|
+
stagedWorker = Buffer.from([
|
|
186
236
|
'#!/bin/sh',
|
|
187
237
|
'set -e',
|
|
188
238
|
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
189
|
-
`exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${
|
|
239
|
+
`exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${realName}" "$@"`,
|
|
190
240
|
'',
|
|
191
241
|
].join('\n'))
|
|
192
|
-
stagedWorker = wrapper
|
|
193
242
|
workerSource = 'x86_64 worker executed through the bundled qemu-x86_64 and glibc rootfs'
|
|
194
|
-
extraEntries.push({ path: `${runtimeDir}/${
|
|
243
|
+
extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
|
|
195
244
|
extraEntries.push({ path: `${runtimeDir}/qemu-x86_64`, data: readFileSync(join(QEMU_ROOT, 'qemu-x86_64')), mode: 0o755 })
|
|
196
245
|
extraEntries.push({ path: `${runtimeDir}/rootfs/lib64/ld-linux-x86-64.so.2`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib64', 'ld-linux-x86-64.so.2')), mode: 0o644 })
|
|
197
246
|
extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libc.so.6`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libc.so.6')), mode: 0o644 })
|
|
198
247
|
extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libgcc_s.so.1`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libgcc_s.so.1')), mode: 0o644 })
|
|
199
|
-
}
|
|
200
|
-
|
|
248
|
+
} else {
|
|
249
|
+
const realName = `${workerName}-bin`
|
|
250
|
+
stagedWorker = Buffer.from([
|
|
251
|
+
'#!/bin/sh',
|
|
252
|
+
'set -e',
|
|
253
|
+
'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
|
254
|
+
`exec "$DIR/${realName}" "$@"`,
|
|
255
|
+
'',
|
|
256
|
+
].join('\n'))
|
|
257
|
+
workerSource = 'external aarch64-unknown-linux-musl build supplied by the caller'
|
|
258
|
+
extraEntries.push({ path: `${runtimeDir}/${realName}`, data: workerBinary, mode: 0o755 })
|
|
201
259
|
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return { status: 'invalid-output', error: `cannot stage the native worker wrapper: ${String(error?.message ?? error)}` }
|
|
202
262
|
}
|
|
203
263
|
const sha256 = createHash('sha256').update(stagedWorker).digest('hex')
|
|
204
264
|
const provenance = {
|
|
@@ -212,14 +272,15 @@ export async function convertS2Native(spec, options = {}) {
|
|
|
212
272
|
/** @type {string[]} */
|
|
213
273
|
const changed = [
|
|
214
274
|
`added ${runtimeTarget} to PLATFORMS and mapped openharmony/arm64 to it`,
|
|
215
|
-
`
|
|
275
|
+
`rewrote ${patchedSources.size} native worker spawn(s) to ['bash', file] so dshm-vm-bash preserves argv`,
|
|
216
276
|
'removed package.json os/cpu gates and non-Linux optional payloads',
|
|
277
|
+
'removed engines.dsh when present so the DSHM bundle loader does not reject the prerelease core',
|
|
217
278
|
'relaxed @deepseek-ai/dsh peer range to workspace:* for the DSHM core',
|
|
218
279
|
`added dsh-plugin.json openharmony/${runtimeTarget} targets`,
|
|
219
280
|
...(acquiredAsset === undefined ? [] : [`acquired ${workerName} from GitHub release asset ${acquiredAsset}`]),
|
|
220
281
|
workerArch === 'x64'
|
|
221
282
|
? `staged ${workerName} as a shell wrapper over qemu-x86_64 + glibc rootfs`
|
|
222
|
-
: `staged ${
|
|
283
|
+
: `staged ${workerName} as a shell wrapper over ${workerName}-bin`,
|
|
223
284
|
]
|
|
224
285
|
|
|
225
286
|
const output = files.filter(file => file.path !== workerPath).map(file => {
|
|
@@ -249,6 +310,7 @@ export async function convertS2Native(spec, options = {}) {
|
|
|
249
310
|
warnings: validation.warnings,
|
|
250
311
|
validation,
|
|
251
312
|
worker: { target: workerArch === 'arm64' ? TARGET : 'x86_64-linux-gnu', arch: workerArch, sha256 },
|
|
313
|
+
...(acquisition?.ok === true ? { acquisition: { asset: acquisition.asset, sha256: acquisition.sha256 } } : {}),
|
|
252
314
|
}
|
|
253
315
|
}
|
|
254
316
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/uninstall.js
ADDED
|
@@ -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
|
+
}
|