@khanhcan148/mk 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhcan148/mk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI to install and manage MyClaudeKit (.claude/) in your projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "dependencies": {
22
22
  "chalk": "^5.0.0",
23
23
  "commander": "^12.0.0",
24
- "fs-extra": "^11.0.0"
24
+ "fs-extra": "^11.0.0",
25
+ "semver": "^7.0.0"
25
26
  },
26
27
  "keywords": [
27
28
  "claude",
@@ -10,6 +10,7 @@ import { MANIFEST_FILENAME } from '../lib/constants.js';
10
10
  import { resolveTokenOrLogin } from '../lib/auth.js';
11
11
  import { writeToken, readStoredToken } from '../lib/config.js';
12
12
  import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
13
+ import { fetchLatestRelease } from '../lib/releases.js';
13
14
 
14
15
  /**
15
16
  * Run the init command.
@@ -19,7 +20,8 @@ import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
19
20
  * targetDir?: string,
20
21
  * manifestPath?: string,
21
22
  * scope?: 'project'|'global',
22
- * dryRun?: boolean
23
+ * dryRun?: boolean,
24
+ * version?: string - Kit version to store in manifest (defaults to pkg.version when omitted)
23
25
  * }} params
24
26
  * @returns {Promise<{ files: Array, totalSize: number }>}
25
27
  */
@@ -29,7 +31,8 @@ export async function runInit(params = {}) {
29
31
  targetDir = resolveTargetDir({ global: false }),
30
32
  manifestPath = resolveManifestPath({ global: false }),
31
33
  scope = 'project',
32
- dryRun = false
34
+ dryRun = false,
35
+ version: explicitVersion
33
36
  } = params;
34
37
 
35
38
  // Guard: abort if already installed
@@ -61,8 +64,10 @@ export async function runInit(params = {}) {
61
64
  }
62
65
  }
63
66
 
64
- // Write manifest
65
- writeManifest(manifestPath, files, pkg.version, scope);
67
+ // Write manifest.
68
+ // Use explicitVersion when provided (e.g. release.version from initAction);
69
+ // fall back to pkg.version for direct runInit calls without a known release version.
70
+ writeManifest(manifestPath, files, explicitVersion ?? pkg.version, scope);
66
71
 
67
72
  const totalSize = fileList.reduce((s, f) => s + f.size, 0);
68
73
  return { files: fileList, totalSize, fileCount: fileList.length };
@@ -81,11 +86,15 @@ export async function initAction(options = {}, deps = {}) {
81
86
  downloadAndExtractKit: download = downloadAndExtractKit,
82
87
  cleanupTempDir: cleanup = cleanupTempDir,
83
88
  writeToken: storeToken = writeToken,
84
- readStoredToken: readToken = readStoredToken
89
+ readStoredToken: readToken = readStoredToken,
90
+ fetchLatestRelease: fetchRelease = fetchLatestRelease,
91
+ // Injectable for tests — allows overriding resolved paths without touching CWD
92
+ targetDir: injectedTargetDir,
93
+ manifestPath: injectedManifestPath
85
94
  } = deps;
86
95
 
87
- const targetDir = resolveTargetDir(options);
88
- const manifestPath = resolveManifestPath(options);
96
+ const targetDir = injectedTargetDir ?? resolveTargetDir(options);
97
+ const manifestPath = injectedManifestPath ?? resolveManifestPath(options);
89
98
  const scope = options.global ? 'global' : 'project';
90
99
 
91
100
  // Pre-flight: warn if target exists with files
@@ -115,11 +124,23 @@ export async function initAction(options = {}, deps = {}) {
115
124
  }
116
125
  });
117
126
 
127
+ // Determine installed version from latest release (more accurate than pkg.version).
128
+ // Falls back silently to pkg.version if no release is available (new repo, API error, etc.)
129
+ let installedVersion;
130
+ if (!options.dryRun) {
131
+ try {
132
+ const release = await fetchRelease(token);
133
+ installedVersion = release.available ? release.version : undefined;
134
+ } catch {
135
+ // Ignore errors — version fallback handled by runInit
136
+ }
137
+ }
138
+
118
139
  process.stdout.write('Downloading kit from GitHub...\n');
119
140
  tempDir = await download(token);
120
141
  const sourceDir = join(tempDir, '.claude');
121
142
 
122
- const result = await runInit({ sourceDir, targetDir, manifestPath, scope, dryRun: options.dryRun });
143
+ const result = await runInit({ sourceDir, targetDir, manifestPath, scope, dryRun: options.dryRun, version: installedVersion });
123
144
 
124
145
  if (options.dryRun) {
125
146
  process.stdout.write(`Would install ${result.files.length} files:\n`);
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
+ import { createInterface } from 'node:readline';
2
3
  import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'node:fs';
3
- import { join, dirname, resolve, sep } from 'node:path';
4
+ import { join, dirname, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
6
7
  import { computeChecksum } from '../lib/checksum.js';
@@ -9,6 +10,28 @@ import { resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePat
9
10
  import { resolveTokenOrLogin } from '../lib/auth.js';
10
11
  import { writeToken, readStoredToken } from '../lib/config.js';
11
12
  import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
13
+ import { fetchLatestRelease, compareVersions } from '../lib/releases.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Prompt helper
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Prompt the user with a yes/no question and return true for yes, false for no.
21
+ * Reads from stdin; defaults to "no" on empty/non-y input.
22
+ *
23
+ * @param {string} question
24
+ * @returns {Promise<boolean>}
25
+ */
26
+ async function defaultPromptUser(question) {
27
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
28
+ return new Promise((resolve) => {
29
+ rl.question(question, (answer) => {
30
+ rl.close();
31
+ resolve(answer.trim().toLowerCase() === 'y');
32
+ });
33
+ });
34
+ }
12
35
 
13
36
  /**
14
37
  * Run the update command.
@@ -17,7 +40,8 @@ import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
17
40
  * sourceDir?: string,
18
41
  * targetDir?: string,
19
42
  * manifestPath?: string,
20
- * force?: boolean
43
+ * force?: boolean,
44
+ * version?: string - Kit version to store in manifest (defaults to pkg.version when omitted)
21
45
  * }} params
22
46
  * @returns {Promise<{ updated: string[], added: string[], removed: string[], conflicts: string[], unchanged: string[], upToDate: boolean }>}
23
47
  */
@@ -26,7 +50,8 @@ export async function runUpdate(params = {}) {
26
50
  sourceDir = resolveSourceDir(),
27
51
  targetDir = resolveTargetDir({ global: false }),
28
52
  manifestPath = resolveManifestPath({ global: false }),
29
- force = false
53
+ force = false,
54
+ version: explicitVersion
30
55
  } = params;
31
56
 
32
57
  // Read existing manifest
@@ -136,8 +161,10 @@ export async function runUpdate(params = {}) {
136
161
  delete newFiles[relPath];
137
162
  }
138
163
 
139
- // Update manifest with new file map
140
- updateManifest(manifestPath, newFiles, pkg.version);
164
+ // Update manifest with new file map.
165
+ // Use explicitVersion when provided (e.g. release.version from updateAction);
166
+ // fall back to pkg.version for direct runUpdate calls or main-branch fallback downloads.
167
+ updateManifest(manifestPath, newFiles, explicitVersion ?? pkg.version);
141
168
 
142
169
  return {
143
170
  updated: diff.updated,
@@ -151,7 +178,9 @@ export async function runUpdate(params = {}) {
151
178
 
152
179
  /**
153
180
  * CLI action handler for 'mk update'.
154
- * Downloads fresh kit from GitHub and applies three-way diff.
181
+ * Checks GitHub Releases for a newer version, prompts the user, then downloads
182
+ * and applies a three-way diff. Falls back to the main-branch tarball if no
183
+ * release information is available.
155
184
  *
156
185
  * @param {{ force: boolean, global: boolean }} options
157
186
  * @param {object} [deps] - Injected dependencies (for testing)
@@ -162,11 +191,21 @@ export async function updateAction(options = {}, deps = {}) {
162
191
  downloadAndExtractKit: download = downloadAndExtractKit,
163
192
  cleanupTempDir: cleanup = cleanupTempDir,
164
193
  writeToken: storeToken = writeToken,
165
- readStoredToken: readToken = readStoredToken
194
+ readStoredToken: readToken = readStoredToken,
195
+ fetchLatestRelease: fetchRelease = fetchLatestRelease,
196
+ compareVersions: cmpVersions = compareVersions,
197
+ promptUser = defaultPromptUser,
198
+ // Injectable for tests — allows overriding resolved paths without touching CWD
199
+ manifestPath: injectedManifestPath
166
200
  } = deps;
167
201
 
202
+ // Read local package version (used as fallback when manifest has no version)
203
+ const pkg = JSON.parse(
204
+ readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')
205
+ );
206
+
168
207
  const targetDir = resolveTargetDir(options);
169
- const manifestPath = resolveManifestPath(options);
208
+ const manifestPath = injectedManifestPath ?? resolveManifestPath(options);
170
209
 
171
210
  let tempDir = null;
172
211
  try {
@@ -183,11 +222,71 @@ export async function updateAction(options = {}, deps = {}) {
183
222
  }
184
223
  });
185
224
 
225
+ // --- Version check via GitHub Releases API ---
226
+ process.stdout.write('Checking for updates...\n');
227
+ const release = await fetchRelease(token);
228
+
229
+ // Read installed version from manifest (more accurate than pkg.version which reflects
230
+ // the CLI package, not the downloaded kit files). Fall back to pkg.version if no manifest.
231
+ let installedVersion = pkg.version;
232
+ if (existsSync(manifestPath)) {
233
+ try {
234
+ const existingManifest = readManifest(manifestPath);
235
+ if (existingManifest?.version) installedVersion = existingManifest.version;
236
+ } catch {
237
+ // Manifest unreadable — proceed with pkg.version fallback
238
+ }
239
+ }
240
+
241
+ let downloadUrl; // undefined = use default (main branch)
242
+ let releaseVersion; // set when downloading from a specific release tarball
243
+
244
+ if (!release.available) {
245
+ // No release found or API error — warn and fall back to main branch
246
+ process.stdout.write(
247
+ chalk.yellow(`Warning: Could not check latest release (${release.reason}). Falling back to main branch.\n`)
248
+ );
249
+ } else {
250
+ const { needsUpdate, local, remote } = cmpVersions(installedVersion, release.version);
251
+
252
+ if (!needsUpdate) {
253
+ process.stdout.write(chalk.green(`Already up to date (v${local}).\n`));
254
+ return;
255
+ }
256
+
257
+ // Show version diff
258
+ process.stdout.write(
259
+ chalk.cyan(`Update available: v${local} -> v${remote}\n`)
260
+ );
261
+
262
+ // Show release notes (if any), truncated to 500 chars
263
+ const body = release.body;
264
+ if (body && body.trim().length > 0) {
265
+ const notes = body.length > 500 ? body.slice(0, 500) + '...' : body;
266
+ process.stdout.write('\nRelease notes:\n');
267
+ process.stdout.write(notes + '\n\n');
268
+ }
269
+
270
+ // Prompt user
271
+ const confirmed = await promptUser(`Update to v${remote}? [y/N] `);
272
+ if (!confirmed) {
273
+ process.stdout.write('Update cancelled.\n');
274
+ return;
275
+ }
276
+
277
+ downloadUrl = release.tarballUrl;
278
+ releaseVersion = release.version;
279
+ }
280
+
281
+ // --- Download and apply ---
186
282
  process.stdout.write('Downloading latest kit from GitHub...\n');
187
- tempDir = await download(token);
283
+ tempDir = await download(token, downloadUrl !== undefined ? { url: downloadUrl } : {});
188
284
  const sourceDir = join(tempDir, '.claude');
189
285
 
190
- const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force });
286
+ // Pass releaseVersion so runUpdate stores it in the manifest.
287
+ // When falling back to main branch (no release), releaseVersion is undefined and
288
+ // runUpdate falls back to pkg.version — which is the correct behaviour for that path.
289
+ const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force, version: releaseVersion });
191
290
 
192
291
  if (result.upToDate) {
193
292
  process.stdout.write(chalk.green('Already up to date.\n'));
@@ -228,15 +228,16 @@ class TarExtractor extends Writable {
228
228
  * Download the kit repository as a tarball and extract .claude/ to targetDir.
229
229
  *
230
230
  * @param {string} token - GitHub Bearer token
231
- * @param {{ targetDir?: string }} [opts]
231
+ * @param {{ targetDir?: string, url?: string }} [opts]
232
+ * - url: override the download URL (e.g. a release tarball URL). Defaults to main-branch TARBALL_URL.
232
233
  * @returns {Promise<string>} The targetDir path
233
234
  */
234
235
  export async function downloadAndExtractKit(token, opts = {}) {
235
- const { targetDir = mkdtempSync(join(tmpdir(), 'mk-kit-')) } = opts;
236
+ const { targetDir = mkdtempSync(join(tmpdir(), 'mk-kit-')), url = TARBALL_URL } = opts;
236
237
 
237
238
  let res;
238
239
  try {
239
- res = await fetch(TARBALL_URL, {
240
+ res = await fetch(url, {
240
241
  headers: {
241
242
  Authorization: `Bearer ${token}`,
242
243
  Accept: 'application/vnd.github.v3+json'
@@ -0,0 +1,94 @@
1
+ import semver from 'semver';
2
+ import { GITHUB_API, KIT_REPO } from './constants.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // GitHub Releases API helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Fetch the latest non-draft, non-prerelease release from the kit repository.
10
+ *
11
+ * Returns a discriminated union:
12
+ * - On success: { available: true, tag, version, body, tarballUrl }
13
+ * - On failure: { available: false, reason }
14
+ *
15
+ * Failure cases:
16
+ * - Network error (GitHub unreachable)
17
+ * - HTTP 404 (no releases, or private repo + bad token)
18
+ * - Any other non-2xx response
19
+ * - tag_name is not parseable as semver even with coerce()
20
+ *
21
+ * @param {string} token - GitHub Bearer token
22
+ * @returns {Promise<{ available: true, tag: string, version: string, body: string|null, tarballUrl: string }
23
+ * | { available: false, reason: string }>}
24
+ */
25
+ export async function fetchLatestRelease(token) {
26
+ const url = `${GITHUB_API}/repos/${KIT_REPO}/releases/latest`;
27
+
28
+ let res;
29
+ try {
30
+ res = await fetch(url, {
31
+ headers: {
32
+ Authorization: `Bearer ${token}`,
33
+ Accept: 'application/vnd.github.v3+json'
34
+ }
35
+ });
36
+ } catch (err) {
37
+ return { available: false, reason: `Network error: ${err.message}` };
38
+ }
39
+
40
+ if (!res.ok) {
41
+ return {
42
+ available: false,
43
+ reason: `HTTP ${res.status}: ${res.statusText}`
44
+ };
45
+ }
46
+
47
+ let data;
48
+ try {
49
+ data = await res.json();
50
+ } catch (err) {
51
+ return { available: false, reason: `Failed to parse release JSON: ${err.message}` };
52
+ }
53
+
54
+ const { tag_name: tag, body = null, tarball_url: tarballUrl } = data;
55
+
56
+ // Parse version: try clean() first (handles v-prefix), then coerce() as fallback.
57
+ // coerce('release-0.2.0') => '0.2.0', coerce('totally-not-semver') => null
58
+ const version = semver.clean(tag) ?? (semver.coerce(tag) ? semver.coerce(tag).version : null);
59
+
60
+ if (!version) {
61
+ return {
62
+ available: false,
63
+ reason: `Release tag "${tag}" is not a valid semver version`
64
+ };
65
+ }
66
+
67
+ return {
68
+ available: true,
69
+ tag,
70
+ version,
71
+ body: body ?? null,
72
+ tarballUrl
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Compare a local version string against a remote version string.
78
+ *
79
+ * Both versions may include a `v` prefix — they are normalized before comparison.
80
+ *
81
+ * @param {string} localVersion - e.g. "0.1.3" or "v0.1.3"
82
+ * @param {string} remoteVersion - e.g. "0.1.5" or "v0.1.5"
83
+ * @returns {{ needsUpdate: boolean, local: string, remote: string }}
84
+ */
85
+ export function compareVersions(localVersion, remoteVersion) {
86
+ // semver.clean strips 'v' prefix and whitespace; returns null for truly invalid strings.
87
+ // Fall back to coerce for any edge-case format.
88
+ const local = semver.clean(localVersion) ?? semver.coerce(localVersion)?.version ?? localVersion;
89
+ const remote = semver.clean(remoteVersion) ?? semver.coerce(remoteVersion)?.version ?? remoteVersion;
90
+
91
+ const needsUpdate = !!semver.gt(remote, local);
92
+
93
+ return { needsUpdate, local, remote };
94
+ }