@drewpayment/mink 0.10.1 → 0.11.0-beta.2

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 (53) 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 +1407 -868
  39. package/package.json +1 -1
  40. package/src/commands/init.ts +29 -4
  41. package/src/commands/note.ts +2 -2
  42. package/src/commands/session-start.ts +8 -2
  43. package/src/commands/sync-migrate.ts +429 -7
  44. package/src/commands/sync.ts +5 -2
  45. package/src/core/dashboard-server.ts +13 -5
  46. package/src/core/git-identity.ts +120 -0
  47. package/src/core/paths.ts +19 -3
  48. package/src/core/project-id.ts +150 -5
  49. package/src/core/project-registry.ts +122 -13
  50. package/src/core/sync.ts +7 -1
  51. package/src/types/config.ts +9 -0
  52. /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WyN-sdaVY7cZaACRaK7vq}/_buildManifest.js +0 -0
  53. /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WyN-sdaVY7cZaACRaK7vq}/_ssgManifest.js +0 -0
@@ -0,0 +1,120 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, realpathSync } from "fs";
3
+
4
+ const GIT_TIMEOUT_MS = 2_000;
5
+
6
+ function gitOut(cwd: string, args: string): string | null {
7
+ if (!existsSync(cwd)) return null;
8
+ try {
9
+ return execSync(`git ${args}`, {
10
+ cwd,
11
+ timeout: GIT_TIMEOUT_MS,
12
+ stdio: ["pipe", "pipe", "pipe"],
13
+ })
14
+ .toString()
15
+ .trim();
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function canonicalCwd(cwd: string): string {
22
+ try {
23
+ return realpathSync(cwd);
24
+ } catch {
25
+ return cwd;
26
+ }
27
+ }
28
+
29
+ export function getRepoRoot(cwd: string): string | null {
30
+ const root = gitOut(canonicalCwd(cwd), "rev-parse --show-toplevel");
31
+ return root && root.length > 0 ? root : null;
32
+ }
33
+
34
+ export function getRepoSubpath(cwd: string): string {
35
+ const prefix = gitOut(canonicalCwd(cwd), "rev-parse --show-prefix");
36
+ if (prefix === null) return "";
37
+ return prefix.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\/+/, "");
38
+ }
39
+
40
+ // Resolves the project's primary remote URL. Prefers `origin` when present —
41
+ // otherwise falls back to the alphabetically-first remote so projects with a
42
+ // non-standard remote name still get a stable identity.
43
+ export function getRepoRemote(cwd: string): string | null {
44
+ const c = canonicalCwd(cwd);
45
+ const origin = gitOut(c, "config --get remote.origin.url");
46
+ if (origin && origin.length > 0) return origin;
47
+
48
+ const list = gitOut(c, "remote");
49
+ if (!list) return null;
50
+ const remotes = list
51
+ .split("\n")
52
+ .map((s) => s.trim())
53
+ .filter((s) => s.length > 0)
54
+ .sort();
55
+ if (remotes.length === 0) return null;
56
+ const url = gitOut(c, `config --get remote.${remotes[0]}.url`);
57
+ return url && url.length > 0 ? url : null;
58
+ }
59
+
60
+ // Reduces remote URL forms to a single canonical string so SSH/HTTPS, with or
61
+ // without credentials, with or without `.git`, and with mixed host casing all
62
+ // collapse to one representation per logical repository.
63
+ //
64
+ // Examples that all collapse to `github.com/owner/repo`:
65
+ // git@github.com:Owner/Repo.git
66
+ // https://github.com/owner/repo.git
67
+ // https://user:token@github.com/owner/repo
68
+ // ssh://git@github.com/owner/repo/
69
+ //
70
+ // Returns the original string only if it cannot be parsed — callers can still
71
+ // treat that as "no usable remote" and fall back to path-derived identity.
72
+ export function normalizeRemoteUrl(url: string): string {
73
+ if (!url) return "";
74
+ let s = url.trim();
75
+ if (s.length === 0) return "";
76
+
77
+ // Skip file/local protocol — these aren't shared identities.
78
+ if (/^(file:|\.\.?\/|\/)/i.test(s)) return "";
79
+
80
+ // SSH scp-style: git@host:owner/repo(.git) → ssh://git@host/owner/repo
81
+ const scp = s.match(/^([^@\s]+)@([^:\s]+):(.+)$/);
82
+ if (scp) {
83
+ s = `ssh://${scp[1]}@${scp[2]}/${scp[3]}`;
84
+ }
85
+
86
+ // Strip leading scheme so we can normalize the rest uniformly.
87
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
88
+
89
+ // Strip embedded credentials: `user:pass@host/...` → `host/...`
90
+ s = s.replace(/^[^@/]*@/, "");
91
+
92
+ // Strip trailing slash and `.git`
93
+ s = s.replace(/\/+$/, "").replace(/\.git$/i, "");
94
+
95
+ // Lowercase the entire path. Major forges (GitHub, GitLab, Bitbucket) treat
96
+ // repo names as case-insensitive for URL routing, so two checkouts whose
97
+ // remotes differ only in casing point at the same logical repo. Users on
98
+ // case-sensitive self-hosted forges can pin identity with the override file.
99
+ return s.toLowerCase();
100
+ }
101
+
102
+ export interface GitIdentityComponents {
103
+ remote: string;
104
+ subpath: string;
105
+ }
106
+
107
+ // Returns the (remote, subpath) pair for `cwd` if it lives inside a git repo
108
+ // with a normalizable remote. Returns null when the directory is not a git
109
+ // repo, has no remote, or the remote URL is a local/file path. Callers should
110
+ // fall back to path-derived identity in those cases.
111
+ export function deriveGitIdentity(cwd: string): GitIdentityComponents | null {
112
+ const root = getRepoRoot(cwd);
113
+ if (!root) return null;
114
+ const remoteRaw = getRepoRemote(cwd);
115
+ if (!remoteRaw) return null;
116
+ const remote = normalizeRemoteUrl(remoteRaw);
117
+ if (!remote) return null;
118
+ const subpath = getRepoSubpath(cwd);
119
+ return { remote, subpath };
120
+ }
package/src/core/paths.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
+ import { existsSync } from "fs";
2
3
  import { homedir } from "os";
3
- import { generateProjectId } from "./project-id";
4
+ import { projectIdFor } from "./project-id";
4
5
 
5
6
  // Resolved per-call so tests can override via MINK_ROOT_OVERRIDE without
6
7
  // reloading modules. Production callers get the default homedir/.mink path.
@@ -19,9 +20,24 @@ export function minkRoot(): string {
19
20
  return MINK_ROOT;
20
21
  }
21
22
 
23
+ // Locates the on-disk project state directory for `cwd`. Walks the alias list
24
+ // when the primary identifier's directory does not exist, so historical
25
+ // references (notes, dashboard URLs) keep resolving after a v3 migration
26
+ // renames the project directory.
22
27
  export function projectDir(cwd: string): string {
23
- const id = generateProjectId(cwd);
24
- return join(minkRoot(), "projects", id);
28
+ const id = projectIdFor(cwd);
29
+ const primary = join(minkRoot(), "projects", id);
30
+ if (existsSync(primary)) return primary;
31
+ // Lazy require: project-registry imports paths, so a top-level import would
32
+ // create a cycle. Only walk aliases on a cold miss.
33
+ try {
34
+ const { findProjectDirByIdOrAlias } = require("./project-registry");
35
+ const aliased = findProjectDirByIdOrAlias(id);
36
+ if (aliased) return aliased;
37
+ } catch {
38
+ // best-effort
39
+ }
40
+ return primary;
25
41
  }
26
42
 
27
43
  export function sessionPath(cwd: string): string {
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "crypto";
2
- import { basename } from "path";
2
+ import { basename, join } from "path";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { deriveGitIdentity, getRepoRoot } from "./git-identity";
3
5
 
4
6
  function slugify(name: string): string {
5
7
  return name
@@ -8,12 +10,155 @@ function slugify(name: string): string {
8
10
  .replace(/^-|-$/g, "");
9
11
  }
10
12
 
13
+ function shortHash(input: string): string {
14
+ return createHash("sha256").update(input).digest("hex").slice(0, 6);
15
+ }
16
+
17
+ // Legacy identity: hash of the absolute path. Retained for two reasons:
18
+ // 1) The path-derived fallback tier of the new resolver returns this verbatim
19
+ // so non-git directories continue to behave exactly as before.
20
+ // 2) The v2→v3 migration computes the prior identifier with this function so
21
+ // it can locate and rename the old per-project state directory.
11
22
  export function generateProjectId(absolutePath: string): string {
12
23
  const normalized = absolutePath.replace(/\/+$/, "");
13
24
  const slug = slugify(basename(normalized));
14
- const hash = createHash("sha256")
15
- .update(normalized)
16
- .digest("hex")
17
- .slice(0, 6);
25
+ const hash = shortHash(normalized);
18
26
  return `${slug}-${hash}`;
19
27
  }
28
+
29
+ // ── Stable-identity resolver ──────────────────────────────────────────────
30
+ //
31
+ // Three-tier priority order:
32
+ // 1. Explicit override file inside the user's repo (.mink/project.json)
33
+ // 2. Git-derived: normalized remote URL + repo-root-relative subpath
34
+ // 3. Path-derived fallback (legacy generateProjectId)
35
+ //
36
+ // Gated by the `projects.identity` config key. When the value is
37
+ // `path-derived` (default during rollout) the resolver short-circuits to the
38
+ // legacy behavior so existing users see no change until they opt in.
39
+
40
+ export type IdentitySource = "override" | "git-remote" | "path-derived";
41
+
42
+ export interface ProjectIdentity {
43
+ id: string;
44
+ source: IdentitySource;
45
+ }
46
+
47
+ const IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
48
+ const OVERRIDE_RELATIVE_PATH = ".mink/project.json";
49
+
50
+ export function validateProjectIdentifier(id: unknown): id is string {
51
+ return typeof id === "string" && IDENTIFIER_PATTERN.test(id);
52
+ }
53
+
54
+ // Reads .mink/project.json from the repo containing `cwd` (or `cwd` itself if
55
+ // it is not inside a git repo). Returns the validated identifier, or null when
56
+ // the file is missing, unreadable, malformed, or declares an invalid identifier.
57
+ //
58
+ // Malformed overrides are reported once to stderr so the user notices when
59
+ // their pin file has been ignored — silent fall-through would let typos linger.
60
+ export function readProjectOverride(cwd: string): string | null {
61
+ const root = getRepoRoot(cwd) ?? cwd;
62
+ const overridePath = join(root, OVERRIDE_RELATIVE_PATH);
63
+ if (!existsSync(overridePath)) return null;
64
+
65
+ let raw: string;
66
+ try {
67
+ raw = readFileSync(overridePath, "utf-8");
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ let parsed: unknown;
73
+ try {
74
+ parsed = JSON.parse(raw);
75
+ } catch {
76
+ warnInvalidOverride(overridePath, "file is not valid JSON");
77
+ return null;
78
+ }
79
+
80
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
81
+ warnInvalidOverride(overridePath, "expected a JSON object");
82
+ return null;
83
+ }
84
+
85
+ const id = (parsed as Record<string, unknown>).projectId;
86
+ if (id === undefined) return null;
87
+ if (!validateProjectIdentifier(id)) {
88
+ warnInvalidOverride(
89
+ overridePath,
90
+ "projectId must start with a letter or digit, contain only [a-z0-9._-], and be 1–128 characters"
91
+ );
92
+ return null;
93
+ }
94
+ return id;
95
+ }
96
+
97
+ const warnedOverrides = new Set<string>();
98
+ function warnInvalidOverride(path: string, reason: string): void {
99
+ if (warnedOverrides.has(path)) return;
100
+ warnedOverrides.add(path);
101
+ console.warn(`[mink] ignoring ${path}: ${reason}`);
102
+ }
103
+
104
+ // Returns the git-derived identifier for `cwd` when it lives inside a git repo
105
+ // with a normalizable remote. Returns null otherwise so the caller can fall
106
+ // through to the path-derived tier.
107
+ function gitDerivedIdentity(cwd: string): string | null {
108
+ const components = deriveGitIdentity(cwd);
109
+ if (!components) return null;
110
+ // Slug derives from the normalized remote or subpath — never from the local
111
+ // checkout's directory name — so two clones at differently-named paths
112
+ // produce the same identifier.
113
+ const remoteLeaf = components.remote.split("/").filter((p) => p).pop();
114
+ const slugSource = components.subpath
115
+ ? components.subpath.split("/").pop()!
116
+ : remoteLeaf ?? "project";
117
+ const slug = slugify(slugSource);
118
+ const hash = shortHash(`git:${components.remote}:${components.subpath}`);
119
+ return `${slug}-${hash}`;
120
+ }
121
+
122
+ function readIdentityMode(): "path-derived" | "git-remote" {
123
+ const envOverride = process.env.MINK_PROJECTS_IDENTITY;
124
+ if (envOverride === "git-remote" || envOverride === "path-derived") {
125
+ return envOverride;
126
+ }
127
+ try {
128
+ // Lazy require to avoid a cycle: global-config imports types/config which
129
+ // is small; project-id is imported very widely.
130
+ const { resolveConfigValue } = require("./global-config");
131
+ const v = resolveConfigValue("projects.identity").value;
132
+ if (v === "git-remote") return "git-remote";
133
+ } catch {
134
+ // fall through
135
+ }
136
+ return "path-derived";
137
+ }
138
+
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();
149
+ if (mode === "path-derived") {
150
+ return { id: generateProjectId(cwd), source: "path-derived" };
151
+ }
152
+
153
+ const override = readProjectOverride(cwd);
154
+ if (override) return { id: override, source: "override" };
155
+
156
+ const git = gitDerivedIdentity(cwd);
157
+ if (git) return { id: git, source: "git-remote" };
158
+
159
+ return { id: generateProjectId(cwd), source: "path-derived" };
160
+ }
161
+
162
+ export function projectIdFor(cwd: string): string {
163
+ return resolveProjectIdentity(cwd).id;
164
+ }
@@ -1,13 +1,27 @@
1
1
  import { readdirSync, existsSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { minkRoot } from "./paths";
4
- import { safeReadJson } from "./fs-utils";
4
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
5
+
6
+ // ── Project metadata ──────────────────────────────────────────────────────
7
+ //
8
+ // `project-meta.json` records who/what/when about a project's state directory.
9
+ // The schema evolved across sync versions:
10
+ //
11
+ // v1/v2: { cwd, name, initTimestamp, version, projectType? }
12
+ // v3: adds { aliases: string[], pathsByDevice: Record<deviceId, cwd> }
13
+ //
14
+ // `cwd` is preserved on disk for forward-compat with older mink versions that
15
+ // downgrade after a v3 migration — they continue to read it. New code prefers
16
+ // `pathsByDevice[deviceId]` and treats `cwd` as a single-device fallback.
5
17
 
6
18
  export interface ProjectMeta {
7
19
  cwd: string;
8
20
  name: string;
9
21
  initTimestamp: string;
10
22
  version: string;
23
+ aliases?: string[];
24
+ pathsByDevice?: Record<string, string>;
11
25
  }
12
26
 
13
27
  export interface RegisteredProject {
@@ -15,30 +29,97 @@ export interface RegisteredProject {
15
29
  cwd: string;
16
30
  name: string;
17
31
  version: string;
32
+ aliases: string[];
33
+ pathsByDevice: Record<string, string>;
34
+ }
35
+
36
+ function projectMetaFilePath(projDir: string): string {
37
+ return join(projDir, "project-meta.json");
18
38
  }
19
39
 
20
40
  export function getProjectMeta(projDir: string): ProjectMeta | null {
21
- const metaPath = join(projDir, "project-meta.json");
22
- const raw = safeReadJson(metaPath);
23
- if (
24
- raw === null ||
25
- typeof raw !== "object" ||
26
- Array.isArray(raw)
27
- ) {
28
- return null;
29
- }
41
+ const raw = safeReadJson(projectMetaFilePath(projDir));
42
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
30
43
  const obj = raw as Record<string, unknown>;
31
- if (typeof obj.cwd !== "string" || typeof obj.name !== "string") {
32
- return null;
33
- }
44
+ if (typeof obj.cwd !== "string" || typeof obj.name !== "string") return null;
45
+
46
+ const aliases = Array.isArray(obj.aliases)
47
+ ? (obj.aliases as unknown[]).filter((s): s is string => typeof s === "string")
48
+ : undefined;
49
+
50
+ const pathsByDevice =
51
+ obj.pathsByDevice &&
52
+ typeof obj.pathsByDevice === "object" &&
53
+ !Array.isArray(obj.pathsByDevice)
54
+ ? Object.fromEntries(
55
+ Object.entries(obj.pathsByDevice as Record<string, unknown>).filter(
56
+ ([, v]) => typeof v === "string"
57
+ ) as [string, string][]
58
+ )
59
+ : undefined;
60
+
34
61
  return {
35
62
  cwd: obj.cwd as string,
36
63
  name: obj.name as string,
37
64
  initTimestamp: (obj.initTimestamp as string) ?? "",
38
65
  version: (obj.version as string) ?? "0.1.0",
66
+ aliases,
67
+ pathsByDevice,
39
68
  };
40
69
  }
41
70
 
71
+ // Idempotently records `aliasId` on the project at `projDir`. Preserves the
72
+ // existing alias list, deduplicates, and preserves any unknown fields on the
73
+ // metadata record so a future-version downgrade doesn't lose data.
74
+ export function addProjectAlias(projDir: string, aliasId: string): boolean {
75
+ const path = projectMetaFilePath(projDir);
76
+ const raw = safeReadJson(path);
77
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return false;
78
+ const obj = raw as Record<string, unknown>;
79
+ const existing = Array.isArray(obj.aliases)
80
+ ? (obj.aliases as unknown[]).filter((s): s is string => typeof s === "string")
81
+ : [];
82
+ if (existing.includes(aliasId)) return false;
83
+ obj.aliases = [...existing, aliasId];
84
+ atomicWriteJson(path, obj);
85
+ return true;
86
+ }
87
+
88
+ // Writes the working-copy path for the given device into the per-device map.
89
+ // Reads the existing map (or seeds it from the legacy singular `cwd` field if
90
+ // no map exists yet) and writes the merged result back. Preserves unknown fields.
91
+ export function setProjectPathForDevice(
92
+ projDir: string,
93
+ deviceId: string,
94
+ cwd: string
95
+ ): void {
96
+ const path = projectMetaFilePath(projDir);
97
+ const raw = safeReadJson(path);
98
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
99
+ const obj = raw as Record<string, unknown>;
100
+ const existing =
101
+ obj.pathsByDevice &&
102
+ typeof obj.pathsByDevice === "object" &&
103
+ !Array.isArray(obj.pathsByDevice)
104
+ ? { ...(obj.pathsByDevice as Record<string, string>) }
105
+ : {};
106
+ // Seed the map from the legacy singular cwd so the first device-keyed write
107
+ // doesn't drop the prior single-device path on the floor.
108
+ if (
109
+ Object.keys(existing).length === 0 &&
110
+ typeof obj.cwd === "string" &&
111
+ obj.cwd !== cwd
112
+ ) {
113
+ existing[deviceId] = obj.cwd;
114
+ }
115
+ existing[deviceId] = cwd;
116
+ obj.pathsByDevice = existing;
117
+ // Keep `cwd` in sync as the local-machine fallback so older versions still
118
+ // read a meaningful value after a downgrade.
119
+ obj.cwd = cwd;
120
+ atomicWriteJson(path, obj);
121
+ }
122
+
42
123
  export function listRegisteredProjects(): RegisteredProject[] {
43
124
  const projectsDir = join(minkRoot(), "projects");
44
125
  if (!existsSync(projectsDir)) return [];
@@ -56,9 +137,37 @@ export function listRegisteredProjects(): RegisteredProject[] {
56
137
  cwd: meta.cwd,
57
138
  name: meta.name,
58
139
  version: meta.version,
140
+ aliases: meta.aliases ?? [],
141
+ pathsByDevice: meta.pathsByDevice ?? {},
59
142
  });
60
143
  }
61
144
  }
62
145
 
63
146
  return projects;
64
147
  }
148
+
149
+ // Scans every project directory for one whose on-disk name or alias list matches
150
+ // `id`. Returns the on-disk project directory or null. Used by paths.ts to
151
+ // tolerate historical references after migration renames the project's
152
+ // directory.
153
+ export function findProjectDirByIdOrAlias(id: string): string | null {
154
+ const projectsDir = join(minkRoot(), "projects");
155
+ if (!existsSync(projectsDir)) return null;
156
+
157
+ const primary = join(projectsDir, id);
158
+ if (existsSync(primary)) return primary;
159
+
160
+ let entries: string[];
161
+ try {
162
+ entries = readdirSync(projectsDir);
163
+ } catch {
164
+ return null;
165
+ }
166
+
167
+ for (const name of entries) {
168
+ const projDir = join(projectsDir, name);
169
+ const meta = getProjectMeta(projDir);
170
+ if (meta?.aliases?.includes(id)) return projDir;
171
+ }
172
+ return null;
173
+ }
package/src/core/sync.ts CHANGED
@@ -15,7 +15,13 @@ const FETCH_TIMEOUT = 15_000;
15
15
  // Sync layout version. Bumped when the on-disk shape of `~/.mink/` changes in
16
16
  // a way that older devices cannot read. Migration runs on first session-start
17
17
  // after upgrade when readSyncVersion() < MINK_SYNC_VERSION.
18
- export const MINK_SYNC_VERSION = 2;
18
+ //
19
+ // v1 → v2: per-device shards under projects/<id>/state/<deviceId>/
20
+ // v2 → v3: stable identity — adds aliases[] and pathsByDevice{} on project-meta;
21
+ // when projects.identity=git-remote, renames per-project directories
22
+ // from path-derived IDs to git-derived IDs and records prior ID as
23
+ // alias. Migration is a no-op when the flag is off.
24
+ export const MINK_SYNC_VERSION = 3;
19
25
 
20
26
  export function readSyncVersion(): number {
21
27
  try {
@@ -17,6 +17,7 @@ export interface GlobalConfig {
17
17
  "cli.auto-update"?: string;
18
18
  "cli.auto-update-schedule"?: string;
19
19
  "cli.auto-update-package-manager"?: string;
20
+ "projects.identity"?: string;
20
21
  }
21
22
 
22
23
  export type ConfigKey = keyof GlobalConfig & string;
@@ -170,6 +171,14 @@ export const CONFIG_KEYS: ConfigKeyMeta[] = [
170
171
  description: "Force a package manager (auto|npm|bun) for self-upgrade installs",
171
172
  scope: "local",
172
173
  },
174
+ {
175
+ key: "projects.identity",
176
+ default: "path-derived",
177
+ envVar: "MINK_PROJECTS_IDENTITY",
178
+ description:
179
+ "Project identity strategy: path-derived (legacy) or git-remote (stable across machines)",
180
+ scope: "shared",
181
+ },
173
182
  ];
174
183
 
175
184
  const VALID_KEYS = new Set<string>(CONFIG_KEYS.map((k) => k.key));