@thermai/flightdeck-cli 0.1.0-pilot.1
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/LICENSE +123 -0
- package/README.md +87 -0
- package/dist/index.js +1176 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
6
|
+
|
|
7
|
+
// src/submit-core.ts
|
|
8
|
+
import { existsSync as existsSync2, mkdtempSync, readFileSync as readFileSync2, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join as join2, resolve } from "node:path";
|
|
11
|
+
import { create as tarCreate } from "tar";
|
|
12
|
+
|
|
13
|
+
// src/client.ts
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
var DEFAULT_API_URL = "https://api.flightdeck.thermai.uk";
|
|
27
|
+
var CONFIG_PATH = join(homedir(), ".flightdeck", "config.json");
|
|
28
|
+
function normalizeApiUrl(value) {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
const localHost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
31
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && localHost)) {
|
|
32
|
+
throw new Error("FlightDeck API URLs must use HTTPS (HTTP is allowed only for localhost)");
|
|
33
|
+
}
|
|
34
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
35
|
+
throw new Error("FlightDeck API URL must not contain credentials, a query, or a fragment");
|
|
36
|
+
}
|
|
37
|
+
url.pathname = url.pathname.replace(/\/$/, "");
|
|
38
|
+
return url.toString().replace(/\/$/, "");
|
|
39
|
+
}
|
|
40
|
+
function validConfig(value) {
|
|
41
|
+
if (!value || typeof value !== "object") return false;
|
|
42
|
+
const candidate = value;
|
|
43
|
+
if (typeof candidate.api_url !== "string" || typeof candidate.api_key !== "string") return false;
|
|
44
|
+
if (candidate.credential_id !== void 0 && typeof candidate.credential_id !== "string") return false;
|
|
45
|
+
try {
|
|
46
|
+
normalizeApiUrl(candidate.api_url);
|
|
47
|
+
return candidate.api_key.length > 0;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function readConfig(path = CONFIG_PATH) {
|
|
53
|
+
if (!existsSync(path)) return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
return validConfig(parsed) ? parsed : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function loadConfig() {
|
|
62
|
+
const config = readConfig();
|
|
63
|
+
if (!config) {
|
|
64
|
+
console.error(
|
|
65
|
+
`No valid login found at ${CONFIG_PATH}.
|
|
66
|
+
Run: flightdeck login`
|
|
67
|
+
);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const stat = statSync(CONFIG_PATH);
|
|
71
|
+
if ((stat.mode & 63) !== 0) {
|
|
72
|
+
console.error(`Warning: ${CONFIG_PATH} is readable by other users; run chmod 600 ${CONFIG_PATH}`);
|
|
73
|
+
}
|
|
74
|
+
return config;
|
|
75
|
+
}
|
|
76
|
+
function saveConfig(config, path = CONFIG_PATH) {
|
|
77
|
+
if (!validConfig(config)) throw new Error("invalid FlightDeck configuration");
|
|
78
|
+
const directory = dirname(path);
|
|
79
|
+
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
80
|
+
chmodSync(directory, 448);
|
|
81
|
+
const temporaryPath = `${path}.tmp-${process.pid}`;
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}
|
|
84
|
+
`, {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
mode: 384,
|
|
87
|
+
flag: "wx"
|
|
88
|
+
});
|
|
89
|
+
chmodSync(temporaryPath, 384);
|
|
90
|
+
renameSync(temporaryPath, path);
|
|
91
|
+
chmodSync(path, 384);
|
|
92
|
+
} finally {
|
|
93
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function deleteConfig(path = CONFIG_PATH) {
|
|
97
|
+
if (!existsSync(path)) return false;
|
|
98
|
+
unlinkSync(path);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function requestBody(body, headers) {
|
|
102
|
+
if (body instanceof FormData) return body;
|
|
103
|
+
if (!body) return void 0;
|
|
104
|
+
headers["Content-Type"] = "application/json";
|
|
105
|
+
return JSON.stringify(body);
|
|
106
|
+
}
|
|
107
|
+
function endpoint(apiUrl, path) {
|
|
108
|
+
return `${normalizeApiUrl(apiUrl)}/api/v1${path}`;
|
|
109
|
+
}
|
|
110
|
+
async function publicApiRequest(apiUrl, method, path, body) {
|
|
111
|
+
const headers = {};
|
|
112
|
+
return fetch(endpoint(apiUrl, path), {
|
|
113
|
+
method,
|
|
114
|
+
headers,
|
|
115
|
+
body: requestBody(body, headers)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async function apiRequest(method, path, body) {
|
|
119
|
+
const config = loadConfig();
|
|
120
|
+
const headers = {
|
|
121
|
+
Authorization: `Bearer ${config.api_key}`
|
|
122
|
+
};
|
|
123
|
+
return fetch(endpoint(config.api_url, path), {
|
|
124
|
+
method,
|
|
125
|
+
headers,
|
|
126
|
+
body: requestBody(body, headers)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ../backend/contracts/environment.js
|
|
131
|
+
var MAX_CUSTOMER_ENV_ENTRIES = 64;
|
|
132
|
+
var MAX_RUNTIME_ENV_ENTRIES = 72;
|
|
133
|
+
var MAX_ENV_KEY_CHARACTERS = 128;
|
|
134
|
+
var MAX_ENV_VALUE_UTF8_BYTES = 4096;
|
|
135
|
+
var MAX_CUSTOMER_ENV_UTF8_BYTES = 16 * 1024;
|
|
136
|
+
var MAX_RUNTIME_ENV_UTF8_BYTES = 24 * 1024;
|
|
137
|
+
var RESERVED_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
138
|
+
"BUNDLE_URL",
|
|
139
|
+
"ENTRYPOINT_CMD",
|
|
140
|
+
"REQUIREMENTS_FILE",
|
|
141
|
+
"OUTPUTS_DIR",
|
|
142
|
+
"FLIGHT_ID",
|
|
143
|
+
"FLIGHTDECK_INGEST_URL",
|
|
144
|
+
"INGEST_TOKEN",
|
|
145
|
+
"NODE_ID",
|
|
146
|
+
"JOB_ID",
|
|
147
|
+
"ATTEMPT_SEQ"
|
|
148
|
+
]);
|
|
149
|
+
var RESERVED = new Set(RESERVED_ENVIRONMENT_VARIABLES);
|
|
150
|
+
var ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
151
|
+
var EnvironmentContractError = class extends Error {
|
|
152
|
+
constructor(issues) {
|
|
153
|
+
super(issues.join("; "));
|
|
154
|
+
this.name = "EnvironmentContractError";
|
|
155
|
+
this.issues = issues;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
function utf8Bytes(value) {
|
|
159
|
+
return new TextEncoder().encode(value).byteLength;
|
|
160
|
+
}
|
|
161
|
+
function environmentContractIssues(value, mode = "customer") {
|
|
162
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
163
|
+
return ["environment must be a string record"];
|
|
164
|
+
}
|
|
165
|
+
const entries = Object.entries(value);
|
|
166
|
+
const runtime = mode === "runtime";
|
|
167
|
+
const maximumEntries = runtime ? MAX_RUNTIME_ENV_ENTRIES : MAX_CUSTOMER_ENV_ENTRIES;
|
|
168
|
+
const maximumBytes = runtime ? MAX_RUNTIME_ENV_UTF8_BYTES : MAX_CUSTOMER_ENV_UTF8_BYTES;
|
|
169
|
+
const issues = [];
|
|
170
|
+
let aggregateBytes = 0;
|
|
171
|
+
if (entries.length > maximumEntries) {
|
|
172
|
+
issues.push(`environment has more than ${maximumEntries} entries`);
|
|
173
|
+
}
|
|
174
|
+
for (const [name, rawValue] of entries) {
|
|
175
|
+
if (typeof rawValue !== "string") {
|
|
176
|
+
issues.push(`${name || "<empty>"} must have a string value`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!ENVIRONMENT_NAME.test(name)) {
|
|
180
|
+
issues.push(`${name || "<empty>"} is an invalid environment variable name`);
|
|
181
|
+
}
|
|
182
|
+
if (name.length > MAX_ENV_KEY_CHARACTERS) {
|
|
183
|
+
issues.push(`${name} exceeds ${MAX_ENV_KEY_CHARACTERS} characters`);
|
|
184
|
+
}
|
|
185
|
+
if (!runtime && RESERVED.has(name)) {
|
|
186
|
+
issues.push(`${name} is reserved by Flightdeck`);
|
|
187
|
+
}
|
|
188
|
+
const valueBytes = utf8Bytes(rawValue);
|
|
189
|
+
if (valueBytes > MAX_ENV_VALUE_UTF8_BYTES) {
|
|
190
|
+
issues.push(`${name} exceeds ${MAX_ENV_VALUE_UTF8_BYTES} UTF-8 bytes`);
|
|
191
|
+
}
|
|
192
|
+
aggregateBytes += utf8Bytes(name) + 1 + valueBytes + 1;
|
|
193
|
+
}
|
|
194
|
+
if (aggregateBytes > maximumBytes) {
|
|
195
|
+
issues.push(`environment exceeds ${maximumBytes} aggregate UTF-8 bytes`);
|
|
196
|
+
}
|
|
197
|
+
return issues;
|
|
198
|
+
}
|
|
199
|
+
function assertEnvironmentContract(value, mode = "customer") {
|
|
200
|
+
const issues = environmentContractIssues(value, mode);
|
|
201
|
+
if (issues.length > 0) throw new EnvironmentContractError(issues);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/submit-core.ts
|
|
205
|
+
var SubmitError = class extends Error {
|
|
206
|
+
constructor(status2, body) {
|
|
207
|
+
super(`Submit failed (${status2})`);
|
|
208
|
+
this.status = status2;
|
|
209
|
+
this.body = body;
|
|
210
|
+
this.name = "SubmitError";
|
|
211
|
+
}
|
|
212
|
+
status;
|
|
213
|
+
body;
|
|
214
|
+
};
|
|
215
|
+
function parseEnvironmentArguments(envArgs) {
|
|
216
|
+
const result = {};
|
|
217
|
+
for (const arg of envArgs ?? []) {
|
|
218
|
+
const equals = arg.indexOf("=");
|
|
219
|
+
if (equals < 1) throw new Error(`invalid env var format: ${arg} (expected KEY=VALUE)`);
|
|
220
|
+
result[arg.slice(0, equals)] = arg.slice(equals + 1);
|
|
221
|
+
}
|
|
222
|
+
assertEnvironmentContract(result, "customer");
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
async function createBundle(filePaths) {
|
|
226
|
+
for (const filePath of filePaths) {
|
|
227
|
+
if (!existsSync2(filePath)) {
|
|
228
|
+
throw new Error(`File not found: ${filePath}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const tempDir = mkdtempSync(join2(tmpdir(), "flightdeck-"));
|
|
232
|
+
const bundlePath = join2(tempDir, "bundle.tar.gz");
|
|
233
|
+
try {
|
|
234
|
+
await tarCreate({ gzip: true, file: bundlePath, cwd: resolve(".") }, filePaths);
|
|
235
|
+
return readFileSync2(bundlePath);
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function submitFlight(plan, bundle) {
|
|
241
|
+
assertEnvironmentContract(plan.env_vars ?? {}, "customer");
|
|
242
|
+
const formData = new FormData();
|
|
243
|
+
formData.append("plan", JSON.stringify(plan));
|
|
244
|
+
formData.append(
|
|
245
|
+
"bundle",
|
|
246
|
+
new Blob([bundle], { type: "application/gzip" }),
|
|
247
|
+
"bundle.tar.gz"
|
|
248
|
+
);
|
|
249
|
+
const response = await apiRequest("POST", "/jobs/submit", formData);
|
|
250
|
+
const body = await response.json();
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
throw new SubmitError(response.status, body);
|
|
253
|
+
}
|
|
254
|
+
return body;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/commands/submit.ts
|
|
258
|
+
function parseDuration(input) {
|
|
259
|
+
const match = input.match(/^(\d+)(h|m|s)?$/);
|
|
260
|
+
if (!match) {
|
|
261
|
+
console.error(`Invalid duration: ${input} (use e.g. 1h, 30m, 3600s)`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
const value = parseInt(match[1], 10);
|
|
265
|
+
const unit = match[2] ?? "s";
|
|
266
|
+
switch (unit) {
|
|
267
|
+
case "h":
|
|
268
|
+
return value * 3600;
|
|
269
|
+
case "m":
|
|
270
|
+
return value * 60;
|
|
271
|
+
default:
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function submit(entrypoint, options) {
|
|
276
|
+
console.log(`Packaging files...`);
|
|
277
|
+
let bundle;
|
|
278
|
+
try {
|
|
279
|
+
bundle = await createBundle(options.files ?? ["."]);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.error(err.message);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
console.log(`Bundle: ${(bundle.length / 1024 / 1024).toFixed(1)} MB`);
|
|
285
|
+
const plan = {
|
|
286
|
+
name: options.name,
|
|
287
|
+
entrypoint,
|
|
288
|
+
requirements_file: options.requirements,
|
|
289
|
+
gpu_type: options.gpu,
|
|
290
|
+
gpu_count: parseInt(options.gpuCount, 10),
|
|
291
|
+
max_duration_s: parseDuration(options.time),
|
|
292
|
+
env_vars: parseEnvironmentArguments(options.env),
|
|
293
|
+
...options.baseImage ? { base_image: options.baseImage } : {},
|
|
294
|
+
...options.provider ? { requested_provider: options.provider } : {}
|
|
295
|
+
};
|
|
296
|
+
console.log(`Submitting to FlightDeck...`);
|
|
297
|
+
try {
|
|
298
|
+
const body = await submitFlight(plan, bundle);
|
|
299
|
+
console.log(`
|
|
300
|
+
Flight submitted successfully!`);
|
|
301
|
+
console.log(` Flight ID: ${body.flight_id}`);
|
|
302
|
+
console.log(` Status: ${body.status}`);
|
|
303
|
+
console.log(` Est. cost: ${body.estimated_cost_pence}p`);
|
|
304
|
+
console.log(` Queue position: ${body.position_in_queue}`);
|
|
305
|
+
console.log(`
|
|
306
|
+
Track with: flightdeck status ${body.flight_id}`);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err instanceof SubmitError) {
|
|
309
|
+
console.error(`Submit failed (${err.status}):`, JSON.stringify(err.body, null, 2));
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/commands/sbatch.ts
|
|
317
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
318
|
+
import { dirname as dirname2, isAbsolute, normalize, relative, resolve as resolve2 } from "node:path";
|
|
319
|
+
|
|
320
|
+
// src/sbatch.ts
|
|
321
|
+
var SHORT_OPTS = {
|
|
322
|
+
"-J": "--job-name",
|
|
323
|
+
"-a": "--array",
|
|
324
|
+
"-t": "--time",
|
|
325
|
+
"-G": "--gpus",
|
|
326
|
+
"-o": "--output",
|
|
327
|
+
"-e": "--error",
|
|
328
|
+
"-D": "--chdir",
|
|
329
|
+
"-n": "--ntasks",
|
|
330
|
+
"-N": "--nodes",
|
|
331
|
+
"-p": "--partition",
|
|
332
|
+
"-c": "--cpus-per-task"
|
|
333
|
+
};
|
|
334
|
+
var VALUE_OPTIONS = /* @__PURE__ */ new Set([
|
|
335
|
+
"--job-name",
|
|
336
|
+
"--array",
|
|
337
|
+
"--time",
|
|
338
|
+
"--gres",
|
|
339
|
+
"--gpus",
|
|
340
|
+
"--gpus-per-node",
|
|
341
|
+
"--gpus-per-task",
|
|
342
|
+
"--output",
|
|
343
|
+
"--error",
|
|
344
|
+
"--chdir",
|
|
345
|
+
"--ntasks",
|
|
346
|
+
"--nodes",
|
|
347
|
+
"--cpus-per-task",
|
|
348
|
+
"--export",
|
|
349
|
+
"--partition",
|
|
350
|
+
"--account",
|
|
351
|
+
"--qos",
|
|
352
|
+
"--constraint",
|
|
353
|
+
"--dependency",
|
|
354
|
+
"--mem",
|
|
355
|
+
"--mem-per-cpu",
|
|
356
|
+
"--mem-per-gpu",
|
|
357
|
+
"--mail-type",
|
|
358
|
+
"--mail-user"
|
|
359
|
+
]);
|
|
360
|
+
var MAX_ARRAY_TASKS = 1e3;
|
|
361
|
+
var MAX_GPU_COUNT = 64;
|
|
362
|
+
function unquote(value) {
|
|
363
|
+
const trimmed = value.trim();
|
|
364
|
+
if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
365
|
+
return trimmed.slice(1, -1);
|
|
366
|
+
}
|
|
367
|
+
return trimmed;
|
|
368
|
+
}
|
|
369
|
+
function tokenizeDirective(rest) {
|
|
370
|
+
const tokens = [];
|
|
371
|
+
let token = "";
|
|
372
|
+
let quote = null;
|
|
373
|
+
let escaped = false;
|
|
374
|
+
let started = false;
|
|
375
|
+
for (const char of rest) {
|
|
376
|
+
if (escaped) {
|
|
377
|
+
token += char;
|
|
378
|
+
escaped = false;
|
|
379
|
+
started = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (char === "\\" && quote !== "'") {
|
|
383
|
+
escaped = true;
|
|
384
|
+
started = true;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (quote) {
|
|
388
|
+
if (char === quote) quote = null;
|
|
389
|
+
else token += char;
|
|
390
|
+
started = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (char === "'" || char === '"') {
|
|
394
|
+
quote = char;
|
|
395
|
+
started = true;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (/\s/.test(char)) {
|
|
399
|
+
if (started) {
|
|
400
|
+
tokens.push(token);
|
|
401
|
+
token = "";
|
|
402
|
+
started = false;
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
token += char;
|
|
407
|
+
started = true;
|
|
408
|
+
}
|
|
409
|
+
if (quote) throw new Error("unterminated quote in SBATCH directive");
|
|
410
|
+
if (escaped) token += "\\";
|
|
411
|
+
if (started) tokens.push(token);
|
|
412
|
+
return tokens;
|
|
413
|
+
}
|
|
414
|
+
function splitOptions(rest) {
|
|
415
|
+
const tokens = tokenizeDirective(rest);
|
|
416
|
+
const options = [];
|
|
417
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
418
|
+
const token = tokens[index];
|
|
419
|
+
let rawKey = token;
|
|
420
|
+
let value;
|
|
421
|
+
if (token.startsWith("--")) {
|
|
422
|
+
const eqIndex = token.indexOf("=");
|
|
423
|
+
if (eqIndex !== -1) {
|
|
424
|
+
rawKey = token.slice(0, eqIndex);
|
|
425
|
+
value = token.slice(eqIndex + 1);
|
|
426
|
+
}
|
|
427
|
+
} else if (token.startsWith("-") && token.length >= 2) {
|
|
428
|
+
rawKey = token.slice(0, 2);
|
|
429
|
+
const remainder = token.slice(2).replace(/^=/, "");
|
|
430
|
+
if (remainder) value = remainder;
|
|
431
|
+
}
|
|
432
|
+
const key = SHORT_OPTS[rawKey] ?? rawKey;
|
|
433
|
+
if (value === void 0 && VALUE_OPTIONS.has(key)) {
|
|
434
|
+
const next = tokens[index + 1];
|
|
435
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
436
|
+
value = next;
|
|
437
|
+
index += 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
options.push({ key, value: value === void 0 ? void 0 : unquote(value) });
|
|
441
|
+
}
|
|
442
|
+
return options;
|
|
443
|
+
}
|
|
444
|
+
function positiveInteger(value, option) {
|
|
445
|
+
if (value === void 0) throw new Error(`${option} requires a value`);
|
|
446
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
|
|
447
|
+
throw new Error(`${option} must be a positive integer`);
|
|
448
|
+
}
|
|
449
|
+
return Number(value);
|
|
450
|
+
}
|
|
451
|
+
function parseExport(value, result) {
|
|
452
|
+
if (value === void 0) throw new Error("--export requires a value");
|
|
453
|
+
for (const item of value.split(",")) {
|
|
454
|
+
if (item === "ALL" || item === "NONE" || item === "NIL") continue;
|
|
455
|
+
const eqIndex = item.indexOf("=");
|
|
456
|
+
if (eqIndex < 1) {
|
|
457
|
+
result.warnings.push(
|
|
458
|
+
`--export=${item} requests a client-shell variable; pass it explicitly with --env ${item}=VALUE`
|
|
459
|
+
);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const name = item.slice(0, eqIndex);
|
|
463
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
464
|
+
throw new Error(`invalid environment variable name in --export: "${name}"`);
|
|
465
|
+
}
|
|
466
|
+
result.exportedEnv[name] = item.slice(eqIndex + 1);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function parseSlurmTime(value) {
|
|
470
|
+
let days = 0;
|
|
471
|
+
let rest = value.trim();
|
|
472
|
+
if (rest === "") return null;
|
|
473
|
+
const dashIndex = rest.indexOf("-");
|
|
474
|
+
const hasDays = dashIndex !== -1;
|
|
475
|
+
if (hasDays) {
|
|
476
|
+
if (rest.indexOf("-", dashIndex + 1) !== -1) return null;
|
|
477
|
+
days = Number(rest.slice(0, dashIndex));
|
|
478
|
+
rest = rest.slice(dashIndex + 1);
|
|
479
|
+
if (!Number.isInteger(days) || days < 0 || rest === "") return null;
|
|
480
|
+
}
|
|
481
|
+
const rawParts = rest.split(":");
|
|
482
|
+
if (rawParts.length < 1 || rawParts.length > 3) return null;
|
|
483
|
+
if (rawParts.some((part) => !/^\d+$/.test(part))) return null;
|
|
484
|
+
const parts = rawParts.map((part) => Number(part));
|
|
485
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0)) return null;
|
|
486
|
+
let hours = 0;
|
|
487
|
+
let minutes = 0;
|
|
488
|
+
let seconds = 0;
|
|
489
|
+
if (hasDays) {
|
|
490
|
+
[hours = 0, minutes = 0, seconds = 0] = parts;
|
|
491
|
+
if (hours > 23 || minutes > 59 || seconds > 59) return null;
|
|
492
|
+
} else if (parts.length === 1) {
|
|
493
|
+
[minutes] = parts;
|
|
494
|
+
} else if (parts.length === 2) {
|
|
495
|
+
[minutes, seconds] = parts;
|
|
496
|
+
if (seconds > 59) return null;
|
|
497
|
+
} else if (parts.length === 3) {
|
|
498
|
+
[hours, minutes, seconds] = parts;
|
|
499
|
+
if (minutes > 59 || seconds > 59) return null;
|
|
500
|
+
} else {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
504
|
+
}
|
|
505
|
+
function parseArraySpec(spec) {
|
|
506
|
+
const raw = spec.trim();
|
|
507
|
+
let body = raw;
|
|
508
|
+
let concurrency;
|
|
509
|
+
const percentIndex = body.indexOf("%");
|
|
510
|
+
if (percentIndex !== -1) {
|
|
511
|
+
concurrency = Number(body.slice(percentIndex + 1));
|
|
512
|
+
body = body.slice(0, percentIndex);
|
|
513
|
+
if (!Number.isInteger(concurrency) || concurrency <= 0) {
|
|
514
|
+
throw new Error(`invalid --array concurrency limit in "${raw}"`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const tasks = /* @__PURE__ */ new Set();
|
|
518
|
+
for (const element of body.split(",")) {
|
|
519
|
+
const part = element.trim();
|
|
520
|
+
const range = part.match(/^(\d+)-(\d+)(?::(\d+))?$/);
|
|
521
|
+
if (range) {
|
|
522
|
+
const start = Number(range[1]);
|
|
523
|
+
const end = Number(range[2]);
|
|
524
|
+
const step = range[3] ? Number(range[3]) : 1;
|
|
525
|
+
if (![start, end, step].every(Number.isSafeInteger)) {
|
|
526
|
+
throw new Error(`--array values must be safe integers in "${raw}"`);
|
|
527
|
+
}
|
|
528
|
+
if (step <= 0) throw new Error(`invalid --array step in "${raw}"`);
|
|
529
|
+
if (end < start) throw new Error(`invalid --array range "${part}" (end < start)`);
|
|
530
|
+
for (let id = start; id <= end; id += step) {
|
|
531
|
+
tasks.add(id);
|
|
532
|
+
if (tasks.size > MAX_ARRAY_TASKS) {
|
|
533
|
+
throw new Error(`--array expands to more than ${MAX_ARRAY_TASKS} tasks`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else if (/^\d+$/.test(part)) {
|
|
537
|
+
const id = Number(part);
|
|
538
|
+
if (!Number.isSafeInteger(id)) throw new Error(`--array values must be safe integers in "${raw}"`);
|
|
539
|
+
tasks.add(id);
|
|
540
|
+
if (tasks.size > MAX_ARRAY_TASKS) {
|
|
541
|
+
throw new Error(`--array expands to more than ${MAX_ARRAY_TASKS} tasks`);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
throw new Error(`invalid --array element "${part}" in "${raw}"`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { tasks: [...tasks].sort((a, b) => a - b), concurrency, raw };
|
|
548
|
+
}
|
|
549
|
+
function parseGpuRequest(value, fromGres) {
|
|
550
|
+
let tokens = value.split(":");
|
|
551
|
+
if (fromGres) {
|
|
552
|
+
if (tokens[0] !== "gpu") {
|
|
553
|
+
return { warning: `--gres=${value} is not a GPU request; ignored` };
|
|
554
|
+
}
|
|
555
|
+
tokens = tokens.slice(1);
|
|
556
|
+
}
|
|
557
|
+
if (tokens.length === 0 || tokens[0] === "") {
|
|
558
|
+
return { gpuCount: 1 };
|
|
559
|
+
}
|
|
560
|
+
if (tokens.length === 1) {
|
|
561
|
+
const onlyToken = tokens[0];
|
|
562
|
+
if (/^\d+$/.test(onlyToken)) {
|
|
563
|
+
const count2 = Number(onlyToken);
|
|
564
|
+
if (!Number.isSafeInteger(count2) || count2 < 1 || count2 > MAX_GPU_COUNT) {
|
|
565
|
+
throw new Error(`GPU count must be between 1 and ${MAX_GPU_COUNT} in "${value}"`);
|
|
566
|
+
}
|
|
567
|
+
return { gpuCount: count2 };
|
|
568
|
+
}
|
|
569
|
+
return { gpuType: onlyToken, gpuCount: 1 };
|
|
570
|
+
}
|
|
571
|
+
const [type, count] = tokens;
|
|
572
|
+
if (!/^\d+$/.test(count) || !Number.isSafeInteger(Number(count)) || Number(count) < 1 || Number(count) > MAX_GPU_COUNT) {
|
|
573
|
+
throw new Error(`GPU count must be between 1 and ${MAX_GPU_COUNT} in "${value}"`);
|
|
574
|
+
}
|
|
575
|
+
return { gpuType: type, gpuCount: Number(count) };
|
|
576
|
+
}
|
|
577
|
+
function parseSbatchScript(text) {
|
|
578
|
+
const result = { warnings: [], exportedEnv: {} };
|
|
579
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
580
|
+
for (const rawLine of text.split("\n")) {
|
|
581
|
+
const line = rawLine.trim();
|
|
582
|
+
if (line === "") continue;
|
|
583
|
+
if (!line.startsWith("#")) break;
|
|
584
|
+
if (!/^#SBATCH\b/.test(line)) continue;
|
|
585
|
+
const rest = line.replace(/^#SBATCH\s*/, "").trim();
|
|
586
|
+
if (rest === "") continue;
|
|
587
|
+
for (const { key, value } of splitOptions(rest)) switch (key) {
|
|
588
|
+
case "--job-name":
|
|
589
|
+
if (!value) throw new Error("--job-name requires a value");
|
|
590
|
+
result.jobName = value;
|
|
591
|
+
break;
|
|
592
|
+
case "--array":
|
|
593
|
+
if (!value) throw new Error("--array requires a value");
|
|
594
|
+
result.array = parseArraySpec(value);
|
|
595
|
+
break;
|
|
596
|
+
case "--time":
|
|
597
|
+
if (!value) throw new Error("--time requires a value");
|
|
598
|
+
{
|
|
599
|
+
const seconds = parseSlurmTime(value);
|
|
600
|
+
if (seconds === null || seconds < 1) throw new Error(`invalid --time value "${value}"`);
|
|
601
|
+
result.maxDurationS = seconds;
|
|
602
|
+
}
|
|
603
|
+
break;
|
|
604
|
+
case "--gres":
|
|
605
|
+
if (!value) throw new Error("--gres requires a value");
|
|
606
|
+
{
|
|
607
|
+
const gpu = parseGpuRequest(value, true);
|
|
608
|
+
if (gpu.warning) result.warnings.push(gpu.warning);
|
|
609
|
+
if (gpu.gpuCount != null) result.gpuCount = gpu.gpuCount;
|
|
610
|
+
if (gpu.gpuType) result.gpuType = gpu.gpuType;
|
|
611
|
+
}
|
|
612
|
+
break;
|
|
613
|
+
case "--gpus":
|
|
614
|
+
case "--gpus-per-node":
|
|
615
|
+
case "--gpus-per-task":
|
|
616
|
+
if (!value) throw new Error(`${key} requires a value`);
|
|
617
|
+
{
|
|
618
|
+
const gpu = parseGpuRequest(value, false);
|
|
619
|
+
if (gpu.warning) result.warnings.push(gpu.warning);
|
|
620
|
+
if (gpu.gpuCount != null) result.gpuCount = gpu.gpuCount;
|
|
621
|
+
if (gpu.gpuType) result.gpuType = gpu.gpuType;
|
|
622
|
+
}
|
|
623
|
+
break;
|
|
624
|
+
case "--output":
|
|
625
|
+
if (!value) throw new Error("--output requires a value");
|
|
626
|
+
result.outputPath = value;
|
|
627
|
+
break;
|
|
628
|
+
case "--error":
|
|
629
|
+
if (!value) throw new Error("--error requires a value");
|
|
630
|
+
result.errorPath = value;
|
|
631
|
+
break;
|
|
632
|
+
case "--chdir":
|
|
633
|
+
if (!value) throw new Error("--chdir requires a value");
|
|
634
|
+
result.workingDirectory = value;
|
|
635
|
+
break;
|
|
636
|
+
case "--nodes":
|
|
637
|
+
result.nodes = positiveInteger(value, "--nodes");
|
|
638
|
+
if (result.nodes !== 1) {
|
|
639
|
+
throw new Error("multi-node jobs are not supported; --nodes must be 1");
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
case "--ntasks":
|
|
643
|
+
result.tasks = positiveInteger(value, "--ntasks");
|
|
644
|
+
if (result.tasks !== 1) {
|
|
645
|
+
result.warnings.push(
|
|
646
|
+
`--ntasks=${result.tasks} records the Slurm environment only; Flightdeck does not provide srun or launch multiple processes`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
case "--cpus-per-task":
|
|
651
|
+
result.cpusPerTask = positiveInteger(value, "--cpus-per-task");
|
|
652
|
+
result.warnings.push(
|
|
653
|
+
"--cpus-per-task is exposed as SLURM_CPUS_PER_TASK but is not a provider resource guarantee"
|
|
654
|
+
);
|
|
655
|
+
break;
|
|
656
|
+
case "--export":
|
|
657
|
+
parseExport(value, result);
|
|
658
|
+
break;
|
|
659
|
+
default:
|
|
660
|
+
ignored.add(key);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (ignored.size > 0) {
|
|
664
|
+
result.warnings.push(`ignored unsupported directives: ${[...ignored].sort().join(", ")}`);
|
|
665
|
+
}
|
|
666
|
+
assertEnvironmentContract(result.exportedEnv, "customer");
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/commands/sbatch.ts
|
|
671
|
+
var MAX_DURATION_S = 86400;
|
|
672
|
+
var WORKSPACE_ROOT = "/workspace";
|
|
673
|
+
var OUTPUT_ROOT = "/outputs";
|
|
674
|
+
function shellQuote(value) {
|
|
675
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
676
|
+
}
|
|
677
|
+
function bundlePathsForScript(filePaths, relativeScript) {
|
|
678
|
+
if (filePaths == null) return ["."];
|
|
679
|
+
if (filePaths.some((filePath) => resolve2(filePath) === resolve2(relativeScript))) {
|
|
680
|
+
return filePaths;
|
|
681
|
+
}
|
|
682
|
+
return [...filePaths, relativeScript];
|
|
683
|
+
}
|
|
684
|
+
function parseEnvVars(envArgs) {
|
|
685
|
+
return parseEnvironmentArguments(envArgs);
|
|
686
|
+
}
|
|
687
|
+
function expandSlurmFilename(pattern, context) {
|
|
688
|
+
const expanded = pattern.replace(/%([%AajJx])/g, (_match, symbol) => {
|
|
689
|
+
switch (symbol) {
|
|
690
|
+
case "%":
|
|
691
|
+
return "%";
|
|
692
|
+
case "A":
|
|
693
|
+
return context.jobId;
|
|
694
|
+
case "a":
|
|
695
|
+
return context.taskId === null ? "4294967294" : String(context.taskId);
|
|
696
|
+
case "j":
|
|
697
|
+
case "J":
|
|
698
|
+
return context.jobId;
|
|
699
|
+
case "x":
|
|
700
|
+
return context.jobName;
|
|
701
|
+
default:
|
|
702
|
+
return _match;
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
const unsupported = expanded.match(/%[A-Za-z]/)?.[0];
|
|
706
|
+
if (unsupported) throw new Error(`unsupported Slurm filename replacement: ${unsupported}`);
|
|
707
|
+
return expanded;
|
|
708
|
+
}
|
|
709
|
+
function safeRelativePath(value, option) {
|
|
710
|
+
if (isAbsolute(value)) throw new Error(`${option} must be relative to the submitted workspace`);
|
|
711
|
+
const path = normalize(value);
|
|
712
|
+
if (path === ".." || path.startsWith("../") || path === "." || path === "") {
|
|
713
|
+
throw new Error(`${option} must stay inside the submitted workspace`);
|
|
714
|
+
}
|
|
715
|
+
return path;
|
|
716
|
+
}
|
|
717
|
+
function outputTarget(pattern, context) {
|
|
718
|
+
const expanded = safeRelativePath(expandSlurmFilename(pattern, context), "--output/--error");
|
|
719
|
+
return `${OUTPUT_ROOT}/${expanded}`;
|
|
720
|
+
}
|
|
721
|
+
function buildEntrypoint(args) {
|
|
722
|
+
const script = safeRelativePath(args.relativeScript, "script path");
|
|
723
|
+
const workingDirectory = args.workingDirectory ? safeRelativePath(args.workingDirectory, "--chdir") : null;
|
|
724
|
+
const context = { jobId: args.jobId, jobName: args.jobName, taskId: args.taskId };
|
|
725
|
+
const stdout = outputTarget(args.outputPath ?? "slurm-%j.out", context);
|
|
726
|
+
const stderr = args.errorPath ? outputTarget(args.errorPath, context) : stdout;
|
|
727
|
+
const outputDirectories = [.../* @__PURE__ */ new Set([dirname2(stdout), dirname2(stderr)])];
|
|
728
|
+
const command = `bash -- ${shellQuote(`${WORKSPACE_ROOT}/${script}`)}`;
|
|
729
|
+
const redirected = stdout === stderr ? `${command} > >(tee -- ${shellQuote(stdout)}) 2>&1` : `${command} > >(tee -- ${shellQuote(stdout)}) 2> >(tee -- ${shellQuote(stderr)} >&2)`;
|
|
730
|
+
const cd = workingDirectory ? `cd -- ${shellQuote(`${WORKSPACE_ROOT}/${workingDirectory}`)} && ` : `cd -- ${shellQuote(WORKSPACE_ROOT)} && `;
|
|
731
|
+
return `mkdir -p -- ${outputDirectories.map(shellQuote).join(" ")} && ${cd}{ ${redirected}; __flightdeck_status=$?; wait; exit "$__flightdeck_status"; }`;
|
|
732
|
+
}
|
|
733
|
+
function slurmEnvironment(args) {
|
|
734
|
+
const tasks = args.directives.array?.tasks ?? [];
|
|
735
|
+
const gpuCount = args.directives.gpuCount ?? 1;
|
|
736
|
+
const env = {
|
|
737
|
+
...args.directives.exportedEnv,
|
|
738
|
+
...args.extraEnv,
|
|
739
|
+
SLURM_CLUSTER_NAME: "flightdeck",
|
|
740
|
+
SLURM_JOB_NAME: args.jobName,
|
|
741
|
+
SLURM_JOB_ID: args.jobId,
|
|
742
|
+
SLURM_JOBID: args.jobId,
|
|
743
|
+
SLURM_SUBMIT_DIR: WORKSPACE_ROOT,
|
|
744
|
+
SLURM_NTASKS: String(args.directives.tasks ?? 1),
|
|
745
|
+
SLURM_NPROCS: String(args.directives.tasks ?? 1),
|
|
746
|
+
SLURM_NNODES: "1",
|
|
747
|
+
SLURM_JOB_NUM_NODES: "1",
|
|
748
|
+
SLURM_GPUS: String(gpuCount),
|
|
749
|
+
SLURM_GPUS_ON_NODE: String(gpuCount),
|
|
750
|
+
SLURM_JOB_GPUS: Array.from({ length: gpuCount }, (_, index) => index).join(",")
|
|
751
|
+
};
|
|
752
|
+
if (args.directives.cpusPerTask !== void 0) {
|
|
753
|
+
env.SLURM_CPUS_PER_TASK = String(args.directives.cpusPerTask);
|
|
754
|
+
}
|
|
755
|
+
if (args.taskId !== null) {
|
|
756
|
+
env.SLURM_ARRAY_JOB_ID = args.jobId;
|
|
757
|
+
env.SLURM_ARRAY_TASK_ID = String(args.taskId);
|
|
758
|
+
env.SLURM_ARRAY_TASK_COUNT = String(tasks.length);
|
|
759
|
+
env.SLURM_ARRAY_TASK_MIN = String(tasks[0]);
|
|
760
|
+
env.SLURM_ARRAY_TASK_MAX = String(tasks[tasks.length - 1]);
|
|
761
|
+
}
|
|
762
|
+
assertEnvironmentContract(env, "customer");
|
|
763
|
+
return env;
|
|
764
|
+
}
|
|
765
|
+
function buildSbatchPlans(args) {
|
|
766
|
+
const jobName = args.options.name ?? args.directives.jobName ?? "sbatch-job";
|
|
767
|
+
const taskIds = args.directives.array?.tasks ?? [null];
|
|
768
|
+
const extraEnv = parseEnvVars(args.options.env);
|
|
769
|
+
let maxDurationS = args.directives.maxDurationS;
|
|
770
|
+
if (maxDurationS !== void 0 && maxDurationS > MAX_DURATION_S) maxDurationS = MAX_DURATION_S;
|
|
771
|
+
return taskIds.map((taskId) => ({
|
|
772
|
+
taskId,
|
|
773
|
+
plan: {
|
|
774
|
+
name: taskId === null ? jobName : `${jobName}[${taskId}]`,
|
|
775
|
+
entrypoint: buildEntrypoint({
|
|
776
|
+
relativeScript: args.relativeScript,
|
|
777
|
+
workingDirectory: args.directives.workingDirectory,
|
|
778
|
+
outputPath: args.directives.outputPath,
|
|
779
|
+
errorPath: args.directives.errorPath,
|
|
780
|
+
jobId: args.jobId,
|
|
781
|
+
jobName,
|
|
782
|
+
taskId
|
|
783
|
+
}),
|
|
784
|
+
requirements_file: args.options.requirements,
|
|
785
|
+
gpu_type: args.directives.gpuType,
|
|
786
|
+
gpu_count: args.directives.gpuCount,
|
|
787
|
+
max_duration_s: maxDurationS,
|
|
788
|
+
env_vars: slurmEnvironment({
|
|
789
|
+
directives: args.directives,
|
|
790
|
+
taskId,
|
|
791
|
+
jobId: args.jobId,
|
|
792
|
+
jobName,
|
|
793
|
+
extraEnv
|
|
794
|
+
}),
|
|
795
|
+
base_image: args.options.baseImage,
|
|
796
|
+
requested_provider: args.options.provider
|
|
797
|
+
}
|
|
798
|
+
}));
|
|
799
|
+
}
|
|
800
|
+
async function sbatch(scriptPath, options) {
|
|
801
|
+
if (!existsSync3(scriptPath)) {
|
|
802
|
+
console.error(`Script not found: ${scriptPath}`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
const scriptText = readFileSync3(scriptPath, "utf-8");
|
|
806
|
+
let directives;
|
|
807
|
+
try {
|
|
808
|
+
directives = parseSbatchScript(scriptText);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
console.error(`Invalid SBATCH directive: ${err.message}`);
|
|
811
|
+
process.exit(1);
|
|
812
|
+
}
|
|
813
|
+
const relativeScript = relative(resolve2("."), resolve2(scriptPath));
|
|
814
|
+
if (relativeScript === ".." || relativeScript.startsWith("../") || isAbsolute(relativeScript) || relativeScript === "") {
|
|
815
|
+
console.error(
|
|
816
|
+
`Script must be inside the current directory so it can be bundled.
|
|
817
|
+
Run flightdeck from the directory containing "${scriptPath}".`
|
|
818
|
+
);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
const jobName = options.name ?? directives.jobName ?? "sbatch-job";
|
|
822
|
+
let maxDurationS = directives.maxDurationS;
|
|
823
|
+
if (maxDurationS != null && maxDurationS > MAX_DURATION_S) {
|
|
824
|
+
directives.warnings.push(
|
|
825
|
+
`--time exceeds the 24h maximum; capping at ${MAX_DURATION_S}s`
|
|
826
|
+
);
|
|
827
|
+
maxDurationS = MAX_DURATION_S;
|
|
828
|
+
}
|
|
829
|
+
const jobId = `${Date.now()}${String(process.pid % 1e3).padStart(3, "0")}`;
|
|
830
|
+
let tasks;
|
|
831
|
+
try {
|
|
832
|
+
tasks = buildSbatchPlans({ directives, relativeScript, options, jobId });
|
|
833
|
+
} catch (err) {
|
|
834
|
+
console.error(`Invalid SBATCH submission: ${err.message}`);
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
console.log(`Parsed ${scriptPath}:`);
|
|
838
|
+
console.log(` Job name: ${jobName}`);
|
|
839
|
+
console.log(` Entrypoint: ${tasks[0].plan.entrypoint}`);
|
|
840
|
+
console.log(` GPU: ${directives.gpuType ?? "any"} x${directives.gpuCount ?? 1}`);
|
|
841
|
+
console.log(` Max time: ${maxDurationS != null ? `${maxDurationS}s` : "platform default"}`);
|
|
842
|
+
console.log(` Base image: ${options.baseImage ?? "platform default"}`);
|
|
843
|
+
if (directives.array) {
|
|
844
|
+
console.log(
|
|
845
|
+
` Array: ${directives.array.raw} \u2192 ${tasks.length} task(s)` + (directives.array.concurrency ? ` (note: %${directives.array.concurrency} concurrency limit is not enforced \u2014 all tasks queue at once)` : "")
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
for (const warning of directives.warnings) {
|
|
849
|
+
console.log(` warning: ${warning}`);
|
|
850
|
+
}
|
|
851
|
+
if (options.dryRun) {
|
|
852
|
+
console.log(`
|
|
853
|
+
Dry run \u2014 ${tasks.length} flight(s) would be submitted:`);
|
|
854
|
+
for (const task of tasks) {
|
|
855
|
+
console.log(` - ${task.plan.name}`);
|
|
856
|
+
}
|
|
857
|
+
return { submitted: [], failures: 0 };
|
|
858
|
+
}
|
|
859
|
+
console.log(`
|
|
860
|
+
Packaging files...`);
|
|
861
|
+
let bundle;
|
|
862
|
+
try {
|
|
863
|
+
bundle = await createBundle(bundlePathsForScript(options.files, relativeScript));
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.error(err.message);
|
|
866
|
+
process.exit(1);
|
|
867
|
+
}
|
|
868
|
+
console.log(`Bundle: ${(bundle.length / 1024 / 1024).toFixed(1)} MB`);
|
|
869
|
+
console.log(`Submitting ${tasks.length} flight(s)...
|
|
870
|
+
`);
|
|
871
|
+
const submitted = [];
|
|
872
|
+
let failures = 0;
|
|
873
|
+
for (const task of tasks) {
|
|
874
|
+
try {
|
|
875
|
+
const result = await submitFlight(task.plan, bundle);
|
|
876
|
+
submitted.push({ flightId: result.flight_id, taskId: task.taskId, name: task.plan.name });
|
|
877
|
+
console.log(` ${task.plan.name}: ${result.flight_id} (queue #${result.position_in_queue})`);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
failures += 1;
|
|
880
|
+
if (err instanceof SubmitError) {
|
|
881
|
+
console.error(` ${task.plan.name}: failed (${err.status}) ${JSON.stringify(err.body)}`);
|
|
882
|
+
} else {
|
|
883
|
+
console.error(` ${task.plan.name}: ${err.message}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
console.log(`
|
|
888
|
+
${submitted.length} submitted, ${failures} failed.`);
|
|
889
|
+
if (submitted.length > 0) {
|
|
890
|
+
console.log(`Track with: flightdeck status <flight_id>`);
|
|
891
|
+
}
|
|
892
|
+
if (failures > 0) process.exit(1);
|
|
893
|
+
return { submitted, failures };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/commands/status.ts
|
|
897
|
+
async function status(flightId) {
|
|
898
|
+
const response = await apiRequest("GET", `/flights/${flightId}`);
|
|
899
|
+
const body = await response.json();
|
|
900
|
+
if (!response.ok) {
|
|
901
|
+
console.error(`Failed (${response.status}):`, JSON.stringify(body, null, 2));
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
console.log(`Flight: ${body.flight_id}`);
|
|
905
|
+
console.log(` Name: ${body.name}`);
|
|
906
|
+
console.log(` Status: ${body.status}`);
|
|
907
|
+
console.log(` Image: ${body.image}`);
|
|
908
|
+
console.log(` GPU: ${body.gpu_type ?? "any"} x${body.gpu_count}`);
|
|
909
|
+
if (body.queued_at) console.log(` Queued: ${body.queued_at}`);
|
|
910
|
+
if (body.started_at) console.log(` Started: ${body.started_at}`);
|
|
911
|
+
if (body.completed_at) console.log(` Finished: ${body.completed_at}`);
|
|
912
|
+
if (body.exit_code != null) console.log(` Exit: ${body.exit_code}`);
|
|
913
|
+
if (body.error_message) console.log(` Error: ${body.error_message}`);
|
|
914
|
+
if (body.cost_pence) console.log(` Cost: ${body.cost_pence}p`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/commands/logs.ts
|
|
918
|
+
async function logs(flightId, options) {
|
|
919
|
+
let cursor;
|
|
920
|
+
do {
|
|
921
|
+
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
|
922
|
+
const response = await apiRequest("GET", `/flights/${flightId}/logs${query}`);
|
|
923
|
+
const body = await response.json();
|
|
924
|
+
if (!response.ok) throw new Error(`Failed (${response.status}): ${JSON.stringify(body)}`);
|
|
925
|
+
for (const entry of body.logs) {
|
|
926
|
+
const target = entry.stream === "stderr" ? process.stderr : process.stdout;
|
|
927
|
+
target.write(entry.content);
|
|
928
|
+
}
|
|
929
|
+
cursor = body.next_cursor;
|
|
930
|
+
if (!options.follow || body.complete) break;
|
|
931
|
+
await new Promise((resolve4) => setTimeout(resolve4, 2e3));
|
|
932
|
+
} while (true);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/commands/cancel.ts
|
|
936
|
+
async function cancel(flightId) {
|
|
937
|
+
const response = await apiRequest("DELETE", `/flights/${flightId}`);
|
|
938
|
+
const body = await response.json();
|
|
939
|
+
if (!response.ok) throw new Error(`Failed (${response.status}): ${JSON.stringify(body)}`);
|
|
940
|
+
console.log(`Cancelled ${flightId}`);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/commands/outputs.ts
|
|
944
|
+
async function listOutputs(flightId) {
|
|
945
|
+
const response = await apiRequest("GET", `/flights/${flightId}/outputs`);
|
|
946
|
+
const body = await response.json();
|
|
947
|
+
if (!response.ok) throw new Error(`Failed (${response.status}): ${JSON.stringify(body)}`);
|
|
948
|
+
const outputs = body.outputs ?? [];
|
|
949
|
+
if (!outputs.length) console.log("No outputs available.");
|
|
950
|
+
for (const output of outputs) console.log(`${output.artifact_id} ${output.size_bytes} bytes ${output.filename}`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/commands/download.ts
|
|
955
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
956
|
+
import { basename, join as join3, resolve as resolve3 } from "node:path";
|
|
957
|
+
async function download(flightId, options) {
|
|
958
|
+
const manifestResponse = await apiRequest("GET", `/flights/${flightId}/outputs`);
|
|
959
|
+
const manifest = await manifestResponse.json();
|
|
960
|
+
if (!manifestResponse.ok) throw new Error(`Failed (${manifestResponse.status}): ${JSON.stringify(manifest)}`);
|
|
961
|
+
const root = resolve3(options.outputDir);
|
|
962
|
+
await mkdir(root, { recursive: true });
|
|
963
|
+
for (const output of manifest.outputs ?? []) {
|
|
964
|
+
const response = await apiRequest("GET", `/flights/${flightId}/outputs/${output.artifact_id}`);
|
|
965
|
+
if (!response.ok) throw new Error(`Download failed for ${output.filename} (${response.status})`);
|
|
966
|
+
const target = join3(root, basename(output.filename));
|
|
967
|
+
await writeFile(target, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
|
|
968
|
+
console.log(`Downloaded ${target}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// src/commands/slurm-test.ts
|
|
973
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
974
|
+
import { join as join4, relative as relative2 } from "node:path";
|
|
975
|
+
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
|
|
976
|
+
var CONFORMANCE_SCRIPT = `#!/bin/bash
|
|
977
|
+
#SBATCH --job-name=slurm-conformance
|
|
978
|
+
#SBATCH --array=2,5
|
|
979
|
+
#SBATCH --nodes=1
|
|
980
|
+
#SBATCH --ntasks=1
|
|
981
|
+
#SBATCH --cpus-per-task=1
|
|
982
|
+
#SBATCH --time=00:05:00
|
|
983
|
+
#SBATCH --output=conformance/%A_%a.out
|
|
984
|
+
#SBATCH --error=conformance/%A_%a.err
|
|
985
|
+
#SBATCH --export=FLIGHTDECK_CONFORMANCE=enabled
|
|
986
|
+
|
|
987
|
+
set -euo pipefail
|
|
988
|
+
test "$FLIGHTDECK_CONFORMANCE" = "enabled"
|
|
989
|
+
test "$SLURM_CLUSTER_NAME" = "flightdeck"
|
|
990
|
+
test "$SLURM_JOB_NAME" = "slurm-conformance"
|
|
991
|
+
test -n "$SLURM_JOB_ID"
|
|
992
|
+
test "$SLURM_ARRAY_JOB_ID" = "$SLURM_JOB_ID"
|
|
993
|
+
test "$SLURM_NNODES" = "1"
|
|
994
|
+
test "$SLURM_NTASKS" = "1"
|
|
995
|
+
test "$SLURM_CPUS_PER_TASK" = "1"
|
|
996
|
+
printf 'task=%s job=%s
|
|
997
|
+
' "$SLURM_ARRAY_TASK_ID" "$SLURM_JOB_ID" > "$OUTPUTS_DIR/conformance-$SLURM_ARRAY_TASK_ID.txt"
|
|
998
|
+
printf 'FLIGHTDECK_SLURM_CONFORMANCE task=%s
|
|
999
|
+
' "$SLURM_ARRAY_TASK_ID"
|
|
1000
|
+
`;
|
|
1001
|
+
function positiveSeconds(value, option) {
|
|
1002
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
1003
|
+
throw new Error(`${option} must be a positive number of seconds`);
|
|
1004
|
+
}
|
|
1005
|
+
return Number(value);
|
|
1006
|
+
}
|
|
1007
|
+
async function readFlight(flightId) {
|
|
1008
|
+
const response = await apiRequest("GET", `/flights/${flightId}`);
|
|
1009
|
+
const body = await response.json();
|
|
1010
|
+
if (!response.ok) throw new Error(`status ${flightId} failed (${response.status}): ${JSON.stringify(body)}`);
|
|
1011
|
+
return body;
|
|
1012
|
+
}
|
|
1013
|
+
async function verifyResults(flightId, taskId) {
|
|
1014
|
+
const [logsResponse, outputsResponse] = await Promise.all([
|
|
1015
|
+
apiRequest("GET", `/flights/${flightId}/logs`),
|
|
1016
|
+
apiRequest("GET", `/flights/${flightId}/outputs`)
|
|
1017
|
+
]);
|
|
1018
|
+
const logs2 = await logsResponse.json();
|
|
1019
|
+
const outputs = await outputsResponse.json();
|
|
1020
|
+
if (!logsResponse.ok) throw new Error(`logs ${flightId} failed (${logsResponse.status}): ${JSON.stringify(logs2)}`);
|
|
1021
|
+
if (!outputsResponse.ok) throw new Error(`outputs ${flightId} failed (${outputsResponse.status}): ${JSON.stringify(outputs)}`);
|
|
1022
|
+
const combinedLogs = (logs2.logs ?? []).map((entry) => entry.content).join("\n");
|
|
1023
|
+
if (!combinedLogs.includes(`FLIGHTDECK_SLURM_CONFORMANCE task=${taskId}`)) {
|
|
1024
|
+
throw new Error(`${flightId}: provider logs do not contain the task ${taskId} conformance marker`);
|
|
1025
|
+
}
|
|
1026
|
+
if (!(outputs.outputs ?? []).some((output) => output.size_bytes > 0)) {
|
|
1027
|
+
throw new Error(`${flightId}: provider returned no non-empty output artifact`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function slurmTest(options) {
|
|
1031
|
+
const timeoutMs = positiveSeconds(options.timeout, "--timeout") * 1e3;
|
|
1032
|
+
const pollIntervalMs = positiveSeconds(options.pollInterval, "--poll-interval") * 1e3;
|
|
1033
|
+
const tempDir = mkdtempSync2(join4(process.cwd(), ".flightdeck-slurm-test-"));
|
|
1034
|
+
const scriptPath = join4(tempDir, "conformance.sbatch");
|
|
1035
|
+
writeFileSync2(scriptPath, CONFORMANCE_SCRIPT, { encoding: "utf8", mode: 448 });
|
|
1036
|
+
let submitted;
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await sbatch(relative2(process.cwd(), scriptPath), {
|
|
1039
|
+
files: [relative2(process.cwd(), tempDir)]
|
|
1040
|
+
});
|
|
1041
|
+
submitted = result.submitted;
|
|
1042
|
+
} finally {
|
|
1043
|
+
rmSync2(tempDir, { recursive: true, force: true });
|
|
1044
|
+
}
|
|
1045
|
+
if (submitted.length !== 2) throw new Error(`expected 2 conformance flights, submitted ${submitted.length}`);
|
|
1046
|
+
const deadline = Date.now() + timeoutMs;
|
|
1047
|
+
const pending = new Map(submitted.map((task) => [task.flightId, task]));
|
|
1048
|
+
const finished = /* @__PURE__ */ new Map();
|
|
1049
|
+
while (pending.size > 0 && Date.now() < deadline) {
|
|
1050
|
+
for (const task of [...pending.values()]) {
|
|
1051
|
+
const flight = await readFlight(task.flightId);
|
|
1052
|
+
if (flight.status && TERMINAL.has(flight.status)) {
|
|
1053
|
+
pending.delete(task.flightId);
|
|
1054
|
+
finished.set(task.flightId, flight);
|
|
1055
|
+
console.log(` ${task.name}: ${flight.status} (${flight.provider ?? "provider pending"})`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (pending.size > 0) await new Promise((resolve4) => setTimeout(resolve4, pollIntervalMs));
|
|
1059
|
+
}
|
|
1060
|
+
if (pending.size > 0) {
|
|
1061
|
+
throw new Error(`timed out waiting for: ${[...pending.keys()].join(", ")}`);
|
|
1062
|
+
}
|
|
1063
|
+
for (const task of submitted) {
|
|
1064
|
+
const flight = finished.get(task.flightId);
|
|
1065
|
+
if (flight.status !== "completed" || flight.exit_code !== 0) {
|
|
1066
|
+
throw new Error(`${task.flightId} failed conformance: ${flight.error_message ?? `exit ${flight.exit_code}`}`);
|
|
1067
|
+
}
|
|
1068
|
+
await verifyResults(task.flightId, task.taskId);
|
|
1069
|
+
}
|
|
1070
|
+
console.log(`
|
|
1071
|
+
Slurm provider conformance passed for ${submitted.length} array tasks.`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/commands/login.ts
|
|
1075
|
+
var SCOPES = ["jobs:read", "jobs:submit", "jobs:cancel", "account:read"];
|
|
1076
|
+
function sleep(milliseconds) {
|
|
1077
|
+
return new Promise((resolve4) => setTimeout(resolve4, milliseconds));
|
|
1078
|
+
}
|
|
1079
|
+
async function errorMessage(response) {
|
|
1080
|
+
try {
|
|
1081
|
+
const body = await response.json();
|
|
1082
|
+
return body.error ?? response.statusText;
|
|
1083
|
+
} catch {
|
|
1084
|
+
return response.statusText;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async function login(options) {
|
|
1088
|
+
if (readConfig()) {
|
|
1089
|
+
throw new Error("Already logged in. Run `flightdeck logout` before replacing this credential.");
|
|
1090
|
+
}
|
|
1091
|
+
const apiUrl = normalizeApiUrl(options.apiUrl ?? DEFAULT_API_URL);
|
|
1092
|
+
const started = await publicApiRequest(apiUrl, "POST", "/auth/device/code", {
|
|
1093
|
+
credential_name: options.name ?? "flightdeck-cli",
|
|
1094
|
+
scopes: SCOPES
|
|
1095
|
+
});
|
|
1096
|
+
if (!started.ok) throw new Error(`Unable to start login: ${await errorMessage(started)}`);
|
|
1097
|
+
const authorization = await started.json();
|
|
1098
|
+
console.log(`Open this URL to approve the CLI:
|
|
1099
|
+
${authorization.verification_uri_complete}`);
|
|
1100
|
+
console.log(`
|
|
1101
|
+
Verification code: ${authorization.user_code}`);
|
|
1102
|
+
console.log("Waiting for approval\u2026");
|
|
1103
|
+
const deadline = Date.now() + authorization.expires_in * 1e3;
|
|
1104
|
+
let intervalSeconds = Math.max(1, authorization.interval);
|
|
1105
|
+
while (Date.now() < deadline) {
|
|
1106
|
+
await sleep(intervalSeconds * 1e3);
|
|
1107
|
+
const response = await publicApiRequest(apiUrl, "POST", "/auth/device/token", {
|
|
1108
|
+
device_code: authorization.device_code
|
|
1109
|
+
});
|
|
1110
|
+
if (response.status === 202 || response.status === 429) {
|
|
1111
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
1112
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) intervalSeconds = retryAfter;
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!response.ok) throw new Error(`Login failed: ${await errorMessage(response)}`);
|
|
1116
|
+
const token = await response.json();
|
|
1117
|
+
saveConfig({ api_url: apiUrl, api_key: token.access_token, credential_id: token.credential_id });
|
|
1118
|
+
console.log("Logged in. The credential was stored in ~/.flightdeck/config.json.");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
throw new Error("Login expired before it was approved. Run `flightdeck login` to try again.");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/commands/logout.ts
|
|
1125
|
+
async function logout(options) {
|
|
1126
|
+
if (!readConfig()) {
|
|
1127
|
+
console.log("No local FlightDeck login was found.");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (!options.local) {
|
|
1131
|
+
let response;
|
|
1132
|
+
try {
|
|
1133
|
+
response = await apiRequest("POST", "/auth/credential/revoke");
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
throw new Error(`Credential revocation failed; local login retained: ${String(error)}`);
|
|
1136
|
+
}
|
|
1137
|
+
if (!response.ok && response.status !== 401) {
|
|
1138
|
+
throw new Error(`Credential revocation failed (${response.status}); local login retained.`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
deleteConfig();
|
|
1142
|
+
console.log(options.local ? "Local login removed." : "Logged out and credential revoked.");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/commands/whoami.ts
|
|
1146
|
+
async function whoami() {
|
|
1147
|
+
const response = await apiRequest("GET", "/auth/me");
|
|
1148
|
+
if (!response.ok) throw new Error(`Unable to read account (${response.status}).`);
|
|
1149
|
+
console.log(JSON.stringify(await response.json(), null, 2));
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// src/commands/balance.ts
|
|
1153
|
+
async function balance() {
|
|
1154
|
+
const response = await apiRequest("GET", "/billing/summary");
|
|
1155
|
+
if (!response.ok) throw new Error(`Unable to read credit balance (${response.status}).`);
|
|
1156
|
+
console.log(JSON.stringify(await response.json(), null, 2));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/index.ts
|
|
1160
|
+
var program = new Command();
|
|
1161
|
+
program.name("flightdeck").description("FlightDeck CLI \u2014 submit GPU compute jobs").version(JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
1162
|
+
program.command("login").description("Log in through the FlightDeck portal").option("--api-url <url>", "FlightDeck API URL", DEFAULT_API_URL).option("--name <name>", "name for this CLI credential", "flightdeck-cli").action(login);
|
|
1163
|
+
program.command("logout").description("Revoke this CLI credential and remove the local login").option("--local", "remove the local login without revoking the credential").action(logout);
|
|
1164
|
+
program.command("whoami").description("Show the current FlightDeck account").action(whoami);
|
|
1165
|
+
program.command("balance").description("Show the current credit balance").action(balance);
|
|
1166
|
+
program.command("submit").description("Submit a job from local files").argument("<entrypoint>", 'command to run (e.g. "python train.py --epochs 50")').option("-n, --name <name>", "job name", "cli-job").option("-f, --files <glob...>", "files to include (default: current directory)").option("-r, --requirements <file>", "pip requirements file", "requirements.txt").option("--gpu <type>", "GPU type (e.g. A100, H100)").option("--gpu-count <n>", "number of GPUs", "1").option("--time <duration>", "max duration (e.g. 1h, 30m, 3600s)", "1h").option("--base-image <image>", "override base Docker image").option("--provider <id>", "pin the flight to one compute provider (default: operator's order)").option("-e, --env <vars...>", "environment variables (KEY=VALUE)").action(submit);
|
|
1167
|
+
program.command("sbatch").description("Submit a Slurm batch script (#SBATCH directives, incl. job arrays)").argument("<script>", "path to the .slurm/.sh batch script").option("-f, --files <glob...>", "files to include (default: current directory)").option("-r, --requirements <file>", "pip requirements file to install before the script runs").option("--base-image <image>", "override base Docker image (e.g. to pin a CUDA version)").option("--provider <id>", "pin every task to one compute provider (default: operator's order)").option("-n, --name <name>", "override job name (default: #SBATCH --job-name)").option("-e, --env <vars...>", "extra environment variables (KEY=VALUE)").option("--dry-run", "parse and show what would be submitted, without submitting").action(async (script, options) => {
|
|
1168
|
+
await sbatch(script, options);
|
|
1169
|
+
});
|
|
1170
|
+
program.command("slurm-test").description("Run the provider-neutral Slurm conformance workload").option("--timeout <seconds>", "maximum time to wait for all tasks", "1800").option("--poll-interval <seconds>", "status polling interval", "5").action(slurmTest);
|
|
1171
|
+
program.command("status").description("Check job status").argument("<flight_id>", "flight ID to check").action(status);
|
|
1172
|
+
program.command("logs").description("Stream flight logs").argument("<flight_id>").option("-f, --follow", "follow until complete").action(logs);
|
|
1173
|
+
program.command("cancel").description("Cancel a flight").argument("<flight_id>").action(cancel);
|
|
1174
|
+
program.command("outputs").description("List flight outputs").argument("<flight_id>").action(listOutputs);
|
|
1175
|
+
program.command("download").description("Download all flight outputs").argument("<flight_id>").option("-o, --output-dir <dir>", "destination directory", ".").action(download);
|
|
1176
|
+
program.parse();
|