kankaku 1.0.2 → 1.1.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/README.md CHANGED
@@ -81,14 +81,16 @@ The wizard's steps, `enter` to advance and `esc` to go back throughout
81
81
  below.
82
82
  2. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
83
83
  inline health check, reusing the current credentials as the default),
84
- `install locally`, or `skip`. Installing locally asks only for the
85
- owner's email and password and then runs the same installer as
86
- `kankaku hub install` (see "Local hub" below): no `kankaku-hub`
87
- checkout is needed, and the wizard writes the local hub's service
88
- account as this machine's hub credentials. The wizard always uses the
89
- default port; if another process already holds it the step fails with
90
- the port-in-use message, and `kankaku hub install --port <N>` is the
91
- way to pick another one.
84
+ `install locally`, or `skip`. Installing locally asks for the owner's
85
+ email and password and a `port` (digits only, 1024-65535), then runs
86
+ the same installer as `kankaku hub install` (see "Local hub" below): no
87
+ `kankaku-hub` checkout is needed. The port field starts at the first
88
+ free port from 8090 upwards (or at the port of an existing install)
89
+ and rejects a port that is in use inline (`port <N> is in use`). The
90
+ Review step says what happens to the sync credentials: either
91
+ `sync credentials → the local hub`, or `sync credentials stay on <url>
92
+ (switch later with kankaku hub use)` when this machine already syncs to
93
+ another hub.
92
94
  3. **Roots** — the comma-separated project roots, defaulting to the
93
95
  current `tui.json` (or the parent of the current directory the first
94
96
  time). See "Configuration" below for how deep each root is searched.
@@ -265,9 +267,18 @@ sets up and runs your own hub on this machine, under `~/.kankaku/hub/`:
265
267
  - `accounts.json` (owner-only, `0600`) — the PocketBase superuser email
266
268
  and generated password, and the owner account's email. The owner logs
267
269
  into the hub's own web admin UI with that owner account.
268
- - `~/.kankaku/credentials.json` — the generated `service` account
269
- (`kankaku-sync@kankaku.local`) this app and kankaku's own sync already
270
- read, exactly like a remote hub's credentials.
270
+ - `service.json` (owner-only, `0600`) — the generated `service` account
271
+ (`kankaku-sync@kankaku.local`) as `{ url, email, password }`. Install
272
+ always writes it.
273
+
274
+ Install never repoints where this machine syncs on its own.
275
+ `~/.kankaku/credentials.json` (the file this app and kankaku's own sync
276
+ read) is written by install only when it does not exist yet, or when its
277
+ `url` already is this local hub's (same host and port). When it points at
278
+ another hub it is left untouched, and the install report says so with a
279
+ `sync credentials` step: `this machine syncs to <url>; run 'kankaku hub
280
+ use' to switch to the local hub`. `kankaku hub use` is the explicit
281
+ switch.
271
282
 
272
283
  Commands (macOS and Linux only — PocketBase ships no other build):
273
284
 
@@ -275,15 +286,33 @@ Commands (macOS and Linux only — PocketBase ships no other build):
275
286
  — installs (or, run again, verifies) the hub and leaves it running. On
276
287
  a real terminal, a missing owner email/password is prompted for
277
288
  (masked); without a TTY, both flags are required. Idempotent: re-running
278
- with everything already in place changes nothing. If another process
279
- already answers on the target port, `install`/`start`/`upgrade` refuse
280
- with `port <N> is already in use by another process — pass --port <N>
281
- or stop it` instead of provisioning accounts against it; pass a
282
- different `--port` or free the port and retry.
289
+ with everything already in place changes nothing, and an existing
290
+ install keeps its recorded port. Without `--port`, a fresh install uses
291
+ 8090 when it is free; when it is not, install fails (exit 1) with `port
292
+ 8090 is already in use — try: kankaku hub install --port <first free>`
293
+ and never picks a port silently. A `--port` that is taken fails the same
294
+ way, with the existing message plus the suggestion; a `--port` that is
295
+ not a whole number between 1 and 65535 is a usage error. The check binds
296
+ `127.0.0.1:<port>` before anything is downloaded or created, and the
297
+ pre-spawn health check still runs after it. `start` and `upgrade` refuse
298
+ with `port <N> is already in use by another process — pass --port <N> or
299
+ stop it` when a foreign process holds the recorded port. Re-running
300
+ install on a hub made by an older version, which has no `service.json`,
301
+ writes it when the service password is still known (it is in
302
+ `credentials.json`); otherwise it says the password is not recoverable
303
+ and does not invent one.
304
+ - `kankaku hub use` — points `~/.kankaku/credentials.json` at the local
305
+ hub, from `service.json` (the previous file is kept once as
306
+ `credentials.json.bak`, mode `0600`) and prints `sync now points at
307
+ <local url> (was <previous url>)`. Running it again reports `unchanged`.
308
+ It fails (exit 1) when no local hub is installed or `service.json` is
309
+ missing.
283
310
  - `kankaku hub start` / `kankaku hub stop` — start or stop the server
284
311
  process; `stop` is a no-op when it isn't running.
285
312
  - `kankaku hub status` — `local hub: running 0.2.0 (PocketBase 0.40.4) at
286
- http://127.0.0.1:8090 · pb_data 1.2 MB`, `stopped`, or `not installed`.
313
+ http://127.0.0.1:8090 · pb_data 1.2 MB`, `stopped`, or `not installed`,
314
+ followed by where this machine's sync points: `sync: local hub`,
315
+ `sync: <other url>` or `sync: not configured`.
287
316
  - `kankaku hub upgrade` — copies a fresh `app/<version>/` from the
288
317
  currently installed `kankaku-hub` package, downloads a new PocketBase
289
318
  binary only if that version changed, and restarts — `pb_data` is never
@@ -318,8 +347,12 @@ developers via `kankaku setup --from-checkout <dir>`.
318
347
  checkout-based local hub install, see "Local hub" above.
319
348
  - `kankaku doctor` — the same read-only report `kankaku setup` ends with,
320
349
  without prompting or writing anything.
321
- - `kankaku hub install|start|stop|status|upgrade|logs` — the local hub's
322
- lifecycle; see "Local hub" above.
350
+ - `kankaku hub install|use|start|stop|status|upgrade|logs` — the local
351
+ hub's lifecycle; see "Local hub" above.
352
+ - `kankaku --version`, `kankaku -v` or `kankaku version` — prints `kankaku
353
+ <version>` and, indented, the version of each package it carries
354
+ (`kankaku-pi`, `kankaku-claude`, `kankaku-hub`), or `not found` for one
355
+ that cannot be resolved; exit 0.
323
356
 
324
357
  `--roots` (on `today`/`tasks`) overrides the configured roots for that run.
325
358
 
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The local hub's service account and where this machine's sync points.
3
+ * Install always stores the service account in `~/.kankaku/hub/service.json`
4
+ * (0600); `~/.kankaku/credentials.json` is a separate decision, made by
5
+ * `installHub` (only when none exists or it already points here) and by
6
+ * `useLocalHub` (`kankaku hub use`, explicit).
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { hubLayout, parseServiceAccount } from "../../domain/local-hub-model.js";
10
+ import { credentialsPath, writeHubCredentials } from "../setup/hub.js";
11
+ import { readJsonObjectOrEmpty, writeJsonAtomic } from "../setup/json-writer.js";
12
+ const OWNER_FILE_MODE = 0o600;
13
+ /** `~/.kankaku/hub/service.json`, or `undefined` when it is missing or malformed. Never throws. */
14
+ export function readServiceAccount(homeDir) {
15
+ const file = hubLayout(homeDir).serviceJson;
16
+ if (!existsSync(file))
17
+ return undefined;
18
+ try {
19
+ return parseServiceAccount(JSON.parse(readFileSync(file, "utf8")));
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /** Writes `account` to `~/.kankaku/hub/service.json` (0600, tmp + rename); a no-op when it already holds exactly these values. */
26
+ export function writeServiceAccount(homeDir, account) {
27
+ const current = readServiceAccount(homeDir);
28
+ if (current && current.url === account.url && current.email === account.email && current.password === account.password)
29
+ return { changed: false };
30
+ writeJsonAtomic(hubLayout(homeDir).serviceJson, { url: account.url, email: account.email, password: account.password }, undefined, OWNER_FILE_MODE);
31
+ return { changed: true };
32
+ }
33
+ /** The `url` currently in `~/.kankaku/credentials.json`, or `undefined` when there is none. */
34
+ export function readCredentialsUrl(homeDir) {
35
+ const url = readJsonObjectOrEmpty(credentialsPath(homeDir))["url"];
36
+ return typeof url === "string" && url !== "" ? url : undefined;
37
+ }
38
+ /**
39
+ * `kankaku hub use`: points `credentials.json` at the local hub's service
40
+ * account (`service.json`), keeping the one-time `.bak` of the previous
41
+ * file. Needs an installed local hub and its `service.json`; idempotent.
42
+ */
43
+ export function useLocalHub(homeDir) {
44
+ const layout = hubLayout(homeDir);
45
+ if (!existsSync(layout.hubJson)) {
46
+ return { ok: false, error: "no local hub is installed; run: kankaku hub install" };
47
+ }
48
+ const service = readServiceAccount(homeDir);
49
+ if (!service) {
50
+ return {
51
+ ok: false,
52
+ error: "the local hub has no service.json (an install made by an older version does not write one); run 'kankaku hub install' again - it is idempotent and writes it when the service password is still known",
53
+ };
54
+ }
55
+ const previousUrl = readCredentialsUrl(homeDir);
56
+ const written = writeHubCredentials(homeDir, service);
57
+ return { ok: true, changed: written.changed, url: service.url, ...(previousUrl !== undefined ? { previousUrl } : {}) };
58
+ }
@@ -9,12 +9,15 @@
9
9
  */
10
10
  import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { assetKeyFor, classifyStatus, generatePassword, hubLayout, parseHubConfig, serveArgs, } from "../../domain/local-hub-model.js";
12
+ import { assetKeyFor, classifyStatus, generatePassword, hubLayout, credentialsKeptDetail, parseHubConfig, sameHubUrl, serveArgs, shouldWriteCredentials, } from "../../domain/local-hub-model.js";
13
13
  import { locateHubPackage } from "./package.js";
14
14
  import { downloadPocketBase } from "./download.js";
15
15
  import { isAlive, readPid, stopProcess, waitForHealth } from "./process.js";
16
+ import { firstFreePort, isPortFree } from "./port-probe.js";
16
17
  import { createUser, upsertSuperuser } from "./accounts.js";
17
- import { writeHubCredentials } from "../setup/hub.js";
18
+ import { credentialsPath, writeHubCredentials } from "../setup/hub.js";
19
+ import { readJsonObjectOrEmpty } from "../setup/json-writer.js";
20
+ import { readCredentialsUrl, readServiceAccount, writeServiceAccount } from "./credentials.js";
18
21
  const OWNER_DIR_MODE = 0o700;
19
22
  const OWNER_FILE_MODE = 0o600;
20
23
  /** The PocketBase superuser account this package provisions on first install; distinct from the owner/service application users. */
@@ -55,8 +58,14 @@ function readLogLines(layout) {
55
58
  lines.pop();
56
59
  return lines;
57
60
  }
58
- function portInUseMessage(port) {
59
- return `port ${port} is already in use by another process — pass --port <N> or stop it`;
61
+ /** The message when a foreign process holds `port`; `suggestion` (the first free port above it) adds the command that would work. */
62
+ function portInUseMessage(port, suggestion) {
63
+ const base = `port ${port} is already in use by another process — pass --port <N> or stop it`;
64
+ return suggestion === undefined ? base : `${base}; try: kankaku hub install --port ${suggestion}`;
65
+ }
66
+ /** The first free port above `port`, for a "try: kankaku hub install --port N" suggestion; `undefined` when none of the next 20 is free. */
67
+ function suggestPortAbove(port, deps) {
68
+ return firstFreePort(port + 1, 20, deps.portBinder);
60
69
  }
61
70
  /**
62
71
  * Before spawning: if `GET /api/health` on `port` answers at all (ok or
@@ -64,17 +73,32 @@ function portInUseMessage(port) {
64
73
  * held by a process that is not ours. Callers only reach this once they
65
74
  * have already established that no pid of ours is alive, so any response
66
75
  * here means a foreign process. Returns the detail message to report, or
67
- * `undefined` when the port is free.
76
+ * `undefined` when the port is free. `suggest` (install only) appends the
77
+ * first free port to try instead.
68
78
  */
69
- async function ensurePortFree(port, deps) {
79
+ async function ensurePortFree(port, deps, suggest = false) {
70
80
  try {
71
81
  await deps.fetch(`${baseUrlFor(port)}/api/health`);
72
- return portInUseMessage(port);
82
+ return portInUseMessage(port, suggest ? await suggestPortAbove(port, deps) : undefined);
73
83
  }
74
84
  catch {
75
85
  return undefined;
76
86
  }
77
87
  }
88
+ /**
89
+ * The earlier, cheaper check of a fresh install: binds `127.0.0.1:<port>`.
90
+ * `explicit` (a `--port` was given) keeps the existing message and adds the
91
+ * first free port; without `--port` the default is never swapped silently -
92
+ * the message names the first free port to pass instead.
93
+ */
94
+ async function checkPortBindable(port, explicit, deps) {
95
+ if (await isPortFree(port, deps.portBinder))
96
+ return undefined;
97
+ const suggestion = await suggestPortAbove(port, deps);
98
+ if (explicit)
99
+ return portInUseMessage(port, suggestion);
100
+ return suggestion === undefined ? `port ${port} is already in use` : `port ${port} is already in use — try: kankaku hub install --port ${suggestion}`;
101
+ }
78
102
  /**
79
103
  * Spawns the hub detached and waits for it to become healthy, stopping
80
104
  * immediately (rather than waiting out the full timeout) if the process
@@ -103,6 +127,51 @@ function healthFailureDetail(layout, exitedEarly) {
103
127
  const lastLine = readLogLines(layout).at(-1);
104
128
  return lastLine ? `the hub exited during startup: ${lastLine}` : "the hub exited during startup";
105
129
  }
130
+ /**
131
+ * Stores the local hub's service account in `service.json` (always) and
132
+ * decides what happens to `~/.kankaku/credentials.json`: written only when
133
+ * none exists or it already points at this local hub, otherwise left
134
+ * byte-for-byte untouched with a step saying how to switch (`kankaku hub
135
+ * use`). Returns the two report steps.
136
+ */
137
+ function storeServiceAccount(homeDir, account, recoveredFrom) {
138
+ const serviceResult = writeServiceAccount(homeDir, account);
139
+ const steps = [
140
+ { step: "write service.json", outcome: serviceResult.changed ? "done" : "unchanged", ...(recoveredFrom ? { detail: recoveredFrom } : {}) },
141
+ ];
142
+ const existingUrl = readCredentialsUrl(homeDir);
143
+ if (!shouldWriteCredentials(existingUrl, account.url)) {
144
+ steps.push({ step: "sync credentials", outcome: "unchanged", detail: credentialsKeptDetail(existingUrl) });
145
+ return steps;
146
+ }
147
+ const written = writeHubCredentials(homeDir, account);
148
+ steps.push({ step: "sync credentials", outcome: written.changed ? "done" : "unchanged", detail: `points at ${account.url}` });
149
+ return steps;
150
+ }
151
+ /**
152
+ * Re-run on an install that already has accounts: `service.json` is kept up
153
+ * to date when it exists (its url follows the recorded port); an install
154
+ * made before `service.json` existed gets it back only when the service
155
+ * password is still known - it is in `credentials.json` (email and url are
156
+ * the local hub's). When it is not recoverable, says so and invents nothing.
157
+ */
158
+ function ensureServiceAccount(homeDir, url) {
159
+ const stored = readServiceAccount(homeDir);
160
+ if (stored)
161
+ return storeServiceAccount(homeDir, { ...stored, url });
162
+ const credentials = readJsonObjectOrEmpty(credentialsPath(homeDir));
163
+ const { url: credentialsUrl, email, password } = credentials;
164
+ if (email === SERVICE_EMAIL && typeof password === "string" && password !== "" && typeof credentialsUrl === "string" && sameHubUrl(credentialsUrl, url)) {
165
+ return storeServiceAccount(homeDir, { url, email: SERVICE_EMAIL, password }, "recovered from credentials.json");
166
+ }
167
+ return [
168
+ {
169
+ step: "write service.json",
170
+ outcome: "unchanged",
171
+ detail: "the service account's password is not recoverable: it was not stored by the version that installed this hub and credentials.json does not hold it; none was invented",
172
+ },
173
+ ];
174
+ }
106
175
  /**
107
176
  * Installs (or, run again, verifies) the local hub under
108
177
  * `~/.kankaku/hub`: locates the `kankaku-hub` package, creates the layout
@@ -112,13 +181,14 @@ function healthFailureDetail(layout, exitedEarly) {
112
181
  * package's migrations/hooks/public into `app/<version>/`, writes
113
182
  * `hub.json`, and — only the first time, when `accounts.json` doesn't
114
183
  * exist yet — provisions the superuser and the owner/service application
115
- * users, then leaves the server running. Idempotent: re-running with
116
- * everything already present reports every step `unchanged` and touches
184
+ * users, then leaves the server running. The service account always goes
185
+ * to `service.json`; `credentials.json` is written only when none exists
186
+ * or it already points at this hub (see `storeServiceAccount`). Idempotent:
187
+ * re-running with everything already present reports every step `unchanged` and touches
117
188
  * neither the process nor the accounts. Never throws; a failing step
118
189
  * stops the sequence and is reported as `error`.
119
190
  */
120
191
  export async function installHub(options, deps) {
121
- const port = options.port ?? DEFAULT_HUB_PORT;
122
192
  const steps = [];
123
193
  let located;
124
194
  try {
@@ -129,13 +199,20 @@ export async function installHub(options, deps) {
129
199
  }
130
200
  const manifest = located.manifest;
131
201
  const layout = hubLayout(deps.homeDir);
202
+ // An existing install keeps its recorded port; a fresh one takes the default, and never a different one silently.
203
+ const existingConfig = readConfigOrUndefined(layout.hubJson);
204
+ const port = options.port ?? existingConfig?.port ?? DEFAULT_HUB_PORT;
205
+ if (!existsSync(layout.accountsJson)) {
206
+ const conflict = await checkPortBindable(port, options.port !== undefined, deps);
207
+ if (conflict)
208
+ return { ok: false, steps: [{ step: "check port", outcome: "error", detail: conflict }] };
209
+ }
132
210
  const rootExisted = existsSync(layout.root);
133
211
  if (!rootExisted)
134
212
  mkdirSync(layout.root, { recursive: true, mode: OWNER_DIR_MODE });
135
213
  mkdirSync(layout.bin, { recursive: true });
136
214
  mkdirSync(layout.pbData, { recursive: true });
137
215
  steps.push({ step: "create ~/.kankaku/hub", outcome: rootExisted ? "unchanged" : "done" });
138
- const existingConfig = readConfigOrUndefined(layout.hubJson);
139
216
  const needsBinary = !existsSync(layout.binary) || existingConfig?.pocketbaseVersion !== manifest.pocketbase.version;
140
217
  if (needsBinary) {
141
218
  try {
@@ -177,11 +254,12 @@ export async function installHub(options, deps) {
177
254
  const accountsExist = existsSync(layout.accountsJson);
178
255
  if (accountsExist) {
179
256
  steps.push({ step: "provision accounts", outcome: "unchanged" });
257
+ steps.push(...ensureServiceAccount(deps.homeDir, baseUrlFor(port)));
180
258
  return { ok: true, steps, url: baseUrlFor(port) };
181
259
  }
182
260
  // Reported as its own step: a hub that cannot start is not an accounts
183
261
  // problem, and the accounts step must never claim to have run.
184
- const portConflict = await ensurePortFree(port, deps);
262
+ const portConflict = await ensurePortFree(port, deps, true);
185
263
  if (portConflict) {
186
264
  steps.push({ step: "start hub", outcome: "error", detail: portConflict });
187
265
  return { ok: false, steps };
@@ -200,11 +278,12 @@ export async function installHub(options, deps) {
200
278
  await createUser(url, superuser, { email: options.ownerEmail, password: options.ownerPassword, role: "owner" }, deps.fetch);
201
279
  const servicePassword = generatePassword(deps.randomBytes);
202
280
  await createUser(url, superuser, { email: SERVICE_EMAIL, password: servicePassword, role: "service" }, deps.fetch);
281
+ // The service password is stored before accounts.json marks the accounts as provisioned: a crash in between must never leave a password nobody has.
282
+ const serviceSteps = storeServiceAccount(deps.homeDir, { url, email: SERVICE_EMAIL, password: servicePassword });
203
283
  const accounts = { superuserEmail: SUPERUSER_EMAIL, superuserPassword, ownerEmail: options.ownerEmail };
204
284
  writeFileSync(layout.accountsJson, JSON.stringify(accounts, null, 2));
205
285
  chmodSync(layout.accountsJson, OWNER_FILE_MODE);
206
- writeHubCredentials(deps.homeDir, { url, email: SERVICE_EMAIL, password: servicePassword });
207
- steps.push({ step: "provision accounts", outcome: "done" });
286
+ steps.push({ step: "provision accounts", outcome: "done" }, ...serviceSteps);
208
287
  return { ok: true, steps, url };
209
288
  }
210
289
  catch (error) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Free-port probe for the local hub: bind `127.0.0.1:<port>` with
3
+ * `node:net` and release it again. The binder is injectable so every test
4
+ * but the one proving the real binder works uses a fake.
5
+ */
6
+ import { createServer } from "node:net";
7
+ const LOOPBACK = "127.0.0.1";
8
+ const MAX_PORT = 65535;
9
+ /** The real binder: a `node:net` server listening on `host:port`. */
10
+ export const realPortBinder = (port, host) => new Promise((resolve, reject) => {
11
+ const server = createServer();
12
+ server.once("error", reject);
13
+ server.listen(port, host, () => {
14
+ resolve(() => new Promise((done) => server.close(() => done())));
15
+ });
16
+ });
17
+ /** `true` when `127.0.0.1:<port>` can be bound (it is released again before returning); `false` on any bind error. */
18
+ export async function isPortFree(port, bind = realPortBinder) {
19
+ try {
20
+ const release = await bind(port, LOOPBACK);
21
+ await release();
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** The first free port among `from`, `from + 1`, … (at most `tries` candidates, never above 65535), or `undefined` when none is free. */
29
+ export async function firstFreePort(from, tries = 20, bind = realPortBinder) {
30
+ for (let offset = 0; offset < tries; offset += 1) {
31
+ const port = from + offset;
32
+ if (port > MAX_PORT)
33
+ return undefined;
34
+ if (await isPortFree(port, bind))
35
+ return port;
36
+ }
37
+ return undefined;
38
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The versions of the packages `kankaku` carries (`kankaku-pi`,
3
+ * `kankaku-claude`, `kankaku-hub`), resolved the same way the rest of the
4
+ * code finds them (`require.resolve("<name>/package.json")`, see
5
+ * `hub-manager/package.ts` and `setup/claude-plugin.ts`). Never throws: a
6
+ * package that cannot be resolved or read has an `undefined` version.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ import { readFileSync } from "node:fs";
10
+ const defaultRequire = createRequire(import.meta.url);
11
+ export const CARRIED_PACKAGES = ["kankaku-pi", "kankaku-claude", "kankaku-hub"];
12
+ function versionOf(name, resolve) {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(resolve(`${name}/package.json`), "utf8"));
15
+ return typeof parsed.version === "string" && parsed.version !== "" ? parsed.version : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ /** Each carried package's version, in {@link CARRIED_PACKAGES} order. `resolve` defaults to `require.resolve`; tests inject one. */
22
+ export function readCarriedVersions(resolve = (specifier) => defaultRequire.resolve(specifier)) {
23
+ return CARRIED_PACKAGES.map((name) => ({ name, version: versionOf(name, resolve) }));
24
+ }
@@ -30,7 +30,7 @@ export function writeHubCredentials(homeDir, credentials) {
30
30
  if (unchanged)
31
31
  return { changed: false };
32
32
  backupOnce(filePath);
33
- writeJsonAtomic(filePath, { ...existing, url: credentials.url, email: credentials.email, password: credentials.password });
33
+ writeJsonAtomic(filePath, { ...existing, url: credentials.url, email: credentials.email, password: credentials.password }, undefined, OWNER_FILE_MODE);
34
34
  chmodSync(filePath, OWNER_FILE_MODE);
35
35
  return { changed: true };
36
36
  }
@@ -59,10 +59,15 @@ export function backupOnce(filePath) {
59
59
  if (sourceIsOwnerOnly)
60
60
  tightenBestEffort(backupPath);
61
61
  }
62
- /** Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename, creating the parent directory when missing. */
63
- export function writeJsonAtomic(filePath, value, dirMode) {
62
+ /**
63
+ * Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename,
64
+ * creating the parent directory when missing. With `fileMode` the temp file
65
+ * is created with that mode from the first byte, so a secret is never
66
+ * readable through a wider mode, not even for the instant before a chmod.
67
+ */
68
+ export function writeJsonAtomic(filePath, value, dirMode, fileMode) {
64
69
  mkdirSync(dirname(filePath), dirMode !== undefined ? { recursive: true, mode: dirMode } : { recursive: true });
65
70
  const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
66
- writeFileSync(tmp, JSON.stringify(value, null, 2));
71
+ writeFileSync(tmp, JSON.stringify(value, null, 2), fileMode !== undefined ? { mode: fileMode } : {});
67
72
  renameSync(tmp, filePath);
68
73
  }
package/dist/cli.js CHANGED
@@ -13,6 +13,8 @@ import { discoverProjects } from "./adapters/project-discovery.js";
13
13
  import { readProjectRecords } from "./adapters/worklog-reader.js";
14
14
  import { computeProjectSyncStatus, createCatalog, refreshCatalog as refreshCatalogAdapter, resolveHub, syncProject } from "./adapters/hub.js";
15
15
  import { readOwnVersion } from "./adapters/app-info.js";
16
+ import { readCarriedVersions } from "./adapters/package-versions.js";
17
+ import { formatVersionLines } from "./domain/version-info.js";
16
18
  import { buildCatalogModel } from "./domain/catalog-model.js";
17
19
  import { buildTodayRows, formatTodayLines } from "./domain/today-model.js";
18
20
  import { buildDashboardModel } from "./domain/dashboard-model.js";
@@ -21,6 +23,7 @@ import { buildTasksModel } from "./domain/tasks-model.js";
21
23
  import { buildSyncRows } from "./domain/sync-model.js";
22
24
  import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
23
25
  import { detectAgents, formatDoctorLines, formatSetupPlanLines, planSetup } from "./domain/setup-plan.js";
26
+ import { parseHubPort } from "./domain/setup-wizard.js";
24
27
  import { readAgentFacts } from "./adapters/setup/agents.js";
25
28
  import { addKankakuPackage, removeKankakuPackage } from "./adapters/setup/pi.js";
26
29
  import { removeClaudeIntegration, writeClaudeIntegration } from "./adapters/setup/claude.js";
@@ -31,11 +34,13 @@ import { tuiConfigPath, writeTuiConfig } from "./adapters/setup/tui-config.js";
31
34
  import { installLocalHub } from "./adapters/setup/local-hub.js";
32
35
  import { createChildProcessRunner } from "./adapters/setup/child-process-runner.js";
33
36
  import { createReadlinePrompter } from "./adapters/setup/readline-prompter.js";
34
- import { hubLayout, parseHubConfig } from "./domain/local-hub-model.js";
37
+ import { describeSyncTarget, hubLayout, parseHubConfig } from "./domain/local-hub-model.js";
35
38
  import { hubLogs, hubStatus, installHub, startHub, stopHub, upgradeHub } from "./adapters/hub-manager/install.js";
36
39
  import { isAlive, readPid, startDetached } from "./adapters/hub-manager/process.js";
37
40
  import { locateHubPackage } from "./adapters/hub-manager/package.js";
38
- const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run] [--from-checkout <dir>] [--claude-plugin-dir <dir>]|doctor|hub install [--port N] [--owner-email E] [--owner-password P]|hub start|hub stop|hub status|hub upgrade|hub logs [-n N]] [--roots a,b] [--theme name]\n";
41
+ import { useLocalHub } from "./adapters/hub-manager/credentials.js";
42
+ import { firstFreePort, isPortFree, realPortBinder } from "./adapters/hub-manager/port-probe.js";
43
+ const USAGE = "usage: kankaku [--version|-v|version|today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run] [--from-checkout <dir>] [--claude-plugin-dir <dir>]|doctor|hub install [--port N] [--owner-email E] [--owner-password P]|hub use|hub start|hub stop|hub status|hub upgrade|hub logs [-n N]] [--roots a,b] [--theme name]\n";
39
44
  /** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
40
45
  export function loadToday(roots) {
41
46
  const projects = discoverProjects(roots);
@@ -267,19 +272,23 @@ async function runDoctorCommand(deps, claudePluginOverride) {
267
272
  * with no network call — the hub's health is checked interactively, from
268
273
  * the wizard's own Hub step, never upfront.
269
274
  */
270
- function gatherWizardFacts(deps, claudePluginOverride) {
275
+ export function gatherWizardFacts(deps, claudePluginOverride) {
271
276
  const agentFacts = readAgentFacts(deps.homeDir, claudePluginOverride);
272
277
  const hubResolution = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
273
278
  const credPath = credentialsPath(deps.homeDir);
274
- const hub = hubResolution.ok
275
- ? {
276
- credentialsPresent: true,
277
- url: hubResolution.credentials.url,
278
- email: hubResolution.credentials.email,
279
- password: hubResolution.credentials.password,
280
- credentialsPath: credPath,
281
- }
282
- : { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath };
279
+ const localPort = localHubPort(deps);
280
+ const hub = {
281
+ ...(hubResolution.ok
282
+ ? {
283
+ credentialsPresent: true,
284
+ url: hubResolution.credentials.url,
285
+ email: hubResolution.credentials.email,
286
+ password: hubResolution.credentials.password,
287
+ credentialsPath: credPath,
288
+ }
289
+ : { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath }),
290
+ ...(localPort !== undefined ? { localPort } : {}),
291
+ };
283
292
  const tuiPath = tuiConfigPath(deps.homeDir);
284
293
  const roots = {
285
294
  current: existsSync(tuiPath) ? readTuiConfig(deps.homeDir, deps.cwd).roots : undefined,
@@ -303,6 +312,7 @@ function buildHubManagerDeps(deps) {
303
312
  platform: process.platform,
304
313
  arch: process.arch,
305
314
  isAlive: (pid) => isAlive(pid),
315
+ portBinder: realPortBinder,
306
316
  ...deps.hubManager,
307
317
  };
308
318
  }
@@ -330,8 +340,10 @@ async function performLocalHubInstallFromCheckout(checkout, deps) {
330
340
  * `--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` — see
331
341
  * `runCli`/`resolveClaudePluginOverride`), never from wizard state.
332
342
  * `install-local-hub` runs the real `hub-manager/install.ts#installHub`
333
- * (which writes `accounts.json` and `~/.kankaku/credentials.json`
334
- * itself), reporting every install step in the result detail. Never
343
+ * (which writes `accounts.json` and `service.json`, and
344
+ * `~/.kankaku/credentials.json` only when none exists or it already
345
+ * points at the local hub), reporting every install step in the result
346
+ * detail - the `sync credentials` step with its own explanation. Never
335
347
  * throws: any adapter failure becomes an `"error"` outcome instead.
336
348
  */
337
349
  async function applyWizardAction(action, state, deps, claudePluginOverride) {
@@ -363,8 +375,8 @@ async function applyWizardAction(action, state, deps, claudePluginOverride) {
363
375
  return { action, outcome: result.changed ? "wrote" : "unchanged" };
364
376
  }
365
377
  case "install-local-hub": {
366
- const report = await installHub({ ownerEmail: state.hub.ownerEmail, ownerPassword: state.hub.ownerPassword }, buildHubManagerDeps(deps));
367
- const detail = report.steps.map((step) => `${step.step}: ${step.outcome}`).join("; ");
378
+ const report = await installHub({ port: parseHubPort(state.hub.port), ownerEmail: state.hub.ownerEmail, ownerPassword: state.hub.ownerPassword }, buildHubManagerDeps(deps));
379
+ const detail = report.steps.map((step) => `${step.step}: ${step.outcome}${step.step === "sync credentials" && step.detail ? ` (${step.detail})` : ""}`).join("; ");
368
380
  if (!report.ok)
369
381
  return { action, outcome: "error", detail };
370
382
  return { action, outcome: "started", detail: `${detail} — running at ${report.url}` };
@@ -401,11 +413,17 @@ function announceCommands(deps, result) {
401
413
  for (const file of result.foreign)
402
414
  deps.stdout(`skipped ${file} (not a kankaku command)`);
403
415
  }
416
+ /** The binder for the wizard's port probe: the real one, unless a test injected another through `CliDeps.hubManager`. */
417
+ function portBinderFor(deps) {
418
+ return deps.hubManager?.portBinder ?? realPortBinder;
419
+ }
404
420
  /** Build the setup wizard's `WizardActions` for the interactive app: every write goes through the same real `adapters/setup/*` writers `kankaku setup --yes` uses. Used only by `renderApp`. */
405
421
  export function buildWizardActions(deps, claudePluginOverride) {
406
422
  return {
407
423
  apply: (action, state) => applyWizardAction(action, state, deps, claudePluginOverride),
408
424
  checkHealth: (url) => checkHubHealth(url, { fetch: deps.fetch }),
425
+ isPortFree: (port) => isPortFree(port, portBinderFor(deps)),
426
+ suggestPort: (from) => firstFreePort(from, 20, portBinderFor(deps)),
409
427
  };
410
428
  }
411
429
  /** `--claude-plugin-dir <dir>`'s value when given, else `KANKAKU_CLAUDE_PLUGIN_DIR`; `undefined` resolves to the bundled `kankaku-claude` package (see `adapters/setup/claude-plugin.ts#locateClaudePlugin`). */
@@ -444,6 +462,28 @@ function formatHubStatusLine(status, pocketbaseVersion, pbDataBytes) {
444
462
  const pbPart = pocketbaseVersion ? ` (PocketBase ${pocketbaseVersion})` : "";
445
463
  return `local hub: ${label}${status.version ? ` ${status.version}` : ""}${pbPart} at ${status.url} · pb_data ${formatBytes(pbDataBytes)}`;
446
464
  }
465
+ /** The URL this machine's sync resolves to (`resolveHub`: environment first, then `~/.kankaku/credentials.json`), or `undefined` when none is configured. */
466
+ function syncCredentialsUrl(deps) {
467
+ const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
468
+ return hub.ok ? hub.credentials.url : undefined;
469
+ }
470
+ /** The installed local hub's port from `hub.json`, or `undefined` when it is not installed (or `hub.json` is unreadable). */
471
+ function localHubPort(deps) {
472
+ const layout = hubLayout(deps.homeDir);
473
+ if (!existsSync(layout.hubJson))
474
+ return undefined;
475
+ try {
476
+ return parseHubConfig(JSON.parse(readFileSync(layout.hubJson, "utf8"))).port;
477
+ }
478
+ catch {
479
+ return undefined;
480
+ }
481
+ }
482
+ /** The installed local hub's URL, or `undefined` when it is not installed. */
483
+ function localHubUrl(deps) {
484
+ const port = localHubPort(deps);
485
+ return port === undefined ? undefined : `http://127.0.0.1:${port}`;
486
+ }
447
487
  /** `-n <count>`'s value, defaulting to 50 when absent or not a positive integer. */
448
488
  function logCountFlag(args) {
449
489
  const index = args.indexOf("-n");
@@ -454,12 +494,13 @@ function flagValue(args, flag) {
454
494
  const index = args.indexOf(flag);
455
495
  return index === -1 ? undefined : args[index + 1];
456
496
  }
497
+ /** `--port`'s value: `undefined` when the flag is absent, `NaN` when it is present but not a whole number between 1 and 65535. */
457
498
  function portFlag(args) {
458
499
  const raw = flagValue(args, "--port");
459
500
  if (raw === undefined)
460
- return undefined;
501
+ return args.includes("--port") ? Number.NaN : undefined;
461
502
  const value = Number(raw);
462
- return Number.isInteger(value) && value > 0 ? value : undefined;
503
+ return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : Number.NaN;
463
504
  }
464
505
  /** Print every `HubActionReport` step, one per line (`<step>: <outcome> (<detail>)`), and the resulting URL when present. */
465
506
  function printHubReport(deps, report) {
@@ -482,6 +523,12 @@ async function runHubCommand(args, deps) {
482
523
  const [sub, ...rest] = args;
483
524
  const hubDeps = buildHubManagerDeps(deps);
484
525
  if (sub === "install") {
526
+ const port = portFlag(rest);
527
+ if (Number.isNaN(port)) {
528
+ deps.stderr("kankaku hub install: --port must be a whole number between 1 and 65535\n");
529
+ deps.exit(1);
530
+ return;
531
+ }
485
532
  let ownerEmail = flagValue(rest, "--owner-email");
486
533
  let ownerPassword = flagValue(rest, "--owner-password");
487
534
  if ((ownerEmail === undefined || ownerPassword === undefined) && (deps.isTTY?.() ?? false) && deps.prompter) {
@@ -493,7 +540,7 @@ async function runHubCommand(args, deps) {
493
540
  deps.exit(1);
494
541
  return;
495
542
  }
496
- const report = await installHub({ port: portFlag(rest), ownerEmail, ownerPassword }, hubDeps);
543
+ const report = await installHub({ port, ownerEmail, ownerPassword }, hubDeps);
497
544
  printHubReport(deps, report);
498
545
  if (!report.ok)
499
546
  deps.exit(1);
@@ -524,6 +571,19 @@ async function runHubCommand(args, deps) {
524
571
  }
525
572
  }
526
573
  deps.stdout(formatHubStatusLine(status, pocketbaseVersion, dirSizeBytes(layout.pbData)));
574
+ deps.stdout(`sync: ${describeSyncTarget(syncCredentialsUrl(deps), localHubUrl(deps))}`);
575
+ return;
576
+ }
577
+ if (sub === "use") {
578
+ const result = useLocalHub(deps.homeDir);
579
+ if (!result.ok) {
580
+ deps.stderr(`kankaku hub use: ${result.error}\n`);
581
+ deps.exit(1);
582
+ return;
583
+ }
584
+ deps.stdout(result.changed
585
+ ? `sync now points at ${result.url} (was ${result.previousUrl ?? "not configured"})`
586
+ : `sync credentials: unchanged (already points at ${result.url})`);
527
587
  return;
528
588
  }
529
589
  if (sub === "upgrade") {
@@ -708,6 +768,10 @@ export async function runCli(argv, deps) {
708
768
  deps.renderApp(roots, theme, { startInWizard });
709
769
  return;
710
770
  }
771
+ if (command === "--version" || command === "-v" || command === "version") {
772
+ deps.stdout(formatVersionLines(readOwnVersion(), readCarriedVersions(deps.resolvePackage)).join("\n"));
773
+ return;
774
+ }
711
775
  if (command === "today") {
712
776
  const model = loadToday(roots);
713
777
  deps.stdout(formatTodayLines(model.rows, model.total).join("\n"));
@@ -101,6 +101,7 @@ export function hubLayout(homeDir) {
101
101
  currentFile: joinPath(root, "current"),
102
102
  hubJson: joinPath(root, "hub.json"),
103
103
  accountsJson: joinPath(root, "accounts.json"),
104
+ serviceJson: joinPath(root, "service.json"),
104
105
  pidFile: joinPath(root, "pid"),
105
106
  logFile: joinPath(root, "hub.log"),
106
107
  };
@@ -177,3 +178,42 @@ export function generatePassword(randomBytes, length = 24) {
177
178
  }
178
179
  return password;
179
180
  }
181
+ /** Narrows `json` to a `ServiceAccount` (three non-empty strings), or `undefined` when it is anything else. */
182
+ export function parseServiceAccount(json) {
183
+ if (!json || typeof json !== "object" || Array.isArray(json))
184
+ return undefined;
185
+ const record = json;
186
+ const { url, email, password } = record;
187
+ if (typeof url !== "string" || url === "" || typeof email !== "string" || email === "" || typeof password !== "string" || password === "")
188
+ return undefined;
189
+ return { url, email, password };
190
+ }
191
+ function hostAndPort(url) {
192
+ try {
193
+ const parsed = new URL(url);
194
+ const host = parsed.hostname === "localhost" ? "127.0.0.1" : parsed.hostname;
195
+ return `${parsed.protocol}//${host}:${parsed.port}`;
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
201
+ /** Whether two hub URLs name the same scheme, host and port (`localhost` and `127.0.0.1` are the same host; a trailing slash or path is ignored). An unparsable URL never matches. */
202
+ export function sameHubUrl(a, b) {
203
+ const left = hostAndPort(a);
204
+ return left !== undefined && left === hostAndPort(b);
205
+ }
206
+ /** Install may write `~/.kankaku/credentials.json` only when none exists, or when it already points at this local hub. */
207
+ export function shouldWriteCredentials(existingUrl, localUrl) {
208
+ return existingUrl === undefined || sameHubUrl(existingUrl, localUrl);
209
+ }
210
+ /** The `sync credentials` step's detail when install leaves `credentials.json` alone. */
211
+ export function credentialsKeptDetail(existingUrl) {
212
+ return `this machine syncs to ${existingUrl}; run 'kankaku hub use' to switch to the local hub`;
213
+ }
214
+ /** Where this machine's sync points, for `kankaku hub status`: `local hub`, the other hub's URL, or `not configured`. */
215
+ export function describeSyncTarget(credentialsUrl, localUrl) {
216
+ if (credentialsUrl === undefined)
217
+ return "not configured";
218
+ return localUrl !== undefined && sameHubUrl(credentialsUrl, localUrl) ? "local hub" : credentialsUrl;
219
+ }
@@ -5,8 +5,9 @@
5
5
  * callers (`src/ui/setup/*`, `src/cli.tsx`) read the real files and pass
6
6
  * plain facts in via `WizardFacts`, exactly like `domain/setup-plan.ts`.
7
7
  */
8
+ import { shouldWriteCredentials } from "./local-hub-model.js";
8
9
  import { detectAgents } from "./setup-plan.js";
9
- /** The local hub's fixed port for the wizard's `install locally` review line and `install-local-hub` action — mirrors `hub-manager/install.ts#DEFAULT_HUB_PORT`. */
10
+ /** The local hub's default port, where the wizard's port field starts looking for a free one - mirrors `hub-manager/install.ts#DEFAULT_HUB_PORT`. */
10
11
  export const DEFAULT_HUB_PORT = 8090;
11
12
  /**
12
13
  * Shorten `path` for display by replacing a leading `homeDir` with `~`.
@@ -31,7 +32,7 @@ function clearError(errors, key) {
31
32
  delete rest[key];
32
33
  return rest;
33
34
  }
34
- const HUB_ERROR_KEYS = ["url", "email", "password", "ownerEmail", "ownerPassword"];
35
+ const HUB_ERROR_KEYS = ["url", "email", "password", "ownerEmail", "ownerPassword", "port"];
35
36
  function clearHubErrors(errors) {
36
37
  return HUB_ERROR_KEYS.reduce((acc, key) => clearError(acc, key), errors);
37
38
  }
@@ -50,6 +51,7 @@ export function createWizardState(facts) {
50
51
  password: facts.hub.password ?? "",
51
52
  ownerEmail: "",
52
53
  ownerPassword: "",
54
+ port: String(facts.hub.localPort ?? DEFAULT_HUB_PORT),
53
55
  },
54
56
  roots: facts.roots.current ?? facts.roots.defaultRoots,
55
57
  errors: {},
@@ -68,6 +70,17 @@ export function setHubMode(state, mode) {
68
70
  export function setHubField(state, field, value) {
69
71
  return { ...state, hub: { ...state.hub, [field]: value }, errors: clearError(state.errors, field) };
70
72
  }
73
+ /** The local hub port as a number when `text` is digits only within 1024-65535, otherwise `undefined`. */
74
+ export function parseHubPort(text) {
75
+ if (!/^\d+$/.test(text))
76
+ return undefined;
77
+ const port = Number(text);
78
+ return port >= 1024 && port <= 65535 ? port : undefined;
79
+ }
80
+ /** Flags `port` as occupied (`port N is in use`), found by the probe the Hub step runs before advancing; editing the port field clears it. */
81
+ export function setHubPortInUse(state, port) {
82
+ return { ...state, errors: { ...state.errors, port: `port ${port} is in use` } };
83
+ }
71
84
  export function setHubHealth(state, ok) {
72
85
  return { ...state, hub: { ...state.hub, healthOk: ok } };
73
86
  }
@@ -96,6 +109,8 @@ function validateHub(hub) {
96
109
  errors.ownerEmail = "enter a valid email";
97
110
  if (hub.ownerPassword.trim() === "")
98
111
  errors.ownerPassword = "enter a password";
112
+ if (parseHubPort(hub.port) === undefined)
113
+ errors.port = "enter a port between 1024 and 65535";
99
114
  return errors;
100
115
  }
101
116
  return {};
@@ -140,8 +155,12 @@ export function planFromWizard(state, facts) {
140
155
  }
141
156
  }
142
157
  else if (state.hub.mode === "local") {
143
- const url = `http://127.0.0.1:${DEFAULT_HUB_PORT}`;
144
- actions.push({ kind: "install-local-hub", file: url, label: `install a local hub at ${url}` });
158
+ const url = `http://127.0.0.1:${parseHubPort(state.hub.port) ?? DEFAULT_HUB_PORT}`;
159
+ const existingUrl = facts.hub.credentialsPresent ? facts.hub.url : undefined;
160
+ const note = shouldWriteCredentials(existingUrl, url)
161
+ ? "sync credentials → the local hub"
162
+ : `sync credentials stay on ${existingUrl} (switch later with kankaku hub use)`;
163
+ actions.push({ kind: "install-local-hub", file: url, label: `install a local hub at ${url}`, note });
145
164
  }
146
165
  const sameRoots = facts.roots.current !== undefined && arraysEqual(facts.roots.current, state.roots);
147
166
  if (!sameRoots) {
@@ -0,0 +1,5 @@
1
+ /** Pure text for `kankaku --version`. No I/O: `adapters/package-versions.ts` reads the versions. */
2
+ /** `kankaku <version>`, then one indented line per carried package: its version, or `not found`. */
3
+ export function formatVersionLines(ownVersion, carried) {
4
+ return [`kankaku ${ownVersion}`, ...carried.map(({ name, version }) => ` ${name} ${version ?? "not found"}`)];
5
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput, useStdout } from "ink";
4
- import { DEFAULT_HUB_PORT, applyResult, back, createWizardState, hintsForStep, next, planFromWizard, setHubField, setHubHealth, setHubMode, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
4
+ import { DEFAULT_HUB_PORT, applyResult, back, createWizardState, hintsForStep, next, parseHubPort, planFromWizard, setHubField, setHubHealth, setHubMode, setHubPortInUse, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
5
5
  import { HeaderBar } from "../components/header-bar.js";
6
6
  import { KeyHints } from "../components/key-hints.js";
7
7
  import { Panel } from "../components/panel.js";
@@ -111,11 +111,27 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
111
111
  const [healthChecking, setHealthChecking] = useState(false);
112
112
  const [rootsText, setRootsText] = useState(() => state.roots.join(", "));
113
113
  const applyStartedRef = useRef(false);
114
+ const portTouchedRef = useRef(false);
114
115
  useEffect(() => {
115
116
  if (state.step === "hub")
116
117
  setHubFocus(0);
117
118
  }, [state.step]);
118
- const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" ? 3 : 1;
119
+ // Prefill the local hub's port with the first free one from the default upwards, unless an install already recorded its own or the user got there first.
120
+ useEffect(() => {
121
+ if (facts.hub.localPort !== undefined)
122
+ return;
123
+ let cancelled = false;
124
+ void actions.suggestPort(DEFAULT_HUB_PORT).then((port) => {
125
+ if (cancelled || port === undefined || portTouchedRef.current)
126
+ return;
127
+ setState((s) => setHubField(s, "port", String(port)));
128
+ });
129
+ return () => {
130
+ cancelled = true;
131
+ };
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, []);
134
+ const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" ? 4 : 1;
119
135
  const textInputFocused = state.step === "roots" || (state.step === "hub" && hubFocus >= 1 && hubFocus < hubFieldCount);
120
136
  useEffect(() => {
121
137
  if (state.step !== "apply" || applyStartedRef.current)
@@ -140,6 +156,14 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
140
156
  onDone();
141
157
  return;
142
158
  }
159
+ if (state.step === "hub" && state.hub.mode === "local") {
160
+ // A valid port is probed before advancing; an existing install's own port is ours and is never probed.
161
+ const port = parseHubPort(state.hub.port);
162
+ if (port !== undefined && port !== facts.hub.localPort) {
163
+ void actions.isPortFree(port).then((free) => setState((s) => (free ? next(s, facts) : setHubPortInUse(s, port))));
164
+ return;
165
+ }
166
+ }
143
167
  setState((s) => next(s, facts));
144
168
  return;
145
169
  }
@@ -172,8 +196,11 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
172
196
  const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
173
197
  const mainWidth = width;
174
198
  const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
175
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `will install and run at http://127.0.0.1:${DEFAULT_HUB_PORT}` }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner email: " }), _jsx(TextInput, { value: state.hub.ownerEmail, onChange: (value) => setState((s) => setHubField(s, "ownerEmail", value)), focused: hubFocus === 1 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner password: " }), _jsx(TextInput, { value: state.hub.ownerPassword, onChange: (value) => setState((s) => setHubField(s, "ownerPassword", value)), masked: true, focused: hubFocus === 2 })] }), state.errors.ownerEmail && _jsx(Text, { color: theme.error, children: state.errors.ownerEmail }), state.errors.ownerPassword && _jsx(Text, { color: theme.error, children: state.errors.ownerPassword })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
199
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `will install and run at http://127.0.0.1:${parseHubPort(state.hub.port) ?? DEFAULT_HUB_PORT}` }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner email: " }), _jsx(TextInput, { value: state.hub.ownerEmail, onChange: (value) => setState((s) => setHubField(s, "ownerEmail", value)), focused: hubFocus === 1 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner password: " }), _jsx(TextInput, { value: state.hub.ownerPassword, onChange: (value) => setState((s) => setHubField(s, "ownerPassword", value)), masked: true, focused: hubFocus === 2 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "port: " }), _jsx(TextInput, { value: state.hub.port, onChange: (value) => {
200
+ portTouchedRef.current = true;
201
+ setState((s) => setHubField(s, "port", value.replace(/\D/g, "")));
202
+ }, focused: hubFocus === 3 })] }), state.errors.ownerEmail && _jsx(Text, { color: theme.error, children: state.errors.ownerEmail }), state.errors.ownerPassword && _jsx(Text, { color: theme.error, children: state.errors.ownerPassword }), state.errors.port && _jsx(Text, { color: theme.error, children: state.errors.port })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
176
203
  setRootsText(value);
177
204
  setState((s) => setRoots(s, value));
178
- }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" })), state.step === "apply" && (_jsxs(Box, { flexDirection: "column", children: [state.results.map((result, index) => (_jsx(Text, { children: applyResultLine(result) }, `${result.action.kind}-${index}`))), state.results.length < state.plan.length && _jsx(Text, { dimColor: true, children: "applying\u2026" })] })), state.step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `setup complete: ${state.results.length} change(s) applied` }), _jsx(Text, { dimColor: true, children: "enter open dashboard" })] }))] }) }), _jsx(KeyHints, { hints: hints })] }));
205
+ }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" }), state.plan.map((action) => action.note !== undefined && _jsx(Text, { children: action.note }, `note-${action.kind}-${action.file}`))] })), state.step === "apply" && (_jsxs(Box, { flexDirection: "column", children: [state.results.map((result, index) => (_jsx(Text, { children: applyResultLine(result) }, `${result.action.kind}-${index}`))), state.results.length < state.plan.length && _jsx(Text, { dimColor: true, children: "applying\u2026" })] })), state.step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `setup complete: ${state.results.length} change(s) applied` }), _jsx(Text, { dimColor: true, children: "enter open dashboard" })] }))] }) }), _jsx(KeyHints, { hints: hints })] }));
179
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "ink": "^7.1.1",
41
- "kankaku-pi": "^1.0.2",
42
- "kankaku-claude": "^1.0.2",
41
+ "kankaku-pi": "^1.1.0",
42
+ "kankaku-claude": "^1.1.0",
43
43
  "kankaku-hub": "^0.2.0",
44
44
  "react": "^19.3.0"
45
45
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-pi",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -58,8 +58,8 @@
58
58
  "prepublishOnly": "npm run check"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "0.85.1",
62
- "@earendil-works/pi-tui": "^0.85.1",
61
+ "@earendil-works/pi-coding-agent": "0.87.1",
62
+ "@earendil-works/pi-tui": "^0.87.1",
63
63
  "@types/node": "^24.13.4",
64
64
  "typescript": "^5.7.0"
65
65
  }