@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.
- package/README.md +80 -11
- package/dist/cli.js +1455 -703
- package/package.json +1 -1
- package/src/cli.ts +25 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/device.ts +65 -0
- package/src/commands/session-start.ts +26 -0
- package/src/commands/session-stop.ts +4 -59
- package/src/commands/sync.ts +111 -0
- package/src/commands/wiki.ts +106 -1
- package/src/core/device.ts +72 -0
- package/src/core/global-config.ts +72 -11
- package/src/core/paths.ts +12 -0
- package/src/core/sync.ts +345 -0
- package/src/core/vault.ts +82 -3
- package/src/types/config.ts +55 -2
- package/src/types/note.ts +7 -0
|
@@ -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
|
-
|
|
12
|
-
const raw = safeReadJson(
|
|
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 " +
|
|
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 =
|
|
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
|
|
88
|
+
const meta = getConfigKeyMeta(key);
|
|
89
|
+
const config = loadConfigForScope(meta.scope);
|
|
62
90
|
config[key] = value;
|
|
63
|
-
|
|
91
|
+
saveConfigForScope(meta.scope, config);
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
export function resetConfigKey(key: ConfigKey): void {
|
|
67
|
-
const
|
|
95
|
+
const meta = getConfigKeyMeta(key);
|
|
96
|
+
const config = loadConfigForScope(meta.scope);
|
|
68
97
|
delete config[key];
|
|
69
|
-
|
|
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
|
}
|
package/src/core/sync.ts
ADDED
|
@@ -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
|
|
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
|
+
}
|