dsh-composer-plus 1.14.0 → 1.16.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.de.md +69 -16
- package/README.es.md +69 -16
- package/README.fr.md +69 -16
- package/README.hi.md +69 -16
- package/README.id.md +69 -16
- package/README.ja.md +69 -16
- package/README.ko.md +69 -16
- package/README.md +69 -16
- package/README.pt-BR.md +69 -16
- package/README.ru.md +69 -16
- package/README.tr.md +69 -16
- package/README.vi.md +69 -16
- package/README.zh.md +69 -16
- package/assets/features/autoBackup.webp +0 -0
- package/assets/features/backup.webp +0 -0
- package/assets/features/branchTree.webp +0 -0
- package/assets/features/changesPanel.webp +0 -0
- package/assets/features/idleSwitch.webp +0 -0
- package/assets/features/projectMemory.webp +0 -0
- package/assets/features/questionFit.webp +0 -0
- package/assets/features/queueEdit.webp +0 -0
- package/assets/features/rescueConsole.webp +0 -0
- package/assets/features/sessionDoctor.webp +0 -0
- package/assets/features/stopSendFirst.webp +0 -0
- package/backup.mjs +1069 -0
- package/client.js +1278 -182
- package/index.js +1602 -37
- package/package.json +3 -2
- package/tether/gateway/server.mjs +1 -1
package/backup.mjs
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Composer Plus backup and rescue (dsh-composer-plus). Node standard library only, so one file works in two places:
|
|
4
|
+
* - The Host half imports it: back up the dsh home, check an archive, check and repair session logs.
|
|
5
|
+
* - Each backup copies it into the backup folder as `composer-plus-rescue.mjs`, next to a double-click launcher. Run there,
|
|
6
|
+
* it is a terminal menu that works while dsh is closed or cannot start: list and check backups, restore one, check and
|
|
7
|
+
* repair session logs. It opens no port.
|
|
8
|
+
* Archive format (the same as dsh-backup 0.13): `dsh-<stamp>.tar.gz`, a gzip tar with one top folder (the dsh home's name),
|
|
9
|
+
* and next to it `<archive>.sha256`, `.meta.json` and `.redacted.json` (the masked files; their real copies stay on this
|
|
10
|
+
* computer in `composer-plus-vault/<archive>/`). `cache/`, `dsh-runtimes/` and every `node_modules` are not archived: a restore moves them
|
|
11
|
+
* over from the current dsh home.
|
|
12
|
+
*/
|
|
13
|
+
import { constants, createReadStream, createWriteStream, realpathSync } from 'node:fs';
|
|
14
|
+
import * as fsp from 'node:fs/promises';
|
|
15
|
+
import { chmod, copyFile, link, lstat, mkdir, open, readdir, readlink, realpath, rename, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import * as zlib from 'node:zlib';
|
|
18
|
+
import { homedir, hostname } from 'node:os';
|
|
19
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { pipeline } from 'node:stream/promises';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
|
|
26
|
+
/** Open flags: a symlink or a FIFO put in place of a file can neither redirect nor block the open. */
|
|
27
|
+
const READ = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
28
|
+
const CREATE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
|
|
29
|
+
const BLOCK = 512;
|
|
30
|
+
const CHUNK = 1 << 20;
|
|
31
|
+
const MB = 1 << 20;
|
|
32
|
+
/** Session logs bigger than this are not checked (read into memory). */
|
|
33
|
+
const LOG_MAX = 512 * MB;
|
|
34
|
+
/** dsh's session log name: `session[.v<N>].jsonl[.zstd]`. */
|
|
35
|
+
const LOG_RE = /^session(?:\.v([1-9]\d*))?\.jsonl(\.zstd)?$/;
|
|
36
|
+
|
|
37
|
+
/** Archive names in the backup folder: ours and dsh-backup's full backups and snapshots (`dsh-t-` typed parts are not). */
|
|
38
|
+
export const ARCHIVE_RE = /^dsh-(?:(pre-upgrade|pre-restore)-)?(\d{8})-(\d{9})\.tar\.gz$/;
|
|
39
|
+
/** Top-level folders of the dsh home that are not archived (a cache and the app's runtime payload). */
|
|
40
|
+
export const NOT_ARCHIVED = ['cache', 'dsh-runtimes'];
|
|
41
|
+
/** Folder names that are never archived, at any depth (pnpm installs them again). */
|
|
42
|
+
const NEVER = new Set(['node_modules', '.system']);
|
|
43
|
+
const SIDECARS = ['.sha256', '.meta.json', '.redacted.json'];
|
|
44
|
+
export const LOCK_FILE = '.composer-plus.lock';
|
|
45
|
+
export const RESCUE_FILE = 'composer-plus-rescue.mjs';
|
|
46
|
+
export const LAUNCHERS = { darwin: 'Composer Plus Rescue.command', linux: 'composer-plus-rescue.sh', win32: 'Composer Plus Rescue.cmd' };
|
|
47
|
+
/** While dsh runs with Composer Plus, each dsh process keeps `host-<pid>.json` in this folder (two dsh processes, such as
|
|
48
|
+
* the desktop app and `dsh web`, can share one dsh home). The rescue console refuses a restore or repair while one lives. */
|
|
49
|
+
export const HOST_DIR = ['storages', 'dsh-composer-plus', 'running'];
|
|
50
|
+
export const hostFile = (home, pid = process.pid) => join(home, ...HOST_DIR, `host-${pid}.json`);
|
|
51
|
+
export const BY = 'dsh-composer-plus';
|
|
52
|
+
/** Masked files of archive X are in `<folder>/composer-plus-vault/X/`. Not dsh-backup's `vault/`: its backups empty that
|
|
53
|
+
* folder (all but `preserved/`) each time. Its own archives' secrets are read from its `vault/` mirror. */
|
|
54
|
+
export const VAULT = 'composer-plus-vault';
|
|
55
|
+
|
|
56
|
+
/** A refused or damaged archive (the message is for the user). */
|
|
57
|
+
export class ArchiveError extends Error {}
|
|
58
|
+
/** Another backup action holds the backup folder. */
|
|
59
|
+
export class BusyError extends Error {
|
|
60
|
+
constructor(held) { super(held?.op ? `another backup action is running (${held.op}, process ${held.pid})` : 'another backup action is running'); this.held = held; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pad2 = (n, w = 2) => String(n).padStart(w, '0');
|
|
64
|
+
/** Local time `YYYYMMDD-HHMMSSmmm` (dsh-backup's archive stamp). */
|
|
65
|
+
export const stamp = (d = new Date()) => `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}${pad2(d.getMilliseconds(), 3)}`;
|
|
66
|
+
const stampDate = (day, time) => new Date(+day.slice(0, 4), +day.slice(4, 6) - 1, +day.slice(6, 8), +time.slice(0, 2), +time.slice(2, 4), +time.slice(4, 6), +time.slice(6, 9));
|
|
67
|
+
const exists = (p) => lstat(p).then(() => true, () => false);
|
|
68
|
+
const isDir = (p) => lstat(p).then((s) => s.isDirectory(), () => false);
|
|
69
|
+
const yieldNow = () => new Promise((r) => setImmediate(r));
|
|
70
|
+
|
|
71
|
+
/** The bytes of one regular file (at most `max`), or undefined: a missing file, a symlink, a FIFO, a device or a bigger
|
|
72
|
+
* file is never read. */
|
|
73
|
+
export async function readRegular(path, max = MB) {
|
|
74
|
+
let file;
|
|
75
|
+
try {
|
|
76
|
+
file = await open(path, READ);
|
|
77
|
+
const info = await file.stat();
|
|
78
|
+
if (!info.isFile() || info.size > max) return undefined;
|
|
79
|
+
const buffer = Buffer.alloc(info.size);
|
|
80
|
+
let done = 0;
|
|
81
|
+
while (done < info.size) { const { bytesRead } = await file.read(buffer, done, info.size - done, done); if (bytesRead === 0) break; done += bytesRead; }
|
|
82
|
+
return buffer.subarray(0, done);
|
|
83
|
+
} catch { return undefined; } finally { await file?.close().catch(() => {}); }
|
|
84
|
+
}
|
|
85
|
+
const readJson = async (path) => { try { return JSON.parse((await readRegular(path)).toString('utf8')); } catch { return undefined; } };
|
|
86
|
+
|
|
87
|
+
// ---------- Masking ----------
|
|
88
|
+
|
|
89
|
+
const SECRET_WORDS = new Set(['token', 'secret', 'password', 'passwd', 'passphrase', 'credential', 'credentials', 'authorization',
|
|
90
|
+
'apikey', 'accesskey', 'secretkey', 'privatekey', 'clientsecret', 'authkey', 'authtoken']);
|
|
91
|
+
/** A config key that holds a secret: its last word (or last two words) is token, secret, password, apiKey, … `maxTokens`
|
|
92
|
+
* and `apiKeyEnv` (a variable name) are not. */
|
|
93
|
+
export function secretKey(key) {
|
|
94
|
+
const words = String(key).replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
95
|
+
return SECRET_WORDS.has(words.at(-1)) || SECRET_WORDS.has(words.slice(-2).join(''));
|
|
96
|
+
}
|
|
97
|
+
/** Files that stay out of the archive as a whole (their copy goes to the vault). */
|
|
98
|
+
export function wholeSecret(rel) {
|
|
99
|
+
const name = rel.split('/').at(-1);
|
|
100
|
+
return name === '.credentials.yaml' || name === '.env' || name.startsWith('.env.') || /\.(pem|key|p12|pfx)$/i.test(name) || rel === 'qq-bridge/config.json';
|
|
101
|
+
}
|
|
102
|
+
/** YAML files (also `cordis.patch.yml.bak-…`): their secret values are masked. */
|
|
103
|
+
export const yamlFile = (rel) => /\.ya?ml(\.|$)/i.test(rel.split('/').at(-1));
|
|
104
|
+
export const MASK = '"[masked by Composer Plus]"';
|
|
105
|
+
/** Replaces the value of each secret key (a plain or quoted scalar, or a `|`/`>` block) with MASK, also on a commented-out
|
|
106
|
+
* line (`# token: x` is still a secret). Tags (`!!js …`), empty values and nested maps stay. Line-based: a value in a flow
|
|
107
|
+
* map (`{token: x}`) is not found. */
|
|
108
|
+
export function maskYaml(text) {
|
|
109
|
+
const out = [];
|
|
110
|
+
let changed = 0;
|
|
111
|
+
let block = -1;
|
|
112
|
+
for (const line of text.split('\n')) {
|
|
113
|
+
if (block >= 0) {
|
|
114
|
+
if (line.trim() === '' || line.match(/^ */)[0].length > block) continue;
|
|
115
|
+
block = -1;
|
|
116
|
+
}
|
|
117
|
+
const m = /^(\s*(?:#\s*)?(?:-\s+)?)(["']?)([A-Za-z0-9_.-]+)\2(\s*:)\s+(\S.*)$/.exec(line);
|
|
118
|
+
if (m !== null && secretKey(m[3]) && !/^(!|#|&|\*|""|''|~$|null$)/.test(m[5])) {
|
|
119
|
+
if (/^[|>]/.test(m[5])) block = m[1].length;
|
|
120
|
+
out.push(`${m[1]}${m[2]}${m[3]}${m[2]}${m[4]} ${MASK}`);
|
|
121
|
+
changed += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
out.push(line);
|
|
125
|
+
}
|
|
126
|
+
return { text: out.join('\n'), changed };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------- Tar writer ----------
|
|
130
|
+
|
|
131
|
+
function octal(buf, off, len, value) { buf.write(`${Math.max(0, Math.floor(value)).toString(8).padStart(len - 1, '0').slice(-(len - 1))}\0`, off, len, 'latin1'); }
|
|
132
|
+
function headerBlock({ name, type, size = 0, mode = 0o644, mtime = 0, linkname = '' }) {
|
|
133
|
+
const b = Buffer.alloc(BLOCK);
|
|
134
|
+
b.write(name, 0, 100, 'utf8');
|
|
135
|
+
octal(b, 100, 8, mode & 0o7777); octal(b, 108, 8, 0); octal(b, 116, 8, 0); octal(b, 124, 12, size); octal(b, 136, 12, mtime);
|
|
136
|
+
b.fill(0x20, 148, 156);
|
|
137
|
+
b.write(type, 156, 1, 'latin1');
|
|
138
|
+
b.write(linkname, 157, 100, 'utf8');
|
|
139
|
+
b.write('ustar\x0000', 257, 8, 'latin1');
|
|
140
|
+
let sum = 0;
|
|
141
|
+
for (const x of b) sum += x;
|
|
142
|
+
b.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'latin1');
|
|
143
|
+
return b;
|
|
144
|
+
}
|
|
145
|
+
function paxRecord(key, value) {
|
|
146
|
+
const body = ` ${key}=${value}\n`;
|
|
147
|
+
const n = Buffer.byteLength(body);
|
|
148
|
+
let len = n + String(n).length;
|
|
149
|
+
while (n + String(len).length !== len) len = n + String(len).length;
|
|
150
|
+
return `${len}${body}`;
|
|
151
|
+
}
|
|
152
|
+
const padding = (n) => Buffer.alloc((BLOCK - (n % BLOCK)) % BLOCK);
|
|
153
|
+
const plainName = (s) => Buffer.byteLength(s) <= 100 && /^[\x20-\x7e]*$/.test(s);
|
|
154
|
+
const MAX_OCTAL = 0o77777777777;
|
|
155
|
+
/** One tar header (ustar), with a pax header before it for a long or non-ASCII name or link and a size over 8 GB. */
|
|
156
|
+
export function tarHeader(entry) {
|
|
157
|
+
const pax = [];
|
|
158
|
+
if (!plainName(entry.name)) pax.push(paxRecord('path', entry.name));
|
|
159
|
+
if (entry.linkname && !plainName(entry.linkname)) pax.push(paxRecord('linkpath', entry.linkname));
|
|
160
|
+
if ((entry.size ?? 0) > MAX_OCTAL) pax.push(paxRecord('size', String(entry.size)));
|
|
161
|
+
const ascii = (s) => s.replace(/[^\x20-\x7e]/g, '_').slice(-100);
|
|
162
|
+
const main = headerBlock({ ...entry, name: plainName(entry.name) ? entry.name : ascii(entry.name), linkname: entry.linkname && plainName(entry.linkname) ? entry.linkname : '',
|
|
163
|
+
size: (entry.size ?? 0) > MAX_OCTAL ? 0 : entry.size });
|
|
164
|
+
if (pax.length === 0) return main;
|
|
165
|
+
const data = Buffer.from(pax.join(''));
|
|
166
|
+
return Buffer.concat([headerBlock({ name: 'PaxHeader', type: 'x', size: data.length, mtime: entry.mtime }), data, padding(data.length), main]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- Backup ----------
|
|
170
|
+
|
|
171
|
+
/** `rel` paths under `root` (sorted, a folder before its contents), with their lstat. `skip(rel, name)` prunes. */
|
|
172
|
+
async function* walk(root, rel, skip, signal) {
|
|
173
|
+
let names;
|
|
174
|
+
try { names = (await readdir(rel === '' ? root : join(root, rel))).sort(); } catch { return; }
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
const r = rel === '' ? name : `${rel}/${name}`;
|
|
178
|
+
if (skip(r, name)) continue;
|
|
179
|
+
let st;
|
|
180
|
+
try { st = await lstat(join(root, r)); } catch { continue; }
|
|
181
|
+
yield { rel: r, st };
|
|
182
|
+
if (st.isDirectory()) yield* walk(root, r, skip, signal);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Real path of `p` relative to `root` (both real), or undefined when it is not inside. */
|
|
187
|
+
const inside = (root, p) => (p.startsWith(`${root}/`) || p.startsWith(`${root}\\`) ? p.slice(root.length + 1).split('\\').join('/') : undefined);
|
|
188
|
+
|
|
189
|
+
/** Back up the dsh home `home` into `dest` as `dsh-[<kind>-]<stamp>.tar.gz` and its sidecars. With `keep`, then only the
|
|
190
|
+
* newest `keep` archives of that kind that Composer Plus made stay (other archives, such as dsh-backup's, are never
|
|
191
|
+
* removed). `progress` gets `files` and `bytes` as they go. On a failure nothing new stays in `dest`. */
|
|
192
|
+
export async function backup({ home, dest, kind = 'backup', keep, signal, progress = {}, now = new Date() }) {
|
|
193
|
+
const realHome = await realpath(home);
|
|
194
|
+
await mkdir(dest, { recursive: true, mode: 0o700 });
|
|
195
|
+
const realDest = await realpath(dest);
|
|
196
|
+
const name = `dsh-${kind === 'backup' ? '' : `${kind}-`}${stamp(now)}.tar.gz`;
|
|
197
|
+
const file = join(realDest, name);
|
|
198
|
+
const vaultDir = join(realDest, VAULT, name);
|
|
199
|
+
const top = basename(realHome);
|
|
200
|
+
const destRel = inside(realHome, realDest);
|
|
201
|
+
const masked = [];
|
|
202
|
+
const skipped = [];
|
|
203
|
+
const changed = [];
|
|
204
|
+
const seen = new Map();
|
|
205
|
+
progress.files = 0; progress.bytes = 0;
|
|
206
|
+
const vaultPut = async (rel, data) => {
|
|
207
|
+
await mkdir(join(realDest, VAULT), { recursive: true, mode: 0o700 });
|
|
208
|
+
const target = join(vaultDir, ...rel.split('/'));
|
|
209
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
210
|
+
await writeFile(target, data, { flag: 'wx', mode: 0o600 });
|
|
211
|
+
masked.push(rel);
|
|
212
|
+
};
|
|
213
|
+
// A backup folder inside the dsh home is not archived into itself; the running dsh's marker is not restored.
|
|
214
|
+
const hostRel = HOST_DIR.join('/');
|
|
215
|
+
const skip = (rel, n) => NEVER.has(n) || (!rel.includes('/') && NOT_ARCHIVED.includes(n)) || rel === destRel || rel === hostRel;
|
|
216
|
+
// ponytail: a hard link to a masked file under a name that is not masked goes into the archive as it is (the walk does
|
|
217
|
+
// not know the other names of an inode). dsh makes no such links; its attachment store links only attachments.
|
|
218
|
+
async function* fileEntry(rel, entryName) {
|
|
219
|
+
let fh;
|
|
220
|
+
try { fh = await open(join(realHome, rel), READ); } catch { skipped.push(rel); return; }
|
|
221
|
+
try {
|
|
222
|
+
const info = await fh.stat();
|
|
223
|
+
if (!info.isFile()) { skipped.push(rel); return; }
|
|
224
|
+
const key = `${info.dev}:${info.ino}`;
|
|
225
|
+
if (info.nlink > 1 && seen.has(key)) { yield tarHeader({ name: entryName, type: '1', mode: info.mode, mtime: info.mtimeMs / 1000, linkname: seen.get(key) }); return; }
|
|
226
|
+
yield tarHeader({ name: entryName, type: '0', size: info.size, mode: info.mode, mtime: info.mtimeMs / 1000 });
|
|
227
|
+
let done = 0;
|
|
228
|
+
while (done < info.size) {
|
|
229
|
+
const want = Math.min(CHUNK, info.size - done);
|
|
230
|
+
const buf = Buffer.allocUnsafe(want);
|
|
231
|
+
const { bytesRead } = await fh.read(buf, 0, want, done);
|
|
232
|
+
// A file that shrinks while it is read keeps its size at open: the rest is zeros (the tar entry needs that size;
|
|
233
|
+
// session logs are read whole before, so this is never one of them).
|
|
234
|
+
if (bytesRead === 0) { changed.push(rel); yield Buffer.alloc(info.size - done); break; }
|
|
235
|
+
done += bytesRead; progress.bytes += bytesRead;
|
|
236
|
+
yield bytesRead === want ? buf : buf.subarray(0, bytesRead);
|
|
237
|
+
}
|
|
238
|
+
yield padding(info.size);
|
|
239
|
+
progress.files += 1;
|
|
240
|
+
if (info.nlink > 1) seen.set(key, entryName);
|
|
241
|
+
} finally { await fh.close().catch(() => {}); }
|
|
242
|
+
}
|
|
243
|
+
async function* entries() {
|
|
244
|
+
const rootInfo = await lstat(realHome);
|
|
245
|
+
yield tarHeader({ name: `${top}/`, type: '5', mode: rootInfo.mode, mtime: rootInfo.mtimeMs / 1000 });
|
|
246
|
+
for await (const { rel, st } of walk(realHome, '', skip, signal)) {
|
|
247
|
+
const entryName = `${top}/${rel}`;
|
|
248
|
+
const mtime = st.mtimeMs / 1000;
|
|
249
|
+
if (st.isDirectory()) { yield tarHeader({ name: `${entryName}/`, type: '5', mode: st.mode, mtime }); continue; }
|
|
250
|
+
if (st.isSymbolicLink()) {
|
|
251
|
+
let target;
|
|
252
|
+
try { target = await readlink(join(realHome, rel)); } catch { skipped.push(rel); continue; }
|
|
253
|
+
yield tarHeader({ name: entryName, type: '2', mode: st.mode, mtime, linkname: target });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!st.isFile()) { skipped.push(rel); continue; }
|
|
257
|
+
if (wholeSecret(rel)) {
|
|
258
|
+
const data = await readRegular(join(realHome, rel), 16 * MB);
|
|
259
|
+
if (data === undefined) skipped.push(rel); else await vaultPut(rel, data);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// A session log is read whole first: dsh can cut its torn tail while the backup runs, and a zstd log must not get
|
|
263
|
+
// a zero-filled end (dsh cannot read that). Its header then has the size that was read.
|
|
264
|
+
if (LOG_RE.test(rel.split('/').at(-1)) && st.size <= LOG_MAX) {
|
|
265
|
+
const data = await readRegular(join(realHome, rel), LOG_MAX);
|
|
266
|
+
if (data === undefined) { skipped.push(rel); continue; }
|
|
267
|
+
yield tarHeader({ name: entryName, type: '0', size: data.length, mode: st.mode, mtime });
|
|
268
|
+
yield data; yield padding(data.length);
|
|
269
|
+
progress.files += 1; progress.bytes += data.length;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (yamlFile(rel)) {
|
|
273
|
+
const data = await readRegular(join(realHome, rel), 16 * MB);
|
|
274
|
+
if (data === undefined) { skipped.push(rel); continue; }
|
|
275
|
+
const { text, changed: n } = maskYaml(data.toString('utf8'));
|
|
276
|
+
const body = n > 0 ? Buffer.from(text) : data;
|
|
277
|
+
if (n > 0) await vaultPut(rel, data);
|
|
278
|
+
yield tarHeader({ name: entryName, type: '0', size: body.length, mode: st.mode, mtime });
|
|
279
|
+
yield body; yield padding(body.length);
|
|
280
|
+
progress.files += 1; progress.bytes += body.length;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
yield* fileEntry(rel, entryName);
|
|
284
|
+
}
|
|
285
|
+
yield Buffer.alloc(BLOCK * 2);
|
|
286
|
+
}
|
|
287
|
+
const hash = createHash('sha256');
|
|
288
|
+
const tap = async function* (source) { for await (const chunk of source) { hash.update(chunk); yield chunk; } };
|
|
289
|
+
let sha;
|
|
290
|
+
try {
|
|
291
|
+
await pipeline(entries, zlib.createGzip({ level: 6 }), tap, createWriteStream(file, { flags: 'wx', mode: 0o600 }), { signal });
|
|
292
|
+
sha = hash.digest('hex');
|
|
293
|
+
await writeFile(`${file}.sha256`, `${sha} ${file}\n`, { mode: 0o600 });
|
|
294
|
+
await writeFile(`${file}.meta.json`, `${JSON.stringify({ host: hostname(), home: homedir(), dshHome: realHome, createdAt: now.toISOString(), redacted: masked.length,
|
|
295
|
+
by: BY, files: progress.files, bytes: progress.bytes, skipped: skipped.length })}\n`, { mode: 0o600 });
|
|
296
|
+
await writeFile(`${file}.redacted.json`, `${JSON.stringify({ files: masked })}\n`, { mode: 0o600 });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await removeArchive(realDest, name).catch(() => {});
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
const size = (await stat(file)).size;
|
|
302
|
+
// The new archive is complete: a failure to remove older ones keeps it.
|
|
303
|
+
const rotated = keep === undefined ? [] : await rotate(realDest, kind, keep).catch(() => []);
|
|
304
|
+
return { name, file, sha, size, files: progress.files, bytes: progress.bytes, masked, skipped, changed, rotated };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Removes one archive, its sidecars and its vault copies. `name` must be an archive name (never a path). */
|
|
308
|
+
export async function removeArchive(dest, name) {
|
|
309
|
+
if (!ARCHIVE_RE.test(name)) throw new ArchiveError('not a backup archive name');
|
|
310
|
+
for (const suffix of ['', ...SIDECARS]) await rm(join(dest, `${name}${suffix}`), { force: true });
|
|
311
|
+
await rm(join(dest, VAULT, name), { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
/** Keeps the newest `keep` Composer Plus archives of one kind; returns the removed names. */
|
|
314
|
+
async function rotate(dest, kind, keep) {
|
|
315
|
+
const old = (await listArchives(dest)).filter((a) => a.kind === kind && a.meta?.by === BY).slice(Math.max(1, keep));
|
|
316
|
+
for (const a of old) await removeArchive(dest, a.name);
|
|
317
|
+
return old.map((a) => a.name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The archives in `dest`, newest first: `{name, kind, at, size, checksum, meta, masked}` (`masked` null when the archive
|
|
321
|
+
* has no `.redacted.json`). */
|
|
322
|
+
export async function listArchives(dest) {
|
|
323
|
+
let names;
|
|
324
|
+
try { names = await readdir(dest); } catch { return []; }
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const m = ARCHIVE_RE.exec(name);
|
|
328
|
+
if (m === null) continue;
|
|
329
|
+
let info;
|
|
330
|
+
try { info = await lstat(join(dest, name)); } catch { continue; }
|
|
331
|
+
if (!info.isFile()) continue;
|
|
332
|
+
const meta = await readJson(join(dest, `${name}.meta.json`));
|
|
333
|
+
const files = (await readJson(join(dest, `${name}.redacted.json`)))?.files;
|
|
334
|
+
out.push({ name, kind: m[1] ?? 'backup', at: stampDate(m[2], m[3]).toISOString(), key: `${m[2]}${m[3]}`, size: info.size, checksum: await exists(join(dest, `${name}.sha256`)),
|
|
335
|
+
meta: meta && typeof meta === 'object' ? { host: str(meta.host), home: str(meta.home), dshHome: str(meta.dshHome), by: str(meta.by), files: num(meta.files), bytes: num(meta.bytes) } : null,
|
|
336
|
+
masked: Array.isArray(files) ? files.filter((f) => typeof f === 'string') : null });
|
|
337
|
+
}
|
|
338
|
+
return out.sort((a, b) => (a.key < b.key ? 1 : a.key > b.key ? -1 : 0)).map(({ key, ...a }) => a);
|
|
339
|
+
}
|
|
340
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
341
|
+
const num = (v) => (Number.isSafeInteger(v) ? v : undefined);
|
|
342
|
+
|
|
343
|
+
// ---------- Tar reader ----------
|
|
344
|
+
|
|
345
|
+
const cstr = (b, off, len) => { const s = b.subarray(off, off + len); const end = s.indexOf(0); return (end < 0 ? s : s.subarray(0, end)).toString('utf8'); };
|
|
346
|
+
function parseOctal(b, off, len) {
|
|
347
|
+
if (b[off] & 0x80) { let n = 0; for (let i = off + 1; i < off + len; i++) n = n * 256 + b[i]; return n; }
|
|
348
|
+
const text = cstr(b, off, len).trim();
|
|
349
|
+
return text === '' ? 0 : parseInt(text, 8);
|
|
350
|
+
}
|
|
351
|
+
function parseHeader(h, next) {
|
|
352
|
+
let sum = 0;
|
|
353
|
+
for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 0x20 : h[i];
|
|
354
|
+
if (parseOctal(h, 148, 8) !== sum) throw new ArchiveError('the archive is damaged (a header checksum does not match)');
|
|
355
|
+
let name = cstr(h, 0, 100);
|
|
356
|
+
if (h.toString('latin1', 257, 263) === 'ustar\0') { const prefix = cstr(h, 345, 155); if (prefix !== '') name = `${prefix}/${name}`; }
|
|
357
|
+
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156]);
|
|
358
|
+
return { name: next.path ?? name, type, size: next.size ?? parseOctal(h, 124, 12), mode: parseOctal(h, 100, 8), mtime: parseOctal(h, 136, 12), linkname: next.linkpath ?? cstr(h, 157, 100) };
|
|
359
|
+
}
|
|
360
|
+
function applyMeta(type, data, next) {
|
|
361
|
+
if (type === 'L') { next.path = cstr(data, 0, data.length); return; }
|
|
362
|
+
if (type === 'K') { next.linkpath = cstr(data, 0, data.length); return; }
|
|
363
|
+
if (type !== 'x') return; // 'g': global pax records are not used
|
|
364
|
+
let off = 0;
|
|
365
|
+
while (off < data.length) {
|
|
366
|
+
const space = data.indexOf(0x20, off);
|
|
367
|
+
if (space < 0) break;
|
|
368
|
+
const len = Number(data.toString('latin1', off, space));
|
|
369
|
+
if (!Number.isSafeInteger(len) || len <= 0 || off + len > data.length) throw new ArchiveError('the archive is damaged (a pax record)');
|
|
370
|
+
const record = data.toString('utf8', space + 1, off + len - 1);
|
|
371
|
+
const eq = record.indexOf('=');
|
|
372
|
+
const key = record.slice(0, eq); const value = record.slice(eq + 1);
|
|
373
|
+
if (key === 'path') next.path = value;
|
|
374
|
+
else if (key === 'linkpath') next.linkpath = value;
|
|
375
|
+
else if (key === 'size') next.size = Number(value);
|
|
376
|
+
off += len;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Reads a `.tar.gz` once: `onEntry(entry)` for each entry (`{name, type, size, mode, mtime, linkname}`) may return a sink
|
|
381
|
+
* `{write(chunk), end()}` for a file's data. Answers `{sha256}` of the compressed bytes. `progress.read` counts them. */
|
|
382
|
+
export async function readArchive(file, { signal, onEntry = () => undefined, progress = {} } = {}) {
|
|
383
|
+
const hash = createHash('sha256');
|
|
384
|
+
progress.read = 0;
|
|
385
|
+
const tap = async function* (source) { for await (const chunk of source) { hash.update(chunk); progress.read += chunk.length; yield chunk; } };
|
|
386
|
+
let ended = false;
|
|
387
|
+
let any = false;
|
|
388
|
+
const parse = async (source) => {
|
|
389
|
+
let buf = Buffer.alloc(0);
|
|
390
|
+
let next = {};
|
|
391
|
+
let cur = null;
|
|
392
|
+
let zeros = 0;
|
|
393
|
+
for await (const chunk of source) {
|
|
394
|
+
if (ended) continue;
|
|
395
|
+
buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
|
|
396
|
+
for (;;) {
|
|
397
|
+
if (cur !== null) {
|
|
398
|
+
if (cur.left > 0) {
|
|
399
|
+
if (buf.length === 0) break;
|
|
400
|
+
const take = Math.min(cur.left, buf.length);
|
|
401
|
+
const part = buf.subarray(0, take);
|
|
402
|
+
buf = buf.subarray(take);
|
|
403
|
+
cur.left -= take;
|
|
404
|
+
if (cur.collect !== undefined) cur.collect.push(Buffer.from(part));
|
|
405
|
+
else if (cur.sink !== undefined) await cur.sink.write(part);
|
|
406
|
+
if (cur.left > 0) break;
|
|
407
|
+
}
|
|
408
|
+
if (buf.length < cur.pad) break;
|
|
409
|
+
buf = buf.subarray(cur.pad);
|
|
410
|
+
const done = cur;
|
|
411
|
+
cur = null;
|
|
412
|
+
if (done.meta !== undefined) applyMeta(done.meta, Buffer.concat(done.collect), next);
|
|
413
|
+
else await done.sink?.end?.();
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (buf.length < BLOCK) break;
|
|
417
|
+
const h = buf.subarray(0, BLOCK);
|
|
418
|
+
buf = buf.subarray(BLOCK);
|
|
419
|
+
if (h.every((x) => x === 0)) { zeros += 1; if (zeros === 2) { ended = true; break; } continue; }
|
|
420
|
+
zeros = 0;
|
|
421
|
+
const entry = parseHeader(h, next);
|
|
422
|
+
next = {};
|
|
423
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0) throw new ArchiveError('the archive is damaged (an entry size)');
|
|
424
|
+
const pad = (BLOCK - (entry.size % BLOCK)) % BLOCK;
|
|
425
|
+
if ('xgLK'.includes(entry.type)) {
|
|
426
|
+
if (entry.size > MB) throw new ArchiveError('the archive is damaged (an extended header is too large)');
|
|
427
|
+
cur = { left: entry.size, pad, meta: entry.type, collect: [] };
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
any = true;
|
|
431
|
+
const sink = await onEntry(entry);
|
|
432
|
+
cur = { left: entry.size, pad, sink: entry.type === '0' || entry.type === '7' ? sink : undefined };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (cur !== null || (!ended && !any)) throw new ArchiveError('the archive is cut off or is not a tar archive');
|
|
436
|
+
};
|
|
437
|
+
try {
|
|
438
|
+
await pipeline(createReadStream(file), tap, zlib.createGunzip(), parse, { signal });
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (error instanceof ArchiveError || error?.name === 'AbortError') throw error;
|
|
441
|
+
if (error?.code?.startsWith?.('Z_') || /unexpected end|incorrect header|invalid/i.test(String(error?.message))) throw new ArchiveError(`the archive is damaged (${error.message})`);
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
return { sha256: hash.digest('hex') };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The parts of a safe entry name: no absolute path, drive, `.` or `..` part, empty part or NUL. */
|
|
448
|
+
export function entryParts(name) {
|
|
449
|
+
const clean = String(name).replace(/\/+$/, '');
|
|
450
|
+
if (clean === '' || clean.startsWith('/') || /^[A-Za-z]:/.test(clean) || clean.includes('\0')) throw new ArchiveError(`unsafe entry in the archive: ${JSON.stringify(name)}`);
|
|
451
|
+
const parts = clean.split('/');
|
|
452
|
+
if (parts.some((p) => p === '' || p === '.' || p === '..' || (process.platform === 'win32' && /[\\:]/.test(p)))) throw new ArchiveError(`unsafe entry in the archive: ${JSON.stringify(name)}`);
|
|
453
|
+
return parts;
|
|
454
|
+
}
|
|
455
|
+
const KINDS = { '0': 'file', '7': 'file', '5': 'dir', '2': 'symlink', '1': 'hardlink' };
|
|
456
|
+
/** macOS tar (bsdtar) stores extended attributes as a `._<name>` file in AppleDouble format next to the entry. */
|
|
457
|
+
const APPLE_DOUBLE = 0x00051607;
|
|
458
|
+
const mayBeAppleDouble = (e) => (e.type === '0' || e.type === '7') && e.size <= MB && /(^|\/)\._[^/]+$/.test(e.name.replace(/\/+$/, ''));
|
|
459
|
+
/** Runs `accept(e)` now, or for a possible AppleDouble entry after its first bytes: those are skipped (`onSkip`). */
|
|
460
|
+
function unlessAppleDouble(e, accept, onSkip = () => {}) {
|
|
461
|
+
if (!mayBeAppleDouble(e)) return accept(e);
|
|
462
|
+
const head = [];
|
|
463
|
+
let sink;
|
|
464
|
+
let decided = false;
|
|
465
|
+
const decide = async () => {
|
|
466
|
+
if (decided) return;
|
|
467
|
+
decided = true;
|
|
468
|
+
const first = Buffer.concat(head);
|
|
469
|
+
if (first.length >= 4 && first.readUInt32BE(0) === APPLE_DOUBLE) { onSkip(e); return; }
|
|
470
|
+
sink = await accept(e);
|
|
471
|
+
if (first.length > 0) await sink?.write(first);
|
|
472
|
+
};
|
|
473
|
+
return {
|
|
474
|
+
write: async (chunk) => {
|
|
475
|
+
if (!decided) { head.push(Buffer.from(chunk)); if (Buffer.concat(head).length < 4) return; await decide(); return; }
|
|
476
|
+
await sink?.write(chunk);
|
|
477
|
+
},
|
|
478
|
+
end: async () => { await decide(); await sink?.end?.(); },
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Reads the whole archive and refuses what a restore must not write: unsafe names, more than one top folder, devices and
|
|
483
|
+
* FIFOs, a duplicate, a hard link to a file that is not before it, an entry below a symbolic link. macOS metadata entries
|
|
484
|
+
* (AppleDouble `._` files of dsh-backup archives) are skipped. */
|
|
485
|
+
export async function checkArchive(file, { signal, progress } = {}) {
|
|
486
|
+
const kinds = new Map();
|
|
487
|
+
const symlinks = [];
|
|
488
|
+
let top;
|
|
489
|
+
let bytes = 0;
|
|
490
|
+
const { sha256 } = await readArchive(file, { signal, progress, onEntry: (entry) => unlessAppleDouble(entry, (e) => {
|
|
491
|
+
const kind = KINDS[e.type];
|
|
492
|
+
if (kind === undefined) throw new ArchiveError(`the archive has an entry of a type that a restore does not write: ${JSON.stringify(e.name)}`);
|
|
493
|
+
const parts = entryParts(e.name);
|
|
494
|
+
if (top === undefined) top = parts[0];
|
|
495
|
+
if (parts[0] !== top) throw new ArchiveError('the archive has more than one top folder');
|
|
496
|
+
const name = parts.join('/');
|
|
497
|
+
const before = kinds.get(name);
|
|
498
|
+
if (before !== undefined && !(before === 'dir' && kind === 'dir')) throw new ArchiveError(`the archive has two entries for ${name}`);
|
|
499
|
+
if (kind === 'hardlink') {
|
|
500
|
+
const target = entryParts(e.linkname).join('/');
|
|
501
|
+
if (kinds.get(target) !== 'file') throw new ArchiveError(`the archive has a hard link to a file that is not in it: ${name}`);
|
|
502
|
+
}
|
|
503
|
+
if (kind === 'symlink') symlinks.push(name);
|
|
504
|
+
if (kind === 'file') bytes += e.size;
|
|
505
|
+
kinds.set(name, kind);
|
|
506
|
+
return undefined;
|
|
507
|
+
}) });
|
|
508
|
+
if (top === undefined) throw new ArchiveError('the archive is empty');
|
|
509
|
+
const links = new Set(symlinks);
|
|
510
|
+
for (const name of kinds.keys()) {
|
|
511
|
+
const parts = name.split('/');
|
|
512
|
+
for (let i = 1; i < parts.length; i++) if (links.has(parts.slice(0, i).join('/'))) throw new ArchiveError(`the archive has an entry below a symbolic link: ${name}`);
|
|
513
|
+
}
|
|
514
|
+
let files = 0, dirs = 0, hardlinks = 0;
|
|
515
|
+
for (const kind of kinds.values()) { if (kind === 'file') files += 1; else if (kind === 'dir') dirs += 1; else if (kind === 'hardlink') hardlinks += 1; }
|
|
516
|
+
return { sha256, top, entries: kinds.size, files, dirs, symlinks: symlinks.length, hardlinks, bytes };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The vault copy of one masked file: `composer-plus-vault/<archive>/<rel>`, or for a dsh-backup archive its one shared
|
|
520
|
+
* mirror `vault/<rel>`. */
|
|
521
|
+
async function vaultCopy(dest, archive, rel, by) {
|
|
522
|
+
const parts = entryParts(`x/${rel}`).slice(1);
|
|
523
|
+
const own = await readRegular(join(dest, VAULT, archive.name, ...parts), 16 * MB);
|
|
524
|
+
if (own !== undefined || by === BY) return own;
|
|
525
|
+
return readRegular(join(dest, 'vault', ...parts), 16 * MB);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Checks one archive of `dest` (its checksum file and a full read, see checkArchive) and tells what a restore would do.
|
|
529
|
+
* Never throws for a damaged archive: `{ok: false, reason}`. */
|
|
530
|
+
export async function verifyArchive(dest, name, { home, signal, progress } = {}) {
|
|
531
|
+
const archive = (await listArchives(dest)).find((a) => a.name === name);
|
|
532
|
+
if (archive === undefined) return { ok: false, name, reason: 'this backup is not in the folder' };
|
|
533
|
+
const expected = (await readRegular(join(dest, `${name}.sha256`)))?.toString('utf8').trim().split(/\s+/)[0];
|
|
534
|
+
let info;
|
|
535
|
+
try { info = await checkArchive(join(dest, name), { signal, progress }); } catch (error) {
|
|
536
|
+
if (error?.name === 'AbortError') throw error;
|
|
537
|
+
return { ok: false, name, reason: String(error?.message ?? error) };
|
|
538
|
+
}
|
|
539
|
+
if (expected !== undefined && /^[0-9a-f]{64}$/.test(expected) && expected !== info.sha256) return { ok: false, name, reason: 'the checksum does not match (the archive or its .sha256 file changed)' };
|
|
540
|
+
const masked = [];
|
|
541
|
+
for (const rel of archive.masked ?? []) {
|
|
542
|
+
let vault = false;
|
|
543
|
+
try { vault = (await vaultCopy(dest, archive, rel, archive.meta?.by)) !== undefined; } catch { vault = false; }
|
|
544
|
+
masked.push({ path: rel, vault });
|
|
545
|
+
}
|
|
546
|
+
const otherHome = archive.meta?.dshHome !== undefined && home !== undefined && resolve(archive.meta.dshHome) !== resolve(home);
|
|
547
|
+
return { ok: true, name, checksum: expected !== undefined, ...info, masked, from: { host: archive.meta?.host, dshHome: archive.meta?.dshHome }, otherHome };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---------- Restore ----------
|
|
551
|
+
|
|
552
|
+
async function writeAll(fh, chunk) {
|
|
553
|
+
let off = 0;
|
|
554
|
+
while (off < chunk.length) { const { bytesWritten } = await fh.write(chunk, off, chunk.length - off); off += bytesWritten; }
|
|
555
|
+
}
|
|
556
|
+
/** Writes a checked archive into the empty folder `root` (its top folder becomes `root`): files with O_EXCL | O_NOFOLLOW,
|
|
557
|
+
* hard links to files written before, symbolic links last (their targets are never followed), folder modes at the end. */
|
|
558
|
+
async function extract(file, root, { signal, progress }) {
|
|
559
|
+
const dirs = [];
|
|
560
|
+
const links = [];
|
|
561
|
+
const unlinked = [];
|
|
562
|
+
let openFile;
|
|
563
|
+
try {
|
|
564
|
+
await readArchive(file, { signal, progress, onEntry: (entry) => unlessAppleDouble(entry, async (e) => {
|
|
565
|
+
// checkArchive read this file before; a file swapped in after it still writes nothing but these kinds.
|
|
566
|
+
if (KINDS[e.type] === undefined) throw new ArchiveError(`the archive has an entry of a type that a restore does not write: ${JSON.stringify(e.name)}`);
|
|
567
|
+
const rest = entryParts(e.name).slice(1);
|
|
568
|
+
if (rest.length === 0) return undefined;
|
|
569
|
+
const path = join(root, ...rest);
|
|
570
|
+
if (e.type === '5') { await mkdir(path, { recursive: true, mode: 0o700 }); dirs.push([path, e.mode, e.mtime]); return undefined; }
|
|
571
|
+
if (e.type === '2') { links.push([path, e.linkname]); return undefined; }
|
|
572
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
573
|
+
if (e.type === '1') { await link(join(root, ...entryParts(e.linkname).slice(1)), path); return undefined; }
|
|
574
|
+
const fh = await open(path, CREATE, (e.mode & 0o777) | 0o600);
|
|
575
|
+
openFile = fh;
|
|
576
|
+
return { write: (chunk) => writeAll(fh, chunk), end: async () => { await fh.utimes(e.mtime, e.mtime).catch(() => {}); await fh.close(); openFile = undefined; } };
|
|
577
|
+
}) });
|
|
578
|
+
} finally { await openFile?.close().catch(() => {}); }
|
|
579
|
+
for (const [path, target] of links) {
|
|
580
|
+
try { await symlink(target, path); } catch { unlinked.push(path.slice(root.length + 1)); }
|
|
581
|
+
}
|
|
582
|
+
for (const [path, mode, mtime] of dirs.reverse()) { await chmod(path, (mode & 0o777) | 0o700).catch(() => {}); await utimes(path, mtime, mtime).catch(() => {}); }
|
|
583
|
+
return { unlinked };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** `root/<parts>` when every folder on the way is a real folder (missing ones are made), else undefined. */
|
|
587
|
+
async function plainTarget(root, parts, make = true) {
|
|
588
|
+
let dir = root;
|
|
589
|
+
for (const part of parts.slice(0, -1)) {
|
|
590
|
+
dir = join(dir, part);
|
|
591
|
+
const info = await lstat(dir).catch(() => undefined);
|
|
592
|
+
if (info === undefined && make) await mkdir(dir, { mode: 0o700 });
|
|
593
|
+
else if (info === undefined || !info.isDirectory()) return undefined;
|
|
594
|
+
}
|
|
595
|
+
const info = await lstat(join(dir, parts.at(-1))).catch(() => undefined);
|
|
596
|
+
return info === undefined || info.isFile() || info.isSymbolicLink() ? join(dir, parts.at(-1)) : undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** What a restore moves over from the current dsh home: the folders that are never archived. */
|
|
600
|
+
async function carryList(home, signal) {
|
|
601
|
+
const out = [];
|
|
602
|
+
for await (const { rel, st } of walk(home, '', (rel, name) => {
|
|
603
|
+
if (NEVER.has(name) || (!rel.includes('/') && NOT_ARCHIVED.includes(name))) { out.push(rel); return true; }
|
|
604
|
+
return rel.split('/').length > 8;
|
|
605
|
+
}, signal)) void st;
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Profiles whose plugin packages must be installed again: no `node_modules`, or a `pnpm-lock.yaml` that is not the
|
|
610
|
+
* one that the current home had. */
|
|
611
|
+
async function profilesToInstall(home, before) {
|
|
612
|
+
const out = [];
|
|
613
|
+
let names;
|
|
614
|
+
try { names = await readdir(join(home, 'profiles')); } catch { return out; }
|
|
615
|
+
for (const name of names.sort()) {
|
|
616
|
+
const dir = join(home, 'profiles', name);
|
|
617
|
+
if (!(await isDir(dir)) || (await readRegular(join(dir, 'package.json'))) === undefined) continue;
|
|
618
|
+
const lock = await readRegular(join(dir, 'pnpm-lock.yaml'), 64 * MB);
|
|
619
|
+
const old = before === undefined ? undefined : await readRegular(join(before, 'profiles', name, 'pnpm-lock.yaml'), 64 * MB);
|
|
620
|
+
if (!(await isDir(join(dir, 'node_modules'))) || (lock !== undefined && (old === undefined || !lock.equals(old)))) out.push(name);
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Restores archive `name` of `dest` as the dsh home `home`: check, write it into a new folder next to the home, put the
|
|
626
|
+
* vault copies of the masked files in, then swap the folders. The old home stays as `<home>.pre-restore-<stamp>`; the
|
|
627
|
+
* folders that are not archived (`cache/`, `dsh-runtimes/`, `node_modules`) move over from it. dsh must be closed. */
|
|
628
|
+
export async function restore({ home, dest, name, signal, progress = {} }) {
|
|
629
|
+
const check = await verifyArchive(dest, name, { home, signal, progress });
|
|
630
|
+
if (!check.ok) throw new ArchiveError(check.reason);
|
|
631
|
+
const archive = (await listArchives(dest)).find((a) => a.name === name);
|
|
632
|
+
const target = resolve(home);
|
|
633
|
+
const real = await realpath(target).catch(() => undefined);
|
|
634
|
+
const base = real ?? target;
|
|
635
|
+
const parent = dirname(base);
|
|
636
|
+
await mkdir(parent, { recursive: true });
|
|
637
|
+
// statfs: Node 18.15 and later (the console can run on an older Node; then the space is not checked).
|
|
638
|
+
const space = typeof fsp.statfs === 'function' ? await fsp.statfs(parent).catch(() => undefined) : undefined;
|
|
639
|
+
if (space !== undefined && space.bavail * space.bsize < check.bytes + 256 * MB) {
|
|
640
|
+
throw new ArchiveError(`not enough free disk space: the restore needs about ${Math.ceil((check.bytes + 256 * MB) / MB)} MB`);
|
|
641
|
+
}
|
|
642
|
+
const staging = join(parent, `.${basename(base)}.restore-${stamp()}`);
|
|
643
|
+
await mkdir(staging, { mode: 0o700 });
|
|
644
|
+
const vaultMissing = [];
|
|
645
|
+
let unlinked;
|
|
646
|
+
try {
|
|
647
|
+
({ unlinked } = await extract(join(dest, name), staging, { signal, progress }));
|
|
648
|
+
for (const rel of archive.masked ?? []) {
|
|
649
|
+
const data = await vaultCopy(dest, archive, rel, archive.meta?.by).catch(() => undefined);
|
|
650
|
+
if (data === undefined) { vaultMissing.push(rel); continue; }
|
|
651
|
+
// The archive made the folders and links of `staging`: a masked file is written only below real folders.
|
|
652
|
+
const path = await plainTarget(staging, entryParts(`x/${rel}`).slice(1));
|
|
653
|
+
if (path === undefined) { vaultMissing.push(rel); continue; }
|
|
654
|
+
await rm(path, { force: true });
|
|
655
|
+
const fh = await open(path, CREATE, 0o600);
|
|
656
|
+
try { await writeAll(fh, data); } finally { await fh.close(); }
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
await rm(staging, { recursive: true, force: true });
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
const carry = real === undefined ? [] : await carryList(real, signal);
|
|
663
|
+
let aside;
|
|
664
|
+
if (real !== undefined) {
|
|
665
|
+
aside = `${base}.pre-restore-${stamp()}`;
|
|
666
|
+
await rename(base, aside);
|
|
667
|
+
try { await rename(staging, base); } catch (error) {
|
|
668
|
+
await rename(aside, base);
|
|
669
|
+
await rm(staging, { recursive: true, force: true });
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
} else await rename(staging, base);
|
|
673
|
+
const moved = [];
|
|
674
|
+
const left = [];
|
|
675
|
+
for (const rel of carry) {
|
|
676
|
+
// Only below real folders of the restored home: a folder that the archive has as a symbolic link is not entered.
|
|
677
|
+
const to = await plainTarget(base, rel.split('/'), false);
|
|
678
|
+
if (to === undefined || (await exists(to))) continue;
|
|
679
|
+
try { await rename(join(aside, ...rel.split('/')), to); moved.push(rel); } catch { left.push(rel); }
|
|
680
|
+
}
|
|
681
|
+
return { name, home: base, aside, entries: check.entries, moved, left, vaultMissing, unlinked, otherHome: check.otherHome, from: check.from,
|
|
682
|
+
install: await profilesToInstall(base, aside) };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------- One action at a time ----------
|
|
686
|
+
|
|
687
|
+
const alive = (pid) => { if (!Number.isSafeInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } };
|
|
688
|
+
/** Takes the backup folder's lock for `op` (one backup action at a time, for the Host and the rescue console). A lock of
|
|
689
|
+
* a process that is gone is taken over. Answers the release function; throws BusyError. */
|
|
690
|
+
export async function takeLock(dest, op) {
|
|
691
|
+
await mkdir(dest, { recursive: true, mode: 0o700 });
|
|
692
|
+
const file = join(dest, LOCK_FILE);
|
|
693
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
694
|
+
try {
|
|
695
|
+
await writeFile(file, JSON.stringify({ pid: process.pid, op, at: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
696
|
+
return async () => { if ((await readJson(file))?.pid === process.pid) await rm(file, { force: true }); };
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
699
|
+
const held = await readJson(file);
|
|
700
|
+
const fresh = held === undefined && Date.now() - ((await stat(file).catch(() => undefined))?.mtimeMs ?? 0) < 10000;
|
|
701
|
+
if (fresh || (held !== undefined && alive(held.pid))) throw new BusyError(held);
|
|
702
|
+
await rm(file, { force: true });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
throw new BusyError();
|
|
706
|
+
}
|
|
707
|
+
/** The pid of a running dsh (other than this process) that loaded Composer Plus for this home, or undefined. A marker of
|
|
708
|
+
* a process that is gone is ignored. */
|
|
709
|
+
export async function runningDsh(home) {
|
|
710
|
+
let names;
|
|
711
|
+
try { names = await readdir(join(home, ...HOST_DIR)); } catch { return undefined; }
|
|
712
|
+
for (const name of names) {
|
|
713
|
+
const m = /^host-(\d+)\.json$/.exec(name);
|
|
714
|
+
const pid = m === null ? undefined : Number(m[1]);
|
|
715
|
+
if (pid !== undefined && pid !== process.pid && alive(pid)) return pid;
|
|
716
|
+
}
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ---------- Session logs ----------
|
|
721
|
+
|
|
722
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
723
|
+
const zstdDecompress = typeof zlib.zstdDecompress === 'function' ? promisify(zlib.zstdDecompress) : undefined;
|
|
724
|
+
/** The complete zstd frames of a session log (dsh's own rules): an incomplete last frame is a torn tail that dsh repairs at
|
|
725
|
+
* its next write; a wrong magic, a reserved bit or a reserved block type is damage. */
|
|
726
|
+
export function zstdFrames(b) {
|
|
727
|
+
const frames = [];
|
|
728
|
+
let off = 0;
|
|
729
|
+
while (off < b.length) {
|
|
730
|
+
const start = off;
|
|
731
|
+
if (b.length - off < 4) return { frames, tornStart: start };
|
|
732
|
+
if (b.readUInt32LE(off) !== ZSTD_MAGIC) throw new Error(`no zstd frame at byte ${off}`);
|
|
733
|
+
off += 4;
|
|
734
|
+
if (off === b.length) return { frames, tornStart: start };
|
|
735
|
+
const d = b[off++];
|
|
736
|
+
if (d & 24) throw new Error(`reserved frame header bit at byte ${off - 1}`);
|
|
737
|
+
const single = (d & 32) !== 0;
|
|
738
|
+
const sizeFlag = d >>> 6;
|
|
739
|
+
const dict = (d & 3) === 3 ? 4 : d & 3;
|
|
740
|
+
off += (single ? 0 : 1) + dict + (sizeFlag === 0 ? (single ? 1 : 0) : 1 << sizeFlag);
|
|
741
|
+
if (off > b.length) return { frames, tornStart: start };
|
|
742
|
+
for (;;) {
|
|
743
|
+
if (b.length - off < 3) return { frames, tornStart: start };
|
|
744
|
+
const h = b.readUIntLE(off, 3);
|
|
745
|
+
off += 3;
|
|
746
|
+
const type = (h >>> 1) & 3;
|
|
747
|
+
if (type === 3) throw new Error(`reserved block type at byte ${off - 3}`);
|
|
748
|
+
off += type === 1 ? 1 : h >>> 3;
|
|
749
|
+
if (off > b.length) return { frames, tornStart: start };
|
|
750
|
+
if (h & 1) break;
|
|
751
|
+
}
|
|
752
|
+
if (d & 4) { off += 4; if (off > b.length) return { frames, tornStart: start }; }
|
|
753
|
+
frames.push([start, off]);
|
|
754
|
+
}
|
|
755
|
+
return { frames, tornStart: undefined };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Checks one session log (its bytes): `{state: 'ok' | 'torn' | 'bad' | 'skipped', reason?}`. A torn tail is not damage
|
|
759
|
+
* (dsh cuts it at its next write); nor is a bad row (not JSON, or in a v4 log a `seq` that is not its place) with no
|
|
760
|
+
* turn/end after it (dsh opens the chat without the rows from there). Bad: a broken frame, a first frame that is not
|
|
761
|
+
* exactly the header line, a record cut inside a complete frame, and a bad row with a turn/end after it. */
|
|
762
|
+
export async function checkLog(data, compressed) {
|
|
763
|
+
const bad = (reason) => ({ state: 'bad', reason });
|
|
764
|
+
let text;
|
|
765
|
+
let torn = false;
|
|
766
|
+
if (compressed) {
|
|
767
|
+
if (zstdDecompress === undefined) return { state: 'skipped', reason: 'this Node.js has no zstd decoder (Node.js 22.15 or later has it)' };
|
|
768
|
+
let scan;
|
|
769
|
+
try { scan = zstdFrames(data); } catch (error) { return bad(error.message); }
|
|
770
|
+
if (scan.frames.length === 0) return bad('the log has no complete frame');
|
|
771
|
+
const parts = [];
|
|
772
|
+
for (const [s, e] of scan.frames) {
|
|
773
|
+
try { parts.push(await zstdDecompress(data.subarray(s, e))); } catch { return bad(`the frame at byte ${s} does not decode`); }
|
|
774
|
+
}
|
|
775
|
+
if (parts[0].length === 0 || parts[0].indexOf(10) !== parts[0].length - 1) return bad('the first frame is not exactly one header line');
|
|
776
|
+
text = Buffer.concat(parts).toString('utf8');
|
|
777
|
+
if (!text.endsWith('\n')) return bad('a complete frame ends inside a record');
|
|
778
|
+
torn = scan.tornStart !== undefined;
|
|
779
|
+
} else {
|
|
780
|
+
text = data.toString('utf8');
|
|
781
|
+
const cut = text.lastIndexOf('\n') + 1;
|
|
782
|
+
torn = cut < text.length;
|
|
783
|
+
text = text.slice(0, cut);
|
|
784
|
+
}
|
|
785
|
+
const lines = text.split('\n');
|
|
786
|
+
lines.pop();
|
|
787
|
+
if (lines.length === 0) return bad('the log has no header line');
|
|
788
|
+
let header;
|
|
789
|
+
try { header = JSON.parse(lines[0]); } catch { return bad('the header line is not JSON'); }
|
|
790
|
+
if (header?.type !== 'session' || typeof header.version !== 'number' || typeof header.id !== 'string') return bad('the first line is not a session header');
|
|
791
|
+
// dsh's own rule when it opens a chat (SessionLogScanner, recovery "recoverable"): after the first bad row, dsh keeps
|
|
792
|
+
// the rows before it and drops the rest, unless a later row is a turn/end: then the log does not open.
|
|
793
|
+
let issue;
|
|
794
|
+
for (let i = 1; i < lines.length; i++) {
|
|
795
|
+
if (i % 5000 === 0) await yieldNow();
|
|
796
|
+
let row;
|
|
797
|
+
try { row = JSON.parse(lines[i]); } catch { issue ??= `line ${i + 1} is not JSON`; continue; }
|
|
798
|
+
if (issue !== undefined) { if (row?.type === 'turn/end') return bad(issue); continue; }
|
|
799
|
+
if (header.version === 4 && row?.seq !== i - 1) issue = `line ${i + 1} has seq ${row?.seq}, expected ${i - 1}`;
|
|
800
|
+
}
|
|
801
|
+
return { state: torn || issue !== undefined ? 'torn' : 'ok', id: header.id, ...(issue === undefined ? {} : { dropped: issue }) };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** dsh's session folder name decoded back to the chat id (`~XXXX` is one UTF-16 code unit). */
|
|
805
|
+
const decodeId = (name) => name.replace(/~([0-9A-Fa-f]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
806
|
+
/** The newest log of each chat folder under `<home>/sessions`: `{rel, id}`. */
|
|
807
|
+
export async function sessionLogs(home, signal) {
|
|
808
|
+
const best = new Map();
|
|
809
|
+
for await (const { rel, st } of walk(home, 'sessions', (rel) => rel.split('/').length > 5, signal)) {
|
|
810
|
+
const m = LOG_RE.exec(rel.split('/').at(-1));
|
|
811
|
+
if (m === null || !st.isFile()) continue;
|
|
812
|
+
const dir = rel.slice(0, rel.lastIndexOf('/'));
|
|
813
|
+
const version = Number(m[1] ?? 0);
|
|
814
|
+
if ((best.get(dir)?.version ?? -1) < version) best.set(dir, { rel, id: decodeId(dir.split('/').at(-1)), version });
|
|
815
|
+
}
|
|
816
|
+
return [...best.values()].map(({ rel, id }) => ({ rel, id }));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Checks every chat's newest log. `{scanned, bad: [{rel, id, reason}], torn, skipped}`. */
|
|
820
|
+
export async function scanSessions(home, { signal, progress = {} } = {}) {
|
|
821
|
+
const logs = await sessionLogs(home, signal);
|
|
822
|
+
progress.total = logs.length; progress.done = 0;
|
|
823
|
+
const out = { scanned: logs.length, bad: [], torn: 0, skipped: 0 };
|
|
824
|
+
for (const log of logs) {
|
|
825
|
+
signal?.throwIfAborted();
|
|
826
|
+
const data = await readRegular(join(home, ...log.rel.split('/')), LOG_MAX);
|
|
827
|
+
const verdict = data === undefined ? { state: 'skipped' } : await checkLog(data, log.rel.endsWith('.zstd'));
|
|
828
|
+
if (verdict.state === 'bad') out.bad.push({ ...log, reason: verdict.reason });
|
|
829
|
+
else if (verdict.state === 'torn') out.torn += 1;
|
|
830
|
+
else if (verdict.state === 'skipped') out.skipped += 1;
|
|
831
|
+
progress.done += 1;
|
|
832
|
+
await yieldNow();
|
|
833
|
+
}
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Puts back each damaged log (`bad` from scanSessions) from the newest archive that has a healthy copy of it. The damaged
|
|
838
|
+
* file stays next to it as `<name>.corrupt-<stamp>`; messages written after that backup are not in the copy. Chats that
|
|
839
|
+
* `live(id)` says are open are left alone. */
|
|
840
|
+
export async function repairSessions({ home, dest, bad, live = () => false, signal, progress = {} }) {
|
|
841
|
+
const busy = bad.filter((b) => live(b.id));
|
|
842
|
+
const todo = new Map(bad.filter((b) => !live(b.id)).map((b) => [b.rel, b]));
|
|
843
|
+
const fixed = [];
|
|
844
|
+
for (const archive of await listArchives(dest)) {
|
|
845
|
+
if (todo.size === 0) break;
|
|
846
|
+
const found = new Map();
|
|
847
|
+
try {
|
|
848
|
+
await readArchive(join(dest, archive.name), { signal, progress, onEntry: (e) => {
|
|
849
|
+
if (e.type !== '0' && e.type !== '7') return undefined;
|
|
850
|
+
const rel = entryParts(e.name).slice(1).join('/');
|
|
851
|
+
if (!LOG_RE.test(rel.split('/').at(-1))) return undefined;
|
|
852
|
+
if (!todo.has(rel) || found.has(rel) || e.size > LOG_MAX) return undefined;
|
|
853
|
+
const chunks = [];
|
|
854
|
+
found.set(rel, chunks);
|
|
855
|
+
return { write: (c) => { chunks.push(Buffer.from(c)); } };
|
|
856
|
+
} });
|
|
857
|
+
} catch (error) { if (error?.name === 'AbortError') throw error; continue; }
|
|
858
|
+
for (const [rel, chunks] of found) {
|
|
859
|
+
const data = Buffer.concat(chunks);
|
|
860
|
+
const verdict = await checkLog(data, rel.endsWith('.zstd'));
|
|
861
|
+
if (verdict.state !== 'ok' && verdict.state !== 'torn') continue;
|
|
862
|
+
// Only a regular file below real folders of the dsh home is replaced (no symbolic link on the way).
|
|
863
|
+
const path = await plainTarget(home, rel.split('/'), false);
|
|
864
|
+
if (path === undefined || !(await lstat(path).then((s) => s.isFile(), () => false))) continue;
|
|
865
|
+
const kept = `${path}.corrupt-${stamp()}`;
|
|
866
|
+
const tmp = `${path}.repair-${process.pid}`;
|
|
867
|
+
await writeFile(tmp, data, { flag: 'wx', mode: 0o600 });
|
|
868
|
+
await rename(path, kept);
|
|
869
|
+
await rename(tmp, path);
|
|
870
|
+
fixed.push({ ...todo.get(rel), from: archive.name, kept: basename(kept) });
|
|
871
|
+
todo.delete(rel);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return { fixed, missing: [...todo.values()], busy };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ---------- Rescue console files ----------
|
|
878
|
+
|
|
879
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
880
|
+
const LAUNCHER_TEXT = {
|
|
881
|
+
darwin: `#!/bin/sh
|
|
882
|
+
# Composer Plus rescue console: a menu in this window. It works while dsh is closed or cannot start.
|
|
883
|
+
cd "$(dirname "$0")" || exit 1
|
|
884
|
+
# Node.js 18 or later; an older one is skipped.
|
|
885
|
+
for node in "$(command -v node)" /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node "$HOME/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/node/bin/node"; do
|
|
886
|
+
if [ -n "$node" ] && [ -x "$node" ] && "$node" -e 'process.exit(+process.versions.node.split(".")[0] >= 18 ? 0 : 1)' 2>/dev/null; then exec "$node" ${RESCUE_FILE} "$@"; fi
|
|
887
|
+
done
|
|
888
|
+
app="/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness"
|
|
889
|
+
if [ -x "$app" ]; then ELECTRON_RUN_AS_NODE=1 exec "$app" ${RESCUE_FILE} "$@"; fi
|
|
890
|
+
echo "Node.js was not found. Install it from https://nodejs.org, then open this file again."
|
|
891
|
+
printf "Press Return to close. "; read -r _
|
|
892
|
+
`,
|
|
893
|
+
linux: `#!/bin/sh
|
|
894
|
+
# Composer Plus rescue console: a menu in this terminal. It works while dsh is closed or cannot start.
|
|
895
|
+
cd "$(dirname "$0")" || exit 1
|
|
896
|
+
for node in "$(command -v node)" /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node "$HOME/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/node/bin/node"; do
|
|
897
|
+
if [ -n "$node" ] && [ -x "$node" ] && "$node" -e 'process.exit(+process.versions.node.split(".")[0] >= 18 ? 0 : 1)' 2>/dev/null; then exec "$node" ${RESCUE_FILE} "$@"; fi
|
|
898
|
+
done
|
|
899
|
+
echo "Node.js was not found. Install it from https://nodejs.org, then run this file again."
|
|
900
|
+
`,
|
|
901
|
+
win32: `@echo off\r\nrem Composer Plus rescue console: a menu in this window. It works while dsh is closed or cannot start.\r\ncd /d "%~dp0"\r\nnode ${RESCUE_FILE} %*\r\npause\r\n`,
|
|
902
|
+
};
|
|
903
|
+
/** The rescue console files of this platform in `dest`. */
|
|
904
|
+
export const rescueFiles = (platform = process.platform) => [RESCUE_FILE, ...(LAUNCHERS[platform] === undefined ? [] : [LAUNCHERS[platform]])];
|
|
905
|
+
/** Writes (or replaces) the rescue console and its launcher in `dest`. */
|
|
906
|
+
export async function writeRescue(dest, platform = process.platform) {
|
|
907
|
+
await mkdir(dest, { recursive: true, mode: 0o700 });
|
|
908
|
+
const put = async (name, data, mode) => {
|
|
909
|
+
const tmp = join(dest, `.${name}.${process.pid}.tmp`);
|
|
910
|
+
await writeFile(tmp, data, { mode });
|
|
911
|
+
await chmod(tmp, mode);
|
|
912
|
+
await rename(tmp, join(dest, name));
|
|
913
|
+
};
|
|
914
|
+
const self = await readRegular(SELF, 16 * MB);
|
|
915
|
+
if (self === undefined) throw new Error('cannot read the rescue console source');
|
|
916
|
+
await put(RESCUE_FILE, self, 0o644);
|
|
917
|
+
if (LAUNCHERS[platform] !== undefined) await put(LAUNCHERS[platform], LAUNCHER_TEXT[platform], 0o755);
|
|
918
|
+
return rescueFiles(platform);
|
|
919
|
+
}
|
|
920
|
+
/** For another computer: a new folder `transfer-<stamp>` in `dest` with archive `name`, its sidecars and the rescue console.
|
|
921
|
+
* The vault is not copied (secrets stay on this computer): the console on the other computer lists what to enter again.
|
|
922
|
+
* On APFS and Btrfs the copy is a clone (no extra disk space). */
|
|
923
|
+
export async function prepareTransfer(dest, name, now = new Date()) {
|
|
924
|
+
if (!ARCHIVE_RE.test(name) || !(await exists(join(dest, name)))) throw new ArchiveError('this backup is not in the folder');
|
|
925
|
+
const dir = join(dest, `transfer-${stamp(now)}`);
|
|
926
|
+
await mkdir(dir, { mode: 0o700 });
|
|
927
|
+
try {
|
|
928
|
+
for (const suffix of ['', ...SIDECARS]) {
|
|
929
|
+
if (await exists(join(dest, `${name}${suffix}`))) await copyFile(join(dest, `${name}${suffix}`), join(dir, `${name}${suffix}`), constants.COPYFILE_FICLONE | constants.COPYFILE_EXCL);
|
|
930
|
+
}
|
|
931
|
+
await writeRescue(dir);
|
|
932
|
+
} catch (error) { await rm(dir, { recursive: true, force: true }); throw error; }
|
|
933
|
+
return { dir, name: basename(dir) };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Removes the rescue console files from `dest`. */
|
|
937
|
+
export async function removeRescue(dest, platform = process.platform) {
|
|
938
|
+
for (const name of rescueFiles(platform)) await rm(join(dest, name), { force: true });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ---------- Terminal console ----------
|
|
942
|
+
|
|
943
|
+
const size = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} kB`);
|
|
944
|
+
const when = (iso) => { const d = new Date(iso); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
|
|
945
|
+
|
|
946
|
+
/** The rescue console: a numbered menu on `input`/`output`. `folder` is the backup folder, `home` the dsh home. */
|
|
947
|
+
export async function rescueConsole({ folder, home, input = process.stdin, output = process.stdout }) {
|
|
948
|
+
const rl = createInterface({ input, terminal: false });
|
|
949
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
950
|
+
const say = (text = '') => output.write(`${text}\n`);
|
|
951
|
+
const ask = async (question) => { output.write(question); const { value, done } = await lines.next(); return done ? null : value.trim(); };
|
|
952
|
+
const pick = async (archives) => {
|
|
953
|
+
if (archives.length === 0) { say('There is no backup in this folder.'); return undefined; }
|
|
954
|
+
archives.forEach((a, i) => say(` ${i + 1}) ${when(a.at)} ${a.kind === 'backup' ? '' : `(${a.kind}) `}${size(a.size)} ${a.name}`));
|
|
955
|
+
const answer = await ask('Backup number (Return = cancel): ');
|
|
956
|
+
const n = Number(answer);
|
|
957
|
+
return Number.isInteger(n) && n >= 1 && n <= archives.length ? archives[n - 1] : undefined;
|
|
958
|
+
};
|
|
959
|
+
const closedDsh = async () => {
|
|
960
|
+
const pid = await runningDsh(home);
|
|
961
|
+
if (pid === undefined) return true;
|
|
962
|
+
say(`dsh is running (process ${pid}). Quit dsh, then try again.`);
|
|
963
|
+
say(`If dsh is not running, remove ${hostFile(home, pid)} and try again.`);
|
|
964
|
+
return false;
|
|
965
|
+
};
|
|
966
|
+
const locked = async (op, fn) => {
|
|
967
|
+
let release;
|
|
968
|
+
try { release = await takeLock(folder, op); } catch (error) { say(String(error.message)); return; }
|
|
969
|
+
try { await fn(); } catch (error) { say(`Failed: ${error?.message ?? error}`); } finally { await release(); }
|
|
970
|
+
};
|
|
971
|
+
say('Composer Plus rescue console');
|
|
972
|
+
say(`Backup folder: ${folder}`);
|
|
973
|
+
say(`dsh home: ${home}`);
|
|
974
|
+
for (;;) {
|
|
975
|
+
say('');
|
|
976
|
+
say('1) List backups');
|
|
977
|
+
say('2) Check a backup');
|
|
978
|
+
say('3) Restore a backup (quit dsh first)');
|
|
979
|
+
say('4) Check session logs');
|
|
980
|
+
say('5) Repair session logs (quit dsh first)');
|
|
981
|
+
say('0) Quit');
|
|
982
|
+
const choice = await ask('Choose: ');
|
|
983
|
+
if (choice === null || choice === '0' || choice === 'q') break;
|
|
984
|
+
if (choice === '1') {
|
|
985
|
+
const archives = await listArchives(folder);
|
|
986
|
+
if (archives.length === 0) say('There is no backup in this folder.');
|
|
987
|
+
for (const a of archives) say(` ${when(a.at)} ${a.kind === 'backup' ? '' : `(${a.kind}) `}${size(a.size)} ${a.name}${a.masked?.length ? ` ${a.masked.length} masked` : ''}`);
|
|
988
|
+
} else if (choice === '2') {
|
|
989
|
+
const a = await pick(await listArchives(folder));
|
|
990
|
+
if (a === undefined) continue;
|
|
991
|
+
await locked('check', async () => {
|
|
992
|
+
say('Reading the backup…');
|
|
993
|
+
const r = await verifyArchive(folder, a.name, { home });
|
|
994
|
+
say(r.ok ? `OK: ${r.entries} entries, ${size(r.bytes)}${r.checksum ? ', checksum matches' : ', no checksum file'}.` : `Not usable: ${r.reason}`);
|
|
995
|
+
});
|
|
996
|
+
} else if (choice === '3') {
|
|
997
|
+
if (!(await closedDsh())) continue;
|
|
998
|
+
const a = await pick(await listArchives(folder));
|
|
999
|
+
if (a === undefined) continue;
|
|
1000
|
+
await locked('restore', async () => {
|
|
1001
|
+
say('Reading the backup…');
|
|
1002
|
+
const r = await verifyArchive(folder, a.name, { home });
|
|
1003
|
+
if (!r.ok) { say(`Not usable: ${r.reason}`); return; }
|
|
1004
|
+
say(`The backup has ${r.entries} entries, ${size(r.bytes)}.`);
|
|
1005
|
+
if (r.otherHome) say(`It comes from another computer or folder (${r.from.host ?? '?'}, ${r.from.dshHome}). Chats keep their old project paths.`);
|
|
1006
|
+
const missing = r.masked.filter((m) => !m.vault).map((m) => m.path);
|
|
1007
|
+
if (r.masked.length > 0) say(`${r.masked.length} masked file(s) come back from the vault on this computer${missing.length ? `; not in the vault (enter these again after the restore): ${missing.join(', ')}` : ''}.`);
|
|
1008
|
+
say(`Your current dsh home stays as ${home}.pre-restore-<time>. cache/, dsh-runtimes/ and node_modules move over from it.`);
|
|
1009
|
+
if (await ask('Type RESTORE to continue: ') !== 'RESTORE') { say('Cancelled. Nothing changed.'); return; }
|
|
1010
|
+
if (!(await closedDsh())) return;
|
|
1011
|
+
say('Restoring…');
|
|
1012
|
+
const done = await restore({ home, dest: folder, name: a.name });
|
|
1013
|
+
say(`Restored ${done.entries} entries into ${done.home}.`);
|
|
1014
|
+
if (done.aside) say(`The old dsh home is ${done.aside}. Remove it when dsh works.`);
|
|
1015
|
+
if (done.vaultMissing.length) say(`Enter these again (not in the vault): ${done.vaultMissing.join(', ')}`);
|
|
1016
|
+
if (done.unlinked.length) say(`These symbolic links could not be made: ${done.unlinked.join(', ')}`);
|
|
1017
|
+
if (done.install.length) {
|
|
1018
|
+
say(`These profiles need their plugin packages installed again: ${done.install.join(', ')}`);
|
|
1019
|
+
const pnpm = findPnpm(done.home);
|
|
1020
|
+
if (pnpm === undefined) say('pnpm was not found. In each profile folder, run: pnpm install');
|
|
1021
|
+
else if ((await ask('Install them now? (y/N): '))?.toLowerCase() === 'y') {
|
|
1022
|
+
for (const p of done.install) {
|
|
1023
|
+
const r2 = spawnSync(pnpm[0], [...pnpm.slice(1), 'install'], { cwd: join(done.home, 'profiles', p), stdio: 'inherit' });
|
|
1024
|
+
say(r2.status === 0 ? `Installed: ${p}` : `Install failed: ${p}. Run "pnpm install" in ${join(done.home, 'profiles', p)}.`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
say('Start dsh now.');
|
|
1029
|
+
});
|
|
1030
|
+
} else if (choice === '4' || choice === '5') {
|
|
1031
|
+
if (choice === '5' && !(await closedDsh())) continue;
|
|
1032
|
+
await locked(choice === '4' ? 'doctor' : 'repair', async () => {
|
|
1033
|
+
say('Checking session logs…');
|
|
1034
|
+
const scan = await scanSessions(home);
|
|
1035
|
+
say(`${scan.scanned} chat logs: ${scan.bad.length} damaged${scan.torn ? `, ${scan.torn} with a torn tail (dsh repairs these itself)` : ''}${scan.skipped ? `, ${scan.skipped} not checked` : ''}.`);
|
|
1036
|
+
for (const b of scan.bad) say(` ${b.id}: ${b.reason}`);
|
|
1037
|
+
if (choice === '4' || scan.bad.length === 0) return;
|
|
1038
|
+
say('A repair puts back the copy from the newest backup that has a healthy one. Messages after that backup are not in it; the damaged file stays next to it.');
|
|
1039
|
+
if (await ask('Type REPAIR to continue: ') !== 'REPAIR') { say('Cancelled. Nothing changed.'); return; }
|
|
1040
|
+
const r = await repairSessions({ home, dest: folder, bad: scan.bad });
|
|
1041
|
+
for (const f of r.fixed) say(` Repaired ${f.id} from ${f.from} (damaged copy: ${f.kept})`);
|
|
1042
|
+
for (const m of r.missing) say(` No healthy copy of ${m.id} in the backups.`);
|
|
1043
|
+
});
|
|
1044
|
+
} else say('Type a number from the menu.');
|
|
1045
|
+
}
|
|
1046
|
+
rl.close();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** pnpm: on PATH, else the copy in dsh's runtime folder (run with this Node.js). No shell: on Windows `pnpm.cmd`
|
|
1050
|
+
* does not start without one, so Windows uses the runtime copy (the install call below has no shell either). */
|
|
1051
|
+
function findPnpm(home) {
|
|
1052
|
+
if (spawnSync('pnpm', ['--version'], { stdio: 'ignore' }).status === 0) return ['pnpm'];
|
|
1053
|
+
const own = join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.cjs');
|
|
1054
|
+
return spawnSync(process.execPath, [own, '--version'], { stdio: 'ignore' }).status === 0 ? [process.execPath, own] : undefined;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** `node composer-plus-rescue.mjs [--folder <backup folder>] [--home <dsh home>]` */
|
|
1058
|
+
async function main(argv) {
|
|
1059
|
+
const value = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
|
|
1060
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
1061
|
+
process.stdout.write('Composer Plus rescue console\nUsage: node composer-plus-rescue.mjs [--folder <backup folder>] [--home <dsh home>]\n'
|
|
1062
|
+
+ 'Default folder: the folder of this file. Default dsh home: $DSH_HOME, else ~/.dsh.\n');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
await rescueConsole({ folder: resolve(value('--folder') ?? dirname(SELF)), home: resolve(value('--home') ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')) });
|
|
1066
|
+
}
|
|
1067
|
+
let direct = false;
|
|
1068
|
+
try { direct = process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(SELF); } catch { direct = false; }
|
|
1069
|
+
if (direct) main(process.argv.slice(2)).catch((error) => { process.stderr.write(`${error?.stack ?? error}\n`); process.exitCode = 1; });
|