dsh-update-status 0.2.1 → 0.3.0
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/CHANGELOG.md +11 -0
- package/README.md +46 -10
- package/README.zh.md +46 -11
- package/assets/update-panel.png +0 -0
- package/assets/update-panel.zh.png +0 -0
- package/bin/dsh-update-status-service.mjs +5 -0
- package/docs/service-supervision.md +129 -0
- package/lib/client.js +568 -37
- package/lib/client.js.map +1 -1
- package/lib/index.js +302 -9
- package/lib/service-cli.js +634 -0
- package/package.json +10 -3
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createServer } from "node:net";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
//#region src/service/plan.ts
|
|
8
|
+
/** Build transparent per-OS user-service definitions; do not install them here. */
|
|
9
|
+
const SERVICE_LABEL = "com.idoall.dsh-update-status.web";
|
|
10
|
+
const SUPERVISOR_MARKER = "dsh-update-status";
|
|
11
|
+
function xml(value) {
|
|
12
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
13
|
+
}
|
|
14
|
+
function systemd(value) {
|
|
15
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
16
|
+
}
|
|
17
|
+
function powerShell(value) {
|
|
18
|
+
return value.replace(/'/g, "''");
|
|
19
|
+
}
|
|
20
|
+
function boundedPort(value) {
|
|
21
|
+
const port = typeof value === "number" ? value : Number(value);
|
|
22
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("`--port` must be an integer from 1 to 65535");
|
|
23
|
+
return port;
|
|
24
|
+
}
|
|
25
|
+
function valueAfter(args, flag) {
|
|
26
|
+
const index = args.indexOf(flag);
|
|
27
|
+
if (index < 0) return void 0;
|
|
28
|
+
const value = args[index + 1];
|
|
29
|
+
if (value === void 0 || value.startsWith("--")) throw new Error(`missing value after ${flag}`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the real JS file behind `@deepseek-ai/dsh`'s `bin.dsh`, using the
|
|
34
|
+
* selected profile as a Node resolution anchor. This avoids platform shell
|
|
35
|
+
* shims (`dsh.cmd`) and keeps every native service on `node <absolute-js>`.
|
|
36
|
+
*/
|
|
37
|
+
function dshPathOf(env, profile, dshHome, override) {
|
|
38
|
+
const fromArg = override?.trim();
|
|
39
|
+
if (fromArg !== void 0 && fromArg !== "") return isAbsolute(fromArg) ? fromArg : resolve(env.cwd, fromArg);
|
|
40
|
+
const home = dshHome ?? join(env.home || homedir(), ".dsh");
|
|
41
|
+
try {
|
|
42
|
+
const anchor = join(home, "profiles", profile, "dsh-update-status-service-resolver.cjs");
|
|
43
|
+
const manifestPath = createRequire(anchor).resolve("@deepseek-ai/dsh/package.json");
|
|
44
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
45
|
+
if (manifest.name !== "@deepseek-ai/dsh" || typeof manifest.bin?.dsh !== "string") throw new Error("invalid dsh package manifest");
|
|
46
|
+
return realpathSync(join(dirname(manifestPath), manifest.bin.dsh));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`cannot resolve @deepseek-ai/dsh from profile ${profile}; rerun with --dsh /absolute/path/to/lib/bin.js (${error instanceof Error ? error.message : String(error)})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function servicePlatform(value) {
|
|
52
|
+
if (value === "darwin" || value === "linux" || value === "win32") return value;
|
|
53
|
+
throw new Error(`unsupported platform: ${value}; supported: macOS, Linux, Windows`);
|
|
54
|
+
}
|
|
55
|
+
function specFromArgs(args, env) {
|
|
56
|
+
const platform = servicePlatform(env.platform);
|
|
57
|
+
const profile = valueAfter(args, "--profile") ?? env.env.DSH_PROFILE ?? "web";
|
|
58
|
+
const workspace = valueAfter(args, "--workspace");
|
|
59
|
+
if (workspace === void 0) throw new Error("`--workspace` is required and must be the absolute directory DSH should use");
|
|
60
|
+
const host = valueAfter(args, "--host") ?? "127.0.0.1";
|
|
61
|
+
const port = boundedPort(valueAfter(args, "--port") ?? 3080);
|
|
62
|
+
const home = env.home || homedir();
|
|
63
|
+
const dshHome = valueAfter(args, "--dsh-home") ?? (env.env.DSH_HOME?.trim() || void 0);
|
|
64
|
+
if (dshHome !== void 0 && !isAbsolute(dshHome)) throw new Error("`--dsh-home` must be an absolute path");
|
|
65
|
+
const logDir = valueAfter(args, "--log-dir") ?? join(dshHome ?? join(home, ".dsh"), "logs");
|
|
66
|
+
const dshPath = dshPathOf(env, profile, dshHome, valueAfter(args, "--dsh"));
|
|
67
|
+
const nodePath = valueAfter(args, "--node") ?? env.execPath;
|
|
68
|
+
if (!isAbsolute(nodePath)) throw new Error("`--node` must be an absolute executable path");
|
|
69
|
+
if (!isAbsolute(dshPath)) throw new Error("`--dsh` must be an absolute executable path");
|
|
70
|
+
if (!isAbsolute(workspace)) throw new Error("`--workspace` must be an absolute path");
|
|
71
|
+
if (!isAbsolute(logDir)) throw new Error("`--log-dir` must be an absolute path");
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
platform,
|
|
75
|
+
label: SERVICE_LABEL,
|
|
76
|
+
nodePath,
|
|
77
|
+
dshPath,
|
|
78
|
+
profile,
|
|
79
|
+
workspace,
|
|
80
|
+
host,
|
|
81
|
+
port,
|
|
82
|
+
home,
|
|
83
|
+
...dshHome === void 0 ? {} : { dshHome },
|
|
84
|
+
logDir,
|
|
85
|
+
supervisorMarker: SUPERVISOR_MARKER
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function pathsFor(spec) {
|
|
89
|
+
const root = spec.dshHome ?? join(spec.home, ".dsh");
|
|
90
|
+
const stdoutFile = join(spec.logDir, "dsh-web-service.stdout.log");
|
|
91
|
+
const stderrFile = join(spec.logDir, "dsh-web-service.stderr.log");
|
|
92
|
+
if (spec.platform === "darwin") return {
|
|
93
|
+
stateFile: join(root, "dsh-update-status-service.json"),
|
|
94
|
+
definitionFile: join(spec.home, "Library", "LaunchAgents", `${spec.label}.plist`),
|
|
95
|
+
stdoutFile,
|
|
96
|
+
stderrFile
|
|
97
|
+
};
|
|
98
|
+
if (spec.platform === "linux") return {
|
|
99
|
+
stateFile: join(root, "dsh-update-status-service.json"),
|
|
100
|
+
definitionFile: join(spec.home, ".config", "systemd", "user", "dsh-update-status-web.service"),
|
|
101
|
+
stdoutFile,
|
|
102
|
+
stderrFile
|
|
103
|
+
};
|
|
104
|
+
const base = envLocalAppData(spec);
|
|
105
|
+
return {
|
|
106
|
+
stateFile: join(root, "dsh-update-status-service.json"),
|
|
107
|
+
definitionFile: join(base, "dsh-update-status", "dsh-web-task.xml"),
|
|
108
|
+
wrapperFile: join(base, "dsh-update-status", "dsh-web-supervisor.ps1"),
|
|
109
|
+
stdoutFile,
|
|
110
|
+
stderrFile
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function envLocalAppData(spec) {
|
|
114
|
+
return join(spec.home, "AppData", "Local");
|
|
115
|
+
}
|
|
116
|
+
function webArguments(spec) {
|
|
117
|
+
return [
|
|
118
|
+
spec.dshPath,
|
|
119
|
+
"--profile",
|
|
120
|
+
spec.profile,
|
|
121
|
+
"--host",
|
|
122
|
+
spec.host,
|
|
123
|
+
"--port",
|
|
124
|
+
String(spec.port),
|
|
125
|
+
"--no-open"
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
function launchdDefinition(spec, paths) {
|
|
129
|
+
const args = [spec.nodePath, ...webArguments(spec)].map((value) => ` <string>${xml(value)}</string>`).join("\n");
|
|
130
|
+
const envXml = [
|
|
131
|
+
["HOME", spec.home],
|
|
132
|
+
["PATH", `${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`],
|
|
133
|
+
["DSH_WEB_SUPERVISOR", spec.supervisorMarker],
|
|
134
|
+
...spec.dshHome === void 0 ? [] : [["DSH_HOME", spec.dshHome]]
|
|
135
|
+
].map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join("\n");
|
|
136
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
137
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
138
|
+
<plist version="1.0">
|
|
139
|
+
<dict>
|
|
140
|
+
<key>Label</key>
|
|
141
|
+
<string>${xml(spec.label)}</string>
|
|
142
|
+
<key>ProgramArguments</key>
|
|
143
|
+
<array>
|
|
144
|
+
${args}
|
|
145
|
+
</array>
|
|
146
|
+
<key>WorkingDirectory</key>
|
|
147
|
+
<string>${xml(spec.workspace)}</string>
|
|
148
|
+
<key>RunAtLoad</key>
|
|
149
|
+
<true/>
|
|
150
|
+
<key>KeepAlive</key>
|
|
151
|
+
<true/>
|
|
152
|
+
<key>ThrottleInterval</key>
|
|
153
|
+
<integer>3</integer>
|
|
154
|
+
<key>EnvironmentVariables</key>
|
|
155
|
+
<dict>
|
|
156
|
+
${envXml}
|
|
157
|
+
</dict>
|
|
158
|
+
<key>StandardOutPath</key>
|
|
159
|
+
<string>${xml(paths.stdoutFile)}</string>
|
|
160
|
+
<key>StandardErrorPath</key>
|
|
161
|
+
<string>${xml(paths.stderrFile)}</string>
|
|
162
|
+
</dict>
|
|
163
|
+
</plist>
|
|
164
|
+
`;
|
|
165
|
+
}
|
|
166
|
+
function systemdDefinition(spec) {
|
|
167
|
+
const command = [spec.nodePath, ...webArguments(spec)].map((value) => `"${systemd(value)}"`).join(" ");
|
|
168
|
+
const env = [
|
|
169
|
+
`Environment="HOME=${systemd(spec.home)}"`,
|
|
170
|
+
`Environment="PATH=${systemd(`${dirname(spec.nodePath)}:/usr/local/bin:/usr/bin:/bin`)}"`,
|
|
171
|
+
`Environment="DSH_WEB_SUPERVISOR=${spec.supervisorMarker}"`,
|
|
172
|
+
...spec.dshHome === void 0 ? [] : [`Environment="DSH_HOME=${systemd(spec.dshHome)}"`]
|
|
173
|
+
].join("\n");
|
|
174
|
+
return `[Unit]
|
|
175
|
+
Description=DSH Web managed by dsh-update-status
|
|
176
|
+
After=network-online.target
|
|
177
|
+
|
|
178
|
+
[Service]
|
|
179
|
+
Type=simple
|
|
180
|
+
WorkingDirectory="${systemd(spec.workspace)}"
|
|
181
|
+
${env}
|
|
182
|
+
ExecStart=${command}
|
|
183
|
+
Restart=on-failure
|
|
184
|
+
RestartSec=3
|
|
185
|
+
|
|
186
|
+
[Install]
|
|
187
|
+
WantedBy=default.target
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
function windowsWrapper(spec, paths) {
|
|
191
|
+
const node = powerShell(spec.nodePath);
|
|
192
|
+
const dsh = powerShell(spec.dshPath);
|
|
193
|
+
const workspace = powerShell(spec.workspace);
|
|
194
|
+
const out = powerShell(paths.stdoutFile);
|
|
195
|
+
const err = powerShell(paths.stderrFile);
|
|
196
|
+
const home = powerShell(spec.home);
|
|
197
|
+
const dshHome = spec.dshHome === void 0 ? "" : `$env:DSH_HOME = '${powerShell(spec.dshHome)}'\n`;
|
|
198
|
+
return `$ErrorActionPreference = 'Stop'
|
|
199
|
+
$env:HOME = '${home}'
|
|
200
|
+
$env:DSH_WEB_SUPERVISOR = '${spec.supervisorMarker}'
|
|
201
|
+
${dshHome}Set-Location -LiteralPath '${workspace}'
|
|
202
|
+
while ($true) {
|
|
203
|
+
& '${node}' '${dsh}' --profile '${powerShell(spec.profile)}' --host '${powerShell(spec.host)}' --port '${String(spec.port)}' --no-open 1>> '${out}' 2>> '${err}'
|
|
204
|
+
$exitCode = $LASTEXITCODE
|
|
205
|
+
Start-Sleep -Seconds 3
|
|
206
|
+
# The user service owns deliberate restarts (exit 42) and unexpected exits.
|
|
207
|
+
# Manual uninstall stops the scheduled task, which ends this wrapper.
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
function windowsDefinition(spec, paths) {
|
|
212
|
+
const wrapper = paths.wrapperFile;
|
|
213
|
+
if (wrapper === void 0) throw new Error("Windows service plan requires a wrapper path");
|
|
214
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
215
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
216
|
+
<RegistrationInfo><Description>DSH Web managed by dsh-update-status</Description></RegistrationInfo>
|
|
217
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
|
|
218
|
+
<Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
219
|
+
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT0S</ExecutionTimeLimit></Settings>
|
|
220
|
+
<Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>-NoProfile -ExecutionPolicy Bypass -File "${xml(wrapper)}"</Arguments></Exec></Actions>
|
|
221
|
+
</Task>
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
/** Generate a service plan without writing, starting, or stopping anything. */
|
|
225
|
+
function planFor(spec, uid = 0) {
|
|
226
|
+
const paths = pathsFor(spec);
|
|
227
|
+
if (spec.platform === "darwin") {
|
|
228
|
+
const domain = `gui/${uid}`;
|
|
229
|
+
return {
|
|
230
|
+
spec,
|
|
231
|
+
paths,
|
|
232
|
+
definition: launchdDefinition(spec, paths),
|
|
233
|
+
installCommand: [
|
|
234
|
+
"launchctl",
|
|
235
|
+
"bootstrap",
|
|
236
|
+
domain,
|
|
237
|
+
paths.definitionFile
|
|
238
|
+
],
|
|
239
|
+
startCommand: [
|
|
240
|
+
"launchctl",
|
|
241
|
+
"kickstart",
|
|
242
|
+
"-k",
|
|
243
|
+
`${domain}/${spec.label}`
|
|
244
|
+
],
|
|
245
|
+
statusCommand: [
|
|
246
|
+
"launchctl",
|
|
247
|
+
"print",
|
|
248
|
+
`${domain}/${spec.label}`
|
|
249
|
+
],
|
|
250
|
+
stopCommand: [
|
|
251
|
+
"launchctl",
|
|
252
|
+
"bootout",
|
|
253
|
+
`${domain}/${spec.label}`
|
|
254
|
+
],
|
|
255
|
+
uninstallCommand: [
|
|
256
|
+
"launchctl",
|
|
257
|
+
"bootout",
|
|
258
|
+
`${domain}/${spec.label}`
|
|
259
|
+
]
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (spec.platform === "linux") return {
|
|
263
|
+
spec,
|
|
264
|
+
paths,
|
|
265
|
+
definition: systemdDefinition(spec),
|
|
266
|
+
installCommand: [
|
|
267
|
+
"systemctl",
|
|
268
|
+
"--user",
|
|
269
|
+
"daemon-reload"
|
|
270
|
+
],
|
|
271
|
+
startCommand: [
|
|
272
|
+
"systemctl",
|
|
273
|
+
"--user",
|
|
274
|
+
"enable",
|
|
275
|
+
"--now",
|
|
276
|
+
"dsh-update-status-web.service"
|
|
277
|
+
],
|
|
278
|
+
statusCommand: [
|
|
279
|
+
"systemctl",
|
|
280
|
+
"--user",
|
|
281
|
+
"status",
|
|
282
|
+
"dsh-update-status-web.service"
|
|
283
|
+
],
|
|
284
|
+
stopCommand: [
|
|
285
|
+
"systemctl",
|
|
286
|
+
"--user",
|
|
287
|
+
"disable",
|
|
288
|
+
"--now",
|
|
289
|
+
"dsh-update-status-web.service"
|
|
290
|
+
],
|
|
291
|
+
uninstallCommand: [
|
|
292
|
+
"systemctl",
|
|
293
|
+
"--user",
|
|
294
|
+
"disable",
|
|
295
|
+
"--now",
|
|
296
|
+
"dsh-update-status-web.service"
|
|
297
|
+
]
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
spec,
|
|
301
|
+
paths,
|
|
302
|
+
definition: windowsDefinition(spec, paths),
|
|
303
|
+
wrapper: windowsWrapper(spec, paths),
|
|
304
|
+
installCommand: [
|
|
305
|
+
"schtasks.exe",
|
|
306
|
+
"/Create",
|
|
307
|
+
"/TN",
|
|
308
|
+
"DSH Update Status Web",
|
|
309
|
+
"/XML",
|
|
310
|
+
paths.definitionFile,
|
|
311
|
+
"/F"
|
|
312
|
+
],
|
|
313
|
+
startCommand: [
|
|
314
|
+
"schtasks.exe",
|
|
315
|
+
"/Run",
|
|
316
|
+
"/TN",
|
|
317
|
+
"DSH Update Status Web"
|
|
318
|
+
],
|
|
319
|
+
statusCommand: [
|
|
320
|
+
"schtasks.exe",
|
|
321
|
+
"/Query",
|
|
322
|
+
"/TN",
|
|
323
|
+
"DSH Update Status Web",
|
|
324
|
+
"/V",
|
|
325
|
+
"/FO",
|
|
326
|
+
"LIST"
|
|
327
|
+
],
|
|
328
|
+
stopCommand: [
|
|
329
|
+
"schtasks.exe",
|
|
330
|
+
"/End",
|
|
331
|
+
"/TN",
|
|
332
|
+
"DSH Update Status Web"
|
|
333
|
+
],
|
|
334
|
+
uninstallCommand: [
|
|
335
|
+
"schtasks.exe",
|
|
336
|
+
"/Delete",
|
|
337
|
+
"/TN",
|
|
338
|
+
"DSH Update Status Web",
|
|
339
|
+
"/F"
|
|
340
|
+
]
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/service/cli.ts
|
|
345
|
+
/** Explicit, cross-platform user-service installer CLI. Never runs at plugin mount. */
|
|
346
|
+
const RECEIPT_VERSION = 1;
|
|
347
|
+
function environment() {
|
|
348
|
+
return {
|
|
349
|
+
platform: process.platform,
|
|
350
|
+
home: process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
351
|
+
cwd: process.cwd(),
|
|
352
|
+
execPath: process.execPath,
|
|
353
|
+
env: process.env,
|
|
354
|
+
path: process.env.PATH,
|
|
355
|
+
...typeof process.getuid === "function" ? { uid: process.getuid() } : {}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function runner(file, args) {
|
|
359
|
+
const result = spawnSync(file, [...args], {
|
|
360
|
+
encoding: "utf8",
|
|
361
|
+
windowsHide: true
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
status: result.status,
|
|
365
|
+
stdout: result.stdout ?? "",
|
|
366
|
+
stderr: result.stderr ?? "",
|
|
367
|
+
...result.error === void 0 ? {} : { error: result.error.message }
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function usage() {
|
|
371
|
+
return `Usage: dsh-update-status-service <plan|install|activate|status|stop|uninstall> [options]
|
|
372
|
+
|
|
373
|
+
Create a current-user DSH Web supervisor. This CLI never runs automatically.
|
|
374
|
+
|
|
375
|
+
Options:
|
|
376
|
+
--dry-run Print the expanded plan without writing or invoking OS services
|
|
377
|
+
--profile <name> DSH profile (default: DSH_PROFILE or web)
|
|
378
|
+
--workspace <path> Required absolute working directory for DSH
|
|
379
|
+
--dsh-home <path> Optional absolute DSH home (default: DSH_HOME or ~/.dsh)
|
|
380
|
+
--host <127.0.0.1|0.0.0.0> Bind host (default: 127.0.0.1)
|
|
381
|
+
--port <1..65535> Web port (default: 3080)
|
|
382
|
+
--node <path> Absolute Node executable (default: current Node)
|
|
383
|
+
--dsh <path> Absolute dsh executable (default: resolve from PATH)
|
|
384
|
+
--log-dir <path> Absolute log directory (default: $DSH_HOME/logs)
|
|
385
|
+
|
|
386
|
+
Workflow: plan --dry-run → install → stop the terminal-started DSH yourself → activate.
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
function assertSafeText(value, description) {
|
|
390
|
+
if (/[\u0000\r\n]/.test(value)) throw new Error(`${description} must not contain NUL or line breaks`);
|
|
391
|
+
}
|
|
392
|
+
function assertReadableRegular(path, description) {
|
|
393
|
+
assertSafeText(path, description);
|
|
394
|
+
if (!existsSync(path)) throw new Error(`${description} does not exist: ${path}`);
|
|
395
|
+
try {
|
|
396
|
+
if (!statSync(path).isFile()) throw new Error(`${description} is not a regular file: ${path}`);
|
|
397
|
+
accessSync(path, constants.R_OK);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof Error && error.message.startsWith(description)) throw error;
|
|
400
|
+
throw new Error(`${description} is not readable: ${path}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function assertExecutable(path, description) {
|
|
404
|
+
assertReadableRegular(path, description);
|
|
405
|
+
if (process.platform !== "win32") try {
|
|
406
|
+
accessSync(path, constants.X_OK);
|
|
407
|
+
} catch {
|
|
408
|
+
throw new Error(`${description} is not executable: ${path}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function validateSpec(spec) {
|
|
412
|
+
for (const [description, value] of [
|
|
413
|
+
["profile", spec.profile],
|
|
414
|
+
["workspace", spec.workspace],
|
|
415
|
+
["home", spec.home],
|
|
416
|
+
["log directory", spec.logDir],
|
|
417
|
+
["host", spec.host]
|
|
418
|
+
]) assertSafeText(value, description);
|
|
419
|
+
if (spec.host !== "127.0.0.1" && spec.host !== "0.0.0.0") throw new Error("`--host` must be 127.0.0.1 or 0.0.0.0");
|
|
420
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(spec.profile)) throw new Error("`--profile` must contain only letters, numbers, dot, underscore, or hyphen");
|
|
421
|
+
assertExecutable(spec.nodePath, "Node executable");
|
|
422
|
+
assertReadableRegular(spec.dshPath, "dsh executable");
|
|
423
|
+
assertSafeText(spec.workspace, "workspace");
|
|
424
|
+
if (!existsSync(spec.workspace) || !statSync(spec.workspace).isDirectory()) throw new Error(`workspace is not a directory: ${spec.workspace}`);
|
|
425
|
+
}
|
|
426
|
+
function atomicWrite(path, content) {
|
|
427
|
+
mkdirSync(dirname(path), {
|
|
428
|
+
recursive: true,
|
|
429
|
+
mode: 448
|
|
430
|
+
});
|
|
431
|
+
const temporary = `${path}.${String(process.pid)}.tmp`;
|
|
432
|
+
writeFileSync(temporary, content, {
|
|
433
|
+
encoding: "utf8",
|
|
434
|
+
mode: 384
|
|
435
|
+
});
|
|
436
|
+
renameSync(temporary, path);
|
|
437
|
+
}
|
|
438
|
+
function ensureManagedTarget(path, expected) {
|
|
439
|
+
if (!existsSync(path)) return;
|
|
440
|
+
let current;
|
|
441
|
+
try {
|
|
442
|
+
current = readFileSync(path, "utf8");
|
|
443
|
+
} catch {
|
|
444
|
+
throw new Error(`refusing to replace unreadable existing file: ${path}`);
|
|
445
|
+
}
|
|
446
|
+
if (current !== expected) throw new Error(`refusing to overwrite an existing unmanaged service artifact: ${path}`);
|
|
447
|
+
}
|
|
448
|
+
function readReceipt(path) {
|
|
449
|
+
try {
|
|
450
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
451
|
+
if (value.version !== RECEIPT_VERSION || value.managedBy !== "dsh-update-status" || value.spec === void 0 || value.paths === void 0) throw new Error("not a dsh-update-status service receipt");
|
|
452
|
+
return value;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new Error(`cannot read managed service receipt ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function portIsFree(host, port) {
|
|
458
|
+
return await new Promise((resolve) => {
|
|
459
|
+
const server = createServer();
|
|
460
|
+
server.once("error", () => resolve(false));
|
|
461
|
+
server.listen(port, host, () => server.close((error) => resolve(error === void 0)));
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function serialisePlan(plan) {
|
|
465
|
+
return JSON.stringify({
|
|
466
|
+
platform: plan.spec.platform,
|
|
467
|
+
label: plan.spec.label,
|
|
468
|
+
nodePath: plan.spec.nodePath,
|
|
469
|
+
dshPath: plan.spec.dshPath,
|
|
470
|
+
profile: plan.spec.profile,
|
|
471
|
+
workspace: plan.spec.workspace,
|
|
472
|
+
host: plan.spec.host,
|
|
473
|
+
port: plan.spec.port,
|
|
474
|
+
definitionFile: plan.paths.definitionFile,
|
|
475
|
+
wrapperFile: plan.paths.wrapperFile,
|
|
476
|
+
stateFile: plan.paths.stateFile,
|
|
477
|
+
logs: {
|
|
478
|
+
stdout: plan.paths.stdoutFile,
|
|
479
|
+
stderr: plan.paths.stderrFile
|
|
480
|
+
},
|
|
481
|
+
commands: {
|
|
482
|
+
install: plan.installCommand,
|
|
483
|
+
start: plan.startCommand,
|
|
484
|
+
status: plan.statusCommand,
|
|
485
|
+
stop: plan.stopCommand,
|
|
486
|
+
uninstall: plan.uninstallCommand
|
|
487
|
+
}
|
|
488
|
+
}, null, 2) + "\n";
|
|
489
|
+
}
|
|
490
|
+
function commandsForActivate(plan, uid) {
|
|
491
|
+
if (plan.spec.platform === "darwin") return [[
|
|
492
|
+
"launchctl",
|
|
493
|
+
"bootstrap",
|
|
494
|
+
`gui/${uid}`,
|
|
495
|
+
plan.paths.definitionFile
|
|
496
|
+
]];
|
|
497
|
+
if (plan.spec.platform === "linux") return [[
|
|
498
|
+
"systemctl",
|
|
499
|
+
"--user",
|
|
500
|
+
"daemon-reload"
|
|
501
|
+
], [
|
|
502
|
+
"systemctl",
|
|
503
|
+
"--user",
|
|
504
|
+
"enable",
|
|
505
|
+
"--now",
|
|
506
|
+
"dsh-update-status-web.service"
|
|
507
|
+
]];
|
|
508
|
+
return [[
|
|
509
|
+
"schtasks.exe",
|
|
510
|
+
"/Create",
|
|
511
|
+
"/TN",
|
|
512
|
+
"DSH Update Status Web",
|
|
513
|
+
"/XML",
|
|
514
|
+
plan.paths.definitionFile,
|
|
515
|
+
"/F"
|
|
516
|
+
], [
|
|
517
|
+
"schtasks.exe",
|
|
518
|
+
"/Run",
|
|
519
|
+
"/TN",
|
|
520
|
+
"DSH Update Status Web"
|
|
521
|
+
]];
|
|
522
|
+
}
|
|
523
|
+
function invoke(command, run) {
|
|
524
|
+
const [file, ...args] = command;
|
|
525
|
+
if (file === void 0) throw new Error("empty service command");
|
|
526
|
+
const result = run(file, args);
|
|
527
|
+
if (result.status !== 0) throw new Error(`${command.join(" ")} failed (${String(result.status)}): ${(result.stderr || result.stdout || result.error || "no diagnostic").trim()}`);
|
|
528
|
+
}
|
|
529
|
+
function preflight(spec) {
|
|
530
|
+
const result = spawnSync(spec.nodePath, [
|
|
531
|
+
spec.dshPath,
|
|
532
|
+
"--profile",
|
|
533
|
+
spec.profile,
|
|
534
|
+
"--dump-config"
|
|
535
|
+
], {
|
|
536
|
+
cwd: spec.workspace,
|
|
537
|
+
encoding: "utf8",
|
|
538
|
+
timeout: 9e4,
|
|
539
|
+
windowsHide: true,
|
|
540
|
+
env: {
|
|
541
|
+
HOME: spec.home,
|
|
542
|
+
...spec.dshHome === void 0 ? {} : { DSH_HOME: spec.dshHome },
|
|
543
|
+
PATH: `${dirname(spec.nodePath)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
if (result.status !== 0) {
|
|
547
|
+
const diagnostic = (result.stderr || result.stdout || result.error?.message || "no diagnostic").trim().slice(0, 800);
|
|
548
|
+
throw new Error(`DSH boot-free preflight failed; no service definition was written: ${diagnostic}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function main(argv = process.argv.slice(2), env = environment(), run = runner, hooks = {}) {
|
|
552
|
+
const [action, ...rawArgs] = argv;
|
|
553
|
+
if (action === void 0 || action === "--help" || action === "-h") {
|
|
554
|
+
process.stdout.write(usage());
|
|
555
|
+
return action === void 0 ? 2 : 0;
|
|
556
|
+
}
|
|
557
|
+
if (![
|
|
558
|
+
"plan",
|
|
559
|
+
"install",
|
|
560
|
+
"activate",
|
|
561
|
+
"status",
|
|
562
|
+
"stop",
|
|
563
|
+
"uninstall"
|
|
564
|
+
].includes(action)) {
|
|
565
|
+
process.stderr.write(`Unknown command: ${action}\n${usage()}`);
|
|
566
|
+
return 2;
|
|
567
|
+
}
|
|
568
|
+
const dryRun = rawArgs.includes("--dry-run") || action === "plan";
|
|
569
|
+
const args = rawArgs.filter((value) => value !== "--dry-run");
|
|
570
|
+
try {
|
|
571
|
+
const spec = specFromArgs(args, env);
|
|
572
|
+
validateSpec(spec);
|
|
573
|
+
const plan = planFor(spec, env.uid ?? 0);
|
|
574
|
+
if (dryRun) {
|
|
575
|
+
process.stdout.write(serialisePlan(plan));
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
if (action === "install") {
|
|
579
|
+
(hooks.preflight ?? preflight)(spec);
|
|
580
|
+
ensureManagedTarget(plan.paths.definitionFile, plan.definition);
|
|
581
|
+
if (plan.wrapper !== void 0 && plan.paths.wrapperFile !== void 0) ensureManagedTarget(plan.paths.wrapperFile, plan.wrapper);
|
|
582
|
+
const receipt = {
|
|
583
|
+
version: RECEIPT_VERSION,
|
|
584
|
+
managedBy: "dsh-update-status",
|
|
585
|
+
spec,
|
|
586
|
+
paths: plan.paths
|
|
587
|
+
};
|
|
588
|
+
const receiptText = JSON.stringify(receipt, null, 2) + "\n";
|
|
589
|
+
ensureManagedTarget(plan.paths.stateFile, receiptText);
|
|
590
|
+
mkdirSync(spec.logDir, {
|
|
591
|
+
recursive: true,
|
|
592
|
+
mode: 448
|
|
593
|
+
});
|
|
594
|
+
atomicWrite(plan.paths.definitionFile, plan.definition);
|
|
595
|
+
if (plan.wrapper !== void 0 && plan.paths.wrapperFile !== void 0) atomicWrite(plan.paths.wrapperFile, plan.wrapper);
|
|
596
|
+
atomicWrite(plan.paths.stateFile, receiptText);
|
|
597
|
+
process.stdout.write(`Staged user service definition: ${plan.paths.definitionFile}\nNo service has started. Stop your terminal-started DSH, then run activate.\n`);
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
const receipt = readReceipt(plan.paths.stateFile);
|
|
601
|
+
if (action === "activate") {
|
|
602
|
+
if (!await portIsFree(receipt.spec.host, receipt.spec.port)) throw new Error(`port ${receipt.spec.host}:${String(receipt.spec.port)} is in use; stop the existing DSH yourself before activate. Nothing was killed.`);
|
|
603
|
+
for (const command of commandsForActivate(plan, env.uid ?? 0)) invoke(command, run);
|
|
604
|
+
process.stdout.write("User service activated. Wait for DSH to become ready, then open the existing Web URL.\n");
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
if (action === "status") {
|
|
608
|
+
const result = run(plan.statusCommand[0], plan.statusCommand.slice(1));
|
|
609
|
+
process.stdout.write(serialisePlan(plan));
|
|
610
|
+
process.stdout.write(`Native status (${plan.statusCommand.join(" ")}):\n${result.stdout || result.stderr || result.error || `exit ${String(result.status)}`}\n`);
|
|
611
|
+
return result.status === 0 ? 0 : 1;
|
|
612
|
+
}
|
|
613
|
+
if (action === "stop") {
|
|
614
|
+
invoke(plan.stopCommand, run);
|
|
615
|
+
process.stdout.write("User service stopped. Its definition and receipt remain; run activate to start it again.\n");
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
const result = run(plan.uninstallCommand[0], plan.uninstallCommand.slice(1));
|
|
619
|
+
if (result.status !== 0) process.stderr.write(`Warning: ${result.stderr || result.stdout || "service was not loaded"}\n`);
|
|
620
|
+
rmSync(receipt.paths.definitionFile, { force: true });
|
|
621
|
+
if (receipt.paths.wrapperFile !== void 0) rmSync(receipt.paths.wrapperFile, { force: true });
|
|
622
|
+
rmSync(receipt.paths.stateFile, { force: true });
|
|
623
|
+
process.stdout.write("Removed the managed user-service definition. Logs were preserved.\n");
|
|
624
|
+
return 0;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
process.stderr.write(`dsh-update-status-service: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (import.meta.url === new URL(process.argv[1] ?? "", "file:").href) main().then((code) => {
|
|
631
|
+
process.exitCode = code;
|
|
632
|
+
});
|
|
633
|
+
//#endregion
|
|
634
|
+
export { main };
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-update-status",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DeepSeek Harness Web plugin for version status, copy-only upgrade guidance, and safe restart when an explicit user-level supervisor is installed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dsh-update-status-service": "./bin/dsh-update-status-service.mjs"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"types": "./lib/index.d.ts",
|
|
@@ -18,14 +21,18 @@
|
|
|
18
21
|
"lib/index.d.ts",
|
|
19
22
|
"lib/client.js",
|
|
20
23
|
"lib/client.js.map",
|
|
24
|
+
"lib/service-cli.js",
|
|
25
|
+
"bin/dsh-update-status-service.mjs",
|
|
21
26
|
"cordis.patch.yml",
|
|
22
27
|
"README.md",
|
|
23
28
|
"README.zh.md",
|
|
24
29
|
"CHANGELOG.md",
|
|
25
30
|
"LICENSE",
|
|
26
31
|
"assets/update-panel.png",
|
|
32
|
+
"assets/update-panel.zh.png",
|
|
27
33
|
"assets/sidebar-chip.png",
|
|
28
|
-
"docs/RELEASING.md"
|
|
34
|
+
"docs/RELEASING.md",
|
|
35
|
+
"docs/service-supervision.md"
|
|
29
36
|
],
|
|
30
37
|
"engines": {
|
|
31
38
|
"node": ">=20"
|