flaghoist 0.3.0 → 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.
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
@@ -11,6 +11,7 @@ var STORAGE_KINDS = [
11
11
  "cloudflare-kv",
12
12
  "redis",
13
13
  "postgres",
14
+ "sqlite",
14
15
  "memory"
15
16
  ];
16
17
  var PLATFORM_KINDS = ["cloudflare", "container"];
package/dist/index.js CHANGED
@@ -7,97 +7,34 @@ import {
7
7
  containerStorageDefault,
8
8
  parseConfig,
9
9
  serializeConfig
10
- } from "./chunk-HOSNPNBP.js";
10
+ } from "./chunk-HSDDPXCM.js";
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
20
- function createAdminClient(options) {
21
- const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
22
- const api = `${options.url.replace(/\/+$/, "")}/api/v1`;
23
- const headers = {
24
- authorization: `Bearer ${options.token}`,
25
- "content-type": "application/json"
26
- };
27
- const path = (key) => `${api}/flags/${encodeURIComponent(key)}`;
28
- return {
29
- async list() {
30
- const res = await doFetch(`${api}/flags`, { headers });
31
- if (!res.ok) throw new Error(`Failed to list flags (${res.status})`);
32
- return (await res.json()).flags;
33
- },
34
- async get(key) {
35
- const res = await doFetch(path(key), { headers });
36
- if (res.status === 404) return null;
37
- if (!res.ok) throw new Error(`Failed to read flag "${key}" (${res.status})`);
38
- return await res.json();
39
- },
40
- async put(key, input) {
41
- const res = await doFetch(path(key), { method: "PUT", headers, body: JSON.stringify(input) });
42
- if (!res.ok)
43
- throw new Error(`Failed to save flag "${key}" (${res.status}): ${await res.text()}`);
44
- return await res.json();
45
- },
46
- async delete(key) {
47
- const res = await doFetch(path(key), { method: "DELETE", headers });
48
- if (!res.ok && res.status !== 404)
49
- throw new Error(`Failed to delete flag "${key}" (${res.status})`);
50
- }
51
- };
52
- }
53
- function requireFlag(flag, key) {
54
- if (!flag) throw new Error(`Flag "${key}" not found`);
55
- return flag;
56
- }
57
- function createFlag(client, key, opts) {
58
- return client.put(key, {
59
- enabled: opts.enabled ?? false,
60
- rollout: { percentage: opts.percentage ?? 0 },
61
- rules: [],
62
- description: opts.description ?? ""
63
- });
64
- }
65
- async function toggleFlag(client, key, to) {
66
- const flag = requireFlag(await client.get(key), key);
67
- const enabled = to === "flip" ? !flag.enabled : to;
68
- return client.put(key, {
69
- enabled,
70
- rollout: flag.rollout,
71
- rules: flag.rules,
72
- description: flag.description
73
- });
74
- }
75
- async function setRollout(client, key, percentage) {
76
- const flag = requireFlag(await client.get(key), key);
77
- return client.put(key, {
78
- enabled: flag.enabled,
79
- rollout: { percentage },
80
- rules: flag.rules,
81
- description: flag.description
82
- });
83
- }
84
- async function setRules(client, key, rules) {
85
- const flag = requireFlag(await client.get(key), key);
86
- return client.put(key, {
87
- enabled: flag.enabled,
88
- rollout: flag.rollout,
89
- rules,
90
- description: flag.description
91
- });
92
- }
21
+ import {
22
+ createAdminClient,
23
+ createFlag,
24
+ setRollout,
25
+ setRules,
26
+ toggleFlag
27
+ } from "@flaghoist/admin-client";
93
28
 
94
29
  // src/generate-container.ts
95
30
  var CONTAINER_DEPS = {
96
31
  "@flaghoist/adapter-memory": "^0.1.2",
97
32
  "@flaghoist/adapter-postgres": "^0.1.2",
98
33
  "@flaghoist/adapter-redis": "^0.1.2",
34
+ "@flaghoist/adapter-sqlite": "^0.1.0",
99
35
  "@flaghoist/server": "^0.3.0",
100
36
  "@hono/node-server": "^2.1.1",
37
+ "better-sqlite3": "^11.0.0",
101
38
  ioredis: "^5.4.0",
102
39
  pg: "^8.13.0"
103
40
  };
@@ -117,6 +54,7 @@ function generateNodeEntry(config) {
117
54
  `import { memoryAdapter } from '@flaghoist/adapter-memory'`,
118
55
  `import { initPostgres, postgresAdapter } from '@flaghoist/adapter-postgres'`,
119
56
  `import { redisAdapter } from '@flaghoist/adapter-redis'`,
57
+ `import { initSqlite, sqliteAdapter } from '@flaghoist/adapter-sqlite'`,
120
58
  `import { ${serverImports.join(", ")} } from '@flaghoist/server'`,
121
59
  ...config.dashboard ? [`import { dashboardHtml } from '@flaghoist/server/dashboard'`] : [],
122
60
  `import { serve } from '@hono/node-server'`
@@ -150,12 +88,19 @@ async function makeStorage() {
150
88
  return redisAdapter(new Redis(env.REDIS_URL), { hashKey: env.FLAGS_HASH_KEY ?? 'flaghoist:flags' })
151
89
  }
152
90
 
91
+ if (kind === 'sqlite') {
92
+ const { default: Database } = await import('better-sqlite3')
93
+ const db = new Database(env.DATABASE_PATH ?? '/data/flags.db')
94
+ initSqlite(db)
95
+ return sqliteAdapter(db)
96
+ }
97
+
153
98
  if (kind === 'memory') {
154
99
  console.warn('[flaghoist] FLAGS_STORAGE=memory: flags are in-process and are lost on restart.')
155
100
  return memoryAdapter()
156
101
  }
157
102
 
158
- throw new Error(\`Unknown FLAGS_STORAGE "\${kind}". Use postgres, redis, or memory.\`)
103
+ throw new Error(\`Unknown FLAGS_STORAGE "\${kind}". Use postgres, redis, sqlite, or memory.\`)
159
104
  }
160
105
 
161
106
  const app = createFlagServer({
@@ -253,6 +198,10 @@ function storageSnippet(storage) {
253
198
  expr: "memoryAdapter()",
254
199
  pkg: "@flaghoist/adapter-memory"
255
200
  };
201
+ case "sqlite":
202
+ throw new Error(
203
+ 'SQLite storage requires a Node or container deployment. Use `npx flaghoist deploy` and pick "Another platform".'
204
+ );
256
205
  }
257
206
  }
258
207
  function adminExpr2(admin) {
@@ -342,27 +291,402 @@ function generatePackageJson(config) {
342
291
  `;
343
292
  }
344
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
+
345
580
  // src/version.ts
346
581
  import { createRequire } from "module";
347
582
  var VERSION = createRequire(import.meta.url)("../package.json").version;
348
583
 
349
584
  // src/index.ts
350
585
  function writeFileSafe(path, content) {
351
- mkdirSync(dirname(path) || ".", { recursive: true });
352
- writeFileSync(path, content);
586
+ mkdirSync2(dirname2(path) || ".", { recursive: true });
587
+ writeFileSync2(path, content);
353
588
  }
354
589
  function loadConfig() {
355
- if (!existsSync("flaghoist.toml")) {
590
+ if (!existsSync2("flaghoist.toml")) {
356
591
  throw new Error("No flaghoist.toml found in this directory. Run `flaghoist init` first.");
357
592
  }
358
- 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.");
359
601
  }
360
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
+ });
361
622
  const url = values.url ?? process.env.FLAGS_URL;
362
- const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN;
363
623
  if (!url) throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
364
- if (!token) throw new Error("Missing admin token. Pass --token or set FLAGS_ADMIN_TOKEN.");
365
- 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);
366
690
  }
367
691
  function flagState(flag) {
368
692
  if (!flag.enabled) return "disabled";
@@ -433,7 +757,7 @@ async function runFlag(args) {
433
757
  if (a !== "set" || !b)
434
758
  throw new Error("Usage: flaghoist flag rules set <key> --file rules.json");
435
759
  if (!values.file) throw new Error("Missing --file <rules.json>");
436
- const rules = JSON.parse(readFileSync(values.file, "utf8"));
760
+ const rules = JSON.parse(readFileSync2(values.file, "utf8"));
437
761
  if (!Array.isArray(rules)) throw new Error("Rules file must contain a JSON array");
438
762
  const flag = await setRules(clientFrom(values), b, rules);
439
763
  console.log(`Updated rules for "${b}"`);
@@ -443,6 +767,24 @@ async function runFlag(args) {
443
767
  throw new Error(`Unknown flag command: ${sub ?? "(none)"}. Try "flaghoist flag list".`);
444
768
  }
445
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
+ }
446
788
  function runInit(args) {
447
789
  const { values } = parseArgs({
448
790
  args,
@@ -453,7 +795,7 @@ function runInit(args) {
453
795
  platform: { type: "string" }
454
796
  }
455
797
  });
456
- if (existsSync("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
798
+ if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
457
799
  if (values.storage && !STORAGE_KINDS.includes(values.storage)) {
458
800
  throw new Error(`Unknown storage "${values.storage}". One of: ${STORAGE_KINDS.join(", ")}.`);
459
801
  }
@@ -466,7 +808,7 @@ function runInit(args) {
466
808
  storage: values.storage ?? DEFAULT_CONFIG.storage
467
809
  };
468
810
  const config = values.platform === "container" ? asContainer(base) : base;
469
- writeFileSync("flaghoist.toml", serializeConfig(config));
811
+ writeFileSync2("flaghoist.toml", serializeConfig(config));
470
812
  console.log("Created flaghoist.toml");
471
813
  console.log("Next: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
472
814
  }
@@ -490,7 +832,7 @@ function projectFiles(config) {
490
832
  }
491
833
  function writeProject(config, dir) {
492
834
  const files = projectFiles(config);
493
- 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)));
494
836
  if (existing.length > 0) {
495
837
  throw new Error(
496
838
  `Refusing to overwrite ${existing.join(", ")} in this directory.
@@ -498,12 +840,12 @@ Flaghoist deploys as its own service, so give it a directory of its own:
498
840
  npm create flaghoist@latest team-flags`
499
841
  );
500
842
  }
501
- for (const [name, contents] of files) writeFileSafe(join(dir, name), contents);
843
+ for (const [name, contents] of files) writeFileSafe(join2(dir, name), contents);
502
844
  }
503
845
  function runEject() {
504
846
  const config = loadConfig();
505
847
  const entry = entryFile(config.platform);
506
- if (existsSync(entry)) throw new Error(`${entry} already exists. Already ejected?`);
848
+ if (existsSync2(entry)) throw new Error(`${entry} already exists. Already ejected?`);
507
849
  writeProject(config, ".");
508
850
  if (config.platform === "container") {
509
851
  console.log("Ejected to a code project you own: server.mjs, Dockerfile, package.json");
@@ -520,7 +862,7 @@ function runEject() {
520
862
  }
521
863
  function ensureKvNamespace(config) {
522
864
  if (config.storage !== "cloudflare-kv") return;
523
- const toml = readFileSync("wrangler.toml", "utf8");
865
+ const toml = readFileSync2("wrangler.toml", "utf8");
524
866
  if (!needsKvNamespace(toml)) return;
525
867
  const title = `${config.name}-FLAGS`;
526
868
  console.log(`Creating the ${title} KV namespace...`);
@@ -541,11 +883,11 @@ function ensureKvNamespace(config) {
541
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."
542
884
  );
543
885
  }
544
- writeFileSync("wrangler.toml", fillKvNamespaceId(toml, id));
886
+ writeFileSync2("wrangler.toml", fillKvNamespaceId(toml, id));
545
887
  console.log(`Bound FLAGS to namespace ${id} in wrangler.toml`);
546
888
  }
547
889
  function ensureDependencies() {
548
- if (existsSync("node_modules")) return;
890
+ if (existsSync2("node_modules")) return;
549
891
  console.log("Installing dependencies...");
550
892
  const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { stdio: "inherit" });
551
893
  if (result.status !== 0) {
@@ -563,7 +905,7 @@ async function promptDeployTarget() {
563
905
  console.log("Where do you want to deploy?");
564
906
  console.log(" 1) Cloudflare Workers recommended, deploys in one command");
565
907
  console.log(" 2) Another platform Render, a container, any Node host");
566
- const rl = createInterface({ input: process.stdin, output: process.stdout });
908
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
567
909
  try {
568
910
  const answer = (await rl.question("Choose [1]: ")).trim().toLowerCase();
569
911
  return answer === "2" || answer.startsWith("o") || answer.startsWith("r") ? "other" : "cloudflare";
@@ -574,9 +916,9 @@ async function promptDeployTarget() {
574
916
  function deployContainer(config) {
575
917
  const container = asContainer(config);
576
918
  if (config.platform !== container.platform || config.storage !== container.storage) {
577
- writeFileSync("flaghoist.toml", serializeConfig(container));
919
+ writeFileSync2("flaghoist.toml", serializeConfig(container));
578
920
  }
579
- if (!existsSync(entryFile("container"))) writeProject(container, ".");
921
+ if (!existsSync2(entryFile("container"))) writeProject(container, ".");
580
922
  console.log(`
581
923
  Scaffolded a container project: server.mjs, Dockerfile, package.json.
582
924
 
@@ -595,7 +937,7 @@ Or deploy it to a host:
595
937
  Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
596
938
  }
597
939
  async function deployCloudflare(config) {
598
- if (!existsSync("src/index.ts")) writeProject(config, ".");
940
+ if (!existsSync2("src/index.ts")) writeProject(config, ".");
599
941
  ensureDependencies();
600
942
  ensureKvNamespace(config);
601
943
  console.log("Deploying with wrangler...");
@@ -622,20 +964,37 @@ Scaffolding
622
964
  eject Generate a code project you own (a Worker, or a container)
623
965
  deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
624
966
 
625
- 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\`)
626
974
  flag list
627
975
  flag get <key>
628
976
  flag create <key> [--on] [--rollout N] [--desc "..."]
629
977
  flag toggle <key> [--on|--off]
630
978
  flag rollout <key> <percentage>
631
979
  flag rules set <key> --file rules.json
632
- 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}`);
633
984
  }
634
985
  async function main() {
635
986
  const [command, ...rest] = process.argv.slice(2);
636
987
  switch (command) {
637
988
  case "flag":
638
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);
639
998
  case "init":
640
999
  return runInit(rest);
641
1000
  case "eject":
package/dist/lib.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- type StorageKind = 'cloudflare-kv' | 'redis' | 'postgres' | 'memory';
1
+ type StorageKind = 'cloudflare-kv' | 'redis' | 'postgres' | 'sqlite' | 'memory';
2
2
  type AdminAuthKind = 'bearer-token' | 'oidc';
3
3
  /**
4
4
  * The shape of project `flaghoist deploy`/`eject` scaffolds. `cloudflare` is a Worker plus a
@@ -26,7 +26,7 @@ declare const STORAGE_KINDS: readonly StorageKind[];
26
26
  /** Every deploy shape selectable by name in `flaghoist.toml`. */
27
27
  declare const PLATFORM_KINDS: readonly PlatformKind[];
28
28
  /** The stores a container can reach. Cloudflare KV is a Worker binding, so it is not one of them. */
29
- type ContainerStorage = 'postgres' | 'redis' | 'memory';
29
+ type ContainerStorage = 'postgres' | 'redis' | 'sqlite' | 'memory';
30
30
  /**
31
31
  * The storage a container project uses. Cloudflare KV cannot be reached off Workers, so a config
32
32
  * that still names it (the scaffolding default) becomes postgres, the store every container deploy
package/dist/lib.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  containerStorageDefault,
7
7
  parseConfig,
8
8
  serializeConfig
9
- } from "./chunk-HOSNPNBP.js";
9
+ } from "./chunk-HSDDPXCM.js";
10
10
  export {
11
11
  DEFAULT_CONFIG,
12
12
  PLATFORM_KINDS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flaghoist",
3
- "version": "0.3.0",
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",
@@ -23,12 +23,13 @@
23
23
  "node": ">=20"
24
24
  },
25
25
  "dependencies": {
26
- "smol-toml": "^1.8.0"
26
+ "smol-toml": "^1.8.0",
27
+ "@flaghoist/admin-client": "0.3.0"
27
28
  },
28
29
  "devDependencies": {
29
- "@flaghoist/adapter-memory": "0.1.2",
30
- "@flaghoist/core": "0.1.2",
31
- "@flaghoist/server": "0.3.1"
30
+ "@flaghoist/adapter-memory": "0.2.0",
31
+ "@flaghoist/core": "0.2.0",
32
+ "@flaghoist/server": "0.4.0"
32
33
  },
33
34
  "publishConfig": {
34
35
  "access": "public"