收口 Makelore 客户端变更
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-12 22:35:06 +08:00
parent 253bad8b40
commit f4113a872f
232 changed files with 4617 additions and 20121 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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>,

View 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',
};
}

View File

@@ -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 {

View 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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)}

View File

@@ -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)),
});
}

View 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 };
}
}

View File

@@ -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;
}