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.
Files changed (166) hide show
  1. package/.env.example +4 -1
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +224 -17
  4. package/dist/adapters/claude.d.ts +3 -2
  5. package/dist/adapters/claude.d.ts.map +1 -1
  6. package/dist/adapters/claude.js +214 -0
  7. package/dist/adapters/claude.js.map +1 -1
  8. package/dist/adapters/gemini.d.ts +2 -2
  9. package/dist/adapters/gemini.d.ts.map +1 -1
  10. package/dist/adapters/grok.d.ts +2 -1
  11. package/dist/adapters/grok.d.ts.map +1 -1
  12. package/dist/adapters/grok.js.map +1 -1
  13. package/dist/adapters/index.d.ts +8 -0
  14. package/dist/adapters/index.d.ts.map +1 -0
  15. package/dist/adapters/index.js +12 -0
  16. package/dist/adapters/index.js.map +1 -0
  17. package/dist/adapters/openai.d.ts +2 -2
  18. package/dist/adapters/openai.d.ts.map +1 -1
  19. package/dist/adapters/openai.js.map +1 -1
  20. package/dist/cli/commands/create.d.ts.map +1 -1
  21. package/dist/cli/commands/create.js +25 -5
  22. package/dist/cli/commands/create.js.map +1 -1
  23. package/dist/cli/index.d.ts +1 -0
  24. package/dist/cli/index.d.ts.map +1 -1
  25. package/dist/cli/index.js +5 -2
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/interactive.d.ts.map +1 -1
  28. package/dist/cli/interactive.js +354 -28
  29. package/dist/cli/interactive.js.map +1 -1
  30. package/dist/config/index.d.ts +2 -0
  31. package/dist/config/index.d.ts.map +1 -1
  32. package/dist/config/schema.d.ts +4 -0
  33. package/dist/config/schema.d.ts.map +1 -1
  34. package/dist/config/schema.js +2 -1
  35. package/dist/config/schema.js.map +1 -1
  36. package/dist/generators/all.d.ts +70 -0
  37. package/dist/generators/all.d.ts.map +1 -0
  38. package/dist/generators/all.js +826 -0
  39. package/dist/generators/all.js.map +1 -0
  40. package/dist/generators/fullstack.d.ts +9 -0
  41. package/dist/generators/fullstack.d.ts.map +1 -1
  42. package/dist/generators/fullstack.js.map +1 -1
  43. package/dist/generators/index.d.ts +3 -1
  44. package/dist/generators/index.d.ts.map +1 -1
  45. package/dist/generators/index.js +33 -0
  46. package/dist/generators/index.js.map +1 -1
  47. package/dist/generators/templates/index.d.ts +2 -0
  48. package/dist/generators/templates/index.d.ts.map +1 -1
  49. package/dist/generators/templates/index.js +2 -0
  50. package/dist/generators/templates/index.js.map +1 -1
  51. package/dist/generators/templates/website.d.ts +85 -0
  52. package/dist/generators/templates/website.d.ts.map +1 -0
  53. package/dist/generators/templates/website.js +877 -0
  54. package/dist/generators/templates/website.js.map +1 -0
  55. package/dist/generators/website.d.ts +56 -0
  56. package/dist/generators/website.d.ts.map +1 -0
  57. package/dist/generators/website.js +269 -0
  58. package/dist/generators/website.js.map +1 -0
  59. package/dist/types/consensus.d.ts +18 -23
  60. package/dist/types/consensus.d.ts.map +1 -1
  61. package/dist/types/consensus.js +8 -3
  62. package/dist/types/consensus.js.map +1 -1
  63. package/dist/types/index.d.ts +2 -2
  64. package/dist/types/index.d.ts.map +1 -1
  65. package/dist/types/index.js +2 -2
  66. package/dist/types/index.js.map +1 -1
  67. package/dist/types/project.d.ts +130 -17
  68. package/dist/types/project.d.ts.map +1 -1
  69. package/dist/types/project.js +55 -8
  70. package/dist/types/project.js.map +1 -1
  71. package/dist/types/workflow.d.ts +2 -0
  72. package/dist/types/workflow.d.ts.map +1 -1
  73. package/dist/types/workflow.js +2 -1
  74. package/dist/types/workflow.js.map +1 -1
  75. package/dist/upgrade/context.d.ts +37 -0
  76. package/dist/upgrade/context.d.ts.map +1 -0
  77. package/dist/upgrade/context.js +284 -0
  78. package/dist/upgrade/context.js.map +1 -0
  79. package/dist/upgrade/handlers.d.ts +103 -0
  80. package/dist/upgrade/handlers.d.ts.map +1 -0
  81. package/dist/upgrade/handlers.js +384 -0
  82. package/dist/upgrade/handlers.js.map +1 -0
  83. package/dist/upgrade/index.d.ts +26 -0
  84. package/dist/upgrade/index.d.ts.map +1 -0
  85. package/dist/upgrade/index.js +194 -0
  86. package/dist/upgrade/index.js.map +1 -0
  87. package/dist/upgrade/transitions.d.ts +34 -0
  88. package/dist/upgrade/transitions.d.ts.map +1 -0
  89. package/dist/upgrade/transitions.js +56 -0
  90. package/dist/upgrade/transitions.js.map +1 -0
  91. package/dist/workflow/consensus.d.ts +2 -1
  92. package/dist/workflow/consensus.d.ts.map +1 -1
  93. package/dist/workflow/consensus.js.map +1 -1
  94. package/dist/workflow/index.d.ts +6 -0
  95. package/dist/workflow/index.d.ts.map +1 -1
  96. package/dist/workflow/index.js +8 -0
  97. package/dist/workflow/index.js.map +1 -1
  98. package/dist/workflow/plan-mode.d.ts +3 -3
  99. package/dist/workflow/plan-mode.d.ts.map +1 -1
  100. package/dist/workflow/plan-mode.js +41 -5
  101. package/dist/workflow/plan-mode.js.map +1 -1
  102. package/dist/workflow/plan-parser.d.ts +97 -0
  103. package/dist/workflow/plan-parser.d.ts.map +1 -0
  104. package/dist/workflow/plan-parser.js +235 -0
  105. package/dist/workflow/plan-parser.js.map +1 -0
  106. package/dist/workflow/plan-storage.d.ts +40 -12
  107. package/dist/workflow/plan-storage.d.ts.map +1 -1
  108. package/dist/workflow/plan-storage.js +47 -20
  109. package/dist/workflow/plan-storage.js.map +1 -1
  110. package/dist/workflow/seo-tests.d.ts +43 -0
  111. package/dist/workflow/seo-tests.d.ts.map +1 -0
  112. package/dist/workflow/seo-tests.js +192 -0
  113. package/dist/workflow/seo-tests.js.map +1 -0
  114. package/dist/workflow/separation-guard.d.ts +35 -0
  115. package/dist/workflow/separation-guard.d.ts.map +1 -0
  116. package/dist/workflow/separation-guard.js +154 -0
  117. package/dist/workflow/separation-guard.js.map +1 -0
  118. package/dist/workflow/task-workflow.d.ts.map +1 -1
  119. package/dist/workflow/task-workflow.js +3 -2
  120. package/dist/workflow/task-workflow.js.map +1 -1
  121. package/dist/workflow/test-runner.d.ts.map +1 -1
  122. package/dist/workflow/test-runner.js +128 -0
  123. package/dist/workflow/test-runner.js.map +1 -1
  124. package/dist/workflow/workspace-manager.d.ts +31 -20
  125. package/dist/workflow/workspace-manager.d.ts.map +1 -1
  126. package/dist/workflow/workspace-manager.js +38 -9
  127. package/dist/workflow/workspace-manager.js.map +1 -1
  128. package/package.json +1 -1
  129. package/src/adapters/claude.ts +221 -4
  130. package/src/adapters/gemini.ts +2 -2
  131. package/src/adapters/grok.ts +2 -1
  132. package/src/adapters/index.ts +15 -0
  133. package/src/adapters/openai.ts +2 -2
  134. package/src/cli/commands/create.ts +25 -5
  135. package/src/cli/index.ts +5 -2
  136. package/src/cli/interactive.ts +400 -29
  137. package/src/config/schema.ts +2 -1
  138. package/src/generators/all.ts +897 -0
  139. package/src/generators/fullstack.ts +10 -0
  140. package/src/generators/index.ts +54 -0
  141. package/src/generators/templates/index.ts +2 -0
  142. package/src/generators/templates/website.ts +906 -0
  143. package/src/generators/website.ts +350 -0
  144. package/src/types/consensus.ts +20 -8
  145. package/src/types/index.ts +35 -0
  146. package/src/types/project.ts +157 -11
  147. package/src/types/workflow.ts +2 -1
  148. package/src/upgrade/context.ts +332 -0
  149. package/src/upgrade/handlers.ts +477 -0
  150. package/src/upgrade/index.ts +244 -0
  151. package/src/upgrade/transitions.ts +80 -0
  152. package/src/workflow/consensus.ts +3 -2
  153. package/src/workflow/index.ts +8 -0
  154. package/src/workflow/plan-mode.ts +44 -10
  155. package/src/workflow/plan-parser.ts +317 -0
  156. package/src/workflow/plan-storage.ts +69 -30
  157. package/src/workflow/seo-tests.ts +246 -0
  158. package/src/workflow/separation-guard.ts +200 -0
  159. package/src/workflow/task-workflow.ts +3 -2
  160. package/src/workflow/test-runner.ts +149 -0
  161. package/src/workflow/workspace-manager.ts +68 -31
  162. package/tests/cli/model-command.test.ts +93 -0
  163. package/tests/types/project.test.ts +90 -15
  164. package/tests/types/workflow-schema.test.ts +59 -0
  165. package/tests/upgrade/context.test.ts +211 -0
  166. 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
+ }