clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

forge users: everyone on the forge, and who is invited

cat committed 81d803a
There was no way to see who is on the forge short of reading .forge/keys.
forge users lists each user with how many SSH keys, passkeys and live
tokens they have, then the invites not taken up yet, with when they
expire and what they grant:

    user  keys  passkeys  tokens
    ana   2     1         0
    bob   1     0         1

    invited  made              until             grants
    fox      2026-09-01 00:00  2026-09-08 00:00  reader of dami/notes
commit
81d803a9b9d79d8d9c7ab0cea86a0ed6fec7db19
parent
577d58d35085c0d554dcf9b0ede9a81a892b96bc
author
cat <admin@dev.site>
ci
✓ passed in 83s, signed by cat
kind
feat risk score 1.1 of 3, Jev's estimate
review
#18 People: collaborators, who, invites and users
ci attestation
forge-attestation 1
type ci
commit 81d803a9b9d79d8d9c7ab0cea86a0ed6fec7db19
tree 1ad54cdf47b060ac2c677e834e00846555fd8a5f
script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7
result pass
exit 0
started 2026-10-19T04:14:51Z
duration 83s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:3277bb4acb9768429bd3dcea30e38538f1852039045cacfe1c1de3cfa7cf93b9 274 lines
|pushed wip/i18n
|5338ae9 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|5338ae9 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|dee2364 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|dee2364 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|57e698d running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|57e698d pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed 31861ee -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|b5ad12f running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|b5ad12f pass in 0s, signed
|pushed main
|e3ecc6c running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|e3ecc6c pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/
|site guide: deployed c3c83eb -> /sites/dami/forge/guide/
|(node:65731) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.1mqNjC/wt/demo/root/.forge/www
|(node:65859) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.1mqNjC/wt/demo/root/.forge/www
|(node:66000) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.1mqNjC/wt/demo/root/.forge/www
|(node:66140) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.1mqNjC/wt/demo/root/.forge/www
|(node:66825) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.1mqNjC/wt/demo/root/.forge/www
|234 of 234 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

4 files changed, +63 -4
MREADME.md2++
Mbin/forge2++
Mlib/identity.ts25+++++++++++++++++---
Atest/scenarios/people-users-who.sh38++++++++++++++++++++++++++++++
M README.md +2 -0
M bin/forge +2 -0
M lib/identity.ts +21 -4
@@ -26,7 +26,7 @@
2626 // node lib/identity.ts token [--repo <owner>/<repo>] [--ttl 30d] [name]2727 // node lib/identity.ts tokens [rm <id>]2828 // node lib/identity.ts invite <name> <ttl> <repo|-> <reader|collaborator|-> (bin/forge user invite checks them)29-// node lib/identity.ts invites [rm <name>] | invited <name> | accept <code>29+// node lib/identity.ts invites [rm <name>] | invited <name> | accept <code> | users3030 // node lib/identity.ts init | purge <user> | snapshot <file> | changed <file> | check <file>3131 import fs from 'node:fs';3232 import path from 'node:path';
@@ -617,7 +617,7 @@ function sshCommand(state: string, user: string, cmd: string, args: string[], ou
617617 618618 const grants = (i: Invite): string => (i.repo ? `${i.role} of ${i.repo}` : '-');619619 620-// forge user invite and invites; and user accept's part, and user add's check.620+// forge user invite, invites, users; and user accept's part, and user add's check.621621 function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {622622 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');623623 const ssh = process.env.FORGE_SSH || 'git@host';
@@ -659,6 +659,23 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
659659 out(`${i!.user} ${i!.repo ?? '-'} ${i!.role ?? '-'}\n`);660660 return 0;661661 }662+ case 'users': {663+ if (args.length) fail('usage: forge users');664+ const keys = new Map<string, number>();665+ let text = '';666+ try {667+ text = fs.readFileSync(path.join(state, 'keys'), 'utf8');668+ } catch {}669+ for (const line of text.split('\n')) {670+ const name = line.trim().split(/\s+/)[0];671+ if (name && !name.startsWith('#')) keys.set(name, (keys.get(name) ?? 0) + 1);672+ }673+ const names = [...keys.keys()].sort();674+ out(columns(['user', 'keys', 'passkeys', 'tokens'], names.map((n) => [n, String(keys.get(n)), String(id.passkeys(n).length), String(id.tokens(n).length)])));675+ const list = id.invites();676+ if (list.length) out('\n' + columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));677+ return 0;678+ }662679 }663680 return fail(`unknown command ${cmd}`);664681 }
@@ -675,12 +692,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
675692 const r = Identity.check(args[0]);676693 if (r !== 'ok') fail(`${args[0]}: ${r}`);677694 }678- else if (['invite', 'invites', 'invited', 'accept'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));695+ else if (['invite', 'invites', 'invited', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));679696 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {680697 const user = process.env.FORGE_USER;681698 if (!user) fail('no FORGE_USER');682699 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));683- } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');700+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');684701 } catch (e) {685702 if (!(e instanceof Usage)) throw e;686703 process.stderr.write(`forge: ${e.message}\n`);
A test/scenarios/people-users-who.sh +38 -0