@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
package/src/lib/copy.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { statSync, lstatSync, existsSync } from 'node:fs';
|
|
3
|
+
import fsExtra from 'fs-extra';
|
|
4
|
+
import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if a path should be filtered out during copy.
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
* @returns {boolean} true if the file should be excluded
|
|
10
|
+
*/
|
|
11
|
+
function shouldFilter(filePath) {
|
|
12
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
13
|
+
for (const pattern of COPY_FILTER_PATTERNS) {
|
|
14
|
+
if (normalized.includes(pattern)) return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collect all file paths recursively in a directory.
|
|
21
|
+
* @param {string} dir
|
|
22
|
+
* @returns {string[]}
|
|
23
|
+
*/
|
|
24
|
+
function collectFiles(dir) {
|
|
25
|
+
const results = [];
|
|
26
|
+
function walk(current) {
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = fsExtra.readdirSync(current);
|
|
30
|
+
} catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const fullPath = join(current, entry);
|
|
35
|
+
try {
|
|
36
|
+
// Use lstatSync (does not follow symlinks) to detect and skip symlinks.
|
|
37
|
+
// Symlinks inside the bundled .claude/ could otherwise redirect writes
|
|
38
|
+
// to arbitrary locations on the user's filesystem.
|
|
39
|
+
const lstat = lstatSync(fullPath);
|
|
40
|
+
if (lstat.isSymbolicLink()) continue;
|
|
41
|
+
if (lstat.isDirectory()) {
|
|
42
|
+
walk(fullPath);
|
|
43
|
+
} else {
|
|
44
|
+
results.push(fullPath);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// skip inaccessible files
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
walk(dir);
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
|
|
57
|
+
* Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
|
|
58
|
+
*
|
|
59
|
+
* @param {string} sourceDir - Absolute path to source .claude/
|
|
60
|
+
* @param {string} targetDir - Absolute path to target .claude/
|
|
61
|
+
* @param {{ dryRun: boolean }} options
|
|
62
|
+
* @returns {Array<{ relativePath: string, absolutePath: string, sourceAbsPath: string, size: number }>}
|
|
63
|
+
*/
|
|
64
|
+
export function copyKitFiles(sourceDir, targetDir, options = {}) {
|
|
65
|
+
const { dryRun = false } = options;
|
|
66
|
+
const fileList = [];
|
|
67
|
+
const warnings = [];
|
|
68
|
+
|
|
69
|
+
for (const subdir of KIT_SUBDIRS) {
|
|
70
|
+
const srcSubdir = join(sourceDir, subdir);
|
|
71
|
+
if (!existsSync(srcSubdir)) continue;
|
|
72
|
+
|
|
73
|
+
// Collect files in this subdir
|
|
74
|
+
const files = collectFiles(srcSubdir);
|
|
75
|
+
|
|
76
|
+
for (const absPath of files) {
|
|
77
|
+
if (shouldFilter(absPath)) continue;
|
|
78
|
+
|
|
79
|
+
const relFromSubdir = relative(srcSubdir, absPath);
|
|
80
|
+
const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
|
|
81
|
+
const destAbs = join(targetDir, subdir, relFromSubdir);
|
|
82
|
+
|
|
83
|
+
// Windows path length check
|
|
84
|
+
if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
|
|
85
|
+
warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let size = 0;
|
|
89
|
+
try {
|
|
90
|
+
size = statSync(absPath).size;
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Print warnings
|
|
100
|
+
for (const w of warnings) {
|
|
101
|
+
process.stderr.write(w + '\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
return fileList;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Actually copy each subdir using fs-extra
|
|
109
|
+
for (const subdir of KIT_SUBDIRS) {
|
|
110
|
+
const srcSubdir = join(sourceDir, subdir);
|
|
111
|
+
if (!existsSync(srcSubdir)) continue;
|
|
112
|
+
const destSubdir = join(targetDir, subdir);
|
|
113
|
+
|
|
114
|
+
fsExtra.copySync(srcSubdir, destSubdir, {
|
|
115
|
+
overwrite: true,
|
|
116
|
+
filter: (src) => {
|
|
117
|
+
if (shouldFilter(src)) return false;
|
|
118
|
+
try {
|
|
119
|
+
// Skip symlinks — prevents traversal to paths outside destSubdir
|
|
120
|
+
if (lstatSync(src).isSymbolicLink()) return false;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return fileList;
|
|
130
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createGunzip } from 'node:zlib';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { Writable, Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { GITHUB_API, KIT_REPO } from './constants.js';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Constants
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const KIT_BRANCH = 'main';
|
|
14
|
+
const TARBALL_URL = `${GITHUB_API}/repos/${KIT_REPO}/tarball/${KIT_BRANCH}`;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Manual tar stream parser (zero-dependency, handles regular files + dirs)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A Writable stream that parses tar format and writes matching entries to disk.
|
|
22
|
+
* Only processes entries whose paths contain '.claude/' after stripping the root prefix.
|
|
23
|
+
*
|
|
24
|
+
* Tar format: 512-byte header blocks followed by data blocks (padded to 512 bytes).
|
|
25
|
+
* Two consecutive 512-byte zero blocks mark end of archive.
|
|
26
|
+
*/
|
|
27
|
+
class TarExtractor extends Writable {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} destDir - Destination directory (resolved to absolute path)
|
|
30
|
+
*/
|
|
31
|
+
constructor(destDir) {
|
|
32
|
+
super();
|
|
33
|
+
this.destDir = resolve(destDir);
|
|
34
|
+
// Chunk list avoids O(n²) Buffer.concat on every write
|
|
35
|
+
this._chunks = [];
|
|
36
|
+
this._totalLen = 0;
|
|
37
|
+
this._state = 'header'; // 'header' | 'data' | 'skip'
|
|
38
|
+
this._remaining = 0; // bytes left in current entry data
|
|
39
|
+
this._paddedSize = 0; // padded size of current entry (multiple of 512)
|
|
40
|
+
this._currentPath = ''; // relative path being written ('' if not .claude/)
|
|
41
|
+
this._rootPrefix = null; // first root directory prefix to strip
|
|
42
|
+
this._zeroBlocks = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_write(chunk, encoding, callback) {
|
|
46
|
+
this._chunks.push(chunk);
|
|
47
|
+
this._totalLen += chunk.length;
|
|
48
|
+
try {
|
|
49
|
+
this._process();
|
|
50
|
+
callback();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
callback(err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Consolidate pending chunks into one Buffer (lazy — only when access needed). */
|
|
57
|
+
_getBuffer() {
|
|
58
|
+
if (this._chunks.length !== 1) {
|
|
59
|
+
this._chunks = [Buffer.concat(this._chunks)];
|
|
60
|
+
}
|
|
61
|
+
return this._chunks[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Consume n bytes from the front of the chunk list. */
|
|
65
|
+
_consumeBuffer(n) {
|
|
66
|
+
const buf = this._getBuffer();
|
|
67
|
+
const remaining = buf.slice(n);
|
|
68
|
+
this._chunks = remaining.length > 0 ? [remaining] : [];
|
|
69
|
+
this._totalLen -= n;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_process() {
|
|
73
|
+
while (this._totalLen >= 512) {
|
|
74
|
+
if (this._state === 'header') {
|
|
75
|
+
this._parseHeader();
|
|
76
|
+
} else if (this._state === 'data') {
|
|
77
|
+
this._readData();
|
|
78
|
+
} else if (this._state === 'skip') {
|
|
79
|
+
this._skipData();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Don't loop if we can't make progress
|
|
83
|
+
if (this._state === 'data' && this._totalLen < 512) break;
|
|
84
|
+
if (this._state === 'skip' && this._totalLen < 512) break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Assert that resolvedPath is safely contained within this.destDir.
|
|
90
|
+
* Throws if the path would escape the destination directory.
|
|
91
|
+
* @param {string} resolvedPath
|
|
92
|
+
*/
|
|
93
|
+
_assertSafe(resolvedPath) {
|
|
94
|
+
if (resolvedPath !== this.destDir && !resolvedPath.startsWith(this.destDir + sep)) {
|
|
95
|
+
throw new Error(`Path traversal detected: "${resolvedPath}" escapes destination directory`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_parseHeader() {
|
|
100
|
+
const block = this._getBuffer().slice(0, 512);
|
|
101
|
+
|
|
102
|
+
// Check for zero block (end of archive)
|
|
103
|
+
if (block[0] === 0 && block.every(b => b === 0)) {
|
|
104
|
+
this._zeroBlocks++;
|
|
105
|
+
this._consumeBuffer(512);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this._zeroBlocks = 0;
|
|
109
|
+
|
|
110
|
+
// Parse header fields
|
|
111
|
+
const rawName = block.slice(0, 100).toString('utf8').replace(/\0+$/, '');
|
|
112
|
+
const prefix = block.slice(345, 500).toString('utf8').replace(/\0+$/, '');
|
|
113
|
+
const fullName = prefix ? `${prefix}/${rawName}` : rawName;
|
|
114
|
+
|
|
115
|
+
const sizeOctal = block.slice(124, 136).toString('utf8').replace(/\0/g, '').trim();
|
|
116
|
+
const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
|
|
117
|
+
|
|
118
|
+
const typeFlag = String.fromCharCode(block[156]) || '0';
|
|
119
|
+
|
|
120
|
+
// Detect and strip root prefix (first directory component)
|
|
121
|
+
if (this._rootPrefix === null) {
|
|
122
|
+
const firstSlash = fullName.indexOf('/');
|
|
123
|
+
this._rootPrefix = firstSlash >= 0 ? fullName.slice(0, firstSlash + 1) : '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const strippedName = fullName.startsWith(this._rootPrefix)
|
|
127
|
+
? fullName.slice(this._rootPrefix.length)
|
|
128
|
+
: fullName;
|
|
129
|
+
|
|
130
|
+
this._consumeBuffer(512);
|
|
131
|
+
|
|
132
|
+
// Block symlinks and hard links entirely — prevents symlink-based escapes
|
|
133
|
+
if (typeFlag === '1' || typeFlag === '2') {
|
|
134
|
+
this._state = 'header';
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Only process entries under .claude/
|
|
139
|
+
const isClaudePath = strippedName.startsWith('.claude/');
|
|
140
|
+
|
|
141
|
+
if (typeFlag === '5' || typeFlag === '\0' || typeFlag === '') {
|
|
142
|
+
// Directory entry
|
|
143
|
+
if (isClaudePath && strippedName) {
|
|
144
|
+
const dirPath = resolve(join(this.destDir, strippedName));
|
|
145
|
+
this._assertSafe(dirPath);
|
|
146
|
+
mkdirSync(dirPath, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
this._state = 'header';
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Regular file entry (typeFlag '0' or empty/null)
|
|
153
|
+
if (size === 0) {
|
|
154
|
+
if (isClaudePath) {
|
|
155
|
+
const filePath = resolve(join(this.destDir, strippedName));
|
|
156
|
+
this._assertSafe(filePath);
|
|
157
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
158
|
+
writeFileSync(filePath, Buffer.alloc(0));
|
|
159
|
+
}
|
|
160
|
+
this._state = 'header';
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this._remaining = size;
|
|
165
|
+
this._paddedSize = Math.ceil(size / 512) * 512;
|
|
166
|
+
|
|
167
|
+
if (isClaudePath) {
|
|
168
|
+
this._currentPath = strippedName;
|
|
169
|
+
this._state = 'data';
|
|
170
|
+
} else {
|
|
171
|
+
this._state = 'skip';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_readData() {
|
|
176
|
+
if (this._totalLen < this._paddedSize && this._totalLen < 512) {
|
|
177
|
+
return; // Wait for more data
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (this._totalLen >= this._paddedSize) {
|
|
181
|
+
// We have all the data for this entry
|
|
182
|
+
const buf = this._getBuffer();
|
|
183
|
+
const rawData = buf.slice(0, this._remaining);
|
|
184
|
+
this._consumeBuffer(this._paddedSize);
|
|
185
|
+
|
|
186
|
+
const filePath = resolve(join(this.destDir, this._currentPath));
|
|
187
|
+
this._assertSafe(filePath);
|
|
188
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
189
|
+
writeFileSync(filePath, rawData);
|
|
190
|
+
|
|
191
|
+
this._currentPath = '';
|
|
192
|
+
this._state = 'header';
|
|
193
|
+
}
|
|
194
|
+
// else: not enough data yet — wait
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_skipData() {
|
|
198
|
+
if (this._totalLen < this._paddedSize) {
|
|
199
|
+
return; // Wait for more data
|
|
200
|
+
}
|
|
201
|
+
this._consumeBuffer(this._paddedSize);
|
|
202
|
+
this._state = 'header';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_final(callback) {
|
|
206
|
+
callback();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Public API
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Download the kit repository as a tarball and extract .claude/ to targetDir.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} token - GitHub Bearer token
|
|
218
|
+
* @param {{ targetDir?: string }} [opts]
|
|
219
|
+
* @returns {Promise<string>} The targetDir path
|
|
220
|
+
*/
|
|
221
|
+
export async function downloadAndExtractKit(token, opts = {}) {
|
|
222
|
+
const { targetDir = mkdtempSync(join(tmpdir(), 'mk-kit-')) } = opts;
|
|
223
|
+
|
|
224
|
+
let res;
|
|
225
|
+
try {
|
|
226
|
+
res = await fetch(TARBALL_URL, {
|
|
227
|
+
headers: {
|
|
228
|
+
Authorization: `Bearer ${token}`,
|
|
229
|
+
Accept: 'application/vnd.github.v3+json'
|
|
230
|
+
},
|
|
231
|
+
redirect: 'follow'
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
throw new Error(`Network connection failed: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
mkdirSync(targetDir, { recursive: true });
|
|
242
|
+
|
|
243
|
+
const gunzip = createGunzip();
|
|
244
|
+
const extractor = new TarExtractor(targetDir);
|
|
245
|
+
|
|
246
|
+
await pipeline(
|
|
247
|
+
Readable.fromWeb(res.body),
|
|
248
|
+
gunzip,
|
|
249
|
+
extractor
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return targetDir;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove a temp directory created by downloadAndExtractKit.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} tempDir
|
|
259
|
+
*/
|
|
260
|
+
export function cleanupTempDir(tempDir) {
|
|
261
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
262
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write a manifest file to disk.
|
|
5
|
+
* @param {string} manifestPath - Absolute path to write
|
|
6
|
+
* @param {Object} files - Map of relativePath -> { checksum, size }
|
|
7
|
+
* @param {string} version - Kit version
|
|
8
|
+
* @param {'project'|'global'} scope
|
|
9
|
+
*/
|
|
10
|
+
export function writeManifest(manifestPath, files, version, scope) {
|
|
11
|
+
const now = new Date().toISOString();
|
|
12
|
+
const manifest = {
|
|
13
|
+
version,
|
|
14
|
+
installedAt: now,
|
|
15
|
+
updatedAt: now,
|
|
16
|
+
scope,
|
|
17
|
+
files
|
|
18
|
+
};
|
|
19
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Update an existing manifest's files, version, and updatedAt.
|
|
24
|
+
* @param {string} manifestPath
|
|
25
|
+
* @param {Object} files - New files map
|
|
26
|
+
* @param {string} version - New kit version
|
|
27
|
+
*/
|
|
28
|
+
export function updateManifest(manifestPath, files, version) {
|
|
29
|
+
const existing = readManifest(manifestPath);
|
|
30
|
+
const updated = {
|
|
31
|
+
...existing,
|
|
32
|
+
version,
|
|
33
|
+
updatedAt: new Date().toISOString(),
|
|
34
|
+
files
|
|
35
|
+
};
|
|
36
|
+
writeFileSync(manifestPath, JSON.stringify(updated, null, 2), 'utf8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read and parse a manifest file.
|
|
41
|
+
* @param {string} manifestPath
|
|
42
|
+
* @returns {Object} Parsed manifest
|
|
43
|
+
* @throws {Error} If file not found or invalid JSON
|
|
44
|
+
*/
|
|
45
|
+
export function readManifest(manifestPath) {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(manifestPath, 'utf8');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') {
|
|
51
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(raw);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`Invalid JSON in manifest: ${manifestPath} (${err.message})`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Perform a three-way diff between manifest, source files, and current disk state.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} manifest - Parsed manifest object
|
|
66
|
+
* @param {Object} sourceFiles - Map of relativePath -> { checksum, size } from source
|
|
67
|
+
* @param {Object} diskChecksums - Map of relativePath -> checksum of current disk state
|
|
68
|
+
* @returns {{ unchanged: string[], updated: string[], conflicts: string[], added: string[], removed: string[] }}
|
|
69
|
+
*/
|
|
70
|
+
export function diffManifest(manifest, sourceFiles, diskChecksums) {
|
|
71
|
+
const result = {
|
|
72
|
+
unchanged: [],
|
|
73
|
+
updated: [],
|
|
74
|
+
conflicts: [],
|
|
75
|
+
added: [],
|
|
76
|
+
removed: []
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const manifestFiles = manifest.files || {};
|
|
80
|
+
|
|
81
|
+
// Check all source files against manifest
|
|
82
|
+
for (const [relPath, sourceEntry] of Object.entries(sourceFiles)) {
|
|
83
|
+
const sourceChecksum = sourceEntry.checksum;
|
|
84
|
+
|
|
85
|
+
if (!(relPath in manifestFiles)) {
|
|
86
|
+
// New file in source not in manifest -> ADD
|
|
87
|
+
result.added.push(relPath);
|
|
88
|
+
} else {
|
|
89
|
+
const manifestChecksum = manifestFiles[relPath].checksum;
|
|
90
|
+
if (sourceChecksum === manifestChecksum) {
|
|
91
|
+
// Source matches manifest -> UNCHANGED
|
|
92
|
+
result.unchanged.push(relPath);
|
|
93
|
+
} else {
|
|
94
|
+
// Source differs from manifest -> kit updated this file
|
|
95
|
+
const diskChecksum = diskChecksums[relPath];
|
|
96
|
+
if (!diskChecksum || diskChecksum === manifestChecksum) {
|
|
97
|
+
// Disk is same as manifest (user didn't modify) -> SAFE UPDATE
|
|
98
|
+
result.updated.push(relPath);
|
|
99
|
+
} else {
|
|
100
|
+
// Disk differs from manifest (user modified) AND kit changed -> CONFLICT
|
|
101
|
+
result.conflicts.push(relPath);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check manifest files not in source -> REMOVED from kit
|
|
108
|
+
for (const relPath of Object.keys(manifestFiles)) {
|
|
109
|
+
if (!(relPath in sourceFiles)) {
|
|
110
|
+
result.removed.push(relPath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the source .claude/ directory from the npm package root.
|
|
9
|
+
* When installed via npm, the package root is two levels up from src/lib/.
|
|
10
|
+
* @returns {string} Absolute path to the package's .claude/ directory
|
|
11
|
+
*/
|
|
12
|
+
export function resolveSourceDir() {
|
|
13
|
+
// src/lib/paths.js -> src/lib -> src -> packageRoot
|
|
14
|
+
const packageRoot = join(__dirname, '..', '..');
|
|
15
|
+
return join(packageRoot, '.claude');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the target .claude/ directory.
|
|
20
|
+
* @param {{ global: boolean }} options
|
|
21
|
+
* @returns {string} Absolute path to target .claude/ directory
|
|
22
|
+
*/
|
|
23
|
+
export function resolveTargetDir(options) {
|
|
24
|
+
if (options.global) {
|
|
25
|
+
return join(homedir(), '.claude');
|
|
26
|
+
}
|
|
27
|
+
return join(process.cwd(), '.claude');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the manifest file path.
|
|
32
|
+
* @param {{ global: boolean }} options
|
|
33
|
+
* @returns {string} Absolute path to .mk-manifest.json
|
|
34
|
+
*/
|
|
35
|
+
export function resolveManifestPath(options) {
|
|
36
|
+
if (options.global) {
|
|
37
|
+
return join(homedir(), '.claude', '.mk-manifest.json');
|
|
38
|
+
}
|
|
39
|
+
return join(process.cwd(), '.mk-manifest.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Derive project root from manifest scope and path.
|
|
44
|
+
* For project installs the manifest is at cwd/.mk-manifest.json → root = cwd.
|
|
45
|
+
* For global installs the manifest is at ~/.claude/.mk-manifest.json → root = homedir,
|
|
46
|
+
* because all relPaths start with '.claude/' and the actual files live at ~/.<file>.
|
|
47
|
+
* @param {Object} manifest - Parsed manifest object
|
|
48
|
+
* @param {string} manifestPath - Absolute path to .mk-manifest.json
|
|
49
|
+
* @returns {string} Absolute project root path
|
|
50
|
+
*/
|
|
51
|
+
export function deriveProjectRoot(manifest, manifestPath) {
|
|
52
|
+
return manifest.scope === 'global' ? homedir() : dirname(manifestPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Assert that absPath is safely inside rootDir (prevent path-traversal from manifest keys).
|
|
57
|
+
* Throws if the resolved path escapes the allowed directory.
|
|
58
|
+
* @param {string} absPath - Path to validate
|
|
59
|
+
* @param {string} rootDir - Allowed root directory
|
|
60
|
+
* @param {string} [label] - Label for error messages
|
|
61
|
+
*/
|
|
62
|
+
export function assertSafePath(absPath, rootDir, label = 'path') {
|
|
63
|
+
const resolved = resolve(absPath);
|
|
64
|
+
const root = resolve(rootDir);
|
|
65
|
+
if (resolved !== root && !resolved.startsWith(root + sep)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Security: ${label} "${absPath}" resolves outside allowed directory "${rootDir}"`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|