449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
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()],
|
||
};
|
||
}
|