@goodandready/dsh-subscriptions 0.7.7 → 0.7.8

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,12 @@ 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 formatSnapshots(payload) {
127
+ const rows = (payload && payload.snapshots) || []
128
+ if (!rows.length) return 'no vault snapshots'
129
+ return rows.map((s) => s.id + ' ' + s.createdAt + ' ' + (s.label || '-') + ' ' + s.slotCount + ' slots').join('\n')
130
+ }
131
+
115
132
  export function mergeProviderPack(packs, pack) {
116
133
  const list = (Array.isArray(packs) ? packs : []).filter((p) => p && p.id !== pack.id)
117
134
  return [...list, pack]
@@ -197,6 +214,14 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
197
214
  const res = await fetchImpl(request.url, request.init)
198
215
  const text = await res.text()
199
216
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 500))
217
+ if (options.command === 'vault' && !options.json) {
218
+ try {
219
+ const payload = JSON.parse(text || '{}')
220
+ if (options.vaultAction === 'snapshots' || !options.vaultAction) return { text: formatSnapshots(payload), contentType: 'text/plain' }
221
+ if (options.vaultAction === 'snapshot') return { text: 'created ' + (payload.snapshot && payload.snapshot.id), contentType: 'text/plain' }
222
+ if (options.vaultAction === 'restore') return { text: 'restored ' + (payload.slotCount || 0) + ' slots', contentType: 'text/plain' }
223
+ } catch { /* fall back to raw */ }
224
+ }
200
225
  if (options.command === 'packs' && options.packAction !== 'add') {
201
226
  try {
202
227
  const payload = JSON.parse(text || '{}')
@@ -212,7 +237,7 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
212
237
  async function main() {
213
238
  const result = await run()
214
239
  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')
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')
216
241
  return
217
242
  }
218
243
  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,50 @@ 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: [], 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 || [], 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.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
3749
+ h('span', { className: 'dsub-h' }, snap.id),
3750
+ h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
3751
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, 'Restore'),
3752
+ )) : h('div', { className: 'dsub-dim' }, 'No snapshots yet.'),
3753
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3754
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3755
+ )
3756
+ }
3757
+
3713
3758
  // #724: provider pack manager. Displays ids/models only; headers are masked.
3714
3759
  function ProviderPacksPanel() {
3715
3760
  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() })
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,43 @@
1
+ // #734: bounded in-memory snapshot ring. Only metadata leaves list(); the
2
+ // encrypted payload is available through get(id) for an explicit restore.
3
+ let seq = 0
4
+
5
+ function meta(snapshot) {
6
+ return {
7
+ id: snapshot.id,
8
+ label: snapshot.label || '',
9
+ createdAt: snapshot.createdAt,
10
+ version: snapshot.version || 2,
11
+ slotCount: Number(snapshot.slotCount) || 0,
12
+ blobCount: Number(snapshot.blobCount) || 0,
13
+ }
14
+ }
15
+
16
+ export function createSnapshotStore({ max = 10, now = () => Date.now() } = {}) {
17
+ let items = []
18
+ return {
19
+ add(snapshot, label = '') {
20
+ const at = now()
21
+ const entry = {
22
+ ...snapshot,
23
+ id: 'vs_' + at.toString(36) + '_' + (++seq).toString(36),
24
+ label: String(label || snapshot.label || ''),
25
+ createdAt: snapshot.createdAt || new Date(at).toISOString(),
26
+ }
27
+ items.unshift(entry)
28
+ if (items.length > Math.max(1, max)) items = items.slice(0, Math.max(1, max))
29
+ return meta(entry)
30
+ },
31
+ list() {
32
+ return items.map(meta)
33
+ },
34
+ get(id) {
35
+ return items.find((item) => item.id === id) || null
36
+ },
37
+ clear() {
38
+ items = []
39
+ },
40
+ }
41
+ }
42
+
43
+ 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.8",
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",