pinokiod 8.0.50 → 8.0.51

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.
@@ -103,8 +103,8 @@ class Vault {
103
103
  get blobRoot() {
104
104
  return path.resolve(this.root, "sha256")
105
105
  }
106
- get sourceRoot() {
107
- return path.resolve(this.root, "sources")
106
+ get externalSourcesPath() {
107
+ return path.resolve(this.root, "external-sources.json")
108
108
  }
109
109
  storePathFor(hash) {
110
110
  if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
@@ -129,6 +129,43 @@ class Vault {
129
129
  if (!st) throw unsafeStoragePath(directory)
130
130
  return st
131
131
  }
132
+ async readExternalSourcePaths() {
133
+ let raw
134
+ try {
135
+ raw = await this.registry.readFile(this.externalSourcesPath)
136
+ } catch (error) {
137
+ if (isMissingError(error)) return []
138
+ throw error
139
+ }
140
+ let parsed
141
+ try {
142
+ parsed = JSON.parse(raw)
143
+ } catch (error) {
144
+ throw new Error("External folder settings are invalid.")
145
+ }
146
+ if (!parsed || !Array.isArray(parsed.paths)) {
147
+ throw new Error("External folder settings are invalid.")
148
+ }
149
+ const paths = []
150
+ for (const value of parsed.paths) {
151
+ if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
152
+ const resolved = path.resolve(value.trim())
153
+ if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
154
+ }
155
+ return paths
156
+ }
157
+ async writeExternalSourcePaths(folderPaths) {
158
+ const paths = []
159
+ for (const value of folderPaths) {
160
+ if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
161
+ const resolved = path.resolve(value.trim())
162
+ if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
163
+ }
164
+ await this.registry.atomicWrite(
165
+ this.externalSourcesPath,
166
+ JSON.stringify({ paths }, null, 2)
167
+ )
168
+ }
132
169
  async storeStatIfPresent(storePath, options = {}) {
133
170
  if (!isPathWithin(this.blobRoot, storePath)) throw unsafeStoragePath(storePath)
134
171
  if (!await this.directoryIfSafe(this.root) || !await this.directoryIfSafe(this.blobRoot)) {
@@ -205,6 +242,11 @@ class Vault {
205
242
  }
206
243
  }
207
244
  }
245
+ case "remove_source":
246
+ if (typeof payload.source_id !== "string" || !payload.source_id) {
247
+ return { error: "Choose an external folder to remove." }
248
+ }
249
+ return this.runMutation(() => this.removeExternalSource(payload.source_id))
208
250
  case "scan": {
209
251
  if (payload.scope_id && !this.scanSource(payload.scope_id)) {
210
252
  return { error: "That scan location is no longer available." }
@@ -313,34 +355,6 @@ class Vault {
313
355
  return source
314
356
  }
315
357
 
316
- const seenExternalRoots = new Set()
317
- const addExternalEntries = async (importRoot, idPrefix = "") => {
318
- let entries = []
319
- try {
320
- entries = await fs.promises.readdir(importRoot, { withFileTypes: true })
321
- } catch (error) {
322
- if (!isMissingError(error)) throw error
323
- }
324
- for (const entry of entries) {
325
- if (!entry.isSymbolicLink()) continue
326
- const mountPath = path.resolve(importRoot, entry.name)
327
- try {
328
- const root = await fs.promises.realpath(mountPath)
329
- const st = await fs.promises.stat(root)
330
- if (!st.isDirectory()) continue
331
- const canonical = path.resolve(root)
332
- if (seenExternalRoots.has(canonical)) continue
333
- seenExternalRoots.add(canonical)
334
- sources.push(await decorate({
335
- id: sourceId("external", `${idPrefix}${entry.name}`), kind: "external", label: entry.name,
336
- root: canonical, mount_path: mountPath, parent_id: "external"
337
- }))
338
- } catch (error) {
339
- if (!isMissingError(error)) throw error
340
- }
341
- }
342
- }
343
-
344
358
  let apiEntries = []
345
359
  try {
346
360
  apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
@@ -356,9 +370,15 @@ class Vault {
356
370
  }))
357
371
  }
358
372
  }
359
- await addExternalEntries(apiRoot)
360
- if (await this.directoryIfSafe(this.sourceRoot)) {
361
- await addExternalEntries(this.sourceRoot, "vault:")
373
+ for (const configuredPath of await this.readExternalSourcePaths()) {
374
+ sources.push(await decorate({
375
+ id: sourceId("external", configuredPath),
376
+ kind: "external",
377
+ label: path.basename(configuredPath) || configuredPath,
378
+ root: configuredPath,
379
+ parent_id: "external",
380
+ configured: true
381
+ }))
362
382
  }
363
383
 
364
384
  let homeEntries = []
@@ -421,49 +441,38 @@ class Vault {
421
441
  return { created: false, source: existing }
422
442
  }
423
443
 
424
- const importRoot = this.sourceRoot
425
- await this.ensureDirectory(importRoot)
426
- const baseLabel = path.basename(canonical) || "external-folder"
427
- let mountPath = null
428
- for (let index = 1; index < 10000; index++) {
429
- const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
430
- const candidate = path.resolve(importRoot, label)
431
- try {
432
- await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
433
- mountPath = candidate
434
- break
435
- } catch (error) {
436
- if (error && error.code === "EEXIST") continue
437
- if (error && (error.code === "EACCES" || error.code === "EPERM")) {
438
- throw new Error("Pinokio does not have permission to add that folder.")
439
- }
440
- throw new Error("Pinokio could not add that folder.")
441
- }
444
+ const configuredPaths = await this.readExternalSourcePaths()
445
+ configuredPaths.push(canonical)
446
+ await this.writeExternalSourcePaths(configuredPaths)
447
+ await this.refreshSources()
448
+ const source = this._sources.find((item) =>
449
+ item.kind === "external" && item.configured && samePath(item.root, canonical))
450
+ if (!source) {
451
+ throw new Error("The folder could not be added. Restart Pinokio and try again.")
442
452
  }
443
- if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
453
+ return { created: true, source }
454
+ }
455
+ async removeExternalSource(sourceIdToRemove) {
456
+ await this.refreshSources()
457
+ const source = this._sources.find((item) =>
458
+ item.id === sourceIdToRemove && item.kind === "external" && item.configured)
459
+ if (!source) return { error: "That external folder is no longer configured." }
444
460
 
445
- try {
446
- await this.refreshSources()
447
- const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
448
- if (!source) throw new Error("The folder could not be added. Restart Pinokio and try again.")
449
- return { created: true, source }
450
- } catch (error) {
451
- // Adding a source is transactional. Roll back only the exact directory
452
- // link created above; preserve any path an external writer replaced.
453
- try {
454
- const [mounted, target] = await Promise.all([
455
- fs.promises.lstat(mountPath),
456
- fs.promises.realpath(mountPath)
457
- ])
458
- if (mounted.isSymbolicLink() && samePath(target, canonical)) {
459
- await fs.promises.unlink(mountPath)
460
- }
461
- } catch {
462
- // The original error remains authoritative; an unverified path is
463
- // deliberately preserved rather than deleted.
464
- }
465
- throw error
461
+ const configuredPaths = await this.readExternalSourcePaths()
462
+ const remaining = configuredPaths.filter((folderPath) => !samePath(folderPath, source.root))
463
+ if (remaining.length === configuredPaths.length) {
464
+ return { error: "That external folder is no longer configured." }
466
465
  }
466
+ await this.writeExternalSourcePaths(remaining)
467
+ await this.refreshSources()
468
+ this.reconcileConfiguredSources()
469
+ for (const [filePath, entry] of [...this.registry.excluded]) {
470
+ if (entry.source_id === source.id) this.registry.excluded.delete(filePath)
471
+ }
472
+ this.registry.sourceScans.delete(source.id)
473
+ this.registry.lastScan = null
474
+ this.registry.schedulePersist()
475
+ return { removed: true, source_id: source.id, label: source.label }
467
476
  }
468
477
  sourceForPath(filePath, preferredId) {
469
478
  let cursor = path.resolve(filePath)
@@ -1811,7 +1820,8 @@ class Vault {
1811
1820
  display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
1812
1821
  target_path: source.kind === "external" ? source.root : null,
1813
1822
  parent_id: scopeId ? null : source.parent_id, app: source.app || null,
1814
- available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
1823
+ available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable,
1824
+ removable: source.kind === "external" && source.configured === true
1815
1825
  }))
1816
1826
  const result = {
1817
1827
  enabled: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.50",
3
+ "version": "8.0.51",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -20,6 +20,9 @@ const COPY = {
20
20
  external_added: "Added to Locations. Run a scan when you’re ready.",
21
21
  external_exists: "That folder is already in Locations.",
22
22
  external_other_disk: "Added to Locations. It can be scanned, but files on this disk cannot be deduplicated with files in your Pinokio folder.",
23
+ remove_external_folder: "Remove from Locations",
24
+ remove_external_confirm: "Remove “{name}” from Locations? This does not delete or modify any files.",
25
+ external_removed: "Removed from Locations. No files were changed.",
23
26
  pinokio_folder: "Pinokio folder",
24
27
  save_space: "Save space",
25
28
  disk_space_freed: "of disk space freed",
@@ -530,6 +533,9 @@ const batchAction = (source) => {
530
533
  if (!source.shareable || !shareable.length) return `<div class="vault-pane-action-note">${esc(COPY.sharing_unavailable)}</div>`
531
534
  return `<button class="vault-button" type="button" data-deduplicate-scope="${attr(source.id)}">${esc(COPY.deduplicate)} ${countLabel(shareable.length)}</button>`
532
535
  }
536
+ const removeSourceAction = (source) => source && source.removable
537
+ ? `<button class="vault-button" type="button" data-remove-source="${attr(source.id)}">${esc(COPY.remove_external_folder)}</button>`
538
+ : ""
533
539
 
534
540
  const toolbarSummary = (visibleItems) => {
535
541
  if (state.view === "duplicates") {
@@ -588,7 +594,8 @@ const renderToolbar = (visibleItems, allItems) => {
588
594
  ${displayModeControl()}
589
595
  ${descriptionMarkup}
590
596
  <span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
591
- ${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}`
597
+ ${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}
598
+ ${removeSourceAction(source)}`
592
599
  }
593
600
 
594
601
  const statusMarkup = (item) => {
@@ -1582,6 +1589,18 @@ document.addEventListener("click", async (event) => {
1582
1589
  if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
1583
1590
  else state.expandedFiles.add(file)
1584
1591
  render()
1592
+ } else if (target.dataset.removeSource) {
1593
+ const source = sourceById(target.dataset.removeSource)
1594
+ if (!source) return
1595
+ const message = COPY.remove_external_confirm.replace("{name}", source.label)
1596
+ if (!window.confirm(message)) return
1597
+ await runAction({
1598
+ action: "remove_source",
1599
+ source_id: source.id
1600
+ }, () => {
1601
+ state.sourceId = null
1602
+ return COPY.external_removed
1603
+ })
1585
1604
  } else if (target.id === "btn-add-source") {
1586
1605
  target.disabled = true
1587
1606
  try {
@@ -265,7 +265,7 @@ describe('vault manual scan (phase 3)', () => {
265
265
  assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
266
266
  })
267
267
 
268
- test('top-level linked imports are scanned as external sources while nested links stay skipped', async (t) => {
268
+ test('top-level api links are not adopted as external sources', async (t) => {
269
269
  const { home, vault } = await makeEnv()
270
270
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-'))
271
271
  const nested = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-nested-'))
@@ -286,22 +286,20 @@ describe('vault manual scan (phase 3)', () => {
286
286
  const source = vault.sources().find((item) => item.kind === 'external' && item.label === 'linked-models')
287
287
  const canonicalImported = path.resolve(await fs.promises.realpath(external), 'models', 'm.bin')
288
288
  const canonicalNestedFile = path.resolve(await fs.promises.realpath(nested), 'should-not-scan.bin')
289
- assert.ok(source, 'linked import becomes an external source')
290
- assert.strictEqual(source.parent_id, 'external')
291
- assert.ok(vault.registry.scanIndex.has(canonicalImported), 'external root was scanned')
289
+ assert.strictEqual(source, undefined, 'api links are not Vault configuration')
290
+ assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
292
291
  assert.strictEqual(vault.registry.scanIndex.has(canonicalNestedFile), false, 'nested directory link was not followed')
293
- assert.strictEqual(vault.registry.duplicates.get(canonicalImported).source_id, source.id)
294
292
  assert.strictEqual((await fs.promises.stat(local)).nlink, 2)
295
293
 
296
294
  const status = await vault.status()
297
- assert.ok(status.sources.some((item) => item.id === source.id && item.kind === 'external'))
298
- assert.strictEqual(status.duplicates.find((item) => item.path === canonicalImported).source_id, source.id)
295
+ assert.strictEqual(status.sources.some((item) => item.kind === 'external'), false)
296
+ assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
299
297
  assert.strictEqual(status.last_scan.home_bytes_total, content.length, 'Pinokio metric excludes external files')
300
- assert.strictEqual(status.last_scan.source_bytes[source.id], content.length, 'external totals remain attributable')
298
+ assert.strictEqual(status.last_scan.bytes_total, content.length)
301
299
  })
302
300
 
303
- test('adding an external source creates one persistent import without starting a scan', async () => {
304
- const { home, vault } = await makeEnv()
301
+ test('adding and removing an external source persists only its path and never creates a link', async () => {
302
+ const { home, vault, kernel } = await makeEnv()
305
303
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-added-external-'))
306
304
  homes.push(external)
307
305
  const content = crypto.randomBytes(4096)
@@ -312,27 +310,42 @@ describe('vault manual scan (phase 3)', () => {
312
310
  assert.strictEqual(added.source.kind, 'external')
313
311
  assert.strictEqual(added.source.parent_id, 'external')
314
312
  assert.strictEqual(vault.registry.scanIndex.size, 0, 'adding a source never scans it')
315
- assert.strictEqual((await fs.promises.lstat(added.source.mount_path)).isSymbolicLink(), true)
316
- assert.strictEqual(path.dirname(added.source.mount_path), path.resolve(home, 'vault', 'sources'))
313
+ assert.strictEqual(added.source.root, path.resolve(await fs.promises.realpath(external)))
314
+ assert.strictEqual(added.source.mount_path, undefined)
315
+ assert.deepStrictEqual(
316
+ JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
317
+ [added.source.root]
318
+ )
319
+ assert.strictEqual(fs.existsSync(path.resolve(home, 'vault', 'sources')), false)
317
320
  assert.deepStrictEqual(await fs.promises.readdir(path.resolve(home, 'api')), [],
318
- 'a scan-only external source never enters global app discovery')
321
+ 'adding a Vault source never creates an api link')
319
322
 
320
323
  const repeated = await vault.addExternalSource(external)
321
324
  assert.strictEqual(repeated.created, false, 'the same physical folder is not imported twice')
322
325
  assert.strictEqual(vault.sources().filter((source) => source.kind === 'external').length, 1)
323
326
 
327
+ const fresh = new Vault(kernel)
328
+ await fresh.init()
329
+ assert.ok(fresh.sources().some((source) =>
330
+ source.kind === 'external' && source.root === added.source.root))
331
+
324
332
  await vault.sweeper.scan()
325
333
  const canonicalImported = path.resolve(await fs.promises.realpath(imported))
326
334
  assert.ok(vault.registry.scanIndex.has(canonicalImported))
327
335
 
328
- await fs.promises.unlink(added.source.mount_path)
329
- await vault.sweeper.scan()
336
+ const removed = await vault.perform('remove_source', { source_id: added.source.id })
337
+ assert.strictEqual(removed.removed, true)
338
+ assert.deepStrictEqual(
339
+ JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
340
+ []
341
+ )
330
342
  const status = await vault.status()
331
343
  assert.strictEqual(vault.registry.links.has(canonicalImported), false)
332
344
  assert.strictEqual(vault.registry.duplicates.has(canonicalImported), false)
333
345
  assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
334
346
  assert.strictEqual(status.sources.some((source) => source.kind === 'external'), false)
335
347
  assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
348
+ assert.deepStrictEqual(await fs.promises.readFile(imported), content)
336
349
  })
337
350
 
338
351
  test('nested external sources are walked once and attributed to the deepest source', async () => {
@@ -393,7 +406,7 @@ describe('vault manual scan (phase 3)', () => {
393
406
  const realStat = fs.promises.stat
394
407
  let externalStats = 0
395
408
  fs.promises.stat = async (target, options) => {
396
- if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 2) {
409
+ if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 1) {
397
410
  const error = new Error('temporary external metadata failure')
398
411
  error.code = 'EIO'
399
412
  throw error
@@ -452,28 +465,26 @@ describe('vault manual scan (phase 3)', () => {
452
465
  assert.strictEqual(vault.registry.lastScan.files, 2)
453
466
  })
454
467
 
455
- test('a failed source refresh rolls back only the import it just created', async () => {
468
+ test('a failed external-source settings write creates no configuration', async () => {
456
469
  const { vault } = await makeEnv()
457
470
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-add-rollback-'))
458
471
  homes.push(external)
459
- const importRoot = vault.sourceRoot
460
- const realReaddir = fs.promises.readdir
461
- let importReads = 0
462
- fs.promises.readdir = async (target, options) => {
463
- if (path.resolve(target) === importRoot && ++importReads === 1) {
472
+ const realAtomicWrite = vault.registry.atomicWrite
473
+ vault.registry.atomicWrite = async (target, contents) => {
474
+ if (path.resolve(target) === vault.externalSourcesPath) {
464
475
  const error = new Error('transient read failure')
465
476
  error.code = 'EIO'
466
477
  throw error
467
478
  }
468
- return realReaddir(target, options)
479
+ return realAtomicWrite.call(vault.registry, target, contents)
469
480
  }
470
481
  try {
471
482
  await assert.rejects(vault.addExternalSource(external), (error) => error.code === 'EIO')
472
483
  } finally {
473
- fs.promises.readdir = realReaddir
484
+ vault.registry.atomicWrite = realAtomicWrite
474
485
  }
475
486
 
476
- assert.deepStrictEqual(await fs.promises.readdir(importRoot), [])
487
+ assert.strictEqual(fs.existsSync(vault.externalSourcesPath), false)
477
488
  assert.strictEqual(vault.sources().some((source) => source.kind === 'external'), false)
478
489
  })
479
490
 
@@ -1334,6 +1334,8 @@ describe('vault dashboard backend (phase 4)', () => {
1334
1334
  assert.match(vaultPage, /id='btn-add-source'/)
1335
1335
  assert.match(vaultPage, /<script src="\/Socket\.js"><\/script>/)
1336
1336
  assert.match(vaultPage, /action:\s*"add_source"/)
1337
+ assert.match(vaultPage, /action:\s*"remove_source"/)
1338
+ assert.match(vaultPage, /remove_external_folder:\s*"Remove from Locations"/)
1337
1339
  assert.match(vaultPage, /identical_contents_at:\s*"Identical contents at"/)
1338
1340
  assert.doesNotMatch(vaultPage, /Matching locations/)
1339
1341
  assert.match(vaultPage, /scan_waiting:\s*"Waiting for scan results"/)
@@ -2592,7 +2594,7 @@ describe('vault dashboard backend (phase 4)', () => {
2592
2594
  assert.doesNotMatch(actionRoute, /convertPending|runExclusive/)
2593
2595
  })
2594
2596
 
2595
- test('a stale external source id cannot authorize files outside its current target', async (t) => {
2597
+ test('a stale external source id cannot authorize files outside its current target', async () => {
2596
2598
  const { home, vault } = await makeEnv()
2597
2599
  const firstTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-old-'))
2598
2600
  const secondTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-new-'))
@@ -2601,16 +2603,8 @@ describe('vault dashboard backend (phase 4)', () => {
2601
2603
  const hash = sha256(content)
2602
2604
  const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
2603
2605
  const outside = await writeFile(path.resolve(firstTarget, 'model.bin'), content)
2604
- const mount = path.resolve(home, 'api', 'external-models')
2605
- try {
2606
- await fs.promises.symlink(firstTarget, mount, process.platform === 'win32' ? 'junction' : 'dir')
2607
- } catch (error) {
2608
- t.skip(`directory links unavailable: ${error.message}`)
2609
- return
2610
- }
2611
- await vault.refreshSources()
2606
+ const externalSource = (await vault.addExternalSource(firstTarget)).source
2612
2607
  const originalSource = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
2613
- const externalSource = vault.sources().find((source) => source.kind === 'external' && source.label === 'external-models')
2614
2608
  await vault.adopt(original, hash, { app: 'appA', source_id: originalSource.id })
2615
2609
  const outsideStat = await fs.promises.stat(outside)
2616
2610
  vault.registry.duplicates.set(outside, {
@@ -2618,12 +2612,12 @@ describe('vault dashboard backend (phase 4)', () => {
2618
2612
  dev: outsideStat.dev, ino: outsideStat.ino,
2619
2613
  mtime: outsideStat.mtimeMs, ctime: outsideStat.ctimeMs
2620
2614
  })
2621
- await fs.promises.unlink(mount)
2622
- await fs.promises.symlink(secondTarget, mount, process.platform === 'win32' ? 'junction' : 'dir')
2615
+ await vault.writeExternalSourcePaths([secondTarget])
2616
+ await vault.refreshSources()
2623
2617
 
2624
2618
  const result = await vault.perform('deduplicate', { scope_id: externalSource.id })
2625
2619
 
2626
- assert.strictEqual(result.converted, 0)
2620
+ assert.match(result.error, /no longer available/i)
2627
2621
  assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
2628
2622
  assert.ok(vault.registry.duplicates.has(outside))
2629
2623
  })
@@ -2772,16 +2766,13 @@ describe('vault dashboard backend (phase 4)', () => {
2772
2766
  assert.strictEqual(vault.registry.links.has(pending), false)
2773
2767
  })
2774
2768
 
2775
- test('status separates app, Pinokio folder, and external source metadata', async (t) => {
2769
+ test('status separates app, Pinokio folder, and external source metadata', async () => {
2776
2770
  const { home, vault } = await makeEnv()
2777
- const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
2778
- homes.push(external)
2779
- try {
2780
- await fs.promises.symlink(external, path.resolve(home, 'api', 'linked-models'), process.platform === 'win32' ? 'junction' : 'dir')
2781
- } catch (error) {
2782
- t.skip(`directory links unavailable: ${error.message}`)
2783
- return
2784
- }
2771
+ const externalParent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
2772
+ const external = path.resolve(externalParent, 'linked-models')
2773
+ homes.push(externalParent)
2774
+ await fs.promises.mkdir(external)
2775
+ await vault.addExternalSource(external)
2785
2776
  await fs.promises.mkdir(path.resolve(home, 'api', 'local-app'), { recursive: true })
2786
2777
  await fs.promises.mkdir(path.resolve(home, 'cache'), { recursive: true })
2787
2778
  await vault.refreshSources()
@@ -2789,6 +2780,8 @@ describe('vault dashboard backend (phase 4)', () => {
2789
2780
  const byKind = new Map(status.sources.map((source) => [source.id, source]))
2790
2781
  assert.ok([...byKind.values()].some((source) => source.kind === 'app' && source.label === 'local-app' && source.parent_id === 'apps'))
2791
2782
  assert.ok([...byKind.values()].some((source) => source.kind === 'folder' && source.label === 'cache' && source.parent_id === 'pinokio'))
2792
- assert.ok([...byKind.values()].some((source) => source.kind === 'external' && source.label === 'linked-models' && source.parent_id === 'external'))
2783
+ assert.ok([...byKind.values()].some((source) =>
2784
+ source.kind === 'external' && source.label === 'linked-models' &&
2785
+ source.parent_id === 'external' && source.removable === true))
2793
2786
  })
2794
2787
  })