@drewpayment/mink 0.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drewpayment/mink",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A hidden presence that moves alongside the developer — token efficiency and cross-project wiki for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -143,6 +143,12 @@ switch (command) {
143
143
  break;
144
144
  }
145
145
 
146
+ case "device": {
147
+ const { device } = await import("./commands/device");
148
+ device(process.argv.slice(3));
149
+ break;
150
+ }
151
+
146
152
  case "bug-search": {
147
153
  const { bugSearch } = await import("./commands/bug-search");
148
154
  bugSearch(cwd, process.argv.slice(3).join(" "));
@@ -202,7 +208,10 @@ switch (command) {
202
208
  console.log(" note search <term> Full-text search across the vault");
203
209
  console.log(" skill install Install /mink:note skill for Claude Code");
204
210
  console.log();
205
- console.log("Sync:");
211
+ console.log("Devices & Sync:");
212
+ console.log(" device Show current device info");
213
+ console.log(" device list List all registered devices");
214
+ console.log(" device rename <name> Set a friendly name for this device");
206
215
  console.log(" sync Full manual sync (pull then push)");
207
216
  console.log(" sync init <remote-url> Connect ~/.mink to a git remote for cross-device sync");
208
217
  console.log(" sync status Show sync state (remote, last sync, pending changes)");
@@ -69,7 +69,7 @@ export async function config(args: string[]): Promise<void> {
69
69
  const all = resolveAllConfig();
70
70
  console.log("[mink] configuration:");
71
71
  for (const entry of all) {
72
- let line = ` ${entry.key} = ${entry.value} (source: ${entry.source})`;
72
+ let line = ` ${entry.key} = ${entry.value} (${entry.scope}, source: ${entry.source})`;
73
73
  if (
74
74
  entry.source === "environment variable" &&
75
75
  entry.configFileValue !== undefined
@@ -0,0 +1,65 @@
1
+ import {
2
+ getOrCreateDeviceId,
3
+ listDevices,
4
+ setDeviceName,
5
+ } from "../core/device";
6
+ import { hostname, platform } from "os";
7
+
8
+ export function device(args: string[]): void {
9
+ const sub = args[0] ?? "status";
10
+
11
+ switch (sub) {
12
+ case "status": {
13
+ const id = getOrCreateDeviceId();
14
+ const devices = listDevices();
15
+ const current = devices.find((d) => d.id === id);
16
+ console.log("[mink] device info:");
17
+ console.log(` id: ${id}`);
18
+ console.log(` name: ${current?.name ?? hostname()}`);
19
+ console.log(` hostname: ${hostname()}`);
20
+ console.log(` platform: ${platform()}`);
21
+ if (current?.firstSeen) {
22
+ console.log(` first seen: ${current.firstSeen}`);
23
+ }
24
+ if (current?.lastSeen) {
25
+ console.log(` last seen: ${current.lastSeen}`);
26
+ }
27
+ break;
28
+ }
29
+
30
+ case "list": {
31
+ const devices = listDevices();
32
+ const currentId = getOrCreateDeviceId();
33
+ if (devices.length === 0) {
34
+ console.log("[mink] no devices registered yet");
35
+ return;
36
+ }
37
+ console.log("[mink] registered devices:");
38
+ for (const d of devices) {
39
+ const marker = d.id === currentId ? " (this device)" : "";
40
+ console.log(` ${d.name}${marker}`);
41
+ console.log(` id: ${d.id}`);
42
+ console.log(` hostname: ${d.hostname}`);
43
+ console.log(` platform: ${d.platform}`);
44
+ console.log(` last seen: ${d.lastSeen}`);
45
+ }
46
+ break;
47
+ }
48
+
49
+ case "rename": {
50
+ const name = args.slice(1).join(" ");
51
+ if (!name) {
52
+ console.error("Usage: mink device rename <name>");
53
+ process.exit(1);
54
+ }
55
+ setDeviceName(name);
56
+ console.log(`[mink] device renamed to "${name}"`);
57
+ break;
58
+ }
59
+
60
+ default:
61
+ console.error(`[mink] unknown device subcommand: ${sub}`);
62
+ console.error("Usage: mink device [status|list|rename <name>]");
63
+ process.exit(1);
64
+ }
65
+ }
@@ -7,6 +7,22 @@ import { isWikiEnabled, isVaultInitialized, isInsideVault } from "../core/vault"
7
7
  import { loadVaultIndex } from "../core/note-index";
8
8
 
9
9
  export function sessionStart(cwd: string): void {
10
+ // Migrate config to shared/local split if needed (before sync pull)
11
+ try {
12
+ const { migrateConfigIfNeeded } = require("../core/global-config");
13
+ migrateConfigIfNeeded();
14
+ } catch {
15
+ // Never crash hooks
16
+ }
17
+
18
+ // Register/update this device in the registry
19
+ try {
20
+ const { updateDeviceHeartbeat } = require("../core/device");
21
+ updateDeviceHeartbeat();
22
+ } catch {
23
+ // Never crash hooks
24
+ }
25
+
10
26
  // Sync pull before session begins (if enabled)
11
27
  try {
12
28
  const { isSyncInitialized, syncPull } = require("../core/sync");
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { hostname, platform } from "os";
4
+ import { randomUUID } from "crypto";
5
+ import { deviceIdPath, deviceRegistryPath } from "./paths";
6
+ import { safeReadJson, atomicWriteJson } from "./fs-utils";
7
+ import type { DeviceInfo, DeviceRegistry } from "../types/config";
8
+
9
+ export function getOrCreateDeviceId(): string {
10
+ const idPath = deviceIdPath();
11
+ if (existsSync(idPath)) {
12
+ return readFileSync(idPath, "utf-8").trim();
13
+ }
14
+ const id = randomUUID();
15
+ mkdirSync(dirname(idPath), { recursive: true });
16
+ writeFileSync(idPath, id + "\n");
17
+ return id;
18
+ }
19
+
20
+ export function loadDeviceRegistry(): DeviceRegistry {
21
+ const raw = safeReadJson(deviceRegistryPath());
22
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && "devices" in (raw as object)) {
23
+ return raw as DeviceRegistry;
24
+ }
25
+ return { devices: {} };
26
+ }
27
+
28
+ export function saveDeviceRegistry(registry: DeviceRegistry): void {
29
+ atomicWriteJson(deviceRegistryPath(), registry);
30
+ }
31
+
32
+ export function updateDeviceHeartbeat(): void {
33
+ const id = getOrCreateDeviceId();
34
+ const registry = loadDeviceRegistry();
35
+ const now = new Date().toISOString();
36
+ const existing = registry.devices[id];
37
+
38
+ registry.devices[id] = {
39
+ name: existing?.name ?? hostname(),
40
+ hostname: hostname(),
41
+ platform: platform(),
42
+ firstSeen: existing?.firstSeen ?? now,
43
+ lastSeen: now,
44
+ };
45
+
46
+ saveDeviceRegistry(registry);
47
+ }
48
+
49
+ export function listDevices(): Array<DeviceInfo & { id: string }> {
50
+ const registry = loadDeviceRegistry();
51
+ return Object.entries(registry.devices).map(([id, info]) => ({
52
+ id,
53
+ ...info,
54
+ }));
55
+ }
56
+
57
+ export function setDeviceName(name: string): void {
58
+ const id = getOrCreateDeviceId();
59
+ const registry = loadDeviceRegistry();
60
+ const now = new Date().toISOString();
61
+ const existing = registry.devices[id];
62
+
63
+ registry.devices[id] = {
64
+ name,
65
+ hostname: hostname(),
66
+ platform: platform(),
67
+ firstSeen: existing?.firstSeen ?? now,
68
+ lastSeen: now,
69
+ };
70
+
71
+ saveDeviceRegistry(registry);
72
+ }
@@ -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
  }
package/src/core/sync.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "path";
3
3
  import { execSync } from "child_process";
4
4
  import { minkRoot } from "./paths";
5
5
  import { resolveConfigValue, setConfigValue } from "./global-config";
6
+ import { updateDeviceHeartbeat } from "./device";
6
7
 
7
8
  // ── Constants ──────────────────────────────────────────────────────────────
8
9
 
@@ -14,6 +15,10 @@ const GITIGNORE_CONTENTS = `# Runtime state — machine-specific
14
15
  scheduler.pid
15
16
  scheduler.log
16
17
 
18
+ # Device identity and local config — machine-specific
19
+ device-id
20
+ config.local
21
+
17
22
  # Local backups — machine-specific snapshots
18
23
  projects/*/backups/
19
24
  `;
@@ -171,6 +176,8 @@ export function syncPull(
171
176
  ): void {
172
177
  if (!isSyncInitialized()) return;
173
178
 
179
+ ensureGitignore();
180
+
174
181
  const root = minkRoot();
175
182
 
176
183
  try {
@@ -218,6 +225,8 @@ export function syncPull(
218
225
  }
219
226
 
220
227
  setConfigValue("sync.last-pull", new Date().toISOString());
228
+
229
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
221
230
  } catch (err) {
222
231
  onMessage(
223
232
  `[mink] sync pull error: ${err instanceof Error ? err.message : String(err)}`
@@ -230,6 +239,9 @@ export function syncPush(
230
239
  ): void {
231
240
  if (!isSyncInitialized()) return;
232
241
 
242
+ ensureGitignore();
243
+ try { updateDeviceHeartbeat(); } catch { /* never crash hooks */ }
244
+
233
245
  const root = minkRoot();
234
246
 
235
247
  try {
@@ -13,11 +13,26 @@ export interface GlobalConfig {
13
13
 
14
14
  export type ConfigKey = keyof GlobalConfig & string;
15
15
 
16
+ export type ConfigScope = "shared" | "local";
17
+
16
18
  export interface ConfigKeyMeta {
17
19
  key: ConfigKey;
18
20
  default: string;
19
21
  envVar: string;
20
22
  description: string;
23
+ scope: ConfigScope;
24
+ }
25
+
26
+ export interface DeviceInfo {
27
+ name: string;
28
+ hostname: string;
29
+ platform: string;
30
+ firstSeen: string;
31
+ lastSeen: string;
32
+ }
33
+
34
+ export interface DeviceRegistry {
35
+ devices: Record<string, DeviceInfo>;
21
36
  }
22
37
 
23
38
  export const CONFIG_KEYS: ConfigKeyMeta[] = [
@@ -26,60 +41,70 @@ export const CONFIG_KEYS: ConfigKeyMeta[] = [
26
41
  default: "~/.mink/wiki/",
27
42
  envVar: "MINK_WIKI_PATH",
28
43
  description: "Wiki vault location",
44
+ scope: "local",
29
45
  },
30
46
  {
31
47
  key: "wiki.enabled",
32
48
  default: "true",
33
49
  envVar: "MINK_WIKI_ENABLED",
34
50
  description: "Enable/disable the wiki feature",
51
+ scope: "shared",
35
52
  },
36
53
  {
37
54
  key: "wiki.sync-mode",
38
55
  default: "immediate",
39
56
  envVar: "MINK_WIKI_SYNC_MODE",
40
57
  description: "Sync mode: immediate or batched",
58
+ scope: "shared",
41
59
  },
42
60
  {
43
61
  key: "wiki.git-backup",
44
62
  default: "false",
45
63
  envVar: "MINK_WIKI_GIT_BACKUP",
46
64
  description: "Deprecated: use sync.enabled instead",
65
+ scope: "shared",
47
66
  },
48
67
  {
49
68
  key: "wiki.git-remote",
50
69
  default: "origin",
51
70
  envVar: "MINK_WIKI_GIT_REMOTE",
52
71
  description: "Deprecated: use sync.remote-url instead",
72
+ scope: "shared",
53
73
  },
54
74
  {
55
75
  key: "notes.default-category",
56
76
  default: "inbox",
57
77
  envVar: "MINK_NOTES_DEFAULT_CATEGORY",
58
78
  description: "Default category for notes captured via CLI",
79
+ scope: "shared",
59
80
  },
60
81
  {
61
82
  key: "sync.enabled",
62
83
  default: "false",
63
84
  envVar: "MINK_SYNC_ENABLED",
64
85
  description: "Enable/disable automatic git sync of ~/.mink",
86
+ scope: "shared",
65
87
  },
66
88
  {
67
89
  key: "sync.remote-url",
68
90
  default: "",
69
91
  envVar: "MINK_SYNC_REMOTE_URL",
70
92
  description: "Git remote URL for ~/.mink sync",
93
+ scope: "shared",
71
94
  },
72
95
  {
73
96
  key: "sync.last-push",
74
97
  default: "",
75
98
  envVar: "MINK_SYNC_LAST_PUSH",
76
99
  description: "ISO timestamp of last successful sync push",
100
+ scope: "local",
77
101
  },
78
102
  {
79
103
  key: "sync.last-pull",
80
104
  default: "",
81
105
  envVar: "MINK_SYNC_LAST_PULL",
82
106
  description: "ISO timestamp of last successful sync pull",
107
+ scope: "local",
83
108
  },
84
109
  ];
85
110