constructor-research-mcp 0.0.0-stage → 0.3.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/dist/files.js ADDED
@@ -0,0 +1,822 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, open, readdir, readFile } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
5
+ import { ToolError } from "./api.js";
6
+ import { listFilesInput, createFolderInput, syncFilesInput, movePathInput, deletePathInput, } from "./schemas.js";
7
+ /** Single PUTs above ~9 MB are rejected with 413, so larger files go in ranged chunks. */
8
+ const CHUNK = 4 * 1024 * 1024;
9
+ const MAX_FILE = 100 * 1024 * 1024;
10
+ const MAX_FILES = 2000;
11
+ /** Stop starting new uploads after this long so each call returns before client tool timeouts. */
12
+ const UPLOAD_WINDOW = 45000;
13
+ /** Parallel storage requests; 8 checksum requests at once drew 502s from the gateway. */
14
+ const PARALLEL = 4;
15
+ /** Constructor UI staging folder; hidden from listings and reused for our temp files. */
16
+ const TEMP = ".rolos-temp";
17
+ const TEMP_PREFIX = "mcp-upload-";
18
+ const SKIP = [
19
+ ".git",
20
+ "node_modules",
21
+ ".DS_Store",
22
+ "__pycache__",
23
+ ".venv",
24
+ ".env",
25
+ ];
26
+ const LIST_LIMIT = 200;
27
+ function bad(message) {
28
+ throw new ToolError("invalid_input", message);
29
+ }
30
+ const segs = (p) => p.split("/").filter(Boolean);
31
+ const href = (parts, folder = false) => parts.map(encodeURIComponent).join("/") + (folder && parts.length ? "/" : "");
32
+ const unxml = (s) => s
33
+ .replace(/&lt;/g, "<")
34
+ .replace(/&gt;/g, ">")
35
+ .replace(/&quot;/g, '"')
36
+ .replace(/&apos;/g, "'")
37
+ .replace(/&amp;/g, "&");
38
+ const tag = (xml, name) => xml.match(new RegExp(`<(?:\\w+:)?${name}\\b[^>]*>([\\s\\S]*?)</(?:\\w+:)?${name}>`))?.[1];
39
+ const cap = (rows) => rows.length > LIST_LIMIT
40
+ ? [...rows.slice(0, LIST_LIMIT), `…and ${rows.length - LIST_LIMIT} more`]
41
+ : rows;
42
+ /** Glob to RegExp: ** spans folders, * and ? stay within one name. */
43
+ function glob(p) {
44
+ const body = p
45
+ .replace(/^\/+|\/+$/g, "")
46
+ .split(/(\*\*\/|\*\*|\*|\?)/)
47
+ .map((x) => x === "**/"
48
+ ? "(?:.*/)?"
49
+ : x === "**"
50
+ ? ".*"
51
+ : x === "*"
52
+ ? "[^/]*"
53
+ : x === "?"
54
+ ? "[^/]"
55
+ : x.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
56
+ .join("");
57
+ return new RegExp("^" + body + "$");
58
+ }
59
+ function matcher(patterns) {
60
+ const res = [...SKIP, ...patterns].map(glob);
61
+ return (rel) => res.some((r) => r.test(basename(rel)) || r.test(rel));
62
+ }
63
+ async function pool(items, size, fn) {
64
+ let next = 0;
65
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
66
+ while (next < items.length)
67
+ await fn(items[next++]);
68
+ }));
69
+ }
70
+ /**
71
+ * Exclude patterns from a .dockerignore in the build folder: files Docker would not send
72
+ * to the build are not uploaded. Files with "!" exceptions are not used (we cannot express them).
73
+ */
74
+ export async function dockerignore(folder) {
75
+ let text;
76
+ try {
77
+ text = await readFile(join(folder, ".dockerignore"), "utf8");
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const lines = text
83
+ .split(/\r?\n/)
84
+ .map((l) => l.trim())
85
+ .filter((l) => l && !l.startsWith("#"));
86
+ if (lines.some((l) => l.startsWith("!")))
87
+ return [];
88
+ return lines.filter((l) => l !== "." && l !== "*" && l !== "**");
89
+ }
90
+ async function sha256(abs) {
91
+ const h = createHash("sha256");
92
+ for await (const chunk of createReadStream(abs))
93
+ h.update(chunk);
94
+ return h.digest("hex");
95
+ }
96
+ export class FileManager {
97
+ api;
98
+ engine;
99
+ /** Last write per disk. Builds started right after an upload failed live. */
100
+ written = new Map();
101
+ /**
102
+ * Files this process uploaded or checksummed: desk|path -> content and the server's
103
+ * modified time. A repeat sync trusts them while size and modified time are unchanged
104
+ * instead of asking the server for a checksum again.
105
+ */
106
+ verified = new Map();
107
+ constructor(api, engine) {
108
+ this.api = api;
109
+ this.engine = engine;
110
+ }
111
+ /** Milliseconds a build on this disk should still wait after the last upload. */
112
+ settleMs(desk) {
113
+ const last = this.written.get(desk);
114
+ const settle = (this.api.config.uploadSettleSeconds ?? 30) * 1000;
115
+ return last === undefined ? 0 : Math.max(0, last + settle - Date.now());
116
+ }
117
+ /** One folder level; null when the folder does not exist. */
118
+ async folder(desk, parts) {
119
+ const r = await this.api.storage("PROPFIND", desk, href(parts, true), {
120
+ headers: { Depth: "1" },
121
+ });
122
+ if (r.status === 404) {
123
+ await r.body?.cancel();
124
+ return null;
125
+ }
126
+ if (r.status !== 207) {
127
+ await r.body?.cancel();
128
+ throw new ToolError("api_error", `Storage listing returned HTTP ${r.status}.`, r.status);
129
+ }
130
+ const root = this.api.davRoot(desk);
131
+ const self = parts.join("/");
132
+ const rows = [];
133
+ for (const m of (await r.text()).matchAll(/<(?:\w+:)?response\b[^>]*>([\s\S]*?)<\/(?:\w+:)?response>/g)) {
134
+ let path;
135
+ try {
136
+ path = decodeURIComponent(unxml(tag(m[1], "href") ?? "").trim());
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ if (!path.startsWith(root) && path + "/" !== root)
142
+ continue;
143
+ path = path.slice(root.length).replace(/\/$/, "");
144
+ const folder = /<(?:\w+:)?collection\s*\/>/.test(m[1]);
145
+ if (path === self) {
146
+ if (!folder)
147
+ bad(`${self} is a file, not a folder.`);
148
+ continue;
149
+ }
150
+ rows.push({
151
+ path,
152
+ type: folder ? "folder" : "file",
153
+ size: folder ? 0 : Number(tag(m[1], "getcontentlength") ?? 0),
154
+ modified: tag(m[1], "getlastmodified")?.trim(),
155
+ });
156
+ }
157
+ return rows;
158
+ }
159
+ /** Server-side SHA-256 of a file; null when the file is missing. */
160
+ async checksum(desk, path, size) {
161
+ if (size === 0)
162
+ return "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
163
+ const r = await this.api.storage("PROPFIND", desk, href(segs(path)), {
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ method: "checksum", from: 0, to: size - 1 }),
166
+ });
167
+ if (r.status === 404) {
168
+ await r.body?.cancel();
169
+ return null;
170
+ }
171
+ const d = r.ok ? await r.json().catch(() => undefined) : undefined;
172
+ if (typeof d?.checksum !== "string")
173
+ throw new ToolError("api_error", `Storage checksum returned HTTP ${r.status}.`, r.status);
174
+ return d.checksum;
175
+ }
176
+ /** Type of a path, or null when missing. */
177
+ async stat(desk, parts) {
178
+ const r = await this.api.storage("PROPFIND", desk, href(parts), {
179
+ headers: { Depth: "0" },
180
+ });
181
+ if (r.status === 404) {
182
+ await r.body?.cancel();
183
+ return null;
184
+ }
185
+ if (r.status !== 207) {
186
+ await r.body?.cancel();
187
+ throw new ToolError("api_error", `Storage lookup returned HTTP ${r.status}.`, r.status);
188
+ }
189
+ return /<(?:\w+:)?collection\s*\/>/.test(await r.text())
190
+ ? "folder"
191
+ : "file";
192
+ }
193
+ async mkcol(desk, parts) {
194
+ const r = await this.api.storage("MKCOL", desk, href(parts, true));
195
+ await r.body?.cancel();
196
+ if (r.status === 201)
197
+ return true;
198
+ if (r.status === 405)
199
+ return false;
200
+ throw new ToolError(r.status === 409 ? "conflict" : "api_error", r.status === 409
201
+ ? `Cannot create ${parts.join("/")}: a parent path is missing or is a file.`
202
+ : `Folder creation returned HTTP ${r.status}.`, r.status);
203
+ }
204
+ async list(raw) {
205
+ const i = listFilesInput.parse(raw);
206
+ await this.engine.filesDesk(i.project_id, i.desk_id);
207
+ const start = segs(i.path);
208
+ const entries = [];
209
+ const queue = [start];
210
+ let truncated = false;
211
+ while (queue.length && !truncated) {
212
+ const dir = queue.shift();
213
+ const rows = await this.folder(i.desk_id, dir);
214
+ if (!rows) {
215
+ if (dir === start)
216
+ bad(`Folder ${i.path} does not exist on the disk.`);
217
+ continue;
218
+ }
219
+ for (const row of rows) {
220
+ if (row.path === TEMP)
221
+ continue;
222
+ if (entries.length >= i.max_entries) {
223
+ truncated = true;
224
+ break;
225
+ }
226
+ entries.push(row);
227
+ if (row.type === "folder" && i.recursive)
228
+ queue.push(segs(row.path));
229
+ }
230
+ }
231
+ return {
232
+ desk_id: i.desk_id,
233
+ path: start.join("/"),
234
+ entries,
235
+ total: entries.length,
236
+ truncated,
237
+ };
238
+ }
239
+ async createFolder(raw) {
240
+ const i = createFolderInput.parse(raw);
241
+ await this.engine.filesDesk(i.project_id, i.desk_id);
242
+ const parts = segs(i.path);
243
+ const created = [];
244
+ for (let n = 1; n <= parts.length; n++)
245
+ if (await this.mkcol(i.desk_id, parts.slice(0, n)))
246
+ created.push(parts.slice(0, n).join("/"));
247
+ return {
248
+ status: created.length ? "created" : "exists",
249
+ message: created.length
250
+ ? `Created ${created.join(", ")}.`
251
+ : `${parts.join("/")} already exists.`,
252
+ path: parts.join("/"),
253
+ created,
254
+ };
255
+ }
256
+ async move(raw) {
257
+ const i = movePathInput.parse(raw);
258
+ await this.engine.filesDesk(i.project_id, i.desk_id);
259
+ const desk = i.desk_id;
260
+ const plan = [];
261
+ for (const m of i.moves) {
262
+ const from = segs(m.from).join("/");
263
+ const to = segs(m.to).join("/");
264
+ const row = { from, to };
265
+ plan.push(row);
266
+ if (from === to)
267
+ row.issue = "source and destination are the same";
268
+ else if (to.startsWith(from + "/"))
269
+ row.issue = "a folder cannot be moved into itself";
270
+ else if (plan.some((p) => p !== row && p.to === to))
271
+ row.issue = "another move in this request has the same destination";
272
+ else {
273
+ row.type = (await this.stat(desk, segs(from))) ?? undefined;
274
+ if (!row.type)
275
+ row.issue = "does not exist";
276
+ else if (await this.stat(desk, segs(to)))
277
+ row.issue = "destination already exists; it is never overwritten";
278
+ }
279
+ }
280
+ const ok = plan.filter((p) => !p.issue);
281
+ const issues = plan
282
+ .filter((p) => p.issue)
283
+ .map((p) => `${p.from} → ${p.to}: ${p.issue}`);
284
+ if (!i.confirm)
285
+ return {
286
+ status: ok.length ? "confirm_required" : "nothing_to_do",
287
+ message: ok.length
288
+ ? `${ok.length} renames/moves ready${issues.length ? `, ${issues.length} cannot be done` : ""}. Nothing was changed: ask the user once to approve the whole list, then repeat the call with confirm=true.`
289
+ : "None of the moves can be done; nothing was changed.",
290
+ moves: ok.map((p) => ({ from: p.from, to: p.to, type: p.type })),
291
+ issues,
292
+ };
293
+ const moved = [];
294
+ for (const p of ok) {
295
+ const folder = p.type === "folder";
296
+ const r = await this.api.storage("MOVE", desk, href(segs(p.from), folder), {
297
+ headers: {
298
+ Destination: this.api.davRoot(desk) + href(segs(p.to), folder),
299
+ Overwrite: "F",
300
+ },
301
+ });
302
+ await r.body?.cancel();
303
+ if (r.status === 201 || r.status === 204)
304
+ moved.push(`${p.from} → ${p.to}`);
305
+ else
306
+ issues.push(`${p.from} → ${p.to}: ${r.status === 412
307
+ ? "destination already exists; it is never overwritten"
308
+ : r.status === 404 || r.status === 409
309
+ ? "destination folder does not exist; create it with create_folder"
310
+ : `move returned HTTP ${r.status}`}`);
311
+ }
312
+ return {
313
+ status: issues.length ? "partial" : "moved",
314
+ message: `Moved ${moved.length}${issues.length ? `; ${issues.length} not moved` : ""}.`,
315
+ moved,
316
+ issues,
317
+ };
318
+ }
319
+ async remove(raw) {
320
+ const i = deletePathInput.parse(raw);
321
+ await this.engine.filesDesk(i.project_id, i.desk_id);
322
+ const desk = i.desk_id;
323
+ const targets = [];
324
+ const issues = [];
325
+ for (const raw of new Set(i.paths.map((p) => segs(p).join("/")))) {
326
+ const type = await this.stat(desk, segs(raw));
327
+ if (!type) {
328
+ issues.push(`${raw}: does not exist`);
329
+ continue;
330
+ }
331
+ const row = { path: raw, type };
332
+ if (type === "folder" && !i.confirm) {
333
+ const inside = await this.list({
334
+ project_id: i.project_id,
335
+ desk_id: desk,
336
+ path: raw,
337
+ recursive: true,
338
+ max_entries: 5000,
339
+ });
340
+ row.files = inside.entries.filter((e) => e.type === "file").length;
341
+ row.folders = inside.total - row.files;
342
+ row.truncated = inside.truncated;
343
+ }
344
+ targets.push(row);
345
+ }
346
+ // A path inside a folder that is also being deleted needs no request of its own.
347
+ const top = targets.filter((t) => !targets.some((o) => o !== t && t.path.startsWith(o.path + "/")));
348
+ if (!i.confirm)
349
+ return {
350
+ status: top.length ? "confirm_required" : "nothing_to_do",
351
+ message: top.length
352
+ ? `${top.length} paths would be deleted${top.some((t) => t.type === "folder") ? " (folders with all contents)" : ""}. This cannot be undone. Nothing was changed: ask the user once to approve the whole list, then repeat the call with confirm=true.`
353
+ : "Nothing to delete.",
354
+ delete: top,
355
+ issues,
356
+ };
357
+ const deleted = [];
358
+ for (const t of top) {
359
+ const r = await this.api.storage("DELETE", desk, href(segs(t.path), t.type === "folder"));
360
+ await r.body?.cancel();
361
+ if (r.status === 200 || r.status === 204)
362
+ deleted.push(t.path);
363
+ else if (r.status === 404)
364
+ issues.push(`${t.path}: no longer exists`);
365
+ else
366
+ issues.push(`${t.path}: delete returned HTTP ${r.status}`);
367
+ }
368
+ return {
369
+ status: issues.length ? "partial" : "deleted",
370
+ message: `Deleted ${deleted.length}${issues.length ? `; ${issues.length} not deleted` : ""}.`,
371
+ deleted,
372
+ issues,
373
+ };
374
+ }
375
+ async sync(raw) {
376
+ const i = syncFilesInput.parse(raw);
377
+ const begun = Date.now();
378
+ await this.engine.filesDesk(i.project_id, i.desk_id);
379
+ const desk = i.desk_id;
380
+ const dest = segs(i.remote_path);
381
+ const skip = matcher(i.exclude);
382
+ // Local side: files with hashes, plus every folder so empty folders are mirrored.
383
+ const files = [];
384
+ const dirs = [""];
385
+ const skipped = [];
386
+ let info;
387
+ try {
388
+ info = await lstat(i.local_path);
389
+ }
390
+ catch {
391
+ bad(`Local path ${i.local_path} does not exist or is unreadable.`);
392
+ }
393
+ if (info.isFile()) {
394
+ files.push({
395
+ rel: basename(i.local_path),
396
+ abs: i.local_path,
397
+ size: info.size,
398
+ sha256: "",
399
+ });
400
+ }
401
+ else if (info.isDirectory()) {
402
+ const walk = async (rel) => {
403
+ for (const d of await readdir(join(i.local_path, rel), {
404
+ withFileTypes: true,
405
+ })) {
406
+ const r = rel ? `${rel}/${d.name}` : d.name;
407
+ if (skip(r))
408
+ continue;
409
+ if (d.isDirectory()) {
410
+ dirs.push(r);
411
+ await walk(r);
412
+ }
413
+ else if (d.isFile()) {
414
+ if (files.length >= MAX_FILES)
415
+ bad(`More than ${MAX_FILES} files; narrow local_path or add exclude patterns.`);
416
+ const abs = join(i.local_path, r);
417
+ files.push({
418
+ rel: r,
419
+ abs,
420
+ size: (await lstat(abs)).size,
421
+ sha256: "",
422
+ });
423
+ }
424
+ else
425
+ skipped.push(`${r} (not a regular file)`);
426
+ }
427
+ };
428
+ await walk("");
429
+ }
430
+ else
431
+ bad("local_path must be a regular file or folder.");
432
+ const tooLarge = files.filter((f) => f.size > MAX_FILE);
433
+ skipped.push(...tooLarge.map((f) => `${f.rel} (over ${MAX_FILE / 1048576} MB)`));
434
+ const todo = files.filter((f) => f.size <= MAX_FILE);
435
+ await pool(todo, 8, async (f) => {
436
+ f.sha256 = await sha256(f.abs);
437
+ });
438
+ // Remote side: list only folders that mirror local ones; children of missing folders are missing too.
439
+ const remote = new Map();
440
+ const missing = new Set();
441
+ const full = (rel) => [...dest, ...segs(rel)].join("/");
442
+ for (const dir of dirs.sort((a, b) => segs(a).length - segs(b).length)) {
443
+ const target = full(dir);
444
+ const parent = segs(target).slice(0, -1).join("/");
445
+ if (target && missing.has(parent)) {
446
+ missing.add(target);
447
+ continue;
448
+ }
449
+ const rows = await this.folder(desk, segs(target)).catch((err) => {
450
+ if (err instanceof ToolError && err.code === "invalid_input")
451
+ return "file";
452
+ throw err;
453
+ });
454
+ if (rows === "file")
455
+ remote.set(target, { path: target, type: "file", size: 0 });
456
+ else if (!rows)
457
+ missing.add(target);
458
+ else {
459
+ remote.set(target, { path: target, type: "folder", size: 0 });
460
+ for (const row of rows)
461
+ remote.set(row.path, row);
462
+ }
463
+ }
464
+ // A missing destination may also lack ancestors: find the deepest one that exists.
465
+ const ancestors = [];
466
+ if (missing.has(dest.join("/")))
467
+ for (let n = dest.length - 1; n >= 1; n--) {
468
+ const rows = await this.folder(desk, dest.slice(0, n)).catch((err) => {
469
+ if (err instanceof ToolError && err.code === "invalid_input")
470
+ return "file";
471
+ throw err;
472
+ });
473
+ if (rows === "file")
474
+ bad(`${dest.slice(0, n).join("/")} is a file, not a folder.`);
475
+ if (rows)
476
+ break;
477
+ ancestors.push(dest.slice(0, n).join("/"));
478
+ }
479
+ const blocked = (p) => segs(p).some((_, n, a) => remote.get(a.slice(0, n + 1).join("/"))?.type === "file");
480
+ const fresh = [];
481
+ const changed = [];
482
+ const conflicts = [];
483
+ let unchanged = 0;
484
+ const same = [];
485
+ for (const f of todo) {
486
+ const p = full(f.rel);
487
+ const e = remote.get(p);
488
+ if (blocked(segs(p).slice(0, -1).join("/")) || e?.type === "folder")
489
+ conflicts.push(`${p} (file/folder type mismatch)`);
490
+ else if (!e)
491
+ fresh.push(f);
492
+ else if (e.size !== f.size)
493
+ changed.push(f);
494
+ else
495
+ same.push(f);
496
+ }
497
+ const unchecked = [];
498
+ await pool(same, PARALLEL, async (f) => {
499
+ const p = full(f.rel);
500
+ const modified = remote.get(p)?.modified;
501
+ const known = this.verified.get(`${desk}|${p}`);
502
+ if (known &&
503
+ known.size === f.size &&
504
+ (known.modified === undefined || known.modified === modified)) {
505
+ known.modified = modified;
506
+ if (known.sha256 === f.sha256)
507
+ unchanged++;
508
+ else
509
+ changed.push(f);
510
+ return;
511
+ }
512
+ let sum;
513
+ try {
514
+ sum = await this.checksum(desk, p, f.size);
515
+ }
516
+ catch (err) {
517
+ if (!(err instanceof ToolError) || err.code !== "api_error")
518
+ throw err;
519
+ unchecked.push(p);
520
+ return;
521
+ }
522
+ if (sum === null)
523
+ fresh.push(f);
524
+ else {
525
+ this.verified.set(`${desk}|${p}`, {
526
+ sha256: sum,
527
+ size: f.size,
528
+ modified,
529
+ });
530
+ if (sum === f.sha256)
531
+ unchanged++;
532
+ else
533
+ changed.push(f);
534
+ }
535
+ });
536
+ const folders = dirs
537
+ .map(full)
538
+ .filter((p) => p && !remote.has(p) && !blocked(p));
539
+ folders.push(...ancestors);
540
+ folders.sort((a, b) => segs(a).length - segs(b).length);
541
+ for (const p of dirs.map(full))
542
+ if (p && remote.get(p)?.type === "file")
543
+ conflicts.push(`${p} (file/folder type mismatch)`);
544
+ const plan = {
545
+ new_files: cap(fresh.map((f) => full(f.rel))),
546
+ changed_files: cap(changed.map((f) => full(f.rel))),
547
+ unchanged_count: unchanged,
548
+ folders_to_create: cap(folders),
549
+ conflicts: cap(conflicts),
550
+ skipped: cap(skipped),
551
+ };
552
+ const summary = `${fresh.length} new, ${changed.length} changed, ${unchanged} unchanged, ${folders.length} folders to create, ${conflicts.length} conflicts, ${skipped.length} skipped${unchecked.length ? `, ${unchecked.length} not checked (storage busy)` : ""}`;
553
+ if (i.dry_run)
554
+ return {
555
+ status: "planned",
556
+ message: `${summary}. Nothing was uploaded (dry run).`,
557
+ ...plan,
558
+ };
559
+ if (changed.length && !i.confirm)
560
+ return {
561
+ status: "confirm_required",
562
+ message: `${summary}. ${changed.length} existing files would be replaced. Nothing was uploaded: ask the user once to approve the whole list, then repeat the call with confirm=true.`,
563
+ ...plan,
564
+ };
565
+ // Apply: folders, then each file via a temp upload moved into place.
566
+ if (fresh.length || changed.length) {
567
+ await this.mkcol(desk, [TEMP]);
568
+ for (const row of (await this.folder(desk, [TEMP])) ?? [])
569
+ if (row.type === "file" && basename(row.path).startsWith(TEMP_PREFIX)) {
570
+ const r = await this.api.storage("DELETE", desk, href(segs(row.path)));
571
+ await r.body?.cancel();
572
+ }
573
+ }
574
+ for (const p of folders)
575
+ await this.mkcol(desk, segs(p));
576
+ const uploaded = [];
577
+ const failed = [];
578
+ const queue = [
579
+ ...fresh.map((f) => ({ f, replace: false })),
580
+ ...changed.map((f) => ({ f, replace: true })),
581
+ ];
582
+ let remaining = unchecked.length;
583
+ let stop;
584
+ await pool(queue, PARALLEL, async ({ f, replace }) => {
585
+ if (stop || Date.now() - begun > UPLOAD_WINDOW) {
586
+ remaining++;
587
+ return;
588
+ }
589
+ const p = full(f.rel);
590
+ try {
591
+ const outcome = await this.upload(desk, f, p, replace);
592
+ this.written.set(desk, Date.now());
593
+ if (outcome === "uploaded")
594
+ uploaded.push(p);
595
+ else
596
+ conflicts.push(`${p} (${outcome})`);
597
+ }
598
+ catch (err) {
599
+ if (err instanceof ToolError &&
600
+ ["needs_auth", "connection_error"].includes(err.code)) {
601
+ stop ??= err;
602
+ remaining++;
603
+ return;
604
+ }
605
+ failed.push(`${p} (${err instanceof ToolError ? err.message : "local read failed"})`);
606
+ }
607
+ });
608
+ if (stop && !uploaded.length)
609
+ throw stop;
610
+ const status = remaining
611
+ ? "in_progress"
612
+ : failed.length || conflicts.length
613
+ ? "partial"
614
+ : "synced";
615
+ return {
616
+ status,
617
+ message: status === "in_progress"
618
+ ? `Uploaded ${uploaded.length}; ${remaining} files remain. Repeat the same call to continue; finished files are skipped.`
619
+ : status === "partial"
620
+ ? `Uploaded ${uploaded.length}; ${failed.length} failed and ${conflicts.length} conflicts need the user's decision.`
621
+ : `Uploaded ${uploaded.length} files; ${unchanged} were already up to date.`,
622
+ uploaded: cap(uploaded),
623
+ failed: cap(failed),
624
+ conflicts: cap(conflicts),
625
+ skipped: cap(skipped),
626
+ unchanged_count: unchanged,
627
+ remaining_count: remaining,
628
+ };
629
+ }
630
+ /** Check each build's Dockerfile on its disk before a build is submitted. */
631
+ async preflight(project, sources) {
632
+ const missing = [];
633
+ const warnings = [];
634
+ for (const b of sources) {
635
+ await this.engine.filesDesk(project, b.desk_id);
636
+ const parts = segs(b.dockerfile);
637
+ const r = await this.api.storage("GET", b.desk_id, href(parts));
638
+ if (r.status === 404) {
639
+ await r.body?.cancel();
640
+ missing.push(`${b.service}: ${parts.join("/")}`);
641
+ continue;
642
+ }
643
+ if (!r.ok) {
644
+ await r.body?.cancel();
645
+ throw new ToolError("api_error", `Reading ${parts.join("/")} returned HTTP ${r.status}.`, r.status);
646
+ }
647
+ const text = (await r.text()).slice(0, 256 * 1024);
648
+ warnings.push(...lintDockerfile(parts.join("/"), text, b.ports).map((w) => `${b.service}: ${w}`));
649
+ const dir = parts.slice(0, -1);
650
+ for (const conf of configsCopied(text)) {
651
+ const c = await this.api.storage("GET", b.desk_id, href([...dir, ...segs(conf)]));
652
+ const body = c.ok ? (await c.text()).slice(0, 64 * 1024) : "";
653
+ if (!c.ok)
654
+ await c.body?.cancel();
655
+ if (/^\s*listen\s+\[::\]/m.test(body))
656
+ warnings.push(`${b.service}: ${conf} has "listen [::]". nginx exits on start when the cluster has no IPv6; keep only the IPv4 listen line (listen <port>;).`);
657
+ }
658
+ for (const row of (await this.folder(b.desk_id, dir)) ?? [])
659
+ if (/^(docker-)?compose\.ya?ml$/.test(basename(row.path)))
660
+ warnings.push(`${b.service}: ${row.path} is ignored; docker-compose is not supported, so each service is configured in deploy_app.`);
661
+ }
662
+ return { missing, warnings: [...new Set(warnings)] };
663
+ }
664
+ /** Upload to a fresh temp file, move it over the target, then verify the server checksum. */
665
+ async upload(desk, f, target, replace) {
666
+ const tmp = [TEMP, TEMP_PREFIX + randomUUID()];
667
+ const hash = createHash("sha256");
668
+ const put = async (body, range) => {
669
+ const r = await this.api.storage("PUT", desk, href(tmp), {
670
+ body,
671
+ headers: {
672
+ "Content-Type": "application/octet-stream",
673
+ ...(range ? { "Content-Range": range } : {}),
674
+ },
675
+ });
676
+ await r.body?.cancel();
677
+ if (r.status !== 201 && r.status !== 204)
678
+ throw new ToolError(r.status === 413 ? "too_large" : "api_error", `Upload returned HTTP ${r.status}.`, r.status);
679
+ };
680
+ const clean = async () => {
681
+ try {
682
+ const r = await this.api.storage("DELETE", desk, href(tmp));
683
+ await r.body?.cancel();
684
+ }
685
+ catch {
686
+ /* Stale temp files are removed at the start of the next sync. */
687
+ }
688
+ };
689
+ try {
690
+ const fh = await open(f.abs, "r");
691
+ try {
692
+ const size = (await fh.stat()).size;
693
+ if (size > MAX_FILE)
694
+ throw new ToolError("too_large", "File grew past the size limit.");
695
+ if (size === 0)
696
+ await put(new Uint8Array());
697
+ for (let at = 0; at < size; at += CHUNK) {
698
+ const buf = Buffer.alloc(Math.min(CHUNK, size - at));
699
+ const { bytesRead } = await fh.read(buf, 0, buf.length, at);
700
+ if (bytesRead !== buf.length)
701
+ throw new ToolError("local_changed", "File changed while uploading.");
702
+ hash.update(buf);
703
+ await put(buf, size > CHUNK
704
+ ? `bytes ${at}-${at + buf.length - 1}/${size}`
705
+ : undefined);
706
+ }
707
+ f.size = size;
708
+ }
709
+ finally {
710
+ await fh.close();
711
+ }
712
+ const r = await this.api.storage("MOVE", desk, href(tmp), {
713
+ headers: {
714
+ Destination: this.api.davRoot(desk) + href(segs(target)),
715
+ Overwrite: replace ? "T" : "F",
716
+ },
717
+ });
718
+ await r.body?.cancel();
719
+ if (r.status === 412) {
720
+ await clean();
721
+ return "created by someone else during sync; not replaced";
722
+ }
723
+ // MOVE is not repeated: after a gateway error the checksum below tells whether it landed.
724
+ if (r.status !== 201 && r.status !== 204 && r.status < 502)
725
+ throw new ToolError("api_error", `Move into place returned HTTP ${r.status}.`, r.status);
726
+ }
727
+ catch (err) {
728
+ await clean();
729
+ throw err;
730
+ }
731
+ const sha = hash.digest("hex");
732
+ if ((await this.checksum(desk, target, f.size)) !== sha)
733
+ throw new ToolError("verify_failed", "The file did not land intact (storage busy); repeat the same call.");
734
+ this.verified.set(`${desk}|${target}`, { sha256: sha, size: f.size });
735
+ return "uploaded";
736
+ }
737
+ }
738
+ /** Dockerfile problems the platform will not report clearly: wrong name, missing FROM/EXPOSE, localhost use. */
739
+ export function lintDockerfile(path, text, ports) {
740
+ const out = [];
741
+ const name = basename(path);
742
+ if (name !== "Dockerfile" && !name.endsWith(".dockerfile"))
743
+ out.push(`${name}: Constructor recognizes only "Dockerfile" or "<name>.dockerfile".`);
744
+ const lines = text
745
+ .replace(/\\\r?\n/g, " ")
746
+ .split(/\r?\n/)
747
+ .map((l) => l.trim())
748
+ .filter((l) => l && !l.startsWith("#"));
749
+ const cmd = (l) => l.split(/\s+/)[0].toUpperCase();
750
+ if (!lines.some((l) => cmd(l) === "FROM"))
751
+ out.push("No FROM instruction.");
752
+ const exposed = lines
753
+ .filter((l) => cmd(l) === "EXPOSE")
754
+ .flatMap((l) => l.split(/\s+/).slice(1))
755
+ .map((p) => Number.parseInt(p, 10))
756
+ .filter((p) => Number.isInteger(p));
757
+ if (!exposed.length)
758
+ out.push("No EXPOSE: check that the service port matches the port the app listens on.");
759
+ else
760
+ for (const p of ports)
761
+ if (!exposed.includes(p))
762
+ out.push(`Service port ${p} is not EXPOSEd (Dockerfile exposes ${exposed.join(", ")}).`);
763
+ if (lines.some((l) => ["ENV", "ARG", "CMD", "ENTRYPOINT"].includes(cmd(l)) &&
764
+ /\b(localhost|127\.0\.0\.1)\b/.test(l)))
765
+ out.push("Uses localhost/127.0.0.1: the app must listen on 0.0.0.0, and other services must be reached through their internal links, not localhost.");
766
+ // Compose-style service hosts ("cerbos:3592") resolve only inside docker-compose.
767
+ const hosts = lines
768
+ .filter((l) => cmd(l) === "ENV")
769
+ .flatMap((l) => [
770
+ ...l.matchAll(/(?:^|[\s="'])(?:[a-z][a-z0-9+.-]*:\/\/)?([a-z][a-z0-9_-]*):(\d{2,5})(?![\w.-])/gi),
771
+ ])
772
+ .map((m) => `${m[1]}:${m[2]}`)
773
+ .filter((h) => !h.startsWith("localhost:"));
774
+ if (hosts.length)
775
+ out.push(`Default address ${[...new Set(hosts)].join(", ")} works only in docker-compose. Set it as a service reference in deploy_app (environment_variables) and do not bake a fallback into the image: a missing variable then points at a host that does not exist.`);
776
+ for (const script of unexecutable(lines, cmd))
777
+ out.push(`${script} is run as the entrypoint but never made executable. Files uploaded to a desk lose the executable bit, so the container exits at once without printing anything. Add RUN chmod 0755 ${script} after the COPY (or COPY --chmod=0755).`);
778
+ return out;
779
+ }
780
+ /** Config files (nginx .conf/.template) the Dockerfile copies from the build context; at most five. */
781
+ function configsCopied(text) {
782
+ return [
783
+ ...new Set(text
784
+ .split(/\r?\n/)
785
+ .filter((l) => /^\s*(COPY|ADD)\s/i.test(l) && !/--from=/.test(l))
786
+ .flatMap((l) => l.trim().split(/\s+/).slice(1, -1))
787
+ .filter((w) => !w.startsWith("--") &&
788
+ !/[*?\[]|^\.\.?\/?$|\.\./.test(w) &&
789
+ /\.(conf|template)$/.test(w))),
790
+ ].slice(0, 5);
791
+ }
792
+ /** Scripts started directly by ENTRYPOINT/CMD (not through sh/bash) with no chmod in the Dockerfile. */
793
+ function unexecutable(lines, cmd) {
794
+ const chmods = lines
795
+ .filter((l) => (cmd(l) === "RUN" && /\bchmod\b/.test(l)) ||
796
+ (["COPY", "ADD"].includes(cmd(l)) && /--chmod=/.test(l)))
797
+ .join(" ");
798
+ const copied = lines
799
+ .filter((l) => ["COPY", "ADD"].includes(cmd(l)) && !/--from=/.test(l))
800
+ .map((l) => l.split(/\s+/).filter((w) => !w.startsWith("--")))
801
+ .filter((w) => w.length >= 3)
802
+ .map((w) => ({ sources: w.slice(1, -1), dest: w.at(-1) }));
803
+ const out = [];
804
+ for (const l of lines.filter((l) => ["ENTRYPOINT", "CMD"].includes(cmd(l)))) {
805
+ const args = l.slice(cmd(l).length).trim();
806
+ const first = args.startsWith("[")
807
+ ? (/^\[\s*"([^"]+)"/.exec(args)?.[1] ?? "")
808
+ : args.split(/\s+/)[0];
809
+ if (!/\.(sh|bash)$/.test(first))
810
+ continue;
811
+ const name = basename(first);
812
+ // Only scripts that came from the build context; scripts in base images keep their mode.
813
+ const fromContext = copied.some((c) => c.sources.some((x) => x === "." || x === "./")
814
+ ? true
815
+ : c.dest.endsWith("/")
816
+ ? c.sources.some((x) => basename(x) === name)
817
+ : c.dest === first || basename(c.dest) === name);
818
+ if (fromContext && !chmods.includes(name) && !chmods.includes("*.sh"))
819
+ out.push(first);
820
+ }
821
+ return [...new Set(out)];
822
+ }