@goodandready/dsh-subscriptions 0.7.8 → 0.7.10

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.
@@ -105,6 +105,8 @@ export function formatDoctor(payload) {
105
105
  lines.push('doctor: ' + (doctorFailed(payload) ? 'degraded' : 'ok'))
106
106
  lines.push('errors: ' + errors + ' conflicts: ' + conflicts)
107
107
  lines.push('key-rotation: ' + (kr.available ? (kr.healthyKeys + '/' + kr.totalKeys + ' healthy keys') : ('unavailable' + (kr.reason ? ' (' + kr.reason + ')' : ''))))
108
+ const snapshots = diag.vaultSnapshots || {}
109
+ lines.push('snapshots: ' + (snapshots.count || 0) + ' (' + (snapshots.bytes || 0) + ' bytes)')
108
110
  for (const row of (sources.providers || [])) lines.push(row.provider + ': ' + (row.selected || 'none'))
109
111
  return lines.join('\n')
110
112
  }
@@ -123,6 +125,11 @@ export function formatPacks(packs) {
123
125
  return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
124
126
  }
125
127
 
128
+ export function formatSnapshotStatus(payload) {
129
+ const stats = (payload && payload.stats) || {}
130
+ return 'snapshots: ' + (stats.count || 0) + ' bytes: ' + (stats.bytes || 0) + ' path: ' + (stats.path || '-')
131
+ }
132
+
126
133
  export function formatSnapshots(payload) {
127
134
  const rows = (payload && payload.snapshots) || []
128
135
  if (!rows.length) return 'no vault snapshots'
@@ -217,6 +224,7 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
217
224
  if (options.command === 'vault' && !options.json) {
218
225
  try {
219
226
  const payload = JSON.parse(text || '{}')
227
+ if (options.vaultAction === 'status') return { text: formatSnapshotStatus(payload), contentType: 'text/plain' }
220
228
  if (options.vaultAction === 'snapshots' || !options.vaultAction) return { text: formatSnapshots(payload), contentType: 'text/plain' }
221
229
  if (options.vaultAction === 'snapshot') return { text: 'created ' + (payload.snapshot && payload.snapshot.id), contentType: 'text/plain' }
222
230
  if (options.vaultAction === 'restore') return { text: 'restored ' + (payload.slotCount || 0) + ' slots', contentType: 'text/plain' }
@@ -237,7 +245,7 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
237
245
  async function main() {
238
246
  const result = await run()
239
247
  if (result.help) {
240
- process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|vault|doctor> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n packs\n packs add <file.json>\n vault snapshots | vault snapshot [label] | vault restore <id>\n doctor [--json]\n')
248
+ process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|vault|doctor> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n packs\n packs add <file.json>\n vault snapshots | vault snapshot [label] | vault restore <id> | vault status\n doctor [--json]\n')
241
249
  return
242
250
  }
243
251
  process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
package/lib/client.js CHANGED
@@ -3713,11 +3713,11 @@ window.__ModuleLoader__.load({
3713
3713
 
3714
3714
  // #736: vault snapshot list with create/restore. Payload stays server-side.
3715
3715
  function VaultSnapshotsPanel() {
3716
- const [state, setState] = React.useState({ snapshots: [], message: '', error: null, busy: false })
3716
+ const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
3717
3717
  const pull = () => {
3718
3718
  fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
3719
3719
  .then((res) => res.json())
3720
- .then((data) => setState((st) => Object.assign({}, st, { snapshots: data.snapshots || [], error: null })))
3720
+ .then((data) => setState((st) => Object.assign({}, st, { snapshots: data.snapshots || [], stats: data.stats || null, error: null })))
3721
3721
  .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
3722
3722
  }
3723
3723
  React.useEffect(() => { pull() }, [])
@@ -3745,6 +3745,7 @@ window.__ModuleLoader__.load({
3745
3745
  return h('div', { className: 'dsub-section-card' },
3746
3746
  h('div', { className: 'dsub-section-title' }, 'Vault snapshots', h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : 'Create snapshot')),
3747
3747
  h('div', { className: 'dsub-section-desc' }, 'Encrypted slots snapshots. A pre-pull snapshot is created automatically. Payload stays on the host.'),
3748
+ state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
3748
3749
  state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
3749
3750
  h('span', { className: 'dsub-h' }, snap.id),
3750
3751
  h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
@@ -0,0 +1,21 @@
1
+ // #745: health is about having a usable source, not about uptime alone.
2
+ export function assessHealth({ accounts = [], sources = null, snapshots = null, webhookUrl = '', ollama = false } = {}) {
3
+ const list = Array.isArray(accounts) ? accounts : []
4
+ const connectedAccounts = list.filter((a) => a && a.configured).length
5
+ const sourceRows = sources && Array.isArray(sources.providers) ? sources.providers : []
6
+ const selectedSources = sourceRows.filter((row) => row && row.selected).length
7
+ const keyRotation = Boolean(sources && sources.keyRotation && sources.keyRotation.available)
8
+ const ok = connectedAccounts > 0 || keyRotation || Boolean(ollama)
9
+ return {
10
+ ok,
11
+ status: ok ? 'healthy' : 'degraded',
12
+ checks: {
13
+ connectedAccounts,
14
+ keyRotation,
15
+ ollama: Boolean(ollama),
16
+ webhook: Boolean(webhookUrl),
17
+ snapshots: snapshots ? Number(snapshots.count) || 0 : 0,
18
+ selectedSources,
19
+ },
20
+ }
21
+ }
package/lib/metrics.js ADDED
@@ -0,0 +1,62 @@
1
+ // #744: Prometheus text exposition. No secrets, labels escaped, deterministic order.
2
+ function label(value) {
3
+ return String(value == null ? '' : value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
4
+ }
5
+
6
+ function add(lines, name, help, type, samples) {
7
+ lines.push('# HELP ' + name + ' ' + help)
8
+ lines.push('# TYPE ' + name + ' ' + type)
9
+ for (const sample of samples) lines.push(name + (sample.labels ? '{' + sample.labels + '}' : '') + ' ' + sample.value)
10
+ }
11
+
12
+ export function formatMetrics({ accounts = [], rows = [], alerts = [], sources = null } = {}) {
13
+ const lines = []
14
+ const accountRows = Array.isArray(accounts) ? accounts : []
15
+ add(lines, 'dsh_subscriptions_connected_accounts', 'Accounts with a configured credential.', 'gauge', [
16
+ { value: accountRows.filter((a) => a && a.configured).length },
17
+ ])
18
+ const configured = []
19
+ const usage = []
20
+ const cooldown = []
21
+ for (const account of accountRows) {
22
+ if (!account || !account.provider) continue
23
+ const labels = 'provider="' + label(account.provider) + '",index="' + label(account.index || 1) + '"'
24
+ configured.push({ labels, value: account.configured ? 1 : 0 })
25
+ if (account.usagePercent != null) usage.push({ labels, value: Number(account.usagePercent) })
26
+ const until = Number(account.cooldownUntil) || 0
27
+ cooldown.push({ labels, value: until > Date.now() ? Math.round((until - Date.now()) / 1000) : 0 })
28
+ }
29
+ add(lines, 'dsh_subscriptions_account_configured', '1 when the slot has a credential.', 'gauge', configured)
30
+ add(lines, 'dsh_subscriptions_account_usage_percent', 'Last observed quota usage.', 'gauge', usage)
31
+ add(lines, 'dsh_subscriptions_account_cooldown_seconds', 'Remaining cooldown.', 'gauge', cooldown)
32
+
33
+ const selected = []
34
+ for (const row of (sources && Array.isArray(sources.providers) ? sources.providers : [])) {
35
+ if (!row || !row.selected) continue
36
+ selected.push({ labels: 'provider="' + label(row.provider) + '",source="' + label(row.selected) + '"', value: 1 })
37
+ }
38
+ add(lines, 'dsh_subscriptions_source_selected', 'Selected source for a provider.', 'gauge', selected)
39
+
40
+ const byRequest = new Map()
41
+ for (const row of (Array.isArray(rows) ? rows : [])) {
42
+ const provider = row && row.provider ? row.provider : 'unknown'
43
+ const status = row && row.status != null ? row.status : 'unknown'
44
+ const key = provider + '|' + status
45
+ byRequest.set(key, (byRequest.get(key) || 0) + 1)
46
+ }
47
+ add(lines, 'dsh_subscriptions_requests_total', 'Recorded requests by provider and status.', 'counter',
48
+ [...byRequest.entries()].sort().map(([key, value]) => {
49
+ const [provider, status] = key.split('|')
50
+ return { labels: 'provider="' + label(provider) + '",status="' + label(status) + '"', value }
51
+ }))
52
+
53
+ const bySeverity = new Map()
54
+ for (const alert of (Array.isArray(alerts) ? alerts : [])) {
55
+ const severity = alert && alert.severity ? alert.severity : 'warn'
56
+ bySeverity.set(severity, (bySeverity.get(severity) || 0) + 1)
57
+ }
58
+ add(lines, 'dsh_subscriptions_alerts_total', 'Recent alerts by severity.', 'counter',
59
+ [...bySeverity.entries()].sort().map(([severity, value]) => ({ labels: 'severity="' + label(severity) + '"', value })))
60
+
61
+ return lines.join('\n') + '\n'
62
+ }
@@ -0,0 +1,33 @@
1
+ import { getRecentAlerts } from '../alerts.js'
2
+ import { isLoopback, isTrustedSettingsRequest } from '../http.js'
3
+ import { formatMetrics } from '../metrics.js'
4
+ import { collectSourceChain } from './sources.js'
5
+
6
+ export function registerMetricsRoutes(ctx, state) {
7
+ ctx.effect(() => ctx.webServer.register({
8
+ kind: 'exact',
9
+ path: '/dsh-subscriptions/metrics',
10
+ handler: async (req, res) => {
11
+ if (req.method !== 'GET') {
12
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
13
+ res.end('method not allowed\n')
14
+ return
15
+ }
16
+ if (!isLoopback(req) && !isTrustedSettingsRequest(req, ctx)) {
17
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
18
+ res.end('forbidden\n')
19
+ return
20
+ }
21
+ const accounts = await state.accountsView().catch(() => [])
22
+ const sources = await collectSourceChain(state, req.socket && req.socket.localPort)
23
+ const text = formatMetrics({
24
+ accounts,
25
+ rows: typeof state.history?.all === 'function' ? state.history.all() : [],
26
+ alerts: getRecentAlerts(1000),
27
+ sources,
28
+ })
29
+ res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' })
30
+ res.end(text)
31
+ },
32
+ }), 'dsh-subscriptions: /metrics')
33
+ }
@@ -1,8 +1,33 @@
1
1
  import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
2
2
  import { buildSourceChain, readKeyRotationStatus, subscriptionCandidate } from '../key-rotation-bridge.js'
3
3
 
4
- export function registerSourcesRoutes(ctx, state) {
4
+ export async function collectSourceChain(state, port) {
5
5
  const { accountsView, live } = state
6
+ let subscriptions = []
7
+ try {
8
+ const rows = await accountsView()
9
+ subscriptions = (rows || []).map(subscriptionCandidate)
10
+ } catch { /* keep the chain available */ }
11
+ const keyRotation = port
12
+ ? await readKeyRotationStatus({ baseUrl: 'http://127.0.0.1:' + port })
13
+ : { available: false, reason: 'no local port', providers: [], totalKeys: 0, healthyKeys: 0 }
14
+ const chain = buildSourceChain({
15
+ subscriptions,
16
+ keyRotation,
17
+ ollama: { available: live().ollamaFallback !== false },
18
+ })
19
+ return {
20
+ keyRotation: {
21
+ available: keyRotation.available,
22
+ reason: keyRotation.reason || null,
23
+ totalKeys: keyRotation.totalKeys,
24
+ healthyKeys: keyRotation.healthyKeys,
25
+ },
26
+ ...chain,
27
+ }
28
+ }
29
+
30
+ export function registerSourcesRoutes(ctx, state) {
6
31
 
7
32
  ctx.effect(() => ctx.webServer.register({
8
33
  kind: 'exact',
@@ -16,30 +41,8 @@ export function registerSourcesRoutes(ctx, state) {
16
41
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
17
42
  return
18
43
  }
19
- let subscriptions = []
20
- try {
21
- const rows = await accountsView()
22
- subscriptions = (rows || []).map(subscriptionCandidate)
23
- } catch { /* keep the chain available */ }
24
- const port = req.socket && req.socket.localPort
25
- const keyRotation = port
26
- ? await readKeyRotationStatus({ baseUrl: 'http://127.0.0.1:' + port })
27
- : { available: false, reason: 'no local port', providers: [], totalKeys: 0, healthyKeys: 0 }
28
- const chain = buildSourceChain({
29
- subscriptions,
30
- keyRotation,
31
- ollama: { available: live().ollamaFallback !== false },
32
- })
33
- writeJson(res, 200, {
34
- ok: true,
35
- keyRotation: {
36
- available: keyRotation.available,
37
- reason: keyRotation.reason || null,
38
- totalKeys: keyRotation.totalKeys,
39
- healthyKeys: keyRotation.healthyKeys,
40
- },
41
- ...chain,
42
- })
44
+ const payload = await collectSourceChain(state, req.socket && req.socket.localPort)
45
+ writeJson(res, 200, { ok: true, ...payload })
43
46
  }),
44
47
  }), 'dsh-subscriptions: /sources')
45
48
  }
@@ -26,6 +26,9 @@ import { quotaSnapshot } from '../ratelimit.js'
26
26
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
27
27
  import { getVendor } from '../vendors/index.js'
28
28
  import { defaultErrorBuffer } from '../error-buffer.js'
29
+ import { assessHealth } from '../health-report.js'
30
+ import { defaultSnapshotStore } from '../vault-snapshots.js'
31
+ import { collectSourceChain } from './sources.js'
29
32
 
30
33
  export function registerStatusRoutes(ctx, state) {
31
34
  const {
@@ -82,7 +85,16 @@ export function registerStatusRoutes(ctx, state) {
82
85
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
83
86
  return
84
87
  }
85
- writeJson(res, 200, { ok: true, status: 'healthy', version: pkgVersion || 'unknown' })
88
+ const accounts = await accountsView().catch(() => [])
89
+ const sources = await collectSourceChain(state, req.socket && req.socket.localPort)
90
+ const health = assessHealth({
91
+ accounts,
92
+ sources,
93
+ snapshots: defaultSnapshotStore.stats(),
94
+ webhookUrl: live().webhookUrl,
95
+ ollama: live().ollamaFallback !== false,
96
+ })
97
+ writeJson(res, 200, { ok: true, status: health.status, version: pkgVersion || 'unknown', checks: health.checks })
86
98
  }),
87
99
  }), 'dsh-subscriptions: /health')
88
100
 
@@ -310,6 +322,8 @@ export function registerStatusRoutes(ctx, state) {
310
322
  report: await diagnosticsReport(),
311
323
  conflicts: getActiveConflicts(),
312
324
  route: buildRouteReport(last, accounts),
325
+ sources: await collectSourceChain(state, req.socket && req.socket.localPort),
326
+ vaultSnapshots: defaultSnapshotStore.stats(),
313
327
  })
314
328
  }),
315
329
  }), 'dsh-subscriptions: /diagnostics')
@@ -15,7 +15,7 @@ export function registerVaultRoutes(ctx, state) {
15
15
  return
16
16
  }
17
17
  if (req.method === 'GET') {
18
- writeJson(res, 200, { ok: true, snapshots: defaultSnapshotStore.list() })
18
+ writeJson(res, 200, { ok: true, snapshots: defaultSnapshotStore.list(), stats: defaultSnapshotStore.stats() })
19
19
  return
20
20
  }
21
21
  if (req.method !== 'POST') {
package/lib/routes.js CHANGED
@@ -9,6 +9,7 @@ import { registerSourcesRoutes } from './routes/sources.js'
9
9
  import { registerPacksRoutes } from './routes/packs.js'
10
10
  import { registerWebhookRoutes } from './routes/webhook.js'
11
11
  import { registerVaultRoutes } from './routes/vault.js'
12
+ import { registerMetricsRoutes } from './routes/metrics.js'
12
13
  import { registerPluginUpdater } from './updater.js'
13
14
 
14
15
  export function registerRoutes(ctx, state) {
@@ -117,4 +118,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
117
118
  registerPacksRoutes(ctx)
118
119
  registerWebhookRoutes(ctx, state)
119
120
  registerVaultRoutes(ctx, state)
121
+ registerMetricsRoutes(ctx, state)
120
122
  }
@@ -1,7 +1,25 @@
1
- // #734: bounded in-memory snapshot ring. Only metadata leaves list(); the
2
- // encrypted payload is available through get(id) for an explicit restore.
1
+ // #739: durable, bounded snapshot ring. Encrypted payloads are written with
2
+ // 0600 permissions through a temp file + rename; list() only returns metadata.
3
+ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+
7
+ const FILE_NAME = 'vault-snapshots.json'
8
+ export const DEFAULT_MAX_BYTES = 2 * 1024 * 1024
9
+
10
+ function bytesOf(items) {
11
+ return Buffer.byteLength(JSON.stringify(items))
12
+ }
3
13
  let seq = 0
4
14
 
15
+ export function resolveSnapshotDir(customDir) {
16
+ if (customDir) return customDir
17
+ if (process.env.DSH_SNAPSHOT_DIR) return process.env.DSH_SNAPSHOT_DIR
18
+ if (process.env.DSH_STORAGE_DIR) return process.env.DSH_STORAGE_DIR
19
+ if (process.env.DSH_HOME) return join(process.env.DSH_HOME, 'storages', 'dsh-subscriptions')
20
+ return join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
21
+ }
22
+
5
23
  function meta(snapshot) {
6
24
  return {
7
25
  id: snapshot.id,
@@ -13,9 +31,36 @@ function meta(snapshot) {
13
31
  }
14
32
  }
15
33
 
16
- export function createSnapshotStore({ max = 10, now = () => Date.now() } = {}) {
17
- let items = []
34
+ function load(file) {
35
+ try {
36
+ const rows = JSON.parse(readFileSync(file, 'utf8'))
37
+ if (!Array.isArray(rows)) return []
38
+ return rows.filter((row) => row && typeof row === 'object' && typeof row.vault === 'string' && row.id)
39
+ } catch {
40
+ return []
41
+ }
42
+ }
43
+
44
+ function persist(dir, file, items) {
45
+ try {
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
47
+ const tmp = file + '.tmp-' + process.pid + '-' + Date.now()
48
+ writeFileSync(tmp, JSON.stringify(items), { mode: 0o600 })
49
+ renameSync(tmp, file)
50
+ try { chmodSync(file, 0o600) } catch { /* best-effort */ }
51
+ return true
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ export function createSnapshotStore({ max = 10, maxBytes = DEFAULT_MAX_BYTES, now = () => Date.now(), dir, persistToDisk = true } = {}) {
58
+ const snapshotDir = resolveSnapshotDir(dir)
59
+ const file = join(snapshotDir, FILE_NAME)
60
+ let items = persistToDisk ? load(file) : []
61
+ const save = () => { if (persistToDisk) persist(snapshotDir, file, items) }
18
62
  return {
63
+ path: file,
19
64
  add(snapshot, label = '') {
20
65
  const at = now()
21
66
  const entry = {
@@ -26,6 +71,8 @@ export function createSnapshotStore({ max = 10, now = () => Date.now() } = {}) {
26
71
  }
27
72
  items.unshift(entry)
28
73
  if (items.length > Math.max(1, max)) items = items.slice(0, Math.max(1, max))
74
+ while (items.length > 1 && bytesOf(items) > Math.max(1, maxBytes)) items.pop()
75
+ save()
29
76
  return meta(entry)
30
77
  },
31
78
  list() {
@@ -36,6 +83,12 @@ export function createSnapshotStore({ max = 10, now = () => Date.now() } = {}) {
36
83
  },
37
84
  clear() {
38
85
  items = []
86
+ save()
87
+ },
88
+ stats() {
89
+ let bytes = bytesOf(items)
90
+ try { bytes = Math.max(bytes, statSync(file).size) } catch { /* file may not exist yet */ }
91
+ return { count: items.length, bytes, path: file, maxBytes }
39
92
  },
40
93
  }
41
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",