dsh-plugin-weapp-gateway 0.8.23 → 0.8.34

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/PROTOCOL.md +1545 -1486
  3. package/README.md +503 -464
  4. package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
  5. package/bin/setup-ip.mjs +474 -474
  6. package/cordis.patch.yml +50 -50
  7. package/docs/agent-team-app-integration.md +945 -0
  8. package/docs/blog-mobile-gateway.md +542 -542
  9. package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
  10. package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
  11. package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
  12. package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
  13. package/docs/dsh-rc2-mobile-integration.md +155 -155
  14. package/docs/file-upload-app-integration.md +250 -0
  15. package/docs/multi-gateway-app-integration.md +141 -125
  16. package/docs/multi-gateway-phase1-acceptance.md +179 -179
  17. package/docs/multi-gateway-todo.md +137 -137
  18. package/docs/remote-gateway-refactor-plan.md +52 -52
  19. package/docs/runtime-architecture.architecture.json +264 -264
  20. package/docs/runtime-architecture.html +15001 -15001
  21. package/docs/runtime-architecture.visual-check.html +32 -32
  22. package/docs/runtime-architecture.visual-check.json +548 -548
  23. package/docs/session-agent-preset-app-integration.md +93 -93
  24. package/docs/turn-outline-app-integration.md +196 -0
  25. package/docs/typert-remote-gateway-feature-checklist.md +294 -294
  26. package/docs/voice-input-implementation.md +219 -219
  27. package/docs/workspace-changes-mobile-integration.md +32 -32
  28. package/helper/dsh_mobile_gateway_helper.py +227 -227
  29. package/lib/account-bridge.mjs +57 -57
  30. package/lib/client.js +1409 -1361
  31. package/lib/cloudflare-tunnel.mjs +314 -314
  32. package/lib/cloudflared-binary.mjs +135 -135
  33. package/lib/context-bridge.mjs +3338 -1715
  34. package/lib/cost-meter-bridge.mjs +794 -794
  35. package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
  36. package/lib/devices.js +273 -245
  37. package/lib/dsh-host-adapter.mjs +508 -426
  38. package/lib/file-upload.mjs +205 -0
  39. package/lib/gateway-probe.mjs +6 -6
  40. package/lib/gateway-state.mjs +57 -57
  41. package/lib/gateway-tools.mjs +36 -36
  42. package/lib/history-cache.mjs +66 -0
  43. package/lib/index.mjs +4390 -3803
  44. package/lib/plugin-management.mjs +251 -233
  45. package/lib/public-endpoint-check.mjs +61 -61
  46. package/lib/relay-connector.mjs +213 -181
  47. package/lib/relay-state.mjs +83 -76
  48. package/lib/session-follower.mjs +140 -136
  49. package/lib/session-resume-bridge.mjs +86 -0
  50. package/lib/session-target.mjs +29 -0
  51. package/lib/speech-input.mjs +192 -192
  52. package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
  53. package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
  54. package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
  55. package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
  56. package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
  57. package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
  58. package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
  59. package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
  60. package/lib/vendor/cost-meter/net.mjs +183 -183
  61. package/lib/vendor/cost-meter/opendesign.mjs +204 -204
  62. package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
  63. package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
  64. package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
  65. package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
  66. package/lib/web-restart.mjs +47 -47
  67. package/lib/wire-json.mjs +7 -7
  68. package/lib/workspace-changes-bridge.mjs +32 -32
  69. package/package.json +54 -54
  70. package/relay/agent-connector.js +588 -569
  71. package/relay/vendor/tweetnacl.js +2391 -2391
package/bin/setup-ip.mjs CHANGED
@@ -1,474 +1,474 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs'
4
- import http from 'node:http'
5
- import net from 'node:net'
6
- import path from 'node:path'
7
- import process from 'node:process'
8
- import readline from 'node:readline/promises'
9
- import { execFileSync } from 'node:child_process'
10
- import { fileURLToPath } from 'node:url'
11
-
12
- const MARKER = '# Managed by dsh-plugin-weapp-gateway'
13
- const CONFIG_DIR = '/etc/dsh-mobile-gateway'
14
- const PUBLIC_URL_FILE = path.join(CONFIG_DIR, 'public-url')
15
- const WEBROOT = '/var/lib/dsh-mobile-gateway/acme'
16
- const NGINX_CONFIG = '/etc/nginx/conf.d/dsh-mobile-gateway.conf'
17
- const CERTBOT_HOME = '/opt/dsh-mobile-gateway/certbot'
18
- const CERTBOT = path.join(CERTBOT_HOME, 'bin/certbot')
19
- const RENEW_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.service'
20
- const RENEW_TIMER = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.timer'
21
- const HELPER_SOURCE = fileURLToPath(new URL('../helper/dsh_mobile_gateway_helper.py', import.meta.url))
22
- const HELPER_INSTALL = '/usr/local/libexec/dsh-mobile-gateway-helper'
23
- const HELPER_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-helper.service'
24
- const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
25
-
26
- function currentPackageSpec() {
27
- const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
28
- if (typeof manifest.name !== 'string' || !manifest.name || typeof manifest.version !== 'string' || !manifest.version) {
29
- throw new Error('package manifest is missing name or version')
30
- }
31
- return `${manifest.name}@${manifest.version}`
32
- }
33
-
34
- function pluginInstallArgs(packageSpec = currentPackageSpec()) {
35
- return [
36
- 'plugin', '--profile', 'web', 'add', packageSpec,
37
- `--config.minimum-release-age-exclude=${packageSpec}`,
38
- ]
39
- }
40
-
41
- function printHelp() {
42
- console.log(`Usage:
43
- dsh-plugin-weapp-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
44
- dsh-plugin-weapp-gateway init
45
- dsh-plugin-weapp-gateway setup-helper
46
- dsh-plugin-weapp-gateway remove-helper [--yes]
47
- dsh-plugin-weapp-gateway status
48
- dsh-plugin-weapp-gateway remove [--yes]
49
-
50
- The setup command supports Ubuntu/Debian servers. It keeps DSH on 127.0.0.1,
51
- publishes only /ws/mobile through Nginx on 443, obtains a short-lived trusted
52
- Let's Encrypt IP certificate, and installs automatic renewal.`)
53
- }
54
-
55
- function parseArgs(argv) {
56
- const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'setup'
57
- const values = { command, port: 3080, yes: false, ip: '', email: '' }
58
- const args = command === argv[0] ? argv.slice(1) : argv
59
- for (let index = 0; index < args.length; index += 1) {
60
- const arg = args[index]
61
- if (arg === '--yes' || arg === '-y') values.yes = true
62
- else if (arg === '--ip') values.ip = args[++index] || ''
63
- else if (arg === '--port') values.port = Number(args[++index])
64
- else if (arg === '--email') values.email = args[++index] || ''
65
- else if (arg === '--help' || arg === '-h') values.command = 'help'
66
- else throw new Error(`unknown argument: ${arg}`)
67
- }
68
- return values
69
- }
70
-
71
- function run(command, args, options = {}) {
72
- console.log(`\n> ${command} ${args.join(' ')}`)
73
- return execFileSync(command, args, { stdio: 'inherit', ...options })
74
- }
75
-
76
- function commandExists(command) {
77
- try {
78
- execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' })
79
- return true
80
- } catch {
81
- return false
82
- }
83
- }
84
-
85
- function writeManagedFile(file, content, mode = 0o644) {
86
- if (fs.existsSync(file)) {
87
- const existing = fs.readFileSync(file, 'utf8')
88
- if (!existing.startsWith(MARKER)) {
89
- throw new Error(`refusing to overwrite unmanaged file: ${file}`)
90
- }
91
- }
92
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 })
93
- const temporary = `${file}.tmp-${process.pid}`
94
- fs.writeFileSync(temporary, content, { mode })
95
- fs.renameSync(temporary, file)
96
- fs.chmodSync(file, mode)
97
- }
98
-
99
- function writeInstalledHelper(source, destination) {
100
- if (fs.existsSync(destination)) {
101
- const existing = fs.readFileSync(destination, 'utf8')
102
- if (!existing.slice(0, 256).includes(MARKER)) {
103
- throw new Error(`refusing to overwrite unmanaged file: ${destination}`)
104
- }
105
- }
106
- fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 })
107
- const temporary = `${destination}.tmp-${process.pid}`
108
- fs.copyFileSync(source, temporary)
109
- fs.chmodSync(temporary, 0o755)
110
- fs.renameSync(temporary, destination)
111
- }
112
-
113
- function helperService(uid) {
114
- return `${MARKER}
115
- [Unit]
116
- Description=DSH mobile gateway privileged configuration helper
117
- After=network.target
118
-
119
- [Service]
120
- Type=simple
121
- ExecStart=/usr/bin/python3 ${HELPER_INSTALL} --uid ${uid} --socket ${HELPER_SOCKET}
122
- Restart=on-failure
123
- RestartSec=2s
124
- RuntimeDirectory=dsh-mobile-gateway
125
- RuntimeDirectoryMode=0755
126
- NoNewPrivileges=true
127
- PrivateTmp=true
128
- ProtectHome=true
129
- ProtectSystem=full
130
- ReadWritePaths=/etc/nginx /etc/dsh-mobile-gateway /etc/letsencrypt /var/lib/dsh-mobile-gateway /var/lib/letsencrypt /var/log/letsencrypt /run/dsh-mobile-gateway
131
-
132
- [Install]
133
- WantedBy=multi-user.target
134
- `
135
- }
136
-
137
- function invokingUserId() {
138
- const value = Number(process.env.SUDO_UID)
139
- if (!Number.isInteger(value) || value < 1) {
140
- throw new Error('setup-helper must be run with sudo from the user that runs dsh web')
141
- }
142
- return value
143
- }
144
-
145
- async function init() {
146
- if (typeof process.getuid === 'function' && process.getuid() === 0) {
147
- throw new Error('init must run as the normal DSH user, without sudo')
148
- }
149
- if (!commandExists('dsh') || !commandExists('sudo')) {
150
- throw new Error('init requires dsh and sudo on PATH')
151
- }
152
- const packageSpec = currentPackageSpec()
153
- run('dsh', pluginInstallArgs(packageSpec))
154
- const script = fileURLToPath(import.meta.url)
155
- run('sudo', ['env', `PATH=${process.env.PATH || ''}`, process.execPath, script, 'setup-helper'])
156
- console.log('\nInitialization completed. Start or restart DSH with: dsh web')
157
- }
158
-
159
- function setupHelper() {
160
- assertRoot()
161
- const uid = invokingUserId()
162
- if (!commandExists('apt-get') || !commandExists('systemctl')) {
163
- throw new Error('helper installation currently supports systemd-based Ubuntu/Debian servers only')
164
- }
165
- run('apt-get', ['update'])
166
- run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
167
- if (!fs.existsSync(CERTBOT)) {
168
- run('python3', ['-m', 'venv', CERTBOT_HOME])
169
- run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
170
- }
171
- run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
172
- for (const directory of [CONFIG_DIR, WEBROOT, '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt']) {
173
- fs.mkdirSync(directory, { recursive: true, mode: 0o755 })
174
- }
175
- writeInstalledHelper(HELPER_SOURCE, HELPER_INSTALL)
176
- writeManagedFile(HELPER_SERVICE, helperService(uid))
177
- writeManagedFile(RENEW_SERVICE, renewalService())
178
- writeManagedFile(RENEW_TIMER, renewalTimer())
179
- run('systemctl', ['daemon-reload'])
180
- run('systemctl', ['enable', '--now', path.basename(HELPER_SERVICE)])
181
- run('systemctl', ['restart', path.basename(HELPER_SERVICE)])
182
- run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
183
- console.log(`\nHelper installed for uid ${uid}. Public access can now be configured from the Mobile Devices panel.`)
184
- }
185
-
186
- async function removeHelper(options) {
187
- assertRoot()
188
- if (!await confirm('Remove the privileged mobile gateway helper? Existing Nginx configuration will be kept.', options.yes)) {
189
- console.log('Cancelled.')
190
- return
191
- }
192
- if (commandExists('systemctl')) {
193
- try { run('systemctl', ['disable', '--now', path.basename(HELPER_SERVICE)]) } catch {}
194
- }
195
- for (const file of [HELPER_SERVICE, HELPER_INSTALL]) {
196
- if (!fs.existsSync(file)) continue
197
- const existing = fs.readFileSync(file, 'utf8')
198
- if (!existing.slice(0, 256).includes(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
199
- fs.rmSync(file)
200
- console.log(`Removed ${file}`)
201
- }
202
- if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
203
- }
204
-
205
- function metadataPublicIp() {
206
- return new Promise((resolve) => {
207
- const request = http.get({
208
- host: 'metadata.tencentyun.com',
209
- path: '/latest/meta-data/public-ipv4',
210
- timeout: 2_500,
211
- headers: { 'User-Agent': 'dsh-mobile-gateway-setup' },
212
- }, (response) => {
213
- let body = ''
214
- response.setEncoding('utf8')
215
- response.on('data', (chunk) => { body += chunk })
216
- response.on('end', () => resolve(response.statusCode === 200 ? body.trim() : ''))
217
- })
218
- request.on('timeout', () => request.destroy())
219
- request.on('error', () => resolve(''))
220
- })
221
- }
222
-
223
- function assertPublicIpv4(value) {
224
- if (net.isIP(value) !== 4) throw new Error(`invalid public IPv4 address: ${value || '(empty)'}`)
225
- const octets = value.split('.').map(Number)
226
- const privateAddress = octets[0] === 10
227
- || octets[0] === 127
228
- || (octets[0] === 169 && octets[1] === 254)
229
- || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
230
- || (octets[0] === 192 && octets[1] === 168)
231
- || octets[0] === 0
232
- || octets[0] >= 224
233
- if (privateAddress) throw new Error(`${value} is not a public IPv4 address`)
234
- }
235
-
236
- function certName(ip) {
237
- return `dsh-mobile-gateway-${ip.replaceAll('.', '-')}`
238
- }
239
-
240
- function nginxHttpConfig(ip) {
241
- return `${MARKER}
242
- server {
243
- listen 80;
244
- server_name ${ip};
245
-
246
- location ^~ /.well-known/acme-challenge/ {
247
- root ${WEBROOT};
248
- default_type text/plain;
249
- }
250
-
251
- location / { return 404; }
252
- }
253
- `
254
- }
255
-
256
- function nginxTlsConfig(ip, backendPort) {
257
- const liveDirectory = `/etc/letsencrypt/live/${certName(ip)}`
258
- return `${nginxHttpConfig(ip)}
259
- server {
260
- listen 443 ssl;
261
- server_name ${ip};
262
- server_tokens off;
263
- access_log off;
264
-
265
- ssl_certificate ${liveDirectory}/fullchain.pem;
266
- ssl_certificate_key ${liveDirectory}/privkey.pem;
267
- ssl_protocols TLSv1.2 TLSv1.3;
268
-
269
- location = /ws/mobile {
270
- if ($args != "") { return 404; }
271
- proxy_pass http://127.0.0.1:${backendPort};
272
- proxy_http_version 1.1;
273
- proxy_set_header Host 127.0.0.1:${backendPort};
274
- proxy_set_header Upgrade $http_upgrade;
275
- proxy_set_header Connection "upgrade";
276
- proxy_set_header X-Forwarded-Proto https;
277
- proxy_read_timeout 3600s;
278
- proxy_send_timeout 3600s;
279
- proxy_buffering off;
280
- }
281
-
282
- location / { return 404; }
283
- }
284
- `
285
- }
286
-
287
- function renewalService() {
288
- return `${MARKER}
289
- [Unit]
290
- Description=Renew the DSH mobile gateway IP certificate
291
- After=network-online.target nginx.service
292
-
293
- [Service]
294
- Type=oneshot
295
- ExecStart=${CERTBOT} renew --quiet --deploy-hook /bin/systemctl reload nginx
296
- `
297
- }
298
-
299
- function renewalTimer() {
300
- return `${MARKER}
301
- [Unit]
302
- Description=Twice-daily renewal check for the DSH mobile gateway IP certificate
303
-
304
- [Timer]
305
- OnCalendar=*-*-* 00,12:00:00
306
- RandomizedDelaySec=20m
307
- Persistent=true
308
-
309
- [Install]
310
- WantedBy=timers.target
311
- `
312
- }
313
-
314
- async function confirm(message, assumeYes) {
315
- if (assumeYes) return true
316
- if (!process.stdin.isTTY) throw new Error('interactive confirmation is unavailable; rerun with --yes')
317
- const terminal = readline.createInterface({ input: process.stdin, output: process.stdout })
318
- const answer = await terminal.question(`${message} [y/N] `)
319
- terminal.close()
320
- return /^y(es)?$/i.test(answer.trim())
321
- }
322
-
323
- function assertRoot() {
324
- if (typeof process.getuid === 'function' && process.getuid() !== 0) {
325
- throw new Error('setup/remove must run as root; prepend sudo to the command')
326
- }
327
- }
328
-
329
- async function setup(options) {
330
- assertRoot()
331
- if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error(`invalid backend port: ${options.port}`)
332
- if (options.email && !/^\S+@\S+\.\S+$/.test(options.email)) throw new Error(`invalid email address: ${options.email}`)
333
- if (!commandExists('apt-get') || !commandExists('systemctl')) {
334
- throw new Error('automatic setup currently supports systemd-based Ubuntu/Debian servers only')
335
- }
336
-
337
- const ip = options.ip || await metadataPublicIp()
338
- if (!ip) throw new Error('unable to detect a Tencent Cloud public IP; rerun with --ip <public IPv4>')
339
- assertPublicIpv4(ip)
340
-
341
- console.log(`
342
- Public IPv4: ${ip}
343
- DSH backend: http://127.0.0.1:${options.port}
344
- Public endpoint: wss://${ip}/ws/mobile
345
-
346
- Before continuing, open inbound TCP ports 80 and 443 in the Tencent Cloud
347
- security group. Only /ws/mobile will be proxied; the WebUI and /mgw remain private.`)
348
- if (!await confirm('Install Nginx/Certbot and configure this public endpoint?', options.yes)) {
349
- console.log('Cancelled.')
350
- return
351
- }
352
-
353
- run('apt-get', ['update'])
354
- run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
355
- if (!fs.existsSync(CERTBOT)) {
356
- run('python3', ['-m', 'venv', CERTBOT_HOME])
357
- run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
358
- }
359
- run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
360
-
361
- fs.mkdirSync(path.join(WEBROOT, '.well-known/acme-challenge'), { recursive: true, mode: 0o755 })
362
- writeManagedFile(NGINX_CONFIG, nginxHttpConfig(ip))
363
- run('nginx', ['-t'])
364
- run('systemctl', ['enable', '--now', 'nginx'])
365
- run('systemctl', ['reload', 'nginx'])
366
-
367
- const certificateArgs = [
368
- 'certonly', '--non-interactive', '--agree-tos',
369
- '--preferred-profile', 'shortlived',
370
- '--webroot', '--webroot-path', WEBROOT,
371
- '--ip-address', ip,
372
- '--cert-name', certName(ip),
373
- '--keep-until-expiring',
374
- ]
375
- if (options.email) certificateArgs.push('--email', options.email)
376
- else certificateArgs.push('--register-unsafely-without-email')
377
- run(CERTBOT, certificateArgs)
378
-
379
- writeManagedFile(NGINX_CONFIG, nginxTlsConfig(ip, options.port))
380
- writeManagedFile(PUBLIC_URL_FILE, `${MARKER}\nwss://${ip}/ws/mobile\n`)
381
- writeManagedFile(RENEW_SERVICE, renewalService())
382
- writeManagedFile(RENEW_TIMER, renewalTimer())
383
- run('nginx', ['-t'])
384
- run('systemctl', ['reload', 'nginx'])
385
- run('systemctl', ['daemon-reload'])
386
- run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
387
-
388
- console.log(`
389
- Setup completed.
390
-
391
- 1. Restart dsh web so the plugin reads ${PUBLIC_URL_FILE}.
392
- 2. Open “移动设备”, enable the gateway, and generate a pairing QR code.
393
- 3. The iOS client should connect to wss://${ip}/ws/mobile.
394
-
395
- Check later with:
396
- sudo npx dsh-plugin-weapp-gateway status`)
397
- }
398
-
399
- function status() {
400
- const endpoint = fs.existsSync(PUBLIC_URL_FILE)
401
- ? fs.readFileSync(PUBLIC_URL_FILE, 'utf8').split(/\r?\n/).find((line) => line.startsWith('wss://'))
402
- : ''
403
- console.log(`Public endpoint: ${endpoint || 'not configured'}`)
404
- console.log(`Nginx config: ${fs.existsSync(NGINX_CONFIG) ? NGINX_CONFIG : 'not installed'}`)
405
- console.log(`Renewal timer: ${fs.existsSync(RENEW_TIMER) ? RENEW_TIMER : 'not installed'}`)
406
- if (commandExists('systemctl') && fs.existsSync(RENEW_TIMER)) {
407
- try { run('systemctl', ['--no-pager', 'status', path.basename(RENEW_TIMER)]) } catch {}
408
- }
409
- }
410
-
411
- async function remove(options) {
412
- assertRoot()
413
- if (!await confirm('Remove the managed Nginx endpoint and renewal timer? Certificates and installed packages will be kept.', options.yes)) {
414
- console.log('Cancelled.')
415
- return
416
- }
417
- if (commandExists('systemctl')) {
418
- try { run('systemctl', ['disable', '--now', path.basename(RENEW_TIMER)]) } catch {}
419
- }
420
- for (const file of [NGINX_CONFIG, PUBLIC_URL_FILE, RENEW_SERVICE, RENEW_TIMER]) {
421
- if (!fs.existsSync(file)) continue
422
- const content = fs.readFileSync(file, 'utf8')
423
- if (!content.startsWith(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
424
- fs.rmSync(file)
425
- console.log(`Removed ${file}`)
426
- }
427
- if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
428
- if (commandExists('nginx')) {
429
- run('nginx', ['-t'])
430
- run('systemctl', ['reload', 'nginx'])
431
- }
432
- console.log('Public IP endpoint removed. Existing certificates and packages were left intact.')
433
- }
434
-
435
- export {
436
- assertPublicIpv4,
437
- certName,
438
- currentPackageSpec,
439
- nginxHttpConfig,
440
- nginxTlsConfig,
441
- parseArgs,
442
- pluginInstallArgs,
443
- helperService,
444
- renewalService,
445
- renewalTimer,
446
- }
447
-
448
- function isMainModule(argvEntry) {
449
- if (!argvEntry) return false
450
- try {
451
- return fs.realpathSync(argvEntry) === fs.realpathSync(fileURLToPath(import.meta.url))
452
- } catch {
453
- return false
454
- }
455
- }
456
-
457
- export { isMainModule }
458
-
459
- if (isMainModule(process.argv[1])) {
460
- try {
461
- const options = parseArgs(process.argv.slice(2))
462
- if (options.command === 'help') printHelp()
463
- else if (options.command === 'init') await init()
464
- else if (options.command === 'setup-helper') setupHelper()
465
- else if (options.command === 'remove-helper') await removeHelper(options)
466
- else if (options.command === 'setup') await setup(options)
467
- else if (options.command === 'status') status()
468
- else if (options.command === 'remove') await remove(options)
469
- else throw new Error(`unknown command: ${options.command}`)
470
- } catch (error) {
471
- console.error(`\nError: ${error && error.message ? error.message : String(error)}`)
472
- process.exitCode = 1
473
- }
474
- }
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import http from 'node:http'
5
+ import net from 'node:net'
6
+ import path from 'node:path'
7
+ import process from 'node:process'
8
+ import readline from 'node:readline/promises'
9
+ import { execFileSync } from 'node:child_process'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ const MARKER = '# Managed by dsh-plugin-weapp-gateway'
13
+ const CONFIG_DIR = '/etc/dsh-mobile-gateway'
14
+ const PUBLIC_URL_FILE = path.join(CONFIG_DIR, 'public-url')
15
+ const WEBROOT = '/var/lib/dsh-mobile-gateway/acme'
16
+ const NGINX_CONFIG = '/etc/nginx/conf.d/dsh-mobile-gateway.conf'
17
+ const CERTBOT_HOME = '/opt/dsh-mobile-gateway/certbot'
18
+ const CERTBOT = path.join(CERTBOT_HOME, 'bin/certbot')
19
+ const RENEW_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.service'
20
+ const RENEW_TIMER = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.timer'
21
+ const HELPER_SOURCE = fileURLToPath(new URL('../helper/dsh_mobile_gateway_helper.py', import.meta.url))
22
+ const HELPER_INSTALL = '/usr/local/libexec/dsh-mobile-gateway-helper'
23
+ const HELPER_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-helper.service'
24
+ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
25
+
26
+ function currentPackageSpec() {
27
+ const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
28
+ if (typeof manifest.name !== 'string' || !manifest.name || typeof manifest.version !== 'string' || !manifest.version) {
29
+ throw new Error('package manifest is missing name or version')
30
+ }
31
+ return `${manifest.name}@${manifest.version}`
32
+ }
33
+
34
+ function pluginInstallArgs(packageSpec = currentPackageSpec()) {
35
+ return [
36
+ 'plugin', '--profile', 'web', 'add', packageSpec,
37
+ `--config.minimum-release-age-exclude=${packageSpec}`,
38
+ ]
39
+ }
40
+
41
+ function printHelp() {
42
+ console.log(`Usage:
43
+ dsh-plugin-weapp-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
44
+ dsh-plugin-weapp-gateway init
45
+ dsh-plugin-weapp-gateway setup-helper
46
+ dsh-plugin-weapp-gateway remove-helper [--yes]
47
+ dsh-plugin-weapp-gateway status
48
+ dsh-plugin-weapp-gateway remove [--yes]
49
+
50
+ The setup command supports Ubuntu/Debian servers. It keeps DSH on 127.0.0.1,
51
+ publishes only /ws/mobile through Nginx on 443, obtains a short-lived trusted
52
+ Let's Encrypt IP certificate, and installs automatic renewal.`)
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'setup'
57
+ const values = { command, port: 3080, yes: false, ip: '', email: '' }
58
+ const args = command === argv[0] ? argv.slice(1) : argv
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const arg = args[index]
61
+ if (arg === '--yes' || arg === '-y') values.yes = true
62
+ else if (arg === '--ip') values.ip = args[++index] || ''
63
+ else if (arg === '--port') values.port = Number(args[++index])
64
+ else if (arg === '--email') values.email = args[++index] || ''
65
+ else if (arg === '--help' || arg === '-h') values.command = 'help'
66
+ else throw new Error(`unknown argument: ${arg}`)
67
+ }
68
+ return values
69
+ }
70
+
71
+ function run(command, args, options = {}) {
72
+ console.log(`\n> ${command} ${args.join(' ')}`)
73
+ return execFileSync(command, args, { stdio: 'inherit', ...options })
74
+ }
75
+
76
+ function commandExists(command) {
77
+ try {
78
+ execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' })
79
+ return true
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ function writeManagedFile(file, content, mode = 0o644) {
86
+ if (fs.existsSync(file)) {
87
+ const existing = fs.readFileSync(file, 'utf8')
88
+ if (!existing.startsWith(MARKER)) {
89
+ throw new Error(`refusing to overwrite unmanaged file: ${file}`)
90
+ }
91
+ }
92
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 })
93
+ const temporary = `${file}.tmp-${process.pid}`
94
+ fs.writeFileSync(temporary, content, { mode })
95
+ fs.renameSync(temporary, file)
96
+ fs.chmodSync(file, mode)
97
+ }
98
+
99
+ function writeInstalledHelper(source, destination) {
100
+ if (fs.existsSync(destination)) {
101
+ const existing = fs.readFileSync(destination, 'utf8')
102
+ if (!existing.slice(0, 256).includes(MARKER)) {
103
+ throw new Error(`refusing to overwrite unmanaged file: ${destination}`)
104
+ }
105
+ }
106
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 })
107
+ const temporary = `${destination}.tmp-${process.pid}`
108
+ fs.copyFileSync(source, temporary)
109
+ fs.chmodSync(temporary, 0o755)
110
+ fs.renameSync(temporary, destination)
111
+ }
112
+
113
+ function helperService(uid) {
114
+ return `${MARKER}
115
+ [Unit]
116
+ Description=DSH mobile gateway privileged configuration helper
117
+ After=network.target
118
+
119
+ [Service]
120
+ Type=simple
121
+ ExecStart=/usr/bin/python3 ${HELPER_INSTALL} --uid ${uid} --socket ${HELPER_SOCKET}
122
+ Restart=on-failure
123
+ RestartSec=2s
124
+ RuntimeDirectory=dsh-mobile-gateway
125
+ RuntimeDirectoryMode=0755
126
+ NoNewPrivileges=true
127
+ PrivateTmp=true
128
+ ProtectHome=true
129
+ ProtectSystem=full
130
+ ReadWritePaths=/etc/nginx /etc/dsh-mobile-gateway /etc/letsencrypt /var/lib/dsh-mobile-gateway /var/lib/letsencrypt /var/log/letsencrypt /run/dsh-mobile-gateway
131
+
132
+ [Install]
133
+ WantedBy=multi-user.target
134
+ `
135
+ }
136
+
137
+ function invokingUserId() {
138
+ const value = Number(process.env.SUDO_UID)
139
+ if (!Number.isInteger(value) || value < 1) {
140
+ throw new Error('setup-helper must be run with sudo from the user that runs dsh web')
141
+ }
142
+ return value
143
+ }
144
+
145
+ async function init() {
146
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
147
+ throw new Error('init must run as the normal DSH user, without sudo')
148
+ }
149
+ if (!commandExists('dsh') || !commandExists('sudo')) {
150
+ throw new Error('init requires dsh and sudo on PATH')
151
+ }
152
+ const packageSpec = currentPackageSpec()
153
+ run('dsh', pluginInstallArgs(packageSpec))
154
+ const script = fileURLToPath(import.meta.url)
155
+ run('sudo', ['env', `PATH=${process.env.PATH || ''}`, process.execPath, script, 'setup-helper'])
156
+ console.log('\nInitialization completed. Start or restart DSH with: dsh web')
157
+ }
158
+
159
+ function setupHelper() {
160
+ assertRoot()
161
+ const uid = invokingUserId()
162
+ if (!commandExists('apt-get') || !commandExists('systemctl')) {
163
+ throw new Error('helper installation currently supports systemd-based Ubuntu/Debian servers only')
164
+ }
165
+ run('apt-get', ['update'])
166
+ run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
167
+ if (!fs.existsSync(CERTBOT)) {
168
+ run('python3', ['-m', 'venv', CERTBOT_HOME])
169
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
170
+ }
171
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
172
+ for (const directory of [CONFIG_DIR, WEBROOT, '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt']) {
173
+ fs.mkdirSync(directory, { recursive: true, mode: 0o755 })
174
+ }
175
+ writeInstalledHelper(HELPER_SOURCE, HELPER_INSTALL)
176
+ writeManagedFile(HELPER_SERVICE, helperService(uid))
177
+ writeManagedFile(RENEW_SERVICE, renewalService())
178
+ writeManagedFile(RENEW_TIMER, renewalTimer())
179
+ run('systemctl', ['daemon-reload'])
180
+ run('systemctl', ['enable', '--now', path.basename(HELPER_SERVICE)])
181
+ run('systemctl', ['restart', path.basename(HELPER_SERVICE)])
182
+ run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
183
+ console.log(`\nHelper installed for uid ${uid}. Public access can now be configured from the Mobile Devices panel.`)
184
+ }
185
+
186
+ async function removeHelper(options) {
187
+ assertRoot()
188
+ if (!await confirm('Remove the privileged mobile gateway helper? Existing Nginx configuration will be kept.', options.yes)) {
189
+ console.log('Cancelled.')
190
+ return
191
+ }
192
+ if (commandExists('systemctl')) {
193
+ try { run('systemctl', ['disable', '--now', path.basename(HELPER_SERVICE)]) } catch {}
194
+ }
195
+ for (const file of [HELPER_SERVICE, HELPER_INSTALL]) {
196
+ if (!fs.existsSync(file)) continue
197
+ const existing = fs.readFileSync(file, 'utf8')
198
+ if (!existing.slice(0, 256).includes(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
199
+ fs.rmSync(file)
200
+ console.log(`Removed ${file}`)
201
+ }
202
+ if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
203
+ }
204
+
205
+ function metadataPublicIp() {
206
+ return new Promise((resolve) => {
207
+ const request = http.get({
208
+ host: 'metadata.tencentyun.com',
209
+ path: '/latest/meta-data/public-ipv4',
210
+ timeout: 2_500,
211
+ headers: { 'User-Agent': 'dsh-mobile-gateway-setup' },
212
+ }, (response) => {
213
+ let body = ''
214
+ response.setEncoding('utf8')
215
+ response.on('data', (chunk) => { body += chunk })
216
+ response.on('end', () => resolve(response.statusCode === 200 ? body.trim() : ''))
217
+ })
218
+ request.on('timeout', () => request.destroy())
219
+ request.on('error', () => resolve(''))
220
+ })
221
+ }
222
+
223
+ function assertPublicIpv4(value) {
224
+ if (net.isIP(value) !== 4) throw new Error(`invalid public IPv4 address: ${value || '(empty)'}`)
225
+ const octets = value.split('.').map(Number)
226
+ const privateAddress = octets[0] === 10
227
+ || octets[0] === 127
228
+ || (octets[0] === 169 && octets[1] === 254)
229
+ || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
230
+ || (octets[0] === 192 && octets[1] === 168)
231
+ || octets[0] === 0
232
+ || octets[0] >= 224
233
+ if (privateAddress) throw new Error(`${value} is not a public IPv4 address`)
234
+ }
235
+
236
+ function certName(ip) {
237
+ return `dsh-mobile-gateway-${ip.replaceAll('.', '-')}`
238
+ }
239
+
240
+ function nginxHttpConfig(ip) {
241
+ return `${MARKER}
242
+ server {
243
+ listen 80;
244
+ server_name ${ip};
245
+
246
+ location ^~ /.well-known/acme-challenge/ {
247
+ root ${WEBROOT};
248
+ default_type text/plain;
249
+ }
250
+
251
+ location / { return 404; }
252
+ }
253
+ `
254
+ }
255
+
256
+ function nginxTlsConfig(ip, backendPort) {
257
+ const liveDirectory = `/etc/letsencrypt/live/${certName(ip)}`
258
+ return `${nginxHttpConfig(ip)}
259
+ server {
260
+ listen 443 ssl;
261
+ server_name ${ip};
262
+ server_tokens off;
263
+ access_log off;
264
+
265
+ ssl_certificate ${liveDirectory}/fullchain.pem;
266
+ ssl_certificate_key ${liveDirectory}/privkey.pem;
267
+ ssl_protocols TLSv1.2 TLSv1.3;
268
+
269
+ location = /ws/mobile {
270
+ if ($args != "") { return 404; }
271
+ proxy_pass http://127.0.0.1:${backendPort};
272
+ proxy_http_version 1.1;
273
+ proxy_set_header Host 127.0.0.1:${backendPort};
274
+ proxy_set_header Upgrade $http_upgrade;
275
+ proxy_set_header Connection "upgrade";
276
+ proxy_set_header X-Forwarded-Proto https;
277
+ proxy_read_timeout 3600s;
278
+ proxy_send_timeout 3600s;
279
+ proxy_buffering off;
280
+ }
281
+
282
+ location / { return 404; }
283
+ }
284
+ `
285
+ }
286
+
287
+ function renewalService() {
288
+ return `${MARKER}
289
+ [Unit]
290
+ Description=Renew the DSH mobile gateway IP certificate
291
+ After=network-online.target nginx.service
292
+
293
+ [Service]
294
+ Type=oneshot
295
+ ExecStart=${CERTBOT} renew --quiet --deploy-hook /bin/systemctl reload nginx
296
+ `
297
+ }
298
+
299
+ function renewalTimer() {
300
+ return `${MARKER}
301
+ [Unit]
302
+ Description=Twice-daily renewal check for the DSH mobile gateway IP certificate
303
+
304
+ [Timer]
305
+ OnCalendar=*-*-* 00,12:00:00
306
+ RandomizedDelaySec=20m
307
+ Persistent=true
308
+
309
+ [Install]
310
+ WantedBy=timers.target
311
+ `
312
+ }
313
+
314
+ async function confirm(message, assumeYes) {
315
+ if (assumeYes) return true
316
+ if (!process.stdin.isTTY) throw new Error('interactive confirmation is unavailable; rerun with --yes')
317
+ const terminal = readline.createInterface({ input: process.stdin, output: process.stdout })
318
+ const answer = await terminal.question(`${message} [y/N] `)
319
+ terminal.close()
320
+ return /^y(es)?$/i.test(answer.trim())
321
+ }
322
+
323
+ function assertRoot() {
324
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
325
+ throw new Error('setup/remove must run as root; prepend sudo to the command')
326
+ }
327
+ }
328
+
329
+ async function setup(options) {
330
+ assertRoot()
331
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error(`invalid backend port: ${options.port}`)
332
+ if (options.email && !/^\S+@\S+\.\S+$/.test(options.email)) throw new Error(`invalid email address: ${options.email}`)
333
+ if (!commandExists('apt-get') || !commandExists('systemctl')) {
334
+ throw new Error('automatic setup currently supports systemd-based Ubuntu/Debian servers only')
335
+ }
336
+
337
+ const ip = options.ip || await metadataPublicIp()
338
+ if (!ip) throw new Error('unable to detect a Tencent Cloud public IP; rerun with --ip <public IPv4>')
339
+ assertPublicIpv4(ip)
340
+
341
+ console.log(`
342
+ Public IPv4: ${ip}
343
+ DSH backend: http://127.0.0.1:${options.port}
344
+ Public endpoint: wss://${ip}/ws/mobile
345
+
346
+ Before continuing, open inbound TCP ports 80 and 443 in the Tencent Cloud
347
+ security group. Only /ws/mobile will be proxied; the WebUI and /mgw remain private.`)
348
+ if (!await confirm('Install Nginx/Certbot and configure this public endpoint?', options.yes)) {
349
+ console.log('Cancelled.')
350
+ return
351
+ }
352
+
353
+ run('apt-get', ['update'])
354
+ run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
355
+ if (!fs.existsSync(CERTBOT)) {
356
+ run('python3', ['-m', 'venv', CERTBOT_HOME])
357
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
358
+ }
359
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
360
+
361
+ fs.mkdirSync(path.join(WEBROOT, '.well-known/acme-challenge'), { recursive: true, mode: 0o755 })
362
+ writeManagedFile(NGINX_CONFIG, nginxHttpConfig(ip))
363
+ run('nginx', ['-t'])
364
+ run('systemctl', ['enable', '--now', 'nginx'])
365
+ run('systemctl', ['reload', 'nginx'])
366
+
367
+ const certificateArgs = [
368
+ 'certonly', '--non-interactive', '--agree-tos',
369
+ '--preferred-profile', 'shortlived',
370
+ '--webroot', '--webroot-path', WEBROOT,
371
+ '--ip-address', ip,
372
+ '--cert-name', certName(ip),
373
+ '--keep-until-expiring',
374
+ ]
375
+ if (options.email) certificateArgs.push('--email', options.email)
376
+ else certificateArgs.push('--register-unsafely-without-email')
377
+ run(CERTBOT, certificateArgs)
378
+
379
+ writeManagedFile(NGINX_CONFIG, nginxTlsConfig(ip, options.port))
380
+ writeManagedFile(PUBLIC_URL_FILE, `${MARKER}\nwss://${ip}/ws/mobile\n`)
381
+ writeManagedFile(RENEW_SERVICE, renewalService())
382
+ writeManagedFile(RENEW_TIMER, renewalTimer())
383
+ run('nginx', ['-t'])
384
+ run('systemctl', ['reload', 'nginx'])
385
+ run('systemctl', ['daemon-reload'])
386
+ run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
387
+
388
+ console.log(`
389
+ Setup completed.
390
+
391
+ 1. Restart dsh web so the plugin reads ${PUBLIC_URL_FILE}.
392
+ 2. Open “移动设备”, enable the gateway, and generate a pairing QR code.
393
+ 3. The iOS client should connect to wss://${ip}/ws/mobile.
394
+
395
+ Check later with:
396
+ sudo npx dsh-plugin-weapp-gateway status`)
397
+ }
398
+
399
+ function status() {
400
+ const endpoint = fs.existsSync(PUBLIC_URL_FILE)
401
+ ? fs.readFileSync(PUBLIC_URL_FILE, 'utf8').split(/\r?\n/).find((line) => line.startsWith('wss://'))
402
+ : ''
403
+ console.log(`Public endpoint: ${endpoint || 'not configured'}`)
404
+ console.log(`Nginx config: ${fs.existsSync(NGINX_CONFIG) ? NGINX_CONFIG : 'not installed'}`)
405
+ console.log(`Renewal timer: ${fs.existsSync(RENEW_TIMER) ? RENEW_TIMER : 'not installed'}`)
406
+ if (commandExists('systemctl') && fs.existsSync(RENEW_TIMER)) {
407
+ try { run('systemctl', ['--no-pager', 'status', path.basename(RENEW_TIMER)]) } catch {}
408
+ }
409
+ }
410
+
411
+ async function remove(options) {
412
+ assertRoot()
413
+ if (!await confirm('Remove the managed Nginx endpoint and renewal timer? Certificates and installed packages will be kept.', options.yes)) {
414
+ console.log('Cancelled.')
415
+ return
416
+ }
417
+ if (commandExists('systemctl')) {
418
+ try { run('systemctl', ['disable', '--now', path.basename(RENEW_TIMER)]) } catch {}
419
+ }
420
+ for (const file of [NGINX_CONFIG, PUBLIC_URL_FILE, RENEW_SERVICE, RENEW_TIMER]) {
421
+ if (!fs.existsSync(file)) continue
422
+ const content = fs.readFileSync(file, 'utf8')
423
+ if (!content.startsWith(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
424
+ fs.rmSync(file)
425
+ console.log(`Removed ${file}`)
426
+ }
427
+ if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
428
+ if (commandExists('nginx')) {
429
+ run('nginx', ['-t'])
430
+ run('systemctl', ['reload', 'nginx'])
431
+ }
432
+ console.log('Public IP endpoint removed. Existing certificates and packages were left intact.')
433
+ }
434
+
435
+ export {
436
+ assertPublicIpv4,
437
+ certName,
438
+ currentPackageSpec,
439
+ nginxHttpConfig,
440
+ nginxTlsConfig,
441
+ parseArgs,
442
+ pluginInstallArgs,
443
+ helperService,
444
+ renewalService,
445
+ renewalTimer,
446
+ }
447
+
448
+ function isMainModule(argvEntry) {
449
+ if (!argvEntry) return false
450
+ try {
451
+ return fs.realpathSync(argvEntry) === fs.realpathSync(fileURLToPath(import.meta.url))
452
+ } catch {
453
+ return false
454
+ }
455
+ }
456
+
457
+ export { isMainModule }
458
+
459
+ if (isMainModule(process.argv[1])) {
460
+ try {
461
+ const options = parseArgs(process.argv.slice(2))
462
+ if (options.command === 'help') printHelp()
463
+ else if (options.command === 'init') await init()
464
+ else if (options.command === 'setup-helper') setupHelper()
465
+ else if (options.command === 'remove-helper') await removeHelper(options)
466
+ else if (options.command === 'setup') await setup(options)
467
+ else if (options.command === 'status') status()
468
+ else if (options.command === 'remove') await remove(options)
469
+ else throw new Error(`unknown command: ${options.command}`)
470
+ } catch (error) {
471
+ console.error(`\nError: ${error && error.message ? error.message : String(error)}`)
472
+ process.exitCode = 1
473
+ }
474
+ }