@goodandready/dsh-subscriptions 0.7.10 → 0.7.11

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,11 +6,12 @@ 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: '', vaultAction: '', vaultArg: '' }
9
+ const options = { command, csv: false, json: false, once: 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
13
13
  else if (arg === '--json') options.json = true
14
+ else if (arg === '--once') options.once = true
14
15
  else if (arg === '--help' || arg === '-h') options.help = true
15
16
  else if (arg === '--type') options.type = args[++i] || 'accounts'
16
17
  else if (arg === '--session') options.sessionId = args[++i] || ''
@@ -125,6 +126,36 @@ export function formatPacks(packs) {
125
126
  return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
126
127
  }
127
128
 
129
+ export function renderTui(data, { color = false } = {}) {
130
+ const health = (data && data.health) || {}
131
+ const checks = health.checks || {}
132
+ const sources = (data && data.sources) || {}
133
+ const snapshots = (data && data.snapshots && data.snapshots.stats) || {}
134
+ const accounts = Array.isArray(data && data.accounts) ? data.accounts : []
135
+ const dim = (text) => color ? '\x1b[2m' + text + '\x1b[0m' : text
136
+ const green = (text) => color ? '\x1b[32m' + text + '\x1b[0m' : text
137
+ const red = (text) => color ? '\x1b[31m' + text + '\x1b[0m' : text
138
+ const lines = []
139
+ lines.push('DSH Subscriptions' + (health.version ? ' ' + health.version : '') + ' ' + (health.status === 'healthy' ? green('healthy') : red(health.status || 'unknown')))
140
+ lines.push(dim('accounts: ' + (checks.connectedAccounts || 0) + ' configured · key-rotation: ' + (checks.keyRotation ? 'yes' : 'no') + ' · ollama: ' + (checks.ollama ? 'yes' : 'no') + ' · webhook: ' + (checks.webhook ? 'yes' : 'no')))
141
+ lines.push('')
142
+ lines.push('ACCOUNTS')
143
+ for (const account of accounts) {
144
+ if (!account || !account.provider) continue
145
+ const usage = account.usagePercent == null ? '-' : Math.round(account.usagePercent) + '%'
146
+ lines.push(' ' + String(account.provider).padEnd(14) + '#' + (account.index || 1) + ' ' + (account.configured ? green('ok') : dim('off')) + ' ' + usage)
147
+ }
148
+ lines.push('')
149
+ lines.push('SOURCES')
150
+ for (const row of (sources.providers || [])) lines.push(' ' + String(row.provider).padEnd(14) + (row.selected || dim('none')))
151
+ lines.push('')
152
+ lines.push('VAULT')
153
+ lines.push(' snapshots: ' + (snapshots.count || 0) + ' · ' + (snapshots.bytes || 0) + ' bytes')
154
+ lines.push('')
155
+ lines.push(dim('r refresh · q quit'))
156
+ return lines.join('\n')
157
+ }
158
+
128
159
  export function formatSnapshotStatus(payload) {
129
160
  const stats = (payload && payload.stats) || {}
130
161
  return 'snapshots: ' + (stats.count || 0) + ' bytes: ' + (stats.bytes || 0) + ' path: ' + (stats.path || '-')
@@ -176,6 +207,21 @@ export function buildSetRequest(options, configPayload, env = process.env) {
176
207
  export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
177
208
  const options = parseArgs(argv)
178
209
  if (options.help) return { help: true }
210
+ if (options.command === 'tui') {
211
+ const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
212
+ const headers = { Accept: 'application/json' }
213
+ if (env.DSH_AUTH_TOKEN) headers.Authorization = 'Bearer ' + env.DSH_AUTH_TOKEN
214
+ const get = (path) => fetchImpl(base + path, { method: 'GET', headers }).then(async (res) => {
215
+ const text = await res.text()
216
+ if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 300))
217
+ return JSON.parse(text || '{}')
218
+ })
219
+ const [health, sources, snapshots, config] = await Promise.all([
220
+ get('/health'), get('/sources'), get('/vault/snapshots'), get('/config'),
221
+ ])
222
+ const data = { health, sources, snapshots, accounts: (config && config.accounts) || [] }
223
+ return { text: renderTui(data, { color: false }), tuiData: data, contentType: 'text/plain' }
224
+ }
179
225
  if (options.command === 'doctor') {
180
226
  const diagReq = buildRequest({ command: 'diagnostics' }, env)
181
227
  const srcReq = buildRequest({ command: 'sources' }, env)
@@ -242,12 +288,41 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
242
288
  return { text, contentType: res.headers.get('content-type') || '' }
243
289
  }
244
290
 
291
+ async function runTui(initial) {
292
+ let cleanup = () => {}
293
+ const paint = (data) => process.stdout.write('\x1b[2J\x1b[H' + renderTui(data, { color: true }) + '\n')
294
+ paint(initial)
295
+ const onKey = async (buf) => {
296
+ const key = String(buf)
297
+ if (key === 'q' || key === '\u0003') { cleanup(); process.exit(0) }
298
+ if (key === 'r') {
299
+ try {
300
+ const next = await run(['tui', '--once'])
301
+ if (next.tuiData) paint(next.tuiData)
302
+ } catch (err) { process.stdout.write('\n' + String(err && err.message || err) + '\n') }
303
+ }
304
+ }
305
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
306
+ process.stdin.setRawMode(true)
307
+ process.stdin.resume()
308
+ process.stdin.on('data', onKey)
309
+ cleanup = () => {
310
+ process.stdin.removeListener('data', onKey)
311
+ process.stdin.setRawMode(false)
312
+ process.stdin.pause()
313
+ }
314
+ }
315
+ }
316
+
245
317
  async function main() {
246
- const result = await run()
318
+ const argv = process.argv.slice(2)
319
+ const options = parseArgs(argv)
320
+ const result = await run(argv)
247
321
  if (result.help) {
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')
322
+ process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|vault|tui|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 tui [--once]\n doctor [--json]\n')
249
323
  return
250
324
  }
325
+ if (result.tuiData && !options.once) { await runTui(result.tuiData); return }
251
326
  process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
252
327
  if (result.code) process.exitCode = result.code
253
328
  }
package/lib/alerts.js CHANGED
@@ -16,6 +16,7 @@ try {
16
16
  const MAX_ALERTS = 100
17
17
  let defaultWebhookSecret = ''
18
18
  let defaultAllowPrivate = false
19
+ let defaultWebhookFormat = 'native'
19
20
 
20
21
  export function setWebhookSecret(secret) {
21
22
  defaultWebhookSecret = String(secret || '')
@@ -24,6 +25,35 @@ export function setWebhookSecret(secret) {
24
25
  export function setWebhookAllowPrivate(allow) {
25
26
  defaultAllowPrivate = Boolean(allow)
26
27
  }
28
+
29
+ export function setWebhookFormat(format) {
30
+ defaultWebhookFormat = format === 'notify' ? 'notify' : 'native'
31
+ }
32
+
33
+ export function toNotifyPayload(body) {
34
+ const sentAt = new Date().toISOString()
35
+ if (body && body.alert) {
36
+ return {
37
+ kind: body.alert.severity === 'info' ? 'task_done' : 'error',
38
+ title: 'DSH Subscriptions',
39
+ sessionId: 'dsh-subscriptions',
40
+ summary: body.alert.message || body.alert.type || 'subscription alert',
41
+ reason: body.alert.type || null,
42
+ source: 'dsh-subscriptions',
43
+ sentAt,
44
+ }
45
+ }
46
+ const digest = (body && body.digest) || { total: 0 }
47
+ return {
48
+ kind: digest.total > 0 ? 'error' : 'task_done',
49
+ title: 'DSH Subscriptions digest',
50
+ sessionId: 'dsh-subscriptions',
51
+ summary: String(digest.total || 0) + ' alerts in the digest',
52
+ reason: 'digest',
53
+ source: 'dsh-subscriptions',
54
+ sentAt,
55
+ }
56
+ }
27
57
  const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
28
58
 
29
59
  const alertBuffer = []
@@ -106,10 +136,13 @@ async function dispatchNotification(body, webhookUrl, fetchImpl, options) {
106
136
  const { retries = 1, timeoutMs = 5000, sleep } = opts
107
137
  const secret = opts.secret == null ? defaultWebhookSecret : opts.secret
108
138
  const allowPrivate = opts.allowPrivate == null ? defaultAllowPrivate : Boolean(opts.allowPrivate)
139
+ const format = opts.format == null ? defaultWebhookFormat : opts.format
109
140
  const checked = validateWebhookUrl(webhookUrl, { allowPrivate })
110
141
  if (!checked.ok) return false
111
142
  webhookUrl = checked.url
112
- const payload = { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
143
+ const payload = format === 'notify'
144
+ ? toNotifyPayload(body)
145
+ : { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
113
146
  const raw = JSON.stringify(payload)
114
147
  const headers = {
115
148
  'content-type': 'application/json',
package/lib/client.js CHANGED
@@ -1885,6 +1885,17 @@ window.__ModuleLoader__.load({
1885
1885
  }),
1886
1886
  ),
1887
1887
  h('div', { className: 'dsub-sub' }, 'Signed POST target for quota, session and billing alerts.'),
1888
+ h('div', { className: 'dsub-row' },
1889
+ h('span', { className: 'dsub-h' }, 'Webhook format'),
1890
+ h('select', {
1891
+ className: 'dsub-select',
1892
+ value: draft.webhookFormat || 'native',
1893
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookFormat: e.target.value })),
1894
+ },
1895
+ h('option', { value: 'native' }, 'Native'),
1896
+ h('option', { value: 'notify' }, 'dsh-plugin-notify compatible'),
1897
+ ),
1898
+ ),
1888
1899
  h('div', { className: 'dsub-row' },
1889
1900
  h('span', { className: 'dsub-h' }, 'Webhook secret'),
1890
1901
  h('input', {
@@ -161,6 +161,8 @@ export const Config = z.object({
161
161
  .description('#720 HMAC-SHA256 secret used to sign webhook payloads.'),
162
162
  webhookAllowPrivate: z.boolean().volatile().default(false)
163
163
  .description('#729 Allow webhook URLs that resolve to private or loopback hosts.'),
164
+ webhookFormat: z.string().volatile().default('native')
165
+ .description('#751 Webhook payload format: native or notify (dsh-plugin-notify compatible).'),
164
166
  digestIntervalHours: z.number().volatile().default(0)
165
167
  .description('#720 Send an alert digest every N hours (0 disables digests).'),
166
168
  alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
@@ -189,6 +191,12 @@ export const Config = z.object({
189
191
  .description('#562 Remote storage backend: webdav, gist, or s3.'),
190
192
  vaultSyncPassphrase: z.string().role('secret').default('')
191
193
  .description('#562 Passphrase used to encrypt/decrypt the remote vault archive.'),
194
+ vaultDeviceId: z.string().volatile().default('')
195
+ .description('#749 Stable per-device vault id. Empty derives one from the host.'),
196
+ vaultDeviceLabel: z.string().volatile().default('')
197
+ .description('#749 Human-readable device label for vault metadata.'),
198
+ vaultPerDeviceKeys: z.boolean().volatile().default(true)
199
+ .description('#749 Derive a per-device passphrase and remote object; legacy shared vault stays readable.'),
192
200
  vaultSyncProviders: z.array(z.string()).volatile().default([])
193
201
  .description('#691 Optional provider allowlist for selective vault sync. Empty means all providers.'),
194
202
  vaultSyncIntervalHours: z.number().volatile().default(24)
@@ -0,0 +1,41 @@
1
+ // #749: stable per-device identity. The passphrase is derived per device so
2
+ // one device cannot decrypt another device's remote object with only the base
3
+ // passphrase; the legacy shared object remains readable as a fallback.
4
+ import { createHash } from 'node:crypto'
5
+ import { hostname, platform, arch } from 'node:os'
6
+
7
+ export function resolveDeviceIdentity({ id = '', label = '' } = {}) {
8
+ const host = hostname()
9
+ const derived = createHash('sha256').update(host + '|' + platform() + '|' + arch()).digest('hex').slice(0, 12)
10
+ const clean = String(id || derived).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32)
11
+ return { id: clean || derived, label: String(label || host) }
12
+ }
13
+
14
+ export function devicePassphrase(passphrase, deviceId) {
15
+ return deviceId ? String(passphrase) + '#' + String(deviceId) : String(passphrase)
16
+ }
17
+
18
+ export function withDeviceSuffix(name, deviceId) {
19
+ if (!deviceId) return name
20
+ const match = String(name).match(/^(.*?)(.[^./]+)?$/)
21
+ return (match && match[1] ? match[1] : String(name)) + '.' + deviceId + (match && match[2] ? match[2] : '')
22
+ }
23
+
24
+ export function deviceWebdavUrl(url, deviceId) {
25
+ if (!url || !deviceId) return url
26
+ try {
27
+ const parsed = new URL(url)
28
+ parsed.pathname = withDeviceSuffix(parsed.pathname, deviceId)
29
+ return parsed.toString()
30
+ } catch {
31
+ return url
32
+ }
33
+ }
34
+
35
+ export function deviceGistName(deviceId) {
36
+ return withDeviceSuffix('dsh-subscriptions-vault.enc', deviceId)
37
+ }
38
+
39
+ export function deviceS3Key(key, deviceId) {
40
+ return withDeviceSuffix(key || 'dsh-subscriptions-vault.enc', deviceId)
41
+ }
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@ import { createAccountsView } from './accounts-view.js'
28
28
  import { inspectScopes } from './scope-inspector.js'
29
29
  import { createAdapterManager } from './adapter-manager.js'
30
30
  import { pickAccount, isAuthError } from './rotate.js'
31
- import { notifySessionExpired, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret, setWebhookAllowPrivate } from './alerts.js'
31
+ import { notifySessionExpired, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret, setWebhookAllowPrivate, setWebhookFormat } from './alerts.js'
32
32
  import { isDigestDue } from './notification-scheduler.js'
33
33
 
34
34
  export { observeForecast, estimateForecast } from './forecast.js'
@@ -95,6 +95,7 @@ export function apply(ctx, rawConfig) {
95
95
  const live = () => {
96
96
  setWebhookSecret(cachedConfig.webhookSecret)
97
97
  setWebhookAllowPrivate(cachedConfig.webhookAllowPrivate)
98
+ setWebhookFormat(cachedConfig.webhookFormat)
98
99
  return cachedConfig
99
100
  }
100
101
 
@@ -616,7 +617,7 @@ export function apply(ctx, rawConfig) {
616
617
  const cfg = live()
617
618
  const alerts = getRecentAlerts(100)
618
619
  if (!isDigestDue({ lastDigestAt, nowMs: Date.now(), intervalHours: cfg.digestIntervalHours, alertCount: alerts.length })) return
619
- dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret, allowPrivate: cfg.webhookAllowPrivate })
620
+ dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret, allowPrivate: cfg.webhookAllowPrivate, format: cfg.webhookFormat })
620
621
  .then((ok) => { if (ok) lastDigestAt = Date.now() })
621
622
  .catch(() => {})
622
623
  }
package/lib/vault-sync.js CHANGED
@@ -7,6 +7,7 @@ import { normalizeSlots } from './accounts.js'
7
7
  import { Config, plainConfig, revisionOf } from './config-schema.js'
8
8
  import { validateCredentialBlob } from './blob.js'
9
9
  import { defaultSnapshotStore } from './vault-snapshots.js'
10
+ import { resolveDeviceIdentity, devicePassphrase, deviceWebdavUrl, deviceGistName, deviceS3Key } from './device-identity.js'
10
11
 
11
12
  let syncStatus = {
12
13
  enabled: false,
@@ -75,7 +76,7 @@ export async function pullFromWebdav({ url, username, password, fetchFn = fetch
75
76
 
76
77
  // --- Driver: Gist (GitHub / Gitea) ---
77
78
 
78
- export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fetchFn = fetch }) {
79
+ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fileName = 'dsh-subscriptions-vault.enc', fetchFn = fetch }) {
79
80
  if (!token) throw new Error('Gist access token is required')
80
81
  const cleanBase = baseUrl.replace(/\/+$/, '')
81
82
  const headers = {
@@ -86,7 +87,7 @@ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, t
86
87
  }
87
88
 
88
89
  const files = {
89
- 'dsh-subscriptions-vault.enc': { content: encryptedData },
90
+ [fileName]: { content: encryptedData },
90
91
  }
91
92
 
92
93
  if (gistId) {
@@ -118,7 +119,7 @@ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, t
118
119
  return { ok: true, gistId: json.id }
119
120
  }
120
121
 
121
- export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fetchFn = fetch }) {
122
+ export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fileName = 'dsh-subscriptions-vault.enc', fetchFn = fetch }) {
122
123
  if (!gistId) throw new Error('Gist ID is required')
123
124
  const cleanBase = baseUrl.replace(/\/+$/, '')
124
125
  const headers = {
@@ -133,9 +134,9 @@ export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId,
133
134
  })
134
135
  if (!res.ok) throw new Error(`Gist pull failed: HTTP ${res.status} ${res.statusText}`)
135
136
  const json = await res.json()
136
- const file = json.files && json.files['dsh-subscriptions-vault.enc']
137
+ const file = json.files && json.files[fileName]
137
138
  if (!file || !file.content) {
138
- throw new Error('dsh-subscriptions-vault.enc not found in Gist')
139
+ throw new Error(fileName + ' not found in Gist')
139
140
  }
140
141
  return file.content
141
142
  }
@@ -308,12 +309,15 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
308
309
  } catch { /* ignore */ }
309
310
  }
310
311
 
311
- const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, passphrase)
312
+ const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
313
+ const perDevice = cfg.vaultPerDeviceKeys !== false
314
+ const activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
315
+ const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, activePass)
312
316
  const backend = cfg.vaultSyncBackend || 'webdav'
313
317
 
314
318
  if (backend === 'webdav') {
315
319
  await pushToWebdav({
316
- url: cfg.vaultSyncWebdavUrl,
320
+ url: perDevice ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
317
321
  username: cfg.vaultSyncWebdavUsername,
318
322
  password: cfg.vaultSyncWebdavPassword,
319
323
  encryptedData: vault,
@@ -325,6 +329,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
325
329
  gistId: cfg.vaultSyncGistId,
326
330
  token: cfg.vaultSyncGistToken,
327
331
  encryptedData: vault,
332
+ fileName: perDevice ? deviceGistName(device.id) : undefined,
328
333
  fetchFn,
329
334
  })
330
335
  if (res.gistId && !cfg.vaultSyncGistId) {
@@ -334,7 +339,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
334
339
  await pushToS3({
335
340
  endpoint: cfg.vaultSyncS3Endpoint,
336
341
  bucket: cfg.vaultSyncS3Bucket,
337
- key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
342
+ key: perDevice ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
338
343
  region: cfg.vaultSyncS3Region || 'us-east-1',
339
344
  accessKey: cfg.vaultSyncS3AccessKey,
340
345
  secretKey: cfg.vaultSyncS3SecretKey,
@@ -376,38 +381,53 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
376
381
 
377
382
  try {
378
383
  const backend = cfg.vaultSyncBackend || 'webdav'
379
- let encryptedData = ''
380
-
381
- if (backend === 'webdav') {
382
- encryptedData = await pullFromWebdav({
383
- url: cfg.vaultSyncWebdavUrl,
384
- username: cfg.vaultSyncWebdavUsername,
385
- password: cfg.vaultSyncWebdavPassword,
386
- fetchFn,
387
- })
388
- } else if (backend === 'gist') {
389
- encryptedData = await pullFromGist({
390
- baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
391
- gistId: cfg.vaultSyncGistId,
392
- token: cfg.vaultSyncGistToken,
393
- fetchFn,
394
- })
395
- } else if (backend === 's3') {
396
- encryptedData = await pullFromS3({
397
- endpoint: cfg.vaultSyncS3Endpoint,
398
- bucket: cfg.vaultSyncS3Bucket,
399
- key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
400
- region: cfg.vaultSyncS3Region || 'us-east-1',
401
- accessKey: cfg.vaultSyncS3AccessKey,
402
- secretKey: cfg.vaultSyncS3SecretKey,
403
- fetchFn,
404
- })
405
- } else {
384
+ const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
385
+ const perDevice = cfg.vaultPerDeviceKeys !== false
386
+ let activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
387
+ let usedLegacy = false
388
+ const load = async (deviceMode) => {
389
+ if (backend === 'webdav') {
390
+ return pullFromWebdav({
391
+ url: deviceMode ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
392
+ username: cfg.vaultSyncWebdavUsername,
393
+ password: cfg.vaultSyncWebdavPassword,
394
+ fetchFn,
395
+ })
396
+ }
397
+ if (backend === 'gist') {
398
+ return pullFromGist({
399
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
400
+ gistId: cfg.vaultSyncGistId,
401
+ token: cfg.vaultSyncGistToken,
402
+ fileName: deviceMode ? deviceGistName(device.id) : undefined,
403
+ fetchFn,
404
+ })
405
+ }
406
+ if (backend === 's3') {
407
+ return pullFromS3({
408
+ endpoint: cfg.vaultSyncS3Endpoint,
409
+ bucket: cfg.vaultSyncS3Bucket,
410
+ key: deviceMode ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
411
+ region: cfg.vaultSyncS3Region || 'us-east-1',
412
+ accessKey: cfg.vaultSyncS3AccessKey,
413
+ secretKey: cfg.vaultSyncS3SecretKey,
414
+ fetchFn,
415
+ })
416
+ }
406
417
  throw new Error(`unsupported vault sync backend: ${backend}`)
407
418
  }
419
+ let encryptedData
420
+ try {
421
+ encryptedData = await load(perDevice)
422
+ } catch (err) {
423
+ if (!perDevice) throw err
424
+ encryptedData = await load(false)
425
+ activePass = passphrase
426
+ usedLegacy = true
427
+ }
408
428
 
409
- try { capturePrePullSnapshot(cfg.slots || [], passphrase) } catch { /* snapshot is best-effort */ }
410
- const result = importVault(encryptedData, passphrase)
429
+ try { capturePrePullSnapshot(cfg.slots || [], activePass) } catch { /* snapshot is best-effort */ }
430
+ const result = importVault(encryptedData, activePass)
411
431
  const providerFilter = new Set((cfg.vaultSyncProviders || []).map((p) => String(p)))
412
432
  const remoteSlots = providerFilter.size
413
433
  ? result.slots.filter((slot) => providerFilter.has(String(slot && slot.provider)))
@@ -447,6 +467,9 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
447
467
  slotCount: result.slots.length,
448
468
  blobCount,
449
469
  conflicts: conflicts.length,
470
+ deviceId: device.id,
471
+ perDevice,
472
+ legacy: usedLegacy,
450
473
  exportedAt: result.exportedAt,
451
474
  timestamp: syncStatus.lastPullAt,
452
475
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
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",