flaghoist 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/{chunk-HOSNPNBP.js → chunk-HSDDPXCM.js} +1 -0
- package/dist/index.js +459 -100
- package/dist/lib.d.ts +2 -2
- package/dist/lib.js +1 -1
- package/package.json +6 -5
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
|
package/dist/index.js
CHANGED
|
@@ -7,97 +7,34 @@ import {
|
|
|
7
7
|
containerStorageDefault,
|
|
8
8
|
parseConfig,
|
|
9
9
|
serializeConfig
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-HSDDPXCM.js";
|
|
11
11
|
|
|
12
12
|
// src/index.ts
|
|
13
13
|
import { spawnSync } from "child_process";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
15
|
+
import { hostname } from "os";
|
|
16
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
17
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
17
18
|
import { parseArgs } from "util";
|
|
18
19
|
|
|
19
20
|
// src/admin.ts
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const path = (key) => `${api}/flags/${encodeURIComponent(key)}`;
|
|
28
|
-
return {
|
|
29
|
-
async list() {
|
|
30
|
-
const res = await doFetch(`${api}/flags`, { headers });
|
|
31
|
-
if (!res.ok) throw new Error(`Failed to list flags (${res.status})`);
|
|
32
|
-
return (await res.json()).flags;
|
|
33
|
-
},
|
|
34
|
-
async get(key) {
|
|
35
|
-
const res = await doFetch(path(key), { headers });
|
|
36
|
-
if (res.status === 404) return null;
|
|
37
|
-
if (!res.ok) throw new Error(`Failed to read flag "${key}" (${res.status})`);
|
|
38
|
-
return await res.json();
|
|
39
|
-
},
|
|
40
|
-
async put(key, input) {
|
|
41
|
-
const res = await doFetch(path(key), { method: "PUT", headers, body: JSON.stringify(input) });
|
|
42
|
-
if (!res.ok)
|
|
43
|
-
throw new Error(`Failed to save flag "${key}" (${res.status}): ${await res.text()}`);
|
|
44
|
-
return await res.json();
|
|
45
|
-
},
|
|
46
|
-
async delete(key) {
|
|
47
|
-
const res = await doFetch(path(key), { method: "DELETE", headers });
|
|
48
|
-
if (!res.ok && res.status !== 404)
|
|
49
|
-
throw new Error(`Failed to delete flag "${key}" (${res.status})`);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
function requireFlag(flag, key) {
|
|
54
|
-
if (!flag) throw new Error(`Flag "${key}" not found`);
|
|
55
|
-
return flag;
|
|
56
|
-
}
|
|
57
|
-
function createFlag(client, key, opts) {
|
|
58
|
-
return client.put(key, {
|
|
59
|
-
enabled: opts.enabled ?? false,
|
|
60
|
-
rollout: { percentage: opts.percentage ?? 0 },
|
|
61
|
-
rules: [],
|
|
62
|
-
description: opts.description ?? ""
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
async function toggleFlag(client, key, to) {
|
|
66
|
-
const flag = requireFlag(await client.get(key), key);
|
|
67
|
-
const enabled = to === "flip" ? !flag.enabled : to;
|
|
68
|
-
return client.put(key, {
|
|
69
|
-
enabled,
|
|
70
|
-
rollout: flag.rollout,
|
|
71
|
-
rules: flag.rules,
|
|
72
|
-
description: flag.description
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
async function setRollout(client, key, percentage) {
|
|
76
|
-
const flag = requireFlag(await client.get(key), key);
|
|
77
|
-
return client.put(key, {
|
|
78
|
-
enabled: flag.enabled,
|
|
79
|
-
rollout: { percentage },
|
|
80
|
-
rules: flag.rules,
|
|
81
|
-
description: flag.description
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
async function setRules(client, key, rules) {
|
|
85
|
-
const flag = requireFlag(await client.get(key), key);
|
|
86
|
-
return client.put(key, {
|
|
87
|
-
enabled: flag.enabled,
|
|
88
|
-
rollout: flag.rollout,
|
|
89
|
-
rules,
|
|
90
|
-
description: flag.description
|
|
91
|
-
});
|
|
92
|
-
}
|
|
21
|
+
import {
|
|
22
|
+
createAdminClient,
|
|
23
|
+
createFlag,
|
|
24
|
+
setRollout,
|
|
25
|
+
setRules,
|
|
26
|
+
toggleFlag
|
|
27
|
+
} from "@flaghoist/admin-client";
|
|
93
28
|
|
|
94
29
|
// src/generate-container.ts
|
|
95
30
|
var CONTAINER_DEPS = {
|
|
96
31
|
"@flaghoist/adapter-memory": "^0.1.2",
|
|
97
32
|
"@flaghoist/adapter-postgres": "^0.1.2",
|
|
98
33
|
"@flaghoist/adapter-redis": "^0.1.2",
|
|
34
|
+
"@flaghoist/adapter-sqlite": "^0.1.0",
|
|
99
35
|
"@flaghoist/server": "^0.3.0",
|
|
100
36
|
"@hono/node-server": "^2.1.1",
|
|
37
|
+
"better-sqlite3": "^11.0.0",
|
|
101
38
|
ioredis: "^5.4.0",
|
|
102
39
|
pg: "^8.13.0"
|
|
103
40
|
};
|
|
@@ -117,6 +54,7 @@ function generateNodeEntry(config) {
|
|
|
117
54
|
`import { memoryAdapter } from '@flaghoist/adapter-memory'`,
|
|
118
55
|
`import { initPostgres, postgresAdapter } from '@flaghoist/adapter-postgres'`,
|
|
119
56
|
`import { redisAdapter } from '@flaghoist/adapter-redis'`,
|
|
57
|
+
`import { initSqlite, sqliteAdapter } from '@flaghoist/adapter-sqlite'`,
|
|
120
58
|
`import { ${serverImports.join(", ")} } from '@flaghoist/server'`,
|
|
121
59
|
...config.dashboard ? [`import { dashboardHtml } from '@flaghoist/server/dashboard'`] : [],
|
|
122
60
|
`import { serve } from '@hono/node-server'`
|
|
@@ -150,12 +88,19 @@ async function makeStorage() {
|
|
|
150
88
|
return redisAdapter(new Redis(env.REDIS_URL), { hashKey: env.FLAGS_HASH_KEY ?? 'flaghoist:flags' })
|
|
151
89
|
}
|
|
152
90
|
|
|
91
|
+
if (kind === 'sqlite') {
|
|
92
|
+
const { default: Database } = await import('better-sqlite3')
|
|
93
|
+
const db = new Database(env.DATABASE_PATH ?? '/data/flags.db')
|
|
94
|
+
initSqlite(db)
|
|
95
|
+
return sqliteAdapter(db)
|
|
96
|
+
}
|
|
97
|
+
|
|
153
98
|
if (kind === 'memory') {
|
|
154
99
|
console.warn('[flaghoist] FLAGS_STORAGE=memory: flags are in-process and are lost on restart.')
|
|
155
100
|
return memoryAdapter()
|
|
156
101
|
}
|
|
157
102
|
|
|
158
|
-
throw new Error(\`Unknown FLAGS_STORAGE "\${kind}". Use postgres, redis, or memory.\`)
|
|
103
|
+
throw new Error(\`Unknown FLAGS_STORAGE "\${kind}". Use postgres, redis, sqlite, or memory.\`)
|
|
159
104
|
}
|
|
160
105
|
|
|
161
106
|
const app = createFlagServer({
|
|
@@ -253,6 +198,10 @@ function storageSnippet(storage) {
|
|
|
253
198
|
expr: "memoryAdapter()",
|
|
254
199
|
pkg: "@flaghoist/adapter-memory"
|
|
255
200
|
};
|
|
201
|
+
case "sqlite":
|
|
202
|
+
throw new Error(
|
|
203
|
+
'SQLite storage requires a Node or container deployment. Use `npx flaghoist deploy` and pick "Another platform".'
|
|
204
|
+
);
|
|
256
205
|
}
|
|
257
206
|
}
|
|
258
207
|
function adminExpr2(admin) {
|
|
@@ -342,27 +291,402 @@ function generatePackageJson(config) {
|
|
|
342
291
|
`;
|
|
343
292
|
}
|
|
344
293
|
|
|
294
|
+
// src/credentials.ts
|
|
295
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
296
|
+
import { homedir } from "os";
|
|
297
|
+
import { dirname, join } from "path";
|
|
298
|
+
function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
|
|
299
|
+
const dir = env.FLAGHOIST_CONFIG_DIR ?? (platform === "win32" && env.APPDATA ? join(env.APPDATA, "flaghoist") : join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "flaghoist"));
|
|
300
|
+
return join(dir, "credentials.json");
|
|
301
|
+
}
|
|
302
|
+
function serverKey(url) {
|
|
303
|
+
return url.trim().replace(/\/+$/, "");
|
|
304
|
+
}
|
|
305
|
+
function read(path) {
|
|
306
|
+
if (!existsSync(path)) return { servers: {} };
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
309
|
+
return { servers: parsed.servers && typeof parsed.servers === "object" ? parsed.servers : {} };
|
|
310
|
+
} catch {
|
|
311
|
+
throw new Error(`Could not read ${path}. Fix or delete it, then sign in again.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function write(path, file) {
|
|
315
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
316
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}
|
|
317
|
+
`, { mode: 384 });
|
|
318
|
+
chmodSync(path, 384);
|
|
319
|
+
}
|
|
320
|
+
function savedCredential(path, url) {
|
|
321
|
+
return read(path).servers[serverKey(url)] ?? null;
|
|
322
|
+
}
|
|
323
|
+
function savedServers(path) {
|
|
324
|
+
return Object.keys(read(path).servers);
|
|
325
|
+
}
|
|
326
|
+
function saveCredential(path, url, credential) {
|
|
327
|
+
const file = read(path);
|
|
328
|
+
file.servers[serverKey(url)] = credential;
|
|
329
|
+
write(path, file);
|
|
330
|
+
}
|
|
331
|
+
function removeCredential(path, url) {
|
|
332
|
+
const file = read(path);
|
|
333
|
+
const key = serverKey(url);
|
|
334
|
+
if (!file.servers[key]) return false;
|
|
335
|
+
delete file.servers[key];
|
|
336
|
+
write(path, file);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/prompt.ts
|
|
341
|
+
import { createInterface } from "readline/promises";
|
|
342
|
+
async function readPassword(options) {
|
|
343
|
+
const input = options.input ?? process.stdin;
|
|
344
|
+
const output = options.output ?? process.stdout;
|
|
345
|
+
if (options.fromStdin) {
|
|
346
|
+
const chunks = [];
|
|
347
|
+
for await (const chunk of input) chunks.push(Buffer.from(chunk));
|
|
348
|
+
const password = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
349
|
+
if (!password) throw new Error("--password-stdin was set but nothing was piped in.");
|
|
350
|
+
return password;
|
|
351
|
+
}
|
|
352
|
+
if (!input.isTTY) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
"No terminal to ask for a password in. Run this in a terminal, or pipe the password in with --password-stdin."
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
358
|
+
output.write("Password: ");
|
|
359
|
+
rl._writeToOutput = () => {
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
return await rl.question("");
|
|
363
|
+
} finally {
|
|
364
|
+
rl.close();
|
|
365
|
+
output.write("\n");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/tokens.ts
|
|
370
|
+
import {
|
|
371
|
+
createAdminClient as createAdminClient2,
|
|
372
|
+
createAuthClient,
|
|
373
|
+
isTwoFactorChallenge
|
|
374
|
+
} from "@flaghoist/admin-client";
|
|
375
|
+
var ROLES = ["viewer", "editor", "admin", "owner"];
|
|
376
|
+
var TOKENS_USAGE = ` tokens list
|
|
377
|
+
tokens create <name> [--role <role>] [--expires-days N|never]
|
|
378
|
+
tokens revoke <id or name>`;
|
|
379
|
+
function when(iso) {
|
|
380
|
+
return iso ? iso.slice(0, 10) : "never";
|
|
381
|
+
}
|
|
382
|
+
function parseExpiry(value) {
|
|
383
|
+
if (value === void 0) return void 0;
|
|
384
|
+
if (value === "never") return null;
|
|
385
|
+
const days = Number(value);
|
|
386
|
+
if (!Number.isInteger(days) || days < 1 || days > 3650) {
|
|
387
|
+
throw new Error('--expires-days must be a whole number from 1 to 3650, or "never".');
|
|
388
|
+
}
|
|
389
|
+
return days;
|
|
390
|
+
}
|
|
391
|
+
function findToken(tokens, ref) {
|
|
392
|
+
const byId = tokens.find((t) => t.id === ref);
|
|
393
|
+
if (byId) return byId;
|
|
394
|
+
const byName = tokens.filter((t) => t.name === ref);
|
|
395
|
+
if (byName.length === 1) return byName[0];
|
|
396
|
+
if (byName.length > 1) throw new Error(`More than one token is named "${ref}". Use its id.`);
|
|
397
|
+
throw new Error(`No token with the id or name "${ref}".`);
|
|
398
|
+
}
|
|
399
|
+
async function runTokens(client, positionals, options) {
|
|
400
|
+
const [sub, a] = positionals;
|
|
401
|
+
switch (sub) {
|
|
402
|
+
case "list": {
|
|
403
|
+
const tokens = await client.listTokens();
|
|
404
|
+
if (tokens.length === 0) return ["No access tokens."];
|
|
405
|
+
return tokens.map(
|
|
406
|
+
(t) => `${t.id} ${t.name.padEnd(24)} ${t.role.padEnd(7)} ${t.prefix}... expires ${when(
|
|
407
|
+
t.expiresAt
|
|
408
|
+
)} last used ${when(t.lastUsedAt)}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
case "create": {
|
|
412
|
+
if (!a) throw new Error("Usage: flaghoist tokens create <name> [--role r] [--expires-days N]");
|
|
413
|
+
if (options.role && !ROLES.includes(options.role)) {
|
|
414
|
+
throw new Error(`Role must be one of ${ROLES.join(", ")}.`);
|
|
415
|
+
}
|
|
416
|
+
const { token, info } = await client.createToken({
|
|
417
|
+
name: a,
|
|
418
|
+
...options.role ? { role: options.role } : {},
|
|
419
|
+
...options.expiresDays !== void 0 ? { expiresInDays: parseExpiry(options.expiresDays) } : {}
|
|
420
|
+
});
|
|
421
|
+
return [
|
|
422
|
+
`Created "${info.name}" (${info.role}, expires ${when(info.expiresAt)}). Copy it now; it is not shown again:`,
|
|
423
|
+
token
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
case "revoke": {
|
|
427
|
+
if (!a) throw new Error("Usage: flaghoist tokens revoke <id or name>");
|
|
428
|
+
const token = findToken(await client.listTokens(), a);
|
|
429
|
+
await client.revokeToken(token.id);
|
|
430
|
+
return [`Revoked "${token.name}".`];
|
|
431
|
+
}
|
|
432
|
+
default:
|
|
433
|
+
throw new Error(`Unknown tokens command: ${sub ?? "(none)"}. Try "flaghoist tokens list".`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function loginForToken(input) {
|
|
437
|
+
const auth = createAuthClient({ url: input.url, fetch: input.fetch });
|
|
438
|
+
const config = await auth.config();
|
|
439
|
+
if (!config.accounts) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
"This server has no user accounts. Use its admin token with --token or FLAGS_ADMIN_TOKEN."
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const first = await auth.signIn(input.email, input.password);
|
|
445
|
+
let signedIn;
|
|
446
|
+
if (isTwoFactorChallenge(first)) {
|
|
447
|
+
if (!input.twoFactorCode) {
|
|
448
|
+
throw new Error("This account uses two-factor codes. Pass the current one with --code.");
|
|
449
|
+
}
|
|
450
|
+
signedIn = await auth.completeTwoFactor(first.challenge, await input.twoFactorCode());
|
|
451
|
+
} else {
|
|
452
|
+
signedIn = first;
|
|
453
|
+
}
|
|
454
|
+
const session = createAdminClient2({ url: input.url, token: signedIn.token, fetch: input.fetch });
|
|
455
|
+
try {
|
|
456
|
+
const { token, info } = await session.createToken({ name: input.tokenName });
|
|
457
|
+
return {
|
|
458
|
+
token,
|
|
459
|
+
tokenId: info.id,
|
|
460
|
+
email: signedIn.user.email,
|
|
461
|
+
role: info.role,
|
|
462
|
+
expiresAt: info.expiresAt
|
|
463
|
+
};
|
|
464
|
+
} finally {
|
|
465
|
+
await session.logout().catch(() => {
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/users.ts
|
|
471
|
+
import { inviteLink } from "@flaghoist/admin-client";
|
|
472
|
+
var ROLES2 = ["viewer", "editor", "admin", "owner"];
|
|
473
|
+
var USERS_USAGE = ` users list
|
|
474
|
+
users invite <email> [--role viewer|editor|admin|owner]
|
|
475
|
+
users role <email> <role> [--env E] With --env, the role in one environment; "default" clears it
|
|
476
|
+
users disable <email>
|
|
477
|
+
users enable <email>
|
|
478
|
+
users remove <email>
|
|
479
|
+
users reset <email> Print a link that sets a new password (valid 24 hours)
|
|
480
|
+
users revoke <email> Cancel an open invite`;
|
|
481
|
+
async function memberByEmail(client, email) {
|
|
482
|
+
const wanted = email.trim().toLowerCase();
|
|
483
|
+
const member = (await client.listMembers()).find((m) => m.email === wanted);
|
|
484
|
+
if (!member) throw new Error(`No member with the email ${wanted}.`);
|
|
485
|
+
return member;
|
|
486
|
+
}
|
|
487
|
+
function requireRole(role) {
|
|
488
|
+
if (!role || !ROLES2.includes(role)) throw new Error(`Role must be one of ${ROLES2.join(", ")}.`);
|
|
489
|
+
return role;
|
|
490
|
+
}
|
|
491
|
+
function when2(iso) {
|
|
492
|
+
return iso ? iso.slice(0, 16).replace("T", " ") : "never";
|
|
493
|
+
}
|
|
494
|
+
async function runUsers(client, serverUrl, positionals, options) {
|
|
495
|
+
const [sub, a, b] = positionals;
|
|
496
|
+
const dashboard = `${serverUrl.replace(/\/+$/, "")}/admin/`;
|
|
497
|
+
switch (sub) {
|
|
498
|
+
case "list": {
|
|
499
|
+
const [members, invites] = await Promise.all([client.listMembers(), client.listInvites()]);
|
|
500
|
+
const lines = members.map(
|
|
501
|
+
(m) => `${m.email.padEnd(32)} ${m.role.padEnd(8)} ${m.status.padEnd(9)} last active ${when2(m.lastActiveAt)}`
|
|
502
|
+
);
|
|
503
|
+
for (const i of invites) {
|
|
504
|
+
lines.push(
|
|
505
|
+
`${i.email.padEnd(32)} ${i.role.padEnd(8)} invited expires ${when2(i.expiresAt)}`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return lines.length > 0 ? lines : ["No members yet."];
|
|
509
|
+
}
|
|
510
|
+
case "invite": {
|
|
511
|
+
if (!a) throw new Error("Usage: flaghoist users invite <email> [--role viewer]");
|
|
512
|
+
const role = requireRole(options.role ?? "viewer");
|
|
513
|
+
const { token, invite, emailed } = await client.createInvite({
|
|
514
|
+
email: a,
|
|
515
|
+
role,
|
|
516
|
+
dashboardUrl: dashboard
|
|
517
|
+
});
|
|
518
|
+
return [
|
|
519
|
+
emailed ? `Invited ${invite.email} as ${invite.role} and emailed them the link (valid until ${when2(invite.expiresAt)}):` : `Invited ${invite.email} as ${invite.role}. Send them this link (valid until ${when2(invite.expiresAt)}):`,
|
|
520
|
+
inviteLink(dashboard, token)
|
|
521
|
+
];
|
|
522
|
+
}
|
|
523
|
+
case "role": {
|
|
524
|
+
if (!a || !b) throw new Error("Usage: flaghoist users role <email> <role> [--env E]");
|
|
525
|
+
const member = await memberByEmail(client, a);
|
|
526
|
+
if (options.env) {
|
|
527
|
+
const clear = b === "default";
|
|
528
|
+
if (!clear && (!ROLES2.includes(b) || b === "owner")) {
|
|
529
|
+
throw new Error('An environment role must be viewer, editor or admin, or "default".');
|
|
530
|
+
}
|
|
531
|
+
const updated2 = await client.updateMember(member.id, {
|
|
532
|
+
environmentRoles: { ...member.environmentRoles, [options.env]: clear ? null : b }
|
|
533
|
+
});
|
|
534
|
+
const now = updated2.environmentRoles?.[options.env];
|
|
535
|
+
return [
|
|
536
|
+
now ? `${updated2.email} is ${now} in ${options.env}.` : `${updated2.email} has their main role (${updated2.role}) in ${options.env}.`
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
const updated = await client.updateMember(member.id, { role: requireRole(b) });
|
|
540
|
+
return [`${updated.email} is now ${updated.role}.`];
|
|
541
|
+
}
|
|
542
|
+
case "disable":
|
|
543
|
+
case "enable": {
|
|
544
|
+
if (!a) throw new Error(`Usage: flaghoist users ${sub} <email>`);
|
|
545
|
+
const member = await memberByEmail(client, a);
|
|
546
|
+
const status = sub === "disable" ? "disabled" : "active";
|
|
547
|
+
await client.updateMember(member.id, { status });
|
|
548
|
+
return [`${member.email} is ${sub === "disable" ? "disabled and signed out" : "enabled"}.`];
|
|
549
|
+
}
|
|
550
|
+
case "remove": {
|
|
551
|
+
if (!a) throw new Error("Usage: flaghoist users remove <email>");
|
|
552
|
+
const member = await memberByEmail(client, a);
|
|
553
|
+
await client.removeMember(member.id);
|
|
554
|
+
return [`Removed ${member.email}.`];
|
|
555
|
+
}
|
|
556
|
+
case "reset": {
|
|
557
|
+
if (!a) throw new Error("Usage: flaghoist users reset <email>");
|
|
558
|
+
const member = await memberByEmail(client, a);
|
|
559
|
+
const { token, invite, emailed } = await client.createResetLink(member.id, {
|
|
560
|
+
dashboardUrl: dashboard
|
|
561
|
+
});
|
|
562
|
+
return [
|
|
563
|
+
emailed ? `Emailed ${member.email} a link to set a new password (valid until ${when2(invite.expiresAt)}):` : `Send ${member.email} this link to set a new password (valid until ${when2(invite.expiresAt)}):`,
|
|
564
|
+
inviteLink(dashboard, token)
|
|
565
|
+
];
|
|
566
|
+
}
|
|
567
|
+
case "revoke": {
|
|
568
|
+
if (!a) throw new Error("Usage: flaghoist users revoke <email>");
|
|
569
|
+
const wanted = a.trim().toLowerCase();
|
|
570
|
+
const invite = (await client.listInvites()).find((i) => i.email === wanted);
|
|
571
|
+
if (!invite) throw new Error(`No open invite for ${wanted}.`);
|
|
572
|
+
await client.revokeInvite(invite.id);
|
|
573
|
+
return [`Cancelled the invite for ${wanted}.`];
|
|
574
|
+
}
|
|
575
|
+
default:
|
|
576
|
+
throw new Error(`Unknown users command: ${sub ?? "(none)"}. Try "flaghoist users list".`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
345
580
|
// src/version.ts
|
|
346
581
|
import { createRequire } from "module";
|
|
347
582
|
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
348
583
|
|
|
349
584
|
// src/index.ts
|
|
350
585
|
function writeFileSafe(path, content) {
|
|
351
|
-
|
|
352
|
-
|
|
586
|
+
mkdirSync2(dirname2(path) || ".", { recursive: true });
|
|
587
|
+
writeFileSync2(path, content);
|
|
353
588
|
}
|
|
354
589
|
function loadConfig() {
|
|
355
|
-
if (!
|
|
590
|
+
if (!existsSync2("flaghoist.toml")) {
|
|
356
591
|
throw new Error("No flaghoist.toml found in this directory. Run `flaghoist init` first.");
|
|
357
592
|
}
|
|
358
|
-
return parseConfig(
|
|
593
|
+
return parseConfig(readFileSync2("flaghoist.toml", "utf8"));
|
|
594
|
+
}
|
|
595
|
+
function serverFrom(values) {
|
|
596
|
+
const url = values.url ?? process.env.FLAGS_URL;
|
|
597
|
+
if (url) return url;
|
|
598
|
+
const saved = savedServers(credentialsPath());
|
|
599
|
+
if (saved.length === 1) return saved[0];
|
|
600
|
+
throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
|
|
359
601
|
}
|
|
360
602
|
function clientFrom(values) {
|
|
603
|
+
const url = serverFrom(values);
|
|
604
|
+
const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN ?? savedCredential(credentialsPath(), url)?.token;
|
|
605
|
+
if (!token) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
"Not signed in. Run `flaghoist login`, or pass --token or set FLAGS_ADMIN_TOKEN."
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return createAdminClient({ url, token });
|
|
611
|
+
}
|
|
612
|
+
async function runLogin(args) {
|
|
613
|
+
const { values } = parseArgs({
|
|
614
|
+
args,
|
|
615
|
+
options: {
|
|
616
|
+
url: { type: "string" },
|
|
617
|
+
email: { type: "string" },
|
|
618
|
+
"password-stdin": { type: "boolean" },
|
|
619
|
+
code: { type: "string" }
|
|
620
|
+
}
|
|
621
|
+
});
|
|
361
622
|
const url = values.url ?? process.env.FLAGS_URL;
|
|
362
|
-
const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN;
|
|
363
623
|
if (!url) throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
|
|
364
|
-
|
|
365
|
-
|
|
624
|
+
let email = values.email;
|
|
625
|
+
if (!email) {
|
|
626
|
+
if (!process.stdin.isTTY) throw new Error("Pass your email with --email.");
|
|
627
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
628
|
+
email = (await rl.question("Email: ")).trim();
|
|
629
|
+
rl.close();
|
|
630
|
+
}
|
|
631
|
+
const password = await readPassword({ fromStdin: values["password-stdin"] === true });
|
|
632
|
+
console.log(`Signing in to ${url} as ${email}...`);
|
|
633
|
+
const result = await loginForToken({
|
|
634
|
+
url,
|
|
635
|
+
email,
|
|
636
|
+
password,
|
|
637
|
+
tokenName: `flaghoist CLI on ${hostname()}`,
|
|
638
|
+
twoFactorCode: async () => {
|
|
639
|
+
if (values.code) return values.code;
|
|
640
|
+
if (!process.stdin.isTTY) {
|
|
641
|
+
throw new Error("This account uses two-factor codes. Pass the current one with --code.");
|
|
642
|
+
}
|
|
643
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
644
|
+
try {
|
|
645
|
+
return (await rl.question("Two-factor code (or a recovery code): ")).trim();
|
|
646
|
+
} finally {
|
|
647
|
+
rl.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
const path = credentialsPath();
|
|
652
|
+
saveCredential(path, url, {
|
|
653
|
+
token: result.token,
|
|
654
|
+
tokenId: result.tokenId,
|
|
655
|
+
email: result.email,
|
|
656
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
657
|
+
});
|
|
658
|
+
console.log(
|
|
659
|
+
`Signed in to ${url} as ${result.email} (${result.role}). The access token expires ${result.expiresAt?.slice(0, 10) ?? "never"} and is saved in ${path}.`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
async function runLogout(args) {
|
|
663
|
+
const { values } = parseArgs({ args, options: { url: { type: "string" } } });
|
|
664
|
+
const url = serverFrom(values);
|
|
665
|
+
const path = credentialsPath();
|
|
666
|
+
const saved = savedCredential(path, url);
|
|
667
|
+
if (!saved) return console.log(`Not signed in to ${url}.`);
|
|
668
|
+
const revoked = await createAdminClient({ url, token: saved.token }).logout().then(() => true).catch(() => false);
|
|
669
|
+
removeCredential(path, url);
|
|
670
|
+
console.log(
|
|
671
|
+
revoked ? `Signed out of ${url}. The access token is revoked.` : `Removed the saved token for ${url}. The server could not be reached to revoke it; revoke it from the dashboard's Account page.`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
async function runTokensCommand(args) {
|
|
675
|
+
const { values, positionals } = parseArgs({
|
|
676
|
+
args,
|
|
677
|
+
allowPositionals: true,
|
|
678
|
+
options: {
|
|
679
|
+
url: { type: "string" },
|
|
680
|
+
token: { type: "string" },
|
|
681
|
+
role: { type: "string" },
|
|
682
|
+
"expires-days": { type: "string" }
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
const lines = await runTokens(clientFrom(values), positionals, {
|
|
686
|
+
role: values.role,
|
|
687
|
+
expiresDays: values["expires-days"]
|
|
688
|
+
});
|
|
689
|
+
for (const line of lines) console.log(line);
|
|
366
690
|
}
|
|
367
691
|
function flagState(flag) {
|
|
368
692
|
if (!flag.enabled) return "disabled";
|
|
@@ -433,7 +757,7 @@ async function runFlag(args) {
|
|
|
433
757
|
if (a !== "set" || !b)
|
|
434
758
|
throw new Error("Usage: flaghoist flag rules set <key> --file rules.json");
|
|
435
759
|
if (!values.file) throw new Error("Missing --file <rules.json>");
|
|
436
|
-
const rules = JSON.parse(
|
|
760
|
+
const rules = JSON.parse(readFileSync2(values.file, "utf8"));
|
|
437
761
|
if (!Array.isArray(rules)) throw new Error("Rules file must contain a JSON array");
|
|
438
762
|
const flag = await setRules(clientFrom(values), b, rules);
|
|
439
763
|
console.log(`Updated rules for "${b}"`);
|
|
@@ -443,6 +767,24 @@ async function runFlag(args) {
|
|
|
443
767
|
throw new Error(`Unknown flag command: ${sub ?? "(none)"}. Try "flaghoist flag list".`);
|
|
444
768
|
}
|
|
445
769
|
}
|
|
770
|
+
async function runUsersCommand(args) {
|
|
771
|
+
const { values, positionals } = parseArgs({
|
|
772
|
+
args,
|
|
773
|
+
allowPositionals: true,
|
|
774
|
+
options: {
|
|
775
|
+
url: { type: "string" },
|
|
776
|
+
token: { type: "string" },
|
|
777
|
+
role: { type: "string" },
|
|
778
|
+
env: { type: "string" }
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
const client = clientFrom(values);
|
|
782
|
+
const url = serverFrom(values);
|
|
783
|
+
const options = { role: values.role, env: values.env };
|
|
784
|
+
for (const line of await runUsers(client, url, positionals, options)) {
|
|
785
|
+
console.log(line);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
446
788
|
function runInit(args) {
|
|
447
789
|
const { values } = parseArgs({
|
|
448
790
|
args,
|
|
@@ -453,7 +795,7 @@ function runInit(args) {
|
|
|
453
795
|
platform: { type: "string" }
|
|
454
796
|
}
|
|
455
797
|
});
|
|
456
|
-
if (
|
|
798
|
+
if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
|
|
457
799
|
if (values.storage && !STORAGE_KINDS.includes(values.storage)) {
|
|
458
800
|
throw new Error(`Unknown storage "${values.storage}". One of: ${STORAGE_KINDS.join(", ")}.`);
|
|
459
801
|
}
|
|
@@ -466,7 +808,7 @@ function runInit(args) {
|
|
|
466
808
|
storage: values.storage ?? DEFAULT_CONFIG.storage
|
|
467
809
|
};
|
|
468
810
|
const config = values.platform === "container" ? asContainer(base) : base;
|
|
469
|
-
|
|
811
|
+
writeFileSync2("flaghoist.toml", serializeConfig(config));
|
|
470
812
|
console.log("Created flaghoist.toml");
|
|
471
813
|
console.log("Next: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
|
|
472
814
|
}
|
|
@@ -490,7 +832,7 @@ function projectFiles(config) {
|
|
|
490
832
|
}
|
|
491
833
|
function writeProject(config, dir) {
|
|
492
834
|
const files = projectFiles(config);
|
|
493
|
-
const existing = files.map(([name]) => name).filter((name) =>
|
|
835
|
+
const existing = files.map(([name]) => name).filter((name) => existsSync2(join2(dir, name)));
|
|
494
836
|
if (existing.length > 0) {
|
|
495
837
|
throw new Error(
|
|
496
838
|
`Refusing to overwrite ${existing.join(", ")} in this directory.
|
|
@@ -498,12 +840,12 @@ Flaghoist deploys as its own service, so give it a directory of its own:
|
|
|
498
840
|
npm create flaghoist@latest team-flags`
|
|
499
841
|
);
|
|
500
842
|
}
|
|
501
|
-
for (const [name, contents] of files) writeFileSafe(
|
|
843
|
+
for (const [name, contents] of files) writeFileSafe(join2(dir, name), contents);
|
|
502
844
|
}
|
|
503
845
|
function runEject() {
|
|
504
846
|
const config = loadConfig();
|
|
505
847
|
const entry = entryFile(config.platform);
|
|
506
|
-
if (
|
|
848
|
+
if (existsSync2(entry)) throw new Error(`${entry} already exists. Already ejected?`);
|
|
507
849
|
writeProject(config, ".");
|
|
508
850
|
if (config.platform === "container") {
|
|
509
851
|
console.log("Ejected to a code project you own: server.mjs, Dockerfile, package.json");
|
|
@@ -520,7 +862,7 @@ function runEject() {
|
|
|
520
862
|
}
|
|
521
863
|
function ensureKvNamespace(config) {
|
|
522
864
|
if (config.storage !== "cloudflare-kv") return;
|
|
523
|
-
const toml =
|
|
865
|
+
const toml = readFileSync2("wrangler.toml", "utf8");
|
|
524
866
|
if (!needsKvNamespace(toml)) return;
|
|
525
867
|
const title = `${config.name}-FLAGS`;
|
|
526
868
|
console.log(`Creating the ${title} KV namespace...`);
|
|
@@ -541,11 +883,11 @@ function ensureKvNamespace(config) {
|
|
|
541
883
|
"Created the namespace but could not read its id from wrangler output. Paste the id above into wrangler.toml, then run `flaghoist deploy` again."
|
|
542
884
|
);
|
|
543
885
|
}
|
|
544
|
-
|
|
886
|
+
writeFileSync2("wrangler.toml", fillKvNamespaceId(toml, id));
|
|
545
887
|
console.log(`Bound FLAGS to namespace ${id} in wrangler.toml`);
|
|
546
888
|
}
|
|
547
889
|
function ensureDependencies() {
|
|
548
|
-
if (
|
|
890
|
+
if (existsSync2("node_modules")) return;
|
|
549
891
|
console.log("Installing dependencies...");
|
|
550
892
|
const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { stdio: "inherit" });
|
|
551
893
|
if (result.status !== 0) {
|
|
@@ -563,7 +905,7 @@ async function promptDeployTarget() {
|
|
|
563
905
|
console.log("Where do you want to deploy?");
|
|
564
906
|
console.log(" 1) Cloudflare Workers recommended, deploys in one command");
|
|
565
907
|
console.log(" 2) Another platform Render, a container, any Node host");
|
|
566
|
-
const rl =
|
|
908
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
567
909
|
try {
|
|
568
910
|
const answer = (await rl.question("Choose [1]: ")).trim().toLowerCase();
|
|
569
911
|
return answer === "2" || answer.startsWith("o") || answer.startsWith("r") ? "other" : "cloudflare";
|
|
@@ -574,9 +916,9 @@ async function promptDeployTarget() {
|
|
|
574
916
|
function deployContainer(config) {
|
|
575
917
|
const container = asContainer(config);
|
|
576
918
|
if (config.platform !== container.platform || config.storage !== container.storage) {
|
|
577
|
-
|
|
919
|
+
writeFileSync2("flaghoist.toml", serializeConfig(container));
|
|
578
920
|
}
|
|
579
|
-
if (!
|
|
921
|
+
if (!existsSync2(entryFile("container"))) writeProject(container, ".");
|
|
580
922
|
console.log(`
|
|
581
923
|
Scaffolded a container project: server.mjs, Dockerfile, package.json.
|
|
582
924
|
|
|
@@ -595,7 +937,7 @@ Or deploy it to a host:
|
|
|
595
937
|
Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
|
|
596
938
|
}
|
|
597
939
|
async function deployCloudflare(config) {
|
|
598
|
-
if (!
|
|
940
|
+
if (!existsSync2("src/index.ts")) writeProject(config, ".");
|
|
599
941
|
ensureDependencies();
|
|
600
942
|
ensureKvNamespace(config);
|
|
601
943
|
console.log("Deploying with wrangler...");
|
|
@@ -622,20 +964,37 @@ Scaffolding
|
|
|
622
964
|
eject Generate a code project you own (a Worker, or a container)
|
|
623
965
|
deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
|
|
624
966
|
|
|
625
|
-
|
|
967
|
+
Signing in, on a server with user accounts
|
|
968
|
+
login [--url U] [--email E] [--password-stdin] [--code C]
|
|
969
|
+
Sign in and save a personal access token for this server
|
|
970
|
+
logout [--url U] Revoke that token and forget it
|
|
971
|
+
${TOKENS_USAGE}
|
|
972
|
+
|
|
973
|
+
Flag management (needs --url/--token or FLAGS_URL/FLAGS_ADMIN_TOKEN, or \`flaghoist login\`)
|
|
626
974
|
flag list
|
|
627
975
|
flag get <key>
|
|
628
976
|
flag create <key> [--on] [--rollout N] [--desc "..."]
|
|
629
977
|
flag toggle <key> [--on|--off]
|
|
630
978
|
flag rollout <key> <percentage>
|
|
631
979
|
flag rules set <key> --file rules.json
|
|
632
|
-
flag delete <key
|
|
980
|
+
flag delete <key>
|
|
981
|
+
|
|
982
|
+
Members, on a server with accounts on (same --url/--token; needs the admin role)
|
|
983
|
+
${USERS_USAGE}`);
|
|
633
984
|
}
|
|
634
985
|
async function main() {
|
|
635
986
|
const [command, ...rest] = process.argv.slice(2);
|
|
636
987
|
switch (command) {
|
|
637
988
|
case "flag":
|
|
638
989
|
return runFlag(rest);
|
|
990
|
+
case "users":
|
|
991
|
+
return runUsersCommand(rest);
|
|
992
|
+
case "login":
|
|
993
|
+
return runLogin(rest);
|
|
994
|
+
case "logout":
|
|
995
|
+
return runLogout(rest);
|
|
996
|
+
case "tokens":
|
|
997
|
+
return runTokensCommand(rest);
|
|
639
998
|
case "init":
|
|
640
999
|
return runInit(rest);
|
|
641
1000
|
case "eject":
|
package/dist/lib.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type StorageKind = 'cloudflare-kv' | 'redis' | 'postgres' | 'memory';
|
|
1
|
+
type StorageKind = 'cloudflare-kv' | 'redis' | 'postgres' | 'sqlite' | 'memory';
|
|
2
2
|
type AdminAuthKind = 'bearer-token' | 'oidc';
|
|
3
3
|
/**
|
|
4
4
|
* The shape of project `flaghoist deploy`/`eject` scaffolds. `cloudflare` is a Worker plus a
|
|
@@ -26,7 +26,7 @@ declare const STORAGE_KINDS: readonly StorageKind[];
|
|
|
26
26
|
/** Every deploy shape selectable by name in `flaghoist.toml`. */
|
|
27
27
|
declare const PLATFORM_KINDS: readonly PlatformKind[];
|
|
28
28
|
/** The stores a container can reach. Cloudflare KV is a Worker binding, so it is not one of them. */
|
|
29
|
-
type ContainerStorage = 'postgres' | 'redis' | 'memory';
|
|
29
|
+
type ContainerStorage = 'postgres' | 'redis' | 'sqlite' | 'memory';
|
|
30
30
|
/**
|
|
31
31
|
* The storage a container project uses. Cloudflare KV cannot be reached off Workers, so a config
|
|
32
32
|
* that still names it (the scaffolding default) becomes postgres, the store every container deploy
|
package/dist/lib.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaghoist",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Scaffold, deploy, and manage your Flaghoist feature-flag service from the terminal.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
"node": ">=20"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"smol-toml": "^1.8.0"
|
|
26
|
+
"smol-toml": "^1.8.0",
|
|
27
|
+
"@flaghoist/admin-client": "0.3.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@flaghoist/adapter-memory": "0.
|
|
30
|
-
"@flaghoist/core": "0.
|
|
31
|
-
"@flaghoist/server": "0.
|
|
30
|
+
"@flaghoist/adapter-memory": "0.2.0",
|
|
31
|
+
"@flaghoist/core": "0.2.0",
|
|
32
|
+
"@flaghoist/server": "0.4.0"
|
|
32
33
|
},
|
|
33
34
|
"publishConfig": {
|
|
34
35
|
"access": "public"
|