Skip to content

Commit 0e78922

Browse files
fix(console): an uninstalled cloud package is not drawn as installed (#28620)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb2Q8KcRWK76vkEfK3pDPdRy)_ Fixes #28521 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:云托管环境里卸载过的软件包(控制面软停用、保留安装记录),控制台不再显示为"已安装"。市场页"你的组织"卡片改为显示"重新安装"按钮;设置 → 软件包页不再把它列为已安装。 - **为什么改**:客户卸载应用后,卡片仍写"已安装"且没有任何按钮,客户既看不出它已停用,也装不回来。 - **风险与回滚**:只改控制台读法,不改云端、不改协议。回滚即还原本 PR 的两个源文件。 - **先看**:`ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts`(`listInstalledPackages` 的停用判定与 `readMarketplaceInstalls` 的过滤)、`MarketplacePage.tsx`(卡片按钮分支)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.disabledInstall-28521.test.tsx`,预期 6 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes The control plane's uninstall is a soft disable that keeps the installation row; `GET /api/v1/cloud-connection/installed` still lists that row with `status: 'disabled'` and `enabled: false` (measured by the cloud seat on the card). The Console read every listed row as installed. - `marketplaceApi.ts` `listInstalledPackages()` reads `status` and `enabled` and marks a disabled row with a new optional `LocalInstallEntry.disabled: true` member. It is absent on an installed row and never sent by the install-local listing, the same convention as `notLoaded`. - `MarketplacePage.tsx`: a disabled cloud row no longer joins `installedIds`. The org card draws **Reinstall** (the existing `marketplace.action.reinstall` key) instead of the Installed badge. The button calls the same `doOrgInstall`, so on a cloud-managed environment it posts to `POST /api/v1/cloud-connection/install`, which answers `reinstalled` and re-enables the row; the page then re-reads the listing and draws Installed. - `readMarketplaceInstalls()` (the Setup → Packages read) drops disabled rows on the cloud leg, so the page neither lists a soft-uninstalled package as an install nor offers its Details/Uninstall actions. The public catalog grid and the package detail page needed no change: the grid reads the install-local ledger only, and the detail page reads `GET /cloud-connection/installation`, which already answers `installed: false` for a disabled row. ## Tests - New `MarketplacePage.disabledInstall-28521.test.tsx` (6 cases) runs the real `listInstalledPackages`, `readMarketplaceInstalls` and `installPackage` against a stubbed `fetch`: - the client marks only the disabled row, and the marker is absent on an installed one; - `status: 'disabled'` alone and `enabled: false` alone each mark the row; - Setup → Packages' read keeps only the installed row; - the org card offers Reinstall, its click posts `{ package_id, seed_sample_data }` to the install door, and the re-enabled row then reads Installed with no button; - CONTROL: an installed row reads Installed; a package with no row offers Install. - Ablations (each through `scripts/ablation-replace.mjs`, restored to the HEAD blob with `git diff HEAD` empty): - `MarketplacePage` counting every cloud row installed: the Reinstall case goes red (1 failed / 5 passed); - `readMarketplaceInstalls` keeping disabled rows: the Setup → Packages case goes red (1 failed / 5 passed). ## Acceptance notes - The card offered two shapes: the Install button, or 已停用 with 重新安装. This PR draws Reinstall alone. A separate 已停用 badge would need a new `@object-ui/i18n` key, which widens the published catalogue (Clause-② yes); the existing Reinstall key already says the package was installed and is off now. - Setup → Packages drops the disabled row rather than drawing it with a reinstall action. That page lists what is installed; reinstall stays a marketplace action. - Not measured against a live hosted stack from this container: the cloud repository is not readable here. The row shape in the test is the one the card measured. ## Verification <details><summary>Local runs</summary> - `pnpm exec vitest run` over the 71 ui test files naming `MarketplacePage`, `marketplaceApi`, `readMarketplaceInstalls`, `listInstalledPackages` or `PackagesPage` (a declared narrowing; CI's Test UI runs the rest): 71 files, 710 tests passed. - After merging `origin/main` (head `5f3027f6`; the 2 incoming commits touch `packages/cli` and the metadata layer, not this diff): the new file plus every test under `ui/packages/app-shell/src/views/metadata-admin/`, 686 files, 7259 tests passed, 1 skipped. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - Derived gates (`dispatch-gates.mjs --commands`, 34 families): all 34 exit 0, `--ran` reconciliation 34 run / 0 NOT-MEASURED. Each command is in the card's os-dev report. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVBzmHX5zVyRfxqBc3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a078da commit 0e78922

4 files changed

Lines changed: 241 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): a package uninstalled on a cloud-managed environment is no longer drawn as installed
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The control plane's uninstall is a soft disable: it keeps the installation row and still lists it from `GET /api/v1/cloud-connection/installed`, with `status: 'disabled'` and `enabled: false`. The Console read every listed row as installed, so the marketplace's "Your organization" card kept saying Installed with no way to get the package back, and Setup → Packages listed it as an install.
11+
12+
- `listInstalledPackages()` now marks such a row `disabled` (either field saying so is enough). The marker is absent on an installed row and on every install-local entry.
13+
- The org card draws Reinstall for a disabled package instead of the Installed badge. Reinstall calls the same install door, `POST /api/v1/cloud-connection/install`, which re-enables the row; the page then reads it as installed.
14+
- `readMarketplaceInstalls()` leaves disabled rows out, so Setup → Packages neither lists nor acts on them.

‎ui/packages/app-shell/src/console/marketplace/MarketplacePage.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ export function MarketplacePage() {
358358
} | null>(null);
359359
const [orgDeploy, setOrgDeploy] = useState<OrgDeployState | null>(null);
360360
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
361+
// Packages the control plane lists as uninstalled (a soft disable that keeps
362+
// the row): not installed, and the org card offers Reinstall for them.
363+
const [disabledIds, setDisabledIds] = useState<Set<string>>(new Set());
361364

362365
const installedByManifestId = useMemo(() => {
363366
const m = new Map<string, LocalInstallEntry>();
@@ -394,9 +397,11 @@ export function MarketplacePage() {
394397
setInstalled(installs);
395398
setOrgItems(org.items ?? []);
396399
const ids = new Set<string>();
400+
const disabled = new Set<string>();
397401
for (const e of installs) ids.add(e.manifestId);
398-
for (const e of cloudInstalled.items) ids.add(e.manifestId);
402+
for (const e of cloudInstalled.items) (e.disabled ? disabled : ids).add(e.manifestId);
399403
setInstalledIds(ids);
404+
setDisabledIds(disabled);
400405
if (catalog.failure !== null) {
401406
setError(catalog.failure);
402407
setItems([]);
@@ -744,6 +749,9 @@ export function MarketplacePage() {
744749
<div className={CARD_GRID}>
745750
{orgItems.map((pkg) => {
746751
const isInstalled = installedIds.has(pkg.manifest_id);
752+
// Uninstalled on the control plane, which keeps the row: the
753+
// install door re-enables that row, so the action reads Reinstall.
754+
const isDisabled = !isInstalled && disabledIds.has(pkg.manifest_id);
747755
// objectui#11760 — the same page's other "Installed": an org
748756
// package installed into this runtime that it refused to load at
749757
// startup reads "Not loaded", from the local listing's marker.
@@ -783,7 +791,9 @@ export function MarketplacePage() {
783791
<Button size="sm" onClick={() => void doOrgInstall(pkg)} disabled={orgInstalling === pkg.id}>
784792
{orgInstalling === pkg.id
785793
? t('marketplace.org.installing', { defaultValue: 'Installing…' })
786-
: t('marketplace.org.install', { defaultValue: 'Install' })}
794+
: isDisabled
795+
? t('marketplace.action.reinstall')
796+
: t('marketplace.org.install', { defaultValue: 'Install' })}
787797
</Button>
788798
)}
789799
</div>
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A package uninstalled on a cloud-managed environment is not drawn as
5+
* installed (objectstack#28521).
6+
*
7+
* The control plane's uninstall is a soft disable: it keeps the installation
8+
* row (the ADR-0025 consent record and the sample-data state) and its env
9+
* listing, `GET /cloud-connection/installed`, still lists that row, with
10+
* `status: 'disabled'` and `enabled: false`. The console read every listed row
11+
* as installed, so the org card said "Installed" with no way back, and Setup →
12+
* Packages (`readMarketplaceInstalls`) drew it as an install. The install door,
13+
* `POST /cloud-connection/install`, re-enables the same row.
14+
*
15+
* `listInstalledPackages` and `installPackage` are the REAL client here, so the
16+
* rows are the ones that crossed the wire and the reinstall is the request the
17+
* door received. Only the catalog, the org listing and the local ledger are
18+
* stubbed. `t` echoes the KEY: the claim is WHICH control the card draws.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import { afterEach, describe, expect, it, vi } from 'vitest';
23+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
24+
25+
vi.mock('react-router-dom', () => ({
26+
useNavigate: () => vi.fn(),
27+
useParams: () => ({}),
28+
}));
29+
30+
vi.mock('@object-ui/auth', async (importOriginal) => ({
31+
...(await importOriginal<Record<string, unknown>>()),
32+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
33+
useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
34+
}));
35+
36+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
useObjectTranslation: () => ({
39+
t: (key: string) => `«${key}»`,
40+
language: 'en',
41+
}),
42+
}));
43+
44+
const PKG = 'com.acme.crm';
45+
const ORG_ROW = {
46+
id: 'pkg_1',
47+
manifest_id: PKG,
48+
display_name: 'Acme CRM',
49+
description: 'A CRM.',
50+
category: null,
51+
icon_url: null,
52+
visibility: 'org',
53+
latest_version: '1.0.0',
54+
};
55+
56+
vi.mock('../marketplaceApi', async (importOriginal) => ({
57+
...(await importOriginal<Record<string, unknown>>()),
58+
listMarketplacePackages: async () => ({ items: [] }),
59+
listLocalInstalls: async () => [],
60+
listOrgPackages: async () => ({ connected: true, items: [ORG_ROW] }),
61+
}));
62+
63+
// The org install's post-install wait is objectui#12097's subject, not this
64+
// suite's: it settles at once, so no poll outlives a case.
65+
vi.mock('../waitForServedApp', async (importOriginal) => ({
66+
...(await importOriginal<Record<string, unknown>>()),
67+
waitForServedApp: async () => ({ served: false }),
68+
}));
69+
70+
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
71+
import { MarketplacePage } from '../MarketplacePage';
72+
import { listInstalledPackages, readMarketplaceInstalls } from '../marketplaceApi';
73+
74+
const INSTALLED_PATH = '/api/v1/cloud-connection/installed';
75+
const INSTALL_PATH = '/api/v1/cloud-connection/install';
76+
77+
/** An installation row as the control plane's env package listing sends it. */
78+
const row = (state: 'enabled' | 'disabled', manifestId = PKG) => ({
79+
package_id: manifestId,
80+
package_version_id: 'ver_1',
81+
version: '1.0.0',
82+
installed_at: '2026-10-31T00:00:00.000Z',
83+
installed_by: 'usr_1',
84+
with_sample_data: false,
85+
status: state === 'enabled' ? 'installed' : 'disabled',
86+
enabled: state === 'enabled',
87+
});
88+
89+
const answer = (status: number, body: unknown) => ({
90+
ok: status >= 200 && status < 300,
91+
status,
92+
statusText: '',
93+
headers: new Headers({ 'content-type': 'application/json' }),
94+
json: async () => body,
95+
text: async () => JSON.stringify(body),
96+
});
97+
98+
/** The environment: its listed rows, and every install request the door received. */
99+
const env = { rows: [] as unknown[], installs: [] as unknown[] };
100+
101+
async function boot(rows: unknown[]) {
102+
resetRuntimeConfigForTesting();
103+
env.rows = rows;
104+
env.installs = [];
105+
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string; body?: string }) => {
106+
const path = new URL(String(input), 'http://console.test').pathname;
107+
const method = (init?.method ?? 'GET').toUpperCase();
108+
if (path === INSTALLED_PATH && method === 'GET') {
109+
return answer(200, { success: true, data: { packages: env.rows, total: env.rows.length, connected: true } });
110+
}
111+
if (path === INSTALL_PATH && method === 'POST') {
112+
const body = JSON.parse(init?.body ?? '{}');
113+
env.installs.push(body);
114+
// The door re-enables the same row.
115+
env.rows = env.rows.map((r: any) => (r.package_id === PKG ? row('enabled') : r));
116+
return answer(200, {
117+
success: true,
118+
data: { outcome: 'reinstalled', installation: { id: 'inst_1', environment_id: 'env_1', package_id: 'pkg_1', version: '1.0.0' } },
119+
});
120+
}
121+
if (path === '/api/v1/runtime/config') {
122+
// A cloud-managed environment: no install-local door, a control plane.
123+
return answer(200, {
124+
cloudUrl: 'https://cloud.example', singleEnvironment: false, defaultEnvironmentId: 'env_1',
125+
features: { installLocal: false, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
126+
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
127+
});
128+
}
129+
return answer(404, { success: false, error: { code: 'NOT_FOUND', message: `no route ${method} ${path}` } });
130+
}));
131+
await initRuntimeConfig();
132+
}
133+
134+
afterEach(() => {
135+
cleanup();
136+
vi.unstubAllGlobals();
137+
resetRuntimeConfigForTesting();
138+
});
139+
140+
describe('the control plane lists an uninstalled package; the console does not call it installed', () => {
141+
it('the client marks the disabled row, and only that one', async () => {
142+
await boot([row('disabled'), row('enabled', 'com.acme.psa')]);
143+
const listed = await listInstalledPackages();
144+
expect(listed.connected).toBe(true);
145+
expect(listed.items.map((i) => [i.manifestId, i.disabled])).toEqual([
146+
[PKG, true],
147+
['com.acme.psa', undefined],
148+
]);
149+
// Absent, never `false`, on an installed row.
150+
expect('disabled' in listed.items[1]).toBe(false);
151+
});
152+
153+
it('either field alone marks the row disabled', async () => {
154+
await boot([
155+
{ ...row('enabled'), status: 'disabled' },
156+
{ ...row('enabled', 'com.acme.psa'), enabled: false },
157+
]);
158+
const listed = await listInstalledPackages();
159+
expect(listed.items.map((i) => i.disabled)).toEqual([true, true]);
160+
});
161+
162+
it('Setup → Packages reads only the installed rows', async () => {
163+
await boot([row('disabled'), row('enabled', 'com.acme.psa')]);
164+
const installs = await readMarketplaceInstalls();
165+
expect(installs.source).toBe('cloud');
166+
expect(installs.items.map((i) => i.manifestId)).toEqual(['com.acme.psa']);
167+
});
168+
169+
it('the org card offers Reinstall, which the install door answers by re-enabling the row', async () => {
170+
await boot([row('disabled')]);
171+
render(<MarketplacePage />);
172+
const card = await screen.findByTestId(`org-card-${PKG}`);
173+
174+
const reinstall = await within(card).findByRole('button', { name: '«marketplace.action.reinstall»' });
175+
expect(within(card).queryByText('«marketplace.org.installedBadge»')).not.toBeInTheDocument();
176+
177+
fireEvent.click(reinstall);
178+
await waitFor(() => expect(env.installs).toEqual([{ package_id: 'pkg_1', seed_sample_data: true }]));
179+
// The page reads the listing again: the re-enabled row is installed.
180+
expect(await within(card).findByText('«marketplace.org.installedBadge»')).toBeInTheDocument();
181+
expect(within(card).queryByRole('button')).not.toBeInTheDocument();
182+
});
183+
184+
it('CONTROL: an installed row still reads Installed, with no action', async () => {
185+
await boot([row('enabled')]);
186+
render(<MarketplacePage />);
187+
const card = await screen.findByTestId(`org-card-${PKG}`);
188+
expect(await within(card).findByText('«marketplace.org.installedBadge»')).toBeInTheDocument();
189+
expect(within(card).queryByRole('button')).not.toBeInTheDocument();
190+
});
191+
192+
it('CONTROL: a package with no row still offers Install', async () => {
193+
await boot([]);
194+
render(<MarketplacePage />);
195+
const card = await screen.findByTestId(`org-card-${PKG}`);
196+
expect(await within(card).findByRole('button', { name: '«marketplace.org.install»' })).toBeInTheDocument();
197+
});
198+
});

‎ui/packages/app-shell/src/console/marketplace/marketplaceApi.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,13 @@ export interface LocalInstallEntry {
740740
* (objectui#11645). The entry is still installed: Uninstall and a
741741
* compatible re-install act on it. */
742742
notLoaded?: LocalInstallNotLoaded;
743+
/** Present only on a control-plane row ({@link listInstalledPackages}) that
744+
* was uninstalled: the control plane's uninstall is a soft disable that
745+
* keeps the installation row (its consent record and sample-data state)
746+
* and lists it with `status: 'disabled'` / `enabled: false`. Such a row is
747+
* NOT installed: nothing of it is served, and the install door re-enables
748+
* the same row. The install-local listing never carries it. */
749+
disabled?: true;
743750
}
744751

745752
/** A `managed` install-local answer: the package registered as code. */
@@ -933,6 +940,10 @@ export async function listLocalInstalls(): Promise<LocalInstallEntry[]> {
933940
*
934941
* `connected: false` means this runtime is self-hosted / not cloud-bound — the
935942
* caller should fall back to {@link listLocalInstalls}.
943+
*
944+
* `items` is every row the control plane lists, a soft-uninstalled one
945+
* included: that one carries `disabled` ({@link LocalInstallEntry.disabled})
946+
* and is not installed, so a reader that counts installs skips it.
936947
*/
937948
export interface InstalledPackagesResult {
938949
connected: boolean;
@@ -959,6 +970,9 @@ export async function listInstalledPackages(): Promise<InstalledPackagesResult>
959970
installedAt: String(p.installed_at ?? p.installedAt ?? ''),
960971
installedBy: (p.installed_by ?? p.installedBy ?? null) as string | null,
961972
withSampleData: p.with_sample_data === true || p.withSampleData === true,
973+
// The control plane lists a soft-uninstalled row too. Its `status`
974+
// and `enabled` both say so, and either one marks the row disabled.
975+
...(p.status === 'disabled' || p.enabled === false ? { disabled: true as const } : {}),
962976
};
963977
});
964978
return { connected: data.connected === true, items };
@@ -979,7 +993,9 @@ export async function listInstalledPackages(): Promise<InstalledPackagesResult>
979993
*/
980994
export async function readMarketplaceInstalls(): Promise<{ source: 'cloud' | 'local'; items: LocalInstallEntry[] }> {
981995
const cloud = await listInstalledPackages();
982-
if (cloud.connected) return { source: 'cloud', items: cloud.items };
996+
// A soft-uninstalled control-plane row is not installed: no row is drawn
997+
// for it and no action acts on it.
998+
if (cloud.connected) return { source: 'cloud', items: cloud.items.filter((i) => !i.disabled) };
983999
return { source: 'local', items: await fetchLocalInstalls() };
9841000
}
9851001

0 commit comments

Comments
 (0)