Skip to content

Commit cd3a167

Browse files
feat(app-shell): the hosted marketplace install offers and sends the install mode a package permits (#30992)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoVXP4s3MFTrPqUtxatiSwyk)_ Fixes #30963 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:托管云环境里的应用市场详情页,现在也会读取包声明的 `installModes`;包允许 unmanaged 时显示与本地安装页相同的「安装模式」选择,并把所选模式作为 `install_mode` 发给 `/api/v1/cloud-connection/install`。只允许 managed 或未声明的包,请求与以前逐字节相同。 - 为什么改:cloud#3624 裁决 A(一个安装入口,`installMode` 作为可选参数由控制面裁决)。此前托管路径无法表达模式,为「被拥有」而做的起步模板在托管云上只能装成 managed。 - 风险与回滚:仅 Console 代码(`@object-ui/app-shell` patch)。环境侧转发(#30964)落地前,该参数在下游被忽略;此时页面会明确提示「已按 managed 安装」,不会假装成功复制。回滚 = revert 本 PR。 - 先看:`MarketplacePackagePage.tsx` 的 `hostedInstallMode` 与 `doInstall` 答复分支;`marketplaceApi.ts` 的 `installPackage()`;测试文件新增的两个 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.installMode-12288.test.tsx`,预期 32/32 通过。 - 需要你做的:无,席位落地。 - **席位意见**:ui 席 2 对照 head `374cdc9c` 的 diff 复核:4 个文件、+278 / −43,无受治理路径。托管安装 `installPackage()` 新增可选 `installMode`(取自 spec 的 `INSTALL_MODES`),只在设定时发送 `install_mode`;只允许托管或未声明安装方式的包,请求体逐字节不变(有断言)。环境控制台(有云端地址)的包页用与本地安装同一解析读取 `installModes`,画同一选择;两个安装入口共用一个拒绝文案函数。#30964(服务端)落地前,选择「非托管副本」会按托管安装并如实提示;控制面自身控制台不画选择,因副本只能由环境内的 cloud-connection 安装路由生成,理由写在 Acceptance notes。CI 59 项全绿,Clause-② no,不欠契约复审。同意落地。 ## What changes - **`installPackage()`** (`marketplaceApi.ts`) takes an optional `installMode`, typed from the spec's `INSTALL_MODES` (`LocalInstallMode` is now the spec's `InstallMode`, and the console's mode list is `INSTALL_MODES` itself, so a mode the spec adds reaches the page without a second list). It is sent as `install_mode` only when set: on the hosted route `POST /api/v1/cloud-connection/install` as a top-level body key, and on the control plane's own `install_package` action inside `params`. Unset, neither body gains a key. - **Wire key `install_mode`.** The cloud-connection install route reads its body in snake case (`package_id`, `seed_sample_data`, with camel-case fallbacks), and it forwards to the control plane's `install_package` action, whose `params` are snake case too. The route does not read the key yet: that is #30964 (`pm:blocked`), which forwards the mode. - **The package page on the hosted path** (a cloud base, no install-local door) reads the manifest the marketplace serves (`GET /api/v1/marketplace/packages/:id/versions/latest/manifest`, proxied on hosted) and resolves it through `declaredInstallModesOf`, the install-local page's resolver; no second reading. The same choice element is drawn: both modes ⇒ the radio choice and Install waits for a pick; `unmanaged` alone ⇒ the "this install is a copy" note and `unmanaged` is sent. A package that declares no `installModes`, permits only `managed`, or whose manifest cannot be read sends no mode. An update of an installed copy sends no mode and draws no choice. - **The answer** is read as install-local reads its own: `installMode: 'unmanaged'` ⇒ the success sentence names the base and the CTA does not flip to Installed (the copy is not a package installation, per the ruling's acceptance note). A requested copy answered without it ⇒ the existing "installed managed instead" sentence (`marketplace.install.unmanagedIgnored`), because that is what an environment that does not forward the mode does today. - **Refusals**: the install-local catch block's code-to-text mapping is now one helper, `installRefusalText`, used by both install doors. The hosted error now carries the envelope's `details`, so `DEPENDENCY_MISSING` is drawn from its facts there too; the install-mode refusals show the server's own message on both. - No i18n key is added: every sentence used exists already. ## Tests `MarketplacePackagePage.installMode-12288.test.tsx` is the shared harness for install modes (`marketplaceApi` not mocked, one stubbed `fetch` answering by path), so the new pins are two top-level `describe` blocks there: - the hosted path: both modes draw the choice and gate Install; each picked mode is sent; `unmanaged` alone is sent with the copy note; no `installModes`, `managed` only and an unreadable manifest each send a body with no `install_mode` key (`not.toHaveProperty`); the unmanaged and ignored answers; two install-mode refusals; `DEPENDENCY_MISSING` drawn from its facts. - `installPackage()` on both branches: absent sends no key, set sends it. - The list page's one-click install keeps its pinned body: `MarketplacePage.disabledInstall-28521.test.tsx` asserts `[{ package_id: 'pkg_1', seed_sample_data: true }]` on `POST /api/v1/cloud-connection/install`, unchanged and green. ## Acceptance notes - **End to end needs #30964.** Until the environment's `CloudConnectionPlugin` forwards `install_mode`, a hosted request for a copy installs managed, and the page shows `marketplace.install.unmanagedIgnored` ("installed managed instead"). That is the truthful outcome until then. The proof here is unit and harness readings (below); no live hosted install was run. - **Answer shape assumed for the hosted copy.** The page recognises an unmanaged hosted install by `installMode: 'unmanaged'` on the answer, the field install-local answers with (with `targetPackageId` and `version`). #30964 defines the hosted answer; if it answers another shape, the page reads it as "installed managed instead", loudly, not as success. The install-local answer's `strippedLocks` list is not drawn on the hosted path yet. - **The choice is drawn only where a cloud base is set** (an environment console on hosted cloud), not on the control plane's own console, because no copy can happen there: per #30964 the unmanaged copy is run by the environment's `CloudConnectionPlugin` (`importUnmanagedPackage` in the environment) on its own install route, and the control-plane console's install goes straight to the `install_package` action, never through that environment. Its sentences would also be false there: the mode hint and success text say "this runtime", while that dialog targets another environment. `installPackage()` still sends a given mode on that branch, so the parameter is never silently dropped. - **Sample data with an unmanaged pick.** The hosted dialog still offers "Include sample data" when `unmanaged` is picked; per #30964 an unmanaged grant loads no sample data. Not changed here, since what the control plane does with the flag on a copy is #30964's and cloud's. - **Not a bug, per the ruling:** an unmanaged copy does not appear in the marketplace "Installed" list (it reads `sys_package_installation`); no code was added for it. - Lint: `eslint` on the three files reports 0 errors; warnings 52 → 53 on the two source files, the one new `no-explicit-any` being `(err as any).details`, the same spelling install-local's error already uses. ## Verification All readings on `objectstack-ai/objectstack`, head `374cdc9c` (base `1b3e22f0`). <details><summary>Before / after: hosted harness reading (cloud base set, install-local off)</summary> A throwaway probe (not committed) rendered the page with three manifests, picked `unmanaged` when a choice was drawn, and installed through the dialog. Before, page code at `1b3e22f0`: ``` READING both: manifestRead=false radios=0 installDisabledBeforePick=false picked=none body={"package_id":"pkg_crm","seed_sample_data":false} READING managed-only: manifestRead=false radios=0 installDisabledBeforePick=false picked=none body={"package_id":"pkg_crm","seed_sample_data":false} READING absent: manifestRead=false radios=0 installDisabledBeforePick=false picked=none body={"package_id":"pkg_crm","seed_sample_data":false} ``` After: ``` READING both: manifestRead=true radios=2 installDisabledBeforePick=true picked=unmanaged body={"package_id":"pkg_crm","seed_sample_data":false,"install_mode":"unmanaged"} READING managed-only: manifestRead=true radios=0 installDisabledBeforePick=false picked=none body={"package_id":"pkg_crm","seed_sample_data":false} READING absent: manifestRead=true radios=0 installDisabledBeforePick=false picked=none body={"package_id":"pkg_crm","seed_sample_data":false} ``` The new pins against the base page code: 14 failed, 18 passed (32). With the fix: 32 passed. </details> <details><summary>Ablation (each mutation landed via scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> - A: `hostedInstallMode` never takes the resolved mode ⇒ 4 failed (both picked modes, `unmanaged` alone, the ignored answer). - B: the `managed`-only guard removed ⇒ 1 failed: "a manifest that permits `managed` only draws no choice, and the request carries no `install_mode` key". - C: the hosted catch shows the bare message instead of `installRefusalText` ⇒ 1 failed: the `DEPENDENCY_MISSING` pin. The subject resolves from source (relative imports), so no dist build was owed. </details> <details><summary>Tests and gates</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/console/marketplace/` (under the verify lock): Test Files 55 passed (55), Tests 485 passed (485). - `cd ui/packages/app-shell && pnpm run type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`, after a full dts build of `@object-ui/app-shell^...`, under the verify lock): VERDICT command-exit 0. - Derived gates, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `374cdc9c`: 36 commands, each run with stdin closed, every one `:: exit 0`; `--ran` reconciliation: "36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED". The list includes `node scripts/check-sdui-manifest.mjs` and its `--self-test`, `pnpm check:nul-bytes`, `pnpm --dir ui exec vitest run scripts/`. - `pnpm --dir ui run check:new-line-citations`: "0 new citation(s)". - `eslint` on the three changed files: 0 errors. </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d463b99 commit cd3a167

4 files changed

Lines changed: 278 additions & 43 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+
feat(app-shell): the hosted marketplace install offers the install modes a package permits and sends the chosen one as `install_mode`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a hosted environment (a cloud base, no install-local door), the marketplace package page now reads the package's `installModes` from the manifest the marketplace serves, through the same resolver the install-local page uses. A package that permits both `managed` and `unmanaged` draws the same Install mode choice, and Install waits for a pick. A package that permits only `unmanaged` says before the click that its install is a copy. The install request to `POST /api/v1/cloud-connection/install` carries the mode as `install_mode`, and the control plane judges it.
11+
12+
A package that declares no `installModes`, or permits only `managed`, sends the request it sent before: no `install_mode` key. The marketplace list's one-click install and an update of an installed package send no mode either.
13+
14+
The answer is read the way install-local reads its own. An answer that names `installMode: 'unmanaged'` reports the base the copy went into, and the package does not flip to Installed, because the copy is the environment's own metadata, not a package installation. A requested copy answered without one says the environment installed the package managed instead, since that is what an environment that does not forward the mode does. Refusals on the hosted install now go through the install-local page's code-to-text path: a `DEPENDENCY_MISSING` refusal lists its unmet dependencies in the reader's language, and the install-mode refusals show the server's own message.

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

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,14 @@ function MarketplacePackagePageBody() {
373373
// ADR-0131 D6 — what the package permits is its manifest's `installModes`,
374374
// so the page reads the manifest the install door reads: the same path and
375375
// the same version (`latest`, the one `installLocal` installs when it names
376-
// none). Only where install-local is the page's install door (the cloud
377-
// install routes take no mode) and only for a viewer the page serves.
376+
// none). Where an install door takes a mode — install-local, or the hosted
377+
// environment's cloud-connection install, which hands it to the control
378+
// plane to judge — and only for a viewer the page serves.
378379
useEffect(() => {
379380
if (!packageId) return;
380381
if (!marketplaceEnabled) return;
381382
if (!isAdmin) return;
382-
if (!getRuntimeConfig().features.installLocal) return;
383+
if (!getRuntimeConfig().features.installLocal && !getCloudBase()) return;
383384
let cancelled = false;
384385
(async () => {
385386
try {
@@ -403,6 +404,13 @@ function MarketplacePackagePageBody() {
403404
const requestedInstallMode: LocalInstallMode | undefined = !declaredModes
404405
? undefined
405406
: declaredModes.length === 1 ? declaredModes[0] : (chosenMode || undefined);
407+
// The hosted install (a cloud base, no install-local door) sends the same
408+
// resolved mode, except where the package permits nothing beyond `managed`:
409+
// that request stays the one every hosted install sent before the mode.
410+
const hostedModeDoor = !getRuntimeConfig().features.installLocal && !!getCloudBase();
411+
const hostedInstallMode: LocalInstallMode | undefined = hostedModeDoor && declaredModes?.some((m) => m !== 'managed')
412+
? requestedInstallMode
413+
: undefined;
406414

407415
// Seed cloud-install state so the primary CTA renders as "Installed" on
408416
// first paint instead of inviting another install.
@@ -592,8 +600,32 @@ function MarketplacePackagePageBody() {
592600
setDeploy({ phase: 'served', manifestId, version, updatedTo, app: filterActiveApps(wait.apps)[0] ?? null });
593601
};
594602

603+
/**
604+
* The text for a refused install, on either install door: the refusal drawn
605+
* from its facts, or the console's sentence for a code it explains, else the
606+
* envelope's own message. The install-mode refusals
607+
* (`INSTALL_MODE_NOT_PERMITTED`, `UNMANAGED_INSTALL_WALLED_POSTURE`,
608+
* `RESOURCE_CONFLICT`) take no branch: each message names its own remedy,
609+
* and the page has nothing to add to it.
610+
*/
611+
const installRefusalText = (e: unknown): string => {
612+
// ADR-0141 D2 — an unsatisfied declared dependency, drawn from the
613+
// refusal's facts in the reader's locale; without facts the envelope's
614+
// message stands.
615+
const dependencyMissing = dependencyMissingText(e);
616+
if (dependencyMissing) return dependencyMissing;
617+
const msg = (e as { message?: string } | null | undefined)?.message ?? String(e);
618+
if (errorCodeIs(e, 'MANIFEST_CONFLICT')) return t('marketplace.install.localManifestConflict', { message: msg });
619+
if (errorCodeIs(e, 'UNAUTHENTICATED')) return t('marketplace.install.localUnauthorized');
620+
if (errorCodeIs(e, 'MARKETPLACE_UNAVAILABLE')) return t('marketplace.install.localMarketplaceUnavailable');
621+
return msg;
622+
};
623+
595624
const doInstall = async () => {
596625
if (!packageId || !selectedEnv) return;
626+
// ADR-0131 D6 — an install, never an update of the managed copy this
627+
// environment holds, sends the mode the package page resolved.
628+
const installMode = dialogUpdate ? undefined : hostedInstallMode;
597629
setInstalling(true);
598630
setInstallResult(null);
599631
try {
@@ -605,7 +637,25 @@ function MarketplacePackagePageBody() {
605637
packageId,
606638
environmentId: selectedEnv,
607639
seedSampleData: dialogUpdate ? false : seedSampleData,
640+
...(installMode ? { installMode } : {}),
608641
});
642+
const name = data?.package ? localizePackage(data.package as any, language).displayName : packageId;
643+
// An unmanaged copy (ADR-0131 D6) is the environment's own base, not a
644+
// package installation: the Installed state does not flip, and no app is
645+
// waited for under the package's id.
646+
if (installResp?.installMode === 'unmanaged') {
647+
try {
648+
await refreshMetadata('app');
649+
} catch {
650+
// Non-fatal: the copy is written; the app list refreshes on next navigation.
651+
}
652+
setInstallResult({
653+
ok: true,
654+
message: t('marketplace.install.unmanagedSuccess', { version: installResp.version, name, base: installResp.targetPackageId }),
655+
});
656+
emitMetadataRefresh();
657+
return;
658+
}
609659
// The version an update reports moving to: the install answer's own, else
610660
// the one the dialog named.
611661
const updatedTo = dialogUpdate ? (installResp?.installation?.version ?? dialogUpdate.to) : null;
@@ -628,7 +678,12 @@ function MarketplacePackagePageBody() {
628678
if (info) setCloudInstall(info);
629679
}
630680
} catch { /* non-fatal */ }
631-
if (installLandsInThisEnvironment(selectedEnv) && data) {
681+
if (installMode === 'unmanaged') {
682+
// Asked for a copy, answered without one: an environment that forwards
683+
// no mode installed the package MANAGED. Said out loud, since the
684+
// opposite of what was asked happened.
685+
setInstallResult({ ok: false, message: t('marketplace.install.unmanagedIgnored', { name }) });
686+
} else if (installLandsInThisEnvironment(selectedEnv) && data) {
632687
// objectui#12087 — the runtime answers this install while it still
633688
// serves the PRE-install kernel, so nothing is refreshed yet: the wait
634689
// below refreshes once the package's app is served. Not awaited, and
@@ -648,7 +703,7 @@ function MarketplacePackagePageBody() {
648703
});
649704
}
650705
} catch (e: any) {
651-
setInstallResult({ ok: false, message: e?.message ?? String(e) });
706+
setInstallResult({ ok: false, message: installRefusalText(e) });
652707
} finally {
653708
setInstalling(false);
654709
}
@@ -748,26 +803,7 @@ function MarketplacePackagePageBody() {
748803
});
749804
emitMetadataRefresh();
750805
} catch (e: any) {
751-
// A refusal shows the envelope's own message. The install-mode refusals
752-
// (`INSTALL_MODE_NOT_PERMITTED`, `UNMANAGED_INSTALL_WALLED_POSTURE`,
753-
// `RESOURCE_CONFLICT`) take no branch below: each message names its own
754-
// remedy, and the page has nothing to add to it.
755-
const code = e?.code;
756-
let msg = e?.message ?? String(e);
757-
// ADR-0141 D2 — an unsatisfied declared dependency, drawn from the
758-
// refusal's facts in the reader's locale; without facts the envelope's
759-
// message stands.
760-
const dependencyMissing = dependencyMissingText(e);
761-
if (dependencyMissing) {
762-
msg = dependencyMissing;
763-
} else if (errorCodeIs(e, 'MANIFEST_CONFLICT')) {
764-
msg = t('marketplace.install.localManifestConflict', { message: msg });
765-
} else if (errorCodeIs(e, 'UNAUTHENTICATED')) {
766-
msg = t('marketplace.install.localUnauthorized');
767-
} else if (errorCodeIs(e, 'MARKETPLACE_UNAVAILABLE')) {
768-
msg = t('marketplace.install.localMarketplaceUnavailable');
769-
}
770-
setLocalResult({ ok: false, message: msg });
806+
setLocalResult({ ok: false, message: installRefusalText(e) });
771807
} finally {
772808
setInstallingLocal(false);
773809
}
@@ -1372,7 +1408,10 @@ function MarketplacePackagePageBody() {
13721408

13731409
// ADR-0131 D6 — where the package permits more than one mode, the install
13741410
// waits for the operator's pick.
1375-
const installModeUnpicked = supportsLocal && !!offeredInstallModes && !chosenMode;
1411+
// On the hosted path the choice belongs to an install, not to the update of a
1412+
// copy this environment already holds.
1413+
const modeChoiceShown = supportsLocal || (hostedModeDoor && !cloudInstalledVersion);
1414+
const installModeUnpicked = modeChoiceShown && !!offeredInstallModes && !chosenMode;
13761415
const primaryDisabled = !latestVersion || installingLocal || installing || installModeUnpicked || (!supportsLocal && !!cloudInstalledVersion && !cloudUpdateAvailable);
13771416
// The reason Install is disabled, when the missing pick is what disables it:
13781417
// the console's convention for a disabled control (`DeclaredActionsBar`'s
@@ -1431,7 +1470,7 @@ function MarketplacePackagePageBody() {
14311470
// only `unmanaged` draws no choice, but says what its install does before
14321471
// the click, since that install is a copy rather than the registration every
14331472
// install was before the key existed.
1434-
const installModeChoice = supportsLocal && offeredInstallModes ? (
1473+
const installModeChoice = modeChoiceShown && offeredInstallModes ? (
14351474
<div className="mt-4 max-w-2xl space-y-2" data-testid="install-mode-choice">
14361475
<p id="install-mode-label" className="text-sm font-medium">{t('marketplace.install.mode.label')}</p>
14371476
<RadioGroup
@@ -1441,7 +1480,7 @@ function MarketplacePackagePageBody() {
14411480
setModePick(mode && packageId ? { packageId, mode } : null);
14421481
}}
14431482
aria-labelledby="install-mode-label"
1444-
disabled={installingLocal}
1483+
disabled={installingLocal || installing}
14451484
className="gap-3"
14461485
>
14471486
{offeredInstallModes.map((mode) => (
@@ -1459,7 +1498,7 @@ function MarketplacePackagePageBody() {
14591498
))}
14601499
</RadioGroup>
14611500
</div>
1462-
) : supportsLocal && declaredModes?.length === 1 && declaredModes[0] === 'unmanaged' ? (
1501+
) : modeChoiceShown && declaredModes?.length === 1 && declaredModes[0] === 'unmanaged' ? (
14631502
<p className="mt-3 max-w-2xl text-sm text-muted-foreground" data-testid="install-mode-sole">
14641503
<span className="font-medium text-foreground">{t('marketplace.install.mode.unmanaged')}</span>
14651504
{': '}

0 commit comments

Comments
 (0)