@goodandready/dsh-remote-workspace 0.3.1 → 0.3.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/docs/design/DESIGN.md +1 -1
- package/lib/client.js +2316 -2179
- package/lib/docker-service.js +1 -1
- package/lib/index.js +42 -12
- package/lib/mirror-sync-service.js +2 -2
- package/lib/routes/docker.js +85 -0
- package/lib/routes/env.js +60 -0
- package/lib/routes/files.js +88 -0
- package/lib/routes/helpers.js +40 -0
- package/lib/routes/profiles.js +141 -0
- package/lib/routes/sync.js +52 -0
- package/lib/routes/terminal.js +103 -0
- package/lib/routes/tunnels.js +59 -0
- package/lib/routes.js +53 -627
- package/lib/ssh-service.js +24 -9
- package/lib/tunnel-service.js +2 -2
- package/lib/updater-service.js +338 -0
- package/lib/vault-service.js +29 -5
- package/lib/watcher-service.js +3 -3
- package/package.json +6 -3
- package/docs/plans/findings.md +0 -19
- package/docs/plans/progress.md +0 -15
- package/docs/plans/task_plan.md +0 -21
package/lib/ssh-service.js
CHANGED
|
@@ -32,6 +32,21 @@ export class SshService {
|
|
|
32
32
|
this.vaultService = vault;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
setProfileResolver(resolver) {
|
|
36
|
+
this.profileResolver = resolver;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async execById(profileId, command, cwd = null, onData = null) {
|
|
40
|
+
if (typeof this.profileResolver !== 'function') {
|
|
41
|
+
throw new Error('Profile resolver not configured on SshService');
|
|
42
|
+
}
|
|
43
|
+
const profile = this.profileResolver(profileId);
|
|
44
|
+
if (!profile) {
|
|
45
|
+
throw new Error(`Remote profile "${profileId}" not found`);
|
|
46
|
+
}
|
|
47
|
+
return this.exec(profile, command, cwd, onData);
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
setJumpProfileResolver(resolver) {
|
|
36
51
|
this.getJumpProfile = resolver;
|
|
37
52
|
}
|
|
@@ -49,7 +64,7 @@ export class SshService {
|
|
|
49
64
|
if (fs.existsSync(resolvedPath)) {
|
|
50
65
|
return fs.readFileSync(resolvedPath, 'utf8');
|
|
51
66
|
}
|
|
52
|
-
} catch (
|
|
67
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
53
68
|
}
|
|
54
69
|
return undefined;
|
|
55
70
|
}
|
|
@@ -227,7 +242,7 @@ export class SshService {
|
|
|
227
242
|
try {
|
|
228
243
|
stream.signal('SIGTERM');
|
|
229
244
|
stream.close();
|
|
230
|
-
} catch (
|
|
245
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
231
246
|
const errTimeout = new Error(`Command timed out after ${timeoutMs / 1000}s: ${command}`);
|
|
232
247
|
errTimeout.code = 'ETIMEDOUT';
|
|
233
248
|
errTimeout.stdout = cleanAnsi ? stripAnsi(stdout) : stdout;
|
|
@@ -316,13 +331,13 @@ export class SshService {
|
|
|
316
331
|
buffer: [],
|
|
317
332
|
subscribers: new Set(),
|
|
318
333
|
write(data) {
|
|
319
|
-
try { stream.write(data); } catch (
|
|
334
|
+
try { stream.write(data); } catch (err) { session.status = 'disconnected'; const lost = '\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'; session.buffer.push(lost); for (const sub of session.subscribers) { try { sub(lost); } catch (e) { /* best-effort notify */ } } }
|
|
320
335
|
},
|
|
321
336
|
resize(c, r) {
|
|
322
|
-
try { stream.setWindow(r, c, 0, 0); } catch (
|
|
337
|
+
try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
|
|
323
338
|
},
|
|
324
339
|
close() {
|
|
325
|
-
try { stream.end(); } catch (
|
|
340
|
+
try { stream.end(); } catch (err) { /* best-effort cleanup */ }
|
|
326
341
|
}
|
|
327
342
|
};
|
|
328
343
|
|
|
@@ -331,7 +346,7 @@ export class SshService {
|
|
|
331
346
|
session.buffer.push(chunk);
|
|
332
347
|
if (session.buffer.length > 500) session.buffer.shift();
|
|
333
348
|
for (const sub of session.subscribers) {
|
|
334
|
-
try { sub(chunk); } catch (
|
|
349
|
+
try { sub(chunk); } catch (err) { /* best-effort cleanup */ }
|
|
335
350
|
}
|
|
336
351
|
});
|
|
337
352
|
|
|
@@ -366,7 +381,7 @@ export class SshService {
|
|
|
366
381
|
disconnect(profileId) {
|
|
367
382
|
const conn = this.connections.get(profileId);
|
|
368
383
|
if (conn) {
|
|
369
|
-
try { conn.end(); } catch (
|
|
384
|
+
try { conn.end(); } catch (err) { /* best-effort cleanup */ }
|
|
370
385
|
this.connections.delete(profileId);
|
|
371
386
|
this.sftpSessions.delete(profileId);
|
|
372
387
|
}
|
|
@@ -374,12 +389,12 @@ export class SshService {
|
|
|
374
389
|
|
|
375
390
|
disconnectAll() {
|
|
376
391
|
for (const [id, conn] of this.connections.entries()) {
|
|
377
|
-
try { conn.end(); } catch (
|
|
392
|
+
try { conn.end(); } catch (err) { /* best-effort cleanup */ }
|
|
378
393
|
}
|
|
379
394
|
this.connections.clear();
|
|
380
395
|
this.sftpSessions.clear();
|
|
381
396
|
for (const [id, term] of this.terminalSessions.entries()) {
|
|
382
|
-
try { term.close(); } catch (
|
|
397
|
+
try { term.close(); } catch (err) { /* best-effort cleanup */ }
|
|
383
398
|
}
|
|
384
399
|
this.terminalSessions.clear();
|
|
385
400
|
}
|
package/lib/tunnel-service.js
CHANGED
|
@@ -109,7 +109,7 @@ export class TunnelService {
|
|
|
109
109
|
if (!tunnel) return false;
|
|
110
110
|
try {
|
|
111
111
|
tunnel.server.close();
|
|
112
|
-
} catch (
|
|
112
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
113
113
|
this.activeTunnels.delete(tunnelId);
|
|
114
114
|
return true;
|
|
115
115
|
}
|
|
@@ -134,7 +134,7 @@ export class TunnelService {
|
|
|
134
134
|
for (const [id, tunnel] of this.activeTunnels.entries()) {
|
|
135
135
|
try {
|
|
136
136
|
tunnel.server.close();
|
|
137
|
-
} catch (
|
|
137
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
138
138
|
}
|
|
139
139
|
this.activeTunnels.clear();
|
|
140
140
|
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host-side one-click updater for @goodandready/dsh-remote-workspace.
|
|
10
|
+
* Implements the unified DSH plugin updater specification.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
14
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
15
|
+
const VERSION_CACHE_MS = 5 * 60_000
|
|
16
|
+
let latestCache = undefined
|
|
17
|
+
|
|
18
|
+
function header(request, name) {
|
|
19
|
+
const value = request?.headers?.[name]
|
|
20
|
+
return Array.isArray(value) ? value[0] : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLoopback(value) {
|
|
24
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
25
|
+
return (
|
|
26
|
+
address === 'localhost' ||
|
|
27
|
+
address === 'localhost.' ||
|
|
28
|
+
address === '::1' ||
|
|
29
|
+
address?.startsWith('127.') === true ||
|
|
30
|
+
address?.startsWith('::ffff:127.') === true
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPrivateLan(value) {
|
|
35
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
36
|
+
if (!address) return false
|
|
37
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
|
|
38
|
+
return (
|
|
39
|
+
ipv4.startsWith('192.168.') ||
|
|
40
|
+
ipv4.startsWith('10.') ||
|
|
41
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isTrustedUpdateRequest(request) {
|
|
46
|
+
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
47
|
+
const remote = request.socket?.remoteAddress
|
|
48
|
+
if (!isLoopback(remote) && !isPrivateLan(remote)) return false
|
|
49
|
+
const site = header(request, 'sec-fetch-site')
|
|
50
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
51
|
+
const origin = header(request, 'origin')
|
|
52
|
+
const host = header(request, 'host')
|
|
53
|
+
if (origin === undefined || host === undefined) return false
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(origin)
|
|
56
|
+
return (
|
|
57
|
+
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
58
|
+
(isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
|
|
59
|
+
url.host === host
|
|
60
|
+
)
|
|
61
|
+
} catch (err) { /* best-effort fallback */
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validProfileName(value) {
|
|
67
|
+
return (
|
|
68
|
+
typeof value === 'string' &&
|
|
69
|
+
value !== '' &&
|
|
70
|
+
value !== '.' &&
|
|
71
|
+
value !== '..' &&
|
|
72
|
+
!value.includes('/') &&
|
|
73
|
+
!value.includes('\\') &&
|
|
74
|
+
!/[\0-\x1f\x7f]/.test(value)
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function profileNameFromArgv(argv) {
|
|
79
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
80
|
+
const arg = argv[index]
|
|
81
|
+
if (arg === '--profile' || arg === '-p') {
|
|
82
|
+
const next = argv[index + 1]
|
|
83
|
+
if (validProfileName(next)) return next
|
|
84
|
+
}
|
|
85
|
+
if (typeof arg === 'string' && arg.startsWith('--profile=')) {
|
|
86
|
+
const value = arg.slice('--profile='.length)
|
|
87
|
+
if (validProfileName(value)) return value
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findDshCliEntry() {
|
|
94
|
+
const entry = process.argv[1]
|
|
95
|
+
if (typeof entry !== 'string' || entry === '') return undefined
|
|
96
|
+
if (!existsSync(entry)) return undefined
|
|
97
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
98
|
+
const manifestPath = resolve(directory, 'package.json')
|
|
99
|
+
if (existsSync(manifestPath)) {
|
|
100
|
+
try {
|
|
101
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
102
|
+
const bin =
|
|
103
|
+
typeof manifest.bin === 'string'
|
|
104
|
+
? manifest.bin
|
|
105
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
106
|
+
? manifest.bin.dsh
|
|
107
|
+
: undefined
|
|
108
|
+
if (
|
|
109
|
+
manifest.name === '@deepseek-ai/dsh' &&
|
|
110
|
+
typeof bin === 'string' &&
|
|
111
|
+
!isAbsolute(bin) &&
|
|
112
|
+
resolve(directory, bin) === resolve(entry)
|
|
113
|
+
) {
|
|
114
|
+
return entry
|
|
115
|
+
}
|
|
116
|
+
} catch (err) { /* best-effort fallback */
|
|
117
|
+
// Continue searching parent directories
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const parent = dirname(directory)
|
|
121
|
+
if (parent === directory) return undefined
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function runtime() {
|
|
126
|
+
const profileDir = resolve(
|
|
127
|
+
process.env.DSH_PROFILE_DIR ?? resolve(homedir(), '.dsh', 'profiles', 'web')
|
|
128
|
+
)
|
|
129
|
+
const selected = profileNameFromArgv(process.argv)
|
|
130
|
+
const profileName = validProfileName(selected)
|
|
131
|
+
? selected
|
|
132
|
+
: validProfileName(basename(profileDir))
|
|
133
|
+
? basename(profileDir)
|
|
134
|
+
: 'web'
|
|
135
|
+
const cliEntry = findDshCliEntry()
|
|
136
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseSemver(value) {
|
|
140
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
|
|
141
|
+
if (match === null) return undefined
|
|
142
|
+
return {
|
|
143
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
144
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function comparePrerelease(left, right) {
|
|
149
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
|
|
150
|
+
const length = Math.max(left.length, right.length)
|
|
151
|
+
for (let index = 0; index < length; index += 1) {
|
|
152
|
+
const a = left[index]
|
|
153
|
+
const b = right[index]
|
|
154
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
|
|
155
|
+
if (a === b) continue
|
|
156
|
+
const aNumeric = /^\d+$/.test(a)
|
|
157
|
+
const bNumeric = /^\d+$/.test(b)
|
|
158
|
+
if (aNumeric && bNumeric) {
|
|
159
|
+
const aNumber = BigInt(a)
|
|
160
|
+
const bNumber = BigInt(b)
|
|
161
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
|
|
165
|
+
return a > b ? 1 : -1
|
|
166
|
+
}
|
|
167
|
+
return 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
171
|
+
const current = parseSemver(currentValue)
|
|
172
|
+
const candidate = parseSemver(candidateValue)
|
|
173
|
+
if (current === undefined || candidate === undefined) return false
|
|
174
|
+
for (let index = 0; index < 3; index += 1) {
|
|
175
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
|
|
176
|
+
}
|
|
177
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function latestVersion(packageName, registry) {
|
|
181
|
+
if (
|
|
182
|
+
latestCache?.packageName === packageName &&
|
|
183
|
+
latestCache.registry === registry &&
|
|
184
|
+
Date.now() < latestCache.expiresAt
|
|
185
|
+
) {
|
|
186
|
+
return latestCache.version
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
190
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
|
|
191
|
+
})
|
|
192
|
+
if (!response.ok) return undefined
|
|
193
|
+
const value = await response.json()
|
|
194
|
+
if (typeof value?.version !== 'string' || value.version === '') return undefined
|
|
195
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
|
|
196
|
+
return value.version
|
|
197
|
+
} catch (err) { /* best-effort fallback */
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function currentVersion(manifestUrl) {
|
|
203
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
|
|
204
|
+
if (typeof value?.version !== 'string' || value.version === '') {
|
|
205
|
+
throw new Error('Cannot read current plugin version.')
|
|
206
|
+
}
|
|
207
|
+
return value.version
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function status(options, target) {
|
|
211
|
+
const current = await currentVersion(options.manifestUrl)
|
|
212
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
|
|
213
|
+
return {
|
|
214
|
+
packageName: options.packageName,
|
|
215
|
+
currentVersion: current,
|
|
216
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
217
|
+
latestCheckFailed: latest === undefined,
|
|
218
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
219
|
+
profileName: target.profileName,
|
|
220
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function installExact(target, packageSpec, options) {
|
|
225
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
226
|
+
await new Promise((resolvePromise, reject) => {
|
|
227
|
+
const child = spawn(
|
|
228
|
+
process.execPath,
|
|
229
|
+
[
|
|
230
|
+
target.cliEntry,
|
|
231
|
+
'plugin',
|
|
232
|
+
'--profile',
|
|
233
|
+
target.profileName,
|
|
234
|
+
'add',
|
|
235
|
+
'--config.minimumReleaseAge=0',
|
|
236
|
+
packageSpec,
|
|
237
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
238
|
+
],
|
|
239
|
+
{
|
|
240
|
+
cwd: target.profileDir,
|
|
241
|
+
windowsHide: true,
|
|
242
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
243
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
let detail = ''
|
|
247
|
+
child.stdout?.on('data', (chunk) => {
|
|
248
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
249
|
+
})
|
|
250
|
+
child.stderr?.on('data', (chunk) => {
|
|
251
|
+
detail = (detail + String(chunk)).slice(-4000)
|
|
252
|
+
})
|
|
253
|
+
const timer = setTimeout(() => {
|
|
254
|
+
child.kill()
|
|
255
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'))
|
|
256
|
+
}, UPDATE_TIMEOUT_MS)
|
|
257
|
+
child.once('error', (error) => {
|
|
258
|
+
clearTimeout(timer)
|
|
259
|
+
reject(error)
|
|
260
|
+
})
|
|
261
|
+
child.once('exit', (code) => {
|
|
262
|
+
clearTimeout(timer)
|
|
263
|
+
if (code === 0) resolvePromise()
|
|
264
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function json(response, statusCode, value) {
|
|
270
|
+
response.writeHead(statusCode, {
|
|
271
|
+
'content-type': 'application/json; charset=utf-8',
|
|
272
|
+
'cache-control': 'no-store',
|
|
273
|
+
})
|
|
274
|
+
response.end(JSON.stringify(value))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function registerPluginUpdater(ctx, options) {
|
|
278
|
+
let installing = false
|
|
279
|
+
if (typeof ctx?.webServer?.register !== 'function') {
|
|
280
|
+
ctx?.logger?.warn?.('[dsh-remote-workspace] webServer service unavailable, updater skipped')
|
|
281
|
+
return () => {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return ctx.webServer.register({
|
|
285
|
+
kind: 'exact',
|
|
286
|
+
path: options.endpoint,
|
|
287
|
+
handler: async (request, response) => {
|
|
288
|
+
try {
|
|
289
|
+
const target = runtime()
|
|
290
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
291
|
+
const payload = await status(options, target)
|
|
292
|
+
response.writeHead(200, {
|
|
293
|
+
'content-type': 'application/json; charset=utf-8',
|
|
294
|
+
'cache-control': 'no-store',
|
|
295
|
+
})
|
|
296
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (request.method !== 'POST') {
|
|
300
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' })
|
|
301
|
+
response.end()
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
305
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
if (installing) {
|
|
309
|
+
json(response, 409, { error: 'This plugin is already updating.' })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
installing = true
|
|
313
|
+
try {
|
|
314
|
+
const before = await status(options, target)
|
|
315
|
+
if (before.latestVersion === undefined) {
|
|
316
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' })
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
if (!before.updateAvailable) {
|
|
320
|
+
json(response, 200, before)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
|
|
324
|
+
json(response, 200, {
|
|
325
|
+
...before,
|
|
326
|
+
updatedVersion: before.latestVersion,
|
|
327
|
+
restartRequired: true,
|
|
328
|
+
})
|
|
329
|
+
} finally {
|
|
330
|
+
installing = false
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
ctx?.logger?.warn?.(`[dsh-remote-workspace] plugin updater failed: ${String(error)}`)
|
|
334
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' })
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
}
|
package/lib/vault-service.js
CHANGED
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
*/
|
|
9
9
|
export class VaultService {
|
|
10
10
|
constructor(customPath = null) {
|
|
11
|
+
this.permissionError = null;
|
|
11
12
|
if (customPath) {
|
|
12
13
|
this.envPath = customPath;
|
|
13
14
|
} else {
|
|
@@ -17,7 +18,16 @@ export class VaultService {
|
|
|
17
18
|
if (!fs.existsSync(secretsDir)) {
|
|
18
19
|
fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
if (fs.existsSync(secretsDir)) {
|
|
22
|
+
try {
|
|
23
|
+
fs.chmodSync(secretsDir, 0o700);
|
|
24
|
+
} catch (chmodErr) {
|
|
25
|
+
this.permissionError = `Failed to set 0700 permissions on secrets dir: ${chmodErr.message}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch (mkdirErr) {
|
|
29
|
+
this.permissionError = `Failed to initialize secrets directory: ${mkdirErr.message}`;
|
|
30
|
+
}
|
|
21
31
|
this.envPath = path.join(secretsDir, 'dsh-remote-workspace.env');
|
|
22
32
|
}
|
|
23
33
|
this.ensureEnvFile();
|
|
@@ -29,9 +39,23 @@ export class VaultService {
|
|
|
29
39
|
fs.writeFileSync(this.envPath, '# DSH Remote Workspace Encrypted Secrets Vault\n', { mode: 0o600, encoding: 'utf8' });
|
|
30
40
|
} else {
|
|
31
41
|
// Ensure file permissions are restricted to owner only
|
|
32
|
-
try {
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(this.envPath, 0o600);
|
|
44
|
+
} catch (chmodErr) {
|
|
45
|
+
this.permissionError = `Failed to restrict permissions on ${this.envPath}: ${chmodErr.message}`;
|
|
46
|
+
}
|
|
33
47
|
}
|
|
34
|
-
} catch (
|
|
48
|
+
} catch (err) {
|
|
49
|
+
this.permissionError = `Failed to ensure vault file: ${err.message}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getPermissionStatus() {
|
|
54
|
+
return {
|
|
55
|
+
secure: !this.permissionError,
|
|
56
|
+
error: this.permissionError,
|
|
57
|
+
path: this.envPath
|
|
58
|
+
};
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/**
|
|
@@ -61,14 +85,14 @@ export class VaultService {
|
|
|
61
85
|
currentKey = key;
|
|
62
86
|
currentValue = val.slice(1) + '\n';
|
|
63
87
|
} else if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
|
64
|
-
store[key] = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
88
|
+
store[key] = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
65
89
|
} else {
|
|
66
90
|
store[key] = val;
|
|
67
91
|
}
|
|
68
92
|
} else {
|
|
69
93
|
if (line.endsWith('"')) {
|
|
70
94
|
currentValue += line.slice(0, -1);
|
|
71
|
-
store[currentKey] = currentValue.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
95
|
+
store[currentKey] = currentValue.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
|
|
72
96
|
isMultiline = false;
|
|
73
97
|
currentKey = null;
|
|
74
98
|
currentValue = '';
|
package/lib/watcher-service.js
CHANGED
|
@@ -59,7 +59,7 @@ export class WatcherService {
|
|
|
59
59
|
if (this.onSyncCallback) {
|
|
60
60
|
this.onSyncCallback({ profileId: profile.id, syncRes });
|
|
61
61
|
}
|
|
62
|
-
} catch (
|
|
62
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
63
63
|
}, this.debounceMs);
|
|
64
64
|
|
|
65
65
|
this.debounceTimers.set(profile.id, timer);
|
|
@@ -81,7 +81,7 @@ export class WatcherService {
|
|
|
81
81
|
if (item) {
|
|
82
82
|
try {
|
|
83
83
|
item.watcher.close();
|
|
84
|
-
} catch (
|
|
84
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
85
85
|
this.watchers.delete(profileId);
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
@@ -100,7 +100,7 @@ export class WatcherService {
|
|
|
100
100
|
for (const [id, item] of this.watchers.entries()) {
|
|
101
101
|
try {
|
|
102
102
|
item.watcher.close();
|
|
103
|
-
} catch (
|
|
103
|
+
} catch (err) { /* best-effort cleanup */ }
|
|
104
104
|
if (this.debounceTimers.has(id)) {
|
|
105
105
|
clearTimeout(this.debounceTimers.get(id));
|
|
106
106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"README.ru.md",
|
|
18
18
|
"README.zh.md",
|
|
19
|
-
"docs/",
|
|
19
|
+
"docs/design/DESIGN.md",
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
@@ -49,7 +49,10 @@
|
|
|
49
49
|
},
|
|
50
50
|
"client": {
|
|
51
51
|
"platform": "web",
|
|
52
|
-
"inject": [
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-locale",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
55
|
+
]
|
|
53
56
|
}
|
|
54
57
|
},
|
|
55
58
|
"dependencies": {
|
package/docs/plans/findings.md
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# findings.md — dsh-remote-workspace
|
|
2
|
-
|
|
3
|
-
## Ключевые выводы анализа dsh-remote
|
|
4
|
-
1. Преимущество 3-way sync: snapshot хешей предотвращает перезапись изменений при одновременном редактировании на сервере и локально.
|
|
5
|
-
2. Проблема 21 инструмента rw_*: перегружает LLM prompt. В новом плагине объединяем в 4 сбалансированных инструмента:
|
|
6
|
-
emote_exec,
|
|
7
|
-
emote_fs,
|
|
8
|
-
emote_sync,
|
|
9
|
-
emote_tunnel.
|
|
10
|
-
3. Модульность: вместо спагетти в index.js реализуем 4 отдельных Cordis сервиса.
|
|
11
|
-
4. UI: использование нативных CSS переменных DSH, чистый React, поддержка i18n (EN/RU/ZH).
|
|
12
|
-
|
|
13
|
-
## Результаты аудита стабильности и UI (Фаза 8)
|
|
14
|
-
1. **Баг localMirrorPath**: в форме профиля отсутствовало поле ввода localMirrorPath, что блокировало синхронизацию зеркала (400 Error).
|
|
15
|
-
2. **Баг рассинхронизации полей testConnection**: бэкенд отдавал latency/os, клиент ожидал latencyMs/
|
|
16
|
-
emoteOs.
|
|
17
|
-
3. **Утечка кэшированных SSH-соединений**: при смене хоста или ключа/пароля в профиле старое соединение с тем же id не сбрасывалось из пула SshService.
|
|
18
|
-
4. **Отсутствие UI для SSH-туннелей**: туннели были поддержаны на бэкенде, но не имели интерфейса в карточке настроек.
|
|
19
|
-
5. **Безопасность API**: отсутствовал фильтр isTrustedSettingsRequest(req) на мутирующих маршрутах.
|
package/docs/plans/progress.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# progress.md — dsh-remote-workspace
|
|
2
|
-
|
|
3
|
-
## Журнал выполнения
|
|
4
|
-
- **2026-09-08**:
|
|
5
|
-
- Создан репозиторий goodandready/dsh-remote-workspace в Gitea.
|
|
6
|
-
- Создана задача Issue #1: [critical] Initial implementation of dsh-remote-workspace plugin.
|
|
7
|
-
- Оформлен дизайн-контракт docs/design/DESIGN.md.
|
|
8
|
-
- Реализованы 4 сервиса и 4 инструмента модели.
|
|
9
|
-
- Пройдены первые 12 тестов.
|
|
10
|
-
- **2026-09-09**:
|
|
11
|
-
- Обогащена документация по стандарту dsh-documentation-standard (EN/RU/ZH).
|
|
12
|
-
- **2026-09-10**:
|
|
13
|
-
- Создано worktree stability-and-clinebot-ui для реализации Фазы 8.
|
|
14
|
-
- Выполнен детальный аудит стабильности кода и визуального слоя.
|
|
15
|
-
- Подготовлен и утвержден комплексный план.
|
package/docs/plans/task_plan.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# task_plan.md — dsh-remote-workspace (v0.3.1)
|
|
2
|
-
|
|
3
|
-
## Цель
|
|
4
|
-
Реализовать 5 ключевых возможностей в рамках релиза v0.3.1:
|
|
5
|
-
1. Smart Tarball Sync (пакетное ускорение передачи каталогов).
|
|
6
|
-
2. remote_diagnose (комплексный агентский инструмент диагностики сервера).
|
|
7
|
-
3. Import from ~/.ssh/config (парсинг хостов в .env vault).
|
|
8
|
-
4. remote_env & Remote Environment Manager (безопасный редактор .env).
|
|
9
|
-
5. AlertService (фоновый мониторинг аномалий и события Cordis).
|
|
10
|
-
|
|
11
|
-
## Статус фаз
|
|
12
|
-
- [ ] **Фаза 1**: Backend-сервисы (lib/tar-sync-service.js, lib/diagnose-service.js, lib/ssh-config-parser.js, lib/env-service.js, lib/alert-service.js)
|
|
13
|
-
- [ ] **Фаза 2**: Интеграция в инструменты модели (remote_diagnose, remote_env, smart sync) и API маршруты
|
|
14
|
-
- [ ] **Фаза 3**: Обновление клиентского интерфейса (кнопка импорта SSH, вкладка/модалка .env, индикатор алертов)
|
|
15
|
-
- [ ] **Фаза 4**: Модульные и интеграционные тесты (test/phase4.test.mjs)
|
|
16
|
-
- [ ] **Фаза 5**: Документация (README EN/RU/ZH, DESIGN.md) и проверка лимитов размера (<250 KiB)
|
|
17
|
-
- [ ] **Фаза 6**: Тестовый гейт на MiniPC (dsh-test-plugin) и боевая верификация на MiniAI
|
|
18
|
-
|
|
19
|
-
## Next Step
|
|
20
|
-
Создание backend-сервисов в lib/.
|
|
21
|
-
EOF
|