mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
35c781b8e4
commit
815eac43db
5 changed files with 575 additions and 7 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
170
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
apps/x/docs/publishing-apps.md
Normal file
74
apps/x/docs/publishing-apps.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue