收口 Makelore 客户端变更
This commit is contained in:
@@ -70,6 +70,7 @@ type DesktopAuthTokenPayload = {
|
||||
};
|
||||
|
||||
const DESKTOP_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const MAX_AUTH_ERROR_LENGTH = 180;
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -164,17 +165,29 @@ async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
}
|
||||
|
||||
function getErrorMessage(payload: unknown, fallback: string): string {
|
||||
const compact = (value: string): string => {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (
|
||||
!normalized
|
||||
|| normalized.length > MAX_AUTH_ERROR_LENGTH
|
||||
|| /<!doctype\b|<html\b|<head\b|<body\b/i.test(normalized)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const field of ['msg', 'message', 'error_description', 'error']) {
|
||||
const value = record[field];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
return compact(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'string' && payload.trim()) {
|
||||
return payload;
|
||||
return compact(payload);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -238,7 +251,7 @@ async function pollDesktopAuthToken(
|
||||
const payload = await readResponsePayload(response) as DesktopAuthTokenPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Desktop authorization failed (${response.status})`));
|
||||
throw new Error(getErrorMessage(payload, '登录授权失败,请稍后重试。'));
|
||||
}
|
||||
|
||||
if (payload.status === 'approved' && payload.token) {
|
||||
@@ -248,7 +261,7 @@ async function pollDesktopAuthToken(
|
||||
await delay(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Authorization timed out');
|
||||
throw new Error('登录超时,请重新尝试。');
|
||||
}
|
||||
|
||||
async function handleBrowserAuthorization(
|
||||
@@ -264,7 +277,7 @@ async function handleBrowserAuthorization(
|
||||
if (!response.ok) {
|
||||
sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, {
|
||||
success: false,
|
||||
error: getErrorMessage(payload, `Desktop authorization start failed (${response.status})`),
|
||||
error: getErrorMessage(payload, '登录服务暂时不可用,请稍后重试。'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { hasRendererCapability } from '../renderer-capability';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
trustedWorksProjectPlayUrl,
|
||||
trustedWorksReleasePreviewUrl,
|
||||
} from '../works-play-url';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
|
||||
import type { DevicePreviewSnapshot } from '../../../shared/device-preview';
|
||||
|
||||
type RemoteProjectSnapshot = {
|
||||
appId: string | null;
|
||||
playable: boolean | null;
|
||||
runtimeUrl: string | null;
|
||||
runtimeVersionName: string | null;
|
||||
latestVersionId: string | null;
|
||||
latestVersionName: string | null;
|
||||
latestReleaseId: string | null;
|
||||
reviewStatus: string | null;
|
||||
};
|
||||
|
||||
class DevicePreviewRequestError extends Error {
|
||||
readonly statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = 'DevicePreviewRequestError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
const DEVICE_PREVIEW_ROUTE = /^\/api\/opencode\/projects\/([^/]+)\/device-preview$/;
|
||||
const OWNER_STATUS_TIMEOUT_MS = 10_000;
|
||||
const READY_REVIEW_STATUSES = new Set(['approved', 'published']);
|
||||
const FAILED_REVIEW_STATUSES = new Set(['blocked', 'failed', 'rejected']);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectFromPayload(payload: unknown): RemoteProjectSnapshot | null {
|
||||
const root = asRecord(payload);
|
||||
if (!root) return null;
|
||||
const wrappedStatus = asRecord(root.status);
|
||||
const project = asRecord(root.project)
|
||||
?? asRecord(wrappedStatus?.project)
|
||||
?? (readString(root.app_id) ? root : null);
|
||||
if (!project) return null;
|
||||
const latestVersion = asRecord(root.latest_version) ?? asRecord(wrappedStatus?.latest_version);
|
||||
return {
|
||||
appId: readString(project.app_id),
|
||||
playable: typeof project.playable === 'boolean' ? project.playable : null,
|
||||
runtimeUrl: readString(project.play_url) ?? readString(project.runtime_url),
|
||||
runtimeVersionName: readString(project.version_name),
|
||||
latestVersionId: readString(latestVersion?.id) ?? readString(latestVersion?.version_id),
|
||||
latestVersionName: readString(latestVersion?.version_name),
|
||||
latestReleaseId: readString(latestVersion?.release_id),
|
||||
reviewStatus: readString(latestVersion?.review_status),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedWorksBaseUrl(): URL {
|
||||
const base = WORKS_SQUARE_CONFIG.apiBaseUrl.trim().replace(/\/+$/, '');
|
||||
return new URL(`${base}/`);
|
||||
}
|
||||
|
||||
async function requireManagedAccessToken(): Promise<string> {
|
||||
const accessToken = await getValidWorksSquareAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new DevicePreviewRequestError('请先登录,再核对当前项目的真机预览版本', 401);
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function fetchOwnedRemoteProject(
|
||||
appId: string,
|
||||
accessToken: string,
|
||||
): Promise<RemoteProjectSnapshot | null> {
|
||||
const worksBase = normalizedWorksBaseUrl();
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), OWNER_STATUS_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await proxyAwareFetch(
|
||||
new URL(`/api/projects/mine/${encodeURIComponent(appId)}/status`, worksBase).toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new DevicePreviewRequestError('服务端预览状态读取超时,请稍后刷新', 502);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new DevicePreviewRequestError('登录状态已失效,请重新登录后刷新预览', response.status);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DevicePreviewRequestError(`服务端预览状态读取失败(${response.status})`, 502);
|
||||
}
|
||||
|
||||
const project = projectFromPayload(await readResponsePayload(response));
|
||||
if (!project) {
|
||||
throw new DevicePreviewRequestError('服务端返回了无法识别的预览状态', 502);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function createOwnedReleasePreview(
|
||||
appId: string,
|
||||
releaseId: string,
|
||||
accessToken: string,
|
||||
): Promise<string> {
|
||||
const worksBase = normalizedWorksBaseUrl();
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), OWNER_STATUS_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await proxyAwareFetch(
|
||||
new URL(
|
||||
`/api/projects/${encodeURIComponent(appId)}/releases/${encodeURIComponent(releaseId)}/preview-url`,
|
||||
worksBase,
|
||||
).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new DevicePreviewRequestError('服务端预览地址生成超时,请稍后刷新', 502);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new DevicePreviewRequestError('登录状态已失效,请重新登录后刷新预览', response.status);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new DevicePreviewRequestError(`服务端预览地址生成失败(${response.status})`, 502);
|
||||
}
|
||||
const payload = asRecord(await readResponsePayload(response));
|
||||
const previewUrl = readString(payload?.url);
|
||||
if (!previewUrl) {
|
||||
throw new DevicePreviewRequestError('服务端返回了无法识别的预览地址', 502);
|
||||
}
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
function localSnapshot(
|
||||
projectId: string,
|
||||
deployment: WorksSubmissionBindingRecord | null,
|
||||
): Omit<DevicePreviewSnapshot, 'state' | 'message'> {
|
||||
return {
|
||||
projectId,
|
||||
...(deployment?.app_id ? { appId: deployment.app_id } : {}),
|
||||
...(deployment?.version_id ? { versionId: deployment.version_id } : {}),
|
||||
...(deployment?.version_name ? { versionName: deployment.version_name } : {}),
|
||||
...(deployment?.review_status ? { reviewStatus: deployment.review_status } : {}),
|
||||
...(deployment?.updated_at ? { updatedAt: deployment.updated_at } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedReviewStatus(value: string | null): string | null {
|
||||
return value?.trim().toLowerCase() || null;
|
||||
}
|
||||
|
||||
async function resolveDevicePreview(
|
||||
projectId: string,
|
||||
deployment: WorksSubmissionBindingRecord | null,
|
||||
): Promise<DevicePreviewSnapshot> {
|
||||
if (!deployment) {
|
||||
return {
|
||||
projectId,
|
||||
state: 'not_deployed',
|
||||
message: '当前项目还没有与本机绑定的服务端预览版本。',
|
||||
};
|
||||
}
|
||||
|
||||
if (deployment.project_id !== projectId) {
|
||||
return {
|
||||
projectId,
|
||||
state: 'unavailable',
|
||||
message: '本地预览记录不属于当前项目,请重新生成。',
|
||||
};
|
||||
}
|
||||
|
||||
const base = localSnapshot(projectId, deployment);
|
||||
|
||||
if (deployment.status === 'legacy_retired') {
|
||||
return {
|
||||
...base,
|
||||
state: 'unavailable',
|
||||
message: deployment.message || '旧版自动部署任务已停用,请重新提交审核。',
|
||||
};
|
||||
}
|
||||
|
||||
if (!deployment.app_id || !deployment.version_id || !deployment.version_name) {
|
||||
return {
|
||||
...base,
|
||||
state: 'unavailable',
|
||||
message: '本地预览记录缺少版本标识,请重新生成。',
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = await requireManagedAccessToken();
|
||||
const remote = await fetchOwnedRemoteProject(deployment.app_id, accessToken);
|
||||
if (!remote) {
|
||||
return {
|
||||
...base,
|
||||
state: 'building',
|
||||
message: '服务端正在接收或构建本次预览版本。',
|
||||
};
|
||||
}
|
||||
|
||||
if (remote.appId !== deployment.app_id) {
|
||||
return {
|
||||
...base,
|
||||
state: 'unavailable',
|
||||
message: '服务端返回的项目标识不匹配,已停止展示预览。',
|
||||
};
|
||||
}
|
||||
|
||||
const reviewStatus = normalizedReviewStatus(remote.reviewStatus);
|
||||
const remoteBase = {
|
||||
...base,
|
||||
...(remote.reviewStatus ? { reviewStatus: remote.reviewStatus } : {}),
|
||||
};
|
||||
const targetVersionMatches = remote.latestVersionId === deployment.version_id
|
||||
&& remote.latestVersionName === deployment.version_name;
|
||||
const launchUrl = remote.runtimeUrl
|
||||
? trustedWorksProjectPlayUrl(
|
||||
remote.runtimeUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
deployment.app_id,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!targetVersionMatches) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'unavailable',
|
||||
message: '服务端最新版本已与本地绑定版本不一致,请重新同步项目状态。',
|
||||
};
|
||||
}
|
||||
|
||||
if (reviewStatus && FAILED_REVIEW_STATUSES.has(reviewStatus)) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'unavailable',
|
||||
message: '本次预览版本未通过服务端检查,请修改后重新生成。',
|
||||
};
|
||||
}
|
||||
|
||||
if (reviewStatus === 'building' || reviewStatus === 'queued' || reviewStatus === 'reviewing') {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'building',
|
||||
message: '本次预览版本仍在构建或审核,暂不展示旧版本。',
|
||||
};
|
||||
}
|
||||
|
||||
if (reviewStatus === 'pending_review') {
|
||||
if (!remote.latestReleaseId) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'building',
|
||||
message: '本次版本已构建完成,正在准备审核前预览。',
|
||||
};
|
||||
}
|
||||
const rawPreviewUrl = await createOwnedReleasePreview(
|
||||
deployment.app_id,
|
||||
remote.latestReleaseId,
|
||||
accessToken,
|
||||
);
|
||||
const previewUrl = trustedWorksReleasePreviewUrl(
|
||||
rawPreviewUrl,
|
||||
normalizedWorksBaseUrl(),
|
||||
remote.latestReleaseId,
|
||||
);
|
||||
if (!previewUrl) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'unavailable',
|
||||
message: '服务端返回的审核前预览地址未通过安全校验。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'ready',
|
||||
launchUrl: previewUrl,
|
||||
message: '审核前真机预览已就绪。',
|
||||
};
|
||||
}
|
||||
|
||||
if (remote.runtimeUrl && !launchUrl) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'unavailable',
|
||||
message: '服务端返回的预览地址未通过安全校验。',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
remote.playable === true
|
||||
&& launchUrl
|
||||
&& remote.runtimeVersionName === deployment.version_name
|
||||
&& reviewStatus !== null
|
||||
&& READY_REVIEW_STATUSES.has(reviewStatus)
|
||||
) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'ready',
|
||||
launchUrl,
|
||||
message: '真机预览已就绪。',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
reviewStatus !== null
|
||||
&& READY_REVIEW_STATUSES.has(reviewStatus)
|
||||
&& remote.runtimeVersionName !== deployment.version_name
|
||||
) {
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'building',
|
||||
message: '本次版本已通过审核,服务端运行地址仍在切换中。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...remoteBase,
|
||||
state: 'unavailable',
|
||||
message: '服务端尚未确认本次版本可供真机访问。',
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleDevicePreviewRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const match = url.pathname.match(DEVICE_PREVIEW_ROUTE);
|
||||
if (!match) return false;
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', 'GET');
|
||||
sendJson(res, 405, { success: false, error: 'Method not allowed' });
|
||||
return true;
|
||||
}
|
||||
if (!hasRendererCapability(req)) {
|
||||
sendJson(res, 403, { success: false, error: 'Renderer capability required' });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectId = decodeURIComponent(match[1] ?? '').trim();
|
||||
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
||||
if (!projectId || !activeProject || activeProject.id !== projectId) {
|
||||
sendJson(res, 409, { success: false, error: 'Select this project before opening device preview' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const deployment = ctx.worksSubmissionBinding
|
||||
? await ctx.worksSubmissionBinding.get(projectId)
|
||||
: null;
|
||||
const preview = await resolveDevicePreview(projectId, deployment);
|
||||
sendJson(res, 200, { success: true, preview });
|
||||
} catch (error) {
|
||||
sendJson(res, error instanceof DevicePreviewRequestError ? error.statusCode : 502, {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../../opencode/playwright-mcp';
|
||||
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config';
|
||||
import { listInstalledOpencodeSkills } from '../../opencode/skill-registry';
|
||||
import { BUNDLED_COURSE_SKILL_IDS } from '../../opencode/superpowers';
|
||||
import { BUNDLED_COURSE_SKILL_IDS } from '../../opencode/course-skills';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from '../../opencode/project-conversations';
|
||||
import {
|
||||
completeProjectSession,
|
||||
markProjectSessionRead,
|
||||
removeProjectSessionMetadata,
|
||||
patchProjectSessionMetadata,
|
||||
upsertProjectSessionMetadata,
|
||||
@@ -1017,16 +1018,8 @@ export async function handleOpencodeRoutes(
|
||||
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
|
||||
const project = await findProjectById(ctx, body.projectId);
|
||||
if (!project) throw new Error('Project not found');
|
||||
const previousResult = await readProjectConfig(project.path);
|
||||
const previousConfig = previousResult.status === 'valid' ? previousResult.config : null;
|
||||
const config = await writeProjectConfig(project.path, body.config);
|
||||
let status = ctx.opencodeManager.getStatus();
|
||||
const runtimeSettingsChanged = !previousConfig
|
||||
|| previousConfig.defaultModel !== config.defaultModel
|
||||
|| previousConfig.superpowersEnabled !== config.superpowersEnabled;
|
||||
if (config.initialized && runtimeSettingsChanged && status.state === 'running') {
|
||||
status = await ctx.opencodeManager.restart();
|
||||
}
|
||||
const status = ctx.opencodeManager.getStatus();
|
||||
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -1079,7 +1072,7 @@ export async function handleOpencodeRoutes(
|
||||
nextState = removeProjectSessionMetadata(state, sessionId, now);
|
||||
break;
|
||||
case 'read':
|
||||
nextState = patchProjectSessionMetadata(state, sessionId, { unreadCount: 0 }, now);
|
||||
nextState = markProjectSessionRead(state, sessionId, now);
|
||||
break;
|
||||
case 'increment-unread': {
|
||||
const current = state.sessions.find((item) => item.sessionId === sessionId);
|
||||
|
||||
@@ -17,7 +17,6 @@ import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleDevicePreviewRoutes } from './routes/device-preview';
|
||||
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||
import { rotateRendererCapability } from './renderer-capability';
|
||||
|
||||
@@ -34,7 +33,6 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleDevicePreviewRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleOpencodeRoutes,
|
||||
|
||||
@@ -16,17 +16,19 @@ import { resolveOpencodeRuntimePaths } from '../opencode/paths';
|
||||
import {
|
||||
resolveBundledAgentBrowserPluginPath,
|
||||
resolveBundledCourseSkillsDir,
|
||||
resolveBundledSuperpowersDir,
|
||||
} from '../opencode/superpowers';
|
||||
} from '../opencode/course-skills';
|
||||
import {
|
||||
createElectronProjectStorage,
|
||||
createProjectStore,
|
||||
type OpencodeProjectStore,
|
||||
} from '../opencode/project-store';
|
||||
import { readProjectConfig } from '../opencode/project-config';
|
||||
import { warmupOpencodeRuntime } from '../opencode/startup-warmup';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
import { registerZoomShortcuts } from './zoom-shortcuts';
|
||||
import { getNativeWindowMaterialOptions } from './window-material';
|
||||
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -90,6 +92,39 @@ const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop';
|
||||
const isE2EMode = process.env.NIANCODE_E2E === '1';
|
||||
const requestedUserDataDir = process.env.NIANCODE_USER_DATA_DIR?.trim();
|
||||
|
||||
async function buildMakeloreOpencodeRuntimeConfig() {
|
||||
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
mcpServers: {
|
||||
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleOpencodeRuntimeWarmup(): void {
|
||||
if (isE2EMode) return;
|
||||
|
||||
void warmupOpencodeRuntime({
|
||||
hasAuthenticatedSession: () => Boolean(getWorksSquareSessionSnapshot()),
|
||||
getStatus: () => opencodeManager.getStatus(),
|
||||
getActiveProject: () => opencodeProjectStore.getActiveProject(),
|
||||
readProjectConfig,
|
||||
getConfiguredProviderCount: async () => {
|
||||
const runtime = await buildMakeloreOpencodeRuntimeConfig();
|
||||
return Object.keys(runtime.config.provider).length;
|
||||
},
|
||||
start: () => opencodeManager.start(),
|
||||
onError: (error, phase) => {
|
||||
logger.warn(`[opencode-runtime] Startup warmup ${phase} failed`, error);
|
||||
},
|
||||
}).then((result) => {
|
||||
if (result.started) {
|
||||
logger.info('[opencode-runtime] Startup warmup completed');
|
||||
}
|
||||
}).catch((error) => {
|
||||
logger.warn('[opencode-runtime] Startup warmup could not be scheduled', error);
|
||||
});
|
||||
}
|
||||
|
||||
if (isE2EMode && requestedUserDataDir) {
|
||||
app.setPath('userData', requestedUserDataDir);
|
||||
}
|
||||
@@ -205,14 +240,17 @@ function createWindow(): BrowserWindow {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const useCustomTitleBar = isWindows;
|
||||
const shouldSkipSetupForE2E = process.env.NIANCODE_E2E_SKIP_SETUP === '1';
|
||||
const minimumWorkspaceColumnWidth = 256;
|
||||
const minimumWorkspaceWidth = minimumWorkspaceColumnWidth * 4;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: 'Makelore',
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1100,
|
||||
minWidth: minimumWorkspaceWidth,
|
||||
minHeight: 700,
|
||||
icon: getAppIcon(),
|
||||
...getNativeWindowMaterialOptions(process.platform),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -221,7 +259,10 @@ function createWindow(): BrowserWindow {
|
||||
webviewTag: false,
|
||||
},
|
||||
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
|
||||
// Keep the native traffic lights on the same centerline as the 40px
|
||||
// renderer title bar. The native glyphs sit about 7px below the
|
||||
// configured origin, so y=13 centers them on the renderer controls.
|
||||
trafficLightPosition: isMac ? { x: 16, y: 13 } : undefined,
|
||||
frame: isMac || !useCustomTitleBar,
|
||||
show: false,
|
||||
});
|
||||
@@ -534,6 +575,11 @@ async function initialize(): Promise<void> {
|
||||
browserOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
// Start the local Code runtime in the background once the Main process has
|
||||
// restored session state and registered the Host API. Chat still retains
|
||||
// its lazy-start fallback for first-run and failed-warmup cases.
|
||||
scheduleOpencodeRuntimeWarmup();
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
@@ -579,11 +625,6 @@ if (gotTheLock) {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
const bundledSuperpowersDir = resolveBundledSuperpowersDir({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
const bundledCourseSkillsDir = resolveBundledCourseSkillsDir({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
@@ -599,17 +640,12 @@ if (gotTheLock) {
|
||||
binPath: opencodePaths.binPath,
|
||||
preflightPreferredPort: true,
|
||||
userDataDir: app.getPath('userData'),
|
||||
bundledSuperpowersDir,
|
||||
bundledCourseSkillsDir,
|
||||
bundledAgentBrowserPluginPath,
|
||||
pythonRuntime,
|
||||
uvRuntime,
|
||||
runtimeConfigProvider: async () => {
|
||||
const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
mcpServers: {
|
||||
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
|
||||
},
|
||||
});
|
||||
const runtime = await buildMakeloreOpencodeRuntimeConfig();
|
||||
return {
|
||||
...runtime,
|
||||
config: runtime.config as unknown as Record<string, unknown>,
|
||||
|
||||
23
electron/main/window-material.ts
Normal file
23
electron/main/window-material.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { BrowserWindowConstructorOptions } from 'electron';
|
||||
|
||||
type NativeWindowMaterialOptions = Pick<
|
||||
BrowserWindowConstructorOptions,
|
||||
'backgroundColor' | 'transparent' | 'vibrancy' | 'visualEffectState'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Use the native macOS material only where Electron and the window manager
|
||||
* support it. Other platforms keep the normal opaque window as a safe fallback.
|
||||
*/
|
||||
export function getNativeWindowMaterialOptions(
|
||||
platform: NodeJS.Platform,
|
||||
): NativeWindowMaterialOptions {
|
||||
if (platform !== 'darwin') return {};
|
||||
|
||||
return {
|
||||
backgroundColor: '#00000000',
|
||||
transparent: true,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'active',
|
||||
};
|
||||
}
|
||||
@@ -90,6 +90,12 @@ export interface OpencodeSkillInfo {
|
||||
description?: string;
|
||||
location: string;
|
||||
content: string;
|
||||
entries?: OpencodeSkillEntry[];
|
||||
}
|
||||
|
||||
export interface OpencodeSkillEntry {
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
export interface RevertOpencodeSessionMessageInput {
|
||||
|
||||
112
electron/opencode/course-skills.ts
Normal file
112
electron/opencode/course-skills.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface BundledSkillsPathInput {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
}
|
||||
|
||||
export interface EnsureBundledCourseSkillsOptions {
|
||||
managedConfigDir: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'agent-browser',
|
||||
'deploy-publish-check',
|
||||
'designer-design-spec',
|
||||
'dev-build-test',
|
||||
'game-assets',
|
||||
'marketing-launch-story',
|
||||
'nianxxgame-skill',
|
||||
'partner-agent-showcase',
|
||||
'pm-project-plan',
|
||||
'product-demo-prototype',
|
||||
'ui-ux-course-quality',
|
||||
'youth-plain-language',
|
||||
'youth-ai-product-course',
|
||||
] as const;
|
||||
const RETIRED_COURSE_SKILL_IDS = ['course-stage-review', 'student-growth-logger'] as const;
|
||||
const LEGACY_SUPERPOWERS_ARTIFACTS = [
|
||||
'plugins/superpowers-niancode.js',
|
||||
'plugins/superpowers.js',
|
||||
'superpowers-active.json',
|
||||
'superpowers-bundles',
|
||||
] as const;
|
||||
|
||||
export function resolveBundledCourseSkillsDir(input: BundledSkillsPathInput): string {
|
||||
return input.isPackaged
|
||||
? join(input.resourcesPath, 'course-skills')
|
||||
: join(input.appPath, '.opencode', 'skills');
|
||||
}
|
||||
|
||||
export function resolveBundledAgentBrowserPluginPath(input: BundledSkillsPathInput): string {
|
||||
return join(
|
||||
resolveBundledCourseSkillsDir(input),
|
||||
'agent-browser',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'niancode-agent-browser.js',
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedOpencodeConfigDir(userDataDir: string): string {
|
||||
return join(userDataDir, 'opencode', 'niancode-config');
|
||||
}
|
||||
|
||||
function copyDirectorySync(sourceDir: string, targetDir: string): void {
|
||||
cpSync(sourceDir, targetDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
filter: (sourcePath, targetPath) => {
|
||||
// Node 24.14's Windows override path can corrupt non-ASCII destinations
|
||||
// when replacing a file. Public unlink keeps force-overwrite semantics.
|
||||
if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) {
|
||||
unlinkSync(targetPath);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function removeLegacySuperpowersArtifacts(managedConfigDir: string): void {
|
||||
for (const artifact of LEGACY_SUPERPOWERS_ARTIFACTS) {
|
||||
rmSync(join(managedConfigDir, artifact), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean {
|
||||
const sourceDir = options.sourceDir?.trim();
|
||||
if (!sourceDir || !existsSync(sourceDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetSkillsDir = join(options.managedConfigDir, 'skills');
|
||||
mkdirSync(targetSkillsDir, { recursive: true });
|
||||
for (const skillId of RETIRED_COURSE_SKILL_IDS) {
|
||||
rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true });
|
||||
}
|
||||
copyDirectorySync(sourceDir, targetSkillsDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureBundledAgentBrowserPlugin(options: {
|
||||
managedConfigDir: string;
|
||||
sourcePath?: string;
|
||||
}): boolean {
|
||||
const sourcePath = options.sourcePath?.trim();
|
||||
if (!sourcePath || !existsSync(sourcePath)) return false;
|
||||
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
|
||||
mkdirSync(targetPluginsDir, { recursive: true });
|
||||
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
|
||||
return true;
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
import {
|
||||
ensureBundledAgentBrowserPlugin,
|
||||
ensureBundledCourseSkills,
|
||||
ensureBundledSuperpowersPlugin,
|
||||
getManagedOpencodeConfigDir,
|
||||
} from './superpowers';
|
||||
removeLegacySuperpowersArtifacts,
|
||||
} from './course-skills';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
prependManagedRuntimesToPath,
|
||||
@@ -164,7 +164,6 @@ export interface OpencodeManagerOptions {
|
||||
port: number;
|
||||
binPath: string;
|
||||
userDataDir?: string;
|
||||
bundledSuperpowersDir?: string;
|
||||
bundledCourseSkillsDir?: string;
|
||||
bundledAgentBrowserPluginPath?: string;
|
||||
pythonRuntime?: PythonRuntime;
|
||||
@@ -1049,10 +1048,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
mkdirSync(dataHome, { recursive: true });
|
||||
mkdirSync(cacheHome, { recursive: true });
|
||||
mkdirSync(managedConfigDir, { recursive: true });
|
||||
ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledSuperpowersDir,
|
||||
});
|
||||
removeLegacySuperpowersArtifacts(managedConfigDir);
|
||||
ensureBundledCourseSkills({
|
||||
managedConfigDir,
|
||||
sourceDir: this.options.bundledCourseSkillsDir,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export interface RuntimePathInput {
|
||||
isPackaged: boolean;
|
||||
@@ -47,11 +48,31 @@ function getNativeOpencodeBinName(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'opencode.exe' : 'opencode';
|
||||
}
|
||||
|
||||
function resolveDevelopmentNativeBinPath(input: RuntimePathInput): string | undefined {
|
||||
function resolveNativePackageRoot(runtimeDir: string, packageName: string): string | undefined {
|
||||
try {
|
||||
const resolvedRuntimeDir = realpathSync(runtimeDir);
|
||||
const runtimeRequire = createRequire(join(resolvedRuntimeDir, 'package.json'));
|
||||
return dirname(runtimeRequire.resolve(`${packageName}/package.json`));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDevelopmentNativeBinPath(
|
||||
input: RuntimePathInput,
|
||||
runtimeDir: string,
|
||||
): string | undefined {
|
||||
const arch = input.arch ?? process.arch;
|
||||
for (const packageName of getDevelopmentNativePackageCandidates(input.platform, arch)) {
|
||||
const binPath = join(input.appPath, 'node_modules', packageName, 'bin', getNativeOpencodeBinName(input.platform));
|
||||
if (existsSync(binPath)) return binPath;
|
||||
const binaryName = getNativeOpencodeBinName(input.platform);
|
||||
const hoistedBinPath = join(input.appPath, 'node_modules', packageName, 'bin', binaryName);
|
||||
if (existsSync(hoistedBinPath)) return hoistedBinPath;
|
||||
|
||||
const nativePackageRoot = resolveNativePackageRoot(runtimeDir, packageName);
|
||||
if (!nativePackageRoot) continue;
|
||||
|
||||
const virtualStoreBinPath = join(nativePackageRoot, 'bin', binaryName);
|
||||
if (existsSync(virtualStoreBinPath)) return virtualStoreBinPath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -73,7 +94,7 @@ export function resolveOpencodeRuntimePaths(input: RuntimePathInput): OpencodeRu
|
||||
: join(input.appPath, 'node_modules', OPENCODE_RUNTIME_PACKAGE);
|
||||
const developmentNativeBinPath = input.isPackaged
|
||||
? undefined
|
||||
: resolveDevelopmentNativeBinPath(input);
|
||||
: resolveDevelopmentNativeBinPath(input, runtimeDir);
|
||||
|
||||
return {
|
||||
runtimeDir,
|
||||
|
||||
@@ -31,6 +31,21 @@ function normalizeProjectType(value: unknown): ProjectType {
|
||||
throw new Error('Invalid project type');
|
||||
}
|
||||
|
||||
function needsLegacyAgentModelMigration(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const raw = value as Partial<ProjectConfig>;
|
||||
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null;
|
||||
if (!defaultModel || !Array.isArray(raw.agents)) return false;
|
||||
return raw.agents.some((agent) => (
|
||||
Boolean(agent)
|
||||
&& typeof agent === 'object'
|
||||
&& !(typeof (agent as { model?: unknown }).model === 'string'
|
||||
&& (agent as { model?: string }).model?.trim())
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Partial<ProjectAgentConfig>;
|
||||
@@ -80,9 +95,16 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
}
|
||||
const raw = value as Partial<ProjectConfig>;
|
||||
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
|
||||
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null;
|
||||
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
|
||||
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
|
||||
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
|
||||
const normalizedAgents = agents
|
||||
.filter((item): item is ProjectAgentConfig => Boolean(item))
|
||||
.map((agent) => agent.model || !defaultModel
|
||||
? agent
|
||||
: { ...agent, model: defaultModel });
|
||||
if (new Set(normalizedAgents.map((item) => item.id)).size !== normalizedAgents.length) {
|
||||
throw new Error('Duplicate project Agent id');
|
||||
}
|
||||
@@ -97,10 +119,9 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
schemaVersion: 1,
|
||||
projectType: normalizeProjectType(raw.projectType),
|
||||
initialized,
|
||||
superpowersEnabled: raw.superpowersEnabled === true,
|
||||
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
? raw.defaultModel.trim()
|
||||
: null,
|
||||
// Keep this legacy field readable for compatibility, but runtime model
|
||||
// selection is owned by each project Agent.
|
||||
defaultModel,
|
||||
agents: normalizedAgents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
createdAt,
|
||||
@@ -111,7 +132,12 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
|
||||
return { status: 'valid', config: normalizeProjectConfig(raw) };
|
||||
const config = normalizeProjectConfig(raw);
|
||||
if (needsLegacyAgentModelMigration(raw)) {
|
||||
if (config.initialized) await materializeAgents(projectPath, config);
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
return { status: 'valid', config };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -165,7 +191,7 @@ function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): s
|
||||
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
|
||||
: ' "*": deny';
|
||||
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
|
||||
const model = agent.model ?? config.defaultModel;
|
||||
const model = agent.model;
|
||||
const prompt = agent.prompt.trim() || buildProjectAgentPrompt(config, agent);
|
||||
return `---
|
||||
description: ${yamlString(agent.name)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { OpencodeSkillInfo } from './client';
|
||||
import { basename, dirname, join, relative, sep } from 'node:path';
|
||||
import type { OpencodeSkillEntry, OpencodeSkillInfo } from './client';
|
||||
|
||||
const SKILL_ROOT_NAMES = ['skill', 'skills'] as const;
|
||||
|
||||
@@ -29,6 +29,36 @@ async function collectSkillFiles(root: string): Promise<string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function collectSkillEntries(root: string, current = root): Promise<OpencodeSkillEntry[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
entries.sort((left, right) => {
|
||||
const directoryOrder = Number(right.isDirectory()) - Number(left.isDirectory());
|
||||
return directoryOrder || left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
const result: OpencodeSkillEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(current, entry.name);
|
||||
const relativePath = relative(root, entryPath).split(sep).join('/');
|
||||
if (entry.isDirectory()) {
|
||||
result.push({ path: relativePath, type: 'directory' });
|
||||
result.push(...await collectSkillEntries(root, entryPath));
|
||||
} else if (entry.isFile()) {
|
||||
result.push({ path: relativePath, type: 'file' });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unquoteYamlScalar(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
@@ -73,6 +103,7 @@ export async function listInstalledOpencodeSkills(managedConfigDir?: string | nu
|
||||
description: readFrontmatterScalar(content, 'description'),
|
||||
location,
|
||||
content,
|
||||
entries: await collectSkillEntries(dirname(location)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
92
electron/opencode/startup-warmup.ts
Normal file
92
electron/opencode/startup-warmup.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OpencodeStatus } from './manager';
|
||||
|
||||
type ProjectConfigReadResult = {
|
||||
status: 'valid' | 'missing' | 'invalid';
|
||||
config?: { initialized?: boolean };
|
||||
};
|
||||
|
||||
export type OpencodeStartupWarmupSkipReason =
|
||||
| 'no-session'
|
||||
| 'runtime-active'
|
||||
| 'no-active-project'
|
||||
| 'project-not-ready'
|
||||
| 'no-provider'
|
||||
| 'eligibility-check-failed'
|
||||
| 'start-failed';
|
||||
|
||||
export type OpencodeStartupWarmupResult =
|
||||
| { started: true; status: OpencodeStatus }
|
||||
| { started: false; reason: OpencodeStartupWarmupSkipReason; error?: unknown };
|
||||
|
||||
export interface OpencodeStartupWarmupDependencies {
|
||||
hasAuthenticatedSession: () => boolean;
|
||||
getStatus: () => OpencodeStatus;
|
||||
getActiveProject: () => Promise<{ path: string } | null>;
|
||||
readProjectConfig: (projectPath: string) => Promise<ProjectConfigReadResult>;
|
||||
getConfiguredProviderCount: () => Promise<number>;
|
||||
start: () => Promise<OpencodeStatus>;
|
||||
onError?: (error: unknown, phase: 'eligibility' | 'start') => void;
|
||||
}
|
||||
|
||||
function reportError(
|
||||
dependencies: OpencodeStartupWarmupDependencies,
|
||||
error: unknown,
|
||||
phase: 'eligibility' | 'start',
|
||||
): void {
|
||||
try {
|
||||
dependencies.onError?.(error, phase);
|
||||
} catch {
|
||||
// Logging must never turn a background warmup into an unhandled rejection.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the local runtime in the background when the persisted app state is
|
||||
* ready for a Code session. This deliberately does not throw: startup
|
||||
* warmup is an optimization and the normal Chat-page lazy start remains the
|
||||
* recovery path when it is unavailable.
|
||||
*/
|
||||
export async function warmupOpencodeRuntime(
|
||||
dependencies: OpencodeStartupWarmupDependencies,
|
||||
): Promise<OpencodeStartupWarmupResult> {
|
||||
if (!dependencies.hasAuthenticatedSession()) {
|
||||
return { started: false, reason: 'no-session' };
|
||||
}
|
||||
|
||||
const status = dependencies.getStatus();
|
||||
if (status.state !== 'stopped') {
|
||||
return { started: false, reason: 'runtime-active' };
|
||||
}
|
||||
|
||||
let project: { path: string } | null;
|
||||
let projectConfig: ProjectConfigReadResult;
|
||||
let providerCount: number;
|
||||
try {
|
||||
project = await dependencies.getActiveProject();
|
||||
if (!project) {
|
||||
return { started: false, reason: 'no-active-project' };
|
||||
}
|
||||
|
||||
projectConfig = await dependencies.readProjectConfig(project.path);
|
||||
if (projectConfig.status !== 'valid' || projectConfig.config?.initialized !== true) {
|
||||
return { started: false, reason: 'project-not-ready' };
|
||||
}
|
||||
|
||||
providerCount = await dependencies.getConfiguredProviderCount();
|
||||
} catch (error) {
|
||||
reportError(dependencies, error, 'eligibility');
|
||||
return { started: false, reason: 'eligibility-check-failed', error };
|
||||
}
|
||||
|
||||
if (!Number.isFinite(providerCount) || providerCount <= 0) {
|
||||
return { started: false, reason: 'no-provider' };
|
||||
}
|
||||
|
||||
try {
|
||||
const startedStatus = await dependencies.start();
|
||||
return { started: true, status: startedStatus };
|
||||
} catch (error) {
|
||||
reportError(dependencies, error, 'start');
|
||||
return { started: false, reason: 'start-failed', error };
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { retryTransientFilesystemOperation } from './filesystem-retry';
|
||||
|
||||
export interface BundledSuperpowersPathInput {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
}
|
||||
|
||||
export interface EnsureBundledSuperpowersOptions {
|
||||
managedConfigDir: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
export interface EnsureBundledCourseSkillsOptions {
|
||||
managedConfigDir: string;
|
||||
sourceDir?: string;
|
||||
}
|
||||
|
||||
const WRAPPER_PLUGIN_NAME = 'superpowers-niancode.js';
|
||||
const SUPERPOWERS_REPO_DIR = 'superpowers';
|
||||
const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles';
|
||||
const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json';
|
||||
export const BUNDLED_COURSE_SKILL_IDS = [
|
||||
'agent-browser',
|
||||
'designer-design-spec',
|
||||
'dev-build-test',
|
||||
'game-assets',
|
||||
'marketing-launch-story',
|
||||
'nianxxgame-skill',
|
||||
'partner-agent-showcase',
|
||||
'pm-project-plan',
|
||||
'product-demo-prototype',
|
||||
'ui-ux-course-quality',
|
||||
'youth-plain-language',
|
||||
'youth-ai-product-course',
|
||||
] as const;
|
||||
const RETIRED_COURSE_SKILL_IDS = [
|
||||
'course-stage-review',
|
||||
'deploy-publish-check',
|
||||
'student-growth-logger',
|
||||
] as const;
|
||||
|
||||
export function resolveBundledSuperpowersDir(input: BundledSuperpowersPathInput): string {
|
||||
const resourcesDir = input.isPackaged
|
||||
? join(input.resourcesPath, 'resources')
|
||||
: join(input.appPath, 'resources');
|
||||
|
||||
return join(resourcesDir, 'skills', SUPERPOWERS_REPO_DIR);
|
||||
}
|
||||
|
||||
export function resolveBundledCourseSkillsDir(input: BundledSuperpowersPathInput): string {
|
||||
return input.isPackaged
|
||||
? join(input.resourcesPath, 'course-skills')
|
||||
: join(input.appPath, '.opencode', 'skills');
|
||||
}
|
||||
|
||||
export function resolveBundledAgentBrowserPluginPath(input: BundledSuperpowersPathInput): string {
|
||||
return join(
|
||||
resolveBundledCourseSkillsDir(input),
|
||||
'agent-browser',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'niancode-agent-browser.js',
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedOpencodeConfigDir(userDataDir: string): string {
|
||||
return join(userDataDir, 'opencode', 'niancode-config');
|
||||
}
|
||||
|
||||
function readBundledSuperpowersVersion(sourceDir: string): string {
|
||||
const packagePath = join(sourceDir, 'package.json');
|
||||
const packageJson = JSON.parse(retryTransientFilesystemOperation(
|
||||
() => readFileSync(packagePath, 'utf8'),
|
||||
)) as { version?: unknown };
|
||||
if (typeof packageJson.version !== 'string' || !packageJson.version.trim()) {
|
||||
throw new Error(`Bundled Superpowers package has no valid version: ${packagePath}`);
|
||||
}
|
||||
return packageJson.version.trim();
|
||||
}
|
||||
|
||||
function toSafeBundleName(version: string): string {
|
||||
const safeVersion = version.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!safeVersion) {
|
||||
throw new Error(`Bundled Superpowers version cannot form a safe directory name: ${version}`);
|
||||
}
|
||||
return safeVersion;
|
||||
}
|
||||
|
||||
function isCompleteBundle(bundleDir: string, expectedVersion: string): boolean {
|
||||
try {
|
||||
if (!existsSync(join(bundleDir, '.opencode', 'plugins', 'superpowers.js'))
|
||||
|| !existsSync(join(bundleDir, 'skills', 'using-superpowers', 'SKILL.md'))) {
|
||||
return false;
|
||||
}
|
||||
return readBundledSuperpowersVersion(bundleDir) === expectedVersion;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManifestBundle(
|
||||
managedConfigDir: string,
|
||||
bundlesDir: string,
|
||||
expectedVersion: string,
|
||||
): string | null {
|
||||
try {
|
||||
const manifest = JSON.parse(retryTransientFilesystemOperation(
|
||||
() => readFileSync(join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST), 'utf8'),
|
||||
)) as { version?: unknown; directory?: unknown };
|
||||
if (manifest.version !== expectedVersion || typeof manifest.directory !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bundlesRoot = resolve(bundlesDir);
|
||||
const bundleDir = resolve(bundlesDir, manifest.directory);
|
||||
const relativeBundlePath = relative(bundlesRoot, bundleDir);
|
||||
if (!relativeBundlePath
|
||||
|| relativeBundlePath === '..'
|
||||
|| relativeBundlePath.startsWith(`..${sep}`)
|
||||
|| isAbsolute(relativeBundlePath)) {
|
||||
return null;
|
||||
}
|
||||
return isCompleteBundle(bundleDir, expectedVersion) ? bundleDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectorySync(sourceDir: string, targetDir: string): void {
|
||||
cpSync(sourceDir, targetDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
filter: (sourcePath, targetPath) => {
|
||||
// Node 24.14's Windows override path can corrupt non-ASCII destinations
|
||||
// when replacing a file. Public unlink keeps force-overwrite semantics.
|
||||
if (lstatSync(sourcePath).isFile() && existsSync(targetPath)) {
|
||||
unlinkSync(targetPath);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function installImmutableBundle(
|
||||
sourceDir: string,
|
||||
bundlesDir: string,
|
||||
safeBundleName: string,
|
||||
expectedVersion: string,
|
||||
): string {
|
||||
retryTransientFilesystemOperation(() => mkdirSync(bundlesDir, { recursive: true }));
|
||||
const stagingDir = join(
|
||||
bundlesDir,
|
||||
`.staging-${safeBundleName}-${process.pid}-${randomUUID()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
retryTransientFilesystemOperation(() => copyDirectorySync(sourceDir, stagingDir));
|
||||
if (!isCompleteBundle(stagingDir, expectedVersion)) {
|
||||
throw new Error(`Bundled Superpowers staging copy is incomplete: ${stagingDir}`);
|
||||
}
|
||||
|
||||
const preferredDir = join(bundlesDir, safeBundleName);
|
||||
if (isCompleteBundle(preferredDir, expectedVersion)) {
|
||||
return preferredDir;
|
||||
}
|
||||
|
||||
const selectedDir = existsSync(preferredDir)
|
||||
? join(bundlesDir, `${safeBundleName}-${randomUUID()}`)
|
||||
: preferredDir;
|
||||
try {
|
||||
retryTransientFilesystemOperation(() => renameSync(stagingDir, selectedDir));
|
||||
} catch (error) {
|
||||
if (!isCompleteBundle(selectedDir, expectedVersion)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return selectedDir;
|
||||
} finally {
|
||||
if (existsSync(stagingDir)) {
|
||||
try {
|
||||
retryTransientFilesystemOperation(() => rmSync(stagingDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}));
|
||||
} catch {
|
||||
// Startup cleanup is best-effort; preserve the installation result or error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeSelectedBundleFiles(
|
||||
managedConfigDir: string,
|
||||
bundlesDir: string,
|
||||
bundleDir: string,
|
||||
version: string,
|
||||
): void {
|
||||
const targetPluginsDir = join(managedConfigDir, 'plugins');
|
||||
retryTransientFilesystemOperation(() => mkdirSync(targetPluginsDir, { recursive: true }));
|
||||
|
||||
const pluginPath = join(bundleDir, '.opencode', 'plugins', 'superpowers.js');
|
||||
let pluginImport = relative(targetPluginsDir, pluginPath).split(sep).join('/');
|
||||
if (!pluginImport.startsWith('.')) pluginImport = `./${pluginImport}`;
|
||||
|
||||
retryTransientFilesystemOperation(() => writeFileSync(
|
||||
join(targetPluginsDir, WRAPPER_PLUGIN_NAME),
|
||||
`export { SuperpowersPlugin } from '${pluginImport}';\n`,
|
||||
'utf8',
|
||||
));
|
||||
retryTransientFilesystemOperation(() => writeFileSync(
|
||||
join(managedConfigDir, SUPERPOWERS_ACTIVE_MANIFEST),
|
||||
`${JSON.stringify({
|
||||
version,
|
||||
directory: relative(bundlesDir, bundleDir),
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
));
|
||||
}
|
||||
|
||||
export function ensureBundledSuperpowersPlugin(options: EnsureBundledSuperpowersOptions): boolean {
|
||||
const sourceDir = options.sourceDir?.trim();
|
||||
if (!sourceDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourcePluginPath = join(sourceDir, '.opencode', 'plugins', 'superpowers.js');
|
||||
const sourceSkillsDir = join(sourceDir, 'skills');
|
||||
if (!existsSync(sourcePluginPath) || !existsSync(sourceSkillsDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const version = readBundledSuperpowersVersion(sourceDir);
|
||||
const safeBundleName = toSafeBundleName(version);
|
||||
const bundlesDir = join(options.managedConfigDir, SUPERPOWERS_BUNDLES_DIR);
|
||||
const preferredDir = join(bundlesDir, safeBundleName);
|
||||
const bundleDir = resolveManifestBundle(
|
||||
options.managedConfigDir,
|
||||
bundlesDir,
|
||||
version,
|
||||
) ?? (isCompleteBundle(preferredDir, version)
|
||||
? preferredDir
|
||||
: installImmutableBundle(sourceDir, bundlesDir, safeBundleName, version));
|
||||
|
||||
writeSelectedBundleFiles(
|
||||
options.managedConfigDir,
|
||||
bundlesDir,
|
||||
bundleDir,
|
||||
version,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureBundledCourseSkills(options: EnsureBundledCourseSkillsOptions): boolean {
|
||||
const sourceDir = options.sourceDir?.trim();
|
||||
if (!sourceDir || !existsSync(sourceDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetSkillsDir = join(options.managedConfigDir, 'skills');
|
||||
mkdirSync(targetSkillsDir, { recursive: true });
|
||||
for (const skillId of RETIRED_COURSE_SKILL_IDS) {
|
||||
rmSync(join(targetSkillsDir, skillId), { recursive: true, force: true });
|
||||
}
|
||||
copyDirectorySync(sourceDir, targetSkillsDir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureBundledAgentBrowserPlugin(options: {
|
||||
managedConfigDir: string;
|
||||
sourcePath?: string;
|
||||
}): boolean {
|
||||
const sourcePath = options.sourcePath?.trim();
|
||||
if (!sourcePath || !existsSync(sourcePath)) return false;
|
||||
const targetPluginsDir = join(options.managedConfigDir, 'plugins');
|
||||
mkdirSync(targetPluginsDir, { recursive: true });
|
||||
cpSync(sourcePath, join(targetPluginsDir, 'niancode-agent-browser.js'), { force: true });
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user