dsh-remote 0.8.23 → 0.8.24
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/client.js +18 -6
- package/lib/index.js +76 -13
- package/lib/update.js +185 -11
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -139,8 +139,10 @@ window.__ModuleLoader__.load({
|
|
|
139
139
|
'settings.updateFail': '检查更新失败',
|
|
140
140
|
'settings.updateAvailable': '发现新版本 v{version}',
|
|
141
141
|
'settings.updateLatest': '已是最新版本(v{version})',
|
|
142
|
-
'settings.updateConfirm': '将更新 dsh-remote 到 v{version}
|
|
143
|
-
'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}
|
|
142
|
+
'settings.updateConfirm': '将更新 dsh-remote 到 v{version},替换本机插件文件并在线热切换宿主半(不重启 Harness)。继续?',
|
|
143
|
+
'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}),宿主半已在线热切换。',
|
|
144
|
+
'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
|
|
145
|
+
'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
|
|
144
146
|
'settings.updateFailed': '更新失败',
|
|
145
147
|
'settings.updateFailedMsg': '更新失败: {msg}',
|
|
146
148
|
'settings.registryUnreachable': '无法连接 npm registry',
|
|
@@ -363,8 +365,10 @@ window.__ModuleLoader__.load({
|
|
|
363
365
|
'settings.updateFail': 'Failed to check for updates',
|
|
364
366
|
'settings.updateAvailable': 'New version found: v{version}',
|
|
365
367
|
'settings.updateLatest': 'Already up to date (v{version})',
|
|
366
|
-
'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and
|
|
367
|
-
'settings.updateApplied': '✅ Updated to v{to} ({from} → {to})
|
|
368
|
+
'settings.updateConfirm': 'Update dsh-remote to v{version}? This replaces the local plugin files and hot-swaps the host half in place (no Harness restart). Continue?',
|
|
369
|
+
'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}); the host half hot-swapped in place.',
|
|
370
|
+
'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
|
|
371
|
+
'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
|
|
368
372
|
'settings.updateFailed': 'Update failed',
|
|
369
373
|
'settings.updateFailedMsg': 'Update failed: {msg}',
|
|
370
374
|
'settings.registryUnreachable': 'Cannot reach the npm registry',
|
|
@@ -836,9 +840,13 @@ window.__ModuleLoader__.load({
|
|
|
836
840
|
setUpdBusy(true); setUpdMsg('')
|
|
837
841
|
api('POST', '/dsh-remote/update-apply', { version: upd.latest })
|
|
838
842
|
.then((r) => {
|
|
839
|
-
if (r && r.ok) setUpdMsg(tr('settings.updateApplied', { to: r.to, from: (r.from || '') }))
|
|
843
|
+
if (r && r.ok) setUpdMsg(tr(r.reloadScheduled ? 'settings.updateApplied' : 'settings.updateAppliedNoReload', { to: r.to, from: (r.from || '') }))
|
|
840
844
|
else setUpdMsg((r && r.error) || tr('settings.updateFailed'))
|
|
841
|
-
|
|
845
|
+
// The host half swaps itself ~300ms after answering, so the panel
|
|
846
|
+
// must not re-check immediately: it would still be talking to the
|
|
847
|
+
// outgoing fiber and report the pre-swap version, i.e. "update
|
|
848
|
+
// available" right after a successful update.
|
|
849
|
+
window.setTimeout(() => checkUpdate(true), 2000)
|
|
842
850
|
})
|
|
843
851
|
.catch((e) => setUpdMsg(tr('settings.updateFailedMsg', { msg: String((e && e.message) || e) })))
|
|
844
852
|
.finally(() => setUpdBusy(false))
|
|
@@ -1127,6 +1135,10 @@ window.__ModuleLoader__.load({
|
|
|
1127
1135
|
}, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
|
|
1128
1136
|
),
|
|
1129
1137
|
),
|
|
1138
|
+
upd && upd.pendingReload
|
|
1139
|
+
? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
|
|
1140
|
+
tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
|
|
1141
|
+
: null,
|
|
1130
1142
|
updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
|
|
1131
1143
|
),
|
|
1132
1144
|
React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
|
package/lib/index.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import z from '@deepseek-ai/schemastery'
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import { execFile } from 'node:child_process'
|
|
24
|
-
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch } from 'node:fs'
|
|
24
|
+
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, copyFileSync, appendFileSync, watch, rmSync } from 'node:fs'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
import iconv from 'iconv-lite'
|
|
@@ -41,7 +41,7 @@ import { searchRemote } from './search.js'
|
|
|
41
41
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
43
|
import { ForwardManager } from './forwards.js'
|
|
44
|
-
import { selfDir,
|
|
44
|
+
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload } from './update.js'
|
|
45
45
|
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
46
46
|
import { registerHttpTransports } from './http-transport.js'
|
|
47
47
|
import { SshPool } from './pool.js'
|
|
@@ -130,6 +130,10 @@ export const Config = z.object({
|
|
|
130
130
|
updateMode: z.string().default('manual'),
|
|
131
131
|
/** How often (ms) auto mode checks the npm registry for a newer release. */
|
|
132
132
|
updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
|
|
133
|
+
/** Whether a landed update also hot-swaps the running host half (default
|
|
134
|
+
* true). With `false` an update only takes effect on the next process start
|
|
135
|
+
* and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
|
|
136
|
+
updateAutoReload: z.boolean().default(true),
|
|
133
137
|
})
|
|
134
138
|
|
|
135
139
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -258,6 +262,15 @@ export async function apply(ctx, config) {
|
|
|
258
262
|
|
|
259
263
|
migrateLegacyData()
|
|
260
264
|
|
|
265
|
+
// The update marker means "a newer version landed on disk but is not the code
|
|
266
|
+
// running yet". Reaching apply() with a marker naming the version we just
|
|
267
|
+
// loaded means the swap already happened (or this is a plain boot into the
|
|
268
|
+
// updated files) → clear it, so the UI's "restart to finish" hint stays true.
|
|
269
|
+
try {
|
|
270
|
+
const marker = path.join(selfDir(), '.dsh-remote-updated')
|
|
271
|
+
if (existsSync(marker) && readFileSync(marker, 'utf8').trim() === LOADED_VERSION) rmSync(marker, { force: true })
|
|
272
|
+
} catch {}
|
|
273
|
+
|
|
261
274
|
// ── machine registry (multi-host) ─────────────────────────────────────────
|
|
262
275
|
const store = loadMachines()
|
|
263
276
|
const machines = store.list
|
|
@@ -2765,17 +2778,26 @@ export async function apply(ctx, config) {
|
|
|
2765
2778
|
path: '/dsh-remote/update-check',
|
|
2766
2779
|
handler: async (req, res) => {
|
|
2767
2780
|
if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2781
|
+
// `loaded` is the code actually running; `disk` may already be newer.
|
|
2782
|
+
// They differ between an applied update and its hot swap (or a boot).
|
|
2783
|
+
const loaded = LOADED_VERSION
|
|
2784
|
+
const disk = diskVersion()
|
|
2771
2785
|
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
2786
|
+
const base = {
|
|
2787
|
+
current: loaded,
|
|
2788
|
+
loaded,
|
|
2789
|
+
disk,
|
|
2790
|
+
pendingReload: gtVersion(disk, loaded),
|
|
2791
|
+
updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
|
|
2792
|
+
updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
|
|
2793
|
+
}
|
|
2794
|
+
const latest = await fetchLatestVersion()
|
|
2795
|
+
if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
|
|
2772
2796
|
return sendJson(res, 200, {
|
|
2773
2797
|
ok: true,
|
|
2774
|
-
|
|
2798
|
+
...base,
|
|
2775
2799
|
latest,
|
|
2776
|
-
updateAvailable: gtVersion(latest,
|
|
2777
|
-
updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
|
|
2778
|
-
updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
|
|
2800
|
+
updateAvailable: gtVersion(latest, loaded),
|
|
2779
2801
|
})
|
|
2780
2802
|
},
|
|
2781
2803
|
},
|
|
@@ -2785,16 +2807,45 @@ export async function apply(ctx, config) {
|
|
|
2785
2807
|
handler: async (req, res) => {
|
|
2786
2808
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2787
2809
|
try {
|
|
2810
|
+
// Capture the outgoing version BEFORE the swap: reading it afterwards
|
|
2811
|
+
// reports the version we just installed, not the one we replaced.
|
|
2812
|
+
const from = diskVersion()
|
|
2813
|
+
const loaded = LOADED_VERSION
|
|
2788
2814
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
2789
2815
|
const target = String(body.version || '')
|
|
2790
2816
|
if (!target) return sendJson(res, 400, { ok: false, error: 'version is required' })
|
|
2791
2817
|
const result = await applyUpdate(target)
|
|
2792
|
-
|
|
2818
|
+
const wantReload = body.reload !== false && config.updateAutoReload !== false
|
|
2819
|
+
// Answer first, then swap: the swap disposes the fiber serving this
|
|
2820
|
+
// very request, so nothing may touch the plugin after it.
|
|
2821
|
+
if (wantReload) scheduleSelfReload(ctx.loader, 300)
|
|
2822
|
+
return sendJson(res, 200, {
|
|
2823
|
+
ok: true,
|
|
2824
|
+
from,
|
|
2825
|
+
loadedBefore: loaded,
|
|
2826
|
+
...result,
|
|
2827
|
+
reloadScheduled: wantReload,
|
|
2828
|
+
// what the swap will land on, for a caller that wants to verify
|
|
2829
|
+
loadedAfterReload: wantReload ? result.to : loaded,
|
|
2830
|
+
})
|
|
2793
2831
|
} catch (err) {
|
|
2794
2832
|
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
2795
2833
|
}
|
|
2796
2834
|
},
|
|
2797
2835
|
},
|
|
2836
|
+
{
|
|
2837
|
+
kind: 'exact',
|
|
2838
|
+
path: '/dsh-remote/update-reload',
|
|
2839
|
+
handler: async (req, res) => {
|
|
2840
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2841
|
+
// Hot-swap the host half to whatever is on disk now. Answers first (with
|
|
2842
|
+
// what was loaded and what it is about to become), then swaps.
|
|
2843
|
+
const loaded = LOADED_VERSION
|
|
2844
|
+
const disk = diskVersion()
|
|
2845
|
+
scheduleSelfReload(ctx.loader, 300)
|
|
2846
|
+
return sendJson(res, 200, { ok: true, scheduled: true, loaded, disk, willReloadTo: disk })
|
|
2847
|
+
},
|
|
2848
|
+
},
|
|
2798
2849
|
{
|
|
2799
2850
|
kind: 'exact',
|
|
2800
2851
|
path: '/dsh-remote/update-mode',
|
|
@@ -2821,12 +2872,24 @@ export async function apply(ctx, config) {
|
|
|
2821
2872
|
const rawMode = readUpdateMode() || config.updateMode || 'manual'
|
|
2822
2873
|
const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
|
|
2823
2874
|
if (effectiveUpdateMode === 'auto') {
|
|
2824
|
-
const currentVersion = readVersion()
|
|
2825
2875
|
const checkAndApply = async () => {
|
|
2826
2876
|
const latest = await fetchLatestVersion()
|
|
2827
|
-
if (latest
|
|
2828
|
-
|
|
2877
|
+
if (!latest) return
|
|
2878
|
+
// The version in flight is whichever of "already on disk" / "already
|
|
2879
|
+
// loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
|
|
2880
|
+
// made every later interval re-download and re-apply the same tarball,
|
|
2881
|
+
// because the pinned value never moves past the version it installed.
|
|
2882
|
+
const disk = diskVersion()
|
|
2883
|
+
const current = gtVersion(disk, LOADED_VERSION) ? disk : LOADED_VERSION
|
|
2884
|
+
if (!gtVersion(latest, current)) return
|
|
2885
|
+
try {
|
|
2886
|
+
await applyUpdate(latest)
|
|
2887
|
+
} catch {
|
|
2888
|
+
return
|
|
2829
2889
|
}
|
|
2890
|
+
// Landed: swap the running host half too, otherwise the code being served
|
|
2891
|
+
// and the code being run stay one version apart until the next boot.
|
|
2892
|
+
if (config.updateAutoReload !== false) scheduleSelfReload(ctx.loader, 500)
|
|
2830
2893
|
}
|
|
2831
2894
|
void checkAndApply()
|
|
2832
2895
|
const updateTimer = setInterval(checkAndApply, Math.max(config.updateCheckIntervalMs, 60000))
|
package/lib/update.js
CHANGED
|
@@ -8,15 +8,24 @@
|
|
|
8
8
|
// • checkLatestVersion() — query the npm registry for the `latest` dist-tag;
|
|
9
9
|
// • applyUpdate() — download the tarball, extract package/{lib,package.json,
|
|
10
10
|
// cordis.patch.yml} with a minimal tar reader, verify the version, then
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// replace the installed files atomically (temp file + rename, never a
|
|
12
|
+
// half-written lib/*.js). A `.dsh-remote-updated` marker records the version
|
|
13
|
+
// that landed; the host half removes it once that version is the code it is
|
|
14
|
+
// actually running (see LOADED_VERSION), so the marker means "restart still
|
|
15
|
+
// required" rather than "an update once happened".
|
|
16
|
+
// • reloadSelf() — swap the RUNNING host half to the code now on disk: drop
|
|
17
|
+
// Node's module caches for this package's own files, then dispose + re-init
|
|
18
|
+
// the loader entry so apply() runs again from the fresh module. Without it
|
|
19
|
+
// an update only takes effect on the next process start, which is what left
|
|
20
|
+
// the served client half newer than the loaded host half.
|
|
13
21
|
//
|
|
14
22
|
// Every failure aborts without touching the installed files (a bad download or
|
|
15
23
|
// a version mismatch must never break the running plugin).
|
|
16
24
|
|
|
17
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
18
27
|
import path from 'node:path'
|
|
19
|
-
import { fileURLToPath } from 'node:url'
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
20
29
|
|
|
21
30
|
const NPM_PACKAGE = 'dsh-remote'
|
|
22
31
|
const REGISTRY_URL = 'https://registry.npmjs.org/' + NPM_PACKAGE + '/latest'
|
|
@@ -40,6 +49,17 @@ export function readVersion() {
|
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** Version of the code this process actually loaded (snapshot at import time).
|
|
53
|
+
* `readVersion()` reads package.json from disk, so after an update lands it
|
|
54
|
+
* reports the NEW version while the running module is still the old one —
|
|
55
|
+
* comparing the two is the only way to tell "applied" from "active". */
|
|
56
|
+
export const LOADED_VERSION = readVersion()
|
|
57
|
+
|
|
58
|
+
/** Package version currently on disk (may be newer than {@link LOADED_VERSION}). */
|
|
59
|
+
export function diskVersion() {
|
|
60
|
+
return readVersion()
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** Compare dotted versions; returns true when a > b. */
|
|
44
64
|
export function gtVersion(a, b) {
|
|
45
65
|
const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0)
|
|
@@ -96,9 +116,10 @@ function parseTar(buf) {
|
|
|
96
116
|
* installed files untouched).
|
|
97
117
|
* @param targetVersion - exact npm version to install (e.g. "0.8.0").
|
|
98
118
|
*/
|
|
99
|
-
export async function applyUpdate(targetVersion) {
|
|
119
|
+
export async function applyUpdate(targetVersion, options = {}) {
|
|
100
120
|
const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
|
|
101
|
-
const dir = selfDir()
|
|
121
|
+
const dir = options.dir || selfDir()
|
|
122
|
+
const doFetch = options.fetchImpl || fetch
|
|
102
123
|
const tmpRoot = path.join(dir, `.dsh-remote-update-${Date.now()}`)
|
|
103
124
|
const tmpPkg = path.join(tmpRoot, 'package')
|
|
104
125
|
try {
|
|
@@ -106,7 +127,7 @@ export async function applyUpdate(targetVersion) {
|
|
|
106
127
|
const timer = setTimeout(() => controller.abort(), 60000)
|
|
107
128
|
let res
|
|
108
129
|
try {
|
|
109
|
-
res = await
|
|
130
|
+
res = await doFetch(tarballUrl, { signal: controller.signal })
|
|
110
131
|
} finally {
|
|
111
132
|
clearTimeout(timer)
|
|
112
133
|
}
|
|
@@ -135,20 +156,34 @@ export async function applyUpdate(targetVersion) {
|
|
|
135
156
|
// break the next boot — check the file size and that it is not empty).
|
|
136
157
|
const newIndex = path.join(tmpPkg, 'lib', 'index.js')
|
|
137
158
|
if (!existsSync(newIndex) || statSize(newIndex) < 100) throw new Error('tarball missing lib/index.js')
|
|
138
|
-
//
|
|
159
|
+
// Swap the installed files one at a time: atomic, and idempotent.
|
|
160
|
+
//
|
|
161
|
+
// Atomicity (temp file + rename) is not cosmetic here — since the browser
|
|
162
|
+
// half is hot-swapped by dsh-client-hmr, which stat-polls lib/client.js
|
|
163
|
+
// every 500 ms, a torn write would be re-hashed and served mid-copy.
|
|
164
|
+
// Skipping byte-identical files keeps a re-apply of the same version from
|
|
165
|
+
// churning mtimes (and the host half from reloading for nothing).
|
|
139
166
|
const installLib = path.join(dir, 'lib')
|
|
140
167
|
const tmpLib = path.join(tmpPkg, 'lib')
|
|
168
|
+
const changed = []
|
|
169
|
+
const install = (src, dest) => {
|
|
170
|
+
const bytes = readFileSync(src)
|
|
171
|
+
if (sameFileContent(dest, bytes)) return
|
|
172
|
+
try { mkdirSync(path.dirname(dest), { recursive: true }) } catch {}
|
|
173
|
+
writeFileAtomic(dest, bytes)
|
|
174
|
+
changed.push(path.relative(dir, dest).split(path.sep).join('/'))
|
|
175
|
+
}
|
|
141
176
|
for (const name of readdirSafe(tmpLib)) {
|
|
142
177
|
if (!name.endsWith('.js')) continue
|
|
143
178
|
const src = path.join(tmpLib, name)
|
|
144
|
-
if (existsSync(src))
|
|
179
|
+
if (existsSync(src)) install(src, path.join(installLib, name))
|
|
145
180
|
}
|
|
146
181
|
for (const rel of ['package.json', 'cordis.patch.yml']) {
|
|
147
182
|
const src = path.join(tmpPkg, rel)
|
|
148
|
-
if (existsSync(src))
|
|
183
|
+
if (existsSync(src)) install(src, path.join(dir, rel))
|
|
149
184
|
}
|
|
150
185
|
writeFileSync(path.join(dir, '.dsh-remote-updated'), String(targetVersion))
|
|
151
|
-
return { ok: true, to: targetVersion }
|
|
186
|
+
return { ok: true, to: targetVersion, changed }
|
|
152
187
|
} catch (err) {
|
|
153
188
|
throw new Error('update failed: ' + ((err && err.message) || err))
|
|
154
189
|
} finally {
|
|
@@ -174,6 +209,145 @@ function readdirSafe(dir) {
|
|
|
174
209
|
}
|
|
175
210
|
}
|
|
176
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Write `data` to `dest` atomically: the bytes land in a sibling temp file and
|
|
214
|
+
* are then renamed over the target, so a concurrent reader (the client-half HMR
|
|
215
|
+
* poll, or the next boot) never observes a truncated file. Falls back to an
|
|
216
|
+
* in-place copy if the platform refuses the replace, and never leaves the temp
|
|
217
|
+
* file behind.
|
|
218
|
+
* @param dest - absolute destination path.
|
|
219
|
+
* @param data - bytes to write.
|
|
220
|
+
*/
|
|
221
|
+
export function writeFileAtomic(dest, data) {
|
|
222
|
+
const tmp = `${dest}.dsh-tmp-${process.pid}-${Date.now().toString(36)}`
|
|
223
|
+
writeFileSync(tmp, data)
|
|
224
|
+
try {
|
|
225
|
+
renameSync(tmp, dest)
|
|
226
|
+
return
|
|
227
|
+
} catch {
|
|
228
|
+
try {
|
|
229
|
+
copyFileSync(tmp, dest)
|
|
230
|
+
} finally {
|
|
231
|
+
try { rmSync(tmp, { force: true }) } catch {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether the file at `p` already holds exactly `bytes` (missing/short → false). */
|
|
237
|
+
function sameFileContent(p, bytes) {
|
|
238
|
+
try {
|
|
239
|
+
return readFileSync(p).equals(bytes)
|
|
240
|
+
} catch {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── hot swap of the running host half ─────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** Loader entry ids this package can own. */
|
|
248
|
+
const SELF_ENTRY_NAMES = ['dsh-remote']
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Find our own loader entry (by id, then by module specifier). Returns undefined
|
|
252
|
+
* when the tree does not contain us (e.g. a test loader, or a renamed row).
|
|
253
|
+
* @param loader - the cordis Loader service (`ctx.loader`).
|
|
254
|
+
*/
|
|
255
|
+
function findSelfEntry(loader) {
|
|
256
|
+
let entries = []
|
|
257
|
+
try {
|
|
258
|
+
entries = [...(loader?.entries?.() ?? [])]
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
for (const want of SELF_ENTRY_NAMES) {
|
|
263
|
+
const byId = entries.find((entry) => entry?.options?.id === want)
|
|
264
|
+
if (byId) return byId
|
|
265
|
+
}
|
|
266
|
+
for (const want of SELF_ENTRY_NAMES) {
|
|
267
|
+
const byName = entries.find((entry) => entry?.options?.name === want)
|
|
268
|
+
if (byName) return byName
|
|
269
|
+
}
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Drop Node's module caches for this package's own files, so the next import of
|
|
275
|
+
* the plugin actually re-reads disk instead of handing back the cached module.
|
|
276
|
+
* Both caches matter: the ESM `loadCache` (a plain Map on Node 22/23, a
|
|
277
|
+
* `LoadCache extends Map` on Node 24 — hence the explicit `Map.prototype` calls)
|
|
278
|
+
* and, for CJS modules pulled in through `import()`, `require.cache`.
|
|
279
|
+
* @param loader - the cordis Loader service.
|
|
280
|
+
* @param dir - package directory whose `lib/` to evict (defaults to this
|
|
281
|
+
* package's own install dir; overridable so the swap can be exercised against
|
|
282
|
+
* a fixture install).
|
|
283
|
+
* @returns how many ESM entries were dropped.
|
|
284
|
+
*/
|
|
285
|
+
export function clearSelfModuleCache(loader, dir = selfDir()) {
|
|
286
|
+
const libDir = path.join(dir, 'lib')
|
|
287
|
+
const loadCache = loader?.internal?.loadCache
|
|
288
|
+
const req = (() => {
|
|
289
|
+
try { return createRequire(import.meta.url) } catch { return undefined }
|
|
290
|
+
})()
|
|
291
|
+
let cleared = 0
|
|
292
|
+
for (const name of readdirSafe(libDir)) {
|
|
293
|
+
if (!name.endsWith('.js')) continue
|
|
294
|
+
const url = pathToFileURL(path.join(libDir, name)).href
|
|
295
|
+
try {
|
|
296
|
+
if (loadCache && Map.prototype.has.call(loadCache, url)) {
|
|
297
|
+
Map.prototype.delete.call(loadCache, url)
|
|
298
|
+
cleared += 1
|
|
299
|
+
}
|
|
300
|
+
} catch {}
|
|
301
|
+
try {
|
|
302
|
+
const file = fileURLToPath(url)
|
|
303
|
+
if (req?.cache && req.cache[file]) delete req.cache[file]
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
return cleared
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Swap the RUNNING host half to the code now on disk: clear the module caches
|
|
311
|
+
* for our own files, dispose our loader entry (tools, JSON routes, SSH pools and
|
|
312
|
+
* every `ctx.effect` disposer go with the fiber), then re-init it so `apply()`
|
|
313
|
+
* runs again from the freshly imported module.
|
|
314
|
+
*
|
|
315
|
+
* Callers must not depend on this plugin after a successful reload — the fiber
|
|
316
|
+
* that served the current request is gone. Schedule it (see
|
|
317
|
+
* {@link scheduleSelfReload}) rather than awaiting it from a request handler.
|
|
318
|
+
* @param loader - the cordis Loader service (`ctx.loader`).
|
|
319
|
+
* @returns a small result object; never throws.
|
|
320
|
+
*/
|
|
321
|
+
export async function reloadSelf(loader) {
|
|
322
|
+
const entry = findSelfEntry(loader)
|
|
323
|
+
if (!entry) return { ok: false, reason: 'own loader entry not found' }
|
|
324
|
+
try {
|
|
325
|
+
const cleared = clearSelfModuleCache(loader)
|
|
326
|
+
if (typeof entry._dispose === 'function') await entry._dispose()
|
|
327
|
+
else if (typeof entry.dispose === 'function') await entry.dispose()
|
|
328
|
+
else return { ok: false, reason: 'entry exposes no dispose hook' }
|
|
329
|
+
await entry.init()
|
|
330
|
+
return { ok: true, cleared }
|
|
331
|
+
} catch (err) {
|
|
332
|
+
return { ok: false, reason: String((err && err.message) || err) }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Fire-and-forget {@link reloadSelf} after a short delay, so the caller can
|
|
338
|
+
* finish answering an HTTP request (or an update check) before its own module is
|
|
339
|
+
* torn down. Failures are swallowed: a failed swap leaves the running code in
|
|
340
|
+
* place, which is the pre-hot-update behaviour, not a crash.
|
|
341
|
+
* @param loader - the cordis Loader service.
|
|
342
|
+
* @param delayMs - grace period before the swap (default 300 ms).
|
|
343
|
+
* @returns true when the swap was scheduled.
|
|
344
|
+
*/
|
|
345
|
+
export function scheduleSelfReload(loader, delayMs = 300) {
|
|
346
|
+
const timer = setTimeout(() => { void reloadSelf(loader).catch(() => {}) }, Math.max(0, delayMs))
|
|
347
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
177
351
|
/** Write the persisted update-mode override (settings UI). */
|
|
178
352
|
export function persistUpdateMode(mode) {
|
|
179
353
|
if (!['manual', 'auto', 'off'].includes(mode)) return false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.24",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|