@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/CHANGELOG.md +226 -0
- package/README.md +517 -34
- package/dist/account-args.d.ts +13 -0
- package/dist/account-args.js +425 -0
- package/dist/account-commands.d.ts +18 -0
- package/dist/account-commands.js +1657 -0
- package/dist/args.d.ts +15 -3
- package/dist/args.js +292 -41
- package/dist/bin.js +5 -16
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +39 -0
- package/dist/commands.d.ts +69 -5
- package/dist/commands.js +440 -30
- package/dist/credentials.d.ts +77 -0
- package/dist/credentials.js +217 -0
- package/dist/errors.d.ts +63 -11
- package/dist/errors.js +114 -21
- package/dist/format.d.ts +89 -6
- package/dist/format.js +479 -10
- 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 +477 -31
- package/dist/update-check.d.ts +64 -0
- package/dist/update-check.js +128 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account plane (0.5.0; docs/progress/account-cli.md §5): everything a person does in the web app, from a
|
|
3
|
+
* terminal. Sign-in and the account calls go through the SDK's ShardfluxAccount with the user session (ctx.account());
|
|
4
|
+
* egress and volumes through the project API key (ctx.cloud()) like the other workspace commands.
|
|
5
|
+
*
|
|
6
|
+
* - Secrets never come from arguments: passwords from standard input or a no-echo prompt (ctx.input); the session
|
|
7
|
+
* and saved key live in the credentials file (credentials.ts) and are never printed. Deliberate exceptions: `shard
|
|
8
|
+
* env` (for eval), `api-keys create` (the new key, once, unless --save), the TOTP secret of `auth totp enroll` and
|
|
9
|
+
* recovery codes (the person must copy them), and the reset token of a verification that needs a password.
|
|
10
|
+
* - 403 step_up_required: on a terminal the password (and a code with two-factor authentication) is asked and the call
|
|
11
|
+
* retried once; otherwise the error's hint names `shard auth step-up`.
|
|
12
|
+
* - Default organization/project: --org/--project (id or slug), else the saved default (orgs use, projects use,
|
|
13
|
+
* setup), else the only one there is; with several the usage error lists them.
|
|
14
|
+
*/
|
|
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";
|
|
18
|
+
import { openUrl } from "./browser.js";
|
|
19
|
+
import { fileExists, oneOf, out, parseSlugVersion, principal, readInput, resolveWorkspace, str, strs, waitTimeout } from "./commands.js";
|
|
20
|
+
import { AuthConfigError, CheckoutEndedError, InterruptedError, MfaPendingError, NotFoundError, UsageError } from "./errors.js";
|
|
21
|
+
import { apiKeyTable, attachmentTable, auditTable, authSessionTable, billingStatusText, dataExportText, egressVersionTable, invitationTable, invoiceTable, kv, memberTable, money, orgTable, overagePolicyText, planTable, projectEgressText, projectTable, table, time, toolsText, volumeDetail, volumeTable, workspaceEgressText, WORKSPACE_HEADERS, workspaceRow, } from "./format.js";
|
|
22
|
+
export const DEFAULT_API_URL = 'https://api.shardflux.dev';
|
|
23
|
+
const TOTP = /^\d{6}$/;
|
|
24
|
+
const ROLES = ['owner', 'admin', 'member', 'billing'];
|
|
25
|
+
const TOOL_NAMES = API_KEY_TOOL_PERMISSIONS;
|
|
26
|
+
const DEFAULT_CHECKOUT_WAIT_MS = 15 * 60_000;
|
|
27
|
+
const CHECKOUT_POLL_MS = 2_000;
|
|
28
|
+
const NEXT_WORKSPACE = 'shard ws open demo --template python-node-browser';
|
|
29
|
+
const SWITCHED_NOTE = 'note: another user was signed in here; their default organization, project and saved API key were removed from this profile (the key stays valid until revoked).\n';
|
|
30
|
+
const pipe = (cmd) => `printf '%s\\n' "$PASSWORD" | ${cmd}`;
|
|
31
|
+
function need(values, option, example) {
|
|
32
|
+
const v = str(values[option])?.trim();
|
|
33
|
+
if (!v)
|
|
34
|
+
throw new UsageError(`--${option} is required, e.g. ${example}`);
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
function emailArg(text, what) {
|
|
38
|
+
const e = text.trim();
|
|
39
|
+
if (!/^[^\s@]+@[^\s@]+$/.test(e))
|
|
40
|
+
throw new UsageError(`${what} must be an email address (got "${text}")`);
|
|
41
|
+
return e;
|
|
42
|
+
}
|
|
43
|
+
/** --code / --recovery-code (at most one). */
|
|
44
|
+
function factorFlags(values) {
|
|
45
|
+
const code = str(values.code)?.trim();
|
|
46
|
+
const recovery = str(values['recovery-code'])?.trim();
|
|
47
|
+
if (code !== undefined && recovery !== undefined)
|
|
48
|
+
throw new UsageError('use --code or --recovery-code, not both');
|
|
49
|
+
if (code !== undefined) {
|
|
50
|
+
if (!TOTP.test(code))
|
|
51
|
+
throw new UsageError('--code must be the 6-digit code of your authenticator app');
|
|
52
|
+
return { code };
|
|
53
|
+
}
|
|
54
|
+
if (recovery !== undefined) {
|
|
55
|
+
if (recovery.length < 8)
|
|
56
|
+
throw new UsageError('--recovery-code must be one of your recovery codes');
|
|
57
|
+
return { recoveryCode: recovery };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** An answer typed at the code prompt: six digits are a TOTP code, anything else a recovery code. */
|
|
62
|
+
function factorOf(answer) {
|
|
63
|
+
const a = answer?.trim() ?? '';
|
|
64
|
+
if (a === '')
|
|
65
|
+
return null;
|
|
66
|
+
const digits = a.replace(/\s+/g, '');
|
|
67
|
+
return TOTP.test(digits) ? { code: digits } : { recoveryCode: a };
|
|
68
|
+
}
|
|
69
|
+
const factorParams = (f) => (f === null ? {} : 'code' in f ? { code: f.code } : { recoveryCode: f.recoveryCode });
|
|
70
|
+
function linkToken(link) {
|
|
71
|
+
try {
|
|
72
|
+
return parseEmailToken(link);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
throw new UsageError((e instanceof Error ? e.message : String(e)).replace(/^parseEmailToken: /, ''));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function mfaEnabled(account) {
|
|
79
|
+
const s = await account.auth.session();
|
|
80
|
+
return s.user?.mfa_enabled === true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Step-up: the password (a prompt, or the next piped line) plus, with two-factor authentication, the factor from the
|
|
84
|
+
* flags, else a prompt (or the next piped line).
|
|
85
|
+
*/
|
|
86
|
+
async function stepUp(ctx, account, factor, command) {
|
|
87
|
+
const needsFactor = factor === null && (await mfaEnabled(account));
|
|
88
|
+
const password = await ctx.input.secret('Password: ', 'The password', pipe(command));
|
|
89
|
+
let f = factor;
|
|
90
|
+
if (needsFactor) {
|
|
91
|
+
f = factorOf(await ctx.input.answer('Authenticator code (or a recovery code): '));
|
|
92
|
+
if (f === null)
|
|
93
|
+
throw new UsageError('two-factor authentication is on: pass --code <totp> (or --recovery-code <code>)');
|
|
94
|
+
}
|
|
95
|
+
return account.auth.stepUp({ password, ...factorParams(f) });
|
|
96
|
+
}
|
|
97
|
+
/** Runs an account call; on 403 step_up_required on a terminal, steps up and retries once (else the error stands). */
|
|
98
|
+
async function withStepUp(ctx, account, call) {
|
|
99
|
+
try {
|
|
100
|
+
return await call();
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (!(err instanceof ShardfluxApiError && err.code === 'step_up_required') || !ctx.input.interactive)
|
|
104
|
+
throw err;
|
|
105
|
+
ctx.io.stderr.write('This needs your password again (a step-up, valid 10 minutes).\n');
|
|
106
|
+
await stepUp(ctx, account, null, 'shard auth step-up');
|
|
107
|
+
return call();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ---- organization / project resolution ------------------------------------------------------------------------
|
|
111
|
+
async function allOrgs(account) {
|
|
112
|
+
const rows = [];
|
|
113
|
+
for await (const o of account.organizations.listAll())
|
|
114
|
+
rows.push(o);
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
async function allProjects(account, organizationId) {
|
|
118
|
+
const rows = [];
|
|
119
|
+
for await (const p of account.projects.listAll(organizationId))
|
|
120
|
+
rows.push(p);
|
|
121
|
+
return rows;
|
|
122
|
+
}
|
|
123
|
+
const choices = (rows) => rows.map((r) => ` ${r.id} ${r.slug} ${r.name}`).join('\n');
|
|
124
|
+
function byRef(rows, ref) {
|
|
125
|
+
const exact = rows.find((r) => r.id === ref || r.slug === ref);
|
|
126
|
+
if (exact)
|
|
127
|
+
return exact;
|
|
128
|
+
const named = rows.filter((r) => r.name.toLowerCase() === ref.toLowerCase());
|
|
129
|
+
return named.length === 1 ? named[0] : undefined;
|
|
130
|
+
}
|
|
131
|
+
const gone = (err) => err instanceof ShardfluxApiError && (err.status === 404 || err.status === 403);
|
|
132
|
+
async function findOrg(account, ref) {
|
|
133
|
+
if (UUID.test(ref))
|
|
134
|
+
return account.organizations.get(ref);
|
|
135
|
+
const hit = byRef(await allOrgs(account), ref);
|
|
136
|
+
if (!hit)
|
|
137
|
+
throw new NotFoundError(`No organization "${ref}" among yours.`, 'list them: shard orgs list');
|
|
138
|
+
return hit;
|
|
139
|
+
}
|
|
140
|
+
/** `ref` (a positional), else --org, else the saved default, else the only organization. */
|
|
141
|
+
async function resolveOrg(ctx, account, values, ref) {
|
|
142
|
+
const given = ref ?? str(values.org);
|
|
143
|
+
if (given !== undefined)
|
|
144
|
+
return findOrg(account, given);
|
|
145
|
+
const saved = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
146
|
+
if (saved) {
|
|
147
|
+
try {
|
|
148
|
+
return await account.organizations.get(saved);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (gone(err))
|
|
152
|
+
throw new NotFoundError(`The default organization ${saved} is no longer available to you.`, 'pick another: shard orgs use <org> (list: shard orgs list)');
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const orgs = await allOrgs(account);
|
|
157
|
+
if (orgs.length === 1)
|
|
158
|
+
return orgs[0];
|
|
159
|
+
if (orgs.length === 0)
|
|
160
|
+
throw new UsageError('You have no organization yet.', 'create one with a project and an API key: shard setup (or shard orgs create <name>)');
|
|
161
|
+
throw new UsageError(`You belong to ${orgs.length} organizations; pick one with --org <id|slug>:\n${choices(orgs)}\n`, 'or save a default: shard orgs use <id|slug>');
|
|
162
|
+
}
|
|
163
|
+
/** `ref` (a positional), else --project, else the saved default (unless --org names another), else the only project. */
|
|
164
|
+
async function resolveProject(ctx, account, values, ref) {
|
|
165
|
+
const given = ref ?? str(values.project);
|
|
166
|
+
const orgFlag = str(values.org);
|
|
167
|
+
if (given !== undefined && UUID.test(given)) {
|
|
168
|
+
const p = await account.projects.get(given);
|
|
169
|
+
if (orgFlag !== undefined) {
|
|
170
|
+
const org = await findOrg(account, orgFlag);
|
|
171
|
+
if (p.organization_id !== org.id)
|
|
172
|
+
throw new UsageError(`project ${given} is not in organization ${org.slug}`);
|
|
173
|
+
}
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
177
|
+
if (given === undefined && profile.project_id && orgFlag === undefined) {
|
|
178
|
+
try {
|
|
179
|
+
return await account.projects.get(profile.project_id);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (gone(err))
|
|
183
|
+
throw new NotFoundError(`The default project ${profile.project_id} is no longer available to you.`, 'pick another: shard projects use <project> (list: shard projects list)');
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const org = await resolveOrg(ctx, account, values);
|
|
188
|
+
const projects = await allProjects(account, org.id);
|
|
189
|
+
if (given !== undefined) {
|
|
190
|
+
const hit = byRef(projects, given);
|
|
191
|
+
if (!hit)
|
|
192
|
+
throw new NotFoundError(`No project "${given}" in ${org.name} (${org.slug}).`, 'list them: shard projects list');
|
|
193
|
+
return hit;
|
|
194
|
+
}
|
|
195
|
+
const saved = profile.project_id ? projects.find((p) => p.id === profile.project_id) : undefined;
|
|
196
|
+
if (saved)
|
|
197
|
+
return saved;
|
|
198
|
+
if (projects.length === 1)
|
|
199
|
+
return projects[0];
|
|
200
|
+
if (projects.length === 0)
|
|
201
|
+
throw new UsageError(`${org.name} (${org.slug}) has no project yet.`, 'create one: shard projects create <name> (or shard setup)');
|
|
202
|
+
throw new UsageError(`${org.name} has ${projects.length} projects; pick one with --project <id|slug>:\n${choices(projects)}\n`, 'or save a default: shard projects use <id|slug>');
|
|
203
|
+
}
|
|
204
|
+
/** Removes a saved default (and the saved key) that pointed into an organization that is gone. */
|
|
205
|
+
function forgetOrganization(ctx, organizationId, projectIds) {
|
|
206
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
207
|
+
if (p.organization_id === organizationId) {
|
|
208
|
+
delete p.organization_id;
|
|
209
|
+
delete p.project_id;
|
|
210
|
+
}
|
|
211
|
+
if (p.project_id && projectIds.includes(p.project_id))
|
|
212
|
+
delete p.project_id;
|
|
213
|
+
if (p.api_key && projectIds.includes(p.api_key.project_id))
|
|
214
|
+
delete p.api_key;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// ---- files ------------------------------------------------------------------------------------------------------
|
|
218
|
+
/** Writes an export or download 0600, refusing to overwrite without --force. */
|
|
219
|
+
async function writePrivate(path, text, force) {
|
|
220
|
+
if (!force && (await fileExists(path)))
|
|
221
|
+
throw new UsageError(`${path} exists; pass --force to overwrite it`);
|
|
222
|
+
await writeFile(path, text, { mode: 0o600, flag: force ? 'w' : 'wx' });
|
|
223
|
+
if (process.platform !== 'win32')
|
|
224
|
+
await chmod(path, 0o600);
|
|
225
|
+
return Buffer.byteLength(text);
|
|
226
|
+
}
|
|
227
|
+
/** An export is built when it is created; waits (bounded) should it not be ready yet, then downloads it. */
|
|
228
|
+
async function downloadExport(ctx, account, created, get, download) {
|
|
229
|
+
let meta = created;
|
|
230
|
+
const deadline = Date.now() + 120_000;
|
|
231
|
+
while (meta.state !== 'ready') {
|
|
232
|
+
if (meta.state === 'expired')
|
|
233
|
+
throw new NotFoundError(`Export ${meta.id} expired before it was downloaded; run the command again.`);
|
|
234
|
+
if (Date.now() > deadline)
|
|
235
|
+
throw new UsageError(`export ${meta.id} is not ready after 120 s`);
|
|
236
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
237
|
+
if (ctx.signal.aborted)
|
|
238
|
+
throw new InterruptedError('Interrupted.');
|
|
239
|
+
meta = await get(meta.id);
|
|
240
|
+
}
|
|
241
|
+
const text = await withStepUp(ctx, account, () => download(meta.id));
|
|
242
|
+
return { meta, text };
|
|
243
|
+
}
|
|
244
|
+
// ---- output pieces -------------------------------------------------------------------------------------------
|
|
245
|
+
function userLine(u) {
|
|
246
|
+
return `${u.display_name ? `${u.display_name} <${u.email}>` : u.email} (user ${u.id})`;
|
|
247
|
+
}
|
|
248
|
+
/** What to do after signing in: verify, set up, or open a workspace. */
|
|
249
|
+
function nextAfterSignIn(ctx, user, me) {
|
|
250
|
+
if (!user.email_verified)
|
|
251
|
+
return `shard auth verify-email '<link from the email>' (a new link: shard auth resend-verification)`;
|
|
252
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
253
|
+
const hasKey = profile.api_key !== undefined || Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim());
|
|
254
|
+
if (!hasKey || !me || me.memberships.length === 0)
|
|
255
|
+
return 'shard setup (an organization, a project and an API key, saved)';
|
|
256
|
+
return NEXT_WORKSPACE;
|
|
257
|
+
}
|
|
258
|
+
async function signedInReport(ctx, account, user, session, extra = {}) {
|
|
259
|
+
let me = null;
|
|
260
|
+
if (user.email_verified) {
|
|
261
|
+
try {
|
|
262
|
+
me = await account.me();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
me = null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const next = nextAfterSignIn(ctx, user, me);
|
|
269
|
+
const memberships = me?.memberships ?? null;
|
|
270
|
+
const human = `Signed in as ${userLine(user)}.\n` +
|
|
271
|
+
kv([
|
|
272
|
+
['email', user.email_verified ? 'verified' : 'NOT verified: open the link we emailed you (workspaces and organizations need a verified email)'],
|
|
273
|
+
['two-factor', user.mfa_enabled ? 'on' : 'off (turn it on: shard auth totp enroll)'],
|
|
274
|
+
['session', `saved in ${ctx.store.file}${session.expires_at ? `, ends ${time(session.expires_at)} at the latest` : ''}`],
|
|
275
|
+
['organizations', memberships === null ? '-' : memberships.length === 0 ? 'none yet' : memberships.map((m) => `${m.organization_name} (${m.organization_slug}, ${m.role})`).join(', ')],
|
|
276
|
+
]) +
|
|
277
|
+
`next: ${next}\n`;
|
|
278
|
+
out(ctx, human, { status: 'authenticated', user, session: { expires_at: session.expires_at, credentials_file: ctx.store.file }, organizations: memberships, next, ...extra });
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
/** Best effort: ends a replaced session server side so it does not linger for 30 days. */
|
|
282
|
+
async function endQuietly(ctx, token) {
|
|
283
|
+
try {
|
|
284
|
+
const old = new ShardfluxAccount({ ...ctx.accountOptions(), sessionToken: token, maxRetries: 0, timeoutMs: 5_000 });
|
|
285
|
+
await old.auth.logout();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Already ended, or unreachable: it expires on its own.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** A key checked against /v1/me with its own client (SHARDFLUX_API_KEY does not interfere). */
|
|
292
|
+
async function checkKey(ctx, token) {
|
|
293
|
+
try {
|
|
294
|
+
const cloud = new Shardflux({ ...ctx.accountOptions(), apiKey: token });
|
|
295
|
+
const me = await cloud.me();
|
|
296
|
+
if (me.type === 'api_key' && me.api_key)
|
|
297
|
+
return { ok: true, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id };
|
|
298
|
+
return { ok: false, reason: 'not a project API key' };
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (err instanceof ShardfluxApiError && err.status === 401)
|
|
302
|
+
return { ok: false, reason: 'revoked or expired' };
|
|
303
|
+
if (err instanceof Error && /apiKey must be/.test(err.message))
|
|
304
|
+
return { ok: false, reason: 'malformed' };
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function toolsFrom(values) {
|
|
309
|
+
const tools = strs(values.tool);
|
|
310
|
+
if (values['read-only'] === true) {
|
|
311
|
+
if (tools.length)
|
|
312
|
+
throw new UsageError('use --tool or --read-only, not both');
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
for (const t of tools)
|
|
316
|
+
if (!TOOL_NAMES.includes(t))
|
|
317
|
+
throw new UsageError(`--tool must be one of ${TOOL_NAMES.join(', ')} (got "${t}")`);
|
|
318
|
+
return tools.length ? [...new Set(tools)] : [...TOOL_NAMES];
|
|
319
|
+
}
|
|
320
|
+
/** `--expires 30d|90d|365d|never` (any 1-366 days). */
|
|
321
|
+
function expiresFrom(values, now = Date.now()) {
|
|
322
|
+
const t = str(values.expires)?.trim().toLowerCase();
|
|
323
|
+
if (t === undefined || t === 'never')
|
|
324
|
+
return undefined;
|
|
325
|
+
const m = /^(\d+)d$/.exec(t);
|
|
326
|
+
const days = m ? Number(m[1]) : NaN;
|
|
327
|
+
if (!m || days < 1 || days > 366)
|
|
328
|
+
throw new UsageError(`--expires must be a number of days (1d-366d, e.g. 30d, 90d, 365d) or never (got "${str(values.expires)}")`);
|
|
329
|
+
return new Date(now + days * 86_400_000).toISOString();
|
|
330
|
+
}
|
|
331
|
+
const storedKey = (k, secret) => ({ token: secret, id: k.id, key_id: k.key_id, project_id: k.project_id, name: k.name });
|
|
332
|
+
/**
|
|
333
|
+
* The organization name setup creates: "<display name or email local part>'s organization", within the API's rules
|
|
334
|
+
* for names (1-200 characters, no control characters or line breaks).
|
|
335
|
+
*/
|
|
336
|
+
export function defaultOrgName(user) {
|
|
337
|
+
const clean = (s) => (s ?? '').replace(/[\p{C}\p{Zl}\p{Zp}]+/gu, ' ').trim();
|
|
338
|
+
const who = [...(clean(user.display_name) || clean(user.email.split('@')[0]) || 'My')].slice(0, 180).join('').trim();
|
|
339
|
+
return `${who}'s organization`;
|
|
340
|
+
}
|
|
341
|
+
/** One line that sets `name` for `shell`, quoted so any value round-trips. */
|
|
342
|
+
export function exportLine(shell, name, value) {
|
|
343
|
+
switch (shell) {
|
|
344
|
+
case 'fish':
|
|
345
|
+
return `set -gx ${name} '${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
346
|
+
case 'powershell':
|
|
347
|
+
return `$env:${name} = '${value.replace(/'/g, "''")}'`;
|
|
348
|
+
default:
|
|
349
|
+
return `export ${name}='${value.replace(/'/g, `'\\''`)}'`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ---- audit filters ---------------------------------------------------------------------------------------------
|
|
353
|
+
const AGO = /^(\d+)(m|h|d|w)$/;
|
|
354
|
+
/** An RFC 3339 time, or a duration ago (`90m`, `24h`, `7d`, `2w`). */
|
|
355
|
+
export function parseTime(text, option, now = Date.now()) {
|
|
356
|
+
const t = text.trim();
|
|
357
|
+
const m = AGO.exec(t);
|
|
358
|
+
if (m)
|
|
359
|
+
return new Date(now - Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2]]).toISOString();
|
|
360
|
+
const ms = /^\d{4}-\d{2}-\d{2}/.test(t) ? Date.parse(t) : NaN;
|
|
361
|
+
if (Number.isNaN(ms))
|
|
362
|
+
throw new UsageError(`--${option} must be an RFC 3339 time (e.g. 2026-09-01T00:00:00Z) or a duration ago such as 24h or 7d (got "${text}")`);
|
|
363
|
+
return new Date(ms).toISOString();
|
|
364
|
+
}
|
|
365
|
+
function auditFilters(values) {
|
|
366
|
+
const f = {};
|
|
367
|
+
const source = oneOf(values, 'source', ['app', 'cell_tool']);
|
|
368
|
+
const actorType = oneOf(values, 'actor-type', ['user', 'api_key', 'system', 'operator', 'anonymous']);
|
|
369
|
+
const outcome = oneOf(values, 'outcome', ['success', 'failure', 'denied']);
|
|
370
|
+
if (source)
|
|
371
|
+
f.source = source;
|
|
372
|
+
if (actorType)
|
|
373
|
+
f.actorType = actorType;
|
|
374
|
+
if (outcome)
|
|
375
|
+
f.outcome = outcome;
|
|
376
|
+
const ids = [
|
|
377
|
+
['actor', 'actorId'],
|
|
378
|
+
['target', 'targetId'],
|
|
379
|
+
['project-id', 'projectId'],
|
|
380
|
+
['workspace', 'workspaceId'],
|
|
381
|
+
['agent-session', 'agentSessionId'],
|
|
382
|
+
];
|
|
383
|
+
for (const [option, key] of ids) {
|
|
384
|
+
const v = str(values[option]);
|
|
385
|
+
if (v === undefined)
|
|
386
|
+
continue;
|
|
387
|
+
if (!UUID.test(v))
|
|
388
|
+
throw new UsageError(`--${option} must be an id (UUID), got "${v}"`);
|
|
389
|
+
f[key] = v;
|
|
390
|
+
}
|
|
391
|
+
const action = str(values.action);
|
|
392
|
+
if (action !== undefined) {
|
|
393
|
+
if (!/^[a-z0-9_.]{1,120}$/.test(action))
|
|
394
|
+
throw new UsageError('--action must be an action prefix such as workspace. or tool.exec');
|
|
395
|
+
f.action = action;
|
|
396
|
+
}
|
|
397
|
+
const targetType = str(values['target-type']);
|
|
398
|
+
if (targetType !== undefined) {
|
|
399
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(targetType))
|
|
400
|
+
throw new UsageError('--target-type must be a type such as workspace or api_key');
|
|
401
|
+
f.targetType = targetType;
|
|
402
|
+
}
|
|
403
|
+
const from = str(values.from);
|
|
404
|
+
const to = str(values.to);
|
|
405
|
+
if (from !== undefined)
|
|
406
|
+
f.from = parseTime(from, 'from');
|
|
407
|
+
if (to !== undefined)
|
|
408
|
+
f.to = parseTime(to, 'to');
|
|
409
|
+
return f;
|
|
410
|
+
}
|
|
411
|
+
// ---- billing ---------------------------------------------------------------------------------------------------
|
|
412
|
+
const OVERAGE_ACTIONS = ['status', 'on', 'off', 'cap'];
|
|
413
|
+
/** A spend cap in dollars ("9", "9.5", "9.50", "$9") as minor units (cents); at most two decimals. */
|
|
414
|
+
export function dollarsToMinor(text, what) {
|
|
415
|
+
const m = /^\$?(\d{1,7})(?:\.(\d{1,2}))?$/.exec(text.trim());
|
|
416
|
+
if (!m)
|
|
417
|
+
throw new UsageError(`${what} must be an amount in dollars such as 9 or 9.50 (got "${text}")`);
|
|
418
|
+
return Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
|
|
419
|
+
}
|
|
420
|
+
function purchasablePlans(catalog) {
|
|
421
|
+
return catalog.plans.filter((p) => p.prices.some((x) => x.purchasable)).map((p) => p.plan_key);
|
|
422
|
+
}
|
|
423
|
+
function priceText(catalog, planKey) {
|
|
424
|
+
const plan = catalog.plans.find((p) => p.plan_key === planKey);
|
|
425
|
+
const price = plan?.prices.find((x) => x.purchasable) ?? plan?.prices[0];
|
|
426
|
+
return price ? `${money(price.amount_minor, price.currency)}/${price.interval}` : '-';
|
|
427
|
+
}
|
|
428
|
+
/** Polls the checkout (the SDK's checkoutStatus) until active, ended, or the timeout; progress on stderr. */
|
|
429
|
+
async function waitForCheckout(ctx, account, organizationId, checkout, timeoutMs) {
|
|
430
|
+
const started = Date.now();
|
|
431
|
+
let last = '';
|
|
432
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
433
|
+
const timer = setTimeout(done, ms);
|
|
434
|
+
function done() {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
ctx.signal.removeEventListener('abort', done);
|
|
437
|
+
resolve();
|
|
438
|
+
}
|
|
439
|
+
ctx.signal.addEventListener('abort', done, { once: true });
|
|
440
|
+
});
|
|
441
|
+
if (!ctx.json)
|
|
442
|
+
ctx.io.stderr.write(`Waiting for the payment (up to ${Math.round(timeoutMs / 1000)} s; Ctrl-C stops waiting, the checkout stays open)...\n`);
|
|
443
|
+
for (;;) {
|
|
444
|
+
let s;
|
|
445
|
+
try {
|
|
446
|
+
s = await account.billing.checkoutStatus(organizationId, checkout.id);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (ctx.signal.aborted)
|
|
450
|
+
throw new InterruptedError(`Interrupted; checkout ${checkout.id} stays open.`);
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
const phase = s.subscription_active ? 'active' : s.status;
|
|
454
|
+
if (phase !== last && !ctx.json) {
|
|
455
|
+
const text = s.subscription_active ? 'subscription active' : s.status === 'completed' ? 'paid; waiting for Stripe to confirm the subscription' : s.status === 'open' ? 'checkout open, not paid yet' : `checkout ${s.status}`;
|
|
456
|
+
ctx.io.stderr.write(` ${text}\n`);
|
|
457
|
+
}
|
|
458
|
+
last = phase;
|
|
459
|
+
if (s.subscription_active)
|
|
460
|
+
return s;
|
|
461
|
+
if (s.status === 'expired' || s.status === 'canceled' || s.status === 'failed')
|
|
462
|
+
throw new CheckoutEndedError(s);
|
|
463
|
+
const waited = Date.now() - started;
|
|
464
|
+
if (waited >= timeoutMs)
|
|
465
|
+
throw new CheckoutTimeoutError(s, waited);
|
|
466
|
+
await sleep(Math.min(CHECKOUT_POLL_MS, timeoutMs - waited));
|
|
467
|
+
if (ctx.signal.aborted)
|
|
468
|
+
throw new InterruptedError(`Interrupted; checkout ${checkout.id} stays open.`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// ---- egress and volumes (project API key) ------------------------------------------------------------------------
|
|
472
|
+
async function egressTarget(ctx, values) {
|
|
473
|
+
const ws = str(values.workspace);
|
|
474
|
+
const project = str(values.project);
|
|
475
|
+
if (ws !== undefined && project !== undefined)
|
|
476
|
+
throw new UsageError('use --project or --workspace, not both');
|
|
477
|
+
if (ws !== undefined)
|
|
478
|
+
return { kind: 'workspace', ws: await resolveWorkspace(ctx, ws) };
|
|
479
|
+
if (project !== undefined) {
|
|
480
|
+
if (!UUID.test(project))
|
|
481
|
+
throw new UsageError('--project must be a project id (UUID)');
|
|
482
|
+
return { kind: 'project', id: project };
|
|
483
|
+
}
|
|
484
|
+
return { kind: 'project', id: (await principal(ctx)).project_id };
|
|
485
|
+
}
|
|
486
|
+
const EGRESS_MODES = ['allow_all', 'allowlist', 'deny_all'];
|
|
487
|
+
async function readPolicy(ctx, file) {
|
|
488
|
+
const text = Buffer.from(await readInput(ctx, file, 256 * 1024, '--file')).toString('utf8');
|
|
489
|
+
let doc;
|
|
490
|
+
try {
|
|
491
|
+
doc = JSON.parse(text);
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
throw new UsageError(`--file: not valid JSON (${e instanceof Error ? e.message : String(e)})`);
|
|
495
|
+
}
|
|
496
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc))
|
|
497
|
+
throw new UsageError('--file: expected a JSON object {"mode": ..., "rules": [...], "cidrs": [...]}');
|
|
498
|
+
const mode = doc.mode;
|
|
499
|
+
if (typeof mode !== 'string' || !EGRESS_MODES.includes(mode))
|
|
500
|
+
throw new UsageError(`--file: "mode" must be one of ${EGRESS_MODES.join(', ')}`);
|
|
501
|
+
return doc;
|
|
502
|
+
}
|
|
503
|
+
function ifVersion(values) {
|
|
504
|
+
const v = str(values['if-version']);
|
|
505
|
+
if (v === undefined)
|
|
506
|
+
return {};
|
|
507
|
+
if (!/^\d+$/.test(v))
|
|
508
|
+
throw new UsageError('--if-version must be a version number (from shard egress get)');
|
|
509
|
+
return { ifMatch: Number(v) };
|
|
510
|
+
}
|
|
511
|
+
async function resolveVolume(ctx, ref) {
|
|
512
|
+
const cloud = ctx.cloud();
|
|
513
|
+
if (UUID.test(ref)) {
|
|
514
|
+
try {
|
|
515
|
+
return await cloud.volumes.get(ref);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
if (!(err instanceof ShardfluxApiError && err.status === 404))
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for await (const v of cloud.volumes.listAll({ includeOrgShared: true }))
|
|
523
|
+
if ((v.id === ref || v.name === ref) && v.deleted_at === null)
|
|
524
|
+
return v;
|
|
525
|
+
throw new NotFoundError(`No volume with id or name "${ref}" in this project.`, 'list them: shard volumes list --include-org-shared');
|
|
526
|
+
}
|
|
527
|
+
const volumeWait = (ctx, values) => (values.wait === false ? false : { timeoutMs: waitTimeout(values), signal: ctx.signal });
|
|
528
|
+
async function waitedVolume(ctx, call) {
|
|
529
|
+
try {
|
|
530
|
+
return await call();
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
if (ctx.signal.aborted)
|
|
534
|
+
throw new InterruptedError('Interrupted; the volume operation continues server side.', ctx.traces.operationId ?? undefined);
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ---- handlers --------------------------------------------------------------------------------------------------
|
|
539
|
+
export const ACCOUNT_HANDLERS = {
|
|
540
|
+
async 'auth register'(ctx, values) {
|
|
541
|
+
const email = emailArg(need(values, 'email', '--email you@example.com'), '--email');
|
|
542
|
+
const name = str(values.name)?.trim();
|
|
543
|
+
const password = await ctx.input.newPassword(`Choose a password for ${email}: `, 'The password', pipe(`shard auth register --email ${email}`));
|
|
544
|
+
const r = await ShardfluxAccount.register({ ...ctx.accountOptions(), email, password, ...(name ? { displayName: name } : {}) });
|
|
545
|
+
const next = [`shard auth verify-email '<link from the email>'`, `${pipe(`shard auth login --email ${email}`)}`];
|
|
546
|
+
out(ctx, `Registration received for ${email}.\n` +
|
|
547
|
+
`Check that inbox for a verification link (valid 24 hours). If the address already has an account, no new one is created: sign in instead.\n` +
|
|
548
|
+
`next:\n 1. open the link, or: ${next[0]}\n 2. ${next[1]}\n`, { status: r.status, email, next_steps: next });
|
|
549
|
+
return 0;
|
|
550
|
+
},
|
|
551
|
+
async 'auth verify-email'(ctx, _values, [link]) {
|
|
552
|
+
const token = linkToken(link);
|
|
553
|
+
const r = await ShardfluxAccount.verifyEmail(token, ctx.accountOptions());
|
|
554
|
+
const login = pipe('shard auth login --email <you@example.com>');
|
|
555
|
+
if (r.status === 'verified') {
|
|
556
|
+
out(ctx, `Email verified. Sign in: ${login}\n`, { status: 'verified', password_set: false, next_steps: [login] });
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
// A resend link: the account needs a password before anyone can sign in.
|
|
560
|
+
const reset = r.reset_token ?? '';
|
|
561
|
+
ctx.addSecret(reset);
|
|
562
|
+
const later = `printf '%s\\n' "$NEW_PASSWORD" | shard auth password reset-confirm ${reset}`;
|
|
563
|
+
let password;
|
|
564
|
+
if (ctx.input.interactive)
|
|
565
|
+
password = await ctx.input.newPassword('Choose a new password: ', 'The new password', later);
|
|
566
|
+
else {
|
|
567
|
+
const line = await ctx.input.line();
|
|
568
|
+
password = line && line.length > 0 ? line : null;
|
|
569
|
+
}
|
|
570
|
+
if (password === null) {
|
|
571
|
+
out(ctx, `Email verified. The account needs a new password before you can sign in (the link was a resend). Set it with:\n ${later}\n`, { status: 'password_reset_required', reset_token: reset, next_steps: [later, login] });
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
await ShardfluxAccount.confirmPasswordReset({ token: reset, newPassword: password }, ctx.accountOptions());
|
|
575
|
+
out(ctx, `Email verified and the new password set. Sign in: ${login}\n`, { status: 'verified', password_set: true, next_steps: [login] });
|
|
576
|
+
return 0;
|
|
577
|
+
},
|
|
578
|
+
async 'auth resend-verification'(ctx) {
|
|
579
|
+
const account = ctx.account();
|
|
580
|
+
await account.auth.resendVerification();
|
|
581
|
+
out(ctx, 'A new verification link is on its way (unless the email is verified already). Then: shard auth verify-email \'<link>\'\n', { status: 'accepted' });
|
|
582
|
+
return 0;
|
|
583
|
+
},
|
|
584
|
+
async 'auth login'(ctx, values) {
|
|
585
|
+
const email = emailArg(need(values, 'email', '--email you@example.com'), '--email');
|
|
586
|
+
let factor = factorFlags(values);
|
|
587
|
+
const password = await ctx.input.secret(`Password for ${email}: `, 'The password', pipe(`shard auth login --email ${email}`));
|
|
588
|
+
let latest = null;
|
|
589
|
+
const { account, result } = await ShardfluxAccount.login({
|
|
590
|
+
...ctx.accountOptions(),
|
|
591
|
+
email,
|
|
592
|
+
password,
|
|
593
|
+
onSessionToken: (s) => {
|
|
594
|
+
ctx.addSecret(s.token);
|
|
595
|
+
latest = s;
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
const replaced = ctx.store.profile(ctx.apiUrl).session?.token ?? null;
|
|
599
|
+
const session = () => ({ token: account.sessionToken ?? '', expires_at: latest?.expiresAt || result.session_expires_at || null, user: null, state: 'active' });
|
|
600
|
+
let user = result.user;
|
|
601
|
+
if (result.status === 'mfa_required') {
|
|
602
|
+
if (factor === null && ctx.input.interactive)
|
|
603
|
+
factor = factorOf(await ctx.input.answer('Authenticator code (or a recovery code): '));
|
|
604
|
+
if (factor === null) {
|
|
605
|
+
const { switched } = ctx.saveSession({ ...session(), state: 'pending_mfa' }, email);
|
|
606
|
+
if (replaced && replaced !== account.sessionToken)
|
|
607
|
+
await endQuietly(ctx, replaced);
|
|
608
|
+
if (switched && !ctx.json)
|
|
609
|
+
ctx.io.stderr.write(SWITCHED_NOTE);
|
|
610
|
+
throw new MfaPendingError(`Password accepted for ${email}; two-factor authentication is 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>');
|
|
611
|
+
}
|
|
612
|
+
user = (await account.auth.completeMfa(factor)).user;
|
|
613
|
+
}
|
|
614
|
+
if (!user)
|
|
615
|
+
throw new UsageError('the API signed you in without a user; run shard auth status');
|
|
616
|
+
const saved = { ...session(), user: { id: user.id, email: user.email } };
|
|
617
|
+
const { switched } = ctx.saveSession(saved, email);
|
|
618
|
+
if (replaced && replaced !== saved.token)
|
|
619
|
+
await endQuietly(ctx, replaced);
|
|
620
|
+
if (switched && !ctx.json)
|
|
621
|
+
ctx.io.stderr.write(SWITCHED_NOTE);
|
|
622
|
+
return signedInReport(ctx, account, user, saved);
|
|
623
|
+
},
|
|
624
|
+
async 'auth mfa'(ctx, values) {
|
|
625
|
+
let factor = factorFlags(values);
|
|
626
|
+
if (factor === null && ctx.input.interactive)
|
|
627
|
+
factor = factorOf(await ctx.input.answer('Authenticator code (or a recovery code): '));
|
|
628
|
+
if (factor === null)
|
|
629
|
+
throw new UsageError('pass the code of your authenticator app: shard auth mfa --code <code> (or --recovery-code <code>)');
|
|
630
|
+
const account = ctx.account();
|
|
631
|
+
const r = await account.auth.completeMfa(factor);
|
|
632
|
+
const user = r.user;
|
|
633
|
+
if (!user)
|
|
634
|
+
throw new UsageError('the API completed the sign-in without a user; run shard auth status');
|
|
635
|
+
const saved = { token: account.sessionToken ?? '', expires_at: r.session_expires_at ?? null, user: { id: user.id, email: user.email }, state: 'active' };
|
|
636
|
+
ctx.saveSession(saved);
|
|
637
|
+
return signedInReport(ctx, account, user, saved);
|
|
638
|
+
},
|
|
639
|
+
async 'auth status'(ctx) {
|
|
640
|
+
const source = ctx.sessionSource();
|
|
641
|
+
let session = null;
|
|
642
|
+
const lines = [
|
|
643
|
+
['api', ctx.apiUrl],
|
|
644
|
+
['credentials', ctx.store.file],
|
|
645
|
+
];
|
|
646
|
+
if (source === null) {
|
|
647
|
+
lines.push(['session', 'not signed in (shard auth login --email <you@example.com>)']);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
const account = ctx.account();
|
|
651
|
+
const s = await account.auth.session();
|
|
652
|
+
const from = source === 'env' ? 'SHARDFLUX_SESSION_TOKEN' : 'the credentials file';
|
|
653
|
+
if (s.state === 'anonymous') {
|
|
654
|
+
if (source === 'file') {
|
|
655
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
656
|
+
delete p.session;
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
session = { source, state: 'invalid' };
|
|
660
|
+
lines.push(['session', `not signed in: the session from ${from} expired or was revoked${source === 'file' ? ' (removed)' : ''}; shard auth login --email <you@example.com>`]);
|
|
661
|
+
}
|
|
662
|
+
else if (s.state === 'mfa_required') {
|
|
663
|
+
session = { source, state: 'mfa_required', session: s.session };
|
|
664
|
+
lines.push(['session', `waiting for the second factor (from ${from}): shard auth mfa --code <code>`]);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
const u = s.user;
|
|
668
|
+
const x = s.session;
|
|
669
|
+
session = { source, state: 'authenticated', user: u, session: x };
|
|
670
|
+
lines.push(['session', `${userLine(u)}, from ${from}`], ['email', u.email_verified ? 'verified' : 'NOT verified (shard auth verify-email \'<link>\')'], ['two-factor', u.mfa_enabled ? 'on' : 'off'], ['expires', `${time(x.absolute_expires_at)} (idle expiry ${time(x.idle_expires_at)})`], ['step-up', x.step_up_valid_until && Date.parse(x.step_up_valid_until) > Date.now() ? `valid until ${time(x.step_up_valid_until)}` : 'none (shard auth step-up)']);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
let apiKey = null;
|
|
674
|
+
try {
|
|
675
|
+
const k = ctx.apiKey();
|
|
676
|
+
const keyId = k.token.split('_')[1] ?? '';
|
|
677
|
+
const from = k.source === 'env' ? 'SHARDFLUX_API_KEY' : 'the credentials file';
|
|
678
|
+
let valid = null;
|
|
679
|
+
let info = {};
|
|
680
|
+
try {
|
|
681
|
+
const me = await ctx.cloud().me();
|
|
682
|
+
valid = me.type === 'api_key';
|
|
683
|
+
if (me.api_key)
|
|
684
|
+
info = { organization_id: me.api_key.organization_id, project_id: me.api_key.project_id, tool_permissions: me.api_key.tool_permissions };
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
if (!(err instanceof ShardfluxApiError && err.status === 401))
|
|
688
|
+
throw err;
|
|
689
|
+
valid = false;
|
|
690
|
+
}
|
|
691
|
+
apiKey = { source: k.source, key_id: keyId, ...(k.saved ? { id: k.saved.id, name: k.saved.name } : {}), valid, ...info };
|
|
692
|
+
const where = info.project_id ? `, project ${info.project_id}` : k.saved ? `, project ${k.saved.project_id}` : '';
|
|
693
|
+
lines.push(['api key', `${keyId}${k.saved ? ` "${k.saved.name}"` : ''}${where}, from ${from}: ${valid ? `works (tools: ${toolsText(info.tool_permissions ?? [])})` : 'rejected (revoked or expired; shard setup --new-key)'}`]);
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
if (!(err instanceof AuthConfigError))
|
|
697
|
+
throw err;
|
|
698
|
+
if (/is not set/.test(err.message))
|
|
699
|
+
lines.push(['api key', 'none (shard setup saves one)']);
|
|
700
|
+
else {
|
|
701
|
+
apiKey = { source: ctx.io.env.SHARDFLUX_API_KEY?.trim() ? 'env' : 'file', valid: false, error: err.message };
|
|
702
|
+
lines.push(['api key', err.message]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
706
|
+
const defaults = { organization_id: profile.organization_id ?? null, project_id: profile.project_id ?? null };
|
|
707
|
+
lines.push(['defaults', `organization ${defaults.organization_id ?? '-'}, project ${defaults.project_id ?? '-'}`]);
|
|
708
|
+
out(ctx, kv(lines), { api_url: ctx.apiUrl, credentials_file: ctx.store.file, session, api_key: apiKey, defaults });
|
|
709
|
+
return 0;
|
|
710
|
+
},
|
|
711
|
+
async 'auth logout'(ctx, values) {
|
|
712
|
+
const source = ctx.sessionSource();
|
|
713
|
+
if (source === null) {
|
|
714
|
+
out(ctx, 'Not signed in.\n', { signed_out: false, reason: 'not_signed_in' });
|
|
715
|
+
return 0;
|
|
716
|
+
}
|
|
717
|
+
const account = ctx.account();
|
|
718
|
+
let ended = true;
|
|
719
|
+
try {
|
|
720
|
+
if (values.all === true)
|
|
721
|
+
await account.auth.logoutAll();
|
|
722
|
+
else
|
|
723
|
+
await account.auth.logout();
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
if (!(err instanceof ShardfluxApiError && err.status === 401))
|
|
727
|
+
throw err;
|
|
728
|
+
ended = false;
|
|
729
|
+
}
|
|
730
|
+
if (source === 'file') {
|
|
731
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
732
|
+
delete p.session;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
const hasKey = ctx.store.profile(ctx.apiUrl).api_key !== undefined;
|
|
736
|
+
const human = `${ended ? 'Signed out' : 'The session had already ended'}${values.all === true && ended ? '; every session of your account ended' : ''}.\n` +
|
|
737
|
+
(hasKey ? 'The saved API key stays, so workspace commands keep working (remove it: shard api-keys revoke <id> --yes).\n' : '') +
|
|
738
|
+
(source === 'env' ? 'SHARDFLUX_SESSION_TOKEN is still set: unset it.\n' : '');
|
|
739
|
+
out(ctx, human, { signed_out: true, all: values.all === true, session_was_active: ended, api_key_kept: hasKey });
|
|
740
|
+
return 0;
|
|
741
|
+
},
|
|
742
|
+
async 'auth sessions'(ctx, values) {
|
|
743
|
+
const account = ctx.account();
|
|
744
|
+
const revoke = str(values.revoke);
|
|
745
|
+
if (revoke !== undefined) {
|
|
746
|
+
if (!UUID.test(revoke))
|
|
747
|
+
throw new UsageError('--revoke must be a session id (UUID) from shard auth sessions');
|
|
748
|
+
const current = (await account.auth.sessions()).data.find((s) => s.current);
|
|
749
|
+
await account.auth.revokeSession(revoke);
|
|
750
|
+
const self = current?.id === revoke;
|
|
751
|
+
if (self && ctx.sessionSource() === 'file') {
|
|
752
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
753
|
+
delete p.session;
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
out(ctx, `Ended session ${revoke}${self ? ' (this one: you are signed out here)' : ''}.\n`, { revoked: revoke, current: self });
|
|
757
|
+
return 0;
|
|
758
|
+
}
|
|
759
|
+
const page = await account.auth.sessions();
|
|
760
|
+
out(ctx, page.data.length ? authSessionTable(page.data) : 'No live sessions.\n', { data: page.data, next_cursor: page.next_cursor });
|
|
761
|
+
return 0;
|
|
762
|
+
},
|
|
763
|
+
async 'auth step-up'(ctx, values) {
|
|
764
|
+
const account = ctx.account();
|
|
765
|
+
const r = await stepUp(ctx, account, factorFlags(values), 'shard auth step-up');
|
|
766
|
+
out(ctx, `Step-up confirmed until ${time(r.step_up_valid_until)}.\n`, { step_up_valid_until: r.step_up_valid_until });
|
|
767
|
+
return 0;
|
|
768
|
+
},
|
|
769
|
+
async 'auth password change'(ctx) {
|
|
770
|
+
const account = ctx.account();
|
|
771
|
+
const hint = `printf '%s\\n%s\\n' "$CURRENT" "$NEW" | shard auth password change`;
|
|
772
|
+
const current = await ctx.input.secret('Current password: ', 'The current password', hint);
|
|
773
|
+
const next = await ctx.input.newPassword('New password: ', 'The new password', hint);
|
|
774
|
+
await account.auth.changePassword({ currentPassword: current, newPassword: next });
|
|
775
|
+
out(ctx, 'Password changed. Your other sessions were signed out; this one continues.\n', { status: 'password_changed' });
|
|
776
|
+
return 0;
|
|
777
|
+
},
|
|
778
|
+
async 'auth password reset'(ctx, values) {
|
|
779
|
+
const email = emailArg(need(values, 'email', '--email you@example.com'), '--email');
|
|
780
|
+
await ShardfluxAccount.requestPasswordReset(email, ctx.accountOptions());
|
|
781
|
+
const next = `printf '%s\\n' "$NEW_PASSWORD" | shard auth password reset-confirm '<link from the email>'`;
|
|
782
|
+
out(ctx, `If ${email} has an account, a reset link is on its way. Then:\n ${next}\n`, { status: 'accepted', email, next_steps: [next] });
|
|
783
|
+
return 0;
|
|
784
|
+
},
|
|
785
|
+
async 'auth password reset-confirm'(ctx, _values, [link]) {
|
|
786
|
+
const token = linkToken(link);
|
|
787
|
+
const password = await ctx.input.newPassword('New password: ', 'The new password', `printf '%s\\n' "$NEW_PASSWORD" | shard auth password reset-confirm '<link>'`);
|
|
788
|
+
await ShardfluxAccount.confirmPasswordReset({ token, newPassword: password }, ctx.accountOptions());
|
|
789
|
+
const login = pipe('shard auth login --email <you@example.com>');
|
|
790
|
+
out(ctx, `Password set; every session of the account was signed out. Sign in: ${login}\n`, { status: 'password_reset', next_steps: [login] });
|
|
791
|
+
return 0;
|
|
792
|
+
},
|
|
793
|
+
async 'auth email change'(ctx, values) {
|
|
794
|
+
const email = emailArg(need(values, 'email', '--email new@example.com'), '--email');
|
|
795
|
+
const account = ctx.account();
|
|
796
|
+
await withStepUp(ctx, account, () => account.auth.changeEmail(email));
|
|
797
|
+
out(ctx, `A confirmation link was sent to ${email}. Confirm with: shard auth email confirm '<link>'\n`, { status: 'accepted', email });
|
|
798
|
+
return 0;
|
|
799
|
+
},
|
|
800
|
+
async 'auth email confirm'(ctx, _values, [link]) {
|
|
801
|
+
const r = await ShardfluxAccount.confirmEmailChange(linkToken(link), ctx.accountOptions());
|
|
802
|
+
out(ctx, 'Your sign-in email is changed. Sign in with the new address from now on.\n', r);
|
|
803
|
+
return 0;
|
|
804
|
+
},
|
|
805
|
+
async 'auth totp enroll'(ctx) {
|
|
806
|
+
const account = ctx.account();
|
|
807
|
+
const e = await withStepUp(ctx, account, () => account.auth.totp.enroll());
|
|
808
|
+
ctx.addSecret(e.secret);
|
|
809
|
+
out(ctx, `Add this to your authenticator app (scan the URI as a QR code, or type the secret):\n` +
|
|
810
|
+
kv([
|
|
811
|
+
['secret', e.secret],
|
|
812
|
+
['uri', e.otpauth_uri],
|
|
813
|
+
]) +
|
|
814
|
+
'Then turn it on with a current code: shard auth totp confirm --code <code>\n', e);
|
|
815
|
+
return 0;
|
|
816
|
+
},
|
|
817
|
+
async 'auth totp confirm'(ctx, values) {
|
|
818
|
+
const code = need(values, 'code', '--code 123456');
|
|
819
|
+
if (!TOTP.test(code))
|
|
820
|
+
throw new UsageError('--code must be the 6-digit code of your authenticator app');
|
|
821
|
+
const account = ctx.account();
|
|
822
|
+
const r = await account.auth.totp.confirm(code);
|
|
823
|
+
out(ctx, `Two-factor authentication is on. Recovery codes (each works once; store them now, they are not shown again):\n${r.recovery_codes.map((c) => ` ${c}`).join('\n')}\n`, { status: 'enabled', recovery_codes: r.recovery_codes });
|
|
824
|
+
return 0;
|
|
825
|
+
},
|
|
826
|
+
async 'auth totp disable'(ctx, values) {
|
|
827
|
+
const account = ctx.account();
|
|
828
|
+
const factor = factorFlags(values);
|
|
829
|
+
// Disabling needs a step-up with a second factor: done first when a code was given, else on demand.
|
|
830
|
+
if (factor !== null)
|
|
831
|
+
await stepUp(ctx, account, factor, 'shard auth totp disable --code <code>');
|
|
832
|
+
await withStepUp(ctx, account, () => account.auth.totp.disable());
|
|
833
|
+
out(ctx, 'Two-factor authentication is off; recovery codes stopped working and your other sessions were signed out.\n', { status: 'disabled' });
|
|
834
|
+
return 0;
|
|
835
|
+
},
|
|
836
|
+
async 'auth totp recovery-codes'(ctx) {
|
|
837
|
+
const account = ctx.account();
|
|
838
|
+
const r = await withStepUp(ctx, account, () => account.auth.totp.regenerateRecoveryCodes());
|
|
839
|
+
out(ctx, `New recovery codes (the old ones stopped working; store these now):\n${r.recovery_codes.map((c) => ` ${c}`).join('\n')}\n`, r);
|
|
840
|
+
return 0;
|
|
841
|
+
},
|
|
842
|
+
async setup(ctx, values) {
|
|
843
|
+
const account = ctx.account();
|
|
844
|
+
const me = await account.me();
|
|
845
|
+
if (me.type !== 'user' || !me.user)
|
|
846
|
+
throw new UsageError('setup needs a user session: shard auth login');
|
|
847
|
+
const tools = toolsFrom(values);
|
|
848
|
+
const profile = ctx.store.profile(ctx.apiUrl);
|
|
849
|
+
const created = { organization: false, project: false, api_key: false };
|
|
850
|
+
const flagged = str(values.org) !== undefined || str(values.project) !== undefined;
|
|
851
|
+
let keyCheck = null;
|
|
852
|
+
if (profile.api_key && values['new-key'] !== true) {
|
|
853
|
+
keyCheck = await checkKey(ctx, profile.api_key.token);
|
|
854
|
+
if (!keyCheck.ok && !ctx.json)
|
|
855
|
+
ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} no longer works (${keyCheck.reason}); creating a new one.\n`);
|
|
856
|
+
}
|
|
857
|
+
let org;
|
|
858
|
+
let project;
|
|
859
|
+
// The working saved key's own organization and project, when this user can see them.
|
|
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) };
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
if (!gone(err))
|
|
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' };
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (home) {
|
|
874
|
+
org = home.org;
|
|
875
|
+
project = home.project;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
const orgs = await allOrgs(account);
|
|
879
|
+
const orgRef = str(values.org);
|
|
880
|
+
const savedOrg = profile.organization_id ? orgs.find((o) => o.id === profile.organization_id) : undefined;
|
|
881
|
+
if (orgRef !== undefined)
|
|
882
|
+
org = await findOrg(account, orgRef);
|
|
883
|
+
else if (savedOrg)
|
|
884
|
+
org = savedOrg;
|
|
885
|
+
else if (orgs.length === 1)
|
|
886
|
+
org = orgs[0];
|
|
887
|
+
else if (orgs.length === 0) {
|
|
888
|
+
org = await account.organizations.create({ name: str(values['org-name'])?.trim() || defaultOrgName(me.user) });
|
|
889
|
+
created.organization = true;
|
|
890
|
+
}
|
|
891
|
+
else
|
|
892
|
+
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>');
|
|
893
|
+
const projects = created.organization ? [] : await allProjects(account, org.id);
|
|
894
|
+
const projectRef = str(values.project);
|
|
895
|
+
const savedProject = profile.project_id ? projects.find((p) => p.id === profile.project_id) : undefined;
|
|
896
|
+
if (projectRef !== undefined) {
|
|
897
|
+
const hit = byRef(projects, projectRef);
|
|
898
|
+
if (!hit)
|
|
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;
|
|
909
|
+
}
|
|
910
|
+
else
|
|
911
|
+
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>');
|
|
912
|
+
}
|
|
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
|
+
else {
|
|
919
|
+
const name = (str(values['key-name'])?.trim() || `shard-cli ${ctx.hostname()}`).slice(0, 200);
|
|
920
|
+
const res = await account.apiKeys.create(project.id, { name, toolPermissions: tools });
|
|
921
|
+
ctx.addSecret(res.secret);
|
|
922
|
+
key = storedKey(res.api_key, res.secret);
|
|
923
|
+
permissions = res.api_key.tool_permissions;
|
|
924
|
+
created.api_key = true;
|
|
925
|
+
}
|
|
926
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
927
|
+
p.organization_id = org.id;
|
|
928
|
+
p.project_id = project.id;
|
|
929
|
+
p.api_key = key;
|
|
930
|
+
});
|
|
931
|
+
const check = keep ? keyCheck : await checkKey(ctx, key.token);
|
|
932
|
+
if (!check?.ok || check.projectId !== project.id)
|
|
933
|
+
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');
|
|
934
|
+
if (keep) {
|
|
935
|
+
const k = (await account.apiKeys.list(project.id, { limit: 200 })).data.find((x) => x.id === key.id);
|
|
936
|
+
if (k)
|
|
937
|
+
permissions = k.tool_permissions;
|
|
938
|
+
}
|
|
939
|
+
const tag = (made) => (made ? 'created' : 'existing');
|
|
940
|
+
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' : '';
|
|
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
|
+
});
|
|
956
|
+
return 0;
|
|
957
|
+
},
|
|
958
|
+
env(ctx, values) {
|
|
959
|
+
// No request: the saved (or environment) key as shell commands. A throw inside the executor rejects.
|
|
960
|
+
return new Promise((resolve) => {
|
|
961
|
+
const shell = oneOf(values, 'shell', ['sh', 'fish', 'powershell']) ?? 'sh';
|
|
962
|
+
const key = ctx.apiKey();
|
|
963
|
+
const vars = [['SHARDFLUX_API_KEY', key.token]];
|
|
964
|
+
if (ctx.apiUrl !== DEFAULT_API_URL)
|
|
965
|
+
vars.push(['SHARDFLUX_API_URL', ctx.apiUrl]);
|
|
966
|
+
out(ctx, `${vars.map(([n, v]) => exportLine(shell, n, v)).join('\n')}\n`, { shell, variables: Object.fromEntries(vars) });
|
|
967
|
+
resolve(0);
|
|
968
|
+
});
|
|
969
|
+
},
|
|
970
|
+
async 'orgs list'(ctx) {
|
|
971
|
+
const rows = await allOrgs(ctx.account());
|
|
972
|
+
const def = ctx.store.profile(ctx.apiUrl).organization_id ?? null;
|
|
973
|
+
out(ctx, rows.length ? orgTable(rows, def) : 'No organizations yet (shard setup creates one).\n', { data: rows, next_cursor: null, default_organization_id: def });
|
|
974
|
+
return 0;
|
|
975
|
+
},
|
|
976
|
+
async 'orgs create'(ctx, values, [name]) {
|
|
977
|
+
const account = ctx.account();
|
|
978
|
+
const slug = str(values.slug);
|
|
979
|
+
const org = await account.organizations.create({ name: name, ...(slug !== undefined ? { slug } : {}) });
|
|
980
|
+
if (values.use === true) {
|
|
981
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
982
|
+
p.organization_id = org.id;
|
|
983
|
+
delete p.project_id;
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
out(ctx, `Created organization ${org.name} (${org.slug}, ${org.id}); you are its owner.\n${values.use === true ? 'It is now the default.\n' : `Make it the default: shard orgs use ${org.slug}\n`}`, org);
|
|
987
|
+
return 0;
|
|
988
|
+
},
|
|
989
|
+
async 'orgs get'(ctx, values, [ref]) {
|
|
990
|
+
const account = ctx.account();
|
|
991
|
+
const org = await resolveOrg(ctx, account, values, ref);
|
|
992
|
+
const ent = await account.organizations.entitlements(org.id);
|
|
993
|
+
out(ctx, kv([
|
|
994
|
+
['id', org.id],
|
|
995
|
+
['name', org.name],
|
|
996
|
+
['slug', org.slug],
|
|
997
|
+
['your role', org.role ?? '-'],
|
|
998
|
+
['plan', ent.plan ? `${ent.plan.plan_key} (${ent.plan.source})` : '-'],
|
|
999
|
+
['created', time(org.created_at)],
|
|
1000
|
+
]), { organization: org, plan: ent.plan });
|
|
1001
|
+
return 0;
|
|
1002
|
+
},
|
|
1003
|
+
async 'orgs use'(ctx, values, [ref]) {
|
|
1004
|
+
const org = await resolveOrg(ctx, ctx.account(), values, ref);
|
|
1005
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
1006
|
+
if (p.organization_id !== org.id)
|
|
1007
|
+
delete p.project_id;
|
|
1008
|
+
p.organization_id = org.id;
|
|
1009
|
+
});
|
|
1010
|
+
out(ctx, `Default organization: ${org.name} (${org.slug}, ${org.id}).\n`, { default_organization_id: org.id, organization: org });
|
|
1011
|
+
return 0;
|
|
1012
|
+
},
|
|
1013
|
+
async 'orgs export'(ctx, values, [ref]) {
|
|
1014
|
+
const account = ctx.account();
|
|
1015
|
+
const org = await resolveOrg(ctx, account, values, ref);
|
|
1016
|
+
const created = await withStepUp(ctx, account, () => account.organizations.exports.create(org.id));
|
|
1017
|
+
const { meta, text } = await downloadExport(ctx, account, created, (id) => account.organizations.exports.get(org.id, id), (id) => account.organizations.exports.download(org.id, id, { signal: ctx.signal }));
|
|
1018
|
+
const path = str(values.out) ?? `./organization-export-${meta.id}.json`;
|
|
1019
|
+
const bytes = await writePrivate(path, text, values.force === true);
|
|
1020
|
+
out(ctx, dataExportText(meta, path), { export: meta, out: path, bytes });
|
|
1021
|
+
return 0;
|
|
1022
|
+
},
|
|
1023
|
+
async 'orgs workspaces'(ctx, values, [ref]) {
|
|
1024
|
+
const account = ctx.account();
|
|
1025
|
+
const org = await resolveOrg(ctx, account, values, ref);
|
|
1026
|
+
const projects = await allProjects(account, org.id);
|
|
1027
|
+
const projectRef = str(values.project);
|
|
1028
|
+
const project = projectRef === undefined ? undefined : projects.find((p) => p.id === projectRef || p.slug === projectRef);
|
|
1029
|
+
if (projectRef !== undefined && !project)
|
|
1030
|
+
throw new NotFoundError(`No project "${projectRef}" in ${org.name} (${projects.map((p) => p.slug).join(', ') || 'none'}).`);
|
|
1031
|
+
const limitText = str(values.limit);
|
|
1032
|
+
const params = {
|
|
1033
|
+
...(project ? { projectId: project.id } : {}),
|
|
1034
|
+
...(str(values.state) ? { state: str(values.state) } : {}),
|
|
1035
|
+
...(str(values.prefix) ? { keyPrefix: str(values.prefix) } : {}),
|
|
1036
|
+
...(values.lifetime !== undefined ? { lifetime: oneOf(values, 'lifetime', ['persistent', 'session', 'any']) } : {}),
|
|
1037
|
+
...(values.purpose !== undefined ? { purpose: oneOf(values, 'purpose', ['standard', 'template_draft', 'template_test', 'any']) } : {}),
|
|
1038
|
+
...(values['include-deleted'] === true ? { includeDeleted: true } : {}),
|
|
1039
|
+
...(limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {}),
|
|
1040
|
+
};
|
|
1041
|
+
let views = [];
|
|
1042
|
+
let cursor = str(values.cursor);
|
|
1043
|
+
let next;
|
|
1044
|
+
do {
|
|
1045
|
+
const page = await account.organizations.workspaces(org.id, { ...params, ...(cursor ? { cursor } : {}) });
|
|
1046
|
+
views = views.concat(page.data);
|
|
1047
|
+
next = page.next_cursor;
|
|
1048
|
+
cursor = next ?? undefined;
|
|
1049
|
+
if (ctx.signal.aborted)
|
|
1050
|
+
throw new InterruptedError('Interrupted.');
|
|
1051
|
+
} while (values.all === true && next);
|
|
1052
|
+
const slugOf = new Map(projects.map((p) => [p.id, p.slug]));
|
|
1053
|
+
const text = views.length
|
|
1054
|
+
? table(['PROJECT', ...WORKSPACE_HEADERS], views.map((w) => [slugOf.get(w.project_id) ?? w.project_id, ...workspaceRow(w)]))
|
|
1055
|
+
: `No workspaces in ${org.name}.\n`;
|
|
1056
|
+
out(ctx, text, { organization_id: org.id, data: views, next_cursor: values.all === true ? null : next });
|
|
1057
|
+
if (!ctx.json && values.all !== true && next)
|
|
1058
|
+
ctx.io.stderr.write(`more: shard orgs workspaces ${org.slug} --cursor ${next}\n`);
|
|
1059
|
+
return 0;
|
|
1060
|
+
},
|
|
1061
|
+
async 'orgs deletion'(ctx, values, [ref]) {
|
|
1062
|
+
const account = ctx.account();
|
|
1063
|
+
const org = await resolveOrg(ctx, account, values, ref);
|
|
1064
|
+
const d = await account.organizations.deletion(org.id);
|
|
1065
|
+
const c = d.counts;
|
|
1066
|
+
const block = d.billing_block;
|
|
1067
|
+
out(ctx, kv([
|
|
1068
|
+
['organization', `${org.name} (${org.slug})`],
|
|
1069
|
+
['request', d.request ? `${d.request.state} since ${time(d.request.requested_at)}; removed after ${time(d.request.finalize_after)}` : 'none'],
|
|
1070
|
+
['blocked by', block ? `${block.reason}${block.reason === 'subscription_active' ? ' (cancel it in the billing portal: shard billing portal)' : ' (an open checkout)'}` : 'nothing'],
|
|
1071
|
+
['would delete', `${c.live_workspaces} workspaces, ${c.projects} projects, ${c.active_api_keys} API keys, ${c.pending_invitations} invitations, ${c.organization_templates} templates`],
|
|
1072
|
+
['grace', `${Math.round(d.grace_seconds / 86_400)} days readable to owners/admins afterwards`],
|
|
1073
|
+
['kept', d.retained.join(', ') || '-'],
|
|
1074
|
+
]) + (block ? '' : `delete it: shard orgs delete ${org.slug} --confirm ${org.slug} --yes\n`), { organization: org, ...d });
|
|
1075
|
+
return 0;
|
|
1076
|
+
},
|
|
1077
|
+
async 'orgs delete'(ctx, values, [ref]) {
|
|
1078
|
+
if (values.yes !== true)
|
|
1079
|
+
throw new UsageError('refusing to delete without --yes (every workspace is deleted, API keys are revoked and projects removed; irreversible)');
|
|
1080
|
+
const confirm = need(values, 'confirm', '--confirm <organization slug>');
|
|
1081
|
+
const account = ctx.account();
|
|
1082
|
+
const org = await resolveOrg(ctx, account, values, ref);
|
|
1083
|
+
if (confirm !== org.slug)
|
|
1084
|
+
throw new UsageError(`--confirm must be the organization's slug "${org.slug}" (got "${confirm}")`);
|
|
1085
|
+
const projectIds = (await allProjects(account, org.id)).map((p) => p.id);
|
|
1086
|
+
const r = await withStepUp(ctx, account, () => account.organizations.delete(org.id, { confirmation: confirm }));
|
|
1087
|
+
forgetOrganization(ctx, org.id, projectIds);
|
|
1088
|
+
out(ctx, `Deleted organization ${org.name} (${org.slug}): ${r.workspace_operations.length} workspace deletions started, ${r.api_keys_revoked} API keys revoked, ${r.projects_deleted} projects deleted, ${r.invitations_revoked} invitations revoked. It stays readable to owners/admins until ${time(r.request.finalize_after)}.\n`, r);
|
|
1089
|
+
return 0;
|
|
1090
|
+
},
|
|
1091
|
+
async 'projects list'(ctx, values) {
|
|
1092
|
+
const account = ctx.account();
|
|
1093
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1094
|
+
const rows = await allProjects(account, org.id);
|
|
1095
|
+
const def = ctx.store.profile(ctx.apiUrl).project_id ?? null;
|
|
1096
|
+
out(ctx, rows.length ? projectTable(rows, def) : `${org.name} has no projects yet (shard projects create <name>).\n`, { organization_id: org.id, data: rows, next_cursor: null, default_project_id: def });
|
|
1097
|
+
return 0;
|
|
1098
|
+
},
|
|
1099
|
+
async 'projects create'(ctx, values, [name]) {
|
|
1100
|
+
const account = ctx.account();
|
|
1101
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1102
|
+
const slug = str(values.slug);
|
|
1103
|
+
const p = await account.projects.create(org.id, { name: name, ...(slug !== undefined ? { slug } : {}) });
|
|
1104
|
+
if (values.use === true) {
|
|
1105
|
+
ctx.store.update(ctx.apiUrl, (x) => {
|
|
1106
|
+
x.organization_id = p.organization_id;
|
|
1107
|
+
x.project_id = p.id;
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
out(ctx, `Created project ${p.name} (${p.slug}, ${p.id}) in ${org.name}.\n${values.use === true ? 'It is now the default.\n' : `Make it the default: shard projects use ${p.id}\n`}`, p);
|
|
1111
|
+
return 0;
|
|
1112
|
+
},
|
|
1113
|
+
async 'projects get'(ctx, values, [ref]) {
|
|
1114
|
+
const p = await resolveProject(ctx, ctx.account(), values, ref);
|
|
1115
|
+
out(ctx, kv([
|
|
1116
|
+
['id', p.id],
|
|
1117
|
+
['name', p.name],
|
|
1118
|
+
['slug', p.slug],
|
|
1119
|
+
['organization', p.organization_id],
|
|
1120
|
+
['created', time(p.created_at)],
|
|
1121
|
+
]), p);
|
|
1122
|
+
return 0;
|
|
1123
|
+
},
|
|
1124
|
+
async 'projects use'(ctx, values, [ref]) {
|
|
1125
|
+
const p = await resolveProject(ctx, ctx.account(), values, ref);
|
|
1126
|
+
ctx.store.update(ctx.apiUrl, (x) => {
|
|
1127
|
+
x.organization_id = p.organization_id;
|
|
1128
|
+
x.project_id = p.id;
|
|
1129
|
+
});
|
|
1130
|
+
out(ctx, `Default project: ${p.name} (${p.slug}, ${p.id}) of organization ${p.organization_id}.\n`, { default_project_id: p.id, default_organization_id: p.organization_id, project: p });
|
|
1131
|
+
return 0;
|
|
1132
|
+
},
|
|
1133
|
+
async 'api-keys list'(ctx, values) {
|
|
1134
|
+
const account = ctx.account();
|
|
1135
|
+
const project = await resolveProject(ctx, account, values);
|
|
1136
|
+
const rows = [];
|
|
1137
|
+
let cursor;
|
|
1138
|
+
do {
|
|
1139
|
+
const page = await account.apiKeys.list(project.id, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
1140
|
+
rows.push(...page.data);
|
|
1141
|
+
cursor = page.next_cursor ?? undefined;
|
|
1142
|
+
} while (cursor);
|
|
1143
|
+
const saved = ctx.store.profile(ctx.apiUrl).api_key?.id ?? null;
|
|
1144
|
+
out(ctx, rows.length ? apiKeyTable(rows, saved) : `No API keys in ${project.name} (shard api-keys create <name>).\n`, { project_id: project.id, data: rows, next_cursor: null, saved_api_key_id: saved });
|
|
1145
|
+
return 0;
|
|
1146
|
+
},
|
|
1147
|
+
async 'api-keys create'(ctx, values, [name]) {
|
|
1148
|
+
const tools = toolsFrom(values);
|
|
1149
|
+
const expiresAt = expiresFrom(values);
|
|
1150
|
+
const account = ctx.account();
|
|
1151
|
+
const project = await resolveProject(ctx, account, values);
|
|
1152
|
+
const res = await account.apiKeys.create(project.id, { name: name, toolPermissions: tools, ...(expiresAt ? { expiresAt } : {}) });
|
|
1153
|
+
ctx.addSecret(res.secret);
|
|
1154
|
+
const k = res.api_key;
|
|
1155
|
+
const what = `API key "${k.name}" ${k.key_id} (${k.id}) for project ${project.name} (${project.id}); tools: ${toolsText(k.tool_permissions)}; expires ${k.expires_at ? time(k.expires_at) : 'never'}`;
|
|
1156
|
+
if (values.save === true) {
|
|
1157
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
1158
|
+
p.api_key = storedKey(k, res.secret);
|
|
1159
|
+
p.project_id = project.id;
|
|
1160
|
+
p.organization_id = project.organization_id;
|
|
1161
|
+
});
|
|
1162
|
+
out(ctx, `Created ${what}.\nSaved in ${ctx.store.file}: workspace commands use it (SHARDFLUX_API_KEY, when set, wins).\n`, { api_key: k, saved: true, credentials_file: ctx.store.file });
|
|
1163
|
+
return 0;
|
|
1164
|
+
}
|
|
1165
|
+
if (ctx.json)
|
|
1166
|
+
ctx.io.stdout.write(`${JSON.stringify({ api_key: k, secret: res.secret, saved: false }, null, 2)}\n`);
|
|
1167
|
+
else {
|
|
1168
|
+
// The key alone on stdout (KEY=$(shard api-keys create ...)); what it is on stderr.
|
|
1169
|
+
ctx.io.stderr.write(`Created ${what}.\nThis is the only time the key is shown; store it now (or next time pass --save).\n`);
|
|
1170
|
+
ctx.io.stdout.write(`${res.secret}\n`);
|
|
1171
|
+
}
|
|
1172
|
+
return 0;
|
|
1173
|
+
},
|
|
1174
|
+
async 'api-keys revoke'(ctx, values, [ref]) {
|
|
1175
|
+
if (values.yes !== true)
|
|
1176
|
+
throw new UsageError('refusing to revoke without --yes (API calls with the key stop at once, workspace tool tokens within 30 s)');
|
|
1177
|
+
const account = ctx.account();
|
|
1178
|
+
const project = await resolveProject(ctx, account, values);
|
|
1179
|
+
let key;
|
|
1180
|
+
let cursor;
|
|
1181
|
+
do {
|
|
1182
|
+
const page = await account.apiKeys.list(project.id, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
1183
|
+
key = page.data.find((k) => k.id === ref || k.key_id === ref);
|
|
1184
|
+
cursor = key ? undefined : (page.next_cursor ?? undefined);
|
|
1185
|
+
} while (cursor);
|
|
1186
|
+
if (!key)
|
|
1187
|
+
throw new NotFoundError(`No API key "${ref}" in project ${project.name} (${project.id}).`, 'list them: shard api-keys list');
|
|
1188
|
+
await account.apiKeys.revoke(project.id, key.id);
|
|
1189
|
+
const wasSaved = ctx.store.profile(ctx.apiUrl).api_key?.id === key.id;
|
|
1190
|
+
if (wasSaved) {
|
|
1191
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
1192
|
+
delete p.api_key;
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
out(ctx, `Revoked API key "${key.name}" ${key.key_id} (${key.id}).${wasSaved ? ' It was the saved key: removed from the credentials file (shard setup saves a new one).' : ''}\n`, { revoked: true, api_key: { ...key, revoked_at: new Date().toISOString() }, removed_from_credentials_file: wasSaved });
|
|
1196
|
+
return 0;
|
|
1197
|
+
},
|
|
1198
|
+
async 'members list'(ctx, values) {
|
|
1199
|
+
const account = ctx.account();
|
|
1200
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1201
|
+
const rows = await allMembers(account, org.id);
|
|
1202
|
+
out(ctx, memberTable(rows), { organization_id: org.id, data: rows, next_cursor: null });
|
|
1203
|
+
return 0;
|
|
1204
|
+
},
|
|
1205
|
+
async 'members set-role'(ctx, values, [ref, role]) {
|
|
1206
|
+
if (!ROLES.includes(role))
|
|
1207
|
+
throw new UsageError(`<role> must be one of ${ROLES.join(', ')} (got "${role}")`);
|
|
1208
|
+
const account = ctx.account();
|
|
1209
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1210
|
+
const m = findMember(await allMembers(account, org.id), ref, org);
|
|
1211
|
+
const updated = await account.members.update(org.id, m.user_id, { role: role });
|
|
1212
|
+
out(ctx, `${updated.email} is now ${updated.role} of ${org.name}.\n`, updated);
|
|
1213
|
+
return 0;
|
|
1214
|
+
},
|
|
1215
|
+
async 'members remove'(ctx, values, [ref]) {
|
|
1216
|
+
if (values.yes !== true)
|
|
1217
|
+
throw new UsageError('refusing to remove without --yes (they lose access to the organization at once)');
|
|
1218
|
+
const account = ctx.account();
|
|
1219
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1220
|
+
const m = findMember(await allMembers(account, org.id), ref, org);
|
|
1221
|
+
const me = await account.me();
|
|
1222
|
+
await account.members.remove(org.id, m.user_id);
|
|
1223
|
+
const self = me.user?.id === m.user_id;
|
|
1224
|
+
if (self)
|
|
1225
|
+
forgetOrganization(ctx, org.id, []);
|
|
1226
|
+
out(ctx, self ? `You left ${org.name}.\n` : `Removed ${m.email} from ${org.name}.\n`, { removed: true, member: m, organization_id: org.id, left: self });
|
|
1227
|
+
return 0;
|
|
1228
|
+
},
|
|
1229
|
+
async 'invitations list'(ctx, values) {
|
|
1230
|
+
const account = ctx.account();
|
|
1231
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1232
|
+
const rows = await allInvitations(account, org.id);
|
|
1233
|
+
out(ctx, rows.length ? invitationTable(rows) : 'No invitations.\n', { organization_id: org.id, data: rows, next_cursor: null });
|
|
1234
|
+
return 0;
|
|
1235
|
+
},
|
|
1236
|
+
async 'invitations create'(ctx, values, [emailText]) {
|
|
1237
|
+
const email = emailArg(emailText, '<email>');
|
|
1238
|
+
const role = need(values, 'role', '--role member');
|
|
1239
|
+
if (!ROLES.includes(role))
|
|
1240
|
+
throw new UsageError(`--role must be one of ${ROLES.join(', ')} (got "${role}")`);
|
|
1241
|
+
const account = ctx.account();
|
|
1242
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1243
|
+
const inv = await account.invitations.create(org.id, { email, role: role });
|
|
1244
|
+
out(ctx, `Invited ${inv.email} to ${org.name} as ${inv.role} (invitation ${inv.id}, expires ${time(inv.expires_at)}). They accept with: shard invitations accept '<link from the email>'\n`, inv);
|
|
1245
|
+
return 0;
|
|
1246
|
+
},
|
|
1247
|
+
async 'invitations revoke'(ctx, values, [ref]) {
|
|
1248
|
+
const account = ctx.account();
|
|
1249
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1250
|
+
let id = ref;
|
|
1251
|
+
if (!UUID.test(id)) {
|
|
1252
|
+
const hit = (await allInvitations(account, org.id)).find((i) => i.status === 'pending' && i.email.toLowerCase() === id.toLowerCase());
|
|
1253
|
+
if (!hit)
|
|
1254
|
+
throw new NotFoundError(`No pending invitation "${ref}" in ${org.name}.`, 'list them: shard invitations list');
|
|
1255
|
+
id = hit.id;
|
|
1256
|
+
}
|
|
1257
|
+
await account.invitations.revoke(org.id, id);
|
|
1258
|
+
out(ctx, `Revoked invitation ${id}.\n`, { revoked: true, id, organization_id: org.id });
|
|
1259
|
+
return 0;
|
|
1260
|
+
},
|
|
1261
|
+
async 'invitations accept'(ctx, values, [link]) {
|
|
1262
|
+
const account = ctx.account();
|
|
1263
|
+
const org = await account.invitations.accept(linkToken(link));
|
|
1264
|
+
if (values.use === true) {
|
|
1265
|
+
ctx.store.update(ctx.apiUrl, (p) => {
|
|
1266
|
+
if (p.organization_id !== org.id)
|
|
1267
|
+
delete p.project_id;
|
|
1268
|
+
p.organization_id = org.id;
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
out(ctx, `You joined ${org.name} (${org.slug}) as ${org.role ?? 'member'}.${values.use === true ? ' It is now the default.' : ` Make it the default: shard orgs use ${org.slug}`}\n`, org);
|
|
1272
|
+
return 0;
|
|
1273
|
+
},
|
|
1274
|
+
async 'billing plans'(ctx) {
|
|
1275
|
+
const catalog = await fetchBillingCatalog({ baseUrl: ctx.apiUrl, fetch: ctx.accountOptions().fetch ?? fetch });
|
|
1276
|
+
out(ctx, planTable(catalog), catalog);
|
|
1277
|
+
return 0;
|
|
1278
|
+
},
|
|
1279
|
+
async 'billing status'(ctx, values) {
|
|
1280
|
+
const account = ctx.account();
|
|
1281
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1282
|
+
const s = await account.billing.subscription(org.id);
|
|
1283
|
+
const next = s.subscription ? 'manage it: shard billing portal' : s.purchase_available ? 'buy a plan: shard billing plans, then shard billing upgrade <plan>' : '';
|
|
1284
|
+
out(ctx, `${billingStatusText(s, org)}${next ? `${next}\n` : ''}`, s);
|
|
1285
|
+
return 0;
|
|
1286
|
+
},
|
|
1287
|
+
async 'billing upgrade'(ctx, values, [planKey]) {
|
|
1288
|
+
const t = str(values.timeout);
|
|
1289
|
+
const timeoutMs = t === undefined ? DEFAULT_CHECKOUT_WAIT_MS : parseDuration(t, 'timeout');
|
|
1290
|
+
const catalog = await fetchBillingCatalog({ baseUrl: ctx.apiUrl, fetch: ctx.accountOptions().fetch ?? fetch });
|
|
1291
|
+
const buyable = purchasablePlans(catalog);
|
|
1292
|
+
const plan = catalog.plans.find((p) => p.plan_key === planKey);
|
|
1293
|
+
const list = buyable.length ? buyable.join(', ') : '(none on this API)';
|
|
1294
|
+
if (!plan)
|
|
1295
|
+
throw new UsageError(`unknown plan "${planKey}"; purchasable plans: ${list} (shard billing plans)`);
|
|
1296
|
+
// With no purchasable plan at all the API itself says why (e.g. billing not configured).
|
|
1297
|
+
if (buyable.length > 0 && !buyable.includes(plan.plan_key))
|
|
1298
|
+
throw new UsageError(`plan "${planKey}" cannot be bought; purchasable plans: ${list} (shard billing plans)`);
|
|
1299
|
+
const account = ctx.account();
|
|
1300
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1301
|
+
let checkout;
|
|
1302
|
+
try {
|
|
1303
|
+
checkout = await account.billing.checkout(org.id, { planKey: plan.plan_key });
|
|
1304
|
+
}
|
|
1305
|
+
catch (err) {
|
|
1306
|
+
if (!(err instanceof ShardfluxApiError && err.code === 'conflict' && err.details?.reason === 'subscription_exists'))
|
|
1307
|
+
throw err;
|
|
1308
|
+
const portal = await account.billing.portal(org.id);
|
|
1309
|
+
const opened = values.open === true && openUrl(portal.url, ctx.io.env);
|
|
1310
|
+
out(ctx, `${org.name} already has a subscription: plan changes happen in the Stripe billing portal (it shows the new price before you confirm):\n${portal.url}\n${values.open === true ? (opened ? 'Opened it in your browser.\n' : 'Could not open a browser; open the URL yourself.\n') : ''}`, { subscription_exists: true, organization_id: org.id, plan_key: plan.plan_key, portal_url: portal.url, opened });
|
|
1311
|
+
return 0;
|
|
1312
|
+
}
|
|
1313
|
+
if (!checkout.url && !checkout.subscription_active)
|
|
1314
|
+
throw new UsageError(`checkout ${checkout.id} is ${checkout.status} and has no URL; start again: shard billing upgrade ${plan.plan_key}`);
|
|
1315
|
+
const opened = values.open === true && checkout.url !== null && openUrl(checkout.url, ctx.io.env);
|
|
1316
|
+
if (!ctx.json) {
|
|
1317
|
+
ctx.io.stdout.write(`Checkout for ${plan.name} (${plan.plan_key}, ${priceText(catalog, plan.plan_key)}) for ${org.name}:\n${checkout.url ?? '-'}\n` +
|
|
1318
|
+
`A person pays there (Stripe Checkout${checkout.expires_at ? `; the link works until ${time(checkout.expires_at)}` : ''}). The plan is active once Stripe confirms the payment${values.wait === true ? '.' : ': shard billing status'}\n` +
|
|
1319
|
+
(values.open === true ? (opened ? 'Opened it in your browser.\n' : 'Could not open a browser; open the URL yourself.\n') : ''));
|
|
1320
|
+
}
|
|
1321
|
+
let final = checkout;
|
|
1322
|
+
if (values.wait === true && !checkout.subscription_active)
|
|
1323
|
+
final = await waitForCheckout(ctx, account, org.id, checkout, timeoutMs);
|
|
1324
|
+
if (ctx.json) {
|
|
1325
|
+
ctx.io.stdout.write(`${JSON.stringify({ subscription_exists: false, organization_id: org.id, plan_key: plan.plan_key, checkout: final, url: checkout.url, opened, subscription_active: final.subscription_active }, null, 2)}\n`);
|
|
1326
|
+
}
|
|
1327
|
+
else if (final.subscription_active)
|
|
1328
|
+
ctx.io.stdout.write(`Subscription active: ${plan.name} for ${org.name}.\n`);
|
|
1329
|
+
return 0;
|
|
1330
|
+
},
|
|
1331
|
+
async 'billing portal'(ctx, values) {
|
|
1332
|
+
const account = ctx.account();
|
|
1333
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1334
|
+
const portal = await account.billing.portal(org.id);
|
|
1335
|
+
const opened = values.open === true && openUrl(portal.url, ctx.io.env);
|
|
1336
|
+
out(ctx, `Billing portal of ${org.name} (plan changes, payment methods, invoices, cancellation; the link is short-lived):\n${portal.url}\n${values.open === true ? (opened ? 'Opened it in your browser.\n' : 'Could not open a browser; open the URL yourself.\n') : ''}`, { organization_id: org.id, url: portal.url, opened });
|
|
1337
|
+
return 0;
|
|
1338
|
+
},
|
|
1339
|
+
async 'billing invoices'(ctx, values) {
|
|
1340
|
+
const account = ctx.account();
|
|
1341
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1342
|
+
const limitText = str(values.limit);
|
|
1343
|
+
const limit = limitText ? parsePositiveInt(limitText, 'limit', 100) : undefined;
|
|
1344
|
+
let rows = [];
|
|
1345
|
+
let next = null;
|
|
1346
|
+
if (values.all === true) {
|
|
1347
|
+
let cursor;
|
|
1348
|
+
do {
|
|
1349
|
+
const page = await account.billing.invoices(org.id, { limit: limit ?? 100, ...(cursor ? { cursor } : {}) });
|
|
1350
|
+
rows.push(...page.data);
|
|
1351
|
+
cursor = page.next_cursor ?? undefined;
|
|
1352
|
+
} while (cursor);
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
const cursor = str(values.cursor);
|
|
1356
|
+
const page = await account.billing.invoices(org.id, { ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) });
|
|
1357
|
+
rows = page.data;
|
|
1358
|
+
next = page.next_cursor;
|
|
1359
|
+
}
|
|
1360
|
+
out(ctx, rows.length ? invoiceTable(rows) : 'No invoices.\n', { organization_id: org.id, data: rows, next_cursor: next });
|
|
1361
|
+
if (!ctx.json && next)
|
|
1362
|
+
ctx.io.stderr.write(`more: shard billing invoices --cursor ${next}\n`);
|
|
1363
|
+
return 0;
|
|
1364
|
+
},
|
|
1365
|
+
async 'billing alerts'(ctx, values) {
|
|
1366
|
+
const set = str(values.set);
|
|
1367
|
+
const clear = values.clear === true;
|
|
1368
|
+
if (set !== undefined && clear)
|
|
1369
|
+
throw new UsageError('use --set or --clear, not both');
|
|
1370
|
+
let thresholds = null;
|
|
1371
|
+
if (clear)
|
|
1372
|
+
thresholds = [];
|
|
1373
|
+
else if (set !== undefined) {
|
|
1374
|
+
const parts = set.split(',').map((x) => x.trim().replace(/%$/, ''));
|
|
1375
|
+
if (parts.some((x) => !/^\d+$/.test(x) || Number(x) < 1 || Number(x) > 100))
|
|
1376
|
+
throw new UsageError(`--set takes percentages from 1 to 100 separated by commas, e.g. 50,80,100 (got "${set}")`);
|
|
1377
|
+
thresholds = [...new Set(parts.map(Number))].sort((a, b) => a - b);
|
|
1378
|
+
if (thresholds.length > 5)
|
|
1379
|
+
throw new UsageError('--set takes at most 5 thresholds');
|
|
1380
|
+
}
|
|
1381
|
+
const account = ctx.account();
|
|
1382
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1383
|
+
const policy = thresholds === null ? await account.billing.spendPolicy(org.id) : await account.billing.setSpendPolicy(org.id, { alertThresholdsPercent: thresholds });
|
|
1384
|
+
const now = policy.alert_thresholds.length ? `alerts at ${policy.alert_thresholds.map((p) => `${p}%`).join(', ')} of each allowance` : 'usage alerts are off';
|
|
1385
|
+
out(ctx, `${org.name}: ${now}${thresholds === null ? '' : ' (saved)'}.\n`, policy);
|
|
1386
|
+
return 0;
|
|
1387
|
+
},
|
|
1388
|
+
async 'billing overage'(ctx, values, [action = 'status', amount]) {
|
|
1389
|
+
if (!OVERAGE_ACTIONS.includes(action))
|
|
1390
|
+
throw new UsageError(`unknown action "${action}"; use status, on, off or cap (shard billing overage --help)`);
|
|
1391
|
+
const capText = str(values.cap);
|
|
1392
|
+
if (capText !== undefined && action !== 'on')
|
|
1393
|
+
throw new UsageError(action === 'cap' ? 'give the cap as an argument: shard billing overage cap <dollars>' : `--cap goes with on: shard billing overage on --cap <dollars>`);
|
|
1394
|
+
if (action === 'cap' && amount === undefined)
|
|
1395
|
+
throw new UsageError('missing <dollars> for "shard billing overage cap" (e.g. shard billing overage cap 9)');
|
|
1396
|
+
if (action !== 'cap' && amount !== undefined)
|
|
1397
|
+
throw new UsageError(`unexpected argument "${amount}"${action === 'on' ? '; give the cap with --cap <dollars>' : ''}`);
|
|
1398
|
+
const update = {};
|
|
1399
|
+
if (action === 'on')
|
|
1400
|
+
update.overageEnabled = true;
|
|
1401
|
+
if (action === 'off')
|
|
1402
|
+
update.overageEnabled = false;
|
|
1403
|
+
if (action === 'on' && capText !== undefined)
|
|
1404
|
+
update.spendCapMinor = dollarsToMinor(capText, '--cap');
|
|
1405
|
+
if (action === 'cap')
|
|
1406
|
+
update.spendCapMinor = dollarsToMinor(amount, 'the cap');
|
|
1407
|
+
const version = str(values['if-version']);
|
|
1408
|
+
if (version !== undefined) {
|
|
1409
|
+
if (action === 'status')
|
|
1410
|
+
throw new UsageError('--if-version goes with on, off or cap');
|
|
1411
|
+
if (!/^\d+$/.test(version))
|
|
1412
|
+
throw new UsageError('--if-version must be a policy version number (shard billing overage --json shows it)');
|
|
1413
|
+
update.ifMatch = Number(version);
|
|
1414
|
+
}
|
|
1415
|
+
const account = ctx.account();
|
|
1416
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1417
|
+
const policy = action === 'status' ? await account.billing.spendPolicy(org.id) : await account.billing.setSpendPolicy(org.id, update);
|
|
1418
|
+
out(ctx, overagePolicyText(policy, org.name, action !== 'status'), policy);
|
|
1419
|
+
return 0;
|
|
1420
|
+
},
|
|
1421
|
+
async 'audit list'(ctx, values) {
|
|
1422
|
+
const filters = auditFilters(values);
|
|
1423
|
+
const limitText = str(values.limit);
|
|
1424
|
+
const limit = limitText ? parsePositiveInt(limitText, 'limit', 200) : undefined;
|
|
1425
|
+
const account = ctx.account();
|
|
1426
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1427
|
+
let rows = [];
|
|
1428
|
+
let next = null;
|
|
1429
|
+
if (values.all === true) {
|
|
1430
|
+
for await (const e of account.audit.listAll(org.id, { ...filters, limit: limit ?? 200 })) {
|
|
1431
|
+
rows.push(e);
|
|
1432
|
+
if (ctx.signal.aborted)
|
|
1433
|
+
throw new InterruptedError('Interrupted.');
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
const cursor = str(values.cursor);
|
|
1438
|
+
const page = await account.audit.list(org.id, { ...filters, ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) });
|
|
1439
|
+
rows = page.data;
|
|
1440
|
+
next = page.nextCursor;
|
|
1441
|
+
}
|
|
1442
|
+
out(ctx, rows.length ? auditTable(rows) : 'No audit events match.\n', { organization_id: org.id, data: rows, next_cursor: next });
|
|
1443
|
+
if (!ctx.json && next)
|
|
1444
|
+
ctx.io.stderr.write(`more: shard audit list --cursor ${next}\n`);
|
|
1445
|
+
return 0;
|
|
1446
|
+
},
|
|
1447
|
+
async 'audit export'(ctx, values) {
|
|
1448
|
+
const filters = auditFilters(values);
|
|
1449
|
+
const format = oneOf(values, 'format', ['ndjson', 'csv']) ?? 'ndjson';
|
|
1450
|
+
const account = ctx.account();
|
|
1451
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1452
|
+
const text = await account.audit.export(org.id, { ...filters, format, signal: ctx.signal });
|
|
1453
|
+
const dest = str(values.out);
|
|
1454
|
+
const rows = text.split('\n').filter((l) => l.trim().length > 0).length - (format === 'csv' ? 1 : 0);
|
|
1455
|
+
if (dest !== undefined) {
|
|
1456
|
+
const bytes = await writePrivate(dest, text, values.force === true);
|
|
1457
|
+
out(ctx, `Wrote ${Math.max(0, rows)} events (${format}, ${bytes} bytes) to ${dest}.\n`, { organization_id: org.id, format, out: dest, bytes, rows: Math.max(0, rows) });
|
|
1458
|
+
}
|
|
1459
|
+
else if (ctx.json)
|
|
1460
|
+
ctx.io.stdout.write(`${JSON.stringify({ organization_id: org.id, format, rows: Math.max(0, rows), content: text }, null, 2)}\n`);
|
|
1461
|
+
else
|
|
1462
|
+
ctx.io.stdout.write(text);
|
|
1463
|
+
return 0;
|
|
1464
|
+
},
|
|
1465
|
+
async 'account status'(ctx) {
|
|
1466
|
+
const account = ctx.account();
|
|
1467
|
+
const me = await account.me();
|
|
1468
|
+
const d = await account.user.deletion();
|
|
1469
|
+
const u = me.user;
|
|
1470
|
+
const lines = [];
|
|
1471
|
+
if (u)
|
|
1472
|
+
lines.push(['user', userLine(u)], ['email', u.email_verified ? 'verified' : 'not verified'], ['two-factor', u.mfa_enabled ? 'on' : 'off']);
|
|
1473
|
+
lines.push(['organizations', me.memberships.length ? me.memberships.map((m) => `${m.organization_name} (${m.organization_slug}, ${m.role})`).join(', ') : 'none']);
|
|
1474
|
+
const r = d.request;
|
|
1475
|
+
lines.push(['deletion', r && r.state === 'scheduled' ? `scheduled: runs after ${time(r.execute_after)} (cancel: shard account cancel-deletion)` : 'not scheduled']);
|
|
1476
|
+
if (d.blocking_organizations.length)
|
|
1477
|
+
lines.push(['blocked by', d.blocking_organizations.map((o) => `${o.name} (${o.slug}): ${o.reason === 'sole_owner' ? 'you are its only owner' : 'you are its only member'}`).join('; ')]);
|
|
1478
|
+
if (d.api_keys_retained.length)
|
|
1479
|
+
lines.push(['keys kept', `${d.api_keys_retained.length} API keys you created stay with their organizations`]);
|
|
1480
|
+
out(ctx, kv(lines), { user: u, memberships: me.memberships, deletion: d });
|
|
1481
|
+
return 0;
|
|
1482
|
+
},
|
|
1483
|
+
async 'account export'(ctx, values) {
|
|
1484
|
+
const account = ctx.account();
|
|
1485
|
+
const created = await withStepUp(ctx, account, () => account.user.exports.create());
|
|
1486
|
+
const { meta, text } = await downloadExport(ctx, account, created, (id) => account.user.exports.get(id), (id) => account.user.exports.download(id, { signal: ctx.signal }));
|
|
1487
|
+
const path = str(values.out) ?? `./account-export-${meta.id}.json`;
|
|
1488
|
+
const bytes = await writePrivate(path, text, values.force === true);
|
|
1489
|
+
out(ctx, dataExportText(meta, path), { export: meta, out: path, bytes });
|
|
1490
|
+
return 0;
|
|
1491
|
+
},
|
|
1492
|
+
async 'account delete'(ctx, values) {
|
|
1493
|
+
if (values.yes !== true)
|
|
1494
|
+
throw new UsageError('refusing to schedule the deletion of your account without --yes');
|
|
1495
|
+
const confirm = need(values, 'confirm', '--confirm you@example.com');
|
|
1496
|
+
const account = ctx.account();
|
|
1497
|
+
const me = await account.me();
|
|
1498
|
+
if (!me.user || confirm.toLowerCase() !== me.user.email.toLowerCase())
|
|
1499
|
+
throw new UsageError(`--confirm must be your email address${me.user ? ` (${me.user.email})` : ''}`);
|
|
1500
|
+
const r = await withStepUp(ctx, account, () => account.user.scheduleDeletion({ confirmation: confirm }));
|
|
1501
|
+
out(ctx, `Your account will be deleted after ${time(r.request.execute_after)}. Every other session was signed out. Cancel before then: shard account cancel-deletion\n`, r);
|
|
1502
|
+
return 0;
|
|
1503
|
+
},
|
|
1504
|
+
async 'account cancel-deletion'(ctx) {
|
|
1505
|
+
const r = await ctx.account().user.cancelDeletion();
|
|
1506
|
+
out(ctx, 'The deletion of your account is canceled.\n', r);
|
|
1507
|
+
return 0;
|
|
1508
|
+
},
|
|
1509
|
+
async 'egress get'(ctx, values) {
|
|
1510
|
+
const target = await egressTarget(ctx, values);
|
|
1511
|
+
const egress = ctx.cloud().egress;
|
|
1512
|
+
if (target.kind === 'workspace') {
|
|
1513
|
+
const p = await egress.getWorkspace(target.ws.id);
|
|
1514
|
+
out(ctx, workspaceEgressText(p), p);
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
const p = await egress.getProject(target.id);
|
|
1518
|
+
out(ctx, projectEgressText(p), p);
|
|
1519
|
+
}
|
|
1520
|
+
return 0;
|
|
1521
|
+
},
|
|
1522
|
+
async 'egress set'(ctx, values) {
|
|
1523
|
+
const file = need(values, 'file', '--file policy.json');
|
|
1524
|
+
const policy = await readPolicy(ctx, file);
|
|
1525
|
+
const opts = ifVersion(values);
|
|
1526
|
+
const target = await egressTarget(ctx, values);
|
|
1527
|
+
const egress = ctx.cloud().egress;
|
|
1528
|
+
if (target.kind === 'workspace') {
|
|
1529
|
+
const p = await egress.putWorkspace(target.ws.id, policy, opts);
|
|
1530
|
+
out(ctx, `Saved the policy of ${target.ws.key} as version ${p.version}; the host applies it (enforcement: ${p.enforcement.state}).\n${workspaceEgressText(p)}`, p);
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
const p = await egress.putProject(target.id, policy, opts);
|
|
1534
|
+
out(ctx, `Saved the project policy as version ${p.version}; hosts apply it to the project's workspaces.\n${projectEgressText(p)}`, p);
|
|
1535
|
+
}
|
|
1536
|
+
return 0;
|
|
1537
|
+
},
|
|
1538
|
+
async 'egress history'(ctx, values) {
|
|
1539
|
+
const limitText = str(values.limit);
|
|
1540
|
+
const limit = limitText ? parsePositiveInt(limitText, 'limit', 200) : undefined;
|
|
1541
|
+
const target = await egressTarget(ctx, values);
|
|
1542
|
+
const egress = ctx.cloud().egress;
|
|
1543
|
+
const page = target.kind === 'workspace' ? await egress.workspaceVersions(target.ws.id, limit ? { limit } : {}) : await egress.projectVersions(target.id, limit ? { limit } : {});
|
|
1544
|
+
out(ctx, page.data.length ? egressVersionTable(page.data) : 'No policy versions yet (the platform default applies).\n', { data: page.data, next_cursor: page.nextCursor });
|
|
1545
|
+
return 0;
|
|
1546
|
+
},
|
|
1547
|
+
async 'egress clear'(ctx, values) {
|
|
1548
|
+
const ref = need(values, 'workspace', '--workspace acme/demo');
|
|
1549
|
+
const ws = await resolveWorkspace(ctx, ref);
|
|
1550
|
+
await ctx.cloud().egress.clearWorkspace(ws.id);
|
|
1551
|
+
out(ctx, `Removed the policy of ${ws.key}: it follows the project policy again.\n`, { cleared: true, workspace_id: ws.id });
|
|
1552
|
+
return 0;
|
|
1553
|
+
},
|
|
1554
|
+
async 'volumes list'(ctx, values) {
|
|
1555
|
+
const cloud = ctx.cloud();
|
|
1556
|
+
const params = { ...(values['include-org-shared'] === true ? { includeOrgShared: true } : {}), ...(values['include-deleted'] === true ? { includeDeleted: true } : {}) };
|
|
1557
|
+
let rows = [];
|
|
1558
|
+
let next = null;
|
|
1559
|
+
if (values.all === true)
|
|
1560
|
+
for await (const v of cloud.volumes.listAll(params))
|
|
1561
|
+
rows.push(v);
|
|
1562
|
+
else {
|
|
1563
|
+
const page = await cloud.volumes.list(params);
|
|
1564
|
+
rows = page.data;
|
|
1565
|
+
next = page.nextCursor;
|
|
1566
|
+
}
|
|
1567
|
+
out(ctx, rows.length ? volumeTable(rows) : 'No volumes.\n', { data: rows, next_cursor: next });
|
|
1568
|
+
if (!ctx.json && next)
|
|
1569
|
+
ctx.io.stderr.write('more: shard volumes list --all\n');
|
|
1570
|
+
return 0;
|
|
1571
|
+
},
|
|
1572
|
+
async 'volumes create'(ctx, values, [name]) {
|
|
1573
|
+
const quota = parsePositiveInt(need(values, 'quota-gib', '--quota-gib 20'), 'quota-gib', 100_000);
|
|
1574
|
+
const r = await waitedVolume(ctx, () => ctx.cloud().volumes.create({ name: name, quotaGib: quota, ...(values['org-shared'] === true ? { orgShared: true } : {}), wait: volumeWait(ctx, values) }));
|
|
1575
|
+
out(ctx, `Volume ${r.volume.name} (${r.volume.id}) is ${r.volume.state}; operation ${r.operation.id} ${r.operation.state}.\n${volumeDetail(r.volume)}`, r);
|
|
1576
|
+
return 0;
|
|
1577
|
+
},
|
|
1578
|
+
async 'volumes get'(ctx, _values, [ref]) {
|
|
1579
|
+
const v = await resolveVolume(ctx, ref);
|
|
1580
|
+
out(ctx, volumeDetail(v), v);
|
|
1581
|
+
return 0;
|
|
1582
|
+
},
|
|
1583
|
+
async 'volumes delete'(ctx, values, [ref]) {
|
|
1584
|
+
if (values.yes !== true)
|
|
1585
|
+
throw new UsageError('refusing to delete without --yes (the volume and its data are deleted)');
|
|
1586
|
+
const v = await resolveVolume(ctx, ref);
|
|
1587
|
+
const r = await waitedVolume(ctx, () => ctx.cloud().volumes.delete(v.id, { ...(values.force === true ? { force: true } : {}), wait: volumeWait(ctx, values) }));
|
|
1588
|
+
out(ctx, `Deleting volume ${v.name} (${v.id}): operation ${r.operation.id} ${r.operation.state}.\n`, r);
|
|
1589
|
+
return 0;
|
|
1590
|
+
},
|
|
1591
|
+
async 'volumes attachments'(ctx, values, [ref]) {
|
|
1592
|
+
const v = await resolveVolume(ctx, ref);
|
|
1593
|
+
const page = await ctx.cloud().volumes.attachments(v.id, { limit: 200, ...(values['include-detached'] === true ? { includeDetached: true } : {}) });
|
|
1594
|
+
out(ctx, page.data.length ? attachmentTable(page.data) : `${v.name} is not attached.\n`, { data: page.data, next_cursor: page.nextCursor });
|
|
1595
|
+
return 0;
|
|
1596
|
+
},
|
|
1597
|
+
async 'volumes attach'(ctx, values, [wsRef, volRef]) {
|
|
1598
|
+
const mount = need(values, 'mount', '--mount /mnt/data');
|
|
1599
|
+
if (!mount.startsWith('/'))
|
|
1600
|
+
throw new UsageError('--mount must be an absolute path, e.g. /mnt/data');
|
|
1601
|
+
const ws = await resolveWorkspace(ctx, wsRef);
|
|
1602
|
+
const v = await resolveVolume(ctx, volRef);
|
|
1603
|
+
const r = await waitedVolume(ctx, () => ctx.cloud().volumes.attach(ws.id, { volumeId: v.id, mountPath: mount, mode: values['read-only'] === true ? 'ro' : 'rw', wait: volumeWait(ctx, values) }));
|
|
1604
|
+
const a = r.attachment;
|
|
1605
|
+
out(ctx, `Attached ${v.name} to ${ws.key} at ${a.mount_path} (${a.mode}): ${a.state}${a.mounted ? ', mounted' : ', mounted on the next start'}.\n`, r);
|
|
1606
|
+
return 0;
|
|
1607
|
+
},
|
|
1608
|
+
async 'volumes detach'(ctx, values, [wsRef, volRef]) {
|
|
1609
|
+
const ws = await resolveWorkspace(ctx, wsRef);
|
|
1610
|
+
const v = await resolveVolume(ctx, volRef);
|
|
1611
|
+
const r = await waitedVolume(ctx, () => ctx.cloud().volumes.detach(ws.id, v.id, { wait: volumeWait(ctx, values) }));
|
|
1612
|
+
out(ctx, `Detaching ${v.name} from ${ws.key}: ${r.attachment.state}.\n`, r);
|
|
1613
|
+
return 0;
|
|
1614
|
+
},
|
|
1615
|
+
async 'templates publish'(ctx, values, [ref]) {
|
|
1616
|
+
const { slug, version } = parseSlugVersion(ref, '<slug@version>');
|
|
1617
|
+
const account = ctx.account();
|
|
1618
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1619
|
+
const t = await account.templates.publishVersion(org.id, slug, version);
|
|
1620
|
+
out(ctx, `Published ${slug} v${version}: new workspaces of ${slug} use the latest published version (existing ones keep theirs).\n`, t);
|
|
1621
|
+
return 0;
|
|
1622
|
+
},
|
|
1623
|
+
async 'templates archive'(ctx, values, [ref]) {
|
|
1624
|
+
const { slug, version } = parseSlugVersion(ref, '<slug@version>');
|
|
1625
|
+
const account = ctx.account();
|
|
1626
|
+
const org = await resolveOrg(ctx, account, values);
|
|
1627
|
+
const t = await account.templates.archiveVersion(org.id, slug, version);
|
|
1628
|
+
out(ctx, `Archived ${slug} v${version}: new opens no longer use it; existing workspaces keep it.\n`, t);
|
|
1629
|
+
return 0;
|
|
1630
|
+
},
|
|
1631
|
+
};
|
|
1632
|
+
async function allMembers(account, organizationId) {
|
|
1633
|
+
const rows = [];
|
|
1634
|
+
let cursor;
|
|
1635
|
+
do {
|
|
1636
|
+
const page = await account.members.list(organizationId, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
1637
|
+
rows.push(...page.data);
|
|
1638
|
+
cursor = page.next_cursor ?? undefined;
|
|
1639
|
+
} while (cursor);
|
|
1640
|
+
return rows;
|
|
1641
|
+
}
|
|
1642
|
+
async function allInvitations(account, organizationId) {
|
|
1643
|
+
const rows = [];
|
|
1644
|
+
let cursor;
|
|
1645
|
+
do {
|
|
1646
|
+
const page = await account.invitations.list(organizationId, { limit: 200, ...(cursor ? { cursor } : {}) });
|
|
1647
|
+
rows.push(...page.data);
|
|
1648
|
+
cursor = page.next_cursor ?? undefined;
|
|
1649
|
+
} while (cursor);
|
|
1650
|
+
return rows;
|
|
1651
|
+
}
|
|
1652
|
+
function findMember(rows, ref, org) {
|
|
1653
|
+
const hit = rows.find((m) => m.user_id === ref || m.email.toLowerCase() === ref.toLowerCase());
|
|
1654
|
+
if (!hit)
|
|
1655
|
+
throw new NotFoundError(`No member "${ref}" in ${org.name}.`, 'list them: shard members list');
|
|
1656
|
+
return hit;
|
|
1657
|
+
}
|