@goodandready/dsh-subscriptions 0.6.26 → 0.6.28

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/lib/images.js CHANGED
@@ -109,3 +109,128 @@ export async function generateOnce(options) {
109
109
  }
110
110
  return parseImages(payload)
111
111
  }
112
+
113
+ // Multimodal Payload Normalization for Vision Models
114
+ export function parseDataUri(uri) {
115
+ if (typeof uri !== 'string') return null
116
+ const m = /^data:([a-zA-Z0-9.+/-]+);base64,(.+)$/.exec(uri.trim())
117
+ if (!m) return null
118
+ return { mimeType: m[1], base64: m[2] }
119
+ }
120
+
121
+ export function normalizeImage(item) {
122
+ if (!item) return null
123
+
124
+ // 1. Direct string (data URI or URL)
125
+ if (typeof item === 'string') {
126
+ const dataUri = parseDataUri(item)
127
+ if (dataUri) {
128
+ return { mimeType: dataUri.mimeType, base64: dataUri.base64, url: item.trim() }
129
+ }
130
+ if (/^https?:\/\//i.test(item.trim())) {
131
+ return { mimeType: 'image/jpeg', base64: null, url: item.trim() }
132
+ }
133
+ return null
134
+ }
135
+
136
+ if (typeof item !== 'object') return null
137
+
138
+ // 2. Anthropic style: { type: 'image', source: { type: 'base64', media_type, data } }
139
+ if (item.source && item.source.type === 'base64' && item.source.data) {
140
+ const mimeType = item.source.media_type || 'image/jpeg'
141
+ const base64 = item.source.data
142
+ return {
143
+ mimeType,
144
+ base64,
145
+ url: `data:${mimeType};base64,${base64}`,
146
+ }
147
+ }
148
+
149
+ // 3. Gemini style: { inlineData: { mimeType, data } }
150
+ if (item.inlineData && item.inlineData.data) {
151
+ const mimeType = item.inlineData.mimeType || 'image/jpeg'
152
+ const base64 = item.inlineData.data
153
+ return {
154
+ mimeType,
155
+ base64,
156
+ url: `data:${mimeType};base64,${base64}`,
157
+ }
158
+ }
159
+
160
+ // 4. OpenAI style: { type: 'image_url', image_url: { url } } or { type: 'image', image: '...' }
161
+ const rawUrl = (item.image_url && (item.image_url.url || item.image_url)) ||
162
+ item.image || item.url
163
+
164
+ if (typeof rawUrl === 'string') {
165
+ const dataUri = parseDataUri(rawUrl)
166
+ if (dataUri) {
167
+ return { mimeType: dataUri.mimeType, base64: dataUri.base64, url: rawUrl.trim() }
168
+ }
169
+ if (/^https?:\/\//i.test(rawUrl.trim())) {
170
+ return { mimeType: 'image/jpeg', base64: null, url: rawUrl.trim() }
171
+ }
172
+ }
173
+
174
+ return null
175
+ }
176
+
177
+ export function extractImages(content) {
178
+ if (!Array.isArray(content)) {
179
+ const norm = normalizeImage(content)
180
+ return norm ? [norm] : []
181
+ }
182
+ const images = []
183
+ for (const block of content) {
184
+ if (!block) continue
185
+ if (typeof block === 'string') {
186
+ const norm = normalizeImage(block)
187
+ if (norm) images.push(norm)
188
+ continue
189
+ }
190
+ if (block.type === 'image' || block.type === 'image_url' || block.inlineData || block.image_url || block.image) {
191
+ const norm = normalizeImage(block)
192
+ if (norm) images.push(norm)
193
+ }
194
+ }
195
+ return images
196
+ }
197
+
198
+ export function toOpenAiImage(norm) {
199
+ const url = norm.url || (norm.base64 ? `data:${norm.mimeType};base64,${norm.base64}` : null)
200
+ if (!url) return null
201
+ return {
202
+ type: 'image_url',
203
+ image_url: { url },
204
+ }
205
+ }
206
+
207
+ export function toAnthropicImage(norm) {
208
+ if (!norm.base64) return null
209
+ return {
210
+ type: 'image',
211
+ source: {
212
+ type: 'base64',
213
+ media_type: norm.mimeType || 'image/jpeg',
214
+ data: norm.base64,
215
+ },
216
+ }
217
+ }
218
+
219
+ export function toGeminiImage(norm) {
220
+ if (!norm.base64) return null
221
+ return {
222
+ inlineData: {
223
+ mimeType: norm.mimeType || 'image/jpeg',
224
+ data: norm.base64,
225
+ },
226
+ }
227
+ }
228
+
229
+ export function toCodexImage(norm) {
230
+ const url = norm.url || (norm.base64 ? `data:${norm.mimeType};base64,${norm.base64}` : null)
231
+ if (!url) return null
232
+ return {
233
+ type: 'input_image',
234
+ image_url: url,
235
+ }
236
+ }
@@ -169,7 +169,27 @@ async function discoverLocalCliSessionsInner() {
169
169
  }
170
170
 
171
171
  // 9. GitHub Copilot token
172
- if (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN) {
172
+ const copilotPaths = [
173
+ homeFile('.config', 'github-copilot', 'hosts.json'),
174
+ homeFile('.config', 'github-copilot', 'apps.json'),
175
+ ]
176
+ for (const p of copilotPaths) {
177
+ const cp = await readJson(p)
178
+ if (cp) {
179
+ const gh = cp['github.com'] || cp
180
+ const tok = gh.oauth_token || gh.token || gh.access_token
181
+ if (tok) {
182
+ detected.copilot = {
183
+ provider: 'copilot',
184
+ path: p,
185
+ email: gh.user || 'GitHub Copilot User',
186
+ hasRefreshToken: false,
187
+ }
188
+ break
189
+ }
190
+ }
191
+ }
192
+ if (!detected.copilot && (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN)) {
173
193
  detected.copilot = {
174
194
  provider: 'copilot',
175
195
  path: 'GITHUB_COPILOT_TOKEN env',
@@ -217,12 +237,23 @@ export async function loadLocalCliBlob(provider, options = {}) {
217
237
  }
218
238
 
219
239
  if (provider === 'copilot') {
220
- const tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
240
+ let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
241
+ let email = 'GitHub Copilot User'
242
+ if (info.path && info.path !== 'GITHUB_COPILOT_TOKEN env') {
243
+ const cp = await readJson(info.path)
244
+ if (cp) {
245
+ const gh = cp['github.com'] || cp
246
+ tok = gh.oauth_token || gh.token || gh.access_token || tok
247
+ email = gh.user || email
248
+ }
249
+ }
221
250
  return {
222
251
  accessToken: tok,
223
252
  refreshToken: tok,
253
+ githubToken: tok,
224
254
  expiresAt: Date.now() + 30 * 86400 * 1000,
225
- email: 'GitHub Copilot User'
255
+ email,
256
+ label: email,
226
257
  }
227
258
  }
228
259
 
package/lib/messages.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { extractImages, toOpenAiImage, toAnthropicImage, toGeminiImage, toCodexImage } from './images.js'
2
+ import { normalizeTools } from './tools-normalizer.js'
1
3
  import { geminiFunctionDeclarations } from './gemini-schema.js'
2
4
 
3
5
  function flattenText(content) {
@@ -64,7 +66,18 @@ export function openaiMessages(options) {
64
66
  }
65
67
  const results = toolResults(message.content)
66
68
  const text = flattenText(message.content)
67
- if (text || !results.length) out.push({ role: 'user', content: text })
69
+ const images = extractImages(message.content)
70
+ if (images.length) {
71
+ const parts = []
72
+ if (text) parts.push({ type: 'text', text })
73
+ for (const img of images) {
74
+ const blk = toOpenAiImage(img)
75
+ if (blk) parts.push(blk)
76
+ }
77
+ out.push({ role: 'user', content: parts })
78
+ } else if (text || !results.length) {
79
+ out.push({ role: 'user', content: text })
80
+ }
68
81
  for (const result of results) {
69
82
  out.push({
70
83
  role: 'tool',
@@ -77,14 +90,14 @@ export function openaiMessages(options) {
77
90
  }
78
91
 
79
92
  export function openaiTools(options) {
80
- const tools = options.tools
81
- if (!Array.isArray(tools) || !tools.length) return undefined
93
+ const tools = normalizeTools(options && options.tools)
94
+ if (!tools) return undefined
82
95
  return tools.map((tool) => ({
83
96
  type: 'function',
84
97
  function: {
85
98
  name: tool.name,
86
- description: tool.description || '',
87
- parameters: tool.parameters || { type: 'object' },
99
+ description: tool.description,
100
+ parameters: tool.parameters,
88
101
  },
89
102
  }))
90
103
  }
@@ -147,6 +160,18 @@ export function reconcileResponsesInput(items) {
147
160
  return result
148
161
  }
149
162
 
163
+ export function normalizeSystemPrefix(parts) {
164
+ if (!Array.isArray(parts)) return ""
165
+ return parts
166
+ .map((p) => {
167
+ if (!p) return ""
168
+ const s = typeof p === "string" ? p : (p.text || String(p))
169
+ return s.replace(/\r\n/g, "\n").trim()
170
+ })
171
+ .filter(Boolean)
172
+ .join("\n\n")
173
+ }
174
+
150
175
  export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
151
176
  const systemParts = []
152
177
  if (options.system) systemParts.push(options.system)
@@ -215,6 +240,15 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
215
240
  const results = toolResults(message.content)
216
241
  const text = flattenText(message.content)
217
242
  if (text) addText('user', text)
243
+ const images = extractImages(message.content)
244
+ for (const img of images) {
245
+ const blk = toCodexImage(img)
246
+ if (blk) {
247
+ if (pendingRole && pendingRole !== 'user') flush()
248
+ pendingRole = 'user'
249
+ pendingContent.push(blk)
250
+ }
251
+ }
218
252
  for (const result of results) {
219
253
  flush()
220
254
  const callId = String(result.toolCallId || result.tool_call_id || result.call_id || result.id || '')
@@ -239,7 +273,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
239
273
  description: tool.function.description,
240
274
  parameters: tool.function.parameters,
241
275
  }))
242
- const instructions = systemParts.filter(Boolean).join('\n\n') || fallbackInstructions
276
+ const instructions = normalizeSystemPrefix(systemParts) || (fallbackInstructions ? fallbackInstructions.replace(/\r\n/g, "\n").trim() : undefined)
243
277
  return {
244
278
  model: options.model,
245
279
  stream: true,
@@ -265,77 +299,123 @@ export function anthropicPayload(options, extraSystem) {
265
299
  if (options.system) systemParts.push(options.system)
266
300
  const messages = []
267
301
  for (const message of options.messages || []) {
268
- if (message.role === 'system') {
302
+ if (message.role === "system") {
269
303
  systemParts.push(flattenText(message.content))
270
304
  continue
271
305
  }
272
- if (message.role === 'assistant') {
306
+ if (message.role === "assistant") {
273
307
  const blocks = []
274
308
  const text = flattenText(message.content)
275
- if (text) blocks.push({ type: 'text', text })
309
+ if (text) blocks.push({ type: "text", text })
276
310
  const calls = [
277
311
  ...toolCalls(message.content),
278
312
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
279
313
  id: c.id || c.call_id,
280
314
  name: c.name || (c.function && c.function.name),
281
- arguments: c.arguments || (c.function && c.function.arguments) || '{}',
315
+ arguments: c.arguments || (c.function && c.function.arguments) || "{}",
282
316
  }))),
283
317
  ]
284
318
  for (const call of calls) {
285
319
  if (!call.id) continue
286
320
  blocks.push({
287
- type: 'tool_use',
321
+ type: "tool_use",
288
322
  id: call.id,
289
323
  name: call.name,
290
324
  input: safeJson(call.arguments),
291
325
  })
292
326
  }
293
- messages.push({ role: 'assistant', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
327
+ messages.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: "" }] })
294
328
  continue
295
329
  }
296
- if (message.role === 'tool') {
297
- const toolUseId = String(message.toolCallId || message.tool_call_id || message.tool_use_id || message.id || '')
298
- const content = typeof message.content === 'string'
330
+ if (message.role === "tool") {
331
+ const toolUseId = String(message.toolCallId || message.tool_call_id || message.tool_use_id || message.id || "")
332
+ const content = typeof message.content === "string"
299
333
  ? message.content
300
- : (flattenText(message.content) || JSON.stringify(message.content || ''))
334
+ : (flattenText(message.content) || JSON.stringify(message.content || ""))
301
335
  messages.push({
302
- role: 'user',
336
+ role: "user",
303
337
  content: [{
304
- type: 'tool_result',
338
+ type: "tool_result",
305
339
  tool_use_id: toolUseId,
306
- content: content || '',
340
+ content: content || "",
307
341
  }],
308
342
  })
309
343
  continue
310
344
  }
311
345
  const blocks = []
312
346
  const text = flattenText(message.content)
313
- if (text) blocks.push({ type: 'text', text })
347
+ if (text) blocks.push({ type: "text", text })
348
+ const images = extractImages(message.content)
349
+ for (const img of images) {
350
+ const blk = toAnthropicImage(img)
351
+ if (blk) blocks.push(blk)
352
+ }
314
353
  for (const result of toolResults(message.content)) {
315
- const toolUseId = String(result.toolCallId || result.tool_call_id || result.tool_use_id || result.id || '')
316
- const content = typeof result.content === 'string'
354
+ const toolUseId = String(result.toolCallId || result.tool_call_id || result.tool_use_id || result.id || "")
355
+ const content = typeof result.content === "string"
317
356
  ? result.content
318
- : (flattenText(result.content) || (result.output != null ? (typeof result.output === 'string' ? result.output : JSON.stringify(result.output)) : ''))
357
+ : (flattenText(result.content) || (result.output != null ? (typeof result.output === "string" ? result.output : JSON.stringify(result.output)) : ""))
319
358
  blocks.push({
320
- type: 'tool_result',
359
+ type: "tool_result",
321
360
  tool_use_id: toolUseId,
322
- content: content || '',
361
+ content: content || "",
323
362
  })
324
363
  }
325
- messages.push({ role: 'user', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
364
+ messages.push({ role: "user", content: blocks.length ? blocks : [{ type: "text", text: "" }] })
326
365
  }
327
- const tools = Array.isArray(options.tools)
328
- ? options.tools.map((tool) => ({
329
- name: tool.name,
330
- description: tool.description || '',
331
- input_schema: tool.parameters || { type: 'object' },
332
- }))
366
+
367
+ const useCacheControl = options.cacheControl !== false
368
+ const systemText = normalizeSystemPrefix(systemParts)
369
+ let system
370
+ if (systemText) {
371
+ if (useCacheControl) {
372
+ system = [{ type: "text", text: systemText, cache_control: { type: "ephemeral" } }]
373
+ } else {
374
+ system = systemText
375
+ }
376
+ }
377
+
378
+ const normTools = normalizeTools(options.tools)
379
+ const tools = normTools
380
+ ? normTools.map((tool, idx) => {
381
+ const item = {
382
+ name: tool.name,
383
+ description: tool.description,
384
+ input_schema: tool.parameters,
385
+ }
386
+ if (useCacheControl && idx === normTools.length - 1) {
387
+ item.cache_control = { type: "ephemeral" }
388
+ }
389
+ return item
390
+ })
333
391
  : undefined
392
+
393
+ if (useCacheControl) {
394
+ const userTurnIndices = []
395
+ for (let i = 0; i < messages.length; i++) {
396
+ if (messages[i].role === "user") {
397
+ userTurnIndices.push(i)
398
+ }
399
+ }
400
+ if (userTurnIndices.length >= 2) {
401
+ const targetIdx = userTurnIndices[userTurnIndices.length - 2]
402
+ const targetMsg = messages[targetIdx]
403
+ if (Array.isArray(targetMsg.content) && targetMsg.content.length > 0) {
404
+ const lastBlockIdx = targetMsg.content.length - 1
405
+ const lastBlock = targetMsg.content[lastBlockIdx]
406
+ targetMsg.content[lastBlockIdx] = {
407
+ ...lastBlock,
408
+ cache_control: { type: "ephemeral" },
409
+ }
410
+ }
411
+ }
412
+ }
413
+
334
414
  return {
335
415
  model: options.model,
336
416
  max_tokens: options.maxTokens || 8192,
337
417
  stream: true,
338
- ...(systemParts.length ? { system: systemParts.join('\n\n') } : {}),
418
+ ...(system ? { system } : {}),
339
419
  messages,
340
420
  ...(tools && tools.length ? { tools } : {}),
341
421
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
@@ -371,6 +451,11 @@ export function googleContents(options) {
371
451
  const parts = []
372
452
  const text = flattenText(message.content)
373
453
  if (text) parts.push({ text })
454
+ const images = extractImages(message.content)
455
+ for (const img of images) {
456
+ const blk = toGeminiImage(img)
457
+ if (blk) parts.push(blk)
458
+ }
374
459
  const calls = [
375
460
  ...toolCalls(message.content),
376
461
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
@@ -400,7 +485,10 @@ export function googleContents(options) {
400
485
  const tools = declarations ? [{ functionDeclarations: declarations }] : undefined
401
486
  return {
402
487
  contents,
403
- ...(systemParts.length ? { systemInstruction: { parts: systemParts } } : {}),
488
+ ...(() => {
489
+ const systemText = normalizeSystemPrefix(systemParts)
490
+ return systemText ? { systemInstruction: { parts: [{ text: systemText }] } } : {}
491
+ })(),
404
492
  ...(tools ? { tools } : {}),
405
493
  generationConfig: {
406
494
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
package/lib/refs.js CHANGED
@@ -55,6 +55,7 @@ const DISPLAY = {
55
55
  glm: 'Zhipu GLM',
56
56
  cursor: 'Cursor',
57
57
  kiro: 'AWS Kiro',
58
+ copilot: 'GitHub Copilot',
58
59
  }
59
60
 
60
61
  export function displayName(provider) {
@@ -349,100 +349,117 @@ export function registerAccountsRoutes(ctx, state) {
349
349
  }),
350
350
  }), 'dsh-subscriptions: /logout')
351
351
 
352
- // #353: Encrypted Vault export
352
+ const handleVaultExport = safeJsonHandler(async (req, res) => {
353
+ if (req.method !== 'POST') {
354
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
355
+ return
356
+ }
357
+ if (!isTrustedSettingsRequest(req)) {
358
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
359
+ return
360
+ }
361
+ let payload
362
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
363
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
364
+ return
365
+ }
366
+ const passphrase = payload.passphrase
367
+ const algorithm = payload.algorithm || 'pbkdf2'
368
+ try {
369
+ const slots = live().slots || []
370
+ const blobs = {}
371
+ for (const slot of normalizeSlots(slots)) {
372
+ try {
373
+ const blob = await store.loadBlob(slot.ref)
374
+ if (blob) blobs[slot.ref] = blob
375
+ } catch { /* ignore */ }
376
+ }
377
+ const result = exportVault({ slots, blobs, algorithm }, passphrase)
378
+ writeJson(res, 200, result)
379
+ } catch (e) {
380
+ writeJson(res, 400, { ok: false, error: { code: 'vault_export', message: String(e && e.message || e) } })
381
+ }
382
+ })
383
+
384
+ // #353 & #396: Encrypted Vault export
353
385
  ctx.effect(() => ctx.webServer.register({
354
386
  kind: 'exact',
355
387
  path: '/dsh-subscriptions/vault/export',
356
- handler: safeJsonHandler(async (req, res) => {
357
- if (req.method !== 'POST') {
358
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
359
- return
360
- }
361
- if (!isTrustedSettingsRequest(req)) {
362
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
363
- return
364
- }
365
- let payload
366
- try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
367
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
368
- return
369
- }
370
- const passphrase = payload.passphrase
371
- try {
372
- const slots = live().slots || []
373
- const blobs = {}
374
- for (const slot of normalizeSlots(slots)) {
375
- try {
376
- const blob = await store.loadBlob(slot.ref)
377
- if (blob) blobs[slot.ref] = blob
378
- } catch { /* ignore */ }
379
- }
380
- const result = exportVault({ slots, blobs }, passphrase)
381
- writeJson(res, 200, result)
382
- } catch (e) {
383
- writeJson(res, 400, { ok: false, error: { code: 'vault_export', message: String(e && e.message || e) } })
384
- }
385
- }),
388
+ handler: handleVaultExport,
386
389
  }), 'dsh-subscriptions: /vault/export')
387
390
 
388
- // #353: Encrypted Vault import
389
391
  ctx.effect(() => ctx.webServer.register({
390
392
  kind: 'exact',
391
- path: '/dsh-subscriptions/vault/import',
392
- handler: safeJsonHandler(async (req, res) => {
393
- if (req.method !== 'POST') {
394
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
395
- return
396
- }
397
- if (!isTrustedSettingsRequest(req)) {
398
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
399
- return
400
- }
401
- let payload
402
- try { payload = JSON.parse((await readBody(req, 2 * 1024 * 1024)).toString('utf8') || '{}') } catch {
403
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
404
- return
405
- }
406
- const { passphrase, vault } = payload
407
- try {
408
- const result = importVault(vault, passphrase)
409
- const settings = getSettingsApi()
410
- let parsed
411
- if (result.slots.length) {
412
- if (!settings) throw new Error('settings are unavailable for vault import')
413
- const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
414
- for (const s of result.slots) {
415
- const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
416
- if (idx >= 0) {
417
- curSlots[idx] = { ...curSlots[idx], ...s }
418
- } else {
419
- curSlots.push(s)
420
- }
393
+ path: '/dsh-subscriptions/export-backup',
394
+ handler: handleVaultExport,
395
+ }), 'dsh-subscriptions: /export-backup')
396
+
397
+ const handleVaultImport = safeJsonHandler(async (req, res) => {
398
+ if (req.method !== 'POST') {
399
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
400
+ return
401
+ }
402
+ if (!isTrustedSettingsRequest(req)) {
403
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
404
+ return
405
+ }
406
+ let payload
407
+ try { payload = JSON.parse((await readBody(req, 2 * 1024 * 1024)).toString('utf8') || '{}') } catch {
408
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
409
+ return
410
+ }
411
+ const { passphrase, vault } = payload
412
+ try {
413
+ const result = importVault(vault, passphrase)
414
+ const settings = getSettingsApi()
415
+ let parsed
416
+ if (result.slots.length) {
417
+ if (!settings) throw new Error('settings are unavailable for vault import')
418
+ const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
419
+ for (const s of result.slots) {
420
+ const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
421
+ if (idx >= 0) {
422
+ curSlots[idx] = { ...curSlots[idx], ...s }
423
+ } else {
424
+ curSlots.push(s)
421
425
  }
422
- parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
423
- }
424
- let blobCount = 0
425
- for (const [ref, blob] of Object.entries(result.blobs)) {
426
- await store.saveBlob(ref, blob)
427
- blobCount++
428
- }
429
- if (parsed) {
430
- await settings.replace(parsed)
431
- syncCustomVendors()
432
426
  }
433
- await syncAdapter()
434
- refreshModels().catch(() => {})
435
- writeJson(res, 200, {
436
- ok: true,
437
- slotCount: result.slots.length,
438
- blobCount,
439
- exportedAt: result.exportedAt,
440
- accounts: await accountsView(),
441
- })
442
- } catch (e) {
443
- writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
427
+ parsed = Config({ ...live(), slots: stripLegacySlots(curSlots) })
444
428
  }
445
- }),
429
+ let blobCount = 0
430
+ for (const [ref, blob] of Object.entries(result.blobs)) {
431
+ await store.saveBlob(ref, blob)
432
+ blobCount++
433
+ }
434
+ if (parsed) {
435
+ await settings.replace(parsed)
436
+ syncCustomVendors()
437
+ }
438
+ await syncAdapter()
439
+ refreshModels().catch(() => {})
440
+ writeJson(res, 200, {
441
+ ok: true,
442
+ slotCount: result.slots.length,
443
+ blobCount,
444
+ exportedAt: result.exportedAt,
445
+ accounts: await accountsView(),
446
+ })
447
+ } catch (e) {
448
+ writeJson(res, 400, { ok: false, error: { code: 'vault_import', message: String(e && e.message || e) } })
449
+ }
450
+ })
451
+
452
+ // #353 & #396: Encrypted Vault import
453
+ ctx.effect(() => ctx.webServer.register({
454
+ kind: 'exact',
455
+ path: '/dsh-subscriptions/vault/import',
456
+ handler: handleVaultImport,
446
457
  }), 'dsh-subscriptions: /vault/import')
447
458
 
459
+ ctx.effect(() => ctx.webServer.register({
460
+ kind: 'exact',
461
+ path: '/dsh-subscriptions/import-backup',
462
+ handler: handleVaultImport,
463
+ }), 'dsh-subscriptions: /import-backup')
464
+
448
465
  }