mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/disk-skills
This commit is contained in:
commit
3fffe12978
22 changed files with 2642 additions and 47 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue