diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b18ed37b..607dc213 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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>>(); 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 §12–13) + '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('agentScheduleRepo'); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index d0ef23dc..08299ce8 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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, @@ -39,6 +39,7 @@ import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.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"; @@ -342,6 +343,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 @@ -383,6 +394,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 @@ -507,13 +536,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(); diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index 3b2e12d3..39d294f0 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -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([]) const [error, setError] = useState(null) const [reloadNonce, setReloadNonce] = useState(0) + const [notice, setNotice] = useState(null) + const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null) + const [showPublish, setShowPublish] = useState(false) + const [busyAction, setBusyAction] = useState(null) + + const runAction = async (name: string, fn: () => Promise) => { + 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: () =>
{error &&
{error}
} + {notice &&
{notice}
} {!app ? (
Loading…
) : ( @@ -120,14 +180,50 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => {app.install.updatedAt && } + ) : app.publish ? ( + <> +

Local app — published to the Rowboat catalog.

+ + {app.publish.lastPublishedVersion && ( + + )} + ) : ( -

Local app — created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.

- )} - {rollback && ( - +

Local app — created on this machine.

)} +
+ {app.kind === 'installed' && app.install?.repo && ( + + )} + {rollback && ( + + )} + {app.kind === 'local' && ( + + )} + {app.kind === 'installed' && ( + + )} +
@@ -172,15 +268,34 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
)} + {showPublish && app && ( + setShowPublish(false)} + onPublished={() => setReloadNonce((n) => n + 1)} + /> + )} ) } -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 (
{k} - {v} + {link ? ( + + {v} + + ) : ( + {v} + )}
) } diff --git a/apps/x/apps/renderer/src/components/apps/app-frame.tsx b/apps/x/apps/renderer/src/components/apps/app-frame.tsx index c406b903..f2c73b0c 100644 --- a/apps/x/apps/renderer/src/components/apps/app-frame.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-frame.tsx @@ -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 (
@@ -62,6 +78,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: > + {app.kind === 'local' && ( + app.publish ? ( + + ) : ( + + ) + )} +
+ If it still doesn't load, go back to Apps and open it again. +
{app.origin}
)} @@ -100,6 +142,14 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
)} + {showPublish && ( + setShowPublish(false)} + onPublished={() => setShowDetail(true)} + /> + )} ) } diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index b698494f..44db99fa 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -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' ? ( -
The community catalog is coming soon.
+ { setSelectedFolder(folder); setTab('mine') }} /> ) : (
{apps.map((app, i) => ( diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx new file mode 100644 index 00000000..3820c6ab --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -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 ( +
+
+
Install {preview.name} v{preview.version}?
+ {preview.description &&

{preview.description}

} + +
+
+ This app will be able to: +
+ {caps.length === 0 ? ( +

Nothing — it declares no capabilities (no tools, LLM, or copilot access).

+ ) : ( +
    + {caps.map((c) =>
  • {c}: {capabilityDescription(c)}
  • )} +
+ )} + {agents.length > 0 && ( +
+
Bundled background agents (installed disabled):
+
    + {agents.map((a) =>
  • {a}
  • )} +
+
+ )} +
+ + {preview.updateSource === 'none' && ( +

Installed from a direct URL — updates will be unavailable.

+ )} + +
+ + +
+
+
+ ) +} + +export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) { + const [records, setRecords] = useState([]) + const [stale, setStale] = useState(false) + const [query, setQuery] = useState('') + const [error, setError] = useState(null) + const [preview, setPreview] = useState(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 ( +
+
+
+ + 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" + /> +
+ + {stale && ( + + )} +
+ + {error &&
{error}
} + + {records.length === 0 ? ( +
+ {query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'} +
+ ) : ( +
+ {records.map((r) => ( +
+
+
+ {r.name} + by {r.owner} +
+

{r.description || 'No description.'}

+
+ +
+ ))} +
+ )} + + {urlDialog && ( +
+
+
Install from URL
+

Paste a direct https link to a .rowboat-app bundle (e.g. a GitHub release asset).

+ 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" + /> +
+ + +
+
+
+ )} + + {preview && ( + void confirmInstall()} + onCancel={() => setPreview(null)} + /> + )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx new file mode 100644 index 00000000..f14dd7e0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -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('auth') + const [login, setLogin] = useState() + const [userCode, setUserCode] = useState('') + const [steps, setSteps] = useState([]) + const [error, setError] = useState(null) + const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null) + const pollTimer = useRef(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 = { + 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 ( +
+
+
+ Publish {appName} +
+ + {error &&
+ {error} + {error.includes('name_taken') &&
That name is taken — rename the app in rowboat-app.json and retry.
} +
} + + {phase === 'auth' && ( +
+

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.

+ +
+ )} + + {phase === 'device' && ( +
+

Enter this code on the GitHub page that just opened:

+
{userCode}
+

Waiting for authorization…

+
+ )} + + {phase === 'confirm' && ( +
+

Signed in as @{login}. This will publish {appName} publicly.

+ +
+ )} + + {(phase === 'publishing' || phase === 'done' || phase === 'error') && ( +
+ {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 ( +
+ {doneStep + ? + : phase === 'error' + ? + : } + {label} +
+ ) + })} + {phase === 'done' && result && ( +
+ + + {result.status === 'pending' && result.prUrl && ( +
Registry validation still pending — track it at the PR.
+ )} +
+ )} +
+ )} + +
+ +
+
+
+ ) +} diff --git a/apps/x/docs/apps-registry/.github/workflows/validate-and-merge.yml b/apps/x/docs/apps-registry/.github/workflows/validate-and-merge.yml new file mode 100644 index 00000000..6e47051e --- /dev/null +++ b/apps/x/docs/apps-registry/.github/workflows/validate-and-merge.yml @@ -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: `. +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/.json (got $filepath)";; esac + + # 2. Filename is .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" diff --git a/apps/x/docs/apps-registry/schema/registry-record.schema.json b/apps/x/docs/apps-registry/schema/registry-record.schema.json new file mode 100644 index 00000000..1973c1ef --- /dev/null +++ b/apps/x/docs/apps-registry/schema/registry-record.schema.json @@ -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" } + } +} diff --git a/apps/x/docs/publishing-apps.md b/apps/x/docs/publishing-apps.md new file mode 100644 index 00000000..46a3cdae --- /dev/null +++ b/apps/x/docs/publishing-apps.md @@ -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 `.rowboat-app` containing, at the +archive root (relative, forward-slash paths): + +``` +rowboat-app.json # the manifest (required) +dist/** # browser-ready static files (required; dist/ must exist) +agents/.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. `.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` (e.g. `v1.2.0`), matching `manifest.version`. + +## Registry record + +The registry (`rowboatlabs/apps-registry`) holds one record per app at +`apps/.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/.rowboat-app` exists. It auto-merges on +success or closes the PR with a `rejected: ` 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. diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 14d35e81..85d7495d 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -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:" } } diff --git a/apps/x/packages/core/src/apps/github-auth.ts b/apps/x/packages/core/src/apps/github-auth.ts new file mode 100644 index 00000000..8c5eec0d --- /dev/null +++ b/apps/x/packages/core/src/apps/github-auth.ts @@ -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 { + try { + return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredAuth; + } catch { + return null; + } +} + +async function writeAuth(auth: StoredAuth): Promise { + 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 { + 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 { + pending = null; + await fs.rm(AUTH_FILE, { force: true }); +} diff --git a/apps/x/packages/core/src/apps/host-api.ts b/apps/x/packages/core/src/apps/host-api.ts index 7fce4129..9b246a4d 100644 --- a/apps/x/packages/core/src/apps/host-api.ts +++ b/apps/x/packages/core/src/apps/host-api.ts @@ -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, }); diff --git a/apps/x/packages/core/src/apps/installer.ts b/apps/x/packages/core/src/apps/installer.ts new file mode 100644 index 00000000..3f40e0b9 --- /dev/null +++ b/apps/x/packages/core/src/apps/installer.ts @@ -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((resolve, reject) => out.write(value, (e) => (e ? reject(e) : resolve()))); + } + await new Promise((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> { + await fsp.mkdir(pkgDir, { recursive: true }); + const fileHashes: Record = {}; + + await new Promise((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 { + 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 { + 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 & { files: Record }, +): Promise { + 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 { + 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 { + 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; + createdAt: number; +}; +const urlStagings = new Map(); + +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 { + 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 { + 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.3–12.4) +// --------------------------------------------------------------------------- + +async function readInstallRecord(folder: string): Promise { + 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 { + 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 9–11: 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 { + 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 { + 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 { + 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 */ } +} diff --git a/apps/x/packages/core/src/apps/packager.ts b/apps/x/packages/core/src/apps/packager.ts new file mode 100644 index 00000000..47ecee8e --- /dev/null +++ b/apps/x/packages/core/src/apps/packager.ts @@ -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 `.rowboat-app` ZIP from the ALLOWLIST +// only — rowboat-app.json, dist/**, 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 { + 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 `.rowboat-app` for the app at `folder`, writing the bundle to + * `outDir` (created if needed). + */ +export async function packageApp(folder: string, outDir: string): Promise { + const dir = appDir(folder); + + // 1. Manifest must parse and dist/ 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((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, + }; +} diff --git a/apps/x/packages/core/src/apps/publisher.ts b/apps/x/packages/core/src/apps/publisher.ts new file mode 100644 index 00000000..fdefae86 --- /dev/null +++ b/apps/x/packages/core/src/apps/publisher.ts @@ -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(token: string, method: string, url: string, body?: unknown): Promise { + 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 { + try { + return AppPublishRecordSchema.parse(JSON.parse(await fsp.readFile(publishRecordPath(folder), 'utf-8'))); + } catch { + return null; + } +} + +async function writePublishRecord(folder: string, record: AppPublishRecord): Promise { + 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 { + 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 { + 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 { + 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>(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>(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; + 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 }; +} diff --git a/apps/x/packages/core/src/apps/registry.ts b/apps/x/packages/core/src/apps/registry.ts new file mode 100644 index 00000000..29f96b8a --- /dev/null +++ b/apps/x/packages/core/src/apps/registry.ts @@ -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; + search(query: string): Promise; + latestManifest(record: RegistryRecord): Promise; +} + +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 { + 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 "-/apps/.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 { + 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 { + 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 { + 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(); diff --git a/apps/x/packages/core/src/apps/server.ts b/apps/x/packages/core/src/apps/server.ts index a612e356..5861f7d6 100644 --- a/apps/x/packages/core/src/apps/server.ts +++ b/apps/x/packages/core/src/apps/server.ts @@ -741,22 +741,29 @@ export async function init(): Promise { startLagMonitor(); await startWatcher(); const expressApp = createApp(); - await new Promise((resolve, reject) => { - const s = expressApp.listen(APPS_PORT, '127.0.0.1', () => { - server = s; + const listenOn = (host: string) => new Promise((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((resolve) => { diff --git a/apps/x/packages/core/src/config/env.ts b/apps/x/packages/core/src/config/env.ts index 0f4026f5..b276d12b 100644 --- a/apps/x/packages/core/src/config/env.ts +++ b/apps/x/packages/core/src/config/env.ts @@ -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'; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f6cf8825..6ac706d3 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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 §12–13). + '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(), diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index ed73f72a..8299f5bd 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -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: {} diff --git a/apps/x/pnpm-workspace.yaml b/apps/x/pnpm-workspace.yaml index 4b07e082..a44d20fe 100644 --- a/apps/x/pnpm-workspace.yaml +++ b/apps/x/pnpm-workspace.yaml @@ -13,6 +13,8 @@ allowBuilds: node-pty: true protobufjs: true +blockExoticSubdeps: false + overrides: vscode-jsonrpc: 8.2.0