@shardflux/cli 0.4.1 → 0.5.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.
@@ -0,0 +1,77 @@
1
+ import { UsageError } from './errors.js';
2
+ export declare const CREDENTIALS_FILE = "credentials.json";
3
+ export declare const FILE_FORMAT_VERSION = 1;
4
+ export interface StoredUser {
5
+ id: string;
6
+ email: string;
7
+ }
8
+ /** A user session (`sfu_...`); `state` pending_mfa while `shard auth mfa` has not completed the sign-in. */
9
+ export interface StoredSession {
10
+ token: string;
11
+ /** When the session ends at the latest (the API's absolute expiry), or null when unknown. */
12
+ expires_at: string | null;
13
+ /** Null while the sign-in waits for its second factor. */
14
+ user: StoredUser | null;
15
+ state: 'active' | 'pending_mfa';
16
+ }
17
+ /** A project API key (`sfk_...`) saved by `shard setup` or `shard api-keys create --save`. */
18
+ export interface StoredApiKey {
19
+ token: string;
20
+ id: string;
21
+ key_id: string;
22
+ project_id: string;
23
+ name: string;
24
+ }
25
+ export interface Profile {
26
+ session?: StoredSession;
27
+ /** Default organization and project (`shard orgs use`, `shard projects use`, `shard setup`). */
28
+ organization_id?: string;
29
+ project_id?: string;
30
+ api_key?: StoredApiKey;
31
+ }
32
+ export interface CredentialsDoc {
33
+ version: 1;
34
+ profiles: Record<string, Profile>;
35
+ }
36
+ /** The credentials file is unreadable (not JSON, another format, or a newer version): exit 2, naming the file. */
37
+ export declare class CredentialsFileError extends UsageError {
38
+ readonly file: string;
39
+ constructor(file: string, problem: string);
40
+ }
41
+ /**
42
+ * The configuration directory: $SHARDFLUX_CONFIG_DIR, else $XDG_CONFIG_HOME/shardflux, else %APPDATA%\shardflux on
43
+ * Windows, else ~/.config/shardflux (home: $HOME, else %USERPROFILE%, else the OS's).
44
+ */
45
+ export declare function configDir(env: Record<string, string | undefined>, platform?: NodeJS.Platform): string;
46
+ /** Creates the directory 0700 (and tightens an existing one that others can read: it holds credentials). */
47
+ export declare function ensurePrivateDir(dir: string): void;
48
+ /**
49
+ * Writes `text` to `file` atomically with mode 0600: a unique temp file in the same directory, fsync, rename over the
50
+ * target. The directory is created 0700 first.
51
+ */
52
+ export declare function writePrivateFile(dir: string, file: string, text: string): void;
53
+ /** Parses and checks a credentials document; returns the problem as text instead of throwing. */
54
+ export declare function parseCredentials(text: string): {
55
+ doc: CredentialsDoc;
56
+ } | {
57
+ problem: string;
58
+ };
59
+ /**
60
+ * The credentials file of one config directory. Reads are cached for the command; `update()` re-reads, applies the
61
+ * change and writes atomically. `warn` receives notes for stderr (a corrupt file moved aside, a file others can read).
62
+ */
63
+ export declare class CredentialStore {
64
+ #private;
65
+ readonly dir: string;
66
+ readonly file: string;
67
+ constructor(dir: string, warn?: (message: string) => void);
68
+ /** The file's contents; an empty document when there is no file. Throws CredentialsFileError when it is unusable. */
69
+ read(): CredentialsDoc;
70
+ /** The profile of an API URL (a copy; empty when there is none). */
71
+ profile(apiUrl: string): Profile;
72
+ /**
73
+ * Changes one profile: `change` edits a copy (or returns a replacement); an empty result removes the profile. An
74
+ * unusable file is moved aside first (never overwritten), with a note. Returns the profile as written.
75
+ */
76
+ update(apiUrl: string, change: (p: Profile) => Profile | void): Profile;
77
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The credentials file (0.5.0+; README "Credentials file"). `shard auth login` saves the user session and
3
+ * `shard setup` (or `shard api-keys create --save`) a project API key, so an agent signs in once and every later
4
+ * command finds its credentials. Environment variables still win: SHARDFLUX_API_KEY over the saved key,
5
+ * SHARDFLUX_SESSION_TOKEN over the saved session.
6
+ *
7
+ * <config dir>/credentials.json {"version":1,"profiles":{"<API URL>":{"session":{...},"organization_id",
8
+ * "project_id","api_key":{...}}}}
9
+ *
10
+ * Config dir: $SHARDFLUX_CONFIG_DIR, else $XDG_CONFIG_HOME/shardflux, else %APPDATA%\shardflux (Windows), else
11
+ * ~/.config/shardflux. The directory is created 0700 and files are written 0600, atomically (a temp file in the same
12
+ * directory, fsync, rename), so a crash never leaves half a file and other users never see a secret. One profile per
13
+ * API URL (the normalized `--api-url`), so staging and production credentials never mix.
14
+ *
15
+ * A file that is not valid JSON, or not this format, is never silently replaced: reading it is a usage error naming
16
+ * the file; a command that writes credentials moves it aside first (credentials.json.corrupt-<time>) and says so.
17
+ * Synchronous on purpose: a few hundred bytes, read once per command, and `ctx.cloud()` resolves synchronously.
18
+ */
19
+ import { randomBytes } from 'node:crypto';
20
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join, win32 } from 'node:path';
23
+ import { UsageError } from "./errors.js";
24
+ export const CREDENTIALS_FILE = 'credentials.json';
25
+ export const FILE_FORMAT_VERSION = 1;
26
+ /** The credentials file is unreadable (not JSON, another format, or a newer version): exit 2, naming the file. */
27
+ export class CredentialsFileError extends UsageError {
28
+ file;
29
+ constructor(file, problem) {
30
+ super(`${file} is not a usable shard credentials file (${problem}). Fix or remove it; "shard auth login" moves it aside and starts a new one.`);
31
+ this.file = file;
32
+ }
33
+ }
34
+ const TRUTHY_NONEMPTY = (v) => typeof v === 'string' && v.trim().length > 0;
35
+ /**
36
+ * The configuration directory: $SHARDFLUX_CONFIG_DIR, else $XDG_CONFIG_HOME/shardflux, else %APPDATA%\shardflux on
37
+ * Windows, else ~/.config/shardflux (home: $HOME, else %USERPROFILE%, else the OS's).
38
+ */
39
+ export function configDir(env, platform = process.platform) {
40
+ if (TRUTHY_NONEMPTY(env.SHARDFLUX_CONFIG_DIR))
41
+ return env.SHARDFLUX_CONFIG_DIR.trim();
42
+ const join_ = (...parts) => (platform === 'win32' ? win32.join(...parts) : join(...parts));
43
+ if (TRUTHY_NONEMPTY(env.XDG_CONFIG_HOME))
44
+ return join_(env.XDG_CONFIG_HOME.trim(), 'shardflux');
45
+ if (platform === 'win32' && TRUTHY_NONEMPTY(env.APPDATA))
46
+ return win32.join(env.APPDATA.trim(), 'shardflux');
47
+ const home = [env.HOME, env.USERPROFILE].find(TRUTHY_NONEMPTY) ?? homedir();
48
+ return join_(home, '.config', 'shardflux');
49
+ }
50
+ /** Creates the directory 0700 (and tightens an existing one that others can read: it holds credentials). */
51
+ export function ensurePrivateDir(dir) {
52
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ if (process.platform === 'win32')
54
+ return;
55
+ const st = statSync(dir);
56
+ if ((st.mode & 0o077) !== 0)
57
+ chmodSync(dir, 0o700);
58
+ }
59
+ /**
60
+ * Writes `text` to `file` atomically with mode 0600: a unique temp file in the same directory, fsync, rename over the
61
+ * target. The directory is created 0700 first.
62
+ */
63
+ export function writePrivateFile(dir, file, text) {
64
+ ensurePrivateDir(dir);
65
+ const tmp = join(dir, `.${file.split(/[\\/]/).pop() ?? 'file'}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
66
+ const fd = openSync(tmp, 'wx', 0o600);
67
+ try {
68
+ writeSync(fd, text);
69
+ fsyncSync(fd);
70
+ }
71
+ catch (err) {
72
+ closeSync(fd);
73
+ rmSync(tmp, { force: true });
74
+ throw err;
75
+ }
76
+ closeSync(fd);
77
+ try {
78
+ renameSync(tmp, file);
79
+ }
80
+ catch (err) {
81
+ rmSync(tmp, { force: true });
82
+ throw err;
83
+ }
84
+ if (process.platform !== 'win32')
85
+ chmodSync(file, 0o600);
86
+ }
87
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
88
+ const isStr = (v) => typeof v === 'string';
89
+ function checkProfile(url, p) {
90
+ if (!isObj(p))
91
+ return `profile "${url}" is not an object`;
92
+ if (p.organization_id !== undefined && !isStr(p.organization_id))
93
+ return `profile "${url}": organization_id is not a string`;
94
+ if (p.project_id !== undefined && !isStr(p.project_id))
95
+ return `profile "${url}": project_id is not a string`;
96
+ if (p.session !== undefined) {
97
+ const s = p.session;
98
+ if (!isObj(s) || !isStr(s.token) || !(s.expires_at === null || isStr(s.expires_at)) || !(s.state === 'active' || s.state === 'pending_mfa'))
99
+ return `profile "${url}": session is malformed`;
100
+ if (!(s.user === null || (isObj(s.user) && isStr(s.user.id) && isStr(s.user.email))))
101
+ return `profile "${url}": session.user is malformed`;
102
+ }
103
+ if (p.api_key !== undefined) {
104
+ const k = p.api_key;
105
+ if (!isObj(k) || !isStr(k.token) || !isStr(k.id) || !isStr(k.key_id) || !isStr(k.project_id) || !isStr(k.name))
106
+ return `profile "${url}": api_key is malformed`;
107
+ }
108
+ return null;
109
+ }
110
+ /** Parses and checks a credentials document; returns the problem as text instead of throwing. */
111
+ export function parseCredentials(text) {
112
+ let raw;
113
+ try {
114
+ raw = JSON.parse(text);
115
+ }
116
+ catch (e) {
117
+ return { problem: `not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
118
+ }
119
+ if (!isObj(raw))
120
+ return { problem: 'not a JSON object' };
121
+ if (typeof raw.version === 'number' && raw.version > FILE_FORMAT_VERSION)
122
+ return { problem: `format version ${raw.version} is newer than this shard understands; update shard` };
123
+ if (raw.version !== FILE_FORMAT_VERSION)
124
+ return { problem: 'no "version": 1' };
125
+ if (!isObj(raw.profiles))
126
+ return { problem: '"profiles" is not an object' };
127
+ for (const [url, p] of Object.entries(raw.profiles)) {
128
+ const problem = checkProfile(url, p);
129
+ if (problem)
130
+ return { problem };
131
+ }
132
+ return { doc: raw };
133
+ }
134
+ const isEmpty = (p) => p.session === undefined && p.api_key === undefined && p.organization_id === undefined && p.project_id === undefined;
135
+ /**
136
+ * The credentials file of one config directory. Reads are cached for the command; `update()` re-reads, applies the
137
+ * change and writes atomically. `warn` receives notes for stderr (a corrupt file moved aside, a file others can read).
138
+ */
139
+ export class CredentialStore {
140
+ dir;
141
+ file;
142
+ #warn;
143
+ #cache = null;
144
+ #warnedMode = false;
145
+ constructor(dir, warn = () => undefined) {
146
+ this.dir = dir;
147
+ this.file = join(dir, CREDENTIALS_FILE);
148
+ this.#warn = warn;
149
+ }
150
+ /** The file's contents; an empty document when there is no file. Throws CredentialsFileError when it is unusable. */
151
+ read() {
152
+ if (this.#cache)
153
+ return this.#cache;
154
+ const loaded = this.#load();
155
+ if ('problem' in loaded)
156
+ throw new CredentialsFileError(this.file, loaded.problem);
157
+ this.#cache = loaded.doc;
158
+ return loaded.doc;
159
+ }
160
+ /** The profile of an API URL (a copy; empty when there is none). */
161
+ profile(apiUrl) {
162
+ return structuredClone(this.read().profiles[apiUrl] ?? {});
163
+ }
164
+ /**
165
+ * Changes one profile: `change` edits a copy (or returns a replacement); an empty result removes the profile. An
166
+ * unusable file is moved aside first (never overwritten), with a note. Returns the profile as written.
167
+ */
168
+ update(apiUrl, change) {
169
+ let loaded = this.#load();
170
+ if ('problem' in loaded) {
171
+ const backup = `${this.file}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`;
172
+ renameSync(this.file, backup);
173
+ this.#warn(`note: ${this.file} was not usable (${loaded.problem}); moved it to ${backup} and started a new one`);
174
+ loaded = { doc: { version: FILE_FORMAT_VERSION, profiles: {} } };
175
+ }
176
+ const doc = loaded.doc;
177
+ const current = structuredClone(doc.profiles[apiUrl] ?? {});
178
+ const next = change(current) ?? current;
179
+ for (const k of Object.keys(next))
180
+ if (next[k] === undefined)
181
+ delete next[k];
182
+ if (isEmpty(next))
183
+ delete doc.profiles[apiUrl];
184
+ else
185
+ doc.profiles[apiUrl] = next;
186
+ writePrivateFile(this.dir, this.file, `${JSON.stringify(doc, null, 2)}\n`);
187
+ this.#cache = doc;
188
+ return structuredClone(next);
189
+ }
190
+ #load() {
191
+ let text;
192
+ try {
193
+ text = readFileSync(this.file, 'utf8');
194
+ }
195
+ catch (err) {
196
+ if (err.code === 'ENOENT')
197
+ return { doc: { version: FILE_FORMAT_VERSION, profiles: {} } };
198
+ return { problem: `cannot read it: ${err.code ?? String(err)}` };
199
+ }
200
+ this.#checkMode();
201
+ return parseCredentials(text);
202
+ }
203
+ #checkMode() {
204
+ if (this.#warnedMode || process.platform === 'win32')
205
+ return;
206
+ try {
207
+ const mode = statSync(this.file).mode & 0o777;
208
+ if ((mode & 0o077) !== 0) {
209
+ this.#warnedMode = true;
210
+ this.#warn(`warning: ${this.file} can be read by other users (mode ${mode.toString(8).padStart(4, '0')}); restrict it: chmod 600 ${this.file}`);
211
+ }
212
+ }
213
+ catch {
214
+ // Unreadable metadata: the read itself reports it.
215
+ }
216
+ }
217
+ }
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CheckoutStatus, ExecutionResult } from '@shardflux/sdk';
1
2
  export declare const EXIT: {
2
3
  readonly ok: 0;
3
4
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -6,31 +7,74 @@ export declare const EXIT: {
6
7
  readonly usage: 2;
7
8
  /** The workspace, operation or route does not exist in this project. */
8
9
  readonly notFound: 3;
9
- /** Missing, malformed, revoked or insufficient API key. */
10
+ /** Missing, malformed, revoked or insufficient API key or session (also a sign-in that waits for its second factor). */
10
11
  readonly auth: 4;
11
12
  /** Waiting ran out of time (the operation continues server side), or a request timed out. */
12
13
  readonly timeout: 5;
13
- /** The awaited operation ended `failed` or `canceled` (the error's `retryable` says whether a retry may succeed). */
14
+ /**
15
+ * The awaited operation ended `failed` or `canceled`, or a file-first execution ended `failed` or `lost` (the error's
16
+ * `retryable` says whether a retry may succeed); also a Checkout that `billing upgrade --wait` saw expire or get
17
+ * canceled.
18
+ */
14
19
  readonly operationFailed: 6;
15
20
  /** SIGINT (Ctrl-C). */
16
21
  readonly interrupted: 130;
17
22
  };
18
- export declare class UsageError extends Error {
23
+ /** A failure `shard` reports with its own next step (`hint:` line); the API's errors get theirs from the code. */
24
+ declare class HintedError extends Error {
25
+ readonly hint: string | undefined;
26
+ constructor(message: string, hint?: string);
27
+ }
28
+ export declare class UsageError extends HintedError {
29
+ name: string;
30
+ }
31
+ /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
32
+ export declare class AuthConfigError extends HintedError {
33
+ name: string;
34
+ }
35
+ /** A workspace key that no workspace of this project has (or another name that resolves to nothing). */
36
+ export declare class NotFoundError extends HintedError {
19
37
  name: string;
20
38
  }
21
- /** SHARDFLUX_API_KEY missing or malformed (never carries the key). */
22
- export declare class AuthConfigError extends Error {
39
+ /** `auth login` of a two-factor account without a code: the pending sign-in is saved; `shard auth mfa` completes it (exit 4). */
40
+ export declare class MfaPendingError extends HintedError {
23
41
  name: string;
24
42
  }
25
- /** A workspace key that no workspace of this project has. */
26
- export declare class NotFoundError extends Error {
43
+ /** `billing upgrade --wait`: the Checkout expired, was canceled or failed without activating a subscription (exit 6). */
44
+ export declare class CheckoutEndedError extends Error {
27
45
  name: string;
46
+ readonly checkout: CheckoutStatus;
47
+ constructor(checkout: CheckoutStatus);
28
48
  }
29
- /** The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running. */
49
+ /**
50
+ * The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running, `executionId` when a
51
+ * file-first execution does (it cannot be canceled), with `hint` naming the command that fetches its result.
52
+ */
30
53
  export declare class InterruptedError extends Error {
31
54
  name: string;
32
55
  readonly operationId: string | undefined;
33
- constructor(message: string, operationId?: string);
56
+ readonly executionId: string | undefined;
57
+ readonly hint: string | undefined;
58
+ constructor(message: string, operationId?: string, more?: {
59
+ executionId?: string;
60
+ hint?: string;
61
+ });
62
+ }
63
+ /** A file-first execution that ended `failed` or `lost` (contracts §29.8): nothing was published. */
64
+ export declare class ExecutionFailedError extends Error {
65
+ name: string;
66
+ readonly result: ExecutionResult;
67
+ constructor(result: ExecutionResult);
68
+ }
69
+ /**
70
+ * A file-first execution that is still queued or running when `shard executions get` returns (no --wait, or --wait ran
71
+ * out). It continues server side; `hint` names the command that waits for it.
72
+ */
73
+ export declare class ExecutionPendingError extends Error {
74
+ name: string;
75
+ readonly result: ExecutionResult;
76
+ readonly hint: string;
77
+ constructor(message: string, result: ExecutionResult, hint: string);
34
78
  }
35
79
  export interface ErrorInfo {
36
80
  code: string;
@@ -43,9 +87,17 @@ export interface ErrorInfo {
43
87
  source?: 'api' | 'cell';
44
88
  last_state?: string;
45
89
  operation?: unknown;
90
+ /** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
91
+ execution_id?: string;
46
92
  }
47
- /** Removes anything shaped like a Shardflux API key (and the configured key itself) from text. */
48
- export declare function redact(text: string, key?: string): string;
93
+ /**
94
+ * Removes anything shaped like a Shardflux API key or user session token from text, and each of the given secrets
95
+ * (the configured key, the saved session, ...) wherever it appears.
96
+ */
97
+ export declare function redact(text: string, ...secrets: (string | null | undefined)[]): string;
98
+ /** The `hint:` a CLI error carries itself, if any. */
99
+ export declare function ownHint(err: unknown): string | undefined;
49
100
  export declare function describeError(err: unknown): ErrorInfo;
50
101
  /** Maps an error to the documented exit code. */
51
102
  export declare function exitCodeFor(err: unknown, interrupted?: boolean): number;
103
+ export {};
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { CheckoutTimeoutError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -12,54 +12,125 @@ export const EXIT = {
12
12
  usage: 2,
13
13
  /** The workspace, operation or route does not exist in this project. */
14
14
  notFound: 3,
15
- /** Missing, malformed, revoked or insufficient API key. */
15
+ /** Missing, malformed, revoked or insufficient API key or session (also a sign-in that waits for its second factor). */
16
16
  auth: 4,
17
17
  /** Waiting ran out of time (the operation continues server side), or a request timed out. */
18
18
  timeout: 5,
19
- /** The awaited operation ended `failed` or `canceled` (the error's `retryable` says whether a retry may succeed). */
19
+ /**
20
+ * The awaited operation ended `failed` or `canceled`, or a file-first execution ended `failed` or `lost` (the error's
21
+ * `retryable` says whether a retry may succeed); also a Checkout that `billing upgrade --wait` saw expire or get
22
+ * canceled.
23
+ */
20
24
  operationFailed: 6,
21
25
  /** SIGINT (Ctrl-C). */
22
26
  interrupted: 130,
23
27
  };
24
- export class UsageError extends Error {
28
+ /** A failure `shard` reports with its own next step (`hint:` line); the API's errors get theirs from the code. */
29
+ class HintedError extends Error {
30
+ hint;
31
+ constructor(message, hint) {
32
+ super(message);
33
+ this.hint = hint;
34
+ }
35
+ }
36
+ export class UsageError extends HintedError {
25
37
  name = 'UsageError';
26
38
  }
27
- /** SHARDFLUX_API_KEY missing or malformed (never carries the key). */
28
- export class AuthConfigError extends Error {
39
+ /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
40
+ export class AuthConfigError extends HintedError {
29
41
  name = 'AuthConfigError';
30
42
  }
31
- /** A workspace key that no workspace of this project has. */
32
- export class NotFoundError extends Error {
43
+ /** A workspace key that no workspace of this project has (or another name that resolves to nothing). */
44
+ export class NotFoundError extends HintedError {
33
45
  name = 'NotFoundError';
34
46
  }
35
- /** The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running. */
47
+ /** `auth login` of a two-factor account without a code: the pending sign-in is saved; `shard auth mfa` completes it (exit 4). */
48
+ export class MfaPendingError extends HintedError {
49
+ name = 'MfaPendingError';
50
+ }
51
+ /** `billing upgrade --wait`: the Checkout expired, was canceled or failed without activating a subscription (exit 6). */
52
+ export class CheckoutEndedError extends Error {
53
+ name = 'CheckoutEndedError';
54
+ checkout;
55
+ constructor(checkout) {
56
+ super(`Checkout ${checkout.id}${checkout.plan_key ? ` (${checkout.plan_key})` : ''} ended ${checkout.status} without activating a subscription.`);
57
+ this.checkout = checkout;
58
+ }
59
+ }
60
+ /**
61
+ * The user pressed Ctrl-C; `operationId` is set when a server-side operation keeps running, `executionId` when a
62
+ * file-first execution does (it cannot be canceled), with `hint` naming the command that fetches its result.
63
+ */
36
64
  export class InterruptedError extends Error {
37
65
  name = 'InterruptedError';
38
66
  operationId;
39
- constructor(message, operationId) {
67
+ executionId;
68
+ hint;
69
+ constructor(message, operationId, more = {}) {
40
70
  super(message);
41
71
  this.operationId = operationId;
72
+ this.executionId = more.executionId;
73
+ this.hint = more.hint;
74
+ }
75
+ }
76
+ /** A file-first execution that ended `failed` or `lost` (contracts §29.8): nothing was published. */
77
+ export class ExecutionFailedError extends Error {
78
+ name = 'ExecutionFailedError';
79
+ result;
80
+ constructor(result) {
81
+ const e = result.error;
82
+ super(`Execution ${result.executionId} ${result.state}${e?.message ? `: ${e.message}` : ''}`);
83
+ this.result = result;
84
+ }
85
+ }
86
+ /**
87
+ * A file-first execution that is still queued or running when `shard executions get` returns (no --wait, or --wait ran
88
+ * out). It continues server side; `hint` names the command that waits for it.
89
+ */
90
+ export class ExecutionPendingError extends Error {
91
+ name = 'ExecutionPendingError';
92
+ result;
93
+ hint;
94
+ constructor(message, result, hint) {
95
+ super(message);
96
+ this.result = result;
97
+ this.hint = hint;
42
98
  }
43
99
  }
44
100
  const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
45
101
  const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
46
- /** Removes anything shaped like a Shardflux API key (and the configured key itself) from text. */
47
- export function redact(text, key) {
48
- let out = text.replace(KEY_PATTERN, 'sfk_[redacted]');
49
- if (key && key.length >= 8)
50
- out = out.split(key).join('[redacted]');
102
+ /** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
103
+ const SESSION_PATTERN = /sfu_[A-Za-z0-9_-]{16,}/g;
104
+ /**
105
+ * Removes anything shaped like a Shardflux API key or user session token from text, and each of the given secrets
106
+ * (the configured key, the saved session, ...) wherever it appears.
107
+ */
108
+ export function redact(text, ...secrets) {
109
+ let out = text.replace(KEY_PATTERN, 'sfk_[redacted]').replace(SESSION_PATTERN, 'sfu_[redacted]');
110
+ for (const s of secrets)
111
+ if (s && s.length >= 8)
112
+ out = out.split(s).join('[redacted]');
51
113
  return out;
52
114
  }
115
+ /** The `hint:` a CLI error carries itself, if any. */
116
+ export function ownHint(err) {
117
+ return err instanceof HintedError ? err.hint : undefined;
118
+ }
53
119
  function isNamed(err, name) {
54
120
  return typeof err === 'object' && err !== null && err.name === name;
55
121
  }
56
122
  export function describeError(err) {
123
+ // A processful command that could not start (0.5.1; the SDK's ExecStartError): the workspace's reason, no HTTP status
124
+ // (the start itself was answered 201) and details.error, details.session_id.
125
+ if (err instanceof ExecStartError)
126
+ return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}), source: err.source };
57
127
  if (err instanceof ShardfluxApiError) {
58
128
  return {
59
129
  code: err.code,
60
130
  message: err.message,
61
131
  status: err.status,
62
- request_id: err.requestId,
132
+ // A refusal the SDK made itself (NotSupportedForModeError.local) has no request id.
133
+ ...(err.requestId ? { request_id: err.requestId } : {}),
63
134
  retryable: err.retryable,
64
135
  ...(err.operationId ? { operation_id: err.operationId } : {}),
65
136
  ...(err.details ? { details: err.details } : {}),
@@ -76,8 +147,22 @@ export function describeError(err) {
76
147
  const details = { ...(err.errorCode ? { error_code: err.errorCode } : {}), ...(typeof reason === 'string' ? { reason } : {}) };
77
148
  return { code: 'operation_failed', message: err.message, operation_id: err.operationId, retryable: err.retryable, ...(Object.keys(details).length ? { details } : {}), operation: err.operation };
78
149
  }
150
+ // File-first executions (0.5.0; contracts §29.8): failed or lost like a failed operation (details.reason says why;
151
+ // retryable: a new execution id may succeed), still running like a wait that gave up.
152
+ if (err instanceof ExecutionFailedError) {
153
+ const e = err.result.error;
154
+ const details = { ...(e?.details ?? {}), ...(e?.code ? { error_code: e.code } : {}), state: err.result.state };
155
+ return { code: 'execution_failed', message: err.message, execution_id: err.result.executionId, retryable: e?.retryable === true, details };
156
+ }
157
+ if (err instanceof ExecutionPendingError)
158
+ return { code: 'timeout', message: err.message, execution_id: err.result.executionId, last_state: err.result.state };
79
159
  if (err instanceof UsageError)
80
160
  return { code: 'usage', message: err.message };
161
+ if (err instanceof CheckoutTimeoutError) {
162
+ return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
163
+ }
164
+ if (err instanceof CheckoutEndedError)
165
+ return { code: 'checkout_ended', message: err.message, details: { checkout_id: err.checkout.id, status: err.checkout.status } };
81
166
  // Template builds (0.4.0): a template file that cannot be read or packed is a usage error; a wait that ran out names the build.
82
167
  if (err instanceof TemplateFileError)
83
168
  return { code: 'usage', message: err.message };
@@ -87,10 +172,13 @@ export function describeError(err) {
87
172
  return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
88
173
  if (err instanceof AuthConfigError)
89
174
  return { code: 'unauthenticated', message: err.message };
175
+ if (err instanceof MfaPendingError)
176
+ return { code: 'mfa_required', message: err.message };
90
177
  if (err instanceof NotFoundError)
91
178
  return { code: 'not_found', message: err.message };
92
- if (err instanceof InterruptedError)
93
- return { code: 'interrupted', message: err.message, ...(err.operationId ? { operation_id: err.operationId } : {}) };
179
+ if (err instanceof InterruptedError) {
180
+ return { code: 'interrupted', message: err.message, ...(err.operationId ? { operation_id: err.operationId } : {}), ...(err.executionId ? { execution_id: err.executionId } : {}) };
181
+ }
94
182
  if (err instanceof ShardfluxProtocolError)
95
183
  return { code: 'protocol_error', message: err.message, status: err.status };
96
184
  if (isNamed(err, 'TimeoutError'))
@@ -109,15 +197,20 @@ export function exitCodeFor(err, interrupted = false) {
109
197
  return EXIT.interrupted;
110
198
  if (err instanceof UsageError || err instanceof TemplateFileError)
111
199
  return EXIT.usage;
112
- if (err instanceof AuthConfigError)
200
+ if (err instanceof AuthConfigError || err instanceof MfaPendingError)
113
201
  return EXIT.auth;
114
202
  if (err instanceof TemplateBuildTimeoutError)
115
203
  return EXIT.timeout;
116
204
  if (err instanceof NotFoundError)
117
205
  return EXIT.notFound;
118
- if (err instanceof OperationTimeoutError || isNamed(err, 'TimeoutError'))
206
+ if (err instanceof OperationTimeoutError || err instanceof CheckoutTimeoutError || isNamed(err, 'TimeoutError'))
207
+ return EXIT.timeout;
208
+ if (err instanceof OperationFailedError || err instanceof ExecutionFailedError || err instanceof CheckoutEndedError)
209
+ return EXIT.operationFailed;
210
+ if (err instanceof ExecutionPendingError)
119
211
  return EXIT.timeout;
120
- if (err instanceof OperationFailedError)
212
+ // A command that could not start exits 6 in both modes, as a failed execution does (0.5.1).
213
+ if (err instanceof ExecStartError)
121
214
  return EXIT.operationFailed;
122
215
  if (err instanceof ShardfluxApiError) {
123
216
  if (AUTH_CODES.has(err.code) || err.status === 401)