diff --git a/docs/repair-release-20260929.md b/docs/repair-release-20260929.md
index 02c1a42d..87eac119 100644
--- a/docs/repair-release-20260929.md
+++ b/docs/repair-release-20260929.md
@@ -189,3 +189,28 @@ live wallet data, host process IDs, or external network. Bitcoin/LND start times
remained unchanged during isolated execution. Production helpers are unchanged;
the namespace-specific command behavior is compiled only into unit tests.
Release and ISO gates now use the isolated runner.
+
+### Final backend deployment and App Store follow-up — 19:36 UTC
+
+Full release harness passed: static/catalog checks, frontend type-check and
+1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
+four existing ignored). Final optimized backend built successfully; SHA256
+16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
+Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
+and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
+Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
+
+Actual desktop pruning screenshot exposed horizontal overflow; moved the
+explanation below the app header. The App Store uses Marketplace.vue, a separate
+install path from Discover.vue. Its first Install button bypassed the version
+modal. The browser check therefore sent an unintended Knots install request at
+19:28 UTC. Core remained running, no Knots container was created, and the full
+chain was not pruned. Removed only the newly created Knots installed-app record
+and newly created version config; preserved root-only rollback copies.
+
+Marketplace now uses the shared version/pruning modal. Added integration tests
+for both Core and Knots: no install request until confirmation, selected version
+and pruning forwarded, cancellation sends no install request. Four Marketplace
+tests pass (three new plus existing refresh check). Further browser checks block
+package.install requests at their network boundary. Final frontend rebuild and
+post-fix live checks remain pending. Final paid-file follow-up is still pending.
diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue
index 5b6dffc4..6b6da47d 100644
--- a/neode-ui/src/views/Marketplace.vue
+++ b/neode-ui/src/views/Marketplace.vue
@@ -137,7 +137,7 @@
:tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails"
- @install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
+ @install="handleInstall(app)"
@launch="launchInstalledApp"
/>
@@ -153,7 +153,13 @@
-
+
@@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
+import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
type MarketplaceApp,
INSTALLED_ALIASES,
+ MULTI_VERSION_APP_IDS,
getAppTier,
categorizeCommunityApp,
getCuratedAppList,
@@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
+const showInstallModal = ref(false)
+const installModalApp = ref(null)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
function installToast(app: MarketplaceApp) {
@@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
}
-async function installApp(app: MarketplaceApp) {
+function handleInstall(app: MarketplaceApp) {
+ if (installingApps.has(app.id) || isInstalled(app.id)) return
+ const blocked = installBlockedReason(app.id)
+ if (blocked) {
+ toast.error(blocked)
+ return
+ }
+ if (MULTI_VERSION_APP_IDS.has(app.id)) {
+ installModalApp.value = app
+ showInstallModal.value = true
+ return
+ }
+ startInstall(app)
+}
+
+function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) {
+ if (app.source === 'local') void installApp(app, version, prune)
+ else void installCommunityApp(app, version, prune)
+}
+
+function onInstallModalConfirm(version: string, prune?: boolean) {
+ const app = installModalApp.value
+ showInstallModal.value = false
+ installModalApp.value = null
+ if (app) startInstall(app, version, prune)
+}
+
+async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({
method: 'package.install',
- params: { id: app.id, url: installUrl, version: app.version },
+ params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
timeout: 600000,
})
} catch (err) {
@@ -545,7 +582,7 @@ async function installApp(app: MarketplaceApp) {
}
}
-async function installCommunityApp(app: MarketplaceApp) {
+async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) {
installToast(app)
try {
- const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: app.version }
+ const installParams: Record = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
if (app.containerConfig) installParams.containerConfig = app.containerConfig
await rpcClient.call({
method: 'package.install',
diff --git a/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts b/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts
index 35146ca3..730d0af6 100644
--- a/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts
+++ b/neode-ui/src/views/__tests__/MarketplaceRefresh.test.ts
@@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Marketplace from '../Marketplace.vue'
+import { rpcClient } from '@/api/rpc-client'
+import InstallVersionModal from '@/components/InstallVersionModal.vue'
+import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue'
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
// mounting a view directly with its heavier deps mocked at the module
@@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
}))
vi.mock('@/composables/useToast', () => ({
- useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
+ useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
+ getPackageVersions: vi.fn(),
},
}))
+vi.mock('../discover/curatedApps', () => ({
+ fetchAppCatalog: vi.fn().mockResolvedValue({
+ apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({
+ id, title: id, version: '29.0', description: 'Bitcoin node',
+ dockerImage: `registry.example/${id}:29.0`, source: 'community',
+ })),
+ }),
+}))
+
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear()
toastErrorMock.mockClear()
toastInfoMock.mockClear()
+ vi.mocked(rpcClient.call).mockReset()
+ vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
+ supportsVersions: true, default: '29.0', bitcoinPrune: false,
+ versions: [{ version: '29.0', default: true, deprecated: false, eol: null }],
+ } as Awaited>)
})
afterEach(() => {
@@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
wrapper.unmount()
})
+
+ it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
+ const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
+ await flushPromises()
+ const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)!
+ expect(card.exists()).toBe(true)
+ card.vm.$emit('install', card.props('app'))
+ await flushPromises()
+ const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')
+ expect(installs()).toHaveLength(0)
+ const modal = wrapper.findComponent(InstallVersionModal)
+ expect(modal.props('show')).toBe(true)
+ await modal.get('input[type="checkbox"]').setValue(true)
+ await modal.get('button.glass-button-warning').trigger('click')
+ await flushPromises()
+ expect(installs()).toHaveLength(1)
+ expect(installs()[0][0].params).toMatchObject({ id, version: '29.0', prune: true })
+ expect(modal.props('show')).toBe(false)
+ wrapper.unmount()
+ })
+
+ it('cancels Bitcoin selection without sending an installation request', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
+ const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
+ await flushPromises()
+ const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')!
+ card.vm.$emit('install', card.props('app'))
+ await flushPromises()
+ wrapper.findComponent(InstallVersionModal).vm.$emit('close')
+ await flushPromises()
+ expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0)
+ wrapper.unmount()
+ })
})