@tillstack/cli 0.2.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/dist/index.js ADDED
@@ -0,0 +1,3447 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin as input, stdout as output, exit } from 'node:process';
4
+ import { readFileSync, writeFileSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { loadConfig, saveConfig, updateConfig, configFilePath } from './config.js';
7
+ import * as api from './api.js';
8
+ import { ApiError } from './api.js';
9
+ import { toDoc, parseDoc, planApply, toApiBody } from './rules.js';
10
+ import { c, table, kv, printJson, ok, info, warn, fail, truncate, ago } from './output.js';
11
+ const VERSION = '0.2.0';
12
+ function parseArgs(argv) {
13
+ const positional = [];
14
+ const named = {};
15
+ const bool = new Set();
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (!a)
19
+ continue;
20
+ if (a === '-h') {
21
+ bool.add('help');
22
+ continue;
23
+ }
24
+ if (a.startsWith('--')) {
25
+ const eq = a.indexOf('=');
26
+ if (eq !== -1) {
27
+ named[a.slice(2, eq)] = a.slice(eq + 1);
28
+ continue;
29
+ }
30
+ const key = a.slice(2);
31
+ const next = argv[i + 1];
32
+ if (next != null && !next.startsWith('--')) {
33
+ named[key] = next;
34
+ i++;
35
+ }
36
+ else {
37
+ bool.add(key);
38
+ }
39
+ }
40
+ else {
41
+ positional.push(a);
42
+ }
43
+ }
44
+ return { positional, named, bool };
45
+ }
46
+ // ── Prompts ──────────────────────────────────────────────────────────────────
47
+ async function promptInput(promptText, opts = {}) {
48
+ const rl = createInterface({ input, output, terminal: true });
49
+ try {
50
+ if (opts.hidden && output.isTTY) {
51
+ output.write(promptText);
52
+ return await new Promise((resolve) => {
53
+ const stdinAny = process.stdin;
54
+ stdinAny.setRawMode?.(true);
55
+ let answer = '';
56
+ const onData = (data) => {
57
+ const s = data.toString('utf8');
58
+ if (s === '\u0003') {
59
+ stdinAny.setRawMode?.(false);
60
+ rl.close();
61
+ exit(130);
62
+ }
63
+ if (s === '\r' || s === '\n') {
64
+ stdinAny.setRawMode?.(false);
65
+ stdinAny.removeListener('data', onData);
66
+ output.write('\n');
67
+ resolve(answer);
68
+ return;
69
+ }
70
+ if (s === '\u007f')
71
+ answer = answer.slice(0, -1);
72
+ else
73
+ answer += s;
74
+ };
75
+ stdinAny.on('data', onData);
76
+ });
77
+ }
78
+ return await rl.question(promptText);
79
+ }
80
+ finally {
81
+ rl.close();
82
+ }
83
+ }
84
+ /** Read all of stdin to a string (for `--<flag>-stdin` secret piping in CI). */
85
+ async function readStdin() {
86
+ const chunks = [];
87
+ for await (const chunk of process.stdin)
88
+ chunks.push(chunk);
89
+ return Buffer.concat(chunks).toString('utf8');
90
+ }
91
+ // Resolve a secret-valued argument WITHOUT forcing it onto the command line,
92
+ // where it would be captured in shell history and `ps` output. Precedence:
93
+ // 1. --<name> <value> convenient, but leaks to history — we warn.
94
+ // 2. --<name>-stdin CI-safe: `printf %s "$TOKEN" | tilldev … --token-stdin`
95
+ // 3. interactive hidden prompt (TTY only).
96
+ async function readSecretArg(flags, name, label) {
97
+ const direct = flags.named[name];
98
+ if (direct !== undefined) {
99
+ warn(`Passing --${name} on the command line leaks it to shell history; prefer --${name}-stdin or the prompt.`);
100
+ return direct;
101
+ }
102
+ if (flags.bool.has(`${name}-stdin`)) {
103
+ const v = (await readStdin()).trim();
104
+ if (!v)
105
+ throw new Error(`--${name}-stdin was set but stdin was empty`);
106
+ return v;
107
+ }
108
+ if (process.stdin.isTTY) {
109
+ const v = (await promptInput(`${label}: `, { hidden: true })).trim();
110
+ if (!v)
111
+ throw new Error(`${label} is required`);
112
+ return v;
113
+ }
114
+ throw new Error(`missing ${label}: pass --${name}, pipe it with --${name}-stdin, or run interactively`);
115
+ }
116
+ // ── Help ─────────────────────────────────────────────────────────────────────
117
+ const HELP = `${c.bold('tilldev')} — the TillDev command line ${c.dim('(v' + VERSION + ')')}
118
+
119
+ ${c.bold('USAGE')}
120
+ tilldev <command> [subcommand] [args] [flags]
121
+
122
+ ${c.bold('ACCOUNT')}
123
+ login Sign in and store a token at ${c.dim('~/.tilldev/config.json')}
124
+ logout Forget the stored token
125
+ whoami Show the signed-in user, org, and API URL
126
+ config get [key] Show config (api-url | org | project | token)
127
+ config set <key> <value> Set api-url / org / project
128
+
129
+ ${c.bold('STACKS')} ${c.dim('(one app across products)')} ${c.gray('tilldev stacks …')}
130
+ stacks ls List your stacks
131
+ stacks show <slug> Products + resource counts for a stack
132
+ stacks create <name> [--slug <s>] [--accent <hex>] [--modules pulse,auth,cache,…]
133
+ stacks enable <slug> <product> Turn a product on for the stack
134
+ stacks disable <slug> <product> Pause a product (keeps attached resources)
135
+ stacks attach <slug> <product> <resource-id> Wire an existing resource in
136
+ stacks detach <slug> <product> <resource-id> Un-group a resource
137
+ stacks rm <slug> Delete a stack ${c.dim('(resources un-grouped, not deleted)')}
138
+
139
+ ${c.bold('TILLPULSE')} ${c.dim('(observability)')} ${c.gray('tilldev pulse …')}
140
+ pulse projects List projects
141
+ pulse projects create <name> --platform <ios|android|react-native|flutter|web>
142
+ pulse project <id> Show a project (incl. DSN)
143
+ pulse issues [--project <id>] [--limit <n>]
144
+ pulse issue <id> Show an issue
145
+ pulse metrics [--project <id>]
146
+ pulse alerts List alerts that have fired
147
+ pulse alerts rules [projectId] List the alert rules that fire them
148
+ pulse releases create <projectId> <version> [--build <b>] [--env <e>] [--commit <sha>]
149
+ pulse sourcemaps upload <projectId> --release <v> --file <path>
150
+ pulse proguard upload <projectId> --release <v> --file <path>
151
+ pulse dsym upload <projectId> --release <v> --file <path>
152
+ pulse dsn rotate <projectId>
153
+ pulse ask "<question>" Ask your telemetry in natural language
154
+ pulse tracker ls <projectId> List Jira/Linear integrations
155
+ pulse tracker connect <projectId> --provider <jira|linear> --target <KEY> [--token…]
156
+ pulse tracker webhook <projectId> --provider <jira|linear> Show inbound webhook URL/status
157
+ pulse tracker webhook-secret <projectId> --provider <jira|linear> Set the per-project secret
158
+ pulse tracker disconnect <projectId> --provider <jira|linear>
159
+
160
+ ${c.bold('TILLSHIELD')} ${c.dim('(edge WAF)')} ${c.gray('tilldev shield …')}
161
+ shield rules ls List inline edge rules
162
+ shield rules export [-f <file>] Export rules to a JSON file ${c.dim('(rules-as-code)')}
163
+ shield rules apply -f <file> [--dry-run] [--prune] Reconcile rules from a file
164
+ shield rules rm <id> Delete a rule
165
+ shield edge-keys ls List edge auth keys
166
+ shield edge-keys create --project <id> [--name <n>] Mint an edge key
167
+ shield edge-keys revoke <id> Revoke an edge key
168
+ shield settings Show TillShield settings
169
+ shield incidents [--status <s>] List security incidents
170
+
171
+ ${c.bold('TILLGATE')} ${c.dim('(bot challenge)')} ${c.gray('tilldev gate …')}
172
+ gate sites ls List challenge sites
173
+ gate sites create --project <id> [--name <n>] [--mode <m>] [--difficulty <8-26>] [--ttl <s>] [--hosts a,b]
174
+ gate sites update <id> [--mode <m>] [--difficulty <n>] [--ttl <s>] [--hosts a,b]
175
+ gate sites rm <id> Delete a site
176
+
177
+ ${c.bold('TILLCACHE')} ${c.dim('(KV, queues, pub/sub)')} ${c.gray('tilldev cache …')}
178
+ cache ns ls List cache namespaces
179
+ cache ns create --name <n> --slug <s> [--backend upstash|durable|byo|auto] [--consistency strong|eventual]
180
+ (backend byo also needs --byo-url + --byo-token)
181
+ cache ns get <id> Show a namespace
182
+ cache ns pause|resume <id> Pause / resume serving
183
+ cache ns rm <id> Delete a namespace
184
+ cache tokens ls --ns <id> List data-plane tokens
185
+ cache tokens create --ns <id> [--name <n>] [--scope rw|ro] Mint a tc_… token
186
+ cache tokens revoke <tokenId> --ns <id>
187
+ cache queues --ns <id> Queue depths + dead-letter counts
188
+ cache keys --ns <id> Hot keys
189
+ cache usage --ns <id> Command/queue/publish usage (7d)
190
+
191
+ ${c.bold('TILLSECRETS')} ${c.dim('(config + secrets)')} ${c.gray('tilldev secrets …')}
192
+ secrets projects ls List secrets projects
193
+ secrets projects create --name <n> --slug <s>
194
+ secrets env ls --project <id> List environments
195
+ secrets env create --project <id> --name <n> --slug <s>
196
+ secrets ls --env <id> List secret keys (no values)
197
+ secrets set <KEY> --env <id> [--value <v>] Set a value (new version); prompts if no --value
198
+ secrets get <secret-id> [--version <n>] Reveal a value (audited)
199
+ secrets versions <secret-id> Version history
200
+ secrets rollback <secret-id> --version <n> Re-point to an older version
201
+ secrets rm <secret-id> Delete a secret
202
+ secrets tokens ls --project <id> List service tokens
203
+ secrets tokens create --project <id> [--scope read|read_write] [--env <id>] Mint a ts_… token
204
+ secrets tokens revoke <token-id> --project <id>
205
+ secrets pull [--env <id>] Boot-time bulk pull → ${c.dim('.env on stdout (--json for JSON)')}
206
+ secrets lease <KEY> [--ttl <sec>] Lease a short-lived dynamic credential ${c.dim('(--backend <id> instead of KEY)')}
207
+ secrets lease revoke <lease-id> Revoke a lease early
208
+ secrets sync ls|add|rm|pause|resume|push --project <id> ${c.dim('One-way push targets')}
209
+ ${c.dim('pull/lease authenticate with a ts_ service token: --token ts_… or $TILLSECRETS_TOKEN.')}
210
+ ${c.dim('lease targets the dynamic-lease service — override with --lease-url / $TILLSECRETS_LEASE_URL.')}
211
+
212
+ ${c.bold('TILLARK')} ${c.dim('(backup & disaster recovery)')} ${c.gray('tilldev ark …')}
213
+ ark sources ls | get <id> | rm <id>
214
+ ark sources create --name <n> --type <postgres|…|object> --provider <p> [--residency <z>] [--with-dsn]
215
+ ark targets ls | get <id> | rm <id>
216
+ ark targets create --name <n> --provider <s3|r2|…> [--bucket <b>] [--endpoint <url>] [--lock-days <n>] [--with-creds]
217
+ ark policies ls [--source <id>]
218
+ ark policies create --source <id> [--cron <expr>] [--rpo <s>] [--rto <s>] [--keep-daily <n> …] [--verify-cron <expr>]
219
+ ark backups ls [--source <id>] List the backup catalog
220
+ ark backups run --source <id> [--policy <id>] Queue a backup run
221
+ ark verify <backup-id> Non-destructive restore-verification
222
+ ark restore <backup-id> --into <source-id> --approved-by <uid> --approval-ref <ref> ${c.dim('(four-eyes)')}
223
+ ark restores [--backup <id>] Restore + verification history
224
+ ark failover status --source <id> Current failover state + replicas
225
+ ark failover promote --source <id> [--to <node>] --approved-by <uid> --approval-ref <ref> ${c.dim('(four-eyes)')}
226
+ ark failover failback --source <id> [--lag <s>] --approved-by <uid> --approval-ref <ref> ${c.dim('(four-eyes)')}
227
+ ark replicas List replicas across the org
228
+ ark tokens ls List tark_ control-plane tokens
229
+ ark tokens create [--scope read|operator|admin] [--can-approve] [--expires <days>]
230
+ ark tokens revoke <token-id>
231
+ ark audit [--limit <n>] The append-only compliance trail
232
+
233
+ ${c.bold('TILLFORGE')} ${c.dim('(sovereign code hosting)')} ${c.gray('tilldev forge …')}
234
+ forge login [--scope read|write] Sign in for git via the browser (device flow — no pasting tokens)
235
+ forge clone <org>/<repo> [dir] Clone over HTTPS (token via helper, never in the URL)
236
+ forge setup-git Wire git's credential helper for the forge host, once
237
+ forge repos ls | get <slug> | rm <slug>
238
+ forge repos create --name <n> [--slug <s>] [--visibility private|internal|public]
239
+ forge tokens ls | revoke <id>
240
+ forge tokens create [--scope read|write|admin] [--repo <slug>] [--expires <days>] ${c.dim('(tfp_ — the git password)')}
241
+ forge tree <repo> [--rev <r>] [--path <p>] Browse a directory
242
+ forge search <repo> <query> [--rev <r>] Code search (fixed-string) across a repo
243
+ forge blob <repo> --path <p> [--rev <r>] Read a file
244
+ forge log <repo> [--rev <r>] [--limit <n>] Commit log
245
+ forge refs <repo> | blame <repo> --path <p>
246
+ forge policy ls <repo>
247
+ forge policy set <repo> --pattern <p> [--require-signed] [--require-linear] [--approvals <n>] [--checks a,b] [--require-mfa] [--allow-force] [--allow-delete]
248
+ forge reflog <repo> Ref-change history (recovery)
249
+ forge recover <repo> <reflog-id> One-click restore a ref ${c.dim('(mistake ≠ fatal)')}
250
+ forge findings <repo> Secrets rejected at the push boundary
251
+ forge allowlist <repo> <finding-id> --reason <r>
252
+ forge pr ls <repo> [--state open|merged|closed]
253
+ forge pr create <repo> --title <t> --source <ref> [--target <ref>] [--draft]
254
+ forge pr get <repo> <number> | review <repo> <number> --state approved|changes_requested|commented
255
+ forge pr merge <repo> <number> [--strategy merge|squash|rebase] ${c.dim('(enforces approvals+checks)')}
256
+ forge pr close <repo> <number> | reopen <repo> <number> | comment <repo> <number> --body <b> [--file <p>] [--line <n>]
257
+ forge release ls <repo> | get <repo> <tag> | rm <repo> <tag>
258
+ forge release create <repo> --tag <t> [--name <n>] [--target <ref>] [--generate|--notes <md>] [--prerelease] [--draft] ${c.dim('(auto-changelog)')}
259
+ forge asset add <repo> <tag> --name <n> --platform linux|darwin|windows|android|ios --url <u> [--arch <a>] [--sha256 <h>]
260
+ forge env ls <repo> | set <repo> --name <n> [--production] [--url <u>] [--approvals <n>]
261
+ forge deploy ls <repo> [--env <e>] | create <repo> --env <e> --ref <r> [--url <u>] | status <repo> <id> --state <s>
262
+ forge webhook ls | add --url <u> --events push,release,deployment [--repo <slug>] [--secret <s>] | rm <id> | ping <id> | deliveries <id>
263
+ forge pr diff <repo> <number> | forge compare <repo> --base <ref> --head <ref> ${c.dim('(changed files)')}
264
+ forge issue ls <repo> [--state open|closed|all] [--label <l>]
265
+ forge issue create <repo> --title <t> [--body <b>] [--labels a,b] [--assignee <uid>]
266
+ forge issue get <repo> <number> | close <repo> <number> | comment <repo> <number> --body <b>
267
+ forge label ls <repo> | create <repo> --name <n> [--color #rrggbb] [--desc <d>]
268
+ forge collab ls <repo> | add <repo> --user <uid> [--role read|triage|write|admin] | rm <repo> <id>
269
+ forge checks <repo> [--sha <s>] | check <repo> --sha <s> --context <c> --state <st> [--url <u>]
270
+ forge ci list --repo <slug> [--status <st>] [--workflow <w>] CI runs, newest first
271
+ forge ci logs <job-id> --repo <slug> One run's full log
272
+ forge ci run --repo <slug> [--sha <s>] [--ref <r>] Enqueue .tillforge/ci.yml jobs ${c.dim('(defaults to the default branch tip)')}
273
+ forge correlation <repo> [--rev <r>] Release-granular commit→crash
274
+ forge egress policy <repo> | set <repo> [--allow] [--sensitive] [--providers a,b] [--max-bytes <n>] [--no-redact] | audit
275
+ forge mirror <repo> --url <u> [--direction pull|push] | forge mirror <repo> --remove ${c.dim('(SSRF-guarded)')}
276
+ forge keys ls <repo> | add <repo> --name <n> --key "<ssh pubkey>" [--scope read|write] [--expires <days>] | revoke <repo> <id>
277
+ forge keys-account ls | add --name <n> --key "<ssh pubkey>" [--scope read|write] [--expires <days>] | rm <id> ${c.dim('(org-wide keys)')}
278
+ forge codeowners <repo> [--rev <r>]
279
+ forge audit [--limit <n>]
280
+
281
+ ${c.bold('TILLAUTH')} ${c.dim('(auth)')} ${c.gray('tilldev auth …')}
282
+ auth apps List auth apps
283
+ auth apps create <name> Create an auth app
284
+ auth app <appId> Show an auth app
285
+
286
+ ${c.bold('WORKSPACE')}
287
+ team List org members
288
+ keys List API keys
289
+ keys create <name> [--scopes read,write,admin]
290
+
291
+ ${c.bold('GLOBAL FLAGS')}
292
+ --api-url <url> Override API base URL ${c.dim('(env: TILLDEV_API_URL)')}
293
+ --token <token> Override stored token ${c.dim('(env: TILLDEV_TOKEN)')}
294
+ --json Print raw JSON instead of tables
295
+ --help, -h Show help`;
296
+ // ── Helpers ──────────────────────────────────────────────────────────────────
297
+ // Guard the base URL before we ever attach the Bearer token to a request. An
298
+ // http:// (or otherwise attacker-controlled) base would send the token in
299
+ // cleartext or straight to a foreign host. Allow https everywhere; allow http
300
+ // ONLY for localhost/127.0.0.1 (local dev).
301
+ function assertSafeApiUrl(apiUrl) {
302
+ let u;
303
+ try {
304
+ u = new URL(apiUrl);
305
+ }
306
+ catch {
307
+ throw new Error(`Invalid --api-url: ${apiUrl}`);
308
+ }
309
+ const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
310
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLocal)) {
311
+ throw new Error(`Refusing to send your token to ${apiUrl} — the API URL must be https:// (http:// is allowed only for localhost).`);
312
+ }
313
+ }
314
+ function resolveOpts(flags) {
315
+ const cfg = loadConfig();
316
+ const apiUrl = flags.named['api-url'] ?? cfg.api_url;
317
+ const token = flags.named['token'] ?? process.env['TILLDEV_TOKEN'] ?? process.env['TILLPULSE_TOKEN'] ?? cfg.token;
318
+ // Only enforce when a token would actually be attached — unauthenticated calls
319
+ // to a custom http endpoint (rare) shouldn't be blocked, but a token must never
320
+ // leave over http to a non-local host.
321
+ if (token !== undefined)
322
+ assertSafeApiUrl(apiUrl);
323
+ const opts = { apiUrl, ...(token !== undefined ? { token } : {}) };
324
+ return { opts, apiUrl, ...(cfg.org !== undefined ? { org: cfg.org } : {}) };
325
+ }
326
+ /** Print `data` as JSON when --json, else run the human renderer. */
327
+ function render(flags, data, human) {
328
+ if (flags.bool.has('json'))
329
+ printJson(data);
330
+ else
331
+ human();
332
+ }
333
+ function need(value, usage) {
334
+ if (!value)
335
+ throw new Error(`usage: ${usage}`);
336
+ return value;
337
+ }
338
+ // Quote a value for dotenv output. Barewords pass through; anything with
339
+ // whitespace/quotes/special chars is double-quoted with escapes, so
340
+ // `tilldev secrets pull > .env` round-trips cleanly.
341
+ function dotenvQuote(v) {
342
+ if (/^[A-Za-z0-9_@%+=:,./-]*$/.test(v))
343
+ return v;
344
+ return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
345
+ }
346
+ // ── Command handlers ─────────────────────────────────────────────────────────
347
+ async function cmdLogin(flags) {
348
+ const { opts, apiUrl } = resolveOpts(flags);
349
+ const email = flags.named['email'] ?? (await promptInput('Email: '));
350
+ const password = flags.named['password'] ?? (await promptInput('Password: ', { hidden: true }));
351
+ let r = await api.login(opts, email, password);
352
+ if (r.totpRequired) {
353
+ const code = await promptInput('2FA code: ');
354
+ r = await api.login(opts, email, password, code);
355
+ }
356
+ saveConfig({ api_url: apiUrl, token: r.token, org: r.org });
357
+ ok(`Signed in as ${c.bold(email)} — token saved to ${c.dim(configFilePath())} (0600)`);
358
+ info(`Org: ${c.bold(r.org)}`);
359
+ }
360
+ function cmdLogout() {
361
+ const cfg = loadConfig();
362
+ saveConfig({ api_url: cfg.api_url, ...(cfg.org ? { org: cfg.org } : {}) });
363
+ ok('Logged out — token removed.');
364
+ }
365
+ async function cmdWhoami(flags) {
366
+ const { opts, apiUrl, org } = resolveOpts(flags);
367
+ if (!opts.token) {
368
+ fail('Not signed in. Run `tilldev login`.');
369
+ exit(1);
370
+ }
371
+ const me = await api.getMe(opts).catch(() => null);
372
+ render(flags, me ?? { api_url: apiUrl, org }, () => {
373
+ console.log(kv([
374
+ ['API URL', apiUrl],
375
+ ['User', me?.user?.email ?? c.dim('<unknown>')],
376
+ ['Org', me?.org?.slug ?? org ?? c.dim('<not set>')],
377
+ ['Role', me?.org?.role ?? me?.role ?? c.dim('-')],
378
+ ]));
379
+ });
380
+ }
381
+ function cmdConfig(flags) {
382
+ const sub = flags.positional[1];
383
+ const cfg = loadConfig();
384
+ if (sub === 'set') {
385
+ const key = need(flags.positional[2], 'tilldev config set <api-url|org|project> <value>');
386
+ const value = need(flags.positional[3], 'tilldev config set <key> <value>');
387
+ const map = { 'api-url': 'api_url', org: 'org', project: 'project' };
388
+ const field = map[key];
389
+ if (!field)
390
+ throw new Error(`unknown config key: ${key} (api-url | org | project)`);
391
+ updateConfig({ [field]: value });
392
+ ok(`Set ${key} = ${value}`);
393
+ return;
394
+ }
395
+ // get (default)
396
+ render(flags, { ...cfg, token: cfg.token ? '<set>' : undefined, forge_token: cfg.forge_token ? '<set>' : undefined }, () => {
397
+ console.log(kv([
398
+ ['api-url', cfg.api_url],
399
+ ['org', cfg.org ?? c.dim('<not set>')],
400
+ ['project', cfg.project ?? c.dim('<not set>')],
401
+ ['token', cfg.token ? c.green('<set>') : c.dim('<missing>')],
402
+ ['forge-token', cfg.forge_token ? c.green('<set>') : c.dim('<missing>')],
403
+ ]));
404
+ });
405
+ }
406
+ const now = Date.now();
407
+ async function cmdPulse(flags) {
408
+ const { opts } = resolveOpts(flags);
409
+ const sub = flags.positional[1];
410
+ const cfgProject = loadConfig().project;
411
+ switch (sub) {
412
+ case undefined:
413
+ case 'projects': {
414
+ if (flags.positional[2] === 'create') {
415
+ const name = need(flags.positional[3], 'tilldev pulse projects create <name> --platform <p>');
416
+ const platform = need(flags.named['platform'], 'tilldev pulse projects create <name> --platform <ios|android|react-native|flutter|web>');
417
+ const r = await api.createProject(opts, name, platform);
418
+ render(flags, r, () => {
419
+ ok(`Created project ${c.bold(r.project.name)}`);
420
+ console.log(kv([['id', r.project.id], ['dsn', r.project.dsn ?? c.dim('-')]]));
421
+ });
422
+ return;
423
+ }
424
+ const r = await api.listProjects(opts);
425
+ render(flags, r, () => {
426
+ const cols = [
427
+ { header: 'NAME', get: (p) => c.bold(p.name) },
428
+ { header: 'PLATFORM', get: (p) => p.platform ?? '-' },
429
+ { header: 'ID', get: (p) => c.dim(p.id) },
430
+ ];
431
+ console.log(table(r.projects, cols));
432
+ });
433
+ return;
434
+ }
435
+ case 'project': {
436
+ const id = need(flags.positional[2] ?? cfgProject, 'tilldev pulse project <id>');
437
+ const r = await api.getProject(opts, id);
438
+ render(flags, r, () => console.log(kv([
439
+ ['name', r.project.name],
440
+ ['id', r.project.id],
441
+ ['platform', r.project.platform ?? '-'],
442
+ ['dsn', r.project.dsn ?? c.dim('-')],
443
+ ])));
444
+ return;
445
+ }
446
+ case 'issues': {
447
+ const projectId = flags.named['project'] ?? cfgProject;
448
+ const r = await api.listIssues(opts, {
449
+ ...(projectId ? { projectId } : {}),
450
+ ...(flags.named['limit'] ? { limit: Number(flags.named['limit']) } : {}),
451
+ });
452
+ render(flags, r, () => {
453
+ const cols = [
454
+ { header: 'TITLE', get: (i) => c.bold(truncate(i.title ?? '(untitled)', 48)) },
455
+ { header: 'LEVEL', get: (i) => levelColor(i.level) },
456
+ { header: 'EVENTS', get: (i) => String(i.events_count ?? 0) },
457
+ { header: 'LAST SEEN', get: (i) => ago(i.last_seen, now) },
458
+ { header: 'ID', get: (i) => c.dim(i.id) },
459
+ ];
460
+ console.log(table(r.issues, cols));
461
+ });
462
+ return;
463
+ }
464
+ case 'issue': {
465
+ const id = need(flags.positional[2], 'tilldev pulse issue <id>');
466
+ const r = await api.getIssue(opts, id);
467
+ printJson(r);
468
+ return;
469
+ }
470
+ case 'metrics': {
471
+ const r = await api.getMetrics(opts, flags.named['project'] ?? cfgProject);
472
+ printJson(r);
473
+ return;
474
+ }
475
+ case 'alerts': {
476
+ // `pulse alerts` → alerts that have fired (what you check after a page)
477
+ // `pulse alerts rules` → the rules that fire them
478
+ if (flags.positional[2] === 'rules') {
479
+ const r = await api.listAlertRules(opts, flags.named.project ?? flags.positional[3]);
480
+ render(flags, r, () => {
481
+ const cols = [
482
+ { header: 'NAME', get: (a) => c.bold(truncate(a.name ?? '-', 40)) },
483
+ { header: 'TRIGGER', get: (a) => a.trigger_type ?? '-' },
484
+ { header: 'ENABLED', get: (a) => (a.enabled ? c.green('yes') : c.dim('no')) },
485
+ { header: 'ID', get: (a) => c.dim(a.id) },
486
+ ];
487
+ console.log(table(r.rules, cols));
488
+ });
489
+ return;
490
+ }
491
+ const r = await api.listAlerts(opts);
492
+ render(flags, r, () => {
493
+ const cols = [
494
+ { header: 'TITLE', get: (a) => c.bold(truncate(a.title ?? '-', 48)) },
495
+ { header: 'SEVERITY', get: (a) => levelColor(a.severity) },
496
+ { header: 'PROJECT', get: (a) => a.project ?? '-' },
497
+ { header: 'ACK', get: (a) => (a.acknowledged ? c.dim('yes') : c.yellow('no')) },
498
+ { header: 'WHEN', get: (a) => ago(a.created_at, now) },
499
+ ];
500
+ console.log(table(r.alerts, cols));
501
+ });
502
+ return;
503
+ }
504
+ case 'releases': {
505
+ if (flags.positional[2] !== 'create')
506
+ throw new Error('usage: tilldev pulse releases create <projectId> <version> [flags]');
507
+ const projectId = need(flags.positional[3], 'tilldev pulse releases create <projectId> <version>');
508
+ const version = need(flags.positional[4], 'tilldev pulse releases create <projectId> <version>');
509
+ const r = await api.createRelease(opts, projectId, {
510
+ version,
511
+ ...(flags.named['build'] ? { build: flags.named['build'] } : {}),
512
+ ...(flags.named['env'] ? { environment: flags.named['env'] } : {}),
513
+ ...(flags.named['commit'] ? { commit_sha: flags.named['commit'] } : {}),
514
+ });
515
+ render(flags, r, () => ok(`Created release ${c.bold(version)}`));
516
+ return;
517
+ }
518
+ case 'sourcemaps':
519
+ case 'proguard':
520
+ case 'dsym': {
521
+ if (flags.positional[2] !== 'upload')
522
+ throw new Error(`usage: tilldev pulse ${sub} upload <projectId> --release <v> --file <path>`);
523
+ const projectId = need(flags.positional[3], `tilldev pulse ${sub} upload <projectId> --release <v> --file <path>`);
524
+ const release = need(flags.named['release'], `--release required`);
525
+ const file = need(flags.named['file'], `--file required`);
526
+ const fn = sub === 'sourcemaps' ? api.uploadSourceMap : sub === 'proguard' ? api.uploadProguardMapping : api.uploadDsym;
527
+ const r = await fn(opts, projectId, { release, filePath: file });
528
+ render(flags, r, () => ok(`Uploaded ${sub} for release ${c.bold(release)}`));
529
+ return;
530
+ }
531
+ case 'dsn': {
532
+ if (flags.positional[2] !== 'rotate')
533
+ throw new Error('usage: tilldev pulse dsn rotate <projectId>');
534
+ const projectId = need(flags.positional[3], 'tilldev pulse dsn rotate <projectId>');
535
+ const r = await api.rotateDsn(opts, projectId);
536
+ render(flags, r, () => ok('DSN rotated.'));
537
+ return;
538
+ }
539
+ case 'ask': {
540
+ const { org } = resolveOpts(flags);
541
+ const question = need(flags.positional[2], 'tilldev pulse ask "<question>"');
542
+ const orgSlug = flags.named['org'] ?? org;
543
+ if (!orgSlug)
544
+ throw new Error('no org set — run `tilldev config set org <slug>` or `tilldev login`');
545
+ await api.askStream(opts, orgSlug, question, (chunk) => output.write(chunk));
546
+ output.write('\n');
547
+ return;
548
+ }
549
+ case 'tracker':
550
+ return cmdTracker(flags, opts, cfgProject);
551
+ default:
552
+ throw new Error(`unknown: tilldev pulse ${sub}`);
553
+ }
554
+ }
555
+ // ── TillPulse: issue-tracker (Jira / Linear) ──────────────────────────────────
556
+ // `pulse tracker <action> <projectId> …`. The webhook-secret action is the
557
+ // user-facing half of the per-project webhook fix — each project gets its own
558
+ // inbound secret so a leak can never reach another tenant.
559
+ function trackerProvider(flags) {
560
+ const p = flags.named['provider'];
561
+ if (p !== 'jira' && p !== 'linear')
562
+ throw new Error('--provider must be jira or linear');
563
+ return p;
564
+ }
565
+ async function cmdTracker(flags, opts, cfgProject) {
566
+ const action = flags.positional[2];
567
+ const projectId = flags.positional[3] ?? flags.named['project'] ?? cfgProject;
568
+ switch (action) {
569
+ case undefined:
570
+ case 'ls':
571
+ case 'list': {
572
+ const pid = need(projectId, 'tilldev pulse tracker ls <projectId>');
573
+ const r = await api.listTracker(opts, pid);
574
+ render(flags, r, () => {
575
+ const cols = [
576
+ { header: 'PROVIDER', get: (t) => c.bold(t.provider) },
577
+ { header: 'TARGET', get: (t) => t.default_target },
578
+ { header: 'SITE', get: (t) => t.site_url ?? c.dim('-') },
579
+ { header: 'WEBHOOK', get: (t) => (t.has_webhook_secret ? c.green('secured') : c.yellow('unset')) },
580
+ { header: 'ID', get: (t) => c.dim(t.id) },
581
+ ];
582
+ console.log(table(r.integrations, cols));
583
+ });
584
+ return;
585
+ }
586
+ case 'connect': {
587
+ const pid = need(projectId, 'tilldev pulse tracker connect <projectId> --provider <p> --target <KEY>');
588
+ const provider = trackerProvider(flags);
589
+ const target = need(flags.named['target'], '--target required (project/team key issues are filed under)');
590
+ const token = await readSecretArg(flags, 'token', `${provider} API token`);
591
+ const body = { provider, api_token: token, default_target: target };
592
+ if (provider === 'jira') {
593
+ body.site_url = need(flags.named['site-url'], 'jira requires --site-url (e.g. https://acme.atlassian.net)');
594
+ body.auth_email = need(flags.named['email'], 'jira requires --email (the Atlassian account email)');
595
+ }
596
+ if (flags.named['issue-type'])
597
+ body.default_issue_type = flags.named['issue-type'];
598
+ const r = await api.connectTracker(opts, pid, body);
599
+ render(flags, r, () => {
600
+ ok(`Connected ${c.bold(provider)} for project ${c.dim(pid)}`);
601
+ info(`Next: secure inbound status syncs with \`tilldev pulse tracker webhook-secret ${pid} --provider ${provider}\``);
602
+ });
603
+ return;
604
+ }
605
+ case 'webhook': {
606
+ const pid = need(projectId, 'tilldev pulse tracker webhook <projectId> --provider <p>');
607
+ const provider = trackerProvider(flags);
608
+ const r = await api.getTrackerWebhook(opts, pid, provider);
609
+ render(flags, r, () => {
610
+ console.log(kv([
611
+ ['provider', provider],
612
+ ['configured', r.configured ? c.green('yes') : c.yellow('no')],
613
+ ['webhook url', r.webhook_url],
614
+ ...(r.header ? [['header', r.header]] : []),
615
+ ]));
616
+ if (!r.configured)
617
+ info(`Set it with \`tilldev pulse tracker webhook-secret ${pid} --provider ${provider}\``);
618
+ });
619
+ return;
620
+ }
621
+ case 'webhook-secret': {
622
+ const pid = need(projectId, 'tilldev pulse tracker webhook-secret <projectId> --provider <p>');
623
+ const provider = trackerProvider(flags);
624
+ if (provider === 'linear') {
625
+ // Linear signs with its own generated secret — the customer pastes it in.
626
+ const secret = await readSecretArg(flags, 'secret', 'Linear webhook signing secret');
627
+ const r = await api.setTrackerWebhook(opts, pid, { provider, secret });
628
+ render(flags, r, () => {
629
+ ok('Stored the Linear signing secret (encrypted). Inbound webhooks are now verified.');
630
+ console.log(kv([['webhook url', r.webhook_url]]));
631
+ });
632
+ return;
633
+ }
634
+ // Jira doesn't sign, so WE mint a token and hand it back once.
635
+ const r = await api.setTrackerWebhook(opts, pid, { provider });
636
+ render(flags, r, () => {
637
+ ok('Generated a Jira webhook token.');
638
+ console.log(kv([
639
+ ['webhook url', r.webhook_url],
640
+ ['header', r.header ?? 'X-TillPulse-Webhook-Token'],
641
+ ['token', c.yellow(r.secret ?? '-')],
642
+ ]));
643
+ info('Add the token as that header on your Jira Automation "Send web request" rule.');
644
+ info('Copy it now — it will not be shown again.');
645
+ });
646
+ return;
647
+ }
648
+ case 'disconnect':
649
+ case 'rm': {
650
+ const pid = need(projectId, 'tilldev pulse tracker disconnect <projectId> --provider <p>');
651
+ const provider = trackerProvider(flags);
652
+ const r = await api.disconnectTracker(opts, pid, provider);
653
+ render(flags, r, () => ok(`Disconnected ${c.bold(provider)} from project ${c.dim(pid)}`));
654
+ return;
655
+ }
656
+ default:
657
+ throw new Error(`unknown: tilldev pulse tracker ${action}`);
658
+ }
659
+ }
660
+ async function cmdAuth(flags) {
661
+ const { opts } = resolveOpts(flags);
662
+ const sub = flags.positional[1];
663
+ switch (sub) {
664
+ case undefined:
665
+ case 'apps': {
666
+ if (flags.positional[2] === 'create') {
667
+ const name = need(flags.positional[3], 'tilldev auth apps create <name>');
668
+ const r = await api.createAuthApp(opts, name);
669
+ render(flags, r, () => {
670
+ ok(`Created auth app ${c.bold(r.app.name)}`);
671
+ console.log(kv([['id', r.app.id], ['public_id', r.app.public_id ?? '-'], ['slug', r.app.slug ?? '-']]));
672
+ });
673
+ return;
674
+ }
675
+ const r = await api.listAuthApps(opts);
676
+ render(flags, r, () => {
677
+ const cols = [
678
+ { header: 'NAME', get: (a) => c.bold(a.name) },
679
+ { header: 'SLUG', get: (a) => a.slug ?? '-' },
680
+ { header: 'PUBLIC ID', get: (a) => c.dim(a.public_id ?? a.id) },
681
+ ];
682
+ console.log(table(r.apps, cols));
683
+ });
684
+ return;
685
+ }
686
+ case 'app': {
687
+ const appId = need(flags.positional[2], 'tilldev auth app <appId>');
688
+ const r = await api.getAuthApp(opts, appId);
689
+ printJson(r);
690
+ return;
691
+ }
692
+ default:
693
+ throw new Error(`unknown: tilldev auth ${sub}`);
694
+ }
695
+ }
696
+ async function cmdTeam(flags) {
697
+ const { opts } = resolveOpts(flags);
698
+ const r = await api.listTeam(opts);
699
+ const members = r.members ?? r.team ?? [];
700
+ render(flags, r, () => {
701
+ const cols = [
702
+ { header: 'EMAIL', get: (m) => c.bold(m.email ?? '-') },
703
+ { header: 'NAME', get: (m) => m.name ?? '-' },
704
+ { header: 'ROLE', get: (m) => roleColor(m.role) },
705
+ { header: 'ID', get: (m) => c.dim(m.user_id ?? m.id ?? '-') },
706
+ ];
707
+ console.log(table(members, cols));
708
+ });
709
+ }
710
+ async function cmdKeys(flags) {
711
+ const { opts } = resolveOpts(flags);
712
+ if (flags.positional[1] === 'create') {
713
+ const name = need(flags.positional[2], 'tilldev keys create <name> [--scopes read,write]');
714
+ const scopes = (flags.named['scopes'] ?? 'read').split(',').map((s) => s.trim()).filter(Boolean);
715
+ const r = await api.createApiKey(opts, name, scopes);
716
+ const key = r.key ?? r.api_key;
717
+ render(flags, r, () => {
718
+ ok(`Created API key ${c.bold(name)}`);
719
+ if (key) {
720
+ console.log(kv([['key', c.yellow(key)]]));
721
+ info('Copy it now — it will not be shown again.');
722
+ }
723
+ });
724
+ return;
725
+ }
726
+ const r = await api.listApiKeys(opts);
727
+ render(flags, r, () => {
728
+ const cols = [
729
+ { header: 'NAME', get: (k) => c.bold(k.name) },
730
+ { header: 'SCOPES', get: (k) => (k.scopes ?? []).join(',') || '-' },
731
+ { header: 'LAST USED', get: (k) => ago(k.last_used_at, now) },
732
+ { header: 'ID', get: (k) => c.dim(k.id) },
733
+ ];
734
+ console.log(table(r.keys, cols));
735
+ });
736
+ }
737
+ // ── TillShield ────────────────────────────────────────────────────────────────
738
+ async function cmdShield(flags) {
739
+ const { opts } = resolveOpts(flags);
740
+ const sub = flags.positional[1];
741
+ switch (sub) {
742
+ case 'rules':
743
+ return cmdShieldRules(flags, opts);
744
+ case 'edge-keys':
745
+ case 'keys':
746
+ return cmdShieldEdgeKeys(flags, opts);
747
+ case 'settings': {
748
+ const { settings } = await api.getShieldSettings(opts);
749
+ render(flags, { settings }, () => console.log(kv([
750
+ ['panic mode', boolCell(settings.panic_mode)],
751
+ ['auto-quarantine compromised', boolCell(settings.auto_quarantine_compromised)],
752
+ ['auto-incident on indicator', boolCell(settings.auto_incident_on_confirmed_indicator)],
753
+ ['contribute threat intel', boolCell(settings.contribute_threat_intel)],
754
+ ])));
755
+ return;
756
+ }
757
+ case 'incidents': {
758
+ const r = await api.listIncidents(opts, flags.named['status']);
759
+ render(flags, r, () => {
760
+ const cols = [
761
+ { header: 'SEVERITY', get: (i) => levelColor(i.severity) },
762
+ { header: 'STATUS', get: (i) => i.status ?? '-' },
763
+ { header: 'TITLE', get: (i) => c.bold(truncate(i.title, 44)) },
764
+ { header: 'TYPE', get: (i) => i.security_type ?? '-' },
765
+ { header: 'WHEN', get: (i) => ago(i.created_at, now) },
766
+ { header: 'ID', get: (i) => c.dim(i.id) },
767
+ ];
768
+ console.log(table(r.incidents, cols));
769
+ });
770
+ return;
771
+ }
772
+ default:
773
+ throw new Error('usage: tilldev shield <rules|edge-keys|settings|incidents>');
774
+ }
775
+ }
776
+ async function cmdShieldRules(flags, opts) {
777
+ const action = flags.positional[2] ?? 'ls';
778
+ switch (action) {
779
+ case 'ls':
780
+ case 'list': {
781
+ const r = await api.listEdgeRules(opts);
782
+ render(flags, r, () => {
783
+ const cols = [
784
+ { header: 'PRI', get: (rule) => String(rule.priority) },
785
+ { header: 'NAME', get: (rule) => c.bold(rule.name) },
786
+ { header: 'MODE', get: (rule) => modeColor(rule.mode) },
787
+ { header: 'ENABLED', get: (rule) => (rule.enabled ? c.green('on') : c.dim('off')) },
788
+ { header: 'SCOPE', get: (rule) => (rule.project_id ? c.dim(truncate(rule.project_id, 12)) : 'org') },
789
+ { header: 'ID', get: (rule) => c.dim(rule.id) },
790
+ ];
791
+ console.log(table(r.rules, cols));
792
+ });
793
+ return;
794
+ }
795
+ case 'export': {
796
+ const { rules } = await api.listEdgeRules(opts);
797
+ const doc = toDoc(rules);
798
+ const json = JSON.stringify(doc, null, 2) + '\n';
799
+ const file = flags.named['file'] ?? flags.named['f'];
800
+ if (file) {
801
+ writeFileSync(file, json, 'utf8');
802
+ ok(`Exported ${rules.length} rule${rules.length === 1 ? '' : 's'} → ${c.bold(file)}`);
803
+ }
804
+ else {
805
+ output.write(json);
806
+ }
807
+ return;
808
+ }
809
+ case 'apply': {
810
+ const file = flags.named['file'] ?? flags.named['f'];
811
+ if (!file)
812
+ throw new Error('usage: tilldev shield rules apply -f <file> [--dry-run] [--prune]');
813
+ const desired = parseDoc(readFileSync(file, 'utf8'));
814
+ const { rules: existing } = await api.listEdgeRules(opts);
815
+ const prune = flags.bool.has('prune');
816
+ const plan = planApply(existing, desired, { prune });
817
+ const changes = plan.create.length + plan.update.length + plan.del.length;
818
+ if (flags.bool.has('json')) {
819
+ printJson({
820
+ create: plan.create.map((r) => r.name),
821
+ update: plan.update.map((u) => u.name),
822
+ delete: plan.del.map((d) => d.name),
823
+ unchanged: plan.unchanged.length,
824
+ dry_run: flags.bool.has('dry-run'),
825
+ });
826
+ }
827
+ else {
828
+ printRulePlan(plan, prune);
829
+ }
830
+ if (changes === 0) {
831
+ if (!flags.bool.has('json'))
832
+ ok('Already in sync — nothing to apply.');
833
+ return;
834
+ }
835
+ if (flags.bool.has('dry-run')) {
836
+ if (!flags.bool.has('json'))
837
+ info(`Dry run — ${changes} change${changes === 1 ? '' : 's'} not applied.`);
838
+ return;
839
+ }
840
+ for (const r of plan.create) {
841
+ await api.createEdgeRule(opts, toApiBody(r));
842
+ if (!flags.bool.has('json'))
843
+ ok(`${c.green('+')} created ${c.bold(r.name)}`);
844
+ }
845
+ for (const u of plan.update) {
846
+ await api.updateEdgeRule(opts, u.id, toApiBody(u.to));
847
+ if (!flags.bool.has('json'))
848
+ ok(`${c.yellow('~')} updated ${c.bold(u.name)}`);
849
+ }
850
+ for (const d of plan.del) {
851
+ await api.deleteEdgeRule(opts, d.id);
852
+ if (!flags.bool.has('json'))
853
+ ok(`${c.red('-')} deleted ${c.bold(d.name)}`);
854
+ }
855
+ if (!flags.bool.has('json'))
856
+ ok(`Applied — ${plan.create.length} created, ${plan.update.length} updated, ${plan.del.length} deleted.`);
857
+ return;
858
+ }
859
+ case 'rm':
860
+ case 'delete': {
861
+ const id = need(flags.positional[3], 'tilldev shield rules rm <id>');
862
+ await api.deleteEdgeRule(opts, id);
863
+ ok(`Deleted rule ${c.bold(id)}`);
864
+ return;
865
+ }
866
+ default:
867
+ throw new Error(`unknown: tilldev shield rules ${action}`);
868
+ }
869
+ }
870
+ function printRulePlan(plan, prune) {
871
+ const line = (sym, name) => console.log(` ${sym} ${name}`);
872
+ for (const r of plan.create)
873
+ line(c.green('+ create'), c.bold(r.name));
874
+ for (const u of plan.update)
875
+ line(c.yellow('~ update'), c.bold(u.name));
876
+ for (const d of plan.del)
877
+ line(c.red('- delete'), c.bold(d.name));
878
+ if (plan.unchanged.length)
879
+ console.log(c.dim(` = ${plan.unchanged.length} unchanged`));
880
+ const summary = `plan: ${plan.create.length} to create, ${plan.update.length} to update, ${plan.del.length} to delete`;
881
+ console.log(c.dim(prune ? summary : `${summary} (server-only rules kept — pass --prune to delete them)`));
882
+ }
883
+ async function cmdShieldEdgeKeys(flags, opts) {
884
+ const action = flags.positional[2] ?? 'ls';
885
+ switch (action) {
886
+ case 'ls':
887
+ case 'list': {
888
+ const r = await api.listEdgeKeys(opts);
889
+ render(flags, r, () => {
890
+ const cols = [
891
+ { header: 'NAME', get: (k) => c.bold(k.name) },
892
+ { header: 'PREFIX', get: (k) => k.key_prefix },
893
+ { header: 'PROJECT', get: (k) => c.dim(truncate(k.project_id, 12)) },
894
+ { header: 'LAST USED', get: (k) => ago(k.last_used_at, now) },
895
+ { header: 'STATUS', get: (k) => (k.revoked_at ? c.red('revoked') : c.green('active')) },
896
+ { header: 'ID', get: (k) => c.dim(k.id) },
897
+ ];
898
+ console.log(table(r.keys, cols));
899
+ });
900
+ return;
901
+ }
902
+ case 'create': {
903
+ const projectId = need(flags.named['project'] ?? flags.positional[3], 'tilldev shield edge-keys create --project <id> [--name <n>]');
904
+ const name = flags.named['name'] ?? 'default';
905
+ const { key } = await api.createEdgeKey(opts, projectId, name);
906
+ render(flags, { key }, () => {
907
+ ok(`Created edge key ${c.bold(key.name)}`);
908
+ console.log(kv([['id', key.id], ['prefix', key.key_prefix], ['secret', c.yellow(key.secret ?? '-')]]));
909
+ info('Copy the secret now — it will not be shown again.');
910
+ });
911
+ return;
912
+ }
913
+ case 'revoke':
914
+ case 'rm': {
915
+ const id = need(flags.positional[3], 'tilldev shield edge-keys revoke <id>');
916
+ await api.revokeEdgeKey(opts, id);
917
+ ok(`Revoked edge key ${c.bold(id)}`);
918
+ return;
919
+ }
920
+ default:
921
+ throw new Error(`unknown: tilldev shield edge-keys ${action}`);
922
+ }
923
+ }
924
+ // ── TillGate ──────────────────────────────────────────────────────────────────
925
+ async function cmdGate(flags) {
926
+ const { opts } = resolveOpts(flags);
927
+ const sub = flags.positional[1];
928
+ if (sub !== undefined && sub !== 'sites')
929
+ throw new Error('usage: tilldev gate sites <ls|create|update|rm>');
930
+ const action = flags.positional[2] ?? 'ls';
931
+ switch (action) {
932
+ case 'ls':
933
+ case 'list': {
934
+ const r = await api.listGateSites(opts);
935
+ render(flags, r, () => {
936
+ const cols = [
937
+ { header: 'NAME', get: (s) => c.bold(s.name) },
938
+ { header: 'SITE KEY', get: (s) => c.dim(s.site_key) },
939
+ { header: 'MODE', get: (s) => s.mode },
940
+ { header: 'DIFF', get: (s) => String(s.difficulty) },
941
+ { header: 'TTL', get: (s) => `${s.token_ttl_seconds}s` },
942
+ { header: 'HOSTS', get: (s) => (s.hostname_allowlist.length ? String(s.hostname_allowlist.length) : c.dim('any')) },
943
+ { header: 'ID', get: (s) => c.dim(s.id) },
944
+ ];
945
+ console.log(table(r.sites, cols));
946
+ });
947
+ return;
948
+ }
949
+ case 'create': {
950
+ const projectId = need(flags.named['project'] ?? flags.positional[3], 'tilldev gate sites create --project <id>');
951
+ const body = { project_id: projectId };
952
+ if (flags.named['name'])
953
+ body.name = flags.named['name'];
954
+ if (flags.named['mode'])
955
+ body.mode = flags.named['mode'];
956
+ if (flags.named['difficulty'])
957
+ body.difficulty = Number(flags.named['difficulty']);
958
+ if (flags.named['ttl'])
959
+ body.token_ttl_seconds = Number(flags.named['ttl']);
960
+ if (flags.named['hosts'])
961
+ body.hostname_allowlist = flags.named['hosts'].split(',').map((h) => h.trim()).filter(Boolean);
962
+ const { site } = await api.createGateSite(opts, body);
963
+ render(flags, { site }, () => {
964
+ ok(`Created TillGate site ${c.bold(site.name)}`);
965
+ console.log(kv([
966
+ ['id', site.id],
967
+ ['site key', site.site_key],
968
+ ['secret', c.yellow(site.secret ?? '-')],
969
+ ['mode', site.mode],
970
+ ['difficulty', String(site.difficulty)],
971
+ ]));
972
+ info('The site key is public (embed it client-side); copy the secret now — it will not be shown again.');
973
+ });
974
+ return;
975
+ }
976
+ case 'update': {
977
+ const id = need(flags.positional[3], 'tilldev gate sites update <id> [flags]');
978
+ const body = {};
979
+ if (flags.named['name'])
980
+ body.name = flags.named['name'];
981
+ if (flags.named['mode'])
982
+ body.mode = flags.named['mode'];
983
+ if (flags.named['difficulty'])
984
+ body.difficulty = Number(flags.named['difficulty']);
985
+ if (flags.named['ttl'])
986
+ body.token_ttl_seconds = Number(flags.named['ttl']);
987
+ if (flags.named['hosts'])
988
+ body.hostname_allowlist = flags.named['hosts'].split(',').map((h) => h.trim()).filter(Boolean);
989
+ if (Object.keys(body).length === 0)
990
+ throw new Error('nothing to update — pass --name/--mode/--difficulty/--ttl/--hosts');
991
+ const { site } = await api.updateGateSite(opts, id, body);
992
+ render(flags, { site }, () => ok(`Updated site ${c.bold(site.name)}`));
993
+ return;
994
+ }
995
+ case 'rm':
996
+ case 'delete': {
997
+ const id = need(flags.positional[3], 'tilldev gate sites rm <id>');
998
+ await api.deleteGateSite(opts, id);
999
+ ok(`Deleted site ${c.bold(id)}`);
1000
+ return;
1001
+ }
1002
+ default:
1003
+ throw new Error(`unknown: tilldev gate sites ${action}`);
1004
+ }
1005
+ }
1006
+ // ── Small color helpers for status/level cells ───────────────────────────────
1007
+ function boolCell(v) {
1008
+ return v ? c.green('on') : c.dim('off');
1009
+ }
1010
+ function modeColor(mode) {
1011
+ switch (mode) {
1012
+ case 'block':
1013
+ return c.red('block');
1014
+ case 'challenge':
1015
+ return c.yellow('challenge');
1016
+ case 'log':
1017
+ return c.blue('log');
1018
+ default:
1019
+ return mode ?? '-';
1020
+ }
1021
+ }
1022
+ function levelColor(level) {
1023
+ switch ((level ?? '').toLowerCase()) {
1024
+ case 'critical':
1025
+ case 'fatal':
1026
+ return c.red(level);
1027
+ case 'high':
1028
+ case 'error':
1029
+ return c.red(level);
1030
+ case 'medium':
1031
+ case 'warning':
1032
+ return c.yellow(level);
1033
+ case 'low':
1034
+ case 'info':
1035
+ return c.blue(level);
1036
+ default:
1037
+ return level ?? '-';
1038
+ }
1039
+ }
1040
+ function roleColor(role) {
1041
+ switch ((role ?? '').toLowerCase()) {
1042
+ case 'owner':
1043
+ return c.magenta('owner');
1044
+ case 'admin':
1045
+ return c.cyan('admin');
1046
+ default:
1047
+ return role ?? '-';
1048
+ }
1049
+ }
1050
+ // ── TillCache ──────────────────────────────────────────────────────────────────
1051
+ async function cmdCache(flags) {
1052
+ const { opts } = resolveOpts(flags);
1053
+ const sub = flags.positional[1] ?? 'ns';
1054
+ switch (sub) {
1055
+ case 'ns':
1056
+ case 'namespaces':
1057
+ return cmdCacheNs(flags, opts);
1058
+ case 'tokens':
1059
+ case 'token':
1060
+ return cmdCacheTokens(flags, opts);
1061
+ case 'queues': {
1062
+ const nsId = need(flags.named['ns'] ?? flags.positional[2], 'tilldev cache queues --ns <id>');
1063
+ const r = await api.listCacheQueues(opts, nsId);
1064
+ render(flags, r, () => {
1065
+ const cols = [
1066
+ { header: 'QUEUE', get: (q) => c.bold(q.name) },
1067
+ { header: 'DEPTH', get: (q) => String(q.depth) },
1068
+ { header: 'IN-FLIGHT', get: (q) => String(q.in_flight) },
1069
+ { header: 'DLQ', get: (q) => (q.dead_lettered ? c.red(String(q.dead_lettered)) : c.dim('0')) },
1070
+ { header: 'DELIVERED', get: (q) => String(q.delivered_total) },
1071
+ ];
1072
+ console.log(table(r.queues, cols));
1073
+ });
1074
+ return;
1075
+ }
1076
+ case 'keys': {
1077
+ const nsId = need(flags.named['ns'] ?? flags.positional[2], 'tilldev cache keys --ns <id>');
1078
+ const r = await api.listCacheKeys(opts, nsId);
1079
+ render(flags, r, () => {
1080
+ const cols = [
1081
+ { header: 'KEY', get: (k) => c.bold(truncate(k.key, 40)) },
1082
+ { header: 'KIND', get: (k) => k.kind },
1083
+ { header: 'HITS', get: (k) => String(k.hits) },
1084
+ { header: 'BYTES', get: (k) => String(k.bytes) },
1085
+ { header: 'HOT', get: (k) => (k.hot ? c.yellow('hot') : c.dim('-')) },
1086
+ ];
1087
+ console.log(table(r.keys, cols));
1088
+ });
1089
+ return;
1090
+ }
1091
+ case 'usage': {
1092
+ const nsId = need(flags.named['ns'] ?? flags.positional[2], 'tilldev cache usage --ns <id>');
1093
+ const r = await api.getCacheUsage(opts, nsId);
1094
+ render(flags, r, () => {
1095
+ ok('Usage (last 7 days)');
1096
+ console.log(kv([
1097
+ ['commands', String(r.totals.commands)],
1098
+ ['queue messages', String(r.totals.queue_messages)],
1099
+ ['publishes', String(r.totals.publishes)],
1100
+ ['peak storage (bytes)', String(r.totals.storage_bytes)],
1101
+ ]));
1102
+ });
1103
+ return;
1104
+ }
1105
+ default:
1106
+ throw new Error('usage: tilldev cache <ns|tokens|queues|keys|usage>');
1107
+ }
1108
+ }
1109
+ async function cmdCacheNs(flags, opts) {
1110
+ const action = flags.positional[2] ?? 'ls';
1111
+ switch (action) {
1112
+ case 'ls':
1113
+ case 'list': {
1114
+ const r = await api.listNamespaces(opts);
1115
+ render(flags, r, () => {
1116
+ const cols = [
1117
+ { header: 'NAME', get: (n) => c.bold(n.name) },
1118
+ { header: 'SLUG', get: (n) => n.slug },
1119
+ { header: 'BACKEND', get: (n) => n.backend },
1120
+ { header: 'CONSIST', get: (n) => n.consistency },
1121
+ { header: 'STATUS', get: (n) => (n.status === 'active' ? c.green(n.status) : c.yellow(n.status)) },
1122
+ { header: 'ID', get: (n) => c.dim(n.id) },
1123
+ ];
1124
+ console.log(table(r.namespaces, cols));
1125
+ });
1126
+ return;
1127
+ }
1128
+ case 'create': {
1129
+ const name = need(flags.named['name'] ?? flags.positional[3], 'tilldev cache ns create --name <n> --slug <s> [--backend upstash|durable|byo|auto]');
1130
+ const slug = need(flags.named['slug'], 'tilldev cache ns create --name <n> --slug <s>');
1131
+ const backend = flags.named['backend'] ?? 'upstash';
1132
+ const input = { name, slug, backend };
1133
+ if (flags.named['consistency'])
1134
+ input.consistency = flags.named['consistency'];
1135
+ if (flags.named['region'])
1136
+ input.region = flags.named['region'];
1137
+ if (backend === 'byo') {
1138
+ const url = need(flags.named['byo-url'], 'backend byo requires --byo-url <https url>');
1139
+ const token = await readSecretArg(flags, 'byo-token', 'BYO backend token');
1140
+ input.byo = { url, token };
1141
+ }
1142
+ const { namespace } = await api.createNamespace(opts, input);
1143
+ render(flags, { namespace }, () => {
1144
+ ok(`Created namespace ${c.bold(namespace.name)} (${namespace.slug})`);
1145
+ console.log(kv([['id', namespace.id], ['backend', namespace.backend], ['consistency', namespace.consistency]]));
1146
+ info('Mint a data-plane token: tilldev cache tokens create --ns ' + namespace.id);
1147
+ });
1148
+ return;
1149
+ }
1150
+ case 'get': {
1151
+ const id = need(flags.positional[3], 'tilldev cache ns get <id>');
1152
+ const r = await api.getNamespace(opts, id);
1153
+ render(flags, r, () => console.log(kv(Object.entries(r.namespace).map(([k, v]) => [k, String(v)]))));
1154
+ return;
1155
+ }
1156
+ case 'pause':
1157
+ case 'resume': {
1158
+ const id = need(flags.positional[3], `tilldev cache ns ${action} <id>`);
1159
+ const { namespace } = await api.updateNamespace(opts, id, { status: action === 'pause' ? 'paused' : 'active' });
1160
+ ok(`Namespace ${c.bold(namespace.name)} is now ${namespace.status}`);
1161
+ return;
1162
+ }
1163
+ case 'rm':
1164
+ case 'delete': {
1165
+ const id = need(flags.positional[3], 'tilldev cache ns rm <id>');
1166
+ await api.deleteNamespace(opts, id);
1167
+ ok(`Deleted namespace ${c.bold(id)}`);
1168
+ return;
1169
+ }
1170
+ default:
1171
+ throw new Error(`unknown: tilldev cache ns ${action}`);
1172
+ }
1173
+ }
1174
+ async function cmdCacheTokens(flags, opts) {
1175
+ const action = flags.positional[2] ?? 'ls';
1176
+ const nsId = need(flags.named['ns'], 'tilldev cache tokens <ls|create|revoke> --ns <namespace-id>');
1177
+ switch (action) {
1178
+ case 'ls':
1179
+ case 'list': {
1180
+ const r = await api.listCacheTokens(opts, nsId);
1181
+ render(flags, r, () => {
1182
+ const cols = [
1183
+ { header: 'NAME', get: (t) => c.bold(t.name) },
1184
+ { header: 'PREFIX', get: (t) => t.token_prefix },
1185
+ { header: 'SCOPE', get: (t) => t.scope },
1186
+ { header: 'LAST USED', get: (t) => ago(t.last_used_at, now) },
1187
+ { header: 'STATUS', get: (t) => (t.revoked_at ? c.red('revoked') : c.green('active')) },
1188
+ { header: 'ID', get: (t) => c.dim(t.id) },
1189
+ ];
1190
+ console.log(table(r.tokens, cols));
1191
+ });
1192
+ return;
1193
+ }
1194
+ case 'create': {
1195
+ const name = flags.named['name'] ?? 'default';
1196
+ const scope = flags.named['scope'] ?? 'rw';
1197
+ const { token } = await api.createCacheToken(opts, nsId, name, scope);
1198
+ render(flags, { token }, () => {
1199
+ ok(`Created ${scope} token ${c.bold(token.name)}`);
1200
+ console.log(kv([['id', token.id], ['prefix', token.token_prefix], ['secret', c.yellow(token.secret ?? '-')]]));
1201
+ info('Copy the secret now — it will not be shown again.');
1202
+ });
1203
+ return;
1204
+ }
1205
+ case 'revoke':
1206
+ case 'rm': {
1207
+ const id = need(flags.positional[3], 'tilldev cache tokens revoke <token-id> --ns <namespace-id>');
1208
+ await api.revokeCacheToken(opts, nsId, id);
1209
+ ok(`Revoked token ${c.bold(id)}`);
1210
+ return;
1211
+ }
1212
+ default:
1213
+ throw new Error(`unknown: tilldev cache tokens ${action}`);
1214
+ }
1215
+ }
1216
+ // ── TillSecrets ────────────────────────────────────────────────────────────────
1217
+ async function cmdSecrets(flags) {
1218
+ const { opts } = resolveOpts(flags);
1219
+ const sub = flags.positional[1] ?? 'ls';
1220
+ switch (sub) {
1221
+ case 'projects':
1222
+ case 'project': {
1223
+ const action = flags.positional[2] ?? 'ls';
1224
+ if (action === 'create') {
1225
+ const name = need(flags.named['name'] ?? flags.positional[3], 'tilldev secrets projects create --name <n> --slug <s>');
1226
+ const slug = need(flags.named['slug'], 'tilldev secrets projects create --name <n> --slug <s>');
1227
+ const { project } = await api.createSecretsProject(opts, name, slug, flags.named['description'] ?? '');
1228
+ render(flags, { project }, () => ok(`Created project ${c.bold(project.name)} (${project.slug}) — ${c.dim(project.id)}`));
1229
+ return;
1230
+ }
1231
+ const r = await api.listSecretsProjects(opts);
1232
+ render(flags, r, () => {
1233
+ const cols = [
1234
+ { header: 'NAME', get: (p) => c.bold(p.name) },
1235
+ { header: 'SLUG', get: (p) => p.slug },
1236
+ { header: 'ID', get: (p) => c.dim(p.id) },
1237
+ ];
1238
+ console.log(table(r.projects, cols));
1239
+ });
1240
+ return;
1241
+ }
1242
+ case 'env':
1243
+ case 'envs':
1244
+ case 'environments': {
1245
+ const projectId = need(flags.named['project'] ?? flags.positional[3], 'tilldev secrets env <ls|create> --project <id>');
1246
+ const action = flags.positional[2] ?? 'ls';
1247
+ if (action === 'create') {
1248
+ const name = need(flags.named['name'], 'tilldev secrets env create --project <id> --name <n> --slug <s>');
1249
+ const slug = need(flags.named['slug'], 'tilldev secrets env create --project <id> --name <n> --slug <s>');
1250
+ const { environment } = await api.createSecretsEnvironment(opts, projectId, name, slug);
1251
+ render(flags, { environment }, () => ok(`Created environment ${c.bold(environment.name)} — ${c.dim(environment.id)}`));
1252
+ return;
1253
+ }
1254
+ const r = await api.listSecretsEnvironments(opts, projectId);
1255
+ render(flags, r, () => {
1256
+ const cols = [
1257
+ { header: 'NAME', get: (e) => c.bold(e.name) },
1258
+ { header: 'SLUG', get: (e) => e.slug },
1259
+ { header: 'ID', get: (e) => c.dim(e.id) },
1260
+ ];
1261
+ console.log(table(r.environments, cols));
1262
+ });
1263
+ return;
1264
+ }
1265
+ case 'ls':
1266
+ case 'list': {
1267
+ const envId = need(flags.named['env'], 'tilldev secrets ls --env <environment-id>');
1268
+ const r = await api.listSecrets(opts, envId);
1269
+ render(flags, r, () => {
1270
+ const cols = [
1271
+ { header: 'KEY', get: (s) => c.bold(s.key) },
1272
+ { header: 'KIND', get: (s) => (s.kind === 'dynamic' ? c.magenta('dynamic') : 'static') },
1273
+ { header: 'VER', get: (s) => `v${s.current_version}` },
1274
+ { header: 'ID', get: (s) => c.dim(s.id) },
1275
+ ];
1276
+ console.log(table(r.secrets, cols));
1277
+ });
1278
+ return;
1279
+ }
1280
+ case 'set': {
1281
+ const envId = need(flags.named['env'], 'tilldev secrets set <KEY> --env <id> [--value <v>]');
1282
+ const key = need(flags.positional[2], 'tilldev secrets set <KEY> --env <id>');
1283
+ const value = await readSecretArg(flags, 'value', `value for ${key}`);
1284
+ const { secret } = await api.setSecret(opts, envId, key, value, flags.named['comment'] ?? '');
1285
+ render(flags, { secret }, () => ok(`Set ${c.bold(secret.key)} → v${secret.version}`));
1286
+ return;
1287
+ }
1288
+ case 'get':
1289
+ case 'reveal': {
1290
+ const secretId = need(flags.positional[2], 'tilldev secrets get <secret-id> [--version <n>]');
1291
+ const version = flags.named['version'] ? Number(flags.named['version']) : undefined;
1292
+ const r = await api.revealSecret(opts, secretId, version);
1293
+ render(flags, r, () => {
1294
+ warn('Revealing a secret is audited.');
1295
+ console.log(kv([['key', r.key], ['version', String(r.version)], ['value', c.yellow(r.value)]]));
1296
+ });
1297
+ return;
1298
+ }
1299
+ case 'versions': {
1300
+ const secretId = need(flags.positional[2], 'tilldev secrets versions <secret-id>');
1301
+ const r = await api.listSecretVersions(opts, secretId);
1302
+ render(flags, r, () => {
1303
+ const cols = [
1304
+ { header: 'VER', get: (v) => (v.version === r.current_version ? c.green(`v${v.version} *`) : `v${v.version}`) },
1305
+ { header: 'HASH', get: (v) => c.dim(truncate(v.value_hash, 12)) },
1306
+ { header: 'COMMENT', get: (v) => v.comment ?? '' },
1307
+ { header: 'WHEN', get: (v) => ago(v.created_at, now) },
1308
+ ];
1309
+ console.log(table(r.versions, cols));
1310
+ });
1311
+ return;
1312
+ }
1313
+ case 'rollback': {
1314
+ const secretId = need(flags.positional[2], 'tilldev secrets rollback <secret-id> --version <n>');
1315
+ const version = Number(need(flags.named['version'], 'tilldev secrets rollback <secret-id> --version <n>'));
1316
+ const r = await api.rollbackSecret(opts, secretId, version);
1317
+ ok(`Rolled back to v${r.current_version}`);
1318
+ return;
1319
+ }
1320
+ case 'rm':
1321
+ case 'delete': {
1322
+ const secretId = need(flags.positional[2], 'tilldev secrets rm <secret-id>');
1323
+ await api.deleteSecret(opts, secretId);
1324
+ ok(`Deleted secret ${c.bold(secretId)}`);
1325
+ return;
1326
+ }
1327
+ case 'sync': {
1328
+ const projectId = need(flags.named['project'], 'tilldev secrets sync <ls|add|rm|pause|resume|push> --project <id>');
1329
+ const action = flags.positional[2] ?? 'ls';
1330
+ if (action === 'add') {
1331
+ const environment_id = need(flags.named['env'], 'tilldev secrets sync add --project <id> --env <envId> --provider <p> --config <json>');
1332
+ const provider = need(flags.named['provider'], 'sync add … --provider <cloudflare|vercel|railway|github|aws_ssm|dotenv|custom>');
1333
+ const configRaw = need(flags.named['config'], "sync add … --config '{\"token\":\"…\"}'");
1334
+ let config;
1335
+ try {
1336
+ config = JSON.parse(configRaw);
1337
+ }
1338
+ catch {
1339
+ throw new Error('--config must be a valid JSON object');
1340
+ }
1341
+ const { target } = await api.createSyncTarget(opts, projectId, { environment_id, provider, config });
1342
+ render(flags, { target }, () => ok(`Added ${c.bold(target.provider)} sync target — ${c.dim(target.id)}`));
1343
+ return;
1344
+ }
1345
+ if (action === 'rm' || action === 'delete') {
1346
+ const targetId = need(flags.positional[3], 'tilldev secrets sync rm <target-id> --project <id>');
1347
+ await api.deleteSyncTarget(opts, projectId, targetId);
1348
+ ok(`Deleted sync target ${c.bold(targetId)}`);
1349
+ return;
1350
+ }
1351
+ if (action === 'pause' || action === 'resume') {
1352
+ const targetId = need(flags.positional[3], `tilldev secrets sync ${action} <target-id> --project <id>`);
1353
+ const status = action === 'pause' ? 'paused' : 'active';
1354
+ await api.setSyncTargetStatus(opts, projectId, targetId, status);
1355
+ ok(`Sync target ${c.bold(targetId)} → ${status}`);
1356
+ return;
1357
+ }
1358
+ if (action === 'push') {
1359
+ const targetId = need(flags.positional[3], 'tilldev secrets sync push <target-id> --project <id>');
1360
+ await api.pushSyncTarget(opts, projectId, targetId);
1361
+ ok(`Pushed sync target ${c.bold(targetId)}`);
1362
+ return;
1363
+ }
1364
+ const r = await api.listSyncTargets(opts, projectId);
1365
+ render(flags, r, () => {
1366
+ const cols = [
1367
+ { header: 'PROVIDER', get: (t) => c.bold(t.provider) },
1368
+ { header: 'ENV', get: (t) => c.dim(t.environment_id) },
1369
+ {
1370
+ header: 'STATUS',
1371
+ get: (t) => (t.status === 'error' ? c.red(t.status) : t.status === 'paused' ? c.yellow(t.status) : c.green(t.status)),
1372
+ },
1373
+ { header: 'LAST SYNC', get: (t) => (t.last_synced_at ? ago(t.last_synced_at, now) : c.dim('never')) },
1374
+ { header: 'ID', get: (t) => c.dim(t.id) },
1375
+ ];
1376
+ console.log(table(r.targets, cols));
1377
+ for (const t of r.targets)
1378
+ if (t.last_error)
1379
+ warn(`${t.provider} (${t.id.slice(0, 8)}): ${t.last_error}`);
1380
+ });
1381
+ return;
1382
+ }
1383
+ case 'tokens':
1384
+ case 'token': {
1385
+ const projectId = need(flags.named['project'], 'tilldev secrets tokens <ls|create|revoke> --project <id>');
1386
+ const action = flags.positional[2] ?? 'ls';
1387
+ if (action === 'create') {
1388
+ const body = {
1389
+ name: flags.named['name'] ?? 'default',
1390
+ scope: flags.named['scope'] ?? 'read',
1391
+ };
1392
+ if (flags.named['env'])
1393
+ body.environment_id = flags.named['env'];
1394
+ const { token } = await api.createSecretsToken(opts, projectId, body);
1395
+ render(flags, { token }, () => {
1396
+ ok(`Created ${token.scope} token ${c.bold(token.name)}`);
1397
+ console.log(kv([['id', token.id], ['prefix', token.token_prefix], ['secret', c.yellow(token.secret ?? '-')]]));
1398
+ info('Copy the secret now — it will not be shown again.');
1399
+ });
1400
+ return;
1401
+ }
1402
+ if (action === 'revoke' || action === 'rm') {
1403
+ const id = need(flags.positional[3], 'tilldev secrets tokens revoke <token-id> --project <id>');
1404
+ await api.revokeSecretsToken(opts, projectId, id);
1405
+ ok(`Revoked token ${c.bold(id)}`);
1406
+ return;
1407
+ }
1408
+ const r = await api.listSecretsTokens(opts, projectId);
1409
+ render(flags, r, () => {
1410
+ const cols = [
1411
+ { header: 'NAME', get: (t) => c.bold(t.name) },
1412
+ { header: 'PREFIX', get: (t) => t.token_prefix },
1413
+ { header: 'SCOPE', get: (t) => t.scope },
1414
+ { header: 'LAST USED', get: (t) => ago(t.last_used_at, now) },
1415
+ { header: 'STATUS', get: (t) => (t.revoked_at ? c.red('revoked') : c.green('active')) },
1416
+ { header: 'ID', get: (t) => c.dim(t.id) },
1417
+ ];
1418
+ console.log(table(r.tokens, cols));
1419
+ });
1420
+ return;
1421
+ }
1422
+ case 'pull': {
1423
+ // Boot-time bulk pull. Uses a `ts_…` service token (NOT the dashboard
1424
+ // session token). Default output is dotenv on stdout (redirect to .env);
1425
+ // --json prints the raw key→value map plus the dynamic-key list.
1426
+ const svcToken = flags.named['token'] ?? process.env['TILLSECRETS_TOKEN'] ?? opts.token;
1427
+ if (!svcToken)
1428
+ throw new Error('secrets pull needs a ts_ service token — pass --token ts_… or set TILLSECRETS_TOKEN');
1429
+ assertSafeApiUrl(opts.apiUrl);
1430
+ const pullOpts = { apiUrl: opts.apiUrl, token: svcToken };
1431
+ const r = await api.pullSecrets(pullOpts, flags.named['env']);
1432
+ if (flags.bool.has('json')) {
1433
+ printJson(r);
1434
+ return;
1435
+ }
1436
+ const keys = Object.keys(r.secrets).sort();
1437
+ for (const k of keys)
1438
+ output.write(`${k}=${dotenvQuote(r.secrets[k] ?? '')}\n`);
1439
+ for (const d of r.dynamic)
1440
+ output.write(`# dynamic: ${d} — lease with \`tilldev secrets lease ${d}\`\n`);
1441
+ if (keys.length === 0 && r.dynamic.length === 0)
1442
+ output.write('# (no secrets in this environment)\n');
1443
+ return;
1444
+ }
1445
+ case 'lease': {
1446
+ // Dynamic-secret leasing hits the SEPARATE lease service (default
1447
+ // secrets.tilldev.dev), not the dashboard host. Needs a read_write ts_ token.
1448
+ const action = flags.positional[2];
1449
+ const svcToken = flags.named['token'] ?? process.env['TILLSECRETS_TOKEN'] ?? opts.token;
1450
+ if (!svcToken)
1451
+ throw new Error('secrets lease needs a read_write ts_ service token — pass --token ts_… or set TILLSECRETS_TOKEN');
1452
+ const leaseBase = (flags.named['lease-url'] ?? process.env['TILLSECRETS_LEASE_URL'] ?? 'https://secrets.tilldev.dev').replace(/\/$/, '');
1453
+ assertSafeApiUrl(leaseBase);
1454
+ const leaseOpts = { apiUrl: leaseBase, token: svcToken };
1455
+ if (action === 'revoke') {
1456
+ const id = need(flags.positional[3], 'tilldev secrets lease revoke <lease-id>');
1457
+ const r = await api.revokeLease(leaseOpts, id);
1458
+ render(flags, r, () => ok(r.already ? `Lease ${c.bold(id)} already ${r.already}` : `Revoked lease ${c.bold(id)}`));
1459
+ return;
1460
+ }
1461
+ // `secrets lease <secret-key>` or `secrets lease --backend <id>`.
1462
+ const positionalKey = action && action !== 'issue' ? action : undefined;
1463
+ const secretKey = flags.named['key'] ?? positionalKey;
1464
+ const backendId = flags.named['backend'];
1465
+ if (!secretKey && !backendId) {
1466
+ throw new Error('usage: tilldev secrets lease <secret-key> [--ttl <sec>] (or --backend <id>)');
1467
+ }
1468
+ const body = {};
1469
+ if (secretKey)
1470
+ body.secret_key = secretKey;
1471
+ if (backendId)
1472
+ body.backend_id = backendId;
1473
+ if (flags.named['ttl'])
1474
+ body.ttl_seconds = Number(flags.named['ttl']);
1475
+ const r = await api.leaseSecret(leaseOpts, body);
1476
+ render(flags, r, () => {
1477
+ ok(`Leased ${c.bold(secretKey ?? backendId ?? 'credential')}`);
1478
+ console.log(kv([['lease id', r.lease_id], ['expires', r.expires_at], ['ttl', `${r.ttl_seconds}s`]]));
1479
+ for (const [k, v] of Object.entries(r.credential))
1480
+ console.log(kv([[k, c.yellow(v)]]));
1481
+ info(`Credential shown once. Revoke early: \`tilldev secrets lease revoke ${r.lease_id}\``);
1482
+ });
1483
+ return;
1484
+ }
1485
+ default:
1486
+ throw new Error('usage: tilldev secrets <projects|env|ls|set|get|versions|rollback|rm|pull|lease|sync|tokens>');
1487
+ }
1488
+ }
1489
+ // ── TillArk ────────────────────────────────────────────────────────────────────
1490
+ // Backup / disaster-recovery control plane. Dangerous verbs (real restore,
1491
+ // failover promote/failback) demand a four-eyes approval: a DIFFERENT owner/
1492
+ // admin's user id (--approved-by) plus a reference (--approval-ref, e.g. a
1493
+ // change ticket). The server re-verifies the approver — it is never trusted here.
1494
+ function arkApproval(flags) {
1495
+ const approved_by = need(flags.named['approved-by'], 'this action needs --approved-by <second approver user id> --approval-ref <ref>');
1496
+ const approval_ref = need(flags.named['approval-ref'], 'this action needs --approval-ref <change ticket / incident id>');
1497
+ return { approved_by, approval_ref };
1498
+ }
1499
+ const ARK_DEFAULT_RETENTION = {
1500
+ keepLast: 7, keepHourly: 24, keepDaily: 7, keepWeekly: 4, keepMonthly: 12, keepYearly: 3,
1501
+ };
1502
+ function arkRetentionFromFlags(flags) {
1503
+ const keys = [
1504
+ ['keep-last', 'keepLast'], ['keep-hourly', 'keepHourly'], ['keep-daily', 'keepDaily'],
1505
+ ['keep-weekly', 'keepWeekly'], ['keep-monthly', 'keepMonthly'], ['keep-yearly', 'keepYearly'],
1506
+ ];
1507
+ if (!keys.some(([flag]) => flags.named[flag] !== undefined))
1508
+ return undefined;
1509
+ const r = { ...ARK_DEFAULT_RETENTION };
1510
+ for (const [flag, field] of keys) {
1511
+ if (flags.named[flag] !== undefined)
1512
+ r[field] = Number(flags.named[flag]);
1513
+ }
1514
+ return r;
1515
+ }
1516
+ function arkState(state) {
1517
+ switch (state) {
1518
+ case 'healthy':
1519
+ return c.green(state);
1520
+ case 'promoted':
1521
+ case 'failback':
1522
+ return c.cyan(state);
1523
+ case 'failed':
1524
+ return c.red(state);
1525
+ case 'suspect':
1526
+ case 'fencing':
1527
+ case 'reverse_sync':
1528
+ return c.yellow(state);
1529
+ default:
1530
+ return state;
1531
+ }
1532
+ }
1533
+ function arkHealth(h) {
1534
+ switch (h) {
1535
+ case 'healthy':
1536
+ return c.green(h);
1537
+ case 'lagging':
1538
+ return c.yellow(h);
1539
+ case 'down':
1540
+ return c.red(h);
1541
+ default:
1542
+ return c.dim(h);
1543
+ }
1544
+ }
1545
+ async function cmdArk(flags) {
1546
+ const { opts } = resolveOpts(flags);
1547
+ const sub = flags.positional[1] ?? 'sources';
1548
+ switch (sub) {
1549
+ case 'sources':
1550
+ case 'source':
1551
+ return cmdArkSources(flags, opts);
1552
+ case 'targets':
1553
+ case 'target':
1554
+ return cmdArkTargets(flags, opts);
1555
+ case 'policies':
1556
+ case 'policy':
1557
+ return cmdArkPolicies(flags, opts);
1558
+ case 'backups':
1559
+ case 'backup':
1560
+ return cmdArkBackups(flags, opts);
1561
+ case 'restores':
1562
+ return cmdArkRestoresList(flags, opts);
1563
+ case 'verify': {
1564
+ const backupId = need(flags.positional[2], 'tilldev ark verify <backup-id>');
1565
+ const { restore } = await api.startArkRestore(opts, { backup_id: backupId, kind: 'verification' });
1566
+ render(flags, { restore }, () => ok(`Queued verification restore ${c.bold(restore.id)} (${restore.status})`));
1567
+ return;
1568
+ }
1569
+ case 'restore': {
1570
+ // Real, destructive restore INTO a registered source — four-eyes gated.
1571
+ const backupId = need(flags.positional[2], 'tilldev ark restore <backup-id> --into <source-id> --approved-by <uid> --approval-ref <ref>');
1572
+ const into = need(flags.named['into'], 'a real restore needs --into <destination source id>');
1573
+ const { restore } = await api.startArkRestore(opts, {
1574
+ backup_id: backupId,
1575
+ kind: 'real',
1576
+ target_source_id: into,
1577
+ ...arkApproval(flags),
1578
+ });
1579
+ render(flags, { restore }, () => {
1580
+ ok(`Authorized real restore ${c.bold(restore.id)} → source ${c.dim(into)} (${restore.status})`);
1581
+ info('The agent verifies the manifest signature before trusting a single byte.');
1582
+ });
1583
+ return;
1584
+ }
1585
+ case 'failover':
1586
+ return cmdArkFailover(flags, opts);
1587
+ case 'canary':
1588
+ return cmdArkCanary(flags, opts);
1589
+ case 'clone':
1590
+ case 'clones':
1591
+ return cmdArkClones(flags, opts);
1592
+ case 'replicas': {
1593
+ const r = await api.listArkReplicas(opts);
1594
+ render(flags, r, () => {
1595
+ const cols = [
1596
+ { header: 'ROLE', get: (x) => (x.role === 'primary' ? c.magenta('primary') : 'replica') },
1597
+ { header: 'PROVIDER', get: (x) => x.provider },
1598
+ { header: 'REGION', get: (x) => x.region || c.dim('-') },
1599
+ { header: 'MODE', get: (x) => x.mode },
1600
+ { header: 'LAG(s)', get: (x) => String(x.lag_sec) },
1601
+ { header: 'HEALTH', get: (x) => arkHealth(x.health) },
1602
+ { header: 'ID', get: (x) => c.dim(x.id) },
1603
+ ];
1604
+ console.log(table(r.replicas, cols));
1605
+ });
1606
+ return;
1607
+ }
1608
+ case 'tokens':
1609
+ case 'token':
1610
+ return cmdArkTokens(flags, opts);
1611
+ case 'audit': {
1612
+ const r = await api.listArkAudit(opts, flags.named['limit'] ? Number(flags.named['limit']) : undefined);
1613
+ render(flags, r, () => {
1614
+ const cols = [
1615
+ { header: 'WHEN', get: (a) => ago(a.created_at, now) },
1616
+ { header: 'ACTOR', get: (a) => `${a.actor_type}${a.actor_id ? c.dim(':' + truncate(a.actor_id, 8)) : ''}` },
1617
+ { header: 'ACTION', get: (a) => c.bold(a.action) },
1618
+ { header: 'CONTEXT', get: (a) => c.dim(truncate(JSON.stringify(a.metadata ?? {}), 40)) },
1619
+ ];
1620
+ console.log(table(r.audit, cols));
1621
+ });
1622
+ return;
1623
+ }
1624
+ default:
1625
+ throw new Error('usage: tilldev ark <sources|targets|policies|backups|restore|verify|restores|failover|replicas|canary|clone|tokens|audit>');
1626
+ }
1627
+ }
1628
+ // ── Ransomware canary (§20) ───────────────────────────────────────────────────
1629
+ function canaryVerdict(v) {
1630
+ return v === 'alarm' ? c.red(c.bold(v)) : c.yellow(v);
1631
+ }
1632
+ async function cmdArkCanary(flags, opts) {
1633
+ const action = flags.positional[2] ?? 'ls';
1634
+ switch (action) {
1635
+ case 'ls':
1636
+ case 'list': {
1637
+ const r = await api.listArkCanary(opts);
1638
+ render(flags, r, () => {
1639
+ if (r.events.length === 0) {
1640
+ ok('No canary events — entropy nominal across all sources.');
1641
+ return;
1642
+ }
1643
+ const cols = [
1644
+ { header: 'WHEN', get: (e) => ago(e.created_at ?? '', now) },
1645
+ { header: 'VERDICT', get: (e) => canaryVerdict(e.verdict) },
1646
+ { header: 'SEV', get: (e) => e.severity },
1647
+ { header: 'ENTROPY', get: (e) => `${e.entropy_bpb.toFixed(2)}${c.dim(' / ' + e.baseline_entropy_bpb.toFixed(2))}` },
1648
+ { header: 'DEDUP', get: (e) => `×${e.dedup_ratio.toFixed(1)}${c.dim(' / ×' + e.baseline_dedup_ratio.toFixed(1))}` },
1649
+ { header: 'STATUS', get: (e) => (e.status === 'open' ? c.yellow(e.status) : c.dim(e.status)) },
1650
+ { header: 'ID', get: (e) => c.dim(e.id) },
1651
+ ];
1652
+ console.log(table(r.events, cols));
1653
+ });
1654
+ return;
1655
+ }
1656
+ case 'ack':
1657
+ case 'confirm':
1658
+ case 'dismiss': {
1659
+ const id = need(flags.positional[3], `tilldev ark canary ${action} <event-id>`);
1660
+ const status = action === 'ack' ? 'acknowledged' : action === 'confirm' ? 'confirmed' : 'dismissed';
1661
+ await api.ackArkCanary(opts, id, status);
1662
+ render(flags, { ok: true }, () => ok(`Canary event ${c.bold(id)} → ${status}`));
1663
+ return;
1664
+ }
1665
+ default:
1666
+ throw new Error('usage: tilldev ark canary <ls|ack|confirm|dismiss> [event-id]');
1667
+ }
1668
+ }
1669
+ // ── PII-scrubbed instant clones (§21) ─────────────────────────────────────────
1670
+ function cloneStatus(s) {
1671
+ if (s === 'ready')
1672
+ return c.green(s);
1673
+ if (s === 'failed')
1674
+ return c.red(s);
1675
+ if (s === 'expired')
1676
+ return c.dim(s);
1677
+ return c.yellow(s);
1678
+ }
1679
+ async function cmdArkClones(flags, opts) {
1680
+ const action = flags.positional[2] ?? 'ls';
1681
+ switch (action) {
1682
+ case 'ls':
1683
+ case 'list': {
1684
+ const r = await api.listArkClones(opts);
1685
+ render(flags, r, () => {
1686
+ const cols = [
1687
+ { header: 'NAME', get: (x) => c.bold(x.name) },
1688
+ { header: 'PROFILE', get: (x) => (x.masking_profile === 'none' ? c.red('raw pii') : x.masking_profile) },
1689
+ { header: 'STATUS', get: (x) => cloneStatus(x.status) },
1690
+ { header: 'MASKED', get: (x) => (x.masking_profile === 'none' ? c.dim('—') : `${x.columns_masked} cols`) },
1691
+ { header: 'EXPIRES', get: (x) => (x.expires_at ? ago(x.expires_at, now).replace(' ago', '') : c.dim('—')) },
1692
+ { header: 'ID', get: (x) => c.dim(x.id) },
1693
+ ];
1694
+ console.log(table(r.clones, cols));
1695
+ });
1696
+ return;
1697
+ }
1698
+ case 'create':
1699
+ case 'new': {
1700
+ const backupId = need(flags.positional[3] ?? flags.named['backup'], 'tilldev ark clone create <backup-id> [--profile standard|strict|none] [--ttl <minutes>] [--name <n>]');
1701
+ const profileRaw = flags.named['profile'] ?? 'standard';
1702
+ if (profileRaw !== 'standard' && profileRaw !== 'strict' && profileRaw !== 'none') {
1703
+ throw new Error("--profile must be one of: standard, strict, none");
1704
+ }
1705
+ const body = { backup_id: backupId, masking_profile: profileRaw };
1706
+ if (flags.named['name'])
1707
+ body.name = flags.named['name'];
1708
+ if (flags.named['ttl'])
1709
+ body.ttl_minutes = Number(flags.named['ttl']);
1710
+ // A raw (unmasked) clone is four-eyes-gated, like a real restore.
1711
+ if (profileRaw === 'none')
1712
+ Object.assign(body, arkApproval(flags));
1713
+ const { clone } = await api.createArkClone(opts, body);
1714
+ render(flags, { clone }, () => {
1715
+ ok(`Queued clone ${c.bold(clone.name)} (${clone.status}) — profile ${clone.masking_profile}`);
1716
+ if (clone.masking_profile === 'none')
1717
+ info('Raw clone — this contains REAL customer PII. Treat as production data.');
1718
+ else
1719
+ info('The agent restores to a scratch target and masks every PII column before it goes ready.');
1720
+ });
1721
+ return;
1722
+ }
1723
+ case 'expire':
1724
+ case 'rm': {
1725
+ const id = need(flags.positional[3], 'tilldev ark clone expire <clone-id>');
1726
+ await api.expireArkClone(opts, id);
1727
+ render(flags, { ok: true }, () => ok(`Clone ${c.bold(id)} torn down.`));
1728
+ return;
1729
+ }
1730
+ case 'show': {
1731
+ const id = need(flags.positional[3], 'tilldev ark clone show <clone-id>');
1732
+ const r = await api.listArkClones(opts);
1733
+ const clone = r.clones.find((x) => x.id === id);
1734
+ if (!clone)
1735
+ throw new Error(`Clone ${id} not found.`);
1736
+ render(flags, { clone }, () => {
1737
+ ok(`${c.bold(clone.name)} · ${cloneStatus(clone.status)} · profile ${clone.masking_profile}`);
1738
+ if (clone.mask_plan.length === 0) {
1739
+ info(clone.masking_profile === 'none' ? 'Raw clone — no masking applied.' : 'Masking plan not yet computed.');
1740
+ }
1741
+ else {
1742
+ console.log(c.dim(' proof of masking:'));
1743
+ for (const m of clone.mask_plan) {
1744
+ console.log(` ${m.table}.${c.bold(m.column)} ${c.dim('·')} ${m.pii} ${c.dim('→')} ${m.transform}`);
1745
+ }
1746
+ }
1747
+ });
1748
+ return;
1749
+ }
1750
+ default:
1751
+ throw new Error('usage: tilldev ark clone <ls|create|show|expire> [args]');
1752
+ }
1753
+ }
1754
+ async function cmdArkSources(flags, opts) {
1755
+ const action = flags.positional[2] ?? 'ls';
1756
+ switch (action) {
1757
+ case 'ls':
1758
+ case 'list': {
1759
+ const r = await api.listArkSources(opts);
1760
+ render(flags, r, () => {
1761
+ const cols = [
1762
+ { header: 'NAME', get: (s) => c.bold(s.name) },
1763
+ { header: 'TYPE', get: (s) => s.type },
1764
+ { header: 'PROVIDER', get: (s) => s.provider },
1765
+ { header: 'RESIDENCY', get: (s) => s.residency_zone },
1766
+ { header: 'DSN', get: (s) => (s.has_dsn ? c.green('set') : c.dim('none')) },
1767
+ { header: 'STATUS', get: (s) => (s.status === 'active' ? c.green(s.status) : c.yellow(s.status)) },
1768
+ { header: 'ID', get: (s) => c.dim(s.id) },
1769
+ ];
1770
+ console.log(table(r.sources, cols));
1771
+ });
1772
+ return;
1773
+ }
1774
+ case 'create': {
1775
+ const name = need(flags.named['name'] ?? flags.positional[3], 'tilldev ark sources create --name <n> --type <t> --provider <p>');
1776
+ const type = need(flags.named['type'], 'tilldev ark sources create --type <postgres|mysql|mongo|clickhouse|meili|redis|object>');
1777
+ const provider = need(flags.named['provider'], '--provider required (neon|rds|supabase|self|s3|r2|…)');
1778
+ const body = { name, type, provider };
1779
+ if (flags.named['region'])
1780
+ body.region = flags.named['region'];
1781
+ if (flags.named['residency'])
1782
+ body.residency_zone = flags.named['residency'];
1783
+ if (flags.bool.has('unmanaged'))
1784
+ body.managed = false;
1785
+ // The DSN is a secret — accept it via prompt/stdin, not bare on argv.
1786
+ if (flags.named['dsn'] !== undefined || flags.bool.has('dsn-stdin') || flags.bool.has('with-dsn')) {
1787
+ body.dsn = await readSecretArg(flags, 'dsn', `${type} connection string`);
1788
+ }
1789
+ const { source } = await api.createArkSource(opts, body);
1790
+ render(flags, { source }, () => {
1791
+ ok(`Registered source ${c.bold(source.name)} (${source.type})`);
1792
+ console.log(kv([['id', source.id], ['provider', source.provider], ['dsn', source.has_dsn ? c.green('encrypted') : c.dim('not set')]]));
1793
+ });
1794
+ return;
1795
+ }
1796
+ case 'get': {
1797
+ const id = need(flags.positional[3], 'tilldev ark sources get <id>');
1798
+ const r = await api.getArkSource(opts, id);
1799
+ render(flags, r, () => console.log(kv(Object.entries(r.source).map(([k, v]) => [k, String(v)]))));
1800
+ return;
1801
+ }
1802
+ case 'rm':
1803
+ case 'delete': {
1804
+ const id = need(flags.positional[3], 'tilldev ark sources rm <id>');
1805
+ await api.deleteArkSource(opts, id);
1806
+ ok(`Deleted source ${c.bold(id)}`);
1807
+ return;
1808
+ }
1809
+ default:
1810
+ throw new Error(`unknown: tilldev ark sources ${action}`);
1811
+ }
1812
+ }
1813
+ async function cmdArkTargets(flags, opts) {
1814
+ const action = flags.positional[2] ?? 'ls';
1815
+ switch (action) {
1816
+ case 'ls':
1817
+ case 'list': {
1818
+ const r = await api.listArkTargets(opts);
1819
+ render(flags, r, () => {
1820
+ const cols = [
1821
+ { header: 'NAME', get: (t) => c.bold(t.name) },
1822
+ { header: 'PROVIDER', get: (t) => t.provider },
1823
+ { header: 'BUCKET', get: (t) => t.bucket || c.dim('-') },
1824
+ { header: 'RESIDENCY', get: (t) => t.residency_zone },
1825
+ { header: 'WORM', get: (t) => (t.immutable ? c.green(`lock ${t.object_lock_days}d`) : c.yellow('off')) },
1826
+ { header: 'CREDS', get: (t) => (t.has_creds ? c.green('set') : c.dim('none')) },
1827
+ { header: 'ID', get: (t) => c.dim(t.id) },
1828
+ ];
1829
+ console.log(table(r.targets, cols));
1830
+ });
1831
+ return;
1832
+ }
1833
+ case 'create': {
1834
+ const name = need(flags.named['name'] ?? flags.positional[3], 'tilldev ark targets create --name <n> --provider <p>');
1835
+ const provider = need(flags.named['provider'], 'tilldev ark targets create --provider <s3|r2|b2|wasabi|gcs|azure|minio|sftp>');
1836
+ const body = { name, provider };
1837
+ if (flags.named['bucket'])
1838
+ body.bucket = flags.named['bucket'];
1839
+ if (flags.named['region'])
1840
+ body.region = flags.named['region'];
1841
+ if (flags.named['residency'])
1842
+ body.residency_zone = flags.named['residency'];
1843
+ if (flags.named['endpoint'])
1844
+ body.endpoint = flags.named['endpoint'];
1845
+ if (flags.named['credential-domain'])
1846
+ body.credential_domain = flags.named['credential-domain'];
1847
+ if (flags.bool.has('mutable'))
1848
+ body.immutable = false;
1849
+ if (flags.named['lock-days'])
1850
+ body.object_lock_days = Number(flags.named['lock-days']);
1851
+ // Provider creds (a JSON object) are secret — take via prompt/stdin.
1852
+ if (flags.named['creds-json'] !== undefined || flags.bool.has('creds-json-stdin') || flags.bool.has('with-creds')) {
1853
+ const raw = await readSecretArg(flags, 'creds-json', 'target credentials JSON');
1854
+ let creds;
1855
+ try {
1856
+ creds = JSON.parse(raw);
1857
+ }
1858
+ catch {
1859
+ throw new Error('--creds-json must be a JSON object, e.g. \'{"access_key_id":"…","secret_access_key":"…"}\'');
1860
+ }
1861
+ body.creds = creds;
1862
+ }
1863
+ const { target } = await api.createArkTarget(opts, body);
1864
+ render(flags, { target }, () => {
1865
+ ok(`Registered target ${c.bold(target.name)} (${target.provider})`);
1866
+ console.log(kv([['id', target.id], ['immutable', String(target.immutable)], ['creds', target.has_creds ? c.green('encrypted') : c.dim('not set')]]));
1867
+ });
1868
+ return;
1869
+ }
1870
+ case 'get': {
1871
+ const id = need(flags.positional[3], 'tilldev ark targets get <id>');
1872
+ const r = await api.getArkTarget(opts, id);
1873
+ render(flags, r, () => console.log(kv(Object.entries(r.target).map(([k, v]) => [k, String(v)]))));
1874
+ return;
1875
+ }
1876
+ case 'rm':
1877
+ case 'delete': {
1878
+ const id = need(flags.positional[3], 'tilldev ark targets rm <id>');
1879
+ await api.deleteArkTarget(opts, id);
1880
+ ok(`Deleted target ${c.bold(id)}`);
1881
+ return;
1882
+ }
1883
+ default:
1884
+ throw new Error(`unknown: tilldev ark targets ${action}`);
1885
+ }
1886
+ }
1887
+ async function cmdArkPolicies(flags, opts) {
1888
+ const action = flags.positional[2] ?? 'ls';
1889
+ switch (action) {
1890
+ case 'ls':
1891
+ case 'list': {
1892
+ const r = await api.listArkPolicies(opts, flags.named['source']);
1893
+ render(flags, r, () => {
1894
+ const cols = [
1895
+ { header: 'NAME', get: (p) => c.bold(p.name) },
1896
+ { header: 'CRON', get: (p) => p.schedule_cron },
1897
+ { header: 'RPO(s)', get: (p) => String(p.rpo_slo_sec) },
1898
+ { header: 'RTO(s)', get: (p) => String(p.rto_slo_sec) },
1899
+ { header: 'ENABLED', get: (p) => (p.enabled ? c.green('yes') : c.dim('no')) },
1900
+ { header: 'SOURCE', get: (p) => c.dim(truncate(p.source_id, 12)) },
1901
+ { header: 'ID', get: (p) => c.dim(p.id) },
1902
+ ];
1903
+ console.log(table(r.policies, cols));
1904
+ });
1905
+ return;
1906
+ }
1907
+ case 'create': {
1908
+ const sourceId = need(flags.named['source'] ?? flags.positional[3], 'tilldev ark policies create --source <id> [flags]');
1909
+ const body = { source_id: sourceId };
1910
+ if (flags.named['name'])
1911
+ body.name = flags.named['name'];
1912
+ if (flags.named['cron'])
1913
+ body.schedule_cron = flags.named['cron'];
1914
+ if (flags.named['rpo'])
1915
+ body.rpo_slo_sec = Number(flags.named['rpo']);
1916
+ if (flags.named['rto'])
1917
+ body.rto_slo_sec = Number(flags.named['rto']);
1918
+ if (flags.named['verify-cron'])
1919
+ body.verify_cadence = flags.named['verify-cron'];
1920
+ if (flags.bool.has('byok'))
1921
+ body.byok = true;
1922
+ const retention = arkRetentionFromFlags(flags);
1923
+ if (retention)
1924
+ body.retention = retention;
1925
+ const { policy } = await api.createArkPolicy(opts, body);
1926
+ render(flags, { policy }, () => {
1927
+ ok(`Created policy ${c.bold(policy.name)} on source ${c.dim(sourceId)}`);
1928
+ console.log(kv([['id', policy.id], ['cron', policy.schedule_cron], ['rpo', `${policy.rpo_slo_sec}s`], ['verify', policy.verify_cadence]]));
1929
+ });
1930
+ return;
1931
+ }
1932
+ default:
1933
+ throw new Error(`unknown: tilldev ark policies ${action}`);
1934
+ }
1935
+ }
1936
+ async function cmdArkBackups(flags, opts) {
1937
+ const action = flags.positional[2] ?? 'ls';
1938
+ switch (action) {
1939
+ case 'ls':
1940
+ case 'list': {
1941
+ const r = await api.listArkBackups(opts, flags.named['source']);
1942
+ render(flags, r, () => {
1943
+ const cols = [
1944
+ { header: 'WHEN', get: (b) => ago(b.created_at, now) },
1945
+ { header: 'ENGINE', get: (b) => b.engine },
1946
+ { header: 'STATUS', get: (b) => arkBackupStatus(b.status) },
1947
+ { header: 'FLAGS', get: (b) => arkBackupFlags(b) },
1948
+ { header: 'SIZE', get: (b) => humanBytes(b.size_bytes) },
1949
+ { header: 'DEDUP', get: (b) => `${b.dedup_ratio.toFixed(1)}x` },
1950
+ { header: 'SOURCE', get: (b) => c.dim(truncate(b.source_id, 12)) },
1951
+ { header: 'ID', get: (b) => c.dim(b.id) },
1952
+ ];
1953
+ console.log(table(r.backups, cols));
1954
+ });
1955
+ return;
1956
+ }
1957
+ case 'run': {
1958
+ const sourceId = need(flags.named['source'] ?? flags.positional[3], 'tilldev ark backups run --source <id> [--policy <id>]');
1959
+ const { backup } = await api.runArkBackup(opts, sourceId, flags.named['policy']);
1960
+ render(flags, { backup }, () => {
1961
+ ok(`Queued ${c.bold(backup.engine)} backup ${c.dim(backup.id)} (${backup.status})`);
1962
+ info('The agent claims the run and reports a signed manifest back — no bytes transit the control plane.');
1963
+ });
1964
+ return;
1965
+ }
1966
+ default:
1967
+ throw new Error(`unknown: tilldev ark backups ${action}`);
1968
+ }
1969
+ }
1970
+ async function cmdArkRestoresList(flags, opts) {
1971
+ const r = await api.listArkRestores(opts, flags.named['backup']);
1972
+ render(flags, r, () => {
1973
+ const cols = [
1974
+ { header: 'WHEN', get: (x) => ago(x.created_at, now) },
1975
+ { header: 'KIND', get: (x) => (x.kind === 'real' ? c.magenta('real') : c.blue('verify')) },
1976
+ { header: 'STATUS', get: (x) => arkRestoreStatus(x.status) },
1977
+ { header: 'APPROVED BY', get: (x) => (x.authorized_by ? c.dim(truncate(x.authorized_by, 10)) : c.dim('-')) },
1978
+ { header: 'BACKUP', get: (x) => c.dim(truncate(x.backup_id, 12)) },
1979
+ { header: 'ID', get: (x) => c.dim(x.id) },
1980
+ ];
1981
+ console.log(table(r.restores, cols));
1982
+ });
1983
+ }
1984
+ async function cmdArkFailover(flags, opts) {
1985
+ const action = flags.positional[2] ?? 'status';
1986
+ switch (action) {
1987
+ case 'status': {
1988
+ const sourceId = need(flags.named['source'] ?? flags.positional[3], 'tilldev ark failover status --source <id>');
1989
+ const r = await api.getArkFailover(opts, sourceId);
1990
+ render(flags, r, () => {
1991
+ if (r.failover) {
1992
+ console.log(kv([
1993
+ ['state', arkState(r.failover.state)],
1994
+ ['fence', r.failover.fence_status],
1995
+ ['trigger', r.failover.trigger],
1996
+ ['approved by', r.failover.approved_by ? truncate(r.failover.approved_by, 12) : c.dim('-')],
1997
+ ['reason', r.failover.reason ?? c.dim('-')],
1998
+ ]));
1999
+ }
2000
+ else {
2001
+ info('No failover events for this source — healthy.');
2002
+ }
2003
+ const cols = [
2004
+ { header: 'ROLE', get: (x) => (x.role === 'primary' ? c.magenta('primary') : 'replica') },
2005
+ { header: 'REGION', get: (x) => x.region || c.dim('-') },
2006
+ { header: 'LAG(s)', get: (x) => String(x.lag_sec) },
2007
+ { header: 'PROMOTABLE', get: (x) => (x.promotable ? c.green('yes') : c.dim('no')) },
2008
+ { header: 'HEALTH', get: (x) => arkHealth(x.health) },
2009
+ ];
2010
+ console.log(table(r.replicas, cols));
2011
+ });
2012
+ return;
2013
+ }
2014
+ case 'promote': {
2015
+ const sourceId = need(flags.named['source'], 'tilldev ark failover promote --source <id> --approved-by <uid> --approval-ref <ref>');
2016
+ const body = {
2017
+ source_id: sourceId,
2018
+ ...arkApproval(flags),
2019
+ };
2020
+ if (flags.named['to'])
2021
+ body.to_node = flags.named['to'];
2022
+ const r = await api.promoteArkFailover(opts, body);
2023
+ render(flags, r, () => {
2024
+ ok(`Failover authorized for source ${c.dim(sourceId)} — state ${arkState(r.failover.state)}`);
2025
+ warn(r.note);
2026
+ });
2027
+ return;
2028
+ }
2029
+ case 'failback': {
2030
+ const sourceId = need(flags.named['source'], 'tilldev ark failover failback --source <id> --approved-by <uid> --approval-ref <ref>');
2031
+ const body = {
2032
+ source_id: sourceId,
2033
+ ...arkApproval(flags),
2034
+ };
2035
+ if (flags.named['lag'])
2036
+ body.replica_lag_sec = Number(flags.named['lag']);
2037
+ const r = await api.failbackArkFailover(opts, body);
2038
+ render(flags, r, () => ok(`Failback advanced to ${arkState(r.state)} — ${r.effects.join(', ') || 'no effects'}`));
2039
+ return;
2040
+ }
2041
+ default:
2042
+ throw new Error(`unknown: tilldev ark failover ${action} (status|promote|failback)`);
2043
+ }
2044
+ }
2045
+ async function cmdArkTokens(flags, opts) {
2046
+ const action = flags.positional[2] ?? 'ls';
2047
+ switch (action) {
2048
+ case 'ls':
2049
+ case 'list': {
2050
+ const r = await api.listArkTokens(opts);
2051
+ render(flags, r, () => {
2052
+ const cols = [
2053
+ { header: 'NAME', get: (t) => c.bold(t.name) },
2054
+ { header: 'PREFIX', get: (t) => t.token_prefix },
2055
+ { header: 'SCOPE', get: (t) => arkScope(t.scope) },
2056
+ { header: 'APPROVER', get: (t) => (t.can_approve ? c.cyan('yes') : c.dim('no')) },
2057
+ { header: 'LAST USED', get: (t) => ago(t.last_used_at, now) },
2058
+ { header: 'STATUS', get: (t) => (t.revoked_at ? c.red('revoked') : c.green('active')) },
2059
+ { header: 'ID', get: (t) => c.dim(t.id) },
2060
+ ];
2061
+ console.log(table(r.tokens, cols));
2062
+ });
2063
+ return;
2064
+ }
2065
+ case 'create': {
2066
+ const body = {
2067
+ name: flags.named['name'] ?? 'default',
2068
+ scope: flags.named['scope'] ?? 'read',
2069
+ };
2070
+ if (flags.bool.has('can-approve'))
2071
+ body.can_approve = true;
2072
+ if (flags.named['expires'])
2073
+ body.expires_in_days = Number(flags.named['expires']);
2074
+ const { token } = await api.createArkToken(opts, body);
2075
+ render(flags, { token }, () => {
2076
+ ok(`Minted ${arkScope(token.scope)} token ${c.bold(token.name)}`);
2077
+ console.log(kv([['id', token.id], ['prefix', token.token_prefix], ['secret', c.yellow(token.secret ?? '-')]]));
2078
+ info('Copy the secret now — it will not be shown again.');
2079
+ });
2080
+ return;
2081
+ }
2082
+ case 'revoke':
2083
+ case 'rm': {
2084
+ const id = need(flags.positional[3], 'tilldev ark tokens revoke <token-id>');
2085
+ await api.revokeArkToken(opts, id);
2086
+ ok(`Revoked token ${c.bold(id)}`);
2087
+ return;
2088
+ }
2089
+ default:
2090
+ throw new Error(`unknown: tilldev ark tokens ${action}`);
2091
+ }
2092
+ }
2093
+ // ── Small helpers for TillArk cells ───────────────────────────────────────────
2094
+ function arkScope(scope) {
2095
+ switch (scope) {
2096
+ case 'admin':
2097
+ return c.magenta('admin');
2098
+ case 'operator':
2099
+ return c.cyan('operator');
2100
+ default:
2101
+ return c.dim('read');
2102
+ }
2103
+ }
2104
+ /** Compact per-backup flags: a ransomware-canary quarantine (loud) and whether the
2105
+ * signed manifest was server-verified at report time. */
2106
+ function arkBackupFlags(b) {
2107
+ const parts = [];
2108
+ if (b.quarantined)
2109
+ parts.push(c.red('⚠ quarantined'));
2110
+ if (b.manifest_verified)
2111
+ parts.push(c.green('✓ signed'));
2112
+ return parts.length ? parts.join(' ') : c.dim('—');
2113
+ }
2114
+ function arkBackupStatus(s) {
2115
+ switch (s) {
2116
+ case 'succeeded':
2117
+ case 'verified':
2118
+ return c.green(s);
2119
+ case 'failed':
2120
+ return c.red(s);
2121
+ case 'running':
2122
+ case 'verifying':
2123
+ return c.yellow(s);
2124
+ default:
2125
+ return c.dim(s);
2126
+ }
2127
+ }
2128
+ function arkRestoreStatus(s) {
2129
+ switch (s) {
2130
+ case 'passed':
2131
+ return c.green(s);
2132
+ case 'failed':
2133
+ return c.red(s);
2134
+ case 'running':
2135
+ return c.yellow(s);
2136
+ default:
2137
+ return c.dim(s);
2138
+ }
2139
+ }
2140
+ function humanBytes(n) {
2141
+ if (n <= 0)
2142
+ return c.dim('0');
2143
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
2144
+ let v = n;
2145
+ let i = 0;
2146
+ while (v >= 1024 && i < units.length - 1) {
2147
+ v /= 1024;
2148
+ i++;
2149
+ }
2150
+ return `${v.toFixed(i === 0 ? 0 : 1)}${units[i]}`;
2151
+ }
2152
+ // ── TillForge ─────────────────────────────────────────────────────────────────
2153
+ // ── Forge git ergonomics — clone + a git credential helper ──────────────────
2154
+ // The point: never put a token in a clone URL or shell history. `setup-git`
2155
+ // wires git's credential helper to call this CLI back for the forge host; the
2156
+ // helper serves the token from the env. `clone` does it inline for one repo.
2157
+ function forgeGitHost() {
2158
+ return process.env['TILLDEV_FORGE_GIT_HOST'] ?? 'git.tilldev.dev';
2159
+ }
2160
+ function forgeGitToken(flags) {
2161
+ const cfg = loadConfig();
2162
+ return (flags.named['git-token'] ??
2163
+ process.env['TILLDEV_FORGE_TOKEN'] ??
2164
+ process.env['TILLDEV_TOKEN'] ??
2165
+ cfg.forge_token ?? // saved by `tilldev forge login`
2166
+ cfg.token);
2167
+ }
2168
+ // `tilldev forge login` — the device flow. No password, no token pasted into a
2169
+ // terminal: we print a short code, the human approves it in a signed-in
2170
+ // browser, and the minted tfp_ token lands in the config file where the
2171
+ // credential helper (and `forge clone`) already look.
2172
+ async function cmdForgeLogin(flags) {
2173
+ const cfg = loadConfig();
2174
+ const apiUrl = flags.named['api-url'] ?? cfg.api_url;
2175
+ assertSafeApiUrl(apiUrl);
2176
+ const scope = flags.named['scope'] ?? 'read';
2177
+ if (scope !== 'read' && scope !== 'write') {
2178
+ throw new Error('usage: tilldev forge login [--scope read|write]');
2179
+ }
2180
+ const opts = { apiUrl };
2181
+ const grant = await api.forgeDeviceCode(opts, scope);
2182
+ info(`Open ${c.bold(grant.verification_uri)} in a signed-in browser and enter:`);
2183
+ console.log(`\n ${c.bold(grant.user_code)}\n`);
2184
+ info(`Waiting for approval — ${c.bold(scope)} scope, code expires in ${Math.round(grant.expires_in / 60)} min. Ctrl-C to abort.`);
2185
+ const deadline = Date.now() + grant.expires_in * 1000;
2186
+ let intervalMs = Math.max(1, grant.interval || 5) * 1000;
2187
+ while (Date.now() < deadline) {
2188
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2189
+ const poll = await api.forgeDeviceToken(opts, grant.device_code);
2190
+ if (poll.status === 'pending')
2191
+ continue;
2192
+ if (poll.status === 'slow_down') {
2193
+ // RFC 8628: back the polling interval off by 5s and keep waiting.
2194
+ intervalMs += 5000;
2195
+ continue;
2196
+ }
2197
+ if (poll.status === 'expired') {
2198
+ fail('The code expired or was denied. Run `tilldev forge login` again.');
2199
+ exit(1);
2200
+ }
2201
+ updateConfig({ forge_token: poll.token });
2202
+ ok(`Signed in to TillForge — ${poll.scope} token saved to ${c.dim(configFilePath())} (0600)`);
2203
+ info('git (via `forge setup-git`) and `tilldev forge clone` will use it automatically.');
2204
+ info(`For CI, mint a dedicated token instead: ${c.dim('tilldev forge tokens create --scope ' + poll.scope)}`);
2205
+ return;
2206
+ }
2207
+ fail('Timed out waiting for approval. Run `tilldev forge login` again.');
2208
+ exit(1);
2209
+ }
2210
+ // The value git runs as a credential helper. `!` = run via the shell; we point
2211
+ // it back at this exact node + script so it works regardless of PATH.
2212
+ function credentialHelperArg() {
2213
+ return `!'${process.execPath}' '${process.argv[1] ?? ''}' forge credential`;
2214
+ }
2215
+ // git credential helper. git invokes `<helper> get|store|erase` and streams the
2216
+ // request on stdin; for `get` on the forge host we answer with the token.
2217
+ async function cmdForgeCredential(flags) {
2218
+ const op = flags.positional[2] ?? 'get';
2219
+ if (op !== 'get')
2220
+ return; // store/erase: nothing to persist — the token lives in the env
2221
+ let raw = '';
2222
+ try {
2223
+ raw = readFileSync(0, 'utf8'); // fd 0 = stdin (git closes it after writing)
2224
+ }
2225
+ catch {
2226
+ /* no stdin */
2227
+ }
2228
+ const req = {};
2229
+ for (const line of raw.split('\n')) {
2230
+ const i = line.indexOf('=');
2231
+ if (i > 0)
2232
+ req[line.slice(0, i)] = line.slice(i + 1).trim();
2233
+ }
2234
+ const host = req['host'] ?? '';
2235
+ const token = forgeGitToken(flags);
2236
+ // Decline (print nothing) if it's not our host or we have no token — git then
2237
+ // falls back to its normal prompt. Never answer for another host.
2238
+ if (!token || (host && host !== forgeGitHost()))
2239
+ return;
2240
+ process.stdout.write(`username=tilldev\npassword=${token}\n`);
2241
+ }
2242
+ async function cmdForgeClone(flags) {
2243
+ const spec = need(flags.positional[2], 'tilldev forge clone <org>/<repo> [dir] (or <repo> with --org <org>)');
2244
+ let org;
2245
+ let slug;
2246
+ if (spec.includes('/')) {
2247
+ ;
2248
+ [org, slug] = [spec.slice(0, spec.indexOf('/')), spec.slice(spec.indexOf('/') + 1)];
2249
+ }
2250
+ else {
2251
+ org = flags.named['org'];
2252
+ slug = spec;
2253
+ }
2254
+ if (!org)
2255
+ throw new Error('an org is required: `tilldev forge clone <org>/<repo>` or `--org <org>`');
2256
+ const host = forgeGitHost();
2257
+ const url = `https://${host}/${org}/${slug.replace(/\.git$/, '')}.git`;
2258
+ const token = forgeGitToken(flags);
2259
+ if (!token)
2260
+ warn('No token found — set TILLDEV_FORGE_TOKEN or pass --git-token; git will prompt otherwise.');
2261
+ const args = ['-c', 'credential.helper=', '-c', `credential.helper=${credentialHelperArg()}`, 'clone', url];
2262
+ const dir = flags.positional[3];
2263
+ if (dir)
2264
+ args.push(dir);
2265
+ info(`Cloning ${c.bold(`${org}/${slug}`)} from ${host}…`);
2266
+ const res = spawnSync('git', args, { stdio: 'inherit', env: { ...process.env, ...(token ? { TILLDEV_FORGE_TOKEN: token } : {}) } });
2267
+ if (res.error)
2268
+ throw new Error(`could not run git (${res.error.code ?? res.error.message}). Is git installed?`);
2269
+ if (res.status !== 0)
2270
+ exit(res.status ?? 1);
2271
+ ok(`Cloned ${org}/${slug}`);
2272
+ }
2273
+ async function cmdForgeSetupGit(flags) {
2274
+ const host = forgeGitHost();
2275
+ const res = spawnSync('git', ['config', '--global', `credential.https://${host}.helper`, credentialHelperArg()], { stdio: 'inherit' });
2276
+ if (res.error)
2277
+ throw new Error(`could not run git (${res.error.code ?? res.error.message}). Is git installed?`);
2278
+ if (res.status !== 0)
2279
+ throw new Error('git config failed');
2280
+ ok(`git will now authenticate ${c.bold(host)} through TillForge.`);
2281
+ info('Set your git token so the helper can use it (mint one with `tilldev forge tokens create --scope write`):');
2282
+ console.log(` ${c.dim('export TILLDEV_FORGE_TOKEN=tfp_…')}`);
2283
+ info(`Then plain git just works — no token in the URL:`);
2284
+ console.log(` ${c.dim(`git clone https://${host}/<org>/<repo>.git`)}`);
2285
+ }
2286
+ async function cmdForge(flags) {
2287
+ const sub = flags.positional[1] ?? 'repos';
2288
+ // Git-ergonomics commands shell out to git rather than the API, and the
2289
+ // credential helper must run even without an API token (git invokes it).
2290
+ if (sub === 'credential' || sub === 'credential-helper')
2291
+ return cmdForgeCredential(flags);
2292
+ if (sub === 'clone')
2293
+ return cmdForgeClone(flags);
2294
+ if (sub === 'setup-git')
2295
+ return cmdForgeSetupGit(flags);
2296
+ if (sub === 'login')
2297
+ return cmdForgeLogin(flags); // device flow — needs no existing token
2298
+ const { opts } = resolveOpts(flags);
2299
+ switch (sub) {
2300
+ case 'repos':
2301
+ case 'repo':
2302
+ return cmdForgeRepos(flags, opts);
2303
+ case 'tree': {
2304
+ const repo = need(flags.positional[2], 'tilldev forge tree <repo> [--rev <r>] [--path <p>]');
2305
+ const r = await api.forgeTree(opts, repo, flags.named['rev'], flags.named['path']);
2306
+ render(flags, r, () => {
2307
+ const cols = [
2308
+ { header: 'TYPE', get: (e) => (e.type === 'tree' ? c.blue('dir') : e.type === 'commit' ? c.magenta('sub') : 'file') },
2309
+ { header: 'NAME', get: (e) => (e.type === 'tree' ? c.bold(e.name) : e.name) },
2310
+ { header: 'SIZE', get: (e) => (e.size >= 0 ? humanBytes(e.size) : c.dim('-')) },
2311
+ ];
2312
+ console.log(table(r.entries, cols));
2313
+ });
2314
+ return;
2315
+ }
2316
+ case 'blob': {
2317
+ const repo = need(flags.positional[2], 'tilldev forge blob <repo> --path <p> [--rev <r>]');
2318
+ const path = need(flags.named['path'], 'a file path is required (--path)');
2319
+ const r = await api.forgeBlob(opts, repo, path, flags.named['rev']);
2320
+ render(flags, r, () => {
2321
+ if (r.blob.binary)
2322
+ info(`${path} is binary (${humanBytes(r.blob.size)})`);
2323
+ else if (r.blob.truncated)
2324
+ info(`${path} is too large to display (${humanBytes(r.blob.size)})`);
2325
+ else
2326
+ process.stdout.write(r.blob.content);
2327
+ });
2328
+ return;
2329
+ }
2330
+ case 'log': {
2331
+ const repo = need(flags.positional[2], 'tilldev forge log <repo> [--rev <r>] [--limit <n>]');
2332
+ const r = await api.forgeCommits(opts, repo, flags.named['rev'], flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2333
+ render(flags, r, () => {
2334
+ for (const cm of r.commits) {
2335
+ console.log(`${c.yellow(cm.short)} ${truncate(cm.subject, 68)} ${c.dim(cm.author + ' · ' + ago(cm.date, now))}`);
2336
+ }
2337
+ });
2338
+ return;
2339
+ }
2340
+ case 'refs': {
2341
+ const repo = need(flags.positional[2], 'tilldev forge refs <repo>');
2342
+ const r = await api.forgeRefs(opts, repo);
2343
+ render(flags, r, () => {
2344
+ const cols = [
2345
+ { header: 'KIND', get: (x) => (x.kind === 'tag' ? c.magenta('tag') : c.green('branch')) },
2346
+ { header: 'NAME', get: (x) => c.bold(x.name) },
2347
+ { header: 'TARGET', get: (x) => c.dim((x.target || x.oid).slice(0, 12)) },
2348
+ ];
2349
+ console.log(table(r.refs, cols));
2350
+ });
2351
+ return;
2352
+ }
2353
+ case 'blame': {
2354
+ const repo = need(flags.positional[2], 'tilldev forge blame <repo> --path <p> [--rev <r>]');
2355
+ const path = need(flags.named['path'], 'a file path is required (--path)');
2356
+ const r = await api.forgeBlame(opts, repo, path, flags.named['rev']);
2357
+ render(flags, r, () => printJson(r));
2358
+ return;
2359
+ }
2360
+ case 'policy':
2361
+ case 'policies':
2362
+ return cmdForgePolicy(flags, opts);
2363
+ case 'reflog': {
2364
+ const repo = need(flags.positional[2], 'tilldev forge reflog <repo>');
2365
+ const r = await api.listForgeReflog(opts, repo, flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2366
+ render(flags, r, () => {
2367
+ const cols = [
2368
+ { header: 'ID', get: (e) => c.bold(String(e.id)) },
2369
+ { header: 'OP', get: (e) => (e.operation === 'force_update' || e.operation === 'delete' ? c.red(e.operation) : e.operation) },
2370
+ { header: 'REF', get: (e) => e.ref },
2371
+ { header: 'FROM→TO', get: (e) => c.dim(`${(e.old_oid || '∅').slice(0, 8)}→${(e.new_oid || '∅').slice(0, 8)}`) },
2372
+ { header: 'WHEN', get: (e) => ago(e.created_at, now) },
2373
+ ];
2374
+ console.log(table(r.reflog, cols));
2375
+ info('Recover a change with: tilldev forge recover <repo> <id>');
2376
+ });
2377
+ return;
2378
+ }
2379
+ case 'recover': {
2380
+ const repo = need(flags.positional[2], 'tilldev forge recover <repo> <reflog-id>');
2381
+ const id = need(flags.positional[3], 'tilldev forge recover <repo> <reflog-id>');
2382
+ const r = await api.recoverForgeRef(opts, repo, id);
2383
+ render(flags, r, () => ok(`Restored ${c.bold(r.recovered.ref)} → ${c.yellow(r.recovered.oid.slice(0, 12))}`));
2384
+ return;
2385
+ }
2386
+ case 'findings': {
2387
+ const repo = need(flags.positional[2], 'tilldev forge findings <repo>');
2388
+ const r = await api.listForgeFindings(opts, repo, flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2389
+ render(flags, r, () => {
2390
+ const cols = [
2391
+ { header: 'STATE', get: (f) => (f.action === 'rejected' ? c.red('rejected') : c.yellow('allowlisted')) },
2392
+ { header: 'RULE', get: (f) => c.bold(f.rule) },
2393
+ { header: 'WHERE', get: (f) => c.dim(`${f.file_path ?? '?'}:${f.line ?? '?'}`) },
2394
+ { header: 'WHEN', get: (f) => ago(f.created_at, now) },
2395
+ { header: 'ID', get: (f) => c.dim(truncate(f.id, 8)) },
2396
+ ];
2397
+ console.log(table(r.findings, cols));
2398
+ });
2399
+ return;
2400
+ }
2401
+ case 'allowlist': {
2402
+ const repo = need(flags.positional[2], 'tilldev forge allowlist <repo> <finding-id> --reason <r>');
2403
+ const id = need(flags.positional[3], 'tilldev forge allowlist <repo> <finding-id> --reason <r>');
2404
+ const reason = need(flags.named['reason'], 'a reason is required (--reason)');
2405
+ const r = await api.allowlistForgeFinding(opts, repo, id, reason);
2406
+ render(flags, r, () => ok(`Allowlisted finding ${c.bold(r.finding.id)} (${r.finding.rule})`));
2407
+ return;
2408
+ }
2409
+ case 'pr':
2410
+ case 'pull':
2411
+ case 'pulls':
2412
+ return cmdForgePR(flags, opts);
2413
+ case 'checks': {
2414
+ const repo = need(flags.positional[2], 'tilldev forge checks <repo> [--sha <s>]');
2415
+ const r = await api.listForgeChecks(opts, repo, flags.named['sha']);
2416
+ render(flags, r, () => {
2417
+ const cols = [
2418
+ { header: 'STATE', get: (x) => (x.state === 'success' ? c.green(x.state) : x.state === 'pending' ? c.yellow(x.state) : c.red(x.state)) },
2419
+ { header: 'CONTEXT', get: (x) => c.bold(x.context) },
2420
+ { header: 'SHA', get: (x) => c.dim(x.commit_sha.slice(0, 8)) },
2421
+ ];
2422
+ console.log(table(r.checks, cols));
2423
+ });
2424
+ return;
2425
+ }
2426
+ case 'check': {
2427
+ const repo = need(flags.positional[2], 'tilldev forge check <repo> --sha <s> --context <c> --state <st> [--url <u>]');
2428
+ const r = await api.reportForgeCheck(opts, repo, {
2429
+ commit_sha: need(flags.named['sha'], 'a commit sha is required (--sha)'),
2430
+ context: need(flags.named['context'], 'a context is required (--context)'),
2431
+ state: need(flags.named['state'], 'a state is required (--state success|failure|pending|error|cancelled)'),
2432
+ ...(flags.named['url'] ? { target_url: flags.named['url'] } : {}),
2433
+ ...(flags.named['description'] ? { description: flags.named['description'] } : {}),
2434
+ });
2435
+ render(flags, r, () => ok(`Reported ${c.bold(r.check.context)} = ${r.check.state} for ${c.dim(r.check.commit_sha.slice(0, 8))}`));
2436
+ return;
2437
+ }
2438
+ case 'ci':
2439
+ return cmdForgeCi(flags, opts);
2440
+ case 'correlation':
2441
+ case 'crash': {
2442
+ const repo = need(flags.positional[2], 'tilldev forge correlation <repo> [--rev <r>]');
2443
+ const r = await api.forgeCorrelation(opts, repo, flags.named['rev']);
2444
+ render(flags, r, () => {
2445
+ info(`${r.shipped} of ${r.commits_scanned} recent commits mapped to a release (release-granular)`);
2446
+ for (const e of r.correlation) {
2447
+ const cf = e.release.crash_free_pct == null ? c.dim('n/a') : `${e.release.crash_free_pct}%`;
2448
+ const grade = e.release.grade ? c.bold(e.release.grade) : c.dim('-');
2449
+ console.log(`${c.yellow(e.short)} ${truncate(e.subject, 44)} ${c.dim('→')} ${c.bold(e.release.version)} [${grade}] crash-free ${cf}`);
2450
+ }
2451
+ });
2452
+ return;
2453
+ }
2454
+ case 'tokens':
2455
+ case 'token':
2456
+ return cmdForgeTokens(flags, opts);
2457
+ case 'egress':
2458
+ return cmdForgeEgress(flags, opts);
2459
+ case 'mirror': {
2460
+ const repo = need(flags.positional[2], 'tilldev forge mirror <repo> --url <u> | tilldev forge mirror <repo> --remove');
2461
+ if (flags.bool.has('remove')) {
2462
+ const r = await api.removeForgeMirror(opts, repo);
2463
+ render(flags, r, () => ok(`Mirror removed for ${c.bold(repo)}`));
2464
+ return;
2465
+ }
2466
+ const url = need(flags.named['url'], 'a mirror URL is required (--url); it is SSRF-checked and encrypted at rest');
2467
+ const r = await api.setForgeMirror(opts, repo, { url, ...(flags.named['direction'] ? { direction: flags.named['direction'] } : {}) });
2468
+ render(flags, r, () => ok(`Mirror configured for ${c.bold(repo)}`));
2469
+ return;
2470
+ }
2471
+ case 'keys':
2472
+ case 'key':
2473
+ return cmdForgeKeys(flags, opts);
2474
+ case 'keys-account':
2475
+ case 'account-keys':
2476
+ return cmdForgeAccountKeys(flags, opts);
2477
+ case 'issue':
2478
+ case 'issues':
2479
+ return cmdForgeIssue(flags, opts);
2480
+ case 'label':
2481
+ case 'labels':
2482
+ return cmdForgeLabel(flags, opts);
2483
+ case 'collab':
2484
+ case 'collaborators':
2485
+ case 'collaborator':
2486
+ return cmdForgeCollab(flags, opts);
2487
+ case 'compare': {
2488
+ const repo = need(flags.positional[2], 'tilldev forge compare <repo> --base <ref> --head <ref>');
2489
+ const base = need(flags.named['base'], 'a base revision is required (--base)');
2490
+ const head = need(flags.named['head'], 'a head revision is required (--head)');
2491
+ const r = await api.forgeCompare(opts, repo, base, head);
2492
+ render(flags, r, () => {
2493
+ info(`${r.files.length} file(s) changed ${c.dim(`${base} → ${head}`)}`);
2494
+ for (const f of r.files)
2495
+ console.log(` ${f.path}`);
2496
+ });
2497
+ return;
2498
+ }
2499
+ case 'codeowners': {
2500
+ const repo = need(flags.positional[2], 'tilldev forge codeowners <repo> [--rev <r>]');
2501
+ const r = await api.getForgeCodeowners(opts, repo, flags.named['rev']);
2502
+ render(flags, r, () => {
2503
+ if (!r.configured) {
2504
+ info('No CODEOWNERS file found.');
2505
+ return;
2506
+ }
2507
+ info(`CODEOWNERS (${r.source}):`);
2508
+ for (const rule of r.rules)
2509
+ console.log(` ${c.bold(rule.pattern)} ${c.dim(rule.owners.join(' '))}`);
2510
+ });
2511
+ return;
2512
+ }
2513
+ case 'audit': {
2514
+ const r = await api.listForgeAudit(opts, flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2515
+ render(flags, r, () => {
2516
+ const cols = [
2517
+ { header: 'WHEN', get: (a) => ago(a.created_at, now) },
2518
+ { header: 'ACTOR', get: (a) => `${a.actor_type}${a.actor_id ? c.dim(':' + truncate(a.actor_id, 8)) : ''}` },
2519
+ { header: 'ACTION', get: (a) => c.bold(a.action) },
2520
+ { header: 'CONTEXT', get: (a) => c.dim(truncate(JSON.stringify(a.metadata ?? {}), 40)) },
2521
+ ];
2522
+ console.log(table(r.audit, cols));
2523
+ });
2524
+ return;
2525
+ }
2526
+ case 'search': {
2527
+ const repo = need(flags.positional[2], 'tilldev forge search <repo> <query> [--rev <r>]');
2528
+ const query = need(flags.positional[3], 'a search query is required');
2529
+ const r = await api.forgeSearch(opts, repo, query, flags.named['rev'], flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2530
+ render(flags, r, () => {
2531
+ if (r.matches.length === 0) {
2532
+ info('no matches');
2533
+ return;
2534
+ }
2535
+ for (const m of r.matches)
2536
+ console.log(`${c.dim(m.path + ':' + m.line)} ${truncate(m.text.trim(), 90)}`);
2537
+ });
2538
+ return;
2539
+ }
2540
+ case 'env':
2541
+ case 'environments': {
2542
+ const action = flags.positional[2] ?? 'ls';
2543
+ const repo = need(flags.positional[3], `tilldev forge env ${action} <repo> …`);
2544
+ if (action === 'set') {
2545
+ const r = await api.forgeSetEnvironment(opts, repo, {
2546
+ name: need(flags.named['name'], '--name is required'),
2547
+ production: flags.bool.has('production'),
2548
+ url: flags.named['url'],
2549
+ required_approvals: flags.named['approvals'] ? Number(flags.named['approvals']) : undefined,
2550
+ });
2551
+ render(flags, r, () => ok(`environment ${r.environment.name} set`));
2552
+ return;
2553
+ }
2554
+ const r = await api.forgeEnvironments(opts, repo);
2555
+ render(flags, r, () => {
2556
+ const cols = [
2557
+ { header: 'NAME', get: (e) => c.bold(e.name) },
2558
+ { header: 'KIND', get: (e) => (e.production ? c.magenta('production') : c.dim('preview')) },
2559
+ { header: 'URL', get: (e) => e.url || c.dim('-') },
2560
+ { header: 'APPROVALS', get: (e) => String(e.required_approvals) },
2561
+ ];
2562
+ console.log(table(r.environments, cols));
2563
+ });
2564
+ return;
2565
+ }
2566
+ case 'deploy':
2567
+ case 'deployments': {
2568
+ const action = flags.positional[2] ?? 'ls';
2569
+ const repo = need(flags.positional[3], `tilldev forge deploy ${action} <repo> …`);
2570
+ if (action === 'create') {
2571
+ const r = await api.forgeCreateDeployment(opts, repo, { environment: need(flags.named['env'], '--env is required'), ref: need(flags.named['ref'], '--ref is required'), url: flags.named['url'], description: flags.named['desc'] });
2572
+ render(flags, r, () => ok(`deployment to ${r.deployment.environment} queued`));
2573
+ return;
2574
+ }
2575
+ if (action === 'status') {
2576
+ const id = need(flags.positional[4], 'tilldev forge deploy status <repo> <id> --state <s>');
2577
+ const r = await api.forgeUpdateDeployment(opts, repo, id, { state: need(flags.named['state'], '--state is required'), url: flags.named['url'] });
2578
+ render(flags, r, () => ok(`deployment ${r.deployment.state}`));
2579
+ return;
2580
+ }
2581
+ const r = await api.forgeDeployments(opts, repo, flags.named['env']);
2582
+ render(flags, r, () => {
2583
+ const cols = [
2584
+ { header: 'WHEN', get: (d) => ago(d.created_at, now) },
2585
+ { header: 'ENV', get: (d) => c.bold(d.environment) },
2586
+ { header: 'STATE', get: (d) => d.state },
2587
+ { header: 'REF', get: (d) => d.ref.replace('refs/heads/', '') },
2588
+ { header: 'URL', get: (d) => d.url || c.dim('-') },
2589
+ ];
2590
+ console.log(table(r.deployments, cols));
2591
+ });
2592
+ return;
2593
+ }
2594
+ case 'release':
2595
+ case 'releases': {
2596
+ const action = flags.positional[2] ?? 'ls';
2597
+ const repo = need(flags.positional[3], `tilldev forge release ${action} <repo> …`);
2598
+ if (action === 'create') {
2599
+ const r = await api.forgeCreateRelease(opts, repo, { tag: need(flags.named['tag'], '--tag is required'), name: flags.named['name'], target_sha: flags.named['target'], body: flags.named['notes'], generate_notes: flags.bool.has('generate') || !flags.named['notes'], prerelease: flags.bool.has('prerelease'), draft: flags.bool.has('draft') });
2600
+ render(flags, r, () => ok(`release ${r.release.tag} created`));
2601
+ return;
2602
+ }
2603
+ if (action === 'get') {
2604
+ const tag = need(flags.positional[4], 'tilldev forge release get <repo> <tag>');
2605
+ const r = await api.forgeGetRelease(opts, repo, tag);
2606
+ render(flags, r, () => { info(c.bold(r.release.tag) + (r.release.name ? ` — ${r.release.name}` : '')); if (r.release.body)
2607
+ console.log('\n' + r.release.body); });
2608
+ return;
2609
+ }
2610
+ if (action === 'rm') {
2611
+ const tag = need(flags.positional[4], 'tilldev forge release rm <repo> <tag>');
2612
+ await api.forgeDeleteRelease(opts, repo, tag);
2613
+ ok(`release ${tag} deleted`);
2614
+ return;
2615
+ }
2616
+ const r = await api.forgeReleases(opts, repo);
2617
+ render(flags, r, () => {
2618
+ const cols = [
2619
+ { header: 'TAG', get: (x) => c.bold(x.tag) },
2620
+ { header: 'NAME', get: (x) => x.name || c.dim('-') },
2621
+ { header: 'STATE', get: (x) => (x.draft ? c.dim('draft') : x.prerelease ? c.yellow('pre') : c.green('published')) },
2622
+ { header: 'BUILDS', get: (x) => String(x.assets.length) },
2623
+ { header: 'WHEN', get: (x) => (x.published_at ? ago(x.published_at, now) : c.dim('-')) },
2624
+ ];
2625
+ console.log(table(r.releases, cols));
2626
+ });
2627
+ return;
2628
+ }
2629
+ case 'asset':
2630
+ case 'assets': {
2631
+ const action = flags.positional[2] ?? 'add';
2632
+ const repo = need(flags.positional[3], `tilldev forge asset ${action} <repo> <tag> …`);
2633
+ const tag = need(flags.positional[4], 'a release tag is required');
2634
+ if (action === 'rm') {
2635
+ const id = need(flags.positional[5], 'tilldev forge asset rm <repo> <tag> <id>');
2636
+ await api.forgeRemoveAsset(opts, repo, tag, id);
2637
+ ok('build removed');
2638
+ return;
2639
+ }
2640
+ const r = await api.forgeAddAsset(opts, repo, tag, { name: need(flags.named['name'], '--name is required'), platform: flags.named['platform'] ?? 'any', arch: flags.named['arch'], url: need(flags.named['url'], '--url is required'), sha256: flags.named['sha256'] });
2641
+ render(flags, r, () => ok(`build attached to ${tag}`));
2642
+ return;
2643
+ }
2644
+ case 'webhook':
2645
+ case 'webhooks': {
2646
+ const action = flags.positional[2] ?? 'ls';
2647
+ if (action === 'add') {
2648
+ const r = await api.forgeCreateWebhook(opts, { url: need(flags.named['url'], '--url is required'), events: (flags.named['events'] ?? 'push').split(',').map((s) => s.trim()).filter(Boolean), repo: flags.named['repo'], secret: flags.named['secret'] });
2649
+ render(flags, r, () => ok(`webhook ${r.webhook.id} created`));
2650
+ return;
2651
+ }
2652
+ if (action === 'rm') {
2653
+ await api.forgeDeleteWebhook(opts, need(flags.positional[3], 'tilldev forge webhook rm <id>'));
2654
+ ok('webhook deleted');
2655
+ return;
2656
+ }
2657
+ if (action === 'ping') {
2658
+ const r = await api.forgePingWebhook(opts, need(flags.positional[3], 'tilldev forge webhook ping <id>'));
2659
+ render(flags, r, () => { const d = r.delivery; (d.success ? ok : warn)(`${d.status_code || 'no response'} · ${d.duration_ms}ms${d.error ? ' · ' + d.error : ''}`); });
2660
+ return;
2661
+ }
2662
+ if (action === 'deliveries') {
2663
+ const r = await api.forgeWebhookDeliveries(opts, need(flags.positional[3], 'tilldev forge webhook deliveries <id>'));
2664
+ render(flags, r, () => {
2665
+ const cols = [
2666
+ { header: 'WHEN', get: (d) => ago(d.created_at, now) },
2667
+ { header: 'EVENT', get: (d) => d.event },
2668
+ { header: 'STATUS', get: (d) => (d.success ? c.green(String(d.status_code)) : c.red(String(d.status_code || '-'))) },
2669
+ { header: 'MS', get: (d) => String(d.duration_ms) },
2670
+ ];
2671
+ console.log(table(r.deliveries, cols));
2672
+ });
2673
+ return;
2674
+ }
2675
+ const r = await api.forgeWebhooks(opts);
2676
+ render(flags, r, () => {
2677
+ const cols = [
2678
+ { header: 'URL', get: (w) => truncate(w.url, 46) },
2679
+ { header: 'EVENTS', get: (w) => w.events.join(',') || c.dim('all') },
2680
+ { header: 'SCOPE', get: (w) => (w.repo_id ? 'repo' : c.dim('org')) },
2681
+ { header: 'ACTIVE', get: (w) => (w.active ? c.green('yes') : c.dim('no')) },
2682
+ ];
2683
+ console.log(table(r.webhooks, cols));
2684
+ });
2685
+ return;
2686
+ }
2687
+ default:
2688
+ throw new Error('usage: tilldev forge <clone|setup-git|repos|tree|blob|log|refs|blame|search|policy|reflog|recover|findings|allowlist|pr|compare|issue|label|collab|checks|check|ci|correlation|env|deploy|release|asset|webhook|tokens|keys|keys-account|audit>');
2689
+ }
2690
+ }
2691
+ async function cmdForgeRepos(flags, opts) {
2692
+ const action = flags.positional[2] ?? 'ls';
2693
+ switch (action) {
2694
+ case 'ls':
2695
+ case 'list': {
2696
+ const r = await api.listForgeRepos(opts);
2697
+ render(flags, r, () => {
2698
+ const cols = [
2699
+ { header: 'NAME', get: (x) => c.bold(x.slug) },
2700
+ { header: 'VIS', get: (x) => (x.visibility === 'public' ? c.yellow(x.visibility) : x.visibility) },
2701
+ { header: 'BRANCH', get: (x) => x.default_branch },
2702
+ { header: 'SIZE', get: (x) => humanBytes(x.size_bytes) },
2703
+ { header: 'STATUS', get: (x) => (x.status === 'active' ? c.green(x.status) : c.yellow(x.status)) },
2704
+ ];
2705
+ console.log(table(r.repos, cols));
2706
+ });
2707
+ return;
2708
+ }
2709
+ case 'get': {
2710
+ const repo = need(flags.positional[3], 'tilldev forge repos get <slug>');
2711
+ const r = await api.getForgeRepo(opts, repo);
2712
+ render(flags, r, () => console.log(kv([
2713
+ ['slug', r.repo.slug],
2714
+ ['name', r.repo.name],
2715
+ ['visibility', r.repo.visibility],
2716
+ ['default branch', r.repo.default_branch],
2717
+ ['size', humanBytes(r.repo.size_bytes)],
2718
+ ['status', r.repo.status],
2719
+ ['id', c.dim(r.repo.id)],
2720
+ ])));
2721
+ return;
2722
+ }
2723
+ case 'create':
2724
+ case 'new': {
2725
+ const name = need(flags.named['name'] ?? flags.positional[3], 'tilldev forge repos create --name <n> [--slug <s>] [--visibility <v>]');
2726
+ const r = await api.createForgeRepo(opts, {
2727
+ name,
2728
+ ...(flags.named['slug'] ? { slug: flags.named['slug'] } : {}),
2729
+ ...(flags.named['desc'] ? { description: flags.named['desc'] } : {}),
2730
+ ...(flags.named['visibility'] ? { visibility: flags.named['visibility'] } : {}),
2731
+ ...(flags.named['default-branch'] ? { default_branch: flags.named['default-branch'] } : {}),
2732
+ });
2733
+ render(flags, r, () => {
2734
+ ok(`Created ${c.bold(r.repo.slug)} (${r.repo.visibility})`);
2735
+ info('Protected default branch seeded. Mint a push token with: tilldev forge tokens create --scope write');
2736
+ });
2737
+ return;
2738
+ }
2739
+ case 'rm':
2740
+ case 'delete': {
2741
+ const repo = need(flags.positional[3], 'tilldev forge repos rm <slug>');
2742
+ const r = await api.deleteForgeRepo(opts, repo);
2743
+ render(flags, r, () => ok(`Deleted ${c.bold(repo)}`));
2744
+ return;
2745
+ }
2746
+ default:
2747
+ throw new Error('usage: tilldev forge repos <ls|get|create|rm>');
2748
+ }
2749
+ }
2750
+ async function cmdForgePolicy(flags, opts) {
2751
+ const action = flags.positional[2] ?? 'ls';
2752
+ if (action === 'ls' || action === 'list') {
2753
+ const repo = need(flags.positional[3], 'tilldev forge policy ls <repo>');
2754
+ const r = await api.listForgePolicies(opts, repo);
2755
+ render(flags, r, () => {
2756
+ const cols = [
2757
+ { header: 'PATTERN', get: (p) => c.bold(p.pattern) },
2758
+ { header: 'PROT', get: (p) => (p.protected ? c.green('yes') : c.dim('no')) },
2759
+ { header: 'SIGNED', get: (p) => (p.require_signed ? c.green('req') : c.dim('-')) },
2760
+ { header: 'LINEAR', get: (p) => (p.require_linear ? c.green('req') : c.dim('-')) },
2761
+ { header: 'APPROVALS', get: (p) => String(p.required_approvals) },
2762
+ { header: 'MFA', get: (p) => (p.require_mfa_to_push ? c.green('req') : c.dim('-')) },
2763
+ ];
2764
+ console.log(table(r.policies, cols));
2765
+ });
2766
+ return;
2767
+ }
2768
+ if (action === 'set') {
2769
+ const repo = need(flags.positional[3], 'tilldev forge policy set <repo> --pattern <p> [flags]');
2770
+ const pattern = need(flags.named['pattern'], 'a branch pattern is required (--pattern), e.g. main or release/*');
2771
+ const body = { pattern };
2772
+ if (flags.bool.has('unprotect'))
2773
+ body['protected'] = false;
2774
+ if (flags.bool.has('require-signed'))
2775
+ body['require_signed'] = true;
2776
+ if (flags.bool.has('require-linear'))
2777
+ body['require_linear'] = true;
2778
+ if (flags.bool.has('require-mfa'))
2779
+ body['require_mfa_to_push'] = true;
2780
+ if (flags.bool.has('allow-force'))
2781
+ body['allow_force_push'] = true;
2782
+ if (flags.bool.has('allow-delete'))
2783
+ body['allow_deletions'] = true;
2784
+ if (flags.named['approvals'])
2785
+ body['required_approvals'] = Number(flags.named['approvals']);
2786
+ if (flags.named['checks'])
2787
+ body['required_checks'] = flags.named['checks'].split(',').map((s) => s.trim()).filter(Boolean);
2788
+ const r = await api.putForgePolicy(opts, repo, body);
2789
+ render(flags, r, () => ok(`Policy for ${c.bold(r.policy.pattern)} saved (protected: ${r.policy.protected})`));
2790
+ return;
2791
+ }
2792
+ throw new Error('usage: tilldev forge policy <ls|set>');
2793
+ }
2794
+ async function cmdForgePR(flags, opts) {
2795
+ const action = flags.positional[2] ?? 'ls';
2796
+ switch (action) {
2797
+ case 'ls':
2798
+ case 'list': {
2799
+ const repo = need(flags.positional[3], 'tilldev forge pr ls <repo> [--state <s>]');
2800
+ const r = await api.listForgePulls(opts, repo, flags.named['state']);
2801
+ render(flags, r, () => {
2802
+ const cols = [
2803
+ { header: '#', get: (p) => c.bold('#' + p.number) },
2804
+ { header: 'STATE', get: (p) => (p.state === 'merged' ? c.magenta(p.state) : p.state === 'open' ? c.green(p.state) : c.dim(p.state)) },
2805
+ { header: 'TITLE', get: (p) => truncate(p.title, 44) },
2806
+ { header: 'HEAD→BASE', get: (p) => c.dim(`${p.source_ref.replace('refs/heads/', '')}→${p.target_ref.replace('refs/heads/', '')}`) },
2807
+ ];
2808
+ console.log(table(r.pulls, cols));
2809
+ });
2810
+ return;
2811
+ }
2812
+ case 'create':
2813
+ case 'open': {
2814
+ const repo = need(flags.positional[3], 'tilldev forge pr create <repo> --title <t> --source <ref> [--target <ref>]');
2815
+ const r = await api.createForgePull(opts, repo, {
2816
+ title: need(flags.named['title'], 'a title is required (--title)'),
2817
+ source_ref: need(flags.named['source'], 'a source branch is required (--source)'),
2818
+ ...(flags.named['target'] ? { target_ref: flags.named['target'] } : {}),
2819
+ ...(flags.named['body'] ? { body: flags.named['body'] } : {}),
2820
+ ...(flags.bool.has('draft') ? { draft: true } : {}),
2821
+ });
2822
+ render(flags, r, () => ok(`Opened PR ${c.bold('#' + r.pull.number)}: ${r.pull.title}`));
2823
+ return;
2824
+ }
2825
+ case 'get':
2826
+ case 'show': {
2827
+ const repo = need(flags.positional[3], 'tilldev forge pr get <repo> <number>');
2828
+ const num = Number(need(flags.positional[4], 'tilldev forge pr get <repo> <number>'));
2829
+ const r = await api.getForgePull(opts, repo, num);
2830
+ render(flags, r, () => {
2831
+ console.log(kv([
2832
+ ['pr', '#' + r.pull.number],
2833
+ ['title', r.pull.title],
2834
+ ['state', r.pull.state],
2835
+ ['source', r.pull.source_ref],
2836
+ ['target', r.pull.target_ref],
2837
+ ['head', r.pull.source_sha ? r.pull.source_sha.slice(0, 12) : c.dim('-')],
2838
+ ...(r.pull.merged_sha ? [['merged', r.pull.merged_sha.slice(0, 12)]] : []),
2839
+ ]));
2840
+ if (r.reviews.length) {
2841
+ console.log(c.dim('\nReviews:'));
2842
+ for (const rv of r.reviews)
2843
+ console.log(` ${rv.state === 'approved' ? c.green(rv.state) : c.yellow(rv.state)} ${c.dim(rv.reviewer_id ?? '')}`);
2844
+ }
2845
+ });
2846
+ return;
2847
+ }
2848
+ case 'merge': {
2849
+ const repo = need(flags.positional[3], 'tilldev forge pr merge <repo> <number> [--strategy <s>] [--message <m>]');
2850
+ const num = Number(need(flags.positional[4], 'tilldev forge pr merge <repo> <number>'));
2851
+ const r = await api.mergeForgePull(opts, repo, num, {
2852
+ ...(flags.named['strategy'] ? { strategy: flags.named['strategy'] } : {}),
2853
+ ...(flags.named['message'] ? { message: flags.named['message'] } : {}),
2854
+ });
2855
+ render(flags, r, () => ok(`Merged PR ${c.bold('#' + num)} → ${c.yellow(r.merged_sha.slice(0, 12))}`));
2856
+ return;
2857
+ }
2858
+ case 'review': {
2859
+ const repo = need(flags.positional[3], 'tilldev forge pr review <repo> <number> --state <approved|changes_requested|commented>');
2860
+ const num = Number(need(flags.positional[4], 'tilldev forge pr review <repo> <number> --state <s>'));
2861
+ const state = need(flags.named['state'], '--state approved|changes_requested|commented');
2862
+ const r = await api.reviewForgePull(opts, repo, num, { state, ...(flags.named['body'] ? { body: flags.named['body'] } : {}) });
2863
+ render(flags, r, () => ok(`Review submitted: ${r.review.state}`));
2864
+ return;
2865
+ }
2866
+ case 'close':
2867
+ case 'reopen': {
2868
+ const repo = need(flags.positional[3], `tilldev forge pr ${action} <repo> <number>`);
2869
+ const num = Number(need(flags.positional[4], `tilldev forge pr ${action} <repo> <number>`));
2870
+ const r = await api.updateForgePull(opts, repo, num, {
2871
+ state: action === 'close' ? 'closed' : 'open',
2872
+ ...(flags.named['title'] ? { title: flags.named['title'] } : {}),
2873
+ ...(flags.named['body'] ? { body: flags.named['body'] } : {}),
2874
+ });
2875
+ render(flags, r, () => ok(`PR ${c.bold('#' + num)} is now ${r.pull.state}`));
2876
+ return;
2877
+ }
2878
+ case 'comment': {
2879
+ const repo = need(flags.positional[3], 'tilldev forge pr comment <repo> <number> --body <b> [--file <p>] [--line <n>]');
2880
+ const num = Number(need(flags.positional[4], 'tilldev forge pr comment <repo> <number> --body <b>'));
2881
+ const r = await api.commentForgePull(opts, repo, num, {
2882
+ body: need(flags.named['body'], 'a comment body is required (--body)'),
2883
+ ...(flags.named['file'] ? { file_path: flags.named['file'] } : {}),
2884
+ ...(flags.named['line'] ? { line: Number(flags.named['line']) } : {}),
2885
+ ...(flags.named['sha'] ? { commit_sha: flags.named['sha'] } : {}),
2886
+ });
2887
+ render(flags, r, () => ok(`Comment added to PR ${c.bold('#' + num)}`));
2888
+ return;
2889
+ }
2890
+ case 'diff': {
2891
+ const repo = need(flags.positional[3], 'tilldev forge pr diff <repo> <number>');
2892
+ const num = Number(need(flags.positional[4], 'tilldev forge pr diff <repo> <number>'));
2893
+ const r = await api.forgePullDiff(opts, repo, num);
2894
+ render(flags, r, () => {
2895
+ info(`${r.files.length} file(s) changed ${c.dim(`${r.base} → ${r.head}`)}`);
2896
+ for (const f of r.files)
2897
+ console.log(` ${f.path}`);
2898
+ });
2899
+ return;
2900
+ }
2901
+ default:
2902
+ throw new Error('usage: tilldev forge pr <ls|create|get|merge|review|close|reopen|comment|diff>');
2903
+ }
2904
+ }
2905
+ // Self-hosted CI: runs are repo-scoped, so every subcommand pins --repo. `list`
2906
+ // shows the ledger, `logs` streams one run's stored log to stdout (pipe-friendly),
2907
+ // `run` enqueues .tillforge/ci.yml at a chosen (or default-tip) commit.
2908
+ async function cmdForgeCi(flags, opts) {
2909
+ const action = flags.positional[2] ?? 'list';
2910
+ const ciStatus = (s) => s === 'success' ? c.green(s)
2911
+ : s === 'failure' || s === 'error' ? c.red(s)
2912
+ : s === 'running' ? c.blue(s)
2913
+ : s === 'timed_out' ? c.yellow('timed out')
2914
+ : c.dim(s);
2915
+ switch (action) {
2916
+ case 'list':
2917
+ case 'ls': {
2918
+ const repo = need(flags.named['repo'], 'tilldev forge ci list --repo <slug> [--status <st>] [--workflow <w>]');
2919
+ const r = await api.listForgeCiJobs(opts, repo, flags.named['status'], flags.named['workflow'], flags.named['limit'] ? Number(flags.named['limit']) : undefined);
2920
+ render(flags, r, () => {
2921
+ const cols = [
2922
+ { header: 'STATUS', get: (j) => ciStatus(j.status) },
2923
+ { header: 'WORKFLOW', get: (j) => c.bold(j.workflow) },
2924
+ { header: 'TRIGGER', get: (j) => j.trigger.replace('_', ' ') },
2925
+ { header: 'SHA', get: (j) => c.dim(j.sha.slice(0, 8)) },
2926
+ { header: 'RUNNER', get: (j) => j.runner_name ?? c.dim('-') },
2927
+ { header: 'WHEN', get: (j) => ago(j.created_at, now) },
2928
+ { header: 'ID', get: (j) => c.dim(j.id) },
2929
+ ];
2930
+ console.log(table(r.jobs, cols));
2931
+ info(`Full log: tilldev forge ci logs <job-id> --repo ${repo}`);
2932
+ });
2933
+ return;
2934
+ }
2935
+ case 'logs':
2936
+ case 'log': {
2937
+ const id = need(flags.positional[3], 'tilldev forge ci logs <job-id> --repo <slug>');
2938
+ const repo = need(flags.named['repo'], 'a repo slug is required (--repo)');
2939
+ const r = await api.getForgeCiJob(opts, repo, id);
2940
+ render(flags, r, () => {
2941
+ const j = r.job;
2942
+ info(`${c.bold(j.workflow)} · ${ciStatus(j.status)} · ${c.dim(j.sha.slice(0, 8))}${j.failure_reason ? ` · ${j.failure_reason}` : ''}`);
2943
+ if (j.log_truncated)
2944
+ warn('log truncated at 1 MiB — the job kept running past this point');
2945
+ if (j.log)
2946
+ process.stdout.write(j.log.endsWith('\n') ? j.log : j.log + '\n');
2947
+ else
2948
+ info(j.status === 'queued' ? 'not started yet' : 'no output recorded');
2949
+ });
2950
+ return;
2951
+ }
2952
+ case 'run': {
2953
+ const repo = need(flags.named['repo'], 'tilldev forge ci run --repo <slug> [--sha <s>] [--ref <r>]');
2954
+ const r = await api.runForgeCi(opts, repo, flags.named['sha'], flags.named['ref']);
2955
+ render(flags, r, () => {
2956
+ if (r.config_missing) {
2957
+ warn(`no .tillforge/ci.yml at ${r.sha.slice(0, 8)} — nothing to run`);
2958
+ return;
2959
+ }
2960
+ if (r.config_error) {
2961
+ warn(`ci.yml invalid: ${r.config_error} (a visible 'config' error run was recorded)`);
2962
+ return;
2963
+ }
2964
+ ok(`enqueued ${r.enqueued} job(s) for ${c.dim(r.sha.slice(0, 8))} on ${r.ref.replace('refs/heads/', '')}`);
2965
+ });
2966
+ return;
2967
+ }
2968
+ default:
2969
+ throw new Error('usage: tilldev forge ci <list|logs|run>');
2970
+ }
2971
+ }
2972
+ async function cmdForgeTokens(flags, opts) {
2973
+ const action = flags.positional[2] ?? 'ls';
2974
+ switch (action) {
2975
+ case 'ls':
2976
+ case 'list': {
2977
+ const r = await api.listForgeTokens(opts);
2978
+ render(flags, r, () => {
2979
+ const cols = [
2980
+ { header: 'NAME', get: (t) => c.bold(t.name) },
2981
+ { header: 'SCOPE', get: (t) => t.scope },
2982
+ { header: 'PIN', get: (t) => (t.repo_id ? c.dim('repo') : c.green('org')) },
2983
+ { header: 'MFA', get: (t) => (t.mfa_verified ? c.green('yes') : c.dim('no')) },
2984
+ { header: 'PREFIX', get: (t) => c.dim(t.token_prefix) },
2985
+ { header: 'STATE', get: (t) => (t.revoked_at ? c.red('revoked') : c.green('active')) },
2986
+ ];
2987
+ console.log(table(r.tokens, cols));
2988
+ });
2989
+ return;
2990
+ }
2991
+ case 'create':
2992
+ case 'new': {
2993
+ const r = await api.createForgeToken(opts, {
2994
+ ...(flags.named['name'] ? { name: flags.named['name'] } : {}),
2995
+ ...(flags.named['scope'] ? { scope: flags.named['scope'] } : {}),
2996
+ ...(flags.named['repo'] ? { repo: flags.named['repo'] } : {}),
2997
+ ...(flags.named['expires'] ? { expires_in_days: Number(flags.named['expires']) } : {}),
2998
+ });
2999
+ render(flags, r, () => {
3000
+ ok(`Minted ${c.bold(r.token.scope)} token ${c.dim(r.token.token_prefix + '…')}`);
3001
+ console.log(`\n ${c.bold(r.token.secret)}\n`);
3002
+ warn('This is the ONLY time the token is shown. Use it as the git password: git clone https://<token>@<host>/<org>/<repo>.git');
3003
+ });
3004
+ return;
3005
+ }
3006
+ case 'revoke':
3007
+ case 'rm': {
3008
+ const id = need(flags.positional[3], 'tilldev forge tokens revoke <token-id>');
3009
+ const r = await api.revokeForgeToken(opts, id);
3010
+ render(flags, r, () => ok(`Revoked ${c.bold(id)}`));
3011
+ return;
3012
+ }
3013
+ default:
3014
+ throw new Error('usage: tilldev forge tokens <ls|create|revoke>');
3015
+ }
3016
+ }
3017
+ async function cmdForgeEgress(flags, opts) {
3018
+ const action = flags.positional[2] ?? 'policy';
3019
+ switch (action) {
3020
+ case 'policy': {
3021
+ const repo = need(flags.positional[3], 'tilldev forge egress policy <repo>');
3022
+ const r = await api.getForgeAiPolicy(opts, repo);
3023
+ render(flags, r, () => {
3024
+ const p = r.policy;
3025
+ console.log(kv([
3026
+ ['allow egress', p.allow_egress ? c.green('yes') : c.red('no (default-deny)')],
3027
+ ['sensitive', p.sensitive ? c.red('yes (always refuse)') : 'no'],
3028
+ ['redact secrets', p.redact_secrets ? c.green('yes') : c.red('no')],
3029
+ ['providers', p.allowed_providers.length ? p.allowed_providers.join(', ') : c.dim('any configured')],
3030
+ ['max context', p.max_context_bytes ? String(p.max_context_bytes) + ' bytes' : c.dim('unbounded')],
3031
+ ]));
3032
+ });
3033
+ return;
3034
+ }
3035
+ case 'set': {
3036
+ const repo = need(flags.positional[3], 'tilldev forge egress set <repo> [--allow] [--sensitive] [--providers a,b] [--max-bytes n] [--no-redact]');
3037
+ const body = {};
3038
+ if (flags.bool.has('allow'))
3039
+ body['allow_egress'] = true;
3040
+ if (flags.bool.has('sensitive'))
3041
+ body['sensitive'] = true;
3042
+ if (flags.bool.has('no-redact'))
3043
+ body['redact_secrets'] = false;
3044
+ if (flags.named['providers'])
3045
+ body['allowed_providers'] = flags.named['providers'].split(',').map((s) => s.trim()).filter(Boolean);
3046
+ if (flags.named['max-bytes'])
3047
+ body['max_context_bytes'] = Number(flags.named['max-bytes']);
3048
+ const r = await api.putForgeAiPolicy(opts, repo, body);
3049
+ render(flags, r, () => ok(`Egress policy set (allow: ${r.policy.allow_egress}, sensitive: ${r.policy.sensitive})`));
3050
+ return;
3051
+ }
3052
+ case 'audit': {
3053
+ const r = await api.listForgeEgress(opts, flags.named['limit'] ? Number(flags.named['limit']) : undefined);
3054
+ render(flags, r, () => printJson(r));
3055
+ return;
3056
+ }
3057
+ default:
3058
+ throw new Error('usage: tilldev forge egress <policy|set|audit>');
3059
+ }
3060
+ }
3061
+ async function cmdForgeKeys(flags, opts) {
3062
+ const action = flags.positional[2] ?? 'ls';
3063
+ switch (action) {
3064
+ case 'ls':
3065
+ case 'list': {
3066
+ const repo = need(flags.positional[3], 'tilldev forge keys ls <repo>');
3067
+ const r = await api.listForgeKeys(opts, repo);
3068
+ render(flags, r, () => {
3069
+ const cols = [
3070
+ { header: 'NAME', get: (k) => c.bold(k.name) },
3071
+ { header: 'SCOPE', get: (k) => k.scope },
3072
+ { header: 'FINGERPRINT', get: (k) => c.dim(truncate(k.fingerprint ?? '-', 24)) },
3073
+ { header: 'STATE', get: (k) => (k.revoked_at ? c.red('revoked') : k.expires_at && new Date(k.expires_at) < new Date() ? c.yellow('expired') : c.green('active')) },
3074
+ ];
3075
+ console.log(table(r.keys, cols));
3076
+ });
3077
+ return;
3078
+ }
3079
+ case 'add': {
3080
+ const repo = need(flags.positional[3], 'tilldev forge keys add <repo> --name <n> --key "<ssh public key>" [--scope read|write] [--expires <days>]');
3081
+ const publicKey = need(flags.named['key'], 'the SSH public key is required (--key "ssh-ed25519 AAAA…")');
3082
+ const r = await api.addForgeKey(opts, repo, {
3083
+ name: need(flags.named['name'], 'a key name is required (--name)'),
3084
+ public_key: publicKey,
3085
+ ...(flags.named['scope'] ? { scope: flags.named['scope'] } : {}),
3086
+ ...(flags.named['expires'] ? { expires_in_days: Number(flags.named['expires']) } : {}),
3087
+ });
3088
+ render(flags, r, () => ok(`Registered key ${c.bold(r.key.name)} (${c.dim(r.key.fingerprint ?? '')})`));
3089
+ return;
3090
+ }
3091
+ case 'revoke':
3092
+ case 'rm': {
3093
+ const repo = need(flags.positional[3], 'tilldev forge keys revoke <repo> <key-id>');
3094
+ const id = need(flags.positional[4], 'tilldev forge keys revoke <repo> <key-id>');
3095
+ const r = await api.revokeForgeKey(opts, repo, id);
3096
+ render(flags, r, () => ok(`Revoked key ${c.bold(id)}`));
3097
+ return;
3098
+ }
3099
+ default:
3100
+ throw new Error('usage: tilldev forge keys <ls|add|revoke>');
3101
+ }
3102
+ }
3103
+ async function cmdForgeAccountKeys(flags, opts) {
3104
+ const action = flags.positional[2] ?? 'ls';
3105
+ switch (action) {
3106
+ case 'ls':
3107
+ case 'list': {
3108
+ const r = await api.listForgeAccountKeys(opts);
3109
+ render(flags, r, () => {
3110
+ const cols = [
3111
+ { header: 'NAME', get: (k) => c.bold(k.name) },
3112
+ { header: 'SCOPE', get: (k) => k.scope },
3113
+ { header: 'FINGERPRINT', get: (k) => c.dim(truncate(k.fingerprint ?? '-', 24)) },
3114
+ { header: 'STATE', get: (k) => (k.revoked_at ? c.red('revoked') : k.expires_at && new Date(k.expires_at) < new Date() ? c.yellow('expired') : c.green('active')) },
3115
+ ];
3116
+ console.log(table(r.keys, cols));
3117
+ });
3118
+ return;
3119
+ }
3120
+ case 'add': {
3121
+ const publicKey = need(flags.named['key'], 'the SSH public key is required (--key "ssh-ed25519 AAAA…")');
3122
+ const r = await api.addForgeAccountKey(opts, {
3123
+ name: need(flags.named['name'], 'a key name is required (--name)'),
3124
+ public_key: publicKey,
3125
+ ...(flags.named['scope'] ? { scope: flags.named['scope'] } : {}),
3126
+ ...(flags.named['expires'] ? { expires_in_days: Number(flags.named['expires']) } : {}),
3127
+ });
3128
+ render(flags, r, () => ok(`Registered org-wide key ${c.bold(r.key.name)} (${c.dim(r.key.fingerprint ?? '')})`));
3129
+ return;
3130
+ }
3131
+ case 'revoke':
3132
+ case 'rm': {
3133
+ const id = need(flags.positional[3], 'tilldev forge keys-account revoke <key-id>');
3134
+ const r = await api.revokeForgeAccountKey(opts, id);
3135
+ render(flags, r, () => ok(`Revoked key ${c.bold(id)}`));
3136
+ return;
3137
+ }
3138
+ default:
3139
+ throw new Error('usage: tilldev forge keys-account <ls|add|rm>');
3140
+ }
3141
+ }
3142
+ async function cmdForgeIssue(flags, opts) {
3143
+ const action = flags.positional[2] ?? 'ls';
3144
+ switch (action) {
3145
+ case 'ls':
3146
+ case 'list': {
3147
+ const repo = need(flags.positional[3], 'tilldev forge issue ls <repo> [--state open|closed|all] [--label <l>]');
3148
+ const r = await api.listForgeIssues(opts, repo, {
3149
+ ...(flags.named['state'] ? { state: flags.named['state'] } : {}),
3150
+ ...(flags.named['label'] ? { label: flags.named['label'] } : {}),
3151
+ });
3152
+ render(flags, r, () => {
3153
+ const cols = [
3154
+ { header: '#', get: (i) => c.bold('#' + i.number) },
3155
+ { header: 'STATE', get: (i) => (i.state === 'open' ? c.green(i.state) : c.dim(i.state)) },
3156
+ { header: 'TITLE', get: (i) => truncate(i.title, 50) },
3157
+ { header: 'LABELS', get: (i) => c.dim(i.labels.join(', ')) },
3158
+ { header: 'COMMENTS', get: (i) => String(i.comment_count) },
3159
+ ];
3160
+ console.log(table(r.issues, cols));
3161
+ });
3162
+ return;
3163
+ }
3164
+ case 'create':
3165
+ case 'new': {
3166
+ const repo = need(flags.positional[3], 'tilldev forge issue create <repo> --title <t> [--body <b>] [--labels a,b] [--assignee <uid>]');
3167
+ const r = await api.createForgeIssue(opts, repo, {
3168
+ title: need(flags.named['title'], 'a title is required (--title)'),
3169
+ ...(flags.named['body'] ? { body: flags.named['body'] } : {}),
3170
+ ...(flags.named['labels'] ? { labels: flags.named['labels'].split(',').map((s) => s.trim()).filter(Boolean) } : {}),
3171
+ ...(flags.named['assignee'] ? { assignee_id: flags.named['assignee'] } : {}),
3172
+ });
3173
+ render(flags, r, () => ok(`Opened issue ${c.bold('#' + r.issue.number)}: ${r.issue.title}`));
3174
+ return;
3175
+ }
3176
+ case 'get':
3177
+ case 'show': {
3178
+ const repo = need(flags.positional[3], 'tilldev forge issue get <repo> <number>');
3179
+ const num = Number(need(flags.positional[4], 'tilldev forge issue get <repo> <number>'));
3180
+ const r = await api.getForgeIssue(opts, repo, num);
3181
+ render(flags, r, () => {
3182
+ console.log(kv([
3183
+ ['issue', '#' + r.issue.number],
3184
+ ['title', r.issue.title],
3185
+ ['state', r.issue.state],
3186
+ ['labels', r.issue.labels.length ? r.issue.labels.join(', ') : c.dim('-')],
3187
+ ['assignee', r.issue.assignee_id ?? c.dim('-')],
3188
+ ]));
3189
+ if (r.issue.body)
3190
+ console.log('\n' + r.issue.body);
3191
+ if (r.comments.length) {
3192
+ console.log(c.dim(`\nComments (${r.comments.length}):`));
3193
+ for (const cm of r.comments)
3194
+ console.log(` ${c.dim(ago(cm.created_at, now))} ${c.dim(cm.author_id ?? '')}\n ${truncate(cm.body, 100)}`);
3195
+ }
3196
+ });
3197
+ return;
3198
+ }
3199
+ case 'close':
3200
+ case 'reopen': {
3201
+ const repo = need(flags.positional[3], `tilldev forge issue ${action} <repo> <number>`);
3202
+ const num = Number(need(flags.positional[4], `tilldev forge issue ${action} <repo> <number>`));
3203
+ const r = await api.updateForgeIssue(opts, repo, num, { state: action === 'close' ? 'closed' : 'open' });
3204
+ render(flags, r, () => ok(`Issue ${c.bold('#' + num)} is now ${r.issue.state}`));
3205
+ return;
3206
+ }
3207
+ case 'comment': {
3208
+ const repo = need(flags.positional[3], 'tilldev forge issue comment <repo> <number> --body <b>');
3209
+ const num = Number(need(flags.positional[4], 'tilldev forge issue comment <repo> <number> --body <b>'));
3210
+ const body = need(flags.named['body'], 'a comment body is required (--body)');
3211
+ const r = await api.commentForgeIssue(opts, repo, num, body);
3212
+ render(flags, r, () => ok(`Comment added to issue ${c.bold('#' + num)}`));
3213
+ return;
3214
+ }
3215
+ default:
3216
+ throw new Error('usage: tilldev forge issue <ls|create|get|close|reopen|comment>');
3217
+ }
3218
+ }
3219
+ async function cmdForgeLabel(flags, opts) {
3220
+ const action = flags.positional[2] ?? 'ls';
3221
+ switch (action) {
3222
+ case 'ls':
3223
+ case 'list': {
3224
+ const repo = need(flags.positional[3], 'tilldev forge label ls <repo>');
3225
+ const r = await api.listForgeLabels(opts, repo);
3226
+ render(flags, r, () => {
3227
+ const cols = [
3228
+ { header: 'NAME', get: (l) => c.bold(l.name) },
3229
+ { header: 'COLOR', get: (l) => c.dim(l.color) },
3230
+ { header: 'DESCRIPTION', get: (l) => truncate(l.description, 50) },
3231
+ ];
3232
+ console.log(table(r.labels, cols));
3233
+ });
3234
+ return;
3235
+ }
3236
+ case 'create':
3237
+ case 'new': {
3238
+ const repo = need(flags.positional[3], 'tilldev forge label create <repo> --name <n> [--color #rrggbb] [--desc <d>]');
3239
+ const r = await api.createForgeLabel(opts, repo, {
3240
+ name: need(flags.named['name'], 'a label name is required (--name)'),
3241
+ ...(flags.named['color'] ? { color: flags.named['color'] } : {}),
3242
+ ...(flags.named['desc'] ? { description: flags.named['desc'] } : {}),
3243
+ });
3244
+ render(flags, r, () => ok(`Created label ${c.bold(r.label.name)} (${c.dim(r.label.color)})`));
3245
+ return;
3246
+ }
3247
+ default:
3248
+ throw new Error('usage: tilldev forge label <ls|create>');
3249
+ }
3250
+ }
3251
+ async function cmdForgeCollab(flags, opts) {
3252
+ const action = flags.positional[2] ?? 'ls';
3253
+ switch (action) {
3254
+ case 'ls':
3255
+ case 'list': {
3256
+ const repo = need(flags.positional[3], 'tilldev forge collab ls <repo>');
3257
+ const r = await api.listForgeCollaborators(opts, repo);
3258
+ render(flags, r, () => {
3259
+ const cols = [
3260
+ { header: 'USER', get: (x) => c.bold(x.user_id) },
3261
+ { header: 'ROLE', get: (x) => x.role },
3262
+ { header: 'ADDED', get: (x) => ago(x.created_at, now) },
3263
+ ];
3264
+ console.log(table(r.collaborators, cols));
3265
+ });
3266
+ return;
3267
+ }
3268
+ case 'add': {
3269
+ const repo = need(flags.positional[3], 'tilldev forge collab add <repo> --user <uid> [--role read|triage|write|admin]');
3270
+ const r = await api.addForgeCollaborator(opts, repo, {
3271
+ user_id: need(flags.named['user'], 'a user id is required (--user)'),
3272
+ ...(flags.named['role'] ? { role: flags.named['role'] } : {}),
3273
+ });
3274
+ render(flags, r, () => ok(`Granted ${c.bold(r.collaborator.role)} to ${c.dim(r.collaborator.user_id)}`));
3275
+ return;
3276
+ }
3277
+ case 'rm':
3278
+ case 'remove': {
3279
+ const repo = need(flags.positional[3], 'tilldev forge collab rm <repo> <collaborator-id>');
3280
+ const id = need(flags.positional[4], 'tilldev forge collab rm <repo> <collaborator-id>');
3281
+ const r = await api.removeForgeCollaborator(opts, repo, id);
3282
+ render(flags, r, () => ok(`Removed collaborator ${c.bold(id)}`));
3283
+ return;
3284
+ }
3285
+ default:
3286
+ throw new Error('usage: tilldev forge collab <ls|add|rm>');
3287
+ }
3288
+ }
3289
+ // ── Dispatch ─────────────────────────────────────────────────────────────────
3290
+ async function main() {
3291
+ const argv = process.argv.slice(2);
3292
+ const flags = parseArgs(argv);
3293
+ const cmd = flags.positional[0];
3294
+ if (!cmd || flags.bool.has('help')) {
3295
+ console.log(HELP);
3296
+ return;
3297
+ }
3298
+ if (cmd === 'version' || flags.bool.has('version')) {
3299
+ console.log(VERSION);
3300
+ return;
3301
+ }
3302
+ switch (cmd) {
3303
+ case 'help':
3304
+ console.log(HELP);
3305
+ return;
3306
+ case 'login':
3307
+ return cmdLogin(flags);
3308
+ case 'logout':
3309
+ return cmdLogout();
3310
+ case 'whoami':
3311
+ return cmdWhoami(flags);
3312
+ case 'config':
3313
+ return cmdConfig(flags);
3314
+ case 'pulse':
3315
+ return cmdPulse(flags);
3316
+ case 'shield':
3317
+ return cmdShield(flags);
3318
+ case 'cache':
3319
+ return cmdCache(flags);
3320
+ case 'secrets':
3321
+ return cmdSecrets(flags);
3322
+ case 'ark':
3323
+ case 'tillark':
3324
+ return cmdArk(flags);
3325
+ case 'forge':
3326
+ case 'tillforge':
3327
+ return cmdForge(flags);
3328
+ case 'gate':
3329
+ case 'tillgate':
3330
+ return cmdGate(flags);
3331
+ case 'auth':
3332
+ return cmdAuth(flags);
3333
+ case 'stacks':
3334
+ case 'stack':
3335
+ return cmdStacks(flags);
3336
+ case 'team':
3337
+ return cmdTeam(flags);
3338
+ case 'keys':
3339
+ return cmdKeys(flags);
3340
+ default:
3341
+ throw new Error(`unknown command: ${cmd}\n\nRun \`tilldev help\` for usage.`);
3342
+ }
3343
+ }
3344
+ // ── TillDev Stacks — the global "app" that spans products ────────────────────
3345
+ async function cmdStacks(flags) {
3346
+ const { opts, org } = resolveOpts(flags);
3347
+ const action = flags.positional[1] ?? 'ls';
3348
+ switch (action) {
3349
+ case 'ls':
3350
+ case 'list': {
3351
+ const { stacks } = await api.listStacks(opts);
3352
+ render(flags, { stacks }, () => {
3353
+ console.log(table(stacks, [
3354
+ { header: 'SLUG', get: (s) => c.bold(s.slug) },
3355
+ { header: 'NAME', get: (s) => s.name },
3356
+ { header: 'PRODUCTS', get: (s) => String((s.modules ?? []).filter((m) => m.status === 'active').length) },
3357
+ { header: 'STATUS', get: (s) => (s.status === 'active' ? c.green(s.status) : c.gray(s.status)) },
3358
+ ]));
3359
+ });
3360
+ return;
3361
+ }
3362
+ case 'show':
3363
+ case 'get': {
3364
+ const slug = need(flags.positional[2], 'tilldev stacks show <slug>');
3365
+ const detail = await api.getStack(opts, slug);
3366
+ render(flags, detail, () => {
3367
+ ok(`Stack ${c.bold(detail.stack.name)} ${c.dim(`(${detail.stack.slug})`)}`);
3368
+ console.log(kv([
3369
+ ['id', detail.stack.id],
3370
+ ['accent', detail.stack.accent],
3371
+ ['status', detail.stack.status],
3372
+ ['description', detail.stack.description || c.dim('—')],
3373
+ ]));
3374
+ console.log();
3375
+ console.log(table(detail.modules, [
3376
+ { header: 'PRODUCT', get: (m) => c.bold(m.module) },
3377
+ { header: 'STATUS', get: (m) => (m.status === 'active' ? c.green(m.status) : c.yellow(m.status)) },
3378
+ { header: 'RESOURCES', get: (m) => String(detail.counts[m.module] ?? 0) },
3379
+ ]));
3380
+ });
3381
+ return;
3382
+ }
3383
+ case 'create':
3384
+ case 'new': {
3385
+ const name = need(flags.named['name'] ?? flags.positional[2], 'tilldev stacks create <name> [--slug <s>] [--accent <hex>] [--modules pulse,auth,cache,…]');
3386
+ const input = { name };
3387
+ if (flags.named['slug'])
3388
+ input.slug = flags.named['slug'];
3389
+ if (flags.named['description'])
3390
+ input.description = flags.named['description'];
3391
+ if (flags.named['accent'])
3392
+ input.accent = flags.named['accent'];
3393
+ if (flags.named['modules'])
3394
+ input.modules = flags.named['modules'].split(',').map((s) => s.trim()).filter(Boolean);
3395
+ const { stack } = await api.createStack(opts, input);
3396
+ render(flags, { stack }, () => {
3397
+ ok(`Created stack ${c.bold(stack.name)} ${c.dim(`(${stack.slug})`)}`);
3398
+ console.log(kv([['id', stack.id], ['accent', stack.accent], ['products', String((stack.modules ?? []).length)]]));
3399
+ if (org)
3400
+ info(`Console: ${opts.apiUrl}/${org}/stacks/${stack.slug}`);
3401
+ info(`Add a product: tilldev stacks enable ${stack.slug} <product>`);
3402
+ info(`Attach a resource: tilldev stacks attach ${stack.slug} <product> <resource-id>`);
3403
+ });
3404
+ return;
3405
+ }
3406
+ case 'enable':
3407
+ case 'disable': {
3408
+ const slug = need(flags.positional[2], `tilldev stacks ${action} <slug> <product>`);
3409
+ const module = need(flags.positional[3], `tilldev stacks ${action} <slug> <product>`);
3410
+ const status = action === 'enable' ? 'active' : 'paused';
3411
+ const r = await api.setStackModule(opts, slug, module, status);
3412
+ render(flags, r, () => ok(`${action === 'enable' ? 'Enabled' : 'Paused'} ${c.bold(module)} for ${c.bold(slug)}`));
3413
+ return;
3414
+ }
3415
+ case 'attach':
3416
+ case 'detach': {
3417
+ const usage = `tilldev stacks ${action} <slug> <product> <resource-id>`;
3418
+ const slug = need(flags.positional[2], usage);
3419
+ const module = need(flags.positional[3], usage);
3420
+ const rid = need(flags.positional[4], usage);
3421
+ const r = await api.setStackResource(opts, slug, module, rid, action === 'attach' ? 'attach' : 'detach');
3422
+ render(flags, r, () => ok(`${action === 'attach' ? 'Attached' : 'Detached'} ${c.bold(module)} ${c.dim(rid)} ${action === 'attach' ? 'to' : 'from'} ${c.bold(slug)}`));
3423
+ return;
3424
+ }
3425
+ case 'rm':
3426
+ case 'delete': {
3427
+ const slug = need(flags.positional[2], 'tilldev stacks rm <slug>');
3428
+ await api.deleteStack(opts, slug);
3429
+ render(flags, { ok: true }, () => ok(`Deleted stack ${c.bold(slug)} ${c.dim('(resources un-grouped, not deleted)')}`));
3430
+ return;
3431
+ }
3432
+ default:
3433
+ throw new Error('usage: tilldev stacks <ls|show|create|enable|disable|attach|detach|rm>');
3434
+ }
3435
+ }
3436
+ main().catch((err) => {
3437
+ if (err instanceof ApiError) {
3438
+ fail(err.message);
3439
+ if (err.status === 401)
3440
+ console.error(c.dim('hint: run `tilldev login` to refresh your token.'));
3441
+ }
3442
+ else {
3443
+ fail(err instanceof Error ? err.message : String(err));
3444
+ }
3445
+ exit(1);
3446
+ });
3447
+ //# sourceMappingURL=index.js.map