@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.
Files changed (3) hide show
  1. package/README.md +125 -6
  2. package/dist/index.js +152 -56
  3. 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
- This revision prepares `0.1.0-pilot.2` to qualify GitHub Actions trusted
13
- publishing; it is not published merely by merging the version change. The
14
- installation command below targets that candidate once its release completes.
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@0.1.0-pilot.2
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. Updating is an
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
- const url = new URL(value);
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 Error("FlightDeck API URLs must use HTTPS (HTTP is allowed only for localhost)");
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 Error("FlightDeck API URL must not contain credentials, a query, or a fragment");
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
- console.error(
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 fetch(endpoint(apiUrl, path), {
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 fetch(endpoint(config.api_url, path), {
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 Error(`invalid env var format: ${arg} (expected KEY=VALUE)`);
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/commands/logs.ts
918
- async function logs(flightId, options) {
970
+ // src/log-reader.ts
971
+ async function drainLogs(follow, deps) {
919
972
  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);
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 (!options.follow || body.complete) break;
931
- await new Promise((resolve4) => setTimeout(resolve4, 2e3));
932
- } while (true);
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
- const body = await response.json();
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 new Error(`Download failed for ${output.filename} (${response.status})`);
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 Error(`${option} must be a positive number of seconds`);
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 Error(`${flightId}: provider logs do not contain the task ${taskId} conformance marker`);
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 Error(`${flightId}: provider returned no non-empty output artifact`);
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 Error(`expected 2 conformance flights, submitted ${submitted.length}`);
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 Error(`timed out waiting for: ${[...pending.keys()].join(", ")}`);
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 Error(`${task.flightId} failed conformance: ${flight.error_message ?? `exit ${flight.exit_code}`}`);
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 errorMessage(response) {
1162
+ async function loginFailure(response) {
1080
1163
  try {
1081
1164
  const body = await response.json();
1082
- return body.error ?? response.statusText;
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 Error("Already logged in. Run `flightdeck logout` before replacing this credential.");
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 new Error(`Unable to start login: ${await errorMessage(started)}`);
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 new Error(`Login failed: ${await errorMessage(response)}`);
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 Error("Login expired before it was approved. Run `flightdeck login` to try again.");
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 (error) {
1135
- throw new Error(`Credential revocation failed; local login retained: ${String(error)}`);
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 Error(`Credential revocation failed (${response.status}); local login retained.`);
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 new Error(`Unable to read account (${response.status}).`);
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 new Error(`Unable to read credit balance (${response.status}).`);
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
- program.parse();
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.2",
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
  },