popeye-cli 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -1
- package/CONTRIBUTING.md +10 -0
- package/README.md +224 -17
- package/dist/adapters/claude.d.ts +3 -2
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +214 -0
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/gemini.d.ts +2 -2
- package/dist/adapters/gemini.d.ts.map +1 -1
- package/dist/adapters/grok.d.ts +2 -1
- package/dist/adapters/grok.d.ts.map +1 -1
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/openai.d.ts +2 -2
- package/dist/adapters/openai.d.ts.map +1 -1
- package/dist/adapters/openai.js.map +1 -1
- package/dist/cli/commands/create.d.ts.map +1 -1
- package/dist/cli/commands/create.js +25 -5
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +5 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +354 -28
- package/dist/cli/interactive.js.map +1 -1
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +2 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/generators/all.d.ts +70 -0
- package/dist/generators/all.d.ts.map +1 -0
- package/dist/generators/all.js +826 -0
- package/dist/generators/all.js.map +1 -0
- package/dist/generators/fullstack.d.ts +9 -0
- package/dist/generators/fullstack.d.ts.map +1 -1
- package/dist/generators/fullstack.js.map +1 -1
- package/dist/generators/index.d.ts +3 -1
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +33 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/templates/index.d.ts +2 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +2 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/generators/templates/website.d.ts +85 -0
- package/dist/generators/templates/website.d.ts.map +1 -0
- package/dist/generators/templates/website.js +877 -0
- package/dist/generators/templates/website.js.map +1 -0
- package/dist/generators/website.d.ts +56 -0
- package/dist/generators/website.d.ts.map +1 -0
- package/dist/generators/website.js +269 -0
- package/dist/generators/website.js.map +1 -0
- package/dist/types/consensus.d.ts +18 -23
- package/dist/types/consensus.d.ts.map +1 -1
- package/dist/types/consensus.js +8 -3
- package/dist/types/consensus.js.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -2
- package/dist/types/index.js.map +1 -1
- package/dist/types/project.d.ts +130 -17
- package/dist/types/project.d.ts.map +1 -1
- package/dist/types/project.js +55 -8
- package/dist/types/project.js.map +1 -1
- package/dist/types/workflow.d.ts +2 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +2 -1
- package/dist/types/workflow.js.map +1 -1
- package/dist/upgrade/context.d.ts +37 -0
- package/dist/upgrade/context.d.ts.map +1 -0
- package/dist/upgrade/context.js +284 -0
- package/dist/upgrade/context.js.map +1 -0
- package/dist/upgrade/handlers.d.ts +103 -0
- package/dist/upgrade/handlers.d.ts.map +1 -0
- package/dist/upgrade/handlers.js +384 -0
- package/dist/upgrade/handlers.js.map +1 -0
- package/dist/upgrade/index.d.ts +26 -0
- package/dist/upgrade/index.d.ts.map +1 -0
- package/dist/upgrade/index.js +194 -0
- package/dist/upgrade/index.js.map +1 -0
- package/dist/upgrade/transitions.d.ts +34 -0
- package/dist/upgrade/transitions.d.ts.map +1 -0
- package/dist/upgrade/transitions.js +56 -0
- package/dist/upgrade/transitions.js.map +1 -0
- package/dist/workflow/consensus.d.ts +2 -1
- package/dist/workflow/consensus.d.ts.map +1 -1
- package/dist/workflow/consensus.js.map +1 -1
- package/dist/workflow/index.d.ts +6 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +8 -0
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/plan-mode.d.ts +3 -3
- package/dist/workflow/plan-mode.d.ts.map +1 -1
- package/dist/workflow/plan-mode.js +41 -5
- package/dist/workflow/plan-mode.js.map +1 -1
- package/dist/workflow/plan-parser.d.ts +97 -0
- package/dist/workflow/plan-parser.d.ts.map +1 -0
- package/dist/workflow/plan-parser.js +235 -0
- package/dist/workflow/plan-parser.js.map +1 -0
- package/dist/workflow/plan-storage.d.ts +40 -12
- package/dist/workflow/plan-storage.d.ts.map +1 -1
- package/dist/workflow/plan-storage.js +47 -20
- package/dist/workflow/plan-storage.js.map +1 -1
- package/dist/workflow/seo-tests.d.ts +43 -0
- package/dist/workflow/seo-tests.d.ts.map +1 -0
- package/dist/workflow/seo-tests.js +192 -0
- package/dist/workflow/seo-tests.js.map +1 -0
- package/dist/workflow/separation-guard.d.ts +35 -0
- package/dist/workflow/separation-guard.d.ts.map +1 -0
- package/dist/workflow/separation-guard.js +154 -0
- package/dist/workflow/separation-guard.js.map +1 -0
- package/dist/workflow/task-workflow.d.ts.map +1 -1
- package/dist/workflow/task-workflow.js +3 -2
- package/dist/workflow/task-workflow.js.map +1 -1
- package/dist/workflow/test-runner.d.ts.map +1 -1
- package/dist/workflow/test-runner.js +128 -0
- package/dist/workflow/test-runner.js.map +1 -1
- package/dist/workflow/workspace-manager.d.ts +31 -20
- package/dist/workflow/workspace-manager.d.ts.map +1 -1
- package/dist/workflow/workspace-manager.js +38 -9
- package/dist/workflow/workspace-manager.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude.ts +221 -4
- package/src/adapters/gemini.ts +2 -2
- package/src/adapters/grok.ts +2 -1
- package/src/adapters/index.ts +15 -0
- package/src/adapters/openai.ts +2 -2
- package/src/cli/commands/create.ts +25 -5
- package/src/cli/index.ts +5 -2
- package/src/cli/interactive.ts +400 -29
- package/src/config/schema.ts +2 -1
- package/src/generators/all.ts +897 -0
- package/src/generators/fullstack.ts +10 -0
- package/src/generators/index.ts +54 -0
- package/src/generators/templates/index.ts +2 -0
- package/src/generators/templates/website.ts +906 -0
- package/src/generators/website.ts +350 -0
- package/src/types/consensus.ts +20 -8
- package/src/types/index.ts +35 -0
- package/src/types/project.ts +157 -11
- package/src/types/workflow.ts +2 -1
- package/src/upgrade/context.ts +332 -0
- package/src/upgrade/handlers.ts +477 -0
- package/src/upgrade/index.ts +244 -0
- package/src/upgrade/transitions.ts +80 -0
- package/src/workflow/consensus.ts +3 -2
- package/src/workflow/index.ts +8 -0
- package/src/workflow/plan-mode.ts +44 -10
- package/src/workflow/plan-parser.ts +317 -0
- package/src/workflow/plan-storage.ts +69 -30
- package/src/workflow/seo-tests.ts +246 -0
- package/src/workflow/separation-guard.ts +200 -0
- package/src/workflow/task-workflow.ts +3 -2
- package/src/workflow/test-runner.ts +149 -0
- package/src/workflow/workspace-manager.ts +68 -31
- package/tests/cli/model-command.test.ts +93 -0
- package/tests/types/project.test.ts +90 -15
- package/tests/types/workflow-schema.test.ts +59 -0
- package/tests/upgrade/context.test.ts +211 -0
- package/tests/upgrade/transitions.test.ts +85 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade path handlers
|
|
3
|
+
* Implements specific upgrade transitions between project types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import type { OutputLanguage, ProjectSpec } from '../types/project.js';
|
|
9
|
+
import { generateWebsiteProject } from '../generators/website.js';
|
|
10
|
+
import { generatePythonProject } from '../generators/python.js';
|
|
11
|
+
import { generateTypeScriptProject } from '../generators/typescript.js';
|
|
12
|
+
import {
|
|
13
|
+
generateAllWorkspaceJson,
|
|
14
|
+
generateRootPackageJson,
|
|
15
|
+
generateAllDockerCompose,
|
|
16
|
+
generateDesignTokensPackage,
|
|
17
|
+
generateUiPackage,
|
|
18
|
+
} from '../generators/all.js';
|
|
19
|
+
import {
|
|
20
|
+
generateWorkspaceJson,
|
|
21
|
+
generateRootDockerCompose,
|
|
22
|
+
} from '../generators/templates/fullstack.js';
|
|
23
|
+
import { loadState, saveState } from '../state/persistence.js';
|
|
24
|
+
import type { UpgradeResult } from './index.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a directory if it doesn't exist
|
|
28
|
+
*/
|
|
29
|
+
async function ensureDir(dirPath: string): Promise<void> {
|
|
30
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if a path exists
|
|
35
|
+
*/
|
|
36
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
await fs.access(filePath);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Update state.json language field
|
|
47
|
+
*
|
|
48
|
+
* @param projectDir - Project directory
|
|
49
|
+
* @param newLanguage - New language value
|
|
50
|
+
*/
|
|
51
|
+
export async function updateStateLanguage(
|
|
52
|
+
projectDir: string,
|
|
53
|
+
newLanguage: OutputLanguage,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const state = await loadState(projectDir);
|
|
56
|
+
if (state) {
|
|
57
|
+
state.language = newLanguage;
|
|
58
|
+
await saveState(projectDir, state);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Update popeye.md language field
|
|
64
|
+
*
|
|
65
|
+
* @param projectDir - Project directory
|
|
66
|
+
* @param newLanguage - New language value
|
|
67
|
+
*/
|
|
68
|
+
export async function updatePopeyeLanguage(
|
|
69
|
+
projectDir: string,
|
|
70
|
+
newLanguage: OutputLanguage,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const configPath = path.join(projectDir, 'popeye.md');
|
|
73
|
+
try {
|
|
74
|
+
let content = await fs.readFile(configPath, 'utf-8');
|
|
75
|
+
content = content.replace(
|
|
76
|
+
/language:\s*.+/,
|
|
77
|
+
`language: ${newLanguage}`,
|
|
78
|
+
);
|
|
79
|
+
await fs.writeFile(configPath, content, 'utf-8');
|
|
80
|
+
} catch {
|
|
81
|
+
// popeye.md doesn't exist, skip
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Update workspace.json for target language (idempotent)
|
|
87
|
+
*
|
|
88
|
+
* @param projectDir - Project directory
|
|
89
|
+
* @param projectName - Project name
|
|
90
|
+
* @param targetLanguage - Target language
|
|
91
|
+
*/
|
|
92
|
+
export async function updateWorkspaceJson(
|
|
93
|
+
projectDir: string,
|
|
94
|
+
projectName: string,
|
|
95
|
+
targetLanguage: OutputLanguage,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const workspacePath = path.join(projectDir, '.popeye', 'workspace.json');
|
|
98
|
+
await ensureDir(path.dirname(workspacePath));
|
|
99
|
+
|
|
100
|
+
if (targetLanguage === 'all') {
|
|
101
|
+
const content = generateAllWorkspaceJson(projectName);
|
|
102
|
+
await fs.writeFile(workspacePath, content, 'utf-8');
|
|
103
|
+
} else if (targetLanguage === 'fullstack') {
|
|
104
|
+
const content = JSON.stringify(generateWorkspaceJson(projectName), null, 2);
|
|
105
|
+
await fs.writeFile(workspacePath, content, 'utf-8');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Update root package.json workspaces (idempotent)
|
|
111
|
+
*
|
|
112
|
+
* @param projectDir - Project directory
|
|
113
|
+
* @param projectName - Project name
|
|
114
|
+
* @param targetLanguage - Target language
|
|
115
|
+
*/
|
|
116
|
+
export async function updateRootPackageJson(
|
|
117
|
+
projectDir: string,
|
|
118
|
+
projectName: string,
|
|
119
|
+
targetLanguage: OutputLanguage,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
122
|
+
|
|
123
|
+
if (targetLanguage === 'all') {
|
|
124
|
+
await fs.writeFile(pkgPath, generateRootPackageJson(projectName), 'utf-8');
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
const content = await fs.readFile(pkgPath, 'utf-8');
|
|
128
|
+
const pkg = JSON.parse(content);
|
|
129
|
+
if (!pkg.workspaces) {
|
|
130
|
+
pkg.workspaces = ['apps/*'];
|
|
131
|
+
} else if (!pkg.workspaces.includes('apps/*')) {
|
|
132
|
+
pkg.workspaces.push('apps/*');
|
|
133
|
+
}
|
|
134
|
+
pkg.private = true;
|
|
135
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
|
|
136
|
+
} catch {
|
|
137
|
+
const pkg = {
|
|
138
|
+
name: `@${projectName}/root`,
|
|
139
|
+
private: true,
|
|
140
|
+
workspaces: ['apps/*'],
|
|
141
|
+
};
|
|
142
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Update docker-compose.yml (idempotent)
|
|
149
|
+
*
|
|
150
|
+
* @param projectDir - Project directory
|
|
151
|
+
* @param projectName - Project name
|
|
152
|
+
* @param targetLanguage - Target language
|
|
153
|
+
*/
|
|
154
|
+
export async function updateDockerCompose(
|
|
155
|
+
projectDir: string,
|
|
156
|
+
projectName: string,
|
|
157
|
+
targetLanguage: OutputLanguage,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const content = targetLanguage === 'all'
|
|
160
|
+
? generateAllDockerCompose(projectName)
|
|
161
|
+
: targetLanguage === 'fullstack'
|
|
162
|
+
? generateRootDockerCompose(projectName)
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
if (content) {
|
|
166
|
+
await fs.writeFile(path.join(projectDir, 'docker-compose.yml'), content, 'utf-8');
|
|
167
|
+
await ensureDir(path.join(projectDir, 'infra', 'docker'));
|
|
168
|
+
await fs.writeFile(
|
|
169
|
+
path.join(projectDir, 'infra', 'docker', 'docker-compose.yml'),
|
|
170
|
+
content,
|
|
171
|
+
'utf-8',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate shared packages for 'all' projects (idempotent)
|
|
178
|
+
*
|
|
179
|
+
* @param projectDir - Project directory
|
|
180
|
+
* @param projectName - Project name
|
|
181
|
+
* @returns List of created files
|
|
182
|
+
*/
|
|
183
|
+
export async function generateSharedPackages(
|
|
184
|
+
projectDir: string,
|
|
185
|
+
projectName: string,
|
|
186
|
+
): Promise<string[]> {
|
|
187
|
+
const filesCreated: string[] = [];
|
|
188
|
+
|
|
189
|
+
const tokensDir = path.join(projectDir, 'packages', 'design-tokens');
|
|
190
|
+
if (!(await pathExists(tokensDir))) {
|
|
191
|
+
const designTokens = generateDesignTokensPackage(projectName);
|
|
192
|
+
for (const file of designTokens.files) {
|
|
193
|
+
const filePath = path.join(tokensDir, file.path);
|
|
194
|
+
await ensureDir(path.dirname(filePath));
|
|
195
|
+
await fs.writeFile(filePath, file.content, 'utf-8');
|
|
196
|
+
filesCreated.push(filePath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const uiDir = path.join(projectDir, 'packages', 'ui');
|
|
201
|
+
if (!(await pathExists(uiDir))) {
|
|
202
|
+
const uiPackage = generateUiPackage(projectName);
|
|
203
|
+
for (const file of uiPackage.files) {
|
|
204
|
+
const filePath = path.join(uiDir, file.path);
|
|
205
|
+
await ensureDir(path.dirname(filePath));
|
|
206
|
+
await fs.writeFile(filePath, file.content, 'utf-8');
|
|
207
|
+
filesCreated.push(filePath);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const contractsDir = path.join(projectDir, 'packages', 'contracts');
|
|
212
|
+
if (!(await pathExists(contractsDir))) {
|
|
213
|
+
await ensureDir(contractsDir);
|
|
214
|
+
await fs.writeFile(path.join(contractsDir, '.gitkeep'), '', 'utf-8');
|
|
215
|
+
filesCreated.push(path.join(contractsDir, '.gitkeep'));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return filesCreated;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initialize plan storage directories for new apps
|
|
223
|
+
*
|
|
224
|
+
* @param projectDir - Project directory
|
|
225
|
+
* @param newApps - New app types
|
|
226
|
+
*/
|
|
227
|
+
export async function initPlanStorageDirs(
|
|
228
|
+
projectDir: string,
|
|
229
|
+
newApps: string[],
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const docsDir = path.join(projectDir, 'docs');
|
|
232
|
+
await ensureDir(docsDir);
|
|
233
|
+
await ensureDir(path.join(docsDir, 'plans'));
|
|
234
|
+
await ensureDir(path.join(docsDir, 'tests'));
|
|
235
|
+
|
|
236
|
+
for (const app of newApps) {
|
|
237
|
+
await ensureDir(path.join(docsDir, 'plans', app));
|
|
238
|
+
await ensureDir(path.join(docsDir, 'tests', app));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Move directory contents to a new location, excluding infrastructure dirs
|
|
244
|
+
*
|
|
245
|
+
* @param src - Source directory
|
|
246
|
+
* @param dest - Destination directory
|
|
247
|
+
* @param excludeDirs - Additional directories to exclude
|
|
248
|
+
* @returns List of moved items
|
|
249
|
+
*/
|
|
250
|
+
export async function moveDirectoryContents(
|
|
251
|
+
src: string,
|
|
252
|
+
dest: string,
|
|
253
|
+
excludeDirs: string[] = [],
|
|
254
|
+
): Promise<string[]> {
|
|
255
|
+
const moved: string[] = [];
|
|
256
|
+
await ensureDir(dest);
|
|
257
|
+
|
|
258
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
259
|
+
const excludeSet = new Set([
|
|
260
|
+
...excludeDirs,
|
|
261
|
+
'node_modules', '.git', '__pycache__', 'apps', 'packages',
|
|
262
|
+
'.popeye', 'docs', 'popeye.md',
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
if (excludeSet.has(entry.name)) continue;
|
|
267
|
+
|
|
268
|
+
const srcPath = path.join(src, entry.name);
|
|
269
|
+
const destPath = path.join(dest, entry.name);
|
|
270
|
+
|
|
271
|
+
await fs.rename(srcPath, destPath);
|
|
272
|
+
moved.push(`${entry.name} -> ${path.relative(src, destPath)}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return moved;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Perform fullstack -> all upgrade
|
|
280
|
+
*
|
|
281
|
+
* @param projectDir - Project directory
|
|
282
|
+
* @param projectName - Project name
|
|
283
|
+
* @returns Upgrade result
|
|
284
|
+
*/
|
|
285
|
+
export async function upgradeFullstackToAll(
|
|
286
|
+
projectDir: string,
|
|
287
|
+
projectName: string,
|
|
288
|
+
): Promise<UpgradeResult> {
|
|
289
|
+
const filesCreated: string[] = [];
|
|
290
|
+
|
|
291
|
+
const websiteDir = path.join(projectDir, 'apps', 'website');
|
|
292
|
+
if (!(await pathExists(websiteDir))) {
|
|
293
|
+
const spec: ProjectSpec = {
|
|
294
|
+
idea: 'Marketing website',
|
|
295
|
+
name: projectName,
|
|
296
|
+
language: 'all',
|
|
297
|
+
openaiModel: 'gpt-4o',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const result = await generateWebsiteProject(spec, projectDir, {
|
|
301
|
+
baseDir: websiteDir,
|
|
302
|
+
workspaceMode: true,
|
|
303
|
+
skipDocker: true,
|
|
304
|
+
skipReadme: true,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (!result.success) {
|
|
308
|
+
return {
|
|
309
|
+
success: false, from: 'fullstack', to: 'all',
|
|
310
|
+
filesCreated, filesMoved: [],
|
|
311
|
+
error: result.error || 'Failed to generate website app',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
filesCreated.push(...result.filesCreated);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const sharedFiles = await generateSharedPackages(projectDir, projectName);
|
|
318
|
+
filesCreated.push(...sharedFiles);
|
|
319
|
+
|
|
320
|
+
await updateWorkspaceJson(projectDir, projectName, 'all');
|
|
321
|
+
await updateRootPackageJson(projectDir, projectName, 'all');
|
|
322
|
+
await updateDockerCompose(projectDir, projectName, 'all');
|
|
323
|
+
await updateStateLanguage(projectDir, 'all');
|
|
324
|
+
await updatePopeyeLanguage(projectDir, 'all');
|
|
325
|
+
await initPlanStorageDirs(projectDir, ['website']);
|
|
326
|
+
|
|
327
|
+
return { success: true, from: 'fullstack', to: 'all', filesCreated, filesMoved: [] };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Perform single-app to fullstack upgrade (requires restructure)
|
|
332
|
+
*
|
|
333
|
+
* @param projectDir - Project directory
|
|
334
|
+
* @param projectName - Project name
|
|
335
|
+
* @param from - Current language
|
|
336
|
+
* @returns Upgrade result
|
|
337
|
+
*/
|
|
338
|
+
export async function upgradeSingleToFullstack(
|
|
339
|
+
projectDir: string,
|
|
340
|
+
projectName: string,
|
|
341
|
+
from: OutputLanguage,
|
|
342
|
+
): Promise<UpgradeResult> {
|
|
343
|
+
const filesCreated: string[] = [];
|
|
344
|
+
const filesMoved: string[] = [];
|
|
345
|
+
|
|
346
|
+
await ensureDir(path.join(projectDir, 'apps'));
|
|
347
|
+
|
|
348
|
+
if (from === 'python') {
|
|
349
|
+
const backendDir = path.join(projectDir, 'apps', 'backend');
|
|
350
|
+
if (!(await pathExists(backendDir))) {
|
|
351
|
+
const moved = await moveDirectoryContents(projectDir, backendDir);
|
|
352
|
+
filesMoved.push(...moved);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const frontendDir = path.join(projectDir, 'apps', 'frontend');
|
|
356
|
+
if (!(await pathExists(frontendDir))) {
|
|
357
|
+
const spec: ProjectSpec = {
|
|
358
|
+
idea: 'Frontend application', name: projectName,
|
|
359
|
+
language: 'fullstack', openaiModel: 'gpt-4o',
|
|
360
|
+
};
|
|
361
|
+
const result = await generateTypeScriptProject(spec, path.join(projectDir, 'apps'), {
|
|
362
|
+
baseDir: frontendDir,
|
|
363
|
+
});
|
|
364
|
+
if (result.success) filesCreated.push(...result.filesCreated);
|
|
365
|
+
}
|
|
366
|
+
} else if (from === 'typescript') {
|
|
367
|
+
const frontendDir = path.join(projectDir, 'apps', 'frontend');
|
|
368
|
+
if (!(await pathExists(frontendDir))) {
|
|
369
|
+
const moved = await moveDirectoryContents(projectDir, frontendDir);
|
|
370
|
+
filesMoved.push(...moved);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const backendDir = path.join(projectDir, 'apps', 'backend');
|
|
374
|
+
if (!(await pathExists(backendDir))) {
|
|
375
|
+
const spec: ProjectSpec = {
|
|
376
|
+
idea: 'Backend API', name: projectName,
|
|
377
|
+
language: 'fullstack', openaiModel: 'gpt-4o',
|
|
378
|
+
};
|
|
379
|
+
const result = await generatePythonProject(spec, path.join(projectDir, 'apps'), {
|
|
380
|
+
baseDir: backendDir,
|
|
381
|
+
});
|
|
382
|
+
if (result.success) filesCreated.push(...result.filesCreated);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await updateWorkspaceJson(projectDir, projectName, 'fullstack');
|
|
387
|
+
await updateRootPackageJson(projectDir, projectName, 'fullstack');
|
|
388
|
+
await updateDockerCompose(projectDir, projectName, 'fullstack');
|
|
389
|
+
await updateStateLanguage(projectDir, 'fullstack');
|
|
390
|
+
await updatePopeyeLanguage(projectDir, 'fullstack');
|
|
391
|
+
await initPlanStorageDirs(projectDir, ['frontend', 'backend']);
|
|
392
|
+
|
|
393
|
+
return { success: true, from, to: 'fullstack', filesCreated, filesMoved };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Perform single-app to all upgrade (delegates to fullstack then all)
|
|
398
|
+
*
|
|
399
|
+
* @param projectDir - Project directory
|
|
400
|
+
* @param projectName - Project name
|
|
401
|
+
* @param from - Current language
|
|
402
|
+
* @returns Upgrade result
|
|
403
|
+
*/
|
|
404
|
+
export async function upgradeSingleToAll(
|
|
405
|
+
projectDir: string,
|
|
406
|
+
projectName: string,
|
|
407
|
+
from: OutputLanguage,
|
|
408
|
+
): Promise<UpgradeResult> {
|
|
409
|
+
const fsResult = await upgradeSingleToFullstack(projectDir, projectName, from);
|
|
410
|
+
if (!fsResult.success) return { ...fsResult, to: 'all' };
|
|
411
|
+
|
|
412
|
+
const allResult = await upgradeFullstackToAll(projectDir, projectName);
|
|
413
|
+
return {
|
|
414
|
+
success: allResult.success, from, to: 'all',
|
|
415
|
+
filesCreated: [...fsResult.filesCreated, ...allResult.filesCreated],
|
|
416
|
+
filesMoved: fsResult.filesMoved,
|
|
417
|
+
error: allResult.error,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Perform website -> all upgrade
|
|
423
|
+
*
|
|
424
|
+
* @param projectDir - Project directory
|
|
425
|
+
* @param projectName - Project name
|
|
426
|
+
* @returns Upgrade result
|
|
427
|
+
*/
|
|
428
|
+
export async function upgradeWebsiteToAll(
|
|
429
|
+
projectDir: string,
|
|
430
|
+
projectName: string,
|
|
431
|
+
): Promise<UpgradeResult> {
|
|
432
|
+
const filesCreated: string[] = [];
|
|
433
|
+
const filesMoved: string[] = [];
|
|
434
|
+
|
|
435
|
+
await ensureDir(path.join(projectDir, 'apps'));
|
|
436
|
+
const websiteDir = path.join(projectDir, 'apps', 'website');
|
|
437
|
+
if (!(await pathExists(websiteDir))) {
|
|
438
|
+
const moved = await moveDirectoryContents(projectDir, websiteDir);
|
|
439
|
+
filesMoved.push(...moved);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const frontendDir = path.join(projectDir, 'apps', 'frontend');
|
|
443
|
+
if (!(await pathExists(frontendDir))) {
|
|
444
|
+
const spec: ProjectSpec = {
|
|
445
|
+
idea: 'Frontend application', name: projectName,
|
|
446
|
+
language: 'all', openaiModel: 'gpt-4o',
|
|
447
|
+
};
|
|
448
|
+
const result = await generateTypeScriptProject(spec, path.join(projectDir, 'apps'), {
|
|
449
|
+
baseDir: frontendDir,
|
|
450
|
+
});
|
|
451
|
+
if (result.success) filesCreated.push(...result.filesCreated);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const backendDir = path.join(projectDir, 'apps', 'backend');
|
|
455
|
+
if (!(await pathExists(backendDir))) {
|
|
456
|
+
const spec: ProjectSpec = {
|
|
457
|
+
idea: 'Backend API', name: projectName,
|
|
458
|
+
language: 'all', openaiModel: 'gpt-4o',
|
|
459
|
+
};
|
|
460
|
+
const result = await generatePythonProject(spec, path.join(projectDir, 'apps'), {
|
|
461
|
+
baseDir: backendDir,
|
|
462
|
+
});
|
|
463
|
+
if (result.success) filesCreated.push(...result.filesCreated);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const sharedFiles = await generateSharedPackages(projectDir, projectName);
|
|
467
|
+
filesCreated.push(...sharedFiles);
|
|
468
|
+
|
|
469
|
+
await updateWorkspaceJson(projectDir, projectName, 'all');
|
|
470
|
+
await updateRootPackageJson(projectDir, projectName, 'all');
|
|
471
|
+
await updateDockerCompose(projectDir, projectName, 'all');
|
|
472
|
+
await updateStateLanguage(projectDir, 'all');
|
|
473
|
+
await updatePopeyeLanguage(projectDir, 'all');
|
|
474
|
+
await initPlanStorageDirs(projectDir, ['frontend', 'backend', 'website']);
|
|
475
|
+
|
|
476
|
+
return { success: true, from: 'website', to: 'all', filesCreated, filesMoved };
|
|
477
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project type upgrade orchestrator
|
|
3
|
+
* Handles transactional upgrades between project types with rollback support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import type { OutputLanguage } from '../types/project.js';
|
|
9
|
+
import { loadState } from '../state/persistence.js';
|
|
10
|
+
import { getTransitionDetails } from './transitions.js';
|
|
11
|
+
import type { UpgradeTransition } from './transitions.js';
|
|
12
|
+
import {
|
|
13
|
+
upgradeFullstackToAll,
|
|
14
|
+
upgradeSingleToFullstack,
|
|
15
|
+
upgradeSingleToAll,
|
|
16
|
+
upgradeWebsiteToAll,
|
|
17
|
+
} from './handlers.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Result of an upgrade operation
|
|
21
|
+
*/
|
|
22
|
+
export interface UpgradeResult {
|
|
23
|
+
success: boolean;
|
|
24
|
+
from: OutputLanguage;
|
|
25
|
+
to: OutputLanguage;
|
|
26
|
+
filesCreated: string[];
|
|
27
|
+
filesMoved: string[];
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Backup entry for rollback
|
|
33
|
+
*/
|
|
34
|
+
interface BackupEntry {
|
|
35
|
+
path: string;
|
|
36
|
+
content: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a directory if it doesn't exist
|
|
41
|
+
*/
|
|
42
|
+
async function ensureDir(dirPath: string): Promise<void> {
|
|
43
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if a path exists
|
|
48
|
+
*/
|
|
49
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(filePath);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Backup critical files for rollback
|
|
60
|
+
*
|
|
61
|
+
* @param projectDir - Project directory
|
|
62
|
+
* @returns Array of backup entries
|
|
63
|
+
*/
|
|
64
|
+
async function createBackup(projectDir: string): Promise<BackupEntry[]> {
|
|
65
|
+
const backups: BackupEntry[] = [];
|
|
66
|
+
const filesToBackup = [
|
|
67
|
+
'.popeye/state.json',
|
|
68
|
+
'.popeye/workspace.json',
|
|
69
|
+
'popeye.md',
|
|
70
|
+
'package.json',
|
|
71
|
+
'docker-compose.yml',
|
|
72
|
+
'infra/docker/docker-compose.yml',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const file of filesToBackup) {
|
|
76
|
+
const filePath = path.join(projectDir, file);
|
|
77
|
+
try {
|
|
78
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
79
|
+
backups.push({ path: filePath, content });
|
|
80
|
+
} catch {
|
|
81
|
+
// File doesn't exist, skip
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return backups;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore files from backup
|
|
90
|
+
*
|
|
91
|
+
* @param backups - Backup entries to restore
|
|
92
|
+
*/
|
|
93
|
+
async function restoreBackup(backups: BackupEntry[]): Promise<void> {
|
|
94
|
+
for (const backup of backups) {
|
|
95
|
+
await ensureDir(path.dirname(backup.path));
|
|
96
|
+
await fs.writeFile(backup.path, backup.content, 'utf-8');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate the upgrade result by checking expected directories exist
|
|
102
|
+
*
|
|
103
|
+
* @param projectDir - Project directory
|
|
104
|
+
* @param transition - Transition details
|
|
105
|
+
* @returns Validation result
|
|
106
|
+
*/
|
|
107
|
+
async function validateUpgrade(
|
|
108
|
+
projectDir: string,
|
|
109
|
+
transition: UpgradeTransition,
|
|
110
|
+
): Promise<{ valid: boolean; issues: string[] }> {
|
|
111
|
+
const issues: string[] = [];
|
|
112
|
+
|
|
113
|
+
// Check state.json is valid
|
|
114
|
+
const state = await loadState(projectDir);
|
|
115
|
+
if (!state) {
|
|
116
|
+
issues.push('state.json is missing or invalid');
|
|
117
|
+
} else if (state.language !== transition.to) {
|
|
118
|
+
issues.push(`state.json language is '${state.language}', expected '${transition.to}'`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check workspace.json exists for workspace types
|
|
122
|
+
if (transition.to === 'fullstack' || transition.to === 'all') {
|
|
123
|
+
const wsPath = path.join(projectDir, '.popeye', 'workspace.json');
|
|
124
|
+
if (!(await pathExists(wsPath))) {
|
|
125
|
+
issues.push('workspace.json is missing');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check new app directories exist
|
|
130
|
+
for (const app of transition.newApps) {
|
|
131
|
+
const appDir = path.join(projectDir, 'apps', app);
|
|
132
|
+
if (!(await pathExists(appDir))) {
|
|
133
|
+
issues.push(`apps/${app}/ directory is missing`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { valid: issues.length === 0, issues };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Upgrade a project from one type to another
|
|
142
|
+
* Transactional: creates backup, applies changes, validates, rolls back on failure
|
|
143
|
+
*
|
|
144
|
+
* @param projectDir - Project directory
|
|
145
|
+
* @param targetLanguage - Target project language
|
|
146
|
+
* @returns Upgrade result
|
|
147
|
+
*/
|
|
148
|
+
export async function upgradeProject(
|
|
149
|
+
projectDir: string,
|
|
150
|
+
targetLanguage: OutputLanguage,
|
|
151
|
+
): Promise<UpgradeResult> {
|
|
152
|
+
// Load current state
|
|
153
|
+
const state = await loadState(projectDir);
|
|
154
|
+
if (!state) {
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
from: 'python',
|
|
158
|
+
to: targetLanguage,
|
|
159
|
+
filesCreated: [],
|
|
160
|
+
filesMoved: [],
|
|
161
|
+
error: 'No project state found. Is this a Popeye project?',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const currentLanguage = state.language;
|
|
166
|
+
const transition = getTransitionDetails(currentLanguage, targetLanguage);
|
|
167
|
+
|
|
168
|
+
if (!transition) {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
from: currentLanguage,
|
|
172
|
+
to: targetLanguage,
|
|
173
|
+
filesCreated: [],
|
|
174
|
+
filesMoved: [],
|
|
175
|
+
error: `Cannot upgrade from '${currentLanguage}' to '${targetLanguage}'`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const projectName = state.name || path.basename(projectDir);
|
|
180
|
+
|
|
181
|
+
// Create backup for rollback
|
|
182
|
+
const backups = await createBackup(projectDir);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
let result: UpgradeResult;
|
|
186
|
+
|
|
187
|
+
// Dispatch to appropriate upgrade handler
|
|
188
|
+
if (currentLanguage === 'fullstack' && targetLanguage === 'all') {
|
|
189
|
+
result = await upgradeFullstackToAll(projectDir, projectName);
|
|
190
|
+
} else if (currentLanguage === 'website' && targetLanguage === 'all') {
|
|
191
|
+
result = await upgradeWebsiteToAll(projectDir, projectName);
|
|
192
|
+
} else if (
|
|
193
|
+
(currentLanguage === 'python' || currentLanguage === 'typescript') &&
|
|
194
|
+
targetLanguage === 'fullstack'
|
|
195
|
+
) {
|
|
196
|
+
result = await upgradeSingleToFullstack(projectDir, projectName, currentLanguage);
|
|
197
|
+
} else if (
|
|
198
|
+
(currentLanguage === 'python' || currentLanguage === 'typescript') &&
|
|
199
|
+
targetLanguage === 'all'
|
|
200
|
+
) {
|
|
201
|
+
result = await upgradeSingleToAll(projectDir, projectName, currentLanguage);
|
|
202
|
+
} else {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
from: currentLanguage,
|
|
206
|
+
to: targetLanguage,
|
|
207
|
+
filesCreated: [],
|
|
208
|
+
filesMoved: [],
|
|
209
|
+
error: `Upgrade path '${currentLanguage}' -> '${targetLanguage}' is not implemented`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!result.success) {
|
|
214
|
+
await restoreBackup(backups);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validate
|
|
219
|
+
const validation = await validateUpgrade(projectDir, transition);
|
|
220
|
+
if (!validation.valid) {
|
|
221
|
+
await restoreBackup(backups);
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
from: currentLanguage,
|
|
225
|
+
to: targetLanguage,
|
|
226
|
+
filesCreated: result.filesCreated,
|
|
227
|
+
filesMoved: result.filesMoved,
|
|
228
|
+
error: `Validation failed: ${validation.issues.join(', ')}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
await restoreBackup(backups);
|
|
235
|
+
return {
|
|
236
|
+
success: false,
|
|
237
|
+
from: currentLanguage,
|
|
238
|
+
to: targetLanguage,
|
|
239
|
+
filesCreated: [],
|
|
240
|
+
filesMoved: [],
|
|
241
|
+
error: error instanceof Error ? error.message : 'Unknown error during upgrade',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|