@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/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -219,7 +219,7 @@ switch (command) {
|
|
|
219
219
|
console.log(" config [key] [value] Manage global user settings");
|
|
220
220
|
console.log();
|
|
221
221
|
console.log("Notes & Wiki:");
|
|
222
|
-
console.log(" wiki <cmd> Manage the notes/wiki vault (init|status|link|unlink|links|rebuild-index|organize)");
|
|
222
|
+
console.log(" wiki <cmd> Manage the notes/wiki vault (init|status|link|unlink|links|rebuild-index|scan|organize)");
|
|
223
223
|
console.log(" note \"text\" Capture a note to the vault");
|
|
224
224
|
console.log(" note --daily [text] Create or append to today's daily note");
|
|
225
225
|
console.log(" note list [filters] List notes (--category, --tag, --recent)");
|
package/src/commands/init.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import { mkdirSync, existsSync } from "fs";
|
|
3
3
|
import { resolve, dirname, basename, join } from "path";
|
|
4
4
|
import { projectDir, projectMetaPath } from "../core/paths";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveProjectIdentity } from "../core/project-id";
|
|
6
6
|
import { atomicWriteJson, atomicWriteText, safeReadJson } from "../core/fs-utils";
|
|
7
|
+
import { getOrCreateDeviceId } from "../core/device";
|
|
8
|
+
import { getRepoRoot, getRepoRemote } from "../core/git-identity";
|
|
7
9
|
import {
|
|
8
10
|
isWikiEnabled,
|
|
9
11
|
isVaultInitialized,
|
|
@@ -176,21 +178,32 @@ export async function init(cwd: string): Promise<void> {
|
|
|
176
178
|
|
|
177
179
|
mkdirSync(dir, { recursive: true });
|
|
178
180
|
|
|
179
|
-
const
|
|
181
|
+
const identity = resolveProjectIdentity(cwd);
|
|
182
|
+
const projectId = identity.id;
|
|
180
183
|
|
|
181
184
|
// Detect notes project type
|
|
182
185
|
const isNotesProject =
|
|
183
186
|
isWikiEnabled() && isVaultInitialized() && isInsideVault(cwd);
|
|
184
187
|
|
|
185
|
-
// Write project metadata
|
|
188
|
+
// Write project metadata. Lift cwd into the per-device map alongside the
|
|
189
|
+
// legacy singular field so older mink versions (which only read `cwd`) keep
|
|
190
|
+
// working after a downgrade and new versions can track each device's path.
|
|
186
191
|
const metaPath = projectMetaPath(cwd);
|
|
187
192
|
const existingMeta = safeReadJson(metaPath) as Record<string, unknown> | null;
|
|
193
|
+
const deviceId = getOrCreateDeviceId();
|
|
194
|
+
const existingPathsByDevice =
|
|
195
|
+
existingMeta?.pathsByDevice &&
|
|
196
|
+
typeof existingMeta.pathsByDevice === "object" &&
|
|
197
|
+
!Array.isArray(existingMeta.pathsByDevice)
|
|
198
|
+
? (existingMeta.pathsByDevice as Record<string, string>)
|
|
199
|
+
: {};
|
|
188
200
|
atomicWriteJson(metaPath, {
|
|
189
201
|
...(existingMeta ?? {}),
|
|
190
202
|
cwd,
|
|
191
203
|
name: basename(cwd),
|
|
192
204
|
initTimestamp: existingMeta?.initTimestamp ?? new Date().toISOString(),
|
|
193
205
|
version: "0.1.0",
|
|
206
|
+
pathsByDevice: { ...existingPathsByDevice, [deviceId]: cwd },
|
|
194
207
|
...(isNotesProject ? { projectType: "notes" } : {}),
|
|
195
208
|
});
|
|
196
209
|
|
|
@@ -201,13 +214,25 @@ export async function init(cwd: string): Promise<void> {
|
|
|
201
214
|
console.log(` rule: ${rulePath}`);
|
|
202
215
|
} else {
|
|
203
216
|
console.log(`[mink] initialized`);
|
|
204
|
-
console.log(` project: ${projectId}`);
|
|
217
|
+
console.log(` project: ${projectId} (${identity.source})`);
|
|
205
218
|
console.log(` state: ${dir}`);
|
|
206
219
|
console.log(` runtime: ${runtime}`);
|
|
207
220
|
console.log(` hooks: ${settingsPath}`);
|
|
208
221
|
console.log(` rule: ${rulePath}`);
|
|
209
222
|
}
|
|
210
223
|
|
|
224
|
+
// Surface a one-time hint when the project is in a git repo with no remote
|
|
225
|
+
// configured — that's the only case where stable cross-machine identity is
|
|
226
|
+
// a config-fix away.
|
|
227
|
+
if (identity.source === "path-derived") {
|
|
228
|
+
const root = getRepoRoot(cwd);
|
|
229
|
+
if (root && !getRepoRemote(cwd)) {
|
|
230
|
+
console.log(
|
|
231
|
+
` note: this repo has no remote configured. Project state will not unify across machines until you add one and run \`mink config projects.identity git-remote\`.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
// Run initial scan
|
|
212
237
|
const { scan } = await import("./scan");
|
|
213
238
|
scan(cwd, { check: false });
|
package/src/commands/note.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
resolveVaultPath,
|
|
7
7
|
} from "../core/vault";
|
|
8
8
|
import { resolveConfigValue } from "../core/global-config";
|
|
9
|
-
import {
|
|
9
|
+
import { projectIdFor } from "../core/project-id";
|
|
10
10
|
import {
|
|
11
11
|
createNote,
|
|
12
12
|
appendToDaily,
|
|
@@ -180,7 +180,7 @@ function detectSourceProject(cwd: string): string | undefined {
|
|
|
180
180
|
const vaultPath = resolveVaultPath();
|
|
181
181
|
// Don't mark notes created from within the vault itself
|
|
182
182
|
if (cwd.startsWith(vaultPath)) return undefined;
|
|
183
|
-
return
|
|
183
|
+
return projectIdFor(cwd);
|
|
184
184
|
} catch {
|
|
185
185
|
return undefined;
|
|
186
186
|
}
|
package/src/commands/scan.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
3
|
import { fileIndexPath, configPath } from "../core/paths";
|
|
4
4
|
import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
scanProject,
|
|
7
|
+
scanProjectWithStats,
|
|
8
|
+
loadConfig,
|
|
9
|
+
getExcludes,
|
|
10
|
+
} from "../core/scanner";
|
|
6
11
|
import { extractDescription } from "../core/description";
|
|
7
12
|
import { estimateTokens } from "../core/token-estimate";
|
|
8
13
|
import {
|
|
@@ -13,6 +18,11 @@ import {
|
|
|
13
18
|
} from "../core/index-store";
|
|
14
19
|
import type { FileIndex, FileIndexEntry } from "../types/file-index";
|
|
15
20
|
|
|
21
|
+
function configRelativePath(cfgPath: string, cwd: string): string {
|
|
22
|
+
const rel = relative(cwd, cfgPath);
|
|
23
|
+
return rel.startsWith("..") ? cfgPath : rel;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
function loadExistingIndex(indexPath: string): FileIndex {
|
|
17
27
|
const raw = safeReadJson(indexPath);
|
|
18
28
|
if (isFileIndex(raw)) return raw;
|
|
@@ -64,7 +74,8 @@ export function scan(cwd: string, options: { check: boolean }): void {
|
|
|
64
74
|
const start = Date.now();
|
|
65
75
|
const index = loadExistingIndex(idxPath);
|
|
66
76
|
|
|
67
|
-
const
|
|
77
|
+
const stats = scanProjectWithStats(cwd, excludes, maxFiles);
|
|
78
|
+
const scanned = stats.files;
|
|
68
79
|
|
|
69
80
|
// Build new entries, preserving lifetime counters
|
|
70
81
|
const newIndex = createEmptyIndex();
|
|
@@ -95,7 +106,19 @@ export function scan(cwd: string, options: { check: boolean }): void {
|
|
|
95
106
|
atomicWriteJson(idxPath, newIndex);
|
|
96
107
|
|
|
97
108
|
const elapsed = Date.now() - start;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
if (stats.truncated > 0) {
|
|
110
|
+
console.log(
|
|
111
|
+
`[mink] scanned ${stats.totalScanned} files; indexed ${newIndex.header.totalFiles} most recent in ${elapsed}ms`
|
|
112
|
+
);
|
|
113
|
+
console.log(
|
|
114
|
+
` ${stats.truncated} files past maxFiles=${maxFiles} were not indexed`
|
|
115
|
+
);
|
|
116
|
+
console.log(
|
|
117
|
+
` raise the cap by setting "maxFiles" in ${configRelativePath(cfgPath, cwd)}`
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
console.log(
|
|
121
|
+
`[mink] indexed ${newIndex.header.totalFiles} files in ${elapsed}ms`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
101
124
|
}
|
|
@@ -24,10 +24,16 @@ export function sessionStart(cwd: string): void {
|
|
|
24
24
|
// Never crash hooks
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// One-shot migration to sync layout
|
|
27
|
+
// One-shot migration to the current sync layout. Idempotent re-run is a
|
|
28
|
+
// no-op. We also re-trigger when projects.identity=git-remote so a user who
|
|
29
|
+
// flips the flag after the version has stamped still gets project directories
|
|
30
|
+
// renamed to their stable identifier on the next session-start.
|
|
28
31
|
try {
|
|
29
32
|
const { readSyncVersion, MINK_SYNC_VERSION } = require("../core/sync");
|
|
30
|
-
|
|
33
|
+
const { resolveConfigValue } = require("../core/global-config");
|
|
34
|
+
const identityOn =
|
|
35
|
+
resolveConfigValue("projects.identity").value === "git-remote";
|
|
36
|
+
if (readSyncVersion() < MINK_SYNC_VERSION || identityOn) {
|
|
31
37
|
const { migrateSyncLayout } = require("./sync-migrate");
|
|
32
38
|
migrateSyncLayout();
|
|
33
39
|
}
|
|
@@ -25,6 +25,16 @@ import {
|
|
|
25
25
|
} from "../core/sync";
|
|
26
26
|
import { getOrCreateDeviceId } from "../core/device";
|
|
27
27
|
import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
|
|
28
|
+
import {
|
|
29
|
+
resolveProjectIdentity,
|
|
30
|
+
generateProjectId,
|
|
31
|
+
} from "../core/project-id";
|
|
32
|
+
import {
|
|
33
|
+
getProjectMeta,
|
|
34
|
+
addProjectAlias,
|
|
35
|
+
setProjectPathForDevice,
|
|
36
|
+
} from "../core/project-registry";
|
|
37
|
+
import { resolveConfigValue } from "../core/global-config";
|
|
28
38
|
import type { FileIndex } from "../types/file-index";
|
|
29
39
|
|
|
30
40
|
const MIGRATE_LOCK = ".sync-migrate.lock";
|
|
@@ -210,6 +220,308 @@ function listProjectsNeedingMigration(): string[] {
|
|
|
210
220
|
return listProjects().filter(projectNeedsMigration);
|
|
211
221
|
}
|
|
212
222
|
|
|
223
|
+
// ── v3 identity migration ─────────────────────────────────────────────────
|
|
224
|
+
//
|
|
225
|
+
// When `projects.identity = git-remote`, walks every project on disk and:
|
|
226
|
+
// 1. Computes its new identifier from the recorded working-copy path.
|
|
227
|
+
// 2. If the new identifier differs from the on-disk directory name, snapshots
|
|
228
|
+
// the project to a rollback backup, then renames the directory (preferring
|
|
229
|
+
// `git mv` so history is preserved when sync is initialised), records the
|
|
230
|
+
// old identifier as an alias, and lifts the singular `cwd` into the
|
|
231
|
+
// per-device path map keyed by this device.
|
|
232
|
+
// 3. If the working-copy path is missing from the local filesystem (the
|
|
233
|
+
// project's repo was cloned on a different machine), the project is left
|
|
234
|
+
// alone — the device that owns the cwd will handle the rename.
|
|
235
|
+
//
|
|
236
|
+
// Idempotent: re-running after a clean pass walks every project, finds every
|
|
237
|
+
// id matches its directory name, and does nothing.
|
|
238
|
+
|
|
239
|
+
export type IdentityPlanAction = "rename" | "skip-converged" | "skip-no-cwd" | "skip-unchanged";
|
|
240
|
+
|
|
241
|
+
export interface IdentityPlanEntry {
|
|
242
|
+
oldId: string;
|
|
243
|
+
newId: string | null;
|
|
244
|
+
cwd: string | null;
|
|
245
|
+
action: IdentityPlanAction;
|
|
246
|
+
reason?: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Walks every project on disk and returns the rename plan without touching it.
|
|
250
|
+
// Backbone for both --dry-run and the real migration so they share logic.
|
|
251
|
+
export function planIdentityMigration(): IdentityPlanEntry[] {
|
|
252
|
+
const plan: IdentityPlanEntry[] = [];
|
|
253
|
+
if (resolveConfigValue("projects.identity").value !== "git-remote") {
|
|
254
|
+
return plan;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
258
|
+
if (!existsSync(projectsRoot)) return plan;
|
|
259
|
+
|
|
260
|
+
let entries: string[];
|
|
261
|
+
try {
|
|
262
|
+
entries = readdirSync(projectsRoot);
|
|
263
|
+
} catch {
|
|
264
|
+
return plan;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const oldId of entries) {
|
|
268
|
+
const oldProjDir = join(projectsRoot, oldId);
|
|
269
|
+
try {
|
|
270
|
+
if (!statSync(oldProjDir).isDirectory()) continue;
|
|
271
|
+
} catch {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const meta = getProjectMeta(oldProjDir);
|
|
276
|
+
if (!meta) continue;
|
|
277
|
+
|
|
278
|
+
if (!existsSync(meta.cwd)) {
|
|
279
|
+
plan.push({
|
|
280
|
+
oldId,
|
|
281
|
+
newId: null,
|
|
282
|
+
cwd: meta.cwd,
|
|
283
|
+
action: "skip-no-cwd",
|
|
284
|
+
reason: "working-copy path not reachable on this device",
|
|
285
|
+
});
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let newId: string;
|
|
290
|
+
try {
|
|
291
|
+
newId = resolveProjectIdentity(meta.cwd).id;
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (newId === oldId) {
|
|
297
|
+
plan.push({ oldId, newId, cwd: meta.cwd, action: "skip-unchanged" });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const newProjDir = join(projectsRoot, newId);
|
|
302
|
+
if (existsSync(newProjDir)) {
|
|
303
|
+
plan.push({
|
|
304
|
+
oldId,
|
|
305
|
+
newId,
|
|
306
|
+
cwd: meta.cwd,
|
|
307
|
+
action: "skip-converged",
|
|
308
|
+
reason: "destination already exists (from sync); alias-only update",
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
plan.push({ oldId, newId, cwd: meta.cwd, action: "rename" });
|
|
314
|
+
}
|
|
315
|
+
return plan;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const IDENTITY_BACKUP_DIRNAME = ".identity-rollback";
|
|
319
|
+
|
|
320
|
+
function identityBackupRoot(timestamp: string): string {
|
|
321
|
+
return join(minkRoot(), IDENTITY_BACKUP_DIRNAME, timestamp);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ensureIdentityBackupTimestamp(): string {
|
|
325
|
+
const now = new Date();
|
|
326
|
+
return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(
|
|
327
|
+
now.getUTCDate()
|
|
328
|
+
).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(
|
|
329
|
+
now.getUTCMinutes()
|
|
330
|
+
).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Copies a project directory tree to the rollback backup. Skips `backups/` so
|
|
334
|
+
// nested backups don't double the snapshot size. Returns the backup path or
|
|
335
|
+
// null on failure (caller logs but does not abort migration on backup failure).
|
|
336
|
+
function backupProjectForRollback(srcDir: string, backupDir: string): string | null {
|
|
337
|
+
try {
|
|
338
|
+
mkdirSync(backupDir, { recursive: true });
|
|
339
|
+
copyDirRecursive(srcDir, backupDir, new Set(["backups"]));
|
|
340
|
+
return backupDir;
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>): void {
|
|
347
|
+
mkdirSync(dest, { recursive: true });
|
|
348
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
if (excludeNames.has(entry.name)) continue;
|
|
351
|
+
const srcPath = join(src, entry.name);
|
|
352
|
+
const destPath = join(dest, entry.name);
|
|
353
|
+
if (entry.isDirectory()) {
|
|
354
|
+
copyDirRecursive(srcPath, destPath, excludeNames);
|
|
355
|
+
} else if (entry.isFile()) {
|
|
356
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function migrateProjectIdentities(deviceId: string): {
|
|
362
|
+
renamed: number;
|
|
363
|
+
visited: number;
|
|
364
|
+
backupDir: string | null;
|
|
365
|
+
} {
|
|
366
|
+
if (resolveConfigValue("projects.identity").value !== "git-remote") {
|
|
367
|
+
return { renamed: 0, visited: 0, backupDir: null };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const plan = planIdentityMigration();
|
|
371
|
+
const willRename = plan.filter((p) => p.action === "rename");
|
|
372
|
+
|
|
373
|
+
// Compute the backup root up-front so all snapshots for this migration pass
|
|
374
|
+
// land in one timestamped directory the user can find and reason about.
|
|
375
|
+
let backupRoot: string | null = null;
|
|
376
|
+
if (willRename.length > 0) {
|
|
377
|
+
backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let renamed = 0;
|
|
381
|
+
let visited = plan.length;
|
|
382
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
383
|
+
|
|
384
|
+
for (const entry of plan) {
|
|
385
|
+
const oldProjDir = join(projectsRoot, entry.oldId);
|
|
386
|
+
|
|
387
|
+
// Lift cwd into pathsByDevice for every project we can see, even
|
|
388
|
+
// skip-unchanged ones, so older records gain the multi-device shape.
|
|
389
|
+
if (entry.cwd && entry.action !== "skip-no-cwd") {
|
|
390
|
+
try {
|
|
391
|
+
setProjectPathForDevice(oldProjDir, deviceId, entry.cwd);
|
|
392
|
+
} catch {
|
|
393
|
+
// best-effort
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (entry.action === "skip-converged" && entry.newId) {
|
|
398
|
+
const newProjDir = join(projectsRoot, entry.newId);
|
|
399
|
+
try {
|
|
400
|
+
addProjectAlias(newProjDir, entry.oldId);
|
|
401
|
+
if (entry.cwd) {
|
|
402
|
+
setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
// best-effort
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (entry.action !== "rename" || !entry.newId) continue;
|
|
411
|
+
|
|
412
|
+
// Snapshot the project before the rename so the user can recover if the
|
|
413
|
+
// alias-based rollback ever fails.
|
|
414
|
+
if (backupRoot) {
|
|
415
|
+
backupProjectForRollback(oldProjDir, join(backupRoot, entry.oldId));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const newProjDir = join(projectsRoot, entry.newId);
|
|
419
|
+
const moved =
|
|
420
|
+
gitSafe(`mv "${oldProjDir}" "${newProjDir}"`) !== null ||
|
|
421
|
+
(() => {
|
|
422
|
+
try {
|
|
423
|
+
renameSync(oldProjDir, newProjDir);
|
|
424
|
+
return true;
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
})();
|
|
429
|
+
|
|
430
|
+
if (!moved) continue;
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
addProjectAlias(newProjDir, entry.oldId);
|
|
434
|
+
if (entry.cwd) {
|
|
435
|
+
setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// best-effort
|
|
439
|
+
}
|
|
440
|
+
renamed++;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { renamed, visited, backupDir: backupRoot };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── v3 identity rollback ──────────────────────────────────────────────────
|
|
447
|
+
//
|
|
448
|
+
// Reverses the most recent identity rename for every project that has at
|
|
449
|
+
// least one alias recorded. Picks the most recently appended alias as the
|
|
450
|
+
// target id, renames the project directory back, and pops that entry from
|
|
451
|
+
// the alias list. Idempotent: a project with no aliases is left alone.
|
|
452
|
+
//
|
|
453
|
+
// This is the primary rollback path. The pre-migration backup is a fallback
|
|
454
|
+
// for when alias-based rollback can't proceed (e.g. metadata corruption).
|
|
455
|
+
|
|
456
|
+
export interface RollbackEntry {
|
|
457
|
+
currentId: string;
|
|
458
|
+
restoredId: string;
|
|
459
|
+
ok: boolean;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function rollbackProjectIdentities(): RollbackEntry[] {
|
|
463
|
+
const results: RollbackEntry[] = [];
|
|
464
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
465
|
+
if (!existsSync(projectsRoot)) return results;
|
|
466
|
+
|
|
467
|
+
let entries: string[];
|
|
468
|
+
try {
|
|
469
|
+
entries = readdirSync(projectsRoot);
|
|
470
|
+
} catch {
|
|
471
|
+
return results;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const currentId of entries) {
|
|
475
|
+
const projDir = join(projectsRoot, currentId);
|
|
476
|
+
try {
|
|
477
|
+
if (!statSync(projDir).isDirectory()) continue;
|
|
478
|
+
} catch {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const meta = getProjectMeta(projDir);
|
|
483
|
+
if (!meta || !meta.aliases || meta.aliases.length === 0) continue;
|
|
484
|
+
|
|
485
|
+
const restoredId = meta.aliases[meta.aliases.length - 1];
|
|
486
|
+
const targetDir = join(projectsRoot, restoredId);
|
|
487
|
+
|
|
488
|
+
if (existsSync(targetDir)) {
|
|
489
|
+
// Refuse to overwrite an existing directory at the target id.
|
|
490
|
+
results.push({ currentId, restoredId, ok: false });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Pop the alias before renaming so the resulting on-disk metadata file
|
|
495
|
+
// reflects the rolled-back state even if the rename itself succeeds.
|
|
496
|
+
const remainingAliases = meta.aliases.slice(0, -1);
|
|
497
|
+
const metaPath = join(projDir, "project-meta.json");
|
|
498
|
+
try {
|
|
499
|
+
const raw = safeReadJson(metaPath);
|
|
500
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
501
|
+
const obj = raw as Record<string, unknown>;
|
|
502
|
+
obj.aliases = remainingAliases;
|
|
503
|
+
atomicWriteJson(metaPath, obj);
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
// best-effort; rollback continues
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const moved =
|
|
510
|
+
gitSafe(`mv "${projDir}" "${targetDir}"`) !== null ||
|
|
511
|
+
(() => {
|
|
512
|
+
try {
|
|
513
|
+
renameSync(projDir, targetDir);
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
})();
|
|
519
|
+
|
|
520
|
+
results.push({ currentId, restoredId, ok: moved });
|
|
521
|
+
}
|
|
522
|
+
return results;
|
|
523
|
+
}
|
|
524
|
+
|
|
213
525
|
export interface MigrateResult {
|
|
214
526
|
ranMigration: boolean;
|
|
215
527
|
fromVersion: number;
|
|
@@ -221,13 +533,21 @@ export interface MigrateResult {
|
|
|
221
533
|
// session-start auto-trigger when readSyncVersion() < MINK_SYNC_VERSION.
|
|
222
534
|
//
|
|
223
535
|
// We treat the version marker as a hint, not a gate — a previous partial run
|
|
224
|
-
// (interrupted by the budget cap) may have written
|
|
225
|
-
// pending. We re-run as long as any project on disk still has
|
|
226
|
-
// its top level, regardless of marker.
|
|
536
|
+
// (interrupted by the budget cap) may have written the latest version with
|
|
537
|
+
// projects still pending. We re-run as long as any project on disk still has
|
|
538
|
+
// legacy files at its top level, regardless of marker. The v3 identity step
|
|
539
|
+
// also runs whenever projects.identity=git-remote so a user who flips the
|
|
540
|
+
// flag after the version has already stamped to 3 still gets their projects
|
|
541
|
+
// migrated.
|
|
227
542
|
export function migrateSyncLayout(): MigrateResult {
|
|
228
543
|
const fromVersion = readSyncVersion();
|
|
229
544
|
const pending = listProjectsNeedingMigration();
|
|
230
|
-
|
|
545
|
+
const identityMode = resolveConfigValue("projects.identity").value;
|
|
546
|
+
if (
|
|
547
|
+
fromVersion >= MINK_SYNC_VERSION &&
|
|
548
|
+
pending.length === 0 &&
|
|
549
|
+
identityMode !== "git-remote"
|
|
550
|
+
) {
|
|
231
551
|
return {
|
|
232
552
|
ranMigration: false,
|
|
233
553
|
fromVersion,
|
|
@@ -288,6 +608,16 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
288
608
|
}
|
|
289
609
|
}
|
|
290
610
|
|
|
611
|
+
// v3 identity migration: rename per-project directories to their stable
|
|
612
|
+
// git-derived identifier when the user has opted in. Cheap no-op when the
|
|
613
|
+
// flag is off or every project's identifier already matches its directory.
|
|
614
|
+
let identity = { renamed: 0, visited: 0 };
|
|
615
|
+
try {
|
|
616
|
+
identity = migrateProjectIdentities(deviceId);
|
|
617
|
+
} catch {
|
|
618
|
+
// best-effort; never block the rest of migration
|
|
619
|
+
}
|
|
620
|
+
|
|
291
621
|
// Only stamp the version marker once nothing is left to migrate. If we
|
|
292
622
|
// still have pending projects, leave the marker as-is so the next session
|
|
293
623
|
// knows to keep going.
|
|
@@ -295,12 +625,16 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
295
625
|
writeSyncVersion(MINK_SYNC_VERSION);
|
|
296
626
|
}
|
|
297
627
|
|
|
298
|
-
if (isSyncInitialized() && processed > 0) {
|
|
628
|
+
if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
|
|
299
629
|
// Skip the lock file — it's part of migration coordination, not state.
|
|
300
630
|
gitSafe("add -A");
|
|
301
631
|
gitSafe(`reset HEAD ".sync-migrate.lock"`);
|
|
632
|
+
const summary =
|
|
633
|
+
identity.renamed > 0
|
|
634
|
+
? `${processed} projects, ${identity.renamed} renamed for identity v3`
|
|
635
|
+
: `${processed} projects`;
|
|
302
636
|
gitSafe(
|
|
303
|
-
`commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${
|
|
637
|
+
`commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
|
|
304
638
|
);
|
|
305
639
|
}
|
|
306
640
|
|
|
@@ -318,7 +652,70 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
318
652
|
}
|
|
319
653
|
}
|
|
320
654
|
|
|
321
|
-
export function syncMigrateCommand(): void {
|
|
655
|
+
export function syncMigrateCommand(args: string[] = []): void {
|
|
656
|
+
const dryRun = args.includes("--dry-run");
|
|
657
|
+
const rollback = args.includes("--rollback");
|
|
658
|
+
|
|
659
|
+
if (rollback && dryRun) {
|
|
660
|
+
console.error("[mink] --rollback and --dry-run cannot be combined");
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (dryRun) {
|
|
665
|
+
const plan = planIdentityMigration();
|
|
666
|
+
if (plan.length === 0) {
|
|
667
|
+
console.log(
|
|
668
|
+
"[mink] sync migrate --dry-run: no projects to rename (flag is off or no projects on disk)"
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const renames = plan.filter((p) => p.action === "rename");
|
|
673
|
+
const converged = plan.filter((p) => p.action === "skip-converged");
|
|
674
|
+
const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
|
|
675
|
+
const unchanged = plan.filter((p) => p.action === "skip-unchanged");
|
|
676
|
+
|
|
677
|
+
console.log(
|
|
678
|
+
`[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
|
|
679
|
+
);
|
|
680
|
+
for (const p of renames) {
|
|
681
|
+
console.log(` rename: ${p.oldId} → ${p.newId}`);
|
|
682
|
+
}
|
|
683
|
+
for (const p of converged) {
|
|
684
|
+
console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
|
|
685
|
+
}
|
|
686
|
+
for (const p of skippedNoCwd) {
|
|
687
|
+
console.log(` skip: ${p.oldId} — ${p.reason}`);
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (rollback) {
|
|
693
|
+
const results = rollbackProjectIdentities();
|
|
694
|
+
if (results.length === 0) {
|
|
695
|
+
console.log("[mink] sync migrate --rollback: nothing to roll back");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const ok = results.filter((r) => r.ok);
|
|
699
|
+
const failed = results.filter((r) => !r.ok);
|
|
700
|
+
console.log(
|
|
701
|
+
`[mink] sync migrate --rollback: ${ok.length} restored, ${failed.length} failed`
|
|
702
|
+
);
|
|
703
|
+
for (const r of ok) {
|
|
704
|
+
console.log(` restored: ${r.currentId} → ${r.restoredId}`);
|
|
705
|
+
}
|
|
706
|
+
for (const r of failed) {
|
|
707
|
+
console.log(
|
|
708
|
+
` failed: ${r.currentId} → ${r.restoredId} (destination already exists or rename blocked)`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (ok.length > 0) {
|
|
712
|
+
console.log(
|
|
713
|
+
"\n[mink] tip: set projects.identity=path-derived to prevent the next session-start from re-migrating"
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
322
719
|
const result = migrateSyncLayout();
|
|
323
720
|
if (!result.ranMigration) {
|
|
324
721
|
console.log(`[mink] sync migrate: ${result.message ?? "no-op"}`);
|
package/src/commands/sync.ts
CHANGED
|
@@ -52,14 +52,17 @@ export async function sync(args: string[]): Promise<void> {
|
|
|
52
52
|
|
|
53
53
|
case "migrate": {
|
|
54
54
|
const { syncMigrateCommand } = await import("./sync-migrate");
|
|
55
|
-
syncMigrateCommand();
|
|
55
|
+
syncMigrateCommand(args.slice(1));
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
default:
|
|
60
60
|
console.error(`[mink] unknown sync subcommand: ${subcommand}`);
|
|
61
61
|
console.error(
|
|
62
|
-
"Usage: mink sync [init|status|push|pull|pause|resume|disconnect|reconcile|
|
|
62
|
+
"Usage: mink sync [init|status|push|pull|pause|resume|disconnect|reconcile|merge-driver]"
|
|
63
|
+
);
|
|
64
|
+
console.error(
|
|
65
|
+
" mink sync migrate [--dry-run|--rollback]"
|
|
63
66
|
);
|
|
64
67
|
process.exit(1);
|
|
65
68
|
}
|