feat(apps): M3 UI — catalog, D18 install dialog, publish dialog, detail actions

- Catalog tab: registry search/list, stale-cache refresh, install flow with
  the D18 capability-disclosure dialog (plain-language capability lines +
  bundled agents, explicit confirm), install-from-URL with preview and
  'updates unavailable' notice for non-GitHub sources
- Publish dialog: GitHub device-flow sign-in (user code + polling), §11.2
  step progress via apps:progress pushes, success links, name_taken
  rename-and-retry hint
- App detail actions: check-for-update / update (new_capabilities and
  modified_files confirmation flows), rollback, uninstall (names data/ and
  bundled agents), publish / publish update
- docs/publishing-apps.md (§11.5): bundle format, two-asset requirement, tag
  convention, registry record, monorepo latest-release constraint
This commit is contained in:
Gagan 2026-07-06 15:34:54 +05:30
parent 35c781b8e4
commit 815eac43db
5 changed files with 575 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { X, RotateCcw, Play } from 'lucide-react'
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { PublishDialog } from '@/components/apps/publish-dialog'
// App detail panel (spec §14): manifest info, provenance/publish state,
// bundled agents with enable toggles, rollback when available. Update/publish
@ -21,6 +22,64 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
const [agents, setAgents] = useState<AgentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
const [showPublish, setShowPublish] = useState(false)
const [busyAction, setBusyAction] = useState<string | null>(null)
const runAction = async (name: string, fn: () => Promise<string | void>) => {
setBusyAction(name)
setError(null)
setNotice(null)
try {
const msg = await fn()
if (msg) setNotice(msg)
setReloadNonce((n) => n + 1)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyAction(null)
}
}
const checkForUpdate = () => runAction('check', async () => {
const r = await window.ipc.invoke('apps:checkUpdate', { folder })
setUpdateInfo(r)
return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).`
})
const doUpdate = () => runAction('update', async () => {
try {
await window.ipc.invoke('apps:update', { folder })
return 'Updated.'
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
// D18 / modified-files confirmation flows (§12.3)
if (msg.includes('new_capabilities')) {
if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true })
return 'Updated (new capabilities confirmed).'
}
if (msg.includes('modified_files')) {
if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return
await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true })
return 'Updated (local modifications overwritten).'
}
throw e
}
})
const doRollback = () => runAction('rollback', async () => {
await window.ipc.invoke('apps:rollback', { folder })
return 'Rolled back to the previous version.'
})
const doUninstall = () => runAction('uninstall', async () => {
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
await window.ipc.invoke('apps:uninstall', { folder })
onClose()
})
useEffect(() => {
let cancelled = false
@ -82,6 +141,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
{!app ? (
<div className="text-muted-foreground">Loading</div>
) : (
@ -123,11 +183,35 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
) : (
<p className="text-muted-foreground">Local app created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.</p>
)}
{rollback && (
<button type="button" className="mt-1 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
<RotateCcw className="size-3.5" /> Roll back to previous version
</button>
)}
<div className="flex flex-wrap gap-2 pt-1">
{app.kind === 'installed' && app.install?.repo && (
<button type="button" disabled={busyAction !== null}
onClick={() => void (updateInfo?.updateAvailable ? doUpdate() : checkForUpdate())}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<ArrowUpCircle className="size-3.5" />
{busyAction === 'check' || busyAction === 'update' ? 'Working…'
: updateInfo?.updateAvailable ? `Update to v${updateInfo.latest}` : 'Check for update'}
</button>
)}
{rollback && (
<button type="button" disabled={busyAction !== null} onClick={() => void doRollback()}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<RotateCcw className="size-3.5" /> Roll back
</button>
)}
{app.kind === 'local' && (
<button type="button" disabled={busyAction !== null} onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
</button>
)}
{app.kind === 'installed' && (
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
<Trash2 className="size-3.5" /> Uninstall
</button>
)}
</div>
</section>
<section className="space-y-2">
@ -172,6 +256,14 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
</div>
)}
</div>
{showPublish && app && (
<PublishDialog
folder={folder}
appName={manifest?.name ?? folder}
onClose={() => setShowPublish(false)}
onPublished={() => setReloadNonce((n) => n + 1)}
/>
)}
</div>
)
}

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Plus, RefreshCw } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { AppFrame } from '@/components/apps/app-frame'
import { CatalogTab } from '@/components/apps/catalog'
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
// AppSummary-driven; click opens the app full-height on its own origin.
@ -219,7 +220,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
)}
{tab === 'catalog' ? (
<div className="ma-empty">The community catalog is coming soon.</div>
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
) : (
<div className="ma-grid">
{apps.map((app, i) => (

View file

@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
// Catalog tab (spec §14): search the registry, install with the D18 capability
// disclosure, install from a direct bundle URL.
type Preview = {
name?: string
version?: string
description?: string
capabilities?: string[]
agents?: string[]
updateSource?: 'github' | 'none'
url?: string // set for URL installs
}
function capabilityDescription(cap: string): string {
if (cap === 'llm') return 'use your AI models (spends your tokens)'
if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)'
return `read and act on your ${cap} through your connected account`
}
/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */
function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
preview: Preview
busy: boolean
onConfirm: () => void
onCancel: () => void
}) {
const caps = preview.capabilities ?? []
const agents = preview.agents ?? []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
</div>
{caps.length === 0 ? (
<p className="text-muted-foreground">Nothing it declares no capabilities (no tools, LLM, or copilot access).</p>
) : (
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
</ul>
)}
{agents.length > 0 && (
<div className="mt-2">
<div className="font-medium">Bundled background agents (installed disabled):</div>
<ul className="list-inside list-disc text-muted-foreground">
{agents.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
</div>
{preview.updateSource === 'none' && (
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL updates will be unavailable.</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={onConfirm} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Installing…' : 'Install'}
</button>
</div>
</div>
</div>
)
}
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
const [stale, setStale] = useState(false)
const [query, setQuery] = useState('')
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Preview | null>(null)
const [busy, setBusy] = useState(false)
const [urlDialog, setUrlDialog] = useState(false)
const [url, setUrl] = useState('')
const load = async (force = false) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:catalogIndex', { force })
setRecords(r.records)
setStale(r.stale)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
useEffect(() => { void load() }, [])
const search = async (q: string) => {
setQuery(q)
try {
const r = q.trim()
? await window.ipc.invoke('apps:catalogSearch', { query: q })
: await window.ipc.invoke('apps:catalogIndex', {})
setRecords(r.records)
} catch { /* keep current list */ }
}
const startInstall = async (name: string) => {
setError(null)
try {
const r = await window.ipc.invoke('apps:install', { name })
if (r.status === 'preview') setPreview({ ...r })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const startUrlPreview = async () => {
setError(null)
try {
const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false })
if (r.status === 'preview') setPreview({ ...r, url: url.trim() })
setUrlDialog(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const confirmInstall = async () => {
if (!preview) return
setBusy(true)
setError(null)
try {
const r = preview.url
? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true })
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
setPreview(null)
if (r.status === 'installed' && r.app) onInstalled(r.app.folder)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => void search(e.target.value)}
placeholder="Search the catalog…"
className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30"
/>
</div>
<button type="button" title="Install from URL"
onClick={() => setUrlDialog(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
<Link2 className="size-4" /> From URL
</button>
{stale && (
<button type="button" onClick={() => void load(true)}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
<RefreshCw className="size-4" /> Stale refresh
</button>
)}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
{records.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
</div>
) : (
<div className="space-y-2">
{records.map((r) => (
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-semibold">{r.name}</span>
<span className="text-xs text-muted-foreground">by {r.owner}</span>
</div>
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
</div>
<button type="button" onClick={() => void startInstall(r.name)}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
<Download className="size-4" /> Install
</button>
</div>
))}
</div>
)}
{urlDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-2 text-base font-semibold">Install from URL</div>
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app"
className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setUrlDialog(false)}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
Preview
</button>
</div>
</div>
</div>
)}
{preview && (
<InstallConfirmDialog
preview={preview}
busy={busy}
onConfirm={() => void confirmInstall()}
onCancel={() => setPreview(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,170 @@
import { useEffect, useRef, useState } from 'react'
import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
// Publish dialog (spec §14): device-flow sign-in → confirmation → step
// progress (§11.2 step names) → success links / typed error (name_taken gets
// an inline hint to rename and retry).
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
export function PublishDialog({ folder, appName, onClose, onPublished }: {
folder: string
appName: string
onClose: () => void
onPublished: () => void
}) {
const [phase, setPhase] = useState<Phase>('auth')
const [login, setLogin] = useState<string | undefined>()
const [userCode, setUserCode] = useState('')
const [steps, setSteps] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null)
const pollTimer = useRef<number | null>(null)
useEffect(() => {
void (async () => {
const s = await window.ipc.invoke('githubAuth:status', {})
if (s.signedIn) {
setLogin(s.login)
setPhase('confirm')
}
})()
return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) }
}, [])
useEffect(() => {
return window.ipc.on('apps:progress', ({ folder: f, step }) => {
if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
})
}, [folder])
const startSignIn = async () => {
setError(null)
try {
const r = await window.ipc.invoke('githubAuth:start', {})
setUserCode(r.userCode)
setPhase('device')
pollTimer.current = window.setInterval(() => {
void (async () => {
const p = await window.ipc.invoke('githubAuth:poll', {})
if (p.status === 'authorized') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setLogin(p.login)
setPhase('confirm')
} else if (p.status === 'expired' || p.status === 'denied') {
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
setPhase('auth')
}
})()
}, 5000)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const publish = async () => {
setPhase('publishing')
setError(null)
setSteps([])
try {
const r = await window.ipc.invoke('apps:publish', { folder })
setResult(r)
setPhase('done')
onPublished()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setPhase('error')
}
}
const STEP_LABELS: Record<string, string> = {
packaged: 'Packaged bundle',
repo_created: 'Created GitHub repo',
source_pushed: 'Pushed source',
release_created: 'Created release',
assets_uploaded: 'Uploaded assets',
registered: 'Opened registry PR',
polling: 'Waiting for registry validation…',
published: 'Published',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
<Github className="size-5" /> Publish {appName}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken rename the app in <code>rowboat-app.json</code> and retry.</div>}
</div>}
{phase === 'auth' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.</p>
<button type="button" onClick={() => void startSignIn()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Sign in with GitHub
</button>
</div>
)}
{phase === 'device' && (
<div className="space-y-3 text-center text-sm">
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization</p>
</div>
)}
{phase === 'confirm' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p>
<button type="button" onClick={() => void publish()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Publish
</button>
</div>
)}
{(phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-1.5 text-sm">
{Object.entries(STEP_LABELS).map(([key, label]) => {
const doneStep = steps.includes(key) || phase === 'done'
const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key
if (key === 'published' && phase !== 'done') return null
return (
<div key={key} className="flex items-center gap-2">
{doneStep
? <CheckCircle2 className="size-4 text-green-600" />
: phase === 'error'
? <XCircle className="size-4 text-muted-foreground/40" />
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
</div>
)
})}
{phase === 'done' && result && (
<div className="mt-3 space-y-1 text-xs">
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
{result.status === 'pending' && result.prUrl && (
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" onClick={onClose}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
{phase === 'done' ? 'Done' : 'Close'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
# Publishing Rowboat Apps — advanced path
Most authors should use the in-app guided publish (Apps → app detail →
Publish): it creates the repo, pushes source, cuts the release, and registers
the app automatically. This doc is for developers who manage their own repo,
build pipeline, or monorepo and want to publish releases themselves.
## The bundle format (`.rowboat-app`)
A `.rowboat-app` file is a ZIP named `<name>.rowboat-app` containing, at the
archive root (relative, forward-slash paths):
```
rowboat-app.json # the manifest (required)
dist/** # browser-ready static files (required; dist/<entry> must exist)
agents/<file>.yaml # only files listed in manifest.agents
defaults/** # optional starter data, copied to data/ on first install
```
Rules:
- Never include `src/`, `package.json`, `node_modules/`, `data/`, dotfiles, or
`.rowboat-*.json`. Installers enforce size limits (100 MB compressed, 500 MB
uncompressed, 10,000 entries) and reject symlink entries and unsafe paths.
- `manifest.name` must match the bundle filename stem and, once registered,
never changes. `version` is strict semver (`MAJOR.MINOR.PATCH`).
- Agent definitions may contain ONLY `name`, `instructions`, `triggers`
runtime state, `active`, and model overrides are rejected.
## The two-asset requirement
Every release on a registered repo MUST attach **both** assets:
1. `<name>.rowboat-app` — the bundle
2. `rowboat-app.json` — a standalone copy of the manifest
The standalone manifest powers Rowboat's quota-free update check (it is
fetched via `releases/latest/download/rowboat-app.json`).
## Tag convention
Tag releases `v<version>` (e.g. `v1.2.0`), matching `manifest.version`.
## Registry record
The registry (`rowboatlabs/apps-registry`) holds one record per app at
`apps/<name>.json`:
```json
{
"schemaVersion": 1,
"name": "my-app",
"owner": "your-github-login",
"repo": "your-github-login/my-app",
"description": "What the app does",
"iconUrl": "https://raw.githubusercontent.com/you/my-app/HEAD/dist/icon.png",
"createdAt": "2026-07-06T00:00:00Z"
}
```
Register either via the in-app form (Apps → Catalog → Register existing
release) or by opening the one-file PR yourself. A validation Action checks:
the PR adds exactly that one file; the name is valid, unique, and not retired;
`owner` equals the PR author; the author has push access to `repo`; and
`releases/latest/download/<name>.rowboat-app` exists. It auto-merges on
success or closes the PR with a `rejected: <code>` comment.
## The latest-release constraint (monorepos)
Version discovery always reads the repo's **latest** release. A monorepo
registering multiple apps therefore works only if **every** release attaches
**every** registered app's asset pair — otherwise resolution for the other
apps breaks whenever any one app releases. One repo per app avoids this
entirely and is what the guided path does.