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-
|
|
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
|
|
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.
|
|
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": {
|