@thermai/flightdeck-cli 0.1.0-pilot.2 → 0.1.0-pilot.3
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/README.md +125 -6
- package/dist/index.js +152 -56
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -9,23 +9,34 @@ service; downloading the CLI does not create an account or grant compute credit.
|
|
|
9
9
|
|
|
10
10
|
The package is `@thermai/flightdeck-cli`. The first pilot, `0.1.0-pilot.1`, was
|
|
11
11
|
published manually on 5 September 2026 and its anonymous install was verified.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
`0.1.0-pilot.2` subsequently passed GitHub Actions trusted publishing and an
|
|
13
|
+
anonymous install check on the same date. Releases publish through the
|
|
14
|
+
versioned GitHub Release workflow, not merely by merging a version change. The
|
|
15
|
+
installation command below follows the published `next` channel without
|
|
16
|
+
tracking an unpublished candidate version.
|
|
15
17
|
Proprietary terms in `LICENSE` permit redistribution of unchanged official
|
|
16
18
|
releases. Internal service qualification is still in progress; package
|
|
17
19
|
availability does not open partner onboarding.
|
|
18
20
|
|
|
21
|
+
This checkout prepares **`0.1.0-pilot.3`**, including complete log pagination and
|
|
22
|
+
concise authentication errors. Until its GitHub prerelease is published and
|
|
23
|
+
verified, `next` still installs `0.1.0-pilot.2`; merging this candidate is not
|
|
24
|
+
publication. Use the operator-approved exact version for a recorded rehearsal.
|
|
25
|
+
|
|
19
26
|
## Installation and login
|
|
20
27
|
|
|
21
28
|
Requires Node.js 22 and npm. Use a user-owned Node installation or npm prefix;
|
|
22
29
|
the CLI does not require administrator/root privileges.
|
|
23
30
|
|
|
31
|
+
<!-- flightdeck-cli-install:start -->
|
|
32
|
+
|
|
24
33
|
```sh
|
|
25
|
-
npm install -g @thermai/flightdeck-cli@
|
|
34
|
+
npm install -g @thermai/flightdeck-cli@next
|
|
26
35
|
flightdeck login
|
|
27
36
|
```
|
|
28
37
|
|
|
38
|
+
<!-- flightdeck-cli-install:end -->
|
|
39
|
+
|
|
29
40
|
Login prints a URL and verification code. Open that URL, sign into
|
|
30
41
|
`https://portal.thermai.uk`, and approve the named CLI credential and requested
|
|
31
42
|
scopes. The CLI waits for approval; no API key needs to be copied manually.
|
|
@@ -44,6 +55,53 @@ permissions. Never share that file or include it in a workload bundle. Use
|
|
|
44
55
|
`flightdeck logout` to revoke the credential and remove the local login. Portal
|
|
45
56
|
credential management can revoke an individual workstation as well.
|
|
46
57
|
|
|
58
|
+
`--name` labels a credential, not a local account profile. There is one saved
|
|
59
|
+
login per OS user. If already logged in, use `whoami` to inspect it; `login`
|
|
60
|
+
refuses to replace it. Do not revoke an operator's working credential merely to
|
|
61
|
+
try a second account: use a separate OS user for the internal member rehearsal.
|
|
62
|
+
|
|
63
|
+
## Pilot quick-start
|
|
64
|
+
|
|
65
|
+
Before live use, your ThermAI operator must provision your organization/account,
|
|
66
|
+
provide a single-use portal invitation, and approve your policy and credit.
|
|
67
|
+
Activate through the portal, then follow the installation/login commands above.
|
|
68
|
+
Confirm the expected account with `whoami` and the available credit with
|
|
69
|
+
`balance`. The internal proving run comes before partner invitations.
|
|
70
|
+
|
|
71
|
+
For a **local-only** readiness check, save the following as `readiness.sbatch`
|
|
72
|
+
in a new empty directory. Run the dry run from that directory; no account,
|
|
73
|
+
upload, API request, or paid resource is needed:
|
|
74
|
+
|
|
75
|
+
<!-- flightdeck-cli-readiness-script:start -->
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
#!/bin/bash
|
|
79
|
+
#SBATCH --array=1-2
|
|
80
|
+
#SBATCH --gres=gpu:A10:1
|
|
81
|
+
#SBATCH --time=00:01:00
|
|
82
|
+
#SBATCH --output=%A_%a.out
|
|
83
|
+
echo smoke
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
<!-- flightdeck-cli-readiness-script:end -->
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
flightdeck --version
|
|
90
|
+
flightdeck sbatch readiness.sbatch --dry-run
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Expect A10 x1, 60 seconds and **two flights would be submitted**, with no
|
|
94
|
+
warnings. Keep `--dry-run`: this is a translation exercise, not the approved
|
|
95
|
+
pilot workload or permission to launch it. A dry run does not check provider
|
|
96
|
+
capacity, account policy/credit, bundle contents, image access or GPU execution.
|
|
97
|
+
|
|
98
|
+
For the real workload, obtain the reviewed private bundle, exact runner-image
|
|
99
|
+
digest, provider selection and runtime from the operator. Review its dry run,
|
|
100
|
+
then use the supplied submission command only after execution approval. Do not
|
|
101
|
+
copy internal test data into public packages or partner bundles. Use explicit
|
|
102
|
+
`--files` selections from a clean directory; the submission default includes
|
|
103
|
+
the current directory. Never include credentials or private workstation files.
|
|
104
|
+
|
|
47
105
|
## Workload commands
|
|
48
106
|
|
|
49
107
|
After internal qualification and explicit policy/credit approval:
|
|
@@ -62,6 +120,65 @@ Submission, including `flightdeck slurm-test`, can provision paid resources.
|
|
|
62
120
|
supports a subset of single-node Slurm directives, not an arbitrary HPC cluster.
|
|
63
121
|
Workload images must satisfy the Flightdeck runner contract.
|
|
64
122
|
|
|
123
|
+
An array prints a separate Flightdeck ID for each accepted task. Record them
|
|
124
|
+
all: inspect, download and cancel each flight individually. Partial submission
|
|
125
|
+
can leave accepted flights running even if the command exits unsuccessfully;
|
|
126
|
+
do not blindly resubmit the whole array. Choose a fresh download directory for
|
|
127
|
+
each flight: downloads use filename basenames and refuse to overwrite existing
|
|
128
|
+
files. Check terminal status and exit code as well as the expected artifacts;
|
|
129
|
+
successful submission or download alone does not mean the workload succeeded.
|
|
130
|
+
|
|
131
|
+
For an incident, stop submitting, cancel the known active flights, and contact
|
|
132
|
+
your named ThermAI operator. Logging out does **not** stop jobs. The operator
|
|
133
|
+
must confirm provider teardown and reconcile any outstanding charges before a
|
|
134
|
+
retry. Support contact, supported hours and credit increases come from your
|
|
135
|
+
agreed pilot handoff; this client does not promise unattended support.
|
|
136
|
+
|
|
137
|
+
### Slurm differences
|
|
138
|
+
|
|
139
|
+
- Single-node jobs only; this is not a full Slurm scheduler or an HPC login node.
|
|
140
|
+
- Array `%N` concurrency throttles are not enforced: tasks are submitted
|
|
141
|
+
immediately. Admission ceilings may reject excess tasks rather than queue
|
|
142
|
+
them for later admission. Review every warning before real submission.
|
|
143
|
+
- Use explicit `%A_%a` output names for arrays, and write results under
|
|
144
|
+
`$OUTPUTS_DIR`. There is no shared university filesystem or implicit `module`,
|
|
145
|
+
Singularity, nested Docker, or cluster software environment.
|
|
146
|
+
- Hardware, runtime, image and credit must fit the operator-approved policy.
|
|
147
|
+
A compatible runner image is required, not just any CUDA image.
|
|
148
|
+
|
|
149
|
+
### Reading logs completely
|
|
150
|
+
|
|
151
|
+
In `0.1.0-pilot.3` (the candidate containing the #99 pagination fix), `flightdeck logs`
|
|
152
|
+
drains all pages in the current stored-log snapshot, then returns. For a running
|
|
153
|
+
job it may append one current live tail after the stored pages. `--follow`
|
|
154
|
+
drains those pages immediately, then polls every two seconds until the job is
|
|
155
|
+
terminal and its stored backlog is exhausted. Every new invocation starts at
|
|
156
|
+
the beginning; stdout/stderr retain their respective output streams.
|
|
157
|
+
|
|
158
|
+
**Rollout order:** deploy and verify the pagination-capable backend before
|
|
159
|
+
releasing/upgrading this CLI. A backend without the `has_more` response field
|
|
160
|
+
produces an explicit compatibility error and exit status 1, not a silently
|
|
161
|
+
partial read. The currently published `0.1.0-pilot.2` does not contain this fix;
|
|
162
|
+
merging source alone neither replaces that immutable package nor publishes a
|
|
163
|
+
new version. Older non-following clients still need upgrading for full paging.
|
|
164
|
+
|
|
165
|
+
### Error handling and support
|
|
166
|
+
|
|
167
|
+
The candidate exits with status 1 on command failure. Expected authentication,
|
|
168
|
+
network and HTTP failures have concise diagnostics instead of a Node stack.
|
|
169
|
+
An already-configured login leaves the saved credential unchanged; a failed
|
|
170
|
+
logout/revocation retains it so you can retry. `logout --local` deliberately
|
|
171
|
+
removes only the local copy and does not revoke remote access; use portal
|
|
172
|
+
credential management if remote revocation is needed.
|
|
173
|
+
|
|
174
|
+
For unexpected faults, `flightdeck --debug <command>` adds allowlisted error
|
|
175
|
+
types/codes and bundled CLI line locations, not raw stacks or error bodies.
|
|
176
|
+
It is not a dry-run flag: only repeat an operation that is safe to repeat.
|
|
177
|
+
Provide support with the CLI/Node versions, exit status, redacted command and
|
|
178
|
+
flight ID if available. Review output before sharing: account details and
|
|
179
|
+
workload logs/results can contain private information. Never send passwords,
|
|
180
|
+
tokens, activation links or `~/.flightdeck/config.json`.
|
|
181
|
+
|
|
65
182
|
## Versions
|
|
66
183
|
|
|
67
184
|
```sh
|
|
@@ -69,8 +186,10 @@ flightdeck --version
|
|
|
69
186
|
npm install -g @thermai/flightdeck-cli@<approved-version>
|
|
70
187
|
```
|
|
71
188
|
|
|
72
|
-
Stable releases use npm's `latest` tag. Explicit pilot/prerelease versions use
|
|
73
|
-
`next`; they do not automatically replace stable installations.
|
|
189
|
+
Stable releases will use npm's `latest` tag. Explicit pilot/prerelease versions use
|
|
190
|
+
`next`; they do not automatically replace stable installations. Currently
|
|
191
|
+
`latest` still points at the initial pilot.1 bootstrap, not a qualified stable
|
|
192
|
+
release; use `next` or the approved exact pilot version. Updating is an
|
|
74
193
|
explicit install, not an automatic mutation performed when the CLI runs.
|
|
75
194
|
Once a stable release exists, `npm install -g @thermai/flightdeck-cli` selects it.
|
|
76
195
|
|
package/dist/index.js
CHANGED
|
@@ -23,16 +23,69 @@ import {
|
|
|
23
23
|
} from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
// src/errors.ts
|
|
28
|
+
var CliError = class extends Error {
|
|
29
|
+
constructor(message, options) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "CliError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
function httpError(action, status2) {
|
|
35
|
+
const guidance = status2 === 401 ? " Check the account with flightdeck whoami; sign in again if the credential expired or was revoked." : status2 === 403 ? " Check the account permissions and requested credential scopes." : status2 === 429 ? " Wait briefly before retrying." : "";
|
|
36
|
+
return new CliError(`${action} (HTTP ${status2}).${guidance}`);
|
|
37
|
+
}
|
|
38
|
+
var ERROR_TYPES = /* @__PURE__ */ new Set(["Error", "TypeError", "RangeError", "SyntaxError", "CliError", "AbortError"]);
|
|
39
|
+
var ERROR_CODES = /* @__PURE__ */ new Set([
|
|
40
|
+
"EACCES",
|
|
41
|
+
"EPERM",
|
|
42
|
+
"ENOENT",
|
|
43
|
+
"ENOSPC",
|
|
44
|
+
"EEXIST",
|
|
45
|
+
"ECONNREFUSED",
|
|
46
|
+
"ECONNRESET",
|
|
47
|
+
"ENOTFOUND",
|
|
48
|
+
"EAI_AGAIN",
|
|
49
|
+
"ETIMEDOUT",
|
|
50
|
+
"ABORT_ERR",
|
|
51
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
52
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
53
|
+
"CERT_HAS_EXPIRED",
|
|
54
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE"
|
|
55
|
+
]);
|
|
56
|
+
function commandFailure(error, debug = false) {
|
|
57
|
+
const message = error instanceof CliError ? error.message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1e3) : "Unexpected command failure. Retry with --debug for safe diagnostics and contact your ThermAI operator if it persists.";
|
|
58
|
+
if (!debug) return `Flightdeck: ${message}`;
|
|
59
|
+
const details = [];
|
|
60
|
+
let current = error;
|
|
61
|
+
for (let depth = 0; depth < 3 && current instanceof Error; depth += 1) {
|
|
62
|
+
const code = current.code;
|
|
63
|
+
details.push(`${depth ? "cause" : "error"}=${ERROR_TYPES.has(current.name) ? current.name : "Error"}`);
|
|
64
|
+
if (code && ERROR_CODES.has(code)) details.push(`code=${code}`);
|
|
65
|
+
const frame = current.stack?.match(/\/dist\/index\.js:(\d+):(\d+)/);
|
|
66
|
+
if (frame) details.push(`cli=index.js:${frame[1]}:${frame[2]}`);
|
|
67
|
+
current = current.cause;
|
|
68
|
+
}
|
|
69
|
+
return `Flightdeck: ${message}
|
|
70
|
+
Diagnostic: ${details.join("; ") || "unknown error type"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/client.ts
|
|
26
74
|
var DEFAULT_API_URL = "https://api.flightdeck.thermai.uk";
|
|
27
75
|
var CONFIG_PATH = join(homedir(), ".flightdeck", "config.json");
|
|
28
76
|
function normalizeApiUrl(value) {
|
|
29
|
-
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(value);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new CliError("Invalid FlightDeck API URL. Use an HTTPS URL.");
|
|
82
|
+
}
|
|
30
83
|
const localHost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
31
84
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && localHost)) {
|
|
32
|
-
throw new
|
|
85
|
+
throw new CliError("FlightDeck API URLs must use HTTPS (HTTP is allowed only for localhost)");
|
|
33
86
|
}
|
|
34
87
|
if (url.username || url.password || url.search || url.hash) {
|
|
35
|
-
throw new
|
|
88
|
+
throw new CliError("FlightDeck API URL must not contain credentials, a query, or a fragment");
|
|
36
89
|
}
|
|
37
90
|
url.pathname = url.pathname.replace(/\/$/, "");
|
|
38
91
|
return url.toString().replace(/\/$/, "");
|
|
@@ -61,11 +114,7 @@ function readConfig(path = CONFIG_PATH) {
|
|
|
61
114
|
function loadConfig() {
|
|
62
115
|
const config = readConfig();
|
|
63
116
|
if (!config) {
|
|
64
|
-
|
|
65
|
-
`No valid login found at ${CONFIG_PATH}.
|
|
66
|
-
Run: flightdeck login`
|
|
67
|
-
);
|
|
68
|
-
process.exit(1);
|
|
117
|
+
throw new CliError("No valid local login found. Run flightdeck login.");
|
|
69
118
|
}
|
|
70
119
|
const stat = statSync(CONFIG_PATH);
|
|
71
120
|
if ((stat.mode & 63) !== 0) {
|
|
@@ -109,7 +158,7 @@ function endpoint(apiUrl, path) {
|
|
|
109
158
|
}
|
|
110
159
|
async function publicApiRequest(apiUrl, method, path, body) {
|
|
111
160
|
const headers = {};
|
|
112
|
-
return
|
|
161
|
+
return request(endpoint(apiUrl, path), {
|
|
113
162
|
method,
|
|
114
163
|
headers,
|
|
115
164
|
body: requestBody(body, headers)
|
|
@@ -120,12 +169,19 @@ async function apiRequest(method, path, body) {
|
|
|
120
169
|
const headers = {
|
|
121
170
|
Authorization: `Bearer ${config.api_key}`
|
|
122
171
|
};
|
|
123
|
-
return
|
|
172
|
+
return request(endpoint(config.api_url, path), {
|
|
124
173
|
method,
|
|
125
174
|
headers,
|
|
126
175
|
body: requestBody(body, headers)
|
|
127
176
|
});
|
|
128
177
|
}
|
|
178
|
+
async function request(url, options) {
|
|
179
|
+
try {
|
|
180
|
+
return await fetch(url, options);
|
|
181
|
+
} catch (cause) {
|
|
182
|
+
throw new CliError("Could not reach the Flightdeck API. Check your network connection and API URL before retrying.", { cause });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
129
185
|
|
|
130
186
|
// ../backend/contracts/environment.js
|
|
131
187
|
var MAX_CUSTOMER_ENV_ENTRIES = 64;
|
|
@@ -216,7 +272,7 @@ function parseEnvironmentArguments(envArgs) {
|
|
|
216
272
|
const result = {};
|
|
217
273
|
for (const arg of envArgs ?? []) {
|
|
218
274
|
const equals = arg.indexOf("=");
|
|
219
|
-
if (equals < 1) throw new
|
|
275
|
+
if (equals < 1) throw new CliError("invalid env var format (expected KEY=VALUE)");
|
|
220
276
|
result[arg.slice(0, equals)] = arg.slice(equals + 1);
|
|
221
277
|
}
|
|
222
278
|
assertEnvironmentContract(result, "customer");
|
|
@@ -896,11 +952,8 @@ ${submitted.length} submitted, ${failures} failed.`);
|
|
|
896
952
|
// src/commands/status.ts
|
|
897
953
|
async function status(flightId) {
|
|
898
954
|
const response = await apiRequest("GET", `/flights/${flightId}`);
|
|
955
|
+
if (!response.ok) throw httpError("Unable to read flight status", response.status);
|
|
899
956
|
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
957
|
console.log(`Flight: ${body.flight_id}`);
|
|
905
958
|
console.log(` Name: ${body.name}`);
|
|
906
959
|
console.log(` Status: ${body.status}`);
|
|
@@ -914,37 +967,67 @@ async function status(flightId) {
|
|
|
914
967
|
if (body.cost_pence) console.log(` Cost: ${body.cost_pence}p`);
|
|
915
968
|
}
|
|
916
969
|
|
|
917
|
-
// src/
|
|
918
|
-
async function
|
|
970
|
+
// src/log-reader.ts
|
|
971
|
+
async function drainLogs(follow, deps) {
|
|
919
972
|
let cursor;
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
973
|
+
const seen = /* @__PURE__ */ new Set();
|
|
974
|
+
while (true) {
|
|
975
|
+
const body = await deps.readPage(cursor);
|
|
976
|
+
if (typeof body.has_more !== "boolean") {
|
|
977
|
+
throw new CliError("This backend does not support complete log pagination. Deploy the updated backend before using this CLI log command.");
|
|
978
|
+
}
|
|
979
|
+
if (!Array.isArray(body.logs) || !body.logs.every((entry) => entry && typeof entry.stream === "string" && typeof entry.content === "string") || typeof body.complete !== "boolean" || typeof body.next_cursor !== "string" || !body.next_cursor || body.has_more && body.complete) {
|
|
980
|
+
throw new CliError("Invalid log pagination response from backend.");
|
|
981
|
+
}
|
|
982
|
+
if (body.has_more && (body.next_cursor === cursor || seen.has(body.next_cursor))) {
|
|
983
|
+
throw new CliError("Log pagination cursor did not advance; stopping to avoid repeated output.");
|
|
928
984
|
}
|
|
985
|
+
for (const entry of body.logs) deps.write(entry.stream, entry.content);
|
|
929
986
|
cursor = body.next_cursor;
|
|
930
|
-
if (
|
|
931
|
-
|
|
932
|
-
|
|
987
|
+
if (body.has_more) {
|
|
988
|
+
seen.add(cursor);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
if (!follow || body.complete) return;
|
|
992
|
+
seen.clear();
|
|
993
|
+
await deps.wait();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/commands/logs.ts
|
|
998
|
+
async function logs(flightId, options) {
|
|
999
|
+
try {
|
|
1000
|
+
await drainLogs(options.follow ?? false, {
|
|
1001
|
+
async readPage(cursor) {
|
|
1002
|
+
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
|
1003
|
+
const response = await apiRequest("GET", `/flights/${flightId}/logs${query}`);
|
|
1004
|
+
if (!response.ok) throw httpError("Log request failed", response.status);
|
|
1005
|
+
const body = await response.json();
|
|
1006
|
+
return body;
|
|
1007
|
+
},
|
|
1008
|
+
write(stream, content) {
|
|
1009
|
+
(stream === "stderr" ? process.stderr : process.stdout).write(content);
|
|
1010
|
+
},
|
|
1011
|
+
wait: () => new Promise((resolve4) => setTimeout(resolve4, 2e3))
|
|
1012
|
+
});
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
if (error instanceof CliError) throw new CliError(`Unable to read logs: ${error.message}`, { cause: error });
|
|
1015
|
+
throw error;
|
|
1016
|
+
}
|
|
933
1017
|
}
|
|
934
1018
|
|
|
935
1019
|
// src/commands/cancel.ts
|
|
936
1020
|
async function cancel(flightId) {
|
|
937
1021
|
const response = await apiRequest("DELETE", `/flights/${flightId}`);
|
|
938
|
-
|
|
939
|
-
if (!response.ok) throw new Error(`Failed (${response.status}): ${JSON.stringify(body)}`);
|
|
1022
|
+
if (!response.ok) throw httpError("Unable to cancel flight", response.status);
|
|
940
1023
|
console.log(`Cancelled ${flightId}`);
|
|
941
1024
|
}
|
|
942
1025
|
|
|
943
1026
|
// src/commands/outputs.ts
|
|
944
1027
|
async function listOutputs(flightId) {
|
|
945
1028
|
const response = await apiRequest("GET", `/flights/${flightId}/outputs`);
|
|
1029
|
+
if (!response.ok) throw httpError("Unable to list outputs", response.status);
|
|
946
1030
|
const body = await response.json();
|
|
947
|
-
if (!response.ok) throw new Error(`Failed (${response.status}): ${JSON.stringify(body)}`);
|
|
948
1031
|
const outputs = body.outputs ?? [];
|
|
949
1032
|
if (!outputs.length) console.log("No outputs available.");
|
|
950
1033
|
for (const output of outputs) console.log(`${output.artifact_id} ${output.size_bytes} bytes ${output.filename}`);
|
|
@@ -956,13 +1039,13 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
956
1039
|
import { basename, join as join3, resolve as resolve3 } from "node:path";
|
|
957
1040
|
async function download(flightId, options) {
|
|
958
1041
|
const manifestResponse = await apiRequest("GET", `/flights/${flightId}/outputs`);
|
|
1042
|
+
if (!manifestResponse.ok) throw httpError("Unable to list outputs for download", manifestResponse.status);
|
|
959
1043
|
const manifest = await manifestResponse.json();
|
|
960
|
-
if (!manifestResponse.ok) throw new Error(`Failed (${manifestResponse.status}): ${JSON.stringify(manifest)}`);
|
|
961
1044
|
const root = resolve3(options.outputDir);
|
|
962
1045
|
await mkdir(root, { recursive: true });
|
|
963
1046
|
for (const output of manifest.outputs ?? []) {
|
|
964
1047
|
const response = await apiRequest("GET", `/flights/${flightId}/outputs/${output.artifact_id}`);
|
|
965
|
-
if (!response.ok) throw
|
|
1048
|
+
if (!response.ok) throw httpError("Unable to download output", response.status);
|
|
966
1049
|
const target = join3(root, basename(output.filename));
|
|
967
1050
|
await writeFile(target, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
|
|
968
1051
|
console.log(`Downloaded ${target}`);
|
|
@@ -1000,14 +1083,14 @@ printf 'FLIGHTDECK_SLURM_CONFORMANCE task=%s
|
|
|
1000
1083
|
`;
|
|
1001
1084
|
function positiveSeconds(value, option) {
|
|
1002
1085
|
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
1003
|
-
throw new
|
|
1086
|
+
throw new CliError(`${option} must be a positive number of seconds`);
|
|
1004
1087
|
}
|
|
1005
1088
|
return Number(value);
|
|
1006
1089
|
}
|
|
1007
1090
|
async function readFlight(flightId) {
|
|
1008
1091
|
const response = await apiRequest("GET", `/flights/${flightId}`);
|
|
1092
|
+
if (!response.ok) throw httpError("Unable to read conformance flight", response.status);
|
|
1009
1093
|
const body = await response.json();
|
|
1010
|
-
if (!response.ok) throw new Error(`status ${flightId} failed (${response.status}): ${JSON.stringify(body)}`);
|
|
1011
1094
|
return body;
|
|
1012
1095
|
}
|
|
1013
1096
|
async function verifyResults(flightId, taskId) {
|
|
@@ -1015,16 +1098,16 @@ async function verifyResults(flightId, taskId) {
|
|
|
1015
1098
|
apiRequest("GET", `/flights/${flightId}/logs`),
|
|
1016
1099
|
apiRequest("GET", `/flights/${flightId}/outputs`)
|
|
1017
1100
|
]);
|
|
1101
|
+
if (!logsResponse.ok) throw httpError("Unable to read conformance logs", logsResponse.status);
|
|
1102
|
+
if (!outputsResponse.ok) throw httpError("Unable to read conformance outputs", outputsResponse.status);
|
|
1018
1103
|
const logs2 = await logsResponse.json();
|
|
1019
1104
|
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
1105
|
const combinedLogs = (logs2.logs ?? []).map((entry) => entry.content).join("\n");
|
|
1023
1106
|
if (!combinedLogs.includes(`FLIGHTDECK_SLURM_CONFORMANCE task=${taskId}`)) {
|
|
1024
|
-
throw new
|
|
1107
|
+
throw new CliError(`${flightId}: provider logs do not contain the task ${taskId} conformance marker`);
|
|
1025
1108
|
}
|
|
1026
1109
|
if (!(outputs.outputs ?? []).some((output) => output.size_bytes > 0)) {
|
|
1027
|
-
throw new
|
|
1110
|
+
throw new CliError(`${flightId}: provider returned no non-empty output artifact`);
|
|
1028
1111
|
}
|
|
1029
1112
|
}
|
|
1030
1113
|
async function slurmTest(options) {
|
|
@@ -1042,7 +1125,7 @@ async function slurmTest(options) {
|
|
|
1042
1125
|
} finally {
|
|
1043
1126
|
rmSync2(tempDir, { recursive: true, force: true });
|
|
1044
1127
|
}
|
|
1045
|
-
if (submitted.length !== 2) throw new
|
|
1128
|
+
if (submitted.length !== 2) throw new CliError(`expected 2 conformance flights, submitted ${submitted.length}`);
|
|
1046
1129
|
const deadline = Date.now() + timeoutMs;
|
|
1047
1130
|
const pending = new Map(submitted.map((task) => [task.flightId, task]));
|
|
1048
1131
|
const finished = /* @__PURE__ */ new Map();
|
|
@@ -1058,12 +1141,12 @@ async function slurmTest(options) {
|
|
|
1058
1141
|
if (pending.size > 0) await new Promise((resolve4) => setTimeout(resolve4, pollIntervalMs));
|
|
1059
1142
|
}
|
|
1060
1143
|
if (pending.size > 0) {
|
|
1061
|
-
throw new
|
|
1144
|
+
throw new CliError(`timed out waiting for: ${[...pending.keys()].join(", ")}`);
|
|
1062
1145
|
}
|
|
1063
1146
|
for (const task of submitted) {
|
|
1064
1147
|
const flight = finished.get(task.flightId);
|
|
1065
1148
|
if (flight.status !== "completed" || flight.exit_code !== 0) {
|
|
1066
|
-
throw new
|
|
1149
|
+
throw new CliError(`${task.flightId} failed conformance. Inspect flightdeck status and logs for this flight.`);
|
|
1067
1150
|
}
|
|
1068
1151
|
await verifyResults(task.flightId, task.taskId);
|
|
1069
1152
|
}
|
|
@@ -1076,24 +1159,32 @@ var SCOPES = ["jobs:read", "jobs:submit", "jobs:cancel", "account:read"];
|
|
|
1076
1159
|
function sleep(milliseconds) {
|
|
1077
1160
|
return new Promise((resolve4) => setTimeout(resolve4, milliseconds));
|
|
1078
1161
|
}
|
|
1079
|
-
async function
|
|
1162
|
+
async function loginFailure(response) {
|
|
1080
1163
|
try {
|
|
1081
1164
|
const body = await response.json();
|
|
1082
|
-
|
|
1165
|
+
if (body.error === "device_authorization_denied") {
|
|
1166
|
+
return new CliError("Login was not approved. Run flightdeck login to try again.");
|
|
1167
|
+
}
|
|
1168
|
+
if (body.error === "expired_device_code") {
|
|
1169
|
+
return new CliError("Login expired before it was approved. Run flightdeck login to try again.");
|
|
1170
|
+
}
|
|
1171
|
+
if (body.error === "credential_name_in_use") {
|
|
1172
|
+
return new CliError("That credential name is already in use. Choose another --name or revoke the old credential in the portal.");
|
|
1173
|
+
}
|
|
1083
1174
|
} catch {
|
|
1084
|
-
return response.statusText;
|
|
1085
1175
|
}
|
|
1176
|
+
return httpError("Login failed", response.status);
|
|
1086
1177
|
}
|
|
1087
1178
|
async function login(options) {
|
|
1088
1179
|
if (readConfig()) {
|
|
1089
|
-
throw new
|
|
1180
|
+
throw new CliError("Already logged in. Run flightdeck whoami to check the account, or flightdeck logout before signing in again.");
|
|
1090
1181
|
}
|
|
1091
1182
|
const apiUrl = normalizeApiUrl(options.apiUrl ?? DEFAULT_API_URL);
|
|
1092
1183
|
const started = await publicApiRequest(apiUrl, "POST", "/auth/device/code", {
|
|
1093
1184
|
credential_name: options.name ?? "flightdeck-cli",
|
|
1094
1185
|
scopes: SCOPES
|
|
1095
1186
|
});
|
|
1096
|
-
if (!started.ok) throw
|
|
1187
|
+
if (!started.ok) throw httpError("Unable to start login", started.status);
|
|
1097
1188
|
const authorization = await started.json();
|
|
1098
1189
|
console.log(`Open this URL to approve the CLI:
|
|
1099
1190
|
${authorization.verification_uri_complete}`);
|
|
@@ -1112,13 +1203,13 @@ Verification code: ${authorization.user_code}`);
|
|
|
1112
1203
|
if (Number.isFinite(retryAfter) && retryAfter > 0) intervalSeconds = retryAfter;
|
|
1113
1204
|
continue;
|
|
1114
1205
|
}
|
|
1115
|
-
if (!response.ok) throw
|
|
1206
|
+
if (!response.ok) throw await loginFailure(response);
|
|
1116
1207
|
const token = await response.json();
|
|
1117
1208
|
saveConfig({ api_url: apiUrl, api_key: token.access_token, credential_id: token.credential_id });
|
|
1118
1209
|
console.log("Logged in. The credential was stored in ~/.flightdeck/config.json.");
|
|
1119
1210
|
return;
|
|
1120
1211
|
}
|
|
1121
|
-
throw new
|
|
1212
|
+
throw new CliError("Login expired before it was approved. Run flightdeck login to try again.");
|
|
1122
1213
|
}
|
|
1123
1214
|
|
|
1124
1215
|
// src/commands/logout.ts
|
|
@@ -1131,11 +1222,11 @@ async function logout(options) {
|
|
|
1131
1222
|
let response;
|
|
1132
1223
|
try {
|
|
1133
1224
|
response = await apiRequest("POST", "/auth/credential/revoke");
|
|
1134
|
-
} catch (
|
|
1135
|
-
throw new
|
|
1225
|
+
} catch (cause) {
|
|
1226
|
+
throw new CliError("Credential revocation failed; local login retained. Check your connection and retry flightdeck logout.", { cause });
|
|
1136
1227
|
}
|
|
1137
1228
|
if (!response.ok && response.status !== 401) {
|
|
1138
|
-
throw new
|
|
1229
|
+
throw new CliError(`Credential revocation failed (HTTP ${response.status}); local login retained. Retry flightdeck logout.`);
|
|
1139
1230
|
}
|
|
1140
1231
|
}
|
|
1141
1232
|
deleteConfig();
|
|
@@ -1145,20 +1236,20 @@ async function logout(options) {
|
|
|
1145
1236
|
// src/commands/whoami.ts
|
|
1146
1237
|
async function whoami() {
|
|
1147
1238
|
const response = await apiRequest("GET", "/auth/me");
|
|
1148
|
-
if (!response.ok) throw
|
|
1239
|
+
if (!response.ok) throw httpError("Unable to read account", response.status);
|
|
1149
1240
|
console.log(JSON.stringify(await response.json(), null, 2));
|
|
1150
1241
|
}
|
|
1151
1242
|
|
|
1152
1243
|
// src/commands/balance.ts
|
|
1153
1244
|
async function balance() {
|
|
1154
1245
|
const response = await apiRequest("GET", "/billing/summary");
|
|
1155
|
-
if (!response.ok) throw
|
|
1246
|
+
if (!response.ok) throw httpError("Unable to read credit balance", response.status);
|
|
1156
1247
|
console.log(JSON.stringify(await response.json(), null, 2));
|
|
1157
1248
|
}
|
|
1158
1249
|
|
|
1159
1250
|
// src/index.ts
|
|
1160
1251
|
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);
|
|
1252
|
+
program.name("flightdeck").description("FlightDeck CLI \u2014 submit GPU compute jobs").option("--debug", "include safe error type/code and CLI frame diagnostics (no raw stack or credentials)").version(JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
1162
1253
|
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
1254
|
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
1255
|
program.command("whoami").description("Show the current FlightDeck account").action(whoami);
|
|
@@ -1173,4 +1264,9 @@ program.command("logs").description("Stream flight logs").argument("<flight_id>"
|
|
|
1173
1264
|
program.command("cancel").description("Cancel a flight").argument("<flight_id>").action(cancel);
|
|
1174
1265
|
program.command("outputs").description("List flight outputs").argument("<flight_id>").action(listOutputs);
|
|
1175
1266
|
program.command("download").description("Download all flight outputs").argument("<flight_id>").option("-o, --output-dir <dir>", "destination directory", ".").action(download);
|
|
1176
|
-
|
|
1267
|
+
try {
|
|
1268
|
+
await program.parseAsync();
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
console.error(commandFailure(error, program.opts().debug === true));
|
|
1271
|
+
process.exitCode = 1;
|
|
1272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thermai/flightdeck-cli",
|
|
3
|
-
"version": "0.1.0-pilot.
|
|
3
|
+
"version": "0.1.0-pilot.3",
|
|
4
4
|
"description": "FlightDeck CLI — submit GPU compute jobs",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc -p tsconfig.json --noEmit && esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --external:commander --external:tar --outfile=dist/index.js",
|
|
25
25
|
"dev": "tsx src/index.ts",
|
|
26
|
-
"test": "node --import tsx --test \"src/**/*.test.ts\" && node --test scripts/release-policy.test.mjs",
|
|
26
|
+
"test": "node --import tsx --test \"src/**/*.test.ts\" && node --test scripts/release-policy.test.mjs && node scripts/sync-install-instructions.mjs --check",
|
|
27
|
+
"sync:install": "node scripts/sync-install-instructions.mjs --write",
|
|
27
28
|
"prepack": "npm run build",
|
|
28
29
|
"test:package": "node scripts/test-package.mjs"
|
|
29
30
|
},
|