@shardflux/cli 0.8.1 → 0.9.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 +67 -0
- package/README.md +87 -23
- package/dist/account-args.js +32 -4
- package/dist/account-commands.js +234 -111
- package/dist/args.d.ts +1 -1
- package/dist/args.js +80 -4
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +144 -3
- package/dist/format.d.ts +21 -1
- package/dist/format.js +61 -0
- package/dist/main.js +8 -0
- package/package.json +2 -2
package/dist/account-commands.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* setup), else the only one there is; with several the usage error lists them.
|
|
14
14
|
*/
|
|
15
15
|
import { chmod, writeFile } from 'node:fs/promises';
|
|
16
|
-
import { API_KEY_TOOL_PERMISSIONS, CheckoutTimeoutError, Shardflux, ShardfluxAccount, ShardfluxApiError, fetchBillingCatalog, parseEmailToken } from '@shardflux/sdk';
|
|
17
|
-
import { UUID, parseDuration, parsePositiveInt } from "./args.js";
|
|
16
|
+
import { API_KEY_TOOL_PERMISSIONS, CheckoutTimeoutError, Shardflux, ShardfluxAccount, ShardfluxApiError, codexIdentityProof, fetchBillingCatalog, parseEmailToken } from '@shardflux/sdk';
|
|
17
|
+
import { CLI_VERSION, UUID, parseDuration, parsePositiveInt } from "./args.js";
|
|
18
18
|
import { openUrl } from "./browser.js";
|
|
19
19
|
import { fileExists, oneOf, out, parseSlugVersion, principal, readInput, resolveWorkspace, str, strs, waitTimeout } from "./commands.js";
|
|
20
20
|
import { AuthConfigError, CheckoutEndedError, InterruptedError, MfaPendingError, NotFoundError, UsageError } from "./errors.js";
|
|
@@ -83,16 +83,24 @@ async function mfaEnabled(account) {
|
|
|
83
83
|
* Step-up: the password (a prompt, or the next piped line) plus, with two-factor authentication, the factor from the
|
|
84
84
|
* flags, else a prompt (or the next piped line).
|
|
85
85
|
*/
|
|
86
|
-
async function stepUp(ctx, account, factor, command) {
|
|
86
|
+
async function stepUp(ctx, account, factor, command, codex = false) {
|
|
87
87
|
const needsFactor = factor === null && (await mfaEnabled(account));
|
|
88
|
-
const
|
|
88
|
+
const first = codex ? { codexIdToken: await codexProofOrThrow(ctx) } : { password: await ctx.input.secret('Password: ', 'The password', pipe(command)) };
|
|
89
89
|
let f = factor;
|
|
90
90
|
if (needsFactor) {
|
|
91
91
|
f = factorOf(await ctx.input.answer('Authenticator code (or a recovery code): '));
|
|
92
92
|
if (f === null)
|
|
93
93
|
throw new UsageError('two-factor authentication is on: pass --code <totp> (or --recovery-code <code>)');
|
|
94
94
|
}
|
|
95
|
-
return account.auth.stepUp({
|
|
95
|
+
return account.auth.stepUp({ ...first, ...factorParams(f) });
|
|
96
|
+
}
|
|
97
|
+
/** The ID token of this machine's Codex login (0.9.0+), refreshed by Codex; a usage error says why there is none. */
|
|
98
|
+
async function codexProofOrThrow(ctx) {
|
|
99
|
+
const proof = await codexIdentityProof({ env: ctx.io.env, clientName: 'shard', clientVersion: CLI_VERSION });
|
|
100
|
+
if (!proof.ok)
|
|
101
|
+
throw new UsageError(proof.message, proof.reason === 'api_key_login' || proof.reason === 'not_signed_in' ? 'codex login (then run this again)' : undefined);
|
|
102
|
+
ctx.addSecret(proof.idToken);
|
|
103
|
+
return proof.idToken;
|
|
96
104
|
}
|
|
97
105
|
/** Runs an account call; on 403 step_up_required on a terminal, steps up and retries once (else the error stands). */
|
|
98
106
|
async function withStepUp(ctx, account, call) {
|
|
@@ -248,7 +256,7 @@ function userLine(u) {
|
|
|
248
256
|
/** What to do after signing in: verify, set up, or open a workspace. */
|
|
249
257
|
function nextAfterSignIn(ctx, user, me) {
|
|
250
258
|
if (!user.email_verified)
|
|
251
|
-
return `shard
|
|
259
|
+
return `shard verify (emails a 6-digit code; or open the link we emailed you: shard auth verify-email '<link>')`;
|
|
252
260
|
const profile = ctx.store.profile(ctx.apiUrl);
|
|
253
261
|
const hasKey = profile.api_key !== undefined || Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim());
|
|
254
262
|
if (!hasKey || !me || me.memberships.length === 0)
|
|
@@ -535,6 +543,127 @@ async function waitedVolume(ctx, call) {
|
|
|
535
543
|
throw err;
|
|
536
544
|
}
|
|
537
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* `shard setup`, also the last step of `shard signup`: uses (or creates) an organization and project, creates an API
|
|
548
|
+
* key and saves it in the credentials file. Returns the report instead of printing it.
|
|
549
|
+
*/
|
|
550
|
+
async function runSetup(ctx, values, account) {
|
|
551
|
+
const me = await account.me();
|
|
552
|
+
if (me.type !== 'user' || !me.user)
|
|
553
|
+
throw new UsageError('setup needs a user session: shard auth login');
|
|
554
|
+
const tools = toolsFrom(values);
|
|
555
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
556
|
+
const created = { organization: false, project: false, api_key: false };
|
|
557
|
+
const flagged = str(values.org) !== undefined || str(values.project) !== undefined;
|
|
558
|
+
let keyCheck = null;
|
|
559
|
+
if (profile.api_key && values['new-key'] !== true) {
|
|
560
|
+
keyCheck = await checkKey(ctx, profile.api_key.token);
|
|
561
|
+
if (!keyCheck.ok && !ctx.json)
|
|
562
|
+
ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} no longer works (${keyCheck.reason}); creating a new one.\n`);
|
|
563
|
+
}
|
|
564
|
+
let org;
|
|
565
|
+
let project;
|
|
566
|
+
// The working saved key's own organization and project, when this user can see them.
|
|
567
|
+
let home = null;
|
|
568
|
+
if (keyCheck?.ok && !flagged) {
|
|
569
|
+
try {
|
|
570
|
+
home = { org: await account.organizations.get(keyCheck.organizationId), project: await account.projects.get(keyCheck.projectId) };
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
if (!gone(err))
|
|
574
|
+
throw err;
|
|
575
|
+
if (!ctx.json)
|
|
576
|
+
ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} belongs to a project you cannot see; creating a key for your own.\n`);
|
|
577
|
+
keyCheck = { ok: false, reason: 'another account' };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (home) {
|
|
581
|
+
org = home.org;
|
|
582
|
+
project = home.project;
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const orgs = await allOrgs(account);
|
|
586
|
+
const orgRef = str(values.org);
|
|
587
|
+
const savedOrg = profile.organization_id ? orgs.find((o) => o.id === profile.organization_id) : undefined;
|
|
588
|
+
if (orgRef !== undefined)
|
|
589
|
+
org = await findOrg(account, orgRef);
|
|
590
|
+
else if (savedOrg)
|
|
591
|
+
org = savedOrg;
|
|
592
|
+
else if (orgs.length === 1)
|
|
593
|
+
org = orgs[0];
|
|
594
|
+
else if (orgs.length === 0) {
|
|
595
|
+
org = await account.organizations.create({ name: str(values['org-name'])?.trim() || defaultOrgName(me.user) });
|
|
596
|
+
created.organization = true;
|
|
597
|
+
}
|
|
598
|
+
else
|
|
599
|
+
throw new UsageError(`You belong to ${orgs.length} organizations; pick one with --org <id|slug>:\n${choices(orgs)}\n`, 'or save a default first: shard orgs use <id|slug>');
|
|
600
|
+
const projects = created.organization ? [] : await allProjects(account, org.id);
|
|
601
|
+
const projectRef = str(values.project);
|
|
602
|
+
const savedProject = profile.project_id ? projects.find((p) => p.id === profile.project_id) : undefined;
|
|
603
|
+
if (projectRef !== undefined) {
|
|
604
|
+
const hit = byRef(projects, projectRef);
|
|
605
|
+
if (!hit)
|
|
606
|
+
throw new NotFoundError(`No project "${projectRef}" in ${org.name} (${org.slug}).`, 'list them: shard projects list');
|
|
607
|
+
project = hit;
|
|
608
|
+
}
|
|
609
|
+
else if (savedProject)
|
|
610
|
+
project = savedProject;
|
|
611
|
+
else if (projects.length === 1)
|
|
612
|
+
project = projects[0];
|
|
613
|
+
else if (projects.length === 0) {
|
|
614
|
+
project = await account.projects.create(org.id, { name: str(values['project-name'])?.trim() || 'Default' });
|
|
615
|
+
created.project = true;
|
|
616
|
+
}
|
|
617
|
+
else
|
|
618
|
+
throw new UsageError(`${org.name} has ${projects.length} projects; pick one with --project <id|slug>:\n${choices(projects)}\n`, 'or save a default first: shard projects use <id|slug>');
|
|
619
|
+
}
|
|
620
|
+
const keep = keyCheck?.ok === true && profile.api_key?.project_id === project.id;
|
|
621
|
+
let key;
|
|
622
|
+
let permissions = tools;
|
|
623
|
+
if (keep)
|
|
624
|
+
key = profile.api_key;
|
|
625
|
+
else {
|
|
626
|
+
const name = (str(values['key-name'])?.trim() || `shard-cli ${ctx.hostname()}`).slice(0, 200);
|
|
627
|
+
const res = await account.apiKeys.create(project.id, { name, toolPermissions: tools });
|
|
628
|
+
ctx.addSecret(res.secret);
|
|
629
|
+
key = storedKey(res.api_key, res.secret);
|
|
630
|
+
permissions = res.api_key.tool_permissions;
|
|
631
|
+
created.api_key = true;
|
|
632
|
+
}
|
|
633
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
634
|
+
p.organization_id = org.id;
|
|
635
|
+
p.project_id = project.id;
|
|
636
|
+
p.api_key = key;
|
|
637
|
+
});
|
|
638
|
+
const check = keep ? keyCheck : await checkKey(ctx, key.token);
|
|
639
|
+
if (!check?.ok || check.projectId !== project.id)
|
|
640
|
+
throw new UsageError(`the API key ${key.key_id} was saved but does not authenticate for project ${project.id} (${check && !check.ok ? check.reason : 'another project'})`, 'try again: shard setup --new-key');
|
|
641
|
+
if (keep) {
|
|
642
|
+
const k = (await account.apiKeys.list(project.id, { limit: 200 })).data.find((x) => x.id === key.id);
|
|
643
|
+
if (k)
|
|
644
|
+
permissions = k.tool_permissions;
|
|
645
|
+
}
|
|
646
|
+
const tag = (made) => (made ? 'created' : 'existing');
|
|
647
|
+
const envNote = ctx.io.env.SHARDFLUX_API_KEY?.trim() ? 'note: SHARDFLUX_API_KEY is set in this environment and wins over the saved key; unset it to use the saved key.\n' : '';
|
|
648
|
+
return {
|
|
649
|
+
text: (keep ? 'Already set up; the saved API key works.\n' : 'Set up.\n') +
|
|
650
|
+
kv([
|
|
651
|
+
['organization', `${org.name} (${org.slug}, ${org.id}) ${tag(created.organization)}`],
|
|
652
|
+
['project', `${project.name} (${project.slug}, ${project.id}) ${tag(created.project)}`],
|
|
653
|
+
['api key', `"${key.name}" ${key.key_id} (tools: ${toolsText(permissions)}) ${keep ? 'saved earlier' : 'created'}, saved in ${ctx.store.file}`],
|
|
654
|
+
]) +
|
|
655
|
+
envNote +
|
|
656
|
+
`next: ${NEXT_WORKSPACE}\n`,
|
|
657
|
+
data: {
|
|
658
|
+
organization: org,
|
|
659
|
+
project,
|
|
660
|
+
api_key: { id: key.id, key_id: key.key_id, name: key.name, project_id: key.project_id, tool_permissions: permissions },
|
|
661
|
+
created,
|
|
662
|
+
credentials_file: ctx.store.file,
|
|
663
|
+
next: NEXT_WORKSPACE,
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
538
667
|
// ---- handlers --------------------------------------------------------------------------------------------------
|
|
539
668
|
export const ACCOUNT_HANDLERS = {
|
|
540
669
|
async 'auth register'(ctx, values) {
|
|
@@ -582,6 +711,8 @@ export const ACCOUNT_HANDLERS = {
|
|
|
582
711
|
return 0;
|
|
583
712
|
},
|
|
584
713
|
async 'auth login'(ctx, values) {
|
|
714
|
+
if (values.codex === true)
|
|
715
|
+
return ACCOUNT_HANDLERS.signup(ctx, { ...values, setup: false, codex: true, email: undefined }, []);
|
|
585
716
|
const email = emailArg(need(values, 'email', '--email you@example.com'), '--email');
|
|
586
717
|
let factor = factorFlags(values);
|
|
587
718
|
const password = await ctx.input.secret(`Password for ${email}: `, 'The password', pipe(`shard auth login --email ${email}`));
|
|
@@ -762,7 +893,7 @@ export const ACCOUNT_HANDLERS = {
|
|
|
762
893
|
},
|
|
763
894
|
async 'auth step-up'(ctx, values) {
|
|
764
895
|
const account = ctx.account();
|
|
765
|
-
const r = await stepUp(ctx, account, factorFlags(values), 'shard auth step-up');
|
|
896
|
+
const r = await stepUp(ctx, account, factorFlags(values), 'shard auth step-up', values.codex === true);
|
|
766
897
|
out(ctx, `Step-up confirmed until ${time(r.step_up_valid_until)}.\n`, { step_up_valid_until: r.step_up_valid_until });
|
|
767
898
|
return 0;
|
|
768
899
|
},
|
|
@@ -840,119 +971,111 @@ export const ACCOUNT_HANDLERS = {
|
|
|
840
971
|
return 0;
|
|
841
972
|
},
|
|
842
973
|
async setup(ctx, values) {
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
let
|
|
852
|
-
if (
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
let home = null;
|
|
861
|
-
if (keyCheck?.ok && !flagged) {
|
|
862
|
-
try {
|
|
863
|
-
home = { org: await account.organizations.get(keyCheck.organizationId), project: await account.projects.get(keyCheck.projectId) };
|
|
974
|
+
const r = await runSetup(ctx, values, ctx.account());
|
|
975
|
+
out(ctx, r.text, r.data);
|
|
976
|
+
return 0;
|
|
977
|
+
},
|
|
978
|
+
async signup(ctx, values) {
|
|
979
|
+
const email = str(values.email) === undefined ? undefined : emailArg(str(values.email), '--email');
|
|
980
|
+
const name = str(values.name)?.trim();
|
|
981
|
+
let codexNote = null;
|
|
982
|
+
let idToken = null;
|
|
983
|
+
if (values.codex !== false) {
|
|
984
|
+
const proof = await codexIdentityProof({ env: ctx.io.env, clientName: 'shard', clientVersion: CLI_VERSION });
|
|
985
|
+
if (proof.ok) {
|
|
986
|
+
idToken = proof.idToken;
|
|
987
|
+
ctx.addSecret(idToken);
|
|
988
|
+
if (email !== undefined && proof.email !== null && proof.email.toLowerCase() !== email.toLowerCase()) {
|
|
989
|
+
codexNote = `note: signed up with your Codex login (${proof.email}), not --email ${email}; pass --no-codex to use ${email}.\n`;
|
|
990
|
+
}
|
|
864
991
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
throw err;
|
|
868
|
-
if (!ctx.json)
|
|
869
|
-
ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} belongs to a project you cannot see; creating a key for your own.\n`);
|
|
870
|
-
keyCheck = { ok: false, reason: 'another account' };
|
|
992
|
+
else if (email === undefined) {
|
|
993
|
+
throw new UsageError(`No Codex login to sign up with: ${proof.message}`, 'shard signup --email you@example.com');
|
|
871
994
|
}
|
|
872
995
|
}
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
project = home.project;
|
|
996
|
+
else if (email === undefined) {
|
|
997
|
+
throw new UsageError('--no-codex needs an email address.', 'shard signup --no-codex --email you@example.com');
|
|
876
998
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
throw new NotFoundError(`No project "${projectRef}" in ${org.name} (${org.slug}).`, 'list them: shard projects list');
|
|
900
|
-
project = hit;
|
|
901
|
-
}
|
|
902
|
-
else if (savedProject)
|
|
903
|
-
project = savedProject;
|
|
904
|
-
else if (projects.length === 1)
|
|
905
|
-
project = projects[0];
|
|
906
|
-
else if (projects.length === 0) {
|
|
907
|
-
project = await account.projects.create(org.id, { name: str(values['project-name'])?.trim() || 'Default' });
|
|
908
|
-
created.project = true;
|
|
999
|
+
let latest = null;
|
|
1000
|
+
const { account, result } = await ShardfluxAccount.signup({
|
|
1001
|
+
...ctx.accountOptions(),
|
|
1002
|
+
...(idToken !== null ? { codexIdToken: idToken } : { email: email }),
|
|
1003
|
+
...(name ? { displayName: name } : {}),
|
|
1004
|
+
onSessionToken: (s) => {
|
|
1005
|
+
ctx.addSecret(s.token);
|
|
1006
|
+
latest = s;
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
const replaced = ctx.store.profile(ctx.apiUrl).session?.token ?? null;
|
|
1010
|
+
const expiresAt = () => latest?.expiresAt || result.session_expires_at || null;
|
|
1011
|
+
let user = result.user;
|
|
1012
|
+
if (result.status === 'mfa_required') {
|
|
1013
|
+
let factor = factorFlags(values);
|
|
1014
|
+
if (factor === null && ctx.input.interactive)
|
|
1015
|
+
factor = factorOf(await ctx.input.answer('Authenticator code (or a recovery code): '));
|
|
1016
|
+
if (factor === null) {
|
|
1017
|
+
ctx.saveSession({ token: account.sessionToken ?? '', expires_at: expiresAt(), user: null, state: 'pending_mfa' });
|
|
1018
|
+
if (replaced && replaced !== account.sessionToken)
|
|
1019
|
+
await endQuietly(ctx, replaced);
|
|
1020
|
+
throw new MfaPendingError(`Your Codex login matches an account with two-factor authentication on, so the sign-in waits for your code (10 minutes). The pending session is saved in ${ctx.store.file}.`, 'shard auth mfa --code <code from your authenticator app>');
|
|
909
1021
|
}
|
|
910
|
-
|
|
911
|
-
|
|
1022
|
+
user = (await account.auth.completeMfa(factor)).user;
|
|
1023
|
+
}
|
|
1024
|
+
if (!user)
|
|
1025
|
+
throw new UsageError('the API signed you up without a user; run shard auth status');
|
|
1026
|
+
const saved = { token: account.sessionToken ?? '', expires_at: expiresAt(), user: { id: user.id, email: user.email }, state: 'active' };
|
|
1027
|
+
const { switched } = ctx.saveSession(saved, user.email);
|
|
1028
|
+
if (replaced && replaced !== saved.token)
|
|
1029
|
+
await endQuietly(ctx, replaced);
|
|
1030
|
+
if (switched && !ctx.json)
|
|
1031
|
+
ctx.io.stderr.write(SWITCHED_NOTE);
|
|
1032
|
+
if (codexNote && !ctx.json)
|
|
1033
|
+
ctx.io.stderr.write(codexNote);
|
|
1034
|
+
const access = result.access;
|
|
1035
|
+
const how = access === 'verified'
|
|
1036
|
+
? idToken !== null
|
|
1037
|
+
? 'email verified by your Codex login'
|
|
1038
|
+
: 'email verified'
|
|
1039
|
+
: access === 'provisional'
|
|
1040
|
+
? 'email not verified yet: workspaces run on the Free plan now; verify to start the trial: shard verify'
|
|
1041
|
+
: 'email not verified yet: verify it to run workspaces: shard verify';
|
|
1042
|
+
let text = `${result.created ? 'Signed up' : 'Signed in'} as ${userLine(user)} (${how}).\nsession: saved in ${ctx.store.file}\n`;
|
|
1043
|
+
const data = { status: 'authenticated', created: result.created, access, user, method: idToken !== null ? 'codex' : 'email', credentials_file: ctx.store.file };
|
|
1044
|
+
if (access !== 'verification_required' && values.setup !== false) {
|
|
1045
|
+
const setup = await runSetup(ctx, values, account);
|
|
1046
|
+
text += setup.text;
|
|
1047
|
+
data.setup = setup.data;
|
|
912
1048
|
}
|
|
913
|
-
const keep = keyCheck?.ok === true && profile.api_key?.project_id === project.id;
|
|
914
|
-
let key;
|
|
915
|
-
let permissions = tools;
|
|
916
|
-
if (keep)
|
|
917
|
-
key = profile.api_key;
|
|
918
1049
|
else {
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
key = storedKey(res.api_key, res.secret);
|
|
923
|
-
permissions = res.api_key.tool_permissions;
|
|
924
|
-
created.api_key = true;
|
|
1050
|
+
const next = access === 'verification_required' ? 'shard verify' : 'shard setup';
|
|
1051
|
+
text += `next: ${next}\n`;
|
|
1052
|
+
data.next = next;
|
|
925
1053
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (
|
|
933
|
-
throw new UsageError(
|
|
934
|
-
if (
|
|
935
|
-
const
|
|
936
|
-
if (
|
|
937
|
-
|
|
1054
|
+
out(ctx, text, data);
|
|
1055
|
+
return 0;
|
|
1056
|
+
},
|
|
1057
|
+
async verify(ctx, _values, [code]) {
|
|
1058
|
+
const account = ctx.account();
|
|
1059
|
+
let digits = code?.replace(/\s+/g, '');
|
|
1060
|
+
if (digits !== undefined && !/^[0-9]{6}$/.test(digits))
|
|
1061
|
+
throw new UsageError('the code is the 6 digits from the email', 'shard verify 123456');
|
|
1062
|
+
if (digits === undefined) {
|
|
1063
|
+
const sent = await account.auth.requestEmailCode();
|
|
1064
|
+
if (sent.status === 'verified') {
|
|
1065
|
+
out(ctx, 'Your email is already verified.\n', { status: 'verified' });
|
|
1066
|
+
return 0;
|
|
1067
|
+
}
|
|
1068
|
+
if (!ctx.input.interactive) {
|
|
1069
|
+
out(ctx, 'A 6-digit code is on its way to your inbox (valid 15 minutes). Then: shard verify <code>\n', { status: 'accepted', next: 'shard verify <code>' });
|
|
1070
|
+
return 0;
|
|
1071
|
+
}
|
|
1072
|
+
ctx.io.stderr.write('A 6-digit code is on its way to your inbox (valid 15 minutes).\n');
|
|
1073
|
+
digits = ((await ctx.input.answer('Code from the email: ')) ?? '').replace(/\s+/g, '');
|
|
1074
|
+
if (!/^[0-9]{6}$/.test(digits))
|
|
1075
|
+
throw new UsageError('the code is the 6 digits from the email', 'shard verify <code>');
|
|
938
1076
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
out(ctx, (keep ? 'Already set up; the saved API key works.\n' : 'Set up.\n') +
|
|
942
|
-
kv([
|
|
943
|
-
['organization', `${org.name} (${org.slug}, ${org.id}) ${tag(created.organization)}`],
|
|
944
|
-
['project', `${project.name} (${project.slug}, ${project.id}) ${tag(created.project)}`],
|
|
945
|
-
['api key', `"${key.name}" ${key.key_id} (tools: ${toolsText(permissions)}) ${keep ? 'saved earlier' : 'created'}, saved in ${ctx.store.file}`],
|
|
946
|
-
]) +
|
|
947
|
-
envNote +
|
|
948
|
-
`next: ${NEXT_WORKSPACE}\n`, {
|
|
949
|
-
organization: org,
|
|
950
|
-
project,
|
|
951
|
-
api_key: { id: key.id, key_id: key.key_id, name: key.name, project_id: key.project_id, tool_permissions: permissions },
|
|
952
|
-
created,
|
|
953
|
-
credentials_file: ctx.store.file,
|
|
954
|
-
next: NEXT_WORKSPACE,
|
|
955
|
-
});
|
|
1077
|
+
await account.auth.verifyEmailCode(digits);
|
|
1078
|
+
out(ctx, 'Email verified. Your session and API keys keep working; a new account gets its trial now.\n', { status: 'verified' });
|
|
956
1079
|
return 0;
|
|
957
1080
|
},
|
|
958
1081
|
env(ctx, values) {
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseArgs } from 'node:util';
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
|
-
export const CLI_VERSION = '0.
|
|
15
|
+
export const CLI_VERSION = '0.9.0';
|
|
16
16
|
export const GLOBAL_OPTIONS = {
|
|
17
17
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
18
18
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -32,9 +32,9 @@ const MEMORY_ALLOCATION = {
|
|
|
32
32
|
'allocation-mode': {
|
|
33
33
|
type: 'string',
|
|
34
34
|
value: '<fixed|elastic>',
|
|
35
|
-
description: 'elastic: --memory-mib is
|
|
35
|
+
description: 'elastic: --memory-mib is the most the workspace may grow to; it holds --memory-held while idle and grows when a command needs more, and billing counts the memory it holds. fixed: a static allocation of --memory-mib. Send it with every caps flag to keep the mode you chose; a command without caps flags keeps the stored layout. Takes effect at the next VM start (shard ws resize changes a running or suspended workspace).',
|
|
36
36
|
},
|
|
37
|
-
'memory-held': { type: 'string', value: '<mib>', description: '
|
|
37
|
+
'memory-held': { type: 'string', value: '<mib>', description: 'Elastic: memory held while idle, in MiB (at least 512, default 1024, at most --memory-mib).' },
|
|
38
38
|
};
|
|
39
39
|
/** `--timing` (0.3.0+): the SDK's lifecycle timing of the command's open, wait or wake (README "Timing"). */
|
|
40
40
|
const TIMING = {
|
|
@@ -61,6 +61,8 @@ const WAIT = {
|
|
|
61
61
|
...TIMING,
|
|
62
62
|
};
|
|
63
63
|
const REF = { name: 'id|key', required: true };
|
|
64
|
+
/** A TCP port inside the workspace (1-65535). */
|
|
65
|
+
const PORT = { name: 'port', required: true };
|
|
64
66
|
const SLUG = { name: 'slug', required: true };
|
|
65
67
|
const PAGE = {
|
|
66
68
|
all: { type: 'boolean', description: 'Fetch every page.' },
|
|
@@ -252,6 +254,7 @@ const KEY_COMMANDS = [
|
|
|
252
254
|
{ path: ['workspaces', 'labels'], summary: 'Read or replace workspace labels', positionals: [REF], options: {
|
|
253
255
|
set: { type: 'string', value: '<json>', description: 'Replace all labels; {} clears them.' },
|
|
254
256
|
} },
|
|
257
|
+
{ path: ['workspaces', 'hint'], summary: 'Stage a parked workspace ahead of an imminent tool call', positionals: [REF], options: {} },
|
|
255
258
|
{ path: ['workspaces', 'idle'], summary: 'Inspect idle status, set policy or declare a keepalive', positionals: [REF], options: {
|
|
256
259
|
policy: { type: 'string', value: '<policy>', description: 'adaptive, never, fixed:<seconds>, or default to clear the override.' },
|
|
257
260
|
keepalive: { type: 'string', value: '<seconds>', description: 'Declare ongoing work for this many seconds.' },
|
|
@@ -266,6 +269,11 @@ const KEY_COMMANDS = [
|
|
|
266
269
|
'session-id': { type: 'string', value: '<id>', description: 'Caller-chosen id to deduplicate starts.' },
|
|
267
270
|
cwd: { type: 'string', value: '<dir>', description: 'Absolute working directory.' },
|
|
268
271
|
timeout: { type: 'string', value: '<duration>', description: 'Kill the command after this long (e.g. 30m, 2h; none unless set). The workspace stays awake until the command ends or this passes (1 hour when unset).' },
|
|
272
|
+
'resource-hint': {
|
|
273
|
+
type: 'string',
|
|
274
|
+
value: '<auto|light|heavy>',
|
|
275
|
+
description: 'Processful (0.9.0+): heavy gives an elastic workspace its memory before the command starts (a build, a test suite, an install); light starts it at once; auto (default) decides from the command.',
|
|
276
|
+
},
|
|
269
277
|
},
|
|
270
278
|
examples: ['shard ws exec-start acme/demo --session-id build-1 --timeout 2h -- bash -lc "make -C /home/user/app"', 'shard ws exec-read acme/demo build-1 --follow'],
|
|
271
279
|
},
|
|
@@ -310,6 +318,11 @@ const KEY_COMMANDS = [
|
|
|
310
318
|
description: 'File-first: the execution’s id (8-128 of A-Z a-z 0-9 . _ : -, starting with a letter or digit; default a fresh ex-<uuid>). Running the same command again with the same id returns its recorded result instead of running it twice, so scripts can repeat the call safely.',
|
|
311
319
|
},
|
|
312
320
|
'output-limit': { type: 'string', value: '<bytes>', description: 'File-first: keep at most this many bytes of stdout and of stderr each (1-16777216; default 1 MiB).' },
|
|
321
|
+
'resource-hint': {
|
|
322
|
+
type: 'string',
|
|
323
|
+
value: '<auto|light|heavy>',
|
|
324
|
+
description: 'Processful (0.9.0+): heavy gives an elastic workspace its memory before the command starts (a build, a test suite, an install); light starts it at once; auto (default) decides from the command.',
|
|
325
|
+
},
|
|
313
326
|
burst: {
|
|
314
327
|
type: 'boolean',
|
|
315
328
|
description: 'Processful: run this command on a larger, short-lived burst VM on the workspace’s host and apply its file changes back when it exits (needs the burst execution entitlement). Its processes and tmpfs content do not come back; the workspace’s own processes pause meanwhile. Not with --stdin; cannot be canceled.',
|
|
@@ -329,6 +342,7 @@ const KEY_COMMANDS = [
|
|
|
329
342
|
'shard ws exec acme/agent-7 -v --execution-id build-42 --timeout 10m -- bash -lc "make -C /home/user/app"',
|
|
330
343
|
'shard ws exec acme/agent-7 --json -- python3 /home/user/report.py > result.json',
|
|
331
344
|
'shard ws exec acme/demo --burst --burst-vcpus 16 -v -- bash -lc "cd /home/user/app && go build ./..."',
|
|
345
|
+
'shard ws exec acme/demo --resource-hint heavy -- python3 train.py',
|
|
332
346
|
],
|
|
333
347
|
},
|
|
334
348
|
{
|
|
@@ -351,6 +365,23 @@ const KEY_COMMANDS = [
|
|
|
351
365
|
positionals: [REF, { name: 'new-key', required: true }],
|
|
352
366
|
options: { ...CAPS, ...MEMORY_ALLOCATION, ...WAIT },
|
|
353
367
|
},
|
|
368
|
+
{
|
|
369
|
+
path: ['workspaces', 'resize'],
|
|
370
|
+
summary: 'Change a workspace’s memory, CPU or disk, running or suspended, without a restart (0.9.0+)',
|
|
371
|
+
description: 'Memory changes live and disks grow online; a suspended workspace gets its new size when it resumes, before its first call. CPU changes live within the vCPUs the workspace booted with; a larger CPU size applies at the next start. The new caps are stored, so every later start uses them. Prints per resource the change and when it applies: now, on resume or next start. Waits until the resize has finished.',
|
|
372
|
+
positionals: [REF],
|
|
373
|
+
options: {
|
|
374
|
+
memory: { type: 'string', value: '<mib>', description: 'Memory in MiB: the size of a fixed workspace, the most an elastic one may grow to.' },
|
|
375
|
+
held: { type: 'string', value: '<mib>', description: 'Elastic: the memory held while idle, in MiB (at most the memory size).' },
|
|
376
|
+
fixed: { type: 'boolean', description: 'Switch to a static allocation of the memory size.' },
|
|
377
|
+
elastic: { type: 'boolean', description: 'Switch to elastic memory: the workspace holds what its commands use and grows up to the memory size.' },
|
|
378
|
+
cpu: { type: 'string', value: '<millis>', description: 'CPU in millicores (1000 = one vCPU).' },
|
|
379
|
+
disk: { type: 'string', value: '<gib>', description: 'Disk in GiB (disks grow only).' },
|
|
380
|
+
timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m); the resize continues server side.' },
|
|
381
|
+
...TIMING,
|
|
382
|
+
},
|
|
383
|
+
examples: ['shard ws resize acme/demo --memory 6144', 'shard ws resize acme/demo --memory 8192 --cpu 4000 --disk 40', 'shard ws resize acme/demo --elastic --held 1024', 'shard ws resize acme/demo --memory 4096 --fixed --json'],
|
|
384
|
+
},
|
|
354
385
|
{
|
|
355
386
|
path: ['workspaces', 'delete'],
|
|
356
387
|
summary: 'Delete a workspace (tool access ends now; storage is cleaned up asynchronously)',
|
|
@@ -410,6 +441,49 @@ const KEY_COMMANDS = [
|
|
|
410
441
|
},
|
|
411
442
|
examples: ['shard ws secrets acme/demo', 'shard ws secrets acme/demo --set OPENAI_API_KEY --set DATABASE_URL', 'shard ws secrets acme/demo --clear'],
|
|
412
443
|
},
|
|
444
|
+
{
|
|
445
|
+
path: ['workspaces', 'port', 'expose'],
|
|
446
|
+
summary: 'Serve a port of a workspace at its own private HTTPS URL (0.9.0+)',
|
|
447
|
+
description: 'Requests to the URL reach TCP <port> inside the workspace; a request to a parked or suspended workspace wakes it and is answered once it runs. Every port is private: a request carries a port token (shard ws port token), a browser link’s session (shard ws port link) or arrives on the callback URL (shard ws port callback). The server inside the workspace listens on 0.0.0.0. Idempotent: an exposed port keeps its URL, tokens, links and callback URL. --json prints the API’s port object {port, url, created_at, callback}.',
|
|
448
|
+
positionals: [REF, PORT],
|
|
449
|
+
options: {},
|
|
450
|
+
examples: ['shard ws port expose acme/demo 3000', 'curl -H "Authorization: Bearer $(shard ws port token acme/demo 3000)" "$(shard ws port expose acme/demo 3000 --json | jq -r .url)/"'],
|
|
451
|
+
},
|
|
452
|
+
{ path: ['workspaces', 'port', 'ls'], summary: 'List the exposed ports of a workspace and their URLs (0.9.0+)', positionals: [REF], options: {} },
|
|
453
|
+
{
|
|
454
|
+
path: ['workspaces', 'port', 'close'],
|
|
455
|
+
summary: 'Close an exposed port: its tokens, links and callback URL stop working at once (0.9.0+)',
|
|
456
|
+
description: 'Idempotent (also when the port is not exposed). Exposing the port again gives it new tokens, links and callback URL; the URL stays the same.',
|
|
457
|
+
positionals: [REF, PORT],
|
|
458
|
+
options: {},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
path: ['workspaces', 'port', 'token'],
|
|
462
|
+
summary: 'Mint a port token and print it alone on stdout (0.9.0+)',
|
|
463
|
+
description: 'Send it as `Authorization: Bearer <token>` (or `X-Shardflux-Token: <token>` when the app reads Authorization itself) with requests to the port’s URL. Closing the port revokes it. --json prints {token, expires_at, url}.',
|
|
464
|
+
positionals: [REF, PORT],
|
|
465
|
+
options: { ttl: { type: 'string', value: '<duration>', description: 'Lifetime, 1m to 24h (e.g. 15m, 2h; default 1h).' } },
|
|
466
|
+
examples: ['TOKEN=$(shard ws port token acme/demo 3000 --ttl 15m)', 'shard ws port token acme/demo 3000 --json | jq -r .token'],
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
path: ['workspaces', 'port', 'link'],
|
|
470
|
+
summary: 'Create a link that opens the port in a browser and print it (0.9.0+)',
|
|
471
|
+
description: 'Opening the link starts a browser session for the port (until it expires) and lands on --path. Anyone with the link can open the port until then: share it like a password. --json prints {url, expires_at}.',
|
|
472
|
+
positionals: [REF, PORT],
|
|
473
|
+
options: {
|
|
474
|
+
ttl: { type: 'string', value: '<duration>', description: 'Lifetime of the link and its browser session, 1m to 7d (default 24h).' },
|
|
475
|
+
path: { type: 'string', value: '<path>', description: 'Where the link lands: a path starting with one / (query allowed; default /).' },
|
|
476
|
+
},
|
|
477
|
+
examples: ['shard ws port link acme/demo 3000 --path /dashboard', 'open "$(shard ws port link acme/demo 3000)"'],
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
path: ['workspaces', 'port', 'callback'],
|
|
481
|
+
summary: 'Create (or replace) the port’s callback URL for webhooks and OAuth redirects, or revoke it (0.9.0+)',
|
|
482
|
+
description: 'Register the printed URL plus your own path with GitHub, Slack, Stripe or an OAuth provider: a request to <url><rest> reaches /<rest> of the port (query kept) without a token header and wakes the workspace like any request. The URL is shown only once; creating it again replaces it (the previous one stops working). --json prints {url, created_at}.',
|
|
483
|
+
positionals: [REF, PORT],
|
|
484
|
+
options: { revoke: { type: 'boolean', description: 'Revoke the port’s callback URL instead (idempotent).' } },
|
|
485
|
+
examples: ['shard ws port callback acme/demo 3000', 'shard ws port callback acme/demo 3000 --revoke'],
|
|
486
|
+
},
|
|
413
487
|
{
|
|
414
488
|
path: ['files', 'read'],
|
|
415
489
|
summary: 'Print (or save) a file from a workspace',
|
|
@@ -785,6 +859,7 @@ const GROUPS = {
|
|
|
785
859
|
egress: 'Outbound network policies of the project and its workspaces (project API key)',
|
|
786
860
|
volumes: 'Shared volumes: persistent storage attached to workspaces (project API key)',
|
|
787
861
|
workspaces: 'Open, inspect and manage workspaces',
|
|
862
|
+
'workspaces port': 'Serve ports of a workspace at private HTTPS URLs: expose, list, close, tokens, browser links and callback URLs (0.9.0+)',
|
|
788
863
|
files: 'Read, write, list, search and patch files in a workspace',
|
|
789
864
|
operations: 'Inspect and wait for lifecycle operations',
|
|
790
865
|
executions: 'Fetch the results of file-first executions (the commands "shard ws exec" runs in file-first workspaces)',
|
|
@@ -811,6 +886,7 @@ const ALIASES = {
|
|
|
811
886
|
invite: 'invitations',
|
|
812
887
|
invites: 'invitations',
|
|
813
888
|
volume: 'volumes',
|
|
889
|
+
ports: 'port',
|
|
814
890
|
};
|
|
815
891
|
/** Groups also reachable under `workspaces` (`shard workspaces files read ...` == `shard files read ...`). */
|
|
816
892
|
const FOLDED = new Set(['files', 'operations', 'executions']);
|
|
@@ -1111,7 +1187,7 @@ export const EXIT_CODE_HELP = [
|
|
|
1111
1187
|
];
|
|
1112
1188
|
/** The top help's sections: every command's group (first word) is in exactly one (args.test.ts checks). */
|
|
1113
1189
|
export const HELP_SECTIONS = [
|
|
1114
|
-
{ title: 'Account (a user session: shard auth login)', groups: ['auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
|
|
1190
|
+
{ title: 'Account (a user session: shard signup or shard auth login)', groups: ['signup', 'verify', 'auth', 'setup', 'env', 'orgs', 'projects', 'api-keys', 'members', 'invitations', 'billing', 'audit', 'account'] },
|
|
1115
1191
|
{ title: 'Workspaces (a project API key: SHARDFLUX_API_KEY, else the one shard setup saved)', groups: ['login', 'whoami', 'usage', 'workspaces', 'files', 'operations', 'executions', 'secrets', 'volumes', 'egress'] },
|
|
1116
1192
|
{ title: 'Templates (publish and archive use the session; the rest the API key)', groups: ['templates'] },
|
|
1117
1193
|
{ title: 'Other', groups: ['feedback', 'version'] },
|
package/dist/commands.d.ts
CHANGED
|
@@ -100,5 +100,19 @@ export declare function principal(ctx: Ctx): Promise<{
|
|
|
100
100
|
}>;
|
|
101
101
|
/** The exit code `shard exec` returns for a finished command. */
|
|
102
102
|
export declare function execExitCode(r: Pick<RunResult, 'exitCode' | 'termSignal' | 'timedOut'>): number;
|
|
103
|
+
/**
|
|
104
|
+
* A lifecycle call a file-first workspace does not have (suspend, resume, fork, reset,
|
|
105
|
+
* save-as-template), refused before any request as the SDK's workspace handle does: NotSupportedForModeError, printed
|
|
106
|
+
* with its hint like the API's own refusal.
|
|
107
|
+
*/
|
|
108
|
+
/** `<port>` of the port commands: a TCP port number, 1-65535. */
|
|
109
|
+
export declare function portArg(text: string): number;
|
|
110
|
+
/** `--ttl <duration>` of port tokens and links: whole seconds within the API's bounds (undefined: the API's default). */
|
|
111
|
+
export declare function ttlSeconds(values: Values, b: {
|
|
112
|
+
option: string;
|
|
113
|
+
min: number;
|
|
114
|
+
max: number;
|
|
115
|
+
range: string;
|
|
116
|
+
}): number | undefined;
|
|
103
117
|
/** The project-key commands; handlers.ts adds the account plane's. */
|
|
104
118
|
export declare const KEY_HANDLERS: Record<string, Handler>;
|