@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
package/package.json
CHANGED
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
|
}
|
|
@@ -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,326 @@ 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
|
+
//
|
|
252
|
+
// Accepts an optional `flagOverride` so callers that have already snapshotted
|
|
253
|
+
// `projects.identity` (e.g. migrateSyncLayout, before its git-stash) can pass
|
|
254
|
+
// the snapshot in rather than re-reading from disk inside a stash window where
|
|
255
|
+
// the config file's uncommitted writes are temporarily hidden.
|
|
256
|
+
export function planIdentityMigration(flagOverride?: string): IdentityPlanEntry[] {
|
|
257
|
+
const plan: IdentityPlanEntry[] = [];
|
|
258
|
+
const flag = flagOverride ?? resolveConfigValue("projects.identity").value;
|
|
259
|
+
if (flag !== "git-remote") {
|
|
260
|
+
return plan;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
264
|
+
if (!existsSync(projectsRoot)) return plan;
|
|
265
|
+
|
|
266
|
+
let entries: string[];
|
|
267
|
+
try {
|
|
268
|
+
entries = readdirSync(projectsRoot);
|
|
269
|
+
} catch {
|
|
270
|
+
return plan;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const oldId of entries) {
|
|
274
|
+
const oldProjDir = join(projectsRoot, oldId);
|
|
275
|
+
try {
|
|
276
|
+
if (!statSync(oldProjDir).isDirectory()) continue;
|
|
277
|
+
} catch {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const meta = getProjectMeta(oldProjDir);
|
|
282
|
+
if (!meta) continue;
|
|
283
|
+
|
|
284
|
+
if (!existsSync(meta.cwd)) {
|
|
285
|
+
plan.push({
|
|
286
|
+
oldId,
|
|
287
|
+
newId: null,
|
|
288
|
+
cwd: meta.cwd,
|
|
289
|
+
action: "skip-no-cwd",
|
|
290
|
+
reason: "working-copy path not reachable on this device",
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let newId: string;
|
|
296
|
+
try {
|
|
297
|
+
newId = resolveProjectIdentity(
|
|
298
|
+
meta.cwd,
|
|
299
|
+
flag === "git-remote" || flag === "path-derived" ? flag : undefined
|
|
300
|
+
).id;
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (newId === oldId) {
|
|
306
|
+
plan.push({ oldId, newId, cwd: meta.cwd, action: "skip-unchanged" });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const newProjDir = join(projectsRoot, newId);
|
|
311
|
+
if (existsSync(newProjDir)) {
|
|
312
|
+
plan.push({
|
|
313
|
+
oldId,
|
|
314
|
+
newId,
|
|
315
|
+
cwd: meta.cwd,
|
|
316
|
+
action: "skip-converged",
|
|
317
|
+
reason: "destination already exists (from sync); alias-only update",
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
plan.push({ oldId, newId, cwd: meta.cwd, action: "rename" });
|
|
323
|
+
}
|
|
324
|
+
return plan;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const IDENTITY_BACKUP_DIRNAME = ".identity-rollback";
|
|
328
|
+
|
|
329
|
+
function identityBackupRoot(timestamp: string): string {
|
|
330
|
+
return join(minkRoot(), IDENTITY_BACKUP_DIRNAME, timestamp);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function ensureIdentityBackupTimestamp(): string {
|
|
334
|
+
const now = new Date();
|
|
335
|
+
return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(
|
|
336
|
+
now.getUTCDate()
|
|
337
|
+
).padStart(2, "0")}-${String(now.getUTCHours()).padStart(2, "0")}${String(
|
|
338
|
+
now.getUTCMinutes()
|
|
339
|
+
).padStart(2, "0")}${String(now.getUTCSeconds()).padStart(2, "0")}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Copies a project directory tree to the rollback backup. Skips `backups/` so
|
|
343
|
+
// nested backups don't double the snapshot size. Returns the backup path or
|
|
344
|
+
// null on failure (caller logs but does not abort migration on backup failure).
|
|
345
|
+
function backupProjectForRollback(srcDir: string, backupDir: string): string | null {
|
|
346
|
+
try {
|
|
347
|
+
mkdirSync(backupDir, { recursive: true });
|
|
348
|
+
copyDirRecursive(srcDir, backupDir, new Set(["backups"]));
|
|
349
|
+
return backupDir;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function copyDirRecursive(src: string, dest: string, excludeNames: Set<string>): void {
|
|
356
|
+
mkdirSync(dest, { recursive: true });
|
|
357
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
358
|
+
for (const entry of entries) {
|
|
359
|
+
if (excludeNames.has(entry.name)) continue;
|
|
360
|
+
const srcPath = join(src, entry.name);
|
|
361
|
+
const destPath = join(dest, entry.name);
|
|
362
|
+
if (entry.isDirectory()) {
|
|
363
|
+
copyDirRecursive(srcPath, destPath, excludeNames);
|
|
364
|
+
} else if (entry.isFile()) {
|
|
365
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Accepts the identity-mode value as a parameter so the caller can snapshot it
|
|
371
|
+
// before any disk side-effects (notably the migrating git-stash in
|
|
372
|
+
// migrateSyncLayout, which would hide uncommitted writes to the config file
|
|
373
|
+
// that drives this very decision). Falls back to a fresh read for callers that
|
|
374
|
+
// don't operate inside a stash window (e.g. session-start triggers and the
|
|
375
|
+
// --dry-run path).
|
|
376
|
+
function migrateProjectIdentities(
|
|
377
|
+
deviceId: string,
|
|
378
|
+
flag: string = resolveConfigValue("projects.identity").value
|
|
379
|
+
): {
|
|
380
|
+
renamed: number;
|
|
381
|
+
visited: number;
|
|
382
|
+
backupDir: string | null;
|
|
383
|
+
} {
|
|
384
|
+
if (flag !== "git-remote") {
|
|
385
|
+
return { renamed: 0, visited: 0, backupDir: null };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const plan = planIdentityMigration(flag);
|
|
389
|
+
const willRename = plan.filter((p) => p.action === "rename");
|
|
390
|
+
|
|
391
|
+
// Compute the backup root up-front so all snapshots for this migration pass
|
|
392
|
+
// land in one timestamped directory the user can find and reason about.
|
|
393
|
+
let backupRoot: string | null = null;
|
|
394
|
+
if (willRename.length > 0) {
|
|
395
|
+
backupRoot = identityBackupRoot(ensureIdentityBackupTimestamp());
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let renamed = 0;
|
|
399
|
+
let visited = plan.length;
|
|
400
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
401
|
+
|
|
402
|
+
for (const entry of plan) {
|
|
403
|
+
const oldProjDir = join(projectsRoot, entry.oldId);
|
|
404
|
+
|
|
405
|
+
// Lift cwd into pathsByDevice for every project we can see, even
|
|
406
|
+
// skip-unchanged ones, so older records gain the multi-device shape.
|
|
407
|
+
if (entry.cwd && entry.action !== "skip-no-cwd") {
|
|
408
|
+
try {
|
|
409
|
+
setProjectPathForDevice(oldProjDir, deviceId, entry.cwd);
|
|
410
|
+
} catch {
|
|
411
|
+
// best-effort
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (entry.action === "skip-converged" && entry.newId) {
|
|
416
|
+
const newProjDir = join(projectsRoot, entry.newId);
|
|
417
|
+
try {
|
|
418
|
+
addProjectAlias(newProjDir, entry.oldId);
|
|
419
|
+
if (entry.cwd) {
|
|
420
|
+
setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
// best-effort
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (entry.action !== "rename" || !entry.newId) continue;
|
|
429
|
+
|
|
430
|
+
// Snapshot the project before the rename so the user can recover if the
|
|
431
|
+
// alias-based rollback ever fails.
|
|
432
|
+
if (backupRoot) {
|
|
433
|
+
backupProjectForRollback(oldProjDir, join(backupRoot, entry.oldId));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const newProjDir = join(projectsRoot, entry.newId);
|
|
437
|
+
const moved =
|
|
438
|
+
gitSafe(`mv "${oldProjDir}" "${newProjDir}"`) !== null ||
|
|
439
|
+
(() => {
|
|
440
|
+
try {
|
|
441
|
+
renameSync(oldProjDir, newProjDir);
|
|
442
|
+
return true;
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
})();
|
|
447
|
+
|
|
448
|
+
if (!moved) continue;
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
addProjectAlias(newProjDir, entry.oldId);
|
|
452
|
+
if (entry.cwd) {
|
|
453
|
+
setProjectPathForDevice(newProjDir, deviceId, entry.cwd);
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
// best-effort
|
|
457
|
+
}
|
|
458
|
+
renamed++;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { renamed, visited, backupDir: backupRoot };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── v3 identity rollback ──────────────────────────────────────────────────
|
|
465
|
+
//
|
|
466
|
+
// Reverses the most recent identity rename for every project that has at
|
|
467
|
+
// least one alias recorded. Picks the most recently appended alias as the
|
|
468
|
+
// target id, renames the project directory back, and pops that entry from
|
|
469
|
+
// the alias list. Idempotent: a project with no aliases is left alone.
|
|
470
|
+
//
|
|
471
|
+
// This is the primary rollback path. The pre-migration backup is a fallback
|
|
472
|
+
// for when alias-based rollback can't proceed (e.g. metadata corruption).
|
|
473
|
+
|
|
474
|
+
export interface RollbackEntry {
|
|
475
|
+
currentId: string;
|
|
476
|
+
restoredId: string;
|
|
477
|
+
ok: boolean;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function rollbackProjectIdentities(): RollbackEntry[] {
|
|
481
|
+
const results: RollbackEntry[] = [];
|
|
482
|
+
const projectsRoot = join(minkRoot(), "projects");
|
|
483
|
+
if (!existsSync(projectsRoot)) return results;
|
|
484
|
+
|
|
485
|
+
let entries: string[];
|
|
486
|
+
try {
|
|
487
|
+
entries = readdirSync(projectsRoot);
|
|
488
|
+
} catch {
|
|
489
|
+
return results;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (const currentId of entries) {
|
|
493
|
+
const projDir = join(projectsRoot, currentId);
|
|
494
|
+
try {
|
|
495
|
+
if (!statSync(projDir).isDirectory()) continue;
|
|
496
|
+
} catch {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const meta = getProjectMeta(projDir);
|
|
501
|
+
if (!meta || !meta.aliases || meta.aliases.length === 0) continue;
|
|
502
|
+
|
|
503
|
+
const restoredId = meta.aliases[meta.aliases.length - 1];
|
|
504
|
+
const targetDir = join(projectsRoot, restoredId);
|
|
505
|
+
|
|
506
|
+
if (existsSync(targetDir)) {
|
|
507
|
+
// Refuse to overwrite an existing directory at the target id.
|
|
508
|
+
results.push({ currentId, restoredId, ok: false });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Pop the alias before renaming so the resulting on-disk metadata file
|
|
513
|
+
// reflects the rolled-back state even if the rename itself succeeds.
|
|
514
|
+
const remainingAliases = meta.aliases.slice(0, -1);
|
|
515
|
+
const metaPath = join(projDir, "project-meta.json");
|
|
516
|
+
try {
|
|
517
|
+
const raw = safeReadJson(metaPath);
|
|
518
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
519
|
+
const obj = raw as Record<string, unknown>;
|
|
520
|
+
obj.aliases = remainingAliases;
|
|
521
|
+
atomicWriteJson(metaPath, obj);
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
// best-effort; rollback continues
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const moved =
|
|
528
|
+
gitSafe(`mv "${projDir}" "${targetDir}"`) !== null ||
|
|
529
|
+
(() => {
|
|
530
|
+
try {
|
|
531
|
+
renameSync(projDir, targetDir);
|
|
532
|
+
return true;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
})();
|
|
537
|
+
|
|
538
|
+
results.push({ currentId, restoredId, ok: moved });
|
|
539
|
+
}
|
|
540
|
+
return results;
|
|
541
|
+
}
|
|
542
|
+
|
|
213
543
|
export interface MigrateResult {
|
|
214
544
|
ranMigration: boolean;
|
|
215
545
|
fromVersion: number;
|
|
@@ -221,13 +551,26 @@ export interface MigrateResult {
|
|
|
221
551
|
// session-start auto-trigger when readSyncVersion() < MINK_SYNC_VERSION.
|
|
222
552
|
//
|
|
223
553
|
// 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.
|
|
554
|
+
// (interrupted by the budget cap) may have written the latest version with
|
|
555
|
+
// projects still pending. We re-run as long as any project on disk still has
|
|
556
|
+
// legacy files at its top level, regardless of marker. The v3 identity step
|
|
557
|
+
// also runs whenever projects.identity=git-remote so a user who flips the
|
|
558
|
+
// flag after the version has already stamped to 3 still gets their projects
|
|
559
|
+
// migrated.
|
|
227
560
|
export function migrateSyncLayout(): MigrateResult {
|
|
228
561
|
const fromVersion = readSyncVersion();
|
|
229
562
|
const pending = listProjectsNeedingMigration();
|
|
230
|
-
|
|
563
|
+
// Snapshot the identity mode BEFORE the migrating stash below. The stash
|
|
564
|
+
// hides any uncommitted edits to ~/.mink/config — including the very
|
|
565
|
+
// `projects.identity = git-remote` write that should be driving this
|
|
566
|
+
// migration. Reading the flag after the stash would see the stale,
|
|
567
|
+
// last-committed config and the v3 identity step would no-op.
|
|
568
|
+
const identityMode = resolveConfigValue("projects.identity").value;
|
|
569
|
+
if (
|
|
570
|
+
fromVersion >= MINK_SYNC_VERSION &&
|
|
571
|
+
pending.length === 0 &&
|
|
572
|
+
identityMode !== "git-remote"
|
|
573
|
+
) {
|
|
231
574
|
return {
|
|
232
575
|
ranMigration: false,
|
|
233
576
|
fromVersion,
|
|
@@ -288,6 +631,18 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
288
631
|
}
|
|
289
632
|
}
|
|
290
633
|
|
|
634
|
+
// v3 identity migration: rename per-project directories to their stable
|
|
635
|
+
// git-derived identifier when the user has opted in. Cheap no-op when the
|
|
636
|
+
// flag is off or every project's identifier already matches its directory.
|
|
637
|
+
// Pass the pre-stash snapshot of identityMode so we don't re-read the
|
|
638
|
+
// config from a stash-hidden working tree.
|
|
639
|
+
let identity = { renamed: 0, visited: 0 };
|
|
640
|
+
try {
|
|
641
|
+
identity = migrateProjectIdentities(deviceId, identityMode);
|
|
642
|
+
} catch {
|
|
643
|
+
// best-effort; never block the rest of migration
|
|
644
|
+
}
|
|
645
|
+
|
|
291
646
|
// Only stamp the version marker once nothing is left to migrate. If we
|
|
292
647
|
// still have pending projects, leave the marker as-is so the next session
|
|
293
648
|
// knows to keep going.
|
|
@@ -295,12 +650,16 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
295
650
|
writeSyncVersion(MINK_SYNC_VERSION);
|
|
296
651
|
}
|
|
297
652
|
|
|
298
|
-
if (isSyncInitialized() && processed > 0) {
|
|
653
|
+
if (isSyncInitialized() && (processed > 0 || identity.renamed > 0)) {
|
|
299
654
|
// Skip the lock file — it's part of migration coordination, not state.
|
|
300
655
|
gitSafe("add -A");
|
|
301
656
|
gitSafe(`reset HEAD ".sync-migrate.lock"`);
|
|
657
|
+
const summary =
|
|
658
|
+
identity.renamed > 0
|
|
659
|
+
? `${processed} projects, ${identity.renamed} renamed for identity v3`
|
|
660
|
+
: `${processed} projects`;
|
|
302
661
|
gitSafe(
|
|
303
|
-
`commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${
|
|
662
|
+
`commit -m "mink: migrate sync layout v${fromVersion} -> v${MINK_SYNC_VERSION} (device ${deviceId.slice(0, 8)}, ${summary})"`
|
|
304
663
|
);
|
|
305
664
|
}
|
|
306
665
|
|
|
@@ -318,7 +677,70 @@ export function migrateSyncLayout(): MigrateResult {
|
|
|
318
677
|
}
|
|
319
678
|
}
|
|
320
679
|
|
|
321
|
-
export function syncMigrateCommand(): void {
|
|
680
|
+
export function syncMigrateCommand(args: string[] = []): void {
|
|
681
|
+
const dryRun = args.includes("--dry-run");
|
|
682
|
+
const rollback = args.includes("--rollback");
|
|
683
|
+
|
|
684
|
+
if (rollback && dryRun) {
|
|
685
|
+
console.error("[mink] --rollback and --dry-run cannot be combined");
|
|
686
|
+
process.exit(1);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (dryRun) {
|
|
690
|
+
const plan = planIdentityMigration();
|
|
691
|
+
if (plan.length === 0) {
|
|
692
|
+
console.log(
|
|
693
|
+
"[mink] sync migrate --dry-run: no projects to rename (flag is off or no projects on disk)"
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const renames = plan.filter((p) => p.action === "rename");
|
|
698
|
+
const converged = plan.filter((p) => p.action === "skip-converged");
|
|
699
|
+
const skippedNoCwd = plan.filter((p) => p.action === "skip-no-cwd");
|
|
700
|
+
const unchanged = plan.filter((p) => p.action === "skip-unchanged");
|
|
701
|
+
|
|
702
|
+
console.log(
|
|
703
|
+
`[mink] sync migrate --dry-run: ${renames.length} rename(s), ${converged.length} alias-only, ${skippedNoCwd.length} skipped (no cwd), ${unchanged.length} unchanged`
|
|
704
|
+
);
|
|
705
|
+
for (const p of renames) {
|
|
706
|
+
console.log(` rename: ${p.oldId} → ${p.newId}`);
|
|
707
|
+
}
|
|
708
|
+
for (const p of converged) {
|
|
709
|
+
console.log(` alias: ${p.oldId} → ${p.newId} (already on disk)`);
|
|
710
|
+
}
|
|
711
|
+
for (const p of skippedNoCwd) {
|
|
712
|
+
console.log(` skip: ${p.oldId} — ${p.reason}`);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (rollback) {
|
|
718
|
+
const results = rollbackProjectIdentities();
|
|
719
|
+
if (results.length === 0) {
|
|
720
|
+
console.log("[mink] sync migrate --rollback: nothing to roll back");
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const ok = results.filter((r) => r.ok);
|
|
724
|
+
const failed = results.filter((r) => !r.ok);
|
|
725
|
+
console.log(
|
|
726
|
+
`[mink] sync migrate --rollback: ${ok.length} restored, ${failed.length} failed`
|
|
727
|
+
);
|
|
728
|
+
for (const r of ok) {
|
|
729
|
+
console.log(` restored: ${r.currentId} → ${r.restoredId}`);
|
|
730
|
+
}
|
|
731
|
+
for (const r of failed) {
|
|
732
|
+
console.log(
|
|
733
|
+
` failed: ${r.currentId} → ${r.restoredId} (destination already exists or rename blocked)`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (ok.length > 0) {
|
|
737
|
+
console.log(
|
|
738
|
+
"\n[mink] tip: set projects.identity=path-derived to prevent the next session-start from re-migrating"
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
322
744
|
const result = migrateSyncLayout();
|
|
323
745
|
if (!result.ranMigration) {
|
|
324
746
|
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
|
}
|
|
@@ -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
|
}
|