@drewpayment/mink 0.2.2 → 0.3.0

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.
@@ -0,0 +1,333 @@
1
+ import { existsSync, writeFileSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { execSync } from "child_process";
4
+ import { minkRoot } from "./paths";
5
+ import { resolveConfigValue, setConfigValue } from "./global-config";
6
+
7
+ // ── Constants ──────────────────────────────────────────────────────────────
8
+
9
+ const GIT_TIMEOUT = 5_000;
10
+ const PUSH_TIMEOUT = 10_000;
11
+ const FETCH_TIMEOUT = 15_000;
12
+
13
+ const GITIGNORE_CONTENTS = `# Runtime state — machine-specific
14
+ scheduler.pid
15
+ scheduler.log
16
+
17
+ # Local backups — machine-specific snapshots
18
+ projects/*/backups/
19
+ `;
20
+
21
+ // ── Helpers ────────────────────────────────────────────────────────────────
22
+
23
+ function git(args: string, timeoutMs: number = GIT_TIMEOUT): string {
24
+ return execSync(`git ${args}`, {
25
+ cwd: minkRoot(),
26
+ timeout: timeoutMs,
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ }).toString().trim();
29
+ }
30
+
31
+ function gitSafe(args: string, timeoutMs: number = GIT_TIMEOUT): string | null {
32
+ try {
33
+ return git(args, timeoutMs);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // ── Public API ─────────────────────────────────────────────────────────────
40
+
41
+ export function isSyncInitialized(): boolean {
42
+ const enabled = resolveConfigValue("sync.enabled").value;
43
+ if (enabled !== "true") return false;
44
+ return existsSync(join(minkRoot(), ".git"));
45
+ }
46
+
47
+ export function ensureGitignore(): void {
48
+ const gitignorePath = join(minkRoot(), ".gitignore");
49
+ writeFileSync(gitignorePath, GITIGNORE_CONTENTS);
50
+ }
51
+
52
+ export interface SyncStatusInfo {
53
+ enabled: boolean;
54
+ gitInitialized: boolean;
55
+ remoteUrl: string;
56
+ lastPush: string;
57
+ lastPull: string;
58
+ pendingChanges: number;
59
+ branch: string;
60
+ }
61
+
62
+ export function getSyncStatus(): SyncStatusInfo {
63
+ const enabled = resolveConfigValue("sync.enabled").value === "true";
64
+ const gitInitialized = existsSync(join(minkRoot(), ".git"));
65
+ const remoteUrl = resolveConfigValue("sync.remote-url").value;
66
+ const lastPush = resolveConfigValue("sync.last-push").value;
67
+ const lastPull = resolveConfigValue("sync.last-pull").value;
68
+
69
+ let pendingChanges = 0;
70
+ let branch = "";
71
+
72
+ if (gitInitialized) {
73
+ const status = gitSafe("status --porcelain");
74
+ if (status !== null) {
75
+ pendingChanges = status
76
+ .split("\n")
77
+ .filter((l) => l.trim().length > 0).length;
78
+ }
79
+ branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "";
80
+ }
81
+
82
+ return {
83
+ enabled,
84
+ gitInitialized,
85
+ remoteUrl,
86
+ lastPush,
87
+ lastPull,
88
+ pendingChanges,
89
+ branch,
90
+ };
91
+ }
92
+
93
+ export function initSync(remoteUrl: string): void {
94
+ const root = minkRoot();
95
+ const gitDir = join(root, ".git");
96
+
97
+ if (existsSync(gitDir)) {
98
+ console.log("[mink] sync is already initialized in " + root);
99
+ console.log("[mink] run 'mink sync disconnect' first to reinitialize");
100
+ return;
101
+ }
102
+
103
+ // Write .gitignore before any git operations
104
+ ensureGitignore();
105
+
106
+ // Initialize git repo
107
+ git("init");
108
+ git(`remote add origin ${remoteUrl}`);
109
+
110
+ // Try to fetch from remote
111
+ const fetchResult = gitSafe("fetch origin", FETCH_TIMEOUT);
112
+
113
+ if (fetchResult !== null) {
114
+ // Check if remote has any branches
115
+ const remoteBranches = gitSafe("branch -r");
116
+ if (remoteBranches && remoteBranches.trim().length > 0) {
117
+ // Remote has content — detect default branch and pull
118
+ const defaultBranch = detectRemoteDefaultBranch();
119
+ try {
120
+ git("add -A");
121
+ // Commit local content first so merge has a base
122
+ const status = gitSafe("status --porcelain");
123
+ if (status && status.trim().length > 0) {
124
+ git(`commit -m "mink: local state before sync"`);
125
+ }
126
+ git(`pull --rebase origin ${defaultBranch}`, FETCH_TIMEOUT);
127
+ } catch {
128
+ // Rebase failed — abort and warn
129
+ gitSafe("rebase --abort");
130
+ console.error(
131
+ "[mink] warning: could not merge remote content. Local state preserved."
132
+ );
133
+ console.error(
134
+ "[mink] you may need to resolve conflicts manually with 'mink sync pull'"
135
+ );
136
+ }
137
+ } else {
138
+ // Remote is empty — do initial push
139
+ git("add -A");
140
+ git(`commit -m "mink: initial sync"`);
141
+ git("branch -M main");
142
+ git("push -u origin main", PUSH_TIMEOUT);
143
+ }
144
+ } else {
145
+ // Fetch failed (network or empty repo) — commit locally and try push
146
+ git("add -A");
147
+ git(`commit -m "mink: initial sync"`);
148
+ git("branch -M main");
149
+ try {
150
+ git("push -u origin main", PUSH_TIMEOUT);
151
+ } catch {
152
+ console.error(
153
+ "[mink] push failed — local commit preserved, will retry on next sync"
154
+ );
155
+ }
156
+ }
157
+
158
+ // Save config
159
+ setConfigValue("sync.enabled", "true");
160
+ setConfigValue("sync.remote-url", remoteUrl);
161
+ setConfigValue("sync.last-push", new Date().toISOString());
162
+
163
+ console.log("[mink] sync initialized successfully");
164
+ console.log("[mink] remote: " + remoteUrl);
165
+ console.log("[mink] auto-sync: pull on session-start, push on session-stop");
166
+ console.log("[mink] manual sync: run 'mink sync' at any time");
167
+ }
168
+
169
+ export function syncPull(
170
+ onMessage: (msg: string) => void = (msg) => console.error(msg)
171
+ ): void {
172
+ if (!isSyncInitialized()) return;
173
+
174
+ const root = minkRoot();
175
+
176
+ try {
177
+ // Stash any uncommitted local changes as safety net
178
+ const status = gitSafe("status --porcelain");
179
+ const hasLocalChanges = status !== null && status.trim().length > 0;
180
+
181
+ if (hasLocalChanges) {
182
+ gitSafe("stash push -m mink-sync-pull");
183
+ }
184
+
185
+ // Determine branch
186
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
187
+
188
+ // Pull with rebase
189
+ try {
190
+ git(`pull --rebase origin ${branch}`, FETCH_TIMEOUT);
191
+ } catch (err) {
192
+ // Check if rebase is in progress and abort
193
+ if (existsSync(join(root, ".git", "rebase-merge")) ||
194
+ existsSync(join(root, ".git", "rebase-apply"))) {
195
+ gitSafe("rebase --abort");
196
+ onMessage(
197
+ "[mink] sync pull: rebase conflict detected — aborted rebase, local state preserved"
198
+ );
199
+ onMessage(
200
+ "[mink] resolve manually with 'mink sync pull' or 'cd ~/.mink && git pull --rebase origin main'"
201
+ );
202
+ } else {
203
+ onMessage(
204
+ `[mink] sync pull failed: ${err instanceof Error ? err.message : String(err)}`
205
+ );
206
+ }
207
+ }
208
+
209
+ // Pop stash if we stashed earlier
210
+ if (hasLocalChanges) {
211
+ try {
212
+ gitSafe("stash pop");
213
+ } catch {
214
+ onMessage(
215
+ "[mink] sync pull: stash pop had conflicts — your local changes are in git stash"
216
+ );
217
+ }
218
+ }
219
+
220
+ setConfigValue("sync.last-pull", new Date().toISOString());
221
+ } catch (err) {
222
+ onMessage(
223
+ `[mink] sync pull error: ${err instanceof Error ? err.message : String(err)}`
224
+ );
225
+ }
226
+ }
227
+
228
+ export function syncPush(
229
+ onMessage: (msg: string) => void = (msg) => console.error(msg)
230
+ ): void {
231
+ if (!isSyncInitialized()) return;
232
+
233
+ const root = minkRoot();
234
+
235
+ try {
236
+ // Check for changes
237
+ const status = gitSafe("status --porcelain");
238
+ if (!status || !status.trim()) {
239
+ // No local changes — still try to push any unpushed commits
240
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
241
+ try {
242
+ git(`push origin ${branch}`, PUSH_TIMEOUT);
243
+ setConfigValue("sync.last-push", new Date().toISOString());
244
+ } catch {
245
+ // No unpushed commits or network error — silent
246
+ }
247
+ return;
248
+ }
249
+
250
+ // Stage all changes (respects .gitignore)
251
+ git("add -A");
252
+
253
+ // Commit
254
+ const now = new Date();
255
+ const timestamp = now.toISOString().replace("T", " ").slice(0, 16);
256
+ git(`commit -m "mink: sync ${timestamp}"`);
257
+
258
+ // Determine branch
259
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
260
+
261
+ // Pull with rebase to reconcile any remote changes
262
+ try {
263
+ git(`pull --rebase origin ${branch}`, FETCH_TIMEOUT);
264
+ } catch {
265
+ // Check for rebase conflict
266
+ if (existsSync(join(root, ".git", "rebase-merge")) ||
267
+ existsSync(join(root, ".git", "rebase-apply"))) {
268
+ gitSafe("rebase --abort");
269
+ onMessage(
270
+ "[mink] sync: rebase conflict during push — local commit preserved, skipping push"
271
+ );
272
+ onMessage(
273
+ "[mink] resolve manually with 'mink sync pull' then 'mink sync push'"
274
+ );
275
+ return;
276
+ }
277
+ }
278
+
279
+ // Push (best-effort)
280
+ try {
281
+ git(`push origin ${branch}`, PUSH_TIMEOUT);
282
+ setConfigValue("sync.last-push", new Date().toISOString());
283
+ } catch {
284
+ onMessage(
285
+ "[mink] sync push failed — local commit preserved, will retry next session"
286
+ );
287
+ }
288
+ } catch (err) {
289
+ onMessage(
290
+ `[mink] sync push error: ${err instanceof Error ? err.message : String(err)}`
291
+ );
292
+ }
293
+ }
294
+
295
+ export function disconnectSync(): void {
296
+ const root = minkRoot();
297
+ const gitDir = join(root, ".git");
298
+
299
+ if (!existsSync(gitDir)) {
300
+ console.log("[mink] sync is not initialized — nothing to disconnect");
301
+ return;
302
+ }
303
+
304
+ // Remove .git directory
305
+ const { rmSync } = require("fs");
306
+ rmSync(gitDir, { recursive: true, force: true });
307
+
308
+ // Clear sync config keys
309
+ setConfigValue("sync.enabled", "false");
310
+ setConfigValue("sync.remote-url", "");
311
+ setConfigValue("sync.last-push", "");
312
+ setConfigValue("sync.last-pull", "");
313
+
314
+ console.log("[mink] sync disconnected — git tracking removed, data preserved");
315
+ }
316
+
317
+ // ── Internal Helpers ───────────────────────────────────────────────────────
318
+
319
+ function detectRemoteDefaultBranch(): string {
320
+ // Try common default branch names
321
+ const remoteBranches = gitSafe("branch -r") ?? "";
322
+ if (remoteBranches.includes("origin/main")) return "main";
323
+ if (remoteBranches.includes("origin/master")) return "master";
324
+
325
+ // Fall back to first remote branch
326
+ const first = remoteBranches
327
+ .split("\n")
328
+ .map((b) => b.trim())
329
+ .filter((b) => b.startsWith("origin/") && !b.includes("HEAD"))
330
+ .map((b) => b.replace("origin/", ""))[0];
331
+
332
+ return first ?? "main";
333
+ }
package/src/core/vault.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { join } from "path";
1
+ import { join, basename, resolve } from "path";
2
2
  import { homedir } from "os";
3
- import { existsSync, mkdirSync } from "fs";
3
+ import { existsSync, mkdirSync, symlinkSync, unlinkSync, lstatSync, readlinkSync } from "fs";
4
4
  import { resolveConfigValue } from "./global-config";
5
5
  import { safeReadJson } from "./fs-utils";
6
- import type { VaultManifest } from "../types/note";
6
+ import { atomicWriteJson } from "./fs-utils";
7
+ import type { VaultManifest, VaultLink } from "../types/note";
7
8
 
8
9
  const DEFAULT_VAULT_PATH = join(homedir(), ".mink", "wiki");
9
10
 
@@ -130,3 +131,81 @@ export function categoryToDir(
130
131
  return join(root, "inbox");
131
132
  }
132
133
  }
134
+
135
+ // ── Symlink management ────────────────────────────────────────────────────
136
+
137
+ function saveManifest(manifest: VaultManifest): void {
138
+ atomicWriteJson(vaultManifestPath(), manifest);
139
+ }
140
+
141
+ export function linkExternal(targetPath: string, name?: string): { ok: true; linkName: string; linkPath: string } | { ok: false; error: string } {
142
+ const root = resolveVaultPath();
143
+ const absTarget = targetPath.startsWith("~/")
144
+ ? join(homedir(), targetPath.slice(2))
145
+ : resolve(targetPath);
146
+
147
+ if (!existsSync(absTarget)) {
148
+ return { ok: false, error: `target does not exist: ${absTarget}` };
149
+ }
150
+
151
+ if (!lstatSync(absTarget).isDirectory()) {
152
+ return { ok: false, error: `target is not a directory: ${absTarget}` };
153
+ }
154
+
155
+ const linkName = name ?? basename(absTarget);
156
+ const linkPath = join(root, linkName);
157
+
158
+ // Don't overwrite existing vault directories
159
+ if (existsSync(linkPath)) {
160
+ if (lstatSync(linkPath).isSymbolicLink()) {
161
+ const existing = readlinkSync(linkPath);
162
+ if (existing === absTarget) {
163
+ return { ok: false, error: `already linked: ${linkName} -> ${absTarget}` };
164
+ }
165
+ return { ok: false, error: `a different link already exists at ${linkName} -> ${existing}` };
166
+ }
167
+ return { ok: false, error: `${linkName} already exists in the vault and is not a symlink` };
168
+ }
169
+
170
+ symlinkSync(absTarget, linkPath, "dir");
171
+
172
+ // Record in manifest
173
+ const manifest = loadVaultManifest();
174
+ if (manifest) {
175
+ const links = manifest.links ?? [];
176
+ links.push({ name: linkName, target: absTarget, linkedAt: new Date().toISOString() });
177
+ manifest.links = links;
178
+ saveManifest(manifest);
179
+ }
180
+
181
+ return { ok: true, linkName, linkPath };
182
+ }
183
+
184
+ export function unlinkExternal(name: string): { ok: true } | { ok: false; error: string } {
185
+ const root = resolveVaultPath();
186
+ const linkPath = join(root, name);
187
+
188
+ if (!existsSync(linkPath)) {
189
+ return { ok: false, error: `no link named "${name}" in the vault` };
190
+ }
191
+
192
+ if (!lstatSync(linkPath).isSymbolicLink()) {
193
+ return { ok: false, error: `"${name}" is not a symlink — refusing to remove` };
194
+ }
195
+
196
+ unlinkSync(linkPath);
197
+
198
+ // Remove from manifest
199
+ const manifest = loadVaultManifest();
200
+ if (manifest && manifest.links) {
201
+ manifest.links = manifest.links.filter(l => l.name !== name);
202
+ saveManifest(manifest);
203
+ }
204
+
205
+ return { ok: true };
206
+ }
207
+
208
+ export function listLinks(): VaultLink[] {
209
+ const manifest = loadVaultManifest();
210
+ return manifest?.links ?? [];
211
+ }
@@ -5,6 +5,10 @@ export interface GlobalConfig {
5
5
  "wiki.git-backup"?: string;
6
6
  "wiki.git-remote"?: string;
7
7
  "notes.default-category"?: string;
8
+ "sync.enabled"?: string;
9
+ "sync.remote-url"?: string;
10
+ "sync.last-push"?: string;
11
+ "sync.last-pull"?: string;
8
12
  }
9
13
 
10
14
  export type ConfigKey = keyof GlobalConfig & string;
@@ -39,13 +43,13 @@ export const CONFIG_KEYS: ConfigKeyMeta[] = [
39
43
  key: "wiki.git-backup",
40
44
  default: "false",
41
45
  envVar: "MINK_WIKI_GIT_BACKUP",
42
- description: "Enable/disable auto-commit and push",
46
+ description: "Deprecated: use sync.enabled instead",
43
47
  },
44
48
  {
45
49
  key: "wiki.git-remote",
46
50
  default: "origin",
47
51
  envVar: "MINK_WIKI_GIT_REMOTE",
48
- description: "Git remote name for push",
52
+ description: "Deprecated: use sync.remote-url instead",
49
53
  },
50
54
  {
51
55
  key: "notes.default-category",
@@ -53,6 +57,30 @@ export const CONFIG_KEYS: ConfigKeyMeta[] = [
53
57
  envVar: "MINK_NOTES_DEFAULT_CATEGORY",
54
58
  description: "Default category for notes captured via CLI",
55
59
  },
60
+ {
61
+ key: "sync.enabled",
62
+ default: "false",
63
+ envVar: "MINK_SYNC_ENABLED",
64
+ description: "Enable/disable automatic git sync of ~/.mink",
65
+ },
66
+ {
67
+ key: "sync.remote-url",
68
+ default: "",
69
+ envVar: "MINK_SYNC_REMOTE_URL",
70
+ description: "Git remote URL for ~/.mink sync",
71
+ },
72
+ {
73
+ key: "sync.last-push",
74
+ default: "",
75
+ envVar: "MINK_SYNC_LAST_PUSH",
76
+ description: "ISO timestamp of last successful sync push",
77
+ },
78
+ {
79
+ key: "sync.last-pull",
80
+ default: "",
81
+ envVar: "MINK_SYNC_LAST_PULL",
82
+ description: "ISO timestamp of last successful sync pull",
83
+ },
56
84
  ];
57
85
 
58
86
  const VALID_KEYS = new Set<string>(CONFIG_KEYS.map((k) => k.key));
package/src/types/note.ts CHANGED
@@ -35,12 +35,19 @@ export interface NoteFrontmatter {
35
35
  [key: string]: unknown;
36
36
  }
37
37
 
38
+ export interface VaultLink {
39
+ name: string;
40
+ target: string;
41
+ linkedAt: string;
42
+ }
43
+
38
44
  export interface VaultManifest {
39
45
  version: number;
40
46
  createdAt: string;
41
47
  totalNotes: number;
42
48
  categories: Record<NoteCategory, number>;
43
49
  lastOrganized: string;
50
+ links?: VaultLink[];
44
51
  }
45
52
 
46
53
  export interface VaultIndexEntry {