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/LICENSE +21 -21
- package/README.md +236 -234
- package/README.zh.md +195 -190
- package/lib/binding.js +97 -97
- package/lib/client.js +28 -26
- package/lib/credential.js +177 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +3288 -3230
- package/lib/paths.js +217 -217
- package/lib/registry.js +89 -89
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +74 -66
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
|
+
}
|