@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.
- package/bin/dsh-subscriptions.mjs +9 -1
- package/lib/client.js +3 -2
- package/lib/health-report.js +21 -0
- package/lib/metrics.js +62 -0
- package/lib/routes/metrics.js +33 -0
- package/lib/routes/sources.js +28 -25
- package/lib/routes/status.js +15 -1
- package/lib/routes/vault.js +1 -1
- package/lib/routes.js +2 -0
- package/lib/vault-snapshots.js +57 -4
- package/package.json +1 -1
|
@@ -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
|
|
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
|
+
}
|
package/lib/routes/sources.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
}
|
package/lib/routes/status.js
CHANGED
|
@@ -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
|
-
|
|
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')
|
package/lib/routes/vault.js
CHANGED
|
@@ -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,'&').replace(/</g,'
|
|
|
117
118
|
registerPacksRoutes(ctx)
|
|
118
119
|
registerWebhookRoutes(ctx, state)
|
|
119
120
|
registerVaultRoutes(ctx, state)
|
|
121
|
+
registerMetricsRoutes(ctx, state)
|
|
120
122
|
}
|
package/lib/vault-snapshots.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
// #
|
|
2
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
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",
|