trajectories-sh 1.9.9 → 1.10.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/cli.js CHANGED
@@ -81,7 +81,7 @@ async function uploadTrajectoryAction(directory, opts) {
81
81
  Uploading trajectory ${directory} \u2192 ${chalk.cyan(slug)}
82
82
  `));
83
83
  try {
84
- const { uploadJob } = await import("./upload-3VW7BFIP.js");
84
+ const { uploadJob } = await import("./upload-EHVKRW64.js");
85
85
  const result = await uploadJob(directory, {
86
86
  slug,
87
87
  name: opts.name,
@@ -145,7 +145,7 @@ async function downloadTrajectoryAction(url, opts) {
145
145
  Downloading trajectory...
146
146
  `));
147
147
  try {
148
- const { downloadJob, parseJobId } = await import("./download-FJ7U5MTQ.js");
148
+ const { downloadJob, parseJobId } = await import("./download-O3JONHXT.js");
149
149
  const outputDir = opts.output ?? parseJobId(url).replace(/^org:/, "").replace(/:/g, "-");
150
150
  const result = await downloadJob(url, outputDir);
151
151
  console.log(chalk.green.bold(` \u2713 Downloaded ${result.files} files`));
@@ -161,7 +161,7 @@ async function downloadTrajectoryAction(url, opts) {
161
161
  }
162
162
  async function deleteTrajectoryAction(url, opts) {
163
163
  const chalk = (await import("chalk")).default;
164
- const { parseJobId } = await import("./download-FJ7U5MTQ.js");
164
+ const { parseJobId } = await import("./download-O3JONHXT.js");
165
165
  const { getApiUrl, getAuthHeader } = await import("./config-NOIAMV2U.js");
166
166
  let jobId = parseJobId(url);
167
167
  if (jobId.startsWith("org:")) {
@@ -56,7 +56,7 @@ async function downloadJob(input, outputDir) {
56
56
  console.log(` Job: ${slug} (${jobId})`);
57
57
  console.log(` ${files.length} files, ${(totalBytes / 1024 / 1024).toFixed(1)} MB`);
58
58
  mkdirSync(outputDir, { recursive: true });
59
- const { ProgressBar } = await import("./progress-2P5L7X24.js");
59
+ const { ProgressBar } = await import("./progress-H7WNKHDR.js");
60
60
  const bar = new ProgressBar({ total: files.length, totalBytes, label: "\u2193" });
61
61
  let downloaded = 0;
62
62
  let totalSize = 0;
@@ -17,6 +17,16 @@ var ProgressBar = class {
17
17
  this.currentBytes += bytes;
18
18
  this.render();
19
19
  }
20
+ /** Set absolute byte count (does not change `current`). Useful for streaming a single large file. */
21
+ setBytes(bytes) {
22
+ this.currentBytes = bytes;
23
+ this.render();
24
+ }
25
+ /** Set absolute file count (does not change `currentBytes`). */
26
+ setCount(count) {
27
+ this.current = count;
28
+ this.render();
29
+ }
20
30
  render() {
21
31
  const pct = this.total > 0 ? Math.round(this.current / this.total * 100) : 0;
22
32
  const elapsed = (Date.now() - this.startTime) / 1e3;
@@ -11,7 +11,6 @@ import { randomBytes, createHash } from "crypto";
11
11
  var SKIP = /* @__PURE__ */ new Set(["__pycache__", ".DS_Store", ".git", "node_modules"]);
12
12
  var MAX_FILE_SIZE = 50 * 1024 * 1024;
13
13
  var CONCURRENCY = Number(process.env.TRAJECTORIES_CLI_CONCURRENCY ?? "8");
14
- var BATCH_SIZE = 100;
15
14
  function collectFiles(dir) {
16
15
  const files = [];
17
16
  function walk(current) {
@@ -323,7 +322,7 @@ async function uploadJob(dir, opts) {
323
322
  }
324
323
  const { job_id: jobId } = await initResp.json();
325
324
  console.log(` Job ID: ${jobId}`);
326
- await uploadDirect(jobId, files);
325
+ await uploadArchive(jobId, convertedDir);
327
326
  console.log(" Finalizing...");
328
327
  const finalResp = await apiPost(`/api/cli/push/${jobId}/finalize`, {});
329
328
  if (!finalResp.ok) {
@@ -354,96 +353,125 @@ async function uploadJob(dir, opts) {
354
353
  viewerUrl: `${siteUrl}/t/${jobId}`
355
354
  };
356
355
  }
357
- async function uploadDirect(jobId, files) {
358
- const { ProgressBar } = await import("./progress-2P5L7X24.js");
359
- const totalBytes = files.reduce((s, f) => s + f.size, 0);
360
- const bar = new ProgressBar({ total: files.length, totalBytes, label: "\u2191" });
361
- const failedFiles = [];
362
- const failureReasons = [];
363
- for (let batchStart = 0; batchStart < files.length; batchStart += BATCH_SIZE) {
364
- const batch = files.slice(batchStart, batchStart + BATCH_SIZE);
365
- const urlResp = await apiPost(`/api/cli/push/${jobId}/upload-urls`, {
366
- paths: batch.map((f) => f.relPath)
367
- });
356
+ async function uploadArchive(jobId, dir) {
357
+ const tar = await import("tar");
358
+ const { createReadStream } = await import("fs");
359
+ const { ProgressBar } = await import("./progress-H7WNKHDR.js");
360
+ const tmpArchive = join(tmpdir(), `trajectories-archive-${randomBytes(4).toString("hex")}.tar.gz`);
361
+ const entries = collectFiles(dir).map((f) => f.relPath);
362
+ await tar.create(
363
+ {
364
+ gzip: true,
365
+ file: tmpArchive,
366
+ cwd: dir,
367
+ portable: true
368
+ },
369
+ entries
370
+ );
371
+ const archiveSize = statSync(tmpArchive).size;
372
+ console.log(` Archive: ${(archiveSize / 1024 / 1024).toFixed(1)} MB (gzipped, ${entries.length} entries)`);
373
+ try {
374
+ const urlResp = await apiPost(`/api/cli/push/${jobId}/upload-url`, {});
368
375
  if (!urlResp.ok) {
369
- throw new Error(`Failed to get upload URLs: ${await urlResp.text()}`);
376
+ throw new Error(`Failed to get archive upload URL: ${await urlResp.text()}`);
377
+ }
378
+ const { signed_url: signedUrl } = await urlResp.json();
379
+ if (!signedUrl) {
380
+ throw new Error("Server returned no signed URL for archive upload");
370
381
  }
371
- const { urls } = await urlResp.json();
372
- const urlMap = /* @__PURE__ */ new Map();
373
- for (const u of urls) {
374
- if (u.signed_url) urlMap.set(u.path, u.signed_url);
375
- else {
376
- failedFiles.push(u.path);
377
- failureReasons.push({
378
- path: u.path,
379
- reason: `no signed URL from server: ${u.error ?? "unknown"}`
382
+ let putError = null;
383
+ for (let attempt = 0; attempt < 3; attempt++) {
384
+ const bar = new ProgressBar({ total: 1, totalBytes: archiveSize, label: "\u2191 archive" });
385
+ const stream = createReadStream(tmpArchive);
386
+ let bytesSent = 0;
387
+ stream.on("data", (chunk) => {
388
+ bytesSent += chunk.length;
389
+ bar.setBytes(bytesSent);
390
+ });
391
+ try {
392
+ const resp = await fetch(signedUrl, {
393
+ method: "PUT",
394
+ headers: {
395
+ "Content-Type": "application/gzip",
396
+ "Content-Length": String(archiveSize)
397
+ },
398
+ // undici accepts Node streams here
399
+ body: stream,
400
+ duplex: "half",
401
+ signal: AbortSignal.timeout(30 * 60 * 1e3)
380
402
  });
381
- bar.tick(0);
403
+ bar.setCount(1);
404
+ bar.done();
405
+ if (resp.ok) {
406
+ putError = null;
407
+ break;
408
+ }
409
+ putError = `HTTP ${resp.status} ${resp.statusText}`;
410
+ try {
411
+ const body = await resp.text();
412
+ if (body) putError += ` \u2014 ${body.slice(0, 200).replace(/\s+/g, " ")}`;
413
+ } catch {
414
+ }
415
+ } catch (e) {
416
+ bar.done();
417
+ putError = `fetch error: ${e?.name ?? "Error"} ${e?.message ?? e}`;
382
418
  }
419
+ try {
420
+ stream.destroy?.();
421
+ } catch {
422
+ }
423
+ if (attempt < 2) await sleep(2e3 * (attempt + 1));
383
424
  }
384
- const pending = /* @__PURE__ */ new Set();
385
- for (const file of batch) {
386
- const signedUrl = urlMap.get(file.relPath);
387
- if (!signedUrl) continue;
388
- const task = (async () => {
389
- const result = await uploadOneFileWithError(file, signedUrl);
390
- if (result.ok) {
391
- bar.tick(file.size);
392
- } else {
393
- failedFiles.push(file.relPath);
394
- failureReasons.push({ path: file.relPath, reason: result.error });
395
- bar.tick(0);
396
- }
397
- })().finally(() => {
398
- pending.delete(task);
399
- });
400
- pending.add(task);
401
- if (pending.size >= CONCURRENCY) {
402
- await Promise.race(pending);
425
+ if (putError) {
426
+ throw new Error(`Archive upload failed: ${putError}`);
427
+ }
428
+ const extractResp = await apiPost(`/api/cli/push/${jobId}/extract`, {});
429
+ if (!extractResp.ok) {
430
+ throw new Error(`Failed to start extraction: ${await extractResp.text()}`);
431
+ }
432
+ const extractBar = new ProgressBar({ total: entries.length, totalBytes: 0, label: "\u2699 extract" });
433
+ const pollStart = Date.now();
434
+ const pollTimeoutMs = 30 * 60 * 1e3;
435
+ let lastStatus = null;
436
+ while (Date.now() - pollStart < pollTimeoutMs) {
437
+ await sleep(1500);
438
+ const statusResp = await apiGet(`/api/cli/push/${jobId}/extract-status`);
439
+ if (!statusResp.ok) {
440
+ throw new Error(`Failed to poll extract status: ${await statusResp.text()}`);
441
+ }
442
+ lastStatus = await statusResp.json();
443
+ const uploaded = Number(lastStatus?.uploaded ?? 0);
444
+ const errors = Number(lastStatus?.errors ?? 0);
445
+ extractBar.setCount(uploaded + errors);
446
+ if (lastStatus?.status === "done") break;
447
+ if (lastStatus?.status === "error") {
448
+ throw new Error(`Archive extraction failed: ${lastStatus?.detail ?? "unknown"}`);
403
449
  }
404
450
  }
405
- await Promise.all(pending);
406
- }
407
- bar.done();
408
- if (failedFiles.length > 0) {
409
- const byReason = /* @__PURE__ */ new Map();
410
- for (const { reason } of failureReasons) {
411
- byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
451
+ extractBar.done();
452
+ if (!lastStatus || lastStatus.status !== "done") {
453
+ throw new Error("Archive extraction timed out");
412
454
  }
413
- const reasonLines = [...byReason.entries()].sort((a, b) => b[1] - a[1]).map(([reason, count]) => ` ${count}\xD7 ${reason}`).join("\n");
414
- const samplePath = failedFiles[0];
415
- throw new Error(
416
- `Upload incomplete: ${failedFiles.length}/${files.length} file(s) failed. First failed file: ${samplePath}.
417
- Failure breakdown:
418
- ${reasonLines}
419
- Retry the upload or check your network connection.`
420
- );
421
- }
422
- }
423
- async function uploadOneFileWithError(file, signedUrl) {
424
- const data = readFileSync(file.absPath);
425
- let lastError = "unknown error";
426
- for (let attempt = 0; attempt < 3; attempt++) {
455
+ if (lastStatus.errors && lastStatus.errors > 0) {
456
+ throw new Error(
457
+ `Archive extraction completed but ${lastStatus.errors} file(s) failed to store. Retry the upload or check server logs.`
458
+ );
459
+ }
460
+ } finally {
427
461
  try {
428
- const resp = await fetch(signedUrl, {
429
- method: "PUT",
430
- headers: { "Content-Type": "application/octet-stream" },
431
- body: data,
432
- signal: AbortSignal.timeout(12e4)
433
- });
434
- if (resp.ok) return { ok: true };
435
- lastError = `HTTP ${resp.status} ${resp.statusText}`;
436
- try {
437
- const body = await resp.text();
438
- if (body) lastError += ` \u2014 ${body.slice(0, 150).replace(/\s+/g, " ")}`;
439
- } catch {
440
- }
441
- } catch (e) {
442
- lastError = `fetch error: ${e?.name ?? "Error"} ${e?.message ?? e}`;
462
+ unlinkSync(tmpArchive);
463
+ } catch {
443
464
  }
444
- if (attempt < 2) await sleep(1e3 * (attempt + 1));
445
465
  }
446
- return { ok: false, error: lastError };
466
+ }
467
+ async function apiGet(path, timeout = 6e4) {
468
+ const auth = await getAuthHeader();
469
+ if (!auth) throw new Error("Not authenticated. Run: trajectories auth login");
470
+ return fetch(`${getApiUrl()}${path}`, {
471
+ method: "GET",
472
+ headers: { Authorization: auth },
473
+ signal: AbortSignal.timeout(timeout)
474
+ });
447
475
  }
448
476
  function sleep(ms) {
449
477
  return new Promise((r) => setTimeout(r, ms));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trajectories-sh",
3
- "version": "1.9.9",
3
+ "version": "1.10.0",
4
4
  "description": "CLI for uploading trajectory jobs to trajectories.sh",
5
5
  "type": "module",
6
6
  "bin": {