Skip to content

Commit 489c00f

Browse files
jasdeepkhalsaclaude
andcommitted
feat(cli): add diff progress, fix schema diff, and add report --send
- supaforge diff/diff --detail now shows per-check progress (▶ start, ✓ done with issue count and duration) via ScanProgressEvent in scanner - Fix supaforge diff --detail schema check erroring when @dbdiff/cli exits 1 (differences found): catch block now checks for output file before throwing, correctly treating exit 1 as "diffs available, not an error" - Add supaforge report --send: interactive anonymous bug reporting with explicit per-entry consent, exact preview of what will be sent, and GDPR notice before any data leaves the machine - Per-check summaries (name/status/count/duration/sanitised error) are written to the local run log after every diff/apply — no SQL, table names, or data values ever stored or transmitted - Add sanitizeForReport() util stripping URLs, file paths, and IPs at source; applied to check errors before run log write and before send Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 06f7fda commit 489c00f

15 files changed

Lines changed: 763 additions & 16 deletions

File tree

packages/cli/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/src/base-command.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,60 @@ import { Preflight } from './preflight.js'
44
import { DEFAULT_MIGRATIONS_DIR } from './checks/migrations.js'
55
import type { SupaForgeConfig, EnvironmentConfig } from './types/config.js'
66
import type { PreflightReport } from './preflight.js'
7+
import { appendRunLog, redactArgs, type RunLogCheckSummary } from './run-log.js'
78

89
/**
910
* Shared base for all supaforge commands.
10-
* Extracts config loading, env resolution, and URL redaction.
11+
* Extracts config loading, env resolution, URL redaction, and run logging.
1112
*/
1213
export abstract class BaseCommand extends Command {
1314

15+
private _startTime = 0
16+
private _logWritten = false
17+
private _checkSummaries?: RunLogCheckSummary[]
18+
19+
/** Call after scan() to attach per-check metadata to the run log entry. */
20+
protected setCheckSummaries(summaries: RunLogCheckSummary[]): void {
21+
this._checkSummaries = summaries
22+
}
23+
24+
override async init(): Promise<void> {
25+
await super.init()
26+
this._startTime = Date.now()
27+
}
28+
29+
override async catch(err: Error): Promise<void> {
30+
this._logWritten = true
31+
await this._writeRunLog('error', err.message)
32+
return super.catch(err)
33+
}
34+
35+
override async finally(err: Error | undefined): Promise<void> {
36+
if (!this._logWritten) {
37+
await this._writeRunLog(err ? 'error' : 'success', err?.message)
38+
}
39+
return super.finally(err)
40+
}
41+
42+
private async _writeRunLog(exitStatus: 'success' | 'error', error?: string): Promise<void> {
43+
try {
44+
const version = this.config?.version
45+
const argv = this.argv ?? []
46+
await appendRunLog({
47+
timestamp: new Date().toISOString(),
48+
command: this.id ?? 'unknown',
49+
args: redactArgs(argv),
50+
durationMs: Date.now() - this._startTime,
51+
exitStatus,
52+
error,
53+
version,
54+
checkSummaries: this._checkSummaries,
55+
})
56+
} catch {
57+
// Never let logging failures surface to users
58+
}
59+
}
60+
1461
/** Load config or exit with a helpful error. */
1562
protected async loadConfigOrFail(): Promise<SupaForgeConfig> {
1663
try {

packages/cli/src/branch.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,22 @@ export async function listBranches(cwd = process.cwd()): Promise<BranchMeta[]> {
279279
return manifest.branches
280280
}
281281

282+
/**
283+
* Add a branch entry to the manifest.
284+
* Used by clone.ts which manages its own DB creation pipeline.
285+
* Overwrites an existing entry with the same name to avoid duplicates.
286+
*/
287+
export async function addBranchToManifest(meta: BranchMeta, cwd = process.cwd()): Promise<void> {
288+
const manifest = await loadManifest(cwd)
289+
const existing = manifest.branches.findIndex(b => b.name === meta.name)
290+
if (existing >= 0) {
291+
manifest.branches[existing] = meta
292+
} else {
293+
manifest.branches.push(meta)
294+
}
295+
await saveManifest(manifest, cwd)
296+
}
297+
282298
/** Delete a branch: drop the database and remove from manifest. */
283299
export async function deleteBranch(
284300
name: string,

packages/cli/src/commands/clone.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
replaceDbName,
1010
listBranches,
1111
deleteBranch,
12+
addBranchToManifest,
1213
} from '../branch.js'
14+
import type { BranchMeta } from '../branch.js'
1315
import { checkPgDumpCompat } from '../pg-tools.js'
1416
import { startLocalPg, DEFAULT_LOCAL_PORT, LOCAL_PG_USER, LOCAL_PG_PASSWORD } from '../local-pg.js'
1517
import { ok, warn, dim, cmd, bold } from '../ui.js'
@@ -89,9 +91,7 @@ export default class Clone extends BaseCommand {
8991
async run(): Promise<void> {
9092
const { flags } = await this.parse(Clone)
9193

92-
const config = await this.loadConfigOrFail()
93-
94-
// ── List clones ──────────────────────────────────────────────────────────
94+
// ── List clones (no config file needed) ─────────────────────────────────
9595
if (flags.list) {
9696
const branches = await listBranches()
9797

@@ -117,6 +117,8 @@ export default class Clone extends BaseCommand {
117117
return
118118
}
119119

120+
const config = await this.loadConfigOrFail()
121+
120122
// ── Delete clone ─────────────────────────────────────────────────────────
121123
if (flags.delete) {
122124
const { env } = this.resolveEnv(config, flags.env)
@@ -297,6 +299,17 @@ export default class Clone extends BaseCommand {
297299
await writeFile(configPath, JSON.stringify(newConfig, null, 2) + '\n')
298300
this.log(` ${ok('✓')} Config updated: ${configPath}`)
299301

302+
// Register clone in .supaforge/branches.json so `clone --list` works
303+
const branchMeta: BranchMeta = {
304+
name: localDbName,
305+
dbName: localDbName,
306+
dbUrl: localDbUrl,
307+
createdFrom: envName,
308+
createdAt: new Date().toISOString(),
309+
schemaOnly: flags['schema-only'],
310+
}
311+
await addBranchToManifest(branchMeta)
312+
300313
if (flags.json) {
301314
this.log(JSON.stringify({ snapshot: snapshot.manifest, config: newConfig }, null, 2))
302315
return

packages/cli/src/commands/diff.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Flags } from '@oclif/core'
22
import { BaseCommand } from '../base-command.js'
33
import { createDefaultRegistry } from '../checks/index.js'
44
import { scan } from '../scanner.js'
5+
import type { ScanProgressEvent } from '../scanner.js'
56
import { renderSummary, renderDetailed } from '../render.js'
67
import { promote } from '../promote.js'
78
import type { CheckName } from '../types/drift.js'
8-
import { CHECK_NAMES } from '../types/drift.js'
9+
import { CHECK_NAMES, CHECK_META } from '../types/drift.js'
910
import { ok, warn, dim, cmd } from '../ui.js'
11+
import { sanitizeForReport } from '../utils/sanitize.js'
1012

1113
/**
1214
* Unified drift detection & resolution command.
@@ -69,10 +71,37 @@ export default class Diff extends BaseCommand {
6971
await this.runPreflight(pre, 'Diff')
7072
}
7173

74+
/** Build a progress callback for scan calls. Only active when not --json. */
75+
const makeProgress = (): ((event: ScanProgressEvent) => void) | undefined => {
76+
if (flags.json) return undefined
77+
process.stdout.write('\n Scanning...\n')
78+
return (event: ScanProgressEvent) => {
79+
const meta = CHECK_META[event.check]
80+
const label = meta?.label ?? event.check
81+
const idx = `[${event.index + 1}/${event.total}]`
82+
if (event.phase === 'check:start') {
83+
process.stdout.write(` ▶ ${idx} ${label}...\n`)
84+
} else {
85+
const dur = `${(event.durationMs / 1000).toFixed(1)}s`
86+
const issues = event.status === 'error' ? warn('error')
87+
: event.status === 'skipped' ? dim('skipped')
88+
: `${event.issueCount} issues`
89+
process.stdout.write(` ${ok('✓')} ${idx} ${label.padEnd(24)} ${issues} (${dur})\n`)
90+
}
91+
}
92+
}
93+
7294
// ── Apply mode (was: promote) ────────────────────────────────────────────
7395
if (flags.apply) {
74-
this.log('\nScanning for drift...\n')
75-
const scanResult = await scan(registry, { config, checks })
96+
const onProgress = makeProgress()
97+
const scanResult = await scan(registry, { config, checks, onProgress })
98+
this.setCheckSummaries(scanResult.checks.map(c => ({
99+
check: c.check,
100+
status: c.status,
101+
issueCount: c.issues.length,
102+
durationMs: c.durationMs,
103+
...(c.error ? { error: sanitizeForReport(c.error) } : {}),
104+
})))
76105

77106
if (scanResult.summary.total === 0) {
78107
this.log(`${ok('No drift detected.')} Nothing to apply. \u2713`)
@@ -118,7 +147,15 @@ export default class Diff extends BaseCommand {
118147
}
119148

120149
// ── Scan mode (summary or detail) ────────────────────────────────────────
121-
const result = await scan(registry, { config, checks })
150+
const onProgress = makeProgress()
151+
const result = await scan(registry, { config, checks, onProgress })
152+
this.setCheckSummaries(result.checks.map(c => ({
153+
check: c.check,
154+
status: c.status,
155+
issueCount: c.issues.length,
156+
durationMs: c.durationMs,
157+
...(c.error ? { error: sanitizeForReport(c.error) } : {}),
158+
})))
122159

123160
if (flags.json) {
124161
this.log(JSON.stringify(result, null, 2))
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Flags } from '@oclif/core'
2+
import { BaseCommand } from '../../base-command.js'
3+
import { readLocalMigrations } from '../../checks/migrations.js'
4+
import { getAppliedVersions, ensureMigrationsTable } from '../../migrate.js'
5+
import { ok, warn, dim, bold } from '../../ui.js'
6+
import { pgQuery } from '../../db.js'
7+
8+
/**
9+
* List local migration files and their applied status.
10+
*
11+
* Without --offline, connects to the target database to show which
12+
* migrations are applied (✓) vs pending (○).
13+
*/
14+
export default class MigrateList extends BaseCommand {
15+
static override description = 'List local migration files and their applied/pending status'
16+
17+
static override examples = [
18+
'<%= config.bin %> migrate list',
19+
'<%= config.bin %> migrate list --env=production',
20+
'<%= config.bin %> migrate list --offline',
21+
'<%= config.bin %> migrate list --json',
22+
]
23+
24+
static override flags = {
25+
env: Flags.string({
26+
char: 'e',
27+
description: 'Environment to check applied status against',
28+
}),
29+
offline: Flags.boolean({
30+
description: 'List local files only without connecting to the database',
31+
default: false,
32+
}),
33+
json: Flags.boolean({ description: 'Output results as JSON' }),
34+
}
35+
36+
async run(): Promise<void> {
37+
const { flags } = await this.parse(MigrateList)
38+
39+
const config = await this.loadConfigOrFail()
40+
const dir = this.resolveMigrationsDir(config)
41+
42+
const migrations = await readLocalMigrations(dir).catch(() => [])
43+
44+
if (flags.offline || !config.source) {
45+
// Offline: list files only
46+
if (flags.json) {
47+
this.log(JSON.stringify(migrations, null, 2))
48+
return
49+
}
50+
if (migrations.length === 0) {
51+
this.log(`\n ${dim('No local migration files found in')} ${dim(dir)}\n`)
52+
return
53+
}
54+
this.log(`\n ${bold(`${migrations.length} local migration(s)`)} ${dim(`in ${dir}`)}\n`)
55+
for (const m of migrations) {
56+
this.log(` ${dim('○')} ${m.filename}`)
57+
}
58+
this.log('')
59+
return
60+
}
61+
62+
// Online: connect to DB and check applied status
63+
const { envName, env } = this.resolveEnv(config, flags.env)
64+
65+
const pre = this.createPreflight('Migrate list preflight checks')
66+
.addDatabase('Target', envName, env.dbUrl)
67+
await this.runPreflight(pre, 'Migrate list')
68+
69+
let applied: Set<string>
70+
try {
71+
await ensureMigrationsTable(env.dbUrl, pgQuery)
72+
applied = await getAppliedVersions(env.dbUrl, pgQuery)
73+
} catch {
74+
applied = new Set()
75+
}
76+
77+
if (flags.json) {
78+
const result = migrations.map(m => ({
79+
...m,
80+
applied: applied.has(m.version),
81+
}))
82+
this.log(JSON.stringify(result, null, 2))
83+
return
84+
}
85+
86+
if (migrations.length === 0) {
87+
this.log(`\n ${dim('No local migration files found in')} ${dim(dir)}\n`)
88+
return
89+
}
90+
91+
const pendingCount = migrations.filter(m => !applied.has(m.version)).length
92+
const appliedCount = migrations.length - pendingCount
93+
94+
this.log(`\n ${bold(`${migrations.length} migration(s)`)} ${dim(`→ ${envName}`)} · ${ok(`${appliedCount} applied`)} · ${pendingCount > 0 ? warn(`${pendingCount} pending`) : dim('0 pending')}\n`)
95+
96+
for (const m of migrations) {
97+
const isApplied = applied.has(m.version)
98+
const icon = isApplied ? ok('✓') : dim('○')
99+
const label = isApplied ? dim(m.filename) : m.filename
100+
this.log(` ${icon} ${label}`)
101+
}
102+
this.log('')
103+
}
104+
}

0 commit comments

Comments
 (0)