dsh-db-studio 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Connection store for dsh-db-studio.
3
+ *
4
+ * Connections live in `<home>/dsh-db-studio/connections.json` (mode 0600,
5
+ * directory 0700). The browser never receives a password: `publicConnection()`
6
+ * strips it and reports `hasPassword` instead. Passwords are stored in the
7
+ * local file because a database client has to be able to reconnect without
8
+ * the user retyping them; the file permissions are the boundary, and the
9
+ * README says so plainly.
10
+ *
11
+ * @module dsh-db-studio/store
12
+ */
13
+
14
+ import { randomUUID } from 'node:crypto'
15
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+ import { absolutePath, studioHome } from './home.js'
18
+
19
+ const FILE_MODE = 0o600
20
+ const DIR_MODE = 0o700
21
+
22
+ /** Connection kinds this plugin knows how to open. */
23
+ export const CONNECTION_KINDS = ['sqlite', 'postgres', 'mysql']
24
+
25
+ /** Default TCP ports per kind. */
26
+ const DEFAULT_PORTS = { postgres: 5432, mysql: 3306 }
27
+
28
+ /** Trimmed string or undefined. */
29
+ function text(value) {
30
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
31
+ }
32
+
33
+ /** A boolean with a default. */
34
+ function bool(value, fallback) {
35
+ return typeof value === 'boolean' ? value : fallback
36
+ }
37
+
38
+ /** A port number, or the kind's default. */
39
+ function portOf(value, kind) {
40
+ const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)
41
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : DEFAULT_PORTS[kind]
42
+ }
43
+
44
+ /**
45
+ * Validate and normalize one connection.
46
+ *
47
+ * Throws a plain Error with a user-facing Chinese/English message when the
48
+ * shape cannot be stored. `existing` supplies the fields an update may omit
49
+ * (notably the password).
50
+ */
51
+ export function normalizeConnection(input, existing = null) {
52
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
53
+ throw new Error('连接配置格式不正确 / invalid connection object')
54
+ }
55
+ const kind = text(input.kind) ?? existing?.kind
56
+ if (!CONNECTION_KINDS.includes(kind)) {
57
+ throw new Error('连接类型必须是 sqlite、postgres 或 mysql / kind must be sqlite, postgres or mysql')
58
+ }
59
+ const now = new Date().toISOString()
60
+ const out = {
61
+ id: existing?.id ?? text(input.id) ?? randomUUID(),
62
+ name: text(input.name) ?? existing?.name ?? (kind === 'sqlite' ? 'SQLite 数据库' : kind === 'postgres' ? 'PostgreSQL' : 'MySQL'),
63
+ kind,
64
+ readOnly: bool(input.readOnly, existing?.readOnly ?? false),
65
+ createdAt: existing?.createdAt ?? now,
66
+ updatedAt: now,
67
+ }
68
+ if (typeof input.demo === 'boolean') out.demo = input.demo
69
+ else if (existing?.demo !== undefined) out.demo = existing.demo
70
+
71
+ if (kind === 'sqlite') {
72
+ const file = text(input.file) ?? existing?.file
73
+ if (!file) throw new Error('SQLite 连接需要文件路径 / a SQLite connection needs a file path')
74
+ out.file = absolutePath(file)
75
+ // SQLite credentials do not exist; keep the rest of the object clean.
76
+ return out
77
+ }
78
+
79
+ out.host = text(input.host) ?? existing?.host ?? '127.0.0.1'
80
+ out.port = portOf(input.port ?? existing?.port, kind)
81
+ out.database = text(input.database) ?? existing?.database
82
+ if (!out.database) throw new Error('请填写数据库名 / a database name is required')
83
+ out.user = text(input.user) ?? existing?.user ?? (kind === 'postgres' ? 'postgres' : 'root')
84
+ if (typeof input.ssl === 'boolean') out.ssl = input.ssl
85
+ else if (existing?.ssl !== undefined) out.ssl = existing.ssl
86
+ else out.ssl = false
87
+
88
+ if (typeof input.password === 'string' && input.password !== '') out.password = input.password
89
+ else if (existing?.password !== undefined) out.password = existing.password
90
+ if (input.clearPassword === true) delete out.password
91
+ return out
92
+ }
93
+
94
+ /** The browser-safe projection of a connection: no password, ever. */
95
+ export function publicConnection(connection) {
96
+ if (!connection) return null
97
+ const { password, ...rest } = connection
98
+ return { ...rest, hasPassword: typeof password === 'string' && password.length > 0 }
99
+ }
100
+
101
+ /** A human-readable one-line target for a connection. */
102
+ export function connectionTarget(connection) {
103
+ if (connection.kind === 'sqlite') return connection.file
104
+ return `${connection.user}@${connection.host}:${connection.port}/${connection.database}`
105
+ }
106
+
107
+ /** The connection file is shared by every reload of the plugin in this home. */
108
+ export class ConnectionStore {
109
+ constructor(home) {
110
+ this.home = home
111
+ this.dir = studioHome(home)
112
+ this.file = join(this.dir, 'connections.json')
113
+ this.connections = []
114
+ this.loaded = false
115
+ }
116
+
117
+ /** Read from disk. A missing or malformed file becomes an empty store. */
118
+ async load() {
119
+ if (this.loaded) return this
120
+ this.loaded = true
121
+ try {
122
+ const raw = await readFile(this.file, 'utf8')
123
+ const parsed = JSON.parse(raw)
124
+ if (Array.isArray(parsed?.connections)) {
125
+ this.connections = parsed.connections.filter((row) => row && typeof row === 'object' && row.id)
126
+ }
127
+ } catch (error) {
128
+ if (error?.code !== 'ENOENT') {
129
+ console.warn('dsh-db-studio: ignoring unreadable connections file —', error?.message ?? error)
130
+ }
131
+ }
132
+ return this
133
+ }
134
+
135
+ /** Persist atomically: write a sibling then rename into place. */
136
+ async save() {
137
+ await mkdir(this.dir, { recursive: true, mode: DIR_MODE })
138
+ const body = JSON.stringify({ version: 1, connections: this.connections }, null, 2) + '\n'
139
+ const temp = `${this.file}.${process.pid}.${Date.now()}.tmp`
140
+ await writeFile(temp, body, { mode: FILE_MODE })
141
+ await rename(temp, this.file)
142
+ // rename preserves the temp file's mode; chmod is belt-and-braces for a
143
+ // pre-existing file whose mode was widened outside this plugin.
144
+ await chmod(this.file, FILE_MODE).catch(() => {})
145
+ }
146
+
147
+ /** All connections, secrets redacted, in insertion order. */
148
+ list() {
149
+ return this.connections.map(publicConnection)
150
+ }
151
+
152
+ /** Raw connection by id (host-side use only). */
153
+ get(id) {
154
+ return this.connections.find((row) => row.id === id) ?? null
155
+ }
156
+
157
+ /** The first connection, used when an agent tool omits an id. */
158
+ first() {
159
+ return this.connections[0] ?? null
160
+ }
161
+
162
+ /** Add a validated connection and persist it. */
163
+ async add(input) {
164
+ const connection = normalizeConnection(input)
165
+ if (this.connections.some((row) => row.id === connection.id)) {
166
+ connection.id = randomUUID()
167
+ }
168
+ this.connections.push(connection)
169
+ await this.save()
170
+ return publicConnection(connection)
171
+ }
172
+
173
+ /** Patch an existing connection. `undefined` keeps a field; password is special. */
174
+ async update(id, patch) {
175
+ const index = this.connections.findIndex((row) => row.id === id)
176
+ if (index === -1) throw new Error('连接不存在 / connection not found')
177
+ const merged = normalizeConnection({ ...this.connections[index], ...patch }, this.connections[index])
178
+ merged.id = id
179
+ this.connections[index] = merged
180
+ await this.save()
181
+ return publicConnection(merged)
182
+ }
183
+
184
+ /** Remove a connection. The demo connection is recreated by `ensureDemo`. */
185
+ async remove(id) {
186
+ const before = this.connections.length
187
+ this.connections = this.connections.filter((row) => row.id !== id)
188
+ if (this.connections.length === before) throw new Error('连接不存在 / connection not found')
189
+ await this.save()
190
+ return true
191
+ }
192
+
193
+ /** Replace the whole list (used by demo seeding); persists immediately. */
194
+ async replaceAll(connections) {
195
+ this.connections = connections
196
+ await this.save()
197
+ }
198
+
199
+ /** Remove the store (tests). */
200
+ async destroy() {
201
+ await rm(this.dir, { recursive: true, force: true })
202
+ }
203
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "dsh-db-studio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.2",
4
+ "description": "Connect to databases in DeepSeek Harness (DSH), then query and edit data right in the conversation.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "dsh-plugin",
11
+ "database",
12
+ "sqlite",
13
+ "postgresql",
14
+ "mysql",
15
+ "conversation",
16
+ "ai-agent"
17
+ ],
18
+ "main": "./lib/index.js",
19
+ "exports": {
20
+ ".": "./lib/index.js",
21
+ "./client": "./lib/client.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "docs",
27
+ "cordis.patch.yml",
28
+ "README.md",
29
+ "README.en.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22.13",
34
+ "dsh": ">=0.2.1-rc.3"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-locale",
44
+ "@deepseek-ai/dsh-client-ui-layout",
45
+ "@deepseek-ai/dsh-client-ui-sidebar",
46
+ "@deepseek-ai/dsh-client-ui-commands"
47
+ ]
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "mysql2": "^3.24.6",
52
+ "pg": "^8.23.1"
53
+ },
54
+ "scripts": {
55
+ "test": "node --test test/*.test.js",
56
+ "check": "node --check lib/index.js && node --check lib/service.js && node --check lib/drivers.js && node --check lib/store.js && node --check lib/home.js && node --check lib/client.js"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/etShaw-zh/dsh-db-studio.git"
61
+ },
62
+ "author": "Jianjun Xiao <et_shaw@126.com>",
63
+ "homepage": "https://github.com/etShaw-zh/dsh-db-studio#readme",
64
+ "bugs": {
65
+ "url": "https://github.com/etShaw-zh/dsh-db-studio/issues"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "registry": "https://registry.npmjs.org/"
70
+ }
71
+ }