@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.
- 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 +1407 -868
- package/package.json +1 -1
- package/src/commands/init.ts +29 -4
- package/src/commands/note.ts +2 -2
- package/src/commands/session-start.ts +8 -2
- package/src/commands/sync-migrate.ts +429 -7
- package/src/commands/sync.ts +5 -2
- package/src/core/dashboard-server.ts +13 -5
- package/src/core/git-identity.ts +120 -0
- package/src/core/paths.ts +19 -3
- package/src/core/project-id.ts +150 -5
- package/src/core/project-registry.ts +122 -13
- package/src/core/sync.ts +7 -1
- package/src/types/config.ts +9 -0
- /package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WyN-sdaVY7cZaACRaK7vq}/_buildManifest.js +0 -0
- /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 {
|
|
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,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 =
|
|
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
|
|
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
|
+
}
|
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
|
-
|
|
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 {
|
package/src/types/config.ts
CHANGED
|
@@ -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));
|
|
File without changes
|
/package/dashboard/out/_next/static/{e0QWU9rPMeSlJJLTwij89 → WyN-sdaVY7cZaACRaK7vq}/_ssgManifest.js
RENAMED
|
File without changes
|