trajectories-sh 1.2.2 → 1.4.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 @@ program.command("upload <directory>").description("Upload a trajectory job direc
81
81
  Pushing ${directory} \u2192 ${chalk.cyan(slug)}
82
82
  `));
83
83
  try {
84
- const { uploadJob } = await import("./upload-3SQPHPNI.js");
84
+ const { uploadJob } = await import("./upload-WZGAR4S4.js");
85
85
  const result = await uploadJob(directory, {
86
86
  slug,
87
87
  name: opts.name,
@@ -101,6 +101,26 @@ program.command("upload <directory>").description("Upload a trajectory job direc
101
101
  process.exit(1);
102
102
  }
103
103
  });
104
+ program.command("download <url>").description("Download a trajectory job from trajectories.sh").option("-o, --output <dir>", "Output directory (default: slug name)").action(async (url, opts) => {
105
+ const chalk = (await import("chalk")).default;
106
+ console.log(chalk.bold(`
107
+ Downloading trajectory...
108
+ `));
109
+ try {
110
+ const { downloadJob, parseJobId } = await import("./download-YWW2BV6K.js");
111
+ const outputDir = opts.output ?? parseJobId(url).replace(/^org:/, "").replace(/:/g, "-");
112
+ const result = await downloadJob(url, outputDir);
113
+ console.log(chalk.green.bold(` \u2713 Downloaded ${result.files} files`));
114
+ console.log(` Size: ${(result.totalSize / 1024 / 1024).toFixed(1)} MB`);
115
+ console.log(` Output: ${outputDir}
116
+ `);
117
+ } catch (e) {
118
+ console.error(chalk.red(`
119
+ \u2717 ${e.message}
120
+ `));
121
+ process.exit(1);
122
+ }
123
+ });
104
124
  program.command("whoami").description("Show current authentication status").action(async () => {
105
125
  const chalk = (await import("chalk")).default;
106
126
  const { status } = await import("./auth-UD3KTSHK.js");
@@ -0,0 +1,111 @@
1
+ import {
2
+ getApiUrl,
3
+ getAuthHeader
4
+ } from "./chunk-P6J5X7LP.js";
5
+
6
+ // src/download.ts
7
+ import { mkdirSync, writeFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
10
+ function parseJobId(input) {
11
+ const trimmed = input.trim();
12
+ if (UUID_RE.test(trimmed) && !trimmed.includes("/")) {
13
+ return trimmed.match(UUID_RE)[0];
14
+ }
15
+ const trajMatch = trimmed.match(/\/trajectories\/([0-9a-f-]{36})/i);
16
+ if (trajMatch) return trajMatch[1];
17
+ const trajViewerMatch = trimmed.match(/\/traj\/([0-9a-f-]{36})\/([^/]+)/i);
18
+ if (trajViewerMatch) {
19
+ return `org:${trajViewerMatch[1]}:${trajViewerMatch[2]}`;
20
+ }
21
+ const anyUuid = trimmed.match(UUID_RE);
22
+ if (anyUuid) return anyUuid[0];
23
+ throw new Error(`Could not find a trajectory job ID in: ${input}`);
24
+ }
25
+ async function downloadJob(input, outputDir) {
26
+ const apiUrl = getApiUrl();
27
+ const auth = await getAuthHeader();
28
+ const headers = {};
29
+ if (auth) headers["Authorization"] = auth;
30
+ let jobId = parseJobId(input);
31
+ if (jobId.startsWith("org:")) {
32
+ const [, orgId, slug2] = jobId.split(":");
33
+ const resp = await fetch(`${apiUrl}/api/org/trajectories/public/${orgId}?slug=${slug2}`, { headers });
34
+ if (!resp.ok) {
35
+ const configResp = await fetch(`${apiUrl}/api/viewer/${orgId}/${slug2}/api/jobs`, { headers });
36
+ if (!configResp.ok) throw new Error(`Could not resolve job: ${orgId}/${slug2}`);
37
+ const configData = await configResp.json();
38
+ const items = configData.items || [];
39
+ if (items.length > 0 && items[0].id) {
40
+ jobId = items[0].id;
41
+ } else {
42
+ throw new Error(`Could not resolve job ID for ${orgId}/${slug2}`);
43
+ }
44
+ } else {
45
+ const data = await resp.json();
46
+ jobId = data.id;
47
+ }
48
+ }
49
+ const listResp = await fetch(`${apiUrl}/api/cli/download/${jobId}/files`, {
50
+ headers,
51
+ signal: AbortSignal.timeout(3e4)
52
+ });
53
+ if (!listResp.ok) {
54
+ const text = await listResp.text();
55
+ throw new Error(`Failed to list files: ${text}`);
56
+ }
57
+ const { slug, files } = await listResp.json();
58
+ console.log(` Job: ${slug} (${jobId})`);
59
+ console.log(` ${files.length} files to download`);
60
+ mkdirSync(outputDir, { recursive: true });
61
+ let downloaded = 0;
62
+ let totalSize = 0;
63
+ const concurrency = 8;
64
+ const pending = [];
65
+ for (const file of files) {
66
+ const task = (async () => {
67
+ for (let attempt = 0; attempt < 3; attempt++) {
68
+ try {
69
+ const fileResp = await fetch(
70
+ `${apiUrl}/api/cli/download/${jobId}/file?path=${encodeURIComponent(file.path)}`,
71
+ { headers, signal: AbortSignal.timeout(6e4) }
72
+ );
73
+ if (!fileResp.ok) throw new Error(`HTTP ${fileResp.status}`);
74
+ const data = Buffer.from(await fileResp.arrayBuffer());
75
+ const outPath = join(outputDir, file.path);
76
+ mkdirSync(dirname(outPath), { recursive: true });
77
+ writeFileSync(outPath, data);
78
+ downloaded++;
79
+ totalSize += data.length;
80
+ if (downloaded % 10 === 0 || downloaded === files.length) {
81
+ process.stdout.write(` \u2193 ${downloaded}/${files.length} downloaded\r`);
82
+ }
83
+ return;
84
+ } catch {
85
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
86
+ }
87
+ }
88
+ console.log(` \u2717 ${file.path}`);
89
+ })();
90
+ pending.push(task);
91
+ if (pending.length >= concurrency) {
92
+ await Promise.race(pending);
93
+ const next = [];
94
+ for (const p of pending) {
95
+ let settled = false;
96
+ p.then(() => settled = true, () => settled = true);
97
+ if (!settled) next.push(p);
98
+ }
99
+ if (next.length === pending.length) await Promise.race(pending);
100
+ pending.length = 0;
101
+ pending.push(...next);
102
+ }
103
+ }
104
+ await Promise.all(pending);
105
+ console.log("");
106
+ return { files: downloaded, totalSize };
107
+ }
108
+ export {
109
+ downloadJob,
110
+ parseJobId
111
+ };
@@ -4,8 +4,10 @@ import {
4
4
  } from "./chunk-P6J5X7LP.js";
5
5
 
6
6
  // src/upload.ts
7
- import { readdirSync, statSync, readFileSync } from "fs";
8
- import { join, relative } from "path";
7
+ import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
8
+ import { join, relative, dirname } from "path";
9
+ import { tmpdir } from "os";
10
+ import { randomBytes } from "crypto";
9
11
  var SKIP = /* @__PURE__ */ new Set(["__pycache__", ".DS_Store", ".git", "node_modules"]);
10
12
  var MAX_FILE_SIZE = 50 * 1024 * 1024;
11
13
  var CONCURRENCY = 12;
@@ -39,11 +41,94 @@ async function apiPost(path, body, timeout = 3e5) {
39
41
  signal: AbortSignal.timeout(timeout)
40
42
  });
41
43
  }
44
+ async function convertToWebP(dir) {
45
+ const files = collectFiles(dir);
46
+ const pngs = files.filter((f) => f.relPath.toLowerCase().endsWith(".png"));
47
+ if (pngs.length === 0) {
48
+ return { convertedDir: dir, pngsConverted: 0, savedBytes: 0 };
49
+ }
50
+ let sharp;
51
+ try {
52
+ sharp = (await import("sharp")).default;
53
+ } catch {
54
+ console.log(" \u26A0 sharp not available, skipping WebP conversion");
55
+ return { convertedDir: dir, pngsConverted: 0, savedBytes: 0 };
56
+ }
57
+ const tmpDir = join(tmpdir(), `trajectories-webp-${randomBytes(4).toString("hex")}`);
58
+ function copyDir(src, dest) {
59
+ mkdirSync(dest, { recursive: true });
60
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
61
+ if (SKIP.has(entry.name)) continue;
62
+ const srcPath = join(src, entry.name);
63
+ const destPath = join(dest, entry.name);
64
+ if (entry.isDirectory()) {
65
+ copyDir(srcPath, destPath);
66
+ } else {
67
+ copyFileSync(srcPath, destPath);
68
+ }
69
+ }
70
+ }
71
+ copyDir(dir, tmpDir);
72
+ let converted = 0;
73
+ let savedBytes = 0;
74
+ const batchSize = 10;
75
+ for (let i = 0; i < pngs.length; i += batchSize) {
76
+ const batch = pngs.slice(i, i + batchSize);
77
+ await Promise.all(
78
+ batch.map(async (f) => {
79
+ const srcPath = join(tmpDir, f.relPath);
80
+ const webpPath = srcPath.replace(/\.png$/i, ".webp");
81
+ try {
82
+ await sharp(srcPath).webp({ quality: 80 }).toFile(webpPath);
83
+ const origSize = statSync(srcPath).size;
84
+ const newSize = statSync(webpPath).size;
85
+ if (newSize < origSize) {
86
+ const { unlinkSync } = await import("fs");
87
+ unlinkSync(srcPath);
88
+ savedBytes += origSize - newSize;
89
+ converted++;
90
+ } else {
91
+ const { unlinkSync } = await import("fs");
92
+ unlinkSync(webpPath);
93
+ }
94
+ } catch {
95
+ }
96
+ })
97
+ );
98
+ process.stdout.write(` Converting: ${Math.min(i + batchSize, pngs.length)}/${pngs.length} PNGs\r`);
99
+ }
100
+ console.log("");
101
+ const trajFiles = collectFiles(tmpDir).filter((f) => f.relPath.endsWith("trajectory.json"));
102
+ for (const tf of trajFiles) {
103
+ try {
104
+ let content = readFileSync(tf.absPath, "utf-8");
105
+ const original = content;
106
+ content = content.replace(/screenshots\/([^"]+)\.png/g, (match, name) => {
107
+ const webpPath = join(dirname(tf.absPath), "screenshots", `${name}.webp`);
108
+ try {
109
+ statSync(webpPath);
110
+ return `screenshots/${name}.webp`;
111
+ } catch {
112
+ return match;
113
+ }
114
+ });
115
+ if (content !== original) {
116
+ writeFileSync(tf.absPath, content);
117
+ }
118
+ } catch {
119
+ }
120
+ }
121
+ return { convertedDir: tmpDir, pngsConverted: converted, savedBytes };
122
+ }
42
123
  async function uploadJob(dir, opts) {
43
124
  const slug = opts.slug ?? dir.replace(/\/+$/, "").split("/").pop();
44
125
  const name = opts.name ?? slug;
45
126
  const visibility = opts.visibility ?? "private";
46
- const files = collectFiles(dir);
127
+ const { convertedDir, pngsConverted, savedBytes } = await convertToWebP(dir);
128
+ if (pngsConverted > 0) {
129
+ console.log(` \u2713 Converted ${pngsConverted} PNGs to WebP (saved ${(savedBytes / 1024 / 1024).toFixed(1)} MB)`);
130
+ }
131
+ const files = collectFiles(convertedDir);
47
132
  const totalSize = files.reduce((s, f) => s + f.size, 0);
48
133
  console.log(` ${files.length} files, ${(totalSize / 1024 / 1024).toFixed(1)} MB`);
49
134
  const initResp = await apiPost("/api/cli/push/init", { slug, name, visibility });
@@ -59,6 +144,13 @@ async function uploadJob(dir, opts) {
59
144
  throw new Error(`Finalize failed: ${await finalResp.text()}`);
60
145
  }
61
146
  const result = await finalResp.json();
147
+ if (convertedDir !== dir) {
148
+ try {
149
+ const { rmSync } = await import("fs");
150
+ rmSync(convertedDir, { recursive: true, force: true });
151
+ } catch {
152
+ }
153
+ }
62
154
  const apiUrl = getApiUrl();
63
155
  const siteUrl = apiUrl.includes("localhost") ? "http://localhost:3000" : apiUrl.replace("api.", "").replace(/\/$/, "");
64
156
  return {
@@ -134,9 +226,7 @@ async function uploadOneFile(file, signedUrl) {
134
226
  try {
135
227
  const resp = await fetch(signedUrl, {
136
228
  method: "PUT",
137
- headers: {
138
- "Content-Type": "application/octet-stream"
139
- },
229
+ headers: { "Content-Type": "application/octet-stream" },
140
230
  body: data,
141
231
  signal: AbortSignal.timeout(12e4)
142
232
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trajectories-sh",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "CLI for uploading trajectory jobs to trajectories.sh",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,11 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
+ "chalk": "^5.0.0",
29
30
  "commander": "^13.0.0",
31
+ "sharp": "^0.34.5",
30
32
  "open": "^10.0.0",
31
33
  "ora": "^8.0.0",
32
- "chalk": "^5.0.0",
33
34
  "tar": "^7.0.0"
34
35
  },
35
36
  "devDependencies": {