dsh-autorestart 0.6.1 → 0.7.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/README.md +457 -41
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5973 -3222
- package/cordis.patch.yml +55 -9
- package/docs/README.zh.md +305 -27
- package/index.js +2522 -127
- package/lib/access.js +212 -0
- package/lib/activity.js +124 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/lifecycle.js +17 -4
- package/lib/node-runtime.js +144 -0
- package/lib/queued.js +146 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -60
- package/locale/zh.json +247 -60
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
package/lib/access.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the instance record: how the running host can be reached.
|
|
3
|
+
*
|
|
4
|
+
* TWO facts make this file necessary:
|
|
5
|
+
*
|
|
6
|
+
* 1. The web server of the desktop app listens on a RANDOM port (`--port 0`),
|
|
7
|
+
* so the port of the PREVIOUS instance is worthless for the next one. The
|
|
8
|
+
* port of the new instance is only known to the new instance itself — so
|
|
9
|
+
* the new instance has to publish it, and the rollback watcher has to read
|
|
10
|
+
* it instead of guessing.
|
|
11
|
+
* 2. The boot token of `dsh web` is created PER PROCESS. After a restart the
|
|
12
|
+
* old access link answers 401, so the user is locked out of their own
|
|
13
|
+
* interface until somebody hands them the new link. The new instance knows
|
|
14
|
+
* its own link (the host's connection service signs it), so the new
|
|
15
|
+
* instance writes it down — into a file only the user can read.
|
|
16
|
+
*
|
|
17
|
+
* The record is ONE small JSON file in the state directory:
|
|
18
|
+
*
|
|
19
|
+
* { "bootId": "…", "pid": 1234, "port": 55537, "url": "http://…/?token=…",
|
|
20
|
+
* "token": "…", "at": 1730000000000 }
|
|
21
|
+
*
|
|
22
|
+
* `bootId` is what tells "the new instance" from "the old one": the plugin
|
|
23
|
+
* generates a new one on every boot, so a watcher can wait for the change
|
|
24
|
+
* instead of guessing from a port.
|
|
25
|
+
*
|
|
26
|
+
* The file holds a SECRET (the boot token), so it is written with the
|
|
27
|
+
* restrictive mode 0600. On POSIX that is enforced by the kernel; on Windows
|
|
28
|
+
* the mode is advisory, so the file inherits the ACL of the (per-user) state
|
|
29
|
+
* directory — which is why the token is never written anywhere else in
|
|
30
|
+
* readable form.
|
|
31
|
+
*
|
|
32
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
/** The name of the instance record inside the state directory. */
|
|
39
|
+
export const ACCESS_FILE_NAME = 'access.json';
|
|
40
|
+
|
|
41
|
+
/** The mode the instance record is written with: readable by its owner only. */
|
|
42
|
+
export const ACCESS_FILE_MODE = 0o600;
|
|
43
|
+
|
|
44
|
+
/** @param {unknown} value */
|
|
45
|
+
function errorText(value) {
|
|
46
|
+
if (value && typeof value.message === 'string' && value.message) return value.message;
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The path of the instance record that belongs to a state directory.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} stateDir
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function accessFilePath(stateDir) {
|
|
57
|
+
return path.join(String(stateDir ?? ''), ACCESS_FILE_NAME);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The access link of the running instance, with its boot token when the host's
|
|
62
|
+
* connection service can sign one.
|
|
63
|
+
*
|
|
64
|
+
* The connection service is optional: a host that does not provide it still
|
|
65
|
+
* gets the plain origin, so the record is always written.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ origin?: string, connection?: any }} options
|
|
68
|
+
* @returns {{ url: string|null, token: string|null }}
|
|
69
|
+
*/
|
|
70
|
+
export function resolveAccessUrl({ origin = '', connection = null } = {}) {
|
|
71
|
+
const base = typeof origin === 'string' ? origin.trim().replace(/\/+$/, '') : '';
|
|
72
|
+
if (base === '') return { url: null, token: null };
|
|
73
|
+
let url = base + '/';
|
|
74
|
+
let token = null;
|
|
75
|
+
try {
|
|
76
|
+
if (connection && typeof connection.authenticatedUrl === 'function') {
|
|
77
|
+
const signed = connection.authenticatedUrl(base);
|
|
78
|
+
if (typeof signed === 'string' && signed.trim() !== '') {
|
|
79
|
+
url = signed.trim();
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url);
|
|
82
|
+
token = parsed.searchParams.get('token');
|
|
83
|
+
} catch {
|
|
84
|
+
token = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// A connection service that throws must not cost us the plain origin.
|
|
90
|
+
url = base + '/';
|
|
91
|
+
token = null;
|
|
92
|
+
}
|
|
93
|
+
return { url, token };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Write the instance record.
|
|
98
|
+
*
|
|
99
|
+
* The write is atomic (a temporary file + a rename), so a reader never sees a
|
|
100
|
+
* half-written record, and the mode is restricted to the owner.
|
|
101
|
+
*
|
|
102
|
+
* @param {{
|
|
103
|
+
* stateDir?: string,
|
|
104
|
+
* bootId?: string|null,
|
|
105
|
+
* pid?: number|null,
|
|
106
|
+
* port?: number|null,
|
|
107
|
+
* url?: string|null,
|
|
108
|
+
* token?: string|null,
|
|
109
|
+
* now?: number,
|
|
110
|
+
* }} [options]
|
|
111
|
+
* @returns {{ ok: boolean, path: string|null, record: object|null, error: string|null }}
|
|
112
|
+
*/
|
|
113
|
+
export function writeAccessRecord({
|
|
114
|
+
stateDir = '',
|
|
115
|
+
bootId = null,
|
|
116
|
+
pid = null,
|
|
117
|
+
port = null,
|
|
118
|
+
url = null,
|
|
119
|
+
token = null,
|
|
120
|
+
now = Date.now(),
|
|
121
|
+
} = {}) {
|
|
122
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
123
|
+
if (dir === '') {
|
|
124
|
+
return { ok: false, path: null, record: null, error: 'writeAccessRecord requires stateDir' };
|
|
125
|
+
}
|
|
126
|
+
const file = accessFilePath(dir);
|
|
127
|
+
const record = {
|
|
128
|
+
bootId: typeof bootId === 'string' && bootId !== '' ? bootId : null,
|
|
129
|
+
pid: Number.isFinite(Number(pid)) ? Number(pid) : null,
|
|
130
|
+
port: Number.isFinite(Number(port)) ? Number(port) : null,
|
|
131
|
+
url: typeof url === 'string' && url !== '' ? url : null,
|
|
132
|
+
token: typeof token === 'string' && token !== '' ? token : null,
|
|
133
|
+
at: Number.isFinite(Number(now)) ? Number(now) : Date.now(),
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
137
|
+
const temporary = file + '.tmp';
|
|
138
|
+
fs.writeFileSync(temporary, JSON.stringify(record, null, 2) + '\n', {
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
mode: ACCESS_FILE_MODE,
|
|
141
|
+
});
|
|
142
|
+
// `writeFileSync` only applies the mode when it CREATES the file, so the
|
|
143
|
+
// permission is set explicitly as well (and after the rename, because the
|
|
144
|
+
// rename keeps the mode of the source anyway).
|
|
145
|
+
try {
|
|
146
|
+
fs.chmodSync(temporary, ACCESS_FILE_MODE);
|
|
147
|
+
} catch {
|
|
148
|
+
/* the mode is advisory on some platforms */
|
|
149
|
+
}
|
|
150
|
+
fs.renameSync(temporary, file);
|
|
151
|
+
try {
|
|
152
|
+
fs.chmodSync(file, ACCESS_FILE_MODE);
|
|
153
|
+
} catch {
|
|
154
|
+
/* the mode is advisory on some platforms */
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, path: file, record, error: null };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return { ok: false, path: file, record, error: 'could not write the instance record: ' + errorText(error) };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the instance record from a file path.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} file
|
|
166
|
+
* @returns {{ ok: boolean, record: object|null, error: string|null }}
|
|
167
|
+
*/
|
|
168
|
+
export function readAccessRecordFile(file) {
|
|
169
|
+
const target = typeof file === 'string' ? file.trim() : '';
|
|
170
|
+
if (target === '') return { ok: false, record: null, error: 'readAccessRecordFile requires a path' };
|
|
171
|
+
try {
|
|
172
|
+
const text = fs.readFileSync(target, 'utf8');
|
|
173
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
174
|
+
const parsed = JSON.parse(text.slice(1));
|
|
175
|
+
return { ok: true, record: parsed && typeof parsed === 'object' ? parsed : null, error: null };
|
|
176
|
+
}
|
|
177
|
+
const parsed = JSON.parse(text);
|
|
178
|
+
return { ok: true, record: parsed && typeof parsed === 'object' ? parsed : null, error: null };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, record: null, error: errorText(error) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read the instance record of a state directory.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} stateDir
|
|
188
|
+
* @returns {{ ok: boolean, record: object|null, error: string|null }}
|
|
189
|
+
*/
|
|
190
|
+
export function readAccessRecord(stateDir) {
|
|
191
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
192
|
+
if (dir === '') return { ok: false, record: null, error: 'readAccessRecord requires stateDir' };
|
|
193
|
+
return readAccessRecordFile(accessFilePath(dir));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Remove the instance record (used when a restart is armed, so a stale record
|
|
198
|
+
* of a dead instance is never mistaken for a live one).
|
|
199
|
+
*
|
|
200
|
+
* @param {string} stateDir
|
|
201
|
+
* @returns {boolean} whether a record was removed
|
|
202
|
+
*/
|
|
203
|
+
export function clearAccessRecord(stateDir) {
|
|
204
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
205
|
+
if (dir === '') return false;
|
|
206
|
+
try {
|
|
207
|
+
fs.rmSync(accessFilePath(dir), { force: true });
|
|
208
|
+
return true;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/lib/activity.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — what counts as REAL activity for the quiet window.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS MODULE FIXES
|
|
5
|
+
*
|
|
6
|
+
* The settle window ("the host must stay quiet for N ms before a restart may
|
|
7
|
+
* fire") used to be reset by EVERY session event. The host emits session
|
|
8
|
+
* events continuously while the interface polls and synchronises (measured on
|
|
9
|
+
* the real machine: one every 2-3 seconds), so a window longer than the event
|
|
10
|
+
* interval could never fill: the restart never fired, no matter how long the
|
|
11
|
+
* user waited.
|
|
12
|
+
*
|
|
13
|
+
* THE POLICY
|
|
14
|
+
*
|
|
15
|
+
* A session event counts as activity only when it says that WORK happened: a
|
|
16
|
+
* message was written, a tool ran, a turn started or ended. Everything else
|
|
17
|
+
* (a title, a projection update, a context request, a job that settled, a
|
|
18
|
+
* permission preset) is routine and must NOT reset the window.
|
|
19
|
+
*
|
|
20
|
+
* The rule is deliberately one-sided: an event whose type cannot be read is
|
|
21
|
+
* NOT activity. When in doubt the plugin would rather restart a little early
|
|
22
|
+
* than never restart at all.
|
|
23
|
+
*
|
|
24
|
+
* The hard guarantee does not live here: a conversation that is really
|
|
25
|
+
* running blocks the restart through `busyAgents()` (the `running` reason),
|
|
26
|
+
* and that rule is never relaxed. This module only decides what the QUIET
|
|
27
|
+
* window measures.
|
|
28
|
+
*
|
|
29
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The session event types that mean "work happened".
|
|
34
|
+
*
|
|
35
|
+
* The vocabulary is the one of the DSH session service (its known event
|
|
36
|
+
* types). Only the events that describe a message, a tool, a turn or a
|
|
37
|
+
* command are listed; the rest (titles, projections, jobs, presets, modes,
|
|
38
|
+
* schedules, telemetry) are routine.
|
|
39
|
+
*/
|
|
40
|
+
export const WORK_SESSION_EVENT_TYPES = Object.freeze([
|
|
41
|
+
// A message was written into a conversation.
|
|
42
|
+
'user/message',
|
|
43
|
+
'developer/message',
|
|
44
|
+
'system/message',
|
|
45
|
+
'assistant/message',
|
|
46
|
+
'assistant/attempt',
|
|
47
|
+
// A tool ran.
|
|
48
|
+
'tool/call',
|
|
49
|
+
'tool/result',
|
|
50
|
+
'tool/ptc-dispatch',
|
|
51
|
+
'tool/ptc-dispatch-start',
|
|
52
|
+
// A turn or a step started or ended.
|
|
53
|
+
'turn/start',
|
|
54
|
+
'turn/end',
|
|
55
|
+
'step/start',
|
|
56
|
+
'step/end',
|
|
57
|
+
// A command ran.
|
|
58
|
+
'command/run',
|
|
59
|
+
'command/done',
|
|
60
|
+
// Work was handed around: an inbox splice, a team message, a workflow run.
|
|
61
|
+
'agent/inbox/spliced',
|
|
62
|
+
'team/message/queued',
|
|
63
|
+
'team/message/delivered',
|
|
64
|
+
'team/task',
|
|
65
|
+
'team/member',
|
|
66
|
+
'tool-workflow/run-start',
|
|
67
|
+
'tool-workflow/run-end',
|
|
68
|
+
'tool-workflow/agent-start',
|
|
69
|
+
'tool-workflow/agent-end',
|
|
70
|
+
// A hook ran, a compaction ran, the model retried, a goal changed.
|
|
71
|
+
'hook/invoked',
|
|
72
|
+
'hook/result',
|
|
73
|
+
'compaction/start',
|
|
74
|
+
'compaction/end',
|
|
75
|
+
'compaction/prune',
|
|
76
|
+
'compaction/summary',
|
|
77
|
+
'llm/retry',
|
|
78
|
+
'llm/retry-started',
|
|
79
|
+
'goal/change',
|
|
80
|
+
'approval/asked',
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
/** The work types as a Set, for a cheap lookup. */
|
|
84
|
+
const WORK_TYPES = new Set(WORK_SESSION_EVENT_TYPES)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The type of one session event, or null when it cannot be read.
|
|
88
|
+
*
|
|
89
|
+
* The payload shape differs between the hosts that call this plugin, so the
|
|
90
|
+
* type is looked for in the places it can be: the payload itself, the nested
|
|
91
|
+
* event, or the data envelope.
|
|
92
|
+
*
|
|
93
|
+
* @param {any} payload the event payload the host delivered
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
export function sessionEventType(payload) {
|
|
97
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
98
|
+
const candidates = [
|
|
99
|
+
payload.type,
|
|
100
|
+
payload.eventType,
|
|
101
|
+
payload.event && typeof payload.event === 'object' ? payload.event.type : undefined,
|
|
102
|
+
payload.data && typeof payload.data === 'object' ? payload.data.type : undefined,
|
|
103
|
+
payload.data && typeof payload.data === 'object' && payload.data.event && typeof payload.data.event === 'object'
|
|
104
|
+
? payload.data.event.type
|
|
105
|
+
: undefined,
|
|
106
|
+
]
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim()
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether one session event means that real work happened.
|
|
115
|
+
*
|
|
116
|
+
* @param {any} payload the event payload the host delivered
|
|
117
|
+
* @returns {boolean} false for a routine event AND for an event whose type
|
|
118
|
+
* cannot be read (the one-sided rule: when in doubt, do not wait)
|
|
119
|
+
*/
|
|
120
|
+
export function isWorkSessionEvent(payload) {
|
|
121
|
+
const type = sessionEventType(payload)
|
|
122
|
+
if (type === null) return false
|
|
123
|
+
return WORK_TYPES.has(type)
|
|
124
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — THE "START AT LOGIN" GUIDE OF EVERY PLATFORM.
|
|
3
|
+
*
|
|
4
|
+
* A hosted web host can be started in a dozen ways, and "start at login" means
|
|
5
|
+
* something different on every platform. The plugin therefore hands out ONE
|
|
6
|
+
* piece of advice per platform, and it always says what the record really is:
|
|
7
|
+
*
|
|
8
|
+
* | platform | what the plugin does |
|
|
9
|
+
* |-----------------------|----------------------------------------------------------|
|
|
10
|
+
* | Windows | a scheduled task (`/SC ONLOGON /RL LIMITED`, no admin); when the machine refuses it, a `.cmd` in the startup folder. The command is a REAL node + supervisor.js — never the app executable. |
|
|
11
|
+
* | Linux (not WSL) | a systemd USER service (`systemctl --user`, no admin) — plus the `loginctl enable-linger` hint, because without the linger it only starts after a login. |
|
|
12
|
+
* | WSL with systemd | the same systemd user unit, plus the linger hint. |
|
|
13
|
+
* | WSL without systemd | NO "at login" at all: two ways out (turn systemd on inside WSL, or let Windows start the distribution). NEVER Docker/Compose. |
|
|
14
|
+
* | container / k8s | no promise at all: the orchestrator brings it back (compose `restart: always`). |
|
|
15
|
+
* | unknown | nothing is installed and nothing is guessed: the user is told why and asked to choose. |
|
|
16
|
+
*
|
|
17
|
+
* The advice is a pure function of its input, so every branch is testable
|
|
18
|
+
* without a machine.
|
|
19
|
+
*
|
|
20
|
+
* ESM only, Node builtins only.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { HOST_KINDS } from './host-kind.js'
|
|
24
|
+
|
|
25
|
+
/** The kinds of the advice (one per platform). */
|
|
26
|
+
export const AUTOSTART_ADVICE_KINDS = Object.freeze({
|
|
27
|
+
/** Windows: a scheduled task (with the startup folder as the fallback). */
|
|
28
|
+
WINDOWS: 'windows-task',
|
|
29
|
+
/** Linux: a systemd user service (+ the linger hint). */
|
|
30
|
+
LINUX: 'linux-systemd',
|
|
31
|
+
/** WSL with systemd: the same, and the linger is the missing piece. */
|
|
32
|
+
WSL_SYSTEMD: 'wsl-systemd',
|
|
33
|
+
/** WSL without systemd: the user has to pick one of two ways. */
|
|
34
|
+
WSL_MANUAL: 'wsl-manual',
|
|
35
|
+
/** A container: the orchestrator decides. */
|
|
36
|
+
CONTAINER: 'container',
|
|
37
|
+
/** Nobody can tell: nothing is installed, nothing is guessed. */
|
|
38
|
+
UNKNOWN: 'unknown',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** @param {unknown} value */
|
|
42
|
+
function text(value) {
|
|
43
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The advice of one platform.
|
|
48
|
+
*
|
|
49
|
+
* @param {{
|
|
50
|
+
* host?: { kind?: string, suspect?: boolean, distro?: string, evidence?: string },
|
|
51
|
+
* platform?: string,
|
|
52
|
+
* hasSystemd?: boolean,
|
|
53
|
+
* container?: boolean,
|
|
54
|
+
* user?: string,
|
|
55
|
+
* distro?: string,
|
|
56
|
+
* startCommand?: string,
|
|
57
|
+
* command?: string,
|
|
58
|
+
* }} [options]
|
|
59
|
+
* @returns {{
|
|
60
|
+
* kind: string,
|
|
61
|
+
* reason: string,
|
|
62
|
+
* notes: string[],
|
|
63
|
+
* commands: string[],
|
|
64
|
+
* options: Array<{ id: string, label: string, command: string, cost: string, recommended: boolean }>,
|
|
65
|
+
* canInstall: boolean,
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
export function planAutostartAdvice(options = {}) {
|
|
69
|
+
const host = options.host && typeof options.host === 'object' ? options.host : {}
|
|
70
|
+
const platform = text(options.platform) || text(host.platform) || process.platform
|
|
71
|
+
const kind = text(host.kind)
|
|
72
|
+
const suspect = host.suspect === true
|
|
73
|
+
const user = text(options.user) || text(process.env.USER) || text(process.env.LOGNAME) || '你的用户名'
|
|
74
|
+
const distro = text(options.distro) || text(host.distro) || '你的发行版'
|
|
75
|
+
const startCommand = text(options.startCommand)
|
|
76
|
+
const command = text(options.command)
|
|
77
|
+
const hasSystemd = options.hasSystemd === true
|
|
78
|
+
const isContainer = options.container === true
|
|
79
|
+
|
|
80
|
+
// A host that is only GUESSED to be WSL (the mounted Windows drive is the
|
|
81
|
+
// only evidence) gets no automatic anything: the plugin says so and leaves
|
|
82
|
+
// the choice to the user.
|
|
83
|
+
const suspectNote = suspect
|
|
84
|
+
? '没把握:这台机器只是「挂载了 Windows 盘」才被猜成 WSL,插件不会替你改任何系统配置,建议你手动选一条。'
|
|
85
|
+
: ''
|
|
86
|
+
|
|
87
|
+
const linger = 'loginctl enable-linger ' + user
|
|
88
|
+
|
|
89
|
+
// (1) A CONTAINER. The orchestrator brings it back — there is no "at login".
|
|
90
|
+
if (isContainer) {
|
|
91
|
+
return {
|
|
92
|
+
kind: AUTOSTART_ADVICE_KINDS.CONTAINER,
|
|
93
|
+
reason: '容器里没有「开机自启」这回事:由编排器负责(compose 的 restart: always / k8s 的 restartPolicy)。',
|
|
94
|
+
notes: [
|
|
95
|
+
'容器里没有「开机自启」这回事:由编排器负责(compose 的 restart: always / k8s 的 restartPolicy)。',
|
|
96
|
+
'插件不会在容器里装 systemd —— 容器里也没有 systemd 可装。',
|
|
97
|
+
],
|
|
98
|
+
commands: [],
|
|
99
|
+
options: [],
|
|
100
|
+
canInstall: false,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// (2) WINDOWS. The scheduled task first, the startup folder as the fallback.
|
|
105
|
+
if (kind === HOST_KINDS.WINDOWS || platform === 'win32') {
|
|
106
|
+
return {
|
|
107
|
+
kind: AUTOSTART_ADVICE_KINDS.WINDOWS,
|
|
108
|
+
reason: 'Windows 托管端:登录时自启用**计划任务**(/SC ONLOGON /RL LIMITED,不需要管理员);这台机器若不允许普通用户建计划任务,会自动降级到「启动文件夹」里的一个 .cmd(同样不需要管理员)。注册的命令是**真正的 node + supervisor.js**,不是 DSH 客户端本体。',
|
|
109
|
+
notes: [
|
|
110
|
+
'登录时自启:计划任务(/SC ONLOGON /RL LIMITED,零管理员权限)。',
|
|
111
|
+
'这台机器拒绝计划任务时自动降级为「启动文件夹」里的 .cmd(同样零权限)。',
|
|
112
|
+
'注册的命令是真正的 node + supervisor.js —— 绝不会用 DSH 客户端本体去跑守护脚本(那会在每次登录时再开一个 DSH)。',
|
|
113
|
+
],
|
|
114
|
+
commands: command === '' ? [] : [command],
|
|
115
|
+
options: [],
|
|
116
|
+
canInstall: true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// (3) WSL. With systemd it is a user unit (+ the linger); without it there
|
|
121
|
+
// is no "at login" at all — and NEVER a Docker answer.
|
|
122
|
+
if (kind === HOST_KINDS.WSL) {
|
|
123
|
+
if (hasSystemd) {
|
|
124
|
+
return {
|
|
125
|
+
kind: AUTOSTART_ADVICE_KINDS.WSL_SYSTEMD,
|
|
126
|
+
reason: 'WSL 里有 systemd:守护装成 systemd 用户单元(登录后自启)。'
|
|
127
|
+
+ '若要「开机自启」(不必先登录),在 WSL 里运行一次:' + linger
|
|
128
|
+
+ (suspectNote !== '' ? suspectNote : ''),
|
|
129
|
+
notes: [
|
|
130
|
+
'WSL 里有 systemd:守护装成 systemd 用户单元(登录后自启)。',
|
|
131
|
+
'若要「开机自启」(不必先登录),在 WSL 里运行一次:' + linger
|
|
132
|
+
+ '(只需一次;linger 让 systemd 在你没登录时也把这个用户的服务拉起来)。',
|
|
133
|
+
],
|
|
134
|
+
commands: [linger],
|
|
135
|
+
options: [],
|
|
136
|
+
canInstall: true,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const systemdOption = {
|
|
140
|
+
id: 'wsl-systemd-enable',
|
|
141
|
+
label: '① 在 WSL 内部开启 systemd',
|
|
142
|
+
command: '在 /etc/wsl.conf 里加上 [boot] systemd=true,然后在 Windows 侧运行 wsl --shutdown 重启子系统',
|
|
143
|
+
cost: '这是系统配置改动:要改发行版里的 /etc/wsl.conf,并且要重启整个 WSL 子系统(别的发行版和正在跑的进程会一起停)。',
|
|
144
|
+
recommended: false,
|
|
145
|
+
}
|
|
146
|
+
const windowsOption = {
|
|
147
|
+
id: 'wsl-windows-task',
|
|
148
|
+
label: '② 在 Windows 侧用计划任务 / 启动文件夹拉起 WSL',
|
|
149
|
+
command: 'wsl.exe -d ' + distro + ' -e ' + (startCommand !== '' ? startCommand : '<启动命令>'),
|
|
150
|
+
cost: '需要 Windows 侧操作(新建一条计划任务,或往启动文件夹放一个快捷方式),但更接近真正的开机自启。',
|
|
151
|
+
recommended: true,
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
kind: AUTOSTART_ADVICE_KINDS.WSL_MANUAL,
|
|
155
|
+
reason: 'WSL 里没有 systemd:这里没有「登录时自启」这回事。'
|
|
156
|
+
+ '两条路(插件不会替你改任何系统配置):① 在 WSL 内部开启 systemd;② 在 Windows 侧用计划任务/启动文件夹拉起 WSL(推荐)。'
|
|
157
|
+
+ (suspectNote !== '' ? suspectNote : ''),
|
|
158
|
+
notes: [
|
|
159
|
+
'WSL 里没有 systemd:这里没有「登录时自启」这回事,所以插件不装任何东西。',
|
|
160
|
+
suspectNote !== '' ? suspectNote : '插件不会替你改任何系统配置,两条路都需要你自己动手。',
|
|
161
|
+
'① 在 WSL 内部开启 systemd —— 这是系统配置改动(改 /etc/wsl.conf + 重启子系统)。',
|
|
162
|
+
'② 在 Windows 侧用计划任务 / 启动文件夹拉起 WSL(推荐)—— 需要 Windows 侧操作,但更接近真正的开机自启。',
|
|
163
|
+
],
|
|
164
|
+
commands: [windowsOption.command],
|
|
165
|
+
options: [systemdOption, windowsOption],
|
|
166
|
+
canInstall: false,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// (4) LINUX (and every other real Linux): a systemd user service, and the
|
|
171
|
+
// linger that turns "after a login" into "at boot".
|
|
172
|
+
if (kind === HOST_KINDS.LINUX || platform === 'linux') {
|
|
173
|
+
return {
|
|
174
|
+
kind: AUTOSTART_ADVICE_KINDS.LINUX,
|
|
175
|
+
reason: 'Linux 托管端:用 systemd 用户服务(systemctl --user,不需要管理员)。这是「登录后自启」;若要「开机自启」(不必先登录),运行一次 ' + linger + '。',
|
|
176
|
+
notes: [
|
|
177
|
+
'systemd 用户服务:登录后自启(零管理员权限)。',
|
|
178
|
+
'若要「开机自启」(不必先登录),运行一次:' + linger + '(只需一次;没有 linger 就只是登录后自启)。',
|
|
179
|
+
],
|
|
180
|
+
commands: [linger],
|
|
181
|
+
options: [],
|
|
182
|
+
canInstall: true,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// (5) UNKNOWN. Nothing is installed, nothing is guessed.
|
|
187
|
+
return {
|
|
188
|
+
kind: AUTOSTART_ADVICE_KINDS.UNKNOWN,
|
|
189
|
+
reason: '认不出这个宿主是怎么被拉起来的:插件不装任何东西,也不猜命令。请你手动选一条(或告诉插件该怎么拉起它)。',
|
|
190
|
+
notes: [
|
|
191
|
+
'认不出这个宿主是怎么被拉起来的:插件不装任何东西,也不猜命令。',
|
|
192
|
+
'这是「宁可说不知道,也不双开」的保守做法 —— 请你手动选一条。',
|
|
193
|
+
],
|
|
194
|
+
commands: [],
|
|
195
|
+
options: [],
|
|
196
|
+
canInstall: false,
|
|
197
|
+
}
|
|
198
|
+
}
|