flaghoist 0.3.1 → 0.5.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/dist/index.js CHANGED
@@ -1,19 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- DEFAULT_CONFIG,
4
3
  PLATFORM_KINDS,
5
4
  STORAGE_KINDS,
6
5
  asContainer,
6
+ collectSetup,
7
7
  containerStorageDefault,
8
+ defaultsAsker,
8
9
  parseConfig,
9
- serializeConfig
10
- } from "./chunk-HSDDPXCM.js";
10
+ readEnvFile,
11
+ serializeConfig,
12
+ setupSummary,
13
+ terminalAsker,
14
+ writeSetup
15
+ } from "./chunk-TQ5S6NOA.js";
11
16
 
12
17
  // src/index.ts
13
18
  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";
19
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
20
+ import { hostname } from "os";
21
+ import { dirname as dirname2, join as join2 } from "path";
22
+ import { createInterface as createInterface2 } from "readline/promises";
17
23
  import { parseArgs } from "util";
18
24
 
19
25
  // src/admin.ts
@@ -25,20 +31,167 @@ import {
25
31
  toggleFlag
26
32
  } from "@flaghoist/admin-client";
27
33
 
34
+ // src/generate.ts
35
+ function storageSnippet(storage) {
36
+ switch (storage) {
37
+ case "cloudflare-kv":
38
+ return {
39
+ imports: [`import { cloudflareKV } from '@flaghoist/adapter-cloudflare-kv'`],
40
+ expr: "cloudflareKV(env.FLAGS)",
41
+ pkg: "@flaghoist/adapter-cloudflare-kv",
42
+ version: "^0.3.0"
43
+ };
44
+ case "redis":
45
+ return {
46
+ imports: [
47
+ `import { redisAdapter } from '@flaghoist/adapter-redis'`,
48
+ `import { Redis } from '@upstash/redis/cloudflare'`
49
+ ],
50
+ expr: "redisAdapter(Redis.fromEnv(env))",
51
+ pkg: "@flaghoist/adapter-redis",
52
+ version: "^0.2.0",
53
+ extraDeps: { "@upstash/redis": "^1.34.0" }
54
+ };
55
+ case "postgres":
56
+ return {
57
+ imports: [
58
+ `import { postgresAdapter } from '@flaghoist/adapter-postgres'`,
59
+ `import { Pool } from 'pg'`
60
+ ],
61
+ expr: "postgresAdapter(new Pool({ connectionString: env.DATABASE_URL }))",
62
+ pkg: "@flaghoist/adapter-postgres",
63
+ version: "^0.2.0",
64
+ extraDeps: { pg: "^8.13.0" }
65
+ };
66
+ case "memory":
67
+ return {
68
+ imports: [`import { memoryAdapter } from '@flaghoist/adapter-memory'`],
69
+ expr: "memoryAdapter()",
70
+ pkg: "@flaghoist/adapter-memory",
71
+ version: "^0.2.0"
72
+ };
73
+ case "sqlite":
74
+ throw new Error(
75
+ 'SQLite storage requires a Node or container deployment. Use `npx flaghoist deploy` and pick "Another platform".'
76
+ );
77
+ }
78
+ }
79
+ function usersBlock(config) {
80
+ if (!config.accounts?.enabled) return "";
81
+ const sso = config.accounts.sso;
82
+ const ssoLines = sso ? [
83
+ " sso: {",
84
+ ` issuer: ${JSON.stringify(sso.issuer)},`,
85
+ ` clientId: ${JSON.stringify(sso.clientId)},`,
86
+ " clientSecret: env.SSO_CLIENT_SECRET,",
87
+ ...sso.adminGroup ? [` roleMapping: { ${JSON.stringify(sso.adminGroup)}: 'admin' },`] : [],
88
+ " // Everyone else who signs in through your provider starts as a viewer.",
89
+ " defaultRole: 'viewer',",
90
+ " },"
91
+ ] : [];
92
+ return ["", " users: {", " pepper: env.AUTH_PEPPER,", ...ssoLines, " },"].join("\n");
93
+ }
94
+ function adminExpr(admin) {
95
+ return admin === "oidc" ? "oidc({ issuer: env.OIDC_ISSUER, audience: env.OIDC_AUDIENCE, groupsClaim: 'cognito:groups', allowedGroups: (env.ADMIN_GROUPS ?? '').split(',') })" : "bearerToken(env.ADMIN_TOKEN)";
96
+ }
97
+ function generateWorkerEntry(config) {
98
+ const storage = storageSnippet(config.storage);
99
+ const serverImports = [
100
+ "apiKey",
101
+ "createFlagServer",
102
+ config.auth.admin === "oidc" ? "oidc" : "bearerToken"
103
+ ].sort();
104
+ const imports = [
105
+ ...storage.imports,
106
+ `import { ${serverImports.join(", ")} } from '@flaghoist/server'`,
107
+ // A subpath import, so a Worker built with `dashboard = false` never pulls the HTML in.
108
+ ...config.dashboard ? [`import { dashboardHtml } from '@flaghoist/server/dashboard'`] : []
109
+ ].join("\n");
110
+ const origins = config.allowedOrigins && config.allowedOrigins.length > 0 ? `
111
+ allowedOrigins: ${JSON.stringify(config.allowedOrigins)},` : "";
112
+ const dashboard = config.dashboard ? "\n dashboard: dashboardHtml," : "";
113
+ return `${imports}
114
+
115
+ export default createFlagServer((env) => ({
116
+ storage: ${storage.expr},
117
+ auth: {
118
+ admin: ${adminExpr(config.auth.admin)},
119
+ read: apiKey(env.READ_API_KEY),
120
+ },${usersBlock(config)}${origins}${dashboard}
121
+ }))
122
+ `;
123
+ }
124
+ var KV_NAMESPACE_PLACEHOLDER = "<your-kv-namespace-id>";
125
+ function generateWranglerToml(config) {
126
+ const lines = [
127
+ `name = ${JSON.stringify(config.name)}`,
128
+ `main = "src/index.ts"`,
129
+ `compatibility_date = "2025-01-01"`
130
+ ];
131
+ if (config.storage === "cloudflare-kv") {
132
+ lines.push(
133
+ "",
134
+ `# Created for you by \`flaghoist deploy\`, or by: npx wrangler kv namespace create ${config.name}-FLAGS`,
135
+ "kv_namespaces = [",
136
+ ` { binding = "FLAGS", id = ${JSON.stringify(KV_NAMESPACE_PLACEHOLDER)} }`,
137
+ "]"
138
+ );
139
+ }
140
+ return `${lines.join("\n")}
141
+ `;
142
+ }
143
+ function needsKvNamespace(toml) {
144
+ return toml.includes(KV_NAMESPACE_PLACEHOLDER);
145
+ }
146
+ function fillKvNamespaceId(toml, id) {
147
+ return toml.replace(/^# Created for you by `flaghoist deploy`.*\n/m, "").replaceAll(KV_NAMESPACE_PLACEHOLDER, id);
148
+ }
149
+ function parseKvNamespaceId(output) {
150
+ const patterns = [
151
+ /\bid\s*=\s*"([0-9a-f]{32})"/i,
152
+ /"id"\s*:\s*"([0-9a-f]{32})"/i,
153
+ /\b([0-9a-f]{32})\b/i
154
+ ];
155
+ for (const re of patterns) {
156
+ const match = re.exec(output);
157
+ if (match) return match[1];
158
+ }
159
+ return void 0;
160
+ }
161
+ function generatePackageJson(config) {
162
+ const storage = storageSnippet(config.storage);
163
+ const dependencies = {
164
+ "@flaghoist/server": "^0.4.0",
165
+ [storage.pkg]: storage.version,
166
+ ...storage.extraDeps
167
+ };
168
+ const pkg = {
169
+ name: config.name,
170
+ version: "0.0.0",
171
+ private: true,
172
+ type: "module",
173
+ scripts: { dev: "wrangler dev", deploy: "wrangler deploy" },
174
+ dependencies,
175
+ devDependencies: { wrangler: "^4.0.0" }
176
+ };
177
+ return `${JSON.stringify(pkg, null, 2)}
178
+ `;
179
+ }
180
+
28
181
  // src/generate-container.ts
29
182
  var CONTAINER_DEPS = {
30
- "@flaghoist/adapter-memory": "^0.1.2",
31
- "@flaghoist/adapter-postgres": "^0.1.2",
32
- "@flaghoist/adapter-redis": "^0.1.2",
33
- "@flaghoist/adapter-sqlite": "^0.1.0",
34
- "@flaghoist/server": "^0.3.0",
183
+ "@flaghoist/adapter-memory": "^0.2.0",
184
+ "@flaghoist/adapter-postgres": "^0.2.0",
185
+ "@flaghoist/adapter-redis": "^0.2.0",
186
+ "@flaghoist/adapter-sqlite": "^0.3.0",
187
+ "@flaghoist/server": "^0.4.0",
35
188
  "@hono/node-server": "^2.1.1",
36
189
  "better-sqlite3": "^11.0.0",
37
190
  ioredis: "^5.4.0",
38
191
  pg: "^8.13.0"
39
192
  };
40
193
  var CONTAINER_PORT = 8080;
41
- function adminExpr(admin) {
194
+ function adminExpr2(admin) {
42
195
  return admin === "oidc" ? "oidc({ issuer: env.OIDC_ISSUER, audience: env.OIDC_AUDIENCE, groupsClaim: 'cognito:groups', allowedGroups: (env.ADMIN_GROUPS ?? '').split(',') })" : "bearerToken(env.ADMIN_TOKEN)";
43
196
  }
44
197
  function generateNodeEntry(config) {
@@ -105,9 +258,9 @@ async function makeStorage() {
105
258
  const app = createFlagServer({
106
259
  storage: await makeStorage(),${dashboard}
107
260
  auth: {
108
- admin: ${adminExpr(config.auth.admin)},
261
+ admin: ${adminExpr2(config.auth.admin)},
109
262
  read: apiKey(env.READ_API_KEY),
110
- },
263
+ },${usersBlock(config)}
111
264
  rateLimit: memoryRateLimit(),
112
265
  // Comma-separated list of browser origins allowed to read flags cross-origin.
113
266
  allowedOrigins: env.FLAGS_CORS ? env.FLAGS_CORS.split(',').map((o) => o.trim()) : ${origins},
@@ -150,144 +303,359 @@ function generateContainerPackageJson(config) {
150
303
  version: "0.0.0",
151
304
  private: true,
152
305
  type: "module",
153
- scripts: { start: "node server.mjs" },
306
+ scripts: {
307
+ start: "node server.mjs",
308
+ // Loads AUTH_PEPPER and the other secrets from .env for a local run. Needs Node 20.6 or later.
309
+ dev: "node --env-file=.env server.mjs"
310
+ },
154
311
  dependencies: CONTAINER_DEPS
155
312
  };
156
313
  return `${JSON.stringify(pkg, null, 2)}
157
314
  `;
158
315
  }
159
316
  function generateDockerignore() {
160
- return ["node_modules", "npm-debug.log", "Dockerfile", ".dockerignore", "README.md", ""].join(
161
- "\n"
162
- );
317
+ return [
318
+ "node_modules",
319
+ "npm-debug.log",
320
+ "Dockerfile",
321
+ ".dockerignore",
322
+ "README.md",
323
+ ".env",
324
+ ""
325
+ ].join("\n");
163
326
  }
164
327
 
165
- // src/generate.ts
166
- function storageSnippet(storage) {
167
- switch (storage) {
168
- case "cloudflare-kv":
169
- return {
170
- imports: [`import { cloudflareKV } from '@flaghoist/adapter-cloudflare-kv'`],
171
- expr: "cloudflareKV(env.FLAGS)",
172
- pkg: "@flaghoist/adapter-cloudflare-kv"
173
- };
174
- case "redis":
175
- return {
176
- imports: [
177
- `import { redisAdapter } from '@flaghoist/adapter-redis'`,
178
- `import { Redis } from '@upstash/redis/cloudflare'`
179
- ],
180
- expr: "redisAdapter(Redis.fromEnv(env))",
181
- pkg: "@flaghoist/adapter-redis",
182
- extraDeps: { "@upstash/redis": "^1.34.0" }
183
- };
184
- case "postgres":
185
- return {
186
- imports: [
187
- `import { postgresAdapter } from '@flaghoist/adapter-postgres'`,
188
- `import { Pool } from 'pg'`
189
- ],
190
- expr: "postgresAdapter(new Pool({ connectionString: env.DATABASE_URL }))",
191
- pkg: "@flaghoist/adapter-postgres",
192
- extraDeps: { pg: "^8.13.0" }
193
- };
194
- case "memory":
195
- return {
196
- imports: [`import { memoryAdapter } from '@flaghoist/adapter-memory'`],
197
- expr: "memoryAdapter()",
198
- pkg: "@flaghoist/adapter-memory"
199
- };
200
- case "sqlite":
201
- throw new Error(
202
- 'SQLite storage requires a Node or container deployment. Use `npx flaghoist deploy` and pick "Another platform".'
203
- );
328
+ // src/credentials.ts
329
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
330
+ import { homedir } from "os";
331
+ import { dirname, join } from "path";
332
+ function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
333
+ const dir = env.FLAGHOIST_CONFIG_DIR ?? (platform === "win32" && env.APPDATA ? join(env.APPDATA, "flaghoist") : join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "flaghoist"));
334
+ return join(dir, "credentials.json");
335
+ }
336
+ function serverKey(url) {
337
+ return url.trim().replace(/\/+$/, "");
338
+ }
339
+ function read(path) {
340
+ if (!existsSync(path)) return { servers: {} };
341
+ try {
342
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
343
+ return { servers: parsed.servers && typeof parsed.servers === "object" ? parsed.servers : {} };
344
+ } catch {
345
+ throw new Error(`Could not read ${path}. Fix or delete it, then sign in again.`);
204
346
  }
205
347
  }
206
- function adminExpr2(admin) {
207
- return admin === "oidc" ? "oidc({ issuer: env.OIDC_ISSUER, audience: env.OIDC_AUDIENCE, groupsClaim: 'cognito:groups', allowedGroups: (env.ADMIN_GROUPS ?? '').split(',') })" : "bearerToken(env.ADMIN_TOKEN)";
348
+ function write(path, file) {
349
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
350
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}
351
+ `, { mode: 384 });
352
+ chmodSync(path, 384);
353
+ }
354
+ function savedCredential(path, url) {
355
+ return read(path).servers[serverKey(url)] ?? null;
356
+ }
357
+ function savedServers(path) {
358
+ return Object.keys(read(path).servers);
359
+ }
360
+ function saveCredential(path, url, credential) {
361
+ const file = read(path);
362
+ file.servers[serverKey(url)] = credential;
363
+ write(path, file);
364
+ }
365
+ function removeCredential(path, url) {
366
+ const file = read(path);
367
+ const key = serverKey(url);
368
+ if (!file.servers[key]) return false;
369
+ delete file.servers[key];
370
+ write(path, file);
371
+ return true;
208
372
  }
209
- function generateWorkerEntry(config) {
210
- const storage = storageSnippet(config.storage);
211
- const serverImports = [
212
- "apiKey",
213
- "createFlagServer",
214
- config.auth.admin === "oidc" ? "oidc" : "bearerToken"
215
- ].sort();
216
- const imports = [
217
- ...storage.imports,
218
- `import { ${serverImports.join(", ")} } from '@flaghoist/server'`,
219
- // A subpath import, so a Worker built with `dashboard = false` never pulls the HTML in.
220
- ...config.dashboard ? [`import { dashboardHtml } from '@flaghoist/server/dashboard'`] : []
221
- ].join("\n");
222
- const origins = config.allowedOrigins && config.allowedOrigins.length > 0 ? `
223
- allowedOrigins: ${JSON.stringify(config.allowedOrigins)},` : "";
224
- const dashboard = config.dashboard ? "\n dashboard: dashboardHtml," : "";
225
- return `${imports}
226
373
 
227
- export default createFlagServer((env) => ({
228
- storage: ${storage.expr},
229
- auth: {
230
- admin: ${adminExpr2(config.auth.admin)},
231
- read: apiKey(env.READ_API_KEY),
232
- },${origins}${dashboard}
233
- }))
234
- `;
374
+ // src/prompt.ts
375
+ import { createInterface } from "readline/promises";
376
+ async function readPassword(options) {
377
+ const input = options.input ?? process.stdin;
378
+ const output = options.output ?? process.stdout;
379
+ if (options.fromStdin) {
380
+ const chunks = [];
381
+ for await (const chunk of input) chunks.push(Buffer.from(chunk));
382
+ const password = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
383
+ if (!password) throw new Error("--password-stdin was set but nothing was piped in.");
384
+ return password;
385
+ }
386
+ if (!input.isTTY) {
387
+ throw new Error(
388
+ "No terminal to ask for a password in. Run this in a terminal, or pipe the password in with --password-stdin."
389
+ );
390
+ }
391
+ const rl = createInterface({ input, output, terminal: true });
392
+ output.write("Password: ");
393
+ rl._writeToOutput = () => {
394
+ };
395
+ try {
396
+ return await rl.question("");
397
+ } finally {
398
+ rl.close();
399
+ output.write("\n");
400
+ }
235
401
  }
236
- var KV_NAMESPACE_PLACEHOLDER = "<your-kv-namespace-id>";
237
- function generateWranglerToml(config) {
238
- const lines = [
239
- `name = ${JSON.stringify(config.name)}`,
240
- `main = "src/index.ts"`,
241
- `compatibility_date = "2025-01-01"`
242
- ];
243
- if (config.storage === "cloudflare-kv") {
244
- lines.push(
245
- "",
246
- `# Created for you by \`flaghoist deploy\`, or by: npx wrangler kv namespace create ${config.name}-FLAGS`,
247
- "kv_namespaces = [",
248
- ` { binding = "FLAGS", id = ${JSON.stringify(KV_NAMESPACE_PLACEHOLDER)} }`,
249
- "]"
402
+
403
+ // src/tokens.ts
404
+ import {
405
+ createAdminClient as createAdminClient2,
406
+ createAuthClient,
407
+ isTwoFactorChallenge
408
+ } from "@flaghoist/admin-client";
409
+ var ROLES = ["viewer", "editor", "admin", "owner"];
410
+ var TOKENS_USAGE = ` tokens list
411
+ tokens create <name> [--role <role>] [--expires-days N|never]
412
+ tokens revoke <id or name>`;
413
+ function when(iso) {
414
+ return iso ? iso.slice(0, 10) : "never";
415
+ }
416
+ function parseExpiry(value) {
417
+ if (value === void 0) return void 0;
418
+ if (value === "never") return null;
419
+ const days = Number(value);
420
+ if (!Number.isInteger(days) || days < 1 || days > 3650) {
421
+ throw new Error('--expires-days must be a whole number from 1 to 3650, or "never".');
422
+ }
423
+ return days;
424
+ }
425
+ function findToken(tokens, ref) {
426
+ const byId = tokens.find((t) => t.id === ref);
427
+ if (byId) return byId;
428
+ const byName = tokens.filter((t) => t.name === ref);
429
+ if (byName.length === 1) return byName[0];
430
+ if (byName.length > 1) throw new Error(`More than one token is named "${ref}". Use its id.`);
431
+ throw new Error(`No token with the id or name "${ref}".`);
432
+ }
433
+ async function runTokens(client, positionals, options) {
434
+ const [sub, a] = positionals;
435
+ switch (sub) {
436
+ case "list": {
437
+ const tokens = await client.listTokens();
438
+ if (tokens.length === 0) return ["No access tokens."];
439
+ return tokens.map(
440
+ (t) => `${t.id} ${t.name.padEnd(24)} ${t.role.padEnd(7)} ${t.prefix}... expires ${when(
441
+ t.expiresAt
442
+ )} last used ${when(t.lastUsedAt)}`
443
+ );
444
+ }
445
+ case "create": {
446
+ if (!a) throw new Error("Usage: flaghoist tokens create <name> [--role r] [--expires-days N]");
447
+ if (options.role && !ROLES.includes(options.role)) {
448
+ throw new Error(`Role must be one of ${ROLES.join(", ")}.`);
449
+ }
450
+ const { token, info } = await client.createToken({
451
+ name: a,
452
+ ...options.role ? { role: options.role } : {},
453
+ ...options.expiresDays !== void 0 ? { expiresInDays: parseExpiry(options.expiresDays) } : {}
454
+ });
455
+ return [
456
+ `Created "${info.name}" (${info.role}, expires ${when(info.expiresAt)}). Copy it now; it is not shown again:`,
457
+ token
458
+ ];
459
+ }
460
+ case "revoke": {
461
+ if (!a) throw new Error("Usage: flaghoist tokens revoke <id or name>");
462
+ const token = findToken(await client.listTokens(), a);
463
+ await client.revokeToken(token.id);
464
+ return [`Revoked "${token.name}".`];
465
+ }
466
+ default:
467
+ throw new Error(`Unknown tokens command: ${sub ?? "(none)"}. Try "flaghoist tokens list".`);
468
+ }
469
+ }
470
+ async function loginForToken(input) {
471
+ const auth = createAuthClient({ url: input.url, fetch: input.fetch });
472
+ const config = await auth.config();
473
+ if (!config.accounts) {
474
+ throw new Error(
475
+ "This server has no user accounts. Use its admin token with --token or FLAGS_ADMIN_TOKEN."
250
476
  );
251
477
  }
252
- return `${lines.join("\n")}
253
- `;
478
+ const first = await auth.signIn(input.email, input.password);
479
+ let signedIn;
480
+ if (isTwoFactorChallenge(first)) {
481
+ if (!input.twoFactorCode) {
482
+ throw new Error("This account uses two-factor codes. Pass the current one with --code.");
483
+ }
484
+ signedIn = await auth.completeTwoFactor(first.challenge, await input.twoFactorCode());
485
+ } else {
486
+ signedIn = first;
487
+ }
488
+ const session = createAdminClient2({ url: input.url, token: signedIn.token, fetch: input.fetch });
489
+ try {
490
+ const { token, info } = await session.createToken({ name: input.tokenName });
491
+ return {
492
+ token,
493
+ tokenId: info.id,
494
+ email: signedIn.user.email,
495
+ role: info.role,
496
+ expiresAt: info.expiresAt
497
+ };
498
+ } finally {
499
+ await session.logout().catch(() => {
500
+ });
501
+ }
254
502
  }
255
- function needsKvNamespace(toml) {
256
- return toml.includes(KV_NAMESPACE_PLACEHOLDER);
503
+
504
+ // src/users.ts
505
+ import { inviteLink } from "@flaghoist/admin-client";
506
+ var ROLES2 = ["viewer", "editor", "admin", "owner"];
507
+ var USERS_USAGE = ` users list
508
+ users invite <email> [--role viewer|editor|admin|owner]
509
+ users role <email> <role> [--env E] With --env, the role in one environment; "default" clears it
510
+ users disable <email>
511
+ users enable <email>
512
+ users remove <email>
513
+ users reset <email> Print a link that sets a new password (valid 24 hours)
514
+ users revoke <email> Cancel an open invite`;
515
+ async function memberByEmail(client, email) {
516
+ const wanted = email.trim().toLowerCase();
517
+ const member = (await client.listMembers()).find((m) => m.email === wanted);
518
+ if (!member) throw new Error(`No member with the email ${wanted}.`);
519
+ return member;
257
520
  }
258
- function fillKvNamespaceId(toml, id) {
259
- return toml.replace(/^# Created for you by `flaghoist deploy`.*\n/m, "").replaceAll(KV_NAMESPACE_PLACEHOLDER, id);
521
+ function requireRole(role) {
522
+ if (!role || !ROLES2.includes(role)) throw new Error(`Role must be one of ${ROLES2.join(", ")}.`);
523
+ return role;
260
524
  }
261
- function parseKvNamespaceId(output) {
262
- const patterns = [
263
- /\bid\s*=\s*"([0-9a-f]{32})"/i,
264
- /"id"\s*:\s*"([0-9a-f]{32})"/i,
265
- /\b([0-9a-f]{32})\b/i
266
- ];
267
- for (const re of patterns) {
268
- const match = re.exec(output);
269
- if (match) return match[1];
525
+ function when2(iso) {
526
+ return iso ? iso.slice(0, 16).replace("T", " ") : "never";
527
+ }
528
+ async function runUsers(client, serverUrl, positionals, options) {
529
+ const [sub, a, b] = positionals;
530
+ const dashboard = `${serverUrl.replace(/\/+$/, "")}/admin/`;
531
+ switch (sub) {
532
+ case "list": {
533
+ const [members, invites] = await Promise.all([client.listMembers(), client.listInvites()]);
534
+ const lines = members.map(
535
+ (m) => `${m.email.padEnd(32)} ${m.role.padEnd(8)} ${m.status.padEnd(9)} last active ${when2(m.lastActiveAt)}`
536
+ );
537
+ for (const i of invites) {
538
+ lines.push(
539
+ `${i.email.padEnd(32)} ${i.role.padEnd(8)} invited expires ${when2(i.expiresAt)}`
540
+ );
541
+ }
542
+ return lines.length > 0 ? lines : ["No members yet."];
543
+ }
544
+ case "invite": {
545
+ if (!a) throw new Error("Usage: flaghoist users invite <email> [--role viewer]");
546
+ const role = requireRole(options.role ?? "viewer");
547
+ const { token, invite, emailed } = await client.createInvite({
548
+ email: a,
549
+ role,
550
+ dashboardUrl: dashboard
551
+ });
552
+ return [
553
+ 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)}):`,
554
+ inviteLink(dashboard, token)
555
+ ];
556
+ }
557
+ case "role": {
558
+ if (!a || !b) throw new Error("Usage: flaghoist users role <email> <role> [--env E]");
559
+ const member = await memberByEmail(client, a);
560
+ if (options.env) {
561
+ const clear = b === "default";
562
+ if (!clear && (!ROLES2.includes(b) || b === "owner")) {
563
+ throw new Error('An environment role must be viewer, editor or admin, or "default".');
564
+ }
565
+ const updated2 = await client.updateMember(member.id, {
566
+ environmentRoles: { ...member.environmentRoles, [options.env]: clear ? null : b }
567
+ });
568
+ const now = updated2.environmentRoles?.[options.env];
569
+ return [
570
+ now ? `${updated2.email} is ${now} in ${options.env}.` : `${updated2.email} has their main role (${updated2.role}) in ${options.env}.`
571
+ ];
572
+ }
573
+ const updated = await client.updateMember(member.id, { role: requireRole(b) });
574
+ return [`${updated.email} is now ${updated.role}.`];
575
+ }
576
+ case "disable":
577
+ case "enable": {
578
+ if (!a) throw new Error(`Usage: flaghoist users ${sub} <email>`);
579
+ const member = await memberByEmail(client, a);
580
+ const status = sub === "disable" ? "disabled" : "active";
581
+ await client.updateMember(member.id, { status });
582
+ return [`${member.email} is ${sub === "disable" ? "disabled and signed out" : "enabled"}.`];
583
+ }
584
+ case "remove": {
585
+ if (!a) throw new Error("Usage: flaghoist users remove <email>");
586
+ const member = await memberByEmail(client, a);
587
+ await client.removeMember(member.id);
588
+ return [`Removed ${member.email}.`];
589
+ }
590
+ case "reset": {
591
+ if (!a) throw new Error("Usage: flaghoist users reset <email>");
592
+ const member = await memberByEmail(client, a);
593
+ const { token, invite, emailed } = await client.createResetLink(member.id, {
594
+ dashboardUrl: dashboard
595
+ });
596
+ return [
597
+ 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)}):`,
598
+ inviteLink(dashboard, token)
599
+ ];
600
+ }
601
+ case "revoke": {
602
+ if (!a) throw new Error("Usage: flaghoist users revoke <email>");
603
+ const wanted = a.trim().toLowerCase();
604
+ const invite = (await client.listInvites()).find((i) => i.email === wanted);
605
+ if (!invite) throw new Error(`No open invite for ${wanted}.`);
606
+ await client.revokeInvite(invite.id);
607
+ return [`Cancelled the invite for ${wanted}.`];
608
+ }
609
+ default:
610
+ throw new Error(`Unknown users command: ${sub ?? "(none)"}. Try "flaghoist users list".`);
270
611
  }
271
- return void 0;
272
612
  }
273
- function generatePackageJson(config) {
274
- const storage = storageSnippet(config.storage);
275
- const dependencies = {
276
- "@flaghoist/server": "^0.1.0",
277
- [storage.pkg]: "^0.1.0",
278
- ...storage.extraDeps
279
- };
280
- const pkg = {
281
- name: config.name,
282
- version: "0.0.0",
283
- private: true,
284
- type: "module",
285
- scripts: { dev: "wrangler dev", deploy: "wrangler deploy" },
286
- dependencies,
287
- devDependencies: { wrangler: "^4.0.0" }
288
- };
289
- return `${JSON.stringify(pkg, null, 2)}
290
- `;
613
+
614
+ // src/worker-secrets.ts
615
+ function requiredSecrets(config) {
616
+ if (!config.accounts?.enabled) return [];
617
+ return ["AUTH_PEPPER", ...config.accounts.sso ? ["SSO_CLIENT_SECRET"] : []];
618
+ }
619
+ function parseSecretNames(output) {
620
+ try {
621
+ const start = output.indexOf("[");
622
+ const parsed = JSON.parse(output.slice(start));
623
+ return new Set(parsed.map((s) => s.name).filter((n) => typeof n === "string"));
624
+ } catch {
625
+ return null;
626
+ }
627
+ }
628
+ function syncWorkerSecrets(config, env, run, log) {
629
+ const needed = requiredSecrets(config);
630
+ if (needed.length === 0) return { set: [], missing: [] };
631
+ const listed = run(["secret", "list"]);
632
+ const existing = listed.status === 0 ? parseSecretNames(listed.stdout) : null;
633
+ if (!existing) {
634
+ log("Could not list the Worker secrets, so none were changed. Set them yourself:");
635
+ for (const name of needed) log(` npx wrangler secret put ${name}`);
636
+ return { set: [], missing: needed };
637
+ }
638
+ const set = [];
639
+ const missing = [];
640
+ for (const name of needed) {
641
+ if (existing.has(name)) continue;
642
+ const value = env[name];
643
+ if (!value) {
644
+ missing.push(name);
645
+ continue;
646
+ }
647
+ const result = run(["secret", "put", name], value);
648
+ if (result.status === 0) set.push(name);
649
+ else missing.push(name);
650
+ }
651
+ if (set.length > 0) log(`Set ${set.join(" and ")} on the Worker from .env.`);
652
+ for (const name of missing) {
653
+ log(
654
+ name === "AUTH_PEPPER" ? "AUTH_PEPPER is not set, so accounts cannot sign in yet. Set it with:" : `${name} is not set. Set it with:`
655
+ );
656
+ log(` npx wrangler secret put ${name}`);
657
+ }
658
+ return { set, missing };
291
659
  }
292
660
 
293
661
  // src/version.ts
@@ -296,21 +664,110 @@ var VERSION = createRequire(import.meta.url)("../package.json").version;
296
664
 
297
665
  // src/index.ts
298
666
  function writeFileSafe(path, content) {
299
- mkdirSync(dirname(path) || ".", { recursive: true });
300
- writeFileSync(path, content);
667
+ mkdirSync2(dirname2(path) || ".", { recursive: true });
668
+ writeFileSync2(path, content);
301
669
  }
302
670
  function loadConfig() {
303
- if (!existsSync("flaghoist.toml")) {
671
+ if (!existsSync2("flaghoist.toml")) {
304
672
  throw new Error("No flaghoist.toml found in this directory. Run `flaghoist init` first.");
305
673
  }
306
- return parseConfig(readFileSync("flaghoist.toml", "utf8"));
674
+ return parseConfig(readFileSync2("flaghoist.toml", "utf8"));
675
+ }
676
+ function serverFrom(values) {
677
+ const url = values.url ?? process.env.FLAGS_URL;
678
+ if (url) return url;
679
+ const saved = savedServers(credentialsPath());
680
+ if (saved.length === 1) return saved[0];
681
+ throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
307
682
  }
308
683
  function clientFrom(values) {
684
+ const url = serverFrom(values);
685
+ const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN ?? savedCredential(credentialsPath(), url)?.token;
686
+ if (!token) {
687
+ throw new Error(
688
+ "Not signed in. Run `flaghoist login`, or pass --token or set FLAGS_ADMIN_TOKEN."
689
+ );
690
+ }
691
+ return createAdminClient({ url, token });
692
+ }
693
+ async function runLogin(args) {
694
+ const { values } = parseArgs({
695
+ args,
696
+ options: {
697
+ url: { type: "string" },
698
+ email: { type: "string" },
699
+ "password-stdin": { type: "boolean" },
700
+ code: { type: "string" }
701
+ }
702
+ });
309
703
  const url = values.url ?? process.env.FLAGS_URL;
310
- const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN;
311
704
  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 });
705
+ let email = values.email;
706
+ if (!email) {
707
+ if (!process.stdin.isTTY) throw new Error("Pass your email with --email.");
708
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
709
+ email = (await rl.question("Email: ")).trim();
710
+ rl.close();
711
+ }
712
+ const password = await readPassword({ fromStdin: values["password-stdin"] === true });
713
+ console.log(`Signing in to ${url} as ${email}...`);
714
+ const result = await loginForToken({
715
+ url,
716
+ email,
717
+ password,
718
+ tokenName: `flaghoist CLI on ${hostname()}`,
719
+ twoFactorCode: async () => {
720
+ if (values.code) return values.code;
721
+ if (!process.stdin.isTTY) {
722
+ throw new Error("This account uses two-factor codes. Pass the current one with --code.");
723
+ }
724
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
725
+ try {
726
+ return (await rl.question("Two-factor code (or a recovery code): ")).trim();
727
+ } finally {
728
+ rl.close();
729
+ }
730
+ }
731
+ });
732
+ const path = credentialsPath();
733
+ saveCredential(path, url, {
734
+ token: result.token,
735
+ tokenId: result.tokenId,
736
+ email: result.email,
737
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
738
+ });
739
+ console.log(
740
+ `Signed in to ${url} as ${result.email} (${result.role}). The access token expires ${result.expiresAt?.slice(0, 10) ?? "never"} and is saved in ${path}.`
741
+ );
742
+ }
743
+ async function runLogout(args) {
744
+ const { values } = parseArgs({ args, options: { url: { type: "string" } } });
745
+ const url = serverFrom(values);
746
+ const path = credentialsPath();
747
+ const saved = savedCredential(path, url);
748
+ if (!saved) return console.log(`Not signed in to ${url}.`);
749
+ const revoked = await createAdminClient({ url, token: saved.token }).logout().then(() => true).catch(() => false);
750
+ removeCredential(path, url);
751
+ console.log(
752
+ 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.`
753
+ );
754
+ }
755
+ async function runTokensCommand(args) {
756
+ const { values, positionals } = parseArgs({
757
+ args,
758
+ allowPositionals: true,
759
+ options: {
760
+ url: { type: "string" },
761
+ token: { type: "string" },
762
+ role: { type: "string" },
763
+ "expires-days": { type: "string" }
764
+ }
765
+ });
766
+ const lines = await runTokens(clientFrom(values), positionals, {
767
+ role: values.role,
768
+ expiresDays: values["expires-days"]
769
+ });
770
+ for (const line of lines) console.log(line);
314
771
  }
315
772
  function flagState(flag) {
316
773
  if (!flag.enabled) return "disabled";
@@ -381,7 +838,7 @@ async function runFlag(args) {
381
838
  if (a !== "set" || !b)
382
839
  throw new Error("Usage: flaghoist flag rules set <key> --file rules.json");
383
840
  if (!values.file) throw new Error("Missing --file <rules.json>");
384
- const rules = JSON.parse(readFileSync(values.file, "utf8"));
841
+ const rules = JSON.parse(readFileSync2(values.file, "utf8"));
385
842
  if (!Array.isArray(rules)) throw new Error("Rules file must contain a JSON array");
386
843
  const flag = await setRules(clientFrom(values), b, rules);
387
844
  console.log(`Updated rules for "${b}"`);
@@ -391,32 +848,70 @@ async function runFlag(args) {
391
848
  throw new Error(`Unknown flag command: ${sub ?? "(none)"}. Try "flaghoist flag list".`);
392
849
  }
393
850
  }
394
- function runInit(args) {
851
+ async function runUsersCommand(args) {
852
+ const { values, positionals } = parseArgs({
853
+ args,
854
+ allowPositionals: true,
855
+ options: {
856
+ url: { type: "string" },
857
+ token: { type: "string" },
858
+ role: { type: "string" },
859
+ env: { type: "string" }
860
+ }
861
+ });
862
+ const client = clientFrom(values);
863
+ const url = serverFrom(values);
864
+ const options = { role: values.role, env: values.env };
865
+ for (const line of await runUsers(client, url, positionals, options)) {
866
+ console.log(line);
867
+ }
868
+ }
869
+ async function runInit(args) {
395
870
  const { values } = parseArgs({
396
871
  args,
397
872
  allowPositionals: true,
398
873
  options: {
399
874
  name: { type: "string" },
400
875
  storage: { type: "string" },
401
- platform: { type: "string" }
876
+ platform: { type: "string" },
877
+ "no-accounts": { type: "boolean" },
878
+ "sso-issuer": { type: "string" },
879
+ "sso-client-id": { type: "string" },
880
+ "sso-admin-group": { type: "string" },
881
+ "no-dashboard": { type: "boolean" },
882
+ yes: { type: "boolean", short: "y" }
402
883
  }
403
884
  });
404
- if (existsSync("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
885
+ if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
405
886
  if (values.storage && !STORAGE_KINDS.includes(values.storage)) {
406
887
  throw new Error(`Unknown storage "${values.storage}". One of: ${STORAGE_KINDS.join(", ")}.`);
407
888
  }
408
889
  if (values.platform && !PLATFORM_KINDS.includes(values.platform)) {
409
890
  throw new Error(`Unknown platform "${values.platform}". One of: ${PLATFORM_KINDS.join(", ")}.`);
410
891
  }
411
- const base = {
412
- ...DEFAULT_CONFIG,
413
- name: values.name ?? DEFAULT_CONFIG.name,
414
- storage: values.storage ?? DEFAULT_CONFIG.storage
892
+ const preset = {
893
+ name: values.name,
894
+ storage: values.storage,
895
+ platform: values.platform,
896
+ ...values["no-accounts"] ? { accounts: false } : {},
897
+ ...values["no-dashboard"] ? { dashboard: false } : {},
898
+ ...values["sso-issuer"] ? { ssoIssuer: values["sso-issuer"] } : {},
899
+ ...values["sso-client-id"] ? { ssoClientId: values["sso-client-id"] } : {},
900
+ ...values["sso-admin-group"] ? { ssoAdminGroup: values["sso-admin-group"] } : {}
415
901
  };
416
- const config = values.platform === "container" ? asContainer(base) : base;
417
- writeFileSync("flaghoist.toml", serializeConfig(config));
418
- console.log("Created flaghoist.toml");
419
- console.log("Next: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
902
+ const interactive = process.stdin.isTTY === true && values.yes !== true;
903
+ const asker = interactive ? terminalAsker() : defaultsAsker();
904
+ let result;
905
+ try {
906
+ result = await collectSetup(asker, preset);
907
+ } finally {
908
+ if ("close" in asker) asker.close();
909
+ }
910
+ const { envPath } = writeSetup(".", result);
911
+ console.log("\nCreated flaghoist.toml");
912
+ const shown = interactive ? result : { ...result, pepperGenerated: false };
913
+ for (const line of setupSummary(shown, envPath !== void 0)) console.log(line);
914
+ console.log("\nNext: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
420
915
  }
421
916
  function entryFile(platform) {
422
917
  return platform === "container" ? "server.mjs" : "src/index.ts";
@@ -438,7 +933,7 @@ function projectFiles(config) {
438
933
  }
439
934
  function writeProject(config, dir) {
440
935
  const files = projectFiles(config);
441
- const existing = files.map(([name]) => name).filter((name) => existsSync(join(dir, name)));
936
+ const existing = files.map(([name]) => name).filter((name) => existsSync2(join2(dir, name)));
442
937
  if (existing.length > 0) {
443
938
  throw new Error(
444
939
  `Refusing to overwrite ${existing.join(", ")} in this directory.
@@ -446,12 +941,12 @@ Flaghoist deploys as its own service, so give it a directory of its own:
446
941
  npm create flaghoist@latest team-flags`
447
942
  );
448
943
  }
449
- for (const [name, contents] of files) writeFileSafe(join(dir, name), contents);
944
+ for (const [name, contents] of files) writeFileSafe(join2(dir, name), contents);
450
945
  }
451
946
  function runEject() {
452
947
  const config = loadConfig();
453
948
  const entry = entryFile(config.platform);
454
- if (existsSync(entry)) throw new Error(`${entry} already exists. Already ejected?`);
949
+ if (existsSync2(entry)) throw new Error(`${entry} already exists. Already ejected?`);
455
950
  writeProject(config, ".");
456
951
  if (config.platform === "container") {
457
952
  console.log("Ejected to a code project you own: server.mjs, Dockerfile, package.json");
@@ -468,7 +963,7 @@ function runEject() {
468
963
  }
469
964
  function ensureKvNamespace(config) {
470
965
  if (config.storage !== "cloudflare-kv") return;
471
- const toml = readFileSync("wrangler.toml", "utf8");
966
+ const toml = readFileSync2("wrangler.toml", "utf8");
472
967
  if (!needsKvNamespace(toml)) return;
473
968
  const title = `${config.name}-FLAGS`;
474
969
  console.log(`Creating the ${title} KV namespace...`);
@@ -489,11 +984,11 @@ function ensureKvNamespace(config) {
489
984
  "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
985
  );
491
986
  }
492
- writeFileSync("wrangler.toml", fillKvNamespaceId(toml, id));
987
+ writeFileSync2("wrangler.toml", fillKvNamespaceId(toml, id));
493
988
  console.log(`Bound FLAGS to namespace ${id} in wrangler.toml`);
494
989
  }
495
990
  function ensureDependencies() {
496
- if (existsSync("node_modules")) return;
991
+ if (existsSync2("node_modules")) return;
497
992
  console.log("Installing dependencies...");
498
993
  const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { stdio: "inherit" });
499
994
  if (result.status !== 0) {
@@ -511,7 +1006,7 @@ async function promptDeployTarget() {
511
1006
  console.log("Where do you want to deploy?");
512
1007
  console.log(" 1) Cloudflare Workers recommended, deploys in one command");
513
1008
  console.log(" 2) Another platform Render, a container, any Node host");
514
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1009
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
515
1010
  try {
516
1011
  const answer = (await rl.question("Choose [1]: ")).trim().toLowerCase();
517
1012
  return answer === "2" || answer.startsWith("o") || answer.startsWith("r") ? "other" : "cloudflare";
@@ -522,9 +1017,9 @@ async function promptDeployTarget() {
522
1017
  function deployContainer(config) {
523
1018
  const container = asContainer(config);
524
1019
  if (config.platform !== container.platform || config.storage !== container.storage) {
525
- writeFileSync("flaghoist.toml", serializeConfig(container));
1020
+ writeFileSync2("flaghoist.toml", serializeConfig(container));
526
1021
  }
527
- if (!existsSync(entryFile("container"))) writeProject(container, ".");
1022
+ if (!existsSync2(entryFile("container"))) writeProject(container, ".");
528
1023
  console.log(`
529
1024
  Scaffolded a container project: server.mjs, Dockerfile, package.json.
530
1025
 
@@ -533,7 +1028,7 @@ DATABASE_URL, ADMIN_TOKEN, READ_API_KEY). Build and run it with Docker:
533
1028
 
534
1029
  docker build -t ${container.name} .
535
1030
  docker run -p 8080:8080 -e ADMIN_TOKEN=... -e READ_API_KEY=... ${container.name}
536
-
1031
+ ${accountsNote(container)}
537
1032
  Or deploy it to a host:
538
1033
  Render https://docs.flaghoist.dev/deploy/render/
539
1034
  Fly.io https://docs.flaghoist.dev/deploy/fly/
@@ -542,12 +1037,42 @@ Or deploy it to a host:
542
1037
 
543
1038
  Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
544
1039
  }
1040
+ function accountsNote(config) {
1041
+ if (!config.accounts?.enabled) return "";
1042
+ const names = config.accounts.sso ? "AUTH_PEPPER and SSO_CLIENT_SECRET" : "AUTH_PEPPER";
1043
+ return `
1044
+ Accounts are on, so the server also needs ${names}. They are in .env (git-ignored):
1045
+ run locally with \`npm run dev\` or \`docker run --env-file .env ...\`, and add the same values
1046
+ to your host's secrets before you deploy. Keep AUTH_PEPPER the same for the life of the server:
1047
+ changing it signs everyone out and breaks every password.
1048
+ `;
1049
+ }
545
1050
  async function deployCloudflare(config) {
546
- if (!existsSync("src/index.ts")) writeProject(config, ".");
1051
+ if (!existsSync2("src/index.ts")) writeProject(config, ".");
547
1052
  ensureDependencies();
548
1053
  ensureKvNamespace(config);
549
1054
  console.log("Deploying with wrangler...");
550
1055
  const result = spawnSync("npx", ["wrangler", "deploy"], { stdio: "inherit" });
1056
+ if (result.status === 0) {
1057
+ syncWorkerSecrets(
1058
+ config,
1059
+ readEnvFile(".env"),
1060
+ (args, input) => {
1061
+ const run = spawnSync("npx", ["wrangler", ...args], {
1062
+ input,
1063
+ encoding: "utf8",
1064
+ stdio: ["pipe", "pipe", "inherit"]
1065
+ });
1066
+ return { status: run.status, stdout: run.stdout ?? "" };
1067
+ },
1068
+ (line) => console.log(line)
1069
+ );
1070
+ if (config.accounts?.enabled) {
1071
+ console.log(
1072
+ "Open /admin on your Worker and sign in with your ADMIN_TOKEN once to create the owner account."
1073
+ );
1074
+ }
1075
+ }
551
1076
  process.exit(result.status ?? 0);
552
1077
  }
553
1078
  async function runDeploy(args) {
@@ -566,24 +1091,43 @@ function printHelp() {
566
1091
  Usage: flaghoist <command>
567
1092
 
568
1093
  Scaffolding
569
- init [--name N] [--storage cloudflare-kv|redis|postgres|memory] [--platform cloudflare|container]
1094
+ init [--name N] [--storage S] [--platform cloudflare|container] [--no-accounts]
1095
+ [--sso-issuer URL --sso-client-id ID [--sso-admin-group G]] [--no-dashboard] [-y]
1096
+ Set up flaghoist.toml, asking what the flags leave open
570
1097
  eject Generate a code project you own (a Worker, or a container)
571
1098
  deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
572
1099
 
573
- Flag management (needs --url/--token or FLAGS_URL/FLAGS_ADMIN_TOKEN)
1100
+ Signing in, on a server with user accounts
1101
+ login [--url U] [--email E] [--password-stdin] [--code C]
1102
+ Sign in and save a personal access token for this server
1103
+ logout [--url U] Revoke that token and forget it
1104
+ ${TOKENS_USAGE}
1105
+
1106
+ Flag management (needs --url/--token or FLAGS_URL/FLAGS_ADMIN_TOKEN, or \`flaghoist login\`)
574
1107
  flag list
575
1108
  flag get <key>
576
1109
  flag create <key> [--on] [--rollout N] [--desc "..."]
577
1110
  flag toggle <key> [--on|--off]
578
1111
  flag rollout <key> <percentage>
579
1112
  flag rules set <key> --file rules.json
580
- flag delete <key>`);
1113
+ flag delete <key>
1114
+
1115
+ Members, on a server with accounts on (same --url/--token; needs the admin role)
1116
+ ${USERS_USAGE}`);
581
1117
  }
582
1118
  async function main() {
583
1119
  const [command, ...rest] = process.argv.slice(2);
584
1120
  switch (command) {
585
1121
  case "flag":
586
1122
  return runFlag(rest);
1123
+ case "users":
1124
+ return runUsersCommand(rest);
1125
+ case "login":
1126
+ return runLogin(rest);
1127
+ case "logout":
1128
+ return runLogout(rest);
1129
+ case "tokens":
1130
+ return runTokensCommand(rest);
587
1131
  case "init":
588
1132
  return runInit(rest);
589
1133
  case "eject":