diff --git a/apps/cli/src/commands/drivers.ts b/apps/cli/src/commands/drivers.ts index 379698f..f481e86 100644 --- a/apps/cli/src/commands/drivers.ts +++ b/apps/cli/src/commands/drivers.ts @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise { const cwd = webWorkspaceRoot(); // Prefer installing into the @foxschema/web package directory. + // ibm_db must run install scripts (--foreground-scripts) or clidriver never + // downloads and Windows connects fail with SQL1042C / missing native binding. + const npmArgs = + entry.pkg === 'ibm_db' + ? ['install', 'ibm_db@4.0.1', '--foreground-scripts', '--prefix', cwd] + : ['install', entry.pkg, '--foreground-scripts', '--prefix', cwd]; + await new Promise((resolve, reject) => { - const child = spawn('npm', ['install', entry.pkg, '--prefix', cwd], { + const child = spawn('npm', npmArgs, { stdio: 'inherit', shell: process.platform === 'win32', - env: process.env, + env: { ...process.env, npm_config_ignore_scripts: '' }, }); child.on('error', reject); child.on('exit', (code) => { diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 4da1dd5..dd56ae3 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -10,6 +10,7 @@ import { DriverDetector, buildConnectionString, normalizeTableSchemas, + getProviderSettings, type MigrationStep, type ConnectionOptions, type DbObjectType, @@ -358,6 +359,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] }; try { const { dialect, option, schema } = await resolveRef((req as AuthedRequest).userId, ref); + const settings = getProviderSettings(dialect); + if (settings.schemaRequired && !schema?.trim()) { + res.status(400).json({ + error: `${settings.label} requires a schema. Load schemas for the connection, then pick one before browsing or editing tables.`, + }); + return; + } const { tables, warnings } = await loadScopedTables(dialect, option, schema, scope); res.json(warnings.length ? { tables, warnings } : { tables }); } catch (error: unknown) { diff --git a/apps/web/src/frontend/lib/provider-settings.ts b/apps/web/src/frontend/lib/provider-settings.ts index e243a05..96d23be 100644 --- a/apps/web/src/frontend/lib/provider-settings.ts +++ b/apps/web/src/frontend/lib/provider-settings.ts @@ -1,3 +1,5 @@ +import { buildConnectionString as coreBuildConnectionString } from '@foxschema/core'; + export type Dialect = 'postgres' | 'mysql' | 'mariadb' | 'db2' | 'sqlserver' | 'oracle' | 'sqlite' | 'redshift' | 'clickhouse' | 'azuresql' | 'cockroachdb' | 'yugabytedb' | 'tidb' | 'duckdb'; export interface ConnectionOptions { @@ -87,13 +89,10 @@ const db2Settings: ProviderSettings = { defaultPort: 50000, defaultSchema: '', schemaRequired: true, + // Delegate to core so pasted strings always get Authentication=SERVER / + // CurrentSchema (verbatim early-return caused SQL1042C / empty browse on Windows). buildConnectionString(o) { - if (o.connectionString?.trim()) return o.connectionString.trim(); - const host = o.host || 'localhost'; - const port = o.port || this.defaultPort; - let cs = `DATABASE=${o.database || ''};HOSTNAME=${host};PORT=${port};PROTOCOL=TCPIP;UID=${o.username || ''};PWD=${o.password || ''};Authentication=SERVER;`; - if (o.schema) cs += `CurrentSchema=${o.schema.toUpperCase()};`; - return cs; + return coreBuildConnectionString('db2', o); }, }; diff --git a/packages/core/src/providers/db2/db2.adapter.ts b/packages/core/src/providers/db2/db2.adapter.ts index 9c85d16..bbd657b 100644 --- a/packages/core/src/providers/db2/db2.adapter.ts +++ b/packages/core/src/providers/db2/db2.adapter.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import { ConnectionOptions, DriverAdapter } from '../../interfaces/schema-provider.interface'; import { assertSafeIdentifier } from '../../cores/sql-identifier'; import { BoundedPoolCache, disposePoolEndOrClose } from '../../cores/pool-cache'; -import { setupDb2ClientEnv } from './db2.env'; +import { setupDb2ClientEnv, hasDb2Clidriver } from './db2.env'; const nodeRequire = createRequire(import.meta.url); @@ -20,12 +20,22 @@ class Db2Adapter implements DriverAdapter { private load(): any { if (this.driver) return this.driver; setupDb2ClientEnv(); // point at the bundled clidriver before native load + if (!hasDb2Clidriver()) { + throw new Error( + 'ibm_db is installed but its CLI driver (clidriver) is missing. ' + + 'On Windows this usually means scripts were skipped — reinstall with: ' + + 'npm install ibm_db@4.0.1 --foreground-scripts (or: foxschema drivers install db2)' + ); + } try { const mod = nodeRequire(this.packageName); this.driver = mod.default ?? mod; } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - throw new Error(`Database driver "${this.packageName}" is not installed for db2. Install it with: npm install ${this.packageName} — ${message}`); + throw new Error( + `Database driver "${this.packageName}" is not installed for db2. ` + + `Install it with: npm install ${this.packageName} --foreground-scripts — ${message}` + ); } return this.driver; } diff --git a/packages/core/src/providers/db2/db2.connection.test.ts b/packages/core/src/providers/db2/db2.connection.test.ts new file mode 100644 index 0000000..7b2c594 --- /dev/null +++ b/packages/core/src/providers/db2/db2.connection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDb2ConnectionString, + odbcEscape, + parseDb2SemicolonMap, +} from './db2.connection'; + +describe('buildDb2ConnectionString', () => { + it('always injects Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + host: 'db.example', + port: 50000, + database: 'SAMPLE', + username: 'db2inst1', + password: 'secret', + }); + expect(cs).toContain('Authentication=SERVER'); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('HOSTNAME=db.example'); + expect(cs).toContain('UID=db2inst1'); + expect(cs).toContain('PWD=secret'); + }); + + it('normalizes a pasted semicolon string and still forces Authentication=SERVER', () => { + const cs = buildDb2ConnectionString({ + connectionString: + 'DATABASE=SAMPLE;HOSTNAME=h;PORT=50000;PROTOCOL=TCPIP;UID=u;PWD=p;', + }); + expect(cs).toMatch(/Authentication=SERVER/); + expect(cs).toContain('DATABASE=SAMPLE'); + expect(cs).toContain('UID=u'); + }); + + it('brace-escapes passwords that contain semicolons so they round-trip', () => { + const password = 'a;b}c'; + const cs = buildDb2ConnectionString({ + host: 'h', + database: 'D', + username: 'u', + password, + }); + expect(cs).toContain(`PWD=${odbcEscape(password)}`); + const map = parseDb2SemicolonMap(cs); + expect(map.get('PWD')).toBe(password); + }); + + it('parses braced PWD from a pasted string', () => { + const cs = buildDb2ConnectionString({ + connectionString: 'DATABASE=D;HOSTNAME=h;PORT=50000;UID=u;PWD={p;a;s;s};', + }); + expect(parseDb2SemicolonMap(cs).get('PWD')).toBe('p;a;s;s'); + }); + + it('adds CurrentSchema when schema is provided', () => { + const cs = buildDb2ConnectionString( + { host: 'h', database: 'D', username: 'u', password: 'p', schema: 'myschema' }, + 'myschema' + ); + expect(cs).toContain('CurrentSchema=MYSCHEMA'); + }); +}); + +describe('odbcEscape', () => { + it('leaves plain values alone', () => { + expect(odbcEscape('plain')).toBe('plain'); + }); + + it('escapes braces inside braced values', () => { + expect(odbcEscape('a}b')).toBe('{a}}b}'); + }); +}); diff --git a/packages/core/src/providers/db2/db2.connection.ts b/packages/core/src/providers/db2/db2.connection.ts index 6a09cb9..59a63c2 100644 --- a/packages/core/src/providers/db2/db2.connection.ts +++ b/packages/core/src/providers/db2/db2.connection.ts @@ -2,20 +2,21 @@ import type { ConnectionOptions } from '../../interfaces/schema-provider.interfa /** * Build a DB2 CLI connection string (semicolon-delimited key=value pairs). - * ibm_db2 does not accept db2:// URLs — those must be converted first. + * ibm_db does not accept db2:// URLs — those must be converted first. * - * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups. + * Authentication=SERVER is required by IBM to avoid SQL1042C on many setups + * (especially Windows when GSKit / system CLI libraries pollute PATH). */ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: string): string { const parsed = parseDb2ConnectionInput(options.connectionString, options); const parts: string[] = [ - `DATABASE=${parsed.database}`, - `HOSTNAME=${parsed.host}`, + `DATABASE=${odbcEscape(parsed.database)}`, + `HOSTNAME=${odbcEscape(parsed.host)}`, `PORT=${parsed.port}`, 'PROTOCOL=TCPIP', - `UID=${parsed.username}`, - `PWD=${parsed.password}`, + `UID=${odbcEscape(parsed.username)}`, + `PWD=${odbcEscape(parsed.password)}`, 'Authentication=SERVER', ]; @@ -25,12 +26,18 @@ export function buildDb2ConnectionString(options: ConnectionOptions, schema?: st const schemaName = schema?.trim() || options.schema?.trim(); if (schemaName) { - parts.push(`CurrentSchema=${schemaName.toUpperCase()}`); + parts.push(`CurrentSchema=${odbcEscape(schemaName.toUpperCase())}`); } return parts.join(';') + ';'; } +/** ODBC brace-escape so values containing `;` or `}` do not truncate the string. */ +export function odbcEscape(value: string): string { + if (!/[;{}]/.test(value) && value === value.trim()) return value; + return `{${value.replace(/}/g, '}}')}}`; +} + function parseDb2ConnectionInput( connectionString: string | undefined, options: ConnectionOptions @@ -81,6 +88,57 @@ function parseDb2Url(url: string): { }; } +/** + * Parse `KEY=value;KEY2={value;with;semicolons};` with ODBC brace rules. + * A plain `split(';')` would truncate passwords that contain `;`. + */ +export function parseDb2SemicolonMap(connStr: string): Map { + const map = new Map(); + let i = 0; + const s = connStr; + + while (i < s.length) { + while (i < s.length && (s[i] === ';' || /\s/.test(s[i]!))) i++; + if (i >= s.length) break; + + const eq = s.indexOf('=', i); + if (eq === -1) break; + const key = s.slice(i, eq).trim().toUpperCase(); + let j = eq + 1; + while (j < s.length && s[j] === ' ') j++; + + let value: string; + if (s[j] === '{') { + j++; + let out = ''; + while (j < s.length) { + if (s[j] === '}' && s[j + 1] === '}') { + out += '}'; + j += 2; + continue; + } + if (s[j] === '}') { + j++; + break; + } + out += s[j]!; + j++; + } + value = out; + while (j < s.length && s[j] !== ';') j++; + } else { + const semi = s.indexOf(';', j); + value = (semi === -1 ? s.slice(j) : s.slice(j, semi)).trim(); + j = semi === -1 ? s.length : semi; + } + + if (key) map.set(key, value); + i = j + 1; + } + + return map; +} + function parseDb2Semicolon( connStr: string, fallback: ConnectionOptions @@ -91,14 +149,7 @@ function parseDb2Semicolon( username: string; password: string; } { - const map = new Map(); - for (const segment of connStr.split(';')) { - const eq = segment.indexOf('='); - if (eq === -1) continue; - const key = segment.slice(0, eq).trim().toUpperCase(); - const value = segment.slice(eq + 1).trim(); - if (key) map.set(key, value); - } + const map = parseDb2SemicolonMap(connStr); return { database: map.get('DATABASE') ?? fallback.database ?? '', diff --git a/packages/core/src/providers/db2/db2.env.test.ts b/packages/core/src/providers/db2/db2.env.test.ts new file mode 100644 index 0000000..685507a --- /dev/null +++ b/packages/core/src/providers/db2/db2.env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { stripIbmGskPath } from './db2.env'; + +describe('stripIbmGskPath', () => { + it('removes IBM GSKit directories that conflict with bundled clidriver', () => { + const sep = process.platform === 'win32' ? ';' : ':'; + const input = ['C:\\Tools', 'C:\\Program Files\\IBM\\gsk8\\lib', 'C:\\clidriver\\bin'].join( + sep + ); + const out = stripIbmGskPath(input) ?? ''; + expect(out.toLowerCase()).not.toContain('gsk8'); + expect(out).toContain('Tools'); + expect(out).toContain('clidriver'); + }); + + it('returns undefined for undefined input', () => { + expect(stripIbmGskPath(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/providers/db2/db2.env.ts b/packages/core/src/providers/db2/db2.env.ts index ac35b97..acf6167 100644 --- a/packages/core/src/providers/db2/db2.env.ts +++ b/packages/core/src/providers/db2/db2.env.ts @@ -9,6 +9,9 @@ let configured = false; /** * Point the process at ibm_db's bundled clidriver before loading native bindings. * Helps avoid SQL1042C caused by missing/wrong CLI library paths. + * + * On Windows, IBM GSKit (`…\IBM\gsk8\…`) on PATH is a known conflict with + * ibm_db's bundled CLI — strip those entries when we install our clidriver PATH. */ export function setupDb2ClientEnv(): void { if (configured) return; @@ -20,6 +23,8 @@ export function setupDb2ClientEnv(): void { const binDir = path.join(clidriverDir, 'bin'); if (!fs.existsSync(clidriverDir)) { + // Leave configured=false so a later successful `npm install ibm_db + // --foreground-scripts` can retry setup on the next connect. return; } @@ -32,7 +37,7 @@ export function setupDb2ClientEnv(): void { process.env.LD_LIBRARY_PATH = prependPath(process.env.LD_LIBRARY_PATH, libDir); process.env.PATH = prependPath(process.env.PATH, binDir); } else if (process.platform === 'win32') { - process.env.PATH = prependPath(process.env.PATH, binDir, libDir); + process.env.PATH = prependPath(stripIbmGskPath(process.env.PATH), binDir, libDir); process.env.LIB = prependPath(process.env.LIB, libDir, binDir); } @@ -42,6 +47,26 @@ export function setupDb2ClientEnv(): void { } } +/** True when ibm_db's installer/clidriver directory is present on disk. */ +export function hasDb2Clidriver(): boolean { + try { + const pkgPath = nodeRequire.resolve('ibm_db/package.json'); + const clidriverDir = path.join(path.dirname(pkgPath), 'installer', 'clidriver'); + return fs.existsSync(clidriverDir); + } catch { + return false; + } +} + +/** Exported for tests — remove IBM GSKit dirs that conflict with bundled CLI. */ +export function stripIbmGskPath(current: string | undefined): string | undefined { + if (!current) return current; + const kept = current + .split(path.delimiter) + .filter((p) => p && !/IBM[/\\]gsk8/i.test(p)); + return kept.join(path.delimiter); +} + function prependPath(current: string | undefined, ...dirs: string[]): string { const existing = current?.split(path.delimiter).filter(Boolean) ?? []; const merged = [...dirs, ...existing.filter((d) => !dirs.includes(d))];