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 +14 -1
- package/dist/chunk-TQ5S6NOA.js +391 -0
- package/dist/index.js +709 -165
- package/dist/lib.d.ts +86 -1
- package/dist/lib.js +21 -3
- package/package.json +5 -5
- package/dist/chunk-HSDDPXCM.js +0 -67
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
|
-
|
|
10
|
-
|
|
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 {
|
|
16
|
-
import {
|
|
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.
|
|
31
|
-
"@flaghoist/adapter-postgres": "^0.
|
|
32
|
-
"@flaghoist/adapter-redis": "^0.
|
|
33
|
-
"@flaghoist/adapter-sqlite": "^0.
|
|
34
|
-
"@flaghoist/server": "^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
|
|
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: ${
|
|
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: {
|
|
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 [
|
|
161
|
-
"
|
|
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/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
|
259
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
300
|
-
|
|
667
|
+
mkdirSync2(dirname2(path) || ".", { recursive: true });
|
|
668
|
+
writeFileSync2(path, content);
|
|
301
669
|
}
|
|
302
670
|
function loadConfig() {
|
|
303
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
313
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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) =>
|
|
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(
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
1020
|
+
writeFileSync2("flaghoist.toml", serializeConfig(container));
|
|
526
1021
|
}
|
|
527
|
-
if (!
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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":
|