@shardflux/cli 0.4.0 → 0.5.0
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/CHANGELOG.md +181 -1
- package/README.md +442 -34
- package/dist/account-args.d.ts +13 -0
- package/dist/account-args.js +413 -0
- package/dist/account-commands.d.ts +16 -0
- package/dist/account-commands.js +1616 -0
- package/dist/args.d.ts +15 -3
- package/dist/args.js +280 -40
- package/dist/bin.js +5 -16
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +39 -0
- package/dist/commands.d.ts +67 -5
- package/dist/commands.js +390 -29
- package/dist/credentials.d.ts +77 -0
- package/dist/credentials.js +217 -0
- package/dist/errors.d.ts +63 -11
- package/dist/errors.js +107 -21
- package/dist/format.d.ts +70 -5
- package/dist/format.js +365 -6
- package/dist/handlers.d.ts +2 -0
- package/dist/handlers.js +7 -0
- package/dist/index.d.ts +19 -7
- package/dist/index.js +13 -6
- package/dist/input.d.ts +25 -0
- package/dist/input.js +167 -0
- package/dist/main.d.ts +16 -2
- package/dist/main.js +399 -31
- package/dist/update-check.d.ts +64 -0
- package/dist/update-check.js +128 -0
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `shard` entry: parse, build the SDK
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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 {
|
|
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 } 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
|
|
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,177 @@ 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
|
-
|
|
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
|
+
// File patches (contracts §26.2): what to do about each refusal. details.index is the 0-based edit (--old) index.
|
|
78
|
+
const reason = info.details?.reason;
|
|
79
|
+
const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
|
|
80
|
+
if (reason === 'revision_mismatch') {
|
|
81
|
+
const current = typeof info.details?.current_revision === 'string' ? info.details.current_revision : 'unknown';
|
|
82
|
+
return `the file changed since that revision (current revision: ${current}); read it again, then retry with --expected-revision ${current}`;
|
|
83
|
+
}
|
|
84
|
+
if (reason === 'edit_not_found')
|
|
85
|
+
return `--old${index} does not occur in the file (it must match exactly, including whitespace)`;
|
|
86
|
+
if (reason === 'edit_ambiguous')
|
|
87
|
+
return `--old${index} occurs more than once; include surrounding text to make it unique, or pass --replace-all`;
|
|
88
|
+
if (reason === 'edit_not_text')
|
|
89
|
+
return 'the file is not UTF-8 text; use files write for binary data';
|
|
90
|
+
if (reason === 'host_capacity')
|
|
91
|
+
return 'the workspace’s host has no room to run it right now; retry in a few seconds';
|
|
92
|
+
if (reason === 'host_feature_unavailable')
|
|
93
|
+
return hostFeatureHint(info.details?.feature);
|
|
94
|
+
return fileFirstHint(info);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 409 conflict host_feature_unavailable (contracts §26.7; 0.5.0): the workspace runs on a host agent that predates the
|
|
98
|
+
* call. Not retryable: it lasts until the workspace runs on an upgraded host (minutes to hours), so the hint names
|
|
99
|
+
* another way to do it now.
|
|
100
|
+
*/
|
|
101
|
+
function hostFeatureHint(feature) {
|
|
102
|
+
const later = 'it will once the workspace runs on an upgraded host (retrying now does not help)';
|
|
103
|
+
if (feature === 'file_search')
|
|
104
|
+
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>`;
|
|
105
|
+
if (feature === 'file_patch')
|
|
106
|
+
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`;
|
|
107
|
+
return `the workspace’s host does not support this call yet; ${later}`;
|
|
108
|
+
}
|
|
109
|
+
/** What an execution that ended failed or lost ran into (error.details.reason), where the reason alone says little. */
|
|
110
|
+
const EXECUTION_CAUSES = {
|
|
111
|
+
exec_failed_to_start: 'the command could not start (is argv[0] installed and on PATH? shell syntax needs -- bash -lc "...")',
|
|
112
|
+
lease_expired: 'its host stopped reporting',
|
|
113
|
+
host_unreachable: 'its host stopped answering',
|
|
114
|
+
host_restarted: 'its host restarted',
|
|
115
|
+
tree_moved: 'the tree changed while it ran',
|
|
116
|
+
no_execution_host: 'no host had room to run it',
|
|
117
|
+
workspace_deleted: 'the workspace was deleted',
|
|
118
|
+
};
|
|
119
|
+
/** Hints for file-first workspaces (0.5.0; contracts §29.7, §29.8): the mode refusals, tree revisions and executions. */
|
|
120
|
+
function fileFirstHint(info) {
|
|
121
|
+
const d = info.details ?? {};
|
|
122
|
+
const reason = d.reason;
|
|
123
|
+
if (info.code === 'execution_failed') {
|
|
124
|
+
const cause = typeof reason === 'string' ? EXECUTION_CAUSES[reason] : undefined;
|
|
125
|
+
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' : ''}.`;
|
|
126
|
+
}
|
|
127
|
+
switch (reason) {
|
|
128
|
+
case 'not_supported_for_mode':
|
|
129
|
+
if (d.mode === 'processful')
|
|
130
|
+
return 'executions and tree revisions exist only in file-first workspaces; open a new key with --mode file-first';
|
|
131
|
+
if (d.field === 'lifetime')
|
|
132
|
+
return 'a file-first workspace is persistent: leave out --lifetime session';
|
|
133
|
+
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)';
|
|
134
|
+
case 'mode_mismatch': {
|
|
135
|
+
const has = d.mode === 'file_first' ? 'file-first' : typeof d.mode === 'string' ? d.mode : 'another';
|
|
136
|
+
const wants = d.requested_mode === 'file_first' ? 'file-first' : typeof d.requested_mode === 'string' ? d.requested_mode : 'differently moded';
|
|
137
|
+
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)`;
|
|
138
|
+
}
|
|
139
|
+
case 'mode_not_available':
|
|
140
|
+
return 'this deployment does not offer file-first workspaces yet; leave out --mode file-first to open a processful workspace';
|
|
141
|
+
case 'layout_unsupported':
|
|
142
|
+
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;
|
|
143
|
+
case 'tree_revision_mismatch': {
|
|
144
|
+
const cur = d.current_tree_revision;
|
|
145
|
+
if (typeof cur !== 'number')
|
|
146
|
+
return 'the tree kept changing during the call; nothing changed. Retry';
|
|
147
|
+
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}`;
|
|
148
|
+
}
|
|
149
|
+
case 'outside_tree_root':
|
|
150
|
+
return `only paths under ${typeof d.tree_root === 'string' ? d.tree_root : '/home/user'} exist in a file-first workspace`;
|
|
151
|
+
case 'execution_in_progress': {
|
|
152
|
+
const id = typeof d.execution_id === 'string' ? d.execution_id : undefined;
|
|
153
|
+
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)` : ''}`;
|
|
154
|
+
}
|
|
155
|
+
case 'execution_id_reused':
|
|
156
|
+
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';
|
|
157
|
+
case 'no_execution_host':
|
|
158
|
+
return 'no host had room to run the execution, also after retries with the same execution id; nothing ran. Retry later';
|
|
159
|
+
default:
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
|
|
164
|
+
/**
|
|
165
|
+
* Codes and reasons that are the expected flow rather than a problem: the caller's own input or credentials, a sign-in
|
|
166
|
+
* or step-up to finish, a wait that gave up while the work continues, a checkout nobody paid, and the refusals whose
|
|
167
|
+
* hint names the exact next step (a stale revision, an edit that does not match, a mode the workspace lacks).
|
|
168
|
+
*/
|
|
169
|
+
const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended']);
|
|
170
|
+
const NO_NUDGE_REASONS = new Set([
|
|
171
|
+
'revision_mismatch',
|
|
172
|
+
'tree_revision_mismatch',
|
|
173
|
+
'edit_not_found',
|
|
174
|
+
'edit_ambiguous',
|
|
175
|
+
'edit_not_text',
|
|
176
|
+
'execution_id_reused',
|
|
177
|
+
'execution_in_progress',
|
|
178
|
+
'exec_failed_to_start',
|
|
179
|
+
'mode_mismatch',
|
|
180
|
+
'not_supported_for_mode',
|
|
181
|
+
'outside_tree_root',
|
|
182
|
+
'layout_unsupported',
|
|
183
|
+
'host_feature_unavailable',
|
|
184
|
+
'subscription_exists',
|
|
185
|
+
]);
|
|
186
|
+
/**
|
|
187
|
+
* Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
|
|
188
|
+
* needs a valid key too, so never a credential failure) and the failures of `shard feedback` itself.
|
|
189
|
+
*/
|
|
190
|
+
function nudges(info, command) {
|
|
191
|
+
if (command === undefined || command === 'feedback')
|
|
192
|
+
return false;
|
|
193
|
+
const reason = reasonOf(info);
|
|
194
|
+
return !NO_NUDGE_CODES.has(info.code) && !(reason !== undefined && NO_NUDGE_REASONS.has(reason));
|
|
195
|
+
}
|
|
196
|
+
/** `shard feedback` could not send: how to fix it, and the way to still reach the founder. */
|
|
197
|
+
function feedbackHint(info, credentialHint) {
|
|
198
|
+
if (info.code === 'usage')
|
|
199
|
+
return 'run "shard feedback --help"';
|
|
200
|
+
if (info.code === 'interrupted')
|
|
201
|
+
return undefined;
|
|
202
|
+
if (info.code === 'unauthenticated')
|
|
203
|
+
return `${credentialHint ?? 'export SHARDFLUX_API_KEY=<project API key>'}; or email the feedback to ${FEEDBACK_EMAIL}`;
|
|
204
|
+
if (info.code === 'rate_limited') {
|
|
205
|
+
const s = info.details?.retry_after_seconds;
|
|
206
|
+
return `feedback is rate limited per API key; send it again ${typeof s === 'number' ? `in ${s} s` : 'later'}, or email it to ${FEEDBACK_EMAIL}`;
|
|
207
|
+
}
|
|
208
|
+
return `the feedback was not sent; email it to ${FEEDBACK_EMAIL} instead`;
|
|
46
209
|
}
|
|
47
210
|
export async function run(argv, io, opts = {}) {
|
|
48
211
|
const signal = opts.signal ?? new AbortController().signal;
|
|
212
|
+
const distribution = opts.distribution ?? CLI_DISTRIBUTION;
|
|
49
213
|
const rawKey = io.env.SHARDFLUX_API_KEY;
|
|
214
|
+
// Every credential this command loaded or received: redacted from anything printed on failure.
|
|
215
|
+
const secrets = [rawKey ?? '', io.env.SHARDFLUX_SESSION_TOKEN ?? ''].filter((s) => s.trim().length >= 8).map((s) => s.trim());
|
|
50
216
|
const wantsJson = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length).includes('--json');
|
|
217
|
+
const cred = { keySource: null, sessionSource: null, sessionRejected: false, keyRejected: false, email: null };
|
|
218
|
+
const store = new CredentialStore(configDir(io.env), (m) => void io.stderr.write(`${m}\n`));
|
|
51
219
|
// Every traced SDK call of the command (open, waited lifecycle calls, waits, wakes) reports here; --timing prints it.
|
|
52
220
|
const traces = new TimingRecorder();
|
|
53
221
|
let showTiming = false;
|
|
222
|
+
let apiUrlForCleanup = null;
|
|
223
|
+
/** The command words once parsed (the error line's hint and feedback suggestion depend on them). */
|
|
224
|
+
let command = undefined;
|
|
54
225
|
const report = (err) => {
|
|
55
226
|
const code = exitCodeFor(err, signal.aborted);
|
|
56
227
|
const info = describeError(err);
|
|
@@ -58,12 +229,30 @@ export async function run(argv, io, opts = {}) {
|
|
|
58
229
|
info.code = 'interrupted';
|
|
59
230
|
info.message = 'Interrupted.';
|
|
60
231
|
}
|
|
61
|
-
info.message = redact(info.message,
|
|
232
|
+
info.message = redact(info.message, ...secrets);
|
|
233
|
+
// A saved session the API no longer accepts is removed, so the next command says "not signed in" at once.
|
|
234
|
+
if (cred.sessionRejected && cred.sessionSource === 'file' && info.status === 401 && info.code === 'unauthenticated' && apiUrlForCleanup) {
|
|
235
|
+
try {
|
|
236
|
+
store.update(apiUrlForCleanup, (p) => {
|
|
237
|
+
delete p.session;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// The hint still says to sign in again.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// An error that knows the command to continue with (an interrupted or still running execution) brings its own hint.
|
|
245
|
+
const own = ownHint(err) ?? ((err instanceof InterruptedError || err instanceof ExecutionPendingError) && err.hint ? err.hint : undefined);
|
|
246
|
+
const hint = command === 'feedback' ? feedbackHint(info, own ?? hintFor(info, cred, store.file)) : (own ?? hintFor(info, cred, store.file));
|
|
62
247
|
// --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
|
|
63
248
|
// --json a `timing` field next to `error`.
|
|
64
249
|
const timing = showTiming ? traces.forError(err) : null;
|
|
65
|
-
|
|
66
|
-
|
|
250
|
+
// A failure suggests reporting it with `shard feedback` (text: a `feedback:` line; --json: a `feedback` field).
|
|
251
|
+
const feedback = nudges(info, command) ? feedbackCommand(info) : undefined;
|
|
252
|
+
const text = wantsJson
|
|
253
|
+
? json({ error: info, ...(showTiming ? { timing: timingJson(timing) } : {}), ...(feedback ? { feedback } : {}) })
|
|
254
|
+
: `${timing ? timingText(timing) : ''}${errorText(info, hint, feedback)}`;
|
|
255
|
+
io.stderr.write(redact(text, ...secrets));
|
|
67
256
|
return code;
|
|
68
257
|
};
|
|
69
258
|
let parsed;
|
|
@@ -81,16 +270,84 @@ export async function run(argv, io, opts = {}) {
|
|
|
81
270
|
io.stdout.write(wantsJson ? json({ cli: CLI_VERSION, sdk: SDK_VERSION }) : `shard ${CLI_VERSION} (@shardflux/sdk ${SDK_VERSION})\n`);
|
|
82
271
|
return EXIT.ok;
|
|
83
272
|
}
|
|
273
|
+
command = parsed.command.path.join(' ');
|
|
84
274
|
let ctx;
|
|
275
|
+
let updates = null;
|
|
85
276
|
try {
|
|
86
277
|
const apiUrl = resolveApiUrl(parsed.global.apiUrl, io.env.SHARDFLUX_API_URL);
|
|
278
|
+
apiUrlForCleanup = apiUrl;
|
|
87
279
|
const agentLabel = parsed.global.agentLabel ?? io.env.SHARDFLUX_AGENT_LABEL ?? 'cli';
|
|
88
280
|
if (!isLabel(agentLabel))
|
|
89
281
|
throw new UsageError('--agent-label must be 1-100 printable characters');
|
|
90
282
|
const wake = resolveWake(parsed.global.wake, io.env.SHARDFLUX_NO_WAKE);
|
|
91
283
|
const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
|
|
92
284
|
showTiming = parsed.values.timing === true;
|
|
285
|
+
const userAgent = `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}${distribution.package === CLI_DISTRIBUTION.package ? '' : ` ${distribution.package}/${distribution.version}`}`;
|
|
286
|
+
const base = makeFetch(io.env, fetch, signal);
|
|
287
|
+
// Notes which credential a 401 unauthenticated answered (the hint, and removing a dead saved session).
|
|
288
|
+
const watchedFetch = async (input, init) => {
|
|
289
|
+
const res = await base(input, init);
|
|
290
|
+
if (res.status === 401) {
|
|
291
|
+
const auth = new Headers(init?.headers).get('authorization') ?? '';
|
|
292
|
+
const kind = auth.startsWith('Bearer sfu_') ? 'session' : auth.startsWith('Bearer sfk_') ? 'key' : null;
|
|
293
|
+
if (kind) {
|
|
294
|
+
try {
|
|
295
|
+
const body = (await res.clone().json());
|
|
296
|
+
if (body.error?.code === 'unauthenticated') {
|
|
297
|
+
if (kind === 'session')
|
|
298
|
+
cred.sessionRejected = true;
|
|
299
|
+
else
|
|
300
|
+
cred.keyRejected = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Not the documented error body: nothing to note.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return res;
|
|
309
|
+
};
|
|
310
|
+
const sleep = abortableSleep(signal);
|
|
311
|
+
const accountOptions = () => ({ baseUrl: apiUrl, fetch: watchedFetch, userAgent, sleep, versionCheck: false, onProgress: traces.listener });
|
|
312
|
+
let envSessionNoted = false;
|
|
313
|
+
/** Saves a rotated token of the session in use (the previous one no longer works). */
|
|
314
|
+
const rotated = (s) => {
|
|
315
|
+
secrets.push(s.token);
|
|
316
|
+
if (cred.sessionSource === 'env' && !envSessionNoted) {
|
|
317
|
+
// The variable cannot be updated from here, and printing the token would expose it: save it instead.
|
|
318
|
+
envSessionNoted = true;
|
|
319
|
+
const replaced = store.profile(apiUrl).session !== undefined;
|
|
320
|
+
store.update(apiUrl, (p) => {
|
|
321
|
+
p.session = { token: s.token, expires_at: s.expiresAt || null, user: null, state: 'active' };
|
|
322
|
+
});
|
|
323
|
+
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`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
store.update(apiUrl, (p) => {
|
|
327
|
+
p.session = { ...(p.session ?? { user: null, state: 'active' }), token: s.token, expires_at: s.expiresAt || p.session?.expires_at || null };
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
/** The project API key: SHARDFLUX_API_KEY, else the key saved for this API URL. */
|
|
331
|
+
const apiKey = () => {
|
|
332
|
+
const env = rawKey?.trim();
|
|
333
|
+
if (env) {
|
|
334
|
+
cred.keySource = 'env';
|
|
335
|
+
if (!KEY_SHAPE.test(env))
|
|
336
|
+
throw new AuthConfigError('SHARDFLUX_API_KEY is not a Shardflux project key (expected sfk_<key id>_<secret>).');
|
|
337
|
+
return { token: env, source: 'env' };
|
|
338
|
+
}
|
|
339
|
+
const saved = store.profile(apiUrl).api_key;
|
|
340
|
+
if (!saved) {
|
|
341
|
+
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>)`);
|
|
342
|
+
}
|
|
343
|
+
cred.keySource = 'file';
|
|
344
|
+
secrets.push(saved.token);
|
|
345
|
+
if (!KEY_SHAPE.test(saved.token))
|
|
346
|
+
throw new AuthConfigError(`The API key saved in ${store.file} is not a Shardflux project key.`, 'save a new one: shard setup --new-key');
|
|
347
|
+
return { token: saved.token, source: 'file', saved };
|
|
348
|
+
};
|
|
93
349
|
let cloud;
|
|
350
|
+
let account;
|
|
94
351
|
ctx = {
|
|
95
352
|
io,
|
|
96
353
|
json: parsed.global.json,
|
|
@@ -101,25 +358,106 @@ export async function run(argv, io, opts = {}) {
|
|
|
101
358
|
signal,
|
|
102
359
|
timing: showTiming,
|
|
103
360
|
traces,
|
|
361
|
+
store,
|
|
362
|
+
input: new Input(io.stdin, io.stderr),
|
|
363
|
+
distribution,
|
|
364
|
+
hostname: () => {
|
|
365
|
+
try {
|
|
366
|
+
return hostname();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return 'unknown host';
|
|
370
|
+
}
|
|
371
|
+
},
|
|
104
372
|
cloud() {
|
|
105
373
|
if (cloud)
|
|
106
374
|
return cloud;
|
|
107
|
-
const key =
|
|
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>).');
|
|
375
|
+
const key = apiKey();
|
|
112
376
|
cloud = new Shardflux({
|
|
113
|
-
apiKey: key,
|
|
377
|
+
apiKey: key.token,
|
|
114
378
|
baseUrl: apiUrl,
|
|
115
|
-
userAgent
|
|
116
|
-
fetch:
|
|
117
|
-
sleep
|
|
379
|
+
userAgent,
|
|
380
|
+
fetch: watchedFetch,
|
|
381
|
+
sleep,
|
|
118
382
|
onProgress: traces.listener,
|
|
383
|
+
versionCheck: false,
|
|
119
384
|
});
|
|
120
385
|
return cloud;
|
|
121
386
|
},
|
|
387
|
+
apiKey,
|
|
388
|
+
account() {
|
|
389
|
+
if (account)
|
|
390
|
+
return account;
|
|
391
|
+
const env = io.env.SHARDFLUX_SESSION_TOKEN?.trim();
|
|
392
|
+
let token;
|
|
393
|
+
if (env) {
|
|
394
|
+
cred.sessionSource = 'env';
|
|
395
|
+
if (!isSessionToken(env))
|
|
396
|
+
throw new AuthConfigError('SHARDFLUX_SESSION_TOKEN is not a Shardflux session token (expected sfu_ and 43 characters).', `unset it, or sign in: ${LOGIN_HINT}`);
|
|
397
|
+
token = env;
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
const saved = store.profile(apiUrl).session;
|
|
401
|
+
if (!saved)
|
|
402
|
+
throw new AuthConfigError(`Not signed in to ${apiUrl}.`, `sign in: ${LOGIN_HINT} (new here? shard auth register --email <you@example.com>)`);
|
|
403
|
+
cred.sessionSource = 'file';
|
|
404
|
+
cred.email = saved.user?.email ?? null;
|
|
405
|
+
secrets.push(saved.token);
|
|
406
|
+
if (!isSessionToken(saved.token))
|
|
407
|
+
throw new AuthConfigError(`The session saved in ${store.file} is malformed.`, `sign in again: ${LOGIN_HINT}`);
|
|
408
|
+
token = saved.token;
|
|
409
|
+
}
|
|
410
|
+
account = new ShardfluxAccount({ ...accountOptions(), sessionToken: token, onSessionToken: rotated });
|
|
411
|
+
return account;
|
|
412
|
+
},
|
|
413
|
+
sessionSource() {
|
|
414
|
+
if (io.env.SHARDFLUX_SESSION_TOKEN?.trim())
|
|
415
|
+
return 'env';
|
|
416
|
+
return store.profile(apiUrl).session ? 'file' : null;
|
|
417
|
+
},
|
|
418
|
+
accountOptions,
|
|
419
|
+
saveSession(session, email) {
|
|
420
|
+
secrets.push(session.token);
|
|
421
|
+
const previous = store.profile(apiUrl);
|
|
422
|
+
const before = previous.session?.user;
|
|
423
|
+
// Another person: a different user id, or (a sign-in still waiting for its code) a different email.
|
|
424
|
+
const switched = before != null && ((session.user !== null && before.id !== session.user.id) || (email !== undefined && before.email.toLowerCase() !== email.toLowerCase()));
|
|
425
|
+
store.update(apiUrl, (p) => {
|
|
426
|
+
p.session = session;
|
|
427
|
+
if (switched) {
|
|
428
|
+
// Another person's defaults and key must not follow the new user.
|
|
429
|
+
delete p.organization_id;
|
|
430
|
+
delete p.project_id;
|
|
431
|
+
delete p.api_key;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return { replaced: previous.session ?? null, switched };
|
|
435
|
+
},
|
|
436
|
+
addSecret(value) {
|
|
437
|
+
if (value.length >= 8)
|
|
438
|
+
secrets.push(value);
|
|
439
|
+
},
|
|
440
|
+
async versionStatus(force) {
|
|
441
|
+
const check = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
442
|
+
if (!force && updateCheckDisabled(io.env)) {
|
|
443
|
+
const cached = check.peek();
|
|
444
|
+
return cached ? updateView(cached.status, cached.checkedAt) : null;
|
|
445
|
+
}
|
|
446
|
+
check.start(force);
|
|
447
|
+
const r = await check.result(5_000);
|
|
448
|
+
return r ? updateView(r.status, r.checkedAt) : null;
|
|
449
|
+
},
|
|
122
450
|
};
|
|
451
|
+
// The update check runs next to the command (not for `version`, which reports it itself).
|
|
452
|
+
if (!updateCheckDisabled(io.env) && parsed.command.path[0] !== 'version') {
|
|
453
|
+
try {
|
|
454
|
+
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
455
|
+
updates.start();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
updates = null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
123
461
|
}
|
|
124
462
|
catch (err) {
|
|
125
463
|
return report(err);
|
|
@@ -137,12 +475,42 @@ export async function run(argv, io, opts = {}) {
|
|
|
137
475
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
138
476
|
});
|
|
139
477
|
const outcome = await Promise.race([running, interrupted]);
|
|
478
|
+
let code;
|
|
140
479
|
if ('interrupted' in outcome)
|
|
141
|
-
|
|
142
|
-
if ('err' in outcome)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
480
|
+
code = report(new InterruptedError('Interrupted.'));
|
|
481
|
+
else if ('err' in outcome)
|
|
482
|
+
code = report(outcome.err);
|
|
483
|
+
else {
|
|
484
|
+
// With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
|
|
485
|
+
if (showTiming && !ctx.json && traces.last)
|
|
486
|
+
io.stderr.write(timingText(traces.last));
|
|
487
|
+
code = outcome.code;
|
|
488
|
+
}
|
|
489
|
+
// The update notice: after the output, never after Ctrl-C or a --json failure (stderr then holds only the error).
|
|
490
|
+
if (updates && code !== EXIT.interrupted && !(ctx.json && code !== EXIT.ok)) {
|
|
491
|
+
const r = await updates.result(NOTICE_WAIT_MS);
|
|
492
|
+
const notice = noticeOf(r?.status);
|
|
493
|
+
if (notice)
|
|
494
|
+
io.stderr.write(notice);
|
|
495
|
+
}
|
|
496
|
+
return code;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Runs `shard` as the process: argv, stdio and environment of this process. Ctrl-C aborts in-flight waits and
|
|
500
|
+
* streams cleanly (exit 130); a second Ctrl-C exits at once. Output is flushed before exiting (stdout/stderr pipes
|
|
501
|
+
* are asynchronous on macOS).
|
|
502
|
+
*/
|
|
503
|
+
export async function main(opts = {}) {
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
let interrupts = 0;
|
|
506
|
+
process.on('SIGINT', () => {
|
|
507
|
+
interrupts += 1;
|
|
508
|
+
if (interrupts > 1)
|
|
509
|
+
process.exit(130);
|
|
510
|
+
controller.abort(new Error('interrupted'));
|
|
511
|
+
});
|
|
512
|
+
const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
|
|
513
|
+
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 } : {}) });
|
|
514
|
+
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
|
515
|
+
process.exit(code);
|
|
148
516
|
}
|
|
@@ -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 {};
|