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