@khanhcan148/mk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +266 -0
- package/bin/mk.js +53 -0
- package/package.json +38 -0
- package/src/commands/auth.js +143 -0
- package/src/commands/init.js +144 -0
- package/src/commands/remove.js +146 -0
- package/src/commands/update.js +221 -0
- package/src/lib/auth.js +211 -0
- package/src/lib/checksum.js +13 -0
- package/src/lib/config.js +72 -0
- package/src/lib/constants.js +37 -0
- package/src/lib/copy.js +130 -0
- package/src/lib/download.js +262 -0
- package/src/lib/manifest.js +115 -0
- package/src/lib/paths.js +70 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, unlinkSync, rmdirSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { readManifest } from '../lib/manifest.js';
|
|
5
|
+
import { resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run the remove command.
|
|
9
|
+
*
|
|
10
|
+
* @param {{
|
|
11
|
+
* targetDir?: string,
|
|
12
|
+
* manifestPath?: string
|
|
13
|
+
* }} params
|
|
14
|
+
* @returns {Promise<{ removed: number, skipped: number, dirsCleaned: number }>}
|
|
15
|
+
*/
|
|
16
|
+
export async function runRemove(params = {}) {
|
|
17
|
+
const {
|
|
18
|
+
targetDir = resolveTargetDir({ global: false }),
|
|
19
|
+
manifestPath = resolveManifestPath({ global: false })
|
|
20
|
+
} = params;
|
|
21
|
+
|
|
22
|
+
// Read manifest
|
|
23
|
+
let manifest;
|
|
24
|
+
try {
|
|
25
|
+
manifest = readManifest(manifestPath);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error(`Not installed (no manifest found). Kit has not been installed here. (${err.message})`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const projectRoot = deriveProjectRoot(manifest, manifestPath);
|
|
31
|
+
const claudeRoot = resolve(join(projectRoot, '.claude'));
|
|
32
|
+
const files = manifest.files || {};
|
|
33
|
+
|
|
34
|
+
let removed = 0;
|
|
35
|
+
let skipped = 0;
|
|
36
|
+
const parentDirs = new Set();
|
|
37
|
+
|
|
38
|
+
// Delete each file in manifest
|
|
39
|
+
for (const relPath of Object.keys(files)) {
|
|
40
|
+
const absPath = join(projectRoot, relPath);
|
|
41
|
+
// Bounds check: relPath from manifest must not escape .claude/ subtree
|
|
42
|
+
try {
|
|
43
|
+
assertSafePath(absPath, claudeRoot, `manifest key "${relPath}"`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
process.stderr.write(`Warning: Skipping unsafe path from manifest: ${err.message}\n`);
|
|
46
|
+
skipped++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (existsSync(absPath)) {
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(absPath);
|
|
52
|
+
removed++;
|
|
53
|
+
// Track parent directory for cleanup
|
|
54
|
+
parentDirs.add(dirname(absPath));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
process.stderr.write(`Warning: Could not delete ${absPath}: ${err.message}\n`);
|
|
57
|
+
skipped++;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
skipped++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Clean up empty directories (bottom-up — deepest first)
|
|
65
|
+
const sortedDirs = [...parentDirs].sort((a, b) => {
|
|
66
|
+
// More path separators = deeper
|
|
67
|
+
const depthA = a.split(/[/\\]/).length;
|
|
68
|
+
const depthB = b.split(/[/\\]/).length;
|
|
69
|
+
return depthB - depthA;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let dirsCleaned = 0;
|
|
73
|
+
for (const dir of sortedDirs) {
|
|
74
|
+
if (isEmptyDir(dir)) {
|
|
75
|
+
try {
|
|
76
|
+
rmdirSync(dir);
|
|
77
|
+
dirsCleaned++;
|
|
78
|
+
// Also try parent directories, but never delete targetDir or above
|
|
79
|
+
const resolvedTarget = resolve(targetDir);
|
|
80
|
+
let current = dirname(dir);
|
|
81
|
+
let prev = dir;
|
|
82
|
+
while (current !== prev && isEmptyDir(current)) {
|
|
83
|
+
const resolvedCurrent = resolve(current);
|
|
84
|
+
// Stop at targetDir boundary — never delete .claude/ root or anything above it
|
|
85
|
+
if (resolvedCurrent === resolvedTarget || !resolvedCurrent.startsWith(resolvedTarget + sep)) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
rmdirSync(current);
|
|
90
|
+
dirsCleaned++;
|
|
91
|
+
} catch {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
prev = current;
|
|
95
|
+
current = dirname(current);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Non-empty or permission error — skip
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Delete manifest itself
|
|
104
|
+
try {
|
|
105
|
+
unlinkSync(manifestPath);
|
|
106
|
+
} catch {
|
|
107
|
+
// If manifest already gone, that's fine
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { removed, skipped, dirsCleaned };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a directory is empty.
|
|
115
|
+
* @param {string} dir
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function isEmptyDir(dir) {
|
|
119
|
+
try {
|
|
120
|
+
return readdirSync(dir).length === 0;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* CLI action handler for 'mk remove'.
|
|
128
|
+
* @param {{ global: boolean }} options
|
|
129
|
+
*/
|
|
130
|
+
export async function removeAction(options = {}) {
|
|
131
|
+
const targetDir = resolveTargetDir(options);
|
|
132
|
+
const manifestPath = resolveManifestPath(options);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
process.stdout.write('Removing MyClaudeKit files...\n');
|
|
136
|
+
const result = await runRemove({ targetDir, manifestPath });
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
chalk.green(
|
|
139
|
+
`\nSummary: ${result.removed} files removed, ${result.skipped} already missing, ${result.dirsCleaned} directories cleaned.\n`
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(chalk.red(`Error: ${err.message}\n`));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
|
|
6
|
+
import { computeChecksum } from '../lib/checksum.js';
|
|
7
|
+
import { copyKitFiles } from '../lib/copy.js';
|
|
8
|
+
import { resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
|
|
9
|
+
import { resolveTokenOrLogin } from '../lib/auth.js';
|
|
10
|
+
import { writeToken, readStoredToken } from '../lib/config.js';
|
|
11
|
+
import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run the update command.
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* sourceDir?: string,
|
|
18
|
+
* targetDir?: string,
|
|
19
|
+
* manifestPath?: string,
|
|
20
|
+
* force?: boolean
|
|
21
|
+
* }} params
|
|
22
|
+
* @returns {Promise<{ updated: string[], added: string[], removed: string[], conflicts: string[], unchanged: string[], upToDate: boolean }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function runUpdate(params = {}) {
|
|
25
|
+
const {
|
|
26
|
+
sourceDir = resolveSourceDir(),
|
|
27
|
+
targetDir = resolveTargetDir({ global: false }),
|
|
28
|
+
manifestPath = resolveManifestPath({ global: false }),
|
|
29
|
+
force = false
|
|
30
|
+
} = params;
|
|
31
|
+
|
|
32
|
+
// Read existing manifest
|
|
33
|
+
let manifest;
|
|
34
|
+
try {
|
|
35
|
+
manifest = readManifest(manifestPath);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`Not installed (no manifest found). Run 'mk init' first. (${err.message})`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Derive project root from manifest scope (global: homedir, project: dirname(manifestPath))
|
|
41
|
+
const projectRoot = deriveProjectRoot(manifest, manifestPath);
|
|
42
|
+
const claudeRoot = resolve(join(projectRoot, '.claude'));
|
|
43
|
+
const sourceFileList = copyKitFiles(sourceDir, targetDir, { dryRun: true });
|
|
44
|
+
const sourceFiles = {};
|
|
45
|
+
for (const entry of sourceFileList) {
|
|
46
|
+
const checksum = computeChecksum(entry.sourceAbsPath);
|
|
47
|
+
sourceFiles[entry.relativePath] = { checksum, size: entry.size };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get disk checksums for files currently in manifest
|
|
51
|
+
const diskChecksums = {};
|
|
52
|
+
for (const relPath of Object.keys(manifest.files)) {
|
|
53
|
+
// relPath is like '.claude/agents/foo.md' — relative to project root
|
|
54
|
+
const absPath = join(projectRoot, relPath);
|
|
55
|
+
// Bounds check: manifest keys must not escape .claude/ subtree
|
|
56
|
+
try {
|
|
57
|
+
assertSafePath(absPath, claudeRoot, `manifest key "${relPath}"`);
|
|
58
|
+
} catch {
|
|
59
|
+
process.stderr.write(`Warning: Skipping unsafe path in manifest: ${relPath}\n`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (existsSync(absPath)) {
|
|
63
|
+
diskChecksums[relPath] = computeChecksum(absPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Three-way diff
|
|
68
|
+
const diff = diffManifest(manifest, sourceFiles, diskChecksums);
|
|
69
|
+
|
|
70
|
+
const upToDate =
|
|
71
|
+
diff.updated.length === 0 &&
|
|
72
|
+
diff.added.length === 0 &&
|
|
73
|
+
diff.removed.length === 0 &&
|
|
74
|
+
diff.conflicts.length === 0;
|
|
75
|
+
|
|
76
|
+
if (upToDate) {
|
|
77
|
+
return { ...diff, upToDate: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Read package version (fileURLToPath handles Windows drive-letter prefix correctly)
|
|
81
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
|
|
82
|
+
|
|
83
|
+
const newFiles = { ...manifest.files };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Copy a source file entry to its destination.
|
|
87
|
+
* @param {string} relPath
|
|
88
|
+
*/
|
|
89
|
+
function applyCopy(relPath) {
|
|
90
|
+
const entry = sourceFileList.find(f => f.relativePath === relPath);
|
|
91
|
+
if (!entry) return;
|
|
92
|
+
const destAbs = join(projectRoot, relPath);
|
|
93
|
+
// Bounds check: destination must stay inside .claude/ subtree
|
|
94
|
+
assertSafePath(destAbs, claudeRoot, `copy destination for "${relPath}"`);
|
|
95
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
96
|
+
copyFileSync(entry.sourceAbsPath, destAbs);
|
|
97
|
+
// Reuse source checksum — copy is bit-for-bit identical, no need to re-hash
|
|
98
|
+
newFiles[relPath] = { checksum: sourceFiles[relPath].checksum, size: entry.size };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Apply safe updates
|
|
102
|
+
for (const relPath of diff.updated) {
|
|
103
|
+
applyCopy(relPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Handle conflicts
|
|
107
|
+
const skippedConflicts = [];
|
|
108
|
+
for (const relPath of diff.conflicts) {
|
|
109
|
+
if (force) {
|
|
110
|
+
applyCopy(relPath);
|
|
111
|
+
} else {
|
|
112
|
+
skippedConflicts.push(relPath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Add new files
|
|
117
|
+
for (const relPath of diff.added) {
|
|
118
|
+
applyCopy(relPath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Remove deleted kit files
|
|
122
|
+
for (const relPath of diff.removed) {
|
|
123
|
+
const absPath = join(projectRoot, relPath);
|
|
124
|
+
// Bounds check: removal must stay inside .claude/ subtree
|
|
125
|
+
try {
|
|
126
|
+
assertSafePath(absPath, claudeRoot, `removal target "${relPath}"`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
process.stderr.write(`Warning: Skipping unsafe removal path: ${err.message}\n`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(absPath);
|
|
133
|
+
} catch {
|
|
134
|
+
// Already missing — skip
|
|
135
|
+
}
|
|
136
|
+
delete newFiles[relPath];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update manifest with new file map
|
|
140
|
+
updateManifest(manifestPath, newFiles, pkg.version);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
updated: diff.updated,
|
|
144
|
+
added: diff.added,
|
|
145
|
+
removed: diff.removed,
|
|
146
|
+
conflicts: skippedConflicts,
|
|
147
|
+
unchanged: diff.unchanged,
|
|
148
|
+
upToDate: false
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* CLI action handler for 'mk update'.
|
|
154
|
+
* Downloads fresh kit from GitHub and applies three-way diff.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ force: boolean, global: boolean }} options
|
|
157
|
+
* @param {object} [deps] - Injected dependencies (for testing)
|
|
158
|
+
*/
|
|
159
|
+
export async function updateAction(options = {}, deps = {}) {
|
|
160
|
+
const {
|
|
161
|
+
resolveTokenOrLogin: resolveAndLogin = resolveTokenOrLogin,
|
|
162
|
+
downloadAndExtractKit: download = downloadAndExtractKit,
|
|
163
|
+
cleanupTempDir: cleanup = cleanupTempDir,
|
|
164
|
+
writeToken: storeToken = writeToken,
|
|
165
|
+
readStoredToken: readToken = readStoredToken
|
|
166
|
+
} = deps;
|
|
167
|
+
|
|
168
|
+
const targetDir = resolveTargetDir(options);
|
|
169
|
+
const manifestPath = resolveManifestPath(options);
|
|
170
|
+
|
|
171
|
+
let tempDir = null;
|
|
172
|
+
try {
|
|
173
|
+
process.stdout.write('Authenticating with GitHub...\n');
|
|
174
|
+
const token = await resolveAndLogin({
|
|
175
|
+
readStored: readToken,
|
|
176
|
+
writeStored: storeToken,
|
|
177
|
+
display: ({ userCode, verificationUri, expiresIn }) => {
|
|
178
|
+
process.stdout.write('\n');
|
|
179
|
+
process.stdout.write(chalk.bold('To authenticate, visit: ') + chalk.cyan(verificationUri) + '\n');
|
|
180
|
+
process.stdout.write(chalk.bold('And enter the code: ') + chalk.yellow(userCode) + '\n');
|
|
181
|
+
process.stdout.write(`(Code expires in ${Math.round(expiresIn / 60)} minutes)\n\n`);
|
|
182
|
+
process.stdout.write('Waiting for authorization...\n');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
process.stdout.write('Downloading latest kit from GitHub...\n');
|
|
187
|
+
tempDir = await download(token);
|
|
188
|
+
const sourceDir = join(tempDir, '.claude');
|
|
189
|
+
|
|
190
|
+
const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force });
|
|
191
|
+
|
|
192
|
+
if (result.upToDate) {
|
|
193
|
+
process.stdout.write(chalk.green('Already up to date.\n'));
|
|
194
|
+
} else {
|
|
195
|
+
if (result.updated.length > 0) {
|
|
196
|
+
process.stdout.write(chalk.green(`Updated: ${result.updated.length} files\n`));
|
|
197
|
+
}
|
|
198
|
+
if (result.added.length > 0) {
|
|
199
|
+
process.stdout.write(chalk.green(`Added: ${result.added.length} new files\n`));
|
|
200
|
+
}
|
|
201
|
+
if (result.removed.length > 0) {
|
|
202
|
+
process.stdout.write(chalk.yellow(`Removed: ${result.removed.length} files\n`));
|
|
203
|
+
}
|
|
204
|
+
if (result.conflicts.length > 0) {
|
|
205
|
+
process.stdout.write(
|
|
206
|
+
chalk.yellow(`Skipped: ${result.conflicts.length} user-modified files (use --force to overwrite)\n`)
|
|
207
|
+
);
|
|
208
|
+
for (const f of result.conflicts) {
|
|
209
|
+
process.stdout.write(` ${f}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
process.stderr.write(chalk.red(`Error: ${err.message}\n`));
|
|
215
|
+
process.exit(1);
|
|
216
|
+
} finally {
|
|
217
|
+
if (tempDir) {
|
|
218
|
+
cleanup(tempDir);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readStoredToken, writeToken } from './config.js';
|
|
2
|
+
import { GITHUB_API, KIT_REPO } from './constants.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Constants
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export const GITHUB_CLIENT_ID = 'Ov23li35aA2A1xVa01B6';
|
|
9
|
+
export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
10
|
+
export const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
11
|
+
|
|
12
|
+
// Default sleep helper (real delay in production, overridable in tests)
|
|
13
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Token resolution
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the current token from env vars or stored file.
|
|
21
|
+
* Priority: GITHUB_TOKEN > GH_TOKEN > stored file > null
|
|
22
|
+
*
|
|
23
|
+
* @param {{ readStored?: () => string|null }} [opts]
|
|
24
|
+
* @returns {string|null}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveToken(opts = {}) {
|
|
27
|
+
const readStored = opts.readStored ?? readStoredToken;
|
|
28
|
+
|
|
29
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
30
|
+
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
|
31
|
+
return readStored() ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Identify the source of a token value.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} token
|
|
38
|
+
* @param {{ readStored?: () => string|null }} [opts]
|
|
39
|
+
* @returns {'env:GITHUB_TOKEN' | 'env:GH_TOKEN' | 'stored' | 'unknown'}
|
|
40
|
+
*/
|
|
41
|
+
export function tokenSource(token, opts = {}) {
|
|
42
|
+
const readStored = opts.readStored ?? readStoredToken;
|
|
43
|
+
|
|
44
|
+
if (process.env.GITHUB_TOKEN === token) return 'env:GITHUB_TOKEN';
|
|
45
|
+
if (process.env.GH_TOKEN === token) return 'env:GH_TOKEN';
|
|
46
|
+
if (readStored() === token) return 'stored';
|
|
47
|
+
return 'unknown';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Token validation
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a token by calling the GitHub /user endpoint.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} token
|
|
58
|
+
* @returns {Promise<{ valid: boolean, username?: string }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function validateToken(token) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${GITHUB_API}/user`, {
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${token}`,
|
|
65
|
+
Accept: 'application/vnd.github.v3+json'
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (res.ok) {
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
return { valid: true, username: data.login };
|
|
71
|
+
}
|
|
72
|
+
return { valid: false };
|
|
73
|
+
} catch {
|
|
74
|
+
return { valid: false };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check whether the token has access to the kit repository.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} token
|
|
82
|
+
* @returns {Promise<{ accessible: boolean }>}
|
|
83
|
+
*/
|
|
84
|
+
export async function checkRepoAccess(token) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${GITHUB_API}/repos/${KIT_REPO}`, {
|
|
87
|
+
headers: {
|
|
88
|
+
Authorization: `Bearer ${token}`,
|
|
89
|
+
Accept: 'application/vnd.github.v3+json'
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return { accessible: res.ok };
|
|
93
|
+
} catch {
|
|
94
|
+
return { accessible: false };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// OAuth Device Flow
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run the GitHub OAuth Device Flow and return the access token.
|
|
104
|
+
*
|
|
105
|
+
* @param {{
|
|
106
|
+
* display: (info: { userCode: string, verificationUri: string, expiresIn: number }) => void,
|
|
107
|
+
* writeStored?: (token: string) => void,
|
|
108
|
+
* sleep?: (ms: number) => Promise<void>
|
|
109
|
+
* }} opts
|
|
110
|
+
* @returns {Promise<string>} Access token
|
|
111
|
+
*/
|
|
112
|
+
export async function startDeviceFlow(opts = {}) {
|
|
113
|
+
const {
|
|
114
|
+
display,
|
|
115
|
+
writeStored = writeToken,
|
|
116
|
+
sleep = defaultSleep
|
|
117
|
+
} = opts;
|
|
118
|
+
|
|
119
|
+
// Step 1: Request device + user code
|
|
120
|
+
const codeRes = await fetch(GITHUB_DEVICE_CODE_URL, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
124
|
+
Accept: 'application/json'
|
|
125
|
+
},
|
|
126
|
+
body: `client_id=${GITHUB_CLIENT_ID}&scope=repo`
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!codeRes.ok) {
|
|
130
|
+
throw new Error(`Device flow failed: ${codeRes.status} ${codeRes.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const codeData = await codeRes.json();
|
|
134
|
+
const { device_code, user_code, verification_uri, interval = 5, expires_in = 900 } = codeData;
|
|
135
|
+
|
|
136
|
+
// Display instructions to the user
|
|
137
|
+
display({ userCode: user_code, verificationUri: verification_uri, expiresIn: expires_in });
|
|
138
|
+
|
|
139
|
+
// Step 2: Poll for the access token
|
|
140
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
141
|
+
let pollInterval = interval * 1000;
|
|
142
|
+
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
await sleep(pollInterval);
|
|
145
|
+
|
|
146
|
+
const tokenRes = await fetch(GITHUB_TOKEN_URL, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
150
|
+
Accept: 'application/json'
|
|
151
|
+
},
|
|
152
|
+
body: `client_id=${GITHUB_CLIENT_ID}&device_code=${device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const tokenData = await tokenRes.json();
|
|
156
|
+
|
|
157
|
+
if (tokenData.access_token) {
|
|
158
|
+
const token = tokenData.access_token;
|
|
159
|
+
writeStored(token);
|
|
160
|
+
return token;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
switch (tokenData.error) {
|
|
164
|
+
case 'authorization_pending':
|
|
165
|
+
// User hasn't authorized yet — keep polling
|
|
166
|
+
break;
|
|
167
|
+
case 'slow_down':
|
|
168
|
+
// GitHub asks us to slow down
|
|
169
|
+
pollInterval += 5000;
|
|
170
|
+
break;
|
|
171
|
+
case 'expired_token':
|
|
172
|
+
throw new Error('Authentication timed out. Please try \'mk auth login\' again.');
|
|
173
|
+
case 'access_denied':
|
|
174
|
+
throw new Error('Access denied by user. Authentication cancelled.');
|
|
175
|
+
default:
|
|
176
|
+
throw new Error(`Device flow error: ${tokenData.error}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new Error('Authentication timed out. Please try \'mk auth login\' again.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Combined: resolve or trigger login
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return an existing token, or run the device flow if none is available.
|
|
189
|
+
*
|
|
190
|
+
* @param {{
|
|
191
|
+
* readStored?: () => string|null,
|
|
192
|
+
* writeStored?: (token: string) => void,
|
|
193
|
+
* display: (info: object) => void,
|
|
194
|
+
* sleep?: (ms: number) => Promise<void>
|
|
195
|
+
* }} opts
|
|
196
|
+
* @returns {Promise<string>}
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveTokenOrLogin(opts = {}) {
|
|
199
|
+
const {
|
|
200
|
+
readStored = readStoredToken,
|
|
201
|
+
writeStored = writeToken,
|
|
202
|
+
display,
|
|
203
|
+
sleep = defaultSleep
|
|
204
|
+
} = opts;
|
|
205
|
+
|
|
206
|
+
const existing = resolveToken({ readStored });
|
|
207
|
+
if (existing) return existing;
|
|
208
|
+
|
|
209
|
+
// No token — run device flow
|
|
210
|
+
return startDeviceFlow({ display, writeStored, sleep });
|
|
211
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute SHA-256 checksum of a file.
|
|
6
|
+
* @param {string} filePath - Absolute path to file
|
|
7
|
+
* @returns {string} Checksum string prefixed with 'sha256:'
|
|
8
|
+
*/
|
|
9
|
+
export function computeChecksum(filePath) {
|
|
10
|
+
const content = readFileSync(filePath);
|
|
11
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
12
|
+
return `sha256:${hash}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, chmodSync, existsSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the config directory for mk.
|
|
7
|
+
* XDG-compliant: ~/.config/mk on Unix/macOS, %APPDATA%/mk on Windows.
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function getConfigDir() {
|
|
11
|
+
const appData = process.env.APPDATA;
|
|
12
|
+
// Reject UNC paths (\\server\share) — these could redirect token storage to attacker-controlled
|
|
13
|
+
// network shares. Fall back to the XDG path if APPDATA looks suspicious.
|
|
14
|
+
if (appData && !appData.startsWith('\\\\')) {
|
|
15
|
+
return join(appData, 'mk');
|
|
16
|
+
}
|
|
17
|
+
return join(homedir(), '.config', 'mk');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the token file path.
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function getTokenPath() {
|
|
25
|
+
return join(getConfigDir(), 'token');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the stored token from disk.
|
|
30
|
+
* @returns {string|null} Token string or null if not found.
|
|
31
|
+
*/
|
|
32
|
+
export function readStoredToken() {
|
|
33
|
+
const tokenPath = getTokenPath();
|
|
34
|
+
if (!existsSync(tokenPath)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(tokenPath, 'utf8').trim();
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the token to disk. Creates config dir if needed. Sets chmod 600 on Unix.
|
|
46
|
+
* @param {string} token
|
|
47
|
+
*/
|
|
48
|
+
export function writeToken(token) {
|
|
49
|
+
const configDir = getConfigDir();
|
|
50
|
+
const tokenPath = getTokenPath();
|
|
51
|
+
mkdirSync(configDir, { recursive: true });
|
|
52
|
+
// Write with mode 0o600 atomically — prevents TOCTOU window between write and chmod.
|
|
53
|
+
// On Windows the mode flag is ignored; home-dir ACLs provide equivalent protection.
|
|
54
|
+
writeFileSync(tokenPath, token, { encoding: 'utf8', mode: 0o600 });
|
|
55
|
+
if (process.platform !== 'win32') {
|
|
56
|
+
// Explicit chmod ensures mode is set even if umask overrides the O_CREAT mode.
|
|
57
|
+
chmodSync(tokenPath, 0o600);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Delete the stored token file.
|
|
63
|
+
* Does not throw if the file does not exist.
|
|
64
|
+
*/
|
|
65
|
+
export function deleteToken() {
|
|
66
|
+
const tokenPath = getTokenPath();
|
|
67
|
+
try {
|
|
68
|
+
unlinkSync(tokenPath);
|
|
69
|
+
} catch {
|
|
70
|
+
// File doesn't exist — that's fine
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kit subdirectories to copy/manage (relative to .claude/)
|
|
3
|
+
*/
|
|
4
|
+
export const KIT_SUBDIRS = ['agents', 'skills', 'workflows'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Manifest file name
|
|
8
|
+
*/
|
|
9
|
+
export const MANIFEST_FILENAME = '.mk-manifest.json';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Files/patterns to exclude during copy
|
|
13
|
+
*/
|
|
14
|
+
export const COPY_FILTER_PATTERNS = [
|
|
15
|
+
'__pycache__',
|
|
16
|
+
'.pyc',
|
|
17
|
+
'.pyo',
|
|
18
|
+
'node_modules',
|
|
19
|
+
'.DS_Store',
|
|
20
|
+
'package-lock.json',
|
|
21
|
+
'.env'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Windows path length warning threshold (leave 20 chars of buffer)
|
|
26
|
+
*/
|
|
27
|
+
export const WINDOWS_PATH_WARN_LENGTH = 240;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GitHub API base URL
|
|
31
|
+
*/
|
|
32
|
+
export const GITHUB_API = 'https://api.github.com';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Kit repository (owner/repo)
|
|
36
|
+
*/
|
|
37
|
+
export const KIT_REPO = 'khanhtran148/mk-kit';
|