flaghoist 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -1
  2. package/dist/index.js +436 -25
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -19,7 +19,8 @@ into `wrangler.toml`.
19
19
  ## Managing flags
20
20
 
21
21
  Point the CLI at your server with `--url` and `--token`, or set `FLAGS_URL` and
22
- `FLAGS_ADMIN_TOKEN`.
22
+ `FLAGS_ADMIN_TOKEN`. On a server with user accounts, `flaghoist login --url <server>` signs you in
23
+ once and saves a personal access token instead.
23
24
 
24
25
  ```bash
25
26
  flaghoist flag list
@@ -31,6 +32,18 @@ flaghoist flag rollout new-checkout 25
31
32
  Rollouts are sticky. A user who lands inside 25 percent stays inside it as you go to 50, so nobody
32
33
  flickers in and out between deploys.
33
34
 
35
+ ## Managing members
36
+
37
+ On a server with user accounts turned on, admins invite and manage people from the command line
38
+ too. Invite and reset commands print a link to send yourself, and the server emails it as well when it has an email sender set up.
39
+
40
+ ```bash
41
+ flaghoist users invite ada@example.com --role editor
42
+ flaghoist users list
43
+ flaghoist users role ada@example.com admin
44
+ flaghoist users reset ada@example.com
45
+ ```
46
+
34
47
  ## Owning the code
35
48
 
36
49
  ```bash
package/dist/index.js CHANGED
@@ -11,9 +11,10 @@ import {
11
11
 
12
12
  // src/index.ts
13
13
  import { spawnSync } from "child_process";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
15
- import { dirname, join } from "path";
16
- import { createInterface } from "readline/promises";
14
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
15
+ import { hostname } from "os";
16
+ import { dirname as dirname2, join as join2 } from "path";
17
+ import { createInterface as createInterface2 } from "readline/promises";
17
18
  import { parseArgs } from "util";
18
19
 
19
20
  // src/admin.ts
@@ -290,27 +291,402 @@ function generatePackageJson(config) {
290
291
  `;
291
292
  }
292
293
 
294
+ // src/credentials.ts
295
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
296
+ import { homedir } from "os";
297
+ import { dirname, join } from "path";
298
+ function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
299
+ const dir = env.FLAGHOIST_CONFIG_DIR ?? (platform === "win32" && env.APPDATA ? join(env.APPDATA, "flaghoist") : join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "flaghoist"));
300
+ return join(dir, "credentials.json");
301
+ }
302
+ function serverKey(url) {
303
+ return url.trim().replace(/\/+$/, "");
304
+ }
305
+ function read(path) {
306
+ if (!existsSync(path)) return { servers: {} };
307
+ try {
308
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
309
+ return { servers: parsed.servers && typeof parsed.servers === "object" ? parsed.servers : {} };
310
+ } catch {
311
+ throw new Error(`Could not read ${path}. Fix or delete it, then sign in again.`);
312
+ }
313
+ }
314
+ function write(path, file) {
315
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
316
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
317
+ `, { mode: 384 });
318
+ chmodSync(path, 384);
319
+ }
320
+ function savedCredential(path, url) {
321
+ return read(path).servers[serverKey(url)] ?? null;
322
+ }
323
+ function savedServers(path) {
324
+ return Object.keys(read(path).servers);
325
+ }
326
+ function saveCredential(path, url, credential) {
327
+ const file = read(path);
328
+ file.servers[serverKey(url)] = credential;
329
+ write(path, file);
330
+ }
331
+ function removeCredential(path, url) {
332
+ const file = read(path);
333
+ const key = serverKey(url);
334
+ if (!file.servers[key]) return false;
335
+ delete file.servers[key];
336
+ write(path, file);
337
+ return true;
338
+ }
339
+
340
+ // src/prompt.ts
341
+ import { createInterface } from "readline/promises";
342
+ async function readPassword(options) {
343
+ const input = options.input ?? process.stdin;
344
+ const output = options.output ?? process.stdout;
345
+ if (options.fromStdin) {
346
+ const chunks = [];
347
+ for await (const chunk of input) chunks.push(Buffer.from(chunk));
348
+ const password = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
349
+ if (!password) throw new Error("--password-stdin was set but nothing was piped in.");
350
+ return password;
351
+ }
352
+ if (!input.isTTY) {
353
+ throw new Error(
354
+ "No terminal to ask for a password in. Run this in a terminal, or pipe the password in with --password-stdin."
355
+ );
356
+ }
357
+ const rl = createInterface({ input, output, terminal: true });
358
+ output.write("Password: ");
359
+ rl._writeToOutput = () => {
360
+ };
361
+ try {
362
+ return await rl.question("");
363
+ } finally {
364
+ rl.close();
365
+ output.write("\n");
366
+ }
367
+ }
368
+
369
+ // src/tokens.ts
370
+ import {
371
+ createAdminClient as createAdminClient2,
372
+ createAuthClient,
373
+ isTwoFactorChallenge
374
+ } from "@flaghoist/admin-client";
375
+ var ROLES = ["viewer", "editor", "admin", "owner"];
376
+ var TOKENS_USAGE = ` tokens list
377
+ tokens create <name> [--role <role>] [--expires-days N|never]
378
+ tokens revoke <id or name>`;
379
+ function when(iso) {
380
+ return iso ? iso.slice(0, 10) : "never";
381
+ }
382
+ function parseExpiry(value) {
383
+ if (value === void 0) return void 0;
384
+ if (value === "never") return null;
385
+ const days = Number(value);
386
+ if (!Number.isInteger(days) || days < 1 || days > 3650) {
387
+ throw new Error('--expires-days must be a whole number from 1 to 3650, or "never".');
388
+ }
389
+ return days;
390
+ }
391
+ function findToken(tokens, ref) {
392
+ const byId = tokens.find((t) => t.id === ref);
393
+ if (byId) return byId;
394
+ const byName = tokens.filter((t) => t.name === ref);
395
+ if (byName.length === 1) return byName[0];
396
+ if (byName.length > 1) throw new Error(`More than one token is named "${ref}". Use its id.`);
397
+ throw new Error(`No token with the id or name "${ref}".`);
398
+ }
399
+ async function runTokens(client, positionals, options) {
400
+ const [sub, a] = positionals;
401
+ switch (sub) {
402
+ case "list": {
403
+ const tokens = await client.listTokens();
404
+ if (tokens.length === 0) return ["No access tokens."];
405
+ return tokens.map(
406
+ (t) => `${t.id} ${t.name.padEnd(24)} ${t.role.padEnd(7)} ${t.prefix}... expires ${when(
407
+ t.expiresAt
408
+ )} last used ${when(t.lastUsedAt)}`
409
+ );
410
+ }
411
+ case "create": {
412
+ if (!a) throw new Error("Usage: flaghoist tokens create <name> [--role r] [--expires-days N]");
413
+ if (options.role && !ROLES.includes(options.role)) {
414
+ throw new Error(`Role must be one of ${ROLES.join(", ")}.`);
415
+ }
416
+ const { token, info } = await client.createToken({
417
+ name: a,
418
+ ...options.role ? { role: options.role } : {},
419
+ ...options.expiresDays !== void 0 ? { expiresInDays: parseExpiry(options.expiresDays) } : {}
420
+ });
421
+ return [
422
+ `Created "${info.name}" (${info.role}, expires ${when(info.expiresAt)}). Copy it now; it is not shown again:`,
423
+ token
424
+ ];
425
+ }
426
+ case "revoke": {
427
+ if (!a) throw new Error("Usage: flaghoist tokens revoke <id or name>");
428
+ const token = findToken(await client.listTokens(), a);
429
+ await client.revokeToken(token.id);
430
+ return [`Revoked "${token.name}".`];
431
+ }
432
+ default:
433
+ throw new Error(`Unknown tokens command: ${sub ?? "(none)"}. Try "flaghoist tokens list".`);
434
+ }
435
+ }
436
+ async function loginForToken(input) {
437
+ const auth = createAuthClient({ url: input.url, fetch: input.fetch });
438
+ const config = await auth.config();
439
+ if (!config.accounts) {
440
+ throw new Error(
441
+ "This server has no user accounts. Use its admin token with --token or FLAGS_ADMIN_TOKEN."
442
+ );
443
+ }
444
+ const first = await auth.signIn(input.email, input.password);
445
+ let signedIn;
446
+ if (isTwoFactorChallenge(first)) {
447
+ if (!input.twoFactorCode) {
448
+ throw new Error("This account uses two-factor codes. Pass the current one with --code.");
449
+ }
450
+ signedIn = await auth.completeTwoFactor(first.challenge, await input.twoFactorCode());
451
+ } else {
452
+ signedIn = first;
453
+ }
454
+ const session = createAdminClient2({ url: input.url, token: signedIn.token, fetch: input.fetch });
455
+ try {
456
+ const { token, info } = await session.createToken({ name: input.tokenName });
457
+ return {
458
+ token,
459
+ tokenId: info.id,
460
+ email: signedIn.user.email,
461
+ role: info.role,
462
+ expiresAt: info.expiresAt
463
+ };
464
+ } finally {
465
+ await session.logout().catch(() => {
466
+ });
467
+ }
468
+ }
469
+
470
+ // src/users.ts
471
+ import { inviteLink } from "@flaghoist/admin-client";
472
+ var ROLES2 = ["viewer", "editor", "admin", "owner"];
473
+ var USERS_USAGE = ` users list
474
+ users invite <email> [--role viewer|editor|admin|owner]
475
+ users role <email> <role> [--env E] With --env, the role in one environment; "default" clears it
476
+ users disable <email>
477
+ users enable <email>
478
+ users remove <email>
479
+ users reset <email> Print a link that sets a new password (valid 24 hours)
480
+ users revoke <email> Cancel an open invite`;
481
+ async function memberByEmail(client, email) {
482
+ const wanted = email.trim().toLowerCase();
483
+ const member = (await client.listMembers()).find((m) => m.email === wanted);
484
+ if (!member) throw new Error(`No member with the email ${wanted}.`);
485
+ return member;
486
+ }
487
+ function requireRole(role) {
488
+ if (!role || !ROLES2.includes(role)) throw new Error(`Role must be one of ${ROLES2.join(", ")}.`);
489
+ return role;
490
+ }
491
+ function when2(iso) {
492
+ return iso ? iso.slice(0, 16).replace("T", " ") : "never";
493
+ }
494
+ async function runUsers(client, serverUrl, positionals, options) {
495
+ const [sub, a, b] = positionals;
496
+ const dashboard = `${serverUrl.replace(/\/+$/, "")}/admin/`;
497
+ switch (sub) {
498
+ case "list": {
499
+ const [members, invites] = await Promise.all([client.listMembers(), client.listInvites()]);
500
+ const lines = members.map(
501
+ (m) => `${m.email.padEnd(32)} ${m.role.padEnd(8)} ${m.status.padEnd(9)} last active ${when2(m.lastActiveAt)}`
502
+ );
503
+ for (const i of invites) {
504
+ lines.push(
505
+ `${i.email.padEnd(32)} ${i.role.padEnd(8)} invited expires ${when2(i.expiresAt)}`
506
+ );
507
+ }
508
+ return lines.length > 0 ? lines : ["No members yet."];
509
+ }
510
+ case "invite": {
511
+ if (!a) throw new Error("Usage: flaghoist users invite <email> [--role viewer]");
512
+ const role = requireRole(options.role ?? "viewer");
513
+ const { token, invite, emailed } = await client.createInvite({
514
+ email: a,
515
+ role,
516
+ dashboardUrl: dashboard
517
+ });
518
+ return [
519
+ emailed ? `Invited ${invite.email} as ${invite.role} and emailed them the link (valid until ${when2(invite.expiresAt)}):` : `Invited ${invite.email} as ${invite.role}. Send them this link (valid until ${when2(invite.expiresAt)}):`,
520
+ inviteLink(dashboard, token)
521
+ ];
522
+ }
523
+ case "role": {
524
+ if (!a || !b) throw new Error("Usage: flaghoist users role <email> <role> [--env E]");
525
+ const member = await memberByEmail(client, a);
526
+ if (options.env) {
527
+ const clear = b === "default";
528
+ if (!clear && (!ROLES2.includes(b) || b === "owner")) {
529
+ throw new Error('An environment role must be viewer, editor or admin, or "default".');
530
+ }
531
+ const updated2 = await client.updateMember(member.id, {
532
+ environmentRoles: { ...member.environmentRoles, [options.env]: clear ? null : b }
533
+ });
534
+ const now = updated2.environmentRoles?.[options.env];
535
+ return [
536
+ now ? `${updated2.email} is ${now} in ${options.env}.` : `${updated2.email} has their main role (${updated2.role}) in ${options.env}.`
537
+ ];
538
+ }
539
+ const updated = await client.updateMember(member.id, { role: requireRole(b) });
540
+ return [`${updated.email} is now ${updated.role}.`];
541
+ }
542
+ case "disable":
543
+ case "enable": {
544
+ if (!a) throw new Error(`Usage: flaghoist users ${sub} <email>`);
545
+ const member = await memberByEmail(client, a);
546
+ const status = sub === "disable" ? "disabled" : "active";
547
+ await client.updateMember(member.id, { status });
548
+ return [`${member.email} is ${sub === "disable" ? "disabled and signed out" : "enabled"}.`];
549
+ }
550
+ case "remove": {
551
+ if (!a) throw new Error("Usage: flaghoist users remove <email>");
552
+ const member = await memberByEmail(client, a);
553
+ await client.removeMember(member.id);
554
+ return [`Removed ${member.email}.`];
555
+ }
556
+ case "reset": {
557
+ if (!a) throw new Error("Usage: flaghoist users reset <email>");
558
+ const member = await memberByEmail(client, a);
559
+ const { token, invite, emailed } = await client.createResetLink(member.id, {
560
+ dashboardUrl: dashboard
561
+ });
562
+ return [
563
+ emailed ? `Emailed ${member.email} a link to set a new password (valid until ${when2(invite.expiresAt)}):` : `Send ${member.email} this link to set a new password (valid until ${when2(invite.expiresAt)}):`,
564
+ inviteLink(dashboard, token)
565
+ ];
566
+ }
567
+ case "revoke": {
568
+ if (!a) throw new Error("Usage: flaghoist users revoke <email>");
569
+ const wanted = a.trim().toLowerCase();
570
+ const invite = (await client.listInvites()).find((i) => i.email === wanted);
571
+ if (!invite) throw new Error(`No open invite for ${wanted}.`);
572
+ await client.revokeInvite(invite.id);
573
+ return [`Cancelled the invite for ${wanted}.`];
574
+ }
575
+ default:
576
+ throw new Error(`Unknown users command: ${sub ?? "(none)"}. Try "flaghoist users list".`);
577
+ }
578
+ }
579
+
293
580
  // src/version.ts
294
581
  import { createRequire } from "module";
295
582
  var VERSION = createRequire(import.meta.url)("../package.json").version;
296
583
 
297
584
  // src/index.ts
298
585
  function writeFileSafe(path, content) {
299
- mkdirSync(dirname(path) || ".", { recursive: true });
300
- writeFileSync(path, content);
586
+ mkdirSync2(dirname2(path) || ".", { recursive: true });
587
+ writeFileSync2(path, content);
301
588
  }
302
589
  function loadConfig() {
303
- if (!existsSync("flaghoist.toml")) {
590
+ if (!existsSync2("flaghoist.toml")) {
304
591
  throw new Error("No flaghoist.toml found in this directory. Run `flaghoist init` first.");
305
592
  }
306
- return parseConfig(readFileSync("flaghoist.toml", "utf8"));
593
+ return parseConfig(readFileSync2("flaghoist.toml", "utf8"));
594
+ }
595
+ function serverFrom(values) {
596
+ const url = values.url ?? process.env.FLAGS_URL;
597
+ if (url) return url;
598
+ const saved = savedServers(credentialsPath());
599
+ if (saved.length === 1) return saved[0];
600
+ throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
307
601
  }
308
602
  function clientFrom(values) {
603
+ const url = serverFrom(values);
604
+ const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN ?? savedCredential(credentialsPath(), url)?.token;
605
+ if (!token) {
606
+ throw new Error(
607
+ "Not signed in. Run `flaghoist login`, or pass --token or set FLAGS_ADMIN_TOKEN."
608
+ );
609
+ }
610
+ return createAdminClient({ url, token });
611
+ }
612
+ async function runLogin(args) {
613
+ const { values } = parseArgs({
614
+ args,
615
+ options: {
616
+ url: { type: "string" },
617
+ email: { type: "string" },
618
+ "password-stdin": { type: "boolean" },
619
+ code: { type: "string" }
620
+ }
621
+ });
309
622
  const url = values.url ?? process.env.FLAGS_URL;
310
- const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN;
311
623
  if (!url) throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
312
- if (!token) throw new Error("Missing admin token. Pass --token or set FLAGS_ADMIN_TOKEN.");
313
- return createAdminClient({ url, token });
624
+ let email = values.email;
625
+ if (!email) {
626
+ if (!process.stdin.isTTY) throw new Error("Pass your email with --email.");
627
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
628
+ email = (await rl.question("Email: ")).trim();
629
+ rl.close();
630
+ }
631
+ const password = await readPassword({ fromStdin: values["password-stdin"] === true });
632
+ console.log(`Signing in to ${url} as ${email}...`);
633
+ const result = await loginForToken({
634
+ url,
635
+ email,
636
+ password,
637
+ tokenName: `flaghoist CLI on ${hostname()}`,
638
+ twoFactorCode: async () => {
639
+ if (values.code) return values.code;
640
+ if (!process.stdin.isTTY) {
641
+ throw new Error("This account uses two-factor codes. Pass the current one with --code.");
642
+ }
643
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
644
+ try {
645
+ return (await rl.question("Two-factor code (or a recovery code): ")).trim();
646
+ } finally {
647
+ rl.close();
648
+ }
649
+ }
650
+ });
651
+ const path = credentialsPath();
652
+ saveCredential(path, url, {
653
+ token: result.token,
654
+ tokenId: result.tokenId,
655
+ email: result.email,
656
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
657
+ });
658
+ console.log(
659
+ `Signed in to ${url} as ${result.email} (${result.role}). The access token expires ${result.expiresAt?.slice(0, 10) ?? "never"} and is saved in ${path}.`
660
+ );
661
+ }
662
+ async function runLogout(args) {
663
+ const { values } = parseArgs({ args, options: { url: { type: "string" } } });
664
+ const url = serverFrom(values);
665
+ const path = credentialsPath();
666
+ const saved = savedCredential(path, url);
667
+ if (!saved) return console.log(`Not signed in to ${url}.`);
668
+ const revoked = await createAdminClient({ url, token: saved.token }).logout().then(() => true).catch(() => false);
669
+ removeCredential(path, url);
670
+ console.log(
671
+ revoked ? `Signed out of ${url}. The access token is revoked.` : `Removed the saved token for ${url}. The server could not be reached to revoke it; revoke it from the dashboard's Account page.`
672
+ );
673
+ }
674
+ async function runTokensCommand(args) {
675
+ const { values, positionals } = parseArgs({
676
+ args,
677
+ allowPositionals: true,
678
+ options: {
679
+ url: { type: "string" },
680
+ token: { type: "string" },
681
+ role: { type: "string" },
682
+ "expires-days": { type: "string" }
683
+ }
684
+ });
685
+ const lines = await runTokens(clientFrom(values), positionals, {
686
+ role: values.role,
687
+ expiresDays: values["expires-days"]
688
+ });
689
+ for (const line of lines) console.log(line);
314
690
  }
315
691
  function flagState(flag) {
316
692
  if (!flag.enabled) return "disabled";
@@ -381,7 +757,7 @@ async function runFlag(args) {
381
757
  if (a !== "set" || !b)
382
758
  throw new Error("Usage: flaghoist flag rules set <key> --file rules.json");
383
759
  if (!values.file) throw new Error("Missing --file <rules.json>");
384
- const rules = JSON.parse(readFileSync(values.file, "utf8"));
760
+ const rules = JSON.parse(readFileSync2(values.file, "utf8"));
385
761
  if (!Array.isArray(rules)) throw new Error("Rules file must contain a JSON array");
386
762
  const flag = await setRules(clientFrom(values), b, rules);
387
763
  console.log(`Updated rules for "${b}"`);
@@ -391,6 +767,24 @@ async function runFlag(args) {
391
767
  throw new Error(`Unknown flag command: ${sub ?? "(none)"}. Try "flaghoist flag list".`);
392
768
  }
393
769
  }
770
+ async function runUsersCommand(args) {
771
+ const { values, positionals } = parseArgs({
772
+ args,
773
+ allowPositionals: true,
774
+ options: {
775
+ url: { type: "string" },
776
+ token: { type: "string" },
777
+ role: { type: "string" },
778
+ env: { type: "string" }
779
+ }
780
+ });
781
+ const client = clientFrom(values);
782
+ const url = serverFrom(values);
783
+ const options = { role: values.role, env: values.env };
784
+ for (const line of await runUsers(client, url, positionals, options)) {
785
+ console.log(line);
786
+ }
787
+ }
394
788
  function runInit(args) {
395
789
  const { values } = parseArgs({
396
790
  args,
@@ -401,7 +795,7 @@ function runInit(args) {
401
795
  platform: { type: "string" }
402
796
  }
403
797
  });
404
- if (existsSync("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
798
+ if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
405
799
  if (values.storage && !STORAGE_KINDS.includes(values.storage)) {
406
800
  throw new Error(`Unknown storage "${values.storage}". One of: ${STORAGE_KINDS.join(", ")}.`);
407
801
  }
@@ -414,7 +808,7 @@ function runInit(args) {
414
808
  storage: values.storage ?? DEFAULT_CONFIG.storage
415
809
  };
416
810
  const config = values.platform === "container" ? asContainer(base) : base;
417
- writeFileSync("flaghoist.toml", serializeConfig(config));
811
+ writeFileSync2("flaghoist.toml", serializeConfig(config));
418
812
  console.log("Created flaghoist.toml");
419
813
  console.log("Next: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
420
814
  }
@@ -438,7 +832,7 @@ function projectFiles(config) {
438
832
  }
439
833
  function writeProject(config, dir) {
440
834
  const files = projectFiles(config);
441
- const existing = files.map(([name]) => name).filter((name) => existsSync(join(dir, name)));
835
+ const existing = files.map(([name]) => name).filter((name) => existsSync2(join2(dir, name)));
442
836
  if (existing.length > 0) {
443
837
  throw new Error(
444
838
  `Refusing to overwrite ${existing.join(", ")} in this directory.
@@ -446,12 +840,12 @@ Flaghoist deploys as its own service, so give it a directory of its own:
446
840
  npm create flaghoist@latest team-flags`
447
841
  );
448
842
  }
449
- for (const [name, contents] of files) writeFileSafe(join(dir, name), contents);
843
+ for (const [name, contents] of files) writeFileSafe(join2(dir, name), contents);
450
844
  }
451
845
  function runEject() {
452
846
  const config = loadConfig();
453
847
  const entry = entryFile(config.platform);
454
- if (existsSync(entry)) throw new Error(`${entry} already exists. Already ejected?`);
848
+ if (existsSync2(entry)) throw new Error(`${entry} already exists. Already ejected?`);
455
849
  writeProject(config, ".");
456
850
  if (config.platform === "container") {
457
851
  console.log("Ejected to a code project you own: server.mjs, Dockerfile, package.json");
@@ -468,7 +862,7 @@ function runEject() {
468
862
  }
469
863
  function ensureKvNamespace(config) {
470
864
  if (config.storage !== "cloudflare-kv") return;
471
- const toml = readFileSync("wrangler.toml", "utf8");
865
+ const toml = readFileSync2("wrangler.toml", "utf8");
472
866
  if (!needsKvNamespace(toml)) return;
473
867
  const title = `${config.name}-FLAGS`;
474
868
  console.log(`Creating the ${title} KV namespace...`);
@@ -489,11 +883,11 @@ function ensureKvNamespace(config) {
489
883
  "Created the namespace but could not read its id from wrangler output. Paste the id above into wrangler.toml, then run `flaghoist deploy` again."
490
884
  );
491
885
  }
492
- writeFileSync("wrangler.toml", fillKvNamespaceId(toml, id));
886
+ writeFileSync2("wrangler.toml", fillKvNamespaceId(toml, id));
493
887
  console.log(`Bound FLAGS to namespace ${id} in wrangler.toml`);
494
888
  }
495
889
  function ensureDependencies() {
496
- if (existsSync("node_modules")) return;
890
+ if (existsSync2("node_modules")) return;
497
891
  console.log("Installing dependencies...");
498
892
  const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { stdio: "inherit" });
499
893
  if (result.status !== 0) {
@@ -511,7 +905,7 @@ async function promptDeployTarget() {
511
905
  console.log("Where do you want to deploy?");
512
906
  console.log(" 1) Cloudflare Workers recommended, deploys in one command");
513
907
  console.log(" 2) Another platform Render, a container, any Node host");
514
- const rl = createInterface({ input: process.stdin, output: process.stdout });
908
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
515
909
  try {
516
910
  const answer = (await rl.question("Choose [1]: ")).trim().toLowerCase();
517
911
  return answer === "2" || answer.startsWith("o") || answer.startsWith("r") ? "other" : "cloudflare";
@@ -522,9 +916,9 @@ async function promptDeployTarget() {
522
916
  function deployContainer(config) {
523
917
  const container = asContainer(config);
524
918
  if (config.platform !== container.platform || config.storage !== container.storage) {
525
- writeFileSync("flaghoist.toml", serializeConfig(container));
919
+ writeFileSync2("flaghoist.toml", serializeConfig(container));
526
920
  }
527
- if (!existsSync(entryFile("container"))) writeProject(container, ".");
921
+ if (!existsSync2(entryFile("container"))) writeProject(container, ".");
528
922
  console.log(`
529
923
  Scaffolded a container project: server.mjs, Dockerfile, package.json.
530
924
 
@@ -543,7 +937,7 @@ Or deploy it to a host:
543
937
  Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
544
938
  }
545
939
  async function deployCloudflare(config) {
546
- if (!existsSync("src/index.ts")) writeProject(config, ".");
940
+ if (!existsSync2("src/index.ts")) writeProject(config, ".");
547
941
  ensureDependencies();
548
942
  ensureKvNamespace(config);
549
943
  console.log("Deploying with wrangler...");
@@ -570,20 +964,37 @@ Scaffolding
570
964
  eject Generate a code project you own (a Worker, or a container)
571
965
  deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
572
966
 
573
- Flag management (needs --url/--token or FLAGS_URL/FLAGS_ADMIN_TOKEN)
967
+ Signing in, on a server with user accounts
968
+ login [--url U] [--email E] [--password-stdin] [--code C]
969
+ Sign in and save a personal access token for this server
970
+ logout [--url U] Revoke that token and forget it
971
+ ${TOKENS_USAGE}
972
+
973
+ Flag management (needs --url/--token or FLAGS_URL/FLAGS_ADMIN_TOKEN, or \`flaghoist login\`)
574
974
  flag list
575
975
  flag get <key>
576
976
  flag create <key> [--on] [--rollout N] [--desc "..."]
577
977
  flag toggle <key> [--on|--off]
578
978
  flag rollout <key> <percentage>
579
979
  flag rules set <key> --file rules.json
580
- flag delete <key>`);
980
+ flag delete <key>
981
+
982
+ Members, on a server with accounts on (same --url/--token; needs the admin role)
983
+ ${USERS_USAGE}`);
581
984
  }
582
985
  async function main() {
583
986
  const [command, ...rest] = process.argv.slice(2);
584
987
  switch (command) {
585
988
  case "flag":
586
989
  return runFlag(rest);
990
+ case "users":
991
+ return runUsersCommand(rest);
992
+ case "login":
993
+ return runLogin(rest);
994
+ case "logout":
995
+ return runLogout(rest);
996
+ case "tokens":
997
+ return runTokensCommand(rest);
587
998
  case "init":
588
999
  return runInit(rest);
589
1000
  case "eject":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flaghoist",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold, deploy, and manage your Flaghoist feature-flag service from the terminal.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,12 +24,12 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "smol-toml": "^1.8.0",
27
- "@flaghoist/admin-client": "0.2.0"
27
+ "@flaghoist/admin-client": "0.3.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@flaghoist/adapter-memory": "0.1.2",
31
- "@flaghoist/core": "0.1.2",
32
- "@flaghoist/server": "0.3.1"
30
+ "@flaghoist/adapter-memory": "0.2.0",
31
+ "@flaghoist/core": "0.2.0",
32
+ "@flaghoist/server": "0.4.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"