@drewpayment/mink 0.11.0-beta.1 → 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 (44) 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 +110 -29
  39. package/package.json +1 -1
  40. package/src/commands/sync-migrate.ts +186 -28
  41. package/src/core/project-id.ts +10 -2
  42. package/src/core/sync.ts +7 -0
  43. /package/dashboard/out/_next/static/{WDjkNLHEd_wI-oOzLyblH → Mmf6YVSNZzpPOZiW-DG5M}/_buildManifest.js +0 -0
  44. /package/dashboard/out/_next/static/{WDjkNLHEd_wI-oOzLyblH → 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;
@@ -248,9 +266,15 @@ export interface IdentityPlanEntry {
248
266
 
249
267
  // Walks every project on disk and returns the rename plan without touching it.
250
268
  // Backbone for both --dry-run and the real migration so they share logic.
251
- export function planIdentityMigration(): IdentityPlanEntry[] {
269
+ //
270
+ // Accepts an optional `flagOverride` so callers that have already snapshotted
271
+ // `projects.identity` (e.g. migrateSyncLayout, before its git-stash) can pass
272
+ // the snapshot in rather than re-reading from disk inside a stash window where
273
+ // the config file's uncommitted writes are temporarily hidden.
274
+ export function planIdentityMigration(flagOverride?: string): IdentityPlanEntry[] {
252
275
  const plan: IdentityPlanEntry[] = [];
253
- if (resolveConfigValue("projects.identity").value !== "git-remote") {
276
+ const flag = flagOverride ?? resolveConfigValue("projects.identity").value;
277
+ if (flag !== "git-remote") {
254
278
  return plan;
255
279
  }
256
280
 
@@ -288,7 +312,10 @@ export function planIdentityMigration(): IdentityPlanEntry[] {
288
312
 
289
313
  let newId: string;
290
314
  try {
291
- newId = resolveProjectIdentity(meta.cwd).id;
315
+ newId = resolveProjectIdentity(
316
+ meta.cwd,
317
+ flag === "git-remote" || flag === "path-derived" ? flag : undefined
318
+ ).id;
292
319
  } catch {
293
320
  continue;
294
321
  }
@@ -300,12 +327,23 @@ export function planIdentityMigration(): IdentityPlanEntry[] {
300
327
 
301
328
  const newProjDir = join(projectsRoot, newId);
302
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;
303
339
  plan.push({
304
340
  oldId,
305
341
  newId,
306
342
  cwd: meta.cwd,
307
- action: "skip-converged",
308
- 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",
309
347
  });
310
348
  continue;
311
349
  }
@@ -358,26 +396,61 @@ function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>):
358
396
  }
359
397
  }
360
398
 
361
- function migrateProjectIdentities(deviceId: string): {
399
+ // Accepts the identity-mode value as a parameter so the caller can snapshot it
400
+ // before any disk side-effects (notably the migrating git-stash in
401
+ // migrateSyncLayout, which would hide uncommitted writes to the config file
402
+ // that drives this very decision). Falls back to a fresh read for callers that
403
+ // don't operate inside a stash window (e.g. session-start triggers and the
404
+ // --dry-run path).
405
+ export interface IdentityMigrationOutcome {
362
406
  renamed: number;
407
+ converged: number;
408
+ evicted: number;
363
409
  visited: number;
364
410
  backupDir: string | null;
365
- } {
366
- if (resolveConfigValue("projects.identity").value !== "git-remote") {
367
- return { renamed: 0, visited: 0, backupDir: null };
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 {
419
+ if (flag !== "git-remote") {
420
+ return {
421
+ renamed: 0,
422
+ converged: 0,
423
+ evicted: 0,
424
+ visited: 0,
425
+ backupDir: null,
426
+ renames: [],
427
+ evictions: [],
428
+ };
368
429
  }
369
430
 
370
- const plan = planIdentityMigration();
371
- const willRename = plan.filter((p) => p.action === "rename");
431
+ const plan = planIdentityMigration(flag);
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
+ );
372
441
 
373
442
  // Compute the backup root up-front so all snapshots for this migration pass
374
443
  // land in one timestamped directory the user can find and reason about.
375
444
  let backupRoot: string | null = null;
376
- if (willRename.length > 0) {
445
+ if (willTouchOldDir.length > 0) {
377
446
  backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
378
447
  }
379
448
 
380
449
  let renamed = 0;
450
+ let converged = 0;
451
+ let evicted = 0;
452
+ const renames: Array<{ from: string; to: string }> = [];
453
+ const evictions: string[] = [];
381
454
  let visited = plan.length;
382
455
  const projectsRoot = join(minkRoot(), "projects");
383
456
 
@@ -394,16 +467,53 @@ function migrateProjectIdentities(deviceId: string): {
394
467
  }
395
468
  }
396
469
 
397
- if (entry.action === "skip-converged" && entry.newId) {
470
+ if (
471
+ (entry.action === "skip-converged" || entry.action === "skip-evict") &&
472
+ entry.newId
473
+ ) {
398
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;
399
480
  try {
400
- 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
+ }
401
488
  if (entry.cwd) {
402
489
  setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
403
490
  }
404
491
  } catch {
405
492
  // best-effort
406
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
+ }
407
517
  continue;
408
518
  }
409
519
 
@@ -438,9 +548,18 @@ function migrateProjectIdentities(deviceId: string): {
438
548
  // best-effort
439
549
  }
440
550
  renamed++;
551
+ renames.push({ from: entry.oldId, to: entry.newId });
441
552
  }
442
553
 
443
- return { renamed, visited, backupDir: backupRoot };
554
+ return {
555
+ renamed,
556
+ converged,
557
+ evicted,
558
+ visited,
559
+ backupDir: backupRoot,
560
+ renames,
561
+ evictions,
562
+ };
444
563
  }
445
564
 
446
565
  // ── v3 identity rollback ──────────────────────────────────────────────────
@@ -527,6 +646,7 @@ export interface MigrateResult {
527
646
  fromVersion: number;
528
647
  toVersion: number;
529
648
  message?: string;
649
+ identity?: IdentityMigrationOutcome;
530
650
  }
531
651
 
532
652
  // Idempotent. Safe to invoke from `mink sync migrate` directly or from a
@@ -542,6 +662,11 @@ export interface MigrateResult {
542
662
  export function migrateSyncLayout(): MigrateResult {
543
663
  const fromVersion = readSyncVersion();
544
664
  const pending = listProjectsNeedingMigration();
665
+ // Snapshot the identity mode BEFORE the migrating stash below. The stash
666
+ // hides any uncommitted edits to ~/.mink/config — including the very
667
+ // `projects.identity = git-remote` write that should be driving this
668
+ // migration. Reading the flag after the stash would see the stale,
669
+ // last-committed config and the v3 identity step would no-op.
545
670
  const identityMode = resolveConfigValue("projects.identity").value;
546
671
  if (
547
672
  fromVersion >= MINK_SYNC_VERSION &&
@@ -611,9 +736,19 @@ export function migrateSyncLayout(): MigrateResult {
611
736
  // v3 identity migration: rename per-project directories to their stable
612
737
  // git-derived identifier when the user has opted in. Cheap no-op when the
613
738
  // flag is off or every project's identifier already matches its directory.
614
- let identity = { renamed: 0, visited: 0 };
739
+ // Pass the pre-stash snapshot of identityMode so we don't re-read the
740
+ // config from a stash-hidden working tree.
741
+ let identity: IdentityMigrationOutcome = {
742
+ renamed: 0,
743
+ converged: 0,
744
+ evicted: 0,
745
+ visited: 0,
746
+ backupDir: null,
747
+ renames: [],
748
+ evictions: [],
749
+ };
615
750
  try {
616
- identity = migrateProjectIdentities(deviceId);
751
+ identity = migrateProjectIdentities(deviceId, identityMode);
617
752
  } catch {
618
753
  // best-effort; never block the rest of migration
619
754
  }
@@ -625,16 +760,19 @@ export function migrateSyncLayout(): MigrateResult {
625
760
  writeSyncVersion(MINK_SYNC_VERSION);
626
761
  }
627
762
 
628
- if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
763
+ if (
764
+ isSyncInitialized() &&
765
+ (processed > 0 || identity.renamed > 0 || identity.evicted > 0)
766
+ ) {
629
767
  // Skip the lock file — it's part of migration coordination, not state.
630
768
  gitSafe("add -A");
631
769
  gitSafe(`reset HEAD ".sync-migrate.lock"`);
632
- const summary =
633
- identity.renamed > 0
634
- ? `${processed} projects, ${identity.renamed} renamed for identity v3`
635
- : `${processed} projects`;
770
+ const identityNote =
771
+ identity.renamed > 0 || identity.evicted > 0
772
+ ? `, ${identity.renamed} renamed + ${identity.evicted} evicted for identity v3`
773
+ : "";
636
774
  gitSafe(
637
- `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})"`
638
776
  );
639
777
  }
640
778
 
@@ -646,6 +784,7 @@ export function migrateSyncLayout(): MigrateResult {
646
784
  ranMigration: true,
647
785
  fromVersion,
648
786
  toVersion: MINK_SYNC_VERSION,
787
+ identity,
649
788
  };
650
789
  } finally {
651
790
  releaseLock();
@@ -671,20 +810,24 @@ export function syncMigrateCommand(args: string[] = []): void {
671
810
  }
672
811
  const renames = plan.filter((p) => p.action === "rename");
673
812
  const converged = plan.filter((p) => p.action === "skip-converged");
813
+ const evictOnly = plan.filter((p) => p.action === "skip-evict");
674
814
  const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
675
815
  const unchanged = plan.filter((p) => p.action === "skip-unchanged");
676
816
 
677
817
  console.log(
678
- `[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`
679
819
  );
680
820
  for (const p of renames) {
681
- console.log(` rename: ${p.oldId} → ${p.newId}`);
821
+ console.log(` rename: ${p.oldId} → ${p.newId}`);
682
822
  }
683
823
  for (const p of converged) {
684
- 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})`);
685
828
  }
686
829
  for (const p of skippedNoCwd) {
687
- console.log(` skip: ${p.oldId} — ${p.reason}`);
830
+ console.log(` skip: ${p.oldId} — ${p.reason}`);
688
831
  }
689
832
  return;
690
833
  }
@@ -724,4 +867,19 @@ export function syncMigrateCommand(args: string[] = []): void {
724
867
  console.log(
725
868
  `[mink] sync migrate: v${result.fromVersion} → v${result.toVersion} complete`
726
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
+ }
727
885
  }
@@ -136,8 +136,16 @@ function readIdentityMode(): "path-derived" | "git-remote" {
136
136
  return "path-derived";
137
137
  }
138
138
 
139
- export function resolveProjectIdentity(cwd: string): ProjectIdentity {
140
- const mode = readIdentityMode();
139
+ // Accepts an optional `modeOverride` so callers that have already snapshotted
140
+ // `projects.identity` (e.g. the v3 migration, which runs inside a git-stash
141
+ // window where the config file's uncommitted writes are hidden from disk) can
142
+ // pass the snapshot in. Without the override, the internal mode read can
143
+ // disagree with the caller's view of the world and produce the wrong id.
144
+ export function resolveProjectIdentity(
145
+ cwd: string,
146
+ modeOverride?: "path-derived" | "git-remote"
147
+ ): ProjectIdentity {
148
+ const mode = modeOverride ?? readIdentityMode();
141
149
  if (mode === "path-derived") {
142
150
  return { id: generateProjectId(cwd), source: "path-derived" };
143
151
  }
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