dshm-compat 0.1.0 → 0.1.1

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,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Tool results are now lossless JSON: `undefined`-valued fields are stripped at
6
+ the tool boundary. This fixes `compat_analyze` / `compat_plan` / `compat_scan`
7
+ failing with `value is not lossless JSON` on DSHM.
8
+ - Local tarball installation: `compat_install` (and `compat_convert` with
9
+ `install:true`) unpacks a `.tgz` into the owning DSH profile's `node_modules`,
10
+ records the dependency, and appends the bundle. This works around DSHM's host
11
+ installer rejecting local paths, `file:` and `http://` specs. A restart is
12
+ required, and the result says `restartRequired: true`.
13
+ - Local install preserves the tarball's file modes (needed for native worker
14
+ binaries such as `dsh-ops-worker`).
15
+ - Docs updated with the DSHM `pnpm view` limitation and the local install path.
16
+
17
+ ## 0.1.0
18
+
19
+ - Initial analyzer, S0/S1 converter, dependency preflight/vendoring, guest
20
+ probe/provision, install queue, and dsh-ops 0.3.x recipe.
package/INSTALL.md CHANGED
@@ -5,15 +5,26 @@ 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:
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**.
11
+ first), or push this directory to a GitHub repository.
12
+
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.1` 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
 
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
+ 0.1.1 fixes DSHM tool-result validation and adds local-tarball installation.
8
9
  Phase 1 ships the analyzer, the S0/S1 converter, the dsh-ops recipe, guest
9
- probing/provisioning, and the DSHM install-queue transport:
10
+ probing/provisioning, the DSHM install-queue transport, and a profile-local
11
+ 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,12 @@ 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_install` installs an npm/GitHub spec through the host queue. Local
28
+ `.tgz` paths (and `file:`/`link:`) are unpacked directly into the owning DSH
29
+ profile and its bundle list, then need a DSHM restart; this is required
30
+ because the DSHM host queue rejects local specs.
31
+ - `compat_convert` with `install:true` uses the same local fallback for its
32
+ converted tarball.
27
33
 
28
34
  The converter deliberately stops at the manifest/repack/vendor boundary:
29
35
  source transpilation and native builds are later phases. A report saying
package/index.js CHANGED
@@ -12,6 +12,7 @@ import { scan } from './lib/batch.js'
12
12
  import { convert } from './lib/convert.js'
13
13
  import { probeGuest, provisionGuest } from './lib/guest.js'
14
14
  import { enqueueInstall } from './lib/install.js'
15
+ import { installLocalTarball, isLocalSpec } from './lib/local-install.js'
15
16
  import { buildPlan } from './lib/plan.js'
16
17
  import { recordInstall, recordReport, status } from './lib/state.js'
17
18
 
@@ -47,7 +48,13 @@ function definition(name, description, parameters, execute) {
47
48
  description: description + RESULT_NOTE,
48
49
  parameters,
49
50
  output: { schema: OUTPUT, render: renderJson },
50
- execute,
51
+ // DSH validates tool results as lossless JSON; undefined-valued fields are
52
+ // rejected. Strip them once at the boundary so every tool is safe.
53
+ execute: async (args, exec) => {
54
+ const value = await execute(args, exec)
55
+ if (value === undefined) return null
56
+ try { return JSON.parse(JSON.stringify(value)) } catch (error) { return errorResult(error) }
57
+ },
51
58
  }
52
59
  }
53
60
 
@@ -145,6 +152,7 @@ export function apply(ctx) {
145
152
  confirm: { type: 'boolean', description: 'Required when install is true. Confirms that installing the converted package runs third-party code.' },
146
153
  vendor: { type: 'boolean', description: 'Vendor missing pure-JS runtime dependencies into the tarball. Default true.' },
147
154
  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.' },
155
+ 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
156
  },
149
157
  required: ['spec'],
150
158
  additionalProperties: false,
@@ -160,7 +168,10 @@ export function apply(ctx) {
160
168
  if (args.confirm !== true) {
161
169
  return { ...result, install: { status: 'confirmation-required', message: 'Re-run with install:true and confirm:true to submit the tarball.' } }
162
170
  }
163
- const installed = await enqueueInstall(`file:${result.tarball}`, { profileDir: args.profileDir, signal: exec?.signal })
171
+ const local = args.allowLocal === false ? undefined : installLocalTarball(result.tarball, { profileDir: args.profileDir })
172
+ const installed = local === undefined
173
+ ? await enqueueInstall(`file:${result.tarball}`, { profileDir: args.profileDir, signal: exec?.signal })
174
+ : { status: local.ok ? 'ok' : 'error', ...local }
164
175
  recordInstall({ spec: args.spec, tarball: result.tarball, at: new Date().toISOString(), ...installed })
165
176
  return { ...result, install: installed }
166
177
  } catch (error) {
@@ -213,13 +224,14 @@ export function apply(ctx) {
213
224
 
214
225
  register(definition(
215
226
  '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.',
227
+ '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. '
228
+ + 'Requires confirm:true because installing a plugin runs third-party code; a local install needs a DSHM restart to load.',
218
229
  {
219
230
  type: 'object',
220
231
  properties: {
221
232
  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.' },
233
+ profileDir: { type: 'string', description: 'Absolute DSHM profile directory. Omit to infer it from this plugin\'s installation path.' },
234
+ allowLocal: { type: 'boolean', description: 'Allow unpacking a local tarball into the profile. Default true. Set false to force the host install queue.' },
223
235
  confirm: { type: 'boolean', description: 'Must be true. The caller confirms that installing this package runs third-party code.' },
224
236
  },
225
237
  required: ['spec', 'confirm'],
@@ -230,6 +242,14 @@ export function apply(ctx) {
230
242
  return { status: 'confirmation-required', message: 'Re-run with confirm:true after reviewing the analyzer report.' }
231
243
  }
232
244
  try {
245
+ if (args.allowLocal !== false && isLocalSpec(args.spec)) {
246
+ const path = args.spec.trim().replace(/^(?:file|link):/i, '')
247
+ const local = installLocalTarball(path, { profileDir: args.profileDir })
248
+ recordInstall({ spec: args.spec, at: new Date().toISOString(), ...local })
249
+ return local.ok
250
+ ? { status: 'ok', ...local }
251
+ : { status: 'error', error: { code: 'LOCAL_INSTALL_FAILED', message: local.error ?? 'local install failed' }, ...local }
252
+ }
233
253
  const result = await enqueueInstall(args.spec, { profileDir: args.profileDir, signal: exec?.signal })
234
254
  recordInstall({ spec: args.spec, at: new Date().toISOString(), ...result })
235
255
  return result
@@ -0,0 +1,155 @@
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
+ writeEntries(destination, entries)
129
+
130
+ const manifestPath = join(profileDir, 'package.json')
131
+ const manifest = readJson(manifestPath)
132
+ 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
+
145
+ return {
146
+ ok: true,
147
+ method: 'local-profile',
148
+ profileDir,
149
+ name,
150
+ version,
151
+ destination,
152
+ restartRequired: true,
153
+ warnings: validation.warnings,
154
+ }
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshm-compat",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {