Skip to content

Commit caed02e

Browse files
committed
fix(app-shell): a local install's Details page offers its local menu on a marketplace-off runtime that mounts install-local (objectui#11627)
On an offline boot (OS_CLOUD_URL=off) the runtime config reports features.marketplace false and features.installLocal true. Installed Apps links each local install's Details to MarketplacePackagePage, which returned the MarketplaceDisabled notice before anything else, so the local re-seed / purge menu was unreachable and no install-local request was ever issued. The marketplace-off branch now draws, for an admin whose package is a local install, the package header and the same local menu above the unchanged notice. The local install is matched on the ledger entry's packageId (the id Installed Apps builds the link from); the menu acts on its manifest id. The catalog fetch, cloud-installation probe, install-to-cloud CTA, readme and version list stay refused. listLocalInstalls is no longer gated on features.marketplace, since its answer now has a consumer there. The objectui#5620 case that pinned "never fires on a marketplace-off runtime, even for an admin with installLocal on" pinned the branch this removes and is replaced by the two marketplace-off cases where the answer still has no consumer (installLocal off; a refused viewer). Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL Co-authored-by: Claude <noreply@anthropic.com>
1 parent c000398 commit caed02e

4 files changed

Lines changed: 480 additions & 90 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
On a runtime with no marketplace that still mounts install-local (an offline boot, `OS_CLOUD_URL=off`), a local install's Details page now offers its local menu: re-seed sample data, purge sample data and uninstall from this runtime (objectui#11627).
6+
7+
Installed Apps links each local install's Details to the marketplace package page. That page answered a marketplace-off runtime with the "App Marketplace is turned off" notice and nothing else, so the local menu below that check could not be reached and no install-local request was ever issued. Now, when the runtime config reports `features.installLocal` and the viewer is an admin, the page draws the package's header (manifest id and installed version) and its local menu above the same notice. The menu issues the same `POST /api/v1/marketplace/install-local/MANIFEST_ID/reseed-sample-data` and `.../purge-sample-data` calls as on a marketplace-on runtime. The page finds the install by the id Installed Apps put in the link (the ledger entry's `packageId`), and the menu acts on its manifest id.
8+
9+
Everything that needs a marketplace stays refused as before: the page sends no catalog request and no cloud-installation probe, and it draws no install-to-cloud button, readme or version list. A viewer who is not an admin, a runtime that does not mount install-local, and a package that is not a local install all see the notice alone, as before.
10+
11+
**Clause-②: no.** Nothing on the package entry changes: no export, prop, type member or i18n key is added. `MarketplacePackagePage` takes no props, and the strings it draws already existed.

‎packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx‎

Lines changed: 148 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,21 @@ export function MarketplacePackagePage() {
166166
// with no marketplace proxy at all), so it stays its own check rather than
167167
// being folded into the other two predicates.
168168
if (!getRuntimeConfig().features.installLocal) return;
169-
// Its only consumer is `localInstalls.find(...)` in the content branch
170-
// below, which is unreachable whenever the page has already returned
171-
// `MarketplaceDisabled` or (post-objectui#5583) `MarketplaceAccessDenied`.
172-
// Firing anyway would be the same discarded-request class `2573ff434`
173-
// closed for this page, on the flag that change was not about
174-
// (objectui#5620).
175-
if (!marketplaceEnabled) return;
169+
// Its only consumer is the `localInstall` match below, read by the content
170+
// branch AND by the marketplace-off local view (objectui#11627). Neither
171+
// is drawn for a viewer refused the admin surface, so firing for one would
172+
// be the same discarded-request class `2573ff434` closed for this page
173+
// (objectui#5620). `marketplaceEnabled` is deliberately NOT a gate any
174+
// more: on a runtime with no marketplace but a mounted install-local
175+
// surface, this answer is what decides whether the local menu is drawn.
176176
if (!isAdmin) return;
177177
let cancelled = false;
178178
(async () => {
179179
const items = await listLocalInstalls();
180180
if (!cancelled) setLocalInstalls(items);
181181
})();
182182
return () => { cancelled = true; };
183-
}, [packageId, localResult, marketplaceEnabled, isAdmin]);
183+
}, [packageId, localResult, isAdmin]);
184184

185185
// Seed cloud-install state so the primary CTA renders as "Installed" on
186186
// first paint instead of inviting another install.
@@ -555,6 +555,103 @@ export function MarketplacePackagePage() {
555555
}
556556
};
557557

558+
// `features.installLocal` is the server's own answer to "is the install-local
559+
// surface mounted here" -- the same read the effect above gates on, never a
560+
// guess from the package's fields.
561+
const supportsLocal = getRuntimeConfig().features.installLocal;
562+
// Which local install, if any, this page is about. With a marketplace the
563+
// catalog row names it, by manifest id. Without one there is no catalog row,
564+
// and the route's id is the one Installed Apps built this link from
565+
// (`entry.packageId`), so it is matched on that same field. Declared ahead of
566+
// every early return: the local sample-data handlers above close over it.
567+
const localInstall = (marketplaceEnabled
568+
? localInstalls.find((i) => !!data && i.manifestId === data.package.manifest_id)
569+
: localInstalls.find((i) => i.packageId === packageId)) ?? null;
570+
571+
// The local-install menu and the two status banners, drawn by both the
572+
// catalog view and the marketplace-off local view below (objectui#11627).
573+
const localInstalledBadge = localInstall && (
574+
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
575+
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
576+
{t('marketplace.detail.installedV', { version: localInstall.version })}
577+
</Badge>
578+
);
579+
const localMenu = localInstall && (
580+
<DropdownMenu>
581+
<DropdownMenuTrigger asChild>
582+
<Button variant="outline" size="lg" className="px-2.5" aria-label={t('marketplace.detail.moreOptions')}>
583+
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
584+
</Button>
585+
</DropdownMenuTrigger>
586+
<DropdownMenuContent align="end" className="w-56">
587+
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
588+
{sampleDataBusy === 'reseed'
589+
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
590+
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
591+
{localInstall.withSampleData
592+
? t('marketplace.detail.reseedAgain')
593+
: t('marketplace.detail.addSampleData')}
594+
</DropdownMenuItem>
595+
<DropdownMenuItem
596+
onSelect={doPurgeLocalSampleData}
597+
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
598+
className="text-destructive focus:text-destructive"
599+
>
600+
{sampleDataBusy === 'purge'
601+
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
602+
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
603+
{t('marketplace.detail.purgeSampleData')}
604+
</DropdownMenuItem>
605+
<DropdownMenuItem onSelect={doUninstallLocal} disabled={installingLocal} className="text-destructive focus:text-destructive">
606+
<Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />
607+
{t('marketplace.detail.uninstallFromRuntime')}
608+
</DropdownMenuItem>
609+
</DropdownMenuContent>
610+
</DropdownMenu>
611+
);
612+
const sampleDataNote = sampleDataMsg && (
613+
<div
614+
role="status"
615+
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${sampleDataMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
616+
>
617+
{sampleDataMsg.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
618+
<div className="flex-1">{sampleDataMsg.text}</div>
619+
<button
620+
type="button"
621+
className="text-xs underline opacity-60 hover:opacity-100"
622+
onClick={() => setSampleDataMsg(null)}
623+
>
624+
{t('marketplace.action.dismiss')}
625+
</button>
626+
</div>
627+
);
628+
// `suggestFor` is the manifest id whose suggested audience bindings a
629+
// successful local INSTALL surfaces (ADR-0090 D5), or `null` where no install
630+
// door is drawn and the only result this banner can carry is an uninstall's.
631+
const localResultNote = (suggestFor: string | null) => localResult && (
632+
<div
633+
role="status"
634+
className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
635+
>
636+
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
637+
<div className="flex-1">
638+
{localResult.message}
639+
{/* ADR-0090 D5 — local installs land in this runtime's kernel, so
640+
its suggested audience bindings are confirmable right here. */}
641+
{localResult.ok && suggestFor ? (
642+
<SuggestedBindingsPanel packageId={suggestFor} strings={suggestionStrings} className="mt-2" />
643+
) : null}
644+
</div>
645+
<button
646+
type="button"
647+
className="text-xs underline opacity-60 hover:opacity-100"
648+
onClick={() => setLocalResult(null)}
649+
>
650+
{t('marketplace.action.dismiss')}
651+
</button>
652+
</div>
653+
);
654+
558655
// A CONFIGURATION CONCLUSION, not a load failure -- the same informational
559656
// state the catalog page renders, so the two pages stop disagreeing about the
560657
// same runtime (`2573ff434`). Reached by a pasted or bookmarked package URL,
@@ -565,7 +662,45 @@ export function MarketplacePackagePage() {
565662
// `features.marketplace` is public runtime config that any client already
566663
// reads. Telling an unprivileged operator they lack permission for a surface
567664
// that exists for nobody is the same misdirection this fix removes.
568-
if (!marketplaceEnabled) return <MarketplaceDisabled />;
665+
//
666+
// objectui#11627 — that answer covers the REMOTE half of the page only. A
667+
// runtime with no marketplace can still mount the install-local surface (an
668+
// offline boot, `OS_CLOUD_URL=off`), and Installed Apps links each local
669+
// install's Details here; the notice used to be all that page showed, so
670+
// local reseed / purge had no way in. Now an admin whose package IS a local
671+
// install also gets its header and local menu, ABOVE the same notice: the
672+
// catalog fetch, the install-to-cloud CTA, the readme and the version list
673+
// stay refused exactly as before, and every other viewer sees the notice
674+
// alone. The local half is ADDED once `listLocalInstalls` answers, so the
675+
// notice is never painted and then retracted.
676+
if (!marketplaceEnabled) {
677+
if (!(supportsLocal && isAdmin && (localInstall || localResult))) return <MarketplaceDisabled />;
678+
return (
679+
<div className="mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6">
680+
{localInstall && (
681+
<div className="flex items-start gap-5 flex-wrap sm:flex-nowrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6 sm:p-8">
682+
<PackageIcon
683+
manifestId={localInstall.manifestId}
684+
className="h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border shrink-0"
685+
initialClassName="text-3xl font-bold"
686+
/>
687+
<div className="flex-1 min-w-0">
688+
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight truncate">{localInstall.manifestId}</h1>
689+
<div className="text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-1.5">
690+
{localInstalledBadge}
691+
</div>
692+
</div>
693+
<div className="flex items-center gap-2 shrink-0 self-start">
694+
{localMenu}
695+
</div>
696+
</div>
697+
)}
698+
{sampleDataNote}
699+
{localResultNote(null)}
700+
<MarketplaceDisabled />
701+
</div>
702+
);
703+
}
569704

570705
// Ahead of BOTH the loading and the load-failure branches below
571706
// (objectui#5583): authorization is not a function of whether the fetch
@@ -630,7 +765,6 @@ export function MarketplacePackagePage() {
630765
const pkg = data.package;
631766
const loc = localizePackage(pkg as any, language);
632767
const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
633-
const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
634768
// PD4 (ADR-0025 §3.11): code-bearing packages must disclose + be acknowledged.
635769
const containsCode = !!pkg.latest_version?.contains_code;
636770
// ADR-0010 version lifecycle: installed cloud env is on an OLDER version than
@@ -643,7 +777,6 @@ export function MarketplacePackagePage() {
643777
&& !!latestVersion
644778
&& isNewerVersion(latestVersion, cloudInstalledVersion);
645779

646-
const supportsLocal = getRuntimeConfig().features.installLocal;
647780
const primaryDisabled = !latestVersion || installingLocal || installing || (!supportsLocal && !!cloudInstalledVersion && !cloudUpdateAvailable);
648781
const primaryAction = supportsLocal
649782
? {
@@ -713,12 +846,7 @@ export function MarketplacePackagePage() {
713846
)}
714847
{categoryLabel && <Badge variant="outline">{categoryLabel}</Badge>}
715848
{pkg.license && <Badge variant="outline" className="font-normal">{pkg.license}</Badge>}
716-
{localInstall && (
717-
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
718-
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
719-
{t('marketplace.detail.installedV', { version: localInstall.version })}
720-
</Badge>
721-
)}
849+
{localInstalledBadge}
722850
{!localInstall && cloudInstalledVersion && (
723851
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
724852
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
@@ -741,39 +869,7 @@ export function MarketplacePackagePage() {
741869
<Download className="h-4 w-4 mr-1.5" aria-hidden="true" />
742870
{primaryAction.label}
743871
</Button>
744-
{localInstall && (
745-
<DropdownMenu>
746-
<DropdownMenuTrigger asChild>
747-
<Button variant="outline" size="lg" className="px-2.5" aria-label={t('marketplace.detail.moreOptions')}>
748-
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
749-
</Button>
750-
</DropdownMenuTrigger>
751-
<DropdownMenuContent align="end" className="w-56">
752-
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
753-
{sampleDataBusy === 'reseed'
754-
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
755-
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
756-
{localInstall.withSampleData
757-
? t('marketplace.detail.reseedAgain')
758-
: t('marketplace.detail.addSampleData')}
759-
</DropdownMenuItem>
760-
<DropdownMenuItem
761-
onSelect={doPurgeLocalSampleData}
762-
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
763-
className="text-destructive focus:text-destructive"
764-
>
765-
{sampleDataBusy === 'purge'
766-
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
767-
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
768-
{t('marketplace.detail.purgeSampleData')}
769-
</DropdownMenuItem>
770-
<DropdownMenuItem onSelect={doUninstallLocal} disabled={installingLocal} className="text-destructive focus:text-destructive">
771-
<Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />
772-
{t('marketplace.detail.uninstallFromRuntime')}
773-
</DropdownMenuItem>
774-
</DropdownMenuContent>
775-
</DropdownMenu>
776-
)}
872+
{localMenu}
777873
{/* Reseed / purge still POST cross-origin to the control plane, which
778874
the browser blocks on a tenant subdomain. Only offer them when the
779875
runtime IS the cloud (same-origin). On tenants the install state is
@@ -811,46 +907,9 @@ export function MarketplacePackagePage() {
811907
</div>
812908
</div>
813909

814-
{sampleDataMsg && (
815-
<div
816-
role="status"
817-
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${sampleDataMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
818-
>
819-
{sampleDataMsg.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
820-
<div className="flex-1">{sampleDataMsg.text}</div>
821-
<button
822-
type="button"
823-
className="text-xs underline opacity-60 hover:opacity-100"
824-
onClick={() => setSampleDataMsg(null)}
825-
>
826-
{t('marketplace.action.dismiss')}
827-
</button>
828-
</div>
829-
)}
910+
{sampleDataNote}
830911

831-
{localResult && (
832-
<div
833-
role="status"
834-
className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
835-
>
836-
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
837-
<div className="flex-1">
838-
{localResult.message}
839-
{/* ADR-0090 D5 — local installs land in this runtime's kernel, so
840-
its suggested audience bindings are confirmable right here. */}
841-
{localResult.ok && (
842-
<SuggestedBindingsPanel packageId={pkg.manifest_id} strings={suggestionStrings} className="mt-2" />
843-
)}
844-
</div>
845-
<button
846-
type="button"
847-
className="text-xs underline opacity-60 hover:opacity-100"
848-
onClick={() => setLocalResult(null)}
849-
>
850-
{t('marketplace.action.dismiss')}
851-
</button>
852-
</div>
853-
)}
912+
{localResultNote(pkg.manifest_id)}
854913

855914
<div className="grid gap-4 lg:grid-cols-3">
856915
<div className="lg:col-span-2 space-y-4">

‎packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage-5620.test.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
* `features.installLocal: true`, the request still fired — and its answer
2323
* was discarded — on a marketplace-off runtime and for a refused viewer.
2424
*
25+
* ## objectui#11627 retired the `marketplaceEnabled` half
26+
*
27+
* On a marketplace-OFF runtime that mounts install-local, the answer now HAS a
28+
* consumer: an admin whose package is a local install gets its local menu
29+
* above the disabled notice, and this request is what decides that. So the
30+
* case that pinned "never fires on a marketplace-off runtime, even for an
31+
* admin with installLocal on" pinned exactly the branch that card removed, and
32+
* was replaced, not respelled. What still holds on a marketplace-off runtime,
33+
* and is pinned below, is the rest of this card's class: no request where its
34+
* answer has nowhere to go — installLocal off, or a refused viewer. The
35+
* positive half lives in `MarketplacePackagePage.offlineLocalMenu-11627.test.tsx`.
36+
*
2537
* ## What is NOT claimed
2638
*
2739
* That `features.installLocal` stops mattering. It is a genuinely separate
@@ -148,8 +160,18 @@ afterEach(() => {
148160
});
149161

150162
describe('listLocalInstalls on a marketplace-OFF runtime', () => {
151-
it('never fires, even with installLocal on and the viewer an admin', async () => {
163+
it('never fires when installLocal is off too, even for an admin', async () => {
152164
viewer.isAdmin = true;
165+
await bootOn(serverConfig(false, false));
166+
167+
render(<MarketplacePackagePage />);
168+
169+
await waitFor(() => expect(screen.getByTestId('marketplace-disabled')).toBeInTheDocument());
170+
expect(listLocalInstalls).not.toHaveBeenCalled();
171+
});
172+
173+
it('never fires for a refused (non-admin) viewer, even with installLocal on', async () => {
174+
viewer.isAdmin = false;
153175
await bootOn(serverConfig(false, true));
154176

155177
render(<MarketplacePackagePage />);

0 commit comments

Comments
 (0)