kablan 0.9.1 → 0.9.3

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 CHANGED
@@ -28,6 +28,11 @@ window — and opens your browser. Output goes to `~/Library/Logs/Kablan/kablan.
28
28
  Because the bundle is assembled on your machine rather than downloaded, macOS does not quarantine
29
29
  it: no "unidentified developer" prompt, and nothing to notarise.
30
30
 
31
+ Installing stops every copy of Kablan that is already running first — a surviving process keeps
32
+ serving the version it started with, which makes an update look like it did nothing. What it
33
+ stopped is printed, and appended to `~/Library/Logs/Kablan/install.log`. A server you built
34
+ yourself is left alone.
35
+
31
36
  - Update: `npx kablan@latest --install`
32
37
  - Remove: `npx kablan --uninstall` (cached binaries stay in `~/.kablan/bin`)
33
38
 
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ const path = require("path");
6
6
  const fs = require("fs");
7
7
  const { ensureBinary, BINARY_TAG, CACHE_DIR, LOCAL_DEV_MODE, LOCAL_DIST_DIR, getLatestVersion } = require("./download");
8
8
  const { installMacApp, uninstallMacApp, appDir } = require("./install-app");
9
+ const { createInstallLogger, installLogPath } = require("./install-log");
9
10
 
10
11
  const CLI_VERSION = require("../package.json").version;
11
12
 
@@ -142,6 +143,19 @@ async function extractAndRun(baseName, launch) {
142
143
  return launch(binPath);
143
144
  }
144
145
 
146
+ /**
147
+ * One line saying what was stopped, so the terminal and the log both show whether the update
148
+ * actually displaced anything — the failure this reporting exists to catch is an update that
149
+ * leaves the old version serving and says nothing.
150
+ */
151
+ function summariseStopped(stopped) {
152
+ if (!stopped || stopped.length === 0) return "Stopped 0 running processes";
153
+ const detail = stopped
154
+ .map((p) => `${p.kind} ${p.version || "(unknown version)"} pid ${p.pid} via ${p.signal}`)
155
+ .join(", ");
156
+ return `Stopped ${stopped.length} running process${stopped.length === 1 ? "" : "es"}: ${detail}`;
157
+ }
158
+
145
159
  async function main() {
146
160
  fs.mkdirSync(versionCacheDir, { recursive: true });
147
161
 
@@ -152,22 +166,25 @@ async function main() {
152
166
  // Kablan first: the swap would otherwise leave the old process serving, and the update would
153
167
  // look like it had not happened.
154
168
  if (args.includes("--uninstall")) {
155
- const { app: removed, stoppedPid } = uninstallMacApp();
156
- if (stoppedPid) console.log(`Stopped the running Kablan (pid ${stoppedPid})`);
157
- console.log(removed ? `Removed ${removed}` : `Nothing installed at ${appDir()}`);
169
+ const log = createInstallLogger();
170
+ log(`Uninstalling (wrapper ${CLI_VERSION})`);
171
+ const { app: removed, stopped } = uninstallMacApp(log);
172
+ log(summariseStopped(stopped));
173
+ log(removed ? `Removed ${removed}` : `Nothing installed at ${appDir()}`);
158
174
  console.log("Cached binaries are still in ~/.kablan/bin; delete that to reclaim the space.");
159
175
  return;
160
176
  }
161
177
 
162
178
  if (args.includes("--install")) {
163
179
  await extractAndRun("kablan", (bin) => {
164
- const { app, stoppedPid } = installMacApp(bin, CLI_VERSION);
165
- if (stoppedPid) {
166
- console.log(`\nStopped the running Kablan (pid ${stoppedPid}) so this version replaces it`);
167
- }
168
- console.log(`\nInstalled ${app}`);
169
- console.log("Open it from Launchpad or Spotlight — it starts Kablan in the background");
180
+ const log = createInstallLogger();
181
+ log(`Installing ${CLI_VERSION}`);
182
+ const { app, stopped } = installMacApp(bin, CLI_VERSION, log);
183
+ log(summariseStopped(stopped));
184
+ log(`Installed ${app}`);
185
+ console.log("\nOpen it from Launchpad or Spotlight — it starts Kablan in the background");
170
186
  console.log("and opens your browser. Logs: ~/Library/Logs/Kablan/kablan.log");
187
+ console.log(`Install log: ${installLogPath()}`);
171
188
  console.log("\nUpdate with `npx kablan@latest --install`, remove with `npx kablan --uninstall`.");
172
189
  });
173
190
  return;
@@ -214,10 +231,15 @@ async function main() {
214
231
  }
215
232
  }
216
233
 
217
- main().catch((err) => {
218
- console.error("Fatal error:", err.message);
219
- if (process.env.KABLAN_DEBUG) {
220
- console.error(err.stack);
221
- }
222
- process.exit(1);
223
- });
234
+ // Only when run as a command. Requiring this file — the tests do — must not start Kablan.
235
+ if (require.main === module) {
236
+ main().catch((err) => {
237
+ console.error("Fatal error:", err.message);
238
+ if (process.env.KABLAN_DEBUG) {
239
+ console.error(err.stack);
240
+ }
241
+ process.exit(1);
242
+ });
243
+ }
244
+
245
+ module.exports = { summariseStopped };
@@ -113,92 +113,163 @@ function infoPlist(version) {
113
113
 
114
114
 
115
115
  /**
116
- * Stop a Kablan that is already running, and say whether one was.
116
+ * Stops every Kablan server this installer manages, and says what it did.
117
117
  *
118
- * Replacing the bundle under a live process does nothing to that process: it keeps serving, the
119
- * launcher finds its port file and simply opens a browser at it, and the update looks like it
120
- * failed — the app still reports the version it was started with. So the install stops it first.
118
+ * The version is baked into the running process, so a copy that survives an update keeps serving
119
+ * the old build — the update looks like it silently failed. This used to read
120
+ * `$TMPDIR/kablan/kablan.port` and stop whoever held that port, which turned out to be one slot
121
+ * shared by every instance: a second server (a release build run from a checkout, say)
122
+ * overwrites it, and the installer then goes looking at the wrong port and stops nothing while
123
+ * the real app keeps running. That is a silent wrong answer, so the port file is no longer the
124
+ * handle.
121
125
  *
122
- * The port file is the only handle we have on it. Whoever is listening on that port has to look
123
- * like Kablan before it is signalled, because a stale port file plus a recycled port would
124
- * otherwise point at some unrelated process. SIGTERM first so the server can close its database
125
- * cleanly; SIGKILL only if it is still there after a grace period.
126
+ * Instead every process is examined and the ones that are ours are stopped — all of them, not
127
+ * the first. "Ours" means the executable lives in a directory this CLI owns: the version cache
128
+ * under ~/.kablan/bin, or the app bundle it builds. A server someone compiled themselves is
129
+ * deliberately left alone: it is not what an update replaces.
126
130
  *
127
- * @returns the pid stopped, or null when nothing was running
131
+ * SIGTERM first, so the server closes its database cleanly; SIGKILL only for one that outstays
132
+ * the grace period.
133
+ *
134
+ * @param {object} [deps] seams for the tests: every call to the outside world goes through one
135
+ * @returns {{stopped: Array<{pid: number, kind: string, version: string|null, signal: string}>,
136
+ * skipped: Array<{pid: number, reason: string}>}}
128
137
  */
129
- function stopRunningApp() {
130
- const bin = APP_NAME.toLowerCase();
131
- const tmp = process.env.TMPDIR || "/tmp";
132
- const portFile = path.join(tmp, bin, `${bin}.port`);
133
-
134
- let port;
135
- try {
136
- port = fs.readFileSync(portFile, "utf8").trim();
137
- } catch {
138
- return null;
139
- }
140
- if (!/^\d+$/.test(port)) return null;
141
-
142
- let pids;
143
- try {
144
- pids = execFileSync("/usr/sbin/lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], {
145
- encoding: "utf8",
146
- stdio: ["ignore", "pipe", "ignore"],
147
- })
148
- .split("\n")
149
- .map((line) => line.trim())
150
- .filter(Boolean);
151
- } catch {
152
- // Nothing listening: the port file outlived the process it described.
153
- return null;
154
- }
155
-
156
- for (const pid of pids) {
157
- let command = "";
158
- try {
159
- command = execFileSync("/bin/ps", ["-o", "command=", "-p", pid], {
160
- encoding: "utf8",
161
- stdio: ["ignore", "pipe", "ignore"],
162
- }).trim();
163
- } catch {
138
+ function stopRunningApps(deps = {}) {
139
+ const {
140
+ listProcesses = defaultListProcesses,
141
+ kill = (pid, signal) => process.kill(pid, signal),
142
+ alive = (pid) => {
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ },
150
+ sleep = () => execFileSync("/bin/sleep", ["0.2"]),
151
+ now = () => Date.now(),
152
+ graceMs = 5000,
153
+ selfPid = process.pid,
154
+ parentPid = process.ppid,
155
+ log = () => {},
156
+ } = deps;
157
+
158
+ const stopped = [];
159
+ const skipped = [];
160
+
161
+ for (const { pid, command } of listProcesses()) {
162
+ const kind = classify(command);
163
+ if (!kind) continue;
164
+
165
+ // Signalling ourselves or the npx wrapper that spawned us would end the install midway.
166
+ if (pid === selfPid || pid === parentPid) {
167
+ skipped.push({ pid, reason: "self" });
164
168
  continue;
165
169
  }
166
- // Ours: either a cached release binary or the one inside the bundle.
167
- if (!command.includes(`.${bin}/bin/`) && !command.includes(`${bin}-server`)) continue;
170
+
171
+ const version = versionFromPath(command);
172
+ log(`Stopping ${kind}${version ? ` ${version}` : ""} (pid ${pid})`);
168
173
 
169
174
  try {
170
- process.kill(Number(pid), "SIGTERM");
175
+ kill(pid, "SIGTERM");
171
176
  } catch {
177
+ // Gone between listing and signalling, or not ours to signal.
178
+ skipped.push({ pid, reason: "could not signal" });
172
179
  continue;
173
180
  }
174
181
 
175
- // Give it a moment to close its database before resorting to SIGKILL.
176
- const deadline = Date.now() + 5000;
177
- while (Date.now() < deadline) {
178
- try {
179
- process.kill(Number(pid), 0);
180
- } catch {
181
- return pid; // gone
182
+ let signal = "SIGTERM";
183
+ const deadline = now() + graceMs;
184
+ while (alive(pid)) {
185
+ if (now() >= deadline) {
186
+ try {
187
+ kill(pid, "SIGKILL");
188
+ signal = "SIGKILL";
189
+ log(` pid ${pid} ignored SIGTERM for ${graceMs / 1000}s; sent SIGKILL`);
190
+ } catch {
191
+ // Raced us to exit.
192
+ }
193
+ break;
182
194
  }
183
- execFileSync("/bin/sleep", ["0.2"]);
184
- }
185
- try {
186
- process.kill(Number(pid), "SIGKILL");
187
- } catch {
188
- // Raced us to exit.
195
+ sleep();
189
196
  }
190
- return pid;
197
+
198
+ stopped.push({ pid, kind, version, signal });
191
199
  }
192
200
 
201
+ return { stopped, skipped };
202
+ }
203
+
204
+ /**
205
+ * Is this command line one of ours, and which kind? Anything else returns null and is left
206
+ * running — including a server built from a checkout, which no update of ours replaces.
207
+ *
208
+ * Only the executable is examined, never the arguments. `ps` prints the whole command line, so a
209
+ * shell that launched a server, a `tail` on its log, or an editor with the path open all carry
210
+ * our directory in their arguments; matching anywhere in the line would signal them.
211
+ */
212
+ function classify(command) {
213
+ const bin = APP_NAME.toLowerCase();
214
+ const executable = executableOf(command);
215
+ if (executable.includes(`/.${bin}/bin/`)) {
216
+ return executable.endsWith(`${bin}-mcp`) ? "MCP server" : "server";
217
+ }
218
+ if (executable.includes(`/${APP_NAME}.app/Contents/MacOS/`)) return "installed app";
193
219
  return null;
194
220
  }
195
221
 
222
+ /**
223
+ * The executable out of a `ps` command line — everything up to the first space.
224
+ *
225
+ * A path containing a space would be cut short and so not recognised as ours; the alternative,
226
+ * matching the whole line, mistakes every process that merely mentions the path for a server,
227
+ * and signalling the wrong process is the worse failure.
228
+ */
229
+ function executableOf(command) {
230
+ const space = command.indexOf(" ");
231
+ return space === -1 ? command : command.slice(0, space);
232
+ }
233
+
234
+ /** ~/.kablan/bin/v0.9.1/macos-arm64/kablan -> "v0.9.1". Best effort; null when the path differs. */
235
+ function versionFromPath(command) {
236
+ const match = command.match(/\/\.?[a-z]+\/bin\/(v[\d.]+)\//);
237
+ return match ? match[1] : null;
238
+ }
239
+
240
+ /** Every process on the machine, as {pid, command}. */
241
+ function defaultListProcesses() {
242
+ let out = "";
243
+ try {
244
+ out = execFileSync("/bin/ps", ["-axo", "pid=,command="], {
245
+ encoding: "utf8",
246
+ stdio: ["ignore", "pipe", "ignore"],
247
+ maxBuffer: 8 * 1024 * 1024,
248
+ });
249
+ } catch {
250
+ return [];
251
+ }
252
+ return out
253
+ .split("\n")
254
+ .map((line) => line.trim())
255
+ .filter(Boolean)
256
+ .map((line) => {
257
+ const space = line.indexOf(" ");
258
+ if (space === -1) return null;
259
+ const pid = Number(line.slice(0, space));
260
+ if (!Number.isInteger(pid)) return null;
261
+ return { pid, command: line.slice(space + 1).trim() };
262
+ })
263
+ .filter(Boolean);
264
+ }
265
+
196
266
  /**
197
267
  * @param binPath the extracted server binary to install
198
268
  * @param version the wrapper's version, shown in Finder's Get Info
199
- * @returns {{ app: string, stoppedPid: string | null }}
269
+ * @param log where progress goes; the caller tees this to the console and a file
270
+ * @returns {{ app: string, stopped: Array<object> }}
200
271
  */
201
- function installMacApp(binPath, version) {
272
+ function installMacApp(binPath, version, log = () => {}) {
202
273
  if (process.platform !== "darwin") {
203
274
  throw new Error(
204
275
  `--install builds a macOS app bundle and this is ${process.platform}.\n` +
@@ -208,7 +279,8 @@ function installMacApp(binPath, version) {
208
279
 
209
280
  // Before anything is replaced: a running copy would survive the swap and keep serving the
210
281
  // version it started with.
211
- const stoppedPid = stopRunningApp();
282
+ const { stopped } = stopRunningApps({ log });
283
+ if (stopped.length === 0) log("No running Kablan found; nothing to stop");
212
284
 
213
285
  const app = appDir();
214
286
  const macos = path.join(app, "Contents", "MacOS");
@@ -247,16 +319,25 @@ function installMacApp(binPath, version) {
247
319
  // Cosmetic only — the app works either way.
248
320
  }
249
321
 
250
- return { app, stoppedPid };
322
+ return { app, stopped };
251
323
  }
252
324
 
253
- function uninstallMacApp() {
325
+ function uninstallMacApp(log = () => {}) {
254
326
  // Removing the bundle from under a running server leaves it running with no way back to it.
255
- const stoppedPid = stopRunningApp();
327
+ const { stopped } = stopRunningApps({ log });
256
328
  const app = appDir();
257
- if (!fs.existsSync(app)) return { app: null, stoppedPid };
329
+ if (!fs.existsSync(app)) return { app: null, stopped };
258
330
  fs.rmSync(app, { recursive: true, force: true });
259
- return { app, stoppedPid };
331
+ return { app, stopped };
260
332
  }
261
333
 
262
- module.exports = { installMacApp, uninstallMacApp, stopRunningApp, appDir, APP_NAME };
334
+ module.exports = {
335
+ installMacApp,
336
+ uninstallMacApp,
337
+ stopRunningApps,
338
+ classify,
339
+ executableOf,
340
+ versionFromPath,
341
+ appDir,
342
+ APP_NAME,
343
+ };
@@ -0,0 +1,48 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+
5
+ /**
6
+ * The record of what an install did.
7
+ *
8
+ * An update that goes wrong is quiet — you find out later, when the app reports a version you
9
+ * thought you had replaced, and by then the terminal that ran it is long gone. So every line the
10
+ * installer prints is also appended here, with a timestamp, next to the app's own log.
11
+ */
12
+
13
+ function logDir() {
14
+ return path.join(os.homedir(), "Library", "Logs", "Kablan");
15
+ }
16
+
17
+ function installLogPath() {
18
+ return path.join(logDir(), "install.log");
19
+ }
20
+
21
+ /**
22
+ * A `log(line)` that prints and appends. Logging is never the reason an install fails: if the
23
+ * file cannot be written the line still reaches the terminal.
24
+ *
25
+ * @param {object} [deps] seams for the tests
26
+ */
27
+ function createInstallLogger(deps = {}) {
28
+ const {
29
+ print = (line) => console.log(line),
30
+ file = installLogPath(),
31
+ appendFile = (target, text) => {
32
+ fs.mkdirSync(path.dirname(target), { recursive: true });
33
+ fs.appendFileSync(target, text);
34
+ },
35
+ stamp = () => new Date().toISOString(),
36
+ } = deps;
37
+
38
+ return function log(line) {
39
+ print(line);
40
+ try {
41
+ appendFile(file, `${stamp()} ${line}\n`);
42
+ } catch {
43
+ // A read-only home directory is not a reason to abandon the update.
44
+ }
45
+ };
46
+ }
47
+
48
+ module.exports = { createInstallLogger, installLogPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kablan",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "NPX wrapper around the Kablan server and its MCP task server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Shaked Amar",
@@ -33,5 +33,8 @@
33
33
  "dependencies": {
34
34
  "adm-zip": "^0.5.16"
35
35
  },
36
- "private": false
36
+ "private": false,
37
+ "scripts": {
38
+ "test": "node --test \"test/*.test.js\""
39
+ }
37
40
  }