diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 65ee191a..2999d365 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -1236,6 +1236,48 @@ describe('setup databases step', () => { expect(config.connections.warehouse).toMatchObject({ driver: 'postgres', url: 'env:DATABASE_URL' }); expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:'); expect(io.stderr()).toContain('Structural scan failed for warehouse.'); + expect(io.stderr()).toContain('│ Structural scan failed for warehouse.'); + expect(io.stderr()).not.toMatch(/^Structural scan failed for warehouse\./m); + }); + + it('prints the native SQLite rebuild command when scanning hits a Node ABI mismatch', async () => { + const io = makeIo(); + const result = await runKtxSetupDatabasesStep( + { + projectDir: tempDir, + inputMode: 'disabled', + databaseDrivers: ['postgres'], + databaseConnectionId: 'warehouse', + databaseUrl: 'env:DATABASE_URL', + databaseSchemas: [], + skipDatabases: false, + }, + io.io, + { + testConnection: vi.fn(async () => 0), + scanConnection: vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => { + commandIo.stderr.write( + [ + "The module '/workspace/node_modules/better-sqlite3/build/Release/better_sqlite3.node'", + 'was compiled against a different Node.js version using', + 'NODE_MODULE_VERSION 147. This version of Node.js requires', + 'NODE_MODULE_VERSION 137. Please try re-compiling or re-installing', + 'the module (for instance, using `npm rebuild` or `npm install`).', + '', + ].join('\n'), + ); + return 1; + }), + }, + ); + + expect(result.status).toBe('failed'); + expect(io.stderr()).toContain('Native SQLite is built for a different Node.js ABI.'); + expect(io.stderr()).toContain('│ Native SQLite is built for a different Node.js ABI.'); + expect(io.stderr()).toContain('Fix: pnpm run native:rebuild'); + expect(io.stderr()).toContain(`Retry: ktx scan --project-dir ${tempDir} warehouse`); + expect(io.stderr()).not.toContain('npm rebuild'); + expect(io.stderr()).not.toMatch(/^Native SQLite is built for a different Node.js ABI\./m); }); it('writes Historic SQL config for supported Snowflake databases after validation succeeds', async () => { diff --git a/packages/cli/src/setup-databases.ts b/packages/cli/src/setup-databases.ts index eceaf5bb..58ee61d9 100644 --- a/packages/cli/src/setup-databases.ts +++ b/packages/cli/src/setup-databases.ts @@ -951,6 +951,36 @@ function flushBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo) } } +function writePrefixedLines(write: (chunk: string) => void, output: string): void { + for (const line of output.split(/\r?\n/)) { + if (line.length > 0) { + write(`│ ${line}\n`); + } + } +} + +function flushPrefixedBufferedCommandOutput(io: KtxCliIo, bufferedIo: BufferedCommandIo): void { + writePrefixedLines((chunk) => io.stdout.write(chunk), bufferedIo.stdoutText()); + writePrefixedLines((chunk) => io.stderr.write(chunk), bufferedIo.stderrText()); +} + +function nativeSqliteAbiMismatchDetail(output: string): string | null { + const mentionsBetterSqlite = /\bbetter-sqlite3\b|better_sqlite3/i.test(output); + const mentionsAbiMismatch = /compiled against a different Node\.js version|NODE_MODULE_VERSION/i.test(output); + if (!mentionsBetterSqlite || !mentionsAbiMismatch) { + return null; + } + + const versionMatch = output.match( + /compiled against[\s\S]*?NODE_MODULE_VERSION\s+(\d+)[\s\S]*?requires[\s\S]*?NODE_MODULE_VERSION\s+(\d+)/i, + ); + if (!versionMatch) { + return 'better-sqlite3 native module could not load for the current Node.js runtime.'; + } + + return `better-sqlite3 was compiled for NODE_MODULE_VERSION ${versionMatch[1]}, but this Node.js requires ${versionMatch[2]}.`; +} + function readOutputValue(output: string, label: string): string | undefined { const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = new RegExp(`^\\s*${escapedLabel}:\\s*(.+?)\\s*$`, 'im').exec(output); @@ -1445,9 +1475,28 @@ async function validateAndScanConnection(input: { const scanIo = createBufferedCommandIo(); const scanCode = await scanConnection(input.projectDir, input.connectionId, scanIo); if (scanCode !== 0) { - flushBufferedCommandOutput(input.io, scanIo); - input.io.stderr.write(`Structural scan failed for ${input.connectionId}.\n`); - input.io.stderr.write(`Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}\n`); + const nativeSqliteDetail = nativeSqliteAbiMismatchDetail(`${scanIo.stderrText()}\n${scanIo.stdoutText()}`); + if (nativeSqliteDetail) { + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), + [ + `Structural scan failed for ${input.connectionId}.`, + 'Native SQLite is built for a different Node.js ABI.', + `Detail: ${nativeSqliteDetail}`, + 'Fix: pnpm run native:rebuild', + `Retry: ktx scan --project-dir ${input.projectDir} ${input.connectionId}`, + ].join('\n'), + ); + } else { + flushPrefixedBufferedCommandOutput(input.io, scanIo); + writePrefixedLines( + (chunk) => input.io.stderr.write(chunk), + [ + `Structural scan failed for ${input.connectionId}.`, + `Debug command: ktx scan --project-dir ${input.projectDir} ${input.connectionId}`, + ].join('\n'), + ); + } return false; } const scanOutput = scanIo.stdoutText(); diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/src/setup-sources.test.ts index 1a7d11b0..b79e43d6 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/src/setup-sources.test.ts @@ -176,7 +176,10 @@ describe('setup sources step', () => { it('writes Metabase config and validates mapping through existing mapping path', async () => { await addPrimarySource(); const validateMetabase = vi.fn(async () => ({ ok: true as const, detail: 'user=admin@example.com' })); - const runMapping = vi.fn(async () => 0); + const runMapping = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => { + commandIo.stdout.write('Mapping validated — 1 mapping configured\n'); + return 0; + }); const io = makeIo(); await expect( @@ -208,7 +211,16 @@ describe('setup sources step', () => { syncMode: 'ALL', }, }); - expect(runMapping).toHaveBeenCalledWith(projectDir, 'prod_metabase', io.io); + expect(runMapping).toHaveBeenCalledWith( + projectDir, + 'prod_metabase', + expect.objectContaining({ + stdout: expect.objectContaining({ write: expect.any(Function) }), + stderr: expect.objectContaining({ write: expect.any(Function) }), + }), + ); + expect(io.stdout()).toContain('│ Mapping validated — 1 mapping configured'); + expect(io.stdout()).not.toMatch(/^Mapping validated — 1 mapping configured$/m); }); it('writes Notion config with the full default knowledge create budget', async () => { @@ -544,7 +556,14 @@ describe('setup sources step', () => { ), ).resolves.toEqual({ status: 'failed', projectDir }); - expect(runMapping).toHaveBeenCalledWith(projectDir, 'metabase-main', io.io); + expect(runMapping).toHaveBeenCalledWith( + projectDir, + 'metabase-main', + expect.objectContaining({ + stdout: expect.objectContaining({ write: expect.any(Function) }), + stderr: expect.objectContaining({ write: expect.any(Function) }), + }), + ); expect(io.stderr()).toContain('1: Metabase database does not match KTX connection database'); expect(io.stderr()).not.toContain('Metabase mapping validation failed'); }); diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index 831def70..5fa4c4af 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -664,6 +664,31 @@ function splitOutputLines(output: string): string[] { .filter(Boolean); } +function writeSetupPrefixedLines(write: (chunk: string) => void, output: string): void { + for (const line of output.split(/\r?\n/)) { + if (line.length > 0) { + write(`│ ${line}\n`); + } + } +} + +function createSetupPrefixedIo(io: KtxCliIo): KtxCliIo { + return { + stdout: { + isTTY: io.stdout.isTTY, + columns: io.stdout.columns, + write(chunk: string) { + writeSetupPrefixedLines((line) => io.stdout.write(line), chunk); + }, + }, + stderr: { + write(chunk: string) { + writeSetupPrefixedLines((line) => io.stderr.write(line), chunk); + }, + }, + }; +} + function parseMappingListJson(output: string): unknown[] { const trimmed = output.trim(); if (!trimmed) { @@ -1524,7 +1549,11 @@ export async function runKtxSetupSourcesStep( } if (source === 'metabase' || source === 'looker') { prompts.log?.(`Validating ${sourceLabel(source)} mapping…`); - const mappingCode = await (deps.runMapping ?? defaultRunMapping)(args.projectDir, connectionId, io); + const mappingCode = await (deps.runMapping ?? defaultRunMapping)( + args.projectDir, + connectionId, + createSetupPrefixedIo(io), + ); if (mappingCode !== 0) { await rollback?.(); return { status: 'failed', projectDir: args.projectDir };