@geekbeer/minion 3.53.1 → 3.57.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.
@@ -1,34 +1,69 @@
1
1
  /**
2
2
  * Variable Store
3
3
  *
4
- * Manages minion-local secrets and variables stored in .env-style files.
5
- * - Secrets: ~/.minion/.env.secrets (or DATA_DIR/.env.secrets)
6
- * - Variables: ~/.minion/.env.variables (or DATA_DIR/.env.variables)
4
+ * Manages minion-local variables and secrets both workspace-scoped.
7
5
  *
8
- * Files use standard .env format: KEY=value (one per line, # for comments).
9
- * Secrets never leave the minion; variables are non-sensitive configuration.
6
+ * Storage: SQLite tables `variables(workspace_id, key, value, updated_at)`
7
+ * and `secrets(workspace_id, key, value, updated_at)`. `workspace_id=''` is
8
+ * the minion-wide bucket; any other value is a workspace-specific bucket.
9
+ *
10
+ * At skill execution time the runner / template-expander merges
11
+ * minion-wide + current-workspace entries for the relevant scope, with the
12
+ * WS-scoped value winning on conflict.
13
+ *
14
+ * Migration: on first read after the SQLite tables are created, any legacy
15
+ * `.env.variables` / `.env.secrets` flat files are imported into the
16
+ * minion-wide bucket and renamed to `.env.variables.migrated` /
17
+ * `.env.secrets.migrated` (kept as one-time backups).
18
+ *
19
+ * Secrets never leave the minion and are never persisted in HQ DB. Variables
20
+ * are non-sensitive and may also be defined at HQ scopes (workspace /
21
+ * project / workflow); see packages/docs-internal/src/content/docs/design/
22
+ * variables-and-secrets.md for the full resolution model.
10
23
  */
11
24
 
12
25
  const fs = require('fs')
13
26
  const path = require('path')
14
27
  const { DATA_DIR } = require('../lib/platform')
15
28
  const { config } = require('../config')
29
+ const { getDb } = require('../db')
16
30
 
17
31
  /**
18
- * Resolve file path for a given store type.
19
- * @param {'secrets' | 'variables'} type
32
+ * Resolve the legacy `.env.variables` path (for one-time migration).
20
33
  * @returns {string}
21
34
  */
22
- function getFilePath(type) {
23
- const filename = type === 'secrets' ? '.env.secrets' : '.env.variables'
35
+ function getLegacyVariablesFilePath() {
24
36
  try {
25
37
  fs.accessSync(DATA_DIR, fs.constants.W_OK)
26
- return path.join(DATA_DIR, filename)
38
+ return path.join(DATA_DIR, '.env.variables')
27
39
  } catch {
28
- return path.join(config.HOME_DIR, '.minion', filename)
40
+ return path.join(config.HOME_DIR, '.minion', '.env.variables')
29
41
  }
30
42
  }
31
43
 
44
+ /**
45
+ * Resolve the legacy `.env.secrets` path (for one-time migration).
46
+ * @returns {string}
47
+ */
48
+ function getLegacySecretsFilePath() {
49
+ try {
50
+ fs.accessSync(DATA_DIR, fs.constants.W_OK)
51
+ return path.join(DATA_DIR, '.env.secrets')
52
+ } catch {
53
+ return path.join(config.HOME_DIR, '.minion', '.env.secrets')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Normalize a workspace identifier to its canonical string form.
59
+ * '' (empty string) represents the minion-wide bucket.
60
+ * @param {string|null|undefined} workspaceId
61
+ * @returns {string}
62
+ */
63
+ function normalizeWorkspaceId(workspaceId) {
64
+ return workspaceId == null ? '' : String(workspaceId)
65
+ }
66
+
32
67
  /**
33
68
  * Parse a .env file into a key-value object.
34
69
  * @param {string} filePath
@@ -54,105 +89,272 @@ function parseEnvFile(filePath) {
54
89
  return result
55
90
  }
56
91
 
92
+ // ─── Legacy flat-file → SQLite migration ────────────────────────────────────
93
+
94
+ let _legacyVariablesMigrationDone = false
95
+ let _legacySecretsMigrationDone = false
96
+
97
+ function migrateLegacyFile(legacyPath, table, doneFlagRef) {
98
+ if (doneFlagRef.done) return
99
+ doneFlagRef.done = true
100
+
101
+ let entries
102
+ try {
103
+ entries = parseEnvFile(legacyPath)
104
+ } catch {
105
+ return
106
+ }
107
+ const keys = Object.keys(entries)
108
+ if (keys.length === 0) return
109
+
110
+ const db = getDb()
111
+ const now = Date.now()
112
+ const insert = db.prepare(
113
+ `INSERT OR IGNORE INTO ${table} (workspace_id, key, value, updated_at) VALUES (?, ?, ?, ?)`
114
+ )
115
+ const tx = db.transaction(() => {
116
+ for (const k of keys) {
117
+ insert.run('', k, String(entries[k] ?? ''), now)
118
+ }
119
+ })
120
+ tx()
121
+
122
+ try {
123
+ fs.renameSync(legacyPath, `${legacyPath}.migrated`)
124
+ console.log(`[VariableStore] Migrated ${keys.length} legacy ${table} entry/ies from ${legacyPath} into SQLite (minion-wide scope)`)
125
+ } catch (err) {
126
+ console.error(`[VariableStore] Migrated ${table} to SQLite but failed to rename ${legacyPath}: ${err.message}`)
127
+ }
128
+ }
129
+
130
+ const _variablesFlag = { done: false }
131
+ const _secretsFlag = { done: false }
132
+
133
+ function migrateLegacyVariablesFile() {
134
+ migrateLegacyFile(getLegacyVariablesFilePath(), 'variables', _variablesFlag)
135
+ }
136
+
137
+ function migrateLegacySecretsFile() {
138
+ migrateLegacyFile(getLegacySecretsFilePath(), 'secrets', _secretsFlag)
139
+ }
140
+
141
+ // ─── Generic table operations (variables and secrets share the shape) ───────
142
+
143
+ function getRowsForScope(table, workspaceId) {
144
+ const wsId = normalizeWorkspaceId(workspaceId)
145
+ const db = getDb()
146
+ return db.prepare(`SELECT key, value FROM ${table} WHERE workspace_id = ? ORDER BY key`).all(wsId)
147
+ }
148
+
149
+ function getScopeAsObject(table, workspaceId) {
150
+ const rows = getRowsForScope(table, workspaceId)
151
+ const out = {}
152
+ for (const r of rows) out[r.key] = r.value
153
+ return out
154
+ }
155
+
156
+ function getEffective(table, workspaceId) {
157
+ const wide = getScopeAsObject(table, '')
158
+ if (!workspaceId) return wide
159
+ const scoped = getScopeAsObject(table, workspaceId)
160
+ return { ...wide, ...scoped }
161
+ }
162
+
163
+ function upsertEntry(table, workspaceId, key, value) {
164
+ const wsId = normalizeWorkspaceId(workspaceId)
165
+ const db = getDb()
166
+ db.prepare(`
167
+ INSERT INTO ${table} (workspace_id, key, value, updated_at) VALUES (?, ?, ?, ?)
168
+ ON CONFLICT(workspace_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
169
+ `).run(wsId, key, String(value ?? ''), Date.now())
170
+ return wsId
171
+ }
172
+
173
+ function deleteEntry(table, workspaceId, key) {
174
+ const wsId = normalizeWorkspaceId(workspaceId)
175
+ const db = getDb()
176
+ const info = db.prepare(`DELETE FROM ${table} WHERE workspace_id = ? AND key = ?`).run(wsId, key)
177
+ return { removed: info.changes > 0, wsId }
178
+ }
179
+
180
+ function listScopes(table) {
181
+ const db = getDb()
182
+ const rows = db.prepare(`SELECT DISTINCT workspace_id FROM ${table}`).all()
183
+ return rows.map(r => r.workspace_id)
184
+ }
185
+
186
+ // ─── Variables (workspace-scoped) ────────────────────────────────────────────
187
+
188
+ function getVariablesForScope(workspaceId) {
189
+ migrateLegacyVariablesFile()
190
+ return getScopeAsObject('variables', workspaceId)
191
+ }
192
+
57
193
  /**
58
- * Write a key-value object to a .env file.
59
- * @param {string} filePath
60
- * @param {Record<string, string>} data
194
+ * Merge minion-wide and workspace-scoped variables. Workspace-scoped values
195
+ * override minion-wide values when the key is the same.
61
196
  */
62
- function writeEnvFile(filePath, data) {
63
- const dir = path.dirname(filePath)
64
- fs.mkdirSync(dir, { recursive: true })
197
+ function getEffectiveVariables(workspaceId) {
198
+ migrateLegacyVariablesFile()
199
+ return getEffective('variables', workspaceId)
200
+ }
201
+
202
+ function getVariable(workspaceId, key) {
203
+ const all = getVariablesForScope(workspaceId)
204
+ return all[key] ?? null
205
+ }
65
206
 
66
- const lines = Object.entries(data).map(([key, value]) => `${key}=${value}`)
67
- fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
207
+ function setVariable(workspaceId, key, value) {
208
+ migrateLegacyVariablesFile()
209
+ const wsId = upsertEntry('variables', workspaceId, key, value)
210
+ console.log(`[VariableStore] Set variable (scope=${wsId || 'minion-wide'}): ${key}`)
211
+ }
212
+
213
+ function removeVariable(workspaceId, key) {
214
+ migrateLegacyVariablesFile()
215
+ const { removed, wsId } = deleteEntry('variables', workspaceId, key)
216
+ if (removed) {
217
+ console.log(`[VariableStore] Removed variable (scope=${wsId || 'minion-wide'}): ${key}`)
218
+ }
219
+ return removed
220
+ }
221
+
222
+ function listVariableKeys(workspaceId) {
223
+ migrateLegacyVariablesFile()
224
+ const rows = getRowsForScope('variables', workspaceId)
225
+ return rows.map(r => r.key)
226
+ }
227
+
228
+ function listVariableScopes() {
229
+ migrateLegacyVariablesFile()
230
+ return listScopes('variables')
231
+ }
232
+
233
+ // ─── Secrets (workspace-scoped, sensitive) ───────────────────────────────────
234
+
235
+ function getSecretsForScope(workspaceId) {
236
+ migrateLegacySecretsFile()
237
+ return getScopeAsObject('secrets', workspaceId)
238
+ }
239
+
240
+ function getEffectiveSecrets(workspaceId) {
241
+ migrateLegacySecretsFile()
242
+ return getEffective('secrets', workspaceId)
243
+ }
244
+
245
+ function listSecretKeys(workspaceId) {
246
+ migrateLegacySecretsFile()
247
+ const rows = getRowsForScope('secrets', workspaceId)
248
+ return rows.map(r => r.key)
68
249
  }
69
250
 
70
251
  /**
71
- * Get all key-value pairs for a store type.
72
- * @param {'secrets' | 'variables'} type
73
- * @returns {Record<string, string>}
252
+ * Set (insert or update) a secret value in the given scope.
253
+ *
254
+ * For minion-wide secrets (workspace_id=''), the value is also synced into
255
+ * `process.env` so in-process consumers (chat plugin sessions, ad-hoc Bash
256
+ * commands) see the change immediately without restarting the server.
257
+ *
258
+ * WS-scoped secrets are NEVER written to `process.env` to prevent cross-WS
259
+ * leakage; runners must call `getEffectiveSecrets(workspaceId)` and inject
260
+ * them explicitly into the session env.
74
261
  */
262
+ function setSecret(workspaceId, key, value) {
263
+ migrateLegacySecretsFile()
264
+ const wsId = upsertEntry('secrets', workspaceId, key, value)
265
+ if (wsId === '') {
266
+ process.env[key] = String(value ?? '')
267
+ }
268
+ console.log(`[VariableStore] Set secret (scope=${wsId || 'minion-wide'}): ${key}`)
269
+ }
270
+
271
+ function removeSecret(workspaceId, key) {
272
+ migrateLegacySecretsFile()
273
+ const { removed, wsId } = deleteEntry('secrets', workspaceId, key)
274
+ if (removed) {
275
+ if (wsId === '') {
276
+ delete process.env[key]
277
+ }
278
+ console.log(`[VariableStore] Removed secret (scope=${wsId || 'minion-wide'}): ${key}`)
279
+ }
280
+ return removed
281
+ }
282
+
283
+ function listSecretScopes() {
284
+ migrateLegacySecretsFile()
285
+ return listScopes('secrets')
286
+ }
287
+
288
+ // ─── Generic / legacy dispatchers (back-compat) ─────────────────────────────
289
+ //
290
+ // The original API used `getAll('variables')` / `getAll('secrets')` etc. with
291
+ // no workspace awareness. We retain the same names but route them to the
292
+ // minion-wide bucket so any straggler caller keeps working.
293
+
75
294
  function getAll(type) {
76
- return parseEnvFile(getFilePath(type))
295
+ if (type === 'secrets') return getSecretsForScope('')
296
+ return getVariablesForScope('')
77
297
  }
78
298
 
79
- /**
80
- * Get a single value by key.
81
- * @param {'secrets' | 'variables'} type
82
- * @param {string} key
83
- * @returns {string | null}
84
- */
85
299
  function get(type, key) {
86
- const data = getAll(type)
87
- return data[key] ?? null
300
+ if (type === 'secrets') {
301
+ const all = getSecretsForScope('')
302
+ return all[key] ?? null
303
+ }
304
+ return getVariable('', key)
88
305
  }
89
306
 
90
- /**
91
- * Set a key-value pair (creates or updates).
92
- * Only secrets are synced to process.env (for child process inheritance).
93
- * Variables use {{VAR}} template expansion in skill content instead.
94
- * @param {'secrets' | 'variables'} type
95
- * @param {string} key
96
- * @param {string} value
97
- */
98
307
  function set(type, key, value) {
99
- const filePath = getFilePath(type)
100
- const data = parseEnvFile(filePath)
101
- data[key] = value
102
- writeEnvFile(filePath, data)
103
- // Only sync secrets to process.env; variables use template expansion instead
104
308
  if (type === 'secrets') {
105
- process.env[key] = value
309
+ setSecret('', key, value)
310
+ return
106
311
  }
107
- console.log(`[VariableStore] Set ${type} key: ${key}`)
312
+ setVariable('', key, value)
108
313
  }
109
314
 
110
- /**
111
- * Remove a key.
112
- * @param {'secrets' | 'variables'} type
113
- * @param {string} key
114
- * @returns {boolean} true if key existed
115
- */
116
315
  function remove(type, key) {
117
- const filePath = getFilePath(type)
118
- const data = parseEnvFile(filePath)
119
- if (!(key in data)) return false
120
- delete data[key]
121
- writeEnvFile(filePath, data)
122
- // Only sync secrets to process.env; variables use template expansion instead
123
- if (type === 'secrets') {
124
- delete process.env[key]
125
- }
126
- console.log(`[VariableStore] Removed ${type} key: ${key}`)
127
- return true
316
+ if (type === 'secrets') return removeSecret('', key)
317
+ return removeVariable('', key)
128
318
  }
129
319
 
130
- /**
131
- * List all keys for a store type.
132
- * @param {'secrets' | 'variables'} type
133
- * @returns {string[]}
134
- */
135
320
  function listKeys(type) {
136
- return Object.keys(getAll(type))
321
+ if (type === 'secrets') return listSecretKeys('')
322
+ return listVariableKeys('')
137
323
  }
138
324
 
139
325
  /**
140
- * Build environment object from minion secrets only.
141
- * Variables are no longer injected as env vars; they use {{VAR}} template
142
- * expansion in skill content (same mechanism as project/workflow variables).
143
- *
144
- * @returns {Record<string, string>} Secret key-value pairs for process.env
326
+ * @deprecated Use `getEffectiveSecrets(workspaceId)` instead. Retained so any
327
+ * stragglers continue compiling; returns minion-wide secrets only.
145
328
  */
146
329
  function buildEnv() {
147
- return getAll('secrets')
330
+ return getSecretsForScope('')
148
331
  }
149
332
 
150
333
  module.exports = {
334
+ // Generic (back-compat)
151
335
  getAll,
152
336
  get,
153
337
  set,
154
338
  remove,
155
339
  listKeys,
156
340
  buildEnv,
157
- getFilePath,
341
+
342
+ // Variables (workspace-scoped)
343
+ getVariablesForScope,
344
+ getEffectiveVariables,
345
+ getVariable,
346
+ setVariable,
347
+ removeVariable,
348
+ listVariableKeys,
349
+ listVariableScopes,
350
+ migrateLegacyVariablesFile,
351
+
352
+ // Secrets (workspace-scoped)
353
+ getSecretsForScope,
354
+ getEffectiveSecrets,
355
+ listSecretKeys,
356
+ setSecret,
357
+ removeSecret,
358
+ listSecretScopes,
359
+ migrateLegacySecretsFile,
158
360
  }