trajectories-sh 1.0.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/auth-2SFY2XB2.js +139 -0
- package/dist/chunk-JSCDM6ZO.js +76 -0
- package/dist/cli.js +117 -0
- package/dist/config-Y3GNDGUO.js +28 -0
- package/dist/upload-DOEOEXFE.js +222 -0
- package/package.json +36 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearAuth,
|
|
3
|
+
getApiUrl,
|
|
4
|
+
getUserEmail,
|
|
5
|
+
isLoggedIn,
|
|
6
|
+
saveApiKey,
|
|
7
|
+
saveSession
|
|
8
|
+
} from "./chunk-JSCDM6ZO.js";
|
|
9
|
+
|
|
10
|
+
// src/auth.ts
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { randomBytes } from "crypto";
|
|
13
|
+
import { URL } from "url";
|
|
14
|
+
function getSiteUrl() {
|
|
15
|
+
const apiUrl = getApiUrl();
|
|
16
|
+
if (apiUrl.includes("localhost") || apiUrl.includes("127.0.0.1")) {
|
|
17
|
+
return "http://localhost:3000";
|
|
18
|
+
}
|
|
19
|
+
return apiUrl.replace("api.", "").replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
function findFreePort() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const server = createServer();
|
|
24
|
+
server.listen(0, () => {
|
|
25
|
+
const addr = server.address();
|
|
26
|
+
if (addr && typeof addr === "object") {
|
|
27
|
+
const port = addr.port;
|
|
28
|
+
server.close(() => resolve(port));
|
|
29
|
+
} else {
|
|
30
|
+
reject(new Error("Could not find free port"));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function loginWithBrowser() {
|
|
36
|
+
const port = await findFreePort();
|
|
37
|
+
const state = randomBytes(16).toString("hex");
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const timeout = setTimeout(() => {
|
|
40
|
+
server.close();
|
|
41
|
+
reject(new Error("Login timed out after 5 minutes"));
|
|
42
|
+
}, 5 * 60 * 1e3);
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
45
|
+
if (url.pathname === "/callback") {
|
|
46
|
+
const returnedState = url.searchParams.get("state");
|
|
47
|
+
const accessToken = url.searchParams.get("access_token");
|
|
48
|
+
const refreshToken = url.searchParams.get("refresh_token");
|
|
49
|
+
const email = url.searchParams.get("email") ?? "unknown";
|
|
50
|
+
const error = url.searchParams.get("error");
|
|
51
|
+
if (error) {
|
|
52
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
53
|
+
res.end(errorPage(error));
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
server.close();
|
|
56
|
+
reject(new Error(`Login failed: ${error}`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (returnedState !== state) {
|
|
60
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
61
|
+
res.end(errorPage("State mismatch \u2014 possible CSRF attack"));
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
server.close();
|
|
64
|
+
reject(new Error("State mismatch"));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!accessToken || !refreshToken) {
|
|
68
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
69
|
+
res.end(errorPage("Missing tokens in callback"));
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
server.close();
|
|
72
|
+
reject(new Error("Missing tokens"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
saveSession({ access_token: accessToken, refresh_token: refreshToken, email });
|
|
76
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
77
|
+
res.end(successPage(email));
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
server.close();
|
|
80
|
+
resolve({ email });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.writeHead(404);
|
|
84
|
+
res.end("Not found");
|
|
85
|
+
});
|
|
86
|
+
server.listen(port, async () => {
|
|
87
|
+
const loginUrl = `${getSiteUrl()}/cli-auth?port=${port}&state=${state}`;
|
|
88
|
+
console.log(` ${loginUrl}
|
|
89
|
+
`);
|
|
90
|
+
const { default: open } = await import("open");
|
|
91
|
+
await open(loginUrl);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function loginWithApiKey(key) {
|
|
96
|
+
const apiUrl = getApiUrl();
|
|
97
|
+
const resp = await fetch(`${apiUrl}/api/cli/whoami`, {
|
|
98
|
+
headers: { Authorization: `Bearer ${key}` }
|
|
99
|
+
});
|
|
100
|
+
if (!resp.ok) {
|
|
101
|
+
throw new Error("Invalid API key");
|
|
102
|
+
}
|
|
103
|
+
saveApiKey(key);
|
|
104
|
+
}
|
|
105
|
+
function logout() {
|
|
106
|
+
clearAuth();
|
|
107
|
+
}
|
|
108
|
+
function status() {
|
|
109
|
+
if (!isLoggedIn()) return { loggedIn: false };
|
|
110
|
+
const email = getUserEmail();
|
|
111
|
+
return { loggedIn: true, email: email ?? void 0, method: email ? "browser" : "api-key" };
|
|
112
|
+
}
|
|
113
|
+
function successPage(email) {
|
|
114
|
+
return `<!DOCTYPE html>
|
|
115
|
+
<html><head><title>Logged in \u2014 Trajectories</title>
|
|
116
|
+
<style>body{font-family:system-ui;background:#1c1c1e;color:#e5e5e7;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
117
|
+
.card{text-align:center;padding:3rem;border-radius:1rem;background:#2c2c2e;max-width:400px}
|
|
118
|
+
h1{font-size:1.5rem;margin-bottom:.5rem}p{color:#a1a1a6;font-size:.9rem}
|
|
119
|
+
.check{font-size:3rem;margin-bottom:1rem}</style></head>
|
|
120
|
+
<body><div class="card"><div class="check">\u2713</div><h1>Logged in!</h1>
|
|
121
|
+
<p>Authenticated as <strong>${email}</strong></p>
|
|
122
|
+
<p style="margin-top:1rem;color:#636366">You can close this window and return to your terminal.</p></div></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
function errorPage(error) {
|
|
125
|
+
return `<!DOCTYPE html>
|
|
126
|
+
<html><head><title>Login failed \u2014 Trajectories</title>
|
|
127
|
+
<style>body{font-family:system-ui;background:#1c1c1e;color:#e5e5e7;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
128
|
+
.card{text-align:center;padding:3rem;border-radius:1rem;background:#2c2c2e;max-width:400px}
|
|
129
|
+
h1{font-size:1.5rem;margin-bottom:.5rem;color:#ff6b6b}p{color:#a1a1a6;font-size:.9rem}
|
|
130
|
+
.icon{font-size:3rem;margin-bottom:1rem}</style></head>
|
|
131
|
+
<body><div class="card"><div class="icon">\u2717</div><h1>Login failed</h1>
|
|
132
|
+
<p>${error}</p></div></body></html>`;
|
|
133
|
+
}
|
|
134
|
+
export {
|
|
135
|
+
loginWithApiKey,
|
|
136
|
+
loginWithBrowser,
|
|
137
|
+
logout,
|
|
138
|
+
status
|
|
139
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
var CONFIG_DIR = join(homedir(), ".trajectories-sh");
|
|
6
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
+
var DEFAULT_API_URL = "https://api.trajectories.sh";
|
|
8
|
+
function loadConfig() {
|
|
9
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function saveConfig(config) {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
19
|
+
}
|
|
20
|
+
function getApiUrl() {
|
|
21
|
+
return loadConfig().api_url ?? DEFAULT_API_URL;
|
|
22
|
+
}
|
|
23
|
+
function getApiKey() {
|
|
24
|
+
return loadConfig().api_key;
|
|
25
|
+
}
|
|
26
|
+
function getAccessToken() {
|
|
27
|
+
return loadConfig().access_token;
|
|
28
|
+
}
|
|
29
|
+
function getAuthHeader() {
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
if (config.api_key) return `Bearer ${config.api_key}`;
|
|
32
|
+
if (config.access_token) return `Bearer ${config.access_token}`;
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
function getUserEmail() {
|
|
36
|
+
return loadConfig().user_email;
|
|
37
|
+
}
|
|
38
|
+
function saveSession(session) {
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
config.access_token = session.access_token;
|
|
41
|
+
config.refresh_token = session.refresh_token;
|
|
42
|
+
if (session.email) config.user_email = session.email;
|
|
43
|
+
saveConfig(config);
|
|
44
|
+
}
|
|
45
|
+
function saveApiKey(key) {
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
config.api_key = key;
|
|
48
|
+
saveConfig(config);
|
|
49
|
+
}
|
|
50
|
+
function saveApiUrl(url) {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
config.api_url = url;
|
|
53
|
+
saveConfig(config);
|
|
54
|
+
}
|
|
55
|
+
function clearAuth() {
|
|
56
|
+
if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE);
|
|
57
|
+
}
|
|
58
|
+
function isLoggedIn() {
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
return !!(config.api_key || config.access_token);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
CONFIG_DIR,
|
|
65
|
+
CONFIG_FILE,
|
|
66
|
+
getApiUrl,
|
|
67
|
+
getApiKey,
|
|
68
|
+
getAccessToken,
|
|
69
|
+
getAuthHeader,
|
|
70
|
+
getUserEmail,
|
|
71
|
+
saveSession,
|
|
72
|
+
saveApiKey,
|
|
73
|
+
saveApiUrl,
|
|
74
|
+
clearAuth,
|
|
75
|
+
isLoggedIn
|
|
76
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
var version = "1.0.0";
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
12
|
+
version = pkg.version;
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
var program = new Command();
|
|
16
|
+
program.name("trajectories").description("trajectories.sh \u2014 Upload and explore trajectory jobs").version(version);
|
|
17
|
+
var auth = program.command("auth").description("Manage authentication");
|
|
18
|
+
auth.command("login").description("Log in to trajectories.sh via your browser").option("--api-key <key>", "Use an API key instead (recommended for CI)").action(async (opts) => {
|
|
19
|
+
const ora = (await import("ora")).default;
|
|
20
|
+
const chalk = (await import("chalk")).default;
|
|
21
|
+
if (opts.apiKey) {
|
|
22
|
+
const spinner = ora("Validating API key...").start();
|
|
23
|
+
try {
|
|
24
|
+
const { loginWithApiKey } = await import("./auth-2SFY2XB2.js");
|
|
25
|
+
await loginWithApiKey(opts.apiKey);
|
|
26
|
+
spinner.succeed("Logged in with API key");
|
|
27
|
+
} catch (e) {
|
|
28
|
+
spinner.fail(e.message);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log(chalk.bold("\n Opening browser to log in...\n"));
|
|
34
|
+
console.log(chalk.dim(" If the browser doesn't open, visit:"));
|
|
35
|
+
try {
|
|
36
|
+
const { loginWithBrowser } = await import("./auth-2SFY2XB2.js");
|
|
37
|
+
const { email } = await loginWithBrowser();
|
|
38
|
+
console.log(chalk.green(`
|
|
39
|
+
\u2713 Logged in as ${email}
|
|
40
|
+
`));
|
|
41
|
+
console.log(chalk.dim(` Credentials saved to ~/.trajectories-sh/config.json`));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(chalk.red(`
|
|
44
|
+
\u2717 ${e.message}
|
|
45
|
+
`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
auth.command("logout").description("Log out and remove saved credentials").action(async () => {
|
|
50
|
+
const chalk = (await import("chalk")).default;
|
|
51
|
+
const { logout } = await import("./auth-2SFY2XB2.js");
|
|
52
|
+
logout();
|
|
53
|
+
console.log(chalk.green(" \u2713 Logged out. Credentials removed."));
|
|
54
|
+
});
|
|
55
|
+
auth.command("status").description("Show current authentication status").action(async () => {
|
|
56
|
+
const chalk = (await import("chalk")).default;
|
|
57
|
+
const { status } = await import("./auth-2SFY2XB2.js");
|
|
58
|
+
const s = status();
|
|
59
|
+
if (s.loggedIn) {
|
|
60
|
+
console.log(chalk.green(" \u2713 Authenticated"));
|
|
61
|
+
if (s.email) console.log(` Email: ${s.email}`);
|
|
62
|
+
console.log(` Method: ${s.method}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(chalk.dim(" Not logged in. Run: trajectories auth login"));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
program.command("upload <directory>").description("Upload a trajectory job directory to trajectories.sh").option("-s, --slug <slug>", "URL slug (default: directory name)").option("-n, --name <name>", "Display name (default: slug)").option("-v, --visibility <vis>", "public, unlisted, or private", "private").action(async (directory, opts) => {
|
|
68
|
+
const chalk = (await import("chalk")).default;
|
|
69
|
+
const { existsSync, statSync } = await import("fs");
|
|
70
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory()) {
|
|
71
|
+
console.error(chalk.red(` \u2717 Directory not found: ${directory}`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
const { isLoggedIn } = await import("./config-Y3GNDGUO.js");
|
|
75
|
+
if (!isLoggedIn()) {
|
|
76
|
+
console.error(chalk.red(" \u2717 Not authenticated. Run: trajectories auth login"));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const slug = opts.slug ?? directory.replace(/\/+$/, "").split("/").pop();
|
|
80
|
+
console.log(chalk.bold(`
|
|
81
|
+
Pushing ${directory} \u2192 ${chalk.cyan(slug)}
|
|
82
|
+
`));
|
|
83
|
+
try {
|
|
84
|
+
const { uploadJob } = await import("./upload-DOEOEXFE.js");
|
|
85
|
+
const result = await uploadJob(directory, {
|
|
86
|
+
slug,
|
|
87
|
+
name: opts.name,
|
|
88
|
+
visibility: opts.visibility
|
|
89
|
+
});
|
|
90
|
+
console.log(chalk.green.bold("\n \u2713 Pushed successfully!"));
|
|
91
|
+
console.log(` Trials: ${result.nTrials}`);
|
|
92
|
+
if (result.meanReward != null) {
|
|
93
|
+
console.log(` Pass rate: ${(result.meanReward * 100).toFixed(0)}%`);
|
|
94
|
+
}
|
|
95
|
+
console.log(` Viewer: ${result.viewerUrl}
|
|
96
|
+
`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error(chalk.red(`
|
|
99
|
+
\u2717 ${e.message}
|
|
100
|
+
`));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
program.command("whoami").description("Show current authentication status").action(async () => {
|
|
105
|
+
const chalk = (await import("chalk")).default;
|
|
106
|
+
const { status } = await import("./auth-2SFY2XB2.js");
|
|
107
|
+
const { getApiUrl } = await import("./config-Y3GNDGUO.js");
|
|
108
|
+
const s = status();
|
|
109
|
+
if (s.loggedIn) {
|
|
110
|
+
console.log(chalk.green(" \u2713 Authenticated"));
|
|
111
|
+
if (s.email) console.log(` Email: ${s.email}`);
|
|
112
|
+
console.log(` API: ${getApiUrl()}`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(chalk.dim(" Not logged in. Run: trajectories auth login"));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
program.parse();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_DIR,
|
|
3
|
+
CONFIG_FILE,
|
|
4
|
+
clearAuth,
|
|
5
|
+
getAccessToken,
|
|
6
|
+
getApiKey,
|
|
7
|
+
getApiUrl,
|
|
8
|
+
getAuthHeader,
|
|
9
|
+
getUserEmail,
|
|
10
|
+
isLoggedIn,
|
|
11
|
+
saveApiKey,
|
|
12
|
+
saveApiUrl,
|
|
13
|
+
saveSession
|
|
14
|
+
} from "./chunk-JSCDM6ZO.js";
|
|
15
|
+
export {
|
|
16
|
+
CONFIG_DIR,
|
|
17
|
+
CONFIG_FILE,
|
|
18
|
+
clearAuth,
|
|
19
|
+
getAccessToken,
|
|
20
|
+
getApiKey,
|
|
21
|
+
getApiUrl,
|
|
22
|
+
getAuthHeader,
|
|
23
|
+
getUserEmail,
|
|
24
|
+
isLoggedIn,
|
|
25
|
+
saveApiKey,
|
|
26
|
+
saveApiUrl,
|
|
27
|
+
saveSession
|
|
28
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getApiUrl,
|
|
3
|
+
getAuthHeader
|
|
4
|
+
} from "./chunk-JSCDM6ZO.js";
|
|
5
|
+
|
|
6
|
+
// src/upload.ts
|
|
7
|
+
import { readdirSync, statSync, readFileSync } from "fs";
|
|
8
|
+
import { join, relative } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { randomBytes } from "crypto";
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import * as tar from "tar";
|
|
13
|
+
var SKIP = /* @__PURE__ */ new Set(["__pycache__", ".DS_Store", ".git", "node_modules"]);
|
|
14
|
+
var MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
15
|
+
var ARCHIVE_THRESHOLD = 50;
|
|
16
|
+
var CHUNK_MAX_BYTES = 15 * 1024 * 1024;
|
|
17
|
+
function collectFiles(dir) {
|
|
18
|
+
const files = [];
|
|
19
|
+
function walk(current) {
|
|
20
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
21
|
+
if (SKIP.has(entry.name)) continue;
|
|
22
|
+
const full = join(current, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
walk(full);
|
|
25
|
+
} else if (entry.isFile()) {
|
|
26
|
+
const st = statSync(full);
|
|
27
|
+
if (st.size <= MAX_FILE_SIZE) {
|
|
28
|
+
files.push({ absPath: full, relPath: relative(dir, full), size: st.size });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
walk(dir);
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
async function apiPost(path, body, timeout = 3e5) {
|
|
37
|
+
const auth = getAuthHeader();
|
|
38
|
+
if (!auth) throw new Error("Not authenticated. Run: trajectories auth login");
|
|
39
|
+
return fetch(`${getApiUrl()}${path}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { Authorization: auth, "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
signal: AbortSignal.timeout(timeout)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function apiPostFile(path, formData) {
|
|
47
|
+
const auth = getAuthHeader();
|
|
48
|
+
if (!auth) throw new Error("Not authenticated. Run: trajectories auth login");
|
|
49
|
+
return fetch(`${getApiUrl()}${path}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { Authorization: auth },
|
|
52
|
+
body: formData,
|
|
53
|
+
signal: AbortSignal.timeout(3e5)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function apiGet(path) {
|
|
57
|
+
const auth = getAuthHeader();
|
|
58
|
+
if (!auth) throw new Error("Not authenticated. Run: trajectories auth login");
|
|
59
|
+
return fetch(`${getApiUrl()}${path}`, {
|
|
60
|
+
headers: { Authorization: auth },
|
|
61
|
+
signal: AbortSignal.timeout(3e4)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function uploadJob(dir, opts) {
|
|
65
|
+
const slug = opts.slug ?? dir.split("/").pop();
|
|
66
|
+
const name = opts.name ?? slug;
|
|
67
|
+
const visibility = opts.visibility ?? "private";
|
|
68
|
+
const files = collectFiles(dir);
|
|
69
|
+
const totalSize = files.reduce((s, f) => s + f.size, 0);
|
|
70
|
+
console.log(` ${files.length} files, ${(totalSize / 1024 / 1024).toFixed(1)} MB`);
|
|
71
|
+
const initResp = await apiPost("/api/cli/push/init", { slug, name, visibility });
|
|
72
|
+
if (!initResp.ok) {
|
|
73
|
+
const text = await initResp.text();
|
|
74
|
+
throw new Error(`Init failed: ${text}`);
|
|
75
|
+
}
|
|
76
|
+
const { job_id: jobId } = await initResp.json();
|
|
77
|
+
console.log(` Job ID: ${jobId}`);
|
|
78
|
+
if (files.length >= ARCHIVE_THRESHOLD) {
|
|
79
|
+
await uploadArchive(jobId, dir, files, totalSize);
|
|
80
|
+
} else {
|
|
81
|
+
await uploadFiles(jobId, files);
|
|
82
|
+
}
|
|
83
|
+
console.log(" Finalizing...");
|
|
84
|
+
const finalResp = await apiPost(`/api/cli/push/${jobId}/finalize`, {});
|
|
85
|
+
if (!finalResp.ok) {
|
|
86
|
+
throw new Error(`Finalize failed: ${await finalResp.text()}`);
|
|
87
|
+
}
|
|
88
|
+
const result = await finalResp.json();
|
|
89
|
+
return {
|
|
90
|
+
jobId,
|
|
91
|
+
slug: result.slug,
|
|
92
|
+
nTrials: result.n_trials,
|
|
93
|
+
meanReward: result.mean_reward,
|
|
94
|
+
viewerUrl: `${getApiUrl()}${result.viewer_url}`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function uploadArchive(jobId, dir, files, totalSize) {
|
|
98
|
+
const archivePath = join(tmpdir(), `trajectories-${randomBytes(4).toString("hex")}.tar.gz`);
|
|
99
|
+
console.log(` Compressing ${files.length} files...`);
|
|
100
|
+
await tar.create(
|
|
101
|
+
{ gzip: true, file: archivePath, cwd: dir },
|
|
102
|
+
files.map((f) => f.relPath)
|
|
103
|
+
);
|
|
104
|
+
const archiveSize = statSync(archivePath).size;
|
|
105
|
+
const ratio = ((1 - archiveSize / totalSize) * 100).toFixed(0);
|
|
106
|
+
console.log(` Archive: ${(archiveSize / 1024 / 1024).toFixed(1)} MB (${ratio}% compressed)`);
|
|
107
|
+
await doSignedUploadAndExtract(jobId, archivePath, archiveSize);
|
|
108
|
+
}
|
|
109
|
+
async function doSignedUploadAndExtract(jobId, archivePath, archiveSize) {
|
|
110
|
+
const urlResp = await apiPost(`/api/cli/push/${jobId}/upload-url`, {});
|
|
111
|
+
if (!urlResp.ok) throw new Error(`Failed to get upload URL: ${await urlResp.text()}`);
|
|
112
|
+
const { signed_url: signedUrl } = await urlResp.json();
|
|
113
|
+
console.log(" Uploading archive...");
|
|
114
|
+
let uploaded = false;
|
|
115
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
116
|
+
try {
|
|
117
|
+
try {
|
|
118
|
+
const result = execFileSync("curl", [
|
|
119
|
+
"--http1.1",
|
|
120
|
+
"-s",
|
|
121
|
+
"-w",
|
|
122
|
+
"%{response_code}",
|
|
123
|
+
"-o",
|
|
124
|
+
"/dev/null",
|
|
125
|
+
"-X",
|
|
126
|
+
"PUT",
|
|
127
|
+
signedUrl,
|
|
128
|
+
"-H",
|
|
129
|
+
"Content-Type: application/gzip",
|
|
130
|
+
"-H",
|
|
131
|
+
"Expect:",
|
|
132
|
+
"--data-binary",
|
|
133
|
+
`@${archivePath}`,
|
|
134
|
+
"--max-time",
|
|
135
|
+
"600"
|
|
136
|
+
], { encoding: "utf-8" });
|
|
137
|
+
if (result.trim() === "200" || result.trim() === "201") {
|
|
138
|
+
uploaded = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
const body = readFileSync(archivePath);
|
|
143
|
+
const resp = await fetch(signedUrl, {
|
|
144
|
+
method: "PUT",
|
|
145
|
+
headers: { "Content-Type": "application/gzip" },
|
|
146
|
+
body,
|
|
147
|
+
signal: AbortSignal.timeout(6e5)
|
|
148
|
+
});
|
|
149
|
+
if (resp.ok) {
|
|
150
|
+
uploaded = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
console.log(` Upload attempt ${attempt + 1} failed`);
|
|
156
|
+
}
|
|
157
|
+
if (attempt < 2) await sleep(5e3);
|
|
158
|
+
}
|
|
159
|
+
if (!uploaded) throw new Error("Archive upload failed after retries");
|
|
160
|
+
console.log(" Extracting on server...");
|
|
161
|
+
const extractResp = await apiPost(`/api/cli/push/${jobId}/extract`, {}, 3e4);
|
|
162
|
+
if (!extractResp.ok) throw new Error(`Extract failed: ${await extractResp.text()}`);
|
|
163
|
+
while (true) {
|
|
164
|
+
await sleep(3e3);
|
|
165
|
+
try {
|
|
166
|
+
const statusResp = await apiGet(`/api/cli/push/${jobId}/extract-status`);
|
|
167
|
+
if (!statusResp.ok) continue;
|
|
168
|
+
const status = await statusResp.json();
|
|
169
|
+
if (status.status === "done") {
|
|
170
|
+
console.log(` \u2713 ${status.uploaded} files stored`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (status.status === "error") throw new Error(`Extraction error: ${status.detail}`);
|
|
174
|
+
if (status.uploaded > 0) {
|
|
175
|
+
process.stdout.write(` ${status.status}... ${status.uploaded} files stored\r`);
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (e.message?.includes("Extraction error")) throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function uploadFiles(jobId, files) {
|
|
183
|
+
let uploaded = 0;
|
|
184
|
+
let errors = 0;
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
let ok = false;
|
|
187
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
188
|
+
try {
|
|
189
|
+
const formData = new FormData();
|
|
190
|
+
const blob = new Blob([readFileSync(file.absPath)]);
|
|
191
|
+
formData.append("file", blob, file.relPath);
|
|
192
|
+
formData.append("path", file.relPath);
|
|
193
|
+
const resp = await apiPostFile(`/api/cli/push/${jobId}/file`, formData);
|
|
194
|
+
if (resp.ok) {
|
|
195
|
+
ok = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
if (attempt < 2) await sleep(1e3 * (attempt + 1));
|
|
201
|
+
}
|
|
202
|
+
if (ok) {
|
|
203
|
+
uploaded++;
|
|
204
|
+
console.log(` \u2713 ${file.relPath}`);
|
|
205
|
+
} else {
|
|
206
|
+
errors++;
|
|
207
|
+
console.log(` \u2717 ${file.relPath}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (errors > 0) {
|
|
211
|
+
console.log(` Uploaded: ${uploaded}, Errors: ${errors}`);
|
|
212
|
+
} else {
|
|
213
|
+
console.log(` \u2713 All ${uploaded} files uploaded`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function sleep(ms) {
|
|
217
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
218
|
+
}
|
|
219
|
+
export {
|
|
220
|
+
collectFiles,
|
|
221
|
+
uploadJob
|
|
222
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trajectories-sh",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI for uploading trajectory jobs to trajectories.sh",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"trajectories": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup src/cli.ts --format esm --target node18 --clean",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"prepublishOnly": "npm run build"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"keywords": ["trajectories", "harbor", "terminal-bench", "cli"],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"commander": "^13.0.0",
|
|
24
|
+
"open": "^10.0.0",
|
|
25
|
+
"ora": "^8.0.0",
|
|
26
|
+
"chalk": "^5.0.0",
|
|
27
|
+
"tar": "^7.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"@types/tar": "^6.1.0",
|
|
32
|
+
"tsup": "^8.0.0",
|
|
33
|
+
"tsx": "^4.0.0",
|
|
34
|
+
"typescript": "^5.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|