@khanhcan148/mk 0.1.19 → 0.1.20

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/bin/mk.js CHANGED
@@ -7,6 +7,7 @@ import { initAction } from '../src/commands/init.js';
7
7
  import { updateAction } from '../src/commands/update.js';
8
8
  import { removeAction } from '../src/commands/remove.js';
9
9
  import { loginAction, logoutAction, statusAction } from '../src/commands/auth.js';
10
+ import vaultCmd from '../src/commands/vault.js';
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -50,4 +51,6 @@ auth.command('status')
50
51
  .description('Show current authentication status')
51
52
  .action(() => statusAction());
52
53
 
54
+ program.addCommand(vaultCmd);
55
+
53
56
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhcan148/mk",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "CLI to install and manage MyClaudeKit (.claude/) in your projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -162,6 +162,7 @@ export async function initAction(options = {}, deps = {}) {
162
162
  chalk.green(`Installed ${result.fileCount} files (${sizeKB} KB) to ${targetDir}\n`)
163
163
  );
164
164
  process.stdout.write(`Manifest written to: ${manifestPath}\n`);
165
+ console.log('\nOptional: enable Obsidian vault context with: mk vault status');
165
166
  }
166
167
  } catch (err) {
167
168
  process.stderr.write(chalk.red(`Error: ${err.message}\n`));
@@ -0,0 +1,363 @@
1
+ /**
2
+ * vault.js
3
+ * Commander subcommand group for 'mk vault'.
4
+ *
5
+ * Subcommands: enable, disable, status, untaint
6
+ *
7
+ * Named action exports (enableAction, disableAction, statusAction, untaintAction)
8
+ * allow direct unit testing without going through Commander.
9
+ */
10
+
11
+ import { Command } from 'commander';
12
+ import {
13
+ existsSync,
14
+ readdirSync,
15
+ unlinkSync,
16
+ mkdirSync,
17
+ statSync
18
+ } from 'node:fs';
19
+ import { join, resolve } from 'node:path';
20
+ import { createHash } from 'node:crypto';
21
+ import { execSync } from 'node:child_process';
22
+ import { createInterface } from 'node:readline';
23
+ import {
24
+ readProjectBinding,
25
+ writeProjectBinding,
26
+ writeUserBinding,
27
+ resolveActiveBinding
28
+ } from '../lib/vault-binding.js';
29
+ import { appendMkToGitignore } from '../lib/gitignore-helper.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // CI detection
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const CI_ENV_VARS = ['CI', 'GITHUB_ACTIONS', 'CIRCLECI', 'BUILDKITE', 'TF_BUILD'];
36
+
37
+ function isCI() {
38
+ return CI_ENV_VARS.some(v => !!process.env[v]);
39
+ }
40
+
41
+ function ciSafeOverride() {
42
+ return process.env.MK_VAULT_CI_SAFE === 'allow';
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Count .md files recursively under a directory.
51
+ * Uses withFileTypes:true; symlinks are skipped (entry.isFile() returns false for symlinks).
52
+ * Exported for testing (H3).
53
+ */
54
+ export function countMdFiles(dir) {
55
+ let count = 0;
56
+ function walk(d) {
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const entry of entries) {
64
+ if (entry.isDirectory()) {
65
+ walk(join(d, entry.name));
66
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
67
+ count++;
68
+ }
69
+ }
70
+ }
71
+ walk(dir);
72
+ return count;
73
+ }
74
+
75
+ /**
76
+ * Hash a path for safe display (first 8 chars of sha256 hex).
77
+ */
78
+ function hashPath(p) {
79
+ return createHash('sha256').update(p).digest('hex').slice(0, 8);
80
+ }
81
+
82
+ /**
83
+ * Prompt user for a choice via readline (returns promise resolving to string).
84
+ */
85
+ function prompt(question) {
86
+ return new Promise((resolve) => {
87
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
88
+ rl.question(question, (answer) => {
89
+ rl.close();
90
+ resolve(answer.trim());
91
+ });
92
+ });
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // enableAction
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Handle 'mk vault enable <folder>'.
101
+ *
102
+ * opts:
103
+ * cwd - working directory (default: process.cwd())
104
+ * _skipGitignore - skip appendMkToGitignore call (test hook)
105
+ * _skipSizeProbe - skip note count (test hook)
106
+ * _noteCount - inject note count directly (test hook)
107
+ * _mode - inject mode directly, skip stdin prompt (test hook)
108
+ * _userConfigPath - override user config path (test hook)
109
+ *
110
+ * @param {string} folder
111
+ * @param {object} [opts]
112
+ */
113
+ export async function enableAction(folder, opts = {}) {
114
+ const {
115
+ cwd = process.cwd(),
116
+ _skipGitignore = false,
117
+ _skipSizeProbe = false,
118
+ _noteCount,
119
+ _mode,
120
+ _userConfigPath
121
+ } = opts;
122
+
123
+ // Resolve absolute path
124
+ const vaultPath = resolve(folder);
125
+
126
+ // Validate folder exists and is accessible
127
+ try {
128
+ statSync(vaultPath);
129
+ } catch {
130
+ process.stderr.write(`Error: Vault folder not found or not accessible: ${vaultPath}\n`);
131
+ return;
132
+ }
133
+
134
+ // Append to .gitignore (unless skipped)
135
+ if (!_skipGitignore) {
136
+ await appendMkToGitignore(cwd);
137
+ }
138
+
139
+ // Determine note count
140
+ let noteCount = _noteCount;
141
+ if (noteCount === undefined && !_skipSizeProbe) {
142
+ noteCount = countMdFiles(vaultPath);
143
+ }
144
+ if (noteCount === undefined) {
145
+ noteCount = 0;
146
+ }
147
+
148
+ // Determine mode
149
+ let mode = _mode;
150
+ if (!mode) {
151
+ if (noteCount > 200) {
152
+ // Prompt user to choose mode
153
+ process.stdout.write(`Vault contains ${noteCount} notes (> 200 threshold).\n`);
154
+ process.stdout.write('Select mode:\n 1) recursive (index all notes)\n 2) digest (summary only)\n');
155
+ const answer = await prompt('Enter 1 or 2: ');
156
+ mode = answer === '2' ? 'digest' : 'recursive';
157
+ } else {
158
+ mode = 'recursive';
159
+ }
160
+ }
161
+
162
+ // Build binding data
163
+ const { getMachineId } = await import('../lib/vault-binding.js');
164
+ const binding = {
165
+ path: vaultPath,
166
+ version: 1,
167
+ machine_id: getMachineId(),
168
+ mode
169
+ };
170
+
171
+ // Write project-level binding
172
+ const mkDir = join(cwd, '.mk');
173
+ mkdirSync(mkDir, { recursive: true });
174
+ await writeProjectBinding(cwd, binding);
175
+
176
+ // Write user-level binding (skip in CI unless MK_VAULT_CI_SAFE=allow)
177
+ if (isCI() && !ciSafeOverride()) {
178
+ process.stderr.write(
179
+ `[AM-3] CI environment detected — skipping user-level vault binding write. ` +
180
+ `Set MK_VAULT_CI_SAFE=allow to override. (refused)\n`
181
+ );
182
+ } else {
183
+ await writeUserBinding(binding, { _userConfigPath });
184
+ }
185
+
186
+ // Print confirmation
187
+ const pathHash = hashPath(vaultPath);
188
+ process.stdout.write(`Vault enabled: ${vaultPath} [hash:${pathHash}] (mode:${mode})\n`);
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // disableAction
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Handle 'mk vault disable'.
197
+ *
198
+ * opts:
199
+ * cwd - working directory (default: process.cwd())
200
+ * _userConfigPath - override user config path (test hook)
201
+ *
202
+ * @param {object} [opts]
203
+ */
204
+ export async function disableAction(opts = {}) {
205
+ const { cwd = process.cwd(), _userConfigPath } = opts;
206
+
207
+ const projectBindingFile = join(cwd, '.mk', 'vault.json');
208
+ const hadProject = existsSync(projectBindingFile);
209
+
210
+ if (hadProject) {
211
+ unlinkSync(projectBindingFile);
212
+ process.stdout.write('Vault disabled: project-level binding removed.\n');
213
+ } else {
214
+ // Try to remove user-level binding if no project binding was found
215
+ const userPath = _userConfigPath || join(
216
+ (await import('node:os')).homedir(),
217
+ '.config', 'mk', 'vault.json'
218
+ );
219
+ if (existsSync(userPath)) {
220
+ unlinkSync(userPath);
221
+ process.stdout.write('Vault disabled: user-level binding removed.\n');
222
+ } else {
223
+ process.stdout.write('No active vault binding found — nothing to disable.\n');
224
+ }
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // statusAction
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * Handle 'mk vault status [--json]'.
234
+ *
235
+ * opts:
236
+ * cwd - working directory (default: process.cwd())
237
+ * json - emit JSON output
238
+ * _userConfigPath - override user config path (test hook)
239
+ * _skipProbe - skip Obsidian probe (test hook)
240
+ *
241
+ * @param {object} [opts]
242
+ */
243
+ export async function statusAction(opts = {}) {
244
+ const {
245
+ cwd = process.cwd(),
246
+ json: jsonMode = false,
247
+ _userConfigPath,
248
+ _skipProbe = false
249
+ } = opts;
250
+
251
+ const binding = resolveActiveBinding(cwd, { _userConfigPath });
252
+
253
+ // Probe Obsidian availability (unless skipped in tests)
254
+ let probe = false;
255
+ if (!_skipProbe) {
256
+ try {
257
+ execSync('obsidian help', { stdio: 'ignore' });
258
+ probe = true;
259
+ } catch {
260
+ probe = false;
261
+ }
262
+ }
263
+
264
+ const result = {
265
+ enabled: binding !== null,
266
+ probe,
267
+ bound_path: binding ? hashPath(binding.path) : null,
268
+ mode: binding ? binding.mode : null
269
+ };
270
+
271
+ if (jsonMode) {
272
+ process.stdout.write(JSON.stringify(result));
273
+ return;
274
+ }
275
+
276
+ // Human-readable table
277
+ process.stdout.write(`Vault status:\n`);
278
+ process.stdout.write(` enabled: ${result.enabled}\n`);
279
+ process.stdout.write(` probe: ${result.probe}\n`);
280
+ process.stdout.write(` bound_path: ${result.bound_path ?? 'none'}\n`);
281
+ process.stdout.write(` mode: ${result.mode ?? 'none'}\n`);
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // untaintAction
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Handle 'mk vault untaint'.
290
+ *
291
+ * opts:
292
+ * cwd - working directory (default: process.cwd())
293
+ * _nonInteractive - skip confirmation prompt (test hook)
294
+ *
295
+ * @param {object} [opts]
296
+ */
297
+ export async function untaintAction(opts = {}) {
298
+ const { cwd = process.cwd(), _nonInteractive = false } = opts;
299
+
300
+ const mkDir = join(cwd, '.mk');
301
+ if (!existsSync(mkDir)) {
302
+ process.stdout.write('No .mk/ directory found — nothing to untaint.\n');
303
+ return;
304
+ }
305
+
306
+ let taintFiles;
307
+ try {
308
+ taintFiles = readdirSync(mkDir).filter(f => f.startsWith('.taint-session-'));
309
+ } catch {
310
+ taintFiles = [];
311
+ }
312
+
313
+ if (taintFiles.length === 0) {
314
+ process.stdout.write('No taint marker found.\n');
315
+ return;
316
+ }
317
+
318
+ process.stdout.write(`Found ${taintFiles.length} taint marker(s).\n`);
319
+
320
+ // Confirm before deleting (skip in non-interactive / test mode)
321
+ if (!_nonInteractive) {
322
+ const answer = await prompt('Delete all taint markers? [y/N] ');
323
+ if (answer.toLowerCase() !== 'y') {
324
+ process.stdout.write('Aborted — no taint markers removed.\n');
325
+ return;
326
+ }
327
+ }
328
+
329
+ for (const f of taintFiles) {
330
+ unlinkSync(join(mkDir, f));
331
+ }
332
+ process.stdout.write(`Taint markers cleared: ${taintFiles.length} file(s) removed.\n`);
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Commander subcommand group
337
+ // ---------------------------------------------------------------------------
338
+
339
+ const vaultCommand = new Command('vault')
340
+ .description('Manage Obsidian vault binding for mk-* skill context');
341
+
342
+ vaultCommand
343
+ .command('enable <folder>')
344
+ .description('Bind a vault folder to this project')
345
+ .action((folder) => enableAction(folder));
346
+
347
+ vaultCommand
348
+ .command('disable')
349
+ .description('Remove vault binding from this project')
350
+ .action(() => disableAction());
351
+
352
+ vaultCommand
353
+ .command('status')
354
+ .description('Show vault binding status')
355
+ .option('--json', 'Emit JSON output')
356
+ .action((options) => statusAction({ json: options.json }));
357
+
358
+ vaultCommand
359
+ .command('untaint')
360
+ .description('Remove .mk/.taint-session-* markers')
361
+ .action(() => untaintAction());
362
+
363
+ export default vaultCommand;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * gitignore-helper.js
3
+ * Append .mk/ entries to the nearest .gitignore file.
4
+ *
5
+ * Behaviour:
6
+ * - If _skipGitCheck: true → skip git rev-parse check (used in tests)
7
+ * - If not inside a git work tree → return { status: 'no-git' } or throw
8
+ * - Find the nearest .gitignore at cwd (creates it if missing)
9
+ * - If .mk/ already present → return { status: 'already-present' }
10
+ * - Append .mk/ and .mk/.taint-session-* → return { status: 'appended', path }
11
+ * - Monorepo: the test only requires cwd-level .gitignore to be updated
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ readFileSync,
17
+ writeFileSync,
18
+ appendFileSync
19
+ } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { execSync } from 'node:child_process';
22
+
23
+ const MK_LINES = '.mk/\n.mk/.taint-session-*\n';
24
+
25
+ /**
26
+ * Append .mk/ entries to the .gitignore nearest to cwd.
27
+ *
28
+ * @param {string} cwd - Directory to operate from
29
+ * @param {object} [opts]
30
+ * @param {boolean} [opts._skipGitCheck] - Skip git rev-parse check (for tests)
31
+ * @returns {{ status: string, path?: string, paths?: string[], error?: string }}
32
+ */
33
+ export async function appendMkToGitignore(cwd, opts = {}) {
34
+ const { _skipGitCheck = false } = opts;
35
+
36
+ // Git check: verify we are inside a git work tree
37
+ if (!_skipGitCheck) {
38
+ try {
39
+ execSync('git rev-parse --show-toplevel', {
40
+ cwd,
41
+ stdio: 'pipe',
42
+ encoding: 'utf8'
43
+ });
44
+ } catch {
45
+ return { status: 'refused', error: 'Not inside a git work tree' };
46
+ }
47
+ }
48
+
49
+ // Find the nearest .gitignore (at cwd level)
50
+ const gitignorePath = join(cwd, '.gitignore');
51
+
52
+ // Read existing content (or empty string if file does not exist)
53
+ let existing = '';
54
+ if (existsSync(gitignorePath)) {
55
+ existing = readFileSync(gitignorePath, 'utf8');
56
+ }
57
+
58
+ // Idempotency check: if .mk/ line already present, skip
59
+ const lines = existing.split('\n').map(l => l.trim());
60
+ if (lines.includes('.mk/')) {
61
+ return { status: 'already-present', path: gitignorePath };
62
+ }
63
+
64
+ // Append the .mk/ entries
65
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
66
+ writeFileSync(gitignorePath, existing + separator + MK_LINES, 'utf8');
67
+
68
+ // Monorepo detection: check if there are other .gitignore files between
69
+ // cwd and the git root. For now we report the appended file; the test only
70
+ // requires that the nearest (cwd-level) .gitignore receives the lines.
71
+ // Return appended-monorepo if a parent .gitignore was also found.
72
+ let status = 'appended';
73
+ const paths = [gitignorePath];
74
+
75
+ // Try to find a parent .gitignore (best-effort for monorepo reporting)
76
+ if (!_skipGitCheck) {
77
+ try {
78
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
79
+ cwd,
80
+ stdio: 'pipe',
81
+ encoding: 'utf8'
82
+ }).trim();
83
+
84
+ let dir = join(cwd, '..');
85
+ while (dir !== gitRoot && dir !== join(dir, '..')) {
86
+ const candidate = join(dir, '.gitignore');
87
+ if (existsSync(candidate)) {
88
+ const parentContent = readFileSync(candidate, 'utf8');
89
+ const parentLines = parentContent.split('\n').map(l => l.trim());
90
+ if (!parentLines.includes('.mk/')) {
91
+ const sep = parentContent.length > 0 && !parentContent.endsWith('\n') ? '\n' : '';
92
+ appendFileSync(candidate, sep + MK_LINES, 'utf8');
93
+ }
94
+ paths.push(candidate);
95
+ status = 'appended-monorepo';
96
+ }
97
+ const parent = join(dir, '..');
98
+ if (parent === dir) break;
99
+ dir = parent;
100
+ }
101
+ } catch {
102
+ // git rev-parse unavailable — no-op on monorepo detection
103
+ }
104
+ }
105
+
106
+ if (status === 'appended-monorepo') {
107
+ return { status, paths };
108
+ }
109
+ return { status: 'appended', path: gitignorePath };
110
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * vault-binding.js
3
+ * Read and write vault binding files (project-level and user-level).
4
+ *
5
+ * Schema v1: { path: string, version: 1, machine_id: string, mode: "recursive" | "digest" }
6
+ *
7
+ * Atomic write pattern: write to .tmp then rename — same strategy as mergeSettingsJson.
8
+ * On read, unknown keys are stripped and the object is validated before returning (H2 + H4).
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync, mkdirSync } from 'node:fs';
12
+ import { join, dirname, isAbsolute } from 'node:path';
13
+ import { homedir, hostname } from 'node:os';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Schema validation (H2 + H4)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const VALID_MODES = new Set(['recursive', 'digest']);
20
+
21
+ /**
22
+ * Validate a raw parsed binding object and return a new object containing
23
+ * ONLY the four known keys: { path, version, machine_id, mode }.
24
+ *
25
+ * Validation rules:
26
+ * - obj must be a non-null plain object
27
+ * - path: non-empty string AND isAbsolute
28
+ * - mode: "recursive" | "digest"
29
+ * - version: strictly equals 1
30
+ * - machine_id: non-empty string
31
+ *
32
+ * Returns null if any rule fails; otherwise returns a new stripped object.
33
+ *
34
+ * @param {unknown} obj
35
+ * @returns {{ path: string, version: number, machine_id: string, mode: string }|null}
36
+ */
37
+ export function validateAndStripBinding(obj) {
38
+ if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) {
39
+ return null;
40
+ }
41
+ const { path, version, machine_id, mode } = obj;
42
+
43
+ if (typeof path !== 'string' || path.length === 0 || !isAbsolute(path)) return null;
44
+ if (!VALID_MODES.has(mode)) return null;
45
+ if (version !== 1) return null;
46
+ if (typeof machine_id !== 'string' || machine_id.length === 0) return null;
47
+
48
+ return { path, version, machine_id, mode };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Paths
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Default user-level config path: ~/.config/mk/vault.json
57
+ * Can be overridden via opts._userConfigPath in tests.
58
+ */
59
+ function userConfigPath(opts = {}) {
60
+ return opts._userConfigPath || join(homedir(), '.config', 'mk', 'vault.json');
61
+ }
62
+
63
+ /**
64
+ * Project-level binding path: <cwd>/.mk/vault.json
65
+ */
66
+ function projectBindingPath(cwd) {
67
+ return join(cwd, '.mk', 'vault.json');
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Read helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Read a JSON binding file safely.
76
+ * Returns parsed object, or null if file is missing or unparseable.
77
+ */
78
+ function readJsonSafe(filePath) {
79
+ if (!existsSync(filePath)) return null;
80
+ try {
81
+ return JSON.parse(readFileSync(filePath, 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read project-level binding from <cwd>/.mk/vault.json.
89
+ * Validates schema and strips unknown keys before returning (H2 + H4).
90
+ * Returns the validated binding object or null if missing or invalid.
91
+ *
92
+ * @param {string} cwd
93
+ * @returns {{ path: string, version: number, machine_id: string, mode: string }|null}
94
+ */
95
+ export function readProjectBinding(cwd) {
96
+ return validateAndStripBinding(readJsonSafe(projectBindingPath(cwd)));
97
+ }
98
+
99
+ /**
100
+ * Read user-level binding from ~/.config/mk/vault.json.
101
+ * Validates schema and strips unknown keys before returning (H2 + H4).
102
+ * Returns the validated binding object or null if missing or invalid.
103
+ *
104
+ * @param {object} [opts]
105
+ * @param {string} [opts._userConfigPath] - Override path for testing
106
+ * @returns {{ path: string, version: number, machine_id: string, mode: string }|null}
107
+ */
108
+ export function readUserBinding(opts = {}) {
109
+ return validateAndStripBinding(readJsonSafe(userConfigPath(opts)));
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Write helpers
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Write a binding file atomically (write to .tmp then rename).
118
+ * Creates a timestamped backup of any pre-existing file.
119
+ * Returns { action: 'created'|'updated', backup?: string }.
120
+ *
121
+ * @param {string} filePath - Absolute path to the binding file
122
+ * @param {object} data - Data to serialize as JSON
123
+ * @returns {{ action: string, backup?: string }}
124
+ */
125
+ function writeBindingAtomic(filePath, data) {
126
+ const dir = dirname(filePath);
127
+ mkdirSync(dir, { recursive: true });
128
+
129
+ const existed = existsSync(filePath);
130
+ let backup;
131
+
132
+ if (existed) {
133
+ const ts = Date.now();
134
+ backup = `${filePath}.${ts}.bak`;
135
+ copyFileSync(filePath, backup);
136
+ }
137
+
138
+ const tmpPath = `${filePath}.tmp`;
139
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
140
+ renameSync(tmpPath, filePath);
141
+
142
+ return {
143
+ action: existed ? 'updated' : 'created',
144
+ ...(backup ? { backup } : {})
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Write project-level binding to <cwd>/.mk/vault.json.
150
+ *
151
+ * @param {string} cwd
152
+ * @param {object} data
153
+ * @returns {{ action: string, backup?: string }}
154
+ */
155
+ export async function writeProjectBinding(cwd, data) {
156
+ return writeBindingAtomic(projectBindingPath(cwd), data);
157
+ }
158
+
159
+ /**
160
+ * Write user-level binding to ~/.config/mk/vault.json.
161
+ *
162
+ * @param {object} data
163
+ * @param {object} [opts]
164
+ * @param {string} [opts._userConfigPath] - Override path for testing
165
+ * @returns {{ action: string, backup?: string }}
166
+ */
167
+ export async function writeUserBinding(data, opts = {}) {
168
+ return writeBindingAtomic(userConfigPath(opts), data);
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // resolveActiveBinding
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Resolve the active binding: project-level takes priority, user-level is fallback.
177
+ * Annotates result with _source: 'project' | 'user'.
178
+ *
179
+ * @param {string} cwd
180
+ * @param {object} [opts]
181
+ * @param {string} [opts._userConfigPath] - Override user config path for testing
182
+ * @returns {object|null}
183
+ */
184
+ export function resolveActiveBinding(cwd, opts = {}) {
185
+ const project = readProjectBinding(cwd);
186
+ if (project) {
187
+ return { ...project, _source: 'project' };
188
+ }
189
+ const user = readUserBinding(opts);
190
+ if (user) {
191
+ return { ...user, _source: 'user' };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // machine_id helper
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * Returns the current machine identifier (hostname).
202
+ */
203
+ export function getMachineId() {
204
+ return hostname();
205
+ }