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 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.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)
7
7
 
8
- ### A1. npm or GitHub (recommended)
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, then in DSHM:
11
+ first), or push this directory to a GitHub repository.
12
12
 
13
- 1. Open **Settings → Plugins → Add plugin**.
14
- 2. Enter `dshm-compat` (npm) or the GitHub repository URL.
15
- 3. Install, enable the bundle, and restart DSHM if the app asks for it.
16
- 4. Confirm the plugin card shows **DSHM Plugin Compatibility**.
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.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,
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.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
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.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'
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
- Phase 1 ships the analyzer, the S0/S1 converter, the dsh-ops recipe, guest
9
- probing/provisioning, and the DSHM install-queue transport:
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
- - `compat_install` submits an already-built tarball to the host install queue
26
- (`confirm:true` required).
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
@@ -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
- execute,
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 installed = await enqueueInstall(`file:${result.tarball}`, { profileDir: args.profileDir, signal: exec?.signal })
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
- 'Submit an already-built plugin tarball or spec to the DSHM host install queue and wait for the host installer result. '
217
- + 'This does NOT convert or build anything; use compat_convert first when it exists. Requires confirm:true because installing a plugin runs third-party code.',
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 let the host choose its profile.' },
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 { 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
  }
@@ -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.0",
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": {