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/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: manifest && Number.isFinite(manifest.at) ? manifest.at : null,
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 { ...base, ok: true, dir, stamp, bytes, files, pruned };
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
  /**