dsh-remote 0.8.13 → 0.8.15

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/lib/tasks.js CHANGED
@@ -1,95 +1,95 @@
1
- // dsh-remote — single-flight async task manager for long-running operations
2
- // (rw_sync / rw_push / rw_search with async:true). Tasks run one at a time
3
- // (the SSH pool is shared), report progress, and can be cancelled cooperatively.
4
-
5
- export class TaskManager {
6
- constructor() {
7
- this.tasks = new Map()
8
- this.nextId = 1
9
- this.queue = []
10
- this.running = false
11
- }
12
-
13
- /** Start a task. `fn(ctx)` where ctx = { progress(partial), cancelled }.
14
- * The task must poll ctx.cancelled between batches and return a result. */
15
- start(kind, label, fn) {
16
- const id = 'task-' + this.nextId++
17
- const task = {
18
- id,
19
- kind,
20
- label,
21
- status: 'queued',
22
- progress: {},
23
- result: null,
24
- error: null,
25
- startedAt: null,
26
- finishedAt: null,
27
- }
28
- this.tasks.set(id, task)
29
- this.queue.push({ task, fn })
30
- this._pump()
31
- return task
32
- }
33
-
34
- get(id) {
35
- return this.tasks.get(id) || null
36
- }
37
-
38
- list() {
39
- return [...this.tasks.values()].map((t) => ({
40
- id: t.id,
41
- kind: t.kind,
42
- label: t.label,
43
- status: t.status,
44
- progress: t.progress,
45
- error: t.error,
46
- startedAt: t.startedAt,
47
- finishedAt: t.finishedAt,
48
- }))
49
- }
50
-
51
- cancel(id) {
52
- const t = this.tasks.get(id)
53
- if (!t) return false
54
- if (t.status === 'queued') {
55
- t.status = 'cancelled'
56
- t.finishedAt = new Date().toISOString()
57
- const qi = this.queue.findIndex((q) => q.task === t)
58
- if (qi >= 0) this.queue.splice(qi, 1)
59
- return true
60
- }
61
- if (t.status === 'running') {
62
- t.cancelled = true
63
- return true
64
- }
65
- return false
66
- }
67
-
68
- async _pump() {
69
- if (this.running) return
70
- this.running = true
71
- try {
72
- while (this.queue.length) {
73
- const { task, fn } = this.queue.shift()
74
- if (task.status === 'cancelled') continue
75
- task.status = 'running'
76
- task.startedAt = new Date().toISOString()
77
- const ctx = {
78
- progress: (p) => { task.progress = { ...task.progress, ...p } },
79
- get cancelled() { return !!task.cancelled },
80
- }
81
- try {
82
- task.result = await fn(ctx)
83
- if (task.cancelled) task.status = 'cancelled'
84
- else task.status = 'done'
85
- } catch (err) {
86
- if (task.cancelled) task.status = 'cancelled'
87
- else { task.status = 'failed'; task.error = String((err && err.message) || err) }
88
- }
89
- task.finishedAt = new Date().toISOString()
90
- }
91
- } finally {
92
- this.running = false
93
- }
94
- }
95
- }
1
+ // dsh-remote — single-flight async task manager for long-running operations
2
+ // (rw_sync / rw_push / rw_search with async:true). Tasks run one at a time
3
+ // (the SSH pool is shared), report progress, and can be cancelled cooperatively.
4
+
5
+ export class TaskManager {
6
+ constructor() {
7
+ this.tasks = new Map()
8
+ this.nextId = 1
9
+ this.queue = []
10
+ this.running = false
11
+ }
12
+
13
+ /** Start a task. `fn(ctx)` where ctx = { progress(partial), cancelled }.
14
+ * The task must poll ctx.cancelled between batches and return a result. */
15
+ start(kind, label, fn) {
16
+ const id = 'task-' + this.nextId++
17
+ const task = {
18
+ id,
19
+ kind,
20
+ label,
21
+ status: 'queued',
22
+ progress: {},
23
+ result: null,
24
+ error: null,
25
+ startedAt: null,
26
+ finishedAt: null,
27
+ }
28
+ this.tasks.set(id, task)
29
+ this.queue.push({ task, fn })
30
+ this._pump()
31
+ return task
32
+ }
33
+
34
+ get(id) {
35
+ return this.tasks.get(id) || null
36
+ }
37
+
38
+ list() {
39
+ return [...this.tasks.values()].map((t) => ({
40
+ id: t.id,
41
+ kind: t.kind,
42
+ label: t.label,
43
+ status: t.status,
44
+ progress: t.progress,
45
+ error: t.error,
46
+ startedAt: t.startedAt,
47
+ finishedAt: t.finishedAt,
48
+ }))
49
+ }
50
+
51
+ cancel(id) {
52
+ const t = this.tasks.get(id)
53
+ if (!t) return false
54
+ if (t.status === 'queued') {
55
+ t.status = 'cancelled'
56
+ t.finishedAt = new Date().toISOString()
57
+ const qi = this.queue.findIndex((q) => q.task === t)
58
+ if (qi >= 0) this.queue.splice(qi, 1)
59
+ return true
60
+ }
61
+ if (t.status === 'running') {
62
+ t.cancelled = true
63
+ return true
64
+ }
65
+ return false
66
+ }
67
+
68
+ async _pump() {
69
+ if (this.running) return
70
+ this.running = true
71
+ try {
72
+ while (this.queue.length) {
73
+ const { task, fn } = this.queue.shift()
74
+ if (task.status === 'cancelled') continue
75
+ task.status = 'running'
76
+ task.startedAt = new Date().toISOString()
77
+ const ctx = {
78
+ progress: (p) => { task.progress = { ...task.progress, ...p } },
79
+ get cancelled() { return !!task.cancelled },
80
+ }
81
+ try {
82
+ task.result = await fn(ctx)
83
+ if (task.cancelled) task.status = 'cancelled'
84
+ else task.status = 'done'
85
+ } catch (err) {
86
+ if (task.cancelled) task.status = 'cancelled'
87
+ else { task.status = 'failed'; task.error = String((err && err.message) || err) }
88
+ }
89
+ task.finishedAt = new Date().toISOString()
90
+ }
91
+ } finally {
92
+ this.running = false
93
+ }
94
+ }
95
+ }
package/lib/update.js CHANGED
@@ -1,196 +1,196 @@
1
- // dsh-remote — self-update support.
2
- //
3
- // dsh-remote is a zero-build plugin: the host half (lib/index.js) is loaded
4
- // straight from the installed package, so "updating" = replacing the package
5
- // files with the newer npm tarball. This module owns the version check and the
6
- // tarball apply:
7
- //
8
- // • checkLatestVersion() — query the npm registry for the `latest` dist-tag;
9
- // • applyUpdate() — download the tarball, extract package/{lib,package.json,
10
- // cordis.patch.yml} with a minimal tar reader, verify the version, then
11
- // atomically copy over the installed files. A `.dsh-remote-updated` marker
12
- // is written so the UI can tell the user to reload.
13
- //
14
- // Every failure aborts without touching the installed files (a bad download or
15
- // a version mismatch must never break the running plugin).
16
-
17
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
- import path from 'node:path'
19
- import { fileURLToPath } from 'node:url'
20
-
21
- const NPM_PACKAGE = 'dsh-remote'
22
- const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
23
-
24
- /** Absolute directory holding this plugin's installed files (lib/ + package.json). */
25
- export function selfDir() {
26
- try {
27
- return path.dirname(path.dirname(fileURLToPath(import.meta.url)))
28
- } catch {
29
- return path.dirname(path.dirname(process.argv[1] || ''))
30
- }
31
- }
32
-
33
- /** Read the installed package.json version; "0.0.0" when unreadable. */
34
- export function readVersion() {
35
- try {
36
- const pkg = JSON.parse(readFileSync(path.join(selfDir(), 'package.json'), 'utf8'))
37
- return String(pkg.version || '0.0.0')
38
- } catch {
39
- return '0.0.0'
40
- }
41
- }
42
-
43
- /** Compare dotted versions; returns true when a > b. */
44
- export function gtVersion(a, b) {
45
- const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
46
- const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0)
47
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
48
- const da = pa[i] || 0
49
- const db = pb[i] || 0
50
- if (da !== db) return da > db
51
- }
52
- return false
53
- }
54
-
55
- /** Query the npm registry for the latest release; resolves null on any failure. */
56
- export async function fetchLatestVersion(timeoutMs = 8000) {
57
- try {
58
- const controller = new AbortController()
59
- const timer = setTimeout(() => controller.abort(), timeoutMs)
60
- try {
61
- const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
62
- if (!res.ok) return null
63
- const data = await res.json()
64
- return typeof data?.version === 'string' && data.version ? data.version : null
65
- } finally {
66
- clearTimeout(timer)
67
- }
68
- } catch {
69
- return null
70
- }
71
- }
72
-
73
- /** Minimal POSIX ustar reader (regular files only) — npm tarballs are gzipped tar. */
74
- function parseTar(buf) {
75
- const files = []
76
- let off = 0
77
- while (off + 512 <= buf.length) {
78
- const header = buf.subarray(off, off + 512)
79
- const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
80
- if (!name) break
81
- const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
82
- const size = parseInt(sizeStr, 8) || 0
83
- const type = String.fromCharCode(header[156] || 48)
84
- off += 512
85
- const data = type === '48' || type === '0' ? Buffer.from(buf.subarray(off, off + size)) : null
86
- if (data) files.push({ name, data })
87
- off += Math.ceil(size / 512) * 512
88
- }
89
- return files
90
- }
91
-
92
- /**
93
- * Download the given version's tarball and atomically replace the installed
94
- * package files (lib/*.js, cordis.patch.yml, package.json). On success writes
95
- * `.dsh-remote-updated` in the install dir; throws on any failure (leaves the
96
- * installed files untouched).
97
- * @param targetVersion - exact npm version to install (e.g. "0.8.0").
98
- */
99
- export async function applyUpdate(targetVersion) {
100
- const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
101
- const dir = selfDir()
102
- const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
103
- const tmpPkg = path.join(tmpRoot, 'package')
104
- try {
105
- const controller = new AbortController()
106
- const timer = setTimeout(() => controller.abort(), 60000)
107
- let res
108
- try {
109
- res = await fetch(tarballUrl, { signal: controller.signal })
110
- } finally {
111
- clearTimeout(timer)
112
- }
113
- if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`)
114
- const buf = Buffer.from(await res.arrayBuffer())
115
- // npm tarballs are gzipped tar; extract the package/ directory.
116
- const { gunzipSync } = await import('node:zlib')
117
- const tar = gunzipSync(buf)
118
- const files = parseTar(tar)
119
- mkdirSync(tmpPkg, { recursive: true })
120
- let extracted = 0
121
- for (const f of files) {
122
- const rel = f.name.replace(/^package\//, '')
123
- if (!rel || rel === 'package/' || rel.endsWith('/')) continue
124
- if (rel !== 'package.json' && rel !== 'cordis.patch.yml' && !rel.startsWith('lib/')) continue
125
- const dest = path.join(tmpPkg, rel)
126
- mkdirSync(path.dirname(dest), { recursive: true })
127
- writeFileSync(dest, f.data)
128
- extracted++
129
- }
130
- if (extracted === 0) throw new Error('tarball contained no package files')
131
- // Verify the downloaded package.json matches the requested version.
132
- const newPkg = JSON.parse(readFileSync(path.join(tmpPkg, 'package.json'), 'utf8'))
133
- if (newPkg.version !== targetVersion) throw new Error(`tarball version mismatch: ${newPkg.version}`)
134
- // Sanity: the new host half must at least parse (a corrupt index.js would
135
- // break the next boot — check the file size and that it is not empty).
136
- const newIndex = path.join(tmpPkg, 'lib', 'index.js')
137
- if (!existsSync(newIndex) || statSize(newIndex) < 100) throw new Error('tarball missing lib/index.js')
138
- // Atomically swap: copy fresh files over the installed ones.
139
- const installLib = path.join(dir, 'lib')
140
- const tmpLib = path.join(tmpPkg, 'lib')
141
- for (const name of readdirSafe(tmpLib)) {
142
- if (!name.endsWith('.js')) continue
143
- const src = path.join(tmpLib, name)
144
- if (existsSync(src)) copyFileSync(src, path.join(installLib, name))
145
- }
146
- for (const rel of ['package.json', 'cordis.patch.yml']) {
147
- const src = path.join(tmpPkg, rel)
148
- if (existsSync(src)) copyFileSync(src, path.join(dir, rel))
149
- }
150
- writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
151
- return { ok: true, to: targetVersion }
152
- } catch (err) {
153
- throw new Error('update failed: ' + ((err && err.message) || err))
154
- } finally {
155
- try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {}
156
- }
157
- }
158
-
159
- /** File size helper (missing/unreadable → 0). */
160
- function statSize(p) {
161
- try {
162
- return statSync(p).size
163
- } catch {
164
- return 0
165
- }
166
- }
167
-
168
- /** readdir helper that returns [] on failure. */
169
- function readdirSafe(dir) {
170
- try {
171
- return readdirSync(dir)
172
- } catch {
173
- return []
174
- }
175
- }
176
-
177
- /** Write the persisted update-mode override (settings UI). */
178
- export function persistUpdateMode(mode) {
179
- if (!['manual', 'auto', 'off'].includes(mode)) return false
180
- try {
181
- writeFileSync(path.join(selfDir(), 'update-mode'), mode)
182
- return true
183
- } catch {
184
- return false
185
- }
186
- }
187
-
188
- /** Read a persisted update-mode override, or null when absent/invalid. */
189
- export function readUpdateMode() {
190
- try {
191
- const m = readFileSync(path.join(selfDir(), 'update-mode'), 'utf8').trim()
192
- return ['manual', 'auto', 'off'].includes(m) ? m : null
193
- } catch {
194
- return null
195
- }
196
- }
1
+ // dsh-remote — self-update support.
2
+ //
3
+ // dsh-remote is a zero-build plugin: the host half (lib/index.js) is loaded
4
+ // straight from the installed package, so "updating" = replacing the package
5
+ // files with the newer npm tarball. This module owns the version check and the
6
+ // tarball apply:
7
+ //
8
+ // • checkLatestVersion() — query the npm registry for the `latest` dist-tag;
9
+ // • applyUpdate() — download the tarball, extract package/{lib,package.json,
10
+ // cordis.patch.yml} with a minimal tar reader, verify the version, then
11
+ // atomically copy over the installed files. A `.dsh-remote-updated` marker
12
+ // is written so the UI can tell the user to reload.
13
+ //
14
+ // Every failure aborts without touching the installed files (a bad download or
15
+ // a version mismatch must never break the running plugin).
16
+
17
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const NPM_PACKAGE = 'dsh-remote'
22
+ const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
23
+
24
+ /** Absolute directory holding this plugin's installed files (lib/ + package.json). */
25
+ export function selfDir() {
26
+ try {
27
+ return path.dirname(path.dirname(fileURLToPath(import.meta.url)))
28
+ } catch {
29
+ return path.dirname(path.dirname(process.argv[1] || ''))
30
+ }
31
+ }
32
+
33
+ /** Read the installed package.json version; "0.0.0" when unreadable. */
34
+ export function readVersion() {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(path.join(selfDir(), 'package.json'), 'utf8'))
37
+ return String(pkg.version || '0.0.0')
38
+ } catch {
39
+ return '0.0.0'
40
+ }
41
+ }
42
+
43
+ /** Compare dotted versions; returns true when a > b. */
44
+ export function gtVersion(a, b) {
45
+ const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
46
+ const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0)
47
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
48
+ const da = pa[i] || 0
49
+ const db = pb[i] || 0
50
+ if (da !== db) return da > db
51
+ }
52
+ return false
53
+ }
54
+
55
+ /** Query the npm registry for the latest release; resolves null on any failure. */
56
+ export async function fetchLatestVersion(timeoutMs = 8000) {
57
+ try {
58
+ const controller = new AbortController()
59
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
60
+ try {
61
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
62
+ if (!res.ok) return null
63
+ const data = await res.json()
64
+ return typeof data?.version === 'string' && data.version ? data.version : null
65
+ } finally {
66
+ clearTimeout(timer)
67
+ }
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ /** Minimal POSIX ustar reader (regular files only) — npm tarballs are gzipped tar. */
74
+ function parseTar(buf) {
75
+ const files = []
76
+ let off = 0
77
+ while (off + 512 <= buf.length) {
78
+ const header = buf.subarray(off, off + 512)
79
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
80
+ if (!name) break
81
+ const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim()
82
+ const size = parseInt(sizeStr, 8) || 0
83
+ const type = String.fromCharCode(header[156] || 48)
84
+ off += 512
85
+ const data = type === '48' || type === '0' ? Buffer.from(buf.subarray(off, off + size)) : null
86
+ if (data) files.push({ name, data })
87
+ off += Math.ceil(size / 512) * 512
88
+ }
89
+ return files
90
+ }
91
+
92
+ /**
93
+ * Download the given version's tarball and atomically replace the installed
94
+ * package files (lib/*.js, cordis.patch.yml, package.json). On success writes
95
+ * `.dsh-remote-updated` in the install dir; throws on any failure (leaves the
96
+ * installed files untouched).
97
+ * @param targetVersion - exact npm version to install (e.g. "0.8.0").
98
+ */
99
+ export async function applyUpdate(targetVersion) {
100
+ const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
101
+ const dir = selfDir()
102
+ const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
103
+ const tmpPkg = path.join(tmpRoot, 'package')
104
+ try {
105
+ const controller = new AbortController()
106
+ const timer = setTimeout(() => controller.abort(), 60000)
107
+ let res
108
+ try {
109
+ res = await fetch(tarballUrl, { signal: controller.signal })
110
+ } finally {
111
+ clearTimeout(timer)
112
+ }
113
+ if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`)
114
+ const buf = Buffer.from(await res.arrayBuffer())
115
+ // npm tarballs are gzipped tar; extract the package/ directory.
116
+ const { gunzipSync } = await import('node:zlib')
117
+ const tar = gunzipSync(buf)
118
+ const files = parseTar(tar)
119
+ mkdirSync(tmpPkg, { recursive: true })
120
+ let extracted = 0
121
+ for (const f of files) {
122
+ const rel = f.name.replace(/^package\//, '')
123
+ if (!rel || rel === 'package/' || rel.endsWith('/')) continue
124
+ if (rel !== 'package.json' && rel !== 'cordis.patch.yml' && !rel.startsWith('lib/')) continue
125
+ const dest = path.join(tmpPkg, rel)
126
+ mkdirSync(path.dirname(dest), { recursive: true })
127
+ writeFileSync(dest, f.data)
128
+ extracted++
129
+ }
130
+ if (extracted === 0) throw new Error('tarball contained no package files')
131
+ // Verify the downloaded package.json matches the requested version.
132
+ const newPkg = JSON.parse(readFileSync(path.join(tmpPkg, 'package.json'), 'utf8'))
133
+ if (newPkg.version !== targetVersion) throw new Error(`tarball version mismatch: ${newPkg.version}`)
134
+ // Sanity: the new host half must at least parse (a corrupt index.js would
135
+ // break the next boot — check the file size and that it is not empty).
136
+ const newIndex = path.join(tmpPkg, 'lib', 'index.js')
137
+ if (!existsSync(newIndex) || statSize(newIndex) < 100) throw new Error('tarball missing lib/index.js')
138
+ // Atomically swap: copy fresh files over the installed ones.
139
+ const installLib = path.join(dir, 'lib')
140
+ const tmpLib = path.join(tmpPkg, 'lib')
141
+ for (const name of readdirSafe(tmpLib)) {
142
+ if (!name.endsWith('.js')) continue
143
+ const src = path.join(tmpLib, name)
144
+ if (existsSync(src)) copyFileSync(src, path.join(installLib, name))
145
+ }
146
+ for (const rel of ['package.json', 'cordis.patch.yml']) {
147
+ const src = path.join(tmpPkg, rel)
148
+ if (existsSync(src)) copyFileSync(src, path.join(dir, rel))
149
+ }
150
+ writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
151
+ return { ok: true, to: targetVersion }
152
+ } catch (err) {
153
+ throw new Error('update failed: ' + ((err && err.message) || err))
154
+ } finally {
155
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch {}
156
+ }
157
+ }
158
+
159
+ /** File size helper (missing/unreadable → 0). */
160
+ function statSize(p) {
161
+ try {
162
+ return statSync(p).size
163
+ } catch {
164
+ return 0
165
+ }
166
+ }
167
+
168
+ /** readdir helper that returns [] on failure. */
169
+ function readdirSafe(dir) {
170
+ try {
171
+ return readdirSync(dir)
172
+ } catch {
173
+ return []
174
+ }
175
+ }
176
+
177
+ /** Write the persisted update-mode override (settings UI). */
178
+ export function persistUpdateMode(mode) {
179
+ if (!['manual', 'auto', 'off'].includes(mode)) return false
180
+ try {
181
+ writeFileSync(path.join(selfDir(), 'update-mode'), mode)
182
+ return true
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+
188
+ /** Read a persisted update-mode override, or null when absent/invalid. */
189
+ export function readUpdateMode() {
190
+ try {
191
+ const m = readFileSync(path.join(selfDir(), 'update-mode'), 'utf8').trim()
192
+ return ['manual', 'auto', 'off'].includes(m) ? m : null
193
+ } catch {
194
+ return null
195
+ }
196
+ }