Merge pull request #676 from rowboatlabs/feature/apps-m3

Rowboat Apps V1 — M3: publish, catalog, install, update
This commit is contained in:
gagan 2026-07-06 21:14:10 +05:30 committed by GitHub
commit 84c71b37af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2642 additions and 47 deletions

View file

@ -66,6 +66,13 @@ import * as appsIndexer from '@x/core/dist/apps/indexer.js';
import * as appsServer from '@x/core/dist/apps/server.js';
import * as appsAgents from '@x/core/dist/apps/agents.js';
import { capture } from '@x/core/dist/analytics/posthog.js';
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
import * as appsInstaller from '@x/core/dist/apps/installer.js';
import { registryClient } from '@x/core/dist/apps/registry.js';
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
// D18 install previews awaiting confirmation, keyed by app name.
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
import { consumePendingDeepLink } from './deeplink.js';
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
@ -1558,6 +1565,9 @@ export function setupIpcHandlers() {
return qualifyAndDisconnectComposioGoogle();
},
// Rowboat Apps handlers (spec §13)
'apps:serverStatus': async () => {
return appsServer.getServerStatus();
},
'apps:list': async () => {
const status = appsServer.getServerStatus();
const apps = await appsIndexer.listApps();
@ -1598,6 +1608,109 @@ export function setupIpcHandlers() {
appsServer.setAppsTheme(args.theme);
return { ok: true as const };
},
// GitHub auth (device flow) — publishing only
// Catalog + install/update (spec §1213)
'apps:catalogIndex': async (_event, args) => {
return registryClient.refreshIndex(args.force);
},
'apps:catalogSearch': async (_event, args) => {
return { records: await registryClient.search(args.query) };
},
'apps:catalogDetail': async (_event, args) => {
const record = await registryClient.resolve(args.name);
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
let manifest;
try { manifest = await registryClient.latestManifest(record); } catch { /* best effort */ }
let readme: string | undefined;
try {
const res = await fetch(`https://raw.githubusercontent.com/${record.repo}/HEAD/README.md`);
if (res.ok) readme = await res.text();
} catch { /* best effort */ }
const installed = (await appsIndexer.listApps()).find((a) => a.install?.name === args.name);
return {
record,
...(manifest ? { manifest } : {}),
...(readme ? { readme } : {}),
...(installed ? { installedFolder: installed.folder } : {}),
};
},
'apps:install': async (_event, args) => {
const record = await registryClient.resolve(args.name);
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
if (!args.confirmed) {
const preview = await appsInstaller.previewInstall(record);
appInstallPreviews.set(args.name, preview);
return preview;
}
// D18: the confirmed phase checks the bundle against what was previewed.
const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record);
const result = await appsInstaller.installFromRegistry(record, preview);
appInstallPreviews.delete(args.name);
capture('app_installed', { name: args.name });
return result;
},
'apps:installFromUrl': async (_event, args) => {
if (!args.confirmed) {
return appsInstaller.previewUrlInstall(args.url);
}
const result = await appsInstaller.confirmUrlInstall(args.url);
capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder });
return result;
},
'apps:uninstall': async (_event, args) => {
await appsInstaller.uninstallApp(args.folder);
capture('app_uninstalled', { folder: args.folder });
return { ok: true as const };
},
'apps:checkUpdate': async (_event, args) => {
return appsInstaller.checkUpdate(args.folder);
},
'apps:update': async (_event, args) => {
const before = (await appsIndexer.getApp(args.folder))?.manifest?.version;
const app = await appsInstaller.updateApp(args.folder, {
confirmOverwriteModified: args.confirmOverwriteModified,
confirmNewCapabilities: args.confirmNewCapabilities,
});
capture('app_updated', { from: before, to: app.manifest?.version });
return { app };
},
'apps:rollback': async (_event, args) => {
return { app: await appsInstaller.rollbackApp(args.folder) };
},
'apps:publish': async (event, args) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await appsPublisher.publishApp(args.folder, (step, detail) => {
win?.webContents.send('apps:progress', { folder: args.folder, step, detail });
});
capture('app_published', { firstPublish: true });
return result;
},
'apps:publishUpdate': async (_event, args) => {
const result = await appsPublisher.publishUpdate(args.folder, args.increment);
capture('app_published', { version: result.version, firstPublish: false });
return result;
},
'apps:registerExisting': async (_event, args) => {
return appsPublisher.registerExisting(args.name, args.repo);
},
'githubAuth:start': async () => {
const result = await githubAuth.startDeviceFlow();
// Surface the code and open GitHub's verification page externally (§10).
void shell.openExternal(result.verificationUri);
return result;
},
'githubAuth:poll': async () => {
const result = await githubAuth.pollDeviceFlow();
console.log(`[GitHubAuth] poll result → ${result.status}`);
return result;
},
'githubAuth:status': async () => {
return githubAuth.getAuthStatus();
},
'githubAuth:signOut': async () => {
await githubAuth.clearAuth();
return { ok: true as const };
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
import path from "node:path";
import {
setupIpcHandlers,
@ -38,6 +38,7 @@ import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tas
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
@ -341,6 +342,16 @@ function createWindow() {
}
}
// Renderer/child process deaths are otherwise silent in packaged builds (the
// window or an app iframe just goes blank). Log the reason so crash reports
// can be correlated with what Chromium thought happened.
app.on('render-process-gone', (_event, webContents, details) => {
console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
});
app.on('child-process-gone', (_event, details) => {
console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
});
app.whenReady().then(async () => {
// Register custom protocol before creating window.
// In production this serves the renderer SPA; in dev (and prod) it also
@ -382,6 +393,24 @@ app.whenReady().then(async () => {
setupIpcHandlers();
setupBrowserEventForwarding();
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
// the window and the long service-init chain below. The Apps view is
// reachable as soon as the window paints; starting the server last meant
// every app iframe hit connection-refused (blank app) for the first ~10s of
// each launch. Route registration and the token cipher are synchronous;
// the listen itself is fire-and-forget.
registerAppsHostApi();
// GitHub publish token at rest: encrypt via the OS keychain when available
// (core stays electron-free; the cipher is injected here).
setGithubTokenCipher({
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
});
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
createWindow();
// Start workspace watcher as a main-process service
@ -502,13 +531,6 @@ app.whenReady().then(async () => {
// start chrome extension sync server
initChromeSync();
// start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) with the
// full Host API (tools/fetch/llm/copilot behind the capability gate)
registerAppsHostApi();
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();

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>
) : (
@ -120,14 +180,50 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
</>
) : app.publish ? (
<>
<p className="text-muted-foreground">Local app published to the Rowboat catalog.</p>
<InfoRow k="Repository" v={app.publish.repo} mono link={`https://github.com/${app.publish.repo}`} />
{app.publish.lastPublishedVersion && (
<InfoRow
k="Published"
v={`v${app.publish.lastPublishedVersion}`}
link={`https://github.com/${app.publish.repo}/releases/tag/v${app.publish.lastPublishedVersion}`}
/>
)}
</>
) : (
<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>
<p className="text-muted-foreground">Local app created on this machine.</p>
)}
<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,15 +268,34 @@ 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>
)
}
function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
function InfoRow({ k, v, mono, link }: { k: string; v: string; mono?: boolean; link?: string }) {
return (
<div className="flex items-baseline gap-2">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className={`min-w-0 truncate text-primary hover:underline ${mono ? 'font-mono text-xs' : ''}`}
>
{v}
</a>
) : (
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
)}
</div>
)
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { ArrowLeft, ExternalLink, Info, RotateCw } from 'lucide-react'
import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { appOpened } from '@/lib/analytics'
import { AppDetail } from '@/components/apps/app-detail'
import { PublishDialog } from '@/components/apps/publish-dialog'
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
@ -11,6 +12,7 @@ import { AppDetail } from '@/components/apps/app-detail'
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
const [reloadNonce, setReloadNonce] = useState(0)
const [showDetail, setShowDetail] = useState(false)
const [showPublish, setShowPublish] = useState(false)
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
// surface a visible retry state instead of a silent blank pane.
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
@ -34,6 +36,20 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
return () => window.clearTimeout(timer)
}, [watchKey])
// Stuck diagnosis: the usual cause is the apps server not being reachable
// yet (it starts with the main process; on a fresh launch or a quick
// relaunch the first iframe load can beat it). Say when the server itself
// is the problem.
const [serverDown, setServerDown] = useState(false)
useEffect(() => {
if (loadState !== 'stuck') return
let cancelled = false
void window.ipc.invoke('apps:serverStatus', {}).then((s) => {
if (!cancelled) setServerDown(!s.running)
}).catch(() => { /* status probe is best-effort */ })
return () => { cancelled = true }
}, [loadState])
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
@ -62,6 +78,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
>
<ExternalLink className="size-4" />
</button>
{app.kind === 'local' && (
app.publish ? (
<button
type="button"
title={`Published as ${app.publish.repo} — view details`}
onClick={() => setShowDetail(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500"
>
<BadgeCheck className="size-4" /> Published
</button>
) : (
<button
type="button"
title="Publish this app"
onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
<UploadCloud className="size-4" /> Publish
</button>
)
)}
<button
type="button"
title="App details"
@ -82,7 +119,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">This app is taking too long to load.</div>
<div className="text-muted-foreground">
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
@ -90,6 +129,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
>
Retry
</button>
<div className="max-w-xs text-center text-xs text-muted-foreground">
If it still doesn&apos;t load, go back to Apps and open it again.
</div>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>
)}
@ -100,6 +142,14 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
</div>
)}
</div>
{showPublish && (
<PublishDialog
folder={app.folder}
appName={title}
onClose={() => setShowPublish(false)}
onPublished={() => setShowDetail(true)}
/>
)}
</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,177 @@
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')
}
})().catch((e: unknown) => {
// A hard failure (bad client config, network) must surface, not spin.
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(e instanceof Error ? e.message : String(e))
setPhase('auth')
})
// Heartbeat only — core paces the actual GitHub requests to the
// flow's required interval (and skips the request when it's too soon).
}, 2000)
} 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,102 @@
# Validates publish PRs against the Rowboat Apps registry and auto-merges on
# success (spec §9.3). On any failure the PR is closed with a comment whose
# first line is machine-readable: `rejected: <code>`.
name: validate-and-merge
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
# Per-name concurrency: two racing PRs for one name serialize; the loser
# fails the name-collision check.
concurrency:
group: publish-${{ github.event.pull_request.title }}
cancel-in-progress: false
steps:
- name: Checkout base (main)
uses: actions/checkout@v4
with:
ref: main
- name: Fetch PR diff and validate
id: validate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
reject() { echo "code=$1" >> "$GITHUB_OUTPUT"; echo "detail=$2" >> "$GITHUB_OUTPUT"; exit 1; }
# 1. The diff adds exactly one file, under apps/, and changes nothing else.
files_json=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate)
count=$(echo "$files_json" | jq 'length')
[ "$count" = "1" ] || reject invalid_diff "PR must add exactly one file (found $count changes)"
status=$(echo "$files_json" | jq -r '.[0].status')
[ "$status" = "added" ] || reject invalid_diff "PR must ADD a record; modifications are not accepted in V1"
filepath=$(echo "$files_json" | jq -r '.[0].filename')
case "$filepath" in apps/*.json) ;; *) reject invalid_path "record must be apps/<name>.json (got $filepath)";; esac
# 2. Filename is <name>.json with a valid name.
name=$(basename "$filepath" .json)
echo "$name" | grep -Eq '^[a-z0-9]+(-[a-z0-9]+)*$' || reject invalid_name "\"$name\" is not a valid package name"
len=${#name}; [ "$len" -ge 3 ] && [ "$len" -le 64 ] || reject invalid_name "name length must be 3-64"
# 3. Content validates against the schema and name matches the filename stem.
gh api "repos/${GITHUB_REPOSITORY}/contents/${filepath}?ref=${HEAD_SHA}" --jq .content | base64 -d > /tmp/record.json
npx --yes ajv-cli@5 validate -s schema/registry-record.schema.json -d /tmp/record.json --strict=false \
|| reject invalid_record "record does not match schema/registry-record.schema.json"
rec_name=$(jq -r .name /tmp/record.json)
[ "$rec_name" = "$name" ] || reject name_mismatch "record.name \"$rec_name\" != filename stem \"$name\""
# 4. record.owner equals the PR author.
rec_owner=$(jq -r .owner /tmp/record.json)
[ "$rec_owner" = "$PR_AUTHOR" ] || reject owner_mismatch "record.owner \"$rec_owner\" != PR author \"$PR_AUTHOR\""
# 5. Name not taken and not retired.
[ ! -f "apps/${name}.json" ] || reject name_taken "apps/${name}.json already exists"
[ ! -f "removed/${name}.json" ] || reject name_retired "\"$name\" was removed and stays retired"
# 6. record.repo is public and the author has push/admin on it.
repo=$(jq -r .repo /tmp/record.json)
visibility=$(gh api "repos/${repo}" --jq .visibility 2>/dev/null) || reject repo_unreachable "cannot read ${repo}"
[ "$visibility" = "public" ] || reject repo_not_public "${repo} is not public"
perm=$(gh api "repos/${repo}/collaborators/${PR_AUTHOR}/permission" --jq .permission 2>/dev/null) \
|| reject permission_check_failed "cannot check ${PR_AUTHOR}'s permission on ${repo}"
case "$perm" in admin|write) ;; *) reject not_repo_collaborator "PR author has \"$perm\" on ${repo}; needs push or admin";; esac
# 7. Latest release carries the bundle asset (existence probe only — D14).
http=$(curl -s -o /dev/null -w "%{http_code}" -I -L "https://github.com/${repo}/releases/latest/download/${name}.rowboat-app")
case "$http" in 200|302) ;; *) reject missing_release_asset "releases/latest has no ${name}.rowboat-app (HTTP $http)";; esac
echo "name=$name" >> "$GITHUB_OUTPUT"
- name: Merge on success
if: success()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" \
--body "published: ${{ steps.validate.outputs.name }}"
gh pr merge "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --squash --admin
- name: Close on failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
code="${{ steps.validate.outputs.code }}"
detail="${{ steps.validate.outputs.detail }}"
gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" \
--body "rejected: ${code:-validation_error}
${detail:-Validation failed; see the Action log.}"
gh pr close "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY"

View file

@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Rowboat Apps registry record",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "name", "owner", "repo", "createdAt"],
"properties": {
"schemaVersion": { "const": 1 },
"name": {
"type": "string",
"minLength": 3,
"maxLength": 64,
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
},
"owner": { "type": "string", "minLength": 1 },
"repo": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"description": { "type": "string", "maxLength": 500 },
"iconUrl": { "type": "string", "format": "uri", "pattern": "^https://" },
"createdAt": { "type": "string" }
}
}

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.

View file

@ -50,6 +50,8 @@
"react": "^19.2.3",
"xlsx": "^0.18.5",
"yaml": "^2.8.2",
"yauzl": "^3.4.0",
"yazl": "^3.3.1",
"zod": "^4.2.1"
},
"devDependencies": {
@ -59,6 +61,8 @@
"@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5",
"@types/qrcode": "^1.5.6",
"@types/yauzl": "^3.4.0",
"@types/yazl": "^3.3.1",
"vitest": "catalog:"
}
}

View file

@ -0,0 +1,205 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { WorkDir } from '../config/config.js';
import { GITHUB_OAUTH_CLIENT_ID } from '../config/env.js';
// GitHub authentication via the OAuth device flow (spec §10). Sign-in is
// required ONLY for publishing; create/run/install/update never touch it.
// The token doubles as publisher identity (D6).
const AUTH_FILE = path.join(WorkDir, 'config', 'github-auth.json');
// Token-at-rest encryption is provided by the Electron main process
// (safeStorage) — core stays electron-free. When no cipher is wired (or the OS
// keychain is unavailable) the token is stored plaintext with a marker,
// matching the existing token-storage approach in auth/.
export interface TokenCipher {
isAvailable(): boolean;
encrypt(plain: string): string; // returns base64
decrypt(encrypted: string): string;
}
let cipher: TokenCipher | null = null;
export function setTokenCipher(c: TokenCipher): void {
cipher = c;
}
type StoredAuth = {
login: string;
createdAt: string;
token?: string; // plaintext fallback
tokenEncrypted?: string; // base64 via cipher
plaintext?: boolean;
};
type PendingFlow = {
deviceCode: string;
intervalMs: number;
expiresAt: number;
/** Last time we actually hit GitHub's token endpoint (pacing). */
lastTokenPollAt?: number;
/** Token already issued but the identity fetch failed — retry that step. */
issuedToken?: string;
identityAttempts?: number;
};
const GH_HEADERS = { 'Accept': 'application/json', 'User-Agent': 'rowboat-apps' };
let pending: PendingFlow | null = null;
async function readAuth(): Promise<StoredAuth | null> {
try {
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredAuth;
} catch {
return null;
}
}
async function writeAuth(auth: StoredAuth): Promise<void> {
await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
}
/** Start the device flow. Returns the code the user enters on github.com. */
export async function startDeviceFlow(): Promise<{ userCode: string; verificationUri: string; expiresIn: number }> {
// GitHub's OAuth endpoints take form-encoded params (JSON is not reliable).
const res = await fetch('https://github.com/login/device/code', {
method: 'POST',
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }).toString(),
});
if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`);
const body = await res.json() as {
device_code: string; user_code: string; verification_uri: string;
expires_in: number; interval: number;
};
pending = {
deviceCode: body.device_code,
// +1s safety margin: GitHub measures arrival spacing, and a request
// that lands even slightly early gets `slow_down`, which RAISES the
// required interval for the rest of the flow.
intervalMs: ((body.interval || 5) + 1) * 1000,
expiresAt: Date.now() + body.expires_in * 1000,
};
return { userCode: body.user_code, verificationUri: body.verification_uri, expiresIn: body.expires_in };
}
export type PollResult =
| { status: 'pending' }
| { status: 'authorized'; login: string }
| { status: 'expired' }
| { status: 'denied' };
/** Poll once for device-flow completion (renderer drives the cadence). */
export async function pollDeviceFlow(): Promise<PollResult> {
if (!pending) return { status: 'expired' };
if (Date.now() > pending.expiresAt) {
pending = null;
return { status: 'expired' };
}
let accessToken = pending.issuedToken;
if (!accessToken) {
// Pacing lives HERE, not in the renderer: the renderer's timer is just
// a heartbeat. GitHub rate-limits the token endpoint per device code —
// polling faster than the flow's interval returns `slow_down` and
// permanently raises the required interval, and a caller that keeps
// its own fixed cadence then gets `slow_down` on EVERY poll (looks
// "pending" forever, even after the user authorized). Skip the request
// entirely until the current interval has elapsed.
const now = Date.now();
if (pending.lastTokenPollAt !== undefined && now - pending.lastTokenPollAt < pending.intervalMs) {
return { status: 'pending' };
}
pending.lastTokenPollAt = now;
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT_ID,
device_code: pending.deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
const body = await res.json() as { access_token?: string; error?: string; error_description?: string };
console.log(`[GitHubAuth] poll: http=${res.status} error=${body.error ?? 'none'} token=${body.access_token ? 'ISSUED' : 'no'}`);
if (body.error === 'authorization_pending') return { status: 'pending' };
if (body.error === 'slow_down') {
pending.intervalMs += 5000;
return { status: 'pending' };
}
if (body.error === 'expired_token' || body.error === 'incorrect_device_code') {
pending = null;
return { status: 'expired' };
}
if (body.error === 'access_denied') {
pending = null;
return { status: 'denied' };
}
if (body.error) {
// Unknown/config errors (unsupported_grant_type, device_flow_disabled…)
// must FAIL loudly, not spin as pending forever.
pending = null;
throw new Error(`device_flow_error: ${body.error}${body.error_description ? `${body.error_description}` : ''}`);
}
if (!body.access_token) return { status: 'pending' };
accessToken = body.access_token;
// The device code is consumed once the token is issued — remember the
// token so a transient identity failure below can retry next poll.
pending.issuedToken = accessToken;
}
// Identity: cache login alongside the token. Bounded retries — a
// persistent failure must surface, never spin as "pending" forever.
const userRes = await fetch('https://api.github.com/user', {
headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/vnd.github+json', 'User-Agent': 'rowboat-apps' },
});
console.log(`[GitHubAuth] identity: http=${userRes.status}`);
if (!userRes.ok) {
pending.identityAttempts = (pending.identityAttempts ?? 0) + 1;
if (pending.identityAttempts >= 3) {
const detail = await userRes.text().catch(() => '');
pending = null;
throw new Error(`identity_failed: GET /user → HTTP ${userRes.status} ${detail.slice(0, 160)}`);
}
return { status: 'pending' };
}
const user = await userRes.json() as { login: string };
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };
if (cipher?.isAvailable()) {
auth.tokenEncrypted = cipher.encrypt(accessToken);
} else {
auth.token = accessToken;
auth.plaintext = true;
}
await writeAuth(auth);
pending = null;
return { status: 'authorized', login: user.login };
}
export async function getAuthStatus(): Promise<{ signedIn: boolean; login?: string }> {
const auth = await readAuth();
return auth ? { signedIn: true, login: auth.login } : { signedIn: false };
}
/** The stored token, or null. Callers hitting a 401 MUST call clearAuth(). */
export async function getGithubToken(): Promise<{ token: string; login: string } | null> {
const auth = await readAuth();
if (!auth) return null;
if (auth.tokenEncrypted && cipher?.isAvailable()) {
try {
return { token: cipher.decrypt(auth.tokenEncrypted), login: auth.login };
} catch {
await clearAuth();
return null;
}
}
if (auth.token) return { token: auth.token, login: auth.login };
return null;
}
/** Sign out / expire (a 401 from any GitHub call surfaces as github_auth_expired). */
export async function clearAuth(): Promise<void> {
pending = null;
await fs.rm(AUTH_FILE, { force: true });
}

View file

@ -354,10 +354,11 @@ async function handleCopilotRun(
// Headless tool profile: the background-task agent (no shell, no
// ask-human/interactive tools) — the same runtime scheduled agents use.
// The run is recorded as a normal attributed turn (visible in history).
const model = await getBackgroundTaskAgentModel();
const selection = await getBackgroundTaskAgentModel();
const run = await createRun({
agentId: 'background-task-agent',
model,
model: selection.model,
provider: selection.provider,
useCase: 'app_copilot_run',
subUseCase: slug,
});

View file

@ -0,0 +1,515 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import yauzl from 'yauzl';
import {
RowboatAppManifestSchema,
AppInstallRecordSchema,
type RowboatAppManifest,
type AppInstallRecord,
type AppSummary,
type RegistryRecord,
} from '@x/shared/dist/rowboat-app.js';
import { WorkDir } from '../config/config.js';
import {
APPS_DIR,
FOLDER_SLUG_RE,
MAX_BUNDLE_COMPRESSED,
MAX_BUNDLE_UNCOMPRESSED,
MAX_BUNDLE_ENTRIES,
} from './constants.js';
import { getApp } from './indexer.js';
import { registryClient } from './registry.js';
import { syncAppAgents, deleteAppAgents } from './agents.js';
// Installer (spec §12): two-phase install with D18 capability disclosure,
// zip-slip/symlink/size guards, bundle-identity + capability-mismatch checks,
// defaults→data on first install only, pinned file hashes, one-step rollback.
const TMP_ROOT = path.join(WorkDir, 'tmp');
const URL_STAGING_TTL_MS = 10 * 60 * 1000;
export class InstallError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
export interface InstallPreview {
status: 'preview';
name: string;
version: string;
description: string;
capabilities: string[];
agents: string[];
/** §12.5 only: whether check-for-update will work after install. */
updateSource?: 'github' | 'none';
}
export interface InstallDone {
status: 'installed';
app: AppSummary;
}
const RELEASE_MANAGED = ['rowboat-app.json', 'dist', 'agents', 'defaults'];
// ---------------------------------------------------------------------------
// Bundle download + extraction (shared by catalog + URL installs)
// ---------------------------------------------------------------------------
async function downloadBundle(url: string, destDir: string): Promise<{ zipPath: string; sha256: string }> {
await fsp.mkdir(destDir, { recursive: true });
const zipPath = path.join(destDir, 'bundle.zip');
const res = await fetch(url, { redirect: 'follow' });
if (!res.ok) throw new InstallError('download_failed', `bundle download: HTTP ${res.status}`);
const hash = crypto.createHash('sha256');
const out = fs.createWriteStream(zipPath);
const reader = res.body?.getReader();
if (!reader) throw new InstallError('download_failed', 'empty response body');
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAX_BUNDLE_COMPRESSED) {
out.destroy();
await fsp.rm(zipPath, { force: true });
throw new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_COMPRESSED} bytes compressed`);
}
hash.update(value);
await new Promise<void>((resolve, reject) => out.write(value, (e) => (e ? reject(e) : resolve())));
}
await new Promise<void>((resolve, reject) => out.end((e?: Error | null) => (e ? reject(e) : resolve())));
return { zipPath, sha256: hash.digest('hex') };
}
/**
* Extract with REQUIRED guards (§12.1 step 4): reject absolute paths, `..`
* segments, backslashes (zip-slip); reject symlink/hardlink entries; enforce
* entry-count and cumulative-size limits. Records per-file sha256 (step 7).
*/
async function extractBundle(zipPath: string, pkgDir: string): Promise<Record<string, string>> {
await fsp.mkdir(pkgDir, { recursive: true });
const fileHashes: Record<string, string> = {};
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
if (err || !zip) return reject(new InstallError('bundle_unreadable', String(err)));
let entries = 0;
let uncompressed = 0;
zip.on('entry', (entry: yauzl.Entry) => {
entries += 1;
if (entries > MAX_BUNDLE_ENTRIES) {
zip.close();
return reject(new InstallError('bundle_too_many_entries', `more than ${MAX_BUNDLE_ENTRIES} entries`));
}
const name = entry.fileName;
if (name.includes('\\') || name.startsWith('/') || name.split('/').includes('..') || name.includes('\0')) {
zip.close();
return reject(new InstallError('zip_slip', `unsafe entry path: ${name}`));
}
// Symlinks/hardlinks: mode is in the top 16 bits of externalFileAttributes.
const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff;
if ((unixMode & 0xf000) === 0xa000) {
zip.close();
return reject(new InstallError('symlink_entry', `symlink entry rejected: ${name}`));
}
if (name.endsWith('/')) {
fsp.mkdir(path.join(pkgDir, name), { recursive: true }).then(() => zip.readEntry(), reject);
return;
}
uncompressed += entry.uncompressedSize;
if (uncompressed > MAX_BUNDLE_UNCOMPRESSED) {
zip.close();
return reject(new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_UNCOMPRESSED} bytes uncompressed`));
}
zip.openReadStream(entry, (streamErr, stream) => {
if (streamErr || !stream) {
zip.close();
return reject(new InstallError('bundle_unreadable', String(streamErr)));
}
const dest = path.join(pkgDir, name);
void fsp.mkdir(path.dirname(dest), { recursive: true }).then(() => {
const hash = crypto.createHash('sha256');
stream.on('data', (chunk: Buffer) => hash.update(chunk));
const out = fs.createWriteStream(dest);
stream.pipe(out);
out.on('close', () => {
fileHashes[name] = hash.digest('hex');
zip.readEntry();
});
out.on('error', reject);
stream.on('error', reject);
}, reject);
});
});
zip.on('end', () => resolve());
zip.on('error', (e) => reject(new InstallError('bundle_unreadable', String(e))));
zip.readEntry();
});
});
return fileHashes;
}
async function copyDefaultsToData(dir: string): Promise<void> {
const defaultsDir = path.join(dir, 'defaults');
const dataDir = path.join(dir, 'data');
await fsp.mkdir(dataDir, { recursive: true });
try {
await fsp.cp(defaultsDir, dataDir, { recursive: true, force: false, errorOnExist: false });
} catch { /* no defaults, or partial copy of existing files — fine */ }
}
async function chooseTargetFolder(name: string): Promise<string> {
let candidate = name;
for (let i = 2; fs.existsSync(path.join(APPS_DIR, candidate)); i++) {
candidate = `${name}-${i}`;
if (i > 50) throw new InstallError('folder_unavailable', 'could not find a free folder name');
}
return candidate;
}
async function assembleInstall(
pkgDir: string,
manifest: RowboatAppManifest,
record: Omit<AppInstallRecord, 'files'> & { files: Record<string, string> },
): Promise<AppSummary> {
const folder = await chooseTargetFolder(manifest.name);
const dir = path.join(APPS_DIR, folder);
try {
await fsp.mkdir(APPS_DIR, { recursive: true });
await fsp.rename(pkgDir, dir).catch(async () => {
// cross-device fallback
await fsp.cp(pkgDir, dir, { recursive: true });
await fsp.rm(pkgDir, { recursive: true, force: true });
});
await copyDefaultsToData(dir);
await fsp.writeFile(
path.join(dir, '.rowboat-install.json'),
JSON.stringify(AppInstallRecordSchema.parse(record), null, 2),
);
const summary = await getApp(folder);
if (!summary) throw new InstallError('install_failed', 'installed app failed to index');
await syncAppAgents(summary); // §8.3 materialize disabled
return summary;
} catch (e) {
await fsp.rm(dir, { recursive: true, force: true });
throw e;
}
}
function validatePkgManifest(pkgDir: string, expected?: { name: string; version: string }): RowboatAppManifest {
let manifest: RowboatAppManifest;
try {
manifest = RowboatAppManifestSchema.parse(
JSON.parse(fs.readFileSync(path.join(pkgDir, 'rowboat-app.json'), 'utf-8')),
);
} catch (e) {
throw new InstallError('bundle_mismatch', `bundle manifest missing/invalid: ${e instanceof Error ? e.message : String(e)}`);
}
if (expected && (manifest.name !== expected.name || manifest.version !== expected.version)) {
throw new InstallError('bundle_mismatch',
`bundle is ${manifest.name}@${manifest.version}, expected ${expected.name}@${expected.version}`);
}
return manifest;
}
/** D18 (§12.1 step 6): the bundle must not exceed what the user confirmed. */
function checkCapabilitySubset(bundle: RowboatAppManifest, confirmed: { capabilities: string[]; agents: string[] }): void {
const extraCaps = bundle.capabilities.filter((c) => !confirmed.capabilities.includes(c));
const extraAgents = bundle.agents.filter((a) => !confirmed.agents.includes(a));
if (extraCaps.length || extraAgents.length) {
throw new InstallError('capability_mismatch',
`bundle declares more than previewed (capabilities: [${extraCaps.join(', ')}], agents: [${extraAgents.join(', ')}])`);
}
}
// ---------------------------------------------------------------------------
// Catalog install (§12.1)
// ---------------------------------------------------------------------------
export async function previewInstall(record: RegistryRecord): Promise<InstallPreview> {
const manifest = await registryClient.latestManifest(record);
return {
status: 'preview',
name: manifest.name,
version: manifest.version,
description: manifest.description,
capabilities: manifest.capabilities,
agents: manifest.agents,
};
}
export async function installFromRegistry(record: RegistryRecord, confirmed: InstallPreview): Promise<InstallDone> {
const staging = path.join(TMP_ROOT, `app-install-${crypto.randomBytes(4).toString('hex')}`);
try {
const bundleUrl = `https://github.com/${record.repo}/releases/latest/download/${record.name}.rowboat-app`;
const { zipPath, sha256 } = await downloadBundle(bundleUrl, staging);
const pkgDir = path.join(staging, 'pkg');
const files = await extractBundle(zipPath, pkgDir);
const manifest = validatePkgManifest(pkgDir, { name: confirmed.name, version: confirmed.version });
checkCapabilitySubset(manifest, confirmed);
const app = await assembleInstall(pkgDir, manifest, {
name: manifest.name,
repo: record.repo,
version: manifest.version,
sha256,
installedAt: new Date().toISOString(),
files,
});
return { status: 'installed', app };
} finally {
await fsp.rm(staging, { recursive: true, force: true });
}
}
// ---------------------------------------------------------------------------
// URL install (§12.5) — two-phase with retained staging
// ---------------------------------------------------------------------------
type UrlStaging = {
staging: string;
sha256: string;
manifest: RowboatAppManifest;
files: Record<string, string>;
createdAt: number;
};
const urlStagings = new Map<string, UrlStaging>();
function githubProvenance(url: string): string | undefined {
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/download\//.exec(url);
return m ? m[1] : undefined;
}
export async function previewUrlInstall(url: string): Promise<InstallPreview> {
if (!url.startsWith('https:')) throw new InstallError('invalid_url', 'only https URLs are allowed');
// Reuse fresh staging for the same URL.
const existing = urlStagings.get(url);
if (existing && Date.now() - existing.createdAt < URL_STAGING_TTL_MS) {
return {
status: 'preview',
name: existing.manifest.name,
version: existing.manifest.version,
description: existing.manifest.description,
capabilities: existing.manifest.capabilities,
agents: existing.manifest.agents,
updateSource: githubProvenance(url) ? 'github' : 'none',
};
}
const staging = path.join(TMP_ROOT, `app-install-${crypto.randomBytes(4).toString('hex')}`);
const { zipPath, sha256 } = await downloadBundle(url, staging);
const pkgDir = path.join(staging, 'pkg');
const files = await extractBundle(zipPath, pkgDir);
const manifest = validatePkgManifest(pkgDir); // identity comes from the bundle itself
urlStagings.set(url, { staging, sha256, manifest, files, createdAt: Date.now() });
return {
status: 'preview',
name: manifest.name,
version: manifest.version,
description: manifest.description,
capabilities: manifest.capabilities,
agents: manifest.agents,
updateSource: githubProvenance(url) ? 'github' : 'none',
};
}
export async function confirmUrlInstall(url: string): Promise<InstallDone> {
let staged = urlStagings.get(url);
if (!staged || Date.now() - staged.createdAt >= URL_STAGING_TTL_MS) {
await previewUrlInstall(url); // re-download if evicted
staged = urlStagings.get(url);
if (!staged) throw new InstallError('staging_missing', 'could not stage the bundle');
}
urlStagings.delete(url);
try {
const repo = githubProvenance(url);
const app = await assembleInstall(path.join(staged.staging, 'pkg'), staged.manifest, {
name: staged.manifest.name,
...(repo ? { repo } : { sourceUrl: url }),
version: staged.manifest.version,
sha256: staged.sha256,
installedAt: new Date().toISOString(),
files: staged.files,
});
return { status: 'installed', app };
} finally {
await fsp.rm(staged.staging, { recursive: true, force: true });
}
}
// ---------------------------------------------------------------------------
// Update / rollback / uninstall (§12.312.4)
// ---------------------------------------------------------------------------
async function readInstallRecord(folder: string): Promise<AppInstallRecord> {
try {
return AppInstallRecordSchema.parse(
JSON.parse(await fsp.readFile(path.join(APPS_DIR, folder, '.rowboat-install.json'), 'utf-8')),
);
} catch {
throw new InstallError('not_installed', `${folder} has no install record`);
}
}
export async function checkUpdate(folder: string): Promise<{ current: string; latest: string; updateAvailable: boolean }> {
const install = await readInstallRecord(folder);
if (!install.repo) throw new InstallError('no_update_source', 'installed from a non-GitHub URL; updates unavailable');
const record: RegistryRecord = {
schemaVersion: 1, name: install.name, owner: '', repo: install.repo,
description: '', createdAt: install.installedAt,
};
const latest = await registryClient.latestManifest(record);
const cmp = compareSemver(latest.version, install.version);
return { current: install.version, latest: latest.version, updateAvailable: cmp > 0 };
}
function compareSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
export async function updateApp(
folder: string,
opts: { confirmOverwriteModified?: boolean; confirmNewCapabilities?: boolean } = {},
): Promise<AppSummary> {
const install = await readInstallRecord(folder);
if (!install.repo) throw new InstallError('no_update_source', 'updates unavailable for URL installs without GitHub provenance');
const dir = path.join(APPS_DIR, folder);
const currentManifest = validatePkgManifest(dir); // current on-disk manifest
const staging = path.join(TMP_ROOT, `app-update-${crypto.randomBytes(4).toString('hex')}`);
try {
const bundleUrl = `https://github.com/${install.repo}/releases/latest/download/${install.name}.rowboat-app`;
const { zipPath, sha256 } = await downloadBundle(bundleUrl, staging);
const pkgDir = path.join(staging, 'pkg');
const files = await extractBundle(zipPath, pkgDir);
const nextManifest = validatePkgManifest(pkgDir);
if (nextManifest.name !== install.name) {
throw new InstallError('bundle_mismatch', `bundle is ${nextManifest.name}, installed app is ${install.name}`);
}
// D18 scoped to the diff: an update must not silently widen access.
const newCaps = nextManifest.capabilities.filter((c) => !currentManifest.capabilities.includes(c));
const newAgents = nextManifest.agents.filter((a) => !currentManifest.agents.includes(a));
if ((newCaps.length || newAgents.length) && !opts.confirmNewCapabilities) {
throw new InstallError('new_capabilities',
`update adds capabilities [${newCaps.join(', ')}] agents [${newAgents.join(', ')}]; confirm to proceed`);
}
// Step 8: warn when locally-modified release-managed files would be lost.
const modified: string[] = [];
for (const [rel, hash] of Object.entries(install.files)) {
try {
const current = crypto.createHash('sha256')
.update(await fsp.readFile(path.join(dir, rel)))
.digest('hex');
if (current !== hash) modified.push(rel);
} catch {
modified.push(`${rel} (deleted)`);
}
}
if (modified.length && !opts.confirmOverwriteModified) {
throw new InstallError('modified_files', modified.join(', '));
}
// Steps 911: swap with one-step rollback.
const previousDir = path.join(dir, '.previous');
await fsp.rm(previousDir, { recursive: true, force: true });
await fsp.mkdir(previousDir, { recursive: true });
for (const item of RELEASE_MANAGED) {
const from = path.join(dir, item);
if (fs.existsSync(from)) await fsp.rename(from, path.join(previousDir, item));
}
try {
for (const item of RELEASE_MANAGED) {
const from = path.join(pkgDir, item);
if (fs.existsSync(from)) await fsp.rename(from, path.join(dir, item));
}
} catch (e) {
// Rollback the half-finished swap.
for (const item of RELEASE_MANAGED) {
await fsp.rm(path.join(dir, item), { recursive: true, force: true });
const backup = path.join(previousDir, item);
if (fs.existsSync(backup)) await fsp.rename(backup, path.join(dir, item));
}
throw e;
}
const nextRecord: AppInstallRecord = {
...install,
version: nextManifest.version,
sha256,
files,
updatedAt: new Date().toISOString(),
previousVersion: install.version,
};
await fsp.writeFile(path.join(dir, '.rowboat-install.json'), JSON.stringify(nextRecord, null, 2));
const summary = await getApp(folder);
if (!summary) throw new InstallError('update_failed', 'updated app failed to index');
await syncAppAgents(summary); // §8.4 update semantics
return summary;
} finally {
await fsp.rm(staging, { recursive: true, force: true });
}
}
export async function rollbackApp(folder: string): Promise<AppSummary> {
const install = await readInstallRecord(folder);
const dir = path.join(APPS_DIR, folder);
const previousDir = path.join(dir, '.previous');
if (!fs.existsSync(previousDir)) throw new InstallError('no_rollback', 'no previous version retained');
for (const item of RELEASE_MANAGED) {
await fsp.rm(path.join(dir, item), { recursive: true, force: true });
const backup = path.join(previousDir, item);
if (fs.existsSync(backup)) await fsp.rename(backup, path.join(dir, item));
}
await fsp.rm(previousDir, { recursive: true, force: true });
const restoredManifest = validatePkgManifest(dir);
const nextRecord: AppInstallRecord = {
...install,
version: restoredManifest.version,
updatedAt: new Date().toISOString(),
};
delete nextRecord.previousVersion;
await fsp.writeFile(path.join(dir, '.rowboat-install.json'), JSON.stringify(nextRecord, null, 2));
const summary = await getApp(folder);
if (!summary) throw new InstallError('rollback_failed', 'rolled-back app failed to index');
await syncAppAgents(summary);
return summary;
}
export async function uninstallApp(folder: string): Promise<void> {
if (!FOLDER_SLUG_RE.test(folder)) throw new InstallError('invalid_folder', folder);
await readInstallRecord(folder); // must be an installed app
await deleteAppAgents(folder); // §8.5 (confirmation happens in the renderer)
await fsp.rm(path.join(APPS_DIR, folder), { recursive: true, force: true });
}
/** Startup hygiene: clear leftover install stagings (§12.1). */
export async function cleanInstallTmp(): Promise<void> {
try {
const entries = await fsp.readdir(TMP_ROOT);
await Promise.all(entries
.filter((e) => e.startsWith('app-install-') || e.startsWith('app-update-'))
.map((e) => fsp.rm(path.join(TMP_ROOT, e), { recursive: true, force: true })));
} catch { /* no tmp dir yet */ }
}

View file

@ -0,0 +1,120 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
import yazl from 'yazl';
import { RowboatAppManifestSchema, type RowboatAppManifest } from '@x/shared/dist/rowboat-app.js';
import { appDir } from './indexer.js';
// Packager (spec §4.4): builds the `<name>.rowboat-app` ZIP from the ALLOWLIST
// only — rowboat-app.json, dist/**, agents/<manifest.agents>, defaults/** — in
// sorted path order (determinism). Personal data cannot leak by omission (D15):
// src/, package.json, node_modules/, data/, dotfiles, and .rowboat-*.json are
// simply never on the list. Symlinks are skipped with a warning, never followed.
export class PackageError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
/** Recursively collect files under root; returns package-relative POSIX paths. */
async function collectFiles(absRoot: string, relPrefix: string, warnings: string[]): Promise<string[]> {
const out: string[] = [];
let entries;
try {
entries = await fsp.readdir(absRoot, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const abs = path.join(absRoot, entry.name);
const rel = `${relPrefix}/${entry.name}`;
if (entry.isSymbolicLink()) {
warnings.push(`skipped symlink: ${rel}`);
continue;
}
if (entry.isDirectory()) {
out.push(...await collectFiles(abs, rel, warnings));
} else if (entry.isFile()) {
out.push(rel);
}
}
return out;
}
export interface PackageResult {
/** Absolute path of the written bundle. */
bundlePath: string;
/** Lowercase-hex SHA-256 of the finished ZIP bytes. */
sha256: string;
manifest: RowboatAppManifest;
/** Package-relative paths included, in the order written. */
files: string[];
warnings: string[];
}
/**
* Build `<name>.rowboat-app` for the app at `folder`, writing the bundle to
* `outDir` (created if needed).
*/
export async function packageApp(folder: string, outDir: string): Promise<PackageResult> {
const dir = appDir(folder);
// 1. Manifest must parse and dist/<entry> must exist.
let manifest: RowboatAppManifest;
try {
manifest = RowboatAppManifestSchema.parse(
JSON.parse(await fsp.readFile(path.join(dir, 'rowboat-app.json'), 'utf-8')),
);
} catch (e) {
throw new PackageError('invalid_manifest', `rowboat-app.json is missing or invalid: ${e instanceof Error ? e.message : String(e)}`);
}
const entryAbs = path.join(dir, 'dist', manifest.entry);
if (!fs.existsSync(entryAbs) || !fs.statSync(entryAbs).isFile()) {
throw new PackageError('missing_entry', `dist/${manifest.entry} does not exist`);
}
// 2. Assemble the allowlist, sorted for determinism.
const warnings: string[] = [];
const files: string[] = ['rowboat-app.json'];
files.push(...(await collectFiles(path.join(dir, 'dist'), 'dist', warnings)).sort());
// agents/: ONLY files listed in manifest.agents (and they must exist).
for (const agentFile of [...manifest.agents].sort()) {
const abs = path.join(dir, 'agents', agentFile);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
throw new PackageError('missing_agent', `agents/${agentFile} is listed in the manifest but missing`);
}
files.push(`agents/${agentFile}`);
}
files.push(...(await collectFiles(path.join(dir, 'defaults'), 'defaults', warnings)).sort());
// 3. Write the ZIP.
await fsp.mkdir(outDir, { recursive: true });
const bundlePath = path.join(outDir, `${manifest.name}.rowboat-app`);
const zip = new yazl.ZipFile();
for (const rel of files) {
zip.addFile(path.join(dir, ...rel.split('/')), rel);
}
zip.end();
const hash = crypto.createHash('sha256');
await new Promise<void>((resolve, reject) => {
const out = fs.createWriteStream(bundlePath);
zip.outputStream.on('data', (chunk: Buffer) => hash.update(chunk));
zip.outputStream.pipe(out);
out.on('close', () => resolve());
out.on('error', reject);
zip.outputStream.on('error', reject);
});
return {
bundlePath,
sha256: hash.digest('hex'),
manifest,
files,
warnings,
};
}

View file

@ -0,0 +1,452 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import {
RowboatAppManifestSchema,
AppPublishRecordSchema,
PACKAGE_NAME_RE,
type RowboatAppManifest,
type AppPublishRecord,
type RegistryRecord,
} from '@x/shared/dist/rowboat-app.js';
import { WorkDir } from '../config/config.js';
import { REGISTRY_REPO } from './constants.js';
import { appDir } from './indexer.js';
import { packageApp } from './packager.js';
import { registryClient } from './registry.js';
import { getGithubToken, clearAuth } from './github-auth.js';
// Publisher (spec §11): guided first publish as a resumable state machine —
// each step is persisted to .rowboat-publish.json so a failed publish resumes
// instead of duplicating side effects.
export class PublishError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
export type PublishStep =
| 'packaged' | 'repo_created' | 'source_pushed' | 'release_created'
| 'assets_uploaded' | 'registered' | 'published';
export type PublishProgress = (step: PublishStep | 'polling', detail?: string) => void;
// ---------------------------------------------------------------------------
// GitHub REST helpers
// ---------------------------------------------------------------------------
async function gh<T = unknown>(token: string, method: string, url: string, body?: unknown): Promise<T> {
const res = await fetch(url.startsWith('http') ? url : `https://api.github.com${url}`, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/vnd.github+json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
await clearAuth();
throw new PublishError('github_auth_expired', 'GitHub session expired; sign in again');
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new PublishError('github_api_error', `${method} ${url}: HTTP ${res.status} ${text.slice(0, 200)}`);
}
return res.status === 204 ? (undefined as T) : (await res.json()) as T;
}
// ---------------------------------------------------------------------------
// Publish record persistence
// ---------------------------------------------------------------------------
function publishRecordPath(folder: string): string {
return path.join(appDir(folder), '.rowboat-publish.json');
}
async function readPublishRecord(folder: string): Promise<AppPublishRecord | null> {
try {
return AppPublishRecordSchema.parse(JSON.parse(await fsp.readFile(publishRecordPath(folder), 'utf-8')));
} catch {
return null;
}
}
async function writePublishRecord(folder: string, record: AppPublishRecord): Promise<void> {
await fsp.writeFile(publishRecordPath(folder), JSON.stringify(record, null, 2));
}
// ---------------------------------------------------------------------------
// Preflight (§11.1)
// ---------------------------------------------------------------------------
async function preflight(folder: string, firstPublish: boolean): Promise<{ manifest: RowboatAppManifest; token: string; login: string }> {
let manifest: RowboatAppManifest;
try {
manifest = RowboatAppManifestSchema.parse(
JSON.parse(await fsp.readFile(path.join(appDir(folder), 'rowboat-app.json'), 'utf-8')),
);
} catch (e) {
throw new PublishError('invalid_manifest', e instanceof Error ? e.message : String(e));
}
if (!PACKAGE_NAME_RE.test(manifest.name)) throw new PublishError('invalid_name', `"${manifest.name}" is not a valid package name`);
const entryAbs = path.join(appDir(folder), 'dist', manifest.entry);
if (!fs.existsSync(entryAbs)) throw new PublishError('missing_entry', `dist/${manifest.entry} does not exist`);
const auth = await getGithubToken();
if (!auth) throw new PublishError('not_signed_in', 'sign in to GitHub to publish');
if (firstPublish) {
const existing = await registryClient.resolve(manifest.name).catch(() => null);
if (existing) throw new PublishError('name_taken', `"${manifest.name}" is already registered`);
}
return { manifest, token: auth.token, login: auth.login };
}
// ---------------------------------------------------------------------------
// Source push (§11.2 step 3) — one commit via the Git Data API
// ---------------------------------------------------------------------------
const PUSH_EXCLUDES = new Set(['data', 'node_modules', '.previous']);
async function collectSourceFiles(dir: string, rel = ''): Promise<string[]> {
const out: string[] = [];
for (const entry of await fsp.readdir(path.join(dir, rel), { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.name.startsWith('.')) continue; // dotfiles incl. .rowboat-*.json
if (PUSH_EXCLUDES.has(entry.name) && !rel) continue;
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) out.push(...await collectSourceFiles(dir, relPath));
else if (entry.isFile()) out.push(relPath);
}
return out;
}
function generatedReadme(manifest: RowboatAppManifest): string {
return `# ${manifest.name}
${manifest.description || 'A Rowboat app.'}
## Install in Rowboat
Open Rowboat Apps Catalog search for **${manifest.name}** Install.
${manifest.agents.length ? `\nBundled background agents: ${manifest.agents.map((a) => `\`${a}\``).join(', ')} (installed disabled; enable them in Rowboat).\n` : ''}`;
}
function generatedLicense(holder: string): string {
const year = new Date().getFullYear();
return `MIT License
Copyright (c) ${year} ${holder}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`;
}
async function pushSource(token: string, login: string, repoName: string, folder: string, manifest: RowboatAppManifest, version: string): Promise<void> {
const dir = appDir(folder);
const files = await collectSourceFiles(dir);
// The Git Data API (blobs/trees/commits) answers 409 "Git Repository is
// empty" on a repo with no commits — it cannot bootstrap an empty repo.
// Ensure main exists first via the Contents API, then chain onto it.
let parents: string[] = [];
try {
const ref = await gh<{ object: { sha: string } }>(token, 'GET', `/repos/${login}/${repoName}/git/ref/heads/main`);
parents = [ref.object.sha];
} catch {
// Use the PUT response's own commit sha — re-reading the ref right
// after the first commit can 409 on a stale replica.
const put = await gh<{ commit: { sha: string } }>(token, 'PUT', `/repos/${login}/${repoName}/contents/README.md`, {
message: 'bootstrap',
content: Buffer.from(generatedReadme(manifest)).toString('base64'),
branch: 'main',
});
parents = [put.commit.sha];
}
type TreeEntry = { path: string; mode: '100644'; type: 'blob'; sha: string };
const tree: TreeEntry[] = [];
for (const rel of files) {
const content = await fsp.readFile(path.join(dir, rel));
const blob = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/blobs`, {
content: content.toString('base64'),
encoding: 'base64',
});
tree.push({ path: rel, mode: '100644', type: 'blob', sha: blob.sha });
}
// Generated companions when the author has none (§11.2 step 3).
const addGenerated = async (relPath: string, content: string) => {
if (files.some((f) => f.toLowerCase() === relPath.toLowerCase())) return;
const blob = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/blobs`, {
content: Buffer.from(content).toString('base64'), encoding: 'base64',
});
tree.push({ path: relPath, mode: '100644', type: 'blob', sha: blob.sha });
};
await addGenerated('README.md', generatedReadme(manifest));
await addGenerated('LICENSE', generatedLicense(login));
await addGenerated('.gitignore', 'data/\nnode_modules/\n.rowboat-install.json\n.rowboat-publish.json\n.previous/\n');
const treeRes = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/trees`, { tree });
const commit = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/commits`, {
message: `Publish ${manifest.name} v${version}`,
tree: treeRes.sha,
parents,
});
await gh(token, 'PATCH', `/repos/${login}/${repoName}/git/refs/heads/main`, { sha: commit.sha, force: false });
}
async function uploadAsset(token: string, uploadUrlBase: string, name: string, data: Buffer, contentType: string): Promise<void> {
const res = await fetch(`${uploadUrlBase}?name=${encodeURIComponent(name)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/vnd.github+json',
'Content-Type': contentType,
'Content-Length': String(data.length),
},
body: new Uint8Array(data),
});
if (!res.ok && res.status !== 422) { // 422 = asset already exists (resume)
throw new PublishError('asset_upload_failed', `upload ${name}: HTTP ${res.status}`);
}
}
// ---------------------------------------------------------------------------
// Guided first publish (§11.2)
// ---------------------------------------------------------------------------
export async function publishApp(
folder: string,
onProgress: PublishProgress = () => undefined,
): Promise<{ status: 'published' | 'pending'; repoUrl: string; releaseUrl: string; prUrl?: string }> {
const { manifest, token, login } = await preflight(folder, true);
const name = manifest.name;
const repoUrl = `https://github.com/${login}/${name}`;
const releaseUrl = `${repoUrl}/releases/tag/v${manifest.version}`;
const prior = await readPublishRecord(folder);
const completed = new Set(prior?.pendingSteps?.version === manifest.version ? prior.pendingSteps.completed : []);
let releaseId = prior?.pendingSteps?.releaseId;
let prUrl = prior?.pendingSteps?.prUrl;
const record: AppPublishRecord = {
name, login, repo: `${login}/${name}`,
pendingSteps: { version: manifest.version, completed: [...completed], ...(releaseId ? { releaseId } : {}), ...(prUrl ? { prUrl } : {}) },
};
const mark = async (step: PublishStep) => {
completed.add(step);
record.pendingSteps = { version: manifest.version, completed: [...completed], ...(releaseId ? { releaseId } : {}), ...(prUrl ? { prUrl } : {}) };
await writePublishRecord(folder, record);
onProgress(step);
};
// Resume: re-report steps completed by a prior attempt so the UI shows
// them as done instead of leaving stale spinners.
for (const step of completed) onProgress(step as PublishStep);
// 1. packaged
const outDir = path.join(WorkDir, 'tmp', `app-publish-${name}`);
const pkg = await packageApp(folder, outDir);
await mark('packaged');
// 2. repo_created (resume-aware)
if (!completed.has('repo_created')) {
try {
await gh(token, 'POST', '/user/repos', { name, description: manifest.description, visibility: 'public', auto_init: false });
} catch (e) {
const exists = await gh(token, 'GET', `/repos/${login}/${name}`).then(() => true).catch(() => false);
if (!exists) throw e;
}
await mark('repo_created');
}
// 3. source_pushed
if (!completed.has('source_pushed')) {
await pushSource(token, login, name, folder, manifest, manifest.version);
await mark('source_pushed');
}
// 4. release_created
if (!completed.has('release_created') || !releaseId) {
try {
const release = await gh<{ id: number }>(token, 'POST', `/repos/${login}/${name}/releases`, {
tag_name: `v${manifest.version}`, name: `v${manifest.version}`, body: `sha256: ${pkg.sha256}`,
});
releaseId = release.id;
} catch {
const existing = await gh<{ id: number }>(token, 'GET', `/repos/${login}/${name}/releases/tags/v${manifest.version}`);
releaseId = existing.id;
}
await mark('release_created');
}
// 5. assets_uploaded (both REQUIRED — the standalone manifest powers update checks)
if (!completed.has('assets_uploaded')) {
const uploadBase = `https://uploads.github.com/repos/${login}/${name}/releases/${releaseId}/assets`;
await uploadAsset(token, uploadBase, `${name}.rowboat-app`, await fsp.readFile(pkg.bundlePath), 'application/zip');
await uploadAsset(token, uploadBase, 'rowboat-app.json',
Buffer.from(await fsp.readFile(path.join(appDir(folder), 'rowboat-app.json'))), 'application/json');
await mark('assets_uploaded');
}
// 7. registered — fork + branch + record + PR
if (!completed.has('registered')) {
const [registryOwner, registryName] = REGISTRY_REPO.split('/');
await gh(token, 'POST', `/repos/${REGISTRY_REPO}/forks`).catch(() => undefined);
// Forks are async — poll up to 60s.
const forkRepo = `${login}/${registryName}`;
let forked = false;
for (let i = 0; i < 30; i++) {
forked = await gh(token, 'GET', `/repos/${forkRepo}`).then(() => true).catch(() => false);
if (forked) break;
await new Promise((r) => setTimeout(r, 2000));
}
if (!forked) throw new PublishError('fork_timeout', `fork of ${REGISTRY_REPO} did not appear`);
const upstreamMain = await gh<{ object: { sha: string } }>(token, 'GET', `/repos/${REGISTRY_REPO}/git/ref/heads/main`);
const branch = `publish-${name}`;
await gh(token, 'POST', `/repos/${forkRepo}/git/refs`, { ref: `refs/heads/${branch}`, sha: upstreamMain.object.sha })
.catch(() => undefined); // resume: branch may exist
const registryRecord: RegistryRecord = {
schemaVersion: 1, name, owner: login, repo: `${login}/${name}`,
description: manifest.description,
...(manifest.icon ? { iconUrl: `https://raw.githubusercontent.com/${login}/${name}/HEAD/dist/${manifest.icon}` } : {}),
createdAt: new Date().toISOString(),
};
await gh(token, 'PUT', `/repos/${forkRepo}/contents/apps/${name}.json`, {
message: `publish: ${name}`,
content: Buffer.from(JSON.stringify(registryRecord, null, 2) + '\n').toString('base64'),
branch,
});
const pr = await gh<{ html_url: string }>(token, 'POST', `/repos/${REGISTRY_REPO}/pulls`, {
title: `publish: ${name}`, head: `${login}:${branch}`, base: 'main',
}).catch(async () => {
// resume: PR may already exist
const open = await gh<Array<{ html_url: string }>>(token, 'GET',
`/repos/${REGISTRY_REPO}/pulls?head=${login}:${branch}&state=all`);
if (!open.length) throw new PublishError('pr_failed', 'could not open the registry PR');
return open[0];
});
prUrl = pr.html_url;
void registryOwner;
await mark('registered');
}
// 8. published — poll the PR (10s cadence, 5-min timeout)
onProgress('polling', prUrl);
const prNumber = prUrl ? Number(prUrl.split('/').pop()) : NaN;
for (let i = 0; i < 30 && prUrl && Number.isFinite(prNumber); i++) {
const pr = await gh<{ merged: boolean; state: string }>(token, 'GET', `/repos/${REGISTRY_REPO}/pulls/${prNumber}`);
if (pr.merged) {
await writePublishRecord(folder, {
name, login, repo: `${login}/${name}`,
lastPublishedVersion: manifest.version, lastSha256: pkg.sha256,
});
onProgress('published');
return { status: 'published', repoUrl, releaseUrl, prUrl };
}
if (pr.state === 'closed') {
const comments = await gh<Array<{ body: string }>>(token, 'GET', `/repos/${REGISTRY_REPO}/issues/${prNumber}/comments`);
const rejection = comments.map((c) => /^rejected: (\S+)/m.exec(c.body)?.[1]).find(Boolean);
throw new PublishError(rejection ?? 'registry_rejected', `registry PR was closed (${rejection ?? 'see PR'})`);
}
await new Promise((r) => setTimeout(r, 10_000));
}
return { status: 'pending', repoUrl, releaseUrl, prUrl };
}
// ---------------------------------------------------------------------------
// Publish update (§11.3) — no registry interaction (D5)
// ---------------------------------------------------------------------------
export async function publishUpdate(
folder: string,
increment: 'patch' | 'minor' | 'major',
): Promise<{ version: string; releaseUrl: string }> {
const record = await readPublishRecord(folder);
if (!record) throw new PublishError('not_published', 'this app has not been published yet');
const { manifest, token, login } = await preflight(folder, false);
if (record.login !== login) throw new PublishError('wrong_account', `published by ${record.login}; signed in as ${login}`);
const [maj, min, pat] = manifest.version.split('.').map(Number);
const version = increment === 'major' ? `${maj + 1}.0.0` : increment === 'minor' ? `${maj}.${min + 1}.0` : `${maj}.${min}.${pat + 1}`;
// Bump the manifest (pretty-printed, §4.2).
const manifestPath = path.join(appDir(folder), 'rowboat-app.json');
const raw = JSON.parse(await fsp.readFile(manifestPath, 'utf-8')) as Record<string, unknown>;
raw.version = version;
await fsp.writeFile(manifestPath, JSON.stringify(raw, null, 2) + '\n');
const repoName = record.repo.split('/')[1];
const pkg = await packageApp(folder, path.join(WorkDir, 'tmp', `app-publish-${manifest.name}`));
await pushSource(token, login, repoName, folder, { ...manifest, version } as RowboatAppManifest, version);
const release = await gh<{ id: number }>(token, 'POST', `/repos/${record.repo}/releases`, {
tag_name: `v${version}`, name: `v${version}`, body: `sha256: ${pkg.sha256}`,
});
const uploadBase = `https://uploads.github.com/repos/${record.repo}/releases/${release.id}/assets`;
await uploadAsset(token, uploadBase, `${manifest.name}.rowboat-app`, await fsp.readFile(pkg.bundlePath), 'application/zip');
await uploadAsset(token, uploadBase, 'rowboat-app.json', Buffer.from(await fsp.readFile(manifestPath)), 'application/json');
await writePublishRecord(folder, { ...record, lastPublishedVersion: version, lastSha256: pkg.sha256, pendingSteps: undefined });
return { version, releaseUrl: `https://github.com/${record.repo}/releases/tag/v${version}` };
}
// ---------------------------------------------------------------------------
// Advanced path (§11.5): register an existing GitHub release
// ---------------------------------------------------------------------------
export async function registerExisting(name: string, repo: string): Promise<{ status: 'published' | 'pending'; prUrl: string }> {
const auth = await getGithubToken();
if (!auth) throw new PublishError('not_signed_in', 'sign in to GitHub first');
if (!PACKAGE_NAME_RE.test(name)) throw new PublishError('invalid_name', name);
// Courtesy client-side probe of §9.3 check 7.
const probe = await fetch(`https://github.com/${repo}/releases/latest/download/${name}.rowboat-app`, { method: 'HEAD', redirect: 'follow' });
if (!probe.ok) throw new PublishError('missing_release_asset', `releases/latest has no ${name}.rowboat-app`);
const [, registryName] = REGISTRY_REPO.split('/');
await gh(auth.token, 'POST', `/repos/${REGISTRY_REPO}/forks`).catch(() => undefined);
const forkRepo = `${auth.login}/${registryName}`;
for (let i = 0; i < 30; i++) {
if (await gh(auth.token, 'GET', `/repos/${forkRepo}`).then(() => true).catch(() => false)) break;
await new Promise((r) => setTimeout(r, 2000));
}
const upstreamMain = await gh<{ object: { sha: string } }>(auth.token, 'GET', `/repos/${REGISTRY_REPO}/git/ref/heads/main`);
const branch = `publish-${name}`;
await gh(auth.token, 'POST', `/repos/${forkRepo}/git/refs`, { ref: `refs/heads/${branch}`, sha: upstreamMain.object.sha }).catch(() => undefined);
const record: RegistryRecord = {
schemaVersion: 1, name, owner: auth.login, repo, description: '', createdAt: new Date().toISOString(),
};
await gh(auth.token, 'PUT', `/repos/${forkRepo}/contents/apps/${name}.json`, {
message: `publish: ${name}`,
content: Buffer.from(JSON.stringify(record, null, 2) + '\n').toString('base64'),
branch,
});
const pr = await gh<{ html_url: string }>(auth.token, 'POST', `/repos/${REGISTRY_REPO}/pulls`, {
title: `publish: ${name}`, head: `${auth.login}:${branch}`, base: 'main',
});
return { status: 'pending', prUrl: pr.html_url };
}

View file

@ -0,0 +1,146 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import zlib from 'node:zlib';
import {
RegistryRecordSchema,
RowboatAppManifestSchema,
type RegistryRecord,
type RowboatAppManifest,
} from '@x/shared/dist/rowboat-app.js';
import { REGISTRY_REPO, REGISTRY_BRANCH, CATALOG_CACHE_PATH, CATALOG_TTL_MS } from './constants.js';
// Registry client (spec §9.2). All registry access goes through RegistryClient —
// this is the backend swap seam (D5). The GitHub implementation reads the
// registry repo as one unauthenticated tarball and resolves versions via
// release-asset URLs (plain HTTPS redirects, no API quota).
export interface RegisterResult {
status: 'published' | 'pending' | 'rejected';
prUrl?: string;
rejectionCode?: string;
}
export interface RegistryClient {
refreshIndex(force?: boolean): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }>;
resolve(name: string): Promise<RegistryRecord | null>;
search(query: string): Promise<RegistryRecord[]>;
latestManifest(record: RegistryRecord): Promise<RowboatAppManifest>;
}
type CatalogCache = { fetchedAt: string; records: RegistryRecord[] };
// ---------------------------------------------------------------------------
// Minimal tar reader — enough to pull apps/*.json out of a codeload tarball.
// ---------------------------------------------------------------------------
function* tarEntries(tarBuf: Buffer): Generator<{ name: string; body: Buffer }> {
let offset = 0;
while (offset + 512 <= tarBuf.length) {
const header = tarBuf.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break; // end-of-archive
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
const sizeOctal = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
const size = parseInt(sizeOctal, 8) || 0;
const body = tarBuf.subarray(offset + 512, offset + 512 + size);
yield { name, body: Buffer.from(body) };
offset += 512 + Math.ceil(size / 512) * 512;
}
}
async function readCache(): Promise<CatalogCache | null> {
try {
return JSON.parse(await fs.readFile(CATALOG_CACHE_PATH, 'utf-8')) as CatalogCache;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// GitHub implementation
// ---------------------------------------------------------------------------
export class GitHubRegistryClient implements RegistryClient {
async refreshIndex(force = false): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }> {
const cache = await readCache();
if (!force && cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
return { records: cache.records, stale: false, fetchedAt: cache.fetchedAt };
}
try {
const res = await fetch(`https://codeload.github.com/${REGISTRY_REPO}/tar.gz/${REGISTRY_BRANCH}`);
if (!res.ok) throw new Error(`registry tarball: HTTP ${res.status}`);
const gz = Buffer.from(await res.arrayBuffer());
const tar = zlib.gunzipSync(gz);
const records: RegistryRecord[] = [];
for (const entry of tarEntries(tar)) {
// Entries look like "<repo>-<branch>/apps/<name>.json".
const m = /^[^/]+\/apps\/([^/]+)\.json$/.exec(entry.name);
if (!m) continue;
try {
const parsed = RegistryRecordSchema.safeParse(JSON.parse(entry.body.toString('utf-8')));
if (parsed.success) {
records.push(parsed.data);
} else {
console.warn(`[Apps] registry record ${m[1]} failed schema; skipping`);
}
} catch {
console.warn(`[Apps] registry record ${m[1]} is invalid JSON; skipping`);
}
}
records.sort((a, b) => a.name.localeCompare(b.name));
const fetchedAt = new Date().toISOString();
await fs.mkdir(path.dirname(CATALOG_CACHE_PATH), { recursive: true });
await fs.writeFile(CATALOG_CACHE_PATH, JSON.stringify({ fetchedAt, records } satisfies CatalogCache, null, 2));
return { records, stale: false, fetchedAt };
} catch (err) {
// Network failure with a cache present → serve cache, marked stale.
if (cache) return { records: cache.records, stale: true, fetchedAt: cache.fetchedAt };
throw err;
}
}
async resolve(name: string): Promise<RegistryRecord | null> {
const cache = await readCache();
if (cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
return cache.records.find((r) => r.name === name) ?? null;
}
try {
const res = await fetch(`https://raw.githubusercontent.com/${REGISTRY_REPO}/${REGISTRY_BRANCH}/apps/${name}.json`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`registry record: HTTP ${res.status}`);
const parsed = RegistryRecordSchema.safeParse(await res.json());
return parsed.success ? parsed.data : null;
} catch {
// Fall back to any cache we have, however old.
return cache?.records.find((r) => r.name === name) ?? null;
}
}
async search(query: string): Promise<RegistryRecord[]> {
const { records } = await this.refreshIndex();
const q = query.trim().toLowerCase();
if (!q) return records;
return records.filter((r) =>
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q));
}
/**
* Latest version's manifest via the release-asset URL a plain HTTPS
* redirect, NOT the REST API, so it costs no unauthenticated API quota.
*/
async latestManifest(record: RegistryRecord): Promise<RowboatAppManifest> {
const res = await fetch(`https://github.com/${record.repo}/releases/latest/download/rowboat-app.json`, {
redirect: 'follow',
});
if (!res.ok) throw new Error(`latest_manifest_unavailable: HTTP ${res.status}`);
const manifest = RowboatAppManifestSchema.parse(await res.json());
if (manifest.name !== record.name) {
throw new Error(`name_mismatch: release manifest says "${manifest.name}" but the registry record is "${record.name}"`);
}
return manifest;
}
}
export const registryClient: RegistryClient = new GitHubRegistryClient();

View file

@ -741,22 +741,29 @@ export async function init(): Promise<void> {
startLagMonitor();
await startWatcher();
const expressApp = createApp();
await new Promise<void>((resolve, reject) => {
const s = expressApp.listen(APPS_PORT, '127.0.0.1', () => {
server = s;
const listenOn = (host: string) => new Promise<Server>((resolve, reject) => {
const s = expressApp.listen(APPS_PORT, host, () => resolve(s));
s.on('error', (error: NodeJS.ErrnoException) => reject(error));
});
// EADDRINUSE almost always means a previous Rowboat instance is
// still shutting down and holding the port (quick relaunch). Retry
// on the SAME port for a while — never scan for alternate ports
// (§6.1), origins embed the port — instead of disabling apps for
// the whole session on the first failure.
for (let attempt = 1; ; attempt++) {
try {
server = await listenOn('127.0.0.1');
serverError = null;
console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`);
resolve();
});
s.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
// Never scan for alternate ports (§6.1) — origins embed the port.
reject(new Error(`Port ${APPS_PORT} is already in use.`));
return;
}
reject(error);
});
});
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EADDRINUSE') throw error;
if (attempt >= 15) throw new Error(`Port ${APPS_PORT} is already in use.`);
if (attempt === 1) console.warn(`[Apps] port ${APPS_PORT} in use — retrying (old instance still shutting down?)`);
await new Promise((r) => setTimeout(r, 1000));
}
}
// Dual-stack loopback: also listen on ::1 (see server6 note above).
// Best-effort — some machines have IPv6 disabled.
await new Promise<void>((resolve) => {

View file

@ -1,2 +1,7 @@
export const API_URL =
process.env.API_URL || 'https://api.x.rowboatlabs.com';
// GitHub OAuth app used for Apps publishing (device flow, public_repo scope).
// Client IDs are public identifiers, not secrets (spec §3).
export const GITHUB_OAUTH_CLIENT_ID =
process.env.ROWBOAT_GITHUB_CLIENT_ID || 'Ov23liAka106zKEovj4B';

View file

@ -18,7 +18,7 @@ import { RequestedAgent, type TurnEvent } from './turns.js';
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { AppSummarySchema } from './rowboat-app.js';
import { AppSummarySchema, RegistryRecordSchema, RowboatAppManifestSchema } from './rowboat-app.js';
import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
@ -1228,6 +1228,13 @@ const ipcSchemas = {
}),
},
// Rowboat Apps (spec §13) — M1 local channels.
'apps:serverStatus': {
req: z.object({}),
res: z.object({
running: z.boolean(),
error: z.string().optional(),
}),
},
'apps:list': {
req: z.object({}),
res: z.object({
@ -1256,6 +1263,111 @@ const ipcSchemas = {
req: z.object({ theme: z.enum(['light', 'dark']) }),
res: z.object({ ok: z.literal(true) }),
},
// Catalog + install/update (spec §1213).
'apps:catalogIndex': {
req: z.object({ force: z.boolean().optional() }),
res: z.object({ records: z.array(RegistryRecordSchema), stale: z.boolean(), fetchedAt: z.string() }),
},
'apps:catalogSearch': {
req: z.object({ query: z.string() }),
res: z.object({ records: z.array(RegistryRecordSchema) }),
},
'apps:catalogDetail': {
req: z.object({ name: z.string() }),
res: z.object({
record: RegistryRecordSchema,
manifest: RowboatAppManifestSchema.optional(),
readme: z.string().optional(),
installedFolder: z.string().optional(),
}),
},
'apps:install': {
req: z.object({ name: z.string(), confirmed: z.boolean().optional() }),
res: z.object({
status: z.enum(['preview', 'installed']),
name: z.string().optional(),
version: z.string().optional(),
description: z.string().optional(),
capabilities: z.array(z.string()).optional(),
agents: z.array(z.string()).optional(),
app: AppSummarySchema.optional(),
}),
},
'apps:installFromUrl': {
req: z.object({ url: z.string(), confirmed: z.boolean() }),
res: z.object({
status: z.enum(['preview', 'installed']),
name: z.string().optional(),
version: z.string().optional(),
description: z.string().optional(),
capabilities: z.array(z.string()).optional(),
agents: z.array(z.string()).optional(),
updateSource: z.enum(['github', 'none']).optional(),
app: AppSummarySchema.optional(),
}),
},
'apps:uninstall': {
req: z.object({ folder: z.string() }),
res: z.object({ ok: z.literal(true) }),
},
'apps:checkUpdate': {
req: z.object({ folder: z.string() }),
res: z.object({ current: z.string(), latest: z.string(), updateAvailable: z.boolean() }),
},
'apps:update': {
req: z.object({
folder: z.string(),
confirmOverwriteModified: z.boolean().optional(),
confirmNewCapabilities: z.boolean().optional(),
}),
res: z.object({ app: AppSummarySchema }),
},
'apps:rollback': {
req: z.object({ folder: z.string() }),
res: z.object({ app: AppSummarySchema }),
},
// Advisory progress pushes for long-running app operations (§13).
'apps:progress': {
req: z.object({ folder: z.string(), step: z.string(), detail: z.string().optional() }),
res: z.null(),
},
'apps:publish': {
req: z.object({ folder: z.string() }),
res: z.object({
status: z.enum(['published', 'pending']),
repoUrl: z.string(),
releaseUrl: z.string(),
prUrl: z.string().optional(),
}),
},
'apps:publishUpdate': {
req: z.object({ folder: z.string(), increment: z.enum(['patch', 'minor', 'major']) }),
res: z.object({ version: z.string(), releaseUrl: z.string() }),
},
'apps:registerExisting': {
req: z.object({ name: z.string(), repo: z.string() }),
res: z.object({ status: z.enum(['published', 'pending']), prUrl: z.string() }),
},
// GitHub auth (device flow) — required only for publishing apps (spec §10).
'githubAuth:start': {
req: z.object({}),
res: z.object({ userCode: z.string(), verificationUri: z.string(), expiresIn: z.number() }),
},
'githubAuth:poll': {
req: z.object({}),
res: z.object({
status: z.enum(['pending', 'authorized', 'expired', 'denied']),
login: z.string().optional(),
}),
},
'githubAuth:status': {
req: z.object({}),
res: z.object({ signedIn: z.boolean(), login: z.string().optional() }),
},
'githubAuth:signOut': {
req: z.object({}),
res: z.object({ ok: z.literal(true) }),
},
'composio:didConnect': {
req: z.object({
toolkitSlug: z.string(),

137
apps/x/pnpm-lock.yaml generated
View file

@ -231,16 +231,16 @@ importers:
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder':
specifier: 3.22.4
version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-table':
specifier: 3.22.4
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-item':
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/extension-task-list':
specifier: 3.22.4
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
'@tiptap/pm':
specifier: 3.22.4
version: 3.22.4
@ -533,6 +533,12 @@ importers:
yaml:
specifier: ^2.8.2
version: 2.8.2
yauzl:
specifier: ^3.4.0
version: 3.4.0
yazl:
specifier: ^3.3.1
version: 3.3.1
zod:
specifier: ^4.2.1
version: 4.2.1
@ -555,6 +561,12 @@ importers:
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/yauzl':
specifier: ^3.4.0
version: 3.4.0
'@types/yazl':
specifier: ^3.3.1
version: 3.3.1
vitest:
specifier: 'catalog:'
version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(jsdom@29.1.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
@ -657,21 +669,25 @@ packages:
resolution: {integrity: sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.198':
resolution: {integrity: sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.198':
resolution: {integrity: sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.198':
resolution: {integrity: sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.198':
resolution: {integrity: sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==}
@ -1150,6 +1166,7 @@ packages:
'@eigenpal/docx-editor-agents@1.0.3':
resolution: {integrity: sha512-Bk/J9/PBnMCOxb6w4cHQiCTuN/1C4FtZM9evC9EXXcLP13yFMdqoEqsYs+Lh3HyaRRAaCZTrkfgOZyTqqyjtwQ==}
deprecated: deprecated
peerDependencies:
'@ai-sdk/vue': ^2.0.0
ai: ^5.0.0 || ^6.0.0
@ -1167,6 +1184,7 @@ packages:
'@eigenpal/docx-editor-core@1.0.3':
resolution: {integrity: sha512-etpupuln9ZlHLW4DgS7877WBdMEChsAG0D1bEZLjF70isYbyxrd2ARWax745P7XMm4GqqkAfByzxE2GGWQmJaA==}
deprecated: deprecated
hasBin: true
peerDependencies:
prosemirror-commands: ^1.5.2
@ -1181,6 +1199,7 @@ packages:
'@eigenpal/docx-editor-i18n@1.0.3':
resolution: {integrity: sha512-zwz/S+duPOnzg/kh4bs28T3UqI8mKMzHdmFgbWgMxwtTfUkAxaUAnAVbuZgrysl1aD2scv4Hfy4EgOZcFy9NnA==}
deprecated: deprecated
'@eigenpal/docx-editor-react@1.0.3':
resolution: {integrity: sha512-KupDVHo6KC4KUs48bM1pMYFFbDJqkW8XyIhgsnLx+BWk2yOPU4bx2HfWB6H+JEVROA1h1AmhTAyE39gk75wg5w==}
@ -1302,7 +1321,7 @@ packages:
engines: {node: '>=14'}
'@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2':
resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
resolution: {gitHosted: true, tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2}
version: 10.2.0-electron.1
engines: {node: '>=12.13.0'}
hasBin: true
@ -1802,89 +1821,105 @@ packages:
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
@ -2126,30 +2161,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.80':
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
@ -3328,56 +3368,67 @@ packages:
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.54.0':
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.54.0':
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.54.0':
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.54.0':
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.54.0':
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.54.0':
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.54.0':
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.54.0':
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
@ -3711,24 +3762,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -3887,6 +3942,12 @@ packages:
peerDependencies:
'@tiptap/extension-list': 3.22.5
'@tiptap/extension-list@3.22.4':
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extension-list@3.22.5':
resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==}
peerDependencies:
@ -3939,6 +4000,12 @@ packages:
peerDependencies:
'@tiptap/core': 3.22.5
'@tiptap/extensions@3.22.4':
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
peerDependencies:
'@tiptap/core': 3.22.4
'@tiptap/pm': 3.22.4
'@tiptap/extensions@3.22.5':
resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==}
peerDependencies:
@ -4250,6 +4317,12 @@ packages:
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@types/yauzl@3.4.0':
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
'@types/yazl@3.3.1':
resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==}
'@typescript-eslint/eslint-plugin@8.50.1':
resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -4311,6 +4384,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@upsetjs/venn.js@2.0.0':
resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
@ -4719,6 +4793,10 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@ -6630,24 +6708,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@ -9142,6 +9224,13 @@ packages:
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yauzl@3.4.0:
resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
engines: {node: '>=12'}
yazl@3.3.1:
resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -13334,6 +13423,11 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -13347,9 +13441,9 @@ snapshots:
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
@ -13360,13 +13454,13 @@ snapshots:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
dependencies:
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
'@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
dependencies:
@ -13376,6 +13470,11 @@ snapshots:
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
'@tiptap/pm': 3.22.4
'@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -13791,6 +13890,14 @@ snapshots:
'@types/node': 25.0.3
optional: true
'@types/yauzl@3.4.0':
dependencies:
'@types/node': 25.0.3
'@types/yazl@3.3.1':
dependencies:
'@types/node': 25.0.3
'@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -14354,6 +14461,8 @@ snapshots:
buffer-crc32@0.2.13: {}
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@ -19637,6 +19746,14 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yauzl@3.4.0:
dependencies:
pend: 1.2.0
yazl@3.3.1:
dependencies:
buffer-crc32: 1.0.0
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}

View file

@ -13,6 +13,8 @@ allowBuilds:
node-pty: true
protobufjs: true
blockExoticSubdeps: false
overrides:
vscode-jsonrpc: 8.2.0