@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.
- package/README.md +62 -1
- package/dashboard/out/404.html +1 -1
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +1 -1
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +1 -1
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +1 -1
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +1 -1
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +1 -1
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +1 -1
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +1 -1
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +1 -1
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +1 -1
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +1 -1
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +1 -1
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +1 -1
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +1 -1
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +1 -1
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +1 -1
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +1 -1
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +1 -1
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +1 -1
- package/dist/cli.js +1505 -896
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/init.ts +29 -4
- package/src/commands/note.ts +2 -2
- package/src/commands/scan.ts +29 -6
- package/src/commands/session-start.ts +8 -2
- package/src/commands/sync-migrate.ts +404 -7
- package/src/commands/sync.ts +5 -2
- package/src/commands/wiki.ts +19 -3
- package/src/core/dashboard-server.ts +13 -5
- package/src/core/git-identity.ts +120 -0
- package/src/core/note-index.ts +50 -1
- package/src/core/paths.ts +19 -3
- package/src/core/project-id.ts +142 -5
- package/src/core/project-registry.ts +122 -13
- package/src/core/scanner.ts +19 -3
- package/src/core/sync.ts +7 -1
- package/src/types/config.ts +9 -0
- package/src/types/note.ts +1 -0
- /package/dashboard/out/_next/static/{frTrvF6NV-Xl2bLk21NkY → WDjkNLHEd_wI-oOzLyblH}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{frTrvF6NV-Xl2bLk21NkY → WDjkNLHEd_wI-oOzLyblH}/_ssgManifest.js +0 -0
package/src/commands/wiki.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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 ===
|
|
157
|
+
if (projectId === projectIdFor(defaultCwd)) return defaultCwd;
|
|
158
158
|
|
|
159
159
|
const projects = listRegisteredProjects();
|
|
160
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/src/core/note-index.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 =
|
|
24
|
-
|
|
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 {
|
package/src/core/project-id.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
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
|
+
}
|