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/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
@@ -0,0 +1,391 @@
1
+ // src/config.ts
2
+ import { parse } from "smol-toml";
3
+ var DEFAULT_CONFIG = {
4
+ name: "team-flags",
5
+ storage: "cloudflare-kv",
6
+ platform: "cloudflare",
7
+ auth: { admin: "bearer-token", read: "api-key" },
8
+ dashboard: true
9
+ };
10
+ var STORAGE_KINDS = [
11
+ "cloudflare-kv",
12
+ "redis",
13
+ "postgres",
14
+ "sqlite",
15
+ "memory"
16
+ ];
17
+ var PLATFORM_KINDS = ["cloudflare", "container"];
18
+ function containerStorageDefault(storage) {
19
+ return storage === "cloudflare-kv" ? "postgres" : storage;
20
+ }
21
+ function asContainer(config) {
22
+ return { ...config, platform: "container", storage: containerStorageDefault(config.storage) };
23
+ }
24
+ function parseConfig(text) {
25
+ const raw = parse(text);
26
+ const name = typeof raw.name === "string" && raw.name ? raw.name : DEFAULT_CONFIG.name;
27
+ const storage = STORAGE_KINDS.includes(raw.storage) ? raw.storage : DEFAULT_CONFIG.storage;
28
+ const platform = raw.platform === "container" ? "container" : DEFAULT_CONFIG.platform;
29
+ const authRaw = typeof raw.auth === "object" && raw.auth !== null ? raw.auth : {};
30
+ const admin = authRaw.admin === "oidc" ? "oidc" : "bearer-token";
31
+ const allowedOrigins = Array.isArray(raw.allowedOrigins) ? raw.allowedOrigins.filter((x) => typeof x === "string") : void 0;
32
+ const dashboard = raw.dashboard !== false;
33
+ const accounts = parseAccounts(raw.accounts);
34
+ return {
35
+ name,
36
+ storage,
37
+ platform,
38
+ auth: { admin, read: "api-key" },
39
+ allowedOrigins,
40
+ dashboard,
41
+ ...accounts ? { accounts } : {}
42
+ };
43
+ }
44
+ function parseAccounts(raw) {
45
+ if (typeof raw !== "object" || raw === null) return void 0;
46
+ const table = raw;
47
+ const enabled = table.enabled === true;
48
+ const ssoRaw = typeof table.sso === "object" && table.sso !== null ? table.sso : null;
49
+ const sso = ssoRaw && typeof ssoRaw.issuer === "string" && typeof ssoRaw.clientId === "string" ? {
50
+ issuer: ssoRaw.issuer,
51
+ clientId: ssoRaw.clientId,
52
+ ...typeof ssoRaw.adminGroup === "string" && ssoRaw.adminGroup ? { adminGroup: ssoRaw.adminGroup } : {}
53
+ } : void 0;
54
+ return { enabled, ...sso ? { sso } : {} };
55
+ }
56
+ function serializeConfig(config) {
57
+ const lines = [
58
+ `name = ${JSON.stringify(config.name)}`,
59
+ `storage = ${JSON.stringify(config.storage)}`
60
+ ];
61
+ if (config.platform !== "cloudflare") {
62
+ lines.push(`platform = ${JSON.stringify(config.platform)}`);
63
+ }
64
+ if (config.allowedOrigins && config.allowedOrigins.length > 0) {
65
+ lines.push(`allowedOrigins = ${JSON.stringify(config.allowedOrigins)}`);
66
+ }
67
+ if (!config.dashboard) {
68
+ lines.push("dashboard = false");
69
+ }
70
+ lines.push(
71
+ "",
72
+ "[auth]",
73
+ `admin = ${JSON.stringify(config.auth.admin)}`,
74
+ `read = ${JSON.stringify(config.auth.read)}`
75
+ );
76
+ if (config.accounts) {
77
+ lines.push(
78
+ "",
79
+ "# Accounts read AUTH_PEPPER from the environment. Never put it in this file.",
80
+ "[accounts]",
81
+ `enabled = ${config.accounts.enabled}`
82
+ );
83
+ const sso = config.accounts.sso;
84
+ if (sso) {
85
+ lines.push(
86
+ "",
87
+ "# The client secret comes from SSO_CLIENT_SECRET in the environment.",
88
+ "[accounts.sso]",
89
+ `issuer = ${JSON.stringify(sso.issuer)}`,
90
+ `clientId = ${JSON.stringify(sso.clientId)}`
91
+ );
92
+ if (sso.adminGroup) lines.push(`adminGroup = ${JSON.stringify(sso.adminGroup)}`);
93
+ }
94
+ }
95
+ return `${lines.join("\n")}
96
+ `;
97
+ }
98
+
99
+ // src/setup.ts
100
+ import { randomBytes } from "crypto";
101
+ import { existsSync, readFileSync, writeFileSync } from "fs";
102
+ import { join } from "path";
103
+ import { createInterface } from "readline/promises";
104
+ var MIN_PEPPER_LENGTH = 32;
105
+ var PLATFORMS = [
106
+ {
107
+ value: "cloudflare",
108
+ label: "Cloudflare Workers",
109
+ hint: "free plan, deploys in one command"
110
+ },
111
+ {
112
+ value: "container",
113
+ label: "Container, any host",
114
+ hint: "Render, Fly.io, Railway, a VPS, Kubernetes"
115
+ }
116
+ ];
117
+ function storageChoices(platform) {
118
+ if (platform === "cloudflare") {
119
+ return [
120
+ { value: "cloudflare-kv", label: "Cloudflare KV", hint: "created for you on first deploy" },
121
+ { value: "redis", label: "Redis", hint: "Upstash, over HTTP" },
122
+ { value: "postgres", label: "Postgres", hint: "an HTTP-friendly host such as Neon" },
123
+ { value: "memory", label: "Memory", hint: "flags are lost on restart; for trying it out" }
124
+ ];
125
+ }
126
+ return [
127
+ { value: "postgres", label: "Postgres" },
128
+ { value: "redis", label: "Redis" },
129
+ { value: "sqlite", label: "SQLite", hint: "one file on a mounted volume" },
130
+ { value: "memory", label: "Memory", hint: "flags are lost on restart; for trying it out" }
131
+ ];
132
+ }
133
+ function generatePepper() {
134
+ return randomBytes(32).toString("hex");
135
+ }
136
+ var NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
137
+ function validName(name) {
138
+ return NAME_PATTERN.test(name);
139
+ }
140
+ async function collectSetup(asker, preset = {}) {
141
+ let name = preset.name ?? "";
142
+ while (!validName(name)) {
143
+ if (name)
144
+ asker.say(" Use lowercase letters, digits and hyphens, starting with a letter or digit.");
145
+ name = (await asker.text("Project name", DEFAULT_CONFIG.name)).trim().toLowerCase();
146
+ }
147
+ const platform = preset.platform ?? await asker.choice("Where will it run?", PLATFORMS, "cloudflare");
148
+ const choices = storageChoices(platform);
149
+ const presetStorage = preset.storage && platform === "container" ? containerStorageDefault(preset.storage) : preset.storage;
150
+ const storage = presetStorage && choices.some((c) => c.value === presetStorage) ? presetStorage : await asker.choice("Storage", choices, choices[0].value);
151
+ const accountsOn = preset.accounts ?? await asker.yesNo("Accounts and roles, so each person signs in as themselves?", true);
152
+ const secrets = {};
153
+ let pepperGenerated = false;
154
+ let sso;
155
+ if (accountsOn) {
156
+ asker.say("");
157
+ asker.say(" Accounts need an AUTH_PEPPER: a secret that protects stored passwords. With it, a");
158
+ asker.say(" stolen copy of your database reveals no password hashes an attacker can use.");
159
+ asker.say(
160
+ " Losing it means everyone has to reset their password, so keep a copy somewhere safe."
161
+ );
162
+ const source = await asker.choice(
163
+ "AUTH_PEPPER",
164
+ [
165
+ { value: "generate", label: "Generate one for me", hint: "recommended" },
166
+ { value: "paste", label: "I have one, let me paste it" }
167
+ ],
168
+ "generate"
169
+ );
170
+ if (source === "paste") {
171
+ let pasted = "";
172
+ while (pasted.length < MIN_PEPPER_LENGTH) {
173
+ if (pasted) asker.say(` It needs at least ${MIN_PEPPER_LENGTH} characters.`);
174
+ pasted = (await asker.secret("Paste your AUTH_PEPPER")).trim();
175
+ }
176
+ secrets.AUTH_PEPPER = pasted;
177
+ } else {
178
+ secrets.AUTH_PEPPER = generatePepper();
179
+ pepperGenerated = true;
180
+ }
181
+ const wantsSso = preset.sso ?? (preset.ssoIssuer !== void 0 || await asker.yesNo("Sign in with your company's identity provider (SSO)?", false));
182
+ if (wantsSso) {
183
+ const issuer = preset.ssoIssuer ?? await askUrl(asker, "Issuer URL (for example https://acme.okta.com)");
184
+ const clientId = preset.ssoClientId ?? await askRequired(asker, "Client ID");
185
+ const adminGroup = preset.ssoAdminGroup ?? (await asker.text("Group whose members become admins (blank for none)", "")).trim();
186
+ const clientSecret = (await asker.secret("Client secret (blank if your app is public, or to add it later)")).trim();
187
+ if (clientSecret) secrets.SSO_CLIENT_SECRET = clientSecret;
188
+ sso = { issuer, clientId, ...adminGroup ? { adminGroup } : {} };
189
+ }
190
+ }
191
+ const dashboard = preset.dashboard ?? await asker.yesNo("Serve the admin dashboard at /admin?", true);
192
+ const config = {
193
+ ...DEFAULT_CONFIG,
194
+ name,
195
+ platform,
196
+ storage,
197
+ dashboard,
198
+ ...accountsOn ? { accounts: { enabled: true, ...sso ? { sso } : {} } } : {}
199
+ };
200
+ return { config, secrets, pepperGenerated };
201
+ }
202
+ async function askRequired(asker, question) {
203
+ let answer = "";
204
+ while (!answer) {
205
+ answer = (await asker.text(question, "")).trim();
206
+ if (!answer && asker.interactive === false) {
207
+ throw new Error(
208
+ `SSO needs ${question.split(" (")[0].toLowerCase()}: pass --sso-issuer and --sso-client-id.`
209
+ );
210
+ }
211
+ }
212
+ return answer;
213
+ }
214
+ async function askUrl(asker, question) {
215
+ for (; ; ) {
216
+ const answer = await askRequired(asker, question);
217
+ try {
218
+ const url = new URL(answer);
219
+ if (url.protocol === "https:" || url.hostname === "localhost") return answer;
220
+ } catch {
221
+ }
222
+ if (asker.interactive === false) throw new Error(`--sso-issuer must be an https:// URL.`);
223
+ asker.say(" That needs to be an https:// URL.");
224
+ }
225
+ }
226
+ var GITIGNORE = [".env", ".dev.vars", "node_modules", ".wrangler", ""].join("\n");
227
+ function writeSetup(dir, result) {
228
+ writeFileSync(join(dir, "flaghoist.toml"), serializeConfig(result.config));
229
+ if (Object.keys(result.secrets).length === 0) return {};
230
+ const envPath = join(dir, ".env");
231
+ const lines = [
232
+ "# Secrets for this Flaghoist project. Never commit this file.",
233
+ ...result.config.platform === "cloudflare" ? ["# `npx flaghoist deploy` sets these as secrets on your Worker."] : [
234
+ "# Pass them to your container host as environment variables, or run with --env-file .env."
235
+ ],
236
+ ...Object.entries(result.secrets).map(([key, value]) => `${key}=${value}`),
237
+ ""
238
+ ];
239
+ writeFileSync(envPath, lines.join("\n"), { mode: 384 });
240
+ const gitignorePath = join(dir, ".gitignore");
241
+ if (!existsSync(gitignorePath)) {
242
+ writeFileSync(gitignorePath, GITIGNORE);
243
+ } else {
244
+ const current = readFileSync(gitignorePath, "utf8");
245
+ if (!/^\.env$/m.test(current)) {
246
+ writeFileSync(gitignorePath, `${current.replace(/\n?$/, "\n")}.env
247
+ `);
248
+ }
249
+ }
250
+ return { envPath };
251
+ }
252
+ function readEnvFile(path) {
253
+ if (!existsSync(path)) return {};
254
+ const out = {};
255
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
256
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
257
+ if (match && !line.trim().startsWith("#"))
258
+ out[match[1]] = match[2].replace(/^["']|["']$/g, "");
259
+ }
260
+ return out;
261
+ }
262
+ function setupSummary(result, envWritten) {
263
+ const { config } = result;
264
+ const lines = [
265
+ "",
266
+ ` name ${config.name}`,
267
+ ` platform ${config.platform === "cloudflare" ? "Cloudflare Workers" : "container"}`,
268
+ ` storage ${config.storage}`,
269
+ ` accounts ${config.accounts?.enabled ? "on" : "off"}`,
270
+ ` sso ${config.accounts?.sso ? config.accounts.sso.issuer : "off"}`,
271
+ ` dashboard ${config.dashboard ? "on" : "off"}`
272
+ ];
273
+ if (result.pepperGenerated && result.secrets.AUTH_PEPPER) {
274
+ lines.push(
275
+ "",
276
+ " Your AUTH_PEPPER (save it in your password manager now; it is not shown again):",
277
+ "",
278
+ ` ${result.secrets.AUTH_PEPPER}`
279
+ );
280
+ }
281
+ if (envWritten) {
282
+ lines.push(
283
+ "",
284
+ config.platform === "cloudflare" ? " Secrets are in .env (git-ignored). `npx flaghoist deploy` sets them on your Worker." : " Secrets are in .env (git-ignored). Add them to your host as environment variables too."
285
+ );
286
+ }
287
+ return lines;
288
+ }
289
+ function terminalAsker(input = process.stdin, output = process.stdout) {
290
+ const rl = createInterface({ input, output, terminal: true });
291
+ let muted = false;
292
+ const internal = rl;
293
+ const write = internal._writeToOutput.bind(rl);
294
+ internal._writeToOutput = (text) => {
295
+ if (!muted) write(text);
296
+ };
297
+ return {
298
+ interactive: true,
299
+ async text(question, fallback) {
300
+ const answer = (await rl.question(` ${question}${fallback ? ` (${fallback})` : ""}: `)).trim();
301
+ return answer || fallback;
302
+ },
303
+ async choice(question, options, fallback) {
304
+ output.write(`
305
+ ${question}
306
+ `);
307
+ options.forEach((o, i) => {
308
+ output.write(` ${i + 1}) ${o.label}${o.hint ? ` ${o.hint}` : ""}
309
+ `);
310
+ });
311
+ const defaultIndex = Math.max(
312
+ 0,
313
+ options.findIndex((o) => o.value === fallback)
314
+ ) + 1;
315
+ for (; ; ) {
316
+ const answer = (await rl.question(` Choose (${defaultIndex}): `)).trim();
317
+ if (!answer) return options[defaultIndex - 1].value;
318
+ const index = Number(answer);
319
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) {
320
+ return options[index - 1].value;
321
+ }
322
+ output.write(` Enter a number from 1 to ${options.length}.
323
+ `);
324
+ }
325
+ },
326
+ async yesNo(question, fallback) {
327
+ for (; ; ) {
328
+ const answer = (await rl.question(`
329
+ ${question} (${fallback ? "Y/n" : "y/N"}): `)).trim().toLowerCase();
330
+ if (!answer) return fallback;
331
+ if (answer === "y" || answer === "yes") return true;
332
+ if (answer === "n" || answer === "no") return false;
333
+ }
334
+ },
335
+ async secret(question) {
336
+ output.write(` ${question}: `);
337
+ muted = true;
338
+ try {
339
+ return await rl.question("");
340
+ } finally {
341
+ muted = false;
342
+ output.write("\n");
343
+ }
344
+ },
345
+ say(line) {
346
+ output.write(`${line}
347
+ `);
348
+ },
349
+ close() {
350
+ rl.close();
351
+ }
352
+ };
353
+ }
354
+ function defaultsAsker() {
355
+ return {
356
+ interactive: false,
357
+ async text(_q, fallback) {
358
+ return fallback;
359
+ },
360
+ async choice(_q, _options, fallback) {
361
+ return fallback;
362
+ },
363
+ async yesNo(_q, fallback) {
364
+ return fallback;
365
+ },
366
+ async secret() {
367
+ return "";
368
+ },
369
+ say() {
370
+ }
371
+ };
372
+ }
373
+
374
+ export {
375
+ DEFAULT_CONFIG,
376
+ STORAGE_KINDS,
377
+ PLATFORM_KINDS,
378
+ containerStorageDefault,
379
+ asContainer,
380
+ parseConfig,
381
+ serializeConfig,
382
+ MIN_PEPPER_LENGTH,
383
+ storageChoices,
384
+ generatePepper,
385
+ collectSetup,
386
+ writeSetup,
387
+ readEnvFile,
388
+ setupSummary,
389
+ terminalAsker,
390
+ defaultsAsker
391
+ };