getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { closeSync, constants, createReadStream, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, statfsSync, writeSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { createGunzip } from 'node:zlib';
|
|
6
|
+
import type { RehearsalProblem, RehearsalWorkspaces } from '../../shared/types.ts';
|
|
7
|
+
import { ARCHIVED_FOLDERS, backupTime, dbFileIn, readManifest, restorable } from './backup.ts';
|
|
8
|
+
import { openSchema, SCHEMA_VERSION } from './schema.ts';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* A restore rehearsal: does this backup restore with this release? It copies the backup's database into a private
|
|
12
|
+
* work folder (never BOTONS_HOME's own files), checks it, upgrades the copy to SCHEMA_VERSION as the server's next
|
|
13
|
+
* start would, checks it again, and reads the whole workspaces archive through (gzip's checksum included), checking
|
|
14
|
+
* every member as the restore does: only regular files and folders, only under the folders a backup archives,
|
|
15
|
+
* no ".." and no absolute paths. It unpacks nothing and runs no other program: the archive is parsed here.
|
|
16
|
+
*
|
|
17
|
+
* It runs in a child process (rehearsals.ts starts it) with a small heap and a low priority, and prints one JSON
|
|
18
|
+
* line: fixed words and counts only, never a path or an error's text. The backup is untrusted data: each file is
|
|
19
|
+
* opened once without following links, checked through its open descriptor, and read only up to the limits.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface RehearsalLimits {
|
|
23
|
+
/** The backup's botons.db, at most. */
|
|
24
|
+
databaseBytes: number;
|
|
25
|
+
/** workspaces.tar.gz as stored, at most. */
|
|
26
|
+
archiveBytes: number;
|
|
27
|
+
/** The tar stream gunzip produces, every byte of it (headers, padding, what follows the end), at most: bounds the time spent reading it through. */
|
|
28
|
+
unpackedBytes: number;
|
|
29
|
+
/** Files and folders in the archive, at most. */
|
|
30
|
+
members: number;
|
|
31
|
+
/** Free space the work folder keeps beyond the copy and its journal. */
|
|
32
|
+
spareBytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const REHEARSAL_LIMITS: RehearsalLimits = {
|
|
36
|
+
databaseBytes: 8 * 1024 ** 3,
|
|
37
|
+
archiveBytes: 64 * 1024 ** 3,
|
|
38
|
+
unpackedBytes: 256 * 1024 ** 3,
|
|
39
|
+
members: 5_000_000,
|
|
40
|
+
spareBytes: 256 * 1024 ** 2,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface RehearsalRequest {
|
|
44
|
+
backupsDir: string;
|
|
45
|
+
/** A backup folder's name: its UTC time. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** An empty private folder for the copy; removed by whoever made it. */
|
|
48
|
+
workDir: string;
|
|
49
|
+
limits: RehearsalLimits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RehearsalResult {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
problem: RehearsalProblem | null;
|
|
55
|
+
fromSchema: number | null;
|
|
56
|
+
toSchema: number | null;
|
|
57
|
+
/** Whether the database's tables hold as many rows as its manifest says; null when the manifest has no counts. */
|
|
58
|
+
matchesManifest: boolean | null;
|
|
59
|
+
workspaces: RehearsalWorkspaces;
|
|
60
|
+
files: number | null;
|
|
61
|
+
folders: number | null;
|
|
62
|
+
bytes: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class Refused extends Error {
|
|
66
|
+
problem: RehearsalProblem;
|
|
67
|
+
constructor(problem: RehearsalProblem) {
|
|
68
|
+
super(problem);
|
|
69
|
+
this.problem = problem;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SQLITE_HEADER = Buffer.from('SQLite format 3\0', 'latin1');
|
|
74
|
+
const CHUNK = 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
/** Opens a member of the backup folder: a regular file, not a link, not a FIFO or device; null when it is not there. */
|
|
77
|
+
function openMember(path: string): { fd: number; size: number } | null {
|
|
78
|
+
let fd: number;
|
|
79
|
+
try {
|
|
80
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
83
|
+
if (code === 'ENOENT') return null;
|
|
84
|
+
// ELOOP: a symlink. A Botons backup holds none.
|
|
85
|
+
throw new Refused(code === 'ELOOP' ? 'not-a-backup' : 'not-found');
|
|
86
|
+
}
|
|
87
|
+
const st = fstatSync(fd);
|
|
88
|
+
if (!st.isFile()) {
|
|
89
|
+
closeSync(fd);
|
|
90
|
+
throw new Refused('not-a-backup');
|
|
91
|
+
}
|
|
92
|
+
return { fd, size: st.size };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The backup's folder: a real folder (not a link) directly in backupsDir, named as the backup command names it. */
|
|
96
|
+
function backupFolder(backupsDir: string, name: string): string {
|
|
97
|
+
if (!Number.isFinite(backupTime(name))) throw new Refused('not-found');
|
|
98
|
+
const dir = join(backupsDir, name);
|
|
99
|
+
try {
|
|
100
|
+
if (!lstatSync(dir).isDirectory()) throw new Refused('not-found');
|
|
101
|
+
// Where it really is, so a parent swapped for a link after the check is noticed when the files are opened.
|
|
102
|
+
if (realpathSync(dir) !== join(realpathSync(backupsDir), name)) throw new Refused('not-found');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof Refused) throw err;
|
|
105
|
+
throw new Refused('not-found');
|
|
106
|
+
}
|
|
107
|
+
return dir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function copyDatabase(src: { fd: number; size: number }, dest: string, limits: RehearsalLimits): void {
|
|
111
|
+
if (src.size > limits.databaseBytes) throw new Refused('database-too-large');
|
|
112
|
+
if (src.size < 512) throw new Refused('not-a-backup');
|
|
113
|
+
try {
|
|
114
|
+
const fs = statfsSync(join(dest, '..'));
|
|
115
|
+
// The copy, then as much again for the upgrade's journal at worst.
|
|
116
|
+
if (fs.bavail * fs.bsize < src.size * 2 + limits.spareBytes) throw new Refused('no-space');
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof Refused) throw err;
|
|
119
|
+
}
|
|
120
|
+
const out = openSync(dest, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
121
|
+
try {
|
|
122
|
+
const buf = Buffer.alloc(CHUNK);
|
|
123
|
+
let pos = 0;
|
|
124
|
+
// No further than fstat said, even if the file grows meanwhile.
|
|
125
|
+
while (pos < src.size) {
|
|
126
|
+
const got = readSync(src.fd, buf, 0, Math.min(CHUNK, src.size - pos), pos);
|
|
127
|
+
if (got === 0) break;
|
|
128
|
+
if (pos === 0 && !buf.subarray(0, SQLITE_HEADER.length).equals(SQLITE_HEADER)) throw new Refused('not-a-backup');
|
|
129
|
+
writeSync(out, buf, 0, got);
|
|
130
|
+
pos += got;
|
|
131
|
+
}
|
|
132
|
+
if (pos !== src.size) throw new Refused('damaged-database');
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof Refused) throw err;
|
|
135
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
136
|
+
throw new Refused(code === 'ENOSPC' || code === 'EDQUOT' ? 'no-space' : 'failed');
|
|
137
|
+
} finally {
|
|
138
|
+
closeSync(out);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const integrityOk = (db: DatabaseSync): boolean => {
|
|
143
|
+
const rows = db.prepare('pragma integrity_check(1)').all();
|
|
144
|
+
return rows.length === 1 && rows[0].integrity_check === 'ok';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function countRows(db: DatabaseSync): Map<string, number> {
|
|
148
|
+
const counts = new Map<string, number>();
|
|
149
|
+
const tables = db.prepare("select name from sqlite_schema where type = 'table' and name not like 'sqlite_%'").all();
|
|
150
|
+
for (const { name } of tables) counts.set(String(name), Number(db.prepare(`select count(*) as n from "${String(name).replaceAll('"', '""')}"`).get()!.n));
|
|
151
|
+
return counts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The tables every Botons database has had in each version restore takes. */
|
|
155
|
+
const CORE_TABLES = ['settings', 'bots', 'messages', 'routines', 'audit_events'];
|
|
156
|
+
|
|
157
|
+
function rehearseDatabase(copy: string, manifestCounts: Record<string, unknown> | null): Pick<RehearsalResult, 'fromSchema' | 'toSchema' | 'matchesManifest'> {
|
|
158
|
+
let db: DatabaseSync;
|
|
159
|
+
try {
|
|
160
|
+
// defensive: the file is untrusted, so no writes to the schema table, no shadow tables and no double-quoted strings.
|
|
161
|
+
db = new DatabaseSync(copy, { defensive: true, allowExtension: false, enableDoubleQuotedStringLiterals: false });
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Refused('damaged-database');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
try {
|
|
167
|
+
// Functions with side effects never run from the file's own views or triggers.
|
|
168
|
+
db.exec('pragma trusted_schema = off; pragma cell_size_check = on;');
|
|
169
|
+
if (!integrityOk(db)) throw new Refused('damaged-database');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof Refused) throw err;
|
|
172
|
+
throw new Refused('damaged-database');
|
|
173
|
+
}
|
|
174
|
+
const fromSchema = Number(db.prepare('pragma user_version').get()!.user_version);
|
|
175
|
+
const counts = countRows(db);
|
|
176
|
+
if (!CORE_TABLES.every((t) => counts.has(t))) throw new Refused('not-a-backup');
|
|
177
|
+
let matchesManifest: boolean | null = null;
|
|
178
|
+
if (manifestCounts) {
|
|
179
|
+
const entries = Object.entries(manifestCounts);
|
|
180
|
+
matchesManifest = entries.length === counts.size && entries.every(([t, n]) => counts.get(t) === n);
|
|
181
|
+
}
|
|
182
|
+
const compat = restorable(fromSchema, SCHEMA_VERSION);
|
|
183
|
+
if (compat !== 'ok') return failWith(compat === 'newer' ? 'newer-schema' : 'older-schema', { fromSchema, toSchema: null, matchesManifest });
|
|
184
|
+
try {
|
|
185
|
+
openSchema(db);
|
|
186
|
+
if (!integrityOk(db)) throw new Error('integrity');
|
|
187
|
+
} catch {
|
|
188
|
+
return failWith('upgrade-failed', { fromSchema, toSchema: null, matchesManifest });
|
|
189
|
+
}
|
|
190
|
+
const toSchema = Number(db.prepare('pragma user_version').get()!.user_version);
|
|
191
|
+
if (toSchema !== SCHEMA_VERSION) return failWith('upgrade-failed', { fromSchema, toSchema, matchesManifest });
|
|
192
|
+
return { fromSchema, toSchema, matchesManifest };
|
|
193
|
+
} finally {
|
|
194
|
+
db.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Carries what was learned before the refusal out with it. */
|
|
199
|
+
class RefusedWith extends Refused {
|
|
200
|
+
partial: Partial<RehearsalResult>;
|
|
201
|
+
constructor(problem: RehearsalProblem, partial: Partial<RehearsalResult>) {
|
|
202
|
+
super(problem);
|
|
203
|
+
this.partial = partial;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function failWith(problem: RehearsalProblem, partial: Partial<RehearsalResult>): never {
|
|
207
|
+
throw new RefusedWith(problem, partial);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ------------------------------------------------------------------ the archive
|
|
211
|
+
|
|
212
|
+
/** A member name in the archive as the restore accepts it: under an archived folder, no "..", no control characters. */
|
|
213
|
+
export function memberNameOk(name: string): boolean {
|
|
214
|
+
if (!name || name.startsWith('/') || /[\u0000-\u001f\u007f]/.test(name)) return false;
|
|
215
|
+
const parts = name.replace(/\/+$/, '').split('/');
|
|
216
|
+
if (parts.some((p) => p === '..' || p === '.' || p === '')) return false;
|
|
217
|
+
return ARCHIVED_FOLDERS.includes(parts[0]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const BLOCK = 512;
|
|
221
|
+
|
|
222
|
+
function octal(field: Buffer): number {
|
|
223
|
+
// GNU base-256 for sizes of 8 GiB and more: the first byte's top bit is set.
|
|
224
|
+
if (field[0] & 0x80) {
|
|
225
|
+
if (field[0] !== 0x80) return Number.NaN;
|
|
226
|
+
let n = 0;
|
|
227
|
+
for (let i = 1; i < field.length; i++) n = n * 256 + field[i];
|
|
228
|
+
return Number.isSafeInteger(n) ? n : Number.NaN;
|
|
229
|
+
}
|
|
230
|
+
const text = field.toString('latin1').replace(/\0.*$/s, '').trim();
|
|
231
|
+
return /^[0-7]+$/.test(text) ? Number.parseInt(text, 8) : text === '' ? 0 : Number.NaN;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A numeric header field GNU tar reads without complaint: octal text, or base-256 (0x80 positive, 0xff negative, as
|
|
236
|
+
* GNU tar writes times before 1970). Mode, uid, gid and mtime are checked so; tar -t refuses a member whose are not.
|
|
237
|
+
*/
|
|
238
|
+
function numericFieldOk(field: Buffer, min: bigint, max: bigint): boolean {
|
|
239
|
+
let value: bigint;
|
|
240
|
+
if (field[0] & 0x80) {
|
|
241
|
+
// The top bit marks binary encoding; the remaining bits are a signed two's-complement value.
|
|
242
|
+
value = BigInt(field[0] & 0x7f);
|
|
243
|
+
for (let i = 1; i < field.length; i++) value = value * 256n + BigInt(field[i]);
|
|
244
|
+
if (field[0] & 0x40) value -= 1n << BigInt(field.length * 8 - 1);
|
|
245
|
+
} else {
|
|
246
|
+
const n = octal(field);
|
|
247
|
+
if (!Number.isSafeInteger(n)) return false;
|
|
248
|
+
value = BigInt(n);
|
|
249
|
+
}
|
|
250
|
+
return value >= min && value <= max;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const INT64_MIN = -(2n ** 63n);
|
|
254
|
+
const INT64_MAX = 2n ** 63n - 1n;
|
|
255
|
+
const UID_MAX = 2n ** 32n - 1n;
|
|
256
|
+
|
|
257
|
+
/** An unsigned decimal pax value in 0..max, as GNU tar reads it: digits only, no sign, no spaces. */
|
|
258
|
+
function paxUnsigned(value: string, max: bigint): bigint | null {
|
|
259
|
+
if (!/^\d+$/.test(value)) return null;
|
|
260
|
+
const n = BigInt(value);
|
|
261
|
+
return n <= max ? n : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A pax time as GNU tar reads it: an optional "-", digits, then optionally "." and digits; whole seconds in int64. */
|
|
265
|
+
function paxTimeOk(value: string): boolean {
|
|
266
|
+
const m = /^(-?\d+)(?:\.\d*)?$/.exec(value);
|
|
267
|
+
if (!m) return false;
|
|
268
|
+
const n = BigInt(m[1]);
|
|
269
|
+
return n >= INT64_MIN && n <= INT64_MAX;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The pax keys a member may carry, and how each value must read. This is stricter than GNU tar on purpose: tar
|
|
274
|
+
* ignores keys it does not know (with a warning) and applies some (xattrs, ACLs, sparse maps, link targets) that a
|
|
275
|
+
* Botons backup never needs, so any key not listed here is refused rather than guessed at. GNU tar's own pax format
|
|
276
|
+
* writes only path, size, mtime, atime, ctime, uid, gid, uname and gname for regular files and folders; its default
|
|
277
|
+
* format, which the backup uses, writes no pax headers at all.
|
|
278
|
+
*/
|
|
279
|
+
const PAX_KEYS: Record<string, (value: string) => boolean> = {
|
|
280
|
+
path: () => true,
|
|
281
|
+
size: (v) => paxUnsigned(v, INT64_MAX) !== null,
|
|
282
|
+
mtime: paxTimeOk,
|
|
283
|
+
atime: paxTimeOk,
|
|
284
|
+
ctime: paxTimeOk,
|
|
285
|
+
uid: (v) => paxUnsigned(v, UID_MAX) !== null,
|
|
286
|
+
gid: (v) => paxUnsigned(v, UID_MAX) !== null,
|
|
287
|
+
uname: () => true,
|
|
288
|
+
gname: () => true,
|
|
289
|
+
comment: () => true,
|
|
290
|
+
charset: () => true,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function checksumOk(h: Buffer): boolean {
|
|
294
|
+
const stored = octal(h.subarray(148, 156));
|
|
295
|
+
let sum = 0;
|
|
296
|
+
for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : h[i];
|
|
297
|
+
return sum === stored;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const cString = (b: Buffer) => {
|
|
301
|
+
const end = b.indexOf(0);
|
|
302
|
+
return b.subarray(0, end === -1 ? b.length : end).toString('utf8');
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** A pax extended header's records ("<len> key=value\n"), or null when one is malformed. */
|
|
306
|
+
function paxRecords(data: Buffer): Map<string, string> | null {
|
|
307
|
+
const out = new Map<string, string>();
|
|
308
|
+
let i = 0;
|
|
309
|
+
while (i < data.length) {
|
|
310
|
+
if (data[i] === 0) break;
|
|
311
|
+
const space = data.indexOf(0x20, i);
|
|
312
|
+
if (space === -1) return null;
|
|
313
|
+
const digits = data.subarray(i, space).toString('latin1');
|
|
314
|
+
// Decimal digits only, as tar reads them ("0x1e", " 30" and "3e1" are not lengths).
|
|
315
|
+
const len = /^\d{1,8}$/.test(digits) ? Number(digits) : Number.NaN;
|
|
316
|
+
if (!Number.isSafeInteger(len) || len <= space - i + 1 || i + len > data.length || data[i + len - 1] !== 0x0a) return null;
|
|
317
|
+
const record = data.subarray(space + 1, i + len - 1).toString('utf8');
|
|
318
|
+
const eq = record.indexOf('=');
|
|
319
|
+
if (eq < 1) return null;
|
|
320
|
+
const key = record.slice(0, eq);
|
|
321
|
+
// Duplicate attributes can hide an earlier error that GNU tar still refuses.
|
|
322
|
+
if (out.has(key)) return null;
|
|
323
|
+
out.set(key, record.slice(eq + 1));
|
|
324
|
+
i += len;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The largest pax or GNU long-name block kept in memory. */
|
|
330
|
+
const META_MAX = 64 * 1024;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reads a tar stream through and checks each member. Regular files ('0', or NUL in old archives) and folders ('5')
|
|
334
|
+
* only; GNU long names ('L') and pax headers ('x') only as they describe the next member, with only the pax keys in
|
|
335
|
+
* PAX_KEYS. Any other type (links, devices, FIFOs, sparse files, global pax headers) is refused, as the restore
|
|
336
|
+
* refuses it, and so is anything tar -t would fail on: a malformed pax value or numeric header field.
|
|
337
|
+
*/
|
|
338
|
+
export class TarChecker {
|
|
339
|
+
#limits: Pick<RehearsalLimits, 'unpackedBytes' | 'members'>;
|
|
340
|
+
#header = Buffer.alloc(BLOCK);
|
|
341
|
+
#headerFill = 0;
|
|
342
|
+
/** Data bytes of the current entry still to come, then the padding to the next block. */
|
|
343
|
+
#skip = 0;
|
|
344
|
+
#meta: { kind: 'L' | 'x'; buf: Buffer; fill: number } | null = null;
|
|
345
|
+
#longName: string | null = null;
|
|
346
|
+
#pax: Map<string, string> | null = null;
|
|
347
|
+
#zeroBlocks = 0;
|
|
348
|
+
ended = false;
|
|
349
|
+
files = 0;
|
|
350
|
+
folders = 0;
|
|
351
|
+
/** What the regular files hold: the count reported. */
|
|
352
|
+
bytes = 0;
|
|
353
|
+
/** Everything gunzip produced: headers, pax and long-name data, padding, the end marker and what follows it. */
|
|
354
|
+
streamed = 0;
|
|
355
|
+
|
|
356
|
+
constructor(limits: Pick<RehearsalLimits, 'unpackedBytes' | 'members'>) {
|
|
357
|
+
this.#limits = limits;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
write(chunk: Buffer): void {
|
|
361
|
+
// The budget is the whole tar stream, counted before any of it is parsed: zero padding, pax and long-name
|
|
362
|
+
// blocks and the space after the end marker cost reading time as file data does, so none of them is free.
|
|
363
|
+
this.streamed += chunk.length;
|
|
364
|
+
if (this.streamed > this.#limits.unpackedBytes) throw new Refused('archive-too-large');
|
|
365
|
+
let i = 0;
|
|
366
|
+
while (i < chunk.length) {
|
|
367
|
+
if (this.ended) {
|
|
368
|
+
// After the end marker only zero padding may follow (tar pads to its record size).
|
|
369
|
+
for (; i < chunk.length; i++) if (chunk[i] !== 0) throw new Refused('damaged-archive');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (this.#skip > 0) {
|
|
373
|
+
const n = Math.min(this.#skip, chunk.length - i);
|
|
374
|
+
if (this.#meta) {
|
|
375
|
+
const room = this.#meta.buf.length - this.#meta.fill;
|
|
376
|
+
chunk.copy(this.#meta.buf, this.#meta.fill, i, i + Math.min(n, room));
|
|
377
|
+
this.#meta.fill += Math.min(n, room);
|
|
378
|
+
}
|
|
379
|
+
this.#skip -= n;
|
|
380
|
+
i += n;
|
|
381
|
+
if (this.#skip === 0 && this.#meta) this.#endMeta();
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const n = Math.min(BLOCK - this.#headerFill, chunk.length - i);
|
|
385
|
+
chunk.copy(this.#header, this.#headerFill, i, i + n);
|
|
386
|
+
this.#headerFill += n;
|
|
387
|
+
i += n;
|
|
388
|
+
if (this.#headerFill === BLOCK) {
|
|
389
|
+
this.#headerFill = 0;
|
|
390
|
+
this.#onHeader(this.#header);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The stream ended: it must have ended at the archive's end marker, with no pax or long name left over. */
|
|
396
|
+
end(): void {
|
|
397
|
+
if (!this.ended || this.#skip > 0 || this.#headerFill > 0 || this.#meta || this.#longName !== null || this.#pax !== null) throw new Refused('damaged-archive');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#onHeader(h: Buffer): void {
|
|
401
|
+
if (h.every((b) => b === 0)) {
|
|
402
|
+
// Two zero blocks end the archive; GNU tar writes both.
|
|
403
|
+
if (++this.#zeroBlocks === 2) this.ended = true;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (this.#zeroBlocks) throw new Refused('damaged-archive');
|
|
407
|
+
if (!checksumOk(h)) throw new Refused('damaged-archive');
|
|
408
|
+
// mode, uid, gid, mtime: tar -t fails on a member whose fields are not numbers, so the rehearsal does too.
|
|
409
|
+
if (!numericFieldOk(h.subarray(100, 108), 0n, UID_MAX) || !numericFieldOk(h.subarray(108, 116), 0n, UID_MAX) ||
|
|
410
|
+
!numericFieldOk(h.subarray(116, 124), 0n, UID_MAX) || !numericFieldOk(h.subarray(136, 148), INT64_MIN, INT64_MAX)) throw new Refused('damaged-archive');
|
|
411
|
+
const type = String.fromCharCode(h[156]);
|
|
412
|
+
let size = octal(h.subarray(124, 136));
|
|
413
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Refused('damaged-archive');
|
|
414
|
+
const padded = Math.ceil(size / BLOCK) * BLOCK;
|
|
415
|
+
|
|
416
|
+
if (type === 'L' || type === 'x') {
|
|
417
|
+
if (this.#meta || size > META_MAX || (type === 'L' && this.#longName !== null) || (type === 'x' && this.#pax !== null)) throw new Refused('unsafe-archive');
|
|
418
|
+
this.#meta = { kind: type, buf: Buffer.alloc(size), fill: 0 };
|
|
419
|
+
this.#skip = padded;
|
|
420
|
+
if (padded === 0) this.#endMeta();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (type !== '0' && type !== '\0' && type !== '5') throw new Refused('unsafe-archive');
|
|
424
|
+
|
|
425
|
+
// Only POSIX ustar headers ("ustar\0") have a name prefix; in GNU headers ("ustar \0") those bytes are other
|
|
426
|
+
// fields, and tar names the member without them.
|
|
427
|
+
const posix = h.subarray(257, 263).equals(Buffer.from('ustar\0', 'latin1'));
|
|
428
|
+
const prefix = posix ? cString(h.subarray(345, 500)) : '';
|
|
429
|
+
let name = this.#longName ?? (prefix ? `${prefix}/${cString(h.subarray(0, 100))}` : cString(h.subarray(0, 100)));
|
|
430
|
+
if (this.#pax) {
|
|
431
|
+
for (const [key, value] of this.#pax) {
|
|
432
|
+
const check = Object.hasOwn(PAX_KEYS, key) ? PAX_KEYS[key] : null;
|
|
433
|
+
if (!check) throw new Refused('unsafe-archive');
|
|
434
|
+
if (!check(value)) throw new Refused('damaged-archive');
|
|
435
|
+
}
|
|
436
|
+
const path = this.#pax.get('path');
|
|
437
|
+
if (path !== undefined) name = path;
|
|
438
|
+
const paxSize = this.#pax.get('size');
|
|
439
|
+
if (paxSize !== undefined) {
|
|
440
|
+
// In int64 (checked above); past what a number holds exactly is past any limit too.
|
|
441
|
+
const n = BigInt(paxSize);
|
|
442
|
+
if (n > BigInt(this.#limits.unpackedBytes)) throw new Refused('archive-too-large');
|
|
443
|
+
size = Number(n);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this.#longName = null;
|
|
447
|
+
this.#pax = null;
|
|
448
|
+
if (!memberNameOk(name)) throw new Refused('unsafe-archive');
|
|
449
|
+
if (type === '5') {
|
|
450
|
+
this.folders++;
|
|
451
|
+
} else {
|
|
452
|
+
this.files++;
|
|
453
|
+
this.bytes += size;
|
|
454
|
+
}
|
|
455
|
+
if (this.files + this.folders > this.#limits.members || this.bytes > this.#limits.unpackedBytes) throw new Refused('archive-too-large');
|
|
456
|
+
this.#skip = type === '5' ? 0 : Math.ceil(size / BLOCK) * BLOCK;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#endMeta(): void {
|
|
460
|
+
const meta = this.#meta!;
|
|
461
|
+
this.#meta = null;
|
|
462
|
+
if (meta.fill !== meta.buf.length) throw new Refused('damaged-archive');
|
|
463
|
+
if (meta.kind === 'L') this.#longName = cString(meta.buf);
|
|
464
|
+
else {
|
|
465
|
+
const records = paxRecords(meta.buf);
|
|
466
|
+
if (!records) throw new Refused('damaged-archive');
|
|
467
|
+
this.#pax = records;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Takes `src.fd` over: the read stream closes it (destroying a stream closes its fd whatever autoClose says), and
|
|
474
|
+
* this returns or throws only once it is closed, so the number is never closed twice after something reuses it.
|
|
475
|
+
*/
|
|
476
|
+
async function rehearseArchive(src: { fd: number; size: number }, limits: RehearsalLimits): Promise<Pick<RehearsalResult, 'files' | 'folders' | 'bytes'>> {
|
|
477
|
+
// end is inclusive; reading stops where fstat said the file ended.
|
|
478
|
+
const input = createReadStream('', { fd: src.fd, autoClose: true, start: 0, end: Math.max(0, src.size - 1), highWaterMark: CHUNK });
|
|
479
|
+
const closed = new Promise<void>((resolve) => input.once('close', () => resolve()));
|
|
480
|
+
if (src.size > limits.archiveBytes) {
|
|
481
|
+
input.destroy();
|
|
482
|
+
await closed;
|
|
483
|
+
throw new Refused('archive-too-large');
|
|
484
|
+
}
|
|
485
|
+
const checker = new TarChecker(limits);
|
|
486
|
+
await new Promise<void>((resolve, reject) => {
|
|
487
|
+
const gunzip = createGunzip({ chunkSize: 64 * 1024 });
|
|
488
|
+
let failed = false;
|
|
489
|
+
const fail = (err: unknown) => {
|
|
490
|
+
if (failed) return;
|
|
491
|
+
failed = true;
|
|
492
|
+
input.destroy();
|
|
493
|
+
gunzip.destroy();
|
|
494
|
+
void closed.then(() => reject(err instanceof Refused ? err : new Refused('damaged-archive')));
|
|
495
|
+
};
|
|
496
|
+
input.on('error', fail);
|
|
497
|
+
gunzip.on('error', fail);
|
|
498
|
+
gunzip.on('data', (chunk: Buffer) => {
|
|
499
|
+
// Nothing more is parsed once the checker has refused (or the limit is reached).
|
|
500
|
+
if (failed) return;
|
|
501
|
+
try {
|
|
502
|
+
checker.write(chunk);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
fail(err);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
gunzip.on('end', () => {
|
|
508
|
+
if (failed) return;
|
|
509
|
+
try {
|
|
510
|
+
checker.end();
|
|
511
|
+
input.destroy();
|
|
512
|
+
void closed.then(() => resolve());
|
|
513
|
+
} catch (err) {
|
|
514
|
+
fail(err);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
input.pipe(gunzip);
|
|
518
|
+
});
|
|
519
|
+
return { files: checker.files, folders: checker.folders, bytes: checker.bytes };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The rehearsal itself. Never throws: every outcome is a result with a fixed problem word. */
|
|
523
|
+
export async function rehearse(req: RehearsalRequest): Promise<RehearsalResult> {
|
|
524
|
+
const result: RehearsalResult = { ok: false, problem: null, fromSchema: null, toSchema: null, matchesManifest: null, workspaces: 'not-checked', files: null, folders: null, bytes: null };
|
|
525
|
+
const open: number[] = [];
|
|
526
|
+
const copy = join(req.workDir, 'botons.db');
|
|
527
|
+
try {
|
|
528
|
+
const dir = backupFolder(req.backupsDir, req.name);
|
|
529
|
+
// Open both before reading either, so the backup's own pruning can't take one away between the steps.
|
|
530
|
+
const db = openMember(dbFileIn(dir));
|
|
531
|
+
if (!db) throw new Refused('not-a-backup');
|
|
532
|
+
open.push(db.fd);
|
|
533
|
+
const archive = openMember(join(dir, 'workspaces.tar.gz'));
|
|
534
|
+
if (archive) open.push(archive.fd);
|
|
535
|
+
const manifest = readManifest(join(dir, 'manifest.json'));
|
|
536
|
+
const counts = manifest?.counts && typeof manifest.counts === 'object' && !Array.isArray(manifest.counts) ? (manifest.counts as Record<string, unknown>) : null;
|
|
537
|
+
|
|
538
|
+
copyDatabase(db, copy, req.limits);
|
|
539
|
+
Object.assign(result, rehearseDatabase(copy, counts));
|
|
540
|
+
rmSync(copy, { force: true });
|
|
541
|
+
for (const side of ['-journal', '-wal', '-shm']) rmSync(`${copy}${side}`, { force: true });
|
|
542
|
+
|
|
543
|
+
if (archive) {
|
|
544
|
+
open.splice(open.indexOf(archive.fd), 1);
|
|
545
|
+
Object.assign(result, await rehearseArchive(archive, req.limits));
|
|
546
|
+
result.workspaces = 'checked';
|
|
547
|
+
} else {
|
|
548
|
+
result.workspaces = 'none';
|
|
549
|
+
}
|
|
550
|
+
result.ok = true;
|
|
551
|
+
} catch (err) {
|
|
552
|
+
if (err instanceof RefusedWith) Object.assign(result, err.partial);
|
|
553
|
+
result.problem = err instanceof Refused ? err.problem : 'failed';
|
|
554
|
+
} finally {
|
|
555
|
+
for (const fd of open) {
|
|
556
|
+
try {
|
|
557
|
+
closeSync(fd);
|
|
558
|
+
} catch {}
|
|
559
|
+
}
|
|
560
|
+
for (const side of ['', '-journal', '-wal', '-shm']) rmSync(`${copy}${side}`, { force: true });
|
|
561
|
+
}
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ------------------------------------------------------------------ as a child process
|
|
566
|
+
|
|
567
|
+
/** Reads one JSON request line from stdin, rehearses, prints one JSON line. Exits as soon as stdin closes: the server went away. */
|
|
568
|
+
async function main(): Promise<void> {
|
|
569
|
+
let input = '';
|
|
570
|
+
let started = false;
|
|
571
|
+
let workDir: string | null = null;
|
|
572
|
+
const cleanUp = () => {
|
|
573
|
+
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
|
574
|
+
};
|
|
575
|
+
process.on('exit', cleanUp);
|
|
576
|
+
process.stdin.setEncoding('utf8');
|
|
577
|
+
process.stdin.on('end', () => process.exit(started ? 3 : 2));
|
|
578
|
+
process.stdin.on('data', (chunk: string) => {
|
|
579
|
+
if (started) return;
|
|
580
|
+
input += chunk;
|
|
581
|
+
if (input.length > 64 * 1024) process.exit(2);
|
|
582
|
+
const nl = input.indexOf('\n');
|
|
583
|
+
if (nl === -1) return;
|
|
584
|
+
started = true;
|
|
585
|
+
const req = JSON.parse(input.slice(0, nl)) as RehearsalRequest;
|
|
586
|
+
workDir = req.workDir;
|
|
587
|
+
void rehearse(req).then((result) => {
|
|
588
|
+
process.stdout.write(`${JSON.stringify(result)}\n`, () => process.exit(0));
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) void main();
|