@drewpayment/mink 0.10.0 → 0.11.0-beta.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.
Files changed (60) hide show
  1. package/README.md +62 -1
  2. package/dashboard/out/404.html +1 -1
  3. package/dashboard/out/action-log.html +1 -1
  4. package/dashboard/out/action-log.txt +1 -1
  5. package/dashboard/out/activity.html +1 -1
  6. package/dashboard/out/activity.txt +1 -1
  7. package/dashboard/out/bugs.html +1 -1
  8. package/dashboard/out/bugs.txt +1 -1
  9. package/dashboard/out/capture.html +1 -1
  10. package/dashboard/out/capture.txt +1 -1
  11. package/dashboard/out/config.html +1 -1
  12. package/dashboard/out/config.txt +1 -1
  13. package/dashboard/out/daemon.html +1 -1
  14. package/dashboard/out/daemon.txt +1 -1
  15. package/dashboard/out/design.html +1 -1
  16. package/dashboard/out/design.txt +1 -1
  17. package/dashboard/out/discord.html +1 -1
  18. package/dashboard/out/discord.txt +1 -1
  19. package/dashboard/out/file-index.html +1 -1
  20. package/dashboard/out/file-index.txt +1 -1
  21. package/dashboard/out/index.html +1 -1
  22. package/dashboard/out/index.txt +1 -1
  23. package/dashboard/out/insights.html +1 -1
  24. package/dashboard/out/insights.txt +1 -1
  25. package/dashboard/out/learning.html +1 -1
  26. package/dashboard/out/learning.txt +1 -1
  27. package/dashboard/out/overview.html +1 -1
  28. package/dashboard/out/overview.txt +1 -1
  29. package/dashboard/out/scheduler.html +1 -1
  30. package/dashboard/out/scheduler.txt +1 -1
  31. package/dashboard/out/sync.html +1 -1
  32. package/dashboard/out/sync.txt +1 -1
  33. package/dashboard/out/tokens.html +1 -1
  34. package/dashboard/out/tokens.txt +1 -1
  35. package/dashboard/out/waste.html +1 -1
  36. package/dashboard/out/waste.txt +1 -1
  37. package/dashboard/out/wiki.html +1 -1
  38. package/dashboard/out/wiki.txt +1 -1
  39. package/dist/cli.js +1505 -896
  40. package/package.json +1 -1
  41. package/src/cli.ts +1 -1
  42. package/src/commands/init.ts +29 -4
  43. package/src/commands/note.ts +2 -2
  44. package/src/commands/scan.ts +29 -6
  45. package/src/commands/session-start.ts +8 -2
  46. package/src/commands/sync-migrate.ts +404 -7
  47. package/src/commands/sync.ts +5 -2
  48. package/src/commands/wiki.ts +19 -3
  49. package/src/core/dashboard-server.ts +13 -5
  50. package/src/core/git-identity.ts +120 -0
  51. package/src/core/note-index.ts +50 -1
  52. package/src/core/paths.ts +19 -3
  53. package/src/core/project-id.ts +142 -5
  54. package/src/core/project-registry.ts +122 -13
  55. package/src/core/scanner.ts +19 -3
  56. package/src/core/sync.ts +7 -1
  57. package/src/types/config.ts +9 -0
  58. package/src/types/note.ts +1 -0
  59. /package/dashboard/out/_next/static/{frTrvF6NV-Xl2bLk21NkY → WDjkNLHEd_wI-oOzLyblH}/_buildManifest.js +0 -0
  60. /package/dashboard/out/_next/static/{frTrvF6NV-Xl2bLk21NkY → WDjkNLHEd_wI-oOzLyblH}/_ssgManifest.js +0 -0
@@ -14,7 +14,11 @@ import {
14
14
  import { atomicWriteJson } from "../core/fs-utils";
15
15
  import { setConfigValue } from "../core/global-config";
16
16
  import { seedTemplates } from "../core/vault-templates";
17
- import { rebuildVaultIndex, loadVaultIndex } from "../core/note-index";
17
+ import {
18
+ rebuildVaultIndex,
19
+ loadVaultIndex,
20
+ vaultIndexStaleness,
21
+ } from "../core/note-index";
18
22
  import { updateMasterIndex } from "../core/note-linker";
19
23
  import type { VaultManifest, NoteCategory } from "../types/note";
20
24
 
@@ -32,6 +36,7 @@ export async function wiki(
32
36
  wikiStatus();
33
37
  break;
34
38
  case "rebuild-index":
39
+ case "scan":
35
40
  wikiRebuildIndex();
36
41
  break;
37
42
  case "organize":
@@ -51,7 +56,8 @@ export async function wiki(
51
56
  console.log();
52
57
  console.log(" init Initialize the notes/wiki vault");
53
58
  console.log(" status Show vault statistics");
54
- console.log(" rebuild-index Full rescan and reindex of vault");
59
+ console.log(" rebuild-index Full rescan and reindex of vault (alias: scan)");
60
+ console.log(" scan Alias for rebuild-index");
55
61
  console.log(" organize List inbox notes needing categorization");
56
62
  console.log(" link <path> [name] Symlink external notes into the vault");
57
63
  console.log(" unlink <name> Remove a symlinked directory from the vault");
@@ -174,6 +180,13 @@ function wikiStatus(): void {
174
180
  }
175
181
 
176
182
  const vaultPath = resolveVaultPath();
183
+ const staleness = vaultIndexStaleness();
184
+ if (staleness.isStale) {
185
+ console.log(
186
+ `[mink] vault index is stale (${staleness.reason}) — rebuilding...`
187
+ );
188
+ rebuildVaultIndex();
189
+ }
177
190
  const index = loadVaultIndex();
178
191
 
179
192
  const categoryCounts: Record<string, number> = {
@@ -200,7 +213,10 @@ function wikiStatus(): void {
200
213
  }
201
214
  console.log();
202
215
  console.log(
203
- ` last indexed: ${index.lastScanTimestamp || "never"}`
216
+ ` last full scan: ${index.lastFullScanTimestamp || "never"}`
217
+ );
218
+ console.log(
219
+ ` last update: ${index.lastScanTimestamp || "never"}`
204
220
  );
205
221
 
206
222
  const links = listLinks();
@@ -35,7 +35,7 @@ import {
35
35
  triggerIngestFile,
36
36
  } from "./dashboard-api";
37
37
  import { listRegisteredProjects, getProjectMeta } from "./project-registry";
38
- import { generateProjectId } from "./project-id";
38
+ import { projectIdFor } from "./project-id";
39
39
  import { runtimeFile, runtimeServe, runtimeSpawn } from "./runtime";
40
40
  import type { StateFileId, StateChangeEvent } from "../types/dashboard";
41
41
  import type { RegisteredProject } from "./project-registry";
@@ -154,10 +154,14 @@ function resolveProjectCwd(
154
154
 
155
155
  // If the requested project matches the currently active project, use it directly
156
156
  // (handles startup projects that may not be in the registry yet)
157
- if (projectId === generateProjectId(defaultCwd)) return defaultCwd;
157
+ if (projectId === projectIdFor(defaultCwd)) return defaultCwd;
158
158
 
159
159
  const projects = listRegisteredProjects();
160
- const match = projects.find((p) => p.id === projectId);
160
+ // Match against primary id first, then walk alias lists so historical
161
+ // dashboard URLs continue routing after a v3 identity migration.
162
+ const match =
163
+ projects.find((p) => p.id === projectId) ??
164
+ projects.find((p) => p.aliases.includes(projectId));
161
165
  if (!match) return null;
162
166
 
163
167
  return match.cwd;
@@ -170,11 +174,11 @@ function getProjectsList(
170
174
  projects: RegisteredProject[];
171
175
  activeProjectId: string;
172
176
  } {
173
- const activeId = generateProjectId(activeCwd);
177
+ const activeId = projectIdFor(activeCwd);
174
178
  const registered = listRegisteredProjects();
175
179
 
176
180
  // Ensure startup project is always in the list
177
- const startupId = generateProjectId(startupCwd);
181
+ const startupId = projectIdFor(startupCwd);
178
182
  const hasStartup = registered.some((p) => p.id === startupId);
179
183
  if (!hasStartup) {
180
184
  const meta = getProjectMeta(projectDir(startupCwd));
@@ -183,6 +187,8 @@ function getProjectsList(
183
187
  cwd: startupCwd,
184
188
  name: meta?.name ?? basename(startupCwd),
185
189
  version: meta?.version ?? "0.1.0",
190
+ aliases: meta?.aliases ?? [],
191
+ pathsByDevice: meta?.pathsByDevice ?? {},
186
192
  });
187
193
  }
188
194
 
@@ -196,6 +202,8 @@ function getProjectsList(
196
202
  cwd: activeCwd,
197
203
  name: meta?.name ?? basename(activeCwd),
198
204
  version: meta?.version ?? "0.1.0",
205
+ aliases: meta?.aliases ?? [],
206
+ pathsByDevice: meta?.pathsByDevice ?? {},
199
207
  });
200
208
  }
201
209
  }
@@ -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
+ }
@@ -7,6 +7,7 @@ import type { VaultIndex, VaultIndexEntry, NoteCategory } from "../types/note";
7
7
  export function createEmptyVaultIndex(): VaultIndex {
8
8
  return {
9
9
  lastScanTimestamp: "",
10
+ lastFullScanTimestamp: "",
10
11
  totalNotes: 0,
11
12
  entries: {},
12
13
  };
@@ -182,7 +183,9 @@ export function rebuildVaultIndex(): VaultIndex {
182
183
  }
183
184
  }
184
185
 
185
- index.lastScanTimestamp = new Date().toISOString();
186
+ const now = new Date().toISOString();
187
+ index.lastScanTimestamp = now;
188
+ index.lastFullScanTimestamp = now;
186
189
  saveVaultIndex(index);
187
190
  return index;
188
191
  }
@@ -219,6 +222,52 @@ export function getRecentNotes(n: number): VaultIndexEntry[] {
219
222
  .slice(0, n);
220
223
  }
221
224
 
225
+ export interface VaultStaleness {
226
+ isStale: boolean;
227
+ reason: string | null;
228
+ diskCount: number;
229
+ indexCount: number;
230
+ lastFullScan: string | null;
231
+ }
232
+
233
+ export function vaultIndexStaleness(): VaultStaleness {
234
+ const index = loadVaultIndex();
235
+ const root = resolveVaultPath();
236
+ const diskCount = collectAllMarkdown(root).length;
237
+ const indexCount = Object.keys(index.entries).length;
238
+ const lastFullScan = index.lastFullScanTimestamp || null;
239
+
240
+ if (!lastFullScan) {
241
+ return {
242
+ isStale: true,
243
+ reason: "no full scan on record",
244
+ diskCount,
245
+ indexCount,
246
+ lastFullScan: null,
247
+ };
248
+ }
249
+
250
+ const delta = Math.abs(diskCount - indexCount);
251
+ const threshold = Math.max(5, Math.floor(diskCount * 0.05));
252
+ if (delta >= threshold) {
253
+ return {
254
+ isStale: true,
255
+ reason: `${diskCount} files on disk but ${indexCount} in index`,
256
+ diskCount,
257
+ indexCount,
258
+ lastFullScan,
259
+ };
260
+ }
261
+
262
+ return {
263
+ isStale: false,
264
+ reason: null,
265
+ diskCount,
266
+ indexCount,
267
+ lastFullScan,
268
+ };
269
+ }
270
+
222
271
  interface ScannedMarkdown {
223
272
  absolutePath: string;
224
273
  relativePath: string;
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,147 @@ 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
+ export function resolveProjectIdentity(cwd: string): ProjectIdentity {
140
+ const mode = readIdentityMode();
141
+ if (mode === "path-derived") {
142
+ return { id: generateProjectId(cwd), source: "path-derived" };
143
+ }
144
+
145
+ const override = readProjectOverride(cwd);
146
+ if (override) return { id: override, source: "override" };
147
+
148
+ const git = gitDerivedIdentity(cwd);
149
+ if (git) return { id: git, source: "git-remote" };
150
+
151
+ return { id: generateProjectId(cwd), source: "path-derived" };
152
+ }
153
+
154
+ export function projectIdFor(cwd: string): string {
155
+ return resolveProjectIdentity(cwd).id;
156
+ }
@@ -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
+ }