@fluid-app/fluid-cli 0.1.64 → 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.
@@ -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, c as getConfigDir, d as FLUID_API_ERROR, f as confirmMfa, g as switchCompany, h as sendMfa, j as filterCatalog, m as getFluidApiBase, n as getAuthToken, o as updateConfig, p as fetchUserCompanies, s as writeConfig, t as getActiveProfile } from "../token-6SEVoGMw.mjs";
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
- async function confirmOverwrite(profileName) {
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
- if (!existing) return true;
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.companyName}). Overwrite?`,
442
+ message: `Profile "${profileName}" already exists (${existing?.companyName}). Overwrite?`,
269
443
  initial: false
270
- });
271
- return Boolean(response["overwrite"]);
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: getFluidApiBase()
462
+ baseUrl
284
463
  })
285
464
  }
286
465
  }));
287
466
  }
288
- async function loginWithToken(token, profileName) {
289
- const spinner = ora("Validating token...").start();
290
- const result = await validateToken(token);
291
- if (!result.success) {
292
- spinner.fail(chalk.red(result.error.message));
293
- if (result.error.details) console.log(chalk.dim(result.error.details));
294
- process.exitCode = 1;
295
- return;
296
- }
297
- const name = profileName ?? result.value.name;
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
- if (!await confirmOverwrite(name)) {
300
- console.log(chalk.yellow("Login cancelled — existing profile not overwritten."));
301
- return;
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
- console.log(chalk.green(`Logged in as ${chalk.bold(result.value.name)} (profile: ${chalk.bold(name)})`));
493
+ return loggedIn(ui, deps, name, { name: result.value.name });
305
494
  }
306
- async function loginWithEmail(initialEmail, profileName) {
307
- let email = initialEmail;
495
+ async function loginWithEmail(opts, ui, deps) {
496
+ let email = opts.email;
308
497
  if (!email) {
309
- email = (await prompts({
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
- console.log(chalk.red("No email provided. Aborting."));
316
- process.exitCode = 1;
317
- return;
504
+ deps.log(chalk.red("No email provided. Aborting."));
505
+ return 1;
318
506
  }
319
507
  }
320
- const sendSpinner = ora("Sending verification code...").start();
321
- const sendResult = await sendMfa(email);
322
- if (!sendResult.success) {
323
- sendSpinner.fail(chalk.red(sendResult.error.message));
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 prompts({
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
- console.log(chalk.red("No code provided. Aborting."));
342
- process.exitCode = 1;
343
- return;
521
+ deps.log(chalk.red("No code provided. Aborting."));
522
+ return 1;
344
523
  }
345
524
  if (!/^\d{6}$/.test(code)) {
346
- console.log(chalk.red("Code must be exactly 6 digits."));
525
+ deps.log(chalk.red("Code must be exactly 6 digits."));
347
526
  if (attempt < MAX_CODE_ATTEMPTS) continue;
348
- console.log(chalk.red("Too many invalid attempts. Aborting."));
349
- process.exitCode = 1;
350
- return;
527
+ deps.log(chalk.red("Too many invalid attempts. Aborting."));
528
+ return 1;
351
529
  }
352
- const confirmSpinner = ora("Verifying code...").start();
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
- confirmSpinner.fail(chalk.red(confirmResult.error.message));
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
- console.log(chalk.red("No companies found for this account."));
371
- process.exitCode = 1;
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 (companies.length === 1) {
550
+ if (named) selected = named;
551
+ else if (companies.length === 1) {
376
552
  const first = companies[0];
377
553
  if (!first) {
378
- console.log(chalk.red("No companies found for this account."));
379
- process.exitCode = 1;
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 companyId = (await prompts({
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: companies.map((c) => ({
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
- console.log(chalk.red("No company selected. Aborting."));
396
- process.exitCode = 1;
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
- console.log(chalk.red("Invalid selection. Aborting."));
402
- process.exitCode = 1;
403
- return;
576
+ deps.log(chalk.red("Invalid selection. Aborting."));
577
+ return 1;
404
578
  }
405
579
  selected = choice;
406
580
  }
407
- const name = profileName ?? selected.name;
408
- if (!await confirmOverwrite(name)) {
409
- console.log(chalk.yellow("Login cancelled — existing profile not overwritten."));
410
- return;
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
- console.log(chalk.green(`Logged in as ${chalk.bold(selected.name)} (profile: ${chalk.bold(name)})`));
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 loginCommand = 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").option("-n, --name <name>", "Profile name (defaults to company name)").action(async (opts) => {
416
- const token = opts.token ?? process.env["FLUID_TOKEN"];
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) console.log(chalk.yellow("Warning: token passed via --token flag is visible in shell history and process listings. Prefer the FLUID_TOKEN environment variable."));
419
- await loginWithToken(token, opts.name);
420
- } else await loginWithEmail(opts.email, opts.name);
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
  /**