@drewpayment/mink 0.11.0-beta.2 → 0.11.0-beta.3

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 (43) hide show
  1. package/dashboard/out/404.html +1 -1
  2. package/dashboard/out/action-log.html +1 -1
  3. package/dashboard/out/action-log.txt +1 -1
  4. package/dashboard/out/activity.html +1 -1
  5. package/dashboard/out/activity.txt +1 -1
  6. package/dashboard/out/bugs.html +1 -1
  7. package/dashboard/out/bugs.txt +1 -1
  8. package/dashboard/out/capture.html +1 -1
  9. package/dashboard/out/capture.txt +1 -1
  10. package/dashboard/out/config.html +1 -1
  11. package/dashboard/out/config.txt +1 -1
  12. package/dashboard/out/daemon.html +1 -1
  13. package/dashboard/out/daemon.txt +1 -1
  14. package/dashboard/out/design.html +1 -1
  15. package/dashboard/out/design.txt +1 -1
  16. package/dashboard/out/discord.html +1 -1
  17. package/dashboard/out/discord.txt +1 -1
  18. package/dashboard/out/file-index.html +1 -1
  19. package/dashboard/out/file-index.txt +1 -1
  20. package/dashboard/out/index.html +1 -1
  21. package/dashboard/out/index.txt +1 -1
  22. package/dashboard/out/insights.html +1 -1
  23. package/dashboard/out/insights.txt +1 -1
  24. package/dashboard/out/learning.html +1 -1
  25. package/dashboard/out/learning.txt +1 -1
  26. package/dashboard/out/overview.html +1 -1
  27. package/dashboard/out/overview.txt +1 -1
  28. package/dashboard/out/scheduler.html +1 -1
  29. package/dashboard/out/scheduler.txt +1 -1
  30. package/dashboard/out/sync.html +1 -1
  31. package/dashboard/out/sync.txt +1 -1
  32. package/dashboard/out/tokens.html +1 -1
  33. package/dashboard/out/tokens.txt +1 -1
  34. package/dashboard/out/waste.html +1 -1
  35. package/dashboard/out/waste.txt +1 -1
  36. package/dashboard/out/wiki.html +1 -1
  37. package/dashboard/out/wiki.txt +1 -1
  38. package/dist/cli.js +100 -20
  39. package/package.json +1 -1
  40. package/src/commands/sync-migrate.ts +158 -25
  41. package/src/core/sync.ts +7 -0
  42. /package/dashboard/out/_next/static/{WyN-sdaVY7cZaACRaK7vq → Mmf6YVSNZzpPOZiW-DG5M}/_buildManifest.js +0 -0
  43. /package/dashboard/out/_next/static/{WyN-sdaVY7cZaACRaK7vq → Mmf6YVSNZzpPOZiW-DG5M}/_ssgManifest.js +0 -0
@@ -6,6 +6,7 @@ import {
6
6
  writeFileSync,
7
7
  readFileSync,
8
8
  renameSync,
9
+ rmSync,
9
10
  unlinkSync,
10
11
  } from "fs";
11
12
  import { join } from "path";
@@ -236,7 +237,24 @@ function listProjectsNeedingMigration(): string[] {
236
237
  // Idempotent: re-running after a clean pass walks every project, finds every
237
238
  // id matches its directory name, and does nothing.
238
239
 
239
- export type IdentityPlanAction = "rename" | "skip-converged" | "skip-no-cwd" | "skip-unchanged";
240
+ // Plan actions:
241
+ // rename old dir present, new dir absent → rename old → new, record alias.
242
+ // skip-converged old dir + new dir both present, alias NOT yet recorded → record
243
+ // alias on new meta and evict old dir to .identity-rollback/. Named
244
+ // "skip-converged" because the rename itself is unnecessary; the
245
+ // convergence work (alias + eviction) is the action.
246
+ // skip-evict old dir + new dir both present, alias already recorded → only
247
+ // evict the old dir. Reached when a previous migration recorded
248
+ // the alias but didn't (or couldn't) finish evicting. Without
249
+ // this, dry-run would keep proposing skip-converged forever.
250
+ // skip-no-cwd project's working-copy path is on a different device — leave alone.
251
+ // skip-unchanged newId === oldId — no work needed at all.
252
+ export type IdentityPlanAction =
253
+ | "rename"
254
+ | "skip-converged"
255
+ | "skip-evict"
256
+ | "skip-no-cwd"
257
+ | "skip-unchanged";
240
258
 
241
259
  export interface IdentityPlanEntry {
242
260
  oldId: string;
@@ -309,12 +327,23 @@ export function planIdentityMigration(flagOverride?: string): IdentityPlanEntry[
309
327
 
310
328
  const newProjDir = join(projectsRoot, newId);
311
329
  if (existsSync(newProjDir)) {
330
+ // If the canonical (new) project already records this oldId in its
331
+ // aliases list, the convergence bookkeeping is already done — only the
332
+ // leftover old directory remains to be cleaned up. Surfacing this as a
333
+ // distinct action (skip-evict) makes dry-run idempotent: once the alias
334
+ // is recorded AND the old directory is gone, the planner stops seeing
335
+ // the project entirely. Pre-fix it would keep proposing the same
336
+ // skip-converged action forever.
337
+ const newMeta = getProjectMeta(newProjDir);
338
+ const aliasAlreadyRecorded = newMeta?.aliases?.includes(oldId) ?? false;
312
339
  plan.push({
313
340
  oldId,
314
341
  newId,
315
342
  cwd: meta.cwd,
316
- action: "skip-converged",
317
- reason: "destination already exists (from sync); alias-only update",
343
+ action: aliasAlreadyRecorded ? "skip-evict" : "skip-converged",
344
+ reason: aliasAlreadyRecorded
345
+ ? "alias already recorded; will evict leftover old directory"
346
+ : "destination already exists (from sync); will record alias and evict old directory",
318
347
  });
319
348
  continue;
320
349
  }
@@ -373,29 +402,55 @@ function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>):
373
402
  // that drives this very decision). Falls back to a fresh read for callers that
374
403
  // don't operate inside a stash window (e.g. session-start triggers and the
375
404
  // --dry-run path).
376
- function migrateProjectIdentities(
377
- deviceId: string,
378
- flag: string = resolveConfigValue("projects.identity").value
379
- ): {
405
+ export interface IdentityMigrationOutcome {
380
406
  renamed: number;
407
+ converged: number;
408
+ evicted: number;
381
409
  visited: number;
382
410
  backupDir: string | null;
383
- } {
411
+ renames: Array<{ from: string; to: string }>;
412
+ evictions: string[];
413
+ }
414
+
415
+ function migrateProjectIdentities(
416
+ deviceId: string,
417
+ flag: string = resolveConfigValue("projects.identity").value
418
+ ): IdentityMigrationOutcome {
384
419
  if (flag !== "git-remote") {
385
- return { renamed: 0, visited: 0, backupDir: null };
420
+ return {
421
+ renamed: 0,
422
+ converged: 0,
423
+ evicted: 0,
424
+ visited: 0,
425
+ backupDir: null,
426
+ renames: [],
427
+ evictions: [],
428
+ };
386
429
  }
387
430
 
388
431
  const plan = planIdentityMigration(flag);
389
- const willRename = plan.filter((p) => p.action === "rename");
432
+ // Any action that moves an old directory aside needs a backup destination.
433
+ // rename, skip-converged (record alias + evict), and skip-evict (alias
434
+ // already recorded; only evict) all qualify.
435
+ const willTouchOldDir = plan.filter(
436
+ (p) =>
437
+ p.action === "rename" ||
438
+ p.action === "skip-converged" ||
439
+ p.action === "skip-evict"
440
+ );
390
441
 
391
442
  // Compute the backup root up-front so all snapshots for this migration pass
392
443
  // land in one timestamped directory the user can find and reason about.
393
444
  let backupRoot: string | null = null;
394
- if (willRename.length > 0) {
445
+ if (willTouchOldDir.length > 0) {
395
446
  backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
396
447
  }
397
448
 
398
449
  let renamed = 0;
450
+ let converged = 0;
451
+ let evicted = 0;
452
+ const renames: Array<{ from: string; to: string }> = [];
453
+ const evictions: string[] = [];
399
454
  let visited = plan.length;
400
455
  const projectsRoot = join(minkRoot(), "projects");
401
456
 
@@ -412,16 +467,53 @@ function migrateProjectIdentities(
412
467
  }
413
468
  }
414
469
 
415
- if (entry.action === "skip-converged" && entry.newId) {
470
+ if (
471
+ (entry.action === "skip-converged" || entry.action === "skip-evict") &&
472
+ entry.newId
473
+ ) {
416
474
  const newProjDir = join(projectsRoot, entry.newId);
475
+ // Record the alias and lift the device path before evicting — if the
476
+ // canonical meta is broken (read returns null in addProjectAlias), the
477
+ // safer path is to leave the old directory alone rather than discard
478
+ // it without ever surfacing the alias. We detect that case below.
479
+ let aliasOnRecord = false;
417
480
  try {
418
- addProjectAlias(newProjDir, entry.oldId);
481
+ if (entry.action === "skip-evict") {
482
+ aliasOnRecord = true;
483
+ } else {
484
+ addProjectAlias(newProjDir, entry.oldId);
485
+ const newMeta = getProjectMeta(newProjDir);
486
+ aliasOnRecord = newMeta?.aliases?.includes(entry.oldId) ?? false;
487
+ }
419
488
  if (entry.cwd) {
420
489
  setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
421
490
  }
422
491
  } catch {
423
492
  // best-effort
424
493
  }
494
+
495
+ if (aliasOnRecord && backupRoot) {
496
+ // Snapshot the old dir before eviction so any local-only state
497
+ // (writes that landed here before sync converged) is recoverable.
498
+ const ok = backupProjectForRollback(
499
+ oldProjDir,
500
+ join(backupRoot, entry.oldId)
501
+ );
502
+ if (ok) {
503
+ let removed = false;
504
+ try {
505
+ rmSync(oldProjDir, { recursive: true, force: true });
506
+ removed = true;
507
+ } catch {
508
+ // best-effort; leave the directory rather than partially deleted
509
+ }
510
+ if (removed) {
511
+ evictions.push(entry.oldId);
512
+ evicted++;
513
+ if (entry.action === "skip-converged") converged++;
514
+ }
515
+ }
516
+ }
425
517
  continue;
426
518
  }
427
519
 
@@ -456,9 +548,18 @@ function migrateProjectIdentities(
456
548
  // best-effort
457
549
  }
458
550
  renamed++;
551
+ renames.push({ from: entry.oldId, to: entry.newId });
459
552
  }
460
553
 
461
- return { renamed, visited, backupDir: backupRoot };
554
+ return {
555
+ renamed,
556
+ converged,
557
+ evicted,
558
+ visited,
559
+ backupDir: backupRoot,
560
+ renames,
561
+ evictions,
562
+ };
462
563
  }
463
564
 
464
565
  // ── v3 identity rollback ──────────────────────────────────────────────────
@@ -545,6 +646,7 @@ export interface MigrateResult {
545
646
  fromVersion: number;
546
647
  toVersion: number;
547
648
  message?: string;
649
+ identity?: IdentityMigrationOutcome;
548
650
  }
549
651
 
550
652
  // Idempotent. Safe to invoke from `mink sync migrate` directly or from a
@@ -636,7 +738,15 @@ export function migrateSyncLayout(): MigrateResult {
636
738
  // flag is off or every project's identifier already matches its directory.
637
739
  // Pass the pre-stash snapshot of identityMode so we don't re-read the
638
740
  // config from a stash-hidden working tree.
639
- let identity = { renamed: 0, visited: 0 };
741
+ let identity: IdentityMigrationOutcome = {
742
+ renamed: 0,
743
+ converged: 0,
744
+ evicted: 0,
745
+ visited: 0,
746
+ backupDir: null,
747
+ renames: [],
748
+ evictions: [],
749
+ };
640
750
  try {
641
751
  identity = migrateProjectIdentities(deviceId, identityMode);
642
752
  } catch {
@@ -650,16 +760,19 @@ export function migrateSyncLayout(): MigrateResult {
650
760
  writeSyncVersion(MINK_SYNC_VERSION);
651
761
  }
652
762
 
653
- if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
763
+ if (
764
+ isSyncInitialized() &&
765
+ (processed > 0 || identity.renamed > 0 || identity.evicted > 0)
766
+ ) {
654
767
  // Skip the lock file — it's part of migration coordination, not state.
655
768
  gitSafe("add -A");
656
769
  gitSafe(`reset HEAD ".sync-migrate.lock"`);
657
- const summary =
658
- identity.renamed > 0
659
- ? `${processed} projects, ${identity.renamed} renamed for identity v3`
660
- : `${processed} projects`;
770
+ const identityNote =
771
+ identity.renamed > 0 || identity.evicted > 0
772
+ ? `, ${identity.renamed} renamed + ${identity.evicted} evicted for identity v3`
773
+ : "";
661
774
  gitSafe(
662
- `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
775
+ `commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${processed} projects${identityNote})"`
663
776
  );
664
777
  }
665
778
 
@@ -671,6 +784,7 @@ export function migrateSyncLayout(): MigrateResult {
671
784
  ranMigration: true,
672
785
  fromVersion,
673
786
  toVersion: MINK_SYNC_VERSION,
787
+ identity,
674
788
  };
675
789
  } finally {
676
790
  releaseLock();
@@ -696,20 +810,24 @@ export function syncMigrateCommand(args: string[] = []): void {
696
810
  }
697
811
  const renames = plan.filter((p) => p.action === "rename");
698
812
  const converged = plan.filter((p) => p.action === "skip-converged");
813
+ const evictOnly = plan.filter((p) => p.action === "skip-evict");
699
814
  const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
700
815
  const unchanged = plan.filter((p) => p.action === "skip-unchanged");
701
816
 
702
817
  console.log(
703
- `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
818
+ `[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} converge (alias + evict), ${evictOnly.length} evict-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
704
819
  );
705
820
  for (const p of renames) {
706
- console.log(` rename: ${p.oldId} → ${p.newId}`);
821
+ console.log(` rename: ${p.oldId} → ${p.newId}`);
707
822
  }
708
823
  for (const p of converged) {
709
- console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
824
+ console.log(` converge: ${p.oldId} → ${p.newId} (record alias on ${p.newId}, evict ${p.oldId} to .identity-rollback/)`);
825
+ }
826
+ for (const p of evictOnly) {
827
+ console.log(` evict: ${p.oldId} → .identity-rollback/ (alias already on ${p.newId})`);
710
828
  }
711
829
  for (const p of skippedNoCwd) {
712
- console.log(` skip: ${p.oldId} — ${p.reason}`);
830
+ console.log(` skip: ${p.oldId} — ${p.reason}`);
713
831
  }
714
832
  return;
715
833
  }
@@ -749,4 +867,19 @@ export function syncMigrateCommand(args: string[] = []): void {
749
867
  console.log(
750
868
  `[mink] sync migrate: v${result.fromVersion} → v${result.toVersion} complete`
751
869
  );
870
+ const identity = result.identity;
871
+ if (identity && (identity.renamed > 0 || identity.evicted > 0)) {
872
+ console.log(
873
+ ` identity: ${identity.renamed} renamed, ${identity.converged} converged, ${identity.evicted} evicted`
874
+ );
875
+ for (const r of identity.renames) {
876
+ console.log(` renamed: ${r.from} → ${r.to}`);
877
+ }
878
+ for (const id of identity.evictions) {
879
+ console.log(` evicted: ${id} → .identity-rollback/`);
880
+ }
881
+ if (identity.backupDir) {
882
+ console.log(` rollback snapshot: ${identity.backupDir}`);
883
+ }
884
+ }
752
885
  }
package/src/core/sync.ts CHANGED
@@ -51,6 +51,13 @@ config.local
51
51
  # Migration coordination — never sync this
52
52
  .sync-migrate.lock
53
53
 
54
+ # Per-device identity migration recovery snapshots — local recovery state
55
+ # for the migrating device, not authoritative project state. A device that
56
+ # needs to recover from its migration must do so from its own snapshot;
57
+ # syncing would make rollback dirs appear on devices that never produced
58
+ # the corresponding migration.
59
+ .identity-rollback/
60
+
54
61
  # Local backups and per-device caches — machine-specific snapshots
55
62
  projects/*/backups/
56
63
  projects/*/session.json