dshm-compat 0.1.1 → 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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
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
+
3
14
  ## 0.1.1
4
15
 
5
16
  - Tool results are now lossless JSON: `undefined`-valued fields are stripped at
package/INSTALL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Installing dshm-compat
2
2
 
3
- The artifact is a normal DSH bundle package: `dshm-compat-0.1.0.tgz`.
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)
@@ -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.1` in its bash tool (the
18
+ - ask the DSHM Agent to run `pnpm add dshm-compat@0.1.2` 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.0.tgz` on any HTTPS host (GitHub Release, Gitee Release,
31
+ Put `dshm-compat-0.1.2.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.0.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.0.tgz
41
+ hdc file send dshm-compat-0.1.2.tgz /data/storage/el2/base/haps/entry/files/dshm-compat-0.1.2.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.0.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.2.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,10 @@ them through the least invasive strategy that can work.
5
5
 
6
6
  ## Current phase
7
7
 
8
- 0.1.1 fixes DSHM tool-result validation and adds local-tarball installation.
9
- Phase 1 ships the analyzer, the S0/S1 converter, the dsh-ops recipe, guest
10
- probing/provisioning, the DSHM install-queue transport, and a profile-local
11
- fallback for artifacts the host queue rejects:
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:
12
12
 
13
13
  - `compat_analyze` downloads an npm/GitHub/local package and classifies it.
14
14
  - `compat_plan` returns the concrete conversion plan without changing anything.
@@ -24,6 +24,8 @@ fallback for artifacts the host queue rejects:
24
24
  - `compat_guest_provision` installs a reviewed allowlist of Alpine packages in
25
25
  the guest (`confirm:true` required).
26
26
  - `compat_status` lists stored reports and install attempts.
27
+ - `compat_doctor` checks DSHM mode, core, profile/bundle, queue, state, and the
28
+ guest in one read-only call.
27
29
  - `compat_install` installs an npm/GitHub spec through the host queue. Local
28
30
  `.tgz` paths (and `file:`/`link:`) are unpacked directly into the owning DSH
29
31
  profile and its bundle list, then need a DSHM restart; this is required
@@ -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,6 +10,7 @@
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'
15
16
  import { installLocalTarball, isLocalSpec } from './lib/local-install.js'
@@ -189,6 +190,15 @@ export function apply(ctx) {
189
190
  },
190
191
  ), 'compat_status')
191
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
+
192
202
  register(definition(
193
203
  'compat_guest_probe',
194
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. '
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 { kind: 'github', name: `${owner}/${repo}`, version: '', tarballUrl }
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
- return downloadBuffer(resolved.tarballUrl, {
233
- maxBytes: options.maxBytes,
234
- signal: options.signal,
235
- headers: resolved.kind === 'github' ? { accept: 'application/vnd.github+json' } : {},
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
  }
@@ -125,31 +125,55 @@ export function installLocalTarball(tarball, options = {}) {
125
125
  }
126
126
 
127
127
  const destination = join(profileDir, 'node_modules', ...name.split('/'))
128
- writeEntries(destination, entries)
129
-
130
128
  const manifestPath = join(profileDir, 'package.json')
131
129
  const manifest = readJson(manifestPath)
132
130
  if (record(manifest) === undefined) return { ok: false, error: `profile manifest is not an object: ${manifestPath}` }
133
- const dependencies = record(manifest.dependencies) ?? {}
134
- dependencies[name] = version
135
- manifest.dependencies = dependencies
136
- const dsh = record(manifest.dsh) ?? {}
137
- const profile = record(dsh.profile) ?? {}
138
- const bundles = Array.isArray(profile.bundles) ? profile.bundles.filter(item => typeof item === 'string') : []
139
- if (!bundles.includes(name)) bundles.push(name)
140
- profile.bundles = bundles
141
- dsh.profile = profile
142
- manifest.dsh = dsh
143
- writeJsonAtomic(manifestPath, manifest)
144
131
 
145
- return {
146
- ok: true,
147
- method: 'local-profile',
148
- profileDir,
149
- name,
150
- version,
151
- destination,
152
- restartRequired: true,
153
- warnings: validation.warnings,
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
+ }
154
178
  }
155
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshm-compat",
3
- "version": "0.1.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",