收敛客户端静态发布链路

This commit is contained in:
2026-08-10 17:04:29 +08:00
parent 2e737dee31
commit 4df047708b
37 changed files with 604 additions and 3071 deletions

View File

@@ -2,13 +2,11 @@ import type { IncomingMessage, ServerResponse } from 'http';
import { randomUUID } from 'node:crypto';
import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, isAbsolute, join, normalize, resolve } from 'node:path';
import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check';
import { readWorksPublishFile } from '../../opencode/works-publish-file';
import {
createStaticProjectPackage,
ProjectPackageError,
@@ -21,14 +19,6 @@ type CreateProjectInput = {
project?: unknown;
};
type UploadProjectVersionInput = {
accessToken?: unknown;
projectId?: unknown;
versionName?: unknown;
changeLog?: unknown;
zipFilePath?: unknown;
};
type PublishProjectSourceInput = {
projectId?: unknown;
project?: unknown;
@@ -62,10 +52,6 @@ function readOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function resolveProjectFilePath(projectPath: string, value: string): string {
return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) ? value : resolve(projectPath, value);
}
function readRequiredHeader(req: IncomingMessage, name: string): string {
const value = req.headers[name.toLowerCase()];
const firstValue = Array.isArray(value) ? value[0] : value;
@@ -209,6 +195,7 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
'difficulty',
'status',
'updated_at',
'play_url',
'runtime_url',
'creator_name',
'buddy_name',
@@ -629,76 +616,6 @@ async function handleGetMyProjectStatus(
sendJson(res, response.status, { success: true, status: statusPayload });
}
async function createArchiveFormData(
body: UploadProjectVersionInput,
ctx: HostApiContext,
appId: string,
): Promise<FormData> {
const versionName = readRequiredString(body.versionName, 'versionName');
const changeLog = readRequiredString(body.changeLog, 'changeLog');
const zipFilePath = readRequiredString(body.zipFilePath, 'zipFilePath');
const projectId = readRequiredString(body.projectId, 'projectId');
const project = (await ctx.opencodeProjectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const publish = await readWorksPublishFile(project.path);
if (publish.status !== 'ready') throw new Error('BLOCKED: works-publish.json is missing or invalid');
if (publish.publish.app_id !== appId) throw new Error('BLOCKED: upload app id does not match project publish data');
const resolvedZipPath = resolveProjectFilePath(project.path, zipFilePath);
const publishZipPath = resolveProjectFilePath(project.path, publish.publish.zip_file_path);
if (normalize(publishZipPath).toLowerCase() !== normalize(resolvedZipPath).toLowerCase()) {
throw new Error('BLOCKED: upload zip path does not match project publish data');
}
const deployCheck = await readWorksDeployCheck(project.path, publish.publish);
if (deployCheck.status !== 'pass' && deployCheck.status !== 'warning') {
throw new Error(`BLOCKED: ${deployCheck.error || 'deployment checks did not pass'}`);
}
if (extname(resolvedZipPath).toLowerCase() !== '.zip') {
throw new Error('zipFilePath must point to a .zip file');
}
const archiveStat = await stat(resolvedZipPath);
if (!archiveStat.isFile()) {
throw new Error('zipFilePath must point to a file');
}
const archiveBytes = await readFile(resolvedZipPath);
const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' });
const form = new FormData();
form.set('version_name', versionName);
form.set('change_log', changeLog);
form.set('archive', archiveBlob, basename(resolvedZipPath));
return form;
}
async function handleUploadProjectVersion(
req: IncomingMessage,
res: ServerResponse,
appId: string,
ctx: HostApiContext,
): Promise<void> {
const body = await parseJsonBody<UploadProjectVersionInput>(req);
const accessToken = readRequiredString(body.accessToken, 'accessToken');
const form = await createArchiveFormData(body, ctx, appId);
const response = await proxyAwareFetch(
createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions/upload`).toString(),
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: form,
},
);
if (!response.ok) {
await sendUpstreamError(res, response, `Works Square upload failed (${response.status})`);
return;
}
sendJson(res, response.status, { success: true, upload: await readResponsePayload(response) });
}
const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交';
const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]);
const VERSION_FILE_MAX_BYTES = 64 * 1024;
@@ -924,9 +841,9 @@ async function handlePublishProjectSource(
);
return;
}
if (ctx.worksCloudDeployment) {
if (ctx.worksSubmissionBinding) {
try {
await ctx.worksCloudDeployment.recordSubmitted(projectId, {
await ctx.worksSubmissionBinding.recordSubmitted(projectId, {
appId,
versionId: uploadPayload.version_id,
versionName,
@@ -1115,12 +1032,6 @@ export async function handleWorksRoutes(
return true;
}
const uploadMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions\/upload$/);
if (uploadMatch && req.method === 'POST') {
await handleUploadProjectVersion(req, res, decodeURIComponent(uploadMatch[1]), ctx);
return true;
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
return true;
} catch (error) {