@drewpayment/mink 0.2.2 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { globalConfigPath } from "./paths";
1
+ import { globalConfigPath, localConfigPath } from "./paths";
2
2
  import { safeReadJson, atomicWriteJson } from "./fs-utils";
3
3
  import {
4
4
  CONFIG_KEYS,
@@ -6,31 +6,57 @@ import {
6
6
  getConfigKeyMeta,
7
7
  type GlobalConfig,
8
8
  type ConfigKey,
9
+ type ConfigScope,
9
10
  } from "../types/config";
10
11
 
11
- export function loadGlobalConfig(): GlobalConfig {
12
- const raw = safeReadJson(globalConfigPath());
12
+ function loadConfigFile(path: string): GlobalConfig {
13
+ const raw = safeReadJson(path);
13
14
  if (raw === null) return {};
14
15
  if (typeof raw !== "object" || Array.isArray(raw)) {
15
- console.warn("[mink] warning: corrupt config file at " + globalConfigPath());
16
+ console.warn("[mink] warning: corrupt config file at " + path);
16
17
  return {};
17
18
  }
18
19
  return raw as GlobalConfig;
19
20
  }
20
21
 
22
+ export function loadGlobalConfig(): GlobalConfig {
23
+ return loadConfigFile(globalConfigPath());
24
+ }
25
+
21
26
  export function saveGlobalConfig(config: GlobalConfig): void {
22
27
  atomicWriteJson(globalConfigPath(), config);
23
28
  }
24
29
 
30
+ export function loadLocalConfig(): GlobalConfig {
31
+ return loadConfigFile(localConfigPath());
32
+ }
33
+
34
+ export function saveLocalConfig(config: GlobalConfig): void {
35
+ atomicWriteJson(localConfigPath(), config);
36
+ }
37
+
38
+ function loadConfigForScope(scope: ConfigScope): GlobalConfig {
39
+ return scope === "local" ? loadLocalConfig() : loadGlobalConfig();
40
+ }
41
+
42
+ function saveConfigForScope(scope: ConfigScope, config: GlobalConfig): void {
43
+ if (scope === "local") {
44
+ saveLocalConfig(config);
45
+ } else {
46
+ saveGlobalConfig(config);
47
+ }
48
+ }
49
+
25
50
  export interface ResolvedValue {
26
51
  value: string;
27
52
  source: "default" | "config file" | "environment variable";
53
+ scope: ConfigScope;
28
54
  configFileValue?: string;
29
55
  }
30
56
 
31
57
  export function resolveConfigValue(key: ConfigKey): ResolvedValue {
32
58
  const meta = getConfigKeyMeta(key);
33
- const config = loadGlobalConfig();
59
+ const config = loadConfigForScope(meta.scope);
34
60
 
35
61
  const envValue = process.env[meta.envVar];
36
62
  const fileValue = config[key];
@@ -39,15 +65,16 @@ export function resolveConfigValue(key: ConfigKey): ResolvedValue {
39
65
  return {
40
66
  value: envValue,
41
67
  source: "environment variable",
68
+ scope: meta.scope,
42
69
  configFileValue: fileValue,
43
70
  };
44
71
  }
45
72
 
46
73
  if (fileValue !== undefined) {
47
- return { value: fileValue, source: "config file" };
74
+ return { value: fileValue, source: "config file", scope: meta.scope };
48
75
  }
49
76
 
50
- return { value: meta.default, source: "default" };
77
+ return { value: meta.default, source: "default", scope: meta.scope };
51
78
  }
52
79
 
53
80
  export function resolveAllConfig(): Array<ResolvedValue & { key: ConfigKey }> {
@@ -58,17 +85,51 @@ export function resolveAllConfig(): Array<ResolvedValue & { key: ConfigKey }> {
58
85
  }
59
86
 
60
87
  export function setConfigValue(key: ConfigKey, value: string): void {
61
- const config = loadGlobalConfig();
88
+ const meta = getConfigKeyMeta(key);
89
+ const config = loadConfigForScope(meta.scope);
62
90
  config[key] = value;
63
- saveGlobalConfig(config);
91
+ saveConfigForScope(meta.scope, config);
64
92
  }
65
93
 
66
94
  export function resetConfigKey(key: ConfigKey): void {
67
- const config = loadGlobalConfig();
95
+ const meta = getConfigKeyMeta(key);
96
+ const config = loadConfigForScope(meta.scope);
68
97
  delete config[key];
69
- saveGlobalConfig(config);
98
+ saveConfigForScope(meta.scope, config);
70
99
  }
71
100
 
72
101
  export function resetAllConfig(): void {
73
102
  saveGlobalConfig({});
103
+ saveLocalConfig({});
104
+ }
105
+
106
+ // ── Migration ─────────────────────────────────────────────────────────────
107
+
108
+ let migrationRan = false;
109
+
110
+ export function migrateConfigIfNeeded(): void {
111
+ if (migrationRan) return;
112
+ migrationRan = true;
113
+
114
+ const { existsSync } = require("fs");
115
+ if (existsSync(localConfigPath())) return;
116
+
117
+ const shared = loadGlobalConfig();
118
+ const localKeys = CONFIG_KEYS.filter((k) => k.scope === "local");
119
+ const localConfig: GlobalConfig = {};
120
+ let hasLocal = false;
121
+
122
+ for (const meta of localKeys) {
123
+ const val = shared[meta.key];
124
+ if (val !== undefined) {
125
+ localConfig[meta.key] = val;
126
+ delete shared[meta.key];
127
+ hasLocal = true;
128
+ }
129
+ }
130
+
131
+ if (hasLocal) {
132
+ saveLocalConfig(localConfig);
133
+ saveGlobalConfig(shared);
134
+ }
74
135
  }
package/src/core/paths.ts CHANGED
@@ -61,6 +61,18 @@ export function globalConfigPath(): string {
61
61
  return join(MINK_ROOT, "config");
62
62
  }
63
63
 
64
+ export function localConfigPath(): string {
65
+ return join(MINK_ROOT, "config.local");
66
+ }
67
+
68
+ export function deviceIdPath(): string {
69
+ return join(MINK_ROOT, "device-id");
70
+ }
71
+
72
+ export function deviceRegistryPath(): string {
73
+ return join(MINK_ROOT, "devices.json");
74
+ }
75
+
64
76
  export function projectMetaPath(cwd: string): string {
65
77
  return join(projectDir(cwd), "project-meta.json");
66
78
  }
@@ -0,0 +1,345 @@
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
+ import { updateDeviceHeartbeat } from "./device";
7
+
8
+ // ── Constants ──────────────────────────────────────────────────────────────
9
+
10
+ const GIT_TIMEOUT = 5_000;
11
+ const PUSH_TIMEOUT = 10_000;
12
+ const FETCH_TIMEOUT = 15_000;
13
+
14
+ const GITIGNORE_CONTENTS = `# Runtime state — machine-specific
15
+ scheduler.pid
16
+ scheduler.log
17
+
18
+ # Device identity and local config — machine-specific
19
+ device-id
20
+ config.local
21
+
22
+ # Local backups — machine-specific snapshots
23
+ projects/*/backups/
24
+ `;
25
+
26
+ // ── Helpers ────────────────────────────────────────────────────────────────
27
+
28
+ function git(args: string, timeoutMs: number = GIT_TIMEOUT): string {
29
+ return execSync(`git ${args}`, {
30
+ cwd: minkRoot(),
31
+ timeout: timeoutMs,
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ }).toString().trim();
34
+ }
35
+
36
+ function gitSafe(args: string, timeoutMs: number = GIT_TIMEOUT): string | null {
37
+ try {
38
+ return git(args, timeoutMs);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ // ── Public API ─────────────────────────────────────────────────────────────
45
+
46
+ export function isSyncInitialized(): boolean {
47
+ const enabled = resolveConfigValue("sync.enabled").value;
48
+ if (enabled !== "true") return false;
49
+ return existsSync(join(minkRoot(), ".git"));
50
+ }
51
+
52
+ export function ensureGitignore(): void {
53
+ const gitignorePath = join(minkRoot(), ".gitignore");
54
+ writeFileSync(gitignorePath, GITIGNORE_CONTENTS);
55
+ }
56
+
57
+ export interface SyncStatusInfo {
58
+ enabled: boolean;
59
+ gitInitialized: boolean;
60
+ remoteUrl: string;
61
+ lastPush: string;
62
+ lastPull: string;
63
+ pendingChanges: number;
64
+ branch: string;
65
+ }
66
+
67
+ export function getSyncStatus(): SyncStatusInfo {
68
+ const enabled = resolveConfigValue("sync.enabled").value === "true";
69
+ const gitInitialized = existsSync(join(minkRoot(), ".git"));
70
+ const remoteUrl = resolveConfigValue("sync.remote-url").value;
71
+ const lastPush = resolveConfigValue("sync.last-push").value;
72
+ const lastPull = resolveConfigValue("sync.last-pull").value;
73
+
74
+ let pendingChanges = 0;
75
+ let branch = "";
76
+
77
+ if (gitInitialized) {
78
+ const status = gitSafe("status --porcelain");
79
+ if (status !== null) {
80
+ pendingChanges = status
81
+ .split("\n")
82
+ .filter((l) => l.trim().length > 0).length;
83
+ }
84
+ branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "";
85
+ }
86
+
87
+ return {
88
+ enabled,
89
+ gitInitialized,
90
+ remoteUrl,
91
+ lastPush,
92
+ lastPull,
93
+ pendingChanges,
94
+ branch,
95
+ };
96
+ }
97
+
98
+ export function initSync(remoteUrl: string): void {
99
+ const root = minkRoot();
100
+ const gitDir = join(root, ".git");
101
+
102
+ if (existsSync(gitDir)) {
103
+ console.log("[mink] sync is already initialized in " + root);
104
+ console.log("[mink] run 'mink sync disconnect' first to reinitialize");
105
+ return;
106
+ }
107
+
108
+ // Write .gitignore before any git operations
109
+ ensureGitignore();
110
+
111
+ // Initialize git repo
112
+ git("init");
113
+ git(`remote add origin ${remoteUrl}`);
114
+
115
+ // Try to fetch from remote
116
+ const fetchResult = gitSafe("fetch origin", FETCH_TIMEOUT);
117
+
118
+ if (fetchResult !== null) {
119
+ // Check if remote has any branches
120
+ const remoteBranches = gitSafe("branch -r");
121
+ if (remoteBranches && remoteBranches.trim().length > 0) {
122
+ // Remote has content — detect default branch and pull
123
+ const defaultBranch = detectRemoteDefaultBranch();
124
+ try {
125
+ git("add -A");
126
+ // Commit local content first so merge has a base
127
+ const status = gitSafe("status --porcelain");
128
+ if (status && status.trim().length > 0) {
129
+ git(`commit -m "mink: local state before sync"`);
130
+ }
131
+ git(`pull --rebase origin ${defaultBranch}`, FETCH_TIMEOUT);
132
+ } catch {
133
+ // Rebase failed — abort and warn
134
+ gitSafe("rebase --abort");
135
+ console.error(
136
+ "[mink] warning: could not merge remote content. Local state preserved."
137
+ );
138
+ console.error(
139
+ "[mink] you may need to resolve conflicts manually with 'mink sync pull'"
140
+ );
141
+ }
142
+ } else {
143
+ // Remote is empty — do initial push
144
+ git("add -A");
145
+ git(`commit -m "mink: initial sync"`);
146
+ git("branch -M main");
147
+ git("push -u origin main", PUSH_TIMEOUT);
148
+ }
149
+ } else {
150
+ // Fetch failed (network or empty repo) — commit locally and try push
151
+ git("add -A");
152
+ git(`commit -m "mink: initial sync"`);
153
+ git("branch -M main");
154
+ try {
155
+ git("push -u origin main", PUSH_TIMEOUT);
156
+ } catch {
157
+ console.error(
158
+ "[mink] push failed — local commit preserved, will retry on next sync"
159
+ );
160
+ }
161
+ }
162
+
163
+ // Save config
164
+ setConfigValue("sync.enabled", "true");
165
+ setConfigValue("sync.remote-url", remoteUrl);
166
+ setConfigValue("sync.last-push", new Date().toISOString());
167
+
168
+ console.log("[mink] sync initialized successfully");
169
+ console.log("[mink] remote: " + remoteUrl);
170
+ console.log("[mink] auto-sync: pull on session-start, push on session-stop");
171
+ console.log("[mink] manual sync: run 'mink sync' at any time");
172
+ }
173
+
174
+ export function syncPull(
175
+ onMessage: (msg: string) => void = (msg) => console.error(msg)
176
+ ): void {
177
+ if (!isSyncInitialized()) return;
178
+
179
+ ensureGitignore();
180
+
181
+ const root = minkRoot();
182
+
183
+ try {
184
+ // Stash any uncommitted local changes as safety net
185
+ const status = gitSafe("status --porcelain");
186
+ const hasLocalChanges = status !== null && status.trim().length > 0;
187
+
188
+ if (hasLocalChanges) {
189
+ gitSafe("stash push -m mink-sync-pull");
190
+ }
191
+
192
+ // Determine branch
193
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
194
+
195
+ // Pull with rebase
196
+ try {
197
+ git(`pull --rebase origin ${branch}`, FETCH_TIMEOUT);
198
+ } catch (err) {
199
+ // Check if rebase is in progress and abort
200
+ if (existsSync(join(root, ".git", "rebase-merge")) ||
201
+ existsSync(join(root, ".git", "rebase-apply"))) {
202
+ gitSafe("rebase --abort");
203
+ onMessage(
204
+ "[mink] sync pull: rebase conflict detected — aborted rebase, local state preserved"
205
+ );
206
+ onMessage(
207
+ "[mink] resolve manually with 'mink sync pull' or 'cd ~/.mink && git pull --rebase origin main'"
208
+ );
209
+ } else {
210
+ onMessage(
211
+ `[mink] sync pull failed: ${err instanceof Error ? err.message : String(err)}`
212
+ );
213
+ }
214
+ }
215
+
216
+ // Pop stash if we stashed earlier
217
+ if (hasLocalChanges) {
218
+ try {
219
+ gitSafe("stash pop");
220
+ } catch {
221
+ onMessage(
222
+ "[mink] sync pull: stash pop had conflicts — your local changes are in git stash"
223
+ );
224
+ }
225
+ }
226
+
227
+ setConfigValue("sync.last-pull", new Date().toISOString());
228
+
229
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
230
+ } catch (err) {
231
+ onMessage(
232
+ `[mink] sync pull error: ${err instanceof Error ? err.message : String(err)}`
233
+ );
234
+ }
235
+ }
236
+
237
+ export function syncPush(
238
+ onMessage: (msg: string) => void = (msg) => console.error(msg)
239
+ ): void {
240
+ if (!isSyncInitialized()) return;
241
+
242
+ ensureGitignore();
243
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
244
+
245
+ const root = minkRoot();
246
+
247
+ try {
248
+ // Check for changes
249
+ const status = gitSafe("status --porcelain");
250
+ if (!status || !status.trim()) {
251
+ // No local changes — still try to push any unpushed commits
252
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
253
+ try {
254
+ git(`push origin ${branch}`, PUSH_TIMEOUT);
255
+ setConfigValue("sync.last-push", new Date().toISOString());
256
+ } catch {
257
+ // No unpushed commits or network error — silent
258
+ }
259
+ return;
260
+ }
261
+
262
+ // Stage all changes (respects .gitignore)
263
+ git("add -A");
264
+
265
+ // Commit
266
+ const now = new Date();
267
+ const timestamp = now.toISOString().replace("T", " ").slice(0, 16);
268
+ git(`commit -m "mink: sync ${timestamp}"`);
269
+
270
+ // Determine branch
271
+ const branch = gitSafe("rev-parse --abbrev-ref HEAD") ?? "main";
272
+
273
+ // Pull with rebase to reconcile any remote changes
274
+ try {
275
+ git(`pull --rebase origin ${branch}`, FETCH_TIMEOUT);
276
+ } catch {
277
+ // Check for rebase conflict
278
+ if (existsSync(join(root, ".git", "rebase-merge")) ||
279
+ existsSync(join(root, ".git", "rebase-apply"))) {
280
+ gitSafe("rebase --abort");
281
+ onMessage(
282
+ "[mink] sync: rebase conflict during push — local commit preserved, skipping push"
283
+ );
284
+ onMessage(
285
+ "[mink] resolve manually with 'mink sync pull' then 'mink sync push'"
286
+ );
287
+ return;
288
+ }
289
+ }
290
+
291
+ // Push (best-effort)
292
+ try {
293
+ git(`push origin ${branch}`, PUSH_TIMEOUT);
294
+ setConfigValue("sync.last-push", new Date().toISOString());
295
+ } catch {
296
+ onMessage(
297
+ "[mink] sync push failed — local commit preserved, will retry next session"
298
+ );
299
+ }
300
+ } catch (err) {
301
+ onMessage(
302
+ `[mink] sync push error: ${err instanceof Error ? err.message : String(err)}`
303
+ );
304
+ }
305
+ }
306
+
307
+ export function disconnectSync(): void {
308
+ const root = minkRoot();
309
+ const gitDir = join(root, ".git");
310
+
311
+ if (!existsSync(gitDir)) {
312
+ console.log("[mink] sync is not initialized — nothing to disconnect");
313
+ return;
314
+ }
315
+
316
+ // Remove .git directory
317
+ const { rmSync } = require("fs");
318
+ rmSync(gitDir, { recursive: true, force: true });
319
+
320
+ // Clear sync config keys
321
+ setConfigValue("sync.enabled", "false");
322
+ setConfigValue("sync.remote-url", "");
323
+ setConfigValue("sync.last-push", "");
324
+ setConfigValue("sync.last-pull", "");
325
+
326
+ console.log("[mink] sync disconnected — git tracking removed, data preserved");
327
+ }
328
+
329
+ // ── Internal Helpers ───────────────────────────────────────────────────────
330
+
331
+ function detectRemoteDefaultBranch(): string {
332
+ // Try common default branch names
333
+ const remoteBranches = gitSafe("branch -r") ?? "";
334
+ if (remoteBranches.includes("origin/main")) return "main";
335
+ if (remoteBranches.includes("origin/master")) return "master";
336
+
337
+ // Fall back to first remote branch
338
+ const first = remoteBranches
339
+ .split("\n")
340
+ .map((b) => b.trim())
341
+ .filter((b) => b.startsWith("origin/") && !b.includes("HEAD"))
342
+ .map((b) => b.replace("origin/", ""))[0];
343
+
344
+ return first ?? "main";
345
+ }
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
+ }