实现小游戏与小程序项目创建发布
This commit is contained in:
@@ -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 failed:14 项因本机缺少 `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 执行。
|
||||
@@ -8,7 +8,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版
|
||||
- `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。
|
||||
- `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。
|
||||
|
||||
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。AI 编程项目只在现有项目配置底部提供“一键提交审核”:Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。项目成果预览 `/deliverables` 继续保留。
|
||||
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
|
||||
|
||||
## 当前产品状态
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
139
electron/opencode/project-template.ts
Normal file
139
electron/opencode/project-template.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
1164
electron/opencode/project-templates/vite/package-lock.json
generated
Normal file
1164
electron/opencode/project-templates/vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
electron/opencode/project-templates/vite/package.json
Normal file
15
electron/opencode/project-templates/vite/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: '项目配置无法读取',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '新增伙伴' }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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', '平台暂时无法保存发布文件', '无需修改项目'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user