Skill Tracer
Skills › gstack › ios-qa › cli-mint.ts

cli-mint.ts

149 lines · ios-qa/daemon/src/cli-mint.ts at 3f79d81

1// Owner-grant CLI. Adds (or upgrades) an identity to the allowlist so a
2// remote agent on the tailnet can self-service mint a session token via
3// POST /auth/mint. Never auto-allowlists; explicit user intent only.
4//
5// Invoked from bin/gstack-ios-qa-mint.
6
7import { grantIdentity, revokeIdentity, loadAllowlist, defaultAllowlistPath } from './allowlist';
8import type { Capability } from './types';
9
10const CAPABILITIES: Capability[] = ['observe', 'interact', 'mutate', 'restore'];
12interface ParsedArgs {
13 command: 'grant' | 'revoke' | 'list' | 'help';
14 identity: string | null;
15 capability: Capability;
16 ttlSeconds: number | null;
17 note: string | null;
18 path: string;
21function parseArgs(argv: string[]): ParsedArgs {
22 // Default: help. Recognized positional commands: grant | revoke | list.
23 let command: ParsedArgs['command'] = 'help';
24 let identity: string | null = null;
25 let capability: Capability = 'interact';
26 let ttlSeconds: number | null = null;
27 let note: string | null = null;
28 let path = defaultAllowlistPath();
30 for (let i = 0; i < argv.length; i++) {
31 const a = argv[i];
32 switch (a) {
33 case 'grant': command = 'grant'; break;
34 case 'revoke': command = 'revoke'; break;
35 case 'list': command = 'list'; break;
36 case '--help':
37 case '-h': command = 'help'; break;
38 case '--remote':
39 case '--identity':
40 identity = argv[++i] ?? null;
41 break;
42 case '--capability':
43 case '--cap': {
44 const v = argv[++i];
45 if (!CAPABILITIES.includes(v as Capability)) {
46 process.stderr.write(`unknown capability: ${v} (want one of ${CAPABILITIES.join(', ')})\n`);
47 process.exit(2);
48 }
49 capability = v as Capability;
50 break;
51 }
52 case '--ttl': {
53 const v = parseInt(argv[++i] ?? '', 10);
54 if (!Number.isFinite(v) || v <= 0) {
55 process.stderr.write('--ttl must be a positive integer (seconds)\n');
56 process.exit(2);
57 }
58 ttlSeconds = v;
59 break;
60 }
61 case '--note': note = argv[++i] ?? null; break;
62 case '--allowlist-path': path = argv[++i] ?? path; break;
63 }
64 }
65 return { command, identity, capability, ttlSeconds, note, path };
68function printHelp() {
69 const help = `gstack-ios-qa-mint — manage the tailnet allowlist for remote iOS QA agents
71USAGE
72 gstack-ios-qa-mint grant --remote <identity> [--capability <tier>] [--ttl <seconds>] [--note <text>]
73 gstack-ios-qa-mint revoke --remote <identity>
74 gstack-ios-qa-mint list
76ARGUMENTS
77 --remote <identity> Canonical tailnet identity (e.g. user@example.com or tag:ci).
78 --capability <tier> observe | interact (default) | mutate | restore
79 --ttl <seconds> Optional expiry. Omit for no-expiry entry.
80 --note <text> Free-form note kept alongside the entry.
81 --allowlist-path <path> Override the allowlist file location.
83EXAMPLES
84 gstack-ios-qa-mint grant --remote 'alice@example.com' --capability interact
85 gstack-ios-qa-mint grant --remote 'tag:ci' --capability mutate --ttl 86400 --note 'nightly run'
86 gstack-ios-qa-mint revoke --remote 'alice@example.com'
87 gstack-ios-qa-mint list
89The allowlist lives at ~/.gstack/ios-qa-allowlist.json (mode 0600). The daemon's
90self-service /auth/mint endpoint reads this file on every request.
91`;
92 process.stdout.write(help);
95async function main(): Promise<void> {
96 const args = parseArgs(process.argv.slice(2));
98 if (args.command === 'help') {
99 printHelp();
100 return;
101 }
103 if (args.command === 'list') {
104 const allowlist = await loadAllowlist(args.path);
105 if (allowlist.entries.length === 0) {
106 process.stdout.write('(empty allowlist)\n');
107 return;
108 }
109 for (const e of allowlist.entries) {
110 const caps = e.capabilities.join(',');
111 const exp = e.expires_at ? ` expires=${e.expires_at}` : '';
112 const note = e.note ? ` note="${e.note}"` : '';
113 process.stdout.write(`${e.identity} cap=${caps}${exp}${note}\n`);
114 }
115 return;
116 }
118 if (!args.identity) {
119 process.stderr.write('error: --remote <identity> required\n');
120 process.exit(2);
121 }
123 if (args.command === 'grant') {
124 const result = await grantIdentity({
125 identity: args.identity,
126 capability: args.capability,
127 ttlSeconds: args.ttlSeconds,
128 note: args.note ?? undefined,
129 path: args.path,
130 });
131 const entry = result.entries.find(e => e.identity === args.identity);
132 process.stdout.write(`granted ${args.identity} capability=${args.capability}` +
133 (entry?.expires_at ? ` expires=${entry.expires_at}` : '') + '\n');
134 return;
135 }
137 if (args.command === 'revoke') {
138 await revokeIdentity(args.identity, args.path);
139 process.stdout.write(`revoked ${args.identity}\n`);
140 return;
141 }
144if (import.meta.main) {
145 main().catch((err) => {
146 process.stderr.write(`gstack-ios-qa-mint: ${(err as Error).message}\n`);
147 process.exit(1);
148 });