Merge remote-tracking branch 'upstream/dev' into feat/disk-skills

This commit is contained in:
Prakhar Pandey 2026-07-06 22:37:30 +05:30
commit 3fffe12978
22 changed files with 2642 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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