kankaku-tui 0.4.0 → 0.4.1

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
@@ -172,7 +172,11 @@ Commands (macOS and Linux only — PocketBase ships no other build):
172
172
  — installs (or, run again, verifies) the hub and leaves it running. On
173
173
  a real terminal, a missing owner email/password is prompted for
174
174
  (masked); without a TTY, both flags are required. Idempotent: re-running
175
- with everything already in place changes nothing.
175
+ with everything already in place changes nothing. If another process
176
+ already answers on the target port, `install`/`start`/`upgrade` refuse
177
+ with `port <N> is already in use by another process — pass --port <N>
178
+ or stop it` instead of provisioning accounts against it; pass a
179
+ different `--port` or free the port and retry.
176
180
  - `kankaku hub start` / `kankaku hub stop` — start or stop the server
177
181
  process; `stop` is a no-op when it isn't running.
178
182
  - `kankaku hub status` — `local hub: running 0.2.0 (PocketBase 0.40.4) at
@@ -7,7 +7,7 @@
7
7
  * injected (`HubManagerDeps`) so tests never touch the network, spawn a
8
8
  * real PocketBase, or run a real script.
9
9
  */
10
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { assetKeyFor, classifyStatus, generatePassword, hubLayout, parseHubConfig, serveArgs, } from "../../domain/local-hub-model.js";
13
13
  import { locateHubPackage } from "./package.js";
@@ -45,6 +45,64 @@ function copyAppFiles(packageDir, appDir) {
45
45
  function baseUrlFor(port) {
46
46
  return `http://127.0.0.1:${port}`;
47
47
  }
48
+ /** `hub.log`'s lines, oldest first, `[]` when unreadable or empty. Shared by `hubLogs` (public, by line count) and `healthFailureDetail` (the last line only). */
49
+ function readLogLines(layout) {
50
+ if (!existsSync(layout.logFile))
51
+ return [];
52
+ const content = readFileSync(layout.logFile, "utf8");
53
+ const lines = content.split("\n");
54
+ if (lines.length > 0 && lines[lines.length - 1] === "")
55
+ lines.pop();
56
+ return lines;
57
+ }
58
+ function portInUseMessage(port) {
59
+ return `port ${port} is already in use by another process — pass --port <N> or stop it`;
60
+ }
61
+ /**
62
+ * Before spawning: if `GET /api/health` on `port` answers at all (ok or
63
+ * not — any response means something is already listening), the port is
64
+ * held by a process that is not ours. Callers only reach this once they
65
+ * have already established that no pid of ours is alive, so any response
66
+ * here means a foreign process. Returns the detail message to report, or
67
+ * `undefined` when the port is free.
68
+ */
69
+ async function ensurePortFree(port, deps) {
70
+ try {
71
+ await deps.fetch(`${baseUrlFor(port)}/api/health`);
72
+ return portInUseMessage(port);
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ /**
79
+ * Spawns the hub detached and waits for it to become healthy, stopping
80
+ * immediately (rather than waiting out the full timeout) if the process
81
+ * dies first. On an early exit, removes the pid file it just wrote so a
82
+ * dead process is never left claiming to be running.
83
+ */
84
+ async function spawnAndAwaitHealth(layout, appVersion, port, deps) {
85
+ const pid = deps.startDetached(layout.binary, serveArgs(layout, appVersion, port), { logFile: layout.logFile, pidFile: layout.pidFile });
86
+ const healthy = await waitForHealth(`${baseUrlFor(port)}/api/health`, {
87
+ fetch: deps.fetch,
88
+ sleep: deps.sleep,
89
+ timeoutMs: HEALTH_TIMEOUT_MS,
90
+ isAlive: () => deps.isAlive(pid),
91
+ });
92
+ if (healthy)
93
+ return { pid, healthy: true, exitedEarly: false };
94
+ const exitedEarly = !deps.isAlive(pid);
95
+ if (exitedEarly && existsSync(layout.pidFile))
96
+ unlinkSync(layout.pidFile);
97
+ return { pid, healthy: false, exitedEarly };
98
+ }
99
+ /** The error detail for a failed `spawnAndAwaitHealth`: the last `hub.log` line when the process exited early and one is readable, otherwise a plain timeout message. */
100
+ function healthFailureDetail(layout, exitedEarly) {
101
+ if (!exitedEarly)
102
+ return "the local hub did not become healthy within 20s";
103
+ const lastLine = readLogLines(layout).at(-1);
104
+ return lastLine ? `the hub exited during startup: ${lastLine}` : "the hub exited during startup";
105
+ }
48
106
  /**
49
107
  * Installs (or, run again, verifies) the local hub under
50
108
  * `~/.kankaku/hub`: locates the `kankaku-hub` package, creates the layout
@@ -121,16 +179,23 @@ export async function installHub(options, deps) {
121
179
  steps.push({ step: "provision accounts", outcome: "unchanged" });
122
180
  return { ok: true, steps, url: baseUrlFor(port) };
123
181
  }
182
+ // Reported as its own step: a hub that cannot start is not an accounts
183
+ // problem, and the accounts step must never claim to have run.
184
+ const portConflict = await ensurePortFree(port, deps);
185
+ if (portConflict) {
186
+ steps.push({ step: "start hub", outcome: "error", detail: portConflict });
187
+ return { ok: false, steps };
188
+ }
124
189
  try {
125
190
  const superuserPassword = generatePassword(deps.randomBytes);
126
191
  await upsertSuperuser(layout.binary, layout.pbData, SUPERUSER_EMAIL, superuserPassword, deps.runner);
127
- deps.startDetached(layout.binary, serveArgs(layout, manifest.version, port), { logFile: layout.logFile, pidFile: layout.pidFile });
128
- const url = baseUrlFor(port);
129
- const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
130
- if (!healthy) {
131
- steps.push({ step: "provision accounts", outcome: "error", detail: "the local hub did not become healthy within 20s" });
192
+ const spawnResult = await spawnAndAwaitHealth(layout, manifest.version, port, deps);
193
+ if (!spawnResult.healthy) {
194
+ steps.push({ step: "start hub", outcome: "error", detail: healthFailureDetail(layout, spawnResult.exitedEarly) });
132
195
  return { ok: false, steps };
133
196
  }
197
+ steps.push({ step: "start hub", outcome: "done" });
198
+ const url = baseUrlFor(port);
134
199
  const superuser = { email: SUPERUSER_EMAIL, password: superuserPassword };
135
200
  await createUser(url, superuser, { email: options.ownerEmail, password: options.ownerPassword, role: "owner" }, deps.fetch);
136
201
  const servicePassword = generatePassword(deps.randomBytes);
@@ -157,13 +222,15 @@ export async function startHub(deps) {
157
222
  if (pid !== undefined && isAlive(pid)) {
158
223
  return { ok: true, steps: [{ step: "start", outcome: "unchanged", detail: "already running" }], url: baseUrlFor(config.port) };
159
224
  }
160
- deps.startDetached(layout.binary, serveArgs(layout, config.appVersion, config.port), { logFile: layout.logFile, pidFile: layout.pidFile });
161
- const url = baseUrlFor(config.port);
162
- const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
225
+ const portConflict = await ensurePortFree(config.port, deps);
226
+ if (portConflict) {
227
+ return { ok: false, steps: [{ step: "start", outcome: "error", detail: portConflict }] };
228
+ }
229
+ const spawnResult = await spawnAndAwaitHealth(layout, config.appVersion, config.port, deps);
163
230
  return {
164
- ok: healthy,
165
- steps: [{ step: "start", outcome: healthy ? "done" : "error", detail: healthy ? undefined : "did not become healthy within 20s" }],
166
- url,
231
+ ok: spawnResult.healthy,
232
+ steps: [{ step: "start", outcome: spawnResult.healthy ? "done" : "error", detail: spawnResult.healthy ? undefined : healthFailureDetail(layout, spawnResult.exitedEarly) }],
233
+ url: baseUrlFor(config.port),
167
234
  };
168
235
  }
169
236
  /** Stops the installed hub via `SIGTERM` (bounded wait, `SIGKILL` as a last resort). A no-op (`unchanged`) when it wasn't running. */
@@ -246,20 +313,19 @@ export async function upgradeHub(deps) {
246
313
  writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
247
314
  steps.push({ step: "write hub.json", outcome: "done" });
248
315
  await stopProcess(layout.pidFile, { timeoutMs: STOP_TIMEOUT_MS, sleep: deps.sleep });
249
- deps.startDetached(layout.binary, serveArgs(layout, newConfig.appVersion, newConfig.port), { logFile: layout.logFile, pidFile: layout.pidFile });
250
316
  const url = baseUrlFor(newConfig.port);
251
- const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
252
- steps.push({ step: "restart", outcome: healthy ? "done" : "error", detail: healthy ? undefined : "did not become healthy within 20s" });
253
- return { ok: healthy, steps, url };
317
+ const portConflict = await ensurePortFree(newConfig.port, deps);
318
+ if (portConflict) {
319
+ steps.push({ step: "restart", outcome: "error", detail: portConflict });
320
+ return { ok: false, steps };
321
+ }
322
+ const spawnResult = await spawnAndAwaitHealth(layout, newConfig.appVersion, newConfig.port, deps);
323
+ steps.push({ step: "restart", outcome: spawnResult.healthy ? "done" : "error", detail: spawnResult.healthy ? undefined : healthFailureDetail(layout, spawnResult.exitedEarly) });
324
+ return { ok: spawnResult.healthy, steps, url };
254
325
  }
255
326
  /** The last `n` lines of `hub.log`, oldest first; `[]` when the hub has never logged anything. */
256
327
  export function hubLogs(n, deps) {
257
328
  const layout = hubLayout(deps.homeDir);
258
- if (!existsSync(layout.logFile))
259
- return [];
260
- const content = readFileSync(layout.logFile, "utf8");
261
- const lines = content.split("\n");
262
- if (lines.length > 0 && lines[lines.length - 1] === "")
263
- lines.pop();
329
+ const lines = readLogLines(layout);
264
330
  return lines.slice(Math.max(lines.length - n, 0));
265
331
  }
@@ -87,7 +87,11 @@ export async function stopProcess(pidFile, deps) {
87
87
  return killedForcibly ? "killed" : "stopped";
88
88
  }
89
89
  const HEALTH_POLL_INTERVAL_MS = 500;
90
- /** Polls `GET url` every 500ms until it responds ok, or `deps.timeoutMs` elapses. Returns whether it became healthy. Never throws. */
90
+ /**
91
+ * Polls `GET url` every 500ms until it responds ok, `deps.timeoutMs`
92
+ * elapses, or (when `deps.isAlive` is given) the process dies. Returns
93
+ * whether it became healthy. Never throws.
94
+ */
91
95
  export async function waitForHealth(url, deps) {
92
96
  let elapsedMs = 0;
93
97
  for (;;) {
@@ -99,6 +103,8 @@ export async function waitForHealth(url, deps) {
99
103
  catch {
100
104
  // Not up yet; keep polling until the deadline.
101
105
  }
106
+ if (deps.isAlive && !deps.isAlive())
107
+ return false;
102
108
  if (elapsedMs >= deps.timeoutMs)
103
109
  return false;
104
110
  await deps.sleep(HEALTH_POLL_INTERVAL_MS);
@@ -3,8 +3,9 @@
3
3
  * atomic tmp+rename, and a one-time `<file>.bak` — mirrors kankaku's own
4
4
  * `adapters/project-config.ts#writeProjectTargetIds`.
5
5
  */
6
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
+ const OWNER_FILE_MODE = 0o600;
8
9
  /** Parse `filePath` as a JSON object, defaulting to `{}` on any absence, malformed JSON, or non-object document. Never throws. */
9
10
  export function readJsonObjectOrEmpty(filePath) {
10
11
  if (!existsSync(filePath))
@@ -19,18 +20,44 @@ export function readJsonObjectOrEmpty(filePath) {
19
20
  return {};
20
21
  }
21
22
  }
23
+ /** Best-effort `chmodSync`; never throws (e.g. a read-only filesystem or an already-gone file). */
24
+ function tightenBestEffort(path) {
25
+ try {
26
+ chmodSync(path, OWNER_FILE_MODE);
27
+ }
28
+ catch {
29
+ // Best-effort only.
30
+ }
31
+ }
22
32
  /**
23
33
  * Copy `filePath`'s current bytes to `<filePath>.bak`, but only the first
24
34
  * time: a no-op when `filePath` does not exist yet (nothing to back up) or
25
- * `<filePath>.bak` already exists (never overwritten).
35
+ * `<filePath>.bak` already exists (its content is never overwritten).
36
+ *
37
+ * When `filePath` is owner-only (0600, e.g. `~/.kankaku/credentials.json`,
38
+ * which carries a live hub's service account password), the backup mirrors
39
+ * that mode instead of the platform's default (umask-derived, typically
40
+ * world-readable) mode — a previous hub's credentials must never end up
41
+ * more exposed in the backup than they were in the live file. An existing
42
+ * `.bak` from before this fix is tightened too, best-effort, whenever a
43
+ * 0600 source is encountered — mirroring kankaku's own "existing looser
44
+ * mode is tightened, best-effort, whenever encountered" rule.
26
45
  */
27
46
  export function backupOnce(filePath) {
28
47
  if (!existsSync(filePath))
29
48
  return;
30
49
  const backupPath = `${filePath}.bak`;
31
- if (existsSync(backupPath))
50
+ const sourceIsOwnerOnly = (statSync(filePath).mode & 0o777) === OWNER_FILE_MODE;
51
+ if (existsSync(backupPath)) {
52
+ if (sourceIsOwnerOnly)
53
+ tightenBestEffort(backupPath);
32
54
  return;
33
- writeFileSync(backupPath, readFileSync(filePath));
55
+ }
56
+ // Created owner-only from the first byte when the source is: a chmod
57
+ // after the write would leave a window where the secret is readable.
58
+ writeFileSync(backupPath, readFileSync(filePath), sourceIsOwnerOnly ? { mode: OWNER_FILE_MODE } : {});
59
+ if (sourceIsOwnerOnly)
60
+ tightenBestEffort(backupPath);
34
61
  }
35
62
  /** Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename, creating the parent directory when missing. */
36
63
  export function writeJsonAtomic(filePath, value, dirMode) {
package/dist/cli.js CHANGED
@@ -300,6 +300,7 @@ function buildHubManagerDeps(deps) {
300
300
  locatePackage: () => locateHubPackage(),
301
301
  platform: process.platform,
302
302
  arch: process.arch,
303
+ isAlive: (pid) => isAlive(pid),
303
304
  ...deps.hubManager,
304
305
  };
305
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "license": "MIT",
6
6
  "type": "module",