diff --git a/.changeset/adr-0070-package-lifecycle-ui.md b/.changeset/adr-0070-package-lifecycle-ui.md
new file mode 100644
index 000000000..034d5b994
--- /dev/null
+++ b/.changeset/adr-0070-package-lifecycle-ui.md
@@ -0,0 +1,18 @@
+---
+"@object-ui/app-shell": patch
+---
+
+feat(studio): package lifecycle UI — Duplicate base, Adopt loose items, structure-only delete (ADR-0070 D4/D5/D6)
+
+`PackageDetailSheet` gains the user-facing affordances for the package-as-
+lifecycle-unit work:
+- **Duplicate** → `POST /packages/:id/duplicate` (clone a base into a new
+ writable package; D4).
+- **Adopt loose items** → `POST /packages/:id/adopt-orphans` (migrate every
+ package-less orphan into this base; D5).
+- **Delete** now asks whether to drop records too (`?keepData`) — structure-only
+ vs everything (D4 Q3).
+
+D6 guardrail test: the scope selector never defaults to the package-less
+`Local / Custom` sentinel (`writableBaseOptions` excludes it; real bases sort
+first).
diff --git a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
index 99c2cc274..aa3062f29 100644
--- a/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
+++ b/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
@@ -32,6 +32,8 @@ import {
ExternalLink,
AlertTriangle,
Trash2,
+ Copy,
+ Inbox,
} from 'lucide-react';
import {
Button,
@@ -455,10 +457,13 @@ function PackageDetailSheet({
tFormat('engine.packages.detail.deleteConfirm', locale, { name: pkg?.manifest.name || id }),
);
if (!ok) return;
+ // ADR-0070 D4 (Q3) — let the user keep records (delete structure only).
+ const alsoData = window.confirm(t('engine.packages.detail.deleteKeepData', locale));
+ const qs = alsoData ? '' : '?keepData=true';
setBusy('delete');
setMsg(null);
try {
- await apiJson(`${API}/${encodeURIComponent(id)}`, { method: 'DELETE' });
+ await apiJson(`${API}/${encodeURIComponent(id)}${qs}`, { method: 'DELETE' });
onChanged();
onOpenChange(false);
} catch (e: any) {
@@ -468,6 +473,46 @@ function PackageDetailSheet({
}
};
+ // ADR-0070 D4 — duplicate this base into a NEW writable package (re-namespaced).
+ const duplicateApp = async () => {
+ const target = window.prompt(t('engine.packages.detail.duplicatePrompt', locale), `${id}-copy`);
+ if (!target || !target.trim()) return;
+ setBusy('duplicate');
+ setMsg(null);
+ try {
+ await apiJson(`${API}/${encodeURIComponent(id)}/duplicate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ targetPackageId: target.trim(), targetName: `${pkg?.manifest.name ?? id} (copy)` }),
+ });
+ setMsg({ kind: 'ok', text: t('engine.packages.detail.duplicated', locale) });
+ onChanged();
+ } catch (e: any) {
+ setMsg({ kind: 'err', text: e?.message ?? 'Duplicate failed' });
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ // ADR-0070 D5 — adopt every package-less (loose) item in this env INTO this base.
+ const adoptOrphans = async () => {
+ const ok = window.confirm(
+ tFormat('engine.packages.detail.adoptConfirm', locale, { name: pkg?.manifest.name || id }),
+ );
+ if (!ok) return;
+ setBusy('adopt');
+ setMsg(null);
+ try {
+ await apiJson(`${API}/${encodeURIComponent(id)}/adopt-orphans`, { method: 'POST' });
+ setMsg({ kind: 'ok', text: t('engine.packages.detail.adopted', locale) });
+ onChanged();
+ } catch (e: any) {
+ setMsg({ kind: 'err', text: e?.message ?? 'Adopt failed' });
+ } finally {
+ setBusy(null);
+ }
+ };
+
const toggleEnable = () =>
run(
'toggle',
@@ -613,6 +658,14 @@ function PackageDetailSheet({
{busy === 'export' ? t('engine.packages.detail.exporting', locale) : t('engine.packages.detail.export', locale)}
+
+