mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #676 from rowboatlabs/feature/apps-m3
Rowboat Apps V1 — M3: publish, catalog, install, update
This commit is contained in:
commit
84c71b37af
22 changed files with 2642 additions and 47 deletions
|
|
@ -66,6 +66,13 @@ import * as appsIndexer from '@x/core/dist/apps/indexer.js';
|
|||
import * as appsServer from '@x/core/dist/apps/server.js';
|
||||
import * as appsAgents from '@x/core/dist/apps/agents.js';
|
||||
import { capture } from '@x/core/dist/analytics/posthog.js';
|
||||
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
||||
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
||||
import { registryClient } from '@x/core/dist/apps/registry.js';
|
||||
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
|
||||
|
||||
// D18 install previews awaiting confirmation, keyed by app name.
|
||||
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||
|
|
@ -1558,6 +1565,9 @@ export function setupIpcHandlers() {
|
|||
return qualifyAndDisconnectComposioGoogle();
|
||||
},
|
||||
// Rowboat Apps handlers (spec §13)
|
||||
'apps:serverStatus': async () => {
|
||||
return appsServer.getServerStatus();
|
||||
},
|
||||
'apps:list': async () => {
|
||||
const status = appsServer.getServerStatus();
|
||||
const apps = await appsIndexer.listApps();
|
||||
|
|
@ -1598,6 +1608,109 @@ export function setupIpcHandlers() {
|
|||
appsServer.setAppsTheme(args.theme);
|
||||
return { ok: true as const };
|
||||
},
|
||||
// GitHub auth (device flow) — publishing only
|
||||
// Catalog + install/update (spec §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<IAgentScheduleRepo>('agentScheduleRepo');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
|
||||
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
|
||||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
|
|
@ -38,6 +38,7 @@ import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tas
|
|||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
|
||||
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
|
||||
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
|
||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
|
||||
|
|
@ -341,6 +342,16 @@ function createWindow() {
|
|||
}
|
||||
}
|
||||
|
||||
// Renderer/child process deaths are otherwise silent in packaged builds (the
|
||||
// window or an app iframe just goes blank). Log the reason so crash reports
|
||||
// can be correlated with what Chromium thought happened.
|
||||
app.on('render-process-gone', (_event, webContents, details) => {
|
||||
console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
|
||||
});
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register custom protocol before creating window.
|
||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||
|
|
@ -382,6 +393,24 @@ app.whenReady().then(async () => {
|
|||
setupIpcHandlers();
|
||||
setupBrowserEventForwarding();
|
||||
|
||||
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
|
||||
// the window and the long service-init chain below. The Apps view is
|
||||
// reachable as soon as the window paints; starting the server last meant
|
||||
// every app iframe hit connection-refused (blank app) for the first ~10s of
|
||||
// each launch. Route registration and the token cipher are synchronous;
|
||||
// the listen itself is fire-and-forget.
|
||||
registerAppsHostApi();
|
||||
// GitHub publish token at rest: encrypt via the OS keychain when available
|
||||
// (core stays electron-free; the cipher is injected here).
|
||||
setGithubTokenCipher({
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
|
||||
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
|
||||
});
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
// Start workspace watcher as a main-process service
|
||||
|
|
@ -502,13 +531,6 @@ app.whenReady().then(async () => {
|
|||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) with the
|
||||
// full Host API (tools/fetch/llm/copilot behind the capability gate)
|
||||
registerAppsHostApi();
|
||||
initAppsServer().catch((error) => {
|
||||
console.error('[Apps] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, RotateCcw, Play } from 'lucide-react'
|
||||
import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
|
||||
// App detail panel (spec §14): manifest info, provenance/publish state,
|
||||
// bundled agents with enable toggles, rollback when available. Update/publish
|
||||
|
|
@ -21,6 +22,64 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
const [agents, setAgents] = useState<AgentRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
|
||||
const [showPublish, setShowPublish] = useState(false)
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
|
||||
const runAction = async (name: string, fn: () => Promise<string | void>) => {
|
||||
setBusyAction(name)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const msg = await fn()
|
||||
if (msg) setNotice(msg)
|
||||
setReloadNonce((n) => n + 1)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForUpdate = () => runAction('check', async () => {
|
||||
const r = await window.ipc.invoke('apps:checkUpdate', { folder })
|
||||
setUpdateInfo(r)
|
||||
return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).`
|
||||
})
|
||||
|
||||
const doUpdate = () => runAction('update', async () => {
|
||||
try {
|
||||
await window.ipc.invoke('apps:update', { folder })
|
||||
return 'Updated.'
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
// D18 / modified-files confirmation flows (§12.3)
|
||||
if (msg.includes('new_capabilities')) {
|
||||
if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return
|
||||
await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true })
|
||||
return 'Updated (new capabilities confirmed).'
|
||||
}
|
||||
if (msg.includes('modified_files')) {
|
||||
if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return
|
||||
await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true })
|
||||
return 'Updated (local modifications overwritten).'
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
const doRollback = () => runAction('rollback', async () => {
|
||||
await window.ipc.invoke('apps:rollback', { folder })
|
||||
return 'Rolled back to the previous version.'
|
||||
})
|
||||
|
||||
const doUninstall = () => runAction('uninstall', async () => {
|
||||
const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : ''
|
||||
if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return
|
||||
await window.ipc.invoke('apps:uninstall', { folder })
|
||||
onClose()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -82,6 +141,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
|
||||
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
|
||||
{!app ? (
|
||||
<div className="text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
|
|
@ -120,14 +180,50 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
|
||||
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
|
||||
</>
|
||||
) : app.publish ? (
|
||||
<>
|
||||
<p className="text-muted-foreground">Local app — published to the Rowboat catalog.</p>
|
||||
<InfoRow k="Repository" v={app.publish.repo} mono link={`https://github.com/${app.publish.repo}`} />
|
||||
{app.publish.lastPublishedVersion && (
|
||||
<InfoRow
|
||||
k="Published"
|
||||
v={`v${app.publish.lastPublishedVersion}`}
|
||||
link={`https://github.com/${app.publish.repo}/releases/tag/v${app.publish.lastPublishedVersion}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Local app — created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.</p>
|
||||
)}
|
||||
{rollback && (
|
||||
<button type="button" className="mt-1 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
|
||||
<RotateCcw className="size-3.5" /> Roll back to previous version
|
||||
</button>
|
||||
<p className="text-muted-foreground">Local app — created on this machine.</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{app.kind === 'installed' && app.install?.repo && (
|
||||
<button type="button" disabled={busyAction !== null}
|
||||
onClick={() => void (updateInfo?.updateAvailable ? doUpdate() : checkForUpdate())}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
|
||||
<ArrowUpCircle className="size-3.5" />
|
||||
{busyAction === 'check' || busyAction === 'update' ? 'Working…'
|
||||
: updateInfo?.updateAvailable ? `Update to v${updateInfo.latest}` : 'Check for update'}
|
||||
</button>
|
||||
)}
|
||||
{rollback && (
|
||||
<button type="button" disabled={busyAction !== null} onClick={() => void doRollback()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
|
||||
<RotateCcw className="size-3.5" /> Roll back
|
||||
</button>
|
||||
)}
|
||||
{app.kind === 'local' && (
|
||||
<button type="button" disabled={busyAction !== null} onClick={() => setShowPublish(true)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
|
||||
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{app.kind === 'installed' && (
|
||||
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
|
||||
<Trash2 className="size-3.5" /> Uninstall
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
|
@ -172,15 +268,34 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showPublish && app && (
|
||||
<PublishDialog
|
||||
folder={folder}
|
||||
appName={manifest?.name ?? folder}
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => setReloadNonce((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
|
||||
function InfoRow({ k, v, mono, link }: { k: string; v: string; mono?: boolean; link?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
|
||||
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
|
||||
{link ? (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`min-w-0 truncate text-primary hover:underline ${mono ? 'font-mono text-xs' : ''}`}
|
||||
>
|
||||
{v}
|
||||
</a>
|
||||
) : (
|
||||
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, ExternalLink, Info, RotateCw } from 'lucide-react'
|
||||
import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { appOpened } from '@/lib/analytics'
|
||||
import { AppDetail } from '@/components/apps/app-detail'
|
||||
import { PublishDialog } from '@/components/apps/publish-dialog'
|
||||
|
||||
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
|
||||
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
|
||||
|
|
@ -11,6 +12,7 @@ import { AppDetail } from '@/components/apps/app-detail'
|
|||
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [showPublish, setShowPublish] = useState(false)
|
||||
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
|
||||
// surface a visible retry state instead of a silent blank pane.
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
|
||||
|
|
@ -34,6 +36,20 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
return () => window.clearTimeout(timer)
|
||||
}, [watchKey])
|
||||
|
||||
// Stuck diagnosis: the usual cause is the apps server not being reachable
|
||||
// yet (it starts with the main process; on a fresh launch or a quick
|
||||
// relaunch the first iframe load can beat it). Say when the server itself
|
||||
// is the problem.
|
||||
const [serverDown, setServerDown] = useState(false)
|
||||
useEffect(() => {
|
||||
if (loadState !== 'stuck') return
|
||||
let cancelled = false
|
||||
void window.ipc.invoke('apps:serverStatus', {}).then((s) => {
|
||||
if (!cancelled) setServerDown(!s.running)
|
||||
}).catch(() => { /* status probe is best-effort */ })
|
||||
return () => { cancelled = true }
|
||||
}, [loadState])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
|
|
@ -62,6 +78,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
{app.kind === 'local' && (
|
||||
app.publish ? (
|
||||
<button
|
||||
type="button"
|
||||
title={`Published as ${app.publish.repo} — view details`}
|
||||
onClick={() => setShowDetail(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500"
|
||||
>
|
||||
<BadgeCheck className="size-4" /> Published
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
title="Publish this app"
|
||||
onClick={() => setShowPublish(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<UploadCloud className="size-4" /> Publish
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="App details"
|
||||
|
|
@ -82,7 +119,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
/>
|
||||
{loadState === 'stuck' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
|
||||
<div className="text-muted-foreground">This app is taking too long to load.</div>
|
||||
<div className="text-muted-foreground">
|
||||
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadNonce((n) => n + 1)}
|
||||
|
|
@ -90,6 +129,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
>
|
||||
Retry
|
||||
</button>
|
||||
<div className="max-w-xs text-center text-xs text-muted-foreground">
|
||||
If it still doesn't load, go back to Apps and open it again.
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -100,6 +142,14 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showPublish && (
|
||||
<PublishDialog
|
||||
folder={app.folder}
|
||||
appName={title}
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => setShowDetail(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { Plus, RefreshCw } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { AppFrame } from '@/components/apps/app-frame'
|
||||
import { CatalogTab } from '@/components/apps/catalog'
|
||||
|
||||
// Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are
|
||||
// AppSummary-driven; click opens the app full-height on its own origin.
|
||||
|
|
@ -219,7 +220,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
)}
|
||||
|
||||
{tab === 'catalog' ? (
|
||||
<div className="ma-empty">The community catalog is coming soon.</div>
|
||||
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
|
||||
) : (
|
||||
<div className="ma-grid">
|
||||
{apps.map((app, i) => (
|
||||
|
|
|
|||
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
|
||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||
// disclosure, install from a direct bundle URL.
|
||||
|
||||
type Preview = {
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
capabilities?: string[]
|
||||
agents?: string[]
|
||||
updateSource?: 'github' | 'none'
|
||||
url?: string // set for URL installs
|
||||
}
|
||||
|
||||
function capabilityDescription(cap: string): string {
|
||||
if (cap === 'llm') return 'use your AI models (spends your tokens)'
|
||||
if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)'
|
||||
return `read and act on your ${cap} through your connected account`
|
||||
}
|
||||
|
||||
/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */
|
||||
function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
|
||||
preview: Preview
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const caps = preview.capabilities ?? []
|
||||
const agents = preview.agents ?? []
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
|
||||
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
|
||||
|
||||
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
|
||||
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
|
||||
</div>
|
||||
{caps.length === 0 ? (
|
||||
<p className="text-muted-foreground">Nothing — it declares no capabilities (no tools, LLM, or copilot access).</p>
|
||||
) : (
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{agents.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Bundled background agents (installed disabled):</div>
|
||||
<ul className="list-inside list-disc text-muted-foreground">
|
||||
{agents.map((a) => <li key={a}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview.updateSource === 'none' && (
|
||||
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL — updates will be unavailable.</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
|
||||
<button type="button" onClick={onConfirm} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Installing…' : 'Install'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
|
||||
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
|
||||
const [stale, setStale] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<Preview | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [urlDialog, setUrlDialog] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
const load = async (force = false) => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:catalogIndex', { force })
|
||||
setRecords(r.records)
|
||||
setStale(r.stale)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
useEffect(() => { void load() }, [])
|
||||
|
||||
const search = async (q: string) => {
|
||||
setQuery(q)
|
||||
try {
|
||||
const r = q.trim()
|
||||
? await window.ipc.invoke('apps:catalogSearch', { query: q })
|
||||
: await window.ipc.invoke('apps:catalogIndex', {})
|
||||
setRecords(r.records)
|
||||
} catch { /* keep current list */ }
|
||||
}
|
||||
|
||||
const startInstall = async (name: string) => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:install', { name })
|
||||
if (r.status === 'preview') setPreview({ ...r })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const startUrlPreview = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false })
|
||||
if (r.status === 'preview') setPreview({ ...r, url: url.trim() })
|
||||
setUrlDialog(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const confirmInstall = async () => {
|
||||
if (!preview) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = preview.url
|
||||
? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true })
|
||||
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
|
||||
setPreview(null)
|
||||
if (r.status === 'installed' && r.app) onInstalled(r.app.folder)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => void search(e.target.value)}
|
||||
placeholder="Search the catalog…"
|
||||
className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" title="Install from URL"
|
||||
onClick={() => setUrlDialog(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
|
||||
<Link2 className="size-4" /> From URL
|
||||
</button>
|
||||
{stale && (
|
||||
<button type="button" onClick={() => void load(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
|
||||
<RefreshCw className="size-4" /> Stale — refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{records.map((r) => (
|
||||
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-semibold">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">by {r.owner}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void startInstall(r.name)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-2 text-base font-semibold">Install from URL</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app"
|
||||
className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setUrlDialog(false)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
|
||||
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<InstallConfirmDialog
|
||||
preview={preview}
|
||||
busy={busy}
|
||||
onConfirm={() => void confirmInstall()}
|
||||
onCancel={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
177
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
|
||||
|
||||
// Publish dialog (spec §14): device-flow sign-in → confirmation → step
|
||||
// progress (§11.2 step names) → success links / typed error (name_taken gets
|
||||
// an inline hint to rename and retry).
|
||||
|
||||
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
|
||||
|
||||
export function PublishDialog({ folder, appName, onClose, onPublished }: {
|
||||
folder: string
|
||||
appName: string
|
||||
onClose: () => void
|
||||
onPublished: () => void
|
||||
}) {
|
||||
const [phase, setPhase] = useState<Phase>('auth')
|
||||
const [login, setLogin] = useState<string | undefined>()
|
||||
const [userCode, setUserCode] = useState('')
|
||||
const [steps, setSteps] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null)
|
||||
const pollTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await window.ipc.invoke('githubAuth:status', {})
|
||||
if (s.signedIn) {
|
||||
setLogin(s.login)
|
||||
setPhase('confirm')
|
||||
}
|
||||
})()
|
||||
return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on('apps:progress', ({ folder: f, step }) => {
|
||||
if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step]))
|
||||
})
|
||||
}, [folder])
|
||||
|
||||
const startSignIn = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await window.ipc.invoke('githubAuth:start', {})
|
||||
setUserCode(r.userCode)
|
||||
setPhase('device')
|
||||
pollTimer.current = window.setInterval(() => {
|
||||
void (async () => {
|
||||
const p = await window.ipc.invoke('githubAuth:poll', {})
|
||||
if (p.status === 'authorized') {
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setLogin(p.login)
|
||||
setPhase('confirm')
|
||||
} else if (p.status === 'expired' || p.status === 'denied') {
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
|
||||
setPhase('auth')
|
||||
}
|
||||
})().catch((e: unknown) => {
|
||||
// A hard failure (bad client config, network) must surface, not spin.
|
||||
if (pollTimer.current) window.clearInterval(pollTimer.current)
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setPhase('auth')
|
||||
})
|
||||
// Heartbeat only — core paces the actual GitHub requests to the
|
||||
// flow's required interval (and skips the request when it's too soon).
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const publish = async () => {
|
||||
setPhase('publishing')
|
||||
setError(null)
|
||||
setSteps([])
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:publish', { folder })
|
||||
setResult(r)
|
||||
setPhase('done')
|
||||
onPublished()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setPhase('error')
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
packaged: 'Packaged bundle',
|
||||
repo_created: 'Created GitHub repo',
|
||||
source_pushed: 'Pushed source',
|
||||
release_created: 'Created release',
|
||||
assets_uploaded: 'Uploaded assets',
|
||||
registered: 'Opened registry PR',
|
||||
polling: 'Waiting for registry validation…',
|
||||
published: 'Published',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<Github className="size-5" /> Publish {appName}
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken — rename the app in <code>rowboat-app.json</code> and retry.</div>}
|
||||
</div>}
|
||||
|
||||
{phase === 'auth' && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.</p>
|
||||
<button type="button" onClick={() => void startSignIn()}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'device' && (
|
||||
<div className="space-y-3 text-center text-sm">
|
||||
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
|
||||
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
|
||||
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'confirm' && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p>
|
||||
<button type="button" onClick={() => void publish()}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{Object.entries(STEP_LABELS).map(([key, label]) => {
|
||||
const doneStep = steps.includes(key) || phase === 'done'
|
||||
const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key
|
||||
if (key === 'published' && phase !== 'done') return null
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{doneStep
|
||||
? <CheckCircle2 className="size-4 text-green-600" />
|
||||
: phase === 'error'
|
||||
? <XCircle className="size-4 text-muted-foreground/40" />
|
||||
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
|
||||
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{phase === 'done' && result && (
|
||||
<div className="mt-3 space-y-1 text-xs">
|
||||
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
|
||||
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
|
||||
{result.status === 'pending' && result.prUrl && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending — track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" onClick={onClose}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
{phase === 'done' ? 'Done' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
apps/x/docs/apps-registry/.github/workflows/validate-and-merge.yml
vendored
Normal file
102
apps/x/docs/apps-registry/.github/workflows/validate-and-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Validates publish PRs against the Rowboat Apps registry and auto-merges on
|
||||
# success (spec §9.3). On any failure the PR is closed with a comment whose
|
||||
# first line is machine-readable: `rejected: <code>`.
|
||||
name: validate-and-merge
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# Per-name concurrency: two racing PRs for one name serialize; the loser
|
||||
# fails the name-collision check.
|
||||
concurrency:
|
||||
group: publish-${{ github.event.pull_request.title }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout base (main)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Fetch PR diff and validate
|
||||
id: validate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
reject() { echo "code=$1" >> "$GITHUB_OUTPUT"; echo "detail=$2" >> "$GITHUB_OUTPUT"; exit 1; }
|
||||
|
||||
# 1. The diff adds exactly one file, under apps/, and changes nothing else.
|
||||
files_json=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate)
|
||||
count=$(echo "$files_json" | jq 'length')
|
||||
[ "$count" = "1" ] || reject invalid_diff "PR must add exactly one file (found $count changes)"
|
||||
status=$(echo "$files_json" | jq -r '.[0].status')
|
||||
[ "$status" = "added" ] || reject invalid_diff "PR must ADD a record; modifications are not accepted in V1"
|
||||
filepath=$(echo "$files_json" | jq -r '.[0].filename')
|
||||
case "$filepath" in apps/*.json) ;; *) reject invalid_path "record must be apps/<name>.json (got $filepath)";; esac
|
||||
|
||||
# 2. Filename is <name>.json with a valid name.
|
||||
name=$(basename "$filepath" .json)
|
||||
echo "$name" | grep -Eq '^[a-z0-9]+(-[a-z0-9]+)*$' || reject invalid_name "\"$name\" is not a valid package name"
|
||||
len=${#name}; [ "$len" -ge 3 ] && [ "$len" -le 64 ] || reject invalid_name "name length must be 3-64"
|
||||
|
||||
# 3. Content validates against the schema and name matches the filename stem.
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/${filepath}?ref=${HEAD_SHA}" --jq .content | base64 -d > /tmp/record.json
|
||||
npx --yes ajv-cli@5 validate -s schema/registry-record.schema.json -d /tmp/record.json --strict=false \
|
||||
|| reject invalid_record "record does not match schema/registry-record.schema.json"
|
||||
rec_name=$(jq -r .name /tmp/record.json)
|
||||
[ "$rec_name" = "$name" ] || reject name_mismatch "record.name \"$rec_name\" != filename stem \"$name\""
|
||||
|
||||
# 4. record.owner equals the PR author.
|
||||
rec_owner=$(jq -r .owner /tmp/record.json)
|
||||
[ "$rec_owner" = "$PR_AUTHOR" ] || reject owner_mismatch "record.owner \"$rec_owner\" != PR author \"$PR_AUTHOR\""
|
||||
|
||||
# 5. Name not taken and not retired.
|
||||
[ ! -f "apps/${name}.json" ] || reject name_taken "apps/${name}.json already exists"
|
||||
[ ! -f "removed/${name}.json" ] || reject name_retired "\"$name\" was removed and stays retired"
|
||||
|
||||
# 6. record.repo is public and the author has push/admin on it.
|
||||
repo=$(jq -r .repo /tmp/record.json)
|
||||
visibility=$(gh api "repos/${repo}" --jq .visibility 2>/dev/null) || reject repo_unreachable "cannot read ${repo}"
|
||||
[ "$visibility" = "public" ] || reject repo_not_public "${repo} is not public"
|
||||
perm=$(gh api "repos/${repo}/collaborators/${PR_AUTHOR}/permission" --jq .permission 2>/dev/null) \
|
||||
|| reject permission_check_failed "cannot check ${PR_AUTHOR}'s permission on ${repo}"
|
||||
case "$perm" in admin|write) ;; *) reject not_repo_collaborator "PR author has \"$perm\" on ${repo}; needs push or admin";; esac
|
||||
|
||||
# 7. Latest release carries the bundle asset (existence probe only — D14).
|
||||
http=$(curl -s -o /dev/null -w "%{http_code}" -I -L "https://github.com/${repo}/releases/latest/download/${name}.rowboat-app")
|
||||
case "$http" in 200|302) ;; *) reject missing_release_asset "releases/latest has no ${name}.rowboat-app (HTTP $http)";; esac
|
||||
|
||||
echo "name=$name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge on success
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "published: ${{ steps.validate.outputs.name }}"
|
||||
gh pr merge "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --squash --admin
|
||||
|
||||
- name: Close on failure
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
code="${{ steps.validate.outputs.code }}"
|
||||
detail="${{ steps.validate.outputs.detail }}"
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "rejected: ${code:-validation_error}
|
||||
|
||||
${detail:-Validation failed; see the Action log.}"
|
||||
gh pr close "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY"
|
||||
24
apps/x/docs/apps-registry/schema/registry-record.schema.json
Normal file
24
apps/x/docs/apps-registry/schema/registry-record.schema.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Rowboat Apps registry record",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "name", "owner", "repo", "createdAt"],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": 1 },
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
|
||||
},
|
||||
"owner": { "type": "string", "minLength": 1 },
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"description": { "type": "string", "maxLength": 500 },
|
||||
"iconUrl": { "type": "string", "format": "uri", "pattern": "^https://" },
|
||||
"createdAt": { "type": "string" }
|
||||
}
|
||||
}
|
||||
74
apps/x/docs/publishing-apps.md
Normal file
74
apps/x/docs/publishing-apps.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Publishing Rowboat Apps — advanced path
|
||||
|
||||
Most authors should use the in-app guided publish (Apps → app detail →
|
||||
Publish): it creates the repo, pushes source, cuts the release, and registers
|
||||
the app automatically. This doc is for developers who manage their own repo,
|
||||
build pipeline, or monorepo and want to publish releases themselves.
|
||||
|
||||
## The bundle format (`.rowboat-app`)
|
||||
|
||||
A `.rowboat-app` file is a ZIP named `<name>.rowboat-app` containing, at the
|
||||
archive root (relative, forward-slash paths):
|
||||
|
||||
```
|
||||
rowboat-app.json # the manifest (required)
|
||||
dist/** # browser-ready static files (required; dist/<entry> must exist)
|
||||
agents/<file>.yaml # only files listed in manifest.agents
|
||||
defaults/** # optional starter data, copied to data/ on first install
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never include `src/`, `package.json`, `node_modules/`, `data/`, dotfiles, or
|
||||
`.rowboat-*.json`. Installers enforce size limits (100 MB compressed, 500 MB
|
||||
uncompressed, 10,000 entries) and reject symlink entries and unsafe paths.
|
||||
- `manifest.name` must match the bundle filename stem and, once registered,
|
||||
never changes. `version` is strict semver (`MAJOR.MINOR.PATCH`).
|
||||
- Agent definitions may contain ONLY `name`, `instructions`, `triggers` —
|
||||
runtime state, `active`, and model overrides are rejected.
|
||||
|
||||
## The two-asset requirement
|
||||
|
||||
Every release on a registered repo MUST attach **both** assets:
|
||||
|
||||
1. `<name>.rowboat-app` — the bundle
|
||||
2. `rowboat-app.json` — a standalone copy of the manifest
|
||||
|
||||
The standalone manifest powers Rowboat's quota-free update check (it is
|
||||
fetched via `releases/latest/download/rowboat-app.json`).
|
||||
|
||||
## Tag convention
|
||||
|
||||
Tag releases `v<version>` (e.g. `v1.2.0`), matching `manifest.version`.
|
||||
|
||||
## Registry record
|
||||
|
||||
The registry (`rowboatlabs/apps-registry`) holds one record per app at
|
||||
`apps/<name>.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"name": "my-app",
|
||||
"owner": "your-github-login",
|
||||
"repo": "your-github-login/my-app",
|
||||
"description": "What the app does",
|
||||
"iconUrl": "https://raw.githubusercontent.com/you/my-app/HEAD/dist/icon.png",
|
||||
"createdAt": "2026-07-06T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Register either via the in-app form (Apps → Catalog → Register existing
|
||||
release) or by opening the one-file PR yourself. A validation Action checks:
|
||||
the PR adds exactly that one file; the name is valid, unique, and not retired;
|
||||
`owner` equals the PR author; the author has push access to `repo`; and
|
||||
`releases/latest/download/<name>.rowboat-app` exists. It auto-merges on
|
||||
success or closes the PR with a `rejected: <code>` comment.
|
||||
|
||||
## The latest-release constraint (monorepos)
|
||||
|
||||
Version discovery always reads the repo's **latest** release. A monorepo
|
||||
registering multiple apps therefore works only if **every** release attaches
|
||||
**every** registered app's asset pair — otherwise resolution for the other
|
||||
apps breaks whenever any one app releases. One repo per app avoids this
|
||||
entirely and is what the guided path does.
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
205
apps/x/packages/core/src/apps/github-auth.ts
Normal file
205
apps/x/packages/core/src/apps/github-auth.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GITHUB_OAUTH_CLIENT_ID } from '../config/env.js';
|
||||
|
||||
// GitHub authentication via the OAuth device flow (spec §10). Sign-in is
|
||||
// required ONLY for publishing; create/run/install/update never touch it.
|
||||
// The token doubles as publisher identity (D6).
|
||||
|
||||
const AUTH_FILE = path.join(WorkDir, 'config', 'github-auth.json');
|
||||
|
||||
// Token-at-rest encryption is provided by the Electron main process
|
||||
// (safeStorage) — core stays electron-free. When no cipher is wired (or the OS
|
||||
// keychain is unavailable) the token is stored plaintext with a marker,
|
||||
// matching the existing token-storage approach in auth/.
|
||||
export interface TokenCipher {
|
||||
isAvailable(): boolean;
|
||||
encrypt(plain: string): string; // returns base64
|
||||
decrypt(encrypted: string): string;
|
||||
}
|
||||
let cipher: TokenCipher | null = null;
|
||||
export function setTokenCipher(c: TokenCipher): void {
|
||||
cipher = c;
|
||||
}
|
||||
|
||||
type StoredAuth = {
|
||||
login: string;
|
||||
createdAt: string;
|
||||
token?: string; // plaintext fallback
|
||||
tokenEncrypted?: string; // base64 via cipher
|
||||
plaintext?: boolean;
|
||||
};
|
||||
|
||||
type PendingFlow = {
|
||||
deviceCode: string;
|
||||
intervalMs: number;
|
||||
expiresAt: number;
|
||||
/** Last time we actually hit GitHub's token endpoint (pacing). */
|
||||
lastTokenPollAt?: number;
|
||||
/** Token already issued but the identity fetch failed — retry that step. */
|
||||
issuedToken?: string;
|
||||
identityAttempts?: number;
|
||||
};
|
||||
|
||||
const GH_HEADERS = { 'Accept': 'application/json', 'User-Agent': 'rowboat-apps' };
|
||||
let pending: PendingFlow | null = null;
|
||||
|
||||
async function readAuth(): Promise<StoredAuth | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredAuth;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuth(auth: StoredAuth): Promise<void> {
|
||||
await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
|
||||
await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Start the device flow. Returns the code the user enters on github.com. */
|
||||
export async function startDeviceFlow(): Promise<{ userCode: string; verificationUri: string; expiresIn: number }> {
|
||||
// GitHub's OAuth endpoints take form-encoded params (JSON is not reliable).
|
||||
const res = await fetch('https://github.com/login/device/code', {
|
||||
method: 'POST',
|
||||
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }).toString(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`);
|
||||
const body = await res.json() as {
|
||||
device_code: string; user_code: string; verification_uri: string;
|
||||
expires_in: number; interval: number;
|
||||
};
|
||||
pending = {
|
||||
deviceCode: body.device_code,
|
||||
// +1s safety margin: GitHub measures arrival spacing, and a request
|
||||
// that lands even slightly early gets `slow_down`, which RAISES the
|
||||
// required interval for the rest of the flow.
|
||||
intervalMs: ((body.interval || 5) + 1) * 1000,
|
||||
expiresAt: Date.now() + body.expires_in * 1000,
|
||||
};
|
||||
return { userCode: body.user_code, verificationUri: body.verification_uri, expiresIn: body.expires_in };
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'authorized'; login: string }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' };
|
||||
|
||||
/** Poll once for device-flow completion (renderer drives the cadence). */
|
||||
export async function pollDeviceFlow(): Promise<PollResult> {
|
||||
if (!pending) return { status: 'expired' };
|
||||
if (Date.now() > pending.expiresAt) {
|
||||
pending = null;
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
let accessToken = pending.issuedToken;
|
||||
if (!accessToken) {
|
||||
// Pacing lives HERE, not in the renderer: the renderer's timer is just
|
||||
// a heartbeat. GitHub rate-limits the token endpoint per device code —
|
||||
// polling faster than the flow's interval returns `slow_down` and
|
||||
// permanently raises the required interval, and a caller that keeps
|
||||
// its own fixed cadence then gets `slow_down` on EVERY poll (looks
|
||||
// "pending" forever, even after the user authorized). Skip the request
|
||||
// entirely until the current interval has elapsed.
|
||||
const now = Date.now();
|
||||
if (pending.lastTokenPollAt !== undefined && now - pending.lastTokenPollAt < pending.intervalMs) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
pending.lastTokenPollAt = now;
|
||||
const res = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: GITHUB_OAUTH_CLIENT_ID,
|
||||
device_code: pending.deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}).toString(),
|
||||
});
|
||||
const body = await res.json() as { access_token?: string; error?: string; error_description?: string };
|
||||
console.log(`[GitHubAuth] poll: http=${res.status} error=${body.error ?? 'none'} token=${body.access_token ? 'ISSUED' : 'no'}`);
|
||||
|
||||
if (body.error === 'authorization_pending') return { status: 'pending' };
|
||||
if (body.error === 'slow_down') {
|
||||
pending.intervalMs += 5000;
|
||||
return { status: 'pending' };
|
||||
}
|
||||
if (body.error === 'expired_token' || body.error === 'incorrect_device_code') {
|
||||
pending = null;
|
||||
return { status: 'expired' };
|
||||
}
|
||||
if (body.error === 'access_denied') {
|
||||
pending = null;
|
||||
return { status: 'denied' };
|
||||
}
|
||||
if (body.error) {
|
||||
// Unknown/config errors (unsupported_grant_type, device_flow_disabled…)
|
||||
// must FAIL loudly, not spin as pending forever.
|
||||
pending = null;
|
||||
throw new Error(`device_flow_error: ${body.error}${body.error_description ? ` — ${body.error_description}` : ''}`);
|
||||
}
|
||||
if (!body.access_token) return { status: 'pending' };
|
||||
accessToken = body.access_token;
|
||||
// The device code is consumed once the token is issued — remember the
|
||||
// token so a transient identity failure below can retry next poll.
|
||||
pending.issuedToken = accessToken;
|
||||
}
|
||||
|
||||
// Identity: cache login alongside the token. Bounded retries — a
|
||||
// persistent failure must surface, never spin as "pending" forever.
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/vnd.github+json', 'User-Agent': 'rowboat-apps' },
|
||||
});
|
||||
console.log(`[GitHubAuth] identity: http=${userRes.status}`);
|
||||
if (!userRes.ok) {
|
||||
pending.identityAttempts = (pending.identityAttempts ?? 0) + 1;
|
||||
if (pending.identityAttempts >= 3) {
|
||||
const detail = await userRes.text().catch(() => '');
|
||||
pending = null;
|
||||
throw new Error(`identity_failed: GET /user → HTTP ${userRes.status} ${detail.slice(0, 160)}`);
|
||||
}
|
||||
return { status: 'pending' };
|
||||
}
|
||||
const user = await userRes.json() as { login: string };
|
||||
|
||||
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };
|
||||
if (cipher?.isAvailable()) {
|
||||
auth.tokenEncrypted = cipher.encrypt(accessToken);
|
||||
} else {
|
||||
auth.token = accessToken;
|
||||
auth.plaintext = true;
|
||||
}
|
||||
await writeAuth(auth);
|
||||
pending = null;
|
||||
return { status: 'authorized', login: user.login };
|
||||
}
|
||||
|
||||
export async function getAuthStatus(): Promise<{ signedIn: boolean; login?: string }> {
|
||||
const auth = await readAuth();
|
||||
return auth ? { signedIn: true, login: auth.login } : { signedIn: false };
|
||||
}
|
||||
|
||||
/** The stored token, or null. Callers hitting a 401 MUST call clearAuth(). */
|
||||
export async function getGithubToken(): Promise<{ token: string; login: string } | null> {
|
||||
const auth = await readAuth();
|
||||
if (!auth) return null;
|
||||
if (auth.tokenEncrypted && cipher?.isAvailable()) {
|
||||
try {
|
||||
return { token: cipher.decrypt(auth.tokenEncrypted), login: auth.login };
|
||||
} catch {
|
||||
await clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (auth.token) return { token: auth.token, login: auth.login };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sign out / expire (a 401 from any GitHub call surfaces as github_auth_expired). */
|
||||
export async function clearAuth(): Promise<void> {
|
||||
pending = null;
|
||||
await fs.rm(AUTH_FILE, { force: true });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
515
apps/x/packages/core/src/apps/installer.ts
Normal file
515
apps/x/packages/core/src/apps/installer.ts
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import yauzl from 'yauzl';
|
||||
import {
|
||||
RowboatAppManifestSchema,
|
||||
AppInstallRecordSchema,
|
||||
type RowboatAppManifest,
|
||||
type AppInstallRecord,
|
||||
type AppSummary,
|
||||
type RegistryRecord,
|
||||
} from '@x/shared/dist/rowboat-app.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import {
|
||||
APPS_DIR,
|
||||
FOLDER_SLUG_RE,
|
||||
MAX_BUNDLE_COMPRESSED,
|
||||
MAX_BUNDLE_UNCOMPRESSED,
|
||||
MAX_BUNDLE_ENTRIES,
|
||||
} from './constants.js';
|
||||
import { getApp } from './indexer.js';
|
||||
import { registryClient } from './registry.js';
|
||||
import { syncAppAgents, deleteAppAgents } from './agents.js';
|
||||
|
||||
// Installer (spec §12): two-phase install with D18 capability disclosure,
|
||||
// zip-slip/symlink/size guards, bundle-identity + capability-mismatch checks,
|
||||
// defaults→data on first install only, pinned file hashes, one-step rollback.
|
||||
|
||||
const TMP_ROOT = path.join(WorkDir, 'tmp');
|
||||
const URL_STAGING_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export class InstallError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallPreview {
|
||||
status: 'preview';
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
capabilities: string[];
|
||||
agents: string[];
|
||||
/** §12.5 only: whether check-for-update will work after install. */
|
||||
updateSource?: 'github' | 'none';
|
||||
}
|
||||
|
||||
export interface InstallDone {
|
||||
status: 'installed';
|
||||
app: AppSummary;
|
||||
}
|
||||
|
||||
const RELEASE_MANAGED = ['rowboat-app.json', 'dist', 'agents', 'defaults'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bundle download + extraction (shared by catalog + URL installs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function downloadBundle(url: string, destDir: string): Promise<{ zipPath: string; sha256: string }> {
|
||||
await fsp.mkdir(destDir, { recursive: true });
|
||||
const zipPath = path.join(destDir, 'bundle.zip');
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new InstallError('download_failed', `bundle download: HTTP ${res.status}`);
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
const out = fs.createWriteStream(zipPath);
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new InstallError('download_failed', 'empty response body');
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > MAX_BUNDLE_COMPRESSED) {
|
||||
out.destroy();
|
||||
await fsp.rm(zipPath, { force: true });
|
||||
throw new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_COMPRESSED} bytes compressed`);
|
||||
}
|
||||
hash.update(value);
|
||||
await new Promise<void>((resolve, reject) => out.write(value, (e) => (e ? reject(e) : resolve())));
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => out.end((e?: Error | null) => (e ? reject(e) : resolve())));
|
||||
return { zipPath, sha256: hash.digest('hex') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract with REQUIRED guards (§12.1 step 4): reject absolute paths, `..`
|
||||
* segments, backslashes (zip-slip); reject symlink/hardlink entries; enforce
|
||||
* entry-count and cumulative-size limits. Records per-file sha256 (step 7).
|
||||
*/
|
||||
async function extractBundle(zipPath: string, pkgDir: string): Promise<Record<string, string>> {
|
||||
await fsp.mkdir(pkgDir, { recursive: true });
|
||||
const fileHashes: Record<string, string> = {};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
|
||||
if (err || !zip) return reject(new InstallError('bundle_unreadable', String(err)));
|
||||
let entries = 0;
|
||||
let uncompressed = 0;
|
||||
|
||||
zip.on('entry', (entry: yauzl.Entry) => {
|
||||
entries += 1;
|
||||
if (entries > MAX_BUNDLE_ENTRIES) {
|
||||
zip.close();
|
||||
return reject(new InstallError('bundle_too_many_entries', `more than ${MAX_BUNDLE_ENTRIES} entries`));
|
||||
}
|
||||
const name = entry.fileName;
|
||||
if (name.includes('\\') || name.startsWith('/') || name.split('/').includes('..') || name.includes('\0')) {
|
||||
zip.close();
|
||||
return reject(new InstallError('zip_slip', `unsafe entry path: ${name}`));
|
||||
}
|
||||
// Symlinks/hardlinks: mode is in the top 16 bits of externalFileAttributes.
|
||||
const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
||||
if ((unixMode & 0xf000) === 0xa000) {
|
||||
zip.close();
|
||||
return reject(new InstallError('symlink_entry', `symlink entry rejected: ${name}`));
|
||||
}
|
||||
if (name.endsWith('/')) {
|
||||
fsp.mkdir(path.join(pkgDir, name), { recursive: true }).then(() => zip.readEntry(), reject);
|
||||
return;
|
||||
}
|
||||
uncompressed += entry.uncompressedSize;
|
||||
if (uncompressed > MAX_BUNDLE_UNCOMPRESSED) {
|
||||
zip.close();
|
||||
return reject(new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_UNCOMPRESSED} bytes uncompressed`));
|
||||
}
|
||||
zip.openReadStream(entry, (streamErr, stream) => {
|
||||
if (streamErr || !stream) {
|
||||
zip.close();
|
||||
return reject(new InstallError('bundle_unreadable', String(streamErr)));
|
||||
}
|
||||
const dest = path.join(pkgDir, name);
|
||||
void fsp.mkdir(path.dirname(dest), { recursive: true }).then(() => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
stream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||
const out = fs.createWriteStream(dest);
|
||||
stream.pipe(out);
|
||||
out.on('close', () => {
|
||||
fileHashes[name] = hash.digest('hex');
|
||||
zip.readEntry();
|
||||
});
|
||||
out.on('error', reject);
|
||||
stream.on('error', reject);
|
||||
}, reject);
|
||||
});
|
||||
});
|
||||
zip.on('end', () => resolve());
|
||||
zip.on('error', (e) => reject(new InstallError('bundle_unreadable', String(e))));
|
||||
zip.readEntry();
|
||||
});
|
||||
});
|
||||
|
||||
return fileHashes;
|
||||
}
|
||||
|
||||
async function copyDefaultsToData(dir: string): Promise<void> {
|
||||
const defaultsDir = path.join(dir, 'defaults');
|
||||
const dataDir = path.join(dir, 'data');
|
||||
await fsp.mkdir(dataDir, { recursive: true });
|
||||
try {
|
||||
await fsp.cp(defaultsDir, dataDir, { recursive: true, force: false, errorOnExist: false });
|
||||
} catch { /* no defaults, or partial copy of existing files — fine */ }
|
||||
}
|
||||
|
||||
async function chooseTargetFolder(name: string): Promise<string> {
|
||||
let candidate = name;
|
||||
for (let i = 2; fs.existsSync(path.join(APPS_DIR, candidate)); i++) {
|
||||
candidate = `${name}-${i}`;
|
||||
if (i > 50) throw new InstallError('folder_unavailable', 'could not find a free folder name');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function assembleInstall(
|
||||
pkgDir: string,
|
||||
manifest: RowboatAppManifest,
|
||||
record: Omit<AppInstallRecord, 'files'> & { files: Record<string, string> },
|
||||
): Promise<AppSummary> {
|
||||
const folder = await chooseTargetFolder(manifest.name);
|
||||
const dir = path.join(APPS_DIR, folder);
|
||||
try {
|
||||
await fsp.mkdir(APPS_DIR, { recursive: true });
|
||||
await fsp.rename(pkgDir, dir).catch(async () => {
|
||||
// cross-device fallback
|
||||
await fsp.cp(pkgDir, dir, { recursive: true });
|
||||
await fsp.rm(pkgDir, { recursive: true, force: true });
|
||||
});
|
||||
await copyDefaultsToData(dir);
|
||||
await fsp.writeFile(
|
||||
path.join(dir, '.rowboat-install.json'),
|
||||
JSON.stringify(AppInstallRecordSchema.parse(record), null, 2),
|
||||
);
|
||||
const summary = await getApp(folder);
|
||||
if (!summary) throw new InstallError('install_failed', 'installed app failed to index');
|
||||
await syncAppAgents(summary); // §8.3 materialize disabled
|
||||
return summary;
|
||||
} catch (e) {
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePkgManifest(pkgDir: string, expected?: { name: string; version: string }): RowboatAppManifest {
|
||||
let manifest: RowboatAppManifest;
|
||||
try {
|
||||
manifest = RowboatAppManifestSchema.parse(
|
||||
JSON.parse(fs.readFileSync(path.join(pkgDir, 'rowboat-app.json'), 'utf-8')),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new InstallError('bundle_mismatch', `bundle manifest missing/invalid: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
if (expected && (manifest.name !== expected.name || manifest.version !== expected.version)) {
|
||||
throw new InstallError('bundle_mismatch',
|
||||
`bundle is ${manifest.name}@${manifest.version}, expected ${expected.name}@${expected.version}`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** D18 (§12.1 step 6): the bundle must not exceed what the user confirmed. */
|
||||
function checkCapabilitySubset(bundle: RowboatAppManifest, confirmed: { capabilities: string[]; agents: string[] }): void {
|
||||
const extraCaps = bundle.capabilities.filter((c) => !confirmed.capabilities.includes(c));
|
||||
const extraAgents = bundle.agents.filter((a) => !confirmed.agents.includes(a));
|
||||
if (extraCaps.length || extraAgents.length) {
|
||||
throw new InstallError('capability_mismatch',
|
||||
`bundle declares more than previewed (capabilities: [${extraCaps.join(', ')}], agents: [${extraAgents.join(', ')}])`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog install (§12.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function previewInstall(record: RegistryRecord): Promise<InstallPreview> {
|
||||
const manifest = await registryClient.latestManifest(record);
|
||||
return {
|
||||
status: 'preview',
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
capabilities: manifest.capabilities,
|
||||
agents: manifest.agents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installFromRegistry(record: RegistryRecord, confirmed: InstallPreview): Promise<InstallDone> {
|
||||
const staging = path.join(TMP_ROOT, `app-install-${crypto.randomBytes(4).toString('hex')}`);
|
||||
try {
|
||||
const bundleUrl = `https://github.com/${record.repo}/releases/latest/download/${record.name}.rowboat-app`;
|
||||
const { zipPath, sha256 } = await downloadBundle(bundleUrl, staging);
|
||||
const pkgDir = path.join(staging, 'pkg');
|
||||
const files = await extractBundle(zipPath, pkgDir);
|
||||
const manifest = validatePkgManifest(pkgDir, { name: confirmed.name, version: confirmed.version });
|
||||
checkCapabilitySubset(manifest, confirmed);
|
||||
|
||||
const app = await assembleInstall(pkgDir, manifest, {
|
||||
name: manifest.name,
|
||||
repo: record.repo,
|
||||
version: manifest.version,
|
||||
sha256,
|
||||
installedAt: new Date().toISOString(),
|
||||
files,
|
||||
});
|
||||
return { status: 'installed', app };
|
||||
} finally {
|
||||
await fsp.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL install (§12.5) — two-phase with retained staging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UrlStaging = {
|
||||
staging: string;
|
||||
sha256: string;
|
||||
manifest: RowboatAppManifest;
|
||||
files: Record<string, string>;
|
||||
createdAt: number;
|
||||
};
|
||||
const urlStagings = new Map<string, UrlStaging>();
|
||||
|
||||
function githubProvenance(url: string): string | undefined {
|
||||
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/download\//.exec(url);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
export async function previewUrlInstall(url: string): Promise<InstallPreview> {
|
||||
if (!url.startsWith('https:')) throw new InstallError('invalid_url', 'only https URLs are allowed');
|
||||
|
||||
// Reuse fresh staging for the same URL.
|
||||
const existing = urlStagings.get(url);
|
||||
if (existing && Date.now() - existing.createdAt < URL_STAGING_TTL_MS) {
|
||||
return {
|
||||
status: 'preview',
|
||||
name: existing.manifest.name,
|
||||
version: existing.manifest.version,
|
||||
description: existing.manifest.description,
|
||||
capabilities: existing.manifest.capabilities,
|
||||
agents: existing.manifest.agents,
|
||||
updateSource: githubProvenance(url) ? 'github' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const staging = path.join(TMP_ROOT, `app-install-${crypto.randomBytes(4).toString('hex')}`);
|
||||
const { zipPath, sha256 } = await downloadBundle(url, staging);
|
||||
const pkgDir = path.join(staging, 'pkg');
|
||||
const files = await extractBundle(zipPath, pkgDir);
|
||||
const manifest = validatePkgManifest(pkgDir); // identity comes from the bundle itself
|
||||
urlStagings.set(url, { staging, sha256, manifest, files, createdAt: Date.now() });
|
||||
|
||||
return {
|
||||
status: 'preview',
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
capabilities: manifest.capabilities,
|
||||
agents: manifest.agents,
|
||||
updateSource: githubProvenance(url) ? 'github' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmUrlInstall(url: string): Promise<InstallDone> {
|
||||
let staged = urlStagings.get(url);
|
||||
if (!staged || Date.now() - staged.createdAt >= URL_STAGING_TTL_MS) {
|
||||
await previewUrlInstall(url); // re-download if evicted
|
||||
staged = urlStagings.get(url);
|
||||
if (!staged) throw new InstallError('staging_missing', 'could not stage the bundle');
|
||||
}
|
||||
urlStagings.delete(url);
|
||||
try {
|
||||
const repo = githubProvenance(url);
|
||||
const app = await assembleInstall(path.join(staged.staging, 'pkg'), staged.manifest, {
|
||||
name: staged.manifest.name,
|
||||
...(repo ? { repo } : { sourceUrl: url }),
|
||||
version: staged.manifest.version,
|
||||
sha256: staged.sha256,
|
||||
installedAt: new Date().toISOString(),
|
||||
files: staged.files,
|
||||
});
|
||||
return { status: 'installed', app };
|
||||
} finally {
|
||||
await fsp.rm(staged.staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update / rollback / uninstall (§12.3–12.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readInstallRecord(folder: string): Promise<AppInstallRecord> {
|
||||
try {
|
||||
return AppInstallRecordSchema.parse(
|
||||
JSON.parse(await fsp.readFile(path.join(APPS_DIR, folder, '.rowboat-install.json'), 'utf-8')),
|
||||
);
|
||||
} catch {
|
||||
throw new InstallError('not_installed', `${folder} has no install record`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdate(folder: string): Promise<{ current: string; latest: string; updateAvailable: boolean }> {
|
||||
const install = await readInstallRecord(folder);
|
||||
if (!install.repo) throw new InstallError('no_update_source', 'installed from a non-GitHub URL; updates unavailable');
|
||||
const record: RegistryRecord = {
|
||||
schemaVersion: 1, name: install.name, owner: '', repo: install.repo,
|
||||
description: '', createdAt: install.installedAt,
|
||||
};
|
||||
const latest = await registryClient.latestManifest(record);
|
||||
const cmp = compareSemver(latest.version, install.version);
|
||||
return { current: install.version, latest: latest.version, updateAvailable: cmp > 0 };
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function updateApp(
|
||||
folder: string,
|
||||
opts: { confirmOverwriteModified?: boolean; confirmNewCapabilities?: boolean } = {},
|
||||
): Promise<AppSummary> {
|
||||
const install = await readInstallRecord(folder);
|
||||
if (!install.repo) throw new InstallError('no_update_source', 'updates unavailable for URL installs without GitHub provenance');
|
||||
const dir = path.join(APPS_DIR, folder);
|
||||
|
||||
const currentManifest = validatePkgManifest(dir); // current on-disk manifest
|
||||
|
||||
const staging = path.join(TMP_ROOT, `app-update-${crypto.randomBytes(4).toString('hex')}`);
|
||||
try {
|
||||
const bundleUrl = `https://github.com/${install.repo}/releases/latest/download/${install.name}.rowboat-app`;
|
||||
const { zipPath, sha256 } = await downloadBundle(bundleUrl, staging);
|
||||
const pkgDir = path.join(staging, 'pkg');
|
||||
const files = await extractBundle(zipPath, pkgDir);
|
||||
const nextManifest = validatePkgManifest(pkgDir);
|
||||
if (nextManifest.name !== install.name) {
|
||||
throw new InstallError('bundle_mismatch', `bundle is ${nextManifest.name}, installed app is ${install.name}`);
|
||||
}
|
||||
|
||||
// D18 scoped to the diff: an update must not silently widen access.
|
||||
const newCaps = nextManifest.capabilities.filter((c) => !currentManifest.capabilities.includes(c));
|
||||
const newAgents = nextManifest.agents.filter((a) => !currentManifest.agents.includes(a));
|
||||
if ((newCaps.length || newAgents.length) && !opts.confirmNewCapabilities) {
|
||||
throw new InstallError('new_capabilities',
|
||||
`update adds capabilities [${newCaps.join(', ')}] agents [${newAgents.join(', ')}]; confirm to proceed`);
|
||||
}
|
||||
|
||||
// Step 8: warn when locally-modified release-managed files would be lost.
|
||||
const modified: string[] = [];
|
||||
for (const [rel, hash] of Object.entries(install.files)) {
|
||||
try {
|
||||
const current = crypto.createHash('sha256')
|
||||
.update(await fsp.readFile(path.join(dir, rel)))
|
||||
.digest('hex');
|
||||
if (current !== hash) modified.push(rel);
|
||||
} catch {
|
||||
modified.push(`${rel} (deleted)`);
|
||||
}
|
||||
}
|
||||
if (modified.length && !opts.confirmOverwriteModified) {
|
||||
throw new InstallError('modified_files', modified.join(', '));
|
||||
}
|
||||
|
||||
// Steps 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<AppSummary> {
|
||||
const install = await readInstallRecord(folder);
|
||||
const dir = path.join(APPS_DIR, folder);
|
||||
const previousDir = path.join(dir, '.previous');
|
||||
if (!fs.existsSync(previousDir)) throw new InstallError('no_rollback', 'no previous version retained');
|
||||
|
||||
for (const item of RELEASE_MANAGED) {
|
||||
await fsp.rm(path.join(dir, item), { recursive: true, force: true });
|
||||
const backup = path.join(previousDir, item);
|
||||
if (fs.existsSync(backup)) await fsp.rename(backup, path.join(dir, item));
|
||||
}
|
||||
await fsp.rm(previousDir, { recursive: true, force: true });
|
||||
|
||||
const restoredManifest = validatePkgManifest(dir);
|
||||
const nextRecord: AppInstallRecord = {
|
||||
...install,
|
||||
version: restoredManifest.version,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
delete nextRecord.previousVersion;
|
||||
await fsp.writeFile(path.join(dir, '.rowboat-install.json'), JSON.stringify(nextRecord, null, 2));
|
||||
|
||||
const summary = await getApp(folder);
|
||||
if (!summary) throw new InstallError('rollback_failed', 'rolled-back app failed to index');
|
||||
await syncAppAgents(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function uninstallApp(folder: string): Promise<void> {
|
||||
if (!FOLDER_SLUG_RE.test(folder)) throw new InstallError('invalid_folder', folder);
|
||||
await readInstallRecord(folder); // must be an installed app
|
||||
await deleteAppAgents(folder); // §8.5 (confirmation happens in the renderer)
|
||||
await fsp.rm(path.join(APPS_DIR, folder), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Startup hygiene: clear leftover install stagings (§12.1). */
|
||||
export async function cleanInstallTmp(): Promise<void> {
|
||||
try {
|
||||
const entries = await fsp.readdir(TMP_ROOT);
|
||||
await Promise.all(entries
|
||||
.filter((e) => e.startsWith('app-install-') || e.startsWith('app-update-'))
|
||||
.map((e) => fsp.rm(path.join(TMP_ROOT, e), { recursive: true, force: true })));
|
||||
} catch { /* no tmp dir yet */ }
|
||||
}
|
||||
120
apps/x/packages/core/src/apps/packager.ts
Normal file
120
apps/x/packages/core/src/apps/packager.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import yazl from 'yazl';
|
||||
import { RowboatAppManifestSchema, type RowboatAppManifest } from '@x/shared/dist/rowboat-app.js';
|
||||
import { appDir } from './indexer.js';
|
||||
|
||||
// Packager (spec §4.4): builds the `<name>.rowboat-app` ZIP from the ALLOWLIST
|
||||
// only — rowboat-app.json, dist/**, agents/<manifest.agents>, defaults/** — in
|
||||
// sorted path order (determinism). Personal data cannot leak by omission (D15):
|
||||
// src/, package.json, node_modules/, data/, dotfiles, and .rowboat-*.json are
|
||||
// simply never on the list. Symlinks are skipped with a warning, never followed.
|
||||
|
||||
export class PackageError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect files under root; returns package-relative POSIX paths. */
|
||||
async function collectFiles(absRoot: string, relPrefix: string, warnings: string[]): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(absRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(absRoot, entry.name);
|
||||
const rel = `${relPrefix}/${entry.name}`;
|
||||
if (entry.isSymbolicLink()) {
|
||||
warnings.push(`skipped symlink: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await collectFiles(abs, rel, warnings));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PackageResult {
|
||||
/** Absolute path of the written bundle. */
|
||||
bundlePath: string;
|
||||
/** Lowercase-hex SHA-256 of the finished ZIP bytes. */
|
||||
sha256: string;
|
||||
manifest: RowboatAppManifest;
|
||||
/** Package-relative paths included, in the order written. */
|
||||
files: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build `<name>.rowboat-app` for the app at `folder`, writing the bundle to
|
||||
* `outDir` (created if needed).
|
||||
*/
|
||||
export async function packageApp(folder: string, outDir: string): Promise<PackageResult> {
|
||||
const dir = appDir(folder);
|
||||
|
||||
// 1. Manifest must parse and dist/<entry> must exist.
|
||||
let manifest: RowboatAppManifest;
|
||||
try {
|
||||
manifest = RowboatAppManifestSchema.parse(
|
||||
JSON.parse(await fsp.readFile(path.join(dir, 'rowboat-app.json'), 'utf-8')),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new PackageError('invalid_manifest', `rowboat-app.json is missing or invalid: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
const entryAbs = path.join(dir, 'dist', manifest.entry);
|
||||
if (!fs.existsSync(entryAbs) || !fs.statSync(entryAbs).isFile()) {
|
||||
throw new PackageError('missing_entry', `dist/${manifest.entry} does not exist`);
|
||||
}
|
||||
|
||||
// 2. Assemble the allowlist, sorted for determinism.
|
||||
const warnings: string[] = [];
|
||||
const files: string[] = ['rowboat-app.json'];
|
||||
files.push(...(await collectFiles(path.join(dir, 'dist'), 'dist', warnings)).sort());
|
||||
// agents/: ONLY files listed in manifest.agents (and they must exist).
|
||||
for (const agentFile of [...manifest.agents].sort()) {
|
||||
const abs = path.join(dir, 'agents', agentFile);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
|
||||
throw new PackageError('missing_agent', `agents/${agentFile} is listed in the manifest but missing`);
|
||||
}
|
||||
files.push(`agents/${agentFile}`);
|
||||
}
|
||||
files.push(...(await collectFiles(path.join(dir, 'defaults'), 'defaults', warnings)).sort());
|
||||
|
||||
// 3. Write the ZIP.
|
||||
await fsp.mkdir(outDir, { recursive: true });
|
||||
const bundlePath = path.join(outDir, `${manifest.name}.rowboat-app`);
|
||||
const zip = new yazl.ZipFile();
|
||||
for (const rel of files) {
|
||||
zip.addFile(path.join(dir, ...rel.split('/')), rel);
|
||||
}
|
||||
zip.end();
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const out = fs.createWriteStream(bundlePath);
|
||||
zip.outputStream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||
zip.outputStream.pipe(out);
|
||||
out.on('close', () => resolve());
|
||||
out.on('error', reject);
|
||||
zip.outputStream.on('error', reject);
|
||||
});
|
||||
|
||||
return {
|
||||
bundlePath,
|
||||
sha256: hash.digest('hex'),
|
||||
manifest,
|
||||
files,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
452
apps/x/packages/core/src/apps/publisher.ts
Normal file
452
apps/x/packages/core/src/apps/publisher.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
RowboatAppManifestSchema,
|
||||
AppPublishRecordSchema,
|
||||
PACKAGE_NAME_RE,
|
||||
type RowboatAppManifest,
|
||||
type AppPublishRecord,
|
||||
type RegistryRecord,
|
||||
} from '@x/shared/dist/rowboat-app.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { REGISTRY_REPO } from './constants.js';
|
||||
import { appDir } from './indexer.js';
|
||||
import { packageApp } from './packager.js';
|
||||
import { registryClient } from './registry.js';
|
||||
import { getGithubToken, clearAuth } from './github-auth.js';
|
||||
|
||||
// Publisher (spec §11): guided first publish as a resumable state machine —
|
||||
// each step is persisted to .rowboat-publish.json so a failed publish resumes
|
||||
// instead of duplicating side effects.
|
||||
|
||||
export class PublishError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type PublishStep =
|
||||
| 'packaged' | 'repo_created' | 'source_pushed' | 'release_created'
|
||||
| 'assets_uploaded' | 'registered' | 'published';
|
||||
|
||||
export type PublishProgress = (step: PublishStep | 'polling', detail?: string) => void;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub REST helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function gh<T = unknown>(token: string, method: string, url: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(url.startsWith('http') ? url : `https://api.github.com${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/vnd.github+json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
await clearAuth();
|
||||
throw new PublishError('github_auth_expired', 'GitHub session expired; sign in again');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new PublishError('github_api_error', `${method} ${url}: HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.status === 204 ? (undefined as T) : (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish record persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function publishRecordPath(folder: string): string {
|
||||
return path.join(appDir(folder), '.rowboat-publish.json');
|
||||
}
|
||||
|
||||
async function readPublishRecord(folder: string): Promise<AppPublishRecord | null> {
|
||||
try {
|
||||
return AppPublishRecordSchema.parse(JSON.parse(await fsp.readFile(publishRecordPath(folder), 'utf-8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writePublishRecord(folder: string, record: AppPublishRecord): Promise<void> {
|
||||
await fsp.writeFile(publishRecordPath(folder), JSON.stringify(record, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preflight (§11.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function preflight(folder: string, firstPublish: boolean): Promise<{ manifest: RowboatAppManifest; token: string; login: string }> {
|
||||
let manifest: RowboatAppManifest;
|
||||
try {
|
||||
manifest = RowboatAppManifestSchema.parse(
|
||||
JSON.parse(await fsp.readFile(path.join(appDir(folder), 'rowboat-app.json'), 'utf-8')),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new PublishError('invalid_manifest', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
if (!PACKAGE_NAME_RE.test(manifest.name)) throw new PublishError('invalid_name', `"${manifest.name}" is not a valid package name`);
|
||||
const entryAbs = path.join(appDir(folder), 'dist', manifest.entry);
|
||||
if (!fs.existsSync(entryAbs)) throw new PublishError('missing_entry', `dist/${manifest.entry} does not exist`);
|
||||
|
||||
const auth = await getGithubToken();
|
||||
if (!auth) throw new PublishError('not_signed_in', 'sign in to GitHub to publish');
|
||||
|
||||
if (firstPublish) {
|
||||
const existing = await registryClient.resolve(manifest.name).catch(() => null);
|
||||
if (existing) throw new PublishError('name_taken', `"${manifest.name}" is already registered`);
|
||||
}
|
||||
return { manifest, token: auth.token, login: auth.login };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source push (§11.2 step 3) — one commit via the Git Data API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PUSH_EXCLUDES = new Set(['data', 'node_modules', '.previous']);
|
||||
|
||||
async function collectSourceFiles(dir: string, rel = ''): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const entry of await fsp.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
||||
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.name.startsWith('.')) continue; // dotfiles incl. .rowboat-*.json
|
||||
if (PUSH_EXCLUDES.has(entry.name) && !rel) continue;
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isDirectory()) out.push(...await collectSourceFiles(dir, relPath));
|
||||
else if (entry.isFile()) out.push(relPath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function generatedReadme(manifest: RowboatAppManifest): string {
|
||||
return `# ${manifest.name}
|
||||
|
||||
${manifest.description || 'A Rowboat app.'}
|
||||
|
||||
## Install in Rowboat
|
||||
|
||||
Open Rowboat → Apps → Catalog → search for **${manifest.name}** → Install.
|
||||
${manifest.agents.length ? `\nBundled background agents: ${manifest.agents.map((a) => `\`${a}\``).join(', ')} (installed disabled; enable them in Rowboat).\n` : ''}`;
|
||||
}
|
||||
|
||||
function generatedLicense(holder: string): string {
|
||||
const year = new Date().getFullYear();
|
||||
return `MIT License
|
||||
|
||||
Copyright (c) ${year} ${holder}
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
`;
|
||||
}
|
||||
|
||||
async function pushSource(token: string, login: string, repoName: string, folder: string, manifest: RowboatAppManifest, version: string): Promise<void> {
|
||||
const dir = appDir(folder);
|
||||
const files = await collectSourceFiles(dir);
|
||||
|
||||
// The Git Data API (blobs/trees/commits) answers 409 "Git Repository is
|
||||
// empty" on a repo with no commits — it cannot bootstrap an empty repo.
|
||||
// Ensure main exists first via the Contents API, then chain onto it.
|
||||
let parents: string[] = [];
|
||||
try {
|
||||
const ref = await gh<{ object: { sha: string } }>(token, 'GET', `/repos/${login}/${repoName}/git/ref/heads/main`);
|
||||
parents = [ref.object.sha];
|
||||
} catch {
|
||||
// Use the PUT response's own commit sha — re-reading the ref right
|
||||
// after the first commit can 409 on a stale replica.
|
||||
const put = await gh<{ commit: { sha: string } }>(token, 'PUT', `/repos/${login}/${repoName}/contents/README.md`, {
|
||||
message: 'bootstrap',
|
||||
content: Buffer.from(generatedReadme(manifest)).toString('base64'),
|
||||
branch: 'main',
|
||||
});
|
||||
parents = [put.commit.sha];
|
||||
}
|
||||
|
||||
type TreeEntry = { path: string; mode: '100644'; type: 'blob'; sha: string };
|
||||
const tree: TreeEntry[] = [];
|
||||
for (const rel of files) {
|
||||
const content = await fsp.readFile(path.join(dir, rel));
|
||||
const blob = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/blobs`, {
|
||||
content: content.toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
tree.push({ path: rel, mode: '100644', type: 'blob', sha: blob.sha });
|
||||
}
|
||||
// Generated companions when the author has none (§11.2 step 3).
|
||||
const addGenerated = async (relPath: string, content: string) => {
|
||||
if (files.some((f) => f.toLowerCase() === relPath.toLowerCase())) return;
|
||||
const blob = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/blobs`, {
|
||||
content: Buffer.from(content).toString('base64'), encoding: 'base64',
|
||||
});
|
||||
tree.push({ path: relPath, mode: '100644', type: 'blob', sha: blob.sha });
|
||||
};
|
||||
await addGenerated('README.md', generatedReadme(manifest));
|
||||
await addGenerated('LICENSE', generatedLicense(login));
|
||||
await addGenerated('.gitignore', 'data/\nnode_modules/\n.rowboat-install.json\n.rowboat-publish.json\n.previous/\n');
|
||||
|
||||
const treeRes = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/trees`, { tree });
|
||||
|
||||
const commit = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/commits`, {
|
||||
message: `Publish ${manifest.name} v${version}`,
|
||||
tree: treeRes.sha,
|
||||
parents,
|
||||
});
|
||||
|
||||
await gh(token, 'PATCH', `/repos/${login}/${repoName}/git/refs/heads/main`, { sha: commit.sha, force: false });
|
||||
}
|
||||
|
||||
async function uploadAsset(token: string, uploadUrlBase: string, name: string, data: Buffer, contentType: string): Promise<void> {
|
||||
const res = await fetch(`${uploadUrlBase}?name=${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(data.length),
|
||||
},
|
||||
body: new Uint8Array(data),
|
||||
});
|
||||
if (!res.ok && res.status !== 422) { // 422 = asset already exists (resume)
|
||||
throw new PublishError('asset_upload_failed', `upload ${name}: HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guided first publish (§11.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function publishApp(
|
||||
folder: string,
|
||||
onProgress: PublishProgress = () => undefined,
|
||||
): Promise<{ status: 'published' | 'pending'; repoUrl: string; releaseUrl: string; prUrl?: string }> {
|
||||
const { manifest, token, login } = await preflight(folder, true);
|
||||
const name = manifest.name;
|
||||
const repoUrl = `https://github.com/${login}/${name}`;
|
||||
const releaseUrl = `${repoUrl}/releases/tag/v${manifest.version}`;
|
||||
|
||||
const prior = await readPublishRecord(folder);
|
||||
const completed = new Set(prior?.pendingSteps?.version === manifest.version ? prior.pendingSteps.completed : []);
|
||||
let releaseId = prior?.pendingSteps?.releaseId;
|
||||
let prUrl = prior?.pendingSteps?.prUrl;
|
||||
|
||||
const record: AppPublishRecord = {
|
||||
name, login, repo: `${login}/${name}`,
|
||||
pendingSteps: { version: manifest.version, completed: [...completed], ...(releaseId ? { releaseId } : {}), ...(prUrl ? { prUrl } : {}) },
|
||||
};
|
||||
const mark = async (step: PublishStep) => {
|
||||
completed.add(step);
|
||||
record.pendingSteps = { version: manifest.version, completed: [...completed], ...(releaseId ? { releaseId } : {}), ...(prUrl ? { prUrl } : {}) };
|
||||
await writePublishRecord(folder, record);
|
||||
onProgress(step);
|
||||
};
|
||||
// Resume: re-report steps completed by a prior attempt so the UI shows
|
||||
// them as done instead of leaving stale spinners.
|
||||
for (const step of completed) onProgress(step as PublishStep);
|
||||
|
||||
// 1. packaged
|
||||
const outDir = path.join(WorkDir, 'tmp', `app-publish-${name}`);
|
||||
const pkg = await packageApp(folder, outDir);
|
||||
await mark('packaged');
|
||||
|
||||
// 2. repo_created (resume-aware)
|
||||
if (!completed.has('repo_created')) {
|
||||
try {
|
||||
await gh(token, 'POST', '/user/repos', { name, description: manifest.description, visibility: 'public', auto_init: false });
|
||||
} catch (e) {
|
||||
const exists = await gh(token, 'GET', `/repos/${login}/${name}`).then(() => true).catch(() => false);
|
||||
if (!exists) throw e;
|
||||
}
|
||||
await mark('repo_created');
|
||||
}
|
||||
|
||||
// 3. source_pushed
|
||||
if (!completed.has('source_pushed')) {
|
||||
await pushSource(token, login, name, folder, manifest, manifest.version);
|
||||
await mark('source_pushed');
|
||||
}
|
||||
|
||||
// 4. release_created
|
||||
if (!completed.has('release_created') || !releaseId) {
|
||||
try {
|
||||
const release = await gh<{ id: number }>(token, 'POST', `/repos/${login}/${name}/releases`, {
|
||||
tag_name: `v${manifest.version}`, name: `v${manifest.version}`, body: `sha256: ${pkg.sha256}`,
|
||||
});
|
||||
releaseId = release.id;
|
||||
} catch {
|
||||
const existing = await gh<{ id: number }>(token, 'GET', `/repos/${login}/${name}/releases/tags/v${manifest.version}`);
|
||||
releaseId = existing.id;
|
||||
}
|
||||
await mark('release_created');
|
||||
}
|
||||
|
||||
// 5. assets_uploaded (both REQUIRED — the standalone manifest powers update checks)
|
||||
if (!completed.has('assets_uploaded')) {
|
||||
const uploadBase = `https://uploads.github.com/repos/${login}/${name}/releases/${releaseId}/assets`;
|
||||
await uploadAsset(token, uploadBase, `${name}.rowboat-app`, await fsp.readFile(pkg.bundlePath), 'application/zip');
|
||||
await uploadAsset(token, uploadBase, 'rowboat-app.json',
|
||||
Buffer.from(await fsp.readFile(path.join(appDir(folder), 'rowboat-app.json'))), 'application/json');
|
||||
await mark('assets_uploaded');
|
||||
}
|
||||
|
||||
// 7. registered — fork + branch + record + PR
|
||||
if (!completed.has('registered')) {
|
||||
const [registryOwner, registryName] = REGISTRY_REPO.split('/');
|
||||
await gh(token, 'POST', `/repos/${REGISTRY_REPO}/forks`).catch(() => undefined);
|
||||
// Forks are async — poll up to 60s.
|
||||
const forkRepo = `${login}/${registryName}`;
|
||||
let forked = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
forked = await gh(token, 'GET', `/repos/${forkRepo}`).then(() => true).catch(() => false);
|
||||
if (forked) break;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
if (!forked) throw new PublishError('fork_timeout', `fork of ${REGISTRY_REPO} did not appear`);
|
||||
|
||||
const upstreamMain = await gh<{ object: { sha: string } }>(token, 'GET', `/repos/${REGISTRY_REPO}/git/ref/heads/main`);
|
||||
const branch = `publish-${name}`;
|
||||
await gh(token, 'POST', `/repos/${forkRepo}/git/refs`, { ref: `refs/heads/${branch}`, sha: upstreamMain.object.sha })
|
||||
.catch(() => undefined); // resume: branch may exist
|
||||
|
||||
const registryRecord: RegistryRecord = {
|
||||
schemaVersion: 1, name, owner: login, repo: `${login}/${name}`,
|
||||
description: manifest.description,
|
||||
...(manifest.icon ? { iconUrl: `https://raw.githubusercontent.com/${login}/${name}/HEAD/dist/${manifest.icon}` } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await gh(token, 'PUT', `/repos/${forkRepo}/contents/apps/${name}.json`, {
|
||||
message: `publish: ${name}`,
|
||||
content: Buffer.from(JSON.stringify(registryRecord, null, 2) + '\n').toString('base64'),
|
||||
branch,
|
||||
});
|
||||
|
||||
const pr = await gh<{ html_url: string }>(token, 'POST', `/repos/${REGISTRY_REPO}/pulls`, {
|
||||
title: `publish: ${name}`, head: `${login}:${branch}`, base: 'main',
|
||||
}).catch(async () => {
|
||||
// resume: PR may already exist
|
||||
const open = await gh<Array<{ html_url: string }>>(token, 'GET',
|
||||
`/repos/${REGISTRY_REPO}/pulls?head=${login}:${branch}&state=all`);
|
||||
if (!open.length) throw new PublishError('pr_failed', 'could not open the registry PR');
|
||||
return open[0];
|
||||
});
|
||||
prUrl = pr.html_url;
|
||||
void registryOwner;
|
||||
await mark('registered');
|
||||
}
|
||||
|
||||
// 8. published — poll the PR (10s cadence, 5-min timeout)
|
||||
onProgress('polling', prUrl);
|
||||
const prNumber = prUrl ? Number(prUrl.split('/').pop()) : NaN;
|
||||
for (let i = 0; i < 30 && prUrl && Number.isFinite(prNumber); i++) {
|
||||
const pr = await gh<{ merged: boolean; state: string }>(token, 'GET', `/repos/${REGISTRY_REPO}/pulls/${prNumber}`);
|
||||
if (pr.merged) {
|
||||
await writePublishRecord(folder, {
|
||||
name, login, repo: `${login}/${name}`,
|
||||
lastPublishedVersion: manifest.version, lastSha256: pkg.sha256,
|
||||
});
|
||||
onProgress('published');
|
||||
return { status: 'published', repoUrl, releaseUrl, prUrl };
|
||||
}
|
||||
if (pr.state === 'closed') {
|
||||
const comments = await gh<Array<{ body: string }>>(token, 'GET', `/repos/${REGISTRY_REPO}/issues/${prNumber}/comments`);
|
||||
const rejection = comments.map((c) => /^rejected: (\S+)/m.exec(c.body)?.[1]).find(Boolean);
|
||||
throw new PublishError(rejection ?? 'registry_rejected', `registry PR was closed (${rejection ?? 'see PR'})`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 10_000));
|
||||
}
|
||||
return { status: 'pending', repoUrl, releaseUrl, prUrl };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish update (§11.3) — no registry interaction (D5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function publishUpdate(
|
||||
folder: string,
|
||||
increment: 'patch' | 'minor' | 'major',
|
||||
): Promise<{ version: string; releaseUrl: string }> {
|
||||
const record = await readPublishRecord(folder);
|
||||
if (!record) throw new PublishError('not_published', 'this app has not been published yet');
|
||||
const { manifest, token, login } = await preflight(folder, false);
|
||||
if (record.login !== login) throw new PublishError('wrong_account', `published by ${record.login}; signed in as ${login}`);
|
||||
|
||||
const [maj, min, pat] = manifest.version.split('.').map(Number);
|
||||
const version = increment === 'major' ? `${maj + 1}.0.0` : increment === 'minor' ? `${maj}.${min + 1}.0` : `${maj}.${min}.${pat + 1}`;
|
||||
|
||||
// Bump the manifest (pretty-printed, §4.2).
|
||||
const manifestPath = path.join(appDir(folder), 'rowboat-app.json');
|
||||
const raw = JSON.parse(await fsp.readFile(manifestPath, 'utf-8')) as Record<string, unknown>;
|
||||
raw.version = version;
|
||||
await fsp.writeFile(manifestPath, JSON.stringify(raw, null, 2) + '\n');
|
||||
|
||||
const repoName = record.repo.split('/')[1];
|
||||
const pkg = await packageApp(folder, path.join(WorkDir, 'tmp', `app-publish-${manifest.name}`));
|
||||
await pushSource(token, login, repoName, folder, { ...manifest, version } as RowboatAppManifest, version);
|
||||
const release = await gh<{ id: number }>(token, 'POST', `/repos/${record.repo}/releases`, {
|
||||
tag_name: `v${version}`, name: `v${version}`, body: `sha256: ${pkg.sha256}`,
|
||||
});
|
||||
const uploadBase = `https://uploads.github.com/repos/${record.repo}/releases/${release.id}/assets`;
|
||||
await uploadAsset(token, uploadBase, `${manifest.name}.rowboat-app`, await fsp.readFile(pkg.bundlePath), 'application/zip');
|
||||
await uploadAsset(token, uploadBase, 'rowboat-app.json', Buffer.from(await fsp.readFile(manifestPath)), 'application/json');
|
||||
|
||||
await writePublishRecord(folder, { ...record, lastPublishedVersion: version, lastSha256: pkg.sha256, pendingSteps: undefined });
|
||||
return { version, releaseUrl: `https://github.com/${record.repo}/releases/tag/v${version}` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Advanced path (§11.5): register an existing GitHub release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function registerExisting(name: string, repo: string): Promise<{ status: 'published' | 'pending'; prUrl: string }> {
|
||||
const auth = await getGithubToken();
|
||||
if (!auth) throw new PublishError('not_signed_in', 'sign in to GitHub first');
|
||||
if (!PACKAGE_NAME_RE.test(name)) throw new PublishError('invalid_name', name);
|
||||
|
||||
// Courtesy client-side probe of §9.3 check 7.
|
||||
const probe = await fetch(`https://github.com/${repo}/releases/latest/download/${name}.rowboat-app`, { method: 'HEAD', redirect: 'follow' });
|
||||
if (!probe.ok) throw new PublishError('missing_release_asset', `releases/latest has no ${name}.rowboat-app`);
|
||||
|
||||
const [, registryName] = REGISTRY_REPO.split('/');
|
||||
await gh(auth.token, 'POST', `/repos/${REGISTRY_REPO}/forks`).catch(() => undefined);
|
||||
const forkRepo = `${auth.login}/${registryName}`;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (await gh(auth.token, 'GET', `/repos/${forkRepo}`).then(() => true).catch(() => false)) break;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
const upstreamMain = await gh<{ object: { sha: string } }>(auth.token, 'GET', `/repos/${REGISTRY_REPO}/git/ref/heads/main`);
|
||||
const branch = `publish-${name}`;
|
||||
await gh(auth.token, 'POST', `/repos/${forkRepo}/git/refs`, { ref: `refs/heads/${branch}`, sha: upstreamMain.object.sha }).catch(() => undefined);
|
||||
const record: RegistryRecord = {
|
||||
schemaVersion: 1, name, owner: auth.login, repo, description: '', createdAt: new Date().toISOString(),
|
||||
};
|
||||
await gh(auth.token, 'PUT', `/repos/${forkRepo}/contents/apps/${name}.json`, {
|
||||
message: `publish: ${name}`,
|
||||
content: Buffer.from(JSON.stringify(record, null, 2) + '\n').toString('base64'),
|
||||
branch,
|
||||
});
|
||||
const pr = await gh<{ html_url: string }>(auth.token, 'POST', `/repos/${REGISTRY_REPO}/pulls`, {
|
||||
title: `publish: ${name}`, head: `${auth.login}:${branch}`, base: 'main',
|
||||
});
|
||||
return { status: 'pending', prUrl: pr.html_url };
|
||||
}
|
||||
146
apps/x/packages/core/src/apps/registry.ts
Normal file
146
apps/x/packages/core/src/apps/registry.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import {
|
||||
RegistryRecordSchema,
|
||||
RowboatAppManifestSchema,
|
||||
type RegistryRecord,
|
||||
type RowboatAppManifest,
|
||||
} from '@x/shared/dist/rowboat-app.js';
|
||||
import { REGISTRY_REPO, REGISTRY_BRANCH, CATALOG_CACHE_PATH, CATALOG_TTL_MS } from './constants.js';
|
||||
|
||||
// Registry client (spec §9.2). All registry access goes through RegistryClient —
|
||||
// this is the backend swap seam (D5). The GitHub implementation reads the
|
||||
// registry repo as one unauthenticated tarball and resolves versions via
|
||||
// release-asset URLs (plain HTTPS redirects, no API quota).
|
||||
|
||||
export interface RegisterResult {
|
||||
status: 'published' | 'pending' | 'rejected';
|
||||
prUrl?: string;
|
||||
rejectionCode?: string;
|
||||
}
|
||||
|
||||
export interface RegistryClient {
|
||||
refreshIndex(force?: boolean): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }>;
|
||||
resolve(name: string): Promise<RegistryRecord | null>;
|
||||
search(query: string): Promise<RegistryRecord[]>;
|
||||
latestManifest(record: RegistryRecord): Promise<RowboatAppManifest>;
|
||||
}
|
||||
|
||||
type CatalogCache = { fetchedAt: string; records: RegistryRecord[] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal tar reader — enough to pull apps/*.json out of a codeload tarball.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function* tarEntries(tarBuf: Buffer): Generator<{ name: string; body: Buffer }> {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tarBuf.length) {
|
||||
const header = tarBuf.subarray(offset, offset + 512);
|
||||
if (header.every((b) => b === 0)) break; // end-of-archive
|
||||
const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
|
||||
const sizeOctal = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
||||
const size = parseInt(sizeOctal, 8) || 0;
|
||||
const body = tarBuf.subarray(offset + 512, offset + 512 + size);
|
||||
yield { name, body: Buffer.from(body) };
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCache(): Promise<CatalogCache | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CATALOG_CACHE_PATH, 'utf-8')) as CatalogCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class GitHubRegistryClient implements RegistryClient {
|
||||
async refreshIndex(force = false): Promise<{ records: RegistryRecord[]; stale: boolean; fetchedAt: string }> {
|
||||
const cache = await readCache();
|
||||
if (!force && cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
|
||||
return { records: cache.records, stale: false, fetchedAt: cache.fetchedAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://codeload.github.com/${REGISTRY_REPO}/tar.gz/${REGISTRY_BRANCH}`);
|
||||
if (!res.ok) throw new Error(`registry tarball: HTTP ${res.status}`);
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
const tar = zlib.gunzipSync(gz);
|
||||
|
||||
const records: RegistryRecord[] = [];
|
||||
for (const entry of tarEntries(tar)) {
|
||||
// Entries look like "<repo>-<branch>/apps/<name>.json".
|
||||
const m = /^[^/]+\/apps\/([^/]+)\.json$/.exec(entry.name);
|
||||
if (!m) continue;
|
||||
try {
|
||||
const parsed = RegistryRecordSchema.safeParse(JSON.parse(entry.body.toString('utf-8')));
|
||||
if (parsed.success) {
|
||||
records.push(parsed.data);
|
||||
} else {
|
||||
console.warn(`[Apps] registry record ${m[1]} failed schema; skipping`);
|
||||
}
|
||||
} catch {
|
||||
console.warn(`[Apps] registry record ${m[1]} is invalid JSON; skipping`);
|
||||
}
|
||||
}
|
||||
records.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const fetchedAt = new Date().toISOString();
|
||||
await fs.mkdir(path.dirname(CATALOG_CACHE_PATH), { recursive: true });
|
||||
await fs.writeFile(CATALOG_CACHE_PATH, JSON.stringify({ fetchedAt, records } satisfies CatalogCache, null, 2));
|
||||
return { records, stale: false, fetchedAt };
|
||||
} catch (err) {
|
||||
// Network failure with a cache present → serve cache, marked stale.
|
||||
if (cache) return { records: cache.records, stale: true, fetchedAt: cache.fetchedAt };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(name: string): Promise<RegistryRecord | null> {
|
||||
const cache = await readCache();
|
||||
if (cache && Date.now() - new Date(cache.fetchedAt).getTime() < CATALOG_TTL_MS) {
|
||||
return cache.records.find((r) => r.name === name) ?? null;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/${REGISTRY_REPO}/${REGISTRY_BRANCH}/apps/${name}.json`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`registry record: HTTP ${res.status}`);
|
||||
const parsed = RegistryRecordSchema.safeParse(await res.json());
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
// Fall back to any cache we have, however old.
|
||||
return cache?.records.find((r) => r.name === name) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string): Promise<RegistryRecord[]> {
|
||||
const { records } = await this.refreshIndex();
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return records;
|
||||
return records.filter((r) =>
|
||||
r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest version's manifest via the release-asset URL — a plain HTTPS
|
||||
* redirect, NOT the REST API, so it costs no unauthenticated API quota.
|
||||
*/
|
||||
async latestManifest(record: RegistryRecord): Promise<RowboatAppManifest> {
|
||||
const res = await fetch(`https://github.com/${record.repo}/releases/latest/download/rowboat-app.json`, {
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`latest_manifest_unavailable: HTTP ${res.status}`);
|
||||
const manifest = RowboatAppManifestSchema.parse(await res.json());
|
||||
if (manifest.name !== record.name) {
|
||||
throw new Error(`name_mismatch: release manifest says "${manifest.name}" but the registry record is "${record.name}"`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
|
||||
export const registryClient: RegistryClient = new GitHubRegistryClient();
|
||||
|
|
@ -741,22 +741,29 @@ export async function init(): Promise<void> {
|
|||
startLagMonitor();
|
||||
await startWatcher();
|
||||
const expressApp = createApp();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const s = expressApp.listen(APPS_PORT, '127.0.0.1', () => {
|
||||
server = s;
|
||||
const listenOn = (host: string) => new Promise<Server>((resolve, reject) => {
|
||||
const s = expressApp.listen(APPS_PORT, host, () => resolve(s));
|
||||
s.on('error', (error: NodeJS.ErrnoException) => reject(error));
|
||||
});
|
||||
// EADDRINUSE almost always means a previous Rowboat instance is
|
||||
// still shutting down and holding the port (quick relaunch). Retry
|
||||
// on the SAME port for a while — never scan for alternate ports
|
||||
// (§6.1), origins embed the port — instead of disabling apps for
|
||||
// the whole session on the first failure.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
server = await listenOn('127.0.0.1');
|
||||
serverError = null;
|
||||
console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`);
|
||||
resolve();
|
||||
});
|
||||
s.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
// Never scan for alternate ports (§6.1) — origins embed the port.
|
||||
reject(new Error(`Port ${APPS_PORT} is already in use.`));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EADDRINUSE') throw error;
|
||||
if (attempt >= 15) throw new Error(`Port ${APPS_PORT} is already in use.`);
|
||||
if (attempt === 1) console.warn(`[Apps] port ${APPS_PORT} in use — retrying (old instance still shutting down?)`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
// Dual-stack loopback: also listen on ::1 (see server6 note above).
|
||||
// Best-effort — some machines have IPv6 disabled.
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
137
apps/x/pnpm-lock.yaml
generated
137
apps/x/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ allowBuilds:
|
|||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
blockExoticSubdeps: false
|
||||
|
||||
overrides:
|
||||
vscode-jsonrpc: 8.2.0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue