@tillstack/cli 0.2.3 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output, exit } from 'node:process';
4
- import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { dirname, join } from 'node:path';
@@ -31,10 +31,16 @@ function parseArgs(argv) {
31
31
  const positional = [];
32
32
  const named = {};
33
33
  const bool = new Set();
34
+ let rest = [];
34
35
  for (let i = 0; i < argv.length; i++) {
35
36
  const a = argv[i];
36
37
  if (!a)
37
38
  continue;
39
+ if (a === '--') {
40
+ // Passthrough boundary: everything after is a verbatim command/argv.
41
+ rest = argv.slice(i + 1);
42
+ break;
43
+ }
38
44
  if (a === '-h') {
39
45
  bool.add('help');
40
46
  continue;
@@ -63,7 +69,7 @@ function parseArgs(argv) {
63
69
  positional.push(a);
64
70
  }
65
71
  }
66
- return { positional, named, bool };
72
+ return { positional, named, bool, rest };
67
73
  }
68
74
  // ── Prompts ──────────────────────────────────────────────────────────────────
69
75
  async function promptInput(promptText, opts = {}) {
@@ -246,6 +252,12 @@ ${c.bold('TILLSECRETS')} ${c.dim('(config + secrets)')} ${c.gray('tilldev secre
246
252
  secrets pull [--env <id>] Boot-time bulk pull → ${c.dim('.env on stdout (--json for JSON)')}
247
253
  secrets lease <KEY> [--ttl <sec>] Lease a short-lived dynamic credential ${c.dim('(--backend <id> instead of KEY)')}
248
254
  secrets lease revoke <lease-id> Revoke a lease early
255
+ ${c.dim('— secretless broker (use a secret without knowing it) —')}
256
+ secrets refs <file> Inspect ${c.dim('secretref://…')} handles (metadata only, no value)
257
+ secrets exec --env-file <f> -- <cmd> Run <cmd> with ${c.dim('secretref://')} env values materialised into its process only
258
+ secrets migrate --ref <ref> --file <sql> Apply a migration to a DB named only by reference ${c.dim('(conn string never leaves the broker)')}
259
+ secrets deploy-worker --ref <ref> --account <id> --name <s> --file <js> Deploy a CF Worker ${c.dim('(API token never leaves the broker)')}
260
+ secrets broker grants List consent grants ${c.dim('(grant create/revoke, allow, spends)')}
249
261
  secrets sync ls|add|rm|pause|resume|push --project <id> ${c.dim('One-way push targets')}
250
262
  ${c.dim('pull/lease authenticate with a ts_ service token: --token ts_… or $TILLSECRETS_TOKEN.')}
251
263
  ${c.dim('lease targets the dynamic-lease service — override with --lease-url / $TILLSECRETS_LEASE_URL.')}
@@ -274,6 +286,7 @@ ${c.bold('TILLARK')} ${c.dim('(backup & disaster recovery)')} ${c.gray('tilldev
274
286
  ${c.bold('TILLFORGE')} ${c.dim('(sovereign code hosting)')} ${c.gray('tilldev forge …')}
275
287
  forge login [--scope read|write] Sign in for git via the browser (device flow — no pasting tokens)
276
288
  forge clone <org>/<repo> [dir] Clone over HTTPS (token via helper, never in the URL)
289
+ forge push [<remote>] [<refspec>] [-u] Push; if a secret is blocked, resolve it interactively then re-push
277
290
  forge setup-git Wire git's credential helper for the forge host, once
278
291
  forge repos ls | get <slug> | rm <slug>
279
292
  forge repos create --name <n> [--slug <s>] [--visibility private|internal|public]
@@ -298,8 +311,11 @@ ${c.bold('TILLFORGE')} ${c.dim('(sovereign code hosting)')} ${c.gray('tilldev f
298
311
  forge release ls <repo> | get <repo> <tag> | rm <repo> <tag>
299
312
  forge release create <repo> --tag <t> [--name <n>] [--target <ref>] [--generate|--notes <md>] [--prerelease] [--draft] ${c.dim('(auto-changelog)')}
300
313
  forge asset add <repo> <tag> --name <n> --platform linux|darwin|windows|android|ios --url <u> [--arch <a>] [--sha256 <h>]
301
- forge env ls <repo> | set <repo> --name <n> [--production] [--url <u>] [--approvals <n>]
302
- forge deploy ls <repo> [--env <e>] | create <repo> --env <e> --ref <r> [--url <u>] | status <repo> <id> --state <s>
314
+ forge provider ls | connect --name <n> --provider <cloudflare|vercel|netlify|supabase|aws|firebase|surge|azure_swa|railway|fly|desktop> --credential '<k=v,…>' | rm <id>
315
+ ${c.dim('desktop signing (optional): --credential signing=none | signing=pfx,cert_base64=…,cert_password=… | signing=azure_trusted,tenant_id=…,client_id=…,client_secret=…,endpoint=…,account=…,certificate_profile=…')}
316
+ forge env ls <repo> | set <repo> --name <n> [--production] [--url <u>] [--approvals <n>] [--provider <p> --credential <id> --target 'project=…' --build '<cmd>' --output <dir> --auto-deploy <branch> | --auto-preview]
317
+ ${c.dim("desktop target: --provider desktop --credential <id> --target 'framework=electron,platforms=linux,windows' (installers publish to the ref's release with sha256 sums)")}
318
+ forge deploy ls <repo> [--env <e>] | create <repo> --env <e> --ref <r> | promote <repo> <id> --env <e> | rollback <repo> <id> | logs <repo> <id> | cancel <repo> <id> | status <repo> <id> --state <s>
303
319
  forge webhook ls | add --url <u> --events push,release,deployment [--repo <slug>] [--secret <s>] | rm <id> | ping <id> | deliveries <id>
304
320
  forge pr diff <repo> <number> | forge compare <repo> --base <ref> --head <ref> ${c.dim('(changed files)')}
305
321
  forge issue ls <repo> [--state open|closed|all] [--label <l>]
@@ -321,8 +337,9 @@ ${c.bold('TILLFORGE')} ${c.dim('(sovereign code hosting)')} ${c.gray('tilldev f
321
337
 
322
338
  ${c.bold('TILLAUTH')} ${c.dim('(auth)')} ${c.gray('tilldev auth …')}
323
339
  auth apps List auth apps
324
- auth apps create <name> Create an auth app
325
- auth app <appId> Show an auth app
340
+ auth apps create <name> [--slug <s>] Create an auth app
341
+ auth app <appRef> Show an auth app (by slug or id)
342
+ auth invite <appRef> <email> [--role <r>] Invite a user to an app
326
343
 
327
344
  ${c.bold('WORKSPACE')}
328
345
  team List org members
@@ -376,6 +393,19 @@ function need(value, usage) {
376
393
  throw new Error(`usage: ${usage}`);
377
394
  return value;
378
395
  }
396
+ // Parse a `k=v,k2=v2` flag into an object (deploy provider credentials + target
397
+ // config). Empty/absent → undefined so the caller can omit the field.
398
+ function parseKv(s) {
399
+ if (!s)
400
+ return undefined;
401
+ const out = {};
402
+ for (const pair of s.split(',')) {
403
+ const i = pair.indexOf('=');
404
+ if (i > 0)
405
+ out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
406
+ }
407
+ return Object.keys(out).length ? out : undefined;
408
+ }
379
409
  // Quote a value for dotenv output. Barewords pass through; anything with
380
410
  // whitespace/quotes/special chars is double-quoted with escapes, so
381
411
  // `tilldev secrets pull > .env` round-trips cleanly.
@@ -392,7 +422,7 @@ async function cmdLogin(flags) {
392
422
  let r = await api.login(opts, email, password);
393
423
  if (r.totpRequired) {
394
424
  const code = await promptInput('2FA code: ');
395
- r = await api.login(opts, email, password, code);
425
+ r = await api.login(opts, email, password, code, r.challengeToken);
396
426
  }
397
427
  saveConfig({ api_url: apiUrl, token: r.token, org: r.org });
398
428
  ok(`Signed in as ${c.bold(email)} — token saved to ${c.dim(configFilePath())} (0600)`);
@@ -705,8 +735,11 @@ async function cmdAuth(flags) {
705
735
  case undefined:
706
736
  case 'apps': {
707
737
  if (flags.positional[2] === 'create') {
708
- const name = need(flags.positional[3], 'tilldev auth apps create <name>');
709
- const r = await api.createAuthApp(opts, name);
738
+ const name = need(flags.positional[3], 'tilldev auth apps create <name> [--slug <s>]');
739
+ // The API requires a URL-safe slug; derive one from the name unless the
740
+ // operator supplies --slug. (Slug must start with a letter, 2–40 chars.)
741
+ const slug = (flags.named['slug']?.trim().toLowerCase()) || slugify(name);
742
+ const r = await api.createAuthApp(opts, name, slug);
710
743
  render(flags, r, () => {
711
744
  ok(`Created auth app ${c.bold(r.app.name)}`);
712
745
  console.log(kv([['id', r.app.id], ['public_id', r.app.public_id ?? '-'], ['slug', r.app.slug ?? '-']]));
@@ -725,11 +758,25 @@ async function cmdAuth(flags) {
725
758
  return;
726
759
  }
727
760
  case 'app': {
728
- const appId = need(flags.positional[2], 'tilldev auth app <appId>');
761
+ const appId = need(flags.positional[2], 'tilldev auth app <appRef>');
729
762
  const r = await api.getAuthApp(opts, appId);
730
763
  printJson(r);
731
764
  return;
732
765
  }
766
+ case 'invite': {
767
+ const appRef = need(flags.positional[2], 'tilldev auth invite <appRef> <email> [--role <r>]');
768
+ const email = need(flags.positional[3], 'tilldev auth invite <appRef> <email> [--role <r>]');
769
+ const role = flags.named['role']?.trim();
770
+ const r = await api.inviteAuthUser(opts, appRef, email, role);
771
+ render(flags, r, () => {
772
+ ok(`Invited ${c.bold(r.invite.email)} as ${c.dim(r.invite.role)}`);
773
+ if (r.delivered)
774
+ console.log(c.dim('An invitation email is on its way.'));
775
+ else if (r.accept_link)
776
+ console.log(`Share this link:\n ${c.bold(r.accept_link)}`);
777
+ });
778
+ return;
779
+ }
733
780
  default:
734
781
  throw new Error(`unknown: tilldev auth ${sub}`);
735
782
  }
@@ -1523,9 +1570,232 @@ async function cmdSecrets(flags) {
1523
1570
  });
1524
1571
  return;
1525
1572
  }
1573
+ case 'refs': {
1574
+ // Inspect the references in a file / --ref / stdin — resolves METADATA only
1575
+ // (resolvable? sensitivity?), never a value. `tilldev secrets refs [file]`.
1576
+ const text = await gatherRefText(flags);
1577
+ if (!text.trim())
1578
+ throw new Error('usage: tilldev secrets refs <file> | --ref <secretref://…> (or pipe on stdin)');
1579
+ const r = await api.resolveRefs(opts, { text, meta_only: true });
1580
+ render(flags, r, () => {
1581
+ if (r.resolved.length) {
1582
+ const cols = [
1583
+ { header: 'REF', get: (x) => c.bold(x.ref) },
1584
+ { header: 'KEY', get: (x) => x.key },
1585
+ { header: 'SENSITIVITY', get: (x) => String(x.sensitivity ?? '') },
1586
+ { header: 'OK', get: () => c.green('resolvable') },
1587
+ ];
1588
+ console.log(table(r.resolved, cols));
1589
+ }
1590
+ for (const f of r.refusals)
1591
+ warn(`${f.ref} — ${f.reason}`);
1592
+ if (!r.resolved.length && !r.refusals.length)
1593
+ info('No references found.');
1594
+ });
1595
+ return;
1596
+ }
1597
+ case 'exec': {
1598
+ // Run a command with `secretref://…` env values materialised into the CHILD
1599
+ // process's env — never printed, never in this process's env. Fail-closed:
1600
+ // if any referenced value can't resolve, the command does NOT run.
1601
+ // tilldev secrets exec --env-file .env.refs -- <cmd> [args…]
1602
+ const cmd = flags.rest;
1603
+ if (cmd.length === 0)
1604
+ throw new Error('usage: tilldev secrets exec --env-file <path> -- <command> [args…]');
1605
+ const file = need(flags.named['env-file'], 'tilldev secrets exec --env-file <path> -- <command>');
1606
+ if (!existsSync(file))
1607
+ throw new Error(`env file not found: ${file}`);
1608
+ const entries = parseDotenv(readFileSync(file, 'utf8'));
1609
+ const agentKey = flags.named['agent'] ?? 'cli';
1610
+ // Resolve every reference across all values in ONE audited call.
1611
+ const refText = entries.map((e) => e.value).join('\n');
1612
+ const r = await api.resolveRefs(opts, { text: refText, agent_key: agentKey, command: cmd.join(' ') });
1613
+ if (r.refusals.length && !flags.bool.has('allow-partial')) {
1614
+ for (const f of r.refusals)
1615
+ warn(`${f.ref} — ${f.reason}`);
1616
+ throw new Error('some references could not be resolved — refusing to exec (pass --allow-partial to run anyway)');
1617
+ }
1618
+ const valueByRef = new Map();
1619
+ for (const rv of r.resolved)
1620
+ if (rv.value != null)
1621
+ valueByRef.set(rv.ref, rv.value);
1622
+ // Substitute resolved values into each env value (literals pass through).
1623
+ const childEnv = {};
1624
+ let materialised = 0;
1625
+ for (const e of entries) {
1626
+ let v = e.value;
1627
+ let had = false;
1628
+ for (const [ref, val] of valueByRef) {
1629
+ if (v.includes(ref)) {
1630
+ v = v.split(ref).join(val);
1631
+ had = true;
1632
+ }
1633
+ }
1634
+ if (had)
1635
+ materialised++;
1636
+ childEnv[e.key] = v;
1637
+ }
1638
+ process.stderr.write(`${c.dim(`materialised ${valueByRef.size} reference(s) into ${materialised} env var(s); running: ${cmd.join(' ')}`)}\n`);
1639
+ const res = spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit', env: { ...process.env, ...childEnv } });
1640
+ if (res.error)
1641
+ throw res.error;
1642
+ process.exit(res.status ?? 1);
1643
+ return;
1644
+ }
1645
+ case 'migrate': {
1646
+ // Catalog action: apply a migration to a DB named only by reference. The
1647
+ // connection string never reaches this CLI — the broker holds + spends it.
1648
+ // tilldev secrets migrate --ref secretref://infra/prod/DATABASE_URL --file 0043.sql
1649
+ const secretRef = need(flags.named['ref'], 'tilldev secrets migrate --ref <secretref://…> --file <sql>');
1650
+ const file = need(flags.named['file'], 'tilldev secrets migrate --ref <secretref://…> --file <sql>');
1651
+ if (!existsSync(file))
1652
+ throw new Error(`sql file not found: ${file}`);
1653
+ const sql = readFileSync(file, 'utf8');
1654
+ const body = { secret_ref: secretRef, sql };
1655
+ if (flags.named['agent'])
1656
+ body.agent_key = flags.named['agent'];
1657
+ const r = await api.applyMigrationAction(opts, body);
1658
+ render(flags, r, () => {
1659
+ if (r.refusal) {
1660
+ fail(`Refused (${r.refusal.code}): ${r.refusal.reason}`);
1661
+ return;
1662
+ }
1663
+ for (const s of r.results ?? [])
1664
+ console.log(` ${s.ok ? c.green('✓') : c.red('✗')} ${s.preview}${s.error ? c.red(` ${s.error}`) : ''}`);
1665
+ if (r.ok)
1666
+ ok(`Applied ${r.statements} statement(s) via ${c.bold(secretRef)} — the connection string never left the broker`);
1667
+ else
1668
+ fail('A statement failed — see above.');
1669
+ });
1670
+ return;
1671
+ }
1672
+ case 'deploy-worker': {
1673
+ // Catalog action: deploy a Cloudflare Worker with the CF API token named only
1674
+ // by reference. The token never reaches this CLI — the broker holds + spends it.
1675
+ // tilldev secrets deploy-worker --ref secretref://infra/prod/CLOUDFLARE_API_TOKEN \
1676
+ // --account <32-hex> --name my-worker --file worker.js [--compat-date 2026-07-01]
1677
+ const secretRef = need(flags.named['ref'], 'tilldev secrets deploy-worker --ref <secretref://…> --account <id> --name <script> --file <worker.js>');
1678
+ const accountId = need(flags.named['account'], 'tilldev secrets deploy-worker --account <cloudflare-account-id>');
1679
+ const scriptName = need(flags.named['name'], 'tilldev secrets deploy-worker --name <script-name>');
1680
+ const file = need(flags.named['file'], 'tilldev secrets deploy-worker --file <worker.js>');
1681
+ if (!existsSync(file))
1682
+ throw new Error(`worker file not found: ${file}`);
1683
+ const script = readFileSync(file, 'utf8');
1684
+ const body = { secret_ref: secretRef, account_id: accountId, script_name: scriptName, script };
1685
+ if (flags.named['compat-date'])
1686
+ body.compatibility_date = flags.named['compat-date'];
1687
+ if (flags.bool.has('service-worker'))
1688
+ body.module = false;
1689
+ if (flags.named['agent'])
1690
+ body.agent_key = flags.named['agent'];
1691
+ const r = await api.deployWorkerAction(opts, body);
1692
+ render(flags, r, () => {
1693
+ if (r.refusal) {
1694
+ fail(`Refused (${r.refusal.code}): ${r.refusal.reason}`);
1695
+ return;
1696
+ }
1697
+ if (r.ok)
1698
+ ok(`Deployed ${c.bold(scriptName)} (${r.size} bytes) via ${c.bold(secretRef)} — the CF token never left the broker`);
1699
+ else
1700
+ fail(`Deploy failed: ${(r.errors ?? []).map((e) => `${e.code}: ${e.message}`).join('; ') || 'unknown error'}`);
1701
+ });
1702
+ return;
1703
+ }
1704
+ case 'broker': {
1705
+ const action = flags.positional[2] ?? 'grants';
1706
+ if (action === 'grants' || (action === 'grant' && (flags.positional[3] ?? 'ls') === 'ls')) {
1707
+ const r = await api.listBrokerGrants(opts);
1708
+ render(flags, r, () => {
1709
+ const cols = [
1710
+ { header: 'AGENT', get: (g) => c.bold(g.agent_key) },
1711
+ { header: 'HOST', get: (g) => g.destination_host },
1712
+ { header: 'SCOPE', get: (g) => g.scope },
1713
+ { header: 'USES', get: (g) => `${g.uses}${g.max_uses != null ? `/${g.max_uses}` : ''}` },
1714
+ { header: 'STATE', get: (g) => (g.revoked_at ? c.red('revoked') : c.green('active')) },
1715
+ { header: 'ID', get: (g) => c.dim(g.id) },
1716
+ ];
1717
+ console.log(table(r.grants, cols));
1718
+ });
1719
+ return;
1720
+ }
1721
+ if (action === 'grant') {
1722
+ const gsub = flags.positional[3];
1723
+ if (gsub === 'revoke') {
1724
+ const id = need(flags.positional[4], 'tilldev secrets broker grant revoke <grant-id>');
1725
+ const r = await api.revokeBrokerGrant(opts, id);
1726
+ render(flags, r, () => ok(`Revoked grant ${c.bold(id)}`));
1727
+ return;
1728
+ }
1729
+ // grant create
1730
+ const scope = (flags.named['scope'] ?? 'ttl');
1731
+ const body = {
1732
+ secret_id: need(flags.named['secret'], 'grant create needs --secret <id> --agent <key> --host <host>'),
1733
+ agent_key: need(flags.named['agent'], 'grant create needs --agent <key>'),
1734
+ destination_host: need(flags.named['host'], 'grant create needs --host <host>'),
1735
+ scope,
1736
+ };
1737
+ if (flags.named['session'])
1738
+ body.session_id = flags.named['session'];
1739
+ if (flags.named['ttl-minutes'])
1740
+ body.ttl_minutes = Number(flags.named['ttl-minutes']);
1741
+ const r = await api.createBrokerGrant(opts, body);
1742
+ render(flags, r, () => ok(`Granted ${c.bold(body.agent_key)} → ${body.destination_host} (${scope}) — ${c.dim(r.grant.id)}`));
1743
+ return;
1744
+ }
1745
+ if (action === 'allow') {
1746
+ const asub = flags.positional[3];
1747
+ if (asub === 'ls') {
1748
+ const secretId = need(flags.positional[4], 'tilldev secrets broker allow ls <secret-id>');
1749
+ const r = await api.listSecretDestinations(opts, secretId);
1750
+ render(flags, r, () => {
1751
+ const cols = [
1752
+ { header: 'HOST', get: (d) => c.bold(d.host) },
1753
+ { header: 'NOTE', get: (d) => d.note },
1754
+ { header: 'ID', get: (d) => c.dim(d.id) },
1755
+ ];
1756
+ console.log(table(r.destinations, cols));
1757
+ });
1758
+ return;
1759
+ }
1760
+ const secretId = need(flags.positional[3], 'tilldev secrets broker allow <secret-id> <host> [--note …]');
1761
+ const host = need(flags.positional[4], 'tilldev secrets broker allow <secret-id> <host>');
1762
+ const r = await api.addSecretDestination(opts, secretId, host, flags.named['note']);
1763
+ render(flags, r, () => ok(`Allowed ${c.bold(host)} for secret ${c.dim(secretId)}`));
1764
+ return;
1765
+ }
1766
+ if (action === 'spends') {
1767
+ const r = await api.listBrokerSpends(opts);
1768
+ render(flags, r, () => {
1769
+ const cols = [
1770
+ { header: 'WHEN', get: (s) => truncate(s.created_at, 19) },
1771
+ { header: 'AGENT', get: (s) => s.agent_key },
1772
+ { header: 'ACTION', get: (s) => s.action },
1773
+ { header: 'REF', get: (s) => s.secret_ref ?? '' },
1774
+ { header: 'HOST', get: (s) => s.destination_host ?? '' },
1775
+ { header: 'OUTCOME', get: (s) => (s.outcome === 'spent' ? c.green('spent') : c.red(`refused${s.refuse_reason ? `: ${s.refuse_reason}` : ''}`)) },
1776
+ ];
1777
+ console.log(table(r.spends, cols));
1778
+ });
1779
+ return;
1780
+ }
1781
+ throw new Error('usage: tilldev secrets broker <grants|grant create|grant revoke|allow|allow ls|spends>');
1782
+ }
1526
1783
  default:
1527
- throw new Error('usage: tilldev secrets <projects|env|ls|set|get|versions|rollback|rm|pull|lease|sync|tokens>');
1784
+ throw new Error('usage: tilldev secrets <projects|env|ls|set|get|versions|rollback|rm|pull|lease|sync|tokens|refs|exec|migrate|deploy-worker|broker>');
1785
+ }
1786
+ }
1787
+ /** Collect reference text for `secrets refs` from a file arg, --ref flags, or stdin. */
1788
+ async function gatherRefText(flags) {
1789
+ const parts = [];
1790
+ const fileArg = flags.positional[2] ?? flags.named['env-file'];
1791
+ if (fileArg && existsSync(fileArg))
1792
+ parts.push(readFileSync(fileArg, 'utf8'));
1793
+ if (flags.named['ref'])
1794
+ parts.push(flags.named['ref']);
1795
+ if (parts.length === 0 && !process.stdin.isTTY) {
1796
+ parts.push(await readStdin());
1528
1797
  }
1798
+ return parts.join('\n');
1529
1799
  }
1530
1800
  // ── TillArk ────────────────────────────────────────────────────────────────────
1531
1801
  // Backup / disaster-recovery control plane. Dangerous verbs (real restore,
@@ -2324,6 +2594,745 @@ async function cmdForgeSetupGit(flags) {
2324
2594
  info(`Then plain git just works — no token in the URL:`);
2325
2595
  console.log(` ${c.dim(`git clone https://${host}/<org>/<repo>.git`)}`);
2326
2596
  }
2597
+ // Parse ONLY the blocking rejection block. The relayed stderr can ALSO carry a
2598
+ // non-blocking "allowlisted (audited, NOT blocking)" block in the same shape,
2599
+ // printed BEFORE the rejection — so we start parsing at the "push rejected:"
2600
+ // header and stop at its footer, never treating a suppressed hit as a blocker.
2601
+ function parseRejectionFindings(stderr) {
2602
+ const lines = stderr.split('\n');
2603
+ let start = -1;
2604
+ for (let i = 0; i < lines.length; i++) {
2605
+ if (/tillforge:\s*push rejected:/.test(lines[i])) {
2606
+ start = i;
2607
+ break;
2608
+ }
2609
+ }
2610
+ if (start === -1)
2611
+ return { findings: [], truncated: false };
2612
+ const out = [];
2613
+ const seen = new Set();
2614
+ let truncated = false;
2615
+ const row = /^remote:\s+(.+?):(\d+)\s+(.+?)\s+\(([a-z0-9-]+)\)/;
2616
+ for (let i = start + 1; i < lines.length; i++) {
2617
+ const ln = lines[i];
2618
+ if (/^remote:\s+Remove the secret/.test(ln))
2619
+ break;
2620
+ if (/(?:…|\.\.\.)\s*and\s+\d+\s+more/.test(ln)) {
2621
+ truncated = true;
2622
+ continue;
2623
+ }
2624
+ const m = row.exec(ln);
2625
+ if (!m)
2626
+ continue;
2627
+ const path = m[1].trim();
2628
+ const key = `${path}:${m[2]}:${m[4]}`;
2629
+ if (seen.has(key))
2630
+ continue;
2631
+ seen.add(key);
2632
+ out.push({ path, line: parseInt(m[2], 10), desc: m[3].trim(), rule: m[4] });
2633
+ }
2634
+ return { findings: out, truncated };
2635
+ }
2636
+ // The complete file-level allow marker for a path, in that file's comment
2637
+ // syntax so it never breaks the file. Returns null for formats with no comment
2638
+ // syntax (e.g. JSON) — those can't carry a marker and must be treated as real.
2639
+ function fileMarkerFor(path, reason) {
2640
+ const base = (path.split('/').pop() ?? path).toLowerCase();
2641
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '';
2642
+ const marker = `tillforge:allow-file-secrets — ${reason}`;
2643
+ const hash = new Set(['.py', '.rb', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.toml', '.env', '.ini', '.cfg', '.conf', '.properties', '.tf', '.tfvars', '.pl', '.r', '.gitignore', '.dockerignore']);
2644
+ const slash = new Set(['.go', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.java', '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.rs', '.swift', '.kt', '.kts', '.scala', '.php', '.dart', '.groovy', '.gradle']);
2645
+ if (base === 'dockerfile' || base === 'makefile' || hash.has(ext))
2646
+ return `# ${marker}`;
2647
+ if (slash.has(ext))
2648
+ return `// ${marker}`;
2649
+ if (ext === '.sql' || ext === '.lua' || ext === '.hs' || ext === '.elm')
2650
+ return `-- ${marker}`;
2651
+ if (ext === '.css' || ext === '.scss' || ext === '.less')
2652
+ return `/* ${marker} */`;
2653
+ if (ext === '.html' || ext === '.htm' || ext === '.xml' || ext === '.svg' || ext === '.vue' || ext === '.svelte')
2654
+ return `<!-- ${marker} -->`;
2655
+ if (ext === '.json' || ext === '.json5' || ext === '.lock')
2656
+ return null;
2657
+ return `# ${marker}`; // safe default for plain/config-ish files
2658
+ }
2659
+ // Insert a marker at the top of a file, after a shebang if present so the file
2660
+ // still runs. The marker only needs to exist as text in the blob (the scanner
2661
+ // does a substring match), but keeping it a real comment keeps the file valid.
2662
+ function insertFileMarker(absPath, markerLine) {
2663
+ const content = readFileSync(absPath, 'utf8');
2664
+ if (content.startsWith('#!')) {
2665
+ const nl = content.indexOf('\n');
2666
+ if (nl >= 0) {
2667
+ writeFileSync(absPath, content.slice(0, nl + 1) + markerLine + '\n' + content.slice(nl + 1));
2668
+ return;
2669
+ }
2670
+ }
2671
+ writeFileSync(absPath, markerLine + '\n' + content);
2672
+ }
2673
+ function readLineFromFile(absPath, line) {
2674
+ try {
2675
+ return readFileSync(absPath, 'utf8').split('\n')[line - 1] ?? null;
2676
+ }
2677
+ catch {
2678
+ return null;
2679
+ }
2680
+ }
2681
+ // Run git in `root`, capturing output. Throws only if git can't be executed.
2682
+ function gitCapture(root, args, env) {
2683
+ const res = spawnSync('git', args, { cwd: root, encoding: 'utf8', env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
2684
+ if (res.error)
2685
+ throw new Error(`could not run git (${res.error.code ?? res.error.message}). Is git installed?`);
2686
+ return { status: res.status ?? 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
2687
+ }
2688
+ // ── Remediation primitives (remove / move-to-TillSecrets / history rewrite) ───
2689
+ // Every path below keeps the SERVER as the wall: we only ever change the working
2690
+ // tree / history so the push-boundary scanner passes honestly. We never fake a
2691
+ // pass. A REAL secret in a commit can only leave by rewriting that commit —
2692
+ // editing the tree and adding a new commit does NOT remove it from what's pushed,
2693
+ // so "remove" amends the tip (fast path) or escalates to a history rewrite.
2694
+ function slugify(s) {
2695
+ return (s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app').slice(0, 60);
2696
+ }
2697
+ // Normalise an identifier into a valid TillSecrets key: [A-Z_][A-Z0-9_]*.
2698
+ function toEnvKey(raw, fallback) {
2699
+ const base = (raw ?? fallback).replace(/[^A-Za-z0-9_]/g, '_').toUpperCase().replace(/^_+|_+$/g, '');
2700
+ const k = base || fallback.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
2701
+ return /^[A-Z_]/.test(k) ? k : '_' + k;
2702
+ }
2703
+ // Best-effort pull of `KEY <sep> "value"` off a finding line. The REAL value comes
2704
+ // from the working tree (server findings are masked), so this is what we store /
2705
+ // redact. Returns nothing when the line isn't a simple assignment.
2706
+ function extractAssignment(line) {
2707
+ const m = line.match(/(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:=>|[:=])\s*(.+?)\s*[,;]?\s*$/);
2708
+ if (!m)
2709
+ return {};
2710
+ let value = m[2].trim();
2711
+ const q = value[0];
2712
+ if ((q === '"' || q === "'" || q === '`') && value.length >= 2 && value.endsWith(q))
2713
+ value = value.slice(1, -1);
2714
+ const out = { value };
2715
+ if (m[1])
2716
+ out.key = m[1];
2717
+ return out;
2718
+ }
2719
+ // A whole file that is itself a credential (so removal = git rm + .gitignore,
2720
+ // never a line edit). Deliberately excludes *.example / *.sample templates.
2721
+ function looksLikeSecretFile(rel) {
2722
+ const base = (rel.split('/').pop() ?? rel).toLowerCase();
2723
+ if (base.endsWith('.example') || base.endsWith('.sample'))
2724
+ return false;
2725
+ if (/^\.env(\..+)?$/.test(base))
2726
+ return true;
2727
+ if (/\.(pem|key|p12|pfx|pkcs12|asc|ppk|keystore|jks)$/.test(base))
2728
+ return true;
2729
+ if (new Set(['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', 'credentials', '.npmrc', '.pypirc', '.netrc', '.dockercfg']).has(base))
2730
+ return true;
2731
+ if (/(service[-_]?account|serviceaccount|firebase[-_]?adminsdk|gcloud).*\.json$/.test(base))
2732
+ return true;
2733
+ return false;
2734
+ }
2735
+ // Parse dotenv KEY=VALUE lines (used when moving a whole .env-style file).
2736
+ function parseDotenv(text) {
2737
+ const out = [];
2738
+ for (const raw of text.split('\n')) {
2739
+ const line = raw.trim();
2740
+ if (!line || line.startsWith('#'))
2741
+ continue;
2742
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
2743
+ if (!m)
2744
+ continue;
2745
+ let v = m[2].trim();
2746
+ const q = v[0];
2747
+ if ((q === '"' || q === "'") && v.length >= 2 && v.endsWith(q))
2748
+ v = v.slice(1, -1);
2749
+ out.push({ key: m[1], value: v });
2750
+ }
2751
+ return out;
2752
+ }
2753
+ // The line-comment token for a file's language (for breadcrumbs). null = no
2754
+ // single-line comment syntax we want to use.
2755
+ function commentPrefix(path) {
2756
+ const base = (path.split('/').pop() ?? path).toLowerCase();
2757
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '';
2758
+ if (base === 'dockerfile' || base === 'makefile')
2759
+ return '#';
2760
+ if (new Set(['.py', '.rb', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.toml', '.env', '.ini', '.cfg', '.conf', '.tf', '.tfvars', '.pl', '.r']).has(ext))
2761
+ return '#';
2762
+ if (new Set(['.go', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.java', '.c', '.h', '.cc', '.cpp', '.hpp', '.cs', '.rs', '.swift', '.kt', '.kts', '.scala', '.php', '.dart']).has(ext))
2763
+ return '//';
2764
+ if (new Set(['.sql', '.lua', '.hs', '.elm']).has(ext))
2765
+ return '--';
2766
+ return null;
2767
+ }
2768
+ function gitignoreAdd(root, rel) {
2769
+ const gi = join(root, '.gitignore');
2770
+ let cur = '';
2771
+ try {
2772
+ cur = readFileSync(gi, 'utf8');
2773
+ }
2774
+ catch { /* no .gitignore yet */ }
2775
+ if (cur.split('\n').some((l) => l.trim() === rel || l.trim() === '/' + rel))
2776
+ return;
2777
+ writeFileSync(gi, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + rel + '\n');
2778
+ }
2779
+ // Redact a secret literal on one line in the working tree, keeping any quotes so
2780
+ // the file stays syntactically valid ('abc' → ''). Returns the before/after line.
2781
+ function redactLiteralInTree(absPath, lineNo, value) {
2782
+ const lines = readFileSync(absPath, 'utf8').split('\n');
2783
+ const idx = lineNo - 1;
2784
+ const before = lines[idx];
2785
+ if (before == null || value === '' || !before.includes(value))
2786
+ return null;
2787
+ const after = before.replace(value, '');
2788
+ lines[idx] = after;
2789
+ writeFileSync(absPath, lines.join('\n'));
2790
+ return { before, after };
2791
+ }
2792
+ // Write/refresh a KEY= template so a removed .env file's shape stays documented.
2793
+ function writeEnvExample(root, rel, keys) {
2794
+ const target = /\.example$/.test(rel) ? rel : rel + '.example';
2795
+ const path = join(root, target);
2796
+ let cur = '';
2797
+ try {
2798
+ cur = readFileSync(path, 'utf8');
2799
+ }
2800
+ catch { /* new */ }
2801
+ const have = new Set(cur.split('\n').map((l) => l.split('=')[0].trim()));
2802
+ const add = keys.filter((k) => !have.has(k)).map((k) => `${k}=`).join('\n');
2803
+ if (add)
2804
+ writeFileSync(path, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + add + '\n');
2805
+ return target;
2806
+ }
2807
+ // Which unpushed commits touch `file`. range is `<remote>/<branch>..HEAD` when an
2808
+ // upstream exists, else `HEAD` (a fresh import — every commit is unpushed).
2809
+ function commitsTouching(root, range, file) {
2810
+ const r = gitCapture(root, ['log', '--format=%H', range, '--', file]);
2811
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
2812
+ }
2813
+ // The unpushed range and whether the branch already has an upstream on the remote.
2814
+ function unpushedRange(root, remote, branch) {
2815
+ const trackingRef = `refs/remotes/${remote}/${branch}`;
2816
+ if (gitCapture(root, ['rev-parse', '--verify', '--quiet', trackingRef]).status === 0) {
2817
+ return { range: `${remote}/${branch}..HEAD`, hasUpstream: true };
2818
+ }
2819
+ return { range: 'HEAD', hasUpstream: false };
2820
+ }
2821
+ function filterRepoAvailable(root) {
2822
+ return gitCapture(root, ['filter-repo', '--version']).status === 0;
2823
+ }
2824
+ async function resolveSecretsTarget(opts, repoSlug) {
2825
+ let projects;
2826
+ try {
2827
+ projects = (await api.listSecretsProjects(opts)).projects;
2828
+ }
2829
+ catch (e) {
2830
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
2831
+ warn('Not signed in to TillSecrets (or not an admin/owner) — can’t store it there right now.');
2832
+ info(c.dim('Run `tilldev forge login` for git only; a TillSecrets write needs a dashboard token (TILLDEV_TOKEN) with admin/owner.'));
2833
+ return null;
2834
+ }
2835
+ if (e instanceof ApiError && e.status === 404) {
2836
+ warn('TillSecrets isn’t enabled for this org — can’t store it there right now.');
2837
+ return null;
2838
+ }
2839
+ throw e;
2840
+ }
2841
+ // Prefer a project whose slug matches the repo; else let the user pick or create.
2842
+ let project = projects.find((p) => p.slug === repoSlug || p.slug === slugify(repoSlug));
2843
+ if (!project) {
2844
+ if (projects.length) {
2845
+ console.log(c.dim(' TillSecrets projects:'));
2846
+ projects.forEach((p, i) => console.log(` ${c.bold(String(i + 1))}. ${p.name} ${c.dim('(' + p.slug + ')')}`));
2847
+ const pick = (await promptInput(` Pick a project [1-${projects.length}], or ${c.bold('n')} for a new one named "${repoSlug}" > `)).trim().toLowerCase();
2848
+ const n = Number(pick);
2849
+ if (pick !== 'n' && Number.isInteger(n) && n >= 1 && n <= projects.length)
2850
+ project = projects[n - 1];
2851
+ }
2852
+ if (!project) {
2853
+ info(`Creating TillSecrets project ${c.bold(repoSlug)}…`);
2854
+ try {
2855
+ project = (await api.createSecretsProject(opts, repoSlug, slugify(repoSlug), `Secrets for the ${repoSlug} repository (created from tilldev forge push)`)).project;
2856
+ }
2857
+ catch (e) {
2858
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
2859
+ warn('Your dashboard token can’t create TillSecrets projects (needs admin/owner).');
2860
+ return null;
2861
+ }
2862
+ throw e;
2863
+ }
2864
+ }
2865
+ }
2866
+ let envs = (await api.listSecretsEnvironments(opts, project.id)).environments;
2867
+ let env = envs.find((e) => e.slug === 'dev' || e.slug === 'development') ?? envs[0];
2868
+ if (!env) {
2869
+ info(`No environments yet — creating ${c.bold('dev')}…`);
2870
+ env = (await api.createSecretsEnvironment(opts, project.id, 'Development', 'dev')).environment;
2871
+ }
2872
+ else if (envs.length > 1) {
2873
+ console.log(c.dim(' Environments:'));
2874
+ envs.forEach((e, i) => console.log(` ${c.bold(String(i + 1))}. ${e.name} ${c.dim('(' + e.slug + ')')}`));
2875
+ const pick = (await promptInput(` Which environment? [1-${envs.length}] (default ${env.slug}) > `)).trim();
2876
+ const n = Number(pick);
2877
+ if (pick && Number.isInteger(n) && n >= 1 && n <= envs.length)
2878
+ env = envs[n - 1];
2879
+ }
2880
+ return { project, env };
2881
+ }
2882
+ async function cmdForgePush(flags) {
2883
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
2884
+ if (top.error || top.status !== 0)
2885
+ throw new Error('not inside a git repository (run this from your repo)');
2886
+ const root = (top.stdout ?? '').trim();
2887
+ const repoSlug = slugify(root.split('/').pop() ?? 'repo');
2888
+ const token = forgeGitToken(flags);
2889
+ if (!token)
2890
+ warn('No token found — set TILLDEV_FORGE_TOKEN or run `tilldev forge login`; git will prompt otherwise.');
2891
+ const host = forgeGitHost();
2892
+ const remote = flags.positional[2] ?? 'origin';
2893
+ const refspec = flags.positional[3];
2894
+ const branch = refspec ? refspec.split(':').pop().replace(/^refs\/heads\//, '') : gitCapture(root, ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim() || 'main';
2895
+ const setUpstream = flags.bool.has('set-upstream') || flags.bool.has('u');
2896
+ const env = { ...process.env, ...(token ? { TILLDEV_FORGE_TOKEN: token } : {}) };
2897
+ const interactive = Boolean(process.stdin.isTTY) && Boolean(output.isTTY);
2898
+ const { opts } = resolveOpts(flags);
2899
+ let forceNext = false; // set after a history rewrite — the remote needs a lease-guarded force
2900
+ const pushArgs = () => {
2901
+ const a = ['-c', 'credential.helper=', '-c', `credential.helper=${credentialHelperArg()}`, 'push'];
2902
+ if (forceNext)
2903
+ a.push('--force-with-lease');
2904
+ if (setUpstream)
2905
+ a.push('-u');
2906
+ a.push(remote);
2907
+ if (refspec)
2908
+ a.push(refspec);
2909
+ return a;
2910
+ };
2911
+ // Resolved lazily + cached so multiple "move to TillSecrets" reuse one project/env.
2912
+ let secretsTarget;
2913
+ const getSecretsTarget = async () => {
2914
+ if (secretsTarget === undefined)
2915
+ secretsTarget = await resolveSecretsTarget(opts, repoSlug);
2916
+ return secretsTarget;
2917
+ };
2918
+ const resolvedInTree = new Set(); // files we redacted at the tip this run
2919
+ const MAX_ROUNDS = 10;
2920
+ for (let round = 1; round <= MAX_ROUNDS; round++) {
2921
+ info(`Pushing ${refspec ? c.bold(refspec) + ' ' : ''}to ${c.bold(remote)} ${c.dim('(' + host + ')')}…`);
2922
+ const res = gitCapture(root, pushArgs(), env);
2923
+ forceNext = false;
2924
+ const remoteLines = res.stderr.split('\n').filter((l) => l.startsWith('remote:'));
2925
+ if (res.status === 0) {
2926
+ if (remoteLines.length)
2927
+ process.stderr.write(remoteLines.join('\n') + '\n');
2928
+ ok('Pushed to TillForge.');
2929
+ return;
2930
+ }
2931
+ const { findings, truncated } = parseRejectionFindings(res.stderr);
2932
+ if (findings.length === 0) {
2933
+ // Not a secret rejection — a normal git failure (auth, non-fast-forward, …).
2934
+ process.stderr.write(res.stderr);
2935
+ exit(res.status || 1);
2936
+ }
2937
+ console.log('');
2938
+ warn(`The push boundary blocked ${c.bold(String(findings.length))} secret finding${findings.length === 1 ? '' : 's'}${truncated ? c.dim(' (more may follow after these)') : ''}. ${c.dim('Nothing entered history.')}`);
2939
+ if (!interactive) {
2940
+ for (const f of findings)
2941
+ console.error(` ${c.yellow(f.path + ':' + f.line)} ${f.desc} ${c.dim('(' + f.rule + ')')}`);
2942
+ fail('Re-run in an interactive terminal to resolve these, or fix them manually.');
2943
+ exit(1);
2944
+ }
2945
+ // Group by file — a file-level marker covers every finding in the file, and a
2946
+ // remove/move resolves them together.
2947
+ const byFile = new Map();
2948
+ for (const f of findings)
2949
+ byFile.set(f.path, [...(byFile.get(f.path) ?? []), f]);
2950
+ const toMark = [];
2951
+ const amendPaths = new Set(); // tree edits to fold into the tip commit
2952
+ const rewriteFiles = []; // files the user chose to purge from history
2953
+ const heldFiles = new Set();
2954
+ let movedCount = 0;
2955
+ let held = false;
2956
+ let excludeRequested = false;
2957
+ for (const [rel, fs] of byFile) {
2958
+ const abs = join(root, rel);
2959
+ const historical = resolvedInTree.has(rel); // we already redacted its tip and it's STILL flagged
2960
+ const range = unpushedRange(root, remote, branch).range;
2961
+ const touching = commitsTouching(root, range, rel);
2962
+ const head = gitCapture(root, ['rev-parse', 'HEAD']).stdout.trim();
2963
+ const tipOnly = touching.length > 0 && touching.every((sha) => sha === head);
2964
+ console.log('');
2965
+ console.log(`${c.bold(rel)} ${c.dim('— ' + fs.length + ' finding' + (fs.length === 1 ? '' : 's'))}`);
2966
+ for (const f of fs) {
2967
+ const ctx = readLineFromFile(abs, f.line);
2968
+ console.log(` ${c.yellow(':' + f.line)} ${c.dim('(' + f.rule + ')')}${ctx ? ' ' + c.gray(truncate(ctx.trim(), 80)) : ''}`);
2969
+ }
2970
+ if (historical || !tipOnly) {
2971
+ console.log(c.dim(` This secret is in earlier commit(s), not just the tip — a tree edit can’t remove it; use ${c.bold('[w]')} purge-from-history or ${c.bold('[x]')} push-without-these-commits.`));
2972
+ }
2973
+ const menu = ` ${c.bold('[a]')}llowlist ${c.bold('[r]')}emove ${c.bold('[m]')}ove to TillSecrets ${c.bold('[w]')} purge history ${c.bold('[x]')} exclude commits ${c.bold('[h]')}old ${c.bold('[q]')}uit > `;
2974
+ const ans = ((await promptInput(menu)).trim().toLowerCase()[0]) ?? '';
2975
+ if (ans === 'q') {
2976
+ fail('Aborted — nothing was pushed.');
2977
+ exit(130);
2978
+ }
2979
+ if (ans === 'h') {
2980
+ held = true;
2981
+ heldFiles.add(rel);
2982
+ info(`Holding ${c.bold(rel)} — nothing pushed for it.`);
2983
+ continue;
2984
+ }
2985
+ if (ans === 'w') {
2986
+ rewriteFiles.push(rel);
2987
+ continue;
2988
+ }
2989
+ if (ans === 'x') {
2990
+ excludeRequested = true;
2991
+ continue;
2992
+ }
2993
+ if (ans === 'a') {
2994
+ if (fileMarkerFor(rel, 'x') === null) {
2995
+ warn(`${rel} has no comment syntax (e.g. JSON) for a marker — remove it or hold instead. Holding.`);
2996
+ held = true;
2997
+ heldFiles.add(rel);
2998
+ continue;
2999
+ }
3000
+ let reason = (await promptInput(' Reason (why is this a false positive?) > ')).trim();
3001
+ if (!reason)
3002
+ reason = 'reviewed: false positive (fixture/public/placeholder)';
3003
+ toMark.push({ path: rel, reason });
3004
+ continue;
3005
+ }
3006
+ if (ans === 'r' || ans === 'm') {
3007
+ if (!tipOnly && !looksLikeSecretFile(rel)) {
3008
+ // Embedded literal that lives in earlier history — a tip edit won't purge it.
3009
+ warn(`${rel} is touched by earlier commits — a plain remove can’t purge it from history.`);
3010
+ if (ans === 'm')
3011
+ info(c.dim(' Storing the value in TillSecrets anyway, then use [w] to purge it from history.'));
3012
+ else {
3013
+ held = true;
3014
+ heldFiles.add(rel);
3015
+ info(' Choose [w] purge history (or [x]) next round. Holding for now.');
3016
+ continue;
3017
+ }
3018
+ }
3019
+ // move: store the value(s) in TillSecrets first (cold-start-aware).
3020
+ if (ans === 'm') {
3021
+ const target = await getSecretsTarget();
3022
+ if (!target) {
3023
+ info(c.dim(' Couldn’t store in TillSecrets — continuing with removal only; store it later with `tilldev secrets set`.'));
3024
+ }
3025
+ else if (looksLikeSecretFile(rel) && /\.env(\..+)?$/.test((rel.split('/').pop() ?? '').toLowerCase())) {
3026
+ // Whole .env-style file: store every KEY=VALUE pair.
3027
+ const pairs = parseDotenv(readFileSync(abs, 'utf8'));
3028
+ for (const p of pairs) {
3029
+ const key = toEnvKey(p.key, p.key);
3030
+ try {
3031
+ await api.setSecret(opts, target.env.id, key, p.value, `imported from ${rel} via forge push`);
3032
+ movedCount++;
3033
+ ok(` → TillSecrets ${c.bold(target.project.slug + '/' + target.env.slug + '/' + key)}`);
3034
+ }
3035
+ catch (e) {
3036
+ if (e instanceof ApiError)
3037
+ warn(` couldn’t store ${key}: ${e.message}`);
3038
+ else
3039
+ throw e;
3040
+ }
3041
+ }
3042
+ }
3043
+ else if (looksLikeSecretFile(rel)) {
3044
+ // Opaque credential file (PEM, service-account JSON): store as one blob.
3045
+ const key = toEnvKey(rel.split('/').pop(), 'SECRET_FILE');
3046
+ try {
3047
+ await api.setSecret(opts, target.env.id, key, readFileSync(abs, 'utf8'), `imported file ${rel} via forge push`);
3048
+ movedCount++;
3049
+ ok(` → TillSecrets ${c.bold(target.project.slug + '/' + target.env.slug + '/' + key)}`);
3050
+ }
3051
+ catch (e) {
3052
+ if (e instanceof ApiError)
3053
+ warn(` couldn’t store ${key}: ${e.message}`);
3054
+ else
3055
+ throw e;
3056
+ }
3057
+ }
3058
+ else {
3059
+ // Embedded literals: store each finding's real value (bottom-up so line
3060
+ // numbers stay valid as we redact + breadcrumb).
3061
+ const cp = commentPrefix(rel);
3062
+ for (const f of [...fs].sort((a, b) => b.line - a.line)) {
3063
+ const line = readLineFromFile(abs, f.line);
3064
+ const guessed = line ? extractAssignment(line) : {};
3065
+ const suggestedKey = toEnvKey(guessed.key, 'SECRET');
3066
+ const keyIn = (await promptInput(` Key for :${f.line} in TillSecrets [${suggestedKey}] > `)).trim();
3067
+ const key = keyIn ? toEnvKey(keyIn, suggestedKey) : suggestedKey;
3068
+ let value = guessed.value ?? '';
3069
+ if (!value)
3070
+ value = await promptInput(` Paste the secret value for ${key} (hidden) > `, { hidden: true });
3071
+ else {
3072
+ const edit = await promptInput(` Value looks like "${truncate(value, 24)}" — Enter to use it, or paste a different one (hidden) > `, { hidden: true });
3073
+ if (edit)
3074
+ value = edit;
3075
+ }
3076
+ try {
3077
+ await api.setSecret(opts, target.env.id, key, value, `moved from ${rel}:${f.line} via forge push`);
3078
+ movedCount++;
3079
+ ok(` → TillSecrets ${c.bold(target.project.slug + '/' + target.env.slug + '/' + key)}`);
3080
+ if (value)
3081
+ redactLiteralInTree(abs, f.line, value);
3082
+ if (cp) {
3083
+ const lines = readFileSync(abs, 'utf8').split('\n');
3084
+ lines.splice(f.line - 1, 0, `${cp} ${key} moved to TillSecrets (${target.project.slug}/${target.env.slug}) — see \`tilldev secrets\``);
3085
+ writeFileSync(abs, lines.join('\n'));
3086
+ }
3087
+ }
3088
+ catch (e) {
3089
+ if (e instanceof ApiError)
3090
+ warn(` couldn’t store ${key}: ${e.message}`);
3091
+ else
3092
+ throw e;
3093
+ }
3094
+ }
3095
+ }
3096
+ }
3097
+ // remove from the working tree (both r and m end here).
3098
+ if (looksLikeSecretFile(rel)) {
3099
+ // rm --cached stages the removal (kept on disk, now gitignored). We do NOT
3100
+ // re-add `rel` to the pathspec below — it's ignored now, so `git add` would
3101
+ // error; the staged deletion is already captured by the --amend.
3102
+ gitCapture(root, ['rm', '--cached', '--', rel]);
3103
+ gitignoreAdd(root, rel);
3104
+ const keys = /\.env/.test(rel) ? parseDotenv((() => { try {
3105
+ return readFileSync(abs, 'utf8');
3106
+ }
3107
+ catch {
3108
+ return '';
3109
+ } })()).map((p) => p.key) : [];
3110
+ if (keys.length) {
3111
+ const ex = writeEnvExample(root, rel, keys);
3112
+ amendPaths.add(ex);
3113
+ }
3114
+ info(` Removed ${c.bold(rel)} from the repo + added it to .gitignore${keys.length ? ' (kept a *.example)' : ''}.`);
3115
+ amendPaths.add('.gitignore');
3116
+ }
3117
+ else if (ans === 'r') {
3118
+ // plain remove of an embedded literal: redact each finding line
3119
+ for (const f of [...fs].sort((a, b) => b.line - a.line)) {
3120
+ const line = readLineFromFile(abs, f.line);
3121
+ const guessed = line ? extractAssignment(line) : {};
3122
+ if (guessed.value) {
3123
+ const r2 = redactLiteralInTree(abs, f.line, guessed.value);
3124
+ if (r2)
3125
+ console.log(c.dim(` :${f.line} ${truncate(r2.before.trim(), 40)} → ${truncate(r2.after.trim(), 40)}`));
3126
+ }
3127
+ else
3128
+ warn(` couldn’t auto-locate the literal on :${f.line} — edit it by hand, then re-run.`);
3129
+ }
3130
+ amendPaths.add(rel);
3131
+ }
3132
+ else {
3133
+ amendPaths.add(rel); // m already redacted above
3134
+ }
3135
+ if (tipOnly)
3136
+ resolvedInTree.add(rel);
3137
+ continue;
3138
+ }
3139
+ // Unrecognised answer → treat as hold (safe: nothing is pushed).
3140
+ held = true;
3141
+ heldFiles.add(rel);
3142
+ info(`Holding ${c.bold(rel)} — no action chosen.`);
3143
+ }
3144
+ // 1) Fold tree removals/moves into the tip commit (a NEW commit wouldn't purge
3145
+ // the secret from HEAD, which is still in the pushed range — so we amend).
3146
+ if (amendPaths.size > 0) {
3147
+ const add = gitCapture(root, ['add', '-A', '--', ...amendPaths]);
3148
+ if (add.status !== 0) {
3149
+ process.stderr.write(add.stderr);
3150
+ throw new Error('git add failed while removing secrets');
3151
+ }
3152
+ const amend = gitCapture(root, ['commit', '--amend', '--no-edit']);
3153
+ if (amend.status !== 0) {
3154
+ process.stderr.write(amend.stdout + amend.stderr);
3155
+ throw new Error('git commit --amend failed while removing secrets');
3156
+ }
3157
+ ok(`Rewrote the tip commit to drop ${amendPaths.size} path${amendPaths.size === 1 ? '' : 's'}.`);
3158
+ if (movedCount > 0) {
3159
+ console.log('');
3160
+ warn(`${movedCount} value${movedCount === 1 ? '' : 's'} now live in TillSecrets. Rotate anything that was ever committed — the old value must be treated as compromised.`);
3161
+ info(c.dim(` Pull them at deploy: \`tilldev secrets ls --env <id>\`, and wire your app to read them (env injection / secrets pull).`));
3162
+ }
3163
+ }
3164
+ // 2) Persist allowlist decisions as one audited commit (marker is tip-resolved).
3165
+ if (toMark.length > 0) {
3166
+ for (const m of toMark)
3167
+ insertFileMarker(join(root, m.path), fileMarkerFor(m.path, m.reason));
3168
+ const add = gitCapture(root, ['add', ...toMark.map((m) => m.path)]);
3169
+ if (add.status !== 0) {
3170
+ process.stderr.write(add.stderr);
3171
+ throw new Error('git add failed while committing allowlist markers');
3172
+ }
3173
+ const body = toMark.map((m) => `- ${m.path}: ${m.reason}`).join('\n');
3174
+ const commit = gitCapture(root, ['commit', '-m', `chore(forge): allowlist reviewed secret findings\n\n${body}\n\nAudited via tillforge:allow-file-secrets; each finding is still recorded at the push boundary.`]);
3175
+ if (commit.status !== 0) {
3176
+ process.stderr.write(commit.stdout + commit.stderr);
3177
+ throw new Error('git commit failed while committing allowlist markers');
3178
+ }
3179
+ ok(`Committed allowlist marker${toMark.length === 1 ? '' : 's'} for ${toMark.length} file${toMark.length === 1 ? '' : 's'}.`);
3180
+ }
3181
+ // 3) History rewrite (purge across ALL commits) — the last resort for a secret
3182
+ // already deep in history. Guard-railed: backup ref, typed confirm, rotate.
3183
+ if (rewriteFiles.length > 0) {
3184
+ const done = await runHistoryRewrite(root, remote, rewriteFiles, byFile);
3185
+ if (!done) {
3186
+ held = true;
3187
+ rewriteFiles.forEach((f) => heldFiles.add(f));
3188
+ }
3189
+ else {
3190
+ forceNext = true;
3191
+ ok('History rewritten — re-pushing with a lease-guarded force…');
3192
+ continue;
3193
+ }
3194
+ }
3195
+ // 4) Exclude: push only the commits before the first one that introduces a
3196
+ // still-blocking secret, holding the rest locally. "Still-blocking" = every
3197
+ // offending file we did NOT allowlist or resolve in the tree this round.
3198
+ if (excludeRequested) {
3199
+ const stillBlocking = [...byFile.keys()].filter((p) => !amendPaths.has(p) && !toMark.some((m) => m.path === p));
3200
+ await runPartialPush(root, remote, branch, stillBlocking.length ? stillBlocking : [...byFile.keys()], env);
3201
+ exit(2);
3202
+ }
3203
+ if (held) {
3204
+ console.log('');
3205
+ info('Some findings were held — nothing was pushed. Re-run `tilldev forge push` once they’re resolved.');
3206
+ exit(2);
3207
+ }
3208
+ if (toMark.length === 0 && amendPaths.size === 0) {
3209
+ fail('No resolution chosen; nothing was pushed.');
3210
+ exit(2);
3211
+ }
3212
+ ok('Re-pushing…');
3213
+ }
3214
+ fail('Still blocked after several rounds — resolve the remaining findings manually and push again.');
3215
+ exit(1);
3216
+ }
3217
+ // Purge secrets from ALL history using git-filter-repo. Whole credential files are
3218
+ // removed (--invert-paths); a secret embedded in a source file is redacted in place
3219
+ // (--replace-text) so the file itself survives — deleting a source file to scrub one
3220
+ // literal would be a nasty surprise. Returns true when history was rewritten.
3221
+ // Heavily guarded: it's destructive and rewrites every commit SHA.
3222
+ async function runHistoryRewrite(root, remote, files, byFile) {
3223
+ console.log('');
3224
+ if (!filterRepoAvailable(root)) {
3225
+ warn('History rewrite needs `git-filter-repo`, which isn’t installed.');
3226
+ info(' Install it, then re-run — it’s the safe, supported tool for this:');
3227
+ console.log(c.dim(' brew install git-filter-repo # macOS'));
3228
+ console.log(c.dim(' pipx install git-filter-repo # or: pip install git-filter-repo'));
3229
+ return false;
3230
+ }
3231
+ const deleteFiles = files.filter((f) => looksLikeSecretFile(f));
3232
+ const redactFiles = files.filter((f) => !looksLikeSecretFile(f));
3233
+ // For source files we must know the exact literal(s) to purge — auto-locating a
3234
+ // secret across historical blob versions isn't reliable, so we ask (seeded from
3235
+ // the working tree). The user knows their secret; this keeps us honest.
3236
+ const literals = [];
3237
+ for (const f of redactFiles) {
3238
+ const abs = join(root, f);
3239
+ for (const fnd of byFile.get(f) ?? []) {
3240
+ const line = readLineFromFile(abs, fnd.line);
3241
+ const guess = line ? extractAssignment(line).value : undefined;
3242
+ const entered = (await promptInput(` Exact secret to purge from ${f}:${fnd.line}${guess ? ` [${truncate(guess, 20)}]` : ''} (blank to skip) > `, { hidden: Boolean(guess) })).trim();
3243
+ const lit = entered || guess;
3244
+ if (lit && lit.length >= 4 && !literals.includes(lit))
3245
+ literals.push(lit);
3246
+ }
3247
+ }
3248
+ if (!deleteFiles.length && !literals.length) {
3249
+ warn('Nothing to purge (no files to remove and no literals given).');
3250
+ return false;
3251
+ }
3252
+ console.log(`${c.bold('History rewrite')} across ${c.bold('every')} commit:`);
3253
+ for (const f of deleteFiles)
3254
+ console.log(` remove file ${c.yellow(f)} ${c.dim('(' + commitsTouching(root, 'HEAD', f).length + ' commit(s))')}`);
3255
+ if (literals.length)
3256
+ console.log(` redact ${c.yellow(String(literals.length) + ' literal value(s)')} ${c.dim('across ' + redactFiles.join(', '))}`);
3257
+ warn('This rewrites every commit SHA after the first change, needs a force-push, and CANNOT be undone remotely once pushed.');
3258
+ warn('Any secret that was ever committed is COMPROMISED — rotate it regardless of the rewrite.');
3259
+ const confirm = (await promptInput(` Type ${c.bold('rewrite')} to proceed (anything else cancels) > `)).trim();
3260
+ if (confirm !== 'rewrite') {
3261
+ info('Cancelled the rewrite — nothing changed.');
3262
+ return false;
3263
+ }
3264
+ // Recovery point: a ref at the current tip so the pre-rewrite history is reachable.
3265
+ const head = gitCapture(root, ['rev-parse', 'HEAD']).stdout.trim();
3266
+ gitCapture(root, ['update-ref', 'refs/tilldev/pre-rewrite', head]);
3267
+ // filter-repo drops the remote to prevent accidental pushes — capture + restore it.
3268
+ const remoteUrl = gitCapture(root, ['remote', 'get-url', remote]).stdout.trim();
3269
+ // Redact literals first (a replace-text file kept inside .git, then unlinked).
3270
+ if (literals.length) {
3271
+ const exprPath = join(root, '.git', 'tilldev-replace-text.txt');
3272
+ writeFileSync(exprPath, literals.map((l) => `${l}==>***REMOVED-SECRET***`).join('\n') + '\n');
3273
+ const rw = gitCapture(root, ['filter-repo', '--force', '--replace-text', exprPath]);
3274
+ try {
3275
+ unlinkSync(exprPath);
3276
+ }
3277
+ catch { /* best effort */ }
3278
+ if (rw.status !== 0) {
3279
+ process.stderr.write(rw.stdout + rw.stderr);
3280
+ warn('filter-repo (redact) failed — history is unchanged.');
3281
+ return false;
3282
+ }
3283
+ }
3284
+ // Then remove whole credential files.
3285
+ if (deleteFiles.length) {
3286
+ const args = ['filter-repo', '--force', '--invert-paths'];
3287
+ for (const f of deleteFiles) {
3288
+ args.push('--path', f);
3289
+ }
3290
+ const rw = gitCapture(root, args);
3291
+ if (rw.status !== 0) {
3292
+ process.stderr.write(rw.stdout + rw.stderr);
3293
+ warn('filter-repo (remove) failed — history is partially rewritten; check `git log`.');
3294
+ return false;
3295
+ }
3296
+ }
3297
+ // Restore the remote filter-repo stripped.
3298
+ if (remoteUrl && gitCapture(root, ['remote', 'get-url', remote]).status !== 0) {
3299
+ gitCapture(root, ['remote', 'add', remote, remoteUrl]);
3300
+ }
3301
+ ok('History rewritten.');
3302
+ info(c.dim(` Recovery: the pre-rewrite tip is kept at refs/tilldev/pre-rewrite (git update-ref -d refs/tilldev/pre-rewrite to drop it).`));
3303
+ return true;
3304
+ }
3305
+ // Push only up to the parent of the earliest unpushed commit that touches any
3306
+ // still-blocking file, leaving those commits (and the secret) local. Lets the rest
3307
+ // of the work land now without a rewrite. Terminal for this invocation.
3308
+ async function runPartialPush(root, remote, branch, files, env) {
3309
+ const range = unpushedRange(root, remote, branch).range;
3310
+ // Oldest unpushed commit that touches any offending file.
3311
+ const r = gitCapture(root, ['log', '--format=%H', '--reverse', range, '--', ...files]);
3312
+ const earliest = r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0];
3313
+ console.log('');
3314
+ if (!earliest) {
3315
+ warn('Couldn’t find the offending commit range — resolve manually.');
3316
+ return;
3317
+ }
3318
+ const parent = gitCapture(root, ['rev-parse', '--verify', '--quiet', earliest + '^']);
3319
+ if (parent.status !== 0) {
3320
+ warn('The secret is in the very first commit — there’s nothing before it to push. Use [w] purge history instead.');
3321
+ return;
3322
+ }
3323
+ const safe = parent.stdout.trim();
3324
+ const behind = gitCapture(root, ['rev-list', '--count', `${safe}..HEAD`]).stdout.trim();
3325
+ info(`Pushing up to ${c.bold(safe.slice(0, 10))} — holding the ${c.bold(behind)} later commit(s) that carry the secret.`);
3326
+ const push = gitCapture(root, ['-c', 'credential.helper=', '-c', `credential.helper=${credentialHelperArg()}`, 'push', remote, `${safe}:refs/heads/${branch}`], env);
3327
+ process.stderr.write(push.stderr.split('\n').filter((l) => l.startsWith('remote:')).join('\n') + (push.stderr ? '\n' : ''));
3328
+ if (push.status === 0) {
3329
+ ok(`Pushed ${c.bold(safe.slice(0, 10))} to ${branch}. Your held commits stay local until you remove/rewrite the secret, then \`tilldev forge push\` again.`);
3330
+ }
3331
+ else {
3332
+ process.stderr.write(push.stderr);
3333
+ fail('Partial push failed.');
3334
+ }
3335
+ }
2327
3336
  async function cmdForge(flags) {
2328
3337
  const sub = flags.positional[1] ?? 'repos';
2329
3338
  // Git-ergonomics commands shell out to git rather than the API, and the
@@ -2332,6 +3341,8 @@ async function cmdForge(flags) {
2332
3341
  return cmdForgeCredential(flags);
2333
3342
  if (sub === 'clone')
2334
3343
  return cmdForgeClone(flags);
3344
+ if (sub === 'push')
3345
+ return cmdForgePush(flags);
2335
3346
  if (sub === 'setup-git')
2336
3347
  return cmdForgeSetupGit(flags);
2337
3348
  if (sub === 'login')
@@ -2583,12 +3594,34 @@ async function cmdForge(flags) {
2583
3594
  const action = flags.positional[2] ?? 'ls';
2584
3595
  const repo = need(flags.positional[3], `tilldev forge env ${action} <repo> …`);
2585
3596
  if (action === 'set') {
2586
- const r = await api.forgeSetEnvironment(opts, repo, {
3597
+ const body = {
2587
3598
  name: need(flags.named['name'], '--name is required'),
2588
3599
  production: flags.bool.has('production'),
2589
3600
  url: flags.named['url'],
2590
3601
  required_approvals: flags.named['approvals'] ? Number(flags.named['approvals']) : undefined,
2591
- });
3602
+ };
3603
+ // Deploy target: --provider '' clears it; --credential links a vault entry;
3604
+ // --target 'project=my-site'; build recipe via --root/--install/--build/
3605
+ // --output/--image; --auto-deploy <branch> and --auto-preview.
3606
+ if (flags.named['provider'] !== undefined)
3607
+ body['provider'] = flags.named['provider'] || null;
3608
+ if (flags.named['credential'])
3609
+ body['provider_id'] = flags.named['credential'];
3610
+ const target = parseKv(flags.named['target']);
3611
+ if (target)
3612
+ body['target_config'] = target;
3613
+ const build = {};
3614
+ for (const [flag, key] of [['root', 'root_dir'], ['install', 'install'], ['build', 'build'], ['output', 'output_dir'], ['image', 'image']]) {
3615
+ if (flags.named[flag] !== undefined)
3616
+ build[key] = flags.named[flag];
3617
+ }
3618
+ if (Object.keys(build).length)
3619
+ body['build_config'] = build;
3620
+ if (flags.named['auto-deploy'] !== undefined)
3621
+ body['auto_deploy_ref'] = flags.named['auto-deploy'] || null;
3622
+ if (flags.bool.has('auto-preview'))
3623
+ body['auto_preview'] = true;
3624
+ const r = await api.forgeSetEnvironment(opts, repo, body);
2592
3625
  render(flags, r, () => ok(`environment ${r.environment.name} set`));
2593
3626
  return;
2594
3627
  }
@@ -2597,6 +3630,7 @@ async function cmdForge(flags) {
2597
3630
  const cols = [
2598
3631
  { header: 'NAME', get: (e) => c.bold(e.name) },
2599
3632
  { header: 'KIND', get: (e) => (e.production ? c.magenta('production') : c.dim('preview')) },
3633
+ { header: 'PROVIDER', get: (e) => (e.provider ? c.cyan(e.provider) : c.dim('tracking')) },
2600
3634
  { header: 'URL', get: (e) => e.url || c.dim('-') },
2601
3635
  { header: 'APPROVALS', get: (e) => String(e.required_approvals) },
2602
3636
  ];
@@ -2604,6 +3638,37 @@ async function cmdForge(flags) {
2604
3638
  });
2605
3639
  return;
2606
3640
  }
3641
+ case 'provider':
3642
+ case 'providers': {
3643
+ const action = flags.positional[2] ?? 'ls';
3644
+ if (action === 'connect') {
3645
+ const provider = need(flags.named['provider'], '--provider is required (cloudflare|vercel|netlify|supabase|aws|firebase|surge|azure_swa|railway|fly|desktop)');
3646
+ const name = need(flags.named['name'], '--name is required');
3647
+ const credential = parseKv(flags.named['credential']) ?? {};
3648
+ const r = await api.forgeConnectProvider(opts, { name, provider, credential });
3649
+ render(flags, r, () => ok(`connected ${r.provider.provider} credential '${r.provider.name}'`));
3650
+ return;
3651
+ }
3652
+ if (action === 'rm' || action === 'revoke') {
3653
+ const id = need(flags.positional[3], 'tilldev forge provider rm <id>');
3654
+ await api.forgeRevokeProvider(opts, id);
3655
+ render(flags, { ok: true }, () => ok('provider credential revoked'));
3656
+ return;
3657
+ }
3658
+ const r = await api.forgeDeployProviders(opts);
3659
+ render(flags, r, () => {
3660
+ const cols = [
3661
+ { header: 'NAME', get: (p) => c.bold(p.name) },
3662
+ { header: 'PROVIDER', get: (p) => c.cyan(p.provider) },
3663
+ { header: 'HINT', get: (p) => p.hint || c.dim('-') },
3664
+ { header: 'STATE', get: (p) => (p.revoked_at ? c.red('revoked') : c.green('active')) },
3665
+ ];
3666
+ console.log(table(r.providers, cols));
3667
+ if (!r.providers.length)
3668
+ console.log(c.dim(`no providers connected — supported: ${r.catalog.map((s) => s.provider).join(', ')}`));
3669
+ });
3670
+ return;
3671
+ }
2607
3672
  case 'deploy':
2608
3673
  case 'deployments': {
2609
3674
  const action = flags.positional[2] ?? 'ls';
@@ -2619,6 +3684,40 @@ async function cmdForge(flags) {
2619
3684
  render(flags, r, () => ok(`deployment ${r.deployment.state}`));
2620
3685
  return;
2621
3686
  }
3687
+ if (action === 'promote') {
3688
+ const id = need(flags.positional[4], 'tilldev forge deploy promote <repo> <id> --env <e>');
3689
+ const r = await api.forgePromoteDeployment(opts, repo, id, need(flags.named['env'], '--env is required'));
3690
+ render(flags, r, () => ok(`promoted to ${r.deployment.environment} — ${r.deployment.state}`));
3691
+ return;
3692
+ }
3693
+ if (action === 'rollback') {
3694
+ const id = need(flags.positional[4], 'tilldev forge deploy rollback <repo> <id>');
3695
+ const r = await api.forgeRollbackDeployment(opts, repo, id);
3696
+ render(flags, r, () => ok(`rolling back ${r.deployment.environment} — ${r.deployment.state}`));
3697
+ return;
3698
+ }
3699
+ if (action === 'cancel') {
3700
+ const id = need(flags.positional[4], 'tilldev forge deploy cancel <repo> <id>');
3701
+ const r = await api.forgeCancelDeployment(opts, repo, id);
3702
+ render(flags, r, () => ok(`deployment ${r.deployment.state}`));
3703
+ return;
3704
+ }
3705
+ if (action === 'logs') {
3706
+ const id = need(flags.positional[4], 'tilldev forge deploy logs <repo> <id>');
3707
+ const r = await api.forgeDeployLogs(opts, repo, id);
3708
+ render(flags, r, () => {
3709
+ if (!r.job) {
3710
+ console.log(c.dim(`no build job (deployment state: ${r.state})`));
3711
+ return;
3712
+ }
3713
+ console.log(c.dim(`state: ${r.state} · job: ${r.job.status}${r.job.log_truncated ? ' · log truncated (1 MiB cap)' : ''}`));
3714
+ if (r.job.log)
3715
+ console.log(r.job.log);
3716
+ if (r.job.failure_reason)
3717
+ console.log(c.red(r.job.failure_reason));
3718
+ });
3719
+ return;
3720
+ }
2622
3721
  const r = await api.forgeDeployments(opts, repo, flags.named['env']);
2623
3722
  render(flags, r, () => {
2624
3723
  const cols = [
@@ -2726,7 +3825,7 @@ async function cmdForge(flags) {
2726
3825
  return;
2727
3826
  }
2728
3827
  default:
2729
- 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>');
3828
+ throw new Error('usage: tilldev forge <clone|push|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>');
2730
3829
  }
2731
3830
  }
2732
3831
  async function cmdForgeRepos(flags, opts) {