dsh-autorestart 0.6.2 → 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 +329 -12
- 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 +5911 -3832
- package/cordis.patch.yml +37 -1
- package/docs/README.zh.md +219 -9
- package/index.js +2266 -193
- package/lib/access.js +212 -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/node-runtime.js +144 -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 -64
- package/locale/zh.json +247 -64
- 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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/backup.js
CHANGED
|
@@ -37,9 +37,87 @@ export const DEFAULT_MAX_BACKUPS = 2;
|
|
|
37
37
|
/** The report the detached helper writes when it rolls a backup back. */
|
|
38
38
|
export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
|
|
39
39
|
|
|
40
|
+
/** The lock that makes sure a snapshot is restored ONCE. */
|
|
41
|
+
export const ROLLBACK_LOCK_NAME = 'rollback.lock';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Claim the right to restore the snapshot.
|
|
45
|
+
*
|
|
46
|
+
* TWO parties can want to restore the snapshot at the same time: the plugin
|
|
47
|
+
* itself (it saw that the restart failed) and the detached watcher (the host
|
|
48
|
+
* never came back). Both copy the same files over the profile — two copies at
|
|
49
|
+
* once would write the same files at the same time, and the plugin tree is
|
|
50
|
+
* 117 MB.
|
|
51
|
+
*
|
|
52
|
+
* The claim is atomic: the file is CREATED, never opened, so exactly ONE
|
|
53
|
+
* caller gets it. The loser stands down.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} stateDir
|
|
56
|
+
* @returns {{ ok: boolean, path: string, error: string|null }}
|
|
57
|
+
*/
|
|
58
|
+
export function claimRollbackLock(stateDir) {
|
|
59
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
60
|
+
if (dir === '') return { ok: false, path: '', error: 'claimRollbackLock requires stateDir' };
|
|
61
|
+
const file = path.join(dir, ROLLBACK_LOCK_NAME);
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
} catch {
|
|
65
|
+
/* the directory may already be there */
|
|
66
|
+
}
|
|
67
|
+
// A lock that is older than 10 minutes belongs to a process that died in the
|
|
68
|
+
// middle of its restore: it must not block the rollback for ever.
|
|
69
|
+
try {
|
|
70
|
+
const stat = fs.statSync(file);
|
|
71
|
+
if (Date.now() - stat.mtimeMs > 10 * 60 * 1000) {
|
|
72
|
+
fs.rmSync(file, { force: true });
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
/* no lock there: nothing to clean up */
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const handle = fs.openSync(file, 'wx');
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(handle, String(process.pid ?? ''), 'utf8');
|
|
81
|
+
} catch {
|
|
82
|
+
/* the owner is what matters, not the note inside the file */
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
fs.closeSync(handle);
|
|
86
|
+
} catch {
|
|
87
|
+
/* nothing else to do */
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, path: file, error: null };
|
|
90
|
+
} catch {
|
|
91
|
+
return { ok: false, path: file, error: 'another rollback is already running' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Release the rollback lock.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} stateDir
|
|
99
|
+
* @returns {boolean} whether a lock was removed
|
|
100
|
+
*/
|
|
101
|
+
export function releaseRollbackLock(stateDir) {
|
|
102
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
103
|
+
if (dir === '') return false;
|
|
104
|
+
try {
|
|
105
|
+
fs.rmSync(path.join(dir, ROLLBACK_LOCK_NAME), { force: true });
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
40
112
|
/**
|
|
41
113
|
* A filesystem-safe timestamp for a backup directory name.
|
|
42
114
|
*
|
|
115
|
+
* The name is in UTC on purpose: it sorts chronologically and it does not
|
|
116
|
+
* depend on the timezone of the machine. The price is that it does NOT look
|
|
117
|
+
* like the wall clock the operator reads (`2026-10-29T00-25-20-779Z` is
|
|
118
|
+
* 08:25 in Shanghai) — so the manifest and the restart record carry the local
|
|
119
|
+
* time as well, and a fresh snapshot can never be mistaken for an old one.
|
|
120
|
+
*
|
|
43
121
|
* @param {number} [now]
|
|
44
122
|
* @returns {string}
|
|
45
123
|
*/
|
|
@@ -48,6 +126,26 @@ export function backupStamp(now = Date.now()) {
|
|
|
48
126
|
return date.toISOString().replace(/[:.]/g, '-');
|
|
49
127
|
}
|
|
50
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The local time of an epoch, with the UTC offset (`2026-10-29T08:25:20+08:00`).
|
|
131
|
+
*
|
|
132
|
+
* @param {number|null|undefined} at
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
export function backupLocalTime(at) {
|
|
136
|
+
const value = Number(at);
|
|
137
|
+
if (!Number.isFinite(value)) return null;
|
|
138
|
+
const date = new Date(value);
|
|
139
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
140
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
141
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
142
|
+
const absolute = Math.abs(offsetMinutes);
|
|
143
|
+
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
|
|
144
|
+
const minutes = String(absolute % 60).padStart(2, '0');
|
|
145
|
+
const local = new Date(value + offsetMinutes * 60000).toISOString();
|
|
146
|
+
return `${local.slice(0, 19)}${sign}${hours}:${minutes}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
51
149
|
/**
|
|
52
150
|
* The backups directory that belongs to a state directory.
|
|
53
151
|
*
|
|
@@ -142,8 +240,12 @@ export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
|
|
|
142
240
|
/**
|
|
143
241
|
* List the backups, newest first.
|
|
144
242
|
*
|
|
243
|
+
* Only the DIRECTORY is read: the name, the manifest and nothing else. The
|
|
244
|
+
* backup tree can be 150 MB per copy, so its contents are never walked — the
|
|
245
|
+
* manifest already carries the size, the file count and the local time.
|
|
246
|
+
*
|
|
145
247
|
* @param {string} backupsDir
|
|
146
|
-
* @returns {Array<{name: string, dir: string, at: number|null, bytes: number|null, includePlugins: boolean}>}
|
|
248
|
+
* @returns {Array<{name: string, dir: string, at: number|null, atLocal: string|null, bytes: number|null, files: number|null, includePlugins: boolean}>}
|
|
147
249
|
*/
|
|
148
250
|
export function listBackups(backupsDir) {
|
|
149
251
|
let names;
|
|
@@ -158,11 +260,18 @@ export function listBackups(backupsDir) {
|
|
|
158
260
|
for (const name of names.sort().reverse()) {
|
|
159
261
|
const dir = path.join(backupsDir, name);
|
|
160
262
|
const manifest = readManifest(dir);
|
|
263
|
+
const at = manifest && Number.isFinite(manifest.at) ? manifest.at : null;
|
|
161
264
|
out.push({
|
|
162
265
|
name,
|
|
163
266
|
dir,
|
|
164
|
-
at
|
|
267
|
+
at,
|
|
268
|
+
// The local time of the snapshot. The directory name is UTC on purpose,
|
|
269
|
+
// so a fresh snapshot must never look like an old one.
|
|
270
|
+
atLocal: manifest && typeof manifest.atLocal === 'string' && manifest.atLocal !== ''
|
|
271
|
+
? manifest.atLocal
|
|
272
|
+
: (at === null ? null : backupLocalTime(at)),
|
|
165
273
|
bytes: manifest && Number.isFinite(manifest.bytes) ? manifest.bytes : null,
|
|
274
|
+
files: manifest && Array.isArray(manifest.files) ? manifest.files.length : null,
|
|
166
275
|
includePlugins: Boolean(manifest && manifest.includePlugins === true),
|
|
167
276
|
});
|
|
168
277
|
}
|
|
@@ -202,7 +311,7 @@ export function readManifest(backupDir) {
|
|
|
202
311
|
* maxBackups?: number,
|
|
203
312
|
* now?: number,
|
|
204
313
|
* }} options
|
|
205
|
-
* @returns {{ ok: boolean, dir: string|null, stamp: string|null, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
314
|
+
* @returns {{ ok: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
206
315
|
*/
|
|
207
316
|
export function createBackup({
|
|
208
317
|
profileDir,
|
|
@@ -213,10 +322,14 @@ export function createBackup({
|
|
|
213
322
|
} = {}) {
|
|
214
323
|
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
215
324
|
const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
|
|
325
|
+
const startedAt = Date.now();
|
|
216
326
|
const base = {
|
|
217
327
|
ok: false,
|
|
218
328
|
dir: null,
|
|
219
329
|
stamp: null,
|
|
330
|
+
at: null,
|
|
331
|
+
atLocal: null,
|
|
332
|
+
durationMs: 0,
|
|
220
333
|
bytes: 0,
|
|
221
334
|
files: [],
|
|
222
335
|
includePlugins: includePlugins === true,
|
|
@@ -261,6 +374,9 @@ export function createBackup({
|
|
|
261
374
|
const manifest = {
|
|
262
375
|
stamp,
|
|
263
376
|
at: Number(now),
|
|
377
|
+
// The wall clock of the machine: the directory name above is UTC, and
|
|
378
|
+
// without this line a fresh snapshot looks like an old leftover.
|
|
379
|
+
atLocal: backupLocalTime(Number(now)),
|
|
264
380
|
profileDir: profile,
|
|
265
381
|
includePlugins: includePlugins === true,
|
|
266
382
|
files,
|
|
@@ -273,7 +389,18 @@ export function createBackup({
|
|
|
273
389
|
}
|
|
274
390
|
|
|
275
391
|
const pruned = pruneBackups(root, maxBackups);
|
|
276
|
-
return {
|
|
392
|
+
return {
|
|
393
|
+
...base,
|
|
394
|
+
ok: true,
|
|
395
|
+
dir,
|
|
396
|
+
stamp,
|
|
397
|
+
at: Number(now),
|
|
398
|
+
atLocal: manifest.atLocal,
|
|
399
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
400
|
+
bytes,
|
|
401
|
+
files,
|
|
402
|
+
pruned,
|
|
403
|
+
};
|
|
277
404
|
}
|
|
278
405
|
|
|
279
406
|
/**
|