dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Model-facing database-access policy: the system-prompt section that keeps
|
|
2
|
+
// database work on the dbhub_* tools and out of shell-level clients.
|
|
3
|
+
//
|
|
4
|
+
// Gates asserted here:
|
|
5
|
+
// 1. the text states every clause the incident requires — the authorized
|
|
6
|
+
// route, the CLI/script ban, "an AGENTS.md recipe is not authorization",
|
|
7
|
+
// the read-only boundary, and the ask-the-user escape hatch;
|
|
8
|
+
// 2. COEXISTENCE: the authority claim is scoped to this plugin's own
|
|
9
|
+
// connections, a database this plugin does not cover is explicitly handed
|
|
10
|
+
// to the user's other plugin, and a configured read-only target may not be
|
|
11
|
+
// reached through another plugin either;
|
|
12
|
+
// 3. a DISABLED plugin, or a user who turned the announcement off,
|
|
13
|
+
// contributes nothing (an empty section is dropped), so the prompt can
|
|
14
|
+
// never claim dbhub is the only route while every call answers
|
|
15
|
+
// "插件已禁用";
|
|
16
|
+
// 4. registration targets the optional `systemPrompt` service, is owned by an
|
|
17
|
+
// effect (a reload re-runs apply, and a duplicate section name throws in
|
|
18
|
+
// one scope), and is a silent no-op where that service is absent
|
|
19
|
+
// (older dsh deployments);
|
|
20
|
+
// 5. the text stays inside its token budget and stays zero-knowledge
|
|
21
|
+
// (no DSN-looking string, no `{{variable}}` that assembly would reject).
|
|
22
|
+
|
|
23
|
+
import test from 'node:test'
|
|
24
|
+
import assert from 'node:assert/strict'
|
|
25
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-policy-'))
|
|
30
|
+
process.env.DSH_HOME = home
|
|
31
|
+
|
|
32
|
+
// Plain specifiers only: these are the exact module instances the plugin
|
|
33
|
+
// resolves, so `setEnabled`/`applyPatch` here reach the state the policy reads.
|
|
34
|
+
const state = await import('../lib/state.mjs')
|
|
35
|
+
const options = await import('../lib/options.mjs')
|
|
36
|
+
const policy = await import('../lib/policy.mjs')
|
|
37
|
+
|
|
38
|
+
test.after(() => {
|
|
39
|
+
rmSync(home, { recursive: true, force: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('the policy text states every clause the incident requires', () => {
|
|
43
|
+
state.setEnabled(true)
|
|
44
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
45
|
+
const text = policy.dbhubPolicyText()
|
|
46
|
+
assert.match(text, /dbhub_\* 工具/, 'the authorized route')
|
|
47
|
+
assert.match(text, /唯一授权途径/, 'the authority claim that outranks a workspace recipe')
|
|
48
|
+
assert.match(text, /psql/, 'the shell-level bypass, named')
|
|
49
|
+
assert.match(text, /AGENTS\.md/, 'workspace recipes are explicitly not authorization')
|
|
50
|
+
assert.match(text, /READONLY_VIOLATION/, 'the read-only boundary')
|
|
51
|
+
assert.match(text, /请用户授权/, 'the escape hatch: ask the user, do not improvise')
|
|
52
|
+
assert.match(text, /不要自行绕过/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('the authority claim is scoped, and other plugins keep the databases this one does not cover', () => {
|
|
56
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
57
|
+
const text = policy.dbhubPolicyText()
|
|
58
|
+
// Scoped to THIS plugin's connections — not "every database on this machine".
|
|
59
|
+
assert.match(text, /本插件已配置的连接/)
|
|
60
|
+
assert.doesNotMatch(text, /本机唯一授权途径/)
|
|
61
|
+
// A target this plugin cannot serve goes to the user's other plugin...
|
|
62
|
+
assert.match(text, /不支持或未配置的库/)
|
|
63
|
+
assert.match(text, /其它数据库插件是允许的/)
|
|
64
|
+
// ...while a configured read-only target stays closed on every route.
|
|
65
|
+
assert.match(text, /包括换用别的数据库插件/)
|
|
66
|
+
// Overlapping targets have a deterministic tie-break plus a fallback question.
|
|
67
|
+
assert.match(text, /默认用本插件的 source/)
|
|
68
|
+
assert.match(text, /先问用户/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a disabled plugin, or the announcement switched off, contributes an EMPTY section', () => {
|
|
72
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
73
|
+
state.setEnabled(false)
|
|
74
|
+
assert.equal(policy.dbhubPolicyText(), '')
|
|
75
|
+
state.setEnabled(true)
|
|
76
|
+
assert.notEqual(policy.dbhubPolicyText(), '')
|
|
77
|
+
|
|
78
|
+
options.applyPatch({ announceAccessPolicy: false })
|
|
79
|
+
assert.equal(policy.dbhubPolicyText(), '', 'the user turned the announcement off')
|
|
80
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
81
|
+
assert.notEqual(policy.dbhubPolicyText(), '')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('the policy text stays inside its budget and its zero-knowledge frame', () => {
|
|
85
|
+
state.setEnabled(true)
|
|
86
|
+
options.applyPatch({ announceAccessPolicy: true })
|
|
87
|
+
const text = policy.dbhubPolicyText()
|
|
88
|
+
assert.ok(text.length <= 420, 'token budget: ' + String(text.length) + ' chars')
|
|
89
|
+
// A real-looking DSN would leak a credential into the system prompt.
|
|
90
|
+
assert.doesNotMatch(text, /[a-z]+:\/\/[^/\s]*:[^@\s]+@/)
|
|
91
|
+
// `{{name}}` is interpolated strictly by prompt assembly: an unregistered
|
|
92
|
+
// name would make every assembly throw, i.e. the model would get no prompt.
|
|
93
|
+
assert.doesNotMatch(text, /\{\{[a-z_]+\}\}/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('registration injects the optional systemPrompt service and owns the section', () => {
|
|
97
|
+
const injected = []
|
|
98
|
+
const sections = []
|
|
99
|
+
const effects = []
|
|
100
|
+
const ctx = {
|
|
101
|
+
inject(deps, cb) {
|
|
102
|
+
injected.push(deps)
|
|
103
|
+
cb({
|
|
104
|
+
systemPrompt: {
|
|
105
|
+
section(def) {
|
|
106
|
+
sections.push(def)
|
|
107
|
+
return () => sections.splice(sections.indexOf(def), 1)
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
effect(fn, label) {
|
|
111
|
+
effects.push(label)
|
|
112
|
+
return fn()
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
policy.registerDbhubPolicy(ctx)
|
|
118
|
+
assert.deepEqual(injected, [['systemPrompt']])
|
|
119
|
+
assert.equal(sections.length, 1)
|
|
120
|
+
assert.equal(sections[0].name, policy.POLICY_SECTION)
|
|
121
|
+
assert.equal(sections[0].order, policy.POLICY_ORDER)
|
|
122
|
+
assert.equal(typeof sections[0].text, 'function', 'the text is a provider, not a snapshot')
|
|
123
|
+
assert.equal(sections[0].text(), policy.dbhubPolicyText())
|
|
124
|
+
// The section is effect-owned, so a re-run of apply() disposes it instead of
|
|
125
|
+
// throwing on the duplicate name.
|
|
126
|
+
assert.deepEqual(effects, ['dsh-dbhub-live: access policy'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a deployment without systemPrompt registers nothing and does not throw', () => {
|
|
130
|
+
const never = { inject() { /* the callback never fires without the service */ } }
|
|
131
|
+
assert.doesNotThrow(() => policy.registerDbhubPolicy(never))
|
|
132
|
+
})
|
package/test/row-cache.test.mjs
CHANGED
|
@@ -1,138 +1,237 @@
|
|
|
1
|
-
// Unit tests for the local row patch behind the card's instant feedback
|
|
2
|
-
// (lib/mcp.mjs). Test isolation: mcp.mjs pulls in config.mjs, which derives the
|
|
3
|
-
// storage directory from DSH_HOME at import time, so point it at a temp dir
|
|
4
|
-
// BEFORE importing.
|
|
5
|
-
|
|
6
|
-
import test from 'node:test'
|
|
7
|
-
import assert from 'node:assert/strict'
|
|
8
|
-
import { mkdtempSync, rmSync } from 'node:fs'
|
|
9
|
-
import { tmpdir } from 'node:os'
|
|
10
|
-
import { join } from 'node:path'
|
|
11
|
-
|
|
12
|
-
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-rowcache-'))
|
|
13
|
-
process.env.DSH_HOME = home
|
|
14
|
-
|
|
15
|
-
const mcp = await import('../lib/mcp.mjs')
|
|
16
|
-
|
|
17
|
-
test.after(() => {
|
|
18
|
-
rmSync(home, { recursive: true, force: true })
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const row = (title, wsPath, env, dsn, extra = {}) =>
|
|
22
|
-
Object.assign({ wsPath, title, env, dsn, source: 'persisted(user)', persisted: true }, extra)
|
|
23
|
-
|
|
24
|
-
const base = () => [
|
|
25
|
-
row('app', 'D:/work/app', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
26
|
-
row('app', 'D:/work/app', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
27
|
-
row('docs', 'D:/work/docs', 'default', 'sqlite://127.0.0.1:0/docs', { source: '工作目录 .env', persisted: false }),
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
test('applyRowPatch adds a row without touching the input array', () => {
|
|
31
|
-
const rows = base()
|
|
32
|
-
const next = mcp.applyRowPatch(rows, {
|
|
33
|
-
op: 'add', path: 'D:/work/new', title: 'new', env: 'default', dsn: 'postgres://127.0.0.1:5432/new',
|
|
34
|
-
})
|
|
35
|
-
assert.equal(rows.length, 3, 'input stays untouched')
|
|
36
|
-
assert.equal(next.length, 4)
|
|
37
|
-
const added = next[next.length - 1]
|
|
38
|
-
assert.deepEqual(
|
|
39
|
-
{ wsPath: added.wsPath, title: added.title, env: added.env, source: added.source, persisted: added.persisted },
|
|
40
|
-
{ wsPath: 'D:/work/new', title: 'new', env: 'default', source: 'persisted(user)', persisted: true },
|
|
41
|
-
)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('applyRowPatch replaces the same workspace+environment', () => {
|
|
45
|
-
const next = mcp.applyRowPatch(base(), {
|
|
46
|
-
op: 'add', path: 'D:/work/app', env: 'prod', dsn: 'mysql://127.0.0.1:3399/other',
|
|
47
|
-
})
|
|
48
|
-
assert.equal(next.length, 3, 'no duplicate row appears')
|
|
49
|
-
const prod = next.find((r) => r.wsPath === 'D:/work/app' && r.env === 'prod')
|
|
50
|
-
assert.equal(prod.dsn, 'mysql://127.0.0.1:3399/other')
|
|
51
|
-
assert.equal(prod.title, 'app', 'the known workspace title is kept')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('applyRowPatch drops a removed row and keeps the other workspaces', () => {
|
|
55
|
-
const next = mcp.applyRowPatch(base(), { op: 'remove', path: 'D:/work/app', env: 'prod' })
|
|
56
|
-
assert.equal(next.length, 2)
|
|
57
|
-
assert.equal(next.some((r) => r.wsPath === 'D:/work/app' && r.env === 'prod'), false)
|
|
58
|
-
assert.equal(next.filter((r) => r.wsPath === 'D:/work/app').length, 1)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('applyRowPatch moves a renamed row and never leaves the old name behind', () => {
|
|
62
|
-
const next = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'prod', env: 'staging' })
|
|
63
|
-
assert.equal(next.length, 3)
|
|
64
|
-
assert.equal(next.some((r) => r.env === 'prod' && r.wsPath === 'D:/work/app'), false)
|
|
65
|
-
const moved = next.find((r) => r.env === 'staging')
|
|
66
|
-
assert.equal(moved.dsn, 'mysql://127.0.0.1:3307/app', 'the connection travels with the name')
|
|
67
|
-
assert.equal(moved.title, 'app')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('applyRowPatch renaming onto an existing name keeps exactly one row', () => {
|
|
71
|
-
const next = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'default', env: 'prod' })
|
|
72
|
-
assert.equal(next.filter((r) => r.wsPath === 'D:/work/app').length, 1)
|
|
73
|
-
assert.equal(next.find((r) => r.wsPath === 'D:/work/app').env, 'prod')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('applying the same rename twice is a no-op (a walk may already have done it)', () => {
|
|
77
|
-
const once = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'default', env: 'staging' })
|
|
78
|
-
const twice = mcp.applyRowPatch(once, { op: 'rename', path: 'D:/work/app', from: 'default', env: 'staging' })
|
|
79
|
-
assert.deepEqual(twice, once, 'the row must not be dropped by a repeated patch')
|
|
80
|
-
assert.equal(twice.filter((r) => r.wsPath === 'D:/work/app').length, 2)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('the row mirror re-applies a patch that landed while a walk was running', () => {
|
|
84
|
-
// The live failure this guards: a walk started BEFORE the write finished AFTER
|
|
85
|
-
// it and published a list without the row the card had just shown.
|
|
86
|
-
const mirror = mcp.createRowMirror()
|
|
87
|
-
const walkStarted = mirror.beginWalk() // walk begins with nothing pending
|
|
88
|
-
assert.equal(walkStarted, 0)
|
|
89
|
-
mirror.patch({ op: 'add', path: 'D:/work/app', title: 'app', env: 'pro', dsn: 'mysql://127.0.0.1:3308/app' })
|
|
90
|
-
assert.equal(mirror.pendingCount(), 1)
|
|
91
|
-
// the walk lands: it re-applies the late patch, so the row survives
|
|
92
|
-
const summaries = mirror.endWalk(walkStarted)
|
|
93
|
-
assert.equal(summaries.some((s) => s.env === 'pro' && s.path === 'D:/work/app'), true)
|
|
94
|
-
assert.equal(mirror.pendingCount(), 1, 'still held until a walk that started after it lands')
|
|
95
|
-
// the follow-up walk started after the patch, so the patch is released
|
|
96
|
-
const after = mirror.endWalk(mirror.beginWalk())
|
|
97
|
-
assert.equal(after.some((s) => s.env === 'pro'), true)
|
|
98
|
-
assert.equal(mirror.pendingCount(), 0)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('a walk that lands without the new row cannot erase it', async () => {
|
|
102
|
-
// Reproduces the reported flicker end to end at module level: a walk started
|
|
103
|
-
// BEFORE the card's write finishes AFTER it and replaces the cache with its own,
|
|
104
|
-
// older row set (the card showed the new row, then lost it again).
|
|
105
|
-
const mirror = mcp.createRowMirror()
|
|
106
|
-
const startedWith = mirror.beginWalk() // walk begins with nothing pending
|
|
107
|
-
const shown = mirror.patch({ op: 'add', path: 'D:/work/w', title: 'w', env: 'pro', dsn: 'mysql://127.0.0.1:3308/w' })
|
|
108
|
-
assert.equal(shown.some((s) => s.env === 'pro'), true, 'the write is visible immediately')
|
|
109
|
-
await mcp.collectSources({ get: () => undefined }, undefined) // the stale walk clobbers the cache
|
|
110
|
-
const after = mirror.endWalk(startedWith)
|
|
111
|
-
assert.equal(after.some((s) => s.env === 'pro' && s.path === 'D:/work/w'), true, 'the patch survives the walk')
|
|
112
|
-
assert.equal(mirror.pendingCount(), 1, 'held until a walk that started after it lands')
|
|
113
|
-
const settled = mirror.endWalk(mirror.beginWalk())
|
|
114
|
-
assert.equal(settled.some((s) => s.env === 'pro'), true)
|
|
115
|
-
assert.equal(mirror.pendingCount(), 0)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('a patched row still summarizes to metadata only (no DSN, no credentials)', () => {
|
|
119
|
-
const rows = mcp.applyRowPatch([], {
|
|
120
|
-
op: 'add', path: 'D:/work/app', title: 'app', env: 'prod',
|
|
121
|
-
dsn: 'mysql://user:CHANGE_ME@127.0.0.1:3307/app',
|
|
122
|
-
})
|
|
123
|
-
const summaries = mcp.summarizeRows(rows)
|
|
124
|
-
assert.equal(summaries.length, 1)
|
|
125
|
-
assert.deepEqual(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
assert.equal(
|
|
131
|
-
assert.equal(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
assert.
|
|
136
|
-
assert.
|
|
137
|
-
|
|
138
|
-
|
|
1
|
+
// Unit tests for the local row patch behind the card's instant feedback
|
|
2
|
+
// (lib/mcp.mjs). Test isolation: mcp.mjs pulls in config.mjs, which derives the
|
|
3
|
+
// storage directory from DSH_HOME at import time, so point it at a temp dir
|
|
4
|
+
// BEFORE importing.
|
|
5
|
+
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-rowcache-'))
|
|
13
|
+
process.env.DSH_HOME = home
|
|
14
|
+
|
|
15
|
+
const mcp = await import('../lib/mcp.mjs')
|
|
16
|
+
|
|
17
|
+
test.after(() => {
|
|
18
|
+
rmSync(home, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const row = (title, wsPath, env, dsn, extra = {}) =>
|
|
22
|
+
Object.assign({ wsPath, title, env, dsn, source: 'persisted(user)', persisted: true }, extra)
|
|
23
|
+
|
|
24
|
+
const base = () => [
|
|
25
|
+
row('app', 'D:/work/app', 'default', 'mysql://127.0.0.1:3306/app'),
|
|
26
|
+
row('app', 'D:/work/app', 'prod', 'mysql://127.0.0.1:3307/app'),
|
|
27
|
+
row('docs', 'D:/work/docs', 'default', 'sqlite://127.0.0.1:0/docs', { source: '工作目录 .env', persisted: false }),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
test('applyRowPatch adds a row without touching the input array', () => {
|
|
31
|
+
const rows = base()
|
|
32
|
+
const next = mcp.applyRowPatch(rows, {
|
|
33
|
+
op: 'add', path: 'D:/work/new', title: 'new', env: 'default', dsn: 'postgres://127.0.0.1:5432/new',
|
|
34
|
+
})
|
|
35
|
+
assert.equal(rows.length, 3, 'input stays untouched')
|
|
36
|
+
assert.equal(next.length, 4)
|
|
37
|
+
const added = next[next.length - 1]
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
{ wsPath: added.wsPath, title: added.title, env: added.env, source: added.source, persisted: added.persisted },
|
|
40
|
+
{ wsPath: 'D:/work/new', title: 'new', env: 'default', source: 'persisted(user)', persisted: true },
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('applyRowPatch replaces the same workspace+environment', () => {
|
|
45
|
+
const next = mcp.applyRowPatch(base(), {
|
|
46
|
+
op: 'add', path: 'D:/work/app', env: 'prod', dsn: 'mysql://127.0.0.1:3399/other',
|
|
47
|
+
})
|
|
48
|
+
assert.equal(next.length, 3, 'no duplicate row appears')
|
|
49
|
+
const prod = next.find((r) => r.wsPath === 'D:/work/app' && r.env === 'prod')
|
|
50
|
+
assert.equal(prod.dsn, 'mysql://127.0.0.1:3399/other')
|
|
51
|
+
assert.equal(prod.title, 'app', 'the known workspace title is kept')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('applyRowPatch drops a removed row and keeps the other workspaces', () => {
|
|
55
|
+
const next = mcp.applyRowPatch(base(), { op: 'remove', path: 'D:/work/app', env: 'prod' })
|
|
56
|
+
assert.equal(next.length, 2)
|
|
57
|
+
assert.equal(next.some((r) => r.wsPath === 'D:/work/app' && r.env === 'prod'), false)
|
|
58
|
+
assert.equal(next.filter((r) => r.wsPath === 'D:/work/app').length, 1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('applyRowPatch moves a renamed row and never leaves the old name behind', () => {
|
|
62
|
+
const next = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'prod', env: 'staging' })
|
|
63
|
+
assert.equal(next.length, 3)
|
|
64
|
+
assert.equal(next.some((r) => r.env === 'prod' && r.wsPath === 'D:/work/app'), false)
|
|
65
|
+
const moved = next.find((r) => r.env === 'staging')
|
|
66
|
+
assert.equal(moved.dsn, 'mysql://127.0.0.1:3307/app', 'the connection travels with the name')
|
|
67
|
+
assert.equal(moved.title, 'app')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('applyRowPatch renaming onto an existing name keeps exactly one row', () => {
|
|
71
|
+
const next = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'default', env: 'prod' })
|
|
72
|
+
assert.equal(next.filter((r) => r.wsPath === 'D:/work/app').length, 1)
|
|
73
|
+
assert.equal(next.find((r) => r.wsPath === 'D:/work/app').env, 'prod')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('applying the same rename twice is a no-op (a walk may already have done it)', () => {
|
|
77
|
+
const once = mcp.applyRowPatch(base(), { op: 'rename', path: 'D:/work/app', from: 'default', env: 'staging' })
|
|
78
|
+
const twice = mcp.applyRowPatch(once, { op: 'rename', path: 'D:/work/app', from: 'default', env: 'staging' })
|
|
79
|
+
assert.deepEqual(twice, once, 'the row must not be dropped by a repeated patch')
|
|
80
|
+
assert.equal(twice.filter((r) => r.wsPath === 'D:/work/app').length, 2)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('the row mirror re-applies a patch that landed while a walk was running', () => {
|
|
84
|
+
// The live failure this guards: a walk started BEFORE the write finished AFTER
|
|
85
|
+
// it and published a list without the row the card had just shown.
|
|
86
|
+
const mirror = mcp.createRowMirror()
|
|
87
|
+
const walkStarted = mirror.beginWalk() // walk begins with nothing pending
|
|
88
|
+
assert.equal(walkStarted, 0)
|
|
89
|
+
mirror.patch({ op: 'add', path: 'D:/work/app', title: 'app', env: 'pro', dsn: 'mysql://127.0.0.1:3308/app' })
|
|
90
|
+
assert.equal(mirror.pendingCount(), 1)
|
|
91
|
+
// the walk lands: it re-applies the late patch, so the row survives
|
|
92
|
+
const summaries = mirror.endWalk(walkStarted)
|
|
93
|
+
assert.equal(summaries.some((s) => s.env === 'pro' && s.path === 'D:/work/app'), true)
|
|
94
|
+
assert.equal(mirror.pendingCount(), 1, 'still held until a walk that started after it lands')
|
|
95
|
+
// the follow-up walk started after the patch, so the patch is released
|
|
96
|
+
const after = mirror.endWalk(mirror.beginWalk())
|
|
97
|
+
assert.equal(after.some((s) => s.env === 'pro'), true)
|
|
98
|
+
assert.equal(mirror.pendingCount(), 0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a walk that lands without the new row cannot erase it', async () => {
|
|
102
|
+
// Reproduces the reported flicker end to end at module level: a walk started
|
|
103
|
+
// BEFORE the card's write finishes AFTER it and replaces the cache with its own,
|
|
104
|
+
// older row set (the card showed the new row, then lost it again).
|
|
105
|
+
const mirror = mcp.createRowMirror()
|
|
106
|
+
const startedWith = mirror.beginWalk() // walk begins with nothing pending
|
|
107
|
+
const shown = mirror.patch({ op: 'add', path: 'D:/work/w', title: 'w', env: 'pro', dsn: 'mysql://127.0.0.1:3308/w' })
|
|
108
|
+
assert.equal(shown.some((s) => s.env === 'pro'), true, 'the write is visible immediately')
|
|
109
|
+
await mcp.collectSources({ get: () => undefined }, undefined) // the stale walk clobbers the cache
|
|
110
|
+
const after = mirror.endWalk(startedWith)
|
|
111
|
+
assert.equal(after.some((s) => s.env === 'pro' && s.path === 'D:/work/w'), true, 'the patch survives the walk')
|
|
112
|
+
assert.equal(mirror.pendingCount(), 1, 'held until a walk that started after it lands')
|
|
113
|
+
const settled = mirror.endWalk(mirror.beginWalk())
|
|
114
|
+
assert.equal(settled.some((s) => s.env === 'pro'), true)
|
|
115
|
+
assert.equal(mirror.pendingCount(), 0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a patched row still summarizes to metadata only (no DSN, no credentials)', () => {
|
|
119
|
+
const rows = mcp.applyRowPatch([], {
|
|
120
|
+
op: 'add', path: 'D:/work/app', title: 'app', env: 'prod',
|
|
121
|
+
dsn: 'mysql://user:CHANGE_ME@127.0.0.1:3307/app',
|
|
122
|
+
})
|
|
123
|
+
const summaries = mcp.summarizeRows(rows)
|
|
124
|
+
assert.equal(summaries.length, 1)
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
Object.keys(summaries[0]).sort(),
|
|
127
|
+
['conn', 'env', 'path', 'persisted', 'ro', 'source', 'ssh', 'srcId', 'title'].sort(),
|
|
128
|
+
)
|
|
129
|
+
const text = JSON.stringify(summaries)
|
|
130
|
+
assert.equal(text.includes('CHANGE_ME'), false)
|
|
131
|
+
assert.equal(text.includes('mysql://user'), false, 'no credentialed URL shape survives')
|
|
132
|
+
assert.equal(text.includes('"dsn"'), false)
|
|
133
|
+
assert.equal(summaries[0].conn, 'mysql://127.0.0.1:3307/app')
|
|
134
|
+
assert.equal(summaries[0].source, 'persisted(user)', 'provenance stays, credentials do not')
|
|
135
|
+
assert.equal(summaries[0].ro, false)
|
|
136
|
+
assert.equal(summaries[0].ssh, null)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('applyRowPatch carries the read-only flag and the secret-free tunnel metadata', () => {
|
|
140
|
+
const rows = mcp.applyRowPatch([], {
|
|
141
|
+
op: 'add', path: 'D:/work/app', title: 'app', env: 'prod',
|
|
142
|
+
dsn: 'mysql://127.0.0.1:3307/app', ro: true,
|
|
143
|
+
ssh: { host: 'bastion.example.com', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' },
|
|
144
|
+
})
|
|
145
|
+
const s = mcp.summarizeRows(rows)[0]
|
|
146
|
+
assert.equal(rows[0].ro, true)
|
|
147
|
+
assert.equal(s.ro, true)
|
|
148
|
+
assert.equal(s.ssh.authKind, 'password')
|
|
149
|
+
assert.equal(s.ssh.hasPassword, true)
|
|
150
|
+
assert.equal(JSON.stringify(s).includes('CHANGE_ME'), false, 'the tunnel secret never reaches the mirror')
|
|
151
|
+
assert.equal(JSON.stringify(s).includes('bastion.example.com'), true, 'the host is metadata, not a secret')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('the options patch flips a row in place and is idempotent', () => {
|
|
155
|
+
const rows = mcp.applyRowPatch(base(), {
|
|
156
|
+
op: 'options', path: 'D:/work/app', env: 'prod', ro: true, ssh: null,
|
|
157
|
+
})
|
|
158
|
+
assert.equal(rows.length, 3, 'no row is added or removed')
|
|
159
|
+
const prod = rows.find((r) => r.wsPath === 'D:/work/app' && r.env === 'prod')
|
|
160
|
+
assert.equal(prod.ro, true)
|
|
161
|
+
assert.equal(prod.dsn, 'mysql://127.0.0.1:3307/app', 'the connection is untouched')
|
|
162
|
+
const twice = mcp.applyRowPatch(rows, { op: 'options', path: 'D:/work/app', env: 'prod', ro: true, ssh: null })
|
|
163
|
+
assert.deepEqual(twice, rows)
|
|
164
|
+
// A patch for a row that is not there changes nothing (no phantom row).
|
|
165
|
+
assert.deepEqual(mcp.applyRowPatch(rows, { op: 'options', path: 'D:/work/none', env: 'prod', ro: true }), rows)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('cachedRows/cachedRow expose the host-side rows the card is showing', async () => {
|
|
169
|
+
// Earlier tests in this file already walked once, so the cache is warm; what
|
|
170
|
+
// matters is that the accessors track the cache AND the patch layer.
|
|
171
|
+
assert.ok(Array.isArray(mcp.cachedRows()))
|
|
172
|
+
const mirror = mcp.createRowMirror()
|
|
173
|
+
mirror.patch({ op: 'add', path: 'D:/work/app', title: 'app', env: 'default', dsn: 'mysql://127.0.0.1:3306/app' })
|
|
174
|
+
const hit = mcp.cachedRow('D:/work/app', 'default')
|
|
175
|
+
assert.ok(hit, 'a patched row is visible through cachedRow')
|
|
176
|
+
assert.equal(hit.dsn, 'mysql://127.0.0.1:3306/app')
|
|
177
|
+
assert.equal(mcp.cachedRows().filter((r) => r.env === 'default').length, 1)
|
|
178
|
+
// A missing row is undefined, never a throw.
|
|
179
|
+
assert.equal(mcp.cachedRow('D:/work/none', 'prod'), undefined)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('concurrent collectSources calls share ONE walk', async () => {
|
|
183
|
+
// A walk spawns `mise env` per workspace without a persisted default; the card
|
|
184
|
+
// publish, a model list and a connection test can all land in the same second.
|
|
185
|
+
const { resetAutoDsnCache } = await import('../lib/config.mjs')
|
|
186
|
+
resetAutoDsnCache()
|
|
187
|
+
let spawns = 0
|
|
188
|
+
const subprocess = {
|
|
189
|
+
resolveExecutable: async () => '/fake/mise',
|
|
190
|
+
spawn: () => {
|
|
191
|
+
spawns += 1
|
|
192
|
+
return {
|
|
193
|
+
stdout: { on: () => {} },
|
|
194
|
+
done: Promise.resolve({ exitCode: 1 }),
|
|
195
|
+
terminate: () => {},
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
const ctx = {
|
|
200
|
+
get: (name) => (name === 'fs' ? { resolve: async () => { throw new Error('no .env') } } : undefined),
|
|
201
|
+
}
|
|
202
|
+
const [a, b] = await Promise.all([
|
|
203
|
+
mcp.collectSources(ctx, subprocess),
|
|
204
|
+
mcp.collectSources(ctx, subprocess),
|
|
205
|
+
])
|
|
206
|
+
assert.equal(a, b, 'both callers receive the same walk result')
|
|
207
|
+
assert.equal(spawns, 1, 'the in-flight walk was shared, not duplicated')
|
|
208
|
+
// A deliberate rescan bypasses the in-flight handle — and, once the short
|
|
209
|
+
// auto-discovery cache is cleared, really spawns again.
|
|
210
|
+
resetAutoDsnCache()
|
|
211
|
+
await mcp.collectSources(ctx, subprocess, { force: true })
|
|
212
|
+
assert.equal(spawns, 2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('resolveTestDsn prefers the store, then the cached row, then asks for a walk', () => {
|
|
216
|
+
const storeRow = { wsPath: 'D:/work/app', env: 'default', dsn: 'mysql://127.0.0.1:3306/store', ro: true, ssh: null }
|
|
217
|
+
const cacheRow = { wsPath: 'D:/work/app', env: 'default', dsn: 'mysql://127.0.0.1:3306/store', ro: false, ssh: null }
|
|
218
|
+
const fromStore = mcp.resolveTestDsn({ wsPath: 'D:/work/app', env: 'default', storeRows: [storeRow], cachedRows: [cacheRow], hasCache: true })
|
|
219
|
+
assert.equal(fromStore.from, 'store')
|
|
220
|
+
assert.equal(fromStore.dsn, storeRow.dsn)
|
|
221
|
+
assert.equal(fromStore.ro, true, 'the authoritative row wins, options included')
|
|
222
|
+
const fromCache = mcp.resolveTestDsn({ wsPath: 'D:/work/app', env: 'default', storeRows: [], cachedRows: [cacheRow], hasCache: true })
|
|
223
|
+
assert.equal(fromCache.from, 'cache')
|
|
224
|
+
assert.equal(fromCache.dsn, storeRow.dsn, 'all three tiers describe the same target')
|
|
225
|
+
// Auto-discovered and never walked: the caller MUST walk (a cold cache cannot
|
|
226
|
+
// prove a connection does not exist).
|
|
227
|
+
assert.equal(mcp.resolveTestDsn({ wsPath: 'D:/work/app', env: 'default', storeRows: [], cachedRows: [], hasCache: false }), null)
|
|
228
|
+
// Environment names are normalized before matching (mirrors the Host).
|
|
229
|
+
const spaced = mcp.resolveTestDsn({ wsPath: 'D:/work/app', env: ' ' , storeRows: [storeRow], cachedRows: [], hasCache: false })
|
|
230
|
+
assert.equal(spaced.from, 'store')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('applyRowPatch tolerates junk input', () => {
|
|
234
|
+
assert.deepEqual(mcp.applyRowPatch(undefined, { op: 'remove', path: 'x', env: 'y' }), [])
|
|
235
|
+
assert.deepEqual(mcp.applyRowPatch(base(), null), base())
|
|
236
|
+
assert.deepEqual(mcp.applyRowPatch(base(), { op: 'unknown' }), base(), 'an unknown op adds nothing')
|
|
237
|
+
})
|