diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf93f8..9f46eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Simple DB are documented in this file. +## Unreleased + +### Added + +- One readable JSON file per database connection, stored locally by Simple DB. +- Automatic migration of existing 0.1.1 connection profiles to JSON files. +- `Open Connection JSON`, `Set Password`, and `Open Connections Folder` actions. +- A native `Simple DB` editor context submenu containing the six main database actions. + +### Changed + +- Simplified connection creation: choose the engine and name, optionally enter a secure network password, then edit all non-secret connection parameters together in JSON. +- Passwords remain in VS Code `SecretStorage` and are explicitly rejected from connection JSON files. + ## 0.1.1 - 2026-08-07 ### Added diff --git a/README.md b/README.md index 8d0496b..85fc5a3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. ## Main features - Create, edit, test, and delete multiple connections for each database engine. +- Keep every connection in its own readable JSON file and edit all connection parameters in one place. - Store passwords in `SecretStorage`, never inside profiles or the repository. - Connect to several database engines simultaneously and disconnect them explicitly. - Explore databases, schemas, and engine-specific objects. @@ -86,13 +87,60 @@ CSV export protects values that spreadsheet applications could interpret as form ## Connections and security -- Connection profiles do not contain passwords. -- Passwords are stored with the VS Code `SecretStorage` API. +- **Create Connection** asks only for the database engine, a connection name, and a password for network databases. SQLite uses the native file picker. +- Simple DB then creates one readable JSON file per connection and opens it in VS Code so host, port, database/service, username, TLS options, and timeouts can be edited together. +- Saving a connection JSON with `Ctrl+S` reloads that connection automatically. Existing connections from Simple DB 0.1.1 are migrated to JSON files on first launch. +- **Open Connection JSON** opens the selected profile, **Set Password** changes its secure password, and **Open Connections Folder** reveals all local connection files. +- Connection JSON files never contain passwords. Passwords are stored with the VS Code `SecretStorage` API. - SSL/TLS, encryption, and certificate trust are explicit options where supported by the database engine. - `simpleDb.confirmDestructiveQueries` is enabled by default. - `simpleDb.warnUnsafeDml` is enabled by default. - Query history can contain literals written in SQL. It can be disabled with `simpleDb.history.enabled` or cleared from the **History** view. +Example Oracle connection JSON: + +```json +{ + "id": "generated-by-simple-db", + "name": "Oracle Production", + "engine": "oracle", + "host": "192.168.1.20", + "port": 1521, + "serviceName": "ORCLPDB1", + "connectString": "", + "user": "report_user", + "connectTimeoutMs": 15000, + "queryTimeoutMs": 300000 +} +``` + +Example SQLite connection JSON: + +```json +{ + "id": "generated-by-simple-db", + "name": "Local SQLite", + "engine": "sqlite", + "filePath": "C:\\data\\sample.db", + "readOnly": false, + "connectTimeoutMs": 15000, + "queryTimeoutMs": 300000 +} +``` + +The `id` is generated and managed by Simple DB. Do not change it. Use **Simple DB: Set Password** instead of adding a `password` field to a JSON file. + +### Editor context menu + +Right-clicking inside an editor now shows a native **Simple DB** submenu with the main actions: + +- Create Connection +- New Query +- Execute Selection or Current Statement +- Execute Entire Document +- Change Editor Connection +- Show History + ### SQLite note SQLite runs in a dedicated WebAssembly Worker so long-running queries do not block the UI and can be cancelled by terminating the Worker. The database file is held as an in-memory snapshot while connected. Before every operation, Simple DB checks whether the main file, WAL, or journal changed externally. If a conflict is detected, it refuses to continue and asks the user to reconnect. If an active WAL exists when the connection is opened, the connection is rejected until the owning process checkpoints/closes the WAL, preventing Simple DB from loading or overwriting an incomplete snapshot. diff --git a/package.json b/package.json index d116a5e..b2a4568 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "commands": [ { "command": "simpleDb.addConnection", - "title": "Add Connection", + "title": "Create Connection", "category": "Simple DB", "icon": "$(add)" }, @@ -91,10 +91,22 @@ }, { "command": "simpleDb.editConnection", - "title": "Edit Connection", + "title": "Open Connection JSON", "category": "Simple DB", "icon": "$(edit)" }, + { + "command": "simpleDb.setPassword", + "title": "Set Password", + "category": "Simple DB", + "icon": "$(key)" + }, + { + "command": "simpleDb.openConnectionsFolder", + "title": "Open Connections Folder", + "category": "Simple DB", + "icon": "$(folder-opened)" + }, { "command": "simpleDb.deleteConnection", "title": "Delete Connection", @@ -219,6 +231,12 @@ "icon": "$(copy)" } ], + "submenus": [ + { + "id": "simpleDb.editorContextMenu", + "label": "Simple DB" + } + ], "viewsContainers": { "activitybar": [ { @@ -254,6 +272,11 @@ "when": "view == simpleDb.connections", "group": "navigation@2" }, + { + "command": "simpleDb.openConnectionsFolder", + "when": "view == simpleDb.connections", + "group": "navigation@3" + }, { "command": "simpleDb.clearHistory", "when": "view == simpleDb.history", @@ -297,10 +320,15 @@ "group": "connection@2" }, { - "command": "simpleDb.deleteConnection", + "command": "simpleDb.setPassword", "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", "group": "connection@3" }, + { + "command": "simpleDb.deleteConnection", + "when": "view == simpleDb.connections && viewItem =~ /simpleDb.connection/", + "group": "connection@4" + }, { "command": "simpleDb.selectTable", "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(table|view|materializedView)/", @@ -370,20 +398,38 @@ } ], "editor/context": [ + { + "submenu": "simpleDb.editorContextMenu", + "group": "simpleDb@1" + } + ], + "simpleDb.editorContextMenu": [ + { + "command": "simpleDb.addConnection", + "group": "1_connection@1" + }, + { + "command": "simpleDb.newQuery", + "group": "1_connection@2" + }, { "command": "simpleDb.executeCurrent", "when": "editorLangId == sql", - "group": "simpleDb@1" + "group": "2_query@1" }, { - "command": "simpleDb.executeSelection", - "when": "editorLangId == sql && editorHasSelection", - "group": "simpleDb@2" + "command": "simpleDb.executeDocument", + "when": "editorLangId == sql", + "group": "2_query@2" }, { "command": "simpleDb.changeEditorConnection", "when": "editorLangId == sql", - "group": "simpleDb@3" + "group": "2_query@3" + }, + { + "command": "simpleDb.showHistory", + "group": "3_history@1" } ] }, diff --git a/src/extension.js b/src/extension.js index fa72399..5557804 100644 --- a/src/extension.js +++ b/src/extension.js @@ -16,7 +16,7 @@ const { const { ConnectionStore } = require('./storage/connectionStore'); const { HistoryStore } = require('./storage/historyStore'); const { ResultStore } = require('./storage/resultStore'); -const { promptConnection } = require('./ui/connectionForm'); +const { promptConnection, promptPassword } = require('./ui/connectionForm'); const { ConnectionsTreeProvider } = require('./views/connectionsTreeProvider'); const { HistoryTreeProvider } = require('./views/historyTreeProvider'); const { ResultPanel } = require('./views/resultPanel'); @@ -124,7 +124,15 @@ function registerCommand(context, commandId, handler) { } async function activate(context) { - const connectionStore = new ConnectionStore(context.globalState, context.secrets); + const connectionStore = new ConnectionStore(context.globalState, context.secrets, { + directoryPath: path.join(context.globalStorageUri.fsPath, 'connections'), + }); + const connectionLoad = await connectionStore.initialize(); + if (connectionLoad.errors.length > 0) { + vscode.window.showWarningMessage( + `Simple DB: ${connectionLoad.errors.length} connection JSON file(s) could not be loaded. Open the Connections folder to review them.`, + ); + } const connectionManager = new ConnectionManager(connectionStore); const editorSessionManager = new EditorSessionManager( connectionStore, @@ -174,21 +182,28 @@ async function activate(context) { registerCommand(context, 'simpleDb.addConnection', async (node) => { const form = await promptConnection({ engineId: node?.engineId }); if (!form) return; - if (form.testBeforeSave) { - const result = await connectionManager.testProfile( - form.profile, - form.effectivePassword, - ); - vscode.window.showInformationMessage( - `Simple DB: connection successful (${result.elapsedMs} ms) · ${result.serverVersion}`, - ); - } const saved = await connectionStore.save(form.profile, form.password); connectionsProvider.refresh(); - vscode.window.showInformationMessage(`Simple DB: connection "${saved.name}" saved.`); + const filePath = connectionStore.connectionFile(saved.id); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document, { preview: false }); + vscode.window.showInformationMessage( + `Simple DB: "${saved.name}" created. Edit the JSON, press Ctrl+S, then test or connect.`, + ); }); - registerCommand(context, 'simpleDb.refreshConnections', () => { + registerCommand(context, 'simpleDb.refreshConnections', async () => { + const hasActiveConnection = connectionStore + .list() + .some((profile) => connectionManager.isConnected(profile.id)); + if (!hasActiveConnection) { + const result = await connectionStore.reload(); + if (result.errors.length > 0) { + vscode.window.showWarningMessage( + `Simple DB: ${result.errors.length} connection JSON file(s) could not be loaded.`, + ); + } + } connectionsProvider.refresh(); }); @@ -222,7 +237,7 @@ async function activate(context) { }); registerCommand(context, 'simpleDb.testConnection', async (node) => { - const profile = profileForNode(connectionStore, node); + const profile = profileForNode(connectionStore, node) || (await pickProfile(connectionStore)); if (!profile) throw new Error('Connection not found.'); const result = await connectionManager.testConnection(profile.id); vscode.window.showInformationMessage( @@ -233,43 +248,48 @@ async function activate(context) { registerCommand(context, 'simpleDb.editConnection', async (node) => { const profile = profileForNode(connectionStore, node); if (!profile) throw new Error('Connection not found.'); - const existingPassword = await connectionStore.getPassword(profile.id); - const form = await promptConnection({ - existingProfile: profile, - existingPassword, - }); - if (!form) return; - - if (form.testBeforeSave) { - const result = await connectionManager.testProfile( - form.profile, - form.effectivePassword, - ); - vscode.window.showInformationMessage( - `Simple DB: settings verified in ${result.elapsedMs} ms · ${result.serverVersion}`, - ); - } - if (connectionManager.isConnected(profile.id)) { const transactions = connectionManager.transactionCount(profile.id); const executions = connectionManager.executionCount(profile.id); const text = transactions > 0 || executions > 0 - ? `Saving requires disconnecting: ${executions} query/queries will be cancelled and ${transactions} transaction(s) will be rolled back.` - : 'Saving requires disconnecting the active connection.'; + ? `Editing requires disconnecting: ${executions} query/queries will be cancelled and ${transactions} transaction(s) will be rolled back.` + : 'Editing requires disconnecting the active connection.'; const answer = await vscode.window.showWarningMessage( text, { modal: true }, transactions > 0 || executions > 0 - ? 'Save, cancel queries, and ROLLBACK' - : 'Save and disconnect', + ? 'Disconnect, cancel queries, and ROLLBACK' + : 'Disconnect and edit', ); if (!answer) return; await connectionManager.disconnect(profile.id); } - await connectionStore.save(form.profile, form.password, { - keepExistingPassword: form.password === undefined, - }); - connectionsProvider.refresh(); + const filePath = connectionStore.connectionFile(profile.id); + if (!filePath) throw new Error('Connection JSON file not found.'); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document, { preview: false }); + }); + + registerCommand(context, 'simpleDb.setPassword', async (node) => { + const profile = profileForNode(connectionStore, node) || (await pickProfile(connectionStore)); + if (!profile) return; + if (profile.engine === 'sqlite') { + vscode.window.showInformationMessage('Simple DB: SQLite connections do not use a password.'); + return; + } + const password = await promptPassword(profile); + if (password === undefined) return; + await connectionStore.setPassword(profile.id, password); + vscode.window.showInformationMessage( + `Simple DB: password for "${profile.name}" stored securely.`, + ); + }); + + registerCommand(context, 'simpleDb.openConnectionsFolder', async () => { + await vscode.commands.executeCommand( + 'revealFileInOS', + vscode.Uri.file(connectionStore.connectionDirectory()), + ); }); registerCommand(context, 'simpleDb.deleteConnection', async (node) => { @@ -601,6 +621,37 @@ async function activate(context) { if (entry) await historyStore.delete(entry.id); }); + const connectionFileSaveDisposable = vscode.workspace.onDidSaveTextDocument( + async (document) => { + const filePath = document.uri.fsPath; + if (!connectionStore.isConnectionFile(filePath)) return; + try { + const profileId = connectionStore.profileIdForFile(filePath); + if (profileId && connectionManager.isConnected(profileId)) { + const transactions = connectionManager.transactionCount(profileId); + const executions = connectionManager.executionCount(profileId); + if (transactions > 0 || executions > 0) { + vscode.window.showWarningMessage( + 'Simple DB: connection settings were saved on disk but cannot be applied while queries or transactions are active. Disconnect, then refresh Connections.', + ); + return; + } + await connectionManager.disconnect(profileId); + } + const saved = await connectionStore.reloadFile(filePath); + connectionsProvider.refresh(); + vscode.window.showInformationMessage( + `Simple DB: connection "${saved.name}" updated from JSON.`, + ); + } catch (error) { + vscode.window.showErrorMessage( + `Simple DB: could not load this connection JSON — ${error.message}`, + ); + } + }, + ); + context.subscriptions.push(connectionFileSaveDisposable); + const closeDisposable = vscode.workspace.onDidCloseTextDocument(async (document) => { const session = editorSessionManager.detach(document); if (!session) return; diff --git a/src/storage/connectionStore.js b/src/storage/connectionStore.js index bb74d1f..d9c12d6 100644 --- a/src/storage/connectionStore.js +++ b/src/storage/connectionStore.js @@ -1,16 +1,19 @@ 'use strict'; +const fs = require('node:fs/promises'); +const path = require('node:path'); const { randomUUID } = require('node:crypto'); const { isDatabaseEngineId } = require('../databaseEngines'); const PROFILES_KEY = 'simpleDb.connectionProfiles.v1'; +const JSON_MIGRATION_KEY = 'simpleDb.connectionProfiles.jsonMigration.v1'; const PASSWORD_PREFIX = 'simpleDb.password.'; function sanitizeProfile(profile) { const common = { - id: String(profile.id), - name: String(profile.name).trim(), - engine: String(profile.engine), + id: String(profile.id || ''), + name: String(profile.name || '').trim(), + engine: String(profile.engine || ''), connectTimeoutMs: Number(profile.connectTimeoutMs || 15000), queryTimeoutMs: Number(profile.queryTimeoutMs ?? 300000), createdAt: profile.createdAt || new Date().toISOString(), @@ -61,18 +64,96 @@ function sanitizeProfile(profile) { return network; } +function validateProfile(profile) { + if (!profile.id) { + throw new Error('Connection id is required.'); + } + if (!profile.name) { + throw new Error('Connection name is required.'); + } + if (!isDatabaseEngineId(profile.engine)) { + throw new Error(`Invalid database engine: ${profile.engine}`); + } + if (profile.engine === 'sqlite' && !profile.filePath) { + throw new Error('SQLite file path is required.'); + } +} + +function profileDocument(profile) { + const common = { + id: profile.id, + name: profile.name, + engine: profile.engine, + }; + + let connection; + if (profile.engine === 'sqlite') { + connection = { + filePath: profile.filePath, + readOnly: Boolean(profile.readOnly), + }; + } else if (profile.engine === 'oracle') { + connection = { + host: profile.host, + port: profile.port || 1521, + serviceName: profile.serviceName || profile.database || '', + connectString: profile.connectString || '', + user: profile.user, + }; + } else if (profile.engine === 'sqlserver') { + connection = { + host: profile.host, + port: profile.port || 1433, + database: profile.database, + user: profile.user, + instanceName: profile.instanceName || '', + encrypt: profile.encrypt !== false, + trustServerCertificate: Boolean(profile.trustServerCertificate), + }; + } else { + connection = { + host: profile.host, + port: profile.port, + database: profile.database, + user: profile.user, + ssl: Boolean(profile.ssl), + trustServerCertificate: Boolean(profile.trustServerCertificate), + }; + } + + return { + ...common, + ...connection, + connectTimeoutMs: profile.connectTimeoutMs, + queryTimeoutMs: profile.queryTimeoutMs, + }; +} + +function fileSlug(name) { + const slug = String(name || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return slug || 'connection'; +} + class ConnectionStore { - constructor(globalState, secrets) { + constructor(globalState, secrets, options = {}) { this.globalState = globalState; this.secrets = secrets; + this.directoryPath = options.directoryPath || ''; + this.profiles = null; + this.profileFiles = new Map(); } - list() { + _legacyProfiles() { const profiles = this.globalState.get(PROFILES_KEY, []); if (!Array.isArray(profiles)) { return []; } - return profiles .filter( (profile) => @@ -84,14 +165,140 @@ class ConnectionStore { .map((profile) => ({ ...profile })); } + async initialize() { + if (!this.directoryPath) { + this.profiles = this._legacyProfiles(); + return { migrated: 0, errors: [] }; + } + + await fs.mkdir(this.directoryPath, { recursive: true }); + let migrated = 0; + if (!this.globalState.get(JSON_MIGRATION_KEY, false)) { + const existingIds = new Set(); + for (const fileName of await this._jsonFileNames()) { + try { + const raw = JSON.parse( + await fs.readFile(path.join(this.directoryPath, fileName), 'utf8'), + ); + if (raw?.id) existingIds.add(String(raw.id)); + } catch { + // Invalid files are reported by reload(); migration leaves them untouched. + } + } + for (const legacyProfile of this._legacyProfiles()) { + if (existingIds.has(legacyProfile.id)) continue; + const profile = sanitizeProfile(legacyProfile); + validateProfile(profile); + const filePath = await this._availableFilePath(profile.name, profile.id); + await this._writeProfileFile(filePath, profile); + migrated += 1; + } + await this.globalState.update(JSON_MIGRATION_KEY, true); + } + + const result = await this.reload(); + return { migrated, errors: result.errors }; + } + + async _jsonFileNames() { + if (!this.directoryPath) return []; + const entries = await fs.readdir(this.directoryPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json')) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } + + async reload() { + if (!this.directoryPath) { + this.profiles = this._legacyProfiles(); + return { profiles: this.list(), errors: [] }; + } + + const profiles = []; + const profileFiles = new Map(); + const errors = []; + const names = new Set(); + + for (const fileName of await this._jsonFileNames()) { + const filePath = path.join(this.directoryPath, fileName); + try { + const raw = JSON.parse(await fs.readFile(filePath, 'utf8')); + if (Object.hasOwn(raw, 'password')) { + throw new Error( + 'Do not store passwords in connection JSON files. Use Simple DB: Set Password.', + ); + } + if (!raw?.id) { + throw new Error('The connection JSON must contain an "id" field.'); + } + const profile = sanitizeProfile(raw); + validateProfile(profile); + const normalizedName = profile.name.toLocaleLowerCase(); + if (profileFiles.has(profile.id)) { + throw new Error(`Duplicate connection id "${profile.id}".`); + } + if (names.has(normalizedName)) { + throw new Error(`Duplicate connection name "${profile.name}".`); + } + names.add(normalizedName); + profiles.push(profile); + profileFiles.set(profile.id, filePath); + } catch (error) { + errors.push({ filePath, message: error.message }); + } + } + + this.profiles = profiles; + this.profileFiles = profileFiles; + await this._syncLegacyState(); + return { profiles: this.list(), errors }; + } + + list() { + const profiles = this.profiles || this._legacyProfiles(); + return profiles.map((profile) => ({ ...profile })); + } + get(profileId) { return this.list().find((profile) => profile.id === profileId); } + connectionFile(profileId) { + return this.profileFiles.get(profileId) || ''; + } + + connectionDirectory() { + return this.directoryPath; + } + + isConnectionFile(filePath) { + if (!this.directoryPath || !filePath) return false; + return ( + path.dirname(path.resolve(filePath)) === path.resolve(this.directoryPath) && + path.extname(filePath).toLowerCase() === '.json' + ); + } + + profileIdForFile(filePath) { + const target = path.resolve(filePath); + for (const [profileId, candidate] of this.profileFiles) { + if (path.resolve(candidate) === target) return profileId; + } + return ''; + } + async getPassword(profileId) { return (await this.secrets.get(`${PASSWORD_PREFIX}${profileId}`)) || ''; } + async setPassword(profileId, password) { + if (!this.get(profileId)) { + throw new Error('The connection no longer exists.'); + } + await this.secrets.store(`${PASSWORD_PREFIX}${profileId}`, String(password ?? '')); + } + async save(input, password, options = {}) { const existing = input.id ? this.get(input.id) : undefined; const id = existing?.id || input.id || randomUUID(); @@ -101,16 +308,7 @@ class ConnectionStore { id, createdAt: existing?.createdAt || input.createdAt, }); - - if (!profile.name) { - throw new Error('Connection name is required.'); - } - if (!isDatabaseEngineId(profile.engine)) { - throw new Error(`Invalid database engine: ${profile.engine}`); - } - if (profile.engine === 'sqlite' && !profile.filePath) { - throw new Error('SQLite file path is required.'); - } + validateProfile(profile); const profiles = this.list(); const duplicate = profiles.find( @@ -125,12 +323,17 @@ class ConnectionStore { } const index = profiles.findIndex((candidate) => candidate.id === id); - if (index >= 0) { - profiles[index] = profile; - } else { - profiles.push(profile); + if (index >= 0) profiles[index] = profile; + else profiles.push(profile); + this.profiles = profiles; + + if (this.directoryPath) { + const filePath = + this.profileFiles.get(id) || (await this._availableFilePath(profile.name, id)); + await this._writeProfileFile(filePath, profile); + this.profileFiles.set(id, filePath); } - await this.globalState.update(PROFILES_KEY, profiles); + await this._syncLegacyState(); if (profile.engine === 'sqlite') { await this.secrets.delete(`${PASSWORD_PREFIX}${id}`); @@ -143,15 +346,87 @@ class ConnectionStore { return { ...profile }; } + async reloadFile(filePath) { + if (!this.isConnectionFile(filePath)) { + throw new Error('The selected file is not a Simple DB connection JSON.'); + } + const raw = JSON.parse(await fs.readFile(filePath, 'utf8')); + if (Object.hasOwn(raw, 'password')) { + throw new Error( + 'Do not store passwords in connection JSON files. Use Simple DB: Set Password.', + ); + } + if (!raw?.id) { + throw new Error('The connection JSON must contain an "id" field.'); + } + const expectedId = this.profileIdForFile(filePath); + if (expectedId && String(raw.id) !== expectedId) { + throw new Error('The connection "id" field is internal and must not be changed.'); + } + + const profile = sanitizeProfile(raw); + validateProfile(profile); + const duplicate = this.list().find( + (candidate) => + candidate.id !== profile.id && + candidate.name.localeCompare(profile.name, undefined, { + sensitivity: 'accent', + }) === 0, + ); + if (duplicate) { + throw new Error(`A connection named "${profile.name}" already exists.`); + } + + const profiles = this.list(); + const index = profiles.findIndex((candidate) => candidate.id === profile.id); + if (index >= 0) profiles[index] = profile; + else profiles.push(profile); + this.profiles = profiles; + this.profileFiles.set(profile.id, filePath); + await this._syncLegacyState(); + return { ...profile }; + } + async delete(profileId) { - const profiles = this.list().filter((profile) => profile.id !== profileId); - await this.globalState.update(PROFILES_KEY, profiles); + this.profiles = this.list().filter((profile) => profile.id !== profileId); + const filePath = this.profileFiles.get(profileId); + if (filePath) { + await fs.unlink(filePath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + this.profileFiles.delete(profileId); + } + await this._syncLegacyState(); await this.secrets.delete(`${PASSWORD_PREFIX}${profileId}`); } + + async _availableFilePath(name, profileId) { + const base = fileSlug(name); + let candidate = path.join(this.directoryPath, `${base}.json`); + try { + await fs.access(candidate); + candidate = path.join(this.directoryPath, `${base}-${profileId.slice(0, 8)}.json`); + } catch { + // The readable name is available. + } + return candidate; + } + + async _writeProfileFile(filePath, profile) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const content = `${JSON.stringify(profileDocument(profile), null, 2)}\n`; + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: 0o600 }); + } + + async _syncLegacyState() { + await this.globalState.update(PROFILES_KEY, this.list()); + } } module.exports = { ConnectionStore, + JSON_MIGRATION_KEY, PROFILES_KEY, + profileDocument, sanitizeProfile, }; diff --git a/src/test/connectionStore.test.js b/src/test/connectionStore.test.js index 32b8cdc..c2a77bb 100644 --- a/src/test/connectionStore.test.js +++ b/src/test/connectionStore.test.js @@ -1,5 +1,8 @@ 'use strict'; +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); const { ConnectionStore, PROFILES_KEY } = require('../storage/connectionStore'); function memoryMemento() { @@ -50,7 +53,7 @@ describe('ConnectionStore', () => { { name: 'MySQL', engine: 'mysql', host: 'localhost', port: 3306, user: 'u' }, 'old-password', ); - await store.save({ ...profile, name: 'MySQL editado' }, undefined, { + await store.save({ ...profile, name: 'MySQL edited' }, undefined, { keepExistingPassword: true, }); await expect(store.getPassword(profile.id)).resolves.toBe('old-password'); @@ -75,3 +78,137 @@ describe('ConnectionStore', () => { await expect(store.getPassword(one.id)).resolves.toBe(''); }); }); + +describe('ConnectionStore JSON files', () => { + let temporaryDirectory; + + afterEach(async () => { + if (temporaryDirectory) { + await fs.rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } + }); + + async function jsonStore(state, secrets) { + temporaryDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), 'simple-db-connections-'), + ); + const directoryPath = path.join(temporaryDirectory, 'connections'); + const store = new ConnectionStore(state, secrets, { directoryPath }); + return { store, directoryPath }; + } + + it('migrates an existing profile to a readable JSON file without its password', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const legacyStore = new ConnectionStore(state, secrets); + const legacyProfile = await legacyStore.save( + { + name: 'Production PG', + engine: 'postgresql', + host: 'db.internal', + port: 5432, + database: 'app', + user: 'reader', + ssl: true, + }, + 'super-secret', + ); + const { store } = await jsonStore(state, secrets); + + const initialized = await store.initialize(); + const filePath = store.connectionFile(legacyProfile.id); + const document = JSON.parse(await fs.readFile(filePath, 'utf8')); + + expect(initialized.migrated).toBe(1); + expect(document).toMatchObject({ + id: legacyProfile.id, + name: 'Production PG', + engine: 'postgresql', + host: 'db.internal', + port: 5432, + database: 'app', + user: 'reader', + }); + expect(document).not.toHaveProperty('password'); + await expect(store.getPassword(legacyProfile.id)).resolves.toBe('super-secret'); + }); + + it('reloads a connection after its JSON file is edited and keeps the secret separate', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const { store } = await jsonStore(state, secrets); + await store.initialize(); + const profile = await store.save( + { + name: 'MySQL Local', + engine: 'mysql', + host: 'localhost', + port: 3306, + database: 'app', + user: 'root', + }, + 'mysql-secret', + ); + const filePath = store.connectionFile(profile.id); + const document = JSON.parse(await fs.readFile(filePath, 'utf8')); + document.host = 'mysql.internal'; + document.port = 3307; + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`); + + const reloaded = await store.reloadFile(filePath); + + expect(reloaded.host).toBe('mysql.internal'); + expect(reloaded.port).toBe(3307); + await expect(store.getPassword(profile.id)).resolves.toBe('mysql-secret'); + }); + + it('rejects passwords in JSON connection files', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const { store } = await jsonStore(state, secrets); + await store.initialize(); + const profile = await store.save( + { + name: 'Oracle', + engine: 'oracle', + host: 'oracle.internal', + port: 1521, + serviceName: 'ORCLPDB1', + user: 'reader', + }, + 'secret', + ); + const filePath = store.connectionFile(profile.id); + const document = JSON.parse(await fs.readFile(filePath, 'utf8')); + document.password = 'must-not-be-here'; + await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`); + + await expect(store.reloadFile(filePath)).rejects.toThrow(/do not store passwords/i); + }); + + it('removes the JSON file and secure password when deleting a connection', async () => { + const state = memoryMemento(); + const secrets = memorySecrets(); + const { store } = await jsonStore(state, secrets); + await store.initialize(); + const profile = await store.save( + { + name: 'SQL Server', + engine: 'sqlserver', + host: 'sql.internal', + port: 1433, + database: 'master', + user: 'sa', + }, + 'secret', + ); + const filePath = store.connectionFile(profile.id); + + await store.delete(profile.id); + + await expect(fs.access(filePath)).rejects.toThrow(); + expect(store.get(profile.id)).toBeUndefined(); + await expect(store.getPassword(profile.id)).resolves.toBe(''); + }); +}); diff --git a/src/ui/connectionForm.js b/src/ui/connectionForm.js index 20194e8..6f2e856 100644 --- a/src/ui/connectionForm.js +++ b/src/ui/connectionForm.js @@ -16,66 +16,20 @@ async function inputText(options) { }); } -async function inputNumber(options) { - const result = await inputText({ - ...options, - value: String(options.value ?? options.defaultValue ?? ''), - required: true, - validateInput: undefined, - }); - if (result === undefined) { - return undefined; - } - const number = Number(result); - if (!Number.isInteger(number) || number < (options.minimum ?? 0)) { - await vscode.window.showErrorMessage( - `${options.prompt}: enter a valid integer.`, - ); - return inputNumber(options); - } - return number; -} - -async function inputBoolean(title, label, value) { - const picked = await vscode.window.showQuickPick( - [ - { label: value ? 'Yes' : 'No', value }, - { label: value ? 'No' : 'Yes', value: !value }, - ], - { - title, - placeHolder: label, - ignoreFocusOut: true, - }, - ); - return picked?.value; -} - -async function chooseSqlitePath(existingProfile) { - if (existingProfile) { - return inputText({ - title: 'Simple DB — Edit SQLite', - prompt: 'Full SQLite file path', - value: existingProfile.filePath, - required: true, - }); - } - +async function chooseSqlitePath() { const mode = await vscode.window.showQuickPick( [ - { label: '$(folder-opened) Open Existing File', value: 'open' }, - { label: '$(new-file) Create New File', value: 'create' }, + { label: '$(folder-opened) Open Existing Database', value: 'open' }, + { label: '$(new-file) Create New Database', value: 'create' }, { label: '$(edit) Enter Path Manually', value: 'manual' }, ], { - title: 'Simple DB — SQLite File', - placeHolder: 'Choose how to specify the SQLite file', + title: 'Simple DB — SQLite Database', + placeHolder: 'Choose the SQLite database file', ignoreFocusOut: true, }, ); - if (!mode) { - return undefined; - } + if (!mode) return undefined; if (mode.value === 'open') { const selected = await vscode.window.showOpenDialog({ @@ -100,282 +54,128 @@ async function chooseSqlitePath(existingProfile) { } return inputText({ - title: 'Simple DB — SQLite File', + title: 'Simple DB — SQLite Database', prompt: 'Full SQLite file path', required: true, }); } -async function promptConnection(options = {}) { - const existing = options.existingProfile; - let engineId = existing?.engine || options.engineId; +function defaultProfile(engineId, name) { + const common = { + name, + engine: engineId, + connectTimeoutMs: 15000, + queryTimeoutMs: 300000, + }; + switch (engineId) { + case 'sqlite': + return { ...common, filePath: '', readOnly: false }; + case 'postgresql': + return { + ...common, + host: 'localhost', + port: 5432, + database: 'postgres', + user: 'postgres', + ssl: false, + trustServerCertificate: false, + }; + case 'mysql': + return { + ...common, + host: 'localhost', + port: 3306, + database: '', + user: 'root', + ssl: false, + trustServerCertificate: false, + }; + case 'sqlserver': + return { + ...common, + host: 'localhost', + port: 1433, + database: 'master', + user: 'sa', + instanceName: '', + encrypt: true, + trustServerCertificate: false, + }; + case 'oracle': + return { + ...common, + host: 'localhost', + port: 1521, + serviceName: '', + connectString: '', + user: '', + }; + default: + throw new Error(`Invalid database engine: ${engineId}`); + } +} + +async function promptConnection(options = {}) { + let engineId = options.engineId; if (!engineId) { const enginePick = await vscode.window.showQuickPick( DATABASE_ENGINES.map((engine) => ({ label: `$(database) ${engine.displayName}`, description: - engine.defaultPort === null ? 'Local file' : `Port ${engine.defaultPort}`, + engine.defaultPort === null ? 'Local file' : `Default port ${engine.defaultPort}`, value: engine.id, })), { - title: 'Simple DB — New Connection', + title: 'Simple DB — Create Connection', placeHolder: 'Select a database engine', ignoreFocusOut: true, }, ); - if (!enginePick) { - return null; - } + if (!enginePick) return null; engineId = enginePick.value; } const engine = getDatabaseEngine(engineId); - if (!engine) { - throw new Error(`Invalid database engine: ${engineId}`); - } + if (!engine) throw new Error(`Invalid database engine: ${engineId}`); const name = await inputText({ - title: `Simple DB — ${existing ? 'Edit' : 'New'} ${engine.displayName} Connection`, - prompt: 'Connection display name', - value: existing?.name || '', + title: `Simple DB — Create ${engine.displayName} Connection`, + prompt: 'Connection name', required: true, }); - if (name === undefined) { - return null; - } - - const profile = { - ...existing, - name: name.trim(), - engine: engineId, - }; + if (name === undefined) return null; - let password; + const profile = defaultProfile(engineId, name.trim()); if (engineId === 'sqlite') { - const sqlitePath = await chooseSqlitePath(existing); - if (sqlitePath === undefined) { - return null; - } - const readOnly = await inputBoolean( - 'Simple DB — SQLite', - 'Open in read-only mode?', - existing?.readOnly || false, - ); - if (readOnly === undefined) { - return null; - } - profile.filePath = sqlitePath; - profile.readOnly = readOnly; - } else if (engineId === 'oracle') { - const mode = await vscode.window.showQuickPick( - [ - { - label: 'Host + port + service/PDB', - value: 'service', - description: 'Standard Oracle Easy Connect format', - }, - { - label: 'Connect string / alias TNS', - value: 'connectString', - description: 'Use a connection string directly', - }, - ], - { - title: 'Simple DB — Oracle', - placeHolder: 'Oracle connection mode', - ignoreFocusOut: true, - }, - ); - if (!mode) { - return null; - } - - if (mode.value === 'service') { - profile.host = await inputText({ - title: 'Simple DB — Oracle', - prompt: 'Server / host', - value: existing?.host || 'localhost', - required: true, - }); - if (profile.host === undefined) return null; - profile.port = await inputNumber({ - title: 'Simple DB — Oracle', - prompt: 'Port', - value: existing?.port || 1521, - minimum: 1, - }); - if (profile.port === undefined) return null; - profile.serviceName = await inputText({ - title: 'Simple DB — Oracle', - prompt: 'Service / PDB', - value: existing?.serviceName || existing?.database || '', - required: true, - }); - if (profile.serviceName === undefined) return null; - profile.database = profile.serviceName; - profile.connectString = ''; - } else { - profile.connectString = await inputText({ - title: 'Simple DB — Oracle', - prompt: 'Connect string or TNS alias', - value: existing?.connectString || '', - required: true, - }); - if (profile.connectString === undefined) return null; - profile.host = existing?.host || ''; - profile.port = existing?.port || 1521; - profile.serviceName = existing?.serviceName || ''; - profile.database = existing?.database || ''; - } - - profile.user = await inputText({ - title: 'Simple DB — Oracle', - prompt: 'Username', - value: existing?.user || '', - required: true, - }); - if (profile.user === undefined) return null; - password = await inputText({ - title: 'Simple DB — Oracle', - prompt: existing ? 'Password (leave empty to keep the current one)' : 'Password', - password: true, - required: false, - }); - if (password === undefined) return null; - } else { - profile.host = await inputText({ - title: `Simple DB — ${engine.displayName}`, - prompt: 'Server / host', - value: existing?.host || 'localhost', - required: true, - }); - if (profile.host === undefined) return null; - - if (engineId === 'sqlserver') { - profile.instanceName = await inputText({ - title: 'Simple DB — SQL Server', - prompt: 'Instance (optional; leave empty to use the TCP port)', - value: existing?.instanceName || '', - }); - if (profile.instanceName === undefined) return null; - } - - profile.port = await inputNumber({ - title: `Simple DB — ${engine.displayName}`, - prompt: 'Port', - value: existing?.port || engine.defaultPort, - minimum: 1, - }); - if (profile.port === undefined) return null; - - profile.database = await inputText({ - title: `Simple DB — ${engine.displayName}`, - prompt: engineId === 'mysql' ? 'Initial database (optional)' : 'Initial database', - value: - existing?.database || - (engineId === 'postgresql' ? 'postgres' : engineId === 'sqlserver' ? 'master' : ''), - required: engineId !== 'mysql', - }); - if (profile.database === undefined) return null; - - profile.user = await inputText({ - title: `Simple DB — ${engine.displayName}`, - prompt: 'Username', - value: existing?.user || '', - required: true, - }); - if (profile.user === undefined) return null; - - password = await inputText({ - title: `Simple DB — ${engine.displayName}`, - prompt: existing ? 'Password (leave empty to keep the current one)' : 'Password', - password: true, - required: false, - }); - if (password === undefined) return null; - - if (engineId === 'sqlserver') { - profile.encrypt = await inputBoolean( - 'Simple DB — SQL Server', - 'Encrypt the connection?', - existing?.encrypt !== false, - ); - if (profile.encrypt === undefined) return null; - profile.trustServerCertificate = await inputBoolean( - 'Simple DB — SQL Server', - 'Trust the server certificate?', - existing?.trustServerCertificate || false, - ); - if (profile.trustServerCertificate === undefined) return null; - } else { - profile.ssl = await inputBoolean( - `Simple DB — ${engine.displayName}`, - 'Use SSL/TLS?', - existing?.ssl || false, - ); - if (profile.ssl === undefined) return null; - if (profile.ssl) { - profile.trustServerCertificate = await inputBoolean( - `Simple DB — ${engine.displayName}`, - 'Accept an unverified certificate?', - existing?.trustServerCertificate || false, - ); - if (profile.trustServerCertificate === undefined) return null; - } else { - profile.trustServerCertificate = false; - } - } + const filePath = await chooseSqlitePath(); + if (filePath === undefined) return null; + profile.filePath = filePath; + return { profile, password: undefined }; } - profile.connectTimeoutMs = await inputNumber({ - title: `Simple DB — ${engine.displayName}`, - prompt: 'Connection timeout (ms)', - value: existing?.connectTimeoutMs ?? 15000, - minimum: 1, + const password = await inputText({ + title: `Simple DB — ${engine.displayName} Password`, + prompt: 'Password (optional; stored securely and never written to the JSON file)', + password: true, + required: false, }); - if (profile.connectTimeoutMs === undefined) return null; + if (password === undefined) return null; + return { profile, password }; +} - profile.queryTimeoutMs = await inputNumber({ - title: `Simple DB — ${engine.displayName}`, - prompt: 'Query timeout (ms; 0 = no timeout)', - value: existing?.queryTimeoutMs ?? 300000, - minimum: 0, +async function promptPassword(profile) { + const engine = getDatabaseEngine(profile.engine); + return inputText({ + title: `Simple DB — Set Password for ${profile.name}`, + prompt: `${engine?.displayName || profile.engine} password (stored in VS Code SecretStorage)`, + password: true, + required: false, }); - if (profile.queryTimeoutMs === undefined) return null; - - const finish = await vscode.window.showQuickPick( - [ - { - label: '$(beaker) Test Connection and Save', - value: 'test', - description: 'Recommended', - }, - { - label: '$(save) Save without Testing', - value: 'save', - }, - ], - { - title: `Simple DB — ${profile.name}`, - placeHolder: 'How do you want to finish?', - ignoreFocusOut: true, - }, - ); - if (!finish) { - return null; - } - - const effectivePassword = - existing && password === '' ? options.existingPassword || '' : password || ''; - return { - profile, - password: existing && password === '' ? undefined : password, - effectivePassword, - testBeforeSave: finish.value === 'test', - }; } module.exports = { + defaultProfile, promptConnection, + promptPassword, }; diff --git a/src/views/connectionsTreeProvider.js b/src/views/connectionsTreeProvider.js index dafcf3a..4b34090 100644 --- a/src/views/connectionsTreeProvider.js +++ b/src/views/connectionsTreeProvider.js @@ -200,6 +200,7 @@ class ConnectionsTreeProvider { ); item.contextValue = 'simpleDb.message'; item.iconPath = new vscode.ThemeIcon(node.icon || 'info'); + if (node.command) item.command = node.command; return item; } } @@ -221,7 +222,16 @@ class ConnectionsTreeProvider { .sort((a, b) => a.name.localeCompare(b.name)); return profiles.length ? profiles.map((profile) => ({ kind: 'connection', profileId: profile.id })) - : [this._message('No connections configured', node.engineId)]; + : [ + { + ...this._message('Create connection…', node.engineId, 'add'), + command: { + command: 'simpleDb.addConnection', + title: 'Create Connection', + arguments: [{ kind: 'engine', engineId: node.engineId }], + }, + }, + ]; } if (node.kind === 'connection') {