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; type ProjectWatcher = Pick; type WatchDirectory = (projectPath: string, onChange: (filename: string | null) => void) => ProjectWatcher; export type WorksCloudDeploymentOptions = { apiBaseUrl?: string; debounceMs?: number; fetchImpl?: (input: string | URL, init?: RequestInit) => Promise; getAccessToken?: () => Promise; watchDirectory?: WatchDirectory; }; type DeploymentState = { project: OpencodeProject; watcher: ProjectWatcher | null; timer: ReturnType | 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 { 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; 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 { try { const parsed = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Partial; 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(); 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 { 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 { 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 { 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 { 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 : {}; 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 { 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 { 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 { 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()], }; }