kandev 0.2.0 → 0.4.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/dist/dev.js CHANGED
@@ -37,17 +37,20 @@ async function runDev({ repoRoot, backendPort, webPort }) {
37
37
  supervisor.children.push(backendProc);
38
38
  (0, shared_1.attachBackendExitHandler)(backendProc, supervisor);
39
39
  const healthTimeoutMs = (0, health_1.resolveHealthTimeoutMs)(constants_1.HEALTH_TIMEOUT_MS_DEV);
40
+ console.log("[kandev] starting backend...");
40
41
  await (0, health_1.waitForHealth)(ports.backendUrl, backendProc, healthTimeoutMs);
41
42
  console.log(`[kandev] backend ready at ${ports.backendUrl}`);
42
43
  const webUrl = `http://localhost:${ports.webPort}`;
43
- (0, web_1.launchWebApp)({
44
+ console.log("[kandev] starting web...");
45
+ const webProc = (0, web_1.launchWebApp)({
44
46
  command: "pnpm",
45
47
  args: ["-C", "apps", "--filter", "@kandev/web", "dev"],
46
48
  cwd: repoRoot,
47
49
  env: webEnv,
48
- url: webUrl,
49
50
  supervisor,
50
51
  label: "web",
51
52
  });
53
+ await (0, health_1.waitForUrlReady)(webUrl, webProc, healthTimeoutMs);
52
54
  console.log(`[kandev] web ready at ${webUrl}`);
55
+ (0, web_1.openBrowser)(webUrl);
53
56
  }
package/dist/github.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getRelease = getRelease;
7
+ exports.readSha256 = readSha256;
7
8
  exports.ensureAsset = ensureAsset;
8
9
  const node_crypto_1 = __importDefault(require("node:crypto"));
9
10
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -12,38 +13,99 @@ const node_path_1 = __importDefault(require("node:path"));
12
13
  // Allow overriding the GitHub repo for forks/testing.
13
14
  const OWNER = process.env.KANDEV_GITHUB_OWNER || "kdlbs";
14
15
  const REPO = process.env.KANDEV_GITHUB_REPO || "kandev";
15
- const API_BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
16
- function requestJson(url) {
16
+ const WEB_BASE = `https://github.com/${OWNER}/${REPO}`;
17
+ function authHeaders() {
18
+ if (process.env.KANDEV_GITHUB_TOKEN) {
19
+ return { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` };
20
+ }
21
+ return {};
22
+ }
23
+ /**
24
+ * Resolve the latest release tag by following the redirect from
25
+ * github.com/{owner}/{repo}/releases/latest.
26
+ *
27
+ * Uses github.com (not api.github.com) so it is not subject to
28
+ * the REST API rate limit (60 req/hour per IP).
29
+ */
30
+ function resolveLatestTag() {
31
+ const url = `${WEB_BASE}/releases/latest`;
17
32
  return new Promise((resolve, reject) => {
18
33
  const req = node_https_1.default.get(url, {
19
- headers: {
20
- "User-Agent": "kandev-npx",
21
- Accept: "application/vnd.github+json",
22
- ...(process.env.KANDEV_GITHUB_TOKEN
23
- ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
24
- : {}),
25
- },
34
+ headers: { "User-Agent": "kandev-npx", ...authHeaders() },
35
+ // Do not follow redirects — we just need the Location header.
36
+ followRedirect: false,
26
37
  }, (res) => {
27
- if (res.statusCode !== 200) {
28
- return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
29
- }
30
- let body = "";
31
- res.on("data", (chunk) => (body += chunk));
32
- res.on("end", () => {
33
- try {
34
- resolve(JSON.parse(body));
35
- }
36
- catch {
37
- reject(new Error(`Failed to parse JSON from ${url}`));
38
+ // Drain the response body to free the socket.
39
+ res.resume();
40
+ if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
41
+ // Location: https://github.com/{owner}/{repo}/releases/tag/v0.2
42
+ const match = res.headers.location.match(/\/releases\/tag\/(.+)$/);
43
+ if (match) {
44
+ return resolve(match[1]);
38
45
  }
39
- });
46
+ return reject(new Error(`Could not parse tag from redirect: ${res.headers.location}`));
47
+ }
48
+ // GitHub returns 200 if there's only one release (no redirect).
49
+ // In that case we need to parse the page — but this is uncommon.
50
+ // Fall back to a HEAD request on the resolved URL.
51
+ if (res.statusCode === 200 && res.headers.location) {
52
+ const match = res.headers.location.match(/\/releases\/tag\/(.+)$/);
53
+ if (match)
54
+ return resolve(match[1]);
55
+ }
56
+ reject(new Error(`Failed to resolve latest release (HTTP ${res.statusCode})`));
40
57
  });
41
58
  req.setTimeout(5000, () => {
42
- req.destroy(new Error(`Request timed out fetching ${url}`));
59
+ req.destroy(new Error("Request timed out resolving latest release"));
43
60
  });
44
61
  req.on("error", reject);
45
62
  });
46
63
  }
64
+ /**
65
+ * Verify that a specific release tag exists.
66
+ */
67
+ function verifyTagExists(tag) {
68
+ const url = `${WEB_BASE}/releases/tag/${tag}`;
69
+ return new Promise((resolve, reject) => {
70
+ const req = node_https_1.default.request(url, {
71
+ method: "HEAD",
72
+ headers: { "User-Agent": "kandev-npx", ...authHeaders() },
73
+ }, (res) => {
74
+ res.resume();
75
+ // GitHub returns 200 for the tag page, or 302 redirect to the tag page.
76
+ if (res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302) {
77
+ return resolve();
78
+ }
79
+ reject(new Error(`Release tag '${tag}' not found (HTTP ${res.statusCode})`));
80
+ });
81
+ req.setTimeout(5000, () => {
82
+ req.destroy(new Error(`Request timed out verifying tag '${tag}'`));
83
+ });
84
+ req.on("error", reject);
85
+ req.end();
86
+ });
87
+ }
88
+ /**
89
+ * Get release info. Uses github.com web URLs (not api.github.com)
90
+ * to avoid REST API rate limits.
91
+ */
92
+ async function getRelease(version) {
93
+ if (version) {
94
+ await verifyTagExists(version);
95
+ return { tag_name: version };
96
+ }
97
+ const tag = await resolveLatestTag();
98
+ return { tag_name: tag };
99
+ }
100
+ // -- Asset downloading --------------------------------------------------------
101
+ function readSha256(pathToSha) {
102
+ if (!node_fs_1.default.existsSync(pathToSha)) {
103
+ return null;
104
+ }
105
+ const content = node_fs_1.default.readFileSync(pathToSha, "utf8").trim();
106
+ const first = content.split(/\s+/)[0];
107
+ return first?.toLowerCase() || null;
108
+ }
47
109
  function downloadFile(url, destPath, expectedSha256, onProgress) {
48
110
  const tempPath = `${destPath}.tmp`;
49
111
  return new Promise((resolve, reject) => {
@@ -56,7 +118,7 @@ function downloadFile(url, destPath, expectedSha256, onProgress) {
56
118
  catch { }
57
119
  };
58
120
  const handleResponse = (res) => {
59
- // Follow redirects (GitHub API returns 302 to signed S3 URL).
121
+ // Follow redirects (GitHub returns 302 to signed S3/CDN URL).
60
122
  // Strip auth header on redirect to avoid S3 rejecting it.
61
123
  if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
62
124
  const redirectReq = node_https_1.default.get(res.headers.location, { headers: { "User-Agent": "kandev-npx" } }, handleResponse);
@@ -104,10 +166,7 @@ function downloadFile(url, destPath, expectedSha256, onProgress) {
104
166
  const req = node_https_1.default.get(url, {
105
167
  headers: {
106
168
  "User-Agent": "kandev-npx",
107
- Accept: "application/octet-stream",
108
- ...(process.env.KANDEV_GITHUB_TOKEN
109
- ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }
110
- : {}),
169
+ ...authHeaders(),
111
170
  },
112
171
  }, handleResponse);
113
172
  req.setTimeout(30000, () => {
@@ -120,39 +179,29 @@ function downloadFile(url, destPath, expectedSha256, onProgress) {
120
179
  });
121
180
  });
122
181
  }
123
- function findAsset(release, name) {
124
- return release.assets?.find((asset) => asset.name === name);
125
- }
126
- function readSha256(pathToSha) {
127
- if (!node_fs_1.default.existsSync(pathToSha)) {
128
- return null;
129
- }
130
- const content = node_fs_1.default.readFileSync(pathToSha, "utf8").trim();
131
- const first = content.split(/\s+/)[0];
132
- return first?.toLowerCase() || null;
133
- }
134
- async function getRelease(version) {
135
- if (version) {
136
- return requestJson(`${API_BASE}/releases/tags/${version}`);
137
- }
138
- return requestJson(`${API_BASE}/releases/latest`);
139
- }
140
- async function ensureAsset(release, assetName, cacheDir, onProgress) {
141
- const asset = findAsset(release, assetName);
142
- if (!asset) {
143
- throw new Error(`Release asset not found: ${assetName}`);
144
- }
182
+ /**
183
+ * Ensure a release asset is downloaded and cached.
184
+ *
185
+ * Downloads directly from github.com/{owner}/{repo}/releases/download/{tag}/{asset}
186
+ * instead of the API, avoiding rate limits.
187
+ */
188
+ async function ensureAsset(tag, assetName, cacheDir, onProgress) {
145
189
  node_fs_1.default.mkdirSync(cacheDir, { recursive: true });
146
190
  const destPath = node_path_1.default.join(cacheDir, assetName);
147
191
  const shaPath = `${destPath}.sha256`;
192
+ // Download sha256 checksum if not already cached.
148
193
  let expectedSha = readSha256(shaPath);
149
194
  if (!expectedSha) {
150
- const shaAsset = findAsset(release, `${assetName}.sha256`);
151
- if (shaAsset) {
152
- await downloadFile(shaAsset.url, shaPath);
195
+ const shaUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}.sha256`;
196
+ try {
197
+ await downloadFile(shaUrl, shaPath);
153
198
  expectedSha = readSha256(shaPath);
154
199
  }
200
+ catch {
201
+ // sha256 file may not exist for this release — continue without it.
202
+ }
155
203
  }
204
+ // Return cached tarball if it exists and checksum matches.
156
205
  if (node_fs_1.default.existsSync(destPath)) {
157
206
  if (!expectedSha) {
158
207
  return destPath;
@@ -165,6 +214,8 @@ async function ensureAsset(release, assetName, cacheDir, onProgress) {
165
214
  }
166
215
  node_fs_1.default.unlinkSync(destPath);
167
216
  }
168
- await downloadFile(asset.url, destPath, expectedSha, onProgress);
217
+ // Download the asset.
218
+ const assetUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}`;
219
+ await downloadFile(assetUrl, destPath, expectedSha, onProgress);
169
220
  return destPath;
170
221
  }
package/dist/health.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.delay = delay;
4
4
  exports.resolveHealthTimeoutMs = resolveHealthTimeoutMs;
5
5
  exports.waitForHealth = waitForHealth;
6
+ exports.waitForUrlReady = waitForUrlReady;
6
7
  function delay(ms) {
7
8
  return new Promise((resolve) => setTimeout(resolve, ms));
8
9
  }
@@ -47,3 +48,20 @@ async function waitForHealth(baseUrl, proc, timeoutMs) {
47
48
  }
48
49
  throw new Error(`Backend healthcheck timed out after ${timeoutMs}ms`);
49
50
  }
51
+ async function waitForUrlReady(url, proc, timeoutMs) {
52
+ const deadline = Date.now() + timeoutMs;
53
+ while (Date.now() < deadline) {
54
+ if (proc.exitCode !== null) {
55
+ throw new Error("Web process exited before URL became reachable");
56
+ }
57
+ try {
58
+ await fetch(url);
59
+ return;
60
+ }
61
+ catch {
62
+ // ignore until timeout
63
+ }
64
+ await delay(300);
65
+ }
66
+ throw new Error(`Web URL readiness timed out after ${timeoutMs}ms (${url})`);
67
+ }
package/dist/run.js CHANGED
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findCachedRelease = findCachedRelease;
7
+ exports.cleanOldReleases = cleanOldReleases;
6
8
  exports.runRelease = runRelease;
7
9
  const node_child_process_1 = require("node:child_process");
8
10
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -10,27 +12,103 @@ const node_path_1 = __importDefault(require("node:path"));
10
12
  const bundle_1 = require("./bundle");
11
13
  const constants_1 = require("./constants");
12
14
  const github_1 = require("./github");
13
- const constants_2 = require("./constants");
14
15
  const health_1 = require("./health");
15
16
  const platform_1 = require("./platform");
17
+ const version_1 = require("./version");
16
18
  const ports_1 = require("./ports");
17
19
  const process_1 = require("./process");
18
20
  const shared_1 = require("./shared");
19
21
  const web_1 = require("./web");
22
+ /**
23
+ * Find a cached release binary to use when GitHub is unreachable.
24
+ * If version is specified, checks that exact tag. Otherwise, picks
25
+ * the highest semver tag available in the cache.
26
+ */
27
+ function findCachedRelease(platformDir, version) {
28
+ if (version) {
29
+ const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, version, platformDir);
30
+ const bundleDir = node_path_1.default.join(cacheDir, "kandev");
31
+ const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
32
+ if (node_fs_1.default.existsSync(backendBin)) {
33
+ return { cacheDir, tag: version };
34
+ }
35
+ return null;
36
+ }
37
+ // No version specified — scan for cached tags and pick the latest.
38
+ if (!node_fs_1.default.existsSync(constants_1.CACHE_DIR))
39
+ return null;
40
+ const entries = node_fs_1.default.readdirSync(constants_1.CACHE_DIR).filter((d) => d.startsWith("v"));
41
+ if (entries.length === 0)
42
+ return null;
43
+ const sorted = (0, version_1.sortVersionsDesc)(entries);
44
+ for (const tag of sorted) {
45
+ const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, tag, platformDir);
46
+ const bundleDir = node_path_1.default.join(cacheDir, "kandev");
47
+ const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
48
+ if (node_fs_1.default.existsSync(backendBin)) {
49
+ return { cacheDir, tag };
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Remove old cached releases, keeping only the 2 most recent tags.
56
+ * Runs after a successful download so we don't accumulate stale versions.
57
+ * The previous version is kept as a fallback for offline use.
58
+ */
59
+ function cleanOldReleases(currentTag) {
60
+ try {
61
+ if (!node_fs_1.default.existsSync(constants_1.CACHE_DIR))
62
+ return;
63
+ const entries = node_fs_1.default.readdirSync(constants_1.CACHE_DIR).filter((d) => d.startsWith("v"));
64
+ if (entries.length <= 2)
65
+ return;
66
+ const sorted = (0, version_1.sortVersionsDesc)(entries);
67
+ // Always keep currentTag + the next most recent.
68
+ const keep = new Set([currentTag, sorted[0], sorted[1]]);
69
+ for (const entry of entries) {
70
+ if (!keep.has(entry)) {
71
+ node_fs_1.default.rmSync(node_path_1.default.join(constants_1.CACHE_DIR, entry), { recursive: true, force: true });
72
+ }
73
+ }
74
+ }
75
+ catch {
76
+ // Non-critical — don't fail the launch if cleanup errors.
77
+ }
78
+ }
20
79
  async function prepareReleaseBundle({ version, backendPort, webPort, }) {
21
80
  const platformDir = (0, platform_1.getPlatformDir)();
22
- const release = await (0, github_1.getRelease)(version);
23
- const tag = release.tag_name || "latest";
24
- const assetName = `kandev-${platformDir}.tar.gz`;
25
- const cacheDir = node_path_1.default.join(constants_2.CACHE_DIR, tag, platformDir);
26
- const archivePath = await (0, github_1.ensureAsset)(release, assetName, cacheDir, (downloaded, total) => {
27
- const percent = total ? Math.round((downloaded / total) * 100) : 0;
28
- const mb = (downloaded / (1024 * 1024)).toFixed(1);
29
- const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?";
30
- process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`);
31
- });
32
- process.stderr.write("\n");
33
- (0, bundle_1.ensureExtracted)(archivePath, cacheDir);
81
+ let tag;
82
+ let cacheDir;
83
+ try {
84
+ const release = await (0, github_1.getRelease)(version);
85
+ tag = release.tag_name;
86
+ const assetName = `kandev-${platformDir}.tar.gz`;
87
+ cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, tag, platformDir);
88
+ const archivePath = await (0, github_1.ensureAsset)(tag, assetName, cacheDir, (downloaded, total) => {
89
+ const percent = total ? Math.round((downloaded / total) * 100) : 0;
90
+ const mb = (downloaded / (1024 * 1024)).toFixed(1);
91
+ const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?";
92
+ process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`);
93
+ });
94
+ process.stderr.write("\n");
95
+ (0, bundle_1.ensureExtracted)(archivePath, cacheDir);
96
+ cleanOldReleases(tag);
97
+ }
98
+ catch (err) {
99
+ // GitHub unreachable — try to launch from cache.
100
+ const cached = findCachedRelease(platformDir, version);
101
+ if (!cached) {
102
+ const target = version ? `version ${version}` : "latest version";
103
+ const reason = err instanceof Error ? err.message : String(err);
104
+ throw new Error(`Failed to fetch ${target} and no cached release found.\n` +
105
+ ` Reason: ${reason}\n` +
106
+ ` Run kandev once while online to cache a release for offline use.`);
107
+ }
108
+ tag = cached.tag;
109
+ cacheDir = cached.cacheDir;
110
+ process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`);
111
+ }
34
112
  const bundleDir = (0, bundle_1.findBundleRoot)(cacheDir);
35
113
  const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
36
114
  if (!node_fs_1.default.existsSync(backendBin)) {
@@ -109,24 +187,27 @@ function launchReleaseApps(prepared) {
109
187
  if (!webServerPath) {
110
188
  throw new Error("Web server entry (server.js) not found in bundle");
111
189
  }
190
+ return { supervisor, backendProc, webServerPath };
191
+ }
192
+ async function runRelease({ version, backendPort, webPort }) {
193
+ const prepared = await prepareReleaseBundle({ version, backendPort, webPort });
194
+ const { supervisor, backendProc, webServerPath } = launchReleaseApps(prepared);
195
+ const healthTimeoutMs = (0, health_1.resolveHealthTimeoutMs)(constants_1.HEALTH_TIMEOUT_MS_RELEASE);
196
+ console.log("[kandev] starting backend...");
197
+ await (0, health_1.waitForHealth)(prepared.backendUrl, backendProc, healthTimeoutMs);
198
+ console.log(`[kandev] backend ready at ${prepared.backendUrl}`);
112
199
  const webUrl = `http://localhost:${prepared.webPort}`;
113
- (0, web_1.launchWebApp)({
200
+ console.log("[kandev] starting web...");
201
+ const webProc = (0, web_1.launchWebApp)({
114
202
  command: "node",
115
203
  args: [webServerPath],
116
204
  cwd: node_path_1.default.dirname(webServerPath),
117
205
  env: prepared.webEnv,
118
- url: webUrl,
119
206
  supervisor,
120
207
  label: "web",
208
+ quiet: true,
121
209
  });
122
- return { supervisor, backendProc, webServerPath };
123
- }
124
- async function runRelease({ version, backendPort, webPort }) {
125
- const prepared = await prepareReleaseBundle({ version, backendPort, webPort });
126
- const { backendProc } = launchReleaseApps(prepared);
127
- // Wait for backend before announcing the web URL.
128
- const healthTimeoutMs = (0, health_1.resolveHealthTimeoutMs)(constants_1.HEALTH_TIMEOUT_MS_RELEASE);
129
- await (0, health_1.waitForHealth)(prepared.backendUrl, backendProc, healthTimeoutMs);
130
- console.log(`[kandev] backend ready at ${prepared.backendUrl}`);
131
- console.log(`[kandev] web ready at http://localhost:${prepared.webPort}`);
210
+ await (0, health_1.waitForUrlReady)(webUrl, webProc, healthTimeoutMs);
211
+ console.log(`[kandev] web ready at ${webUrl}`);
212
+ (0, web_1.openBrowser)(webUrl);
132
213
  }
package/dist/start.js CHANGED
@@ -107,19 +107,22 @@ async function runStart({ repoRoot, backendPort, webPort, verbose = false, debug
107
107
  }
108
108
  (0, shared_1.attachBackendExitHandler)(backendProc, supervisor);
109
109
  const healthTimeoutMs = (0, health_1.resolveHealthTimeoutMs)(constants_1.HEALTH_TIMEOUT_MS_RELEASE);
110
+ console.log("[kandev] starting backend...");
110
111
  await (0, health_1.waitForHealth)(ports.backendUrl, backendProc, healthTimeoutMs);
112
+ console.log(`[kandev] backend ready at ${ports.backendUrl}`);
111
113
  // Use standalone server.js directly (not pnpm start)
112
114
  const webUrl = `http://localhost:${ports.webPort}`;
113
- (0, web_1.launchWebApp)({
115
+ console.log("[kandev] starting web...");
116
+ const webProc = (0, web_1.launchWebApp)({
114
117
  command: "node",
115
118
  args: [webServerPath],
116
119
  cwd: webStandaloneDir,
117
120
  env: webEnv,
118
- url: webUrl,
119
121
  supervisor,
120
122
  label: "web",
121
123
  quiet: !showOutput,
122
124
  });
123
- console.log(`[kandev] backend ready at ${ports.backendUrl}`);
125
+ await (0, health_1.waitForUrlReady)(webUrl, webProc, healthTimeoutMs);
124
126
  console.log(`[kandev] web ready at ${webUrl}`);
127
+ (0, web_1.openBrowser)(webUrl);
125
128
  }
package/dist/update.js CHANGED
@@ -7,6 +7,7 @@ exports.maybePromptForUpdate = maybePromptForUpdate;
7
7
  const node_child_process_1 = require("node:child_process");
8
8
  const node_https_1 = __importDefault(require("node:https"));
9
9
  const node_readline_1 = __importDefault(require("node:readline"));
10
+ const version_1 = require("./version");
10
11
  function requestJson(url) {
11
12
  return new Promise((resolve, reject) => {
12
13
  const req = node_https_1.default.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => {
@@ -34,19 +35,6 @@ async function getLatestNpmVersion() {
34
35
  const data = await requestJson("https://registry.npmjs.org/kandev");
35
36
  return data?.["dist-tags"]?.latest;
36
37
  }
37
- function compareVersions(a, b) {
38
- const pa = String(a).replace(/^v/, "").split(".").map(Number);
39
- const pb = String(b).replace(/^v/, "").split(".").map(Number);
40
- for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
41
- const av = pa[i] ?? 0;
42
- const bv = pb[i] ?? 0;
43
- if (av > bv)
44
- return 1;
45
- if (av < bv)
46
- return -1;
47
- }
48
- return 0;
49
- }
50
38
  function promptYesNo(question, defaultYes = false) {
51
39
  return new Promise((resolve) => {
52
40
  if (!process.stdin.isTTY) {
@@ -80,7 +68,7 @@ async function maybePromptForUpdate(currentVersion, args) {
80
68
  const latest = await getLatestNpmVersion();
81
69
  if (!latest)
82
70
  return;
83
- if (compareVersions(latest, currentVersion) <= 0)
71
+ if ((0, version_1.compareVersions)(latest, currentVersion) <= 0)
84
72
  return;
85
73
  const wantsUpdate = await promptYesNo(`Update available: ${currentVersion} -> ${latest}. Update now?`, false);
86
74
  if (!wantsUpdate)
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * Centralized version parsing and comparison for the CLI.
4
+ *
5
+ * Version format: optional "v" prefix + dot-separated numeric segments.
6
+ * Examples: "v1.2", "0.3.0", "v2.0.1", "1"
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.parseVersion = parseVersion;
10
+ exports.compareVersions = compareVersions;
11
+ exports.sortVersionsDesc = sortVersionsDesc;
12
+ /**
13
+ * Parse a version string into numeric segments.
14
+ * Strips leading "v" prefix if present.
15
+ *
16
+ * "v1.2.3" -> { raw: "v1.2.3", segments: [1, 2, 3] }
17
+ * "0.3" -> { raw: "0.3", segments: [0, 3] }
18
+ * "bad" -> { raw: "bad", segments: [] }
19
+ */
20
+ function parseVersion(version) {
21
+ const cleaned = String(version).replace(/^v/, "");
22
+ const parts = cleaned.split(".");
23
+ const segments = [];
24
+ for (const part of parts) {
25
+ const n = parseInt(part, 10);
26
+ if (Number.isNaN(n))
27
+ return { raw: version, segments: [] };
28
+ segments.push(n);
29
+ }
30
+ return { raw: version, segments };
31
+ }
32
+ /**
33
+ * Compare two version strings.
34
+ * Returns 1 if a > b, -1 if a < b, 0 if equal.
35
+ * Missing segments are treated as 0 (e.g. "1.0" == "1.0.0").
36
+ */
37
+ function compareVersions(a, b) {
38
+ const pa = parseVersion(a).segments;
39
+ const pb = parseVersion(b).segments;
40
+ const len = Math.max(pa.length, pb.length);
41
+ for (let i = 0; i < len; i++) {
42
+ const av = pa[i] ?? 0;
43
+ const bv = pb[i] ?? 0;
44
+ if (av > bv)
45
+ return 1;
46
+ if (av < bv)
47
+ return -1;
48
+ }
49
+ return 0;
50
+ }
51
+ /**
52
+ * Sort version strings in descending order (newest first).
53
+ * Non-parseable strings sort to the end.
54
+ */
55
+ function sortVersionsDesc(versions) {
56
+ return [...versions].sort((a, b) => compareVersions(b, a));
57
+ }
package/dist/web.js CHANGED
@@ -32,7 +32,7 @@ function openBrowser(url) {
32
32
  // ignore browser launch errors
33
33
  }
34
34
  }
35
- function launchWebApp({ command, args, cwd, env, url, supervisor, label, quiet = false, }) {
35
+ function launchWebApp({ command, args, cwd, env, supervisor, label, quiet = false, }) {
36
36
  const stdio = quiet ? ["ignore", "pipe", "pipe"] : "inherit";
37
37
  const proc = (0, node_child_process_1.spawn)(command, args, { cwd, env, stdio });
38
38
  supervisor.children.push(proc);
@@ -45,6 +45,5 @@ function launchWebApp({ command, args, cwd, env, url, supervisor, label, quiet =
45
45
  const exitCode = signal ? 0 : (code ?? 1);
46
46
  void supervisor.shutdown(`${label} exit`).then(() => process.exit(exitCode));
47
47
  });
48
- openBrowser(url);
49
48
  return proc;
50
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kandev",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "NPX launcher for Kandev",
6
6
  "license": "AGPL-3.0-only",
@@ -20,12 +20,14 @@
20
20
  "devDependencies": {
21
21
  "@types/node": "^20",
22
22
  "tsx": "^4.15.7",
23
- "typescript": "^5"
23
+ "typescript": "^5",
24
+ "vitest": "^1.6.0"
24
25
  },
25
26
  "scripts": {
26
27
  "dev": "tsx src/cli.ts",
27
28
  "build": "tsc -p tsconfig.json",
28
29
  "start": "node dist/cli.js",
30
+ "test": "vitest run",
29
31
  "prepublishOnly": "pnpm build"
30
32
  }
31
33
  }