@nikelyh/metamorph 2.0.11 → 2.1.1
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/LICENSE +674 -0
- package/README.md +56 -31
- package/bin/metamorph.js +0 -0
- package/dist/NextMigrationHints-BGO2W3K4.js +32 -0
- package/dist/{chunk-GNWRSZXT.js → chunk-GKRBTA6E.js} +13 -0
- package/dist/chunk-H2LZEPNI.js +352 -0
- package/dist/chunk-L7B6KUCW.js +1018 -0
- package/dist/chunk-O2QLEES3.js +61 -0
- package/dist/chunk-VEAYUIOZ.js +1256 -0
- package/dist/index.js +2342 -1603
- package/dist/public/assets/index-C0AxAuiv.css +1 -0
- package/dist/public/assets/index-DtMWFmXl.js +63 -0
- package/dist/public/github.svg +1 -0
- package/dist/public/icons/angular.svg +1 -1
- package/dist/public/icons/expressjs.svg +1 -1
- package/dist/public/icons/fastify.svg +1 -1
- package/dist/public/icons/hapi.svg +1 -14
- package/dist/public/icons/nestjs.svg +1 -1
- package/dist/public/icons/nextjs.svg +1 -1
- package/dist/public/icons/react.svg +1 -1
- package/dist/public/icons/svelte.svg +1 -21
- package/dist/public/icons/vue.svg +1 -1
- package/dist/public/icons.svg +1 -24
- package/dist/public/index.html +5 -4
- package/dist/public/logo_metamorph.png +0 -0
- package/dist/registry-7WZ5RTGE.js +14 -0
- package/dist/{runtime-5OVGH2Y7.js → runtime-L7VOQROE.js} +1 -1
- package/dist/src-JHOGUCRS.js +43 -0
- package/dist/src-UC3SN46X.js +40 -0
- package/package.json +17 -4
- package/dist/public/assets/index-BsEDNMSB.css +0 -1
- package/dist/public/assets/index-CYjhs-Jc.js +0 -66
- package/dist/public/favicon.svg +0 -1
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveMigrationCatalog
|
|
3
|
+
} from "./chunk-VEAYUIOZ.js";
|
|
4
|
+
|
|
5
|
+
// ../infrastructure/src/db/SQLiteStateStore.ts
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { mkdirSync } from "fs";
|
|
9
|
+
var SQLiteStateStore = class {
|
|
10
|
+
db;
|
|
11
|
+
constructor(storageDir = ".metamorph") {
|
|
12
|
+
try {
|
|
13
|
+
mkdirSync(storageDir, { recursive: true });
|
|
14
|
+
} catch (e) {
|
|
15
|
+
}
|
|
16
|
+
const dbPath = join(storageDir, "history.db");
|
|
17
|
+
this.db = new DatabaseSync(dbPath);
|
|
18
|
+
this.initTables();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Creates the necessary tables if they do not exist.
|
|
22
|
+
*/
|
|
23
|
+
initTables() {
|
|
24
|
+
this.db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS plans (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
run_id TEXT,
|
|
28
|
+
target_path TEXT,
|
|
29
|
+
source_framework TEXT,
|
|
30
|
+
target_framework TEXT,
|
|
31
|
+
rules_json TEXT,
|
|
32
|
+
created_at TEXT,
|
|
33
|
+
integration_rounds INTEGER DEFAULT 0,
|
|
34
|
+
phase TEXT
|
|
35
|
+
);
|
|
36
|
+
`);
|
|
37
|
+
try {
|
|
38
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN run_id TEXT");
|
|
39
|
+
} catch (e) {
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN target_path TEXT");
|
|
43
|
+
} catch (e) {
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN integration_rounds INTEGER");
|
|
47
|
+
} catch (e) {
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN phase TEXT");
|
|
51
|
+
} catch (e) {
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN outcome TEXT");
|
|
55
|
+
} catch (e) {
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN applied_at TEXT");
|
|
59
|
+
} catch (e) {
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
this.db.exec("ALTER TABLE plans ADD COLUMN applied_branch TEXT");
|
|
63
|
+
} catch (e) {
|
|
64
|
+
}
|
|
65
|
+
this.db.exec(`
|
|
66
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
67
|
+
plan_id TEXT,
|
|
68
|
+
file_path TEXT,
|
|
69
|
+
status TEXT,
|
|
70
|
+
dependencies_json TEXT,
|
|
71
|
+
error TEXT,
|
|
72
|
+
PRIMARY KEY (plan_id, file_path),
|
|
73
|
+
FOREIGN KEY (plan_id) REFERENCES plans(id)
|
|
74
|
+
);
|
|
75
|
+
`);
|
|
76
|
+
this.db.exec(`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
78
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
79
|
+
event_name TEXT,
|
|
80
|
+
payload_json TEXT,
|
|
81
|
+
timestamp TEXT
|
|
82
|
+
);
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
async savePlan(plan) {
|
|
86
|
+
const stmtPlan = this.db.prepare(`
|
|
87
|
+
INSERT OR REPLACE INTO plans (id, run_id, target_path, source_framework, target_framework, rules_json, created_at, integration_rounds, phase, outcome, applied_at, applied_branch)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
89
|
+
`);
|
|
90
|
+
stmtPlan.run(
|
|
91
|
+
plan.id,
|
|
92
|
+
plan.runId,
|
|
93
|
+
plan.targetPath || "",
|
|
94
|
+
plan.profile.source,
|
|
95
|
+
plan.profile.target,
|
|
96
|
+
JSON.stringify(plan.profile.rules || []),
|
|
97
|
+
plan.createdAt.toISOString(),
|
|
98
|
+
plan.integrationRounds ?? 0,
|
|
99
|
+
plan.phase || "files",
|
|
100
|
+
plan.outcome || null,
|
|
101
|
+
plan.appliedAt ? plan.appliedAt.toISOString() : null,
|
|
102
|
+
plan.appliedBranch || null
|
|
103
|
+
);
|
|
104
|
+
const stmtTask = this.db.prepare(`
|
|
105
|
+
INSERT OR REPLACE INTO tasks (plan_id, file_path, status, dependencies_json, error)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?)
|
|
107
|
+
`);
|
|
108
|
+
for (const task of plan.tasks) {
|
|
109
|
+
stmtTask.run(
|
|
110
|
+
plan.id,
|
|
111
|
+
task.filePath,
|
|
112
|
+
task.status,
|
|
113
|
+
JSON.stringify(task.dependencies || []),
|
|
114
|
+
task.error || null
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
mapPlan(planRow, taskRows) {
|
|
119
|
+
return {
|
|
120
|
+
id: planRow.id,
|
|
121
|
+
runId: planRow.run_id,
|
|
122
|
+
targetPath: planRow.target_path,
|
|
123
|
+
profile: {
|
|
124
|
+
source: planRow.source_framework,
|
|
125
|
+
target: planRow.target_framework,
|
|
126
|
+
rules: JSON.parse(planRow.rules_json)
|
|
127
|
+
},
|
|
128
|
+
createdAt: new Date(planRow.created_at),
|
|
129
|
+
integrationRounds: planRow.integration_rounds ?? 0,
|
|
130
|
+
phase: planRow.phase || "files",
|
|
131
|
+
outcome: planRow.outcome || void 0,
|
|
132
|
+
appliedAt: planRow.applied_at ? new Date(planRow.applied_at) : void 0,
|
|
133
|
+
appliedBranch: planRow.applied_branch || void 0,
|
|
134
|
+
tasks: taskRows.map((row) => ({
|
|
135
|
+
filePath: row.file_path,
|
|
136
|
+
status: row.status,
|
|
137
|
+
dependencies: JSON.parse(row.dependencies_json),
|
|
138
|
+
error: row.error || void 0
|
|
139
|
+
}))
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async getPlan(id) {
|
|
143
|
+
const stmtPlan = this.db.prepare(`SELECT * FROM plans WHERE id = ?`);
|
|
144
|
+
const planRow = stmtPlan.get(id);
|
|
145
|
+
if (!planRow) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const stmtTasks = this.db.prepare(`SELECT * FROM tasks WHERE plan_id = ?`);
|
|
149
|
+
const taskRows = stmtTasks.all(id);
|
|
150
|
+
return this.mapPlan(planRow, taskRows);
|
|
151
|
+
}
|
|
152
|
+
async updateTaskStatus(planId, filePath, status, error) {
|
|
153
|
+
const stmt = this.db.prepare(`
|
|
154
|
+
INSERT INTO tasks (plan_id, file_path, status, error, dependencies_json)
|
|
155
|
+
VALUES (?, ?, ?, ?, '[]')
|
|
156
|
+
ON CONFLICT(plan_id, file_path) DO UPDATE
|
|
157
|
+
SET status = excluded.status, error = excluded.error
|
|
158
|
+
`);
|
|
159
|
+
stmt.run(planId, filePath, status, error || null);
|
|
160
|
+
}
|
|
161
|
+
async logEvent(eventName, payload) {
|
|
162
|
+
const stmt = this.db.prepare(`
|
|
163
|
+
INSERT INTO events (event_name, payload_json, timestamp)
|
|
164
|
+
VALUES (?, ?, ?)
|
|
165
|
+
`);
|
|
166
|
+
stmt.run(eventName, JSON.stringify(payload), (/* @__PURE__ */ new Date()).toISOString());
|
|
167
|
+
}
|
|
168
|
+
async getAllPlans() {
|
|
169
|
+
const stmtPlans = this.db.prepare(`SELECT * FROM plans ORDER BY created_at DESC`);
|
|
170
|
+
const planRows = stmtPlans.all();
|
|
171
|
+
const stmtTasks = this.db.prepare(`SELECT * FROM tasks`);
|
|
172
|
+
const taskRows = stmtTasks.all();
|
|
173
|
+
return planRows.map((planRow) => {
|
|
174
|
+
const planTasks = taskRows.filter((t) => t.plan_id === planRow.id);
|
|
175
|
+
return this.mapPlan(planRow, planTasks);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async getEvents() {
|
|
179
|
+
const stmt = this.db.prepare(`SELECT * FROM events ORDER BY id DESC LIMIT 2000`);
|
|
180
|
+
const rows = stmt.all();
|
|
181
|
+
return rows.map((row) => ({
|
|
182
|
+
id: row.id,
|
|
183
|
+
eventName: row.event_name,
|
|
184
|
+
payload: JSON.parse(row.payload_json),
|
|
185
|
+
timestamp: new Date(row.timestamp)
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
async reset() {
|
|
189
|
+
this.db.exec(`DELETE FROM tasks`);
|
|
190
|
+
this.db.exec(`DELETE FROM plans`);
|
|
191
|
+
this.db.exec(`DELETE FROM events`);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ../infrastructure/src/tools/AstTools.ts
|
|
196
|
+
import { Project } from "ts-morph";
|
|
197
|
+
import { existsSync } from "fs";
|
|
198
|
+
import * as path from "path";
|
|
199
|
+
var project = new Project();
|
|
200
|
+
function assertSandbox(filePath, sandboxDir) {
|
|
201
|
+
const resolved = path.resolve(filePath);
|
|
202
|
+
if (sandboxDir) {
|
|
203
|
+
const resolvedSandbox = path.resolve(sandboxDir);
|
|
204
|
+
const isInside = resolved === resolvedSandbox || resolved.startsWith(resolvedSandbox + path.sep);
|
|
205
|
+
if (!isInside) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`BLOCKED: Path "${resolved}" is outside the sandbox "${resolvedSandbox}". All file operations must stay within the shadow workspace.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
212
|
+
}
|
|
213
|
+
function createReadFileTool(sandboxDir) {
|
|
214
|
+
return {
|
|
215
|
+
type: "function",
|
|
216
|
+
name: "read_file",
|
|
217
|
+
description: "Reads the content of a source file using ts-morph.",
|
|
218
|
+
parameters: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
filePath: { type: "string", description: "The absolute path to the file to read." }
|
|
222
|
+
},
|
|
223
|
+
required: ["filePath"],
|
|
224
|
+
additionalProperties: false
|
|
225
|
+
},
|
|
226
|
+
strict: true,
|
|
227
|
+
invoke: async ({ filePath }) => {
|
|
228
|
+
const resolvedPath = assertSandbox(filePath, sandboxDir);
|
|
229
|
+
if (!existsSync(resolvedPath)) {
|
|
230
|
+
return { error: `File not found: ${resolvedPath}` };
|
|
231
|
+
}
|
|
232
|
+
let sourceFile = project.getSourceFile(resolvedPath);
|
|
233
|
+
if (!sourceFile) {
|
|
234
|
+
project.addSourceFileAtPath(resolvedPath);
|
|
235
|
+
sourceFile = project.getSourceFileOrThrow(resolvedPath);
|
|
236
|
+
} else {
|
|
237
|
+
await sourceFile.refreshFromFileSystem();
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
filePath: resolvedPath,
|
|
241
|
+
content: sourceFile.getFullText()
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function createWriteFileTool(sandboxDir) {
|
|
247
|
+
return {
|
|
248
|
+
type: "function",
|
|
249
|
+
name: "write_file",
|
|
250
|
+
description: "Overwrites the content of a source file with the provided refactored code using ts-morph.",
|
|
251
|
+
parameters: {
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: {
|
|
254
|
+
filePath: { type: "string", description: "The absolute path to the file to modify." },
|
|
255
|
+
newContent: { type: "string", description: "The entire new source code to write to the file." }
|
|
256
|
+
},
|
|
257
|
+
required: ["filePath", "newContent"],
|
|
258
|
+
additionalProperties: false
|
|
259
|
+
},
|
|
260
|
+
strict: true,
|
|
261
|
+
invoke: async ({ filePath, newContent }) => {
|
|
262
|
+
const resolvedPath = assertSandbox(filePath, sandboxDir);
|
|
263
|
+
let sourceFile = project.getSourceFile(resolvedPath);
|
|
264
|
+
if (!sourceFile) {
|
|
265
|
+
if (existsSync(resolvedPath)) {
|
|
266
|
+
project.addSourceFileAtPath(resolvedPath);
|
|
267
|
+
sourceFile = project.getSourceFileOrThrow(resolvedPath);
|
|
268
|
+
} else {
|
|
269
|
+
sourceFile = project.createSourceFile(resolvedPath, "");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
sourceFile.replaceWithText(newContent);
|
|
273
|
+
await sourceFile.save();
|
|
274
|
+
return {
|
|
275
|
+
success: true,
|
|
276
|
+
message: `File ${resolvedPath} successfully updated.`
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function createRenameFileTool(sandboxDir) {
|
|
282
|
+
return {
|
|
283
|
+
type: "function",
|
|
284
|
+
name: "rename_file",
|
|
285
|
+
description: "Renames a file in the project.",
|
|
286
|
+
parameters: {
|
|
287
|
+
type: "object",
|
|
288
|
+
properties: {
|
|
289
|
+
oldPath: { type: "string", description: "Current absolute path of the file." },
|
|
290
|
+
newPath: { type: "string", description: "New absolute path for the file." }
|
|
291
|
+
},
|
|
292
|
+
required: ["oldPath", "newPath"],
|
|
293
|
+
additionalProperties: false
|
|
294
|
+
},
|
|
295
|
+
strict: true,
|
|
296
|
+
invoke: async ({ oldPath, newPath }) => {
|
|
297
|
+
const resolvedOld = assertSandbox(oldPath, sandboxDir);
|
|
298
|
+
const resolvedNew = assertSandbox(newPath, sandboxDir);
|
|
299
|
+
const { renameSync, existsSync: fsExists } = await import("fs");
|
|
300
|
+
if (!fsExists(resolvedOld)) {
|
|
301
|
+
return { error: `File not found: ${resolvedOld}` };
|
|
302
|
+
}
|
|
303
|
+
const { mkdirSync: mkdirSync2 } = await import("fs");
|
|
304
|
+
const { dirname } = await import("path");
|
|
305
|
+
mkdirSync2(dirname(resolvedNew), { recursive: true });
|
|
306
|
+
renameSync(resolvedOld, resolvedNew);
|
|
307
|
+
const sourceFile = project.getSourceFile(resolvedOld);
|
|
308
|
+
if (sourceFile) {
|
|
309
|
+
project.removeSourceFile(sourceFile);
|
|
310
|
+
if (fsExists(resolvedNew)) {
|
|
311
|
+
project.addSourceFileAtPath(resolvedNew);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { success: true, message: `File renamed from ${resolvedOld} to ${resolvedNew}.` };
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function createCreateFileTool(sandboxDir) {
|
|
319
|
+
return {
|
|
320
|
+
type: "function",
|
|
321
|
+
name: "create_file",
|
|
322
|
+
description: "Creates a new file with the specified content.",
|
|
323
|
+
parameters: {
|
|
324
|
+
type: "object",
|
|
325
|
+
properties: {
|
|
326
|
+
filePath: { type: "string", description: "Absolute path of the new file to create." },
|
|
327
|
+
content: { type: "string", description: "The content of the new file." }
|
|
328
|
+
},
|
|
329
|
+
required: ["filePath", "content"],
|
|
330
|
+
additionalProperties: false
|
|
331
|
+
},
|
|
332
|
+
strict: true,
|
|
333
|
+
invoke: async ({ filePath, content }) => {
|
|
334
|
+
const resolvedPath = assertSandbox(filePath, sandboxDir);
|
|
335
|
+
const { writeFileSync, mkdirSync: mkdirSync2 } = await import("fs");
|
|
336
|
+
const { dirname } = await import("path");
|
|
337
|
+
mkdirSync2(dirname(resolvedPath), { recursive: true });
|
|
338
|
+
writeFileSync(resolvedPath, content, "utf-8");
|
|
339
|
+
project.addSourceFileAtPath(resolvedPath);
|
|
340
|
+
return { success: true, message: `File created at ${resolvedPath}.` };
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function createDeleteFileTool(sandboxDir) {
|
|
345
|
+
return {
|
|
346
|
+
type: "function",
|
|
347
|
+
name: "delete_file",
|
|
348
|
+
description: "Deletes a file from the project.",
|
|
349
|
+
parameters: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: {
|
|
352
|
+
filePath: { type: "string", description: "Absolute path of the file to delete." }
|
|
353
|
+
},
|
|
354
|
+
required: ["filePath"],
|
|
355
|
+
additionalProperties: false
|
|
356
|
+
},
|
|
357
|
+
strict: true,
|
|
358
|
+
invoke: async ({ filePath }) => {
|
|
359
|
+
const resolvedPath = assertSandbox(filePath, sandboxDir);
|
|
360
|
+
const { renameSync, existsSync: fsExists } = await import("fs");
|
|
361
|
+
if (!fsExists(resolvedPath)) {
|
|
362
|
+
return { error: `File not found: ${resolvedPath}` };
|
|
363
|
+
}
|
|
364
|
+
const obsoletePath = `${resolvedPath}.obsolete`;
|
|
365
|
+
renameSync(resolvedPath, obsoletePath);
|
|
366
|
+
const sourceFile = project.getSourceFile(resolvedPath);
|
|
367
|
+
if (sourceFile) {
|
|
368
|
+
project.removeSourceFile(sourceFile);
|
|
369
|
+
}
|
|
370
|
+
return { success: true, message: `File marked as obsolete at ${obsoletePath} instead of deleting.` };
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function createListDirectoryTool(sandboxDir) {
|
|
375
|
+
return {
|
|
376
|
+
type: "function",
|
|
377
|
+
name: "list_directory",
|
|
378
|
+
description: "Lists files and folders in a directory inside the shadow workspace. Use this to find existing screens, entry files, or imports instead of guessing paths.",
|
|
379
|
+
parameters: {
|
|
380
|
+
type: "object",
|
|
381
|
+
properties: {
|
|
382
|
+
dirPath: { type: "string", description: "Absolute directory path inside the shadow workspace." }
|
|
383
|
+
},
|
|
384
|
+
required: ["dirPath"],
|
|
385
|
+
additionalProperties: false
|
|
386
|
+
},
|
|
387
|
+
strict: true,
|
|
388
|
+
invoke: async ({ dirPath }) => {
|
|
389
|
+
const resolvedPath = assertSandbox(dirPath, sandboxDir);
|
|
390
|
+
const { readdirSync, statSync: statSync2, existsSync: fsExists } = await import("fs");
|
|
391
|
+
if (!fsExists(resolvedPath)) {
|
|
392
|
+
return { error: `Directory not found: ${resolvedPath}` };
|
|
393
|
+
}
|
|
394
|
+
if (!statSync2(resolvedPath).isDirectory()) {
|
|
395
|
+
return { error: `Not a directory: ${resolvedPath}` };
|
|
396
|
+
}
|
|
397
|
+
const entries = readdirSync(resolvedPath, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules").slice(0, 80).map((entry) => ({
|
|
398
|
+
name: entry.name,
|
|
399
|
+
path: path.join(resolvedPath, entry.name),
|
|
400
|
+
kind: entry.isDirectory() ? "dir" : "file"
|
|
401
|
+
}));
|
|
402
|
+
return { dirPath: resolvedPath, entries };
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function createShadowTools(shadowDir) {
|
|
407
|
+
return [
|
|
408
|
+
createReadFileTool(shadowDir),
|
|
409
|
+
createWriteFileTool(shadowDir),
|
|
410
|
+
createRenameFileTool(shadowDir),
|
|
411
|
+
createCreateFileTool(shadowDir),
|
|
412
|
+
createDeleteFileTool(shadowDir),
|
|
413
|
+
createListDirectoryTool(shadowDir)
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ../infrastructure/src/workspace/ShadowWorkspace.ts
|
|
418
|
+
import fs from "fs-extra";
|
|
419
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
420
|
+
var ShadowWorkspace = class {
|
|
421
|
+
baseDir;
|
|
422
|
+
constructor(basePath = ".metamorph/shadow") {
|
|
423
|
+
this.baseDir = resolve2(process.cwd(), basePath);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Clones a target directory into the shadow workspace.
|
|
427
|
+
* @param targetPath The absolute or relative path to the directory to clone.
|
|
428
|
+
* @param runId A unique identifier for this run (e.g. timestamp).
|
|
429
|
+
* @returns The absolute path to the cloned directory inside the shadow workspace.
|
|
430
|
+
*/
|
|
431
|
+
cloneDirectory(targetPath, runId) {
|
|
432
|
+
const sourceDir = resolve2(process.cwd(), targetPath);
|
|
433
|
+
if (!fs.existsSync(sourceDir)) {
|
|
434
|
+
throw new Error(`Target directory does not exist: ${sourceDir}`);
|
|
435
|
+
}
|
|
436
|
+
const shadowDir = join3(this.baseDir, runId);
|
|
437
|
+
if (fs.existsSync(shadowDir)) {
|
|
438
|
+
fs.emptyDirSync(shadowDir);
|
|
439
|
+
}
|
|
440
|
+
const ignoredDirs = ["node_modules", ".git", ".metamorph", "dist", "build", "out", "coverage", ".next"];
|
|
441
|
+
const items = fs.readdirSync(sourceDir);
|
|
442
|
+
for (const item of items) {
|
|
443
|
+
if (ignoredDirs.includes(item)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const itemSrc = join3(sourceDir, item);
|
|
447
|
+
const itemDest = join3(shadowDir, item);
|
|
448
|
+
fs.copySync(itemSrc, itemDest, {
|
|
449
|
+
filter: (src) => !ignoredDirs.some((dir) => src.includes(`/${dir}/`) || src.endsWith(`/${dir}`) || src.includes(`\\${dir}\\`) || src.endsWith(`\\${dir}`))
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
return shadowDir;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Removes the shadow workspace for a given run, effectively
|
|
456
|
+
* discarding any in-progress migration work.
|
|
457
|
+
*/
|
|
458
|
+
rollback(runId) {
|
|
459
|
+
const shadowDir = join3(this.baseDir, runId);
|
|
460
|
+
if (fs.existsSync(shadowDir)) {
|
|
461
|
+
fs.removeSync(shadowDir);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Returns the absolute path of the shadow dir for a given runId.
|
|
466
|
+
*/
|
|
467
|
+
getShadowPath(runId) {
|
|
468
|
+
return join3(this.baseDir, runId);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Cleans up old shadow runs to save disk space, keeping only the specified number of most recent runs.
|
|
472
|
+
* @param keepCount Number of recent runs to keep (default: 3)
|
|
473
|
+
*/
|
|
474
|
+
cleanupOldRuns(keepCount = 3, protectedRunIds = []) {
|
|
475
|
+
if (!fs.existsSync(this.baseDir)) return;
|
|
476
|
+
const protectedSet = new Set(protectedRunIds);
|
|
477
|
+
const runs = fs.readdirSync(this.baseDir).filter((f) => f.startsWith("run_") && fs.statSync(join3(this.baseDir, f)).isDirectory());
|
|
478
|
+
runs.sort();
|
|
479
|
+
const unprotected = runs.filter((run) => !protectedSet.has(run));
|
|
480
|
+
if (unprotected.length <= keepCount) return;
|
|
481
|
+
const toDelete = unprotected.slice(0, unprotected.length - keepCount);
|
|
482
|
+
for (const run of toDelete) {
|
|
483
|
+
this.rollback(run);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// ../infrastructure/src/workspace/MigrationIntegrator.ts
|
|
489
|
+
import fs2 from "fs-extra";
|
|
490
|
+
import * as path2 from "path";
|
|
491
|
+
import { execFileSync } from "child_process";
|
|
492
|
+
var COPY_IGNORED = ["node_modules", ".git", ".metamorph", "dist", "build", "out", "coverage", ".next"];
|
|
493
|
+
function isCopyIgnored(itemName) {
|
|
494
|
+
if (COPY_IGNORED.includes(itemName)) return true;
|
|
495
|
+
if (itemName === ".env" || itemName.startsWith(".env.")) return true;
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
var MigrationIntegrator = class {
|
|
499
|
+
shadowWorkspace;
|
|
500
|
+
constructor(shadowWorkspace) {
|
|
501
|
+
this.shadowWorkspace = shadowWorkspace;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Applies the migrated code from the shadow workspace back to the target directory.
|
|
505
|
+
* Requires a Git repository that actually versions the target (not a parent repo that gitignores it).
|
|
506
|
+
*/
|
|
507
|
+
async applyMigration(runId, targetPath) {
|
|
508
|
+
const shadowDir = this.shadowWorkspace.getShadowPath(runId);
|
|
509
|
+
if (!fs2.existsSync(shadowDir)) {
|
|
510
|
+
throw new Error(`Shadow run not found: ${shadowDir}`);
|
|
511
|
+
}
|
|
512
|
+
const resolvedTarget = path2.resolve(process.cwd(), targetPath);
|
|
513
|
+
const gitRoot = this.resolveGitRoot(resolvedTarget);
|
|
514
|
+
if (!gitRoot) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
"Metamorph apply requires a Git repository. Run this from a project that already has git (including monorepo packages whose .git lives in a parent folder). Nested git init is not supported."
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const rootPkgJson = path2.join(gitRoot, "package.json");
|
|
520
|
+
if (fs2.existsSync(rootPkgJson)) {
|
|
521
|
+
try {
|
|
522
|
+
const pkg = JSON.parse(fs2.readFileSync(rootPkgJson, "utf-8"));
|
|
523
|
+
const normalizedTarget = resolvedTarget.replace(/\\/g, "/");
|
|
524
|
+
if (pkg.name === "metamorph-monorepo" && normalizedTarget.includes("/scratch/")) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`BLOCKED: Cannot apply to "${resolvedTarget}" because it is inside the development scratch/ directory of the Metamorph repository. Use a standalone git repository for user project migrations.`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
} catch (e) {
|
|
530
|
+
if (e instanceof Error && e.message.startsWith("BLOCKED:")) throw e;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (this.isIgnoredByGit(gitRoot, resolvedTarget)) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Cannot apply via Git: "${resolvedTarget}" is ignored by the repository at "${gitRoot}" (.gitignore). Metamorph will not create branches on a parent repo for ignored sandboxes. Use a standalone git project, or a tracked package inside a monorepo.`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return this.applyWithGit(runId, shadowDir, resolvedTarget, gitRoot, `metamorph/${runId}`);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Walks up from targetPath via `git rev-parse --show-toplevel`.
|
|
542
|
+
*/
|
|
543
|
+
resolveGitRoot(targetPath) {
|
|
544
|
+
try {
|
|
545
|
+
const root = this.git(["rev-parse", "--show-toplevel"], targetPath).trim();
|
|
546
|
+
return root || null;
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
isIgnoredByGit(gitRoot, targetPath) {
|
|
552
|
+
try {
|
|
553
|
+
execFileSync("git", ["check-ignore", "-q", "--", targetPath], {
|
|
554
|
+
cwd: gitRoot,
|
|
555
|
+
stdio: "ignore"
|
|
556
|
+
});
|
|
557
|
+
return true;
|
|
558
|
+
} catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
git(args, cwd) {
|
|
563
|
+
try {
|
|
564
|
+
return execFileSync("git", args, {
|
|
565
|
+
cwd,
|
|
566
|
+
encoding: "utf-8",
|
|
567
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
568
|
+
});
|
|
569
|
+
} catch (error) {
|
|
570
|
+
const err = error;
|
|
571
|
+
const detail = [err.stderr, err.stdout, err.message].map((part) => (Buffer.isBuffer(part) ? part.toString("utf-8") : part) || "").filter(Boolean).join("\n").trim();
|
|
572
|
+
throw new Error(`git ${args.join(" ")} failed: ${detail || "unknown git error"}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async applyWithGit(runId, shadowDir, targetPath, gitRoot, branchName) {
|
|
576
|
+
const relativeTarget = path2.relative(gitRoot, targetPath).replace(/\\/g, "/");
|
|
577
|
+
const addPath = relativeTarget === "" ? "." : relativeTarget;
|
|
578
|
+
const dirtyStatus = this.git(["status", "--porcelain", "--", addPath], gitRoot).trim();
|
|
579
|
+
if (dirtyStatus) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Cannot apply migration: the target directory has uncommitted changes:
|
|
582
|
+
${dirtyStatus}
|
|
583
|
+
Please commit or stash your changes before applying.`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
this.git(["checkout", "-B", branchName], gitRoot);
|
|
588
|
+
} catch (e) {
|
|
589
|
+
if (e instanceof Error) {
|
|
590
|
+
throw new Error(`Failed to create/reset git branch ${branchName}: ${e.message}`);
|
|
591
|
+
}
|
|
592
|
+
throw new Error(`Failed to create/reset git branch ${branchName}: ${String(e)}`);
|
|
593
|
+
}
|
|
594
|
+
this.copyShadowToTarget(shadowDir, targetPath);
|
|
595
|
+
try {
|
|
596
|
+
try {
|
|
597
|
+
this.git(["-c", "advice.addIgnoredFile=false", "add", "-A", "--", addPath], gitRoot);
|
|
598
|
+
} catch (addError) {
|
|
599
|
+
const detail = addError instanceof Error ? addError.message : String(addError);
|
|
600
|
+
const ignoredOnly = /paths are ignored by one of your \.gitignore/i.test(detail) && !/not a git repository|index\.lock|permission denied|tell me who you are/i.test(detail);
|
|
601
|
+
if (!ignoredOnly) {
|
|
602
|
+
throw addError;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
this.git(
|
|
606
|
+
["-c", "advice.addIgnoredFile=false", "commit", "--allow-empty", "-m", `chore: apply metamorph ai migration (${runId})`],
|
|
607
|
+
gitRoot
|
|
608
|
+
);
|
|
609
|
+
} catch (e) {
|
|
610
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
611
|
+
if (/tell me who you are|user\.email|user\.name/i.test(detail)) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`Failed to commit to branch ${branchName}: Git user.name / user.email are not set. Run git config user.email and git config user.name in this repo, then retry apply.`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
throw new Error(`Failed to commit changes to branch ${branchName}: ${detail}`);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
message: `Successfully applied migration via Git to branch: ${branchName}`,
|
|
620
|
+
gitUsed: true,
|
|
621
|
+
branch: branchName
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
copyShadowToTarget(shadowDir, targetPath) {
|
|
625
|
+
if (fs2.existsSync(targetPath)) {
|
|
626
|
+
const targetItems = fs2.readdirSync(targetPath);
|
|
627
|
+
for (const item of targetItems) {
|
|
628
|
+
if (isCopyIgnored(item)) continue;
|
|
629
|
+
fs2.removeSync(path2.join(targetPath, item));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const shadowItems = fs2.readdirSync(shadowDir);
|
|
633
|
+
for (const item of shadowItems) {
|
|
634
|
+
if (isCopyIgnored(item)) continue;
|
|
635
|
+
const itemSrc = path2.join(shadowDir, item);
|
|
636
|
+
const itemDest = path2.join(targetPath, item);
|
|
637
|
+
fs2.copySync(itemSrc, itemDest, { overwrite: true });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// ../infrastructure/src/server/api.ts
|
|
643
|
+
async function createApiServer(store, migrationRunner) {
|
|
644
|
+
const express = (await import("express")).default;
|
|
645
|
+
const cors = (await import("cors")).default;
|
|
646
|
+
const app = express();
|
|
647
|
+
app.use(cors());
|
|
648
|
+
app.use(express.json());
|
|
649
|
+
app.get("/api/plans", async (_req, res) => {
|
|
650
|
+
try {
|
|
651
|
+
const plans = await store.getAllPlans();
|
|
652
|
+
res.json(plans);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (error instanceof Error) {
|
|
655
|
+
res.status(500).json({ error: error.message });
|
|
656
|
+
} else {
|
|
657
|
+
res.status(500).json({ error: String(error) });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
app.get("/api/events", async (_req, res) => {
|
|
662
|
+
try {
|
|
663
|
+
const events = await store.getEvents();
|
|
664
|
+
res.json(events);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (error instanceof Error) {
|
|
667
|
+
res.status(500).json({ error: error.message });
|
|
668
|
+
} else {
|
|
669
|
+
res.status(500).json({ error: String(error) });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
app.get("/api/browse", async (req, res) => {
|
|
674
|
+
try {
|
|
675
|
+
const { readdirSync, statSync: statSync2 } = await import("fs");
|
|
676
|
+
const { resolve: resolve5, join: join7 } = await import("path");
|
|
677
|
+
const targetPath = req.query.path ? resolve5(String(req.query.path)) : resolve5(".");
|
|
678
|
+
const entries = readdirSync(targetPath).filter(
|
|
679
|
+
(name) => !name.startsWith(".") && name !== "node_modules" && name !== "dist" && name !== "build"
|
|
680
|
+
).map((name) => {
|
|
681
|
+
const full = join7(targetPath, name);
|
|
682
|
+
try {
|
|
683
|
+
const stat = statSync2(full);
|
|
684
|
+
return {
|
|
685
|
+
name,
|
|
686
|
+
path: full,
|
|
687
|
+
isDirectory: stat.isDirectory()
|
|
688
|
+
};
|
|
689
|
+
} catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}).filter(Boolean);
|
|
693
|
+
res.json({
|
|
694
|
+
current: targetPath,
|
|
695
|
+
parent: resolve5(targetPath, ".."),
|
|
696
|
+
entries
|
|
697
|
+
});
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error instanceof Error) {
|
|
700
|
+
res.status(500).json({ error: error.message });
|
|
701
|
+
} else {
|
|
702
|
+
res.status(500).json({ error: String(error) });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
app.get("/api/detect", async (req, res) => {
|
|
707
|
+
try {
|
|
708
|
+
const { detectTechnologies } = await import("./TechDetector-5YP44RVR.js");
|
|
709
|
+
const targetPath = req.query.path ? String(req.query.path) : ".";
|
|
710
|
+
const detected = detectTechnologies(targetPath);
|
|
711
|
+
res.json({
|
|
712
|
+
detected,
|
|
713
|
+
primary: detected.length > 0 ? detected[0].framework : null
|
|
714
|
+
});
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (error instanceof Error) {
|
|
717
|
+
res.status(500).json({ error: error.message });
|
|
718
|
+
} else {
|
|
719
|
+
res.status(500).json({ error: String(error) });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
if (migrationRunner) {
|
|
724
|
+
app.post("/api/migrations/start", async (req, res) => {
|
|
725
|
+
try {
|
|
726
|
+
const { targetPath, from, to } = req.body;
|
|
727
|
+
if (!targetPath || !from || !to) {
|
|
728
|
+
res.status(400).json({
|
|
729
|
+
error: "Missing required fields: targetPath, from, to"
|
|
730
|
+
});
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const catalogEntry = resolveMigrationCatalog(from, to);
|
|
734
|
+
if (!catalogEntry) {
|
|
735
|
+
res.status(400).json({
|
|
736
|
+
error: `Unsupported migration profile: ${from} -> ${to}. Please check the supported catalog.`
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const result = await migrationRunner.startMigration({
|
|
741
|
+
targetPath,
|
|
742
|
+
from,
|
|
743
|
+
to
|
|
744
|
+
});
|
|
745
|
+
res.json({ ok: true, ...result });
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error instanceof Error) {
|
|
748
|
+
res.status(500).json({ error: error.message });
|
|
749
|
+
} else {
|
|
750
|
+
res.status(500).json({ error: String(error) });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
app.post("/api/migrations/apply", async (req, res) => {
|
|
755
|
+
try {
|
|
756
|
+
const { runId, targetPath } = req.body;
|
|
757
|
+
if (!runId || !targetPath) {
|
|
758
|
+
res.status(400).json({
|
|
759
|
+
error: "Missing required fields: runId, targetPath"
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const plans = await store.getAllPlans();
|
|
764
|
+
const plan = plans.find((item) => item.runId === runId);
|
|
765
|
+
if (!plan) {
|
|
766
|
+
res.status(404).json({ error: `Plan for run "${runId}" not found in database.` });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (plan.phase !== "completed" || plan.outcome !== "success") {
|
|
770
|
+
res.status(400).json({
|
|
771
|
+
error: `Cannot apply migration: plan is not completed successfully (phase: "${plan.phase}", outcome: "${plan.outcome || "none"}"). Only successful migrations can be applied.`
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (plan.appliedAt) {
|
|
776
|
+
res.status(400).json({
|
|
777
|
+
error: `Migration for run "${runId}" has already been applied at ${new Date(plan.appliedAt).toISOString()} to branch ${plan.appliedBranch || "unknown"}.`
|
|
778
|
+
});
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const integrator = new MigrationIntegrator(migrationRunner.workspace);
|
|
782
|
+
const result = await integrator.applyMigration(runId, targetPath);
|
|
783
|
+
plan.appliedAt = /* @__PURE__ */ new Date();
|
|
784
|
+
plan.appliedBranch = result.branch;
|
|
785
|
+
await store.savePlan(plan);
|
|
786
|
+
await store.logEvent("migration.applied", { runId, branch: result.branch, targetPath });
|
|
787
|
+
res.json({ ok: true, ...result });
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error instanceof Error) {
|
|
790
|
+
res.status(500).json({ error: error.message });
|
|
791
|
+
} else {
|
|
792
|
+
res.status(500).json({ error: String(error) });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
app.post("/api/migrations/rollback", async (req, res) => {
|
|
797
|
+
try {
|
|
798
|
+
const { runId } = req.body;
|
|
799
|
+
if (!runId) {
|
|
800
|
+
res.status(400).json({
|
|
801
|
+
error: "Missing required field: runId"
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
await migrationRunner.rollbackMigration(runId);
|
|
806
|
+
res.json({ ok: true, message: `Rolled back run: ${runId}` });
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (error instanceof Error) {
|
|
809
|
+
res.status(500).json({ error: error.message });
|
|
810
|
+
} else {
|
|
811
|
+
res.status(500).json({ error: String(error) });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
app.post("/api/migrations/reset", async (_req, res) => {
|
|
816
|
+
try {
|
|
817
|
+
await store.reset();
|
|
818
|
+
res.json({ ok: true, message: "Migration state reset successfully" });
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (error instanceof Error) {
|
|
821
|
+
res.status(500).json({ error: error.message });
|
|
822
|
+
} else {
|
|
823
|
+
res.status(500).json({ error: String(error) });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const path5 = await import("path");
|
|
829
|
+
const fs4 = await import("fs");
|
|
830
|
+
let publicDir = path5.join(import.meta.dirname, "public");
|
|
831
|
+
if (!fs4.existsSync(publicDir)) {
|
|
832
|
+
publicDir = path5.join(import.meta.dirname, "../public");
|
|
833
|
+
}
|
|
834
|
+
if (fs4.existsSync(publicDir)) {
|
|
835
|
+
app.use(express.static(publicDir));
|
|
836
|
+
app.get(/^(.*)$/, (_req, res) => {
|
|
837
|
+
res.sendFile(path5.join(publicDir, "index.html"));
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
return app;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ../infrastructure/src/tools/LinterTools.ts
|
|
844
|
+
import { Project as Project2 } from "ts-morph";
|
|
845
|
+
import * as path3 from "path";
|
|
846
|
+
var createCheckProjectDiagnosticsTool = (shadowBase) => ({
|
|
847
|
+
type: "function",
|
|
848
|
+
name: "check_project_diagnostics",
|
|
849
|
+
description: "Checks the project for TypeScript errors like broken imports/exports. Call this to see if the project has cross-file integration issues. Returns a list of errors.",
|
|
850
|
+
parameters: {
|
|
851
|
+
type: "object",
|
|
852
|
+
properties: {}
|
|
853
|
+
// No params needed, it checks the whole shadow workspace
|
|
854
|
+
},
|
|
855
|
+
strict: true,
|
|
856
|
+
invoke: async () => {
|
|
857
|
+
try {
|
|
858
|
+
const tsConfigPath = path3.join(shadowBase, "tsconfig.json");
|
|
859
|
+
let project2;
|
|
860
|
+
try {
|
|
861
|
+
project2 = new Project2({
|
|
862
|
+
tsConfigFilePath: tsConfigPath,
|
|
863
|
+
skipAddingFilesFromTsConfig: false
|
|
864
|
+
});
|
|
865
|
+
} catch (err) {
|
|
866
|
+
project2 = new Project2();
|
|
867
|
+
}
|
|
868
|
+
project2.addSourceFilesAtPaths(path3.join(shadowBase, "src/**/*.ts"));
|
|
869
|
+
project2.addSourceFilesAtPaths(path3.join(shadowBase, "src/**/*.tsx"));
|
|
870
|
+
const diagnostics = project2.getPreEmitDiagnostics();
|
|
871
|
+
const relevantErrors = diagnostics.filter((d) => {
|
|
872
|
+
const code = d.getCode();
|
|
873
|
+
const message = d.getMessageText().toString();
|
|
874
|
+
if (code === 17004) return false;
|
|
875
|
+
if (code === 2307 || code === 2792) {
|
|
876
|
+
if (message.includes("'.") || message.includes("'/") || message.includes("'@/")) {
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
if ([2304, 2305, 2306, 2614, 2552, 2322, 2786].includes(code)) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
});
|
|
886
|
+
if (relevantErrors.length === 0) {
|
|
887
|
+
return "No local import/export errors found! The project integration looks solid.";
|
|
888
|
+
}
|
|
889
|
+
const formattedErrors = relevantErrors.map((d) => {
|
|
890
|
+
const file = d.getSourceFile();
|
|
891
|
+
const start = d.getStart();
|
|
892
|
+
const line = file && start !== void 0 ? file.getLineAndColumnAtPos(start).line : "unknown";
|
|
893
|
+
const filePath = file ? path3.relative(shadowBase, file.getFilePath()) : "unknown path";
|
|
894
|
+
return `[TS${d.getCode()}] ${filePath}:${line} - ${d.getMessageText().toString()}`;
|
|
895
|
+
}).join("\n");
|
|
896
|
+
return `Found ${relevantErrors.length} integration errors:
|
|
897
|
+
${formattedErrors}`;
|
|
898
|
+
} catch (e) {
|
|
899
|
+
return `Failed to run diagnostics: ${e.message}. Note: if tsconfig.json is missing, you may need to check files manually.`;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ../infrastructure/src/tools/BuildTools.ts
|
|
905
|
+
import { spawn } from "child_process";
|
|
906
|
+
import * as fs3 from "fs";
|
|
907
|
+
import * as path4 from "path";
|
|
908
|
+
async function runInShadowWorkspace(shadowBase, command, onChunk) {
|
|
909
|
+
return new Promise((resolve5) => {
|
|
910
|
+
const child = spawn(command, {
|
|
911
|
+
cwd: shadowBase,
|
|
912
|
+
shell: true,
|
|
913
|
+
env: {
|
|
914
|
+
...process.env,
|
|
915
|
+
CI: "true",
|
|
916
|
+
NPM_CONFIG_WORKSPACES: "false",
|
|
917
|
+
NPM_CONFIG_FUND: "false",
|
|
918
|
+
NPM_CONFIG_AUDIT: "false",
|
|
919
|
+
npm_config_progress: "false"
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
let output = "";
|
|
923
|
+
const append = (buf) => {
|
|
924
|
+
const text = buf.toString("utf-8");
|
|
925
|
+
output += text;
|
|
926
|
+
onChunk?.(text);
|
|
927
|
+
};
|
|
928
|
+
child.stdout?.on("data", append);
|
|
929
|
+
child.stderr?.on("data", append);
|
|
930
|
+
const timeout = setTimeout(() => {
|
|
931
|
+
child.kill("SIGTERM");
|
|
932
|
+
resolve5({
|
|
933
|
+
ok: false,
|
|
934
|
+
output: `${output}
|
|
935
|
+
Command timed out after 5 minutes: ${command}`
|
|
936
|
+
});
|
|
937
|
+
}, 3e5);
|
|
938
|
+
child.on("error", (error) => {
|
|
939
|
+
clearTimeout(timeout);
|
|
940
|
+
resolve5({ ok: false, output: `${output}
|
|
941
|
+
${error.message}` });
|
|
942
|
+
});
|
|
943
|
+
child.on("close", (code) => {
|
|
944
|
+
clearTimeout(timeout);
|
|
945
|
+
resolve5({ ok: code === 0, output: output.trim() });
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function parseImplicatedFiles(output, shadowPath) {
|
|
950
|
+
const found = /* @__PURE__ */ new Set();
|
|
951
|
+
const root = path4.resolve(shadowPath);
|
|
952
|
+
const ext = String.raw`tsx|ts|jsx|js|mjs|cjs|css`;
|
|
953
|
+
const patterns = [
|
|
954
|
+
new RegExp(String.raw`(?:^|[\s'"(\`])((?:\.{1,2}[\\/])?[^\s:'"()<>]+\.(?:${ext}))`, "g"),
|
|
955
|
+
new RegExp(String.raw`\b([A-Za-z]:[\\/][^\s'"()<>]+\.(?:${ext}))`, "g")
|
|
956
|
+
];
|
|
957
|
+
const consider = (raw) => {
|
|
958
|
+
const stripped = raw.replace(/[\\/]+$/, "").replace(/:[\d]+(?::[\d]+)?$/, "");
|
|
959
|
+
const abs = /^[A-Za-z]:[\\/]/.test(stripped) || path4.isAbsolute(stripped) ? stripped : path4.join(shadowPath, stripped.replace(/^\.[\\/]/, ""));
|
|
960
|
+
const resolved = path4.resolve(abs);
|
|
961
|
+
const inside = resolved === root || resolved.startsWith(root + path4.sep);
|
|
962
|
+
if (!inside) return;
|
|
963
|
+
if (fs3.existsSync(resolved)) {
|
|
964
|
+
if (fs3.statSync(resolved).isFile()) found.add(resolved);
|
|
965
|
+
} else {
|
|
966
|
+
found.add(resolved);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
for (const re of patterns) {
|
|
970
|
+
let match;
|
|
971
|
+
while ((match = re.exec(output)) !== null) consider(match[1]);
|
|
972
|
+
}
|
|
973
|
+
return [...found];
|
|
974
|
+
}
|
|
975
|
+
var createRunBuildTool = (shadowBase) => ({
|
|
976
|
+
type: "function",
|
|
977
|
+
name: "run_project_build",
|
|
978
|
+
description: `Runs a command (typically "npm install" or "npm run build") in the shadow workspace for this migration run. Never runs against the user's original tree.`,
|
|
979
|
+
parameters: {
|
|
980
|
+
type: "object",
|
|
981
|
+
properties: {
|
|
982
|
+
command: {
|
|
983
|
+
type: "string",
|
|
984
|
+
description: 'The command to run. Use "npm install" then "npm run build".'
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
required: ["command"]
|
|
988
|
+
},
|
|
989
|
+
strict: true,
|
|
990
|
+
invoke: async (args) => {
|
|
991
|
+
const result = await runInShadowWorkspace(shadowBase, args.command || "npm run build", (chunk) => {
|
|
992
|
+
process.stdout.write(chunk);
|
|
993
|
+
});
|
|
994
|
+
return result.ok ? `Build Output:
|
|
995
|
+
${result.output}` : `Build Failed!
|
|
996
|
+
${result.output}`;
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
export {
|
|
1001
|
+
SQLiteStateStore,
|
|
1002
|
+
assertSandbox,
|
|
1003
|
+
createReadFileTool,
|
|
1004
|
+
createWriteFileTool,
|
|
1005
|
+
createRenameFileTool,
|
|
1006
|
+
createCreateFileTool,
|
|
1007
|
+
createDeleteFileTool,
|
|
1008
|
+
createListDirectoryTool,
|
|
1009
|
+
createShadowTools,
|
|
1010
|
+
ShadowWorkspace,
|
|
1011
|
+
isCopyIgnored,
|
|
1012
|
+
MigrationIntegrator,
|
|
1013
|
+
createApiServer,
|
|
1014
|
+
createCheckProjectDiagnosticsTool,
|
|
1015
|
+
runInShadowWorkspace,
|
|
1016
|
+
parseImplicatedFiles,
|
|
1017
|
+
createRunBuildTool
|
|
1018
|
+
};
|