Files
makelore/electron/services/works-cloud-deployment.ts
2026-07-29 17:22:35 +08:00

449 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { watch, type FSWatcher } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import type { OpencodeProject, OpencodeProjectStore } from '../opencode/project-store';
import { readWorksDeployCheck } from '../opencode/works-square-deploy-check';
import { readWorksPublishFile, type WorksPublishData } from '../opencode/works-publish-file';
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
import { proxyAwareFetch } from '../utils/proxy-fetch';
import { logger } from '../utils/logger';
import {
WORKS_CLOUD_DEPLOYMENT_FILE_NAME,
WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
type WorksCloudDeploymentRecord,
type WorksCloudDeploymentStatus,
} from '../../shared/works-cloud-deployment';
import { getValidWorksSquareAccessToken, subscribeWorksSquareSession } from './works-square-session';
const DEFAULT_DEBOUNCE_MS = 700;
type ProjectStore = Pick<OpencodeProjectStore, 'listProjects' | 'subscribe'>;
type ProjectWatcher = Pick<FSWatcher, 'close'>;
type WatchDirectory = (projectPath: string, onChange: (filename: string | null) => void) => ProjectWatcher;
export type WorksCloudDeploymentOptions = {
apiBaseUrl?: string;
debounceMs?: number;
fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
getAccessToken?: () => Promise<string | null>;
watchDirectory?: WatchDirectory;
};
type DeploymentState = {
project: OpencodeProject;
watcher: ProjectWatcher | null;
timer: ReturnType<typeof setTimeout> | null;
running: boolean;
record: WorksCloudDeploymentRecord | null;
};
type DeploymentPatch = {
status: WorksCloudDeploymentStatus;
app_id?: string;
version_name?: string;
zip_sha256?: string;
version_id?: string;
review_status?: string;
error?: string | null;
};
function normalizeApiBaseUrl(value: string): string {
const baseUrl = value.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(baseUrl)) {
throw new Error('Works Square API base URL must start with http:// or https://');
}
return baseUrl;
}
function defaultWatchDirectory(projectPath: string, onChange: (filename: string | null) => void): ProjectWatcher {
return watch(projectPath, { persistent: false }, (_event, filename) => {
onChange(typeof filename === 'string' ? filename : filename ? filename.toString() : null);
});
}
function normalizeFilename(filename: string | null): string | null {
return filename ? filename.replaceAll('\\', '/') : null;
}
function isRelevantFile(filename: string | null): boolean {
const normalized = normalizeFilename(filename);
if (!normalized) return true;
return normalized === 'works-publish.json'
|| normalized === 'works-deploy-check.json'
|| normalized === '部署报告.md'
|| extname(normalized).toLowerCase() === '.zip';
}
function resolveProjectFilePath(projectPath: string, value: string): string {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/') ? value : resolve(projectPath, value);
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
async function readPayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function responseMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>;
for (const key of ['message', 'msg', 'detail', 'error']) {
const value = readString(record[key]);
if (value) return value;
}
}
return typeof payload === 'string' && payload.trim() ? payload.trim() : fallback;
}
function isPendingStatus(status: WorksCloudDeploymentStatus): boolean {
return status === 'armed'
|| status === 'waiting_for_package'
|| status === 'waiting_for_login'
|| status === 'uploading';
}
async function readDeploymentRecord(projectPath: string): Promise<WorksCloudDeploymentRecord | null> {
try {
const parsed = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Partial<WorksCloudDeploymentRecord>;
if (parsed.schema_version !== WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION
|| typeof parsed.project_id !== 'string'
|| typeof parsed.status !== 'string'
|| typeof parsed.requested_at !== 'string'
|| typeof parsed.updated_at !== 'string') {
return null;
}
return parsed as WorksCloudDeploymentRecord;
} catch {
return null;
}
}
export function createWorksCloudDeployment(
projectStore: ProjectStore,
options: WorksCloudDeploymentOptions = {},
) {
const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl);
const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
const fetchImpl = options.fetchImpl ?? proxyAwareFetch;
const getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken;
const watchDirectory = options.watchDirectory ?? defaultWatchDirectory;
const states = new Map<string, DeploymentState>();
let started = false;
let unsubscribeProjectStore: (() => void) | null = null;
let unsubscribeSession: (() => void) | null = null;
function clearTimer(state: DeploymentState): void {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
function closeState(state: DeploymentState): void {
clearTimer(state);
state.watcher?.close();
state.watcher = null;
}
function disposeProject(projectId: string): void {
const state = states.get(projectId);
if (!state) return;
closeState(state);
states.delete(projectId);
}
function attachWatcher(state: DeploymentState): void {
if (state.watcher) return;
try {
state.watcher = watchDirectory(state.project.path, (filename) => {
if (isRelevantFile(filename)) schedule(state, false);
});
} catch (error) {
logger.warn(`[works-cloud-deployment] Failed to watch ${state.project.path}`, error);
}
}
function schedule(state: DeploymentState, immediate: boolean): void {
if (!started || !isPendingStatus(state.record?.status ?? 'armed')) return;
clearTimer(state);
state.timer = setTimeout(() => {
state.timer = null;
void attempt(state);
}, immediate ? 0 : debounceMs);
}
async function persist(state: DeploymentState, record: WorksCloudDeploymentRecord): Promise<WorksCloudDeploymentRecord> {
state.record = record;
await writeFile(
join(state.project.path, WORKS_CLOUD_DEPLOYMENT_FILE_NAME),
`${JSON.stringify(record, null, 2)}\n`,
'utf8',
);
return record;
}
async function updateStatus(state: DeploymentState, patch: DeploymentPatch): Promise<WorksCloudDeploymentRecord> {
const now = new Date().toISOString();
const previous = state.record;
const next: WorksCloudDeploymentRecord = {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: state.project.id,
status: patch.status,
requested_at: previous?.requested_at ?? now,
updated_at: now,
...(previous?.app_id ? { app_id: previous.app_id } : {}),
...(previous?.version_name ? { version_name: previous.version_name } : {}),
...(previous?.zip_sha256 ? { zip_sha256: previous.zip_sha256 } : {}),
...(previous?.version_id ? { version_id: previous.version_id } : {}),
...(previous?.review_status ? { review_status: previous.review_status } : {}),
...(previous?.error ? { error: previous.error } : {}),
...(patch.app_id ? { app_id: patch.app_id } : {}),
...(patch.version_name ? { version_name: patch.version_name } : {}),
...(patch.zip_sha256 ? { zip_sha256: patch.zip_sha256 } : {}),
...(patch.version_id ? { version_id: patch.version_id } : {}),
...(patch.review_status ? { review_status: patch.review_status } : {}),
};
if (patch.error === null) delete next.error;
if (patch.error) next.error = patch.error;
return await persist(state, next);
}
function getOrCreateState(project: OpencodeProject, record: WorksCloudDeploymentRecord | null = null): DeploymentState {
const existing = states.get(project.id);
if (existing) {
existing.project = project;
if (record) existing.record = record;
attachWatcher(existing);
return existing;
}
const state: DeploymentState = {
project,
watcher: null,
timer: null,
running: false,
record,
};
states.set(project.id, state);
attachWatcher(state);
return state;
}
async function arm(projectId: string): Promise<WorksCloudDeploymentRecord> {
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
if (!project) throw new Error('Project not found');
const state = getOrCreateState(project);
const now = new Date().toISOString();
const record: WorksCloudDeploymentRecord = {
schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION,
project_id: project.id,
status: 'armed',
requested_at: now,
updated_at: now,
};
await persist(state, record);
schedule(state, true);
return record;
}
async function ensureRemoteProject(accessToken: string, publish: WorksPublishData): Promise<void> {
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
const response = await fetchImpl(`${apiBaseUrl}/api/projects`, {
method: 'POST',
headers,
body: JSON.stringify({
app_id: publish.app_id,
title: publish.title,
summary: publish.summary,
cover_url: null,
category: publish.category,
age_band: publish.age_band.trim() || null,
difficulty: publish.difficulty,
}),
});
if (response.ok) return;
const payload = await readPayload(response);
if (response.status !== 409) {
throw new Error(`Works Square project create failed (${response.status}): ${responseMessage(payload, 'unknown error')}`);
}
const ownership = await fetchImpl(`${apiBaseUrl}/api/projects/mine/${encodeURIComponent(publish.app_id)}/status`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!ownership.ok) {
const ownershipPayload = await readPayload(ownership);
throw new Error(`Works Square project ownership check failed (${ownership.status}): ${responseMessage(ownershipPayload, 'unknown error')}`);
}
}
async function uploadArchive(
accessToken: string,
projectPath: string,
publish: WorksPublishData,
): Promise<{ versionId: string; reviewStatus: string }> {
const zipPath = resolveProjectFilePath(projectPath, publish.zip_file_path);
const archive = await readFile(zipPath);
const form = new FormData();
form.set('version_name', publish.version_name || 'v1.0.0');
form.set('change_log', publish.change_log || '提交部署程序包');
form.set('archive', new Blob([new Uint8Array(archive)], { type: 'application/zip' }), basename(zipPath));
const response = await fetchImpl(`${apiBaseUrl}/api/projects/${encodeURIComponent(publish.app_id)}/versions/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: form,
});
const payload = await readPayload(response);
if (!response.ok) {
throw new Error(`Works Square cloud upload failed (${response.status}): ${responseMessage(payload, 'unknown error')}`);
}
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
const versionId = readString(record.version_id);
if (!versionId) throw new Error('Works Square cloud upload returned no version_id');
return {
versionId,
reviewStatus: readString(record.review_status) ?? 'building',
};
}
async function attempt(state: DeploymentState): Promise<void> {
if (!started || state.running || !isPendingStatus(state.record?.status ?? 'armed')) return;
state.running = true;
try {
const publish = await readWorksPublishFile(state.project.path);
if (publish.status !== 'ready') {
await updateStatus(state, { status: 'waiting_for_package', error: null });
return;
}
const deploymentCheck = await readWorksDeployCheck(state.project.path, publish.publish, { mode: 'cloud' });
if (deploymentCheck.status === 'missing') {
await updateStatus(state, { status: 'waiting_for_package', app_id: publish.publish.app_id, version_name: publish.publish.version_name, error: null });
return;
}
if (deploymentCheck.status !== 'pass' || !deploymentCheck.zip_sha256) {
await updateStatus(state, {
status: 'failed',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
error: deploymentCheck.error || 'ZIP 静态安全检查未通过,未提交云端',
});
closeState(state);
return;
}
const accessToken = await getAccessToken();
if (!accessToken) {
await updateStatus(state, {
status: 'waiting_for_login',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
error: '请先登录 Works Square云端部署会在登录态恢复后继续',
});
return;
}
await updateStatus(state, {
status: 'uploading',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
error: null,
});
await ensureRemoteProject(accessToken, publish.publish);
const uploaded = await uploadArchive(accessToken, state.project.path, publish.publish);
await updateStatus(state, {
status: 'submitted',
app_id: publish.publish.app_id,
version_name: publish.publish.version_name,
zip_sha256: deploymentCheck.zip_sha256,
version_id: uploaded.versionId,
review_status: uploaded.reviewStatus,
error: null,
});
closeState(state);
logger.info(`[works-cloud-deployment] Submitted ${state.project.id} as ${uploaded.versionId}`);
} catch (error) {
await updateStatus(state, {
status: 'failed',
error: error instanceof Error ? error.message : String(error),
});
closeState(state);
logger.warn(`[works-cloud-deployment] Failed for ${state.project.id}`, error);
} finally {
state.running = false;
}
}
async function get(projectId: string): Promise<WorksCloudDeploymentRecord | null> {
const state = states.get(projectId);
if (state?.record) return state.record;
const project = (await projectStore.listProjects()).find((item) => item.id === projectId);
return project ? await readDeploymentRecord(project.path) : null;
}
async function start(): Promise<void> {
if (started) return;
started = true;
unsubscribeProjectStore = projectStore.subscribe((change) => {
if (change.type === 'remove') {
disposeProject(change.projectId);
return;
}
const state = states.get(change.project.id);
if (state) {
state.project = change.project;
attachWatcher(state);
}
});
unsubscribeSession = subscribeWorksSquareSession((session) => {
if (!session) return;
for (const state of states.values()) {
if (state.record?.status === 'waiting_for_login') schedule(state, true);
}
});
for (const project of await projectStore.listProjects()) {
const record = await readDeploymentRecord(project.path);
if (!record || record.project_id !== project.id || !isPendingStatus(record.status)) continue;
const state = getOrCreateState(project, record);
if (record.status === 'uploading') {
await updateStatus(state, { status: 'armed', error: '应用重启后恢复云端部署任务' });
}
schedule(state, true);
}
}
function stop(): void {
if (!started) return;
started = false;
unsubscribeProjectStore?.();
unsubscribeSession?.();
unsubscribeProjectStore = null;
unsubscribeSession = null;
for (const state of states.values()) closeState(state);
states.clear();
}
return {
arm,
get,
start,
stop,
getWatchedProjectIds: (): string[] => [...states.keys()],
};
}