diff --git a/packages/core/src/config/sources.ts b/packages/core/src/config/sources.ts new file mode 100644 index 0000000..409a083 --- /dev/null +++ b/packages/core/src/config/sources.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from 'node:fs' +import type * as z from 'zod' +import { parseConnections, type StoreOptions } from './store.js' + +export const readEnvConnections = ( + schema: S, + env: NodeJS.ProcessEnv, + envVar: string, + opts: StoreOptions +): z.infer[] => { + const raw = env[envVar] + if (!raw) { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.error(opts.logLabel + ': ' + envVar + ' is not valid JSON, ignoring') + return [] + } + if (!Array.isArray(parsed)) { + console.error(opts.logLabel + ': ' + envVar + ' must be a JSON array, ignoring') + return [] + } + return parseConnections(schema, parsed, 'env ' + envVar, opts) +} + +export const readConfigFile = ( + schema: S, + path: string | undefined, + opts: StoreOptions +): z.infer[] => { + if (!path) { + return [] + } + if (!existsSync(path)) { + console.error(opts.logLabel + ': config file ' + path + ' not found, ignoring') + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch { + console.error(opts.logLabel + ': config file ' + path + ' is not valid JSON, ignoring') + return [] + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.error( + opts.logLabel + + ': config file ' + + path + + ' must contain { "connections": [...] }, ignoring' + ) + return [] + } + const connections = (parsed as { connections?: unknown }).connections + if (!Array.isArray(connections)) { + console.error( + opts.logLabel + + ': config file ' + + path + + ' must contain { "connections": [...] }, ignoring' + ) + return [] + } + return parseConnections(schema, connections, 'config ' + path, opts) +} diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts new file mode 100644 index 0000000..8eaca30 --- /dev/null +++ b/packages/core/src/config/store.ts @@ -0,0 +1,84 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import type * as z from 'zod' + +export type StoreOptions = { logLabel: string } + +export const parseConnections = ( + schema: S, + items: unknown[], + origin: string, + opts: StoreOptions +): z.infer[] => { + const result: z.infer[] = [] + for (const item of items) { + const check = schema.safeParse(item) + if (check.success) { + result.push(check.data) + } else { + const name = (item as { name?: unknown })?.name ?? '' + console.error( + opts.logLabel + + ": skipping invalid connection '" + + String(name) + + "' from " + + origin + ) + } + } + return result +} + +export const readStore = ( + schema: S, + path: string, + opts: StoreOptions +): z.infer[] => { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + if (!existsSync(path)) { + return [] + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(path, 'utf8')) + } catch { + console.error( + opts.logLabel + ': store file ' + path + ' is not valid JSON, treating as empty' + ) + return [] + } + if (!Array.isArray(parsed)) { + console.error( + opts.logLabel + ': store file ' + path + ' must contain a JSON array, treating as empty' + ) + return [] + } + return parseConnections(schema, parsed, 'store ' + path, opts) +} + +let writeSeq = 0 + +export const writeStore = (path: string, connections: unknown[]): void => { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + const items = connections.map((c) => JSON.stringify(c, null, 4)) + const content = items.length === 0 ? '[]\n' : '[\n' + items.join(',\n') + '\n]\n' + let tmp: string + for (;;) { + writeSeq += 1 + tmp = path + '.' + process.pid + '.' + writeSeq + '.tmp' + try { + writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' }) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } + } + } + try { + renameSync(tmp, path) + } catch (error) { + rmSync(tmp, { force: true }) + throw error + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e0b39d..8d52ac1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,8 @@ export type { ConnectionSource, ResolvedConnection, SshConfig } from './config/baseSchema.js' export { baseConnectionShape, sshConfigSchema } from './config/baseSchema.js' +export { readConfigFile, readEnvConnections } from './config/sources.js' +export type { StoreOptions } from './config/store.js' +export { parseConnections, readStore, writeStore } from './config/store.js' export { clampLimit, DEFAULT_ROW_LIMIT, diff --git a/packages/core/test/unit/config/sources.test.ts b/packages/core/test/unit/config/sources.test.ts new file mode 100644 index 0000000..36e834f --- /dev/null +++ b/packages/core/test/unit/config/sources.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as z from 'zod' +import { readConfigFile, readEnvConnections } from '../../../src/config/sources.js' +import type { StoreOptions } from '../../../src/config/store.js' + +const schema = z.object({ name: z.string(), value: z.number() }).strict() +const opts: StoreOptions = { logLabel: 'testlabel' } +const conn = { name: 'a', value: 1 } + +describe('readEnvConnections', () => { + let errSpy: ReturnType + + beforeEach(() => { + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns empty when the env var is unset', () => { + expect(readEnvConnections(schema, {}, 'X_CONNECTIONS', opts)).toEqual([]) + expect(errSpy).not.toHaveBeenCalled() + }) + + it('parses a JSON array of connections', () => { + const env = { X_CONNECTIONS: JSON.stringify([conn]) } + expect(readEnvConnections(schema, env, 'X_CONNECTIONS', opts)).toEqual([conn]) + }) + + it('ignores invalid JSON and logs with the label and var name', () => { + expect( + readEnvConnections(schema, { X_CONNECTIONS: 'nope{' }, 'X_CONNECTIONS', opts) + ).toEqual([]) + expect(errSpy).toHaveBeenCalledWith('testlabel: X_CONNECTIONS is not valid JSON, ignoring') + }) + + it('ignores non-array JSON and logs with the label and var name', () => { + expect(readEnvConnections(schema, { X_CONNECTIONS: '{}' }, 'X_CONNECTIONS', opts)).toEqual( + [] + ) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: X_CONNECTIONS must be a JSON array, ignoring' + ) + }) +}) + +describe('readConfigFile', () => { + let dir: string + let errSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'core-config-')) + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('returns empty when path is undefined', () => { + expect(readConfigFile(schema, undefined, opts)).toEqual([]) + expect(errSpy).not.toHaveBeenCalled() + }) + + it('warns and returns empty for a missing file', () => { + const path = join(dir, 'absent.json') + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: config file ' + path + ' not found, ignoring' + ) + }) + + it('reads the { connections: [...] } shape', () => { + const path = join(dir, 'config.json') + writeFileSync(path, JSON.stringify({ connections: [conn] })) + expect(readConfigFile(schema, path, opts)).toEqual([conn]) + }) + + it('warns on invalid JSON', () => { + const path = join(dir, 'config.json') + writeFileSync(path, '{ broken') + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: config file ' + path + ' is not valid JSON, ignoring' + ) + }) + + it('warns when the top level is an array, not an object', () => { + const path = join(dir, 'config.json') + writeFileSync(path, JSON.stringify([conn])) + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: config file ' + path + ' must contain { "connections": [...] }, ignoring' + ) + }) + + it('warns on null config', () => { + const path = join(dir, 'config.json') + writeFileSync(path, 'null') + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalled() + }) + + it('warns on primitive config', () => { + const path = join(dir, 'config.json') + writeFileSync(path, '"just a string"') + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalled() + }) + + it('warns when the connections key is missing or not an array', () => { + const path = join(dir, 'config.json') + writeFileSync(path, JSON.stringify({ connections: 'nope' })) + expect(readConfigFile(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: config file ' + path + ' must contain { "connections": [...] }, ignoring' + ) + }) +}) diff --git a/packages/core/test/unit/config/store.test.ts b/packages/core/test/unit/config/store.test.ts new file mode 100644 index 0000000..9d8d5e6 --- /dev/null +++ b/packages/core/test/unit/config/store.test.ts @@ -0,0 +1,146 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as z from 'zod' +import { + parseConnections, + readStore, + type StoreOptions, + writeStore +} from '../../../src/config/store.js' + +const schema = z.object({ name: z.string(), value: z.number() }).strict() +const opts: StoreOptions = { logLabel: 'testlabel' } +const conn = { name: 'a', value: 1 } + +describe('parseConnections', () => { + let errSpy: ReturnType + + beforeEach(() => { + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps valid items', () => { + expect(parseConnections(schema, [conn], 'origin', opts)).toEqual([conn]) + expect(errSpy).not.toHaveBeenCalled() + }) + + it('skips invalid items and logs with the injected label and name', () => { + const result = parseConnections(schema, [conn, { name: 'bad', value: 'x' }], 'origin', opts) + expect(result).toEqual([conn]) + expect(errSpy).toHaveBeenCalledWith( + "testlabel: skipping invalid connection 'bad' from origin" + ) + }) + + it('uses when the item has no name', () => { + parseConnections(schema, [42], 'origin', opts) + expect(errSpy).toHaveBeenCalledWith( + "testlabel: skipping invalid connection '' from origin" + ) + }) +}) + +describe('readStore / writeStore', () => { + let dir: string + let path: string + let errSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'core-store-')) + path = join(dir, 'nested', 'connections.json') + errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('reads a missing file as an empty list', () => { + expect(readStore(schema, path, opts)).toEqual([]) + }) + + it('round-trips through an atomic write and creates parent dirs', () => { + writeStore(path, [conn]) + expect(readStore(schema, path, opts)).toEqual([conn]) + }) + + it('writes with 0600 perms and leaves no tmp files', () => { + writeStore(path, [conn]) + expect(statSync(path).mode & 0o777).toBe(0o600) + const leftovers = readdirSync(join(dir, 'nested')).filter((f) => f.includes('.tmp')) + expect(leftovers).toEqual([]) + }) + + it('writes an empty array literal when there are no connections', () => { + writeStore(path, []) + expect(readFileSync(path, 'utf8')).toBe('[]\n') + expect(readStore(schema, path, opts)).toEqual([]) + }) + + it('treats invalid JSON as empty and logs with the label', () => { + writeStore(path, [conn]) + writeFileSync(path, '{ broken') + expect(readStore(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: store file ' + path + ' is not valid JSON, treating as empty' + ) + }) + + it('treats non-array JSON as empty and logs with the label', () => { + writeStore(path, [conn]) + writeFileSync(path, '{"connections": []}') + expect(readStore(schema, path, opts)).toEqual([]) + expect(errSpy).toHaveBeenCalledWith( + 'testlabel: store file ' + path + ' must contain a JSON array, treating as empty' + ) + }) + + it('skips invalid entries from the store but keeps valid ones', () => { + mkdirSync(join(dir, 'nested'), { recursive: true }) + writeFileSync(path, JSON.stringify([conn, { name: 'broken' }])) + expect(readStore(schema, path, opts)).toEqual([conn]) + expect(errSpy).toHaveBeenCalled() + }) + + it('cleans up the tmp file when rename fails', () => { + mkdirSync(path, { recursive: true }) + expect(() => writeStore(path, [conn])).toThrow() + const leftovers = readdirSync(join(dir, 'nested')).filter((f) => f.includes('.tmp')) + expect(leftovers).toEqual([]) + }) + + it('retries past a pre-existing tmp file (EEXIST loop) then writes', () => { + mkdirSync(join(dir, 'nested'), { recursive: true }) + // writeStore picks the next tmp name as path...tmp. We + // cannot read the private writeSeq, but every prior writeStore in this + // suite has used a small value, so seeding the low candidate slots forces + // at least one EEXIST collision before the loop finds a free slot. writeStore + // is the only caller of this counter, so this window stays valid. + const seeded: string[] = [] + for (let n = 1; n <= 50; n += 1) { + const tmp = path + '.' + process.pid + '.' + n + '.tmp' + writeFileSync(tmp, 'x') + seeded.push(tmp) + } + writeStore(path, [conn]) + expect(readStore(schema, path, opts)).toEqual([conn]) + for (const tmp of seeded) { + rmSync(tmp, { force: true }) + } + }) +}) diff --git a/packages/dbmole-mcp/src/config/sources.ts b/packages/dbmole-mcp/src/config/sources.ts index 4b1c170..c4464e0 100644 --- a/packages/dbmole-mcp/src/config/sources.ts +++ b/packages/dbmole-mcp/src/config/sources.ts @@ -1,53 +1,14 @@ -import { existsSync, readFileSync } from 'node:fs' -import type { ConnectionConfig } from '../schema.js' -import { parseConnections } from './store.js' +import { + readConfigFile as coreReadConfig, + readEnvConnections as coreReadEnv, + type StoreOptions +} from '@dbmole/core' +import { type ConnectionConfig, connectionConfigSchema } from '../schema.js' -export const readEnvConnections = (env: NodeJS.ProcessEnv = process.env): ConnectionConfig[] => { - const raw = env.DBMOLE_CONNECTIONS - if (!raw) { - return [] - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - console.error('dbmole: DBMOLE_CONNECTIONS is not valid JSON, ignoring') - return [] - } - if (!Array.isArray(parsed)) { - console.error('dbmole: DBMOLE_CONNECTIONS must be a JSON array, ignoring') - return [] - } - return parseConnections(parsed, 'env DBMOLE_CONNECTIONS') -} +const OPTS: StoreOptions = { logLabel: 'dbmole' } -export const readConfigFile = (path: string | undefined): ConnectionConfig[] => { - if (!path) { - return [] - } - if (!existsSync(path)) { - console.error('dbmole: config file ' + path + ' not found, ignoring') - return [] - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(path, 'utf8')) - } catch { - console.error('dbmole: config file ' + path + ' is not valid JSON, ignoring') - return [] - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - console.error( - 'dbmole: config file ' + path + ' must contain { "connections": [...] }, ignoring' - ) - return [] - } - const connections = (parsed as { connections?: unknown }).connections - if (!Array.isArray(connections)) { - console.error( - 'dbmole: config file ' + path + ' must contain { "connections": [...] }, ignoring' - ) - return [] - } - return parseConnections(connections, 'config ' + path) -} +export const readEnvConnections = (env: NodeJS.ProcessEnv = process.env): ConnectionConfig[] => + coreReadEnv(connectionConfigSchema, env, 'DBMOLE_CONNECTIONS', OPTS) + +export const readConfigFile = (path: string | undefined): ConnectionConfig[] => + coreReadConfig(connectionConfigSchema, path, OPTS) diff --git a/packages/dbmole-mcp/src/config/store.ts b/packages/dbmole-mcp/src/config/store.ts index 703e6c0..2d3c4ad 100644 --- a/packages/dbmole-mcp/src/config/store.ts +++ b/packages/dbmole-mcp/src/config/store.ts @@ -1,71 +1,22 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' +import { + parseConnections as coreParse, + readStore as coreReadStore, + type StoreOptions, + writeStore +} from '@dbmole/core' import { type ConnectionConfig, connectionConfigSchema } from '../schema.js' +const OPTS: StoreOptions = { logLabel: 'dbmole' } + export const defaultStorePath = (env: NodeJS.ProcessEnv = process.env): string => env.DBMOLE_STORE ?? join(homedir(), '.config', 'dbmole', 'connections.json') -export const parseConnections = (items: unknown[], origin: string): ConnectionConfig[] => { - const result: ConnectionConfig[] = [] - for (const item of items) { - const check = connectionConfigSchema.safeParse(item) - if (check.success) { - result.push(check.data) - } else { - const name = (item as { name?: unknown })?.name ?? '' - console.error( - "dbmole: skipping invalid connection '" + String(name) + "' from " + origin - ) - } - } - return result -} +export const parseConnections = (items: unknown[], origin: string): ConnectionConfig[] => + coreParse(connectionConfigSchema, items, origin, OPTS) -export const readStore = (path: string): ConnectionConfig[] => { - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - if (!existsSync(path)) { - return [] - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(path, 'utf8')) - } catch { - console.error('dbmole: store file ' + path + ' is not valid JSON, treating as empty') - return [] - } - if (!Array.isArray(parsed)) { - console.error( - 'dbmole: store file ' + path + ' must contain a JSON array, treating as empty' - ) - return [] - } - return parseConnections(parsed, 'store ' + path) -} +export const readStore = (path: string): ConnectionConfig[] => + coreReadStore(connectionConfigSchema, path, OPTS) -let writeSeq = 0 - -export const writeStore = (path: string, connections: ConnectionConfig[]): void => { - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - const items = connections.map((c) => JSON.stringify(c, null, 4)) - const content = items.length === 0 ? '[]\n' : '[\n' + items.join(',\n') + '\n]\n' - let tmp: string - for (;;) { - writeSeq += 1 - tmp = path + '.' + process.pid + '.' + writeSeq + '.tmp' - try { - writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' }) - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw error - } - } - } - try { - renameSync(tmp, path) - } catch (error) { - rmSync(tmp, { force: true }) - throw error - } -} +export { writeStore }