@fluid-app/fluid-cli 0.1.63 → 0.1.65
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/README.md +43 -0
- package/dist/bin/fluid.mjs +457 -89
- package/dist/bin/fluid.mjs.map +1 -1
- package/dist/index.d.mts +18 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{token-6SEVoGMw.mjs → token-DnFYT9pe.mjs} +41 -21
- package/dist/token-DnFYT9pe.mjs.map +1 -0
- package/package.json +1 -1
- package/dist/token-6SEVoGMw.mjs.map +0 -1
package/dist/bin/fluid.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { F as walkCommands, M as rootCommand, N as visibleOptions, P as visibleSubcommands, _ as validateToken, a as readConfig,
|
|
2
|
+
import { F as walkCommands, M as rootCommand, N as visibleOptions, P as visibleSubcommands, _ as validateToken, a as readConfig, d as FLUID_API_ERROR, f as confirmMfa, g as switchCompany, h as sendMfa, j as filterCatalog, l as getConfigDir, m as getFluidApiBase, n as getAuthToken, o as updateConfig, p as fetchUserCompanies, s as writeConfig, t as getActiveProfile } from "../token-DnFYT9pe.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { Argument, Command, Option } from "commander";
|
|
7
|
+
import { text } from "node:stream/consumers";
|
|
7
8
|
import chalk from "chalk";
|
|
8
9
|
import prompts from "prompts";
|
|
9
10
|
import ora from "ora";
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
12
|
import { dirname, join, sep } from "node:path";
|
|
11
|
-
import { randomBytes } from "node:crypto";
|
|
12
13
|
import { spawn } from "node:child_process";
|
|
13
14
|
//#region src/commands/commands.ts
|
|
14
15
|
/**
|
|
@@ -244,6 +245,93 @@ const completeCommand = new Command("__complete").description("Print completions
|
|
|
244
245
|
process.stdout.write(formatCandidates(completeWords(rootCommand(command), typed), opts.shell));
|
|
245
246
|
});
|
|
246
247
|
//#endregion
|
|
248
|
+
//#region src/auth/pending-login.ts
|
|
249
|
+
/**
|
|
250
|
+
* Email sign-ins started with `fluid login --send-code` and not finished yet.
|
|
251
|
+
*
|
|
252
|
+
* The send and finish steps run as separate processes, and the finish step
|
|
253
|
+
* needs the server's id for the sign-in (`uuid`), which only the send step
|
|
254
|
+
* saw. Keeping it here means an agent carries the emailed code alone. The
|
|
255
|
+
* uuid cannot sign anyone in without that code.
|
|
256
|
+
*
|
|
257
|
+
* One record per address, so a new send replaces the last one. Records live
|
|
258
|
+
* under the config dir (`~/.fluid/pending-logins/`), each named by a hash of
|
|
259
|
+
* the address so the file names do not list who is signing in.
|
|
260
|
+
*/
|
|
261
|
+
const STALE_AFTER_MS = 3600 * 1e3;
|
|
262
|
+
/** The server compares addresses trimmed and lowercased; so does the store. */
|
|
263
|
+
function normaliseEmail(email) {
|
|
264
|
+
return email.trim().toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
function fileName(email) {
|
|
267
|
+
return `${createHash("sha256").update(normaliseEmail(email)).digest("hex")}.json`;
|
|
268
|
+
}
|
|
269
|
+
function isPendingLogin(value) {
|
|
270
|
+
if (typeof value !== "object" || value === null) return false;
|
|
271
|
+
const record = value;
|
|
272
|
+
return typeof record["uuid"] === "string" && typeof record["email"] === "string" && typeof record["shop"] === "string" && typeof record["expiresAt"] === "string" && typeof record["baseUrl"] === "string";
|
|
273
|
+
}
|
|
274
|
+
function readRecord(path) {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
277
|
+
return isPendingLogin(parsed) ? parsed : null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function removeStale(dir, now) {
|
|
283
|
+
let names;
|
|
284
|
+
try {
|
|
285
|
+
names = readdirSync(dir);
|
|
286
|
+
} catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (const name of names) {
|
|
290
|
+
if (!name.endsWith(".json")) continue;
|
|
291
|
+
const path = join(dir, name);
|
|
292
|
+
const record = readRecord(path);
|
|
293
|
+
const expiresAt = record ? Date.parse(record.expiresAt) : NaN;
|
|
294
|
+
if (!record || !(expiresAt + STALE_AFTER_MS > now)) rmSync(path, { force: true });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The store, in `dir` or by default `<config dir>/pending-logins`, resolved
|
|
299
|
+
* on each call so a changed `FLUID_CONFIG_DIR` is honoured.
|
|
300
|
+
*/
|
|
301
|
+
function createPendingLoginStore(dir) {
|
|
302
|
+
const storeDir = () => dir ?? join(getConfigDir(), "pending-logins");
|
|
303
|
+
return {
|
|
304
|
+
save(record) {
|
|
305
|
+
const base = storeDir();
|
|
306
|
+
mkdirSync(base, {
|
|
307
|
+
recursive: true,
|
|
308
|
+
mode: 448
|
|
309
|
+
});
|
|
310
|
+
removeStale(base, Date.now());
|
|
311
|
+
const tmpPath = join(base, `.pending-${randomBytes(6).toString("hex")}.tmp`);
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n", {
|
|
314
|
+
encoding: "utf8",
|
|
315
|
+
mode: 384
|
|
316
|
+
});
|
|
317
|
+
renameSync(tmpPath, join(base, fileName(record.email)));
|
|
318
|
+
} catch (err) {
|
|
319
|
+
try {
|
|
320
|
+
unlinkSync(tmpPath);
|
|
321
|
+
} catch {}
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
load(email) {
|
|
326
|
+
const record = readRecord(join(storeDir(), fileName(email)));
|
|
327
|
+
return record && record.email === normaliseEmail(email) ? record : null;
|
|
328
|
+
},
|
|
329
|
+
remove(email) {
|
|
330
|
+
rmSync(join(storeDir(), fileName(email)), { force: true });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
247
335
|
//#region src/auth/profiles.ts
|
|
248
336
|
function createFluidProfile({ name, token, companyName, baseUrl, storedAt }) {
|
|
249
337
|
return {
|
|
@@ -258,19 +346,110 @@ function createFluidProfile({ name, token, companyName, baseUrl, storedAt }) {
|
|
|
258
346
|
//#region src/commands/login.ts
|
|
259
347
|
/**
|
|
260
348
|
* fluid login — authenticate via email MFA or API token
|
|
349
|
+
*
|
|
350
|
+
* Besides the interactive sign-in, an email sign-in can run in two steps
|
|
351
|
+
* with no terminal, for an agent: `--send-code` emails the code and saves
|
|
352
|
+
* the server's sign-in id (auth/pending-login.ts), then `--code` redeems
|
|
353
|
+
* it. `--send-code` requires `--shop`, which ties the code to one company:
|
|
354
|
+
* only such a code lets an admin's sign-in confirm a new signup.
|
|
355
|
+
*/
|
|
356
|
+
const defaultLoginDeps = {
|
|
357
|
+
api: {
|
|
358
|
+
validateToken,
|
|
359
|
+
sendMfa,
|
|
360
|
+
confirmMfa
|
|
361
|
+
},
|
|
362
|
+
prompt: (question) => prompts(question),
|
|
363
|
+
spinner: (text) => ora(text).start(),
|
|
364
|
+
log: (line) => console.log(line),
|
|
365
|
+
err: (line) => console.error(line),
|
|
366
|
+
env: process.env,
|
|
367
|
+
isInteractive: () => Boolean(process.stdin.isTTY),
|
|
368
|
+
readStdin: () => text(process.stdin),
|
|
369
|
+
pending: createPendingLoginStore()
|
|
370
|
+
};
|
|
371
|
+
const SILENT_SPINNER = {
|
|
372
|
+
succeed: () => {},
|
|
373
|
+
fail: () => {}
|
|
374
|
+
};
|
|
375
|
+
const TWO_STEP_HINT = "Sign in in two steps instead:\n fluid login --send-code --email <email> --shop <shop>\n fluid login --code --email <email> --yes\nwith the emailed code in FLUID_LOGIN_CODE (or --code - to read it from stdin). --yes replaces a profile with the company's name; --name <profile> picks another. Or set FLUID_TOKEN.";
|
|
376
|
+
function createUi(json, deps) {
|
|
377
|
+
return {
|
|
378
|
+
json,
|
|
379
|
+
spinner: (text) => json ? SILENT_SPINNER : deps.spinner(text),
|
|
380
|
+
result: (payload) => {
|
|
381
|
+
if (json) deps.log(JSON.stringify(payload, null, 2));
|
|
382
|
+
},
|
|
383
|
+
fail: (error, spinner) => {
|
|
384
|
+
if (json) {
|
|
385
|
+
deps.log(JSON.stringify({
|
|
386
|
+
status: "error",
|
|
387
|
+
code: error.code,
|
|
388
|
+
message: error.message,
|
|
389
|
+
...error.details ? { details: error.details } : {}
|
|
390
|
+
}, null, 2));
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
if (spinner) spinner.fail(chalk.red(error.message));
|
|
394
|
+
else deps.log(chalk.red(error.message));
|
|
395
|
+
if (error.details) deps.log(chalk.dim(error.details));
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/** The shop option for an API call, or no argument at all without one. */
|
|
401
|
+
function shopArgs(shop) {
|
|
402
|
+
return shop ? [{ fluidShop: shop }] : [];
|
|
403
|
+
}
|
|
404
|
+
/** The part of a shop the server matches on: "acme" in "acme.fluid.app". */
|
|
405
|
+
function shopLabel(shop) {
|
|
406
|
+
return (shop.trim().toLowerCase().split(".")[0] ?? "").trim();
|
|
407
|
+
}
|
|
408
|
+
function findByShop(companies, shop) {
|
|
409
|
+
return companies.find((c) => shopLabel(c.shopName) === shopLabel(shop));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Whether the entry's token is an admin or rep session, the only kind the
|
|
413
|
+
* CLI's commands accept. A shopper's code returns a "member" or "customer"
|
|
414
|
+
* token instead. The server types every entry, so one with no type is
|
|
415
|
+
* refused rather than stored.
|
|
261
416
|
*/
|
|
262
|
-
|
|
417
|
+
function isStaffSession(company) {
|
|
418
|
+
return company.authType === "rep";
|
|
419
|
+
}
|
|
420
|
+
function shopperSession(email, company) {
|
|
421
|
+
return {
|
|
422
|
+
code: "SHOPPER_SESSION",
|
|
423
|
+
message: `${email} does not sign in at ${company.shopName} as an admin or rep, so it cannot sign in to the CLI.`
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* What to do about profile `profileName` before writing it: replace it when
|
|
428
|
+
* it is new or with `--yes`, ask in a terminal, and refuse otherwise, since
|
|
429
|
+
* no one could answer the question.
|
|
430
|
+
*/
|
|
431
|
+
function overwriteRule(profileName, opts) {
|
|
432
|
+
if (!readConfig().profiles[profileName] || opts.yes) return "replace";
|
|
433
|
+
return opts.interactive ? "ask" : "refuse";
|
|
434
|
+
}
|
|
435
|
+
async function overwriteDecision(profileName, opts, deps) {
|
|
436
|
+
const rule = overwriteRule(profileName, opts);
|
|
437
|
+
if (rule !== "ask") return rule;
|
|
263
438
|
const existing = readConfig().profiles[profileName];
|
|
264
|
-
|
|
265
|
-
const response = await prompts({
|
|
439
|
+
return (await deps.prompt({
|
|
266
440
|
type: "confirm",
|
|
267
441
|
name: "overwrite",
|
|
268
|
-
message: `Profile "${profileName}" already exists (${existing
|
|
442
|
+
message: `Profile "${profileName}" already exists (${existing?.companyName}). Overwrite?`,
|
|
269
443
|
initial: false
|
|
270
|
-
});
|
|
271
|
-
|
|
444
|
+
}))["overwrite"] ? "replace" : "keep";
|
|
445
|
+
}
|
|
446
|
+
function profileExists(profileName, codeUsed) {
|
|
447
|
+
return {
|
|
448
|
+
code: "PROFILE_EXISTS",
|
|
449
|
+
message: `Profile "${profileName}" already exists. Pass --yes to replace it` + (codeUsed ? "; the code is used, so send a new one first." : ".")
|
|
450
|
+
};
|
|
272
451
|
}
|
|
273
|
-
function storeProfile(profileName, token, companyName) {
|
|
452
|
+
function storeProfile(profileName, token, companyName, baseUrl = getFluidApiBase()) {
|
|
274
453
|
updateConfig((config) => ({
|
|
275
454
|
...config,
|
|
276
455
|
activeProfile: profileName,
|
|
@@ -280,77 +459,76 @@ function storeProfile(profileName, token, companyName) {
|
|
|
280
459
|
name: profileName,
|
|
281
460
|
token,
|
|
282
461
|
companyName,
|
|
283
|
-
baseUrl
|
|
462
|
+
baseUrl
|
|
284
463
|
})
|
|
285
464
|
}
|
|
286
465
|
}));
|
|
287
466
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
467
|
+
function loggedIn(ui, deps, profileName, company) {
|
|
468
|
+
ui.result({
|
|
469
|
+
status: "signed_in",
|
|
470
|
+
profile: profileName,
|
|
471
|
+
company
|
|
472
|
+
});
|
|
473
|
+
if (!ui.json) deps.log(chalk.green(`Logged in as ${chalk.bold(company.name)} (profile: ${chalk.bold(profileName)})`));
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
function cancelled(deps) {
|
|
477
|
+
deps.log(chalk.yellow("Login cancelled — existing profile not overwritten."));
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
async function loginWithToken(token, opts, interactive, ui, deps) {
|
|
481
|
+
const spinner = ui.spinner("Validating token...");
|
|
482
|
+
const result = await deps.api.validateToken(token);
|
|
483
|
+
if (!result.success) return ui.fail(result.error, spinner);
|
|
484
|
+
const name = opts.name ?? result.value.name;
|
|
298
485
|
spinner.succeed(chalk.green(`Token valid — ${chalk.bold(result.value.name)}`));
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
486
|
+
const decision = await overwriteDecision(name, {
|
|
487
|
+
yes: Boolean(opts.yes),
|
|
488
|
+
interactive
|
|
489
|
+
}, deps);
|
|
490
|
+
if (decision === "refuse") return ui.fail(profileExists(name, false));
|
|
491
|
+
if (decision === "keep") return cancelled(deps);
|
|
303
492
|
storeProfile(name, token, result.value.name);
|
|
304
|
-
|
|
493
|
+
return loggedIn(ui, deps, name, { name: result.value.name });
|
|
305
494
|
}
|
|
306
|
-
async function loginWithEmail(
|
|
307
|
-
let email =
|
|
495
|
+
async function loginWithEmail(opts, ui, deps) {
|
|
496
|
+
let email = opts.email;
|
|
308
497
|
if (!email) {
|
|
309
|
-
email = (await
|
|
498
|
+
email = (await deps.prompt({
|
|
310
499
|
type: "text",
|
|
311
500
|
name: "email",
|
|
312
501
|
message: "Enter your email"
|
|
313
502
|
}))["email"];
|
|
314
503
|
if (!email) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return;
|
|
504
|
+
deps.log(chalk.red("No email provided. Aborting."));
|
|
505
|
+
return 1;
|
|
318
506
|
}
|
|
319
507
|
}
|
|
320
|
-
const sendSpinner =
|
|
321
|
-
const sendResult = await sendMfa(email);
|
|
322
|
-
if (!sendResult.success)
|
|
323
|
-
|
|
324
|
-
if (sendResult.error.details) console.log(chalk.dim(sendResult.error.details));
|
|
325
|
-
process.exitCode = 1;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const expiresInMs = new Date(sendResult.value.expiresAt).getTime() - Date.now();
|
|
329
|
-
const expiresInMin = Math.round(expiresInMs / 1e3 / 60);
|
|
330
|
-
const expiryNote = expiresInMin > 0 ? `expires in ~${expiresInMin} min` : "code may expire soon — check your email quickly";
|
|
331
|
-
sendSpinner.succeed(`Verification code sent — check your email (${expiryNote})`);
|
|
508
|
+
const sendSpinner = ui.spinner("Sending verification code...");
|
|
509
|
+
const sendResult = await deps.api.sendMfa(email, ...shopArgs(opts.shop));
|
|
510
|
+
if (!sendResult.success) return ui.fail(sendResult.error, sendSpinner);
|
|
511
|
+
sendSpinner.succeed(`Verification code sent — check your email (${expiryNote(sendResult.value.expiresAt)})`);
|
|
332
512
|
const MAX_CODE_ATTEMPTS = 3;
|
|
333
513
|
let confirmResult;
|
|
334
514
|
for (let attempt = 1; attempt <= MAX_CODE_ATTEMPTS; attempt++) {
|
|
335
|
-
const code = (await
|
|
515
|
+
const code = (await deps.prompt({
|
|
336
516
|
type: "text",
|
|
337
517
|
name: "code",
|
|
338
518
|
message: "Enter the 6-digit code"
|
|
339
519
|
}))["code"];
|
|
340
520
|
if (!code) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return;
|
|
521
|
+
deps.log(chalk.red("No code provided. Aborting."));
|
|
522
|
+
return 1;
|
|
344
523
|
}
|
|
345
524
|
if (!/^\d{6}$/.test(code)) {
|
|
346
|
-
|
|
525
|
+
deps.log(chalk.red("Code must be exactly 6 digits."));
|
|
347
526
|
if (attempt < MAX_CODE_ATTEMPTS) continue;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return;
|
|
527
|
+
deps.log(chalk.red("Too many invalid attempts. Aborting."));
|
|
528
|
+
return 1;
|
|
351
529
|
}
|
|
352
|
-
const confirmSpinner =
|
|
353
|
-
confirmResult = await confirmMfa(sendResult.value.uuid, code);
|
|
530
|
+
const confirmSpinner = ui.spinner("Verifying code...");
|
|
531
|
+
confirmResult = await deps.api.confirmMfa(sendResult.value.uuid, code, ...shopArgs(opts.shop));
|
|
354
532
|
if (confirmResult.success) {
|
|
355
533
|
confirmSpinner.succeed("Verified");
|
|
356
534
|
break;
|
|
@@ -359,66 +537,256 @@ async function loginWithEmail(initialEmail, profileName) {
|
|
|
359
537
|
confirmSpinner.fail(chalk.red(`${confirmResult.error.message} (attempt ${attempt}/${MAX_CODE_ATTEMPTS})`));
|
|
360
538
|
continue;
|
|
361
539
|
}
|
|
362
|
-
|
|
363
|
-
if (confirmResult.error.details) console.log(chalk.dim(confirmResult.error.details));
|
|
364
|
-
process.exitCode = 1;
|
|
365
|
-
return;
|
|
540
|
+
return ui.fail(confirmResult.error, confirmSpinner);
|
|
366
541
|
}
|
|
367
|
-
if (!confirmResult?.success) return;
|
|
542
|
+
if (!confirmResult?.success) return 0;
|
|
368
543
|
const { companies } = confirmResult.value;
|
|
369
544
|
if (companies.length === 0) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return;
|
|
545
|
+
deps.log(chalk.red("No companies found for this account."));
|
|
546
|
+
return 1;
|
|
373
547
|
}
|
|
548
|
+
const named = opts.shop ? findByShop(companies, opts.shop) : void 0;
|
|
374
549
|
let selected;
|
|
375
|
-
if (
|
|
550
|
+
if (named) selected = named;
|
|
551
|
+
else if (companies.length === 1) {
|
|
376
552
|
const first = companies[0];
|
|
377
553
|
if (!first) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
return;
|
|
554
|
+
deps.log(chalk.red("No companies found for this account."));
|
|
555
|
+
return 1;
|
|
381
556
|
}
|
|
382
557
|
selected = first;
|
|
383
558
|
} else {
|
|
384
|
-
const
|
|
559
|
+
const companyChoices = companies.map((c) => ({
|
|
560
|
+
title: `${c.name} (${c.shopName})`,
|
|
561
|
+
value: c.id
|
|
562
|
+
}));
|
|
563
|
+
const companyId = (await deps.prompt({
|
|
385
564
|
type: "autocomplete",
|
|
386
565
|
name: "companyId",
|
|
387
566
|
message: "Select a company (type to search)",
|
|
388
|
-
choices:
|
|
389
|
-
title: `${c.name} (${c.shopName})`,
|
|
390
|
-
value: c.id
|
|
391
|
-
})),
|
|
567
|
+
choices: companyChoices,
|
|
392
568
|
suggest: (input, choices) => Promise.resolve(input ? choices.filter((c) => c.title.toLowerCase().includes(input.toLowerCase())) : choices)
|
|
393
569
|
}))["companyId"];
|
|
394
570
|
if (companyId === void 0) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return;
|
|
571
|
+
deps.log(chalk.red("No company selected. Aborting."));
|
|
572
|
+
return 1;
|
|
398
573
|
}
|
|
399
574
|
const choice = companies.find((c) => c.id === companyId);
|
|
400
575
|
if (!choice) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return;
|
|
576
|
+
deps.log(chalk.red("Invalid selection. Aborting."));
|
|
577
|
+
return 1;
|
|
404
578
|
}
|
|
405
579
|
selected = choice;
|
|
406
580
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
581
|
+
if (!isStaffSession(selected)) return ui.fail(shopperSession(email, selected));
|
|
582
|
+
const name = opts.name ?? selected.name;
|
|
583
|
+
if (await overwriteDecision(name, {
|
|
584
|
+
yes: Boolean(opts.yes),
|
|
585
|
+
interactive: true
|
|
586
|
+
}, deps) !== "replace") return cancelled(deps);
|
|
412
587
|
storeProfile(name, selected.jwt, selected.name);
|
|
413
|
-
|
|
588
|
+
return loggedIn(ui, deps, name, { name: selected.name });
|
|
589
|
+
}
|
|
590
|
+
function expiryNote(expiresAtIso) {
|
|
591
|
+
const expiresInMs = new Date(expiresAtIso).getTime() - Date.now();
|
|
592
|
+
const expiresInMin = Math.round(expiresInMs / 1e3 / 60);
|
|
593
|
+
return expiresInMin > 0 ? `expires in ~${expiresInMin} min` : "code may expire soon — check your email quickly";
|
|
414
594
|
}
|
|
415
|
-
const
|
|
416
|
-
|
|
595
|
+
const SHELL_SAFE = /^[\w.@+-]+$/;
|
|
596
|
+
/**
|
|
597
|
+
* The command that finishes this sign-in, for a person or agent to run.
|
|
598
|
+
* Values with characters a shell would act on are left as placeholders.
|
|
599
|
+
*/
|
|
600
|
+
function finishCommand(email, shop) {
|
|
601
|
+
const safe = (value, placeholder) => SHELL_SAFE.test(value) ? value : placeholder;
|
|
602
|
+
return `fluid login --code --email ${safe(email, "<email>")} --shop ${safe(shop, "<shop>")}`;
|
|
603
|
+
}
|
|
604
|
+
/** `fluid login --send-code`: email a code, save the sign-in, exit. */
|
|
605
|
+
async function sendCodeStep(opts, ui, deps) {
|
|
606
|
+
if (!opts.email) return ui.fail({
|
|
607
|
+
code: "EMAIL_REQUIRED",
|
|
608
|
+
message: "--send-code needs --email <address>."
|
|
609
|
+
});
|
|
610
|
+
if (!opts.shop) return ui.fail({
|
|
611
|
+
code: "SHOP_REQUIRED",
|
|
612
|
+
message: "--send-code needs --shop <shop>, the company to sign in to (acme or acme.fluid.app). A new signup's response names it as fluid_shop."
|
|
613
|
+
});
|
|
614
|
+
const email = normaliseEmail(opts.email);
|
|
615
|
+
const shop = opts.shop;
|
|
616
|
+
const spinner = ui.spinner("Sending verification code...");
|
|
617
|
+
const sent = await deps.api.sendMfa(email, ...shopArgs(shop));
|
|
618
|
+
if (!sent.success) return ui.fail(sent.error, spinner);
|
|
619
|
+
deps.pending.save({
|
|
620
|
+
uuid: sent.value.uuid,
|
|
621
|
+
email,
|
|
622
|
+
shop,
|
|
623
|
+
expiresAt: sent.value.expiresAt,
|
|
624
|
+
baseUrl: getFluidApiBase()
|
|
625
|
+
});
|
|
626
|
+
spinner.succeed(`If ${email} can sign in at ${shop}, a code is on its way (${expiryNote(sent.value.expiresAt)})`);
|
|
627
|
+
ui.result({
|
|
628
|
+
status: "code_sent",
|
|
629
|
+
email,
|
|
630
|
+
shop,
|
|
631
|
+
expiresAt: sent.value.expiresAt
|
|
632
|
+
});
|
|
633
|
+
if (!ui.json) {
|
|
634
|
+
deps.log(`Finish with: ${finishCommand(email, shop)}`);
|
|
635
|
+
deps.log(chalk.dim("with the code in FLUID_LOGIN_CODE, or add - after --code to read it from stdin. With no terminal, add --yes to replace a profile with the company's name, or --name <profile>."));
|
|
636
|
+
}
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* The code for the finish step. A bare `--code` reads FLUID_LOGIN_CODE,
|
|
641
|
+
* `--code -` reads stdin, and any other value is the code. Stdin is read
|
|
642
|
+
* only when asked for: an agent's stdin may be a pipe that never closes,
|
|
643
|
+
* and reading it uninvited would wait forever.
|
|
644
|
+
*/
|
|
645
|
+
async function readCode(opts, deps) {
|
|
646
|
+
if (opts.code === "-") return (await deps.readStdin()).trim() || void 0;
|
|
647
|
+
if (typeof opts.code === "string") {
|
|
648
|
+
deps.err(chalk.yellow("Warning: a code passed via --code is visible in shell history and process listings. Prefer FLUID_LOGIN_CODE, or --code - to read it from stdin."));
|
|
649
|
+
return opts.code.trim();
|
|
650
|
+
}
|
|
651
|
+
return deps.env["FLUID_LOGIN_CODE"]?.trim() || void 0;
|
|
652
|
+
}
|
|
653
|
+
/** The server's answer for a code that cannot finish, by error code. */
|
|
654
|
+
function finishError(error, shop) {
|
|
655
|
+
if (error.code === "INVALID_CODE") return {
|
|
656
|
+
code: error.code,
|
|
657
|
+
message: `${error.message}. Five wrong codes lock this sign-in; then run --send-code again.`
|
|
658
|
+
};
|
|
659
|
+
if (error.code === "UUID_NOT_FOUND") return {
|
|
660
|
+
code: error.code,
|
|
661
|
+
message: `This sign-in can't be finished with a code: the shop "${shop}" is wrong, the address is not an admin or rep there, or the company signs in with an emailed link instead. Check them and run --send-code again.`
|
|
662
|
+
};
|
|
663
|
+
return error;
|
|
664
|
+
}
|
|
665
|
+
/** `fluid login --code`: redeem the code for the saved sign-in. */
|
|
666
|
+
async function finishStep(opts, interactive, ui, deps) {
|
|
667
|
+
if (!opts.email) return ui.fail({
|
|
668
|
+
code: "EMAIL_REQUIRED",
|
|
669
|
+
message: "--code needs --email <address>, the one used with --send-code."
|
|
670
|
+
});
|
|
671
|
+
if (!interactive && !opts.yes && !opts.name) return ui.fail({
|
|
672
|
+
code: "YES_OR_NAME_REQUIRED",
|
|
673
|
+
message: "With no terminal, --code needs --yes, to replace a profile with the company's name, or --name <profile>."
|
|
674
|
+
});
|
|
675
|
+
const email = normaliseEmail(opts.email);
|
|
676
|
+
const record = deps.pending.load(email);
|
|
677
|
+
if (!record) return ui.fail({
|
|
678
|
+
code: "NO_PENDING_LOGIN",
|
|
679
|
+
message: `No sign-in is waiting for ${email}. Run fluid login --send-code --email ${email} first.`
|
|
680
|
+
});
|
|
681
|
+
const { shop } = record;
|
|
682
|
+
if (opts.shop && shopLabel(opts.shop) !== shopLabel(shop)) return ui.fail({
|
|
683
|
+
code: "SHOP_MISMATCH",
|
|
684
|
+
message: `The code for ${email} was sent for ${shop}, not ${opts.shop}.`
|
|
685
|
+
});
|
|
686
|
+
if (!(Date.parse(record.expiresAt) > Date.now())) {
|
|
687
|
+
deps.pending.remove(email);
|
|
688
|
+
return ui.fail({
|
|
689
|
+
code: "CODE_EXPIRED",
|
|
690
|
+
message: `The code for ${email} has expired. Run fluid login --send-code again.`
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
const code = await readCode(opts, deps);
|
|
694
|
+
if (!code) return ui.fail({
|
|
695
|
+
code: "NO_CODE",
|
|
696
|
+
message: "No code given. Put it in FLUID_LOGIN_CODE, pass --code - to read it from stdin, or pass it to --code."
|
|
697
|
+
});
|
|
698
|
+
if (!/^\d{6}$/.test(code)) return ui.fail({
|
|
699
|
+
code: "INVALID_CODE_FORMAT",
|
|
700
|
+
message: "The code must be exactly 6 digits."
|
|
701
|
+
});
|
|
702
|
+
const overwrite = {
|
|
703
|
+
yes: Boolean(opts.yes),
|
|
704
|
+
interactive
|
|
705
|
+
};
|
|
706
|
+
if (opts.name && overwriteRule(opts.name, overwrite) === "refuse") return ui.fail(profileExists(opts.name, false));
|
|
707
|
+
const spinner = ui.spinner("Verifying code...");
|
|
708
|
+
const confirmed = await deps.api.confirmMfa(record.uuid, code, {
|
|
709
|
+
fluidShop: shop,
|
|
710
|
+
baseUrl: record.baseUrl
|
|
711
|
+
});
|
|
712
|
+
if (!confirmed.success) {
|
|
713
|
+
if (![
|
|
714
|
+
"INVALID_CODE",
|
|
715
|
+
"RATE_LIMITED",
|
|
716
|
+
"API_UNREACHABLE"
|
|
717
|
+
].includes(confirmed.error.code)) deps.pending.remove(email);
|
|
718
|
+
return ui.fail(finishError(confirmed.error, shop), spinner);
|
|
719
|
+
}
|
|
720
|
+
spinner.succeed("Verified");
|
|
721
|
+
deps.pending.remove(email);
|
|
722
|
+
const { companies } = confirmed.value;
|
|
723
|
+
const selected = findByShop(companies, shop);
|
|
724
|
+
if (!selected) {
|
|
725
|
+
if (companies.length === 0) return ui.fail({
|
|
726
|
+
code: "NO_COMPANIES",
|
|
727
|
+
message: "No companies found for this account."
|
|
728
|
+
});
|
|
729
|
+
const shops = companies.map((c) => c.shopName).join(", ");
|
|
730
|
+
return ui.fail({
|
|
731
|
+
code: "SHOP_NOT_FOUND",
|
|
732
|
+
message: `This account has no company at ${shop}. It has: ${shops}.`
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (!isStaffSession(selected)) return ui.fail(shopperSession(email, selected));
|
|
736
|
+
const name = opts.name ?? selected.name;
|
|
737
|
+
const decision = await overwriteDecision(name, overwrite, deps);
|
|
738
|
+
if (decision === "refuse") return ui.fail(profileExists(name, true));
|
|
739
|
+
if (decision === "keep") return cancelled(deps);
|
|
740
|
+
storeProfile(name, selected.jwt, selected.name, record.baseUrl);
|
|
741
|
+
return loggedIn(ui, deps, name, {
|
|
742
|
+
id: selected.id,
|
|
743
|
+
name: selected.name,
|
|
744
|
+
shop: selected.shopName
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Run `fluid login` and return its exit code.
|
|
749
|
+
*/
|
|
750
|
+
async function runLogin(opts, deps = defaultLoginDeps) {
|
|
751
|
+
const json = Boolean(opts.json);
|
|
752
|
+
const ui = createUi(json, deps);
|
|
753
|
+
const interactive = deps.isInteractive() && !json && opts.code !== "-";
|
|
754
|
+
if (opts.sendCode || opts.code !== void 0) {
|
|
755
|
+
if (opts.token) return ui.fail({
|
|
756
|
+
code: "FLAG_CONFLICT",
|
|
757
|
+
message: "--token cannot be combined with --send-code or --code."
|
|
758
|
+
});
|
|
759
|
+
if (opts.sendCode && opts.code !== void 0) return ui.fail({
|
|
760
|
+
code: "FLAG_CONFLICT",
|
|
761
|
+
message: "Run --send-code and --code as two separate commands."
|
|
762
|
+
});
|
|
763
|
+
return opts.sendCode ? sendCodeStep(opts, ui, deps) : finishStep(opts, interactive, ui, deps);
|
|
764
|
+
}
|
|
765
|
+
const token = opts.token ?? deps.env["FLUID_TOKEN"];
|
|
417
766
|
if (token) {
|
|
418
|
-
if (opts.token)
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
767
|
+
if (opts.token) (json ? deps.err : deps.log)(chalk.yellow("Warning: token passed via --token flag is visible in shell history and process listings. Prefer the FLUID_TOKEN environment variable."));
|
|
768
|
+
return loginWithToken(token, opts, interactive, ui, deps);
|
|
769
|
+
}
|
|
770
|
+
if (!interactive) {
|
|
771
|
+
const why = json ? "fluid login --json cannot ask for the email and code." : "fluid login needs a terminal to ask for the email and code.";
|
|
772
|
+
return ui.fail({
|
|
773
|
+
code: "NOT_INTERACTIVE",
|
|
774
|
+
message: `${why}\n${TWO_STEP_HINT}`
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return loginWithEmail(opts, ui, deps);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* The `login` command, running `run` with the parsed options. Built by a
|
|
781
|
+
* function so a test can check the flag parsing without signing in.
|
|
782
|
+
*/
|
|
783
|
+
function buildLoginCommand(run = (opts) => runLogin(opts)) {
|
|
784
|
+
return new Command("login").description("Authenticate with the Fluid API").option("-t, --token <token>", "API token (skips email flow). Prefer FLUID_TOKEN env var to avoid shell history exposure").option("-e, --email <email>", "Email address for MFA login (required with --send-code and --code)").option("-n, --name <name>", "Profile name (defaults to company name)").option("--shop <shop>", "Sign in to this company (e.g. acme or acme.fluid.app); required with --send-code. An admin's emailed code also confirms a new signup").option("--send-code", "Email a sign-in code and exit, for use without a terminal; finish with --code").option("--code [code]", "Finish a sign-in started with --send-code. Reads the code from FLUID_LOGIN_CODE, or from stdin with --code -; a code given here is visible in shell history").option("-y, --yes", "Replace an existing profile without asking").option("--json", "Print the result as JSON").action(async (opts) => {
|
|
785
|
+
const exitCode = await run(opts);
|
|
786
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
const loginCommand = buildLoginCommand();
|
|
422
790
|
//#endregion
|
|
423
791
|
//#region src/commands/logout.ts
|
|
424
792
|
/**
|