@jskit-ai/jskit-cli 0.2.13 → 0.2.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.16",
4
4
  "description": "Bundle and package orchestration CLI for JSKIT apps.",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,7 +20,7 @@
20
20
  "test": "node --test"
21
21
  },
22
22
  "dependencies": {
23
- "@jskit-ai/jskit-catalog": "0.1.13"
23
+ "@jskit-ai/jskit-catalog": "0.1.16"
24
24
  },
25
25
  "engines": {
26
26
  "node": "20.x"
@@ -101,7 +101,6 @@ function normalizeFileMutationRecord(value) {
101
101
  toSurfacePath: String(record.toSurfacePath || "").trim(),
102
102
  toSurfaceRoot: record.toSurfaceRoot === true,
103
103
  toDir: String(record.toDir || "").trim(),
104
- slug: String(record.slug || "").trim(),
105
104
  extension: normalizeMutationExtension(record.extension),
106
105
  preserveOnRemove: record.preserveOnRemove === true,
107
106
  id: String(record.id || "").trim(),
@@ -262,14 +261,14 @@ function shouldApplyMutationWhen(
262
261
  return true;
263
262
  }
264
263
 
265
- function buildFileWriteGroups(fileMutations) {
264
+ function buildFileWriteGroups(fileMutations, { packageId = "" } = {}) {
266
265
  const groups = [];
267
266
  const groupsByKey = new Map();
268
267
 
269
268
  for (const mutation of ensureArray(fileMutations)) {
270
269
  const normalized = normalizeFileMutationRecord(mutation);
271
270
  if (normalized.op === "install-migration") {
272
- if (!normalized.from || !normalized.slug) {
271
+ if (!normalized.from || !normalized.id) {
273
272
  continue;
274
273
  }
275
274
  } else if (!normalized.from || (!normalized.to && !normalized.toSurface)) {
@@ -312,7 +311,12 @@ function buildFileWriteGroups(fileMutations) {
312
311
  const extension = normalized.extension || ".cjs";
313
312
  group.files.push({
314
313
  from: normalized.from,
315
- to: `${toDir}/<timestamp>_${normalized.slug}${extension}`
314
+ to: buildManagedMigrationRelativePathLabel({
315
+ toDir,
316
+ packageId,
317
+ migrationId: normalized.id,
318
+ extension
319
+ })
316
320
  });
317
321
  continue;
318
322
  }
@@ -334,29 +338,6 @@ function hashBuffer(buffer) {
334
338
  return createHash("sha256").update(buffer).digest("hex");
335
339
  }
336
340
 
337
- function formatMigrationTimestamp(date = new Date()) {
338
- const source = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
339
- const year = source.getUTCFullYear();
340
- const month = String(source.getUTCMonth() + 1).padStart(2, "0");
341
- const day = String(source.getUTCDate()).padStart(2, "0");
342
- const hours = String(source.getUTCHours()).padStart(2, "0");
343
- const minutes = String(source.getUTCMinutes()).padStart(2, "0");
344
- const seconds = String(source.getUTCSeconds()).padStart(2, "0");
345
- return `${year}${month}${day}${hours}${minutes}${seconds}`;
346
- }
347
-
348
- function normalizeMigrationSlug(value, packageId) {
349
- const normalized = String(value || "")
350
- .trim()
351
- .toLowerCase()
352
- .replace(/[^a-z0-9_]+/g, "_")
353
- .replace(/^_+|_+$/g, "");
354
- if (!normalized) {
355
- throw createCliError(`Invalid install-migration mutation in ${packageId}: \"slug\" is required.`);
356
- }
357
- return normalized;
358
- }
359
-
360
341
  function normalizeMigrationExtension(value = "", fallback = ".cjs") {
361
342
  const normalizedFallback = String(fallback || ".cjs").trim() || ".cjs";
362
343
  const raw = String(value || "").trim();
@@ -367,46 +348,174 @@ function normalizeMigrationExtension(value = "", fallback = ".cjs") {
367
348
  return candidate.toLowerCase();
368
349
  }
369
350
 
370
- const JSKIT_MIGRATION_ID_PATTERN = /JSKIT_MIGRATION_ID:\s*([A-Za-z0-9._-]+)/i;
351
+ const MIGRATION_ID_PATTERN = /^[a-z0-9._-]+$/;
371
352
 
372
- function extractMigrationIdFromSource(source) {
373
- const content = String(source || "");
374
- const match = content.match(JSKIT_MIGRATION_ID_PATTERN);
375
- if (!match) {
376
- return "";
353
+ function normalizeMigrationId(value, packageId) {
354
+ const normalized = String(value || "").trim();
355
+ if (!normalized) {
356
+ throw createCliError(`Invalid install-migration mutation in ${packageId}: \"id\" is required.`);
377
357
  }
378
- return String(match[1] || "").trim();
358
+ if (!MIGRATION_ID_PATTERN.test(normalized)) {
359
+ throw createCliError(
360
+ `Invalid install-migration mutation in ${packageId}: "id" must match ${MIGRATION_ID_PATTERN.source}.`
361
+ );
362
+ }
363
+ return normalized;
379
364
  }
380
365
 
381
- async function findExistingMigrationById({ appRoot, migrationsDirectory, migrationId }) {
382
- const normalizedMigrationId = String(migrationId || "").trim();
383
- if (!normalizedMigrationId) {
384
- return null;
366
+ function resolveAppRelativePathWithinRoot(appRoot, relativePath, contextLabel = "path") {
367
+ const normalized = normalizeRelativePosixPath(String(relativePath || "").trim());
368
+ if (!normalized) {
369
+ throw createCliError(`Invalid ${contextLabel}: path is required.`);
370
+ }
371
+ const segments = normalized.split("/");
372
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
373
+ throw createCliError(`Invalid ${contextLabel}: path must be a safe relative path.`);
374
+ }
375
+
376
+ const appRootAbsolute = path.resolve(appRoot);
377
+ const absolutePath = path.resolve(appRootAbsolute, normalized);
378
+ const relativeFromRoot = path.relative(appRootAbsolute, absolutePath);
379
+ if (
380
+ relativeFromRoot === ".." ||
381
+ relativeFromRoot.startsWith(`..${path.sep}`) ||
382
+ path.isAbsolute(relativeFromRoot)
383
+ ) {
384
+ throw createCliError(`Invalid ${contextLabel}: path must stay within app root.`);
385
385
  }
386
386
 
387
- const absoluteDirectory = path.join(appRoot, migrationsDirectory);
388
- if (!(await fileExists(absoluteDirectory))) {
387
+ return {
388
+ relativePath: normalized,
389
+ absolutePath
390
+ };
391
+ }
392
+
393
+ function normalizeMigrationDirectory(value, packageId) {
394
+ const normalized = normalizeRelativePosixPath(String(value || "").trim() || "migrations");
395
+ if (!normalized) {
396
+ throw createCliError(`Invalid install-migration mutation in ${packageId}: "toDir" cannot be empty.`);
397
+ }
398
+
399
+ const segments = normalized.split("/");
400
+ if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
401
+ throw createCliError(`Invalid install-migration mutation in ${packageId}: "toDir" must be a safe relative path.`);
402
+ }
403
+
404
+ return segments.join("/");
405
+ }
406
+
407
+ function formatMigrationTimestamp(date = new Date()) {
408
+ const source = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
409
+ const year = String(source.getUTCFullYear()).padStart(4, "0");
410
+ const month = String(source.getUTCMonth() + 1).padStart(2, "0");
411
+ const day = String(source.getUTCDate()).padStart(2, "0");
412
+ const hour = String(source.getUTCHours()).padStart(2, "0");
413
+ const minute = String(source.getUTCMinutes()).padStart(2, "0");
414
+ const second = String(source.getUTCSeconds()).padStart(2, "0");
415
+ return `${year}${month}${day}${hour}${minute}${second}`;
416
+ }
417
+
418
+ function buildManagedMigrationFileName({ packageId = "", migrationId = "", extension = ".cjs", timestamp = "" } = {}) {
419
+ const normalizedMigrationId = normalizeMigrationId(migrationId, packageId);
420
+ const normalizedExtension = normalizeMigrationExtension(extension, ".cjs");
421
+ const normalizedTimestamp = String(timestamp || "").trim();
422
+ if (!/^\d{14}$/.test(normalizedTimestamp)) {
423
+ throw createCliError(
424
+ `Invalid install-migration mutation in ${packageId}: timestamp must be a 14-digit UTC string (YYYYMMDDHHmmss).`
425
+ );
426
+ }
427
+ return `${normalizedTimestamp}_${normalizedMigrationId}${normalizedExtension}`;
428
+ }
429
+
430
+ function buildManagedMigrationRelativePath({ toDir = "migrations", packageId = "", migrationId = "", extension = ".cjs", timestamp = "" } = {}) {
431
+ const normalizedDirectory = normalizeMigrationDirectory(toDir, packageId);
432
+ const fileName = buildManagedMigrationFileName({
433
+ packageId,
434
+ migrationId,
435
+ extension,
436
+ timestamp
437
+ });
438
+ return path.posix.join(normalizedDirectory, fileName);
439
+ }
440
+
441
+ function buildManagedMigrationRelativePathLabel({ toDir = "migrations", migrationId = "", extension = ".cjs" } = {}) {
442
+ const directory = normalizeRelativePosixPath(String(toDir || "").trim() || "migrations") || "migrations";
443
+ const id = String(migrationId || "<migration-id>").trim() || "<migration-id>";
444
+ const rawExtension = String(extension || ".cjs").trim() || ".cjs";
445
+ const ext = rawExtension.startsWith(".") ? rawExtension : `.${rawExtension}`;
446
+ return `${directory}/<timestamp>_${id}${ext}`.replace(/\/{2,}/g, "/");
447
+ }
448
+
449
+ async function findExistingManagedMigrationPathById({
450
+ appRoot,
451
+ toDir = "migrations",
452
+ packageId = "",
453
+ migrationId = "",
454
+ extension = ".cjs"
455
+ } = {}) {
456
+ const normalizedDirectory = normalizeMigrationDirectory(toDir, packageId);
457
+ const resolvedDirectory = resolveAppRelativePathWithinRoot(
458
+ appRoot,
459
+ normalizedDirectory,
460
+ `${packageId} migration directory for ${migrationId}`
461
+ );
462
+ if (!(await fileExists(resolvedDirectory.absolutePath))) {
389
463
  return null;
390
464
  }
391
465
 
392
- const entries = await readdir(absoluteDirectory, { withFileTypes: true }).catch(() => []);
466
+ const normalizedMigrationId = normalizeMigrationId(migrationId, packageId);
467
+ const normalizedExtension = normalizeMigrationExtension(extension, ".cjs");
468
+ const suffix = `_${normalizedMigrationId}${normalizedExtension}`;
469
+ const entries = await readdir(resolvedDirectory.absolutePath, { withFileTypes: true }).catch(() => []);
470
+ const matches = [];
471
+
393
472
  for (const entry of entries) {
394
473
  if (!entry.isFile()) {
395
474
  continue;
396
475
  }
397
- const absolutePath = path.join(absoluteDirectory, entry.name);
398
- const fileContent = await readFile(absolutePath, "utf8").catch(() => "");
399
- const fileMigrationId = extractMigrationIdFromSource(fileContent);
400
- if (!fileMigrationId || fileMigrationId !== normalizedMigrationId) {
476
+ const fileName = String(entry.name || "").trim();
477
+ if (!fileName.endsWith(suffix)) {
401
478
  continue;
402
479
  }
480
+ const timestamp = fileName.slice(0, fileName.length - suffix.length);
481
+ if (!/^\d{14}$/.test(timestamp)) {
482
+ continue;
483
+ }
484
+ matches.push({
485
+ relativePath: path.posix.join(resolvedDirectory.relativePath, fileName),
486
+ absolutePath: path.join(resolvedDirectory.absolutePath, fileName)
487
+ });
488
+ }
403
489
 
404
- return {
405
- path: normalizeRelativePath(appRoot, absolutePath)
406
- };
490
+ matches.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
491
+ if (matches.length > 1) {
492
+ throw createCliError(
493
+ `${packageId}: found multiple migration files for ${normalizedMigrationId} in ${resolvedDirectory.relativePath}. Keep one file for this migration id.`
494
+ );
495
+ }
496
+ return matches[0] || null;
497
+ }
498
+
499
+ function upsertManagedMigrationRecord(managedMigrations, record) {
500
+ const records = ensureArray(managedMigrations);
501
+ const normalizedId = String(ensureObject(record).id || "").trim();
502
+ if (!normalizedId) {
503
+ return;
407
504
  }
408
505
 
409
- return null;
506
+ const nextRecord = {
507
+ ...ensureObject(record),
508
+ id: normalizedId
509
+ };
510
+ const existingIndex = records.findIndex(
511
+ (entry) => String(ensureObject(entry).id || "").trim() === normalizedId
512
+ );
513
+ if (existingIndex >= 0) {
514
+ records[existingIndex] = nextRecord;
515
+ return;
516
+ }
517
+
518
+ records.push(nextRecord);
410
519
  }
411
520
 
412
521
  function toScopedPackageId(input) {
@@ -1055,6 +1164,28 @@ function normalizePackageInstallationMode(rawValue, descriptorPath) {
1055
1164
  return normalized;
1056
1165
  }
1057
1166
 
1167
+ function validateInstallMigrationMutationShape(descriptor, descriptorPath) {
1168
+ const packageId = String(ensureObject(descriptor).packageId || "").trim() || "unknown-package";
1169
+ const mutations = ensureObject(ensureObject(descriptor).mutations);
1170
+ const files = ensureArray(mutations.files);
1171
+ for (const fileMutation of files) {
1172
+ const normalized = normalizeFileMutationRecord(fileMutation);
1173
+ if (normalized.op !== "install-migration") {
1174
+ continue;
1175
+ }
1176
+ if (!normalized.from) {
1177
+ throw createCliError(
1178
+ `Invalid package descriptor at ${descriptorPath}: install-migration in ${packageId} requires "from".`
1179
+ );
1180
+ }
1181
+ if (!normalized.id) {
1182
+ throw createCliError(
1183
+ `Invalid package descriptor at ${descriptorPath}: install-migration in ${packageId} requires "id".`
1184
+ );
1185
+ }
1186
+ }
1187
+ }
1188
+
1058
1189
  function validatePackageDescriptorShape(descriptor, descriptorPath) {
1059
1190
  const normalized = ensureObject(descriptor);
1060
1191
  const packageId = String(normalized.packageId || "").trim();
@@ -1078,6 +1209,8 @@ function validatePackageDescriptorShape(descriptor, descriptorPath) {
1078
1209
  );
1079
1210
  }
1080
1211
 
1212
+ validateInstallMigrationMutationShape(normalized, descriptorPath);
1213
+
1081
1214
  return {
1082
1215
  ...normalized,
1083
1216
  installationMode: normalizePackageInstallationMode(normalized.installationMode, descriptorPath)
@@ -1106,6 +1239,8 @@ function validateAppLocalPackageDescriptorShape(descriptor, descriptorPath, { ex
1106
1239
  throw createCliError(`Invalid app-local package descriptor at ${descriptorPath}: missing version.`);
1107
1240
  }
1108
1241
 
1242
+ validateInstallMigrationMutationShape(normalized, descriptorPath);
1243
+
1109
1244
  return {
1110
1245
  ...normalized,
1111
1246
  packageId,
@@ -1545,15 +1680,30 @@ function resolvePackageDependencySpecifier(packageEntry, { existingValue = "" }
1545
1680
 
1546
1681
  const descriptorVersion = String(packageEntry?.version || "").trim();
1547
1682
  if (descriptorVersion) {
1548
- return descriptorVersion;
1683
+ return normalizeJskitDependencySpecifier(packageEntry?.packageId, descriptorVersion);
1549
1684
  }
1550
1685
  const packageJsonVersion = String(packageEntry?.packageJson?.version || "").trim();
1551
1686
  if (packageJsonVersion) {
1552
- return packageJsonVersion;
1687
+ return normalizeJskitDependencySpecifier(packageEntry?.packageId, packageJsonVersion);
1553
1688
  }
1554
1689
  throw createCliError(`Unable to resolve dependency specifier for ${String(packageEntry?.packageId || "unknown package")}.`);
1555
1690
  }
1556
1691
 
1692
+ function normalizeJskitDependencySpecifier(packageId, dependencySpecifier) {
1693
+ const normalizedPackageId = String(packageId || "").trim();
1694
+ const normalizedSpecifier = String(dependencySpecifier || "").trim();
1695
+ if (!normalizedSpecifier || !normalizedPackageId.startsWith("@jskit-ai/")) {
1696
+ return normalizedSpecifier;
1697
+ }
1698
+
1699
+ const semverMatch = /^(\d+)\.\d+\.\d+(?:[.+-][0-9A-Za-z.-]+)?$/.exec(normalizedSpecifier);
1700
+ if (!semverMatch) {
1701
+ return normalizedSpecifier;
1702
+ }
1703
+
1704
+ return `${semverMatch[1]}.x`;
1705
+ }
1706
+
1557
1707
  function normalizePackageNameSegment(rawValue, { label = "package name" } = {}) {
1558
1708
  const lowered = String(rawValue || "")
1559
1709
  .trim()
@@ -3415,7 +3565,7 @@ async function cleanupMaterializedPackageRoots() {
3415
3565
 
3416
3566
  function interpolateFileMutationRecord(mutation, options, packageId) {
3417
3567
  const mutationKey = String(
3418
- mutation?.id || mutation?.slug || mutation?.to || mutation?.toSurface || mutation?.from || "files"
3568
+ mutation?.id || mutation?.to || mutation?.toSurface || mutation?.from || "files"
3419
3569
  ).trim();
3420
3570
  const interpolate = (value, field) =>
3421
3571
  interpolateOptionValue(String(value || ""), options, packageId, `${mutationKey}.${field}`);
@@ -3427,7 +3577,6 @@ function interpolateFileMutationRecord(mutation, options, packageId) {
3427
3577
  toSurface: interpolate(mutation.toSurface, "toSurface"),
3428
3578
  toSurfacePath: interpolate(mutation.toSurfacePath, "toSurfacePath"),
3429
3579
  toDir: interpolate(mutation.toDir, "toDir"),
3430
- slug: interpolate(mutation.slug, "slug"),
3431
3580
  extension: interpolate(mutation.extension, "extension"),
3432
3581
  id: interpolate(mutation.id, "id"),
3433
3582
  category: interpolate(mutation.category, "category"),
@@ -3627,6 +3776,16 @@ async function applyFileMutations(
3627
3776
  touchedFiles,
3628
3777
  warnings = []
3629
3778
  ) {
3779
+ const managedMigrationById = new Map();
3780
+ for (const managedMigrationValue of ensureArray(managedMigrations)) {
3781
+ const managedMigration = ensureObject(managedMigrationValue);
3782
+ const migrationId = String(managedMigration.id || "").trim();
3783
+ if (!migrationId) {
3784
+ continue;
3785
+ }
3786
+ managedMigrationById.set(migrationId, managedMigration);
3787
+ }
3788
+
3630
3789
  for (const mutationValue of fileMutations) {
3631
3790
  const normalizedMutation = normalizeFileMutationRecord(mutationValue);
3632
3791
  const requiresConfigContext = Boolean(normalizedMutation.when?.config || normalizedMutation.toSurface);
@@ -3658,8 +3817,8 @@ async function applyFileMutations(
3658
3817
  if (!from) {
3659
3818
  throw createCliError(`Invalid install-migration mutation in ${packageEntry.packageId}: \"from\" is required.`);
3660
3819
  }
3820
+ const migrationId = normalizeMigrationId(mutation.id, packageEntry.packageId);
3661
3821
 
3662
- const slug = normalizeMigrationSlug(mutation.slug, packageEntry.packageId);
3663
3822
  const sourcePath = path.join(packageEntry.rootDir, from);
3664
3823
  if (!(await fileExists(sourcePath))) {
3665
3824
  throw createCliError(`Missing migration template source ${sourcePath} for ${packageEntry.packageId}.`);
@@ -3667,65 +3826,138 @@ async function applyFileMutations(
3667
3826
 
3668
3827
  const sourceContent = await readFile(sourcePath, "utf8");
3669
3828
  const renderedSourceContent = sourceContent.includes("${")
3670
- ? interpolateOptionValue(sourceContent, options, packageEntry.packageId, `${mutation.id || slug}.source`)
3829
+ ? interpolateOptionValue(sourceContent, options, packageEntry.packageId, `${mutation.id || from}.source`)
3671
3830
  : sourceContent;
3672
3831
  const sourceExtension = normalizeMigrationExtension(path.extname(from), ".cjs");
3673
3832
  const extension = normalizeMigrationExtension(mutation.extension, sourceExtension);
3674
- const migrationId =
3675
- String(mutation.id || "").trim() ||
3676
- extractMigrationIdFromSource(renderedSourceContent) ||
3677
- `${packageEntry.packageId}:${slug}`;
3678
- const existingMigration = await findExistingMigrationById({
3679
- appRoot,
3680
- migrationsDirectory: toDir,
3681
- migrationId
3682
- });
3833
+ const sourceHash = hashBuffer(Buffer.from(renderedSourceContent, "utf8"));
3834
+
3835
+ const existingManagedRecord = managedMigrationById.get(migrationId);
3836
+ if (existingManagedRecord) {
3837
+ const existingManagedPath = normalizeRelativePosixPath(String(existingManagedRecord.path || "").trim());
3838
+ if (!existingManagedPath) {
3839
+ throw createCliError(
3840
+ `${packageEntry.packageId}: managed migration ${migrationId} is missing path in lock.`
3841
+ );
3842
+ }
3843
+ const resolvedManagedPath = resolveAppRelativePathWithinRoot(
3844
+ appRoot,
3845
+ existingManagedPath,
3846
+ `${packageEntry.packageId} managed migration path for ${migrationId}`
3847
+ );
3848
+ const relativePath = resolvedManagedPath.relativePath;
3849
+ const absolutePath = resolvedManagedPath.absolutePath;
3850
+ let existingSourceHash = String(existingManagedRecord.hash || "").trim();
3851
+ if (!existingSourceHash && existingManagedPath && (await fileExists(absolutePath))) {
3852
+ const existingSource = await readFile(absolutePath);
3853
+ existingSourceHash = hashBuffer(existingSource);
3854
+ }
3683
3855
 
3684
- if (existingMigration) {
3856
+ if (existingSourceHash && existingSourceHash !== sourceHash) {
3857
+ throw createCliError(
3858
+ `${packageEntry.packageId}: migration ${migrationId} changed after install. Keep migrations immutable and create a new migration id.`
3859
+ );
3860
+ }
3861
+
3862
+ if (!(await fileExists(absolutePath))) {
3863
+ await mkdir(path.dirname(absolutePath), { recursive: true });
3864
+ await writeFile(absolutePath, renderedSourceContent, "utf8");
3865
+ touchedFiles.add(relativePath);
3866
+ }
3867
+
3868
+ const nextManagedRecord = {
3869
+ ...existingManagedRecord,
3870
+ id: migrationId,
3871
+ path: relativePath,
3872
+ hash: sourceHash,
3873
+ skipped: true,
3874
+ reason: mutation.reason || String(existingManagedRecord.reason || ""),
3875
+ category: mutation.category || String(existingManagedRecord.category || "")
3876
+ };
3877
+ managedMigrationById.set(migrationId, nextManagedRecord);
3878
+ upsertManagedMigrationRecord(managedMigrations, nextManagedRecord);
3685
3879
  warnings.push(
3686
- `${packageEntry.packageId}: skipped migration ${migrationId} (already installed at ${existingMigration.path}).`
3880
+ `${packageEntry.packageId}: skipped migration ${migrationId} (already managed at ${nextManagedRecord.path}).`
3687
3881
  );
3688
- managedMigrations.push({
3882
+ continue;
3883
+ }
3884
+
3885
+ const existingPathById = await findExistingManagedMigrationPathById({
3886
+ appRoot,
3887
+ toDir,
3888
+ packageId: packageEntry.packageId,
3889
+ migrationId,
3890
+ extension
3891
+ });
3892
+ if (existingPathById) {
3893
+ const existingSource = await readFile(existingPathById.absolutePath);
3894
+ const existingSourceHash = hashBuffer(existingSource);
3895
+ if (existingSourceHash !== sourceHash) {
3896
+ throw createCliError(
3897
+ `${packageEntry.packageId}: migration ${migrationId} changed after install. Keep migrations immutable and create a new migration id.`
3898
+ );
3899
+ }
3900
+ const nextManagedRecord = {
3689
3901
  id: migrationId,
3690
- path: existingMigration.path,
3902
+ path: existingPathById.relativePath,
3903
+ hash: sourceHash,
3691
3904
  skipped: true,
3692
3905
  reason: mutation.reason,
3693
3906
  category: mutation.category
3694
- });
3907
+ };
3908
+ managedMigrationById.set(migrationId, nextManagedRecord);
3909
+ upsertManagedMigrationRecord(managedMigrations, nextManagedRecord);
3910
+ warnings.push(
3911
+ `${packageEntry.packageId}: skipped migration ${migrationId} (already exists at ${nextManagedRecord.path}).`
3912
+ );
3695
3913
  continue;
3696
3914
  }
3697
3915
 
3698
- const migrationsDirectoryAbsolute = path.join(appRoot, toDir);
3699
- await mkdir(migrationsDirectoryAbsolute, { recursive: true });
3700
-
3701
- const baseNow = Date.now();
3702
- let targetPath = "";
3703
- let offsetSeconds = 0;
3704
- while (offsetSeconds < 86400) {
3705
- const timestamp = formatMigrationTimestamp(new Date(baseNow + offsetSeconds * 1000));
3706
- const fileName = `${timestamp}_${slug}${extension}`;
3707
- const candidatePath = path.join(migrationsDirectoryAbsolute, fileName);
3708
- if (!(await fileExists(candidatePath))) {
3709
- targetPath = candidatePath;
3710
- break;
3916
+ const baseNowMs = Date.now();
3917
+ let targetPath = null;
3918
+ for (let secondOffset = 0; secondOffset < 86400; secondOffset += 1) {
3919
+ const timestamp = formatMigrationTimestamp(new Date(baseNowMs + secondOffset * 1000));
3920
+ const candidateRelativePath = buildManagedMigrationRelativePath({
3921
+ toDir,
3922
+ packageId: packageEntry.packageId,
3923
+ migrationId,
3924
+ extension,
3925
+ timestamp
3926
+ });
3927
+ const candidatePath = resolveAppRelativePathWithinRoot(
3928
+ appRoot,
3929
+ candidateRelativePath,
3930
+ `${packageEntry.packageId} migration path for ${migrationId}`
3931
+ );
3932
+ if (await fileExists(candidatePath.absolutePath)) {
3933
+ continue;
3711
3934
  }
3712
- offsetSeconds += 1;
3935
+ targetPath = candidatePath;
3936
+ break;
3713
3937
  }
3714
3938
 
3715
3939
  if (!targetPath) {
3716
- throw createCliError(`Unable to allocate migration filename for ${packageEntry.packageId}:${migrationId}.`);
3940
+ throw createCliError(
3941
+ `${packageEntry.packageId}: unable to allocate migration filename for ${migrationId} in ${toDir}.`
3942
+ );
3717
3943
  }
3718
3944
 
3719
- await writeFile(targetPath, renderedSourceContent, "utf8");
3720
- const relativePath = normalizeRelativePath(appRoot, targetPath);
3945
+ const relativePath = targetPath.relativePath;
3946
+ const absolutePath = targetPath.absolutePath;
3947
+ await mkdir(path.dirname(absolutePath), { recursive: true });
3948
+ await writeFile(absolutePath, renderedSourceContent, "utf8");
3721
3949
  touchedFiles.add(relativePath);
3722
- managedMigrations.push({
3950
+
3951
+ const nextManagedRecord = {
3723
3952
  id: migrationId,
3724
3953
  path: relativePath,
3954
+ hash: sourceHash,
3725
3955
  skipped: false,
3726
3956
  reason: mutation.reason,
3727
3957
  category: mutation.category
3728
- });
3958
+ };
3959
+ managedMigrationById.set(migrationId, nextManagedRecord);
3960
+ upsertManagedMigrationRecord(managedMigrations, nextManagedRecord);
3729
3961
  continue;
3730
3962
  }
3731
3963
 
@@ -4121,6 +4353,7 @@ async function applyPackageInstall({
4121
4353
  });
4122
4354
 
4123
4355
  const managedRecord = createManagedRecordBase(packageEntry, packageOptions);
4356
+ managedRecord.managed.migrations = cloneManagedArray(existingManaged.migrations);
4124
4357
  const cloneOnlyPackage = isCloneOnlyPackageEntry(packageEntry);
4125
4358
  const mutationWarnings = [];
4126
4359
  const mutations = ensureObject(packageEntry.descriptor.mutations);
@@ -4161,7 +4394,8 @@ async function applyPackageInstall({
4161
4394
  const resolvedValue = localPackage
4162
4395
  ? resolvePackageDependencySpecifier(localPackage, { existingValue: existingRuntimeDependencyValue })
4163
4396
  : String(dependencyVersion);
4164
- const applied = applyPackageJsonField(appPackageJson, "dependencies", dependencyId, resolvedValue);
4397
+ const normalizedResolvedValue = normalizeJskitDependencySpecifier(dependencyId, resolvedValue);
4398
+ const applied = applyPackageJsonField(appPackageJson, "dependencies", dependencyId, normalizedResolvedValue);
4165
4399
  if (applied.changed) {
4166
4400
  managedRecord.managed.packageJson.dependencies[dependencyId] = applied.managed;
4167
4401
  touchedFiles.add("package.json");
@@ -4192,7 +4426,8 @@ async function applyPackageInstall({
4192
4426
  const resolvedValue = localPackage
4193
4427
  ? resolvePackageDependencySpecifier(localPackage, { existingValue: existingDevDependencyValue })
4194
4428
  : String(dependencyVersion);
4195
- const applied = applyPackageJsonField(appPackageJson, "devDependencies", dependencyId, resolvedValue);
4429
+ const normalizedResolvedValue = normalizeJskitDependencySpecifier(dependencyId, resolvedValue);
4430
+ const applied = applyPackageJsonField(appPackageJson, "devDependencies", dependencyId, normalizedResolvedValue);
4196
4431
  if (applied.changed) {
4197
4432
  managedRecord.managed.packageJson.devDependencies[dependencyId] = applied.managed;
4198
4433
  touchedFiles.add("package.json");
@@ -4210,7 +4445,13 @@ async function applyPackageInstall({
4210
4445
  const selfDependencyValue = resolvePackageDependencySpecifier(packageEntry, {
4211
4446
  existingValue: existingSelfDependencyValue
4212
4447
  });
4213
- const selfApplied = applyPackageJsonField(appPackageJson, "dependencies", packageEntry.packageId, selfDependencyValue);
4448
+ const normalizedSelfDependencyValue = normalizeJskitDependencySpecifier(packageEntry.packageId, selfDependencyValue);
4449
+ const selfApplied = applyPackageJsonField(
4450
+ appPackageJson,
4451
+ "dependencies",
4452
+ packageEntry.packageId,
4453
+ normalizedSelfDependencyValue
4454
+ );
4214
4455
  if (selfApplied.changed) {
4215
4456
  managedRecord.managed.packageJson.dependencies[packageEntry.packageId] = selfApplied.managed;
4216
4457
  touchedFiles.add("package.json");
@@ -417,7 +417,10 @@ function createCommandHandlers(deps) {
417
417
  if (resolvedPackageId) {
418
418
  const packageEntry = packageRegistry.get(resolvedPackageId);
419
419
  const descriptor = packageEntry.descriptor;
420
- const fileWriteGroups = buildFileWriteGroups(ensureArray(ensureObject(descriptor.mutations).files));
420
+ const fileWriteGroups = buildFileWriteGroups(
421
+ ensureArray(ensureObject(descriptor.mutations).files),
422
+ { packageId: descriptor.packageId }
423
+ );
421
424
  const fileWriteCount = fileWriteGroups.reduce((total, group) => total + ensureArray(group.files).length, 0);
422
425
  const capabilities = ensureObject(descriptor.capabilities);
423
426
  const runtime = ensureObject(descriptor.runtime);