@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.
package/dist/main.js CHANGED
@@ -1,19 +1,31 @@
1
1
  /**
2
- * `shard` entry: parse, build the SDK client from the environment, run the
3
- * command, report errors (stderr, with the API error code and request id) and
4
- * return the exit code. In-process and side-effect free apart from `io`, so
5
- * the whole CLI can be driven by tests; bin.ts wires it to the real process.
2
+ * `shard` entry: parse, build the SDK clients from the environment and the credentials file, run the command,
3
+ * report errors (stderr, with the API error code and request id) and return the exit code. In-process and side-effect
4
+ * free apart from `io` and the config directory, so the whole CLI can be driven by tests; `main()` wires it to the
5
+ * real process (bin.ts, and the `shardflux` npm bundle with its own identity for the version check).
6
+ *
7
+ * Credentials (0.5.0, README "Credentials file"):
8
+ * - project API key (workspace commands): SHARDFLUX_API_KEY, else the key `shard setup` saved for this API URL;
9
+ * - user session (account commands): SHARDFLUX_SESSION_TOKEN, else the session `shard auth login` saved. A rotation
10
+ * (step-up, password change, TOTP change) is saved at once; a saved session the API rejects (401) is removed.
11
+ * Every credential the command loaded is redacted from what it prints on failure.
6
12
  */
7
- import { SDK_VERSION, Shardflux } from '@shardflux/sdk';
13
+ import { hostname } from 'node:os';
14
+ import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
8
15
  import { CLI_VERSION, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
9
- import { HANDLERS } from "./commands.js";
10
- import { AuthConfigError, EXIT, InterruptedError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
11
- import { errorText, json } from "./format.js";
16
+ import { CredentialStore, configDir } from "./credentials.js";
17
+ import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
18
+ import { errorText, feedbackCommand, json, time } from "./format.js";
19
+ import { HANDLERS } from "./handlers.js";
12
20
  import { makeFetch } from "./http.js";
21
+ import { Input } from "./input.js";
13
22
  import { TimingRecorder, timingJson, timingText } from "./timing.js";
23
+ import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
14
24
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
15
25
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
16
26
  const isLabel = (s) => s.length >= 1 && s.length <= 100 && ![...s].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
27
+ const LOGIN_HINT = 'shard auth login --email <you@example.com>';
28
+ const STEP_UP_HINT = `confirm your password, then run the command again (valid 10 minutes): printf '%s\\n' "$PASSWORD" | shard auth step-up (add --code <totp> with two-factor authentication)`;
17
29
  /** A sleep that ends early when the signal aborts (so Ctrl-C never waits out a poll interval). */
18
30
  export function abortableSleep(signal) {
19
31
  return (ms) => new Promise((resolve) => {
@@ -28,7 +40,10 @@ export function abortableSleep(signal) {
28
40
  signal.addEventListener('abort', done, { once: true });
29
41
  });
30
42
  }
31
- function hintFor(info) {
43
+ function reasonOf(info) {
44
+ return typeof info.details?.reason === 'string' ? info.details.reason : undefined;
45
+ }
46
+ function hintFor(info, cred, file) {
32
47
  if (info.code === 'timeout' && info.operation_id)
33
48
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
34
49
  if (info.code === 'operation_failed' && info.retryable) {
@@ -36,21 +51,255 @@ function hintFor(info) {
36
51
  }
37
52
  if (info.code === 'interrupted' && info.operation_id)
38
53
  return `keep waiting with: shard operations wait ${info.operation_id}`;
39
- if (info.code === 'unauthenticated' && info.status === undefined)
40
- return 'export SHARDFLUX_API_KEY=<project API key> (create one in the console under Project > API keys)';
41
54
  if (info.code === 'usage')
42
55
  return 'run "shard --help" or "shard <command> --help"';
43
56
  if (info.source === 'cell' && info.code === 'workspace_not_running')
44
57
  return 'open or resume the workspace first: shard workspaces open <key> --template <slug>';
45
- return undefined;
58
+ if (info.status === 401 && info.code === 'unauthenticated') {
59
+ if (cred.sessionRejected && cred.sessionSource === 'file')
60
+ return `the saved session expired or was revoked (removed from ${file}); sign in again: shard auth login --email ${cred.email ?? '<you@example.com>'}`;
61
+ if (cred.sessionRejected && cred.sessionSource === 'env')
62
+ return `SHARDFLUX_SESSION_TOKEN expired or was revoked; sign in again (${LOGIN_HINT}) and unset it`;
63
+ if (cred.keyRejected && cred.keySource === 'file')
64
+ return `the API key saved in ${file} was revoked or expired; save a new one: shard setup --new-key`;
65
+ return undefined;
66
+ }
67
+ if (info.code === 'step_up_required')
68
+ return STEP_UP_HINT;
69
+ if (info.code === 'mfa_required')
70
+ return 'finish signing in with the code of your authenticator app: shard auth mfa --code <code>';
71
+ if (info.code === 'email_unverified')
72
+ return `verify your email first: shard auth verify-email '<link from the email>' (a new link: shard auth resend-verification)`;
73
+ if (info.code === 'conflict' && reasonOf(info) === 'subscription_exists')
74
+ return 'change plans in the billing portal: shard billing portal --open';
75
+ if (info.code === 'dependency_unavailable' && reasonOf(info) === 'billing_disabled')
76
+ return 'this API has no billing configured: plans cannot be bought here';
77
+ if (info.code === 'allowance_exhausted')
78
+ return allowanceHint(info);
79
+ const policyHint = spendPolicyHint(info);
80
+ if (policyHint)
81
+ return policyHint;
82
+ // File patches (contracts §26.2): what to do about each refusal. details.index is the 0-based edit (--old) index.
83
+ const reason = info.details?.reason;
84
+ const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
85
+ if (reason === 'revision_mismatch') {
86
+ const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
87
+ return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
88
+ }
89
+ if (reason === 'edit_not_found')
90
+ return `--old${index} does not occur in the file (it must match exactly, including whitespace)`;
91
+ if (reason === 'edit_ambiguous')
92
+ return `--old${index} occurs more than once; include surrounding text to make it unique, or pass --replace-all`;
93
+ if (reason === 'edit_not_text')
94
+ return 'the file is not UTF-8 text; use files write for binary data';
95
+ if (reason === 'host_capacity')
96
+ return 'the workspace’s host has no room to run it right now; retry in a few seconds';
97
+ if (reason === 'host_feature_unavailable')
98
+ return hostFeatureHint(info.details?.feature);
99
+ // A processful command that could not start (0.5.1): what to change, from the workspace's reason.
100
+ if (info.code === 'conflict' && reason === 'exec_failed_to_start')
101
+ return startFailureHint(typeof info.details?.error === 'string' ? info.details.error : info.message, false);
102
+ return fileFirstHint(info);
103
+ }
104
+ /**
105
+ * What to do about a command that could not start (exec_failed_to_start), from the workspace's reason: a working
106
+ * directory that is not a directory, else a program that is not on PATH. An unknown user needs no more than the reason.
107
+ */
108
+ function startFailureHint(why, fileFirst) {
109
+ if (/working directory|chdir|\bcwd\b/i.test(why)) {
110
+ return fileFirst
111
+ ? '--cwd must name a directory that exists in the workspace’s files, as an absolute path under /home/user'
112
+ : '--cwd must name a directory that exists in the workspace, as an absolute path (commands start in /home/user without --cwd)';
113
+ }
114
+ if (/unknown user/i.test(why))
115
+ return undefined;
116
+ return 'is argv[0] installed and on PATH? shell syntax needs -- bash -lc "..."';
117
+ }
118
+ /**
119
+ * 402 allowance_exhausted (0.5.1): what to do per details.reason (opt-in overage with a spend cap). An API older than
120
+ * overage sends no reason: no hint.
121
+ */
122
+ function allowanceHint(info) {
123
+ const resets = typeof info.details?.resets_at === 'string' ? ` (the period resets ${time(info.details.resets_at)})` : '';
124
+ switch (info.details?.reason) {
125
+ case 'allowance_used':
126
+ return `upgrade (shard billing plans), or turn on overage: shard billing overage on --cap <dollars>${resets}`;
127
+ case 'spend_cap_reached':
128
+ return `the spend cap is reached: raise it (shard billing overage cap <dollars>, at most the plan price) or upgrade${resets}`;
129
+ case 'overage_paused':
130
+ return 'a payment is past due: update the payment method (shard billing portal --open); overage resumes once the invoice is paid';
131
+ default:
132
+ return undefined;
133
+ }
134
+ }
135
+ /** Dollars for a hint: `9` for 900 minor units, `9.50` for 950. */
136
+ const dollars = (minor) => (typeof minor === 'number' ? (minor % 100 === 0 ? String(minor / 100) : (minor / 100).toFixed(2)) : undefined);
137
+ /**
138
+ * The spend-policy refusals (0.5.1; shard billing overage): 422 validation_failed per details.reason, with the bound the
139
+ * API names (min_minor, max_minor, charges_minor), and a 409 version_mismatch (--if-version, here or on egress set).
140
+ */
141
+ function spendPolicyHint(info) {
142
+ const d = info.details ?? {};
143
+ const reason = reasonOf(info);
144
+ if (info.code === 'conflict' && reason === 'version_mismatch') {
145
+ const now = typeof d.current_version === 'number' ? d.current_version : undefined;
146
+ return `it changed since that version${now === undefined ? '' : ` (it is at version ${now} now)`}; nothing was saved. Look at it again, then retry${now === undefined ? '' : ` with --if-version ${now}`}`;
147
+ }
148
+ if (info.code !== 'validation_failed')
149
+ return undefined;
150
+ switch (reason) {
151
+ case 'overage_unavailable':
152
+ return 'overage needs a paid plan with opt-in overage: shard billing plans, then shard billing upgrade <plan>';
153
+ case 'spend_cap_required':
154
+ return 'give a spend cap: shard billing overage on --cap <dollars> (shard billing overage shows the range)';
155
+ case 'spend_cap_below_minimum':
156
+ return `the smallest spend cap is $${dollars(d.min_minor) ?? '1'}: shard billing overage cap ${dollars(d.min_minor) ?? '1'}`;
157
+ case 'spend_cap_above_plan_price': {
158
+ const max = dollars(d.max_minor);
159
+ return `the spend cap is at most the plan price${max ? `, $${max}: shard billing overage cap ${max}` : ''}; a higher cap needs a larger plan (shard billing plans)`;
160
+ }
161
+ case 'spend_cap_below_charges': {
162
+ const charged = dollars(d.charges_minor);
163
+ return `overage already charged ${charged ? `$${charged}` : 'more than that'} this period: set a cap of at least that${charged ? ` (shard billing overage cap ${charged})` : ''}, or turn overage off (shard billing overage off)`;
164
+ }
165
+ default:
166
+ return undefined;
167
+ }
168
+ }
169
+ /**
170
+ * 409 conflict host_feature_unavailable (contracts §26.7; 0.5.0): the workspace runs on a host agent that predates the
171
+ * call. Not retryable: it lasts until the workspace runs on an upgraded host (minutes to hours), so the hint names
172
+ * another way to do it now.
173
+ */
174
+ function hostFeatureHint(feature) {
175
+ const later = 'it will once the workspace runs on an upgraded host (retrying now does not help)';
176
+ if (feature === 'file_search')
177
+ return `the workspace’s host does not support files search yet; ${later}. Search with a command instead: shard ws exec <id|key> -- grep -rn <pattern> <path>`;
178
+ if (feature === 'file_patch')
179
+ return `the workspace’s host does not support files patch yet; ${later}. Rewrite the file instead: shard files read <id|key> <path> --out F, edit F, then shard files write <id|key> <path> --from F`;
180
+ return `the workspace’s host does not support this call yet; ${later}`;
181
+ }
182
+ /** What an execution that ended failed or lost ran into (error.details.reason), where the reason alone says little. */
183
+ const EXECUTION_CAUSES = {
184
+ exec_failed_to_start: 'the command could not start (is argv[0] installed and on PATH? shell syntax needs -- bash -lc "...")',
185
+ lease_expired: 'its host stopped reporting',
186
+ host_unreachable: 'its host stopped answering',
187
+ host_restarted: 'its host restarted',
188
+ tree_moved: 'the tree changed while it ran',
189
+ no_execution_host: 'no host had room to run it',
190
+ workspace_deleted: 'the workspace was deleted',
191
+ };
192
+ /** Hints for file-first workspaces (0.5.0; contracts §29.7, §29.8): the mode refusals, tree revisions and executions. */
193
+ function fileFirstHint(info) {
194
+ const d = info.details ?? {};
195
+ const reason = d.reason;
196
+ if (info.code === 'execution_failed') {
197
+ const startHint = reason === 'exec_failed_to_start' ? startFailureHint(info.message, true) : undefined;
198
+ const cause = startHint ? `the command could not start (${startHint})` : typeof reason === 'string' ? EXECUTION_CAUSES[reason] : undefined;
199
+ return `${cause ? `${cause}; ` : ''}nothing was published (the tree is unchanged). Running it again needs a new --execution-id (omit it for a fresh one)${info.retryable ? '; a retry may succeed' : ''}.`;
200
+ }
201
+ switch (reason) {
202
+ case 'not_supported_for_mode':
203
+ if (d.mode === 'processful')
204
+ return 'executions and tree revisions exist only in file-first workspaces; open a new key with --mode file-first';
205
+ if (d.field === 'lifetime')
206
+ return 'a file-first workspace is persistent: leave out --lifetime session';
207
+ return 'this is a file-first workspace: there is no VM between executions, so suspend, resume, fork, reset, snapshot, save-as-template, changes, PTY and exec sessions do not exist. Run commands with: shard ws exec <ws> -- <command> (each runs as an execution in a fresh VM and keeps the files it changes under /home/user)';
208
+ case 'mode_mismatch': {
209
+ const has = d.mode === 'file_first' ? 'file-first' : typeof d.mode === 'string' ? d.mode : 'another';
210
+ const wants = d.requested_mode === 'file_first' ? 'file-first' : typeof d.requested_mode === 'string' ? d.requested_mode : 'differently moded';
211
+ return `this key already belongs to a ${has} workspace and a workspace’s mode never changes; use another key for a ${wants} workspace (or reopen this one without --mode)`;
212
+ }
213
+ case 'mode_not_available':
214
+ return 'this deployment does not offer file-first workspaces yet; leave out --mode file-first to open a processful workspace';
215
+ case 'layout_unsupported':
216
+ return d.mode === 'file_first' ? 'file-first workspaces run on layered template versions only; open a template version whose disk layouts include layered (shard templates get <slug> lists them)' : undefined;
217
+ case 'tree_revision_mismatch': {
218
+ const cur = d.current_tree_revision;
219
+ if (typeof cur !== 'number')
220
+ return 'the tree kept changing during the call; nothing changed. Retry';
221
+ return `the tree is at revision ${cur} now (another write or execution changed it); nothing changed. Read what you need again, then retry with --if-revision ${cur}`;
222
+ }
223
+ case 'outside_tree_root':
224
+ return `only paths under ${typeof d.tree_root === 'string' ? d.tree_root : '/home/user'} exist in a file-first workspace`;
225
+ case 'execution_in_progress': {
226
+ const id = typeof d.execution_id === 'string' ? d.execution_id : undefined;
227
+ return `another execution${id ? ` (${id})` : ''} holds the workspace and did not end within --wake-timeout; retry when it ended${id ? ` (follow it with: shard executions get <ws> ${id} --wait 10m)` : ''}`;
228
+ }
229
+ case 'execution_id_reused':
230
+ return 'this --execution-id was already used for a different request (the same id with the same request returns its recorded result); pass a new --execution-id';
231
+ case 'no_execution_host':
232
+ return 'no host had room to run the execution, also after retries with the same execution id; nothing ran. Retry later';
233
+ default:
234
+ return undefined;
235
+ }
236
+ }
237
+ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
238
+ /**
239
+ * Codes and reasons that are the expected flow rather than a problem: the caller's own input or credentials, a sign-in
240
+ * or step-up to finish, a wait that gave up while the work continues, a checkout nobody paid, and the refusals whose
241
+ * hint names the exact next step (a stale revision, an edit that does not match, a mode the workspace lacks).
242
+ */
243
+ // allowance_exhausted (0.5.1): a used-up allowance, a reached spend cap or a paused overage is a billing state whose hint
244
+ // names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
245
+ const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
246
+ const NO_NUDGE_REASONS = new Set([
247
+ 'revision_mismatch',
248
+ 'tree_revision_mismatch',
249
+ 'edit_not_found',
250
+ 'edit_ambiguous',
251
+ 'edit_not_text',
252
+ 'execution_id_reused',
253
+ 'execution_in_progress',
254
+ 'exec_failed_to_start',
255
+ 'mode_mismatch',
256
+ 'not_supported_for_mode',
257
+ 'outside_tree_root',
258
+ 'layout_unsupported',
259
+ 'host_feature_unavailable',
260
+ 'subscription_exists',
261
+ // A stale spend policy (If-Match): the hint names the version to retry with (0.5.1).
262
+ 'version_mismatch',
263
+ ]);
264
+ /**
265
+ * Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
266
+ * needs a valid key too, so never a credential failure) and the failures of `shard feedback` itself.
267
+ */
268
+ function nudges(info, command) {
269
+ if (command === undefined || command === 'feedback')
270
+ return false;
271
+ const reason = reasonOf(info);
272
+ return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
273
+ }
274
+ /** `shard feedback` could not send: how to fix it, and the way to still reach the founder. */
275
+ function feedbackHint(info, credentialHint) {
276
+ if (info.code === 'usage')
277
+ return 'run "shard feedback --help"';
278
+ if (info.code === 'interrupted')
279
+ return undefined;
280
+ if (info.code === 'unauthenticated')
281
+ return `${credentialHint ?? 'export SHARDFLUX_API_KEY=<project API key>'}; or email the feedback to ${FEEDBACK_EMAIL}`;
282
+ if (info.code === 'rate_limited') {
283
+ const s = info.details?.retry_after_seconds;
284
+ return `feedback is rate limited per API key; send it again ${typeof s === 'number' ? `in ${s} s` : 'later'}, or email it to ${FEEDBACK_EMAIL}`;
285
+ }
286
+ return `the feedback was not sent; email it to ${FEEDBACK_EMAIL} instead`;
46
287
  }
47
288
  export async function run(argv, io, opts = {}) {
48
289
  const signal = opts.signal ?? new AbortController().signal;
290
+ const distribution = opts.distribution ?? CLI_DISTRIBUTION;
49
291
  const rawKey = io.env.SHARDFLUX_API_KEY;
292
+ // Every credential this command loaded or received: redacted from anything printed on failure.
293
+ const secrets = [rawKey ?? '', io.env.SHARDFLUX_SESSION_TOKEN ?? ''].filter((s) => s.trim().length >= 8).map((s) => s.trim());
50
294
  const wantsJson = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length).includes('--json');
295
+ const cred = { keySource: null, sessionSource: null, sessionRejected: false, keyRejected: false, email: null };
296
+ const store = new CredentialStore(configDir(io.env), (m) => void io.stderr.write(`${m}\n`));
51
297
  // Every traced SDK call of the command (open, waited lifecycle calls, waits, wakes) reports here; --timing prints it.
52
298
  const traces = new TimingRecorder();
53
299
  let showTiming = false;
300
+ let apiUrlForCleanup = null;
301
+ /** The command words once parsed (the error line's hint and feedback suggestion depend on them). */
302
+ let command = undefined;
54
303
  const report = (err) => {
55
304
  const code = exitCodeFor(err, signal.aborted);
56
305
  const info = describeError(err);
@@ -58,12 +307,30 @@ export async function run(argv, io, opts = {}) {
58
307
  info.code = 'interrupted';
59
308
  info.message = 'Interrupted.';
60
309
  }
61
- info.message = redact(info.message, rawKey);
310
+ info.message = redact(info.message, ...secrets);
311
+ // A saved session the API no longer accepts is removed, so the next command says "not signed in" at once.
312
+ if (cred.sessionRejected && cred.sessionSource === 'file' && info.status === 401 && info.code === 'unauthenticated' && apiUrlForCleanup) {
313
+ try {
314
+ store.update(apiUrlForCleanup, (p) => {
315
+ delete p.session;
316
+ });
317
+ }
318
+ catch {
319
+ // The hint still says to sign in again.
320
+ }
321
+ }
322
+ // An error that knows the command to continue with (an interrupted or still running execution) brings its own hint.
323
+ const own = ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
324
+ const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file)) : (own ?? hintFor(info, cred, store.file));
62
325
  // --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
63
326
  // --json a `timing` field next to `error`.
64
327
  const timing = showTiming ? traces.forError(err) : null;
65
- const text = wantsJson ? json(showTiming ? { error: info, timing: timingJson(timing) } : { error: info }) : `${timing ? timingText(timing) : ''}${errorText(info, hintFor(info))}`;
66
- io.stderr.write(redact(text, rawKey));
328
+ // A failure suggests reporting it with `shard feedback` (text: a `feedback:` line; --json: a `feedback` field).
329
+ const feedback = nudges(info, command) ? feedbackCommand(info) : undefined;
330
+ const text = wantsJson
331
+ ? json({ error: info, ...(showTiming ? { timing: timingJson(timing) } : {}), ...(feedback ? { feedback } : {}) })
332
+ : `${timing ? timingText(timing) : ''}${errorText(info, hint, feedback)}`;
333
+ io.stderr.write(redact(text, ...secrets));
67
334
  return code;
68
335
  };
69
336
  let parsed;
@@ -81,16 +348,84 @@ export async function run(argv, io, opts = {}) {
81
348
  io.stdout.write(wantsJson ? json({ cli: CLI_VERSION, sdk: SDK_VERSION }) : `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`);
82
349
  return EXIT.ok;
83
350
  }
351
+ command = parsed.command.path.join(' ');
84
352
  let ctx;
353
+ let updates = null;
85
354
  try {
86
355
  const apiUrl = resolveApiUrl(parsed.global.apiUrl, io.env.SHARDFLUX_API_URL);
356
+ apiUrlForCleanup = apiUrl;
87
357
  const agentLabel = parsed.global.agentLabel ?? io.env.SHARDFLUX_AGENT_LABEL ?? 'cli';
88
358
  if (!isLabel(agentLabel))
89
359
  throw new UsageError('--agent-label must be 1-100 printable characters');
90
360
  const wake = resolveWake(parsed.global.wake, io.env.SHARDFLUX_NO_WAKE);
91
361
  const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
92
362
  showTiming = parsed.values.timing === true;
363
+ const userAgent = `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}${distribution.package === CLI_DISTRIBUTION.package ? '' : ` ${distribution.package}/${distribution.version}`}`;
364
+ const base = makeFetch(io.env, fetch, signal);
365
+ // Notes which credential a 401 unauthenticated answered (the hint, and removing a dead saved session).
366
+ const watchedFetch = async (input, init) => {
367
+ const res = await base(input, init);
368
+ if (res.status === 401) {
369
+ const auth = new Headers(init?.headers).get('authorization') ?? '';
370
+ const kind = auth.startsWith('Bearer sfu_') ? 'session' : auth.startsWith('Bearer sfk_') ? 'key' : null;
371
+ if (kind) {
372
+ try {
373
+ const body = (await res.clone().json());
374
+ if (body.error?.code === 'unauthenticated') {
375
+ if (kind === 'session')
376
+ cred.sessionRejected = true;
377
+ else
378
+ cred.keyRejected = true;
379
+ }
380
+ }
381
+ catch {
382
+ // Not the documented error body: nothing to note.
383
+ }
384
+ }
385
+ }
386
+ return res;
387
+ };
388
+ const sleep = abortableSleep(signal);
389
+ const accountOptions = () => ({ baseUrl: apiUrl, fetch: watchedFetch, userAgent, sleep, versionCheck: false, onProgress: traces.listener });
390
+ let envSessionNoted = false;
391
+ /** Saves a rotated token of the session in use (the previous one no longer works). */
392
+ const rotated = (s) => {
393
+ secrets.push(s.token);
394
+ if (cred.sessionSource === 'env' && !envSessionNoted) {
395
+ // The variable cannot be updated from here, and printing the token would expose it: save it instead.
396
+ envSessionNoted = true;
397
+ const replaced = store.profile(apiUrl).session !== undefined;
398
+ store.update(apiUrl, (p) => {
399
+ p.session = { token: s.token, expires_at: s.expiresAt || null, user: null, state: 'active' };
400
+ });
401
+ io.stderr.write(`note: the session was renewed; SHARDFLUX_SESSION_TOKEN no longer works. The new session is saved in ${store.file}${replaced ? ' (replacing the one saved there)' : ''}: unset SHARDFLUX_SESSION_TOKEN to use it.\n`);
402
+ return;
403
+ }
404
+ store.update(apiUrl, (p) => {
405
+ p.session = { ...(p.session ?? { user: null, state: 'active' }), token: s.token, expires_at: s.expiresAt || p.session?.expires_at || null };
406
+ });
407
+ };
408
+ /** The project API key: SHARDFLUX_API_KEY, else the key saved for this API URL. */
409
+ const apiKey = () => {
410
+ const env = rawKey?.trim();
411
+ if (env) {
412
+ cred.keySource = 'env';
413
+ if (!KEY_SHAPE.test(env))
414
+ throw new AuthConfigError('SHARDFLUX_API_KEY is not a Shardflux project key (expected sfk_<key id>_<secret>).');
415
+ return { token: env, source: 'env' };
416
+ }
417
+ const saved = store.profile(apiUrl).api_key;
418
+ if (!saved) {
419
+ throw new AuthConfigError('SHARDFLUX_API_KEY is not set.', `sign in and save a key: ${LOGIN_HINT}, then shard setup (or export SHARDFLUX_API_KEY=<project API key>)`);
420
+ }
421
+ cred.keySource = 'file';
422
+ secrets.push(saved.token);
423
+ if (!KEY_SHAPE.test(saved.token))
424
+ throw new AuthConfigError(`The API key saved in ${store.file} is not a Shardflux project key.`, 'save a new one: shard setup --new-key');
425
+ return { token: saved.token, source: 'file', saved };
426
+ };
93
427
  let cloud;
428
+ let account;
94
429
  ctx = {
95
430
  io,
96
431
  json: parsed.global.json,
@@ -101,25 +436,106 @@ export async function run(argv, io, opts = {}) {
101
436
  signal,
102
437
  timing: showTiming,
103
438
  traces,
439
+ store,
440
+ input: new Input(io.stdin, io.stderr),
441
+ distribution,
442
+ hostname: () => {
443
+ try {
444
+ return hostname();
445
+ }
446
+ catch {
447
+ return 'unknown host';
448
+ }
449
+ },
104
450
  cloud() {
105
451
  if (cloud)
106
452
  return cloud;
107
- const key = rawKey?.trim();
108
- if (!key)
109
- throw new AuthConfigError('SHARDFLUX_API_KEY is not set.');
110
- if (!KEY_SHAPE.test(key))
111
- throw new AuthConfigError('SHARDFLUX_API_KEY is not a Shardflux project key (expected sfk_<key id>_<secret>).');
453
+ const key = apiKey();
112
454
  cloud = new Shardflux({
113
- apiKey: key,
455
+ apiKey: key.token,
114
456
  baseUrl: apiUrl,
115
- userAgent: `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
116
- fetch: makeFetch(io.env, fetch, signal),
117
- sleep: abortableSleep(signal),
457
+ userAgent,
458
+ fetch: watchedFetch,
459
+ sleep,
118
460
  onProgress: traces.listener,
461
+ versionCheck: false,
119
462
  });
120
463
  return cloud;
121
464
  },
465
+ apiKey,
466
+ account() {
467
+ if (account)
468
+ return account;
469
+ const env = io.env.SHARDFLUX_SESSION_TOKEN?.trim();
470
+ let token;
471
+ if (env) {
472
+ cred.sessionSource = 'env';
473
+ if (!isSessionToken(env))
474
+ throw new AuthConfigError('SHARDFLUX_SESSION_TOKEN is not a Shardflux session token (expected sfu_ and 43 characters).', `unset it, or sign in: ${LOGIN_HINT}`);
475
+ token = env;
476
+ }
477
+ else {
478
+ const saved = store.profile(apiUrl).session;
479
+ if (!saved)
480
+ throw new AuthConfigError(`Not signed in to ${apiUrl}.`, `sign in: ${LOGIN_HINT} (new here? shard auth register --email <you@example.com>)`);
481
+ cred.sessionSource = 'file';
482
+ cred.email = saved.user?.email ?? null;
483
+ secrets.push(saved.token);
484
+ if (!isSessionToken(saved.token))
485
+ throw new AuthConfigError(`The session saved in ${store.file} is malformed.`, `sign in again: ${LOGIN_HINT}`);
486
+ token = saved.token;
487
+ }
488
+ account = new ShardfluxAccount({ ...accountOptions(), sessionToken: token, onSessionToken: rotated });
489
+ return account;
490
+ },
491
+ sessionSource() {
492
+ if (io.env.SHARDFLUX_SESSION_TOKEN?.trim())
493
+ return 'env';
494
+ return store.profile(apiUrl).session ? 'file' : null;
495
+ },
496
+ accountOptions,
497
+ saveSession(session, email) {
498
+ secrets.push(session.token);
499
+ const previous = store.profile(apiUrl);
500
+ const before = previous.session?.user;
501
+ // Another person: a different user id, or (a sign-in still waiting for its code) a different email.
502
+ const switched = before != null && ((session.user !== null && before.id !== session.user.id) || (email !== undefined && before.email.toLowerCase() !== email.toLowerCase()));
503
+ store.update(apiUrl, (p) => {
504
+ p.session = session;
505
+ if (switched) {
506
+ // Another person's defaults and key must not follow the new user.
507
+ delete p.organization_id;
508
+ delete p.project_id;
509
+ delete p.api_key;
510
+ }
511
+ });
512
+ return { replaced: previous.session ?? null, switched };
513
+ },
514
+ addSecret(value) {
515
+ if (value.length >= 8)
516
+ secrets.push(value);
517
+ },
518
+ async versionStatus(force) {
519
+ const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
520
+ if (!force && updateCheckDisabled(io.env)) {
521
+ const cached = check.peek();
522
+ return cached ? updateView(cached.status, cached.checkedAt) : null;
523
+ }
524
+ check.start(force);
525
+ const r = await check.result(5_000);
526
+ return r ? updateView(r.status, r.checkedAt) : null;
527
+ },
122
528
  };
529
+ // The update check runs next to the command (not for `version`, which reports it itself).
530
+ if (!updateCheckDisabled(io.env) && parsed.command.path[0] !== 'version') {
531
+ try {
532
+ updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
533
+ updates.start();
534
+ }
535
+ catch {
536
+ updates = null;
537
+ }
538
+ }
123
539
  }
124
540
  catch (err) {
125
541
  return report(err);
@@ -137,12 +553,42 @@ export async function run(argv, io, opts = {}) {
137
553
  signal.addEventListener('abort', onAbort, { once: true });
138
554
  });
139
555
  const outcome = await Promise.race([running, interrupted]);
556
+ let code;
140
557
  if ('interrupted' in outcome)
141
- return report(new InterruptedError('Interrupted.'));
142
- if ('err' in outcome)
143
- return report(outcome.err);
144
- // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
145
- if (showTiming && !ctx.json && traces.last)
146
- io.stderr.write(timingText(traces.last));
147
- return outcome.code;
558
+ code = report(new InterruptedError('Interrupted.'));
559
+ else if ('err' in outcome)
560
+ code = report(outcome.err);
561
+ else {
562
+ // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
563
+ if (showTiming && !ctx.json && traces.last)
564
+ io.stderr.write(timingText(traces.last));
565
+ code = outcome.code;
566
+ }
567
+ // The update notice: after the output, never after Ctrl-C or a --json failure (stderr then holds only the error).
568
+ if (updates && code !== EXIT.interrupted && !(ctx.json && code !== EXIT.ok)) {
569
+ const r = await updates.result(NOTICE_WAIT_MS);
570
+ const notice = noticeOf(r?.status);
571
+ if (notice)
572
+ io.stderr.write(notice);
573
+ }
574
+ return code;
575
+ }
576
+ /**
577
+ * Runs `shard` as the process: argv, stdio and environment of this process. Ctrl-C aborts in-flight waits and
578
+ * streams cleanly (exit 130); a second Ctrl-C exits at once. Output is flushed before exiting (stdout/stderr pipes
579
+ * are asynchronous on macOS).
580
+ */
581
+ export async function main(opts = {}) {
582
+ const controller = new AbortController();
583
+ let interrupts = 0;
584
+ process.on('SIGINT', () => {
585
+ interrupts += 1;
586
+ if (interrupts > 1)
587
+ process.exit(130);
588
+ controller.abort(new Error('interrupted'));
589
+ });
590
+ const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
591
+ const code = await run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env }, { signal: controller.signal, ...(opts.distribution ? { distribution: opts.distribution } : {}) });
592
+ await Promise.all([flush(process.stdout), flush(process.stderr)]);
593
+ process.exit(code);
148
594
  }
@@ -0,0 +1,64 @@
1
+ import type { ClientVersionStatus } from '@shardflux/sdk';
2
+ export declare const UPDATE_CHECK_FILE = "update-check.json";
3
+ export declare const CHECK_INTERVAL_MS: number;
4
+ /** How long the end of a command waits for a check that is still running. */
5
+ export declare const NOTICE_WAIT_MS = 300;
6
+ /** What runs the CLI: `@shardflux/cli` itself, or a package that embeds it (the `shardflux` bundle). */
7
+ export interface Distribution {
8
+ package: string;
9
+ version: string;
10
+ }
11
+ export declare const CLI_DISTRIBUTION: Distribution;
12
+ interface CacheEntry {
13
+ checked_at: string;
14
+ latest: string | null;
15
+ minimum_supported: string | null;
16
+ upgrade_command: string | null;
17
+ release_notes_url: string | null;
18
+ }
19
+ /** The `update` object of `shard version --json`. */
20
+ export interface UpdateView {
21
+ package: string;
22
+ current: string;
23
+ status: ClientVersionStatus['status'];
24
+ latest: string | null;
25
+ minimum_supported: string | null;
26
+ upgrade_command: string | null;
27
+ /** When the API was last asked (null: never, from this config directory). */
28
+ checked_at: string | null;
29
+ }
30
+ export declare function updateCheckDisabled(env: Record<string, string | undefined>): boolean;
31
+ /** The status the cached answer gives this distribution (the same rules as the SDK; no request). */
32
+ export declare function statusFromEntry(entry: Pick<CacheEntry, 'latest' | 'minimum_supported' | 'upgrade_command' | 'release_notes_url'> | null, d: Distribution): ClientVersionStatus;
33
+ export declare function updateView(s: ClientVersionStatus, checkedAt: string | null): UpdateView;
34
+ export interface UpdateCheckOptions {
35
+ dir: string;
36
+ apiUrl: string;
37
+ distribution: Distribution;
38
+ fetch: typeof fetch;
39
+ userAgent: string;
40
+ now?: () => number;
41
+ }
42
+ /**
43
+ * One command's check: `start()` returns at once (a fresh cache answers without a request); `result(waitMs)` is the
44
+ * status, or null when the request has not finished within `waitMs`.
45
+ */
46
+ export declare class UpdateCheck {
47
+ #private;
48
+ constructor(opts: UpdateCheckOptions);
49
+ /** The cached answer, whatever its age (no request). */
50
+ peek(): {
51
+ status: ClientVersionStatus;
52
+ checkedAt: string | null;
53
+ } | null;
54
+ /** Starts the check: from the cache when it is younger than 24 h (unless `force`), else one request. */
55
+ start(force?: boolean): void;
56
+ /** The status (with when it was fetched), or null when not started or still running after `waitMs`. */
57
+ result(waitMs: number): Promise<{
58
+ status: ClientVersionStatus;
59
+ checkedAt: string | null;
60
+ } | null>;
61
+ }
62
+ /** The one-line notice for stderr, or null when there is nothing to say. */
63
+ export declare function noticeOf(s: ClientVersionStatus | undefined | null): string | null;
64
+ export {};