@coderook/cli 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.txt +46 -0
- package/README.md +99 -0
- package/dist/cli/src/api.js +67 -0
- package/dist/cli/src/cli.js +461 -0
- package/dist/cli/src/config.js +96 -0
- package/dist/desktop-app/src/main/cbx.js +455 -0
- package/dist/desktop-app/src/main/credentials.js +2 -0
- package/dist/desktop-app/src/main/download.js +147 -0
- package/dist/desktop-app/src/main/rules.js +172 -0
- package/dist/desktop-app/src/main/upload.js +433 -0
- package/dist/desktop-app/src/main/worktree.js +626 -0
- package/dist/desktop-app/src/shared/types.js +3 -0
- package/package.json +36 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.credentials = exports.DEFAULT_API = void 0;
|
|
7
|
+
exports.apiOrigin = apiOrigin;
|
|
8
|
+
exports.configDirectory = configDirectory;
|
|
9
|
+
exports.storeToken = storeToken;
|
|
10
|
+
exports.loadToken = loadToken;
|
|
11
|
+
exports.clearToken = clearToken;
|
|
12
|
+
exports.readLink = readLink;
|
|
13
|
+
exports.writeLink = writeLink;
|
|
14
|
+
/**
|
|
15
|
+
* Where the command-line tool keeps its credential and its project links.
|
|
16
|
+
*
|
|
17
|
+
* The desktop uses the operating-system vault; a terminal has no such thing
|
|
18
|
+
* everywhere, so the token lives in a file that only its owner can read. The
|
|
19
|
+
* location follows each platform's convention rather than scattering dot
|
|
20
|
+
* directories about.
|
|
21
|
+
*/
|
|
22
|
+
const promises_1 = require("node:fs/promises");
|
|
23
|
+
const node_os_1 = require("node:os");
|
|
24
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
25
|
+
exports.DEFAULT_API = "https://api.coderook.com";
|
|
26
|
+
function apiOrigin() {
|
|
27
|
+
return (process.env.CODEROOK_API_URL || exports.DEFAULT_API).replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
/** The per-user configuration directory, per platform convention. */
|
|
30
|
+
function configDirectory() {
|
|
31
|
+
if (process.env.CODEROOK_CONFIG_DIR)
|
|
32
|
+
return process.env.CODEROOK_CONFIG_DIR;
|
|
33
|
+
if (process.platform === "win32") {
|
|
34
|
+
return node_path_1.default.join(process.env.APPDATA ?? node_path_1.default.join((0, node_os_1.homedir)(), "AppData", "Roaming"), "CodeRook");
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === "darwin") {
|
|
37
|
+
return node_path_1.default.join((0, node_os_1.homedir)(), "Library", "Application Support", "CodeRook");
|
|
38
|
+
}
|
|
39
|
+
return node_path_1.default.join(process.env.XDG_CONFIG_HOME ?? node_path_1.default.join((0, node_os_1.homedir)(), ".config"), "coderook");
|
|
40
|
+
}
|
|
41
|
+
const tokenFile = () => node_path_1.default.join(configDirectory(), "token");
|
|
42
|
+
const linksFile = () => node_path_1.default.join(configDirectory(), "links.json");
|
|
43
|
+
/** Write a file only its owner can read, and atomically. */
|
|
44
|
+
async function writePrivate(target, body) {
|
|
45
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
46
|
+
const temporary = `${target}.tmp`;
|
|
47
|
+
await (0, promises_1.writeFile)(temporary, body, "utf8");
|
|
48
|
+
// Windows ignores the mode; on everything else this is what keeps the
|
|
49
|
+
// token out of other accounts' reach.
|
|
50
|
+
await (0, promises_1.chmod)(temporary, 0o600).catch(() => undefined);
|
|
51
|
+
await (0, promises_1.rename)(temporary, target);
|
|
52
|
+
}
|
|
53
|
+
async function storeToken(token) {
|
|
54
|
+
await writePrivate(tokenFile(), `${token.trim()}\n`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The token, from the environment first so continuous integration can supply
|
|
58
|
+
* one without writing anything to disk.
|
|
59
|
+
*/
|
|
60
|
+
async function loadToken() {
|
|
61
|
+
const fromEnvironment = process.env.CODEROOK_TOKEN?.trim();
|
|
62
|
+
if (fromEnvironment)
|
|
63
|
+
return fromEnvironment;
|
|
64
|
+
try {
|
|
65
|
+
return (await (0, promises_1.readFile)(tokenFile(), "utf8")).trim();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function clearToken() {
|
|
72
|
+
await (0, promises_1.rm)(tokenFile(), { force: true });
|
|
73
|
+
}
|
|
74
|
+
exports.credentials = {
|
|
75
|
+
origin: apiOrigin,
|
|
76
|
+
token: loadToken,
|
|
77
|
+
};
|
|
78
|
+
function keyFor(localPath) {
|
|
79
|
+
return node_path_1.default.resolve(localPath).toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
async function readLinks() {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(await (0, promises_1.readFile)(linksFile(), "utf8"));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readLink(localPath) {
|
|
90
|
+
return (await readLinks())[keyFor(localPath)] ?? null;
|
|
91
|
+
}
|
|
92
|
+
async function writeLink(localPath, link) {
|
|
93
|
+
const links = await readLinks();
|
|
94
|
+
links[keyFor(localPath)] = link;
|
|
95
|
+
await writePrivate(linksFile(), JSON.stringify(links, null, 2));
|
|
96
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FOOTER_MAGIC = exports.MANIFEST_MAGIC = exports.CHUNK_MAGIC = exports.HEADER_MAGIC = void 0;
|
|
7
|
+
exports.cutPoints = cutPoints;
|
|
8
|
+
exports.packBundle = packBundle;
|
|
9
|
+
exports.readManifest = readManifest;
|
|
10
|
+
exports.unpackBundle = unpackBundle;
|
|
11
|
+
exports.collectEntries = collectEntries;
|
|
12
|
+
/**
|
|
13
|
+
* The CodeBox `.cbx` bundle, as `prototype/cbx_benchmark.py` writes it.
|
|
14
|
+
*
|
|
15
|
+
* This is a port of that format, not a second one. The byte layout, the
|
|
16
|
+
* manifest keys, the codec rule and the chunk index are taken from the
|
|
17
|
+
* prototype so that an archive written here can be opened by
|
|
18
|
+
* `codebox-compression.cmd unpack`, and an archive written by that tool can
|
|
19
|
+
* be opened here — including format version 2 solid packs.
|
|
20
|
+
*
|
|
21
|
+
* header <4sHH16s> CBX1, format version, flags, bundle id
|
|
22
|
+
* chunk frame <4sB3xQQ32s32s> CHNK, codec, raw size, stored size, digests
|
|
23
|
+
* manifest <4sQQ32s> MANF, raw length, stored length, raw digest
|
|
24
|
+
* footer <4sQQ32s> CBXF, manifest offset, record length, digest
|
|
25
|
+
*
|
|
26
|
+
* Chunks are Zstandard level 3, stored raw unless compression saves at least
|
|
27
|
+
* two percent, and identified by the SHA-256 of their original bytes.
|
|
28
|
+
*/
|
|
29
|
+
const node_crypto_1 = require("node:crypto");
|
|
30
|
+
const node_fs_1 = require("node:fs");
|
|
31
|
+
const promises_1 = require("node:fs/promises");
|
|
32
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
33
|
+
const node_zlib_1 = require("node:zlib");
|
|
34
|
+
exports.HEADER_MAGIC = "CBX1";
|
|
35
|
+
exports.CHUNK_MAGIC = "CHNK";
|
|
36
|
+
exports.MANIFEST_MAGIC = "MANF";
|
|
37
|
+
exports.FOOTER_MAGIC = "CBXF";
|
|
38
|
+
const HEADER_BYTES = 24;
|
|
39
|
+
const CHUNK_HEADER_BYTES = 88;
|
|
40
|
+
const MANIFEST_HEADER_BYTES = 52;
|
|
41
|
+
const FOOTER_BYTES = 52;
|
|
42
|
+
/** The prototype writes 2 and accepts 1; without solid packs this is a 1. */
|
|
43
|
+
const WRITTEN_FORMAT_VERSION = 1;
|
|
44
|
+
const SUPPORTED_FORMAT_VERSIONS = new Set([1, 2]);
|
|
45
|
+
const CODEC_NONE = 0;
|
|
46
|
+
const CODEC_ZSTD = 1;
|
|
47
|
+
const ZSTD_LEVEL = 3;
|
|
48
|
+
const MIN_COMPRESSION_SAVING = 0.02;
|
|
49
|
+
const READ_SIZE = 8 * 1024 * 1024;
|
|
50
|
+
const MIN_CHUNK_SIZE = 2 * 1024 * 1024;
|
|
51
|
+
const AVG_CHUNK_SIZE = 8 * 1024 * 1024;
|
|
52
|
+
const MAX_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
53
|
+
const zstd = (raw) => (0, node_zlib_1.zstdCompressSync)(raw, {
|
|
54
|
+
params: { [node_zlib_1.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
|
|
55
|
+
});
|
|
56
|
+
function sha256(data) {
|
|
57
|
+
return (0, node_crypto_1.createHash)("sha256").update(data).digest("hex");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Streaming content-defined chunking, as FastCDC does it: a rolling gear
|
|
61
|
+
* hash cuts where the content says to, so inserting bytes early in a large
|
|
62
|
+
* file re-cuts one chunk instead of every chunk after it. The prototype uses
|
|
63
|
+
* the `fastcdc` package; cut positions do not have to agree between writers
|
|
64
|
+
* for an archive to be readable, because the manifest records the chunk list.
|
|
65
|
+
*/
|
|
66
|
+
const GEAR = (() => {
|
|
67
|
+
const table = new Uint32Array(256);
|
|
68
|
+
let seed = 0x9e3779b9;
|
|
69
|
+
for (let index = 0; index < 256; index += 1) {
|
|
70
|
+
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
|
|
71
|
+
table[index] = seed;
|
|
72
|
+
}
|
|
73
|
+
return table;
|
|
74
|
+
})();
|
|
75
|
+
const NORMAL_MASK = 0x0003_5000;
|
|
76
|
+
const SMALL_MASK = 0x0003_5403;
|
|
77
|
+
/** Cut positions inside `buffer`; the tail is left for the next read. */
|
|
78
|
+
function cutPoints(buffer) {
|
|
79
|
+
const cuts = [];
|
|
80
|
+
let from = 0;
|
|
81
|
+
let hash = 0;
|
|
82
|
+
for (let at = 0; at < buffer.length; at += 1) {
|
|
83
|
+
hash = ((hash << 1) + GEAR[buffer[at]]) >>> 0;
|
|
84
|
+
const length = at - from + 1;
|
|
85
|
+
if (length < MIN_CHUNK_SIZE)
|
|
86
|
+
continue;
|
|
87
|
+
const mask = length < AVG_CHUNK_SIZE ? SMALL_MASK : NORMAL_MASK;
|
|
88
|
+
if (length >= MAX_CHUNK_SIZE || (hash & mask) === 0) {
|
|
89
|
+
cuts.push(at + 1);
|
|
90
|
+
from = at + 1;
|
|
91
|
+
hash = 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return cuts;
|
|
95
|
+
}
|
|
96
|
+
function chunkFrame(raw) {
|
|
97
|
+
const rawDigest = (0, node_crypto_1.createHash)("sha256").update(raw).digest();
|
|
98
|
+
let codec = CODEC_NONE;
|
|
99
|
+
let stored = raw;
|
|
100
|
+
if (raw.length) {
|
|
101
|
+
const squeezed = zstd(raw);
|
|
102
|
+
// Raw storage unless compression is worth at least the stated margin.
|
|
103
|
+
if (squeezed.length <= raw.length * (1 - MIN_COMPRESSION_SAVING)) {
|
|
104
|
+
codec = CODEC_ZSTD;
|
|
105
|
+
stored = squeezed;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const storedDigest = (0, node_crypto_1.createHash)("sha256").update(stored).digest();
|
|
109
|
+
const head = Buffer.alloc(CHUNK_HEADER_BYTES);
|
|
110
|
+
head.write(exports.CHUNK_MAGIC, 0, "latin1");
|
|
111
|
+
head.writeUInt8(codec, 4);
|
|
112
|
+
// Three padding bytes, as the "3x" in the struct format.
|
|
113
|
+
head.writeBigUInt64LE(BigInt(raw.length), 8);
|
|
114
|
+
head.writeBigUInt64LE(BigInt(stored.length), 16);
|
|
115
|
+
rawDigest.copy(head, 24);
|
|
116
|
+
storedDigest.copy(head, 56);
|
|
117
|
+
return {
|
|
118
|
+
frame: Buffer.concat([head, stored]),
|
|
119
|
+
record: {
|
|
120
|
+
raw_size: raw.length,
|
|
121
|
+
stored_size: stored.length,
|
|
122
|
+
codec,
|
|
123
|
+
stored_sha256: storedDigest.toString("hex"),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Write a bundle. Frames stream out as files are read, and the completed
|
|
129
|
+
* file is renamed into place only at the end, so an interrupted pack leaves
|
|
130
|
+
* a `.partial` that is never mistaken for a finished archive.
|
|
131
|
+
*/
|
|
132
|
+
async function packBundle(request, report) {
|
|
133
|
+
const partial = `${request.target}.partial`;
|
|
134
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(node_path_1.default.resolve(request.target)), { recursive: true });
|
|
135
|
+
const handle = await (0, promises_1.open)(partial, "w");
|
|
136
|
+
const chunks = {};
|
|
137
|
+
const files = [];
|
|
138
|
+
const directories = new Set();
|
|
139
|
+
let offset = 0;
|
|
140
|
+
let duplicateBytes = 0;
|
|
141
|
+
let compressedChunks = 0;
|
|
142
|
+
let sourceBytes = 0;
|
|
143
|
+
const append = async (data) => {
|
|
144
|
+
const at = offset;
|
|
145
|
+
await handle.write(data, 0, data.length, at);
|
|
146
|
+
offset += data.length;
|
|
147
|
+
return at;
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
151
|
+
header.write(exports.HEADER_MAGIC, 0, "latin1");
|
|
152
|
+
header.writeUInt16LE(WRITTEN_FORMAT_VERSION, 4);
|
|
153
|
+
header.writeUInt16LE(0, 6);
|
|
154
|
+
(0, node_crypto_1.randomBytes)(16).copy(header, 8);
|
|
155
|
+
await append(header);
|
|
156
|
+
let totalBytes = 0;
|
|
157
|
+
const sizes = new Map();
|
|
158
|
+
for (const relative of request.entries) {
|
|
159
|
+
const info = await (0, promises_1.stat)(node_path_1.default.join(request.root, relative));
|
|
160
|
+
sizes.set(relative, {
|
|
161
|
+
size: info.size,
|
|
162
|
+
mtimeNs: Number(info.mtimeMs) * 1e6,
|
|
163
|
+
mode: info.mode,
|
|
164
|
+
});
|
|
165
|
+
totalBytes += info.size;
|
|
166
|
+
const parent = node_path_1.default.posix.dirname(relative);
|
|
167
|
+
if (parent && parent !== ".")
|
|
168
|
+
directories.add(parent);
|
|
169
|
+
}
|
|
170
|
+
for (const [position, relative] of request.entries.entries()) {
|
|
171
|
+
const details = sizes.get(relative);
|
|
172
|
+
report?.({
|
|
173
|
+
files: position,
|
|
174
|
+
totalFiles: request.entries.length,
|
|
175
|
+
bytes: sourceBytes,
|
|
176
|
+
totalBytes,
|
|
177
|
+
path: relative,
|
|
178
|
+
percent: Math.round((sourceBytes / Math.max(totalBytes, 1)) * 100),
|
|
179
|
+
});
|
|
180
|
+
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
181
|
+
const references = [];
|
|
182
|
+
const emit = async (raw) => {
|
|
183
|
+
const digest = sha256(raw);
|
|
184
|
+
references.push(digest);
|
|
185
|
+
if (chunks[digest]) {
|
|
186
|
+
duplicateBytes += raw.length;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const { frame, record } = chunkFrame(raw);
|
|
190
|
+
const at = await append(frame);
|
|
191
|
+
chunks[digest] = { payload_offset: at + CHUNK_HEADER_BYTES, ...record };
|
|
192
|
+
if (record.codec === CODEC_ZSTD)
|
|
193
|
+
compressedChunks += 1;
|
|
194
|
+
};
|
|
195
|
+
let pending = Buffer.alloc(0);
|
|
196
|
+
for await (const piece of (0, node_fs_1.createReadStream)(node_path_1.default.join(request.root, relative), { highWaterMark: READ_SIZE })) {
|
|
197
|
+
const block = Buffer.from(piece);
|
|
198
|
+
whole.update(block);
|
|
199
|
+
pending = pending.length ? Buffer.concat([pending, block]) : block;
|
|
200
|
+
let from = 0;
|
|
201
|
+
for (const cut of cutPoints(pending)) {
|
|
202
|
+
await emit(pending.subarray(from, cut));
|
|
203
|
+
from = cut;
|
|
204
|
+
}
|
|
205
|
+
pending = pending.subarray(from);
|
|
206
|
+
}
|
|
207
|
+
if (pending.length)
|
|
208
|
+
await emit(pending);
|
|
209
|
+
files.push({
|
|
210
|
+
path: relative,
|
|
211
|
+
size: details.size,
|
|
212
|
+
mtime_ns: Math.round(details.mtimeNs),
|
|
213
|
+
mode: details.mode,
|
|
214
|
+
sha256: whole.digest("hex"),
|
|
215
|
+
chunks: references,
|
|
216
|
+
logical_stored_bytes: references.reduce((total, digest) => total + (chunks[digest]?.stored_size ?? 0), 0),
|
|
217
|
+
});
|
|
218
|
+
sourceBytes += details.size;
|
|
219
|
+
}
|
|
220
|
+
const manifest = {
|
|
221
|
+
format: "cbx-prototype",
|
|
222
|
+
format_version: WRITTEN_FORMAT_VERSION,
|
|
223
|
+
source_name: request.sourceName,
|
|
224
|
+
source_kind: request.sourceKind ?? "directory",
|
|
225
|
+
created_unix_ns: Date.now() * 1e6,
|
|
226
|
+
chunking: {
|
|
227
|
+
algorithm: "gear-streaming",
|
|
228
|
+
min_size: MIN_CHUNK_SIZE,
|
|
229
|
+
avg_size: AVG_CHUNK_SIZE,
|
|
230
|
+
max_size: MAX_CHUNK_SIZE,
|
|
231
|
+
read_size: READ_SIZE,
|
|
232
|
+
small_file_packing: { enabled: false },
|
|
233
|
+
},
|
|
234
|
+
compression: {
|
|
235
|
+
codec: "zstd",
|
|
236
|
+
level: ZSTD_LEVEL,
|
|
237
|
+
minimum_saving: MIN_COMPRESSION_SAVING,
|
|
238
|
+
},
|
|
239
|
+
directories: [...directories].sort(),
|
|
240
|
+
skipped_links: [],
|
|
241
|
+
files,
|
|
242
|
+
chunks,
|
|
243
|
+
};
|
|
244
|
+
const manifestRaw = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
245
|
+
const manifestStored = zstd(manifestRaw);
|
|
246
|
+
const manifestDigest = (0, node_crypto_1.createHash)("sha256").update(manifestRaw).digest();
|
|
247
|
+
const manifestHead = Buffer.alloc(MANIFEST_HEADER_BYTES);
|
|
248
|
+
manifestHead.write(exports.MANIFEST_MAGIC, 0, "latin1");
|
|
249
|
+
manifestHead.writeBigUInt64LE(BigInt(manifestRaw.length), 4);
|
|
250
|
+
manifestHead.writeBigUInt64LE(BigInt(manifestStored.length), 12);
|
|
251
|
+
manifestDigest.copy(manifestHead, 20);
|
|
252
|
+
const manifestRecord = Buffer.concat([manifestHead, manifestStored]);
|
|
253
|
+
const manifestOffset = await append(manifestRecord);
|
|
254
|
+
const footer = Buffer.alloc(FOOTER_BYTES);
|
|
255
|
+
footer.write(exports.FOOTER_MAGIC, 0, "latin1");
|
|
256
|
+
footer.writeBigUInt64LE(BigInt(manifestOffset), 4);
|
|
257
|
+
footer.writeBigUInt64LE(BigInt(manifestRecord.length), 12);
|
|
258
|
+
manifestDigest.copy(footer, 20);
|
|
259
|
+
await append(footer);
|
|
260
|
+
await handle.sync();
|
|
261
|
+
await handle.close();
|
|
262
|
+
await (0, promises_1.rm)(request.target, { force: true });
|
|
263
|
+
await (0, promises_1.rename)(partial, request.target);
|
|
264
|
+
return {
|
|
265
|
+
archiveBytes: offset,
|
|
266
|
+
sourceBytes,
|
|
267
|
+
uniqueChunks: Object.keys(chunks).length,
|
|
268
|
+
compressedChunks,
|
|
269
|
+
duplicateBytes,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
await handle.close().catch(() => undefined);
|
|
274
|
+
await (0, promises_1.rm)(partial, { force: true });
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Read a bundle's manifest without touching a single chunk payload. */
|
|
279
|
+
async function readManifest(source) {
|
|
280
|
+
const handle = await (0, promises_1.open)(source, "r");
|
|
281
|
+
try {
|
|
282
|
+
const size = (await handle.stat()).size;
|
|
283
|
+
if (size < HEADER_BYTES + FOOTER_BYTES) {
|
|
284
|
+
throw new Error("That file is too small to be a CodeBox bundle");
|
|
285
|
+
}
|
|
286
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
287
|
+
await handle.read(header, 0, HEADER_BYTES, 0);
|
|
288
|
+
if (header.subarray(0, 4).toString("latin1") !== exports.HEADER_MAGIC) {
|
|
289
|
+
throw new Error("That file is not a CodeBox bundle");
|
|
290
|
+
}
|
|
291
|
+
const version = header.readUInt16LE(4);
|
|
292
|
+
if (!SUPPORTED_FORMAT_VERSIONS.has(version)) {
|
|
293
|
+
throw new Error(`This bundle needs a newer CodeRook (format ${version})`);
|
|
294
|
+
}
|
|
295
|
+
const footer = Buffer.alloc(FOOTER_BYTES);
|
|
296
|
+
await handle.read(footer, 0, FOOTER_BYTES, size - FOOTER_BYTES);
|
|
297
|
+
if (footer.subarray(0, 4).toString("latin1") !== exports.FOOTER_MAGIC) {
|
|
298
|
+
throw new Error("That bundle is incomplete");
|
|
299
|
+
}
|
|
300
|
+
const manifestOffset = Number(footer.readBigUInt64LE(4));
|
|
301
|
+
const recordLength = Number(footer.readBigUInt64LE(12));
|
|
302
|
+
const expected = footer.subarray(20, 52).toString("hex");
|
|
303
|
+
const record = Buffer.alloc(recordLength);
|
|
304
|
+
await handle.read(record, 0, recordLength, manifestOffset);
|
|
305
|
+
if (record.subarray(0, 4).toString("latin1") !== exports.MANIFEST_MAGIC) {
|
|
306
|
+
throw new Error("That bundle's manifest record is not where it should be");
|
|
307
|
+
}
|
|
308
|
+
const rawLength = Number(record.readBigUInt64LE(4));
|
|
309
|
+
const storedLength = Number(record.readBigUInt64LE(12));
|
|
310
|
+
if (record.subarray(20, 52).toString("hex") !== expected) {
|
|
311
|
+
throw new Error("That bundle's manifest digests disagree");
|
|
312
|
+
}
|
|
313
|
+
const stored = record.subarray(MANIFEST_HEADER_BYTES, MANIFEST_HEADER_BYTES + storedLength);
|
|
314
|
+
const raw = (0, node_zlib_1.zstdDecompressSync)(stored);
|
|
315
|
+
if (raw.length !== rawLength || sha256(raw) !== expected) {
|
|
316
|
+
throw new Error("That bundle's manifest does not match its checksum");
|
|
317
|
+
}
|
|
318
|
+
return JSON.parse(raw.toString("utf8"));
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await handle.close();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Extract a bundle into `destination`. Everything is written to a staging
|
|
326
|
+
* directory beside it and moved into place only once every file has been
|
|
327
|
+
* verified against the digest the manifest recorded, so a damaged archive
|
|
328
|
+
* cannot leave a half-written tree looking like a finished project.
|
|
329
|
+
*/
|
|
330
|
+
async function unpackBundle(source, destination, report) {
|
|
331
|
+
const manifest = await readManifest(source);
|
|
332
|
+
const handle = await (0, promises_1.open)(source, "r");
|
|
333
|
+
const staging = `${destination}.incoming`;
|
|
334
|
+
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
335
|
+
// Solid packs mean several small files share one decoded chunk, so a
|
|
336
|
+
// bounded cache stops the same block being decompressed for each of them.
|
|
337
|
+
const cache = new Map();
|
|
338
|
+
let cached = 0;
|
|
339
|
+
const chunkBytes = async (digest) => {
|
|
340
|
+
const held = cache.get(digest);
|
|
341
|
+
if (held)
|
|
342
|
+
return held;
|
|
343
|
+
const record = manifest.chunks[digest];
|
|
344
|
+
if (!record)
|
|
345
|
+
throw new Error(`The bundle is missing chunk ${digest.slice(0, 12)}`);
|
|
346
|
+
const stored = Buffer.alloc(record.stored_size);
|
|
347
|
+
await handle.read(stored, 0, record.stored_size, record.payload_offset);
|
|
348
|
+
if (sha256(stored) !== record.stored_sha256) {
|
|
349
|
+
throw new Error("A chunk in that bundle is damaged");
|
|
350
|
+
}
|
|
351
|
+
const raw = record.codec === CODEC_ZSTD ? (0, node_zlib_1.zstdDecompressSync)(stored) : stored;
|
|
352
|
+
if (raw.length !== record.raw_size || sha256(raw) !== digest) {
|
|
353
|
+
throw new Error("A chunk in that bundle does not match its identity");
|
|
354
|
+
}
|
|
355
|
+
if (cached + raw.length <= 64 * 1024 * 1024) {
|
|
356
|
+
cache.set(digest, raw);
|
|
357
|
+
cached += raw.length;
|
|
358
|
+
}
|
|
359
|
+
return raw;
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
const totalBytes = manifest.files.reduce((total, file) => total + file.size, 0);
|
|
363
|
+
let written = 0;
|
|
364
|
+
let bytes = 0;
|
|
365
|
+
for (const directory of manifest.directories ?? []) {
|
|
366
|
+
const parts = safeParts(directory);
|
|
367
|
+
if (parts.length)
|
|
368
|
+
await (0, promises_1.mkdir)(node_path_1.default.join(staging, ...parts), { recursive: true });
|
|
369
|
+
}
|
|
370
|
+
for (const record of manifest.files) {
|
|
371
|
+
report?.({
|
|
372
|
+
files: written,
|
|
373
|
+
totalFiles: manifest.files.length,
|
|
374
|
+
bytes,
|
|
375
|
+
totalBytes,
|
|
376
|
+
path: record.path,
|
|
377
|
+
percent: Math.round((bytes / Math.max(totalBytes, 1)) * 100),
|
|
378
|
+
});
|
|
379
|
+
const parts = safeParts(record.path);
|
|
380
|
+
if (!parts.length)
|
|
381
|
+
throw new Error(`Unsafe path in bundle: ${record.path}`);
|
|
382
|
+
const full = node_path_1.default.join(staging, ...parts);
|
|
383
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
384
|
+
const pieces = [];
|
|
385
|
+
if (record.segments) {
|
|
386
|
+
for (const segment of record.segments) {
|
|
387
|
+
const raw = await chunkBytes(segment.chunk);
|
|
388
|
+
const end = segment.offset + segment.length;
|
|
389
|
+
if (segment.offset < 0 || end > raw.length) {
|
|
390
|
+
throw new Error(`Invalid solid segment in ${record.path}`);
|
|
391
|
+
}
|
|
392
|
+
pieces.push(raw.subarray(segment.offset, end));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
for (const digest of record.chunks ?? [])
|
|
397
|
+
pieces.push(await chunkBytes(digest));
|
|
398
|
+
}
|
|
399
|
+
const contents = Buffer.concat(pieces);
|
|
400
|
+
if (contents.length !== record.size || sha256(contents) !== record.sha256) {
|
|
401
|
+
throw new Error(`${record.path} did not survive the bundle intact`);
|
|
402
|
+
}
|
|
403
|
+
await (0, promises_1.writeFile)(full, contents);
|
|
404
|
+
written += 1;
|
|
405
|
+
bytes += contents.length;
|
|
406
|
+
}
|
|
407
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(node_path_1.default.resolve(destination)), { recursive: true });
|
|
408
|
+
await (0, promises_1.rm)(destination, { recursive: true, force: true });
|
|
409
|
+
await (0, promises_1.rename)(staging, destination);
|
|
410
|
+
report?.({
|
|
411
|
+
files: written,
|
|
412
|
+
totalFiles: manifest.files.length,
|
|
413
|
+
bytes,
|
|
414
|
+
totalBytes,
|
|
415
|
+
path: "Done",
|
|
416
|
+
percent: 100,
|
|
417
|
+
});
|
|
418
|
+
return { files: written, bytes };
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
await handle.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Reject anything that would escape the destination before it is used. */
|
|
429
|
+
function safeParts(relative) {
|
|
430
|
+
const parts = relative.split(/[\\/]/).filter((part) => part && part !== ".");
|
|
431
|
+
if (parts.some((part) => part === ".." || part.includes("\0")))
|
|
432
|
+
return [];
|
|
433
|
+
if (node_path_1.default.isAbsolute(relative) || /^[a-zA-Z]:/.test(relative))
|
|
434
|
+
return [];
|
|
435
|
+
return parts;
|
|
436
|
+
}
|
|
437
|
+
/** Every file under `root`, relative and POSIX-separated, sorted. */
|
|
438
|
+
async function collectEntries(root) {
|
|
439
|
+
const found = [];
|
|
440
|
+
const walk = async (directory) => {
|
|
441
|
+
for (const entry of await (0, promises_1.readdir)(directory, { withFileTypes: true })) {
|
|
442
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
443
|
+
if (entry.isSymbolicLink())
|
|
444
|
+
continue;
|
|
445
|
+
if (entry.isDirectory()) {
|
|
446
|
+
await walk(full);
|
|
447
|
+
}
|
|
448
|
+
else if (entry.isFile()) {
|
|
449
|
+
found.push(node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/"));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
await walk(root);
|
|
454
|
+
return found.sort();
|
|
455
|
+
}
|