@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.
@@ -0,0 +1,433 @@
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.Uploader = exports.UploadCancelled = void 0;
7
+ /**
8
+ * Sending a version to CodeRook.
9
+ *
10
+ * Every file becomes a content-addressed object, then one version record
11
+ * names them all. Objects are addressed by the digest of their contents, so
12
+ * a file that has not changed since the last version costs nothing to send
13
+ * again — the server already has it.
14
+ */
15
+ const node_crypto_1 = require("node:crypto");
16
+ const node_fs_1 = require("node:fs");
17
+ const promises_1 = require("node:fs/promises");
18
+ const node_path_1 = __importDefault(require("node:path"));
19
+ const worktree_js_1 = require("./worktree.js");
20
+ /** Above this the API insists on a multipart session. */
21
+ const DIRECT_LIMIT = 95 * 1024 * 1024;
22
+ const PART_SIZE = 32 * 1024 * 1024;
23
+ const MEDIA_TYPES = {
24
+ ".css": "text/css",
25
+ ".csv": "text/csv",
26
+ ".gif": "image/gif",
27
+ ".html": "text/html",
28
+ ".jpeg": "image/jpeg",
29
+ ".jpg": "image/jpeg",
30
+ ".js": "text/javascript",
31
+ ".json": "application/json",
32
+ ".md": "text/markdown",
33
+ ".mjs": "text/javascript",
34
+ ".pdf": "application/pdf",
35
+ ".png": "image/png",
36
+ ".py": "text/x-python",
37
+ ".svg": "image/svg+xml",
38
+ ".ts": "text/typescript",
39
+ ".tsx": "text/typescript",
40
+ ".txt": "text/plain",
41
+ ".wasm": "application/wasm",
42
+ ".webp": "image/webp",
43
+ ".xml": "application/xml",
44
+ ".yaml": "application/yaml",
45
+ ".yml": "application/yaml",
46
+ ".zip": "application/zip",
47
+ };
48
+ function mediaTypeOf(file) {
49
+ return MEDIA_TYPES[node_path_1.default.extname(file).toLowerCase()] ?? "application/octet-stream";
50
+ }
51
+ /** Hash without holding the file in memory; some of these are large. */
52
+ async function digestOf(full) {
53
+ const hash = (0, node_crypto_1.createHash)("sha256");
54
+ for await (const chunk of (0, node_fs_1.createReadStream)(full))
55
+ hash.update(chunk);
56
+ return hash.digest("hex");
57
+ }
58
+ class UploadCancelled extends Error {
59
+ constructor() {
60
+ super("Upload cancelled");
61
+ }
62
+ }
63
+ exports.UploadCancelled = UploadCancelled;
64
+ class Uploader {
65
+ credentials;
66
+ aborted = false;
67
+ controller = new AbortController();
68
+ constructor(credentials) {
69
+ this.credentials = credentials;
70
+ }
71
+ cancel() {
72
+ this.aborted = true;
73
+ this.controller.abort();
74
+ }
75
+ check() {
76
+ if (this.aborted)
77
+ throw new UploadCancelled();
78
+ }
79
+ async call(route, init) {
80
+ const token = await this.credentials.token();
81
+ if (!token)
82
+ throw new Error("Sign in again before uploading");
83
+ const response = await fetch(`${this.credentials.origin()}${route}`, {
84
+ method: init.method,
85
+ headers: {
86
+ accept: "application/json",
87
+ authorization: `Bearer ${token}`,
88
+ "user-agent": "CodeRook/0.1",
89
+ ...(init.contentType ? { "content-type": init.contentType } : {}),
90
+ },
91
+ body: init.body,
92
+ signal: this.controller.signal,
93
+ });
94
+ const text = await response.text();
95
+ const body = text ? JSON.parse(text) : {};
96
+ if (!response.ok) {
97
+ const message = body?.error?.message ?? `${route} failed (${response.status})`;
98
+ throw new Error(message);
99
+ }
100
+ return body;
101
+ }
102
+ async run(request, report) {
103
+ // A version is a snapshot, not a delta, so it has to name every file in
104
+ // the project — not merely the ones being sent this time. Anything
105
+ // unchanged keeps the object the previous version already pointed at.
106
+ const rules = await (0, worktree_js_1.readRules)(request.localPath);
107
+ const everything = await (0, worktree_js_1.changedFiles)(request.localPath, rules, null);
108
+ const ticked = new Set(request.include);
109
+ if (!ticked.size)
110
+ throw new Error("Nothing is selected to upload");
111
+ const prior = request.repositoryId
112
+ ? await this.latestVersionFiles(request.repositoryId)
113
+ : new Map();
114
+ // Ticked files are sent; everything else the project still has keeps the
115
+ // copy already stored, and a file that is new but unticked is left out.
116
+ const sending = everything.filter((file) => ticked.has(file.path));
117
+ const reused = everything.filter((file) => !ticked.has(file.path) && prior.has(file.path));
118
+ /*
119
+ A ticked path that the rescan cannot see is either a deletion or a file
120
+ that has gone since the changes list was drawn. Deletions are meant to
121
+ disappear; anything else silently missing from a version is data loss,
122
+ so it is collected and reported rather than quietly skipped.
123
+ */
124
+ const onDisk = new Set(everything.map((file) => file.path));
125
+ const vanished = [...ticked].filter((chosen) => !onDisk.has(chosen) && !prior.has(chosen));
126
+ // 1. Measure and hash. This is what makes an unchanged file free.
127
+ const declarations = [];
128
+ let totalBytes = 0;
129
+ for (const [index, file] of sending.entries()) {
130
+ this.check();
131
+ const full = node_path_1.default.join(request.localPath, file.path);
132
+ let size;
133
+ try {
134
+ size = (await (0, promises_1.stat)(full)).size;
135
+ }
136
+ catch {
137
+ // Unreadable now, though it was listed a moment ago. Skipping it
138
+ // would publish a version quietly missing a file the person chose.
139
+ vanished.push(file.path);
140
+ continue;
141
+ }
142
+ report({
143
+ stage: "hash",
144
+ files: index,
145
+ totalFiles: sending.length,
146
+ bytes: totalBytes,
147
+ totalBytes: 0,
148
+ path: file.path,
149
+ percent: Math.round((index / sending.length) * 20),
150
+ bytesPerSecond: 0,
151
+ });
152
+ declarations.push({
153
+ path: file.path,
154
+ full,
155
+ size,
156
+ sha256: await digestOf(full),
157
+ mediaType: mediaTypeOf(file.path),
158
+ });
159
+ totalBytes += size;
160
+ }
161
+ if (!declarations.length)
162
+ throw new Error("Nothing is selected to upload");
163
+ // 2. The project needs somewhere on the account to live.
164
+ this.check();
165
+ let repositoryId = request.repositoryId;
166
+ if (!repositoryId) {
167
+ const slug = request.projectName
168
+ .trim()
169
+ .toLowerCase()
170
+ .replace(/[^a-z0-9]+/g, "-")
171
+ .replace(/^-|-$/g, "") || "project";
172
+ try {
173
+ const created = await this.call("/v1/repositories", {
174
+ method: "POST",
175
+ contentType: "application/json",
176
+ body: JSON.stringify({
177
+ slug,
178
+ displayName: request.projectName.trim() || slug,
179
+ description: "",
180
+ visibility: "private",
181
+ }),
182
+ });
183
+ repositoryId = created.id;
184
+ }
185
+ catch (error) {
186
+ // The account already has a project of this name — which happens
187
+ // after a reinstall, or when the folder has moved. Saving into it is
188
+ // what was meant; failing would strand the folder.
189
+ const existing = await this.findBySlug(slug);
190
+ if (!existing)
191
+ throw error;
192
+ repositoryId = existing;
193
+ }
194
+ }
195
+ // 3. Send the objects.
196
+ const uploaded = [];
197
+ let sentBytes = 0;
198
+ const startedAt = Date.now();
199
+ const rate = () => {
200
+ const seconds = (Date.now() - startedAt) / 1000;
201
+ return seconds > 0.2 ? Math.round(sentBytes / seconds) : 0;
202
+ };
203
+ for (const [index, declaration] of declarations.entries()) {
204
+ this.check();
205
+ report({
206
+ stage: "upload",
207
+ files: index,
208
+ totalFiles: declarations.length,
209
+ bytes: sentBytes,
210
+ totalBytes,
211
+ path: declaration.path,
212
+ percent: 20 + Math.round((sentBytes / Math.max(totalBytes, 1)) * 72),
213
+ bytesPerSecond: rate(),
214
+ });
215
+ const result = declaration.size <= DIRECT_LIMIT
216
+ ? await this.putDirect(repositoryId, declaration).catch((error) => {
217
+ // Naming the file turns "it failed" into something actionable.
218
+ const reason = error instanceof Error ? error.message : String(error);
219
+ throw new Error(`${declaration.path}: ${reason}`);
220
+ })
221
+ : await this.putMultipart(repositoryId, declaration, (offset) => {
222
+ report({
223
+ stage: "upload",
224
+ files: index,
225
+ totalFiles: declarations.length,
226
+ bytes: sentBytes + offset,
227
+ totalBytes,
228
+ path: declaration.path,
229
+ percent: 20 +
230
+ Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
231
+ bytesPerSecond: rate(),
232
+ });
233
+ });
234
+ uploaded.push({
235
+ path: declaration.path,
236
+ objectId: result.objectId,
237
+ sourceSize: declaration.size,
238
+ storedSize: result.size,
239
+ mediaType: declaration.mediaType,
240
+ });
241
+ sentBytes += declaration.size;
242
+ }
243
+ // 4. Name the version, which is what makes the upload visible.
244
+ this.check();
245
+ report({
246
+ stage: "publish",
247
+ files: declarations.length,
248
+ totalFiles: declarations.length,
249
+ bytes: sentBytes,
250
+ totalBytes,
251
+ path: "Recording the version",
252
+ percent: 96,
253
+ bytesPerSecond: rate(),
254
+ });
255
+ // The version names everything the project holds: what was just sent,
256
+ // plus every file that kept the object the last version pointed at.
257
+ const contents = [
258
+ ...uploaded,
259
+ ...reused.map((file) => {
260
+ const held = prior.get(file.path);
261
+ return {
262
+ path: file.path,
263
+ objectId: held.objectId,
264
+ sourceSize: held.sourceSize,
265
+ storedSize: held.storedSize,
266
+ mediaType: held.mediaType,
267
+ };
268
+ }),
269
+ ].sort((left, right) => left.path.localeCompare(right.path));
270
+ /*
271
+ Last check before the version is written: everything the person ticked
272
+ has to be in it. Reaching this with something missing would publish a
273
+ snapshot that silently lacked a file, which is the one failure a backup
274
+ tool must never have.
275
+ */
276
+ const named = new Set(contents.map((item) => item.path));
277
+ const dropped = [...new Set([...vanished, ...[...ticked].filter((chosen) => !named.has(chosen))])];
278
+ if (dropped.length) {
279
+ const shown = dropped.slice(0, 5).join(", ");
280
+ const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
281
+ throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be read, ` +
282
+ `so nothing was saved: ${shown}${rest}. Nothing was changed on your account.`);
283
+ }
284
+ const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
285
+ const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
286
+ const completed = await this.call(`/v1/repositories/${repositoryId}/versions`, {
287
+ method: "POST",
288
+ contentType: "application/json",
289
+ body: JSON.stringify({
290
+ message: request.message.trim() || "Saved from the CodeRook desktop app",
291
+ sourceSize: sourceBytes,
292
+ storedSize: storedBytes,
293
+ files: contents.map((item) => ({
294
+ path: item.path,
295
+ objectId: item.objectId,
296
+ sourceSize: item.sourceSize,
297
+ storedSize: item.storedSize,
298
+ mediaType: item.mediaType,
299
+ executable: false,
300
+ })),
301
+ }),
302
+ });
303
+ report({
304
+ stage: "done",
305
+ files: declarations.length,
306
+ totalFiles: declarations.length,
307
+ bytes: sentBytes,
308
+ totalBytes,
309
+ path: `Version ${completed.version.sequence} saved`,
310
+ percent: 100,
311
+ bytesPerSecond: rate(),
312
+ });
313
+ return {
314
+ repositoryId,
315
+ versionId: completed.version.id,
316
+ sequence: completed.version.sequence,
317
+ sourceBytes,
318
+ storedBytes,
319
+ sentBytes,
320
+ sentFiles: uploaded.length,
321
+ reusedFiles: reused.length,
322
+ // A file that kept its old object records the digest of *that* copy,
323
+ // not of the file on disk, so an unticked edit is still pending next
324
+ // time rather than looking as though it had been saved.
325
+ manifest: {
326
+ ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
327
+ ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
328
+ },
329
+ };
330
+ }
331
+ /** What the newest version holds, so unchanged files can point at it. */
332
+ async latestVersionFiles(repositoryId) {
333
+ const held = new Map();
334
+ try {
335
+ const versions = await this.call(`/v1/repositories/${repositoryId}/versions`, { method: "GET" });
336
+ // The API returns them newest first.
337
+ const latest = (versions.versions ?? [])[0];
338
+ if (!latest?.id)
339
+ return held;
340
+ const body = await this.call(`/v1/repositories/${repositoryId}/versions/${latest.id}/files`, {
341
+ method: "GET",
342
+ });
343
+ for (const row of body.files ?? []) {
344
+ held.set(String(row.path ?? ""), {
345
+ objectId: String(row.objectId ?? ""),
346
+ sha256: String(row.sha256 ?? ""),
347
+ sourceSize: Number(row.sourceSize ?? 0),
348
+ storedSize: Number(row.storedSize ?? 0),
349
+ mediaType: String(row.mediaType ?? "application/octet-stream"),
350
+ });
351
+ }
352
+ }
353
+ catch {
354
+ // Without the previous list the version can still be written; it will
355
+ // simply contain only what is being sent now.
356
+ }
357
+ return held;
358
+ }
359
+ /** The account's repository with this slug, if it has one. */
360
+ async findBySlug(slug) {
361
+ try {
362
+ const body = await this.call("/v1/repositories", { method: "GET" });
363
+ const found = (body.repositories ?? []).find((row) => row.slug === slug);
364
+ return found?.id ?? null;
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ }
370
+ async putDirect(repositoryId, declaration) {
371
+ const body = await (0, promises_1.readFile)(declaration.full);
372
+ // The file was hashed earlier, and a live file — a database, a log — can
373
+ // change in between. The bytes about to be sent are what must be
374
+ // described, so the digest is taken from them rather than trusted.
375
+ const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
376
+ if (digest !== declaration.sha256) {
377
+ declaration.sha256 = digest;
378
+ declaration.size = body.length;
379
+ }
380
+ return this.call(`/v1/repositories/${repositoryId}/objects/${digest}?kind=chunk&role=chunk`, {
381
+ method: "PUT",
382
+ contentType: declaration.mediaType,
383
+ body: new Uint8Array(body),
384
+ });
385
+ }
386
+ /** Files past the direct limit go up in parts under an upload session. */
387
+ async putMultipart(repositoryId, declaration, onOffset) {
388
+ const session = await this.call(`/v1/repositories/${repositoryId}/uploads`, {
389
+ method: "POST",
390
+ contentType: "application/json",
391
+ body: JSON.stringify({
392
+ sha256: declaration.sha256,
393
+ size: declaration.size,
394
+ mediaType: declaration.mediaType,
395
+ kind: "chunk",
396
+ repositoryRole: "chunk",
397
+ }),
398
+ });
399
+ let offset = 0;
400
+ let partNumber = 1;
401
+ try {
402
+ while (offset < declaration.size) {
403
+ this.check();
404
+ const end = Math.min(declaration.size, offset + PART_SIZE);
405
+ const part = await this.readSlice(declaration.full, offset, end);
406
+ await this.call(`/v1/uploads/${session.uploadSessionId}/parts/${partNumber}`, {
407
+ method: "PUT",
408
+ contentType: "application/octet-stream",
409
+ body: part,
410
+ });
411
+ offset = end;
412
+ partNumber += 1;
413
+ onOffset(offset);
414
+ }
415
+ return await this.call(`/v1/uploads/${session.uploadSessionId}/complete`, { method: "POST", contentType: "application/json", body: "{}" });
416
+ }
417
+ catch (error) {
418
+ // A half-finished session would hold storage forever.
419
+ await this.call(`/v1/uploads/${session.uploadSessionId}`, {
420
+ method: "DELETE",
421
+ }).catch(() => undefined);
422
+ throw error;
423
+ }
424
+ }
425
+ async readSlice(full, start, end) {
426
+ const chunks = [];
427
+ for await (const chunk of (0, node_fs_1.createReadStream)(full, { start, end: end - 1 })) {
428
+ chunks.push(chunk);
429
+ }
430
+ return new Uint8Array(Buffer.concat(chunks));
431
+ }
432
+ }
433
+ exports.Uploader = Uploader;