dsh-ssh-workbench 0.0.0-stage → 1.7.0

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +178 -2
  5. package/SECURITY.md +27 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +146 -0
  8. package/docs/execution-model.md +83 -0
  9. package/docs/images/chat-at-resource.png +0 -0
  10. package/docs/images/plugin.png +0 -0
  11. package/docs/images/remote-workbench.png +0 -0
  12. package/docs/images/workbench.png +0 -0
  13. package/docs/quickstart.md +106 -0
  14. package/lib/client.js +4991 -0
  15. package/lib/core/activity.js +88 -0
  16. package/lib/core/background.js +69 -0
  17. package/lib/core/broker.js +802 -0
  18. package/lib/core/dialect.js +28 -0
  19. package/lib/core/errors.js +138 -0
  20. package/lib/core/execution-stats.js +88 -0
  21. package/lib/core/gate-pwsh.js +88 -0
  22. package/lib/core/gate.js +572 -0
  23. package/lib/core/guard.js +123 -0
  24. package/lib/core/known-hosts.js +74 -0
  25. package/lib/core/live-executions.js +101 -0
  26. package/lib/core/local-file-api.js +174 -0
  27. package/lib/core/localfs.js +381 -0
  28. package/lib/core/managed-context.js +276 -0
  29. package/lib/core/mentions.js +216 -0
  30. package/lib/core/paths.js +104 -0
  31. package/lib/core/posix-quote.js +4 -0
  32. package/lib/core/posix-shell.js +89 -0
  33. package/lib/core/prefs.js +38 -0
  34. package/lib/core/remote-exec-tool.js +394 -0
  35. package/lib/core/remote-file-tools.js +559 -0
  36. package/lib/core/remote-shell-tool.js +310 -0
  37. package/lib/core/secrets.js +95 -0
  38. package/lib/core/server-ref.js +61 -0
  39. package/lib/core/shell-adapters.js +48 -0
  40. package/lib/core/ssh.js +761 -0
  41. package/lib/core/store.js +83 -0
  42. package/lib/core/terminals.js +299 -0
  43. package/lib/core/win32-adapter.js +141 -0
  44. package/lib/index.js +409 -0
  45. package/lib/routes.js +759 -0
  46. package/locale/en.json +6 -0
  47. package/locale/zh.json +6 -0
  48. package/package.json +89 -4
  49. package/vendor/NOTICE.md +50 -0
  50. package/vendor/XTERM-LICENSE +21 -0
  51. package/vendor/node_modules/asn1/LICENSE +19 -0
  52. package/vendor/node_modules/asn1/README.md +50 -0
  53. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  54. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  55. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  56. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  57. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  58. package/vendor/node_modules/asn1/lib/index.js +20 -0
  59. package/vendor/node_modules/asn1/package.json +31 -0
  60. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  61. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  62. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  63. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  64. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  65. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  66. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  67. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  68. package/vendor/node_modules/safer-buffer/package.json +34 -0
  69. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  70. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  71. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  72. package/vendor/node_modules/tweetnacl/README.md +459 -0
  73. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  74. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  75. package/vendor/node_modules/tweetnacl/package.json +58 -0
  76. package/vendor/ssh2/LICENSE +19 -0
  77. package/vendor/ssh2/lib/Channel.js +295 -0
  78. package/vendor/ssh2/lib/agent.js +1123 -0
  79. package/vendor/ssh2/lib/client.js +2176 -0
  80. package/vendor/ssh2/lib/http-agents.js +84 -0
  81. package/vendor/ssh2/lib/index.js +44 -0
  82. package/vendor/ssh2/lib/keygen.js +582 -0
  83. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  84. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  85. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  86. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  87. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  88. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  89. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  90. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  91. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  92. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  93. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  94. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  95. package/vendor/ssh2/lib/server.js +1380 -0
  96. package/vendor/ssh2/lib/utils.js +336 -0
  97. package/vendor/ssh2/package.json +34 -0
  98. package/vendor/xterm.js +53 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Per-session resource hints for the composer and the injected context.
3
+ *
4
+ * Every remote tool call carries its own `server`, so this store only
5
+ * remembers which managed resources the human mentioned with `@name` in the
6
+ * current conversation. It is a hint for the model and the UI, never an
7
+ * execution binding. The durable input is still the Session log, which
8
+ * `replayTargets()` folds back in after a resume.
9
+ */
10
+ import { loadServers } from './store.js'
11
+ import { findServerRef } from './server-ref.js'
12
+
13
+ /** Source kind recorded on the injected resource-context message. */
14
+ export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
15
+
16
+ /** Concatenate the text blocks of one message. */
17
+ export function extractUserText(message) {
18
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
19
+ return message.content
20
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
21
+ .map((block) => block.text)
22
+ .join('\n')
23
+ }
24
+
25
+ /** Find the latest human-authored user message in a step's messages. */
26
+ export function lastHumanMessage(messages) {
27
+ if (!Array.isArray(messages)) return undefined
28
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
29
+ const message = messages[index]
30
+ if (message && message.role === 'user' && message.source?.kind === 'user') return message
31
+ }
32
+ return undefined
33
+ }
34
+
35
+ /**
36
+ * Match `@name` tokens against the managed inventory (name or id,
37
+ * case-insensitive). Returns matched server records in mention order, without
38
+ * duplicates. Unknown tokens are ignored.
39
+ */
40
+ export function extractServerMentions(text, servers) {
41
+ if (typeof text !== 'string' || !Array.isArray(servers) || servers.length === 0) return []
42
+ const byAlias = new Map()
43
+ for (const server of servers) {
44
+ if (!server || typeof server.name !== 'string' || server.name.length === 0) continue
45
+ byAlias.set(server.name.toLowerCase(), server)
46
+ if (typeof server.id === 'string' && server.id.length > 0) byAlias.set(server.id.toLowerCase(), server)
47
+ }
48
+ // Match known aliases directly instead of a generic `@token` regex: a CJK
49
+ // server name is often followed immediately by CJK text (`@测试机看下磁盘`),
50
+ // so only the known name length tells us where the mention ends.
51
+ const aliases = [...byAlias.keys()].sort((left, right) => right.length - left.length)
52
+ const seen = new Set()
53
+ const matched = []
54
+ for (let index = 0; index < text.length; index += 1) {
55
+ if (text[index] !== '@') continue
56
+ // Like an email address: `ops@example.com` is not a mention.
57
+ const previous = index > 0 ? text[index - 1] : ''
58
+ if (previous && /[A-Za-z0-9._-]/.test(previous)) continue
59
+ for (const alias of aliases) {
60
+ if (text.slice(index + 1, index + 1 + alias.length).toLowerCase() !== alias) continue
61
+ const tail = text[index + 1 + alias.length] ?? ''
62
+ // `@web-12` is a different token than `@web-1`; CJK continuation is fine.
63
+ if (tail && /[A-Za-z0-9._-]/.test(tail)) break
64
+ const server = byAlias.get(alias)
65
+ if (server && !seen.has(server.id ?? server.name)) {
66
+ seen.add(server.id ?? server.name)
67
+ matched.push(server)
68
+ }
69
+ break
70
+ }
71
+ }
72
+ return matched
73
+ }
74
+
75
+ function sameList(left, right) {
76
+ if (left.length !== right.length) return false
77
+ return left.every((value, index) => value === right[index])
78
+ }
79
+
80
+ function mentionNames(mentions) {
81
+ const names = []
82
+ for (const server of Array.isArray(mentions) ? mentions : []) {
83
+ const name = typeof server?.name === 'string' ? server.name : ''
84
+ if (name && !names.includes(name)) names.push(name)
85
+ }
86
+ return names
87
+ }
88
+
89
+ function freshState() {
90
+ return { candidates: [], lastHumanId: null, version: 0, injectedVersion: -1, seeded: false }
91
+ }
92
+
93
+ /** Remember the latest @mentions. No target is selected or cleared. */
94
+ export function applyMentions(state, mentions) {
95
+ const names = mentionNames(mentions)
96
+ if (names.length === 0) return state
97
+ if (sameList(state.candidates, names)) return state
98
+ return { ...state, candidates: names, version: state.version + 1 }
99
+ }
100
+
101
+ /** Replay the mention hints over a Session's historical messages. */
102
+ export function replayTargets(messages, servers) {
103
+ let state = freshState()
104
+ if (Array.isArray(messages)) {
105
+ for (const message of messages) {
106
+ if (message?.role !== 'user' || message.source?.kind !== 'user') continue
107
+ const mentions = extractServerMentions(extractUserText(message), servers)
108
+ if (mentions.length > 0) state = applyMentions(state, mentions)
109
+ }
110
+ }
111
+ return state
112
+ }
113
+
114
+ /** Resolve one resource reference against the inventory (name or id). */
115
+ export function findTarget(servers, ref) {
116
+ return findServerRef(servers, ref)
117
+ }
118
+
119
+ /**
120
+ * Create the per-session resource-hint store.
121
+ * @param {{ loadInventory?: () => object[] }} [options]
122
+ */
123
+ export function createTargetStore(options = {}) {
124
+ const loadInventory = options.loadInventory ?? loadServers
125
+ /** @type {Map<string, object>} */
126
+ const sessions = new Map()
127
+
128
+ function sessionKey(session) {
129
+ if (session === undefined || session === null) return undefined
130
+ if (typeof session === 'string') return session
131
+ const id = session.id
132
+ return typeof id === 'string' && id.length > 0 ? id : undefined
133
+ }
134
+
135
+ function get(key) {
136
+ const sessionId = sessionKey(key)
137
+ if (sessionId === undefined) return undefined
138
+ return sessions.get(sessionId)
139
+ }
140
+
141
+ function ensure(key) {
142
+ const sessionId = sessionKey(key)
143
+ if (sessionId === undefined) return undefined
144
+ let state = sessions.get(sessionId)
145
+ if (state === undefined) {
146
+ state = freshState()
147
+ sessions.set(sessionId, state)
148
+ }
149
+ return state
150
+ }
151
+
152
+ /** Seed one session once from its historical messages (resume support). */
153
+ function ensureSeeded(key, messages, servers = loadInventory()) {
154
+ const state = ensure(key)
155
+ if (state === undefined || state.seeded) return state
156
+ const replayed = replayTargets(messages, servers)
157
+ const history = Array.isArray(messages) ? messages : []
158
+ const lastHuman = [...history].reverse().find(
159
+ (message) => message?.role === 'user' && message.source?.kind === 'user',
160
+ )
161
+ const next = {
162
+ ...state,
163
+ candidates: replayed.candidates,
164
+ lastHumanId: typeof lastHuman?.id === 'string' ? lastHuman.id : state.lastHumanId,
165
+ version: replayed.candidates.length > 0 ? 1 : 0,
166
+ seeded: true,
167
+ }
168
+ sessions.set(sessionKey(key), next)
169
+ return next
170
+ }
171
+
172
+ /**
173
+ * Fold one human message into the hint state.
174
+ * A new human message always bumps `version` so the per-turn resource
175
+ * context is injected even when the message contains no @mention.
176
+ */
177
+ function bindMentions(key, mentions, humanId) {
178
+ const state = ensure(key)
179
+ if (state === undefined) return undefined
180
+ const newHuman = typeof humanId === 'string' && humanId.length > 0 && humanId !== state.lastHumanId
181
+ const names = mentionNames(mentions)
182
+ const changedMentions = names.length > 0 && !sameList(state.candidates, names)
183
+ if (!newHuman && !changedMentions) return state
184
+ const next = {
185
+ ...state,
186
+ candidates: names.length > 0 ? names : state.candidates,
187
+ lastHumanId: typeof humanId === 'string' && humanId.length > 0 ? humanId : state.lastHumanId,
188
+ version: state.version + 1,
189
+ seeded: true,
190
+ }
191
+ sessions.set(sessionKey(key), next)
192
+ return next
193
+ }
194
+
195
+ function markInjected(key, version) {
196
+ const state = get(key)
197
+ if (state === undefined) return
198
+ if (state.injectedVersion === version) return
199
+ sessions.set(sessionKey(key), { ...state, injectedVersion: version })
200
+ }
201
+
202
+ function clear(key) {
203
+ const sessionId = sessionKey(key)
204
+ if (sessionId !== undefined) sessions.delete(sessionId)
205
+ }
206
+
207
+ function clearAll() {
208
+ sessions.clear()
209
+ }
210
+
211
+ function size() {
212
+ return sessions.size
213
+ }
214
+
215
+ return { get, ensure, ensureSeeded, bindMentions, markInjected, clear, clearAll, size, loadInventory }
216
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Filesystem layout for dsh-ssh-workbench.
3
+ *
4
+ * Everything the plugin persists lives under the harness home so a profile
5
+ * export/import carries it, and so tests can redirect it with DSH_SSH_WORKBENCH_DIR.
6
+ */
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import fs from 'node:fs'
10
+
11
+ function dshHome() {
12
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
13
+ }
14
+
15
+ /** New storage directory name, plus the pre-1.6.4 name for one-time migration. */
16
+ const DIR_NAME = 'ssh-workbench'
17
+ const LEGACY_DIR_NAME = 'ssh-ops'
18
+
19
+ /**
20
+ * Directory that holds servers, credentials, known hosts and preferences.
21
+ *
22
+ * `DSH_SSH_WORKBENCH_DIR` wins in tests and for power users. Otherwise the
23
+ * plugin uses `$DSH_HOME/ssh-workbench` and, on the first launch after the
24
+ * 1.6.4 rename, moves an existing `$DSH_HOME/ssh-ops` directory into place so
25
+ * credentials and trusted host keys are not lost. If the move fails (for
26
+ * example because of permissions) the legacy directory stays in use.
27
+ */
28
+ export function workbenchDir() {
29
+ const configured = process.env.DSH_SSH_WORKBENCH_DIR
30
+ if (configured) return configured
31
+ const current = path.join(dshHome(), DIR_NAME)
32
+ if (!fs.existsSync(current)) {
33
+ const legacy = path.join(dshHome(), LEGACY_DIR_NAME)
34
+ if (fs.existsSync(legacy)) {
35
+ try {
36
+ fs.renameSync(legacy, current)
37
+ } catch {
38
+ return legacy
39
+ }
40
+ }
41
+ }
42
+ return current
43
+ }
44
+
45
+ export function serversPath() {
46
+ return path.join(workbenchDir(), 'servers.json')
47
+ }
48
+
49
+ export function activityPath() {
50
+ return path.join(workbenchDir(), 'activity.json')
51
+ }
52
+
53
+ export function knownHostsPath() {
54
+ return path.join(workbenchDir(), 'known_hosts')
55
+ }
56
+
57
+ export function secretsPath() {
58
+ return path.join(workbenchDir(), 'secrets.json')
59
+ }
60
+
61
+ export function secretsKeyPath() {
62
+ return path.join(workbenchDir(), 'secrets.key')
63
+ }
64
+
65
+ export function prefsPath() {
66
+ return path.join(workbenchDir(), 'prefs.json')
67
+ }
68
+
69
+ /** Create a private directory (0700) if missing. */
70
+ function ensurePrivateDir(dir = workbenchDir()) {
71
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
72
+ try {
73
+ fs.chmodSync(dir, 0o700)
74
+ } catch {
75
+ // Windows cannot honour POSIX modes; the mkdir mode is best effort there.
76
+ }
77
+ return dir
78
+ }
79
+
80
+ /** Write a private file atomically (temp file + rename, 0600). */
81
+ export function writePrivateFile(file, text) {
82
+ ensurePrivateDir(path.dirname(file))
83
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
84
+ fs.writeFileSync(tmp, text, { mode: 0o600 })
85
+ try {
86
+ fs.chmodSync(tmp, 0o600)
87
+ } catch {
88
+ // best effort on Windows
89
+ }
90
+ fs.renameSync(tmp, file)
91
+ try {
92
+ fs.chmodSync(file, 0o600)
93
+ } catch {
94
+ // best effort on Windows
95
+ }
96
+ }
97
+
98
+ export function readTextFile(file, fallback = '') {
99
+ try {
100
+ return fs.readFileSync(file, 'utf8')
101
+ } catch {
102
+ return fallback
103
+ }
104
+ }
@@ -0,0 +1,4 @@
1
+ /** POSIX single-quote escaping for a remote shell word. */
2
+ export function quotePosix(value) {
3
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
4
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared POSIX remote-shell helpers.
3
+ *
4
+ * Pure command construction used by `remote_bash` and `remote_exec`; the
5
+ * native local `bash` tool never reaches this module.
6
+ */
7
+ import { quotePosix } from './posix-quote.js'
8
+
9
+ const MIN_TIMEOUT_MS = 1000
10
+
11
+ /**
12
+ * Wrap a remote command with the remote `timeout` utility.
13
+ *
14
+ * A client-side timeout only closes the SSH channel; a process that ignores
15
+ * SIGHUP (nohup/background) keeps running on the server. Wrapping the command
16
+ * gives the remote side the same deadline: coreutils `timeout` runs the
17
+ * managed command in its own process group and signals the whole group, so
18
+ * when the client gives up the remote work stops too. `kill` adds `-k <n>`
19
+ * (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
20
+ * capability) leaves the command untouched.
21
+ *
22
+ * The wrapper re-enters the remote login shell (`<shell> -c`) so shell
23
+ * semantics do not change. The shell path comes from the capability probe and
24
+ * is referenced as a quoted literal, so this also works on fish/csh (no POSIX
25
+ * `${SHELL:-...}` expansion). The command itself is single-quote escaped and
26
+ * therefore expanded exactly once, inside that shell.
27
+ *
28
+ * @param {string} command
29
+ * @param {number} timeoutMs
30
+ * @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
31
+ * @param {{ killAfterMs?: number }} [options]
32
+ */
33
+ export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
34
+ const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
35
+ const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
36
+ if (mode === 'none') return String(command ?? '')
37
+ const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
38
+ const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
39
+ const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
40
+ const seconds = Math.max(1, Number(timeoutMs) / 1000)
41
+ const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
42
+ return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
43
+ }
44
+
45
+ /**
46
+ * Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
47
+ *
48
+ * The remote shell starts in the login home; absolute paths are used verbatim,
49
+ * `~`/`~/x` and relative paths are anchored at `$HOME`.
50
+ *
51
+ * @returns {{ command: string, cwd: string } | { error: string }}
52
+ */
53
+ export function applyWorkdir(command, workdir) {
54
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
55
+ const body = String(command ?? '')
56
+ if (!raw) return { command: body, cwd: '~' }
57
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
58
+ if (raw.startsWith('~')) {
59
+ const rest = raw === '~' ? '' : raw.slice(1)
60
+ if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
61
+ const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
62
+ return { command: `cd -- ${target} && ${body}`, cwd: raw }
63
+ }
64
+ if (raw.startsWith('/')) {
65
+ return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
66
+ }
67
+ return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
68
+ }
69
+
70
+ /** Effective timeout for one call, clamped to the configured ceiling. */
71
+ export function resolveTimeoutMs(args, config) {
72
+ const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
73
+ const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
74
+ const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
75
+ const value = requested ?? fallback
76
+ return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
77
+ }
78
+
79
+ /**
80
+ * Whether a runtime result means the command never reached the remote shell.
81
+ * Timeouts and aborts are real command outcomes (timedOut / aborted on the
82
+ * native value), not transport failures.
83
+ */
84
+ export function isTransportFailure(result) {
85
+ if (!result || typeof result !== 'object') return true
86
+ if (result.exitCode !== null && result.exitCode !== undefined) return false
87
+ if (typeof result.signal === 'string' && result.signal.length > 0) return false
88
+ return result.outcome === 'failed'
89
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Small user-visible UI preferences that are not part of the server inventory.
3
+ *
4
+ * Kept in the same private data directory as the inventory so a profile export
5
+ * carries them, and so the plugin manager's sidebar switch can change them
6
+ * without rewriting the profile's cordis.patch.yml.
7
+ */
8
+ import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
9
+
10
+ export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
11
+
12
+ /** Coerce a stored document into the current preference shape. */
13
+ function normalizePrefs(raw) {
14
+ const prefs = { ...DEFAULT_PREFS }
15
+ if (!raw || typeof raw !== 'object') return prefs
16
+ if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
17
+ return prefs
18
+ }
19
+
20
+ export function loadPrefs() {
21
+ const text = readTextFile(prefsPath(), '')
22
+ if (!text.trim()) return { ...DEFAULT_PREFS }
23
+ try {
24
+ return normalizePrefs(JSON.parse(text))
25
+ } catch {
26
+ // A corrupt preferences file must never make the plugin unusable; fall
27
+ // back to the defaults and let the next save rewrite it.
28
+ return { ...DEFAULT_PREFS }
29
+ }
30
+ }
31
+
32
+ /** Merge one patch into the stored preferences and persist the result. */
33
+ export function savePrefs(patch) {
34
+ if (!patch || typeof patch !== 'object') throw new Error('prefs patch must be an object')
35
+ const next = normalizePrefs({ ...loadPrefs(), ...patch })
36
+ writePrivateFile(prefsPath(), `${JSON.stringify(next, null, 2)}\n`)
37
+ return next
38
+ }