@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.
- package/bin/dsh-subscriptions.mjs +78 -3
- package/lib/alerts.js +34 -1
- package/lib/client.js +11 -0
- package/lib/config-schema.js +8 -0
- package/lib/device-identity.js +41 -0
- package/lib/index.js +3 -2
- package/lib/vault-sync.js +60 -37
- package/package.json +1 -1
|
@@ -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
|
|
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 =
|
|
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', {
|
package/lib/config-schema.js
CHANGED
|
@@ -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
|
-
|
|
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[
|
|
137
|
+
const file = json.files && json.files[fileName]
|
|
137
138
|
if (!file || !file.content) {
|
|
138
|
-
throw new Error('
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 || [],
|
|
410
|
-
const result = importVault(encryptedData,
|
|
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.
|
|
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",
|