@goodandready/dsh-subscriptions 0.7.7 → 0.7.9

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.
@@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises'
6
6
  export function parseArgs(argv) {
7
7
  const args = Array.isArray(argv) ? argv.slice() : []
8
8
  const command = args.shift() || 'status'
9
- const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '' }
9
+ const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '', vaultAction: '', vaultArg: '' }
10
10
  for (let i = 0; i < args.length; i += 1) {
11
11
  const arg = args[i]
12
12
  if (arg === '--csv') options.csv = true
@@ -18,6 +18,8 @@ export function parseArgs(argv) {
18
18
  else if (command === 'set') options.setValue = arg
19
19
  else if (command === 'packs' && arg === 'add') options.packAction = 'add'
20
20
  else if (command === 'packs' && options.packAction === 'add' && !options.packFile) options.packFile = arg
21
+ else if (command === 'vault' && !options.vaultAction) options.vaultAction = arg
22
+ else if (command === 'vault' && !options.vaultArg) options.vaultArg = arg
21
23
  else if (command === 'fast' && !options.sessionId) options.sessionId = arg
22
24
  else if (command === 'fast') options.mode = arg === 'standard' || arg === 'off' ? 'standard' : 'fast'
23
25
  }
@@ -72,6 +74,15 @@ export function buildRequest(options, env = process.env) {
72
74
  if (options.command === 'sources') return { url: base + '/sources', init: { method: 'GET', headers } }
73
75
  if (options.command === 'packs') return { url: base + '/config', init: { method: 'GET', headers } }
74
76
  if (options.command === 'diagnostics') return { url: base + '/diagnostics', init: { method: 'GET', headers } }
77
+ if (options.command === 'vault') {
78
+ if (options.vaultAction === 'restore') {
79
+ return { url: base + '/vault/snapshots/restore', init: { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ id: options.vaultArg }) } }
80
+ }
81
+ if (options.vaultAction === 'snapshot') {
82
+ return { url: base + '/vault/snapshots', init: { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ label: options.vaultArg || 'manual' }) } }
83
+ }
84
+ return { url: base + '/vault/snapshots', init: { method: 'GET', headers } }
85
+ }
75
86
  throw new Error('unknown command: ' + options.command)
76
87
  }
77
88
 
@@ -112,6 +123,17 @@ export function formatPacks(packs) {
112
123
  return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
113
124
  }
114
125
 
126
+ export function formatSnapshotStatus(payload) {
127
+ const stats = (payload && payload.stats) || {}
128
+ return 'snapshots: ' + (stats.count || 0) + ' bytes: ' + (stats.bytes || 0) + ' path: ' + (stats.path || '-')
129
+ }
130
+
131
+ export function formatSnapshots(payload) {
132
+ const rows = (payload && payload.snapshots) || []
133
+ if (!rows.length) return 'no vault snapshots'
134
+ return rows.map((s) => s.id + ' ' + s.createdAt + ' ' + (s.label || '-') + ' ' + s.slotCount + ' slots').join('\n')
135
+ }
136
+
115
137
  export function mergeProviderPack(packs, pack) {
116
138
  const list = (Array.isArray(packs) ? packs : []).filter((p) => p && p.id !== pack.id)
117
139
  return [...list, pack]
@@ -197,6 +219,15 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
197
219
  const res = await fetchImpl(request.url, request.init)
198
220
  const text = await res.text()
199
221
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 500))
222
+ if (options.command === 'vault' && !options.json) {
223
+ try {
224
+ const payload = JSON.parse(text || '{}')
225
+ if (options.vaultAction === 'status') return { text: formatSnapshotStatus(payload), contentType: 'text/plain' }
226
+ if (options.vaultAction === 'snapshots' || !options.vaultAction) return { text: formatSnapshots(payload), contentType: 'text/plain' }
227
+ if (options.vaultAction === 'snapshot') return { text: 'created ' + (payload.snapshot && payload.snapshot.id), contentType: 'text/plain' }
228
+ if (options.vaultAction === 'restore') return { text: 'restored ' + (payload.slotCount || 0) + ' slots', contentType: 'text/plain' }
229
+ } catch { /* fall back to raw */ }
230
+ }
200
231
  if (options.command === 'packs' && options.packAction !== 'add') {
201
232
  try {
202
233
  const payload = JSON.parse(text || '{}')
@@ -212,7 +243,7 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
212
243
  async function main() {
213
244
  const result = await run()
214
245
  if (result.help) {
215
- process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|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 doctor [--json]\n')
246
+ 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')
216
247
  return
217
248
  }
218
249
  process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
package/lib/client.js CHANGED
@@ -1843,6 +1843,7 @@ window.__ModuleLoader__.load({
1843
1843
  h('div', { className: 'dsub-sub' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
1844
1844
  h(SourcesPanel, { t }),
1845
1845
  h(ProviderPacksPanel),
1846
+ h(VaultSnapshotsPanel),
1846
1847
 
1847
1848
  h('div', { className: 'dsub-row' },
1848
1849
  h('span', { className: 'dsub-h' }, t('composerQuota')),
@@ -3710,6 +3711,51 @@ window.__ModuleLoader__.load({
3710
3711
  )
3711
3712
  }
3712
3713
 
3714
+ // #736: vault snapshot list with create/restore. Payload stays server-side.
3715
+ function VaultSnapshotsPanel() {
3716
+ const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
3717
+ const pull = () => {
3718
+ fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
3719
+ .then((res) => res.json())
3720
+ .then((data) => setState((st) => Object.assign({}, st, { snapshots: data.snapshots || [], stats: data.stats || null, error: null })))
3721
+ .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
3722
+ }
3723
+ React.useEffect(() => { pull() }, [])
3724
+ const call = (url, body) => fetch(url, {
3725
+ method: 'POST',
3726
+ headers: { 'content-type': 'application/json' },
3727
+ body: JSON.stringify(body || {}),
3728
+ }).then(async (res) => {
3729
+ const data = await res.json().catch(() => ({}))
3730
+ if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3731
+ return data
3732
+ })
3733
+ const create = () => {
3734
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3735
+ call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
3736
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: 'created ' + data.snapshot.id })); pull() })
3737
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3738
+ }
3739
+ const restore = (id) => {
3740
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3741
+ call('/dsh-subscriptions/vault/snapshots/restore', { id })
3742
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: 'restored ' + data.slotCount + ' slots' })); pull() })
3743
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3744
+ }
3745
+ return h('div', { className: 'dsub-section-card' },
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
+ 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,
3749
+ state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
3750
+ h('span', { className: 'dsub-h' }, snap.id),
3751
+ h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
3752
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, 'Restore'),
3753
+ )) : h('div', { className: 'dsub-dim' }, 'No snapshots yet.'),
3754
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3755
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3756
+ )
3757
+ }
3758
+
3713
3759
  // #724: provider pack manager. Displays ids/models only; headers are masked.
3714
3760
  function ProviderPacksPanel() {
3715
3761
  const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
@@ -0,0 +1,88 @@
1
+ import { Config, plainConfig, revisionOf } from '../config-schema.js'
2
+ import { isTrustedSettingsRequest, readBody, safeJsonHandler, writeJson } from '../http.js'
3
+ import { capturePrePullSnapshot, restoreVaultSnapshot } from '../vault-sync.js'
4
+ import { defaultSnapshotStore } from '../vault-snapshots.js'
5
+
6
+ export function registerVaultRoutes(ctx, state) {
7
+ const { live, getSettingsApi, syncAdapter, stripLegacySlots } = state
8
+
9
+ ctx.effect(() => ctx.webServer.register({
10
+ kind: 'exact',
11
+ path: '/dsh-subscriptions/vault/snapshots',
12
+ handler: safeJsonHandler(async (req, res) => {
13
+ if (!isTrustedSettingsRequest(req, ctx)) {
14
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
15
+ return
16
+ }
17
+ if (req.method === 'GET') {
18
+ writeJson(res, 200, { ok: true, snapshots: defaultSnapshotStore.list(), stats: defaultSnapshotStore.stats() })
19
+ return
20
+ }
21
+ if (req.method !== 'POST') {
22
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
23
+ return
24
+ }
25
+ let body = {}
26
+ try { body = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
27
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
28
+ return
29
+ }
30
+ const passphrase = String(live().vaultSyncPassphrase || '').trim()
31
+ if (!passphrase) {
32
+ writeJson(res, 400, { ok: false, error: { code: 'vault', message: 'vaultSyncPassphrase is required' } })
33
+ return
34
+ }
35
+ try {
36
+ const snapshot = capturePrePullSnapshot(live().slots || [], passphrase, String(body.label || 'manual'))
37
+ writeJson(res, 200, { ok: true, snapshot })
38
+ } catch (err) {
39
+ writeJson(res, 400, { ok: false, error: { code: 'vault', message: String(err && err.message || err) } })
40
+ }
41
+ }),
42
+ }), 'dsh-subscriptions: /vault/snapshots')
43
+
44
+ ctx.effect(() => ctx.webServer.register({
45
+ kind: 'exact',
46
+ path: '/dsh-subscriptions/vault/snapshots/restore',
47
+ handler: safeJsonHandler(async (req, res) => {
48
+ if (req.method !== 'POST') {
49
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
50
+ return
51
+ }
52
+ if (!isTrustedSettingsRequest(req, ctx)) {
53
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
54
+ return
55
+ }
56
+ let body = {}
57
+ try { body = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
58
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
59
+ return
60
+ }
61
+ const snapshot = defaultSnapshotStore.get(String(body.id || ''))
62
+ if (!snapshot) {
63
+ writeJson(res, 404, { ok: false, error: { code: 'snapshot', message: 'snapshot not found' } })
64
+ return
65
+ }
66
+ const passphrase = String(live().vaultSyncPassphrase || '').trim()
67
+ if (!passphrase) {
68
+ writeJson(res, 400, { ok: false, error: { code: 'vault', message: 'vaultSyncPassphrase is required' } })
69
+ return
70
+ }
71
+ const api = getSettingsApi()
72
+ if (!api) {
73
+ writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
74
+ return
75
+ }
76
+ try {
77
+ const restored = restoreVaultSnapshot(snapshot, passphrase)
78
+ const cfg = live()
79
+ const slots = stripLegacySlots(restored.slots || [])
80
+ await api.replace(plainConfig(Config({ ...cfg, slots })), revisionOf(api))
81
+ if (typeof syncAdapter === 'function') await syncAdapter()
82
+ writeJson(res, 200, { ok: true, id: snapshot.id, slotCount: slots.length })
83
+ } catch (err) {
84
+ writeJson(res, 400, { ok: false, error: { code: 'vault', message: String(err && err.message || err) } })
85
+ }
86
+ }),
87
+ }), 'dsh-subscriptions: /vault/snapshots/restore')
88
+ }
package/lib/routes.js CHANGED
@@ -8,6 +8,7 @@ import { registerProxyRoutes } from './routes/proxy.js'
8
8
  import { registerSourcesRoutes } from './routes/sources.js'
9
9
  import { registerPacksRoutes } from './routes/packs.js'
10
10
  import { registerWebhookRoutes } from './routes/webhook.js'
11
+ import { registerVaultRoutes } from './routes/vault.js'
11
12
  import { registerPluginUpdater } from './updater.js'
12
13
 
13
14
  export function registerRoutes(ctx, state) {
@@ -115,4 +116,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
115
116
  registerSourcesRoutes(ctx, state)
116
117
  registerPacksRoutes(ctx)
117
118
  registerWebhookRoutes(ctx, state)
119
+ registerVaultRoutes(ctx, state)
118
120
  }
@@ -0,0 +1,96 @@
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
+ }
13
+ let seq = 0
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
+
23
+ function meta(snapshot) {
24
+ return {
25
+ id: snapshot.id,
26
+ label: snapshot.label || '',
27
+ createdAt: snapshot.createdAt,
28
+ version: snapshot.version || 2,
29
+ slotCount: Number(snapshot.slotCount) || 0,
30
+ blobCount: Number(snapshot.blobCount) || 0,
31
+ }
32
+ }
33
+
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) }
62
+ return {
63
+ path: file,
64
+ add(snapshot, label = '') {
65
+ const at = now()
66
+ const entry = {
67
+ ...snapshot,
68
+ id: 'vs_' + at.toString(36) + '_' + (++seq).toString(36),
69
+ label: String(label || snapshot.label || ''),
70
+ createdAt: snapshot.createdAt || new Date(at).toISOString(),
71
+ }
72
+ items.unshift(entry)
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()
76
+ return meta(entry)
77
+ },
78
+ list() {
79
+ return items.map(meta)
80
+ },
81
+ get(id) {
82
+ return items.find((item) => item.id === id) || null
83
+ },
84
+ clear() {
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 }
92
+ },
93
+ }
94
+ }
95
+
96
+ export const defaultSnapshotStore = createSnapshotStore({ max: 10 })
package/lib/vault-sync.js CHANGED
@@ -6,6 +6,7 @@ import { exportVault, importVault } from './vault.js'
6
6
  import { normalizeSlots } from './accounts.js'
7
7
  import { Config, plainConfig, revisionOf } from './config-schema.js'
8
8
  import { validateCredentialBlob } from './blob.js'
9
+ import { defaultSnapshotStore } from './vault-snapshots.js'
9
10
 
10
11
  let syncStatus = {
11
12
  enabled: false,
@@ -273,6 +274,10 @@ export function createVaultSnapshot({ slots, blobs = {} }, passphrase, label = '
273
274
  return { version: 2, label: String(label || ''), createdAt: new Date().toISOString(), vault, slotCount, blobCount }
274
275
  }
275
276
 
277
+ export function capturePrePullSnapshot(slots, passphrase, label = 'pre-pull') {
278
+ return defaultSnapshotStore.add(createVaultSnapshot({ slots: slots || [], blobs: {} }, passphrase, label))
279
+ }
280
+
276
281
  export function restoreVaultSnapshot(snapshot, passphrase) {
277
282
  if (!snapshot || typeof snapshot.vault !== 'string' || !snapshot.vault) throw new Error('snapshot is missing its encrypted vault')
278
283
  return importVault(snapshot.vault, passphrase)
@@ -401,6 +406,7 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
401
406
  throw new Error(`unsupported vault sync backend: ${backend}`)
402
407
  }
403
408
 
409
+ try { capturePrePullSnapshot(cfg.slots || [], passphrase) } catch { /* snapshot is best-effort */ }
404
410
  const result = importVault(encryptedData, passphrase)
405
411
  const providerFilter = new Set((cfg.vaultSyncProviders || []).map((p) => String(p)))
406
412
  const remoteSlots = providerFilter.size
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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",