flaghoist 0.4.0 → 0.5.1

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.
@@ -0,0 +1,396 @@
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
+ import { Writable } from "stream";
105
+ var MIN_PEPPER_LENGTH = 32;
106
+ var PLATFORMS = [
107
+ {
108
+ value: "cloudflare",
109
+ label: "Cloudflare Workers",
110
+ hint: "free plan, deploys in one command"
111
+ },
112
+ {
113
+ value: "container",
114
+ label: "Container, any host",
115
+ hint: "Render, Fly.io, Railway, a VPS, Kubernetes"
116
+ }
117
+ ];
118
+ function storageChoices(platform) {
119
+ if (platform === "cloudflare") {
120
+ return [
121
+ { value: "cloudflare-kv", label: "Cloudflare KV", hint: "created for you on first deploy" },
122
+ { value: "redis", label: "Redis", hint: "Upstash, over HTTP" },
123
+ { value: "postgres", label: "Postgres", hint: "an HTTP-friendly host such as Neon" },
124
+ { value: "memory", label: "Memory", hint: "flags are lost on restart; for trying it out" }
125
+ ];
126
+ }
127
+ return [
128
+ { value: "postgres", label: "Postgres" },
129
+ { value: "redis", label: "Redis" },
130
+ { value: "sqlite", label: "SQLite", hint: "one file on a mounted volume" },
131
+ { value: "memory", label: "Memory", hint: "flags are lost on restart; for trying it out" }
132
+ ];
133
+ }
134
+ function generatePepper() {
135
+ return randomBytes(32).toString("hex");
136
+ }
137
+ var NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
138
+ function validName(name) {
139
+ return NAME_PATTERN.test(name);
140
+ }
141
+ async function collectSetup(asker, preset = {}) {
142
+ let name = preset.name ?? "";
143
+ while (!validName(name)) {
144
+ if (name)
145
+ asker.say(" Use lowercase letters, digits and hyphens, starting with a letter or digit.");
146
+ name = (await asker.text("Project name", DEFAULT_CONFIG.name)).trim().toLowerCase();
147
+ }
148
+ const platform = preset.platform ?? await asker.choice("Where will it run?", PLATFORMS, "cloudflare");
149
+ const choices = storageChoices(platform);
150
+ const presetStorage = preset.storage && platform === "container" ? containerStorageDefault(preset.storage) : preset.storage;
151
+ const storage = presetStorage && choices.some((c) => c.value === presetStorage) ? presetStorage : await asker.choice("Storage", choices, choices[0].value);
152
+ const accountsOn = preset.accounts ?? await asker.yesNo("Accounts and roles, so each person signs in as themselves?", true);
153
+ const secrets = {};
154
+ let pepperGenerated = false;
155
+ let sso;
156
+ if (accountsOn) {
157
+ asker.say("");
158
+ asker.say(" Accounts need an AUTH_PEPPER: a secret that protects stored passwords. With it, a");
159
+ asker.say(" stolen copy of your database reveals no password hashes an attacker can use.");
160
+ asker.say(
161
+ " Losing it means everyone has to reset their password, so keep a copy somewhere safe."
162
+ );
163
+ const source = await asker.choice(
164
+ "AUTH_PEPPER",
165
+ [
166
+ { value: "generate", label: "Generate one for me", hint: "recommended" },
167
+ { value: "paste", label: "I have one, let me paste it" }
168
+ ],
169
+ "generate"
170
+ );
171
+ if (source === "paste") {
172
+ let pasted = "";
173
+ while (pasted.length < MIN_PEPPER_LENGTH) {
174
+ if (pasted) asker.say(` It needs at least ${MIN_PEPPER_LENGTH} characters.`);
175
+ pasted = (await asker.secret("Paste your AUTH_PEPPER")).trim();
176
+ }
177
+ secrets.AUTH_PEPPER = pasted;
178
+ } else {
179
+ secrets.AUTH_PEPPER = generatePepper();
180
+ pepperGenerated = true;
181
+ }
182
+ const wantsSso = preset.sso ?? (preset.ssoIssuer !== void 0 || await asker.yesNo("Sign in with your company's identity provider (SSO)?", false));
183
+ if (wantsSso) {
184
+ const issuer = preset.ssoIssuer ?? await askUrl(asker, "Issuer URL (for example https://acme.okta.com)");
185
+ const clientId = preset.ssoClientId ?? await askRequired(asker, "Client ID");
186
+ const adminGroup = preset.ssoAdminGroup ?? (await asker.text("Group whose members become admins (blank for none)", "")).trim();
187
+ const clientSecret = (await asker.secret("Client secret (blank if your app is public, or to add it later)")).trim();
188
+ if (clientSecret) secrets.SSO_CLIENT_SECRET = clientSecret;
189
+ sso = { issuer, clientId, ...adminGroup ? { adminGroup } : {} };
190
+ }
191
+ }
192
+ const dashboard = preset.dashboard ?? await asker.yesNo("Serve the admin dashboard at /admin?", true);
193
+ const config = {
194
+ ...DEFAULT_CONFIG,
195
+ name,
196
+ platform,
197
+ storage,
198
+ dashboard,
199
+ ...accountsOn ? { accounts: { enabled: true, ...sso ? { sso } : {} } } : {}
200
+ };
201
+ return { config, secrets, pepperGenerated };
202
+ }
203
+ async function askRequired(asker, question) {
204
+ let answer = "";
205
+ while (!answer) {
206
+ answer = (await asker.text(question, "")).trim();
207
+ if (!answer && asker.interactive === false) {
208
+ throw new Error(
209
+ `SSO needs ${question.split(" (")[0].toLowerCase()}: pass --sso-issuer and --sso-client-id.`
210
+ );
211
+ }
212
+ }
213
+ return answer;
214
+ }
215
+ async function askUrl(asker, question) {
216
+ for (; ; ) {
217
+ const answer = await askRequired(asker, question);
218
+ try {
219
+ const url = new URL(answer);
220
+ if (url.protocol === "https:" || url.hostname === "localhost") return answer;
221
+ } catch {
222
+ }
223
+ if (asker.interactive === false) throw new Error(`--sso-issuer must be an https:// URL.`);
224
+ asker.say(" That needs to be an https:// URL.");
225
+ }
226
+ }
227
+ var GITIGNORE = [".env", ".dev.vars", "node_modules", ".wrangler", ""].join("\n");
228
+ function writeSetup(dir, result) {
229
+ writeFileSync(join(dir, "flaghoist.toml"), serializeConfig(result.config));
230
+ if (Object.keys(result.secrets).length === 0) return {};
231
+ const envPath = join(dir, ".env");
232
+ const lines = [
233
+ "# Secrets for this Flaghoist project. Never commit this file.",
234
+ ...result.config.platform === "cloudflare" ? ["# `npx flaghoist deploy` sets these as secrets on your Worker."] : [
235
+ "# Pass them to your container host as environment variables, or run with --env-file .env."
236
+ ],
237
+ ...Object.entries(result.secrets).map(([key, value]) => `${key}=${value}`),
238
+ ""
239
+ ];
240
+ writeFileSync(envPath, lines.join("\n"), { mode: 384 });
241
+ const gitignorePath = join(dir, ".gitignore");
242
+ if (!existsSync(gitignorePath)) {
243
+ writeFileSync(gitignorePath, GITIGNORE);
244
+ } else {
245
+ const current = readFileSync(gitignorePath, "utf8");
246
+ if (!/^\.env$/m.test(current)) {
247
+ writeFileSync(gitignorePath, `${current.replace(/\n?$/, "\n")}.env
248
+ `);
249
+ }
250
+ }
251
+ return { envPath };
252
+ }
253
+ function readEnvFile(path) {
254
+ if (!existsSync(path)) return {};
255
+ const out = {};
256
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
257
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i.exec(line);
258
+ if (match && !line.trim().startsWith("#"))
259
+ out[match[1]] = match[2].replace(/^["']|["']$/g, "");
260
+ }
261
+ return out;
262
+ }
263
+ function setupSummary(result, envWritten) {
264
+ const { config } = result;
265
+ const lines = [
266
+ "",
267
+ ` name ${config.name}`,
268
+ ` platform ${config.platform === "cloudflare" ? "Cloudflare Workers" : "container"}`,
269
+ ` storage ${config.storage}`,
270
+ ` accounts ${config.accounts?.enabled ? "on" : "off"}`,
271
+ ` sso ${config.accounts?.sso ? config.accounts.sso.issuer : "off"}`,
272
+ ` dashboard ${config.dashboard ? "on" : "off"}`
273
+ ];
274
+ if (result.pepperGenerated && result.secrets.AUTH_PEPPER) {
275
+ lines.push(
276
+ "",
277
+ " Your AUTH_PEPPER (save it in your password manager now; it is not shown again):",
278
+ "",
279
+ ` ${result.secrets.AUTH_PEPPER}`
280
+ );
281
+ }
282
+ if (envWritten) {
283
+ lines.push(
284
+ "",
285
+ 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."
286
+ );
287
+ }
288
+ return lines;
289
+ }
290
+ function terminalAsker(input = process.stdin, output = process.stdout) {
291
+ let muted = false;
292
+ const echo = new Writable({
293
+ write(chunk, encoding, done) {
294
+ if (muted) done();
295
+ else output.write(chunk, encoding, done);
296
+ }
297
+ });
298
+ Object.defineProperty(echo, "columns", {
299
+ get: () => output.columns ?? 80
300
+ });
301
+ const rl = createInterface({ input, output: echo, terminal: true });
302
+ return {
303
+ interactive: true,
304
+ async text(question, fallback) {
305
+ const answer = (await rl.question(` ${question}${fallback ? ` (${fallback})` : ""}: `)).trim();
306
+ return answer || fallback;
307
+ },
308
+ async choice(question, options, fallback) {
309
+ output.write(`
310
+ ${question}
311
+ `);
312
+ options.forEach((o, i) => {
313
+ output.write(` ${i + 1}) ${o.label}${o.hint ? ` ${o.hint}` : ""}
314
+ `);
315
+ });
316
+ const defaultIndex = Math.max(
317
+ 0,
318
+ options.findIndex((o) => o.value === fallback)
319
+ ) + 1;
320
+ for (; ; ) {
321
+ const answer = (await rl.question(` Choose (${defaultIndex}): `)).trim();
322
+ if (!answer) return options[defaultIndex - 1].value;
323
+ const index = Number(answer);
324
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) {
325
+ return options[index - 1].value;
326
+ }
327
+ output.write(` Enter a number from 1 to ${options.length}.
328
+ `);
329
+ }
330
+ },
331
+ async yesNo(question, fallback) {
332
+ for (; ; ) {
333
+ const answer = (await rl.question(`
334
+ ${question} (${fallback ? "Y/n" : "y/N"}): `)).trim().toLowerCase();
335
+ if (!answer) return fallback;
336
+ if (answer === "y" || answer === "yes") return true;
337
+ if (answer === "n" || answer === "no") return false;
338
+ }
339
+ },
340
+ async secret(question) {
341
+ output.write(` ${question}: `);
342
+ muted = true;
343
+ try {
344
+ return await rl.question("");
345
+ } finally {
346
+ muted = false;
347
+ output.write("\n");
348
+ }
349
+ },
350
+ say(line) {
351
+ output.write(`${line}
352
+ `);
353
+ },
354
+ close() {
355
+ rl.close();
356
+ }
357
+ };
358
+ }
359
+ function defaultsAsker() {
360
+ return {
361
+ interactive: false,
362
+ async text(_q, fallback) {
363
+ return fallback;
364
+ },
365
+ async choice(_q, _options, fallback) {
366
+ return fallback;
367
+ },
368
+ async yesNo(_q, fallback) {
369
+ return fallback;
370
+ },
371
+ async secret() {
372
+ return "";
373
+ },
374
+ say() {
375
+ }
376
+ };
377
+ }
378
+
379
+ export {
380
+ DEFAULT_CONFIG,
381
+ STORAGE_KINDS,
382
+ PLATFORM_KINDS,
383
+ containerStorageDefault,
384
+ asContainer,
385
+ parseConfig,
386
+ serializeConfig,
387
+ MIN_PEPPER_LENGTH,
388
+ storageChoices,
389
+ generatePepper,
390
+ collectSetup,
391
+ writeSetup,
392
+ readEnvFile,
393
+ setupSummary,
394
+ terminalAsker,
395
+ defaultsAsker
396
+ };
package/dist/index.js CHANGED
@@ -1,13 +1,18 @@
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-LZB44MXJ.js";
11
16
 
12
17
  // src/index.ts
13
18
  import { spawnSync } from "child_process";
@@ -26,20 +31,167 @@ import {
26
31
  toggleFlag
27
32
  } from "@flaghoist/admin-client";
28
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
+
29
181
  // src/generate-container.ts
30
182
  var CONTAINER_DEPS = {
31
- "@flaghoist/adapter-memory": "^0.1.2",
32
- "@flaghoist/adapter-postgres": "^0.1.2",
33
- "@flaghoist/adapter-redis": "^0.1.2",
34
- "@flaghoist/adapter-sqlite": "^0.1.0",
35
- "@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",
36
188
  "@hono/node-server": "^2.1.1",
37
189
  "better-sqlite3": "^11.0.0",
38
190
  ioredis: "^5.4.0",
39
191
  pg: "^8.13.0"
40
192
  };
41
193
  var CONTAINER_PORT = 8080;
42
- function adminExpr(admin) {
194
+ function adminExpr2(admin) {
43
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)";
44
196
  }
45
197
  function generateNodeEntry(config) {
@@ -106,9 +258,9 @@ async function makeStorage() {
106
258
  const app = createFlagServer({
107
259
  storage: await makeStorage(),${dashboard}
108
260
  auth: {
109
- admin: ${adminExpr(config.auth.admin)},
261
+ admin: ${adminExpr2(config.auth.admin)},
110
262
  read: apiKey(env.READ_API_KEY),
111
- },
263
+ },${usersBlock(config)}
112
264
  rateLimit: memoryRateLimit(),
113
265
  // Comma-separated list of browser origins allowed to read flags cross-origin.
114
266
  allowedOrigins: env.FLAGS_CORS ? env.FLAGS_CORS.split(',').map((o) => o.trim()) : ${origins},
@@ -151,144 +303,26 @@ function generateContainerPackageJson(config) {
151
303
  version: "0.0.0",
152
304
  private: true,
153
305
  type: "module",
154
- 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
+ },
155
311
  dependencies: CONTAINER_DEPS
156
312
  };
157
313
  return `${JSON.stringify(pkg, null, 2)}
158
314
  `;
159
315
  }
160
316
  function generateDockerignore() {
161
- return ["node_modules", "npm-debug.log", "Dockerfile", ".dockerignore", "README.md", ""].join(
162
- "\n"
163
- );
164
- }
165
-
166
- // src/generate.ts
167
- function storageSnippet(storage) {
168
- switch (storage) {
169
- case "cloudflare-kv":
170
- return {
171
- imports: [`import { cloudflareKV } from '@flaghoist/adapter-cloudflare-kv'`],
172
- expr: "cloudflareKV(env.FLAGS)",
173
- pkg: "@flaghoist/adapter-cloudflare-kv"
174
- };
175
- case "redis":
176
- return {
177
- imports: [
178
- `import { redisAdapter } from '@flaghoist/adapter-redis'`,
179
- `import { Redis } from '@upstash/redis/cloudflare'`
180
- ],
181
- expr: "redisAdapter(Redis.fromEnv(env))",
182
- pkg: "@flaghoist/adapter-redis",
183
- extraDeps: { "@upstash/redis": "^1.34.0" }
184
- };
185
- case "postgres":
186
- return {
187
- imports: [
188
- `import { postgresAdapter } from '@flaghoist/adapter-postgres'`,
189
- `import { Pool } from 'pg'`
190
- ],
191
- expr: "postgresAdapter(new Pool({ connectionString: env.DATABASE_URL }))",
192
- pkg: "@flaghoist/adapter-postgres",
193
- extraDeps: { pg: "^8.13.0" }
194
- };
195
- case "memory":
196
- return {
197
- imports: [`import { memoryAdapter } from '@flaghoist/adapter-memory'`],
198
- expr: "memoryAdapter()",
199
- pkg: "@flaghoist/adapter-memory"
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
- );
205
- }
206
- }
207
- function adminExpr2(admin) {
208
- return admin === "oidc" ? "oidc({ issuer: env.OIDC_ISSUER, audience: env.OIDC_AUDIENCE, groupsClaim: 'cognito:groups', allowedGroups: (env.ADMIN_GROUPS ?? '').split(',') })" : "bearerToken(env.ADMIN_TOKEN)";
209
- }
210
- function generateWorkerEntry(config) {
211
- const storage = storageSnippet(config.storage);
212
- const serverImports = [
213
- "apiKey",
214
- "createFlagServer",
215
- config.auth.admin === "oidc" ? "oidc" : "bearerToken"
216
- ].sort();
217
- const imports = [
218
- ...storage.imports,
219
- `import { ${serverImports.join(", ")} } from '@flaghoist/server'`,
220
- // A subpath import, so a Worker built with `dashboard = false` never pulls the HTML in.
221
- ...config.dashboard ? [`import { dashboardHtml } from '@flaghoist/server/dashboard'`] : []
317
+ return [
318
+ "node_modules",
319
+ "npm-debug.log",
320
+ "Dockerfile",
321
+ ".dockerignore",
322
+ "README.md",
323
+ ".env",
324
+ ""
222
325
  ].join("\n");
223
- const origins = config.allowedOrigins && config.allowedOrigins.length > 0 ? `
224
- allowedOrigins: ${JSON.stringify(config.allowedOrigins)},` : "";
225
- const dashboard = config.dashboard ? "\n dashboard: dashboardHtml," : "";
226
- return `${imports}
227
-
228
- export default createFlagServer((env) => ({
229
- storage: ${storage.expr},
230
- auth: {
231
- admin: ${adminExpr2(config.auth.admin)},
232
- read: apiKey(env.READ_API_KEY),
233
- },${origins}${dashboard}
234
- }))
235
- `;
236
- }
237
- var KV_NAMESPACE_PLACEHOLDER = "<your-kv-namespace-id>";
238
- function generateWranglerToml(config) {
239
- const lines = [
240
- `name = ${JSON.stringify(config.name)}`,
241
- `main = "src/index.ts"`,
242
- `compatibility_date = "2025-01-01"`
243
- ];
244
- if (config.storage === "cloudflare-kv") {
245
- lines.push(
246
- "",
247
- `# Created for you by \`flaghoist deploy\`, or by: npx wrangler kv namespace create ${config.name}-FLAGS`,
248
- "kv_namespaces = [",
249
- ` { binding = "FLAGS", id = ${JSON.stringify(KV_NAMESPACE_PLACEHOLDER)} }`,
250
- "]"
251
- );
252
- }
253
- return `${lines.join("\n")}
254
- `;
255
- }
256
- function needsKvNamespace(toml) {
257
- return toml.includes(KV_NAMESPACE_PLACEHOLDER);
258
- }
259
- function fillKvNamespaceId(toml, id) {
260
- return toml.replace(/^# Created for you by `flaghoist deploy`.*\n/m, "").replaceAll(KV_NAMESPACE_PLACEHOLDER, id);
261
- }
262
- function parseKvNamespaceId(output) {
263
- const patterns = [
264
- /\bid\s*=\s*"([0-9a-f]{32})"/i,
265
- /"id"\s*:\s*"([0-9a-f]{32})"/i,
266
- /\b([0-9a-f]{32})\b/i
267
- ];
268
- for (const re of patterns) {
269
- const match = re.exec(output);
270
- if (match) return match[1];
271
- }
272
- return void 0;
273
- }
274
- function generatePackageJson(config) {
275
- const storage = storageSnippet(config.storage);
276
- const dependencies = {
277
- "@flaghoist/server": "^0.1.0",
278
- [storage.pkg]: "^0.1.0",
279
- ...storage.extraDeps
280
- };
281
- const pkg = {
282
- name: config.name,
283
- version: "0.0.0",
284
- private: true,
285
- type: "module",
286
- scripts: { dev: "wrangler dev", deploy: "wrangler deploy" },
287
- dependencies,
288
- devDependencies: { wrangler: "^4.0.0" }
289
- };
290
- return `${JSON.stringify(pkg, null, 2)}
291
- `;
292
326
  }
293
327
 
294
328
  // src/credentials.ts
@@ -577,6 +611,53 @@ async function runUsers(client, serverUrl, positionals, options) {
577
611
  }
578
612
  }
579
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 };
659
+ }
660
+
580
661
  // src/version.ts
581
662
  import { createRequire } from "module";
582
663
  var VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -785,14 +866,20 @@ async function runUsersCommand(args) {
785
866
  console.log(line);
786
867
  }
787
868
  }
788
- function runInit(args) {
869
+ async function runInit(args) {
789
870
  const { values } = parseArgs({
790
871
  args,
791
872
  allowPositionals: true,
792
873
  options: {
793
874
  name: { type: "string" },
794
875
  storage: { type: "string" },
795
- 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" }
796
883
  }
797
884
  });
798
885
  if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
@@ -802,15 +889,29 @@ function runInit(args) {
802
889
  if (values.platform && !PLATFORM_KINDS.includes(values.platform)) {
803
890
  throw new Error(`Unknown platform "${values.platform}". One of: ${PLATFORM_KINDS.join(", ")}.`);
804
891
  }
805
- const base = {
806
- ...DEFAULT_CONFIG,
807
- name: values.name ?? DEFAULT_CONFIG.name,
808
- 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"] } : {}
809
901
  };
810
- const config = values.platform === "container" ? asContainer(base) : base;
811
- writeFileSync2("flaghoist.toml", serializeConfig(config));
812
- console.log("Created flaghoist.toml");
813
- 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.");
814
915
  }
815
916
  function entryFile(platform) {
816
917
  return platform === "container" ? "server.mjs" : "src/index.ts";
@@ -927,7 +1028,7 @@ DATABASE_URL, ADMIN_TOKEN, READ_API_KEY). Build and run it with Docker:
927
1028
 
928
1029
  docker build -t ${container.name} .
929
1030
  docker run -p 8080:8080 -e ADMIN_TOKEN=... -e READ_API_KEY=... ${container.name}
930
-
1031
+ ${accountsNote(container)}
931
1032
  Or deploy it to a host:
932
1033
  Render https://docs.flaghoist.dev/deploy/render/
933
1034
  Fly.io https://docs.flaghoist.dev/deploy/fly/
@@ -936,12 +1037,42 @@ Or deploy it to a host:
936
1037
 
937
1038
  Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
938
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
+ }
939
1050
  async function deployCloudflare(config) {
940
1051
  if (!existsSync2("src/index.ts")) writeProject(config, ".");
941
1052
  ensureDependencies();
942
1053
  ensureKvNamespace(config);
943
1054
  console.log("Deploying with wrangler...");
944
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
+ }
945
1076
  process.exit(result.status ?? 0);
946
1077
  }
947
1078
  async function runDeploy(args) {
@@ -960,7 +1091,9 @@ function printHelp() {
960
1091
  Usage: flaghoist <command>
961
1092
 
962
1093
  Scaffolding
963
- 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
964
1097
  eject Generate a code project you own (a Worker, or a container)
965
1098
  deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
966
1099
 
package/dist/lib.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Readable, Writable } from 'node:stream';
2
+
1
3
  type StorageKind = 'cloudflare-kv' | 'redis' | 'postgres' | 'sqlite' | 'memory';
2
4
  type AdminAuthKind = 'bearer-token' | 'oidc';
3
5
  /**
@@ -7,6 +9,21 @@ type AdminAuthKind = 'bearer-token' | 'oidc';
7
9
  * this key existed still scaffolds a Worker.
8
10
  */
9
11
  type PlatformKind = 'cloudflare' | 'container';
12
+ /** Single sign-on settings kept in `flaghoist.toml`. The client secret is never stored here. */
13
+ interface SsoSettings {
14
+ issuer: string;
15
+ clientId: string;
16
+ /** Members of this identity-provider group become admins. Everyone else signs in as a viewer. */
17
+ adminGroup?: string;
18
+ }
19
+ /**
20
+ * User accounts. Only the choices live in `flaghoist.toml`; the secrets (`AUTH_PEPPER`, and
21
+ * `SSO_CLIENT_SECRET` with SSO) come from the environment, so the file stays safe to commit.
22
+ */
23
+ interface AccountsSettings {
24
+ enabled: boolean;
25
+ sso?: SsoSettings;
26
+ }
10
27
  interface FlaghoistConfig {
11
28
  name: string;
12
29
  storage: StorageKind;
@@ -19,6 +36,8 @@ interface FlaghoistConfig {
19
36
  allowedOrigins?: string[];
20
37
  /** Serve the admin dashboard at `/admin`. On by default. */
21
38
  dashboard: boolean;
39
+ /** Absent means off, so a config written before accounts existed behaves as it always did. */
40
+ accounts?: AccountsSettings;
22
41
  }
23
42
  declare const DEFAULT_CONFIG: FlaghoistConfig;
24
43
  /** Every storage backend selectable by name in `flaghoist.toml`. */
@@ -46,4 +65,70 @@ declare function parseConfig(text: string): FlaghoistConfig;
46
65
  /** Render a config back to `flaghoist.toml` text. */
47
66
  declare function serializeConfig(config: FlaghoistConfig): string;
48
67
 
49
- export { type AdminAuthKind, type ContainerStorage, DEFAULT_CONFIG, type FlaghoistConfig, PLATFORM_KINDS, type PlatformKind, STORAGE_KINDS, type StorageKind, asContainer, containerStorageDefault, parseConfig, serializeConfig };
68
+ /**
69
+ * The interactive setup behind `npm create flaghoist` and `flaghoist init`. It asks for the choices
70
+ * that shape a project, and writes them to `flaghoist.toml`. Secrets (the accounts pepper and an SSO
71
+ * client secret) never go in that file: they go in a git-ignored `.env`, and `flaghoist deploy`
72
+ * hands them to Cloudflare as Worker secrets.
73
+ */
74
+ /** Everything the wizard asks through, so tests can script the answers. */
75
+ interface Asker {
76
+ text(question: string, fallback: string): Promise<string>;
77
+ choice<T extends string>(question: string, options: Choice<T>[], fallback: T): Promise<T>;
78
+ yesNo(question: string, fallback: boolean): Promise<boolean>;
79
+ secret(question: string): Promise<string>;
80
+ say(line: string): void;
81
+ /** False when nobody can answer, so a required value that is missing is an error, not a loop. */
82
+ interactive?: boolean;
83
+ }
84
+ interface Choice<T extends string> {
85
+ value: T;
86
+ label: string;
87
+ hint?: string;
88
+ }
89
+ /** Answers given up front on the command line. Each one skips its question. */
90
+ interface SetupPreset {
91
+ name?: string;
92
+ platform?: PlatformKind;
93
+ storage?: StorageKind;
94
+ accounts?: boolean;
95
+ dashboard?: boolean;
96
+ sso?: boolean;
97
+ ssoIssuer?: string;
98
+ ssoClientId?: string;
99
+ ssoAdminGroup?: string;
100
+ }
101
+ interface SetupResult {
102
+ config: FlaghoistConfig;
103
+ /** Secrets for `.env`: never written to `flaghoist.toml`. */
104
+ secrets: Record<string, string>;
105
+ /** True when the pepper was generated here rather than pasted in. */
106
+ pepperGenerated: boolean;
107
+ }
108
+ declare const MIN_PEPPER_LENGTH = 32;
109
+ /** The stores each platform can reach. Cloudflare KV only exists on Workers; SQLite needs a disk. */
110
+ declare function storageChoices(platform: PlatformKind): Choice<StorageKind>[];
111
+ declare function generatePepper(): string;
112
+ /** Ask every question the preset does not already answer, and build the project from the answers. */
113
+ declare function collectSetup(asker: Asker, preset?: SetupPreset): Promise<SetupResult>;
114
+ /**
115
+ * Write `flaghoist.toml`, and when there are secrets, a `.env` holding them plus a `.gitignore` that
116
+ * keeps it out of git. An existing `.gitignore` gets `.env` appended rather than replaced.
117
+ */
118
+ declare function writeSetup(dir: string, result: SetupResult): {
119
+ envPath?: string;
120
+ };
121
+ /** Read `KEY=value` lines from a `.env` file. Comments and blank lines are skipped. */
122
+ declare function readEnvFile(path: string): Record<string, string>;
123
+ /** What to tell the person at the end, including the pepper once if it was generated. */
124
+ declare function setupSummary(result: SetupResult, envWritten: boolean): string[];
125
+ /** Ask on a real terminal. Choices are numbered; Enter takes the default shown in brackets. */
126
+ declare function terminalAsker(input?: Readable & {
127
+ isTTY?: boolean;
128
+ }, output?: Writable): Asker & {
129
+ close(): void;
130
+ };
131
+ /** The asker used without a terminal: every question takes its default, and nothing is printed. */
132
+ declare function defaultsAsker(): Asker;
133
+
134
+ export { type AccountsSettings, type AdminAuthKind, type Asker, type Choice, type ContainerStorage, DEFAULT_CONFIG, type FlaghoistConfig, MIN_PEPPER_LENGTH, PLATFORM_KINDS, type PlatformKind, STORAGE_KINDS, type SetupPreset, type SetupResult, type SsoSettings, type StorageKind, asContainer, collectSetup, containerStorageDefault, defaultsAsker, generatePepper, parseConfig, readEnvFile, serializeConfig, setupSummary, storageChoices, terminalAsker, writeSetup };
package/dist/lib.js CHANGED
@@ -1,18 +1,36 @@
1
1
  import {
2
2
  DEFAULT_CONFIG,
3
+ MIN_PEPPER_LENGTH,
3
4
  PLATFORM_KINDS,
4
5
  STORAGE_KINDS,
5
6
  asContainer,
7
+ collectSetup,
6
8
  containerStorageDefault,
9
+ defaultsAsker,
10
+ generatePepper,
7
11
  parseConfig,
8
- serializeConfig
9
- } from "./chunk-HSDDPXCM.js";
12
+ readEnvFile,
13
+ serializeConfig,
14
+ setupSummary,
15
+ storageChoices,
16
+ terminalAsker,
17
+ writeSetup
18
+ } from "./chunk-LZB44MXJ.js";
10
19
  export {
11
20
  DEFAULT_CONFIG,
21
+ MIN_PEPPER_LENGTH,
12
22
  PLATFORM_KINDS,
13
23
  STORAGE_KINDS,
14
24
  asContainer,
25
+ collectSetup,
15
26
  containerStorageDefault,
27
+ defaultsAsker,
28
+ generatePepper,
16
29
  parseConfig,
17
- serializeConfig
30
+ readEnvFile,
31
+ serializeConfig,
32
+ setupSummary,
33
+ storageChoices,
34
+ terminalAsker,
35
+ writeSetup
18
36
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flaghoist",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -27,9 +27,9 @@
27
27
  "@flaghoist/admin-client": "0.3.0"
28
28
  },
29
29
  "devDependencies": {
30
+ "@flaghoist/server": "0.4.0",
30
31
  "@flaghoist/adapter-memory": "0.2.0",
31
- "@flaghoist/core": "0.2.0",
32
- "@flaghoist/server": "0.4.0"
32
+ "@flaghoist/core": "0.2.0"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -1,67 +0,0 @@
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
- return { name, storage, platform, auth: { admin, read: "api-key" }, allowedOrigins, dashboard };
34
- }
35
- function serializeConfig(config) {
36
- const lines = [
37
- `name = ${JSON.stringify(config.name)}`,
38
- `storage = ${JSON.stringify(config.storage)}`
39
- ];
40
- if (config.platform !== "cloudflare") {
41
- lines.push(`platform = ${JSON.stringify(config.platform)}`);
42
- }
43
- if (config.allowedOrigins && config.allowedOrigins.length > 0) {
44
- lines.push(`allowedOrigins = ${JSON.stringify(config.allowedOrigins)}`);
45
- }
46
- if (!config.dashboard) {
47
- lines.push("dashboard = false");
48
- }
49
- lines.push(
50
- "",
51
- "[auth]",
52
- `admin = ${JSON.stringify(config.auth.admin)}`,
53
- `read = ${JSON.stringify(config.auth.read)}`
54
- );
55
- return `${lines.join("\n")}
56
- `;
57
- }
58
-
59
- export {
60
- DEFAULT_CONFIG,
61
- STORAGE_KINDS,
62
- PLATFORM_KINDS,
63
- containerStorageDefault,
64
- asContainer,
65
- parseConfig,
66
- serializeConfig
67
- };