@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 +3 -0
- package/package.json +1 -1
- package/src/commands/init.js +1 -0
- package/src/commands/vault.js +363 -0
- package/src/lib/gitignore-helper.js +110 -0
- package/src/lib/vault-binding.js +205 -0
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
package/src/commands/init.js
CHANGED
|
@@ -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
|
+
}
|