kablan 0.9.2 → 0.9.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/README.md +5 -0
- package/bin/cli.js +38 -16
- package/bin/install-app.js +151 -70
- package/bin/install-log.js +48 -0
- package/package.json +5 -2
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
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
console.log("
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
console.error(err.
|
|
221
|
-
|
|
222
|
-
|
|
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 };
|
package/bin/install-app.js
CHANGED
|
@@ -113,92 +113,163 @@ function infoPlist(version) {
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
116
|
+
* Stops every Kablan server this installer manages, and says what it did.
|
|
117
117
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
167
|
-
|
|
170
|
+
|
|
171
|
+
const version = versionFromPath(command);
|
|
172
|
+
log(`Stopping ${kind}${version ? ` ${version}` : ""} (pid ${pid})`);
|
|
168
173
|
|
|
169
174
|
try {
|
|
170
|
-
|
|
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
|
-
|
|
176
|
-
const deadline =
|
|
177
|
-
while (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
process.kill(Number(pid), "SIGKILL");
|
|
187
|
-
} catch {
|
|
188
|
-
// Raced us to exit.
|
|
195
|
+
sleep();
|
|
189
196
|
}
|
|
190
|
-
|
|
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
|
-
* @
|
|
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
|
|
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,
|
|
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
|
|
327
|
+
const { stopped } = stopRunningApps({ log });
|
|
256
328
|
const app = appDir();
|
|
257
|
-
if (!fs.existsSync(app)) return { app: null,
|
|
329
|
+
if (!fs.existsSync(app)) return { app: null, stopped };
|
|
258
330
|
fs.rmSync(app, { recursive: true, force: true });
|
|
259
|
-
return { app,
|
|
331
|
+
return { app, stopped };
|
|
260
332
|
}
|
|
261
333
|
|
|
262
|
-
module.exports = {
|
|
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.
|
|
3
|
+
"version": "0.9.4",
|
|
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
|
}
|