dshm-compat 0.1.0 → 0.1.2
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 +31 -0
- package/INSTALL.md +21 -10
- package/README.md +12 -4
- package/docs/ARCHITECTURE.md +3 -0
- package/index.js +35 -5
- package/lib/doctor.js +78 -0
- package/lib/fetch.js +25 -6
- package/lib/local-install.js +179 -0
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- Added `compat_doctor`: one read-only diagnosis of DSHM mode, core version,
|
|
6
|
+
owning profile/bundle registration, install queue, state, and the Alpine
|
|
7
|
+
guest (bash/apk/node).
|
|
8
|
+
- Local installs now stage into a sibling directory, move the old install to a
|
|
9
|
+
backup, and restore it if writing the new tree or the profile manifest fails.
|
|
10
|
+
- GitHub specs now carry codeload fallbacks (`HEAD`, `refs/heads/main`,
|
|
11
|
+
`refs/heads/master`) when the API tarball endpoint fails or is rate-limited.
|
|
12
|
+
- Offline regression suite: 23/23.
|
|
13
|
+
|
|
14
|
+
## 0.1.1
|
|
15
|
+
|
|
16
|
+
- Tool results are now lossless JSON: `undefined`-valued fields are stripped at
|
|
17
|
+
the tool boundary. This fixes `compat_analyze` / `compat_plan` / `compat_scan`
|
|
18
|
+
failing with `value is not lossless JSON` on DSHM.
|
|
19
|
+
- Local tarball installation: `compat_install` (and `compat_convert` with
|
|
20
|
+
`install:true`) unpacks a `.tgz` into the owning DSH profile's `node_modules`,
|
|
21
|
+
records the dependency, and appends the bundle. This works around DSHM's host
|
|
22
|
+
installer rejecting local paths, `file:` and `http://` specs. A restart is
|
|
23
|
+
required, and the result says `restartRequired: true`.
|
|
24
|
+
- Local install preserves the tarball's file modes (needed for native worker
|
|
25
|
+
binaries such as `dsh-ops-worker`).
|
|
26
|
+
- Docs updated with the DSHM `pnpm view` limitation and the local install path.
|
|
27
|
+
|
|
28
|
+
## 0.1.0
|
|
29
|
+
|
|
30
|
+
- Initial analyzer, S0/S1 converter, dependency preflight/vendoring, guest
|
|
31
|
+
probe/provision, install queue, and dsh-ops 0.3.x recipe.
|
package/INSTALL.md
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
# Installing dshm-compat
|
|
2
2
|
|
|
3
|
-
The artifact is a normal DSH bundle package: `dshm-compat-0.1.
|
|
3
|
+
The artifact is a normal DSH bundle package: `dshm-compat-0.1.2.tgz`.
|
|
4
4
|
It is host-only, has no runtime dependencies, and adds eight agent tools.
|
|
5
5
|
|
|
6
6
|
## A. DSHM (the tablet)
|
|
7
7
|
|
|
8
|
-
### A1. npm or GitHub
|
|
8
|
+
### A1. npm or GitHub
|
|
9
9
|
|
|
10
10
|
Publish the package to npm (remove `"private": true` from `package.json`
|
|
11
|
-
first), or push this directory to a GitHub repository
|
|
11
|
+
first), or push this directory to a GitHub repository.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
On DSHM builds without a shell, the official **Settings → Plugins → Add plugin**
|
|
14
|
+
input calls `pnpm view` before installing and fails with
|
|
15
|
+
`Command failed with EACCES: pnpm view ... spawn pnpm EACCES`. Use one of these
|
|
16
|
+
instead:
|
|
17
|
+
|
|
18
|
+
- ask the DSHM Agent to run `pnpm add dshm-compat@0.1.2` in its bash tool (the
|
|
19
|
+
bundled pnpm shim routes that command to the host install queue); or
|
|
20
|
+
- install from a GitHub repository URL; or
|
|
21
|
+
- after any working install, use the plugin's own `compat_install` tool for
|
|
22
|
+
npm/GitHub specs.
|
|
23
|
+
|
|
24
|
+
The plugin's local converted tarballs are installed by `compat_install` itself:
|
|
25
|
+
it unpacks them into `profiles/<profile>/node_modules/<name>`, records the
|
|
26
|
+
dependency and bundle, and asks for a DSHM restart. The host install queue does
|
|
27
|
+
not accept local paths, `file:` or `http://` specs.
|
|
17
28
|
|
|
18
29
|
### A2. HTTPS tarball
|
|
19
30
|
|
|
20
|
-
Put `dshm-compat-0.1.
|
|
31
|
+
Put `dshm-compat-0.1.2.tgz` on any HTTPS host (GitHub Release, Gitee Release,
|
|
21
32
|
object storage) and paste the `.tgz` URL into the same Add plugin input. DSHM's
|
|
22
33
|
installer accepts tarball specs; if the current market UI refuses a plain URL,
|
|
23
34
|
use A1.
|
|
@@ -27,7 +38,7 @@ use A1.
|
|
|
27
38
|
If `hdc` is available and the app can read the path:
|
|
28
39
|
|
|
29
40
|
```sh
|
|
30
|
-
hdc file send dshm-compat-0.1.
|
|
41
|
+
hdc file send dshm-compat-0.1.2.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.2.tgz
|
|
31
42
|
```
|
|
32
43
|
|
|
33
44
|
Then enter the absolute path (or `file:/...`) in the Add plugin input. This
|
|
@@ -36,7 +47,7 @@ depends on the DSHM build's local-file support; A1 is safer.
|
|
|
36
47
|
## B. Desktop DSH (for testing only)
|
|
37
48
|
|
|
38
49
|
```powershell
|
|
39
|
-
& '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.2.tgz'
|
|
40
51
|
```
|
|
41
52
|
|
|
42
53
|
If the profile reports `ERR_PNPM_UNEXPECTED_VIRTUAL_STORE`, its `node_modules`
|
package/README.md
CHANGED
|
@@ -5,8 +5,10 @@ them through the least invasive strategy that can work.
|
|
|
5
5
|
|
|
6
6
|
## Current phase
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
0.1.2 adds `compat_doctor`, rollback-safe local installs, and codeload
|
|
9
|
+
fallbacks for GitHub. Phase 1 ships the analyzer, the S0/S1 converter, the
|
|
10
|
+
dsh-ops recipe, guest probing/provisioning, the DSHM install-queue transport,
|
|
11
|
+
and a profile-local fallback for artifacts the host queue rejects:
|
|
10
12
|
|
|
11
13
|
- `compat_analyze` downloads an npm/GitHub/local package and classifies it.
|
|
12
14
|
- `compat_plan` returns the concrete conversion plan without changing anything.
|
|
@@ -22,8 +24,14 @@ probing/provisioning, and the DSHM install-queue transport:
|
|
|
22
24
|
- `compat_guest_provision` installs a reviewed allowlist of Alpine packages in
|
|
23
25
|
the guest (`confirm:true` required).
|
|
24
26
|
- `compat_status` lists stored reports and install attempts.
|
|
25
|
-
- `
|
|
26
|
-
|
|
27
|
+
- `compat_doctor` checks DSHM mode, core, profile/bundle, queue, state, and the
|
|
28
|
+
guest in one read-only call.
|
|
29
|
+
- `compat_install` installs an npm/GitHub spec through the host queue. Local
|
|
30
|
+
`.tgz` paths (and `file:`/`link:`) are unpacked directly into the owning DSH
|
|
31
|
+
profile and its bundle list, then need a DSHM restart; this is required
|
|
32
|
+
because the DSHM host queue rejects local specs.
|
|
33
|
+
- `compat_convert` with `install:true` uses the same local fallback for its
|
|
34
|
+
converted tarball.
|
|
27
35
|
|
|
28
36
|
The converter deliberately stops at the manifest/repack/vendor boundary:
|
|
29
37
|
source transpilation and native builds are later phases. A report saying
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -57,6 +57,8 @@ spec ──► resolve (npm / GitHub / local .tgz)
|
|
|
57
57
|
| `lib/plan.js` | user-visible conversion plan for every strategy |
|
|
58
58
|
| `lib/guest.js` | read-only Alpine guest probe through `subprocess` |
|
|
59
59
|
| `lib/install.js` | DSHM install-queue producer with polling and cancellation |
|
|
60
|
+
| `lib/local-install.js` | profile-local tarball install with staging, backup, and rollback |
|
|
61
|
+
| `lib/doctor.js` | one-shot DSHM/core/profile/queue/guest diagnosis |
|
|
60
62
|
| `lib/state.js` | bounded report/install index and atomic writes |
|
|
61
63
|
| `lib/pack.js` | dependency-free ustar/gzip writer |
|
|
62
64
|
|
|
@@ -70,6 +72,7 @@ spec ──► resolve (npm / GitHub / local .tgz)
|
|
|
70
72
|
| `compat_convert` | writes a converted tarball; optionally submits it to the install queue |
|
|
71
73
|
| `compat_guest_probe` | read-only guest command |
|
|
72
74
|
| `compat_guest_provision` | installs allowlisted Alpine packages in the guest |
|
|
75
|
+
| `compat_doctor` | read-only DSHM/core/profile/queue/guest diagnosis |
|
|
73
76
|
| `compat_install` | writes one install-queue request; runs third-party code |
|
|
74
77
|
| `compat_status` | read-only |
|
|
75
78
|
|
package/index.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
import { analyze } from './lib/analyze.js'
|
|
11
11
|
import { scan } from './lib/batch.js'
|
|
12
12
|
import { convert } from './lib/convert.js'
|
|
13
|
+
import { runDoctor } from './lib/doctor.js'
|
|
13
14
|
import { probeGuest, provisionGuest } from './lib/guest.js'
|
|
14
15
|
import { enqueueInstall } from './lib/install.js'
|
|
16
|
+
import { installLocalTarball, isLocalSpec } from './lib/local-install.js'
|
|
15
17
|
import { buildPlan } from './lib/plan.js'
|
|
16
18
|
import { recordInstall, recordReport, status } from './lib/state.js'
|
|
17
19
|
|
|
@@ -47,7 +49,13 @@ function definition(name, description, parameters, execute) {
|
|
|
47
49
|
description: description + RESULT_NOTE,
|
|
48
50
|
parameters,
|
|
49
51
|
output: { schema: OUTPUT, render: renderJson },
|
|
50
|
-
|
|
52
|
+
// DSH validates tool results as lossless JSON; undefined-valued fields are
|
|
53
|
+
// rejected. Strip them once at the boundary so every tool is safe.
|
|
54
|
+
execute: async (args, exec) => {
|
|
55
|
+
const value = await execute(args, exec)
|
|
56
|
+
if (value === undefined) return null
|
|
57
|
+
try { return JSON.parse(JSON.stringify(value)) } catch (error) { return errorResult(error) }
|
|
58
|
+
},
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -145,6 +153,7 @@ export function apply(ctx) {
|
|
|
145
153
|
confirm: { type: 'boolean', description: 'Required when install is true. Confirms that installing the converted package runs third-party code.' },
|
|
146
154
|
vendor: { type: 'boolean', description: 'Vendor missing pure-JS runtime dependencies into the tarball. Default true.' },
|
|
147
155
|
workerPath: { type: 'string', description: 'Absolute path to a prebuilt AArch64 Linux worker for recipes that need one (currently dsh-ops). Omit to receive the build command instead.' },
|
|
156
|
+
allowLocal: { type: 'boolean', description: 'When install is true, unpack the converted tarball into the profile instead of the host queue. Default true; set false to force the queue.' },
|
|
148
157
|
},
|
|
149
158
|
required: ['spec'],
|
|
150
159
|
additionalProperties: false,
|
|
@@ -160,7 +169,10 @@ export function apply(ctx) {
|
|
|
160
169
|
if (args.confirm !== true) {
|
|
161
170
|
return { ...result, install: { status: 'confirmation-required', message: 'Re-run with install:true and confirm:true to submit the tarball.' } }
|
|
162
171
|
}
|
|
163
|
-
const
|
|
172
|
+
const local = args.allowLocal === false ? undefined : installLocalTarball(result.tarball, { profileDir: args.profileDir })
|
|
173
|
+
const installed = local === undefined
|
|
174
|
+
? await enqueueInstall(`file:${result.tarball}`, { profileDir: args.profileDir, signal: exec?.signal })
|
|
175
|
+
: { status: local.ok ? 'ok' : 'error', ...local }
|
|
164
176
|
recordInstall({ spec: args.spec, tarball: result.tarball, at: new Date().toISOString(), ...installed })
|
|
165
177
|
return { ...result, install: installed }
|
|
166
178
|
} catch (error) {
|
|
@@ -178,6 +190,15 @@ export function apply(ctx) {
|
|
|
178
190
|
},
|
|
179
191
|
), 'compat_status')
|
|
180
192
|
|
|
193
|
+
register(definition(
|
|
194
|
+
'compat_doctor',
|
|
195
|
+
'One-shot diagnosis of the DSHM environment: whether this is DSHM, the core version, the owning profile and bundle registration, the install queue, stored state, and the Alpine guest (bash/apk/node). Read-only.',
|
|
196
|
+
{ type: 'object', properties: {}, additionalProperties: false },
|
|
197
|
+
async (_args, exec) => {
|
|
198
|
+
try { return { status: 'ok', ...(await runDoctor(ctx, process.env)) } } catch (error) { return errorResult(error) }
|
|
199
|
+
},
|
|
200
|
+
), 'compat_doctor')
|
|
201
|
+
|
|
181
202
|
register(definition(
|
|
182
203
|
'compat_guest_probe',
|
|
183
204
|
'Probe the DSHM guest Linux runtime through the host subprocess service: reports the guest kernel, Alpine version, and whether bash/apk/node/npm/python3 are present. '
|
|
@@ -213,13 +234,14 @@ export function apply(ctx) {
|
|
|
213
234
|
|
|
214
235
|
register(definition(
|
|
215
236
|
'compat_install',
|
|
216
|
-
'
|
|
217
|
-
+ '
|
|
237
|
+
'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. '
|
|
238
|
+
+ 'Requires confirm:true because installing a plugin runs third-party code; a local install needs a DSHM restart to load.',
|
|
218
239
|
{
|
|
219
240
|
type: 'object',
|
|
220
241
|
properties: {
|
|
221
242
|
spec: { type: 'string', description: 'A file:/absolute-path .tgz produced by the converter, or an npm/GitHub spec.' },
|
|
222
|
-
profileDir: { type: 'string', description: 'Absolute DSHM profile directory. Omit to
|
|
243
|
+
profileDir: { type: 'string', description: 'Absolute DSHM profile directory. Omit to infer it from this plugin\'s installation path.' },
|
|
244
|
+
allowLocal: { type: 'boolean', description: 'Allow unpacking a local tarball into the profile. Default true. Set false to force the host install queue.' },
|
|
223
245
|
confirm: { type: 'boolean', description: 'Must be true. The caller confirms that installing this package runs third-party code.' },
|
|
224
246
|
},
|
|
225
247
|
required: ['spec', 'confirm'],
|
|
@@ -230,6 +252,14 @@ export function apply(ctx) {
|
|
|
230
252
|
return { status: 'confirmation-required', message: 'Re-run with confirm:true after reviewing the analyzer report.' }
|
|
231
253
|
}
|
|
232
254
|
try {
|
|
255
|
+
if (args.allowLocal !== false && isLocalSpec(args.spec)) {
|
|
256
|
+
const path = args.spec.trim().replace(/^(?:file|link):/i, '')
|
|
257
|
+
const local = installLocalTarball(path, { profileDir: args.profileDir })
|
|
258
|
+
recordInstall({ spec: args.spec, at: new Date().toISOString(), ...local })
|
|
259
|
+
return local.ok
|
|
260
|
+
? { status: 'ok', ...local }
|
|
261
|
+
: { status: 'error', error: { code: 'LOCAL_INSTALL_FAILED', message: local.error ?? 'local install failed' }, ...local }
|
|
262
|
+
}
|
|
233
263
|
const result = await enqueueInstall(args.spec, { profileDir: args.profileDir, signal: exec?.signal })
|
|
234
264
|
recordInstall({ spec: args.spec, at: new Date().toISOString(), ...result })
|
|
235
265
|
return result
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot DSHM compatibility doctor.
|
|
3
|
+
*
|
|
4
|
+
* Collects the facts that decide whether an install can work: whether this is
|
|
5
|
+
* DSHM, which core is present, which profile owns the plugin, whether the queue
|
|
6
|
+
* is reachable, and what the guest looks like. It never writes anything.
|
|
7
|
+
*
|
|
8
|
+
* @module dshm-compat/doctor
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { inspectCore } from './core.js'
|
|
13
|
+
import { probeGuest } from './guest.js'
|
|
14
|
+
import { resolveProfileDir } from './local-install.js'
|
|
15
|
+
import { dshHome, status } from './state.js'
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} value */
|
|
18
|
+
function record(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} file */
|
|
23
|
+
function readJson(file) {
|
|
24
|
+
try { return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) } catch { return undefined }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} file */
|
|
28
|
+
function pluginVersion(file) {
|
|
29
|
+
const pkg = record(readJson(file))
|
|
30
|
+
return typeof pkg?.version === 'string' ? pkg.version : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
35
|
+
* @param {NodeJS.ProcessEnv} env
|
|
36
|
+
*/
|
|
37
|
+
export async function runDoctor(ctx, env = process.env) {
|
|
38
|
+
/** @type {{ name: string, status: 'ok'|'warn'|'error', detail: string }[]} */
|
|
39
|
+
const checks = []
|
|
40
|
+
const add = (name, status, detail) => checks.push({ name, status, detail: String(detail) })
|
|
41
|
+
|
|
42
|
+
const dshm = env.DSHM_PLATFORM === 'ohos' || typeof env.DSHM_HOME === 'string'
|
|
43
|
+
add('dshm', dshm ? 'ok' : 'warn', dshm ? `DSHM_HOME=${dshHome(env)}` : 'not running on DSHM; install/queue checks will be limited')
|
|
44
|
+
|
|
45
|
+
const core = inspectCore(env)
|
|
46
|
+
add('core', core.available ? 'ok' : 'warn', core.available ? `DSHM core ${core.version ?? 'unknown'}` : core.reason)
|
|
47
|
+
|
|
48
|
+
const profileDir = resolveProfileDir(env)
|
|
49
|
+
if (profileDir === undefined) {
|
|
50
|
+
add('profile', 'warn', 'cannot locate the owning profile; pass profileDir to local installs')
|
|
51
|
+
} else {
|
|
52
|
+
const manifest = record(readJson(join(profileDir, 'package.json')))
|
|
53
|
+
const bundles = Array.isArray(manifest?.dsh?.profile?.bundles) ? manifest.dsh.profile.bundles : []
|
|
54
|
+
add('profile', bundles.includes('dshm-compat') ? 'ok' : 'warn',
|
|
55
|
+
`${profileDir}; dshm-compat in bundles: ${bundles.includes('dshm-compat')}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const queue = join(dshHome(env), 'install-queue')
|
|
59
|
+
add('install-queue', existsSync(queue) ? 'ok' : 'warn', existsSync(queue) ? queue : `${queue} does not exist yet`)
|
|
60
|
+
|
|
61
|
+
const state = status(env)
|
|
62
|
+
add('state', 'ok', `${state.reports.length} report(s), ${state.installs.length} install attempt(s) in ${state.compatDir}`)
|
|
63
|
+
|
|
64
|
+
const guest = await probeGuest(ctx, { timeoutMs: 15000 })
|
|
65
|
+
if (guest.status === 'unavailable') add('guest', 'warn', guest.reason)
|
|
66
|
+
else if (guest.status === 'ok') add('guest', 'ok', `${guest.run?.uname ?? 'Linux'} / Alpine ${guest.run?.alpine ?? '?'}; bash=${guest.run?.bash} apk=${guest.run?.apk} node=${guest.run?.node}`)
|
|
67
|
+
else add('guest', 'error', guest.reason ?? guest.stderr ?? 'guest probe failed')
|
|
68
|
+
|
|
69
|
+
const overall = checks.some(check => check.status === 'error') ? 'error'
|
|
70
|
+
: checks.some(check => check.status === 'warn') ? 'warn' : 'ok'
|
|
71
|
+
return {
|
|
72
|
+
status: overall,
|
|
73
|
+
pluginVersion: pluginVersion(new URL('../package.json', import.meta.url)),
|
|
74
|
+
dshHome: dshHome(env),
|
|
75
|
+
compatDir: state.compatDir,
|
|
76
|
+
checks,
|
|
77
|
+
}
|
|
78
|
+
}
|
package/lib/fetch.js
CHANGED
|
@@ -188,7 +188,17 @@ export async function resolveSpec(spec, options = {}) {
|
|
|
188
188
|
const repo = github[2]
|
|
189
189
|
if (owner.includes('://') || repo.includes('://')) throw new Error(`unsupported GitHub spec: ${raw}`)
|
|
190
190
|
const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball`
|
|
191
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
kind: 'github',
|
|
193
|
+
name: `${owner}/${repo}`,
|
|
194
|
+
version: '',
|
|
195
|
+
tarballUrl,
|
|
196
|
+
fallbackUrls: [
|
|
197
|
+
`https://codeload.github.com/${owner}/${repo}/tar.gz/HEAD`,
|
|
198
|
+
`https://codeload.github.com/${owner}/${repo}/tar.gz/refs/heads/main`,
|
|
199
|
+
`https://codeload.github.com/${owner}/${repo}/tar.gz/refs/heads/master`,
|
|
200
|
+
],
|
|
201
|
+
}
|
|
192
202
|
}
|
|
193
203
|
|
|
194
204
|
// Direct tarball URL.
|
|
@@ -229,9 +239,18 @@ export async function resolveSpec(spec, options = {}) {
|
|
|
229
239
|
*/
|
|
230
240
|
export async function loadArchive(resolved, options = {}) {
|
|
231
241
|
if (resolved.buffer !== undefined) return resolved.buffer
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
242
|
+
const urls = [resolved.tarballUrl, ...(Array.isArray(resolved.fallbackUrls) ? resolved.fallbackUrls : [])]
|
|
243
|
+
let lastError
|
|
244
|
+
for (const url of urls) {
|
|
245
|
+
try {
|
|
246
|
+
return await downloadBuffer(url, {
|
|
247
|
+
maxBytes: options.maxBytes,
|
|
248
|
+
signal: options.signal,
|
|
249
|
+
headers: resolved.kind === 'github' ? { accept: 'application/vnd.github+json' } : {},
|
|
250
|
+
})
|
|
251
|
+
} catch (error) {
|
|
252
|
+
lastError = error
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw lastError ?? new Error(`no archive URL for ${resolved.name ?? 'spec'}`)
|
|
237
256
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual profile installation for converted artifacts.
|
|
3
|
+
*
|
|
4
|
+
* DSHM's host installer accepts npm names and GitHub repositories, but rejects
|
|
5
|
+
* local tarballs, `file:` and `http://` specs. A converted package has to land
|
|
6
|
+
* in the profile another way: unpack it under `<profile>/node_modules/<name>`,
|
|
7
|
+
* record the dependency, and append the bundle so the next boot composes its
|
|
8
|
+
* patch. This mirrors the manual installation the DSHM agent performed for
|
|
9
|
+
* `dshm-compat` itself.
|
|
10
|
+
*
|
|
11
|
+
* @module dshm-compat/local-install
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { dshHome } from './state.js'
|
|
17
|
+
import { extractTar } from './tar.js'
|
|
18
|
+
import { validateTarball } from './validate.js'
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value */
|
|
21
|
+
function record(value) {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} file */
|
|
26
|
+
function readJson(file) {
|
|
27
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} file @param {unknown} value */
|
|
31
|
+
function writeJsonAtomic(file, value) {
|
|
32
|
+
const text = `${JSON.stringify(value, null, 2)}\n`
|
|
33
|
+
const temporary = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
34
|
+
writeFileSync(temporary, text, 'utf8')
|
|
35
|
+
try { renameSync(temporary, file) } catch {
|
|
36
|
+
writeFileSync(file, text, 'utf8')
|
|
37
|
+
try { rmSync(temporary, { force: true }) } catch { /* best effort */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {string} candidate */
|
|
42
|
+
function looksLikeProfile(candidate) {
|
|
43
|
+
return existsSync(join(candidate, 'package.json'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
47
|
+
function profileFromModule() {
|
|
48
|
+
let current = dirname(fileURLToPath(import.meta.url))
|
|
49
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
50
|
+
if (basename(current) === 'node_modules') return dirname(current)
|
|
51
|
+
const parent = dirname(current)
|
|
52
|
+
if (parent === current) break
|
|
53
|
+
current = parent
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the profile that owns this plugin.
|
|
60
|
+
* @param {NodeJS.ProcessEnv} env
|
|
61
|
+
* @returns {string | undefined}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveProfileDir(env = process.env) {
|
|
64
|
+
if (typeof env.DSH_PROFILE_DIR === 'string' && looksLikeProfile(env.DSH_PROFILE_DIR)) return env.DSH_PROFILE_DIR
|
|
65
|
+
const fromModule = profileFromModule()
|
|
66
|
+
if (fromModule !== undefined && looksLikeProfile(fromModule)) return fromModule
|
|
67
|
+
const name = env.DSH_PROFILE
|
|
68
|
+
if (typeof name === 'string' && name !== '') {
|
|
69
|
+
const candidate = join(dshHome(env), 'profiles', name)
|
|
70
|
+
if (looksLikeProfile(candidate)) return candidate
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} spec */
|
|
76
|
+
export function isLocalSpec(spec) {
|
|
77
|
+
if (typeof spec !== 'string' || spec.trim() === '') return false
|
|
78
|
+
const raw = spec.trim()
|
|
79
|
+
if (/^(?:file|link):/i.test(raw)) return true
|
|
80
|
+
if (isAbsolute(raw)) return true
|
|
81
|
+
return /\.(?:tgz|tar\.gz)(?:[?#].*)?$/i.test(raw)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @param {string} target @param {import('./tar.js').TarEntry[]} entries */
|
|
85
|
+
function writeEntries(target, entries) {
|
|
86
|
+
rmSync(target, { recursive: true, force: true })
|
|
87
|
+
mkdirSync(target, { recursive: true })
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.type !== 'file') continue
|
|
90
|
+
const relative = entry.path.replace(/^package\//, '')
|
|
91
|
+
if (relative === '' || relative.startsWith('/') || relative.split('/').includes('..')) continue
|
|
92
|
+
const file = join(target, relative)
|
|
93
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
94
|
+
writeFileSync(file, entry.data, { mode: entry.mode === 0 ? 0o644 : entry.mode & 0o777 })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} tarball
|
|
100
|
+
* @param {{ profileDir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
101
|
+
*/
|
|
102
|
+
export function installLocalTarball(tarball, options = {}) {
|
|
103
|
+
const env = options.env ?? process.env
|
|
104
|
+
if (typeof tarball !== 'string' || !existsSync(tarball)) {
|
|
105
|
+
return { ok: false, error: `tarball not found: ${String(tarball)}` }
|
|
106
|
+
}
|
|
107
|
+
const validation = validateTarball(tarball)
|
|
108
|
+
if (!validation.ok) return { ok: false, error: `converted tarball is invalid: ${validation.errors.join('; ')}` }
|
|
109
|
+
|
|
110
|
+
const profileDir = typeof options.profileDir === 'string' && options.profileDir !== ''
|
|
111
|
+
? resolve(options.profileDir)
|
|
112
|
+
: resolveProfileDir(env)
|
|
113
|
+
if (profileDir === undefined || !looksLikeProfile(profileDir)) {
|
|
114
|
+
return { ok: false, error: 'cannot locate the DSH profile that owns this plugin; pass profileDir explicitly' }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { entries } = extractTar(readFileSync(tarball))
|
|
118
|
+
const packageEntry = entries.find(entry => entry.path === 'package/package.json')
|
|
119
|
+
if (packageEntry?.data === undefined) return { ok: false, error: 'converted tarball has no package/package.json' }
|
|
120
|
+
const pkg = record(JSON.parse(packageEntry.data.toString('utf8')))
|
|
121
|
+
const name = typeof pkg?.name === 'string' ? pkg.name : undefined
|
|
122
|
+
const version = typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
|
|
123
|
+
if (name === undefined || !/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(name)) {
|
|
124
|
+
return { ok: false, error: 'converted tarball has no valid package name' }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const destination = join(profileDir, 'node_modules', ...name.split('/'))
|
|
128
|
+
const manifestPath = join(profileDir, 'package.json')
|
|
129
|
+
const manifest = readJson(manifestPath)
|
|
130
|
+
if (record(manifest) === undefined) return { ok: false, error: `profile manifest is not an object: ${manifestPath}` }
|
|
131
|
+
|
|
132
|
+
const staging = `${destination}.staging-${process.pid}-${Date.now()}`
|
|
133
|
+
const backup = `${destination}.backup-${process.pid}-${Date.now()}`
|
|
134
|
+
let movedOld = false
|
|
135
|
+
try {
|
|
136
|
+
writeEntries(staging, entries)
|
|
137
|
+
if (existsSync(destination)) {
|
|
138
|
+
renameSync(destination, backup)
|
|
139
|
+
movedOld = true
|
|
140
|
+
}
|
|
141
|
+
renameSync(staging, destination)
|
|
142
|
+
|
|
143
|
+
const dependencies = record(manifest.dependencies) ?? {}
|
|
144
|
+
dependencies[name] = version
|
|
145
|
+
manifest.dependencies = dependencies
|
|
146
|
+
const dsh = record(manifest.dsh) ?? {}
|
|
147
|
+
const profile = record(dsh.profile) ?? {}
|
|
148
|
+
const bundles = Array.isArray(profile.bundles) ? profile.bundles.filter(item => typeof item === 'string') : []
|
|
149
|
+
if (!bundles.includes(name)) bundles.push(name)
|
|
150
|
+
profile.bundles = bundles
|
|
151
|
+
dsh.profile = profile
|
|
152
|
+
manifest.dsh = dsh
|
|
153
|
+
writeJsonAtomic(manifestPath, manifest)
|
|
154
|
+
|
|
155
|
+
if (movedOld) {
|
|
156
|
+
try { rmSync(backup, { recursive: true, force: true }) } catch { /* keep the backup if it is locked */ }
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
method: 'local-profile',
|
|
161
|
+
profileDir,
|
|
162
|
+
name,
|
|
163
|
+
version,
|
|
164
|
+
destination,
|
|
165
|
+
restartRequired: true,
|
|
166
|
+
warnings: validation.warnings,
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
try { rmSync(staging, { recursive: true, force: true }) } catch { /* staging may be gone */ }
|
|
170
|
+
try {
|
|
171
|
+
if (existsSync(destination)) rmSync(destination, { recursive: true, force: true })
|
|
172
|
+
if (movedOld && existsSync(backup)) renameSync(backup, destination)
|
|
173
|
+
} catch { /* report the original error; recovery is best effort */ }
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: `local install failed: ${String(error?.message ?? error)}${movedOld ? '; previous files restored' : ''}`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshm-compat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"icon.svg",
|
|
30
30
|
"README.md",
|
|
31
31
|
"INSTALL.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
32
33
|
"docs/*.md"
|
|
33
34
|
],
|
|
34
35
|
"engines": {
|