@nikelyh/metamorph 2.1.0 → 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.
@@ -1,5 +1,5 @@
1
1
  import {
2
- findMigrationCatalogEntry
2
+ resolveMigrationCatalog
3
3
  } from "./chunk-VEAYUIOZ.js";
4
4
 
5
5
  // ../infrastructure/src/db/SQLiteStateStore.ts
@@ -50,6 +50,10 @@ var SQLiteStateStore = class {
50
50
  this.db.exec("ALTER TABLE plans ADD COLUMN phase TEXT");
51
51
  } catch (e) {
52
52
  }
53
+ try {
54
+ this.db.exec("ALTER TABLE plans ADD COLUMN outcome TEXT");
55
+ } catch (e) {
56
+ }
53
57
  try {
54
58
  this.db.exec("ALTER TABLE plans ADD COLUMN applied_at TEXT");
55
59
  } catch (e) {
@@ -80,8 +84,8 @@ var SQLiteStateStore = class {
80
84
  }
81
85
  async savePlan(plan) {
82
86
  const stmtPlan = this.db.prepare(`
83
- INSERT OR REPLACE INTO plans (id, run_id, target_path, source_framework, target_framework, rules_json, created_at, integration_rounds, phase, applied_at, applied_branch)
84
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
85
89
  `);
86
90
  stmtPlan.run(
87
91
  plan.id,
@@ -93,6 +97,7 @@ var SQLiteStateStore = class {
93
97
  plan.createdAt.toISOString(),
94
98
  plan.integrationRounds ?? 0,
95
99
  plan.phase || "files",
100
+ plan.outcome || null,
96
101
  plan.appliedAt ? plan.appliedAt.toISOString() : null,
97
102
  plan.appliedBranch || null
98
103
  );
@@ -123,6 +128,7 @@ var SQLiteStateStore = class {
123
128
  createdAt: new Date(planRow.created_at),
124
129
  integrationRounds: planRow.integration_rounds ?? 0,
125
130
  phase: planRow.phase || "files",
131
+ outcome: planRow.outcome || void 0,
126
132
  appliedAt: planRow.applied_at ? new Date(planRow.applied_at) : void 0,
127
133
  appliedBranch: planRow.applied_branch || void 0,
128
134
  tasks: taskRows.map((row) => ({
@@ -195,7 +201,8 @@ function assertSandbox(filePath, sandboxDir) {
195
201
  const resolved = path.resolve(filePath);
196
202
  if (sandboxDir) {
197
203
  const resolvedSandbox = path.resolve(sandboxDir);
198
- if (!resolved.startsWith(resolvedSandbox)) {
204
+ const isInside = resolved === resolvedSandbox || resolved.startsWith(resolvedSandbox + path.sep);
205
+ if (!isInside) {
199
206
  throw new Error(
200
207
  `BLOCKED: Path "${resolved}" is outside the sandbox "${resolvedSandbox}". All file operations must stay within the shadow workspace.`
201
208
  );
@@ -396,6 +403,16 @@ function createListDirectoryTool(sandboxDir) {
396
403
  }
397
404
  };
398
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
+ }
399
416
 
400
417
  // ../infrastructure/src/workspace/ShadowWorkspace.ts
401
418
  import fs from "fs-extra";
@@ -454,15 +471,16 @@ var ShadowWorkspace = class {
454
471
  * Cleans up old shadow runs to save disk space, keeping only the specified number of most recent runs.
455
472
  * @param keepCount Number of recent runs to keep (default: 3)
456
473
  */
457
- cleanupOldRuns(keepCount = 3) {
474
+ cleanupOldRuns(keepCount = 3, protectedRunIds = []) {
458
475
  if (!fs.existsSync(this.baseDir)) return;
476
+ const protectedSet = new Set(protectedRunIds);
459
477
  const runs = fs.readdirSync(this.baseDir).filter((f) => f.startsWith("run_") && fs.statSync(join3(this.baseDir, f)).isDirectory());
460
478
  runs.sort();
461
- if (runs.length > keepCount) {
462
- const toDelete = runs.slice(0, runs.length - keepCount);
463
- for (const run of toDelete) {
464
- this.rollback(run);
465
- }
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);
466
484
  }
467
485
  }
468
486
  };
@@ -472,7 +490,11 @@ import fs2 from "fs-extra";
472
490
  import * as path2 from "path";
473
491
  import { execFileSync } from "child_process";
474
492
  var COPY_IGNORED = ["node_modules", ".git", ".metamorph", "dist", "build", "out", "coverage", ".next"];
475
- var GITIGNORE_ENTRIES = [".metamorph/", ".next/", "node_modules/", "dist/", "build/", "coverage/", ".env"];
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
+ }
476
498
  var MigrationIntegrator = class {
477
499
  shadowWorkspace;
478
500
  constructor(shadowWorkspace) {
@@ -494,6 +516,20 @@ var MigrationIntegrator = class {
494
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."
495
517
  );
496
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
+ }
497
533
  if (this.isIgnoredByGit(gitRoot, resolvedTarget)) {
498
534
  throw new Error(
499
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,22 +572,17 @@ var MigrationIntegrator = class {
536
572
  throw new Error(`git ${args.join(" ")} failed: ${detail || "unknown git error"}`);
537
573
  }
538
574
  }
539
- ensureApplyGitignore(gitRoot) {
540
- const gitignorePath = path2.join(gitRoot, ".gitignore");
541
- const existing = fs2.existsSync(gitignorePath) ? fs2.readFileSync(gitignorePath, "utf-8") : "";
542
- const missing = GITIGNORE_ENTRIES.filter((entry) => {
543
- const name = entry.replace(/\/$/, "").replace(".", "\\.");
544
- const pattern = new RegExp(`(^|\\n)${name}(/)?(\\n|$)`);
545
- return !pattern.test(existing.replace(/\r/g, "\n"));
546
- });
547
- if (missing.length === 0) return;
548
- const block = `
549
- # Metamorph apply
550
- ${missing.join("\n")}
551
- `;
552
- fs2.appendFileSync(gitignorePath, block, "utf-8");
553
- }
554
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
+ }
555
586
  try {
556
587
  this.git(["checkout", "-B", branchName], gitRoot);
557
588
  } catch (e) {
@@ -561,9 +592,6 @@ ${missing.join("\n")}
561
592
  throw new Error(`Failed to create/reset git branch ${branchName}: ${String(e)}`);
562
593
  }
563
594
  this.copyShadowToTarget(shadowDir, targetPath);
564
- this.ensureApplyGitignore(gitRoot);
565
- const relativeTarget = path2.relative(gitRoot, targetPath).replace(/\\/g, "/");
566
- const addPath = relativeTarget === "" ? "." : relativeTarget;
567
595
  try {
568
596
  try {
569
597
  this.git(["-c", "advice.addIgnoredFile=false", "add", "-A", "--", addPath], gitRoot);
@@ -597,13 +625,13 @@ ${missing.join("\n")}
597
625
  if (fs2.existsSync(targetPath)) {
598
626
  const targetItems = fs2.readdirSync(targetPath);
599
627
  for (const item of targetItems) {
600
- if (COPY_IGNORED.includes(item)) continue;
628
+ if (isCopyIgnored(item)) continue;
601
629
  fs2.removeSync(path2.join(targetPath, item));
602
630
  }
603
631
  }
604
632
  const shadowItems = fs2.readdirSync(shadowDir);
605
633
  for (const item of shadowItems) {
606
- if (COPY_IGNORED.includes(item)) continue;
634
+ if (isCopyIgnored(item)) continue;
607
635
  const itemSrc = path2.join(shadowDir, item);
608
636
  const itemDest = path2.join(targetPath, item);
609
637
  fs2.copySync(itemSrc, itemDest, { overwrite: true });
@@ -702,7 +730,7 @@ async function createApiServer(store, migrationRunner) {
702
730
  });
703
731
  return;
704
732
  }
705
- const catalogEntry = findMigrationCatalogEntry(from, to);
733
+ const catalogEntry = resolveMigrationCatalog(from, to);
706
734
  if (!catalogEntry) {
707
735
  res.status(400).json({
708
736
  error: `Unsupported migration profile: ${from} -> ${to}. Please check the supported catalog.`
@@ -732,15 +760,29 @@ async function createApiServer(store, migrationRunner) {
732
760
  });
733
761
  return;
734
762
  }
735
- const integrator = new MigrationIntegrator(migrationRunner.workspace);
736
- const result = await integrator.applyMigration(runId, targetPath);
737
763
  const plans = await store.getAllPlans();
738
764
  const plan = plans.find((item) => item.runId === runId);
739
- if (plan) {
740
- plan.appliedAt = /* @__PURE__ */ new Date();
741
- plan.appliedBranch = result.branch;
742
- await store.savePlan(plan);
765
+ if (!plan) {
766
+ res.status(404).json({ error: `Plan for run "${runId}" not found in database.` });
767
+ return;
743
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);
744
786
  await store.logEvent("migration.applied", { runId, branch: result.branch, targetPath });
745
787
  res.json({ ok: true, ...result });
746
788
  } catch (error) {
@@ -918,7 +960,11 @@ function parseImplicatedFiles(output, shadowPath) {
918
960
  const resolved = path4.resolve(abs);
919
961
  const inside = resolved === root || resolved.startsWith(root + path4.sep);
920
962
  if (!inside) return;
921
- if (fs3.existsSync(resolved) && fs3.statSync(resolved).isFile()) found.add(resolved);
963
+ if (fs3.existsSync(resolved)) {
964
+ if (fs3.statSync(resolved).isFile()) found.add(resolved);
965
+ } else {
966
+ found.add(resolved);
967
+ }
922
968
  };
923
969
  for (const re of patterns) {
924
970
  let match;
@@ -953,13 +999,16 @@ ${result.output}`;
953
999
 
954
1000
  export {
955
1001
  SQLiteStateStore,
1002
+ assertSandbox,
956
1003
  createReadFileTool,
957
1004
  createWriteFileTool,
958
1005
  createRenameFileTool,
959
1006
  createCreateFileTool,
960
1007
  createDeleteFileTool,
961
1008
  createListDirectoryTool,
1009
+ createShadowTools,
962
1010
  ShadowWorkspace,
1011
+ isCopyIgnored,
963
1012
  MigrationIntegrator,
964
1013
  createApiServer,
965
1014
  createCheckProjectDiagnosticsTool,