@khanhcan148/mk 0.1.18 → 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/README.md +1 -1
- package/bin/mk.js +3 -0
- package/package.json +1 -1
- package/src/commands/auth.js +12 -1
- package/src/commands/init.js +1 -0
- package/src/commands/update.js +1 -1
- package/src/commands/vault.js +363 -0
- package/src/lib/auth.js +23 -5
- package/src/lib/copy.js +9 -1
- package/src/lib/download.js +18 -0
- package/src/lib/gitignore-helper.js +110 -0
- package/src/lib/vault-binding.js +205 -0
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ cp -r .claude ~/.claude/
|
|
|
88
88
|
|
|
89
89
|
```
|
|
90
90
|
├── .claude/
|
|
91
|
-
│ ├── agents/ #
|
|
91
|
+
│ ├── agents/ # 36 agents (5 primary + 31 utility: implementers, quality, docs, specialized, concerns, brainstorm critics)
|
|
92
92
|
│ ├── skills/ # 67 skill packages (SKILL.md + scripts/references/assets)
|
|
93
93
|
│ │ ├── mk-*/ # 20 workflow commands (/mk-audit, /mk-brainstorm, /mk-log-analysis, /mk-overview, /mk-wiki, etc.)
|
|
94
94
|
│ │ └── ... # Domain skills (frontend, backend, testing, browser automation, etc.)
|
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/auth.js
CHANGED
|
@@ -138,6 +138,17 @@ export async function statusAction(deps = {}) {
|
|
|
138
138
|
process.stdout.write(chalk.green('Repo access: granted\n'));
|
|
139
139
|
} else {
|
|
140
140
|
process.stdout.write(chalk.red('Repo access: denied\n'));
|
|
141
|
-
|
|
141
|
+
const hasRepoScope = access.scopes?.some((s) => s === 'repo');
|
|
142
|
+
if (access.status === 404 && access.scopes && !hasRepoScope) {
|
|
143
|
+
// 404 with no 'repo' scope usually means the token can't see the private
|
|
144
|
+
// KIT_REPO — re-authenticating picks up the new default scope.
|
|
145
|
+
const current = access.scopes.length ? access.scopes.join(', ') : 'none';
|
|
146
|
+
process.stdout.write(
|
|
147
|
+
`Token is missing the 'repo' scope (current scopes: ${current}).\n` +
|
|
148
|
+
"Run 'mk auth logout && mk auth login' to re-authenticate with the required scope.\n"
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
process.stdout.write('Contact the repository owner for collaborator access.\n');
|
|
152
|
+
}
|
|
142
153
|
}
|
|
143
154
|
}
|
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`));
|
package/src/commands/update.js
CHANGED
|
@@ -32,7 +32,7 @@ import { isEmptyDir } from '../lib/fs-utils.js';
|
|
|
32
32
|
* @param {string} str
|
|
33
33
|
* @returns {string}
|
|
34
34
|
*/
|
|
35
|
-
function stripTerminalEscapes(str) {
|
|
35
|
+
export function stripTerminalEscapes(str) {
|
|
36
36
|
return str
|
|
37
37
|
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
38
38
|
.replace(/\x1b\].*?(\x07|\x1b\\)/gs, '') // OSC sequences (dotAll for multiline)
|
|
@@ -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;
|
package/src/lib/auth.js
CHANGED
|
@@ -82,8 +82,15 @@ export async function validateToken(token) {
|
|
|
82
82
|
/**
|
|
83
83
|
* Check whether the token has access to the kit repository.
|
|
84
84
|
*
|
|
85
|
+
* Returns the HTTP status and any scopes reported via the `x-oauth-scopes`
|
|
86
|
+
* response header so callers can disambiguate common failure modes:
|
|
87
|
+
* - 404 + no 'repo' scope → token lacks scope (most common for a private KIT_REPO)
|
|
88
|
+
* - 404 + has 'repo' scope → authenticated account is not a collaborator
|
|
89
|
+
* - 403 → SSO or rate-limit block
|
|
90
|
+
* - 0 → network error
|
|
91
|
+
*
|
|
85
92
|
* @param {string} token
|
|
86
|
-
* @returns {Promise<{ accessible: boolean }>}
|
|
93
|
+
* @returns {Promise<{ accessible: boolean, status: number, scopes: string[] }>}
|
|
87
94
|
*/
|
|
88
95
|
export async function checkRepoAccess(token) {
|
|
89
96
|
try {
|
|
@@ -93,12 +100,17 @@ export async function checkRepoAccess(token) {
|
|
|
93
100
|
Accept: 'application/vnd.github.v3+json'
|
|
94
101
|
}
|
|
95
102
|
});
|
|
96
|
-
return { accessible: res.ok };
|
|
103
|
+
return { accessible: res.ok, status: res.status, scopes: parseScopes(res) };
|
|
97
104
|
} catch {
|
|
98
|
-
return { accessible: false };
|
|
105
|
+
return { accessible: false, status: 0, scopes: [] };
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
108
|
|
|
109
|
+
function parseScopes(res) {
|
|
110
|
+
const raw = res.headers?.get?.('x-oauth-scopes') || '';
|
|
111
|
+
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
102
114
|
// ---------------------------------------------------------------------------
|
|
103
115
|
// OAuth Device Flow
|
|
104
116
|
// ---------------------------------------------------------------------------
|
|
@@ -127,9 +139,15 @@ export async function startDeviceFlow(opts = {}) {
|
|
|
127
139
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
128
140
|
Accept: 'application/json'
|
|
129
141
|
},
|
|
130
|
-
//
|
|
142
|
+
// KIT_REPO is private, so 'repo' scope is required to read it. Default to 'repo'.
|
|
143
|
+
// Override with MK_OAUTH_SCOPE for public forks (e.g. MK_OAUTH_SCOPE='' for no scope,
|
|
144
|
+
// or MK_OAUTH_SCOPE='public_repo' for public-only access).
|
|
145
|
+
// `??` (not `||`) so an explicit empty string is honored as an opt-out.
|
|
131
146
|
// Use URLSearchParams to prevent parameter injection via env var containing '&' chars.
|
|
132
|
-
body: new URLSearchParams({
|
|
147
|
+
body: new URLSearchParams({
|
|
148
|
+
client_id: GITHUB_CLIENT_ID,
|
|
149
|
+
scope: process.env.MK_OAUTH_SCOPE ?? 'repo'
|
|
150
|
+
}).toString()
|
|
133
151
|
});
|
|
134
152
|
|
|
135
153
|
if (!codeRes.ok) {
|
package/src/lib/copy.js
CHANGED
|
@@ -81,6 +81,14 @@ export function collectDiskFiles(targetDir) {
|
|
|
81
81
|
return results;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {Object} FileEntry
|
|
86
|
+
* @property {string} relativePath - Path relative to the target .claude/ (POSIX separators, e.g. ".claude/agents/foo.md")
|
|
87
|
+
* @property {string} absolutePath - Destination absolute path under targetDir (where the file is copied to)
|
|
88
|
+
* @property {string} sourceAbsPath - Source absolute path under sourceDir (where the file is copied from)
|
|
89
|
+
* @property {number} size - File size in bytes, captured at copy time
|
|
90
|
+
*/
|
|
91
|
+
|
|
84
92
|
/**
|
|
85
93
|
* Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
|
|
86
94
|
* Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
|
|
@@ -88,7 +96,7 @@ export function collectDiskFiles(targetDir) {
|
|
|
88
96
|
* @param {string} sourceDir - Absolute path to source .claude/
|
|
89
97
|
* @param {string} targetDir - Absolute path to target .claude/
|
|
90
98
|
* @param {{ dryRun: boolean }} options
|
|
91
|
-
* @returns {
|
|
99
|
+
* @returns {FileEntry[]}
|
|
92
100
|
* @remarks Naming convention: `absolutePath` is the destination (under targetDir),
|
|
93
101
|
* `sourceAbsPath` is the source (under sourceDir). The asymmetry is intentional —
|
|
94
102
|
* renaming would break consumers (update.js). See DEBT-016.
|
package/src/lib/download.js
CHANGED
|
@@ -39,6 +39,24 @@ export function assertGitHubHostname(url) {
|
|
|
39
39
|
} catch {
|
|
40
40
|
throw new Error(`SSRF guard: invalid URL "${url}"`);
|
|
41
41
|
}
|
|
42
|
+
// H9/H10: block non-TLS schemes. Plain http:// to github.com can be MITM-redirected,
|
|
43
|
+
// and the kit download path has no reason to accept anything but https.
|
|
44
|
+
if (parsed.protocol !== 'https:') {
|
|
45
|
+
// Truncate to guard against log injection via crafted long / newline-containing schemes.
|
|
46
|
+
const safeScheme = String(parsed.protocol).slice(0, 20);
|
|
47
|
+
throw new Error(
|
|
48
|
+
`SSRF guard: scheme "${safeScheme}" is not allowed. ` +
|
|
49
|
+
`Only https: is permitted for kit downloads.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
// Block userinfo-prefixed URLs (user:pass@host) — they can mask the true
|
|
53
|
+
// hostname in server-side url parsers or confuse logging/auditing.
|
|
54
|
+
if (parsed.username || parsed.password) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`SSRF guard: userinfo-prefixed URL is not allowed. ` +
|
|
57
|
+
`Credentials in the URL (user:pass@host) are rejected for kit downloads.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
42
60
|
const { hostname } = parsed;
|
|
43
61
|
if (!ALLOWED_HOSTS.has(hostname)) {
|
|
44
62
|
throw new Error(
|
|
@@ -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
|
+
}
|