mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(apps): M3 publisher — guided publish, updates, register-existing
- publisher (§11): resumable state machine persisted in .rowboat-publish.json (packaged → repo_created → source_pushed → release_created → assets_uploaded → registered → published); Git Data API single-commit source push with generated README/LICENSE/.gitignore when absent; both release assets uploaded (bundle + standalone manifest); fork+PR registry registration with rejected:<code> parsing and rename-retry surface - publish update (§11.3): semver bump, package, push, release — no registry - register existing release (§11.5) with client-side asset probe - IPC: apps:publish/publishUpdate/registerExisting + apps:progress pushes
This commit is contained in:
parent
6dea528fb4
commit
35c781b8e4
3 changed files with 480 additions and 0 deletions
|
|
@ -69,6 +69,7 @@ import { capture } from '@x/core/dist/analytics/posthog.js';
|
|||
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
|
||||
import * as appsInstaller from '@x/core/dist/apps/installer.js';
|
||||
import { registryClient } from '@x/core/dist/apps/registry.js';
|
||||
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
|
||||
|
||||
// D18 install previews awaiting confirmation, keyed by app name.
|
||||
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
|
||||
|
|
@ -1673,6 +1674,22 @@ export function setupIpcHandlers() {
|
|||
'apps:rollback': async (_event, args) => {
|
||||
return { app: await appsInstaller.rollbackApp(args.folder) };
|
||||
},
|
||||
'apps:publish': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await appsPublisher.publishApp(args.folder, (step, detail) => {
|
||||
win?.webContents.send('apps:progress', { folder: args.folder, step, detail });
|
||||
});
|
||||
capture('app_published', { firstPublish: true });
|
||||
return result;
|
||||
},
|
||||
'apps:publishUpdate': async (_event, args) => {
|
||||
const result = await appsPublisher.publishUpdate(args.folder, args.increment);
|
||||
capture('app_published', { version: result.version, firstPublish: false });
|
||||
return result;
|
||||
},
|
||||
'apps:registerExisting': async (_event, args) => {
|
||||
return appsPublisher.registerExisting(args.name, args.repo);
|
||||
},
|
||||
'githubAuth:start': async () => {
|
||||
const result = await githubAuth.startDeviceFlow();
|
||||
// Surface the code and open GitHub's verification page externally (§10).
|
||||
|
|
|
|||
441
apps/x/packages/core/src/apps/publisher.ts
Normal file
441
apps/x/packages/core/src/apps/publisher.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS = ['packaged', 'repo_created', 'source_pushed', 'release_created', 'assets_uploaded', 'registered', 'published'] as const;
|
||||
export type PublishStep = typeof STEPS[number];
|
||||
|
||||
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);
|
||||
|
||||
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 });
|
||||
|
||||
// Parent-less commit for an empty repo; otherwise chain onto main.
|
||||
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 { /* empty repo */ }
|
||||
|
||||
const commit = await gh<{ sha: string }>(token, 'POST', `/repos/${login}/${repoName}/git/commits`, {
|
||||
message: `Publish ${manifest.name} v${version}`,
|
||||
tree: treeRes.sha,
|
||||
parents,
|
||||
});
|
||||
|
||||
if (parents.length) {
|
||||
await gh(token, 'PATCH', `/repos/${login}/${repoName}/git/refs/heads/main`, { sha: commit.sha, force: false });
|
||||
} else {
|
||||
await gh(token, 'POST', `/repos/${login}/${repoName}/git/refs`, { ref: 'refs/heads/main', sha: commit.sha });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
|
@ -1319,6 +1319,28 @@ const ipcSchemas = {
|
|||
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({}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue