实现小游戏与小程序项目创建发布

This commit is contained in:
2026-08-09 13:31:06 +08:00
parent a6fe14a8d6
commit 493b31cff0
26 changed files with 1827 additions and 39 deletions

View File

@@ -0,0 +1,71 @@
# Task: 实现小游戏小程序和自定义项目类型
## Identity
- Task ID: 20260809-project-types-impl-3f7a
- Mode: Feature
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: a6fe14a8d60ae5bfd83529b36bead34b8b8c3348
- Owner: codex
- Status: Ready for Integration
## Scope
- 为 AI 编程项目引入创建时选择且不可变的 `ProjectType``mini_game``mini_program``custom`
- 在核心配置、目录初始化和安全打包层贯通项目类型;为小游戏和小程序生成可真实 `npm ci`、Vite build 的固定版本模板。
- 旧配置缺少类型时归一为 `custom`;自定义项目拒绝进入受控发布打包。
- 更新配置、初始化和打包聚焦测试Renderer、Host API 路由和 README 由同任务根代理负责。
## Intent And Constraints
- `ProjectType` 是不可变产品类型,`BuildPreset` 是平台内部构建实现,两者不得混用;第一期两个可发布类型都使用受控 Vite preset。
- 直接复用已有项目时,显式请求的类型必须与已有配置一致;未传类型的旧 API 调用保留既有复用行为。
- 模板在 staging 目录完整生成后沿用现有排他复制与回滚机制,不覆盖用户文件。
- `custom` 和旧配置不得被打包为可发布作品;安全扫描、敏感文件排除、符号链接和包体限制不得削弱。
- 只修改任务记录,不写 canonical `.project-docs` 文件;不提交代码。
## Plan
1. 固定配置类型、旧配置归一和保存/复用不可变规则,并用单元测试锁定。
2. 增加两个受控 Vite 项目模板和真实 npm 锁文件,接入原子目录初始化。
3. 按项目类型生成带 `project_type` 的发布清单,并拒绝 `custom`
4. 运行聚焦测试、模板 `npm ci`/build、typecheck、scoped lint、build 和文档漂移检查。
## Outcome
- 新增不可变 `ProjectType` 契约:`mini_game``mini_program``custom`。新配置明确保存类型;旧配置仅在字段缺失时归一为 `custom`,显式未知值会判为无效配置。
- 项目配置保存不能改变已有类型;直接复用所选文件夹时,显式类型必须与已有配置一致,未传类型的旧 API 调用仍可按原行为复用。
- 小游戏和小程序都在 staging 中生成固定 Vite 7.3.1、真实 npm v3 锁文件、相对资源构建配置和可运行示例。小游戏包含 `game.json`/`game.js` 与 Canvas 示例;小程序包含 `app.json`/`app.js`/`pages/index` 与交互首页。自定义项目保持原最小结构。
- 模板继续使用排他复制和失败回滚;目标文件冲突时不会在用户文件夹留下 `.niancode` 或其他半成品。
- 安全打包先读取项目类型:`custom` 返回 `PROJECT_TYPE_UNPUBLISHABLE`;两种可发布类型继续使用内部受控 Vite preset并在生成的 `niancode.yml` 顶层写入 `project_type`。既有敏感文件排除、符号链接、稳定读取和包体限制未放宽。
- 同任务根代理已贯通创建对话框、Renderer store、Host API、发布入口/分类和 README本记录仅描述已观察到的共享任务结果不改 canonical 项目文档。
- 新建弹窗默认选择“小游戏”,同时提供“小程序”和“自定义项目”两种明确选项;自定义项目的配置页不显示“一键提交审核”,而是说明需新建小游戏或小程序项目。
- 一键提交的作品分类随项目类型发送;模板文件缺失时不再要求非专业用户手工补三个文件,而是提示让开发助手修复项目模板。
## Verification
- `pnpm exec vitest run tests/unit/project-config.test.ts tests/unit/project-directory-initialization.test.ts tests/unit/project-packager.test.ts`3 files / 24 tests passed。
- `pnpm exec vitest run tests/unit/works-routes.test.ts`1 file / 27 tests passed可发布路由 fixture 已明确写入 `mini_game` 配置,避免旧的隐式“任意 Vite 项目都可发布”前提。
- 使用真实 `initializeProjectDirectory` 生成临时 `mini_game``mini_program` 项目后,分别执行 `npm ci --ignore-scripts --no-audit --no-fund``npm run build`:两者均安装 14 个锁定依赖并由 Vite 7.3.1 构建成功;验证结束已清理临时项目与临时测试工具。
- `pnpm run typecheck`:通过。
- 对核心配置、初始化、模板、打包及三份聚焦测试执行 scoped ESLint通过。
- `pnpm run build:vite`Renderer、Electron Main、Preload 均构建成功;仅有仓库既有 chunk/dynamic-import 提示。
- `git diff --check`:通过;仅有 Git 的 LF/CRLF 工作区提示。
- `pnpm exec vitest run tests/unit/project-config.test.ts tests/unit/project-directory-initialization.test.ts tests/unit/project-packager.test.ts tests/unit/opencode-routes.test.ts tests/unit/opencode-store.test.ts tests/unit/sidebar-opencode-projects.test.tsx tests/unit/project-configuration-page.test.tsx tests/unit/project-publish-action.test.tsx tests/unit/works-project-publish.test.ts tests/unit/works-routes.test.ts`10 files / 310 tests passed。
- `pnpm exec playwright test tests/e2e/project-superpowers-toggle.spec.ts --reporter=line`1 passed真实 Electron 新建弹窗验证三种项目类型、默认小游戏及小游戏/小程序切换。
- `pnpm test` 的全仓尝试结果为 1471 passed / 20 failed14 项因本机缺少 `zip` 可执行文件、1 项因仓库缺少 `.opencode/agent` 夹具;其余为未修改的图片认证/项目进度同步用例和 1 项时序用例(该时序用例单独重跑通过)。这些失败不位于本任务变更链路,已保留为基线信息,未扩张本任务去修改无关模块。
## Follow-ups
- Works Square 服务端已在兄弟任务 `20260809-project-types-server-8d2c` 接受并校验顶层 `project_type`,并继续映射到内部受控 Vite preset两仓变更需成组集成后才能进行真实发布验收。
- 完成服务端类型分流后,使用真实账号执行“小程序/小游戏创建 → 一键提交 → 构建 → 运营审核 → 发布”的生产整链验收。
## Promotion Candidates
- Target: `.project-docs/40-domain/business-rules.md` 与 README 当前产品状态。
- Proposal: 记录 `ProjectType` 是创建时选择且不可变的产品类型;小游戏和小程序创建即具备平台受控发布骨架,自定义及旧项目默认不可一键发布。
- Evidence: 本任务配置/初始化/打包实现24 项核心回归,两类真实 `npm ci` + Vite build以及同任务 Renderer/Host API 回归。
- Future impact: 后续不得把 `custom` 当成构建 preset也不得通过任意 Shell 命令绕过平台受控构建边界。
- Semantic conflicts: 需要服务端同步接受 `project_type`;与现有 Main-owned 安全打包和单入口发布边界一致。
- Human confirmation required: 是用户已确认三种创建类型和“参照微信、自有平台实现”的方向canonical promotion 仍由 Integration Gate 执行。

View File

@@ -8,7 +8,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版
- `Makelore CanvasAI 绘画`以设计项目Workspace组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。
- `Makelore LearningAI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。AI 编程项目只在现有项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。项目成果预览 `/deliverables` 继续保留。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
## 当前产品状态

View File

@@ -48,7 +48,11 @@ import {
upsertProjectSessionMetadata,
type ProjectConversationState,
} from '../../../shared/project-conversations';
import type { ProjectConfig } from '../../../shared/project-config';
import {
isProjectType,
type ProjectConfig,
type ProjectType,
} from '../../../shared/project-config';
import { initializeProjectDirectory } from '../../opencode/project-directory-initialization';
const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
@@ -82,6 +86,13 @@ const COMMAND_IMAGE_DATA_URL_PATTERN =
/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/iu;
const STRICT_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
function parseOptionalProjectType(value: unknown): ProjectType | undefined {
if (value === undefined) return undefined;
if (!isProjectType(value)) {
throw new Error('Invalid project type');
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -956,8 +967,10 @@ export async function handleOpencodeRoutes(
projectPath?: string;
parentPath?: string;
projectName?: string;
projectType?: unknown;
defaultModel?: unknown;
}>(req);
const projectType = parseOptionalProjectType(body.projectType);
const defaultModel = body.defaultModel === undefined || body.defaultModel === null
? null
: typeof body.defaultModel === 'string' && body.defaultModel.trim()
@@ -972,6 +985,7 @@ export async function handleOpencodeRoutes(
: buildNewProjectPath(body.parentPath, body.projectName);
const { config } = await initializeProjectDirectory({
projectPath,
projectType,
defaultModel,
allowExistingDirectory: useSelectedDirectory,
});

View File

@@ -2,11 +2,14 @@ import path from 'node:path';
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import {
createProjectConfig,
isProjectType,
validateAgentConfigs,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
type ProjectType,
} from '../../shared/project-config';
import { createPublishableProjectTemplate } from './project-template';
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
@@ -22,6 +25,12 @@ function normalizeStringList(value: unknown): string[] {
.filter(Boolean))];
}
function normalizeProjectType(value: unknown): ProjectType {
if (value === undefined) return 'custom';
if (isProjectType(value)) return value;
throw new Error('Invalid project type');
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
@@ -86,6 +95,7 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
}
return {
schemaVersion: 1,
projectType: normalizeProjectType(raw.projectType),
initialized,
superpowersEnabled: raw.superpowersEnabled === true,
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
@@ -231,9 +241,9 @@ Last Updated By: user
export async function createInitialProjectConfig(
projectPath: string,
options?: { defaultModel?: string | null },
options?: { defaultModel?: string | null; projectType?: ProjectType },
): Promise<ProjectConfig> {
const config = createProjectConfig();
const config = createProjectConfig(new Date().toISOString(), options?.projectType ?? 'custom');
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
config.defaultModel = options.defaultModel.trim();
}
@@ -242,15 +252,25 @@ export async function createInitialProjectConfig(
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(config.createdAt), { encoding: 'utf8', flag: 'wx' });
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' });
if (config.projectType !== 'custom') {
await createPublishableProjectTemplate(projectPath, config.projectType);
}
return config;
}
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
const previous = await readProjectConfig(projectPath);
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
const requestedProjectType = value && typeof value === 'object' && !Array.isArray(value)
? (value as { projectType?: unknown }).projectType
: undefined;
if (requestedProjectType !== undefined && requestedProjectType !== previous.config.projectType) {
throw new Error('已有项目类型不可更改');
}
const config = normalizeProjectConfig({
...(value as object),
schemaVersion: 1,
projectType: previous.config.projectType,
createdAt: previous.config.createdAt,
updatedAt: new Date().toISOString(),
});

View File

@@ -11,7 +11,7 @@ import {
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import type { ProjectConfig } from '../../shared/project-config';
import type { ProjectConfig, ProjectType } from '../../shared/project-config';
import { createInitialProjectConfig, readProjectConfig } from './project-config';
type StagedProjectEntries = {
@@ -27,6 +27,7 @@ export type ProjectDirectoryInitializationResult = {
export type ProjectDirectoryInitializationInput = {
projectPath: string;
defaultModel?: string | null;
projectType?: ProjectType;
allowExistingDirectory: boolean;
};
@@ -210,6 +211,9 @@ export async function initializeProjectDirectory(
await requireExistingDirectory(input.projectPath);
const existing = await readProjectConfig(input.projectPath);
if (existing.status === 'valid') {
if (input.projectType !== undefined && input.projectType !== existing.config.projectType) {
throw new Error('已有项目类型不可更改');
}
return { config: existing.config, reusedExistingConfig: true };
}
if (existing.status === 'invalid') {
@@ -228,6 +232,7 @@ export async function initializeProjectDirectory(
try {
config = await createInitialProjectConfig(stagingPath, {
defaultModel: input.defaultModel,
projectType: input.projectType,
});
} catch (error) {
throw new Error(

View File

@@ -0,0 +1,139 @@
import { dirname, join } from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import type { ProjectType } from '../../shared/project-config';
import vitePackageJson from './project-templates/vite/package.json';
import vitePackageLock from './project-templates/vite/package-lock.json';
const VITE_CONFIG = `import { defineConfig } from 'vite';
export default defineConfig({
base: './',
});
`;
const MINI_GAME_FILES: Record<string, string> = {
'index.html': `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Makelore 小游戏</title>
<style>
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #10233d; }
body { display: grid; place-items: center; font-family: system-ui, sans-serif; }
#game { width: 100%; height: 100%; touch-action: none; }
</style>
</head>
<body>
<canvas id="game" aria-label="Makelore 小游戏画布"></canvas>
<script type="module" src="/game.js"></script>
</body>
</html>
`,
'game.json': `${JSON.stringify({ deviceOrientation: 'portrait', showStatusBar: false }, null, 2)}\n`,
'game.js': `const canvas = document.querySelector('#game');
const context = canvas.getContext('2d');
function render() {
const ratio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#10233d');
gradient.addColorStop(1, '#3a5578');
context.fillStyle = gradient;
context.fillRect(0, 0, width, height);
context.textAlign = 'center';
context.fillStyle = '#ffffff';
context.font = '700 28px system-ui, sans-serif';
context.fillText('Makelore 小游戏', width / 2, height / 2 - 10);
context.fillStyle = '#f6b45f';
context.font = '16px system-ui, sans-serif';
context.fillText('点击画布,开始创造', width / 2, height / 2 + 26);
}
canvas.addEventListener('pointerdown', (event) => {
context.beginPath();
context.fillStyle = '#f26a3d';
context.arc(event.clientX, event.clientY, 18, 0, Math.PI * 2);
context.fill();
});
window.addEventListener('resize', render);
render();
`,
};
const MINI_PROGRAM_FILES: Record<string, string> = {
'index.html': `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Makelore 小程序</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/pages/index/index.js"></script>
</body>
</html>
`,
'app.json': `${JSON.stringify({ pages: ['pages/index/index'], window: { navigationBarTitleText: 'Makelore 小程序' } }, null, 2)}\n`,
'app.js': `export const app = {
globalData: {
platform: 'makelore',
},
};
`,
'pages/index/index.json': `${JSON.stringify({ navigationBarTitleText: '首页' }, null, 2)}\n`,
'pages/index/index.css': `:root {
color: #24364b;
background: #f4f7fb;
font-family: system-ui, sans-serif;
}
body { margin: 0; }
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card { width: min(420px, 100%); border: 1px solid #d8e1ec; border-radius: 20px; background: #fff; padding: 28px; text-align: center; box-shadow: 0 16px 48px rgb(36 54 75 / 10%); }
button { border: 0; border-radius: 12px; background: #3a5578; color: #fff; padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
`,
'pages/index/index.js': `import { app } from '../../app.js';
import './index.css';
const root = document.querySelector('#app');
let count = 0;
root.innerHTML = \`<section class="page"><div class="card"><p>运行于 \${app.globalData.platform}</p><h1>Makelore 小程序</h1><p id="count">已点击 0 次</p><button type="button">点我试试</button></div></section>\`;
root.querySelector('button').addEventListener('click', () => {
count += 1;
root.querySelector('#count').textContent = \`已点击 \${count}\`;
});
`,
};
function serializeJson(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
export async function createPublishableProjectTemplate(
projectPath: string,
projectType: Exclude<ProjectType, 'custom'>,
): Promise<void> {
const files = projectType === 'mini_game' ? MINI_GAME_FILES : MINI_PROGRAM_FILES;
const templateFiles: Record<string, string> = {
'package.json': serializeJson(vitePackageJson),
'package-lock.json': serializeJson(vitePackageLock),
'vite.config.js': VITE_CONFIG,
...files,
};
for (const [relativePath, content] of Object.entries(templateFiles)) {
const filePath = join(projectPath, relativePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' });
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "makelore-project",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "npm@10.9.2",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"devDependencies": {
"vite": "7.3.1"
}
}

View File

@@ -10,19 +10,26 @@ import {
writeFile,
} from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
import type { ProjectType } from '../../shared/project-config';
import { readProjectConfig } from '../opencode/project-config';
const require = createRequire(import.meta.url);
const AdmZip = require('adm-zip') as typeof import('adm-zip');
const GENERATED_MANIFEST = (
'schema_version: 1\n'
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
+ ' preset: vite\n'
+ ' package_manager: npm\n'
+ ' entry: index.html\n'
);
type PublishableProjectType = Exclude<ProjectType, 'custom'>;
function generatedManifest(projectType: PublishableProjectType): string {
return (
'schema_version: 1\n'
+ `project_type: ${projectType}\n`
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
+ ' preset: vite\n'
+ ' package_manager: npm\n'
+ ' entry: index.html\n'
);
}
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
const FILE_MODE = 0o100644;
const DEFAULT_LIMITS: ProjectPackageLimits = {
@@ -125,6 +132,7 @@ export type StaticProjectPackageSummary = {
excludedPaths: string[];
manifest: {
schema_version: 1;
project_type: PublishableProjectType;
kind: 'web';
runtime: 'static';
build: {
@@ -177,9 +185,10 @@ export async function createStaticProjectPackage(input: {
}): Promise<StaticProjectPackageSummary> {
const limits = input.limits ?? DEFAULT_LIMITS;
const projectPath = await resolveProjectDirectory(input.projectPath);
const projectType = await readPublishableProjectType(projectPath);
await validateViteProject(projectPath, limits);
const scan = await scanProject(projectPath, limits);
const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8');
const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8');
const sourceBytes = scan.sourceBytes + manifestBytes.length;
if (sourceBytes > limits.maxSourceBytes) {
throw new ProjectPackageError(
@@ -228,6 +237,7 @@ export async function createStaticProjectPackage(input: {
excludedPaths: scan.excludedPaths,
manifest: {
schema_version: 1,
project_type: projectType,
kind: 'web',
runtime: 'static',
build: {
@@ -239,6 +249,23 @@ export async function createStaticProjectPackage(input: {
};
}
async function readPublishableProjectType(projectPath: string): Promise<PublishableProjectType> {
const result = await readProjectConfig(projectPath);
if (result.status === 'invalid') {
throw new ProjectPackageError(
'PROJECT_CONFIG_INVALID',
'项目配置无效,无法确认发布类型',
);
}
if (result.status === 'missing' || result.config.projectType === 'custom') {
throw new ProjectPackageError(
'PROJECT_TYPE_UNPUBLISHABLE',
'自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
);
}
return result.config.projectType;
}
async function resolveProjectDirectory(projectPath: string): Promise<string> {
try {
const resolved = await realpath(projectPath);

View File

@@ -1,6 +1,13 @@
export const PRODUCT_OVERVIEW_FILENAME = 'PRODUCT_OVERVIEW.md';
export const LEGACY_PROMOTION_PLAN_FILENAME = 'PROMOTION_PLAN.md';
export const PROJECT_TYPES = ['mini_game', 'mini_program', 'custom'] as const;
export type ProjectType = (typeof PROJECT_TYPES)[number];
export function isProjectType(value: unknown): value is ProjectType {
return typeof value === 'string' && PROJECT_TYPES.includes(value as ProjectType);
}
export type ProjectAgentResponsibility = {
mission: string;
owns: string[];
@@ -28,6 +35,7 @@ export type ProjectAgentConfig = {
export type ProjectConfig = {
schemaVersion: 1;
projectType: ProjectType;
initialized: boolean;
superpowersEnabled: boolean;
defaultModel: string | null;
@@ -37,9 +45,13 @@ export type ProjectConfig = {
updatedAt: string;
};
export function createProjectConfig(now = new Date().toISOString()): ProjectConfig {
export function createProjectConfig(
now = new Date().toISOString(),
projectType: ProjectType = 'custom',
): ProjectConfig {
return {
schemaVersion: 1,
projectType,
initialized: false,
superpowersEnabled: false,
defaultModel: null,

View File

@@ -39,6 +39,7 @@ import type { OpencodeProject } from '@/types/opencode';
import { buildConfiguredModelOptions } from '@/lib/model-options';
import { useProjectConfigStore } from '@/stores/project-config';
import { EMPTY_AGENT_USER_PROFILE, getProfileAccountKey, isUserProfileComplete, useUserProfileStore, type UserGender } from '@/stores/user-profile';
import type { ProjectType } from '../../../shared/project-config';
import { toast } from 'sonner';
type OpenDialogResult = {
@@ -162,6 +163,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('mini_game');
const [newProjectDefaultModel, setNewProjectDefaultModel] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
@@ -391,6 +393,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('mini_game');
setNewProjectDefaultModel('');
setCreateProjectError(null);
setCreateDialogOpen(true);
@@ -399,6 +402,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('mini_game');
setNewProjectDefaultModel('');
setCreateProjectError(null);
};
@@ -460,6 +464,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const project = await createProject({
directoryMode: newProjectDirectoryMode,
selectedPath,
projectType: newProjectType,
...(newProjectDirectoryMode === 'create-child-directory' ? { projectName } : {}),
...(newProjectDefaultModel ? { defaultModel: newProjectDefaultModel } : {}),
});
@@ -666,6 +671,46 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
void confirmCreateProject();
}}
>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-3">
{([
['mini_game', '小游戏', '适合实时互动、Canvas 和关卡玩法;创建后可直接提交审核。'],
['mini_program', '小程序', '适合页面、表单和轻量工具;创建后可直接提交审核。'],
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
] as const).map(([value, title, description]) => (
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft transition-[box-shadow,transform] active:scale-[0.96]',
newProjectType === value
? 'ring-2 ring-brand/35 shadow-float'
: 'ring-1 ring-foreground/10 hover:shadow-float',
)}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<input
type="radio"
name="project-type"
value={value}
aria-label={title}
checked={newProjectType === value}
onChange={() => {
setNewProjectType(value);
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
{title}
</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
{description}
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold"></legend>
<div className="grid gap-2 sm:grid-cols-2">

View File

@@ -12,6 +12,7 @@ import {
type WorksProjectMetadataInput,
} from '@/lib/works-square';
import type { OpencodeProject } from '@/types/opencode';
import type { ProjectType } from '../../../shared/project-config';
type PublishPhase = 'idle' | 'submitting' | 'polling' | 'succeeded' | 'failed' | 'uncertain';
@@ -23,6 +24,7 @@ type BuildVersion = {
type ProjectPublishActionProps = {
project: OpencodeProject;
projectType: Exclude<ProjectType, 'custom'>;
};
const BUILD_POLL_INTERVAL_MS = 2_000;
@@ -63,7 +65,7 @@ function buttonLabel(phase: PublishPhase): string {
return '一键提交审核';
}
export function ProjectPublishAction({ project }: ProjectPublishActionProps) {
export function ProjectPublishAction({ project, projectType }: ProjectPublishActionProps) {
const [phase, setPhase] = useState<PublishPhase>('idle');
const [failure, setFailure] = useState<WorksPublishFailure | null>(null);
const pollGeneration = useRef(0);
@@ -79,7 +81,7 @@ export function ProjectPublishAction({ project }: ProjectPublishActionProps) {
title,
summary: `${title},由 Makelore 创建的作品。`,
cover_url: null,
category: 'web',
category: projectType,
age_band: null,
difficulty: null,
};

View File

@@ -19,8 +19,13 @@ const PUBLISH_FAILURES: Record<string, WorksPublishFailure> = {
},
PROJECT_FILE_MISSING: {
title: '项目文件不完整',
reason: '发布所需的基础文件缺失。',
nextStep: '请让开发助手补齐 package.json、package-lock.json 和 index.html 后重试。',
reason: '项目模板中的发布文件被删除或损坏。',
nextStep: '请让开发助手修复当前项目模板后重试。',
},
PROJECT_TYPE_UNPUBLISHABLE: {
title: '这个项目没有配置发布方式',
reason: '自定义项目只创建工作空间,不会自动生成平台发布模板。',
nextStep: '如需一键提交,请新建小游戏或小程序项目。',
},
PROJECT_FILE_INVALID: {
title: '项目配置无法读取',

View File

@@ -324,7 +324,17 @@ export function ProjectConfiguration() {
<Button type="button" variant="outline" onClick={() => setDeleteDialogOpen(true)} className="h-12 border border-foreground/15 bg-accent-soft px-5 font-semibold text-foreground shadow-soft"><Trash2 className="mr-2 h-5 w-5" /></Button>
<div className="ml-auto flex flex-wrap items-start justify-end gap-3">
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-12 border border-brand/20 bg-brand px-6 font-semibold text-primary-foreground"><Check className="mr-2 h-5 w-5" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
<ProjectPublishAction key={activeProject.id} project={activeProject} />
{draft.projectType === 'mini_game' || draft.projectType === 'mini_program' ? (
<ProjectPublishAction
key={activeProject.id}
project={activeProject}
projectType={draft.projectType}
/>
) : (
<p className="max-w-sm self-center text-right text-xs font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
</p>
)}
</div>
</div>
</div>

View File

@@ -60,6 +60,7 @@ import {
getOpencodeErrorKind,
type OpencodeErrorKind,
} from '../../shared/opencode-error-kind';
import type { ProjectType } from '../../shared/project-config';
export type { OpencodeErrorKind } from '../../shared/opencode-error-kind';
@@ -80,6 +81,7 @@ export type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-dire
export interface ProjectCreationInput {
directoryMode: ProjectDirectoryMode;
selectedPath: string;
projectType: ProjectType;
projectName?: string;
defaultModel?: string | null;
}
@@ -3956,6 +3958,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
...(input.directoryMode === 'use-selected-directory'
? { projectPath: input.selectedPath }
: { parentPath: input.selectedPath, projectName: input.projectName }),
projectType: input.projectType,
...(input.defaultModel !== undefined ? { defaultModel: input.defaultModel } : {}),
}),
});

View File

@@ -16,6 +16,12 @@ test.describe('Project-level Superpowers setting', () => {
const page = await getStableWindow(app);
await page.getByTestId('sidebar-create-project').click();
await expect(page.getByRole('radio', { name: '小游戏' })).toBeChecked();
await expect(page.getByRole('radio', { name: '小程序' })).not.toBeChecked();
await expect(page.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
await page.getByRole('radio', { name: '小程序' }).click();
await expect(page.getByRole('radio', { name: '小程序' })).toBeChecked();
await page.getByRole('radio', { name: '小游戏' }).click();
await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
await page.getByRole('button', { name: '选择路径' }).click();
await expect(page.getByLabel('项目路径')).toHaveValue(parentPath);

View File

@@ -515,6 +515,53 @@ describe('opencode host api routes', () => {
}
});
it.each([
['mini_game', ['game.json', 'game.js']],
['mini_program', ['app.json', 'pages/index/index.js']],
] as const)('creates a complete %s project template', async (projectType, expectedTypeFiles) => {
const parentPath = await mkdtemp(join(tmpdir(), `niancode-create-${projectType}-`));
try {
const response = createResponse();
const expectedProjectPath = join(parentPath, '新作品');
const project = {
id: `prj_${projectType}`,
path: expectedProjectPath,
name: '新作品',
};
const rememberProject = vi.fn(async () => project);
const handled = await handleOpencodeRoutes(
createRequest('POST', { parentPath, projectName: '新作品', projectType }),
response.res,
new URL('http://127.0.0.1/api/opencode/projects/create'),
{
opencodeProjectStore: {
rememberProject,
listProjects: vi.fn(async () => [project]),
getActiveProject: vi.fn(async () => null),
},
} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
config: { projectType },
});
const config = JSON.parse(await readFile(join(expectedProjectPath, '.niancode', 'project.json'), 'utf8')) as Record<string, unknown>;
expect(config.projectType).toBe(projectType);
await expect(stat(join(expectedProjectPath, 'package.json'))).resolves.toBeTruthy();
await expect(stat(join(expectedProjectPath, 'package-lock.json'))).resolves.toBeTruthy();
await expect(stat(join(expectedProjectPath, 'index.html'))).resolves.toBeTruthy();
for (const relativePath of expectedTypeFiles) {
await expect(stat(join(expectedProjectPath, relativePath))).resolves.toBeTruthy();
}
} finally {
await rm(parentPath, { recursive: true, force: true });
}
});
it('initializes the selected non-empty folder in place without changing existing files', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-direct-project-'));
try {

View File

@@ -3650,6 +3650,7 @@ describe('opencode store', () => {
directoryMode: 'create-child-directory',
selectedPath: '/Users/kid/NianCode',
projectName: '星星收集器',
projectType: 'mini_game',
});
expect(opened).toEqual(project);
@@ -3658,6 +3659,7 @@ describe('opencode store', () => {
body: JSON.stringify({
parentPath: '/Users/kid/NianCode',
projectName: '星星收集器',
projectType: 'mini_game',
}),
});
expect(useOpencodeStore.getState().activeProject).toBeNull();
@@ -3676,6 +3678,7 @@ describe('opencode store', () => {
const opened = await useOpencodeStore.getState().createProject({
directoryMode: 'use-selected-directory',
selectedPath: '/Users/kid/NianCode/星星收集器',
projectType: 'custom',
});
expect(opened).toEqual(project);
@@ -3683,6 +3686,7 @@ describe('opencode store', () => {
method: 'POST',
body: JSON.stringify({
projectPath: '/Users/kid/NianCode/星星收集器',
projectType: 'custom',
}),
});
expect(useOpencodeStore.getState().activeProject).toBeNull();

View File

@@ -45,6 +45,7 @@ describe('project-owned contact configuration', () => {
it('creates an empty, template-free project config', () => {
const config = createProjectConfig('2026-07-11T00:00:00.000Z');
expect(config).toMatchObject({
projectType: 'custom',
initialized: false,
superpowersEnabled: false,
agents: [],
@@ -53,6 +54,19 @@ describe('project-owned contact configuration', () => {
expect('templateId' in config).toBe(false);
});
it('normalizes legacy configs without a project type as custom', () => {
const legacyConfig = { ...createProjectConfig(), projectType: undefined };
expect(normalizeProjectConfig(legacyConfig).projectType).toBe('custom');
expect(normalizeProjectConfig({
...legacyConfig,
projectType: 'mini_game',
}).projectType).toBe('mini_game');
expect(() => normalizeProjectConfig({
...legacyConfig,
projectType: 'future_project',
})).toThrow('Invalid project type');
});
it('requires a unique name, preset avatar, model, and responsibility for every contact', () => {
expect(validateAgentConfigs([])).toEqual([]);
expect(validateAgentConfigs([createContact({ name: '' })])).toContain('agent-test:name-required');
@@ -130,4 +144,18 @@ describe('project-owned contact configuration', () => {
agents: [createContact({ model: null })],
})).rejects.toThrow('model-required');
});
it('does not allow the persisted project type to change', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-type-'));
const initial = await createInitialProjectConfig(projectPath, { projectType: 'mini_game' });
await expect(writeProjectConfig(projectPath, {
...initial,
projectType: 'mini_program',
})).rejects.toThrow('已有项目类型不可更改');
await expect(readProjectConfig(projectPath)).resolves.toMatchObject({
status: 'valid',
config: { projectType: 'mini_game' },
});
});
});

View File

@@ -52,7 +52,7 @@ describe('ProjectConfiguration', () => {
dispatchEvent: vi.fn(),
})),
});
const config = createProjectConfig();
const config = createProjectConfig(undefined, 'mini_game');
useOpencodeStore.setState({ activeProject: project, projects: [project] });
useProjectConfigStore.setState({ configsByProjectId: { [project.id]: config }, knowledgeByProjectId: { [project.id]: [] }, errorsByProjectId: {}, loadingProjectId: null });
useProviderStore.setState({
@@ -85,6 +85,29 @@ describe('ProjectConfiguration', () => {
expect(screen.queryAllByTestId(/^project-agent-/)).toHaveLength(0);
});
it('does not offer one-click submission for custom projects', async () => {
const customConfig = createProjectConfig();
useProjectConfigStore.setState({
configsByProjectId: { [project.id]: customConfig },
knowledgeByProjectId: { [project.id]: [] },
errorsByProjectId: {},
loadingProjectId: null,
});
const fallback = hostApiFetchMock.getMockImplementation();
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === `/api/opencode/projects/config?projectId=${project.id}`) {
return { status: 'valid', config: customConfig, knowledgeFiles: [] };
}
if (!fallback) throw new Error(`Unexpected path ${path}`);
return fallback(path, init);
});
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
expect(await screen.findByText('自定义项目暂未配置发布方式。如需一键提交,请新建小游戏或小程序项目。')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '一键提交审核' })).not.toBeInTheDocument();
});
it('creates a contact with the required basics and leaves advanced settings empty', async () => {
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新增伙伴' }));

View File

@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { initializeProjectDirectory } from '@electron/opencode/project-directory-initialization';
import type { ProjectType } from '../../shared/project-config';
const temporaryDirectories: string[] = [];
@@ -13,6 +14,98 @@ afterEach(async () => {
});
describe('project directory initialization', () => {
it.each([
['mini_game', ['game.json', 'game.js']],
['mini_program', ['app.json', 'app.js', join('pages', 'index', 'index.js')]],
] satisfies Array<[ProjectType, string[]]>)('creates a complete %s Vite project template', async (projectType, typeFiles) => {
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-project-parent-'));
temporaryDirectories.push(parentPath);
const projectPath = join(parentPath, projectType);
const result = await initializeProjectDirectory({
projectPath,
projectType,
allowExistingDirectory: false,
});
expect(result).toMatchObject({
reusedExistingConfig: false,
config: { projectType },
});
const packageJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
devDependencies: Record<string, string>;
};
const packageLock = JSON.parse(await readFile(join(projectPath, 'package-lock.json'), 'utf8')) as {
lockfileVersion: number;
packages: Record<string, unknown>;
};
expect(packageJson.scripts.build).toBe('vite build');
expect(packageJson.devDependencies.vite).toBe('7.3.1');
expect(packageLock.lockfileVersion).toBe(3);
expect(packageLock.packages['node_modules/vite']).toBeTruthy();
await expect(readFile(join(projectPath, 'index.html'), 'utf8')).resolves.toContain('type="module"');
for (const relativePath of typeFiles) {
await expect(readFile(join(projectPath, relativePath), 'utf8')).resolves.not.toBe('');
}
});
it('keeps custom projects on the existing minimal project structure', async () => {
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-custom-parent-'));
temporaryDirectories.push(parentPath);
const projectPath = join(parentPath, 'custom-project');
const result = await initializeProjectDirectory({
projectPath,
projectType: 'custom',
allowExistingDirectory: false,
});
expect(result.config.projectType).toBe('custom');
expect((await readdir(projectPath)).sort()).toEqual(['.niancode', 'TASKS.md', 'VERSION.md', 'knowledge']);
});
it('reuses an existing project only when an explicitly selected type matches', async () => {
const parentPath = await mkdtemp(join(tmpdir(), 'makelore-existing-parent-'));
temporaryDirectories.push(parentPath);
const projectPath = join(parentPath, 'existing-project');
await initializeProjectDirectory({
projectPath,
projectType: 'mini_game',
allowExistingDirectory: false,
});
await expect(initializeProjectDirectory({
projectPath,
projectType: 'mini_game',
allowExistingDirectory: true,
})).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } });
await expect(initializeProjectDirectory({
projectPath,
projectType: 'mini_program',
allowExistingDirectory: true,
})).rejects.toThrow('已有项目类型不可更改');
await expect(initializeProjectDirectory({
projectPath,
allowExistingDirectory: true,
})).resolves.toMatchObject({ reusedExistingConfig: true, config: { projectType: 'mini_game' } });
});
it('does not change an existing folder when a publishable template file conflicts', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-template-conflict-'));
temporaryDirectories.push(projectPath);
await writeFile(join(projectPath, 'package.json'), '{"name":"user-project"}\n', 'utf8');
await expect(initializeProjectDirectory({
projectPath,
projectType: 'mini_game',
allowExistingDirectory: true,
})).rejects.toThrow('Project initialization conflict: package.json already exists');
expect((await readdir(projectPath)).sort()).toEqual(['package.json']);
await expect(readFile(join(projectPath, 'package.json'), 'utf8')).resolves.toBe('{"name":"user-project"}\n');
});
it('rolls back only files and directories created by a failed in-place merge', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-in-place-rollback-'));
temporaryDirectories.push(projectPath);

View File

@@ -18,6 +18,7 @@ import {
createStaticProjectPackage,
ProjectPackageError,
} from '@electron/services/project-packager';
import { createProjectConfig, type ProjectType } from '../../shared/project-config';
const openRaceHook = vi.hoisted(() => ({
beforeOpen: null as null | ((path: string) => Promise<void>),
@@ -39,11 +40,17 @@ const AdmZip = require('adm-zip') as typeof import('adm-zip');
const tempDirectories: string[] = [];
async function createProject(): Promise<string> {
async function createProject(projectType: ProjectType = 'mini_game'): Promise<string> {
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-'));
tempDirectories.push(projectPath);
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(projectPath, 'public'), { recursive: true });
await mkdir(join(projectPath, '.niancode'), { recursive: true });
await writeFile(
join(projectPath, '.niancode', 'project.json'),
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', projectType)),
'utf8',
);
await writeFile(
join(projectPath, 'package.json'),
JSON.stringify({
@@ -83,7 +90,6 @@ describe('project packager', () => {
await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true });
await mkdir(join(projectPath, '.git'), { recursive: true });
await mkdir(join(projectPath, 'dist'), { recursive: true });
await mkdir(join(projectPath, '.niancode'), { recursive: true });
for (const directory of ['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'secrets']) {
await mkdir(join(projectPath, directory), { recursive: true });
await writeFile(join(projectPath, directory, 'credential'), 'private', 'utf8');
@@ -91,7 +97,7 @@ describe('project packager', () => {
await writeFile(join(projectPath, 'node_modules', 'vite', 'index.js'), 'generated', 'utf8');
await writeFile(join(projectPath, '.git', 'config'), 'private', 'utf8');
await writeFile(join(projectPath, 'dist', 'index.html'), 'built', 'utf8');
await writeFile(join(projectPath, '.niancode', 'project.json'), '{}', 'utf8');
await writeFile(join(projectPath, '.niancode', 'local-state.json'), '{}', 'utf8');
await writeFile(join(projectPath, '.env'), 'TOKEN=secret', 'utf8');
await writeFile(join(projectPath, '.env.example'), 'TOKEN=replace-me', 'utf8');
await writeFile(join(projectPath, '.npmrc'), '//registry/:_authToken=secret', 'utf8');
@@ -170,6 +176,7 @@ describe('project packager', () => {
]);
expect(archive.readAsText('niancode.yml')).toBe(
'schema_version: 1\n'
+ 'project_type: mini_game\n'
+ 'kind: web\n'
+ 'runtime: static\n'
+ 'build:\n'
@@ -177,10 +184,44 @@ describe('project packager', () => {
+ ' package_manager: npm\n'
+ ' entry: index.html\n',
);
expect(first.manifest.project_type).toBe('mini_game');
expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true);
await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n');
});
it('writes the selected publishable project type into the generated manifest', async () => {
const projectPath = await createProject('mini_program');
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
const archivePath = join(outputDirectory, 'project.zip');
const summary = await createStaticProjectPackage({ projectPath, archivePath });
expect(summary.manifest.project_type).toBe('mini_program');
expect(new AdmZip(archivePath).readAsText('niancode.yml')).toContain('project_type: mini_program\n');
});
it('rejects custom projects before validating Vite source files', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-custom-packager-'));
tempDirectories.push(projectPath);
await mkdir(join(projectPath, '.niancode'), { recursive: true });
await writeFile(
join(projectPath, '.niancode', 'project.json'),
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', 'custom')),
'utf8',
);
const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-'));
tempDirectories.push(outputDirectory);
await expect(createStaticProjectPackage({
projectPath,
archivePath: join(outputDirectory, 'project.zip'),
})).rejects.toMatchObject<ProjectPackageError>({
code: 'PROJECT_TYPE_UNPUBLISHABLE',
message: '自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
});
});
it('rejects a project that is not a locked npm Vite project', async () => {
const projectPath = await createProject();
await writeFile(

View File

@@ -76,7 +76,7 @@ describe('ProjectPublishAction', () => {
it('submits automatic metadata and locks after Builder succeeds', async () => {
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled();
@@ -89,7 +89,7 @@ describe('ProjectPublishAction', () => {
title: 'space-cleaner',
summary: 'space-cleaner由 Makelore 创建的作品。',
cover_url: null,
category: 'web',
category: 'mini_game',
age_band: null,
difficulty: null,
},
@@ -109,7 +109,7 @@ describe('ProjectPublishAction', () => {
fetchCurrentWorksProjectStatusMock.mockResolvedValue(
projectStatus('failed', 'BROWSER_SMOKE_FAILED'),
);
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
@@ -128,7 +128,7 @@ describe('ProjectPublishAction', () => {
fetchCurrentWorksProjectStatusMock.mockRejectedValue(
new Error('socket closed at /srv/private/status.py'),
);
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
@@ -145,7 +145,7 @@ describe('ProjectPublishAction', () => {
const status = projectStatus('queued');
status.latest_version.id = 'another-version';
fetchCurrentWorksProjectStatusMock.mockResolvedValue(status);
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
@@ -165,7 +165,7 @@ describe('ProjectPublishAction', () => {
new Error('Traceback: failed at /srv/private/package.ts token=secret'),
{ code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 },
));
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
@@ -182,14 +182,14 @@ describe('ProjectPublishAction', () => {
new Error('D:/repo/space-cleaner/package-lock.json does not exist'),
{ code: 'PROJECT_FILE_MISSING' },
));
render(<ProjectPublishAction project={project} />);
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('项目文件不完整');
expect(failure).toHaveTextContent('请让开发助手补齐 package.json、package-lock.json 和 index.html');
expect(failure).toHaveTextContent('请让开发助手修复当前项目模板');
expect(failure).not.toHaveTextContent('D:/repo');
expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING');
expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled();
@@ -197,7 +197,7 @@ describe('ProjectPublishAction', () => {
it('keeps generated metadata within the server limits', async () => {
const longName = `作品-${'x'.repeat(250)}`;
render(<ProjectPublishAction project={{ ...project, name: longName }} />);
render(<ProjectPublishAction project={{ ...project, name: longName }} projectType="mini_program" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await flushSubmission();
@@ -206,5 +206,6 @@ describe('ProjectPublishAction', () => {
expect(input.project.app_id).toBe('makelore-prj-space-cleaner');
expect(input.project.title).toHaveLength(160);
expect(input.project.summary).toBe(`${input.project.title},由 Makelore 创建的作品。`);
expect(input.project.category).toBe('mini_program');
});
});

View File

@@ -232,9 +232,9 @@ describe('Sidebar project initialization flow', () => {
expect(update).toHaveAccessibleName('下载新版本 0.9.2');
});
it('creates a path-backed project without selecting a template and opens configuration', async () => {
it('defaults to a publishable mini-game project and opens configuration', async () => {
const project = { id: 'prj_game', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '2026-07-11T00:00:00.000Z', updatedAt: '2026-07-11T00:00:00.000Z', lastOpenedAt: '2026-07-11T00:00:00.000Z' };
const config = createProjectConfig();
const config = createProjectConfig(undefined, 'mini_game');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo/星星收集器'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
@@ -245,19 +245,22 @@ describe('Sidebar project initialization flow', () => {
});
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
expect(screen.getByRole('radio', { name: '小游戏' })).toBeChecked();
expect(screen.getByRole('radio', { name: '小程序' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '自定义项目' })).not.toBeChecked();
expect(screen.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked();
expect(screen.queryByLabelText('项目名称')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
await waitFor(() => expect(screen.getByLabelText('项目路径')).toHaveValue('D:/repo/星星收集器'));
expect(screen.getByText('将直接接入此文件夹,项目名称使用“星星收集器”,不会重命名目录。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认创建' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器' }) }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/create', { method: 'POST', body: JSON.stringify({ projectPath: 'D:/repo/星星收集器', projectType: 'mini_game' }) }));
await waitFor(() => expect(navigateMock).toHaveBeenCalledWith('/project-config'));
});
it('keeps creating a named child folder when that directory mode is selected', async () => {
const project = { id: 'prj_child', path: 'D:/repo/星星收集器', name: '星星收集器', createdAt: '', updatedAt: '', lastOpenedAt: '' };
const config = createProjectConfig();
const config = createProjectConfig(undefined, 'mini_program');
invokeIpcMock.mockResolvedValue({ canceled: false, filePaths: ['D:/repo'] });
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/opencode/projects') return { projects: [], activeProject: null };
@@ -269,6 +272,7 @@ describe('Sidebar project initialization flow', () => {
render(<MemoryRouter><Sidebar /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '新建项目' }));
fireEvent.click(screen.getByRole('radio', { name: '小程序' }));
fireEvent.click(screen.getByRole('radio', { name: '在所选位置新建下级文件夹' }));
fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '星星收集器' } });
fireEvent.click(screen.getByRole('button', { name: '选择路径' }));
@@ -280,6 +284,7 @@ describe('Sidebar project initialization flow', () => {
body: JSON.stringify({
parentPath: 'D:/repo',
projectName: '星星收集器',
projectType: 'mini_program',
}),
}));
});

View File

@@ -6,7 +6,8 @@ import {
describe('works project publish guidance', () => {
it.each([
['PROJECT_FILE_MISSING', '项目文件不完整', 'package-lock.json'],
['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'],
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'],
['DEPENDENCY_PREFETCH_FAILED', '暂时无法下载项目依赖', 'package-lock.json'],
['OUTPUT_MISSING', '没有生成可运行页面', 'index.html'],
['RELEASE_STORE_FAILED', '平台暂时无法保存发布文件', '无需修改项目'],

View File

@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleWorksRoutes } from '@electron/api/routes/works';
import { createProjectConfig } from '../../shared/project-config';
const readWorksPublishFileMock = vi.hoisted(() => vi.fn());
const readWorksDeployCheckMock = vi.hoisted(() => vi.fn());
@@ -57,6 +58,12 @@ function createRequest(method: string, body?: unknown, headers: Record<string, s
async function writePublishableProject(projectPath: string): Promise<void> {
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(projectPath, '.niancode'), { recursive: true });
await writeFile(
join(projectPath, '.niancode', 'project.json'),
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', 'mini_game')),
'utf8',
);
await writeFile(join(projectPath, 'package.json'), JSON.stringify({
name: 'space-cleaner',
private: true,