@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.
@@ -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 fast 3-way delta synchronization between local mirror and remote server',
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
  }
@@ -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
+ }