@khanhcan148/mk 0.1.3 → 0.1.4

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.4",
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",
@@ -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.
@@ -151,7 +174,9 @@ export async function runUpdate(params = {}) {
151
174
 
152
175
  /**
153
176
  * CLI action handler for 'mk update'.
154
- * Downloads fresh kit from GitHub and applies three-way diff.
177
+ * Checks GitHub Releases for a newer version, prompts the user, then downloads
178
+ * and applies a three-way diff. Falls back to the main-branch tarball if no
179
+ * release information is available.
155
180
  *
156
181
  * @param {{ force: boolean, global: boolean }} options
157
182
  * @param {object} [deps] - Injected dependencies (for testing)
@@ -162,9 +187,17 @@ export async function updateAction(options = {}, deps = {}) {
162
187
  downloadAndExtractKit: download = downloadAndExtractKit,
163
188
  cleanupTempDir: cleanup = cleanupTempDir,
164
189
  writeToken: storeToken = writeToken,
165
- readStoredToken: readToken = readStoredToken
190
+ readStoredToken: readToken = readStoredToken,
191
+ fetchLatestRelease: fetchRelease = fetchLatestRelease,
192
+ compareVersions: cmpVersions = compareVersions,
193
+ promptUser = defaultPromptUser
166
194
  } = deps;
167
195
 
196
+ // Read local package version for comparison
197
+ const pkg = JSON.parse(
198
+ readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8')
199
+ );
200
+
168
201
  const targetDir = resolveTargetDir(options);
169
202
  const manifestPath = resolveManifestPath(options);
170
203
 
@@ -183,8 +216,51 @@ export async function updateAction(options = {}, deps = {}) {
183
216
  }
184
217
  });
185
218
 
219
+ // --- Version check via GitHub Releases API ---
220
+ process.stdout.write('Checking for updates...\n');
221
+ const release = await fetchRelease(token);
222
+
223
+ let downloadUrl; // undefined = use default (main branch)
224
+
225
+ if (!release.available) {
226
+ // No release found or API error — warn and fall back to main branch
227
+ process.stdout.write(
228
+ chalk.yellow(`Warning: Could not check latest release (${release.reason}). Falling back to main branch.\n`)
229
+ );
230
+ } else {
231
+ const { needsUpdate, local, remote } = cmpVersions(pkg.version, release.version);
232
+
233
+ if (!needsUpdate) {
234
+ process.stdout.write(chalk.green(`Already up to date (v${local}).\n`));
235
+ return;
236
+ }
237
+
238
+ // Show version diff
239
+ process.stdout.write(
240
+ chalk.cyan(`Update available: v${local} -> v${remote}\n`)
241
+ );
242
+
243
+ // Show release notes (if any), truncated to 500 chars
244
+ const body = release.body;
245
+ if (body && body.trim().length > 0) {
246
+ const notes = body.length > 500 ? body.slice(0, 500) + '...' : body;
247
+ process.stdout.write('\nRelease notes:\n');
248
+ process.stdout.write(notes + '\n\n');
249
+ }
250
+
251
+ // Prompt user
252
+ const confirmed = await promptUser(`Update to v${remote}? [y/N] `);
253
+ if (!confirmed) {
254
+ process.stdout.write('Update cancelled.\n');
255
+ return;
256
+ }
257
+
258
+ downloadUrl = release.tarballUrl;
259
+ }
260
+
261
+ // --- Download and apply ---
186
262
  process.stdout.write('Downloading latest kit from GitHub...\n');
187
- tempDir = await download(token);
263
+ tempDir = await download(token, downloadUrl !== undefined ? { url: downloadUrl } : {});
188
264
  const sourceDir = join(tempDir, '.claude');
189
265
 
190
266
  const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force });
@@ -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
+ }