@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/api.d.ts +195 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +59 -5
- package/dist/api.js.map +1 -1
- package/dist/index.js +1132 -18
- package/dist/index.js.map +1 -1
- package/dist/update-check.d.ts +10 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +124 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +11 -10
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
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
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()
|
|
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)
|