flaghoist 0.3.1 → 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/index.js +436 -25
- package/package.json +5 -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
|
@@ -11,9 +11,10 @@ import {
|
|
|
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
|
|
@@ -290,27 +291,402 @@ function generatePackageJson(config) {
|
|
|
290
291
|
`;
|
|
291
292
|
}
|
|
292
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
|
+
|
|
293
580
|
// src/version.ts
|
|
294
581
|
import { createRequire } from "module";
|
|
295
582
|
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
296
583
|
|
|
297
584
|
// src/index.ts
|
|
298
585
|
function writeFileSafe(path, content) {
|
|
299
|
-
|
|
300
|
-
|
|
586
|
+
mkdirSync2(dirname2(path) || ".", { recursive: true });
|
|
587
|
+
writeFileSync2(path, content);
|
|
301
588
|
}
|
|
302
589
|
function loadConfig() {
|
|
303
|
-
if (!
|
|
590
|
+
if (!existsSync2("flaghoist.toml")) {
|
|
304
591
|
throw new Error("No flaghoist.toml found in this directory. Run `flaghoist init` first.");
|
|
305
592
|
}
|
|
306
|
-
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.");
|
|
307
601
|
}
|
|
308
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
|
+
});
|
|
309
622
|
const url = values.url ?? process.env.FLAGS_URL;
|
|
310
|
-
const token = values.token ?? process.env.FLAGS_ADMIN_TOKEN;
|
|
311
623
|
if (!url) throw new Error("Missing server URL. Pass --url or set FLAGS_URL.");
|
|
312
|
-
|
|
313
|
-
|
|
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);
|
|
314
690
|
}
|
|
315
691
|
function flagState(flag) {
|
|
316
692
|
if (!flag.enabled) return "disabled";
|
|
@@ -381,7 +757,7 @@ async function runFlag(args) {
|
|
|
381
757
|
if (a !== "set" || !b)
|
|
382
758
|
throw new Error("Usage: flaghoist flag rules set <key> --file rules.json");
|
|
383
759
|
if (!values.file) throw new Error("Missing --file <rules.json>");
|
|
384
|
-
const rules = JSON.parse(
|
|
760
|
+
const rules = JSON.parse(readFileSync2(values.file, "utf8"));
|
|
385
761
|
if (!Array.isArray(rules)) throw new Error("Rules file must contain a JSON array");
|
|
386
762
|
const flag = await setRules(clientFrom(values), b, rules);
|
|
387
763
|
console.log(`Updated rules for "${b}"`);
|
|
@@ -391,6 +767,24 @@ async function runFlag(args) {
|
|
|
391
767
|
throw new Error(`Unknown flag command: ${sub ?? "(none)"}. Try "flaghoist flag list".`);
|
|
392
768
|
}
|
|
393
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
|
+
}
|
|
394
788
|
function runInit(args) {
|
|
395
789
|
const { values } = parseArgs({
|
|
396
790
|
args,
|
|
@@ -401,7 +795,7 @@ function runInit(args) {
|
|
|
401
795
|
platform: { type: "string" }
|
|
402
796
|
}
|
|
403
797
|
});
|
|
404
|
-
if (
|
|
798
|
+
if (existsSync2("flaghoist.toml")) throw new Error("flaghoist.toml already exists.");
|
|
405
799
|
if (values.storage && !STORAGE_KINDS.includes(values.storage)) {
|
|
406
800
|
throw new Error(`Unknown storage "${values.storage}". One of: ${STORAGE_KINDS.join(", ")}.`);
|
|
407
801
|
}
|
|
@@ -414,7 +808,7 @@ function runInit(args) {
|
|
|
414
808
|
storage: values.storage ?? DEFAULT_CONFIG.storage
|
|
415
809
|
};
|
|
416
810
|
const config = values.platform === "container" ? asContainer(base) : base;
|
|
417
|
-
|
|
811
|
+
writeFileSync2("flaghoist.toml", serializeConfig(config));
|
|
418
812
|
console.log("Created flaghoist.toml");
|
|
419
813
|
console.log("Next: `flaghoist deploy` to ship it, or `flaghoist eject` to own the code.");
|
|
420
814
|
}
|
|
@@ -438,7 +832,7 @@ function projectFiles(config) {
|
|
|
438
832
|
}
|
|
439
833
|
function writeProject(config, dir) {
|
|
440
834
|
const files = projectFiles(config);
|
|
441
|
-
const existing = files.map(([name]) => name).filter((name) =>
|
|
835
|
+
const existing = files.map(([name]) => name).filter((name) => existsSync2(join2(dir, name)));
|
|
442
836
|
if (existing.length > 0) {
|
|
443
837
|
throw new Error(
|
|
444
838
|
`Refusing to overwrite ${existing.join(", ")} in this directory.
|
|
@@ -446,12 +840,12 @@ Flaghoist deploys as its own service, so give it a directory of its own:
|
|
|
446
840
|
npm create flaghoist@latest team-flags`
|
|
447
841
|
);
|
|
448
842
|
}
|
|
449
|
-
for (const [name, contents] of files) writeFileSafe(
|
|
843
|
+
for (const [name, contents] of files) writeFileSafe(join2(dir, name), contents);
|
|
450
844
|
}
|
|
451
845
|
function runEject() {
|
|
452
846
|
const config = loadConfig();
|
|
453
847
|
const entry = entryFile(config.platform);
|
|
454
|
-
if (
|
|
848
|
+
if (existsSync2(entry)) throw new Error(`${entry} already exists. Already ejected?`);
|
|
455
849
|
writeProject(config, ".");
|
|
456
850
|
if (config.platform === "container") {
|
|
457
851
|
console.log("Ejected to a code project you own: server.mjs, Dockerfile, package.json");
|
|
@@ -468,7 +862,7 @@ function runEject() {
|
|
|
468
862
|
}
|
|
469
863
|
function ensureKvNamespace(config) {
|
|
470
864
|
if (config.storage !== "cloudflare-kv") return;
|
|
471
|
-
const toml =
|
|
865
|
+
const toml = readFileSync2("wrangler.toml", "utf8");
|
|
472
866
|
if (!needsKvNamespace(toml)) return;
|
|
473
867
|
const title = `${config.name}-FLAGS`;
|
|
474
868
|
console.log(`Creating the ${title} KV namespace...`);
|
|
@@ -489,11 +883,11 @@ function ensureKvNamespace(config) {
|
|
|
489
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."
|
|
490
884
|
);
|
|
491
885
|
}
|
|
492
|
-
|
|
886
|
+
writeFileSync2("wrangler.toml", fillKvNamespaceId(toml, id));
|
|
493
887
|
console.log(`Bound FLAGS to namespace ${id} in wrangler.toml`);
|
|
494
888
|
}
|
|
495
889
|
function ensureDependencies() {
|
|
496
|
-
if (
|
|
890
|
+
if (existsSync2("node_modules")) return;
|
|
497
891
|
console.log("Installing dependencies...");
|
|
498
892
|
const result = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { stdio: "inherit" });
|
|
499
893
|
if (result.status !== 0) {
|
|
@@ -511,7 +905,7 @@ async function promptDeployTarget() {
|
|
|
511
905
|
console.log("Where do you want to deploy?");
|
|
512
906
|
console.log(" 1) Cloudflare Workers recommended, deploys in one command");
|
|
513
907
|
console.log(" 2) Another platform Render, a container, any Node host");
|
|
514
|
-
const rl =
|
|
908
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
515
909
|
try {
|
|
516
910
|
const answer = (await rl.question("Choose [1]: ")).trim().toLowerCase();
|
|
517
911
|
return answer === "2" || answer.startsWith("o") || answer.startsWith("r") ? "other" : "cloudflare";
|
|
@@ -522,9 +916,9 @@ async function promptDeployTarget() {
|
|
|
522
916
|
function deployContainer(config) {
|
|
523
917
|
const container = asContainer(config);
|
|
524
918
|
if (config.platform !== container.platform || config.storage !== container.storage) {
|
|
525
|
-
|
|
919
|
+
writeFileSync2("flaghoist.toml", serializeConfig(container));
|
|
526
920
|
}
|
|
527
|
-
if (!
|
|
921
|
+
if (!existsSync2(entryFile("container"))) writeProject(container, ".");
|
|
528
922
|
console.log(`
|
|
529
923
|
Scaffolded a container project: server.mjs, Dockerfile, package.json.
|
|
530
924
|
|
|
@@ -543,7 +937,7 @@ Or deploy it to a host:
|
|
|
543
937
|
Missing a platform you need? Open an issue at https://github.com/flaghoist/flaghoist/issues.`);
|
|
544
938
|
}
|
|
545
939
|
async function deployCloudflare(config) {
|
|
546
|
-
if (!
|
|
940
|
+
if (!existsSync2("src/index.ts")) writeProject(config, ".");
|
|
547
941
|
ensureDependencies();
|
|
548
942
|
ensureKvNamespace(config);
|
|
549
943
|
console.log("Deploying with wrangler...");
|
|
@@ -570,20 +964,37 @@ Scaffolding
|
|
|
570
964
|
eject Generate a code project you own (a Worker, or a container)
|
|
571
965
|
deploy [--target T] Deploy (prompts for the platform; T is cloudflare or other)
|
|
572
966
|
|
|
573
|
-
|
|
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\`)
|
|
574
974
|
flag list
|
|
575
975
|
flag get <key>
|
|
576
976
|
flag create <key> [--on] [--rollout N] [--desc "..."]
|
|
577
977
|
flag toggle <key> [--on|--off]
|
|
578
978
|
flag rollout <key> <percentage>
|
|
579
979
|
flag rules set <key> --file rules.json
|
|
580
|
-
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}`);
|
|
581
984
|
}
|
|
582
985
|
async function main() {
|
|
583
986
|
const [command, ...rest] = process.argv.slice(2);
|
|
584
987
|
switch (command) {
|
|
585
988
|
case "flag":
|
|
586
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);
|
|
587
998
|
case "init":
|
|
588
999
|
return runInit(rest);
|
|
589
1000
|
case "eject":
|
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",
|
|
@@ -24,12 +24,12 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"smol-toml": "^1.8.0",
|
|
27
|
-
"@flaghoist/admin-client": "0.
|
|
27
|
+
"@flaghoist/admin-client": "0.3.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@flaghoist/adapter-memory": "0.
|
|
31
|
-
"@flaghoist/core": "0.
|
|
32
|
-
"@flaghoist/server": "0.
|
|
30
|
+
"@flaghoist/adapter-memory": "0.2.0",
|
|
31
|
+
"@flaghoist/core": "0.2.0",
|
|
32
|
+
"@flaghoist/server": "0.4.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public"
|