@goodandready/dsh-remote-workspace 0.3.0 → 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/README.md +17 -0
- package/README.ru.md +17 -0
- package/README.zh.md +17 -0
- package/docs/design/DESIGN.md +1 -1
- package/lib/alert-service.js +109 -0
- package/lib/client.js +2316 -1991
- package/lib/diagnose-service.js +57 -0
- package/lib/docker-service.js +1 -1
- package/lib/env-service.js +122 -0
- package/lib/index.js +83 -16
- 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 +47 -516
- package/lib/ssh-config-parser.js +89 -0
- package/lib/ssh-service.js +24 -9
- package/lib/tar-sync-service.js +85 -0
- package/lib/tools.js +78 -3
- 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 -23
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
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export class TarSyncService {
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
8
|
+
*/
|
|
9
|
+
constructor(sshService) {
|
|
10
|
+
this.sshService = sshService;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stream tar archive from Remote to Local directory
|
|
15
|
+
* Remote runs: tar -czf - -C <remoteDir> .
|
|
16
|
+
* Local receives stream and runs: tar -xzf - -C <localDir>
|
|
17
|
+
*/
|
|
18
|
+
async pullTar(profile, remoteDir, localDir) {
|
|
19
|
+
if (!profile) throw new Error('No profile provided');
|
|
20
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const client = await this.sshService.getConnection(profile);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C "${remoteDir}" .`;
|
|
25
|
+
client.exec(remoteCmd, (err, stream) => {
|
|
26
|
+
if (err) return reject(err);
|
|
27
|
+
|
|
28
|
+
const localTar = spawn('tar', ['-xzf', '-', '-C', localDir]);
|
|
29
|
+
let stderr = '';
|
|
30
|
+
localTar.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
31
|
+
|
|
32
|
+
stream.pipe(localTar.stdin);
|
|
33
|
+
|
|
34
|
+
localTar.on('close', (code) => {
|
|
35
|
+
if (code !== 0) {
|
|
36
|
+
reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
|
|
37
|
+
} else {
|
|
38
|
+
resolve({ ok: true, direction: 'pull', remoteDir, localDir });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
stream.on('error', reject);
|
|
43
|
+
localTar.stdin.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stream tar archive from Local to Remote directory
|
|
50
|
+
* Local runs: tar -czf - -C <localDir> .
|
|
51
|
+
* Remote receives stream and runs: tar -xzf - -C <remoteDir>
|
|
52
|
+
*/
|
|
53
|
+
async pushTar(profile, localDir, remoteDir) {
|
|
54
|
+
if (!profile) throw new Error('No profile provided');
|
|
55
|
+
if (!fs.existsSync(localDir)) throw new Error(`Local dir does not exist: ${localDir}`);
|
|
56
|
+
|
|
57
|
+
const client = await this.sshService.getConnection(profile);
|
|
58
|
+
// Ensure remoteDir exists
|
|
59
|
+
await this.sshService.exec(profile, `mkdir -p "${remoteDir}"`);
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const remoteCmd = `tar -xzf - -C "${remoteDir}"`;
|
|
63
|
+
client.exec(remoteCmd, (err, stream) => {
|
|
64
|
+
if (err) return reject(err);
|
|
65
|
+
|
|
66
|
+
const localTar = spawn('tar', ['--exclude=.git', '--exclude=node_modules', '-czf', '-', '-C', localDir, '.']);
|
|
67
|
+
let remoteStderr = '';
|
|
68
|
+
stream.stderr.on('data', (d) => { remoteStderr += d.toString(); });
|
|
69
|
+
|
|
70
|
+
localTar.stdout.pipe(stream);
|
|
71
|
+
|
|
72
|
+
stream.on('close', (code) => {
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
|
|
75
|
+
} else {
|
|
76
|
+
resolve({ ok: true, direction: 'push', localDir, remoteDir });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
stream.on('error', reject);
|
|
81
|
+
localTar.on('error', reject);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService) {
|
|
1
|
+
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService) {
|
|
2
2
|
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
3
3
|
return;
|
|
4
4
|
}
|
|
@@ -96,9 +96,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
96
96
|
// 3. remote_sync
|
|
97
97
|
ctx.tools.register({
|
|
98
98
|
name: 'remote_sync',
|
|
99
|
-
description: 'Perform
|
|
99
|
+
description: 'Perform 3-way delta or streaming tarball synchronization between local mirror and remote server',
|
|
100
100
|
parameters: {
|
|
101
101
|
direction: { type: 'string', required: true, description: 'pull (remote -> local) or push (local -> remote)' },
|
|
102
|
+
mode: { type: 'string', description: 'sync mode: delta (default) or tarball (fast batch stream)' },
|
|
102
103
|
force: { type: 'boolean', description: 'Overwrite conflicts if true (use with caution)' },
|
|
103
104
|
dryRun: { type: 'boolean', description: 'Simulate changes without writing files' }
|
|
104
105
|
},
|
|
@@ -110,6 +111,14 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
110
111
|
throw new Error('Both remoteWorkspace and localMirrorPath must be configured');
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
if (args.mode === 'tarball' && tarSyncService) {
|
|
115
|
+
if (args.direction === 'pull') {
|
|
116
|
+
return await tarSyncService.pullTar(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
117
|
+
} else {
|
|
118
|
+
return await tarSyncService.pushTar(profile, profile.localMirrorPath, profile.remoteWorkspace);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
if (args.direction === 'pull') {
|
|
114
123
|
return await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath, args.dryRun);
|
|
115
124
|
} else {
|
|
@@ -212,7 +221,6 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
212
221
|
if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
|
|
213
222
|
else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
|
|
214
223
|
} else {
|
|
215
|
-
// systemd
|
|
216
224
|
if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
|
|
217
225
|
else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
|
|
218
226
|
else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
|
|
@@ -247,4 +255,71 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
247
255
|
return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
|
|
248
256
|
}
|
|
249
257
|
});
|
|
258
|
+
|
|
259
|
+
// 8. remote_diagnose (New in v0.3.1)
|
|
260
|
+
if (diagnoseService) {
|
|
261
|
+
ctx.tools.register({
|
|
262
|
+
name: 'remote_diagnose',
|
|
263
|
+
description: 'Run targeted system health diagnostics on remote host (ports, disk, memory, oom_killer, service_logs)',
|
|
264
|
+
parameters: {
|
|
265
|
+
category: {
|
|
266
|
+
type: 'string',
|
|
267
|
+
required: true,
|
|
268
|
+
description: 'Diagnostic aspect: ports, disk, memory, oom_killer, service_logs'
|
|
269
|
+
},
|
|
270
|
+
target: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
description: 'Optional port number, directory path, or service name to inspect'
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
output: genericOutput,
|
|
276
|
+
async execute(args) {
|
|
277
|
+
const profile = getActiveProfile();
|
|
278
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
279
|
+
return await diagnoseService.diagnose(profile, args.category, args.target);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 9. remote_env (New in v0.3.1)
|
|
285
|
+
if (envService) {
|
|
286
|
+
ctx.tools.register({
|
|
287
|
+
name: 'remote_env',
|
|
288
|
+
description: 'Inspect and atomically update remote .env files preserving comments and structure',
|
|
289
|
+
parameters: {
|
|
290
|
+
action: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
required: true,
|
|
293
|
+
description: 'Action to perform: read or set'
|
|
294
|
+
},
|
|
295
|
+
filePath: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
required: true,
|
|
298
|
+
description: 'Path to the .env file on the remote server'
|
|
299
|
+
},
|
|
300
|
+
key: {
|
|
301
|
+
type: 'string',
|
|
302
|
+
description: 'Environment variable key (required for set)'
|
|
303
|
+
},
|
|
304
|
+
value: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
description: 'Environment variable value (for set action)'
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
output: genericOutput,
|
|
310
|
+
async execute(args) {
|
|
311
|
+
const profile = getActiveProfile();
|
|
312
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
313
|
+
|
|
314
|
+
if (args.action === 'read') {
|
|
315
|
+
return await envService.readEnv(profile, args.filePath);
|
|
316
|
+
} else if (args.action === 'set') {
|
|
317
|
+
if (!args.key) throw new Error('key is required for set action');
|
|
318
|
+
return await envService.setEnvVar(profile, args.filePath, args.key, args.value || '');
|
|
319
|
+
} else {
|
|
320
|
+
throw new Error(`Unknown env action: ${args.action}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
250
325
|
}
|
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
|
+
}
|