kankaku 1.0.2 → 1.2.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
 
@@ -412,7 +445,8 @@ the hub has no credentials — in which case `c`/`s`/`S` do nothing.
412
445
  - **Tasks** — a table (time, project, work, cost, prompt) with a
413
446
  highlighted row on the left, and a `[ Task ]` detail panel on the right
414
447
  showing the selected row's full prompt, client, project, hub task,
415
- wall/work/wait time, cost, cache hit and subagent count.
448
+ wall/work/wait time, cost, cache hit and subagent count. `m` and `M`
449
+ move (reassign) hub rows (see "Reassigning from the Tasks screen" below).
416
450
  - **Catalog** — `[ Clients ]` on the left; the selected client's
417
451
  `[ Projects ]`, with open/doing hub task counts, on the right. The
418
452
  Clients panel header shows the cache's age and a `(stale)` flag.
@@ -420,6 +454,64 @@ the hub has no credentials — in which case `c`/`s`/`S` do nothing.
420
454
  highlighted, and each action's result line shows inside its card while
421
455
  it runs and once it settles.
422
456
 
457
+ ### Reassigning from the Tasks screen
458
+
459
+ Sync never changes the assignment of a row that is already on the hub
460
+ (it stays create-only, so a reassignment made anywhere is never undone by
461
+ a later sync). The Tasks screen is the explicit way to change it from
462
+ kankaku: with the content zone focused, `m` moves (reassigns) the selected task
463
+ and `M` reassigns every task of the current view (the project filter and
464
+ the today/all scope) whose hub row is on the unassigned client.
465
+
466
+ Before anything opens, kankaku asks the hub for the rows involved (the
467
+ footer shows `asking the hub…`) and refreshes the catalog. If the hub is
468
+ not configured, rejects the credentials or cannot be reached, the footer
469
+ says so and nothing opens; so does `m` on a task that is not on the hub
470
+ yet (`not on the hub yet — sync first`) and `M` when no task of the view
471
+ is unassigned there.
472
+
473
+ The picker is a panel over the content zone with its own footer hints:
474
+
475
+ 1. **Client** — the active clients, with the unassigned client last, as
476
+ `unassigned`.
477
+ 2. **Project** — the client's active projects, plus `no project`.
478
+ 3. **Task** — the project's tasks that are not done, plus `no task`.
479
+ Skipped when `no project` was chosen.
480
+ 4. **Review** — one line per row, `current → new` by names, the count of
481
+ rows that will change and what will not be touched. `enter` applies.
482
+ 5. **Result** — one line per row (`reassigned`, `unchanged`, or
483
+ `failed: <reason>`); a failure never stops the remaining rows. `enter`
484
+ or `esc` returns to the list, which then shows the hub's assignment
485
+ (`hub …`) in the detail panel next to the local one, for the rows asked
486
+ about in this session.
487
+
488
+ `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move inside a step, `enter`
489
+ advances and `esc` (or `←`) goes back one step, closing the picker at the
490
+ first. While the picker is open, the screen's other keys and the app's
491
+ own `esc`/`←`/`Tab`/`1`-`4` are inert; `q` still quits.
492
+
493
+ What it does and does not do:
494
+
495
+ - Each changed row gets one `PATCH` of `{ client, project, task }` (an
496
+ empty relation is sent as `""`). Nothing else is written: not
497
+ `legacy_client_label`, not any measurement field.
498
+ - The selection is checked against the catalog before any request: the
499
+ project must belong to the client, the task to the project, all
500
+ active, the task not done. The picker cannot build an inconsistent
501
+ choice, and the planner rejects one anyway (the hub itself only checks
502
+ that each id exists).
503
+ - `M` only ever touches rows that are on the unassigned client on the
504
+ hub; a row that already has a real assignment is never changed by it.
505
+ Use `m` on that task to move it deliberately.
506
+ - The local worklog is never rewritten; the Tasks screen keeps showing
507
+ the local assignment. After a reassignment the hub's assignment is shown
508
+ next to it for the rows asked about in this session.
509
+ - Tasks that are not on the hub yet must be synced first (Sync screen or
510
+ `kankaku sync`).
511
+ - The hub keeps no record of who reassigned a row or when. The
512
+ service account must be allowed to update `task_entries` (the hub's
513
+ `owner` and `service` roles are).
514
+
423
515
  ## Keys (TUI)
424
516
 
425
517
  The app has two focus zones — the sidebar and the active screen's own main
@@ -455,9 +547,11 @@ jump to the first/last row.
455
547
  (filtered to it), `r` refresh; the Quick actions panel additionally
456
548
  takes `c` (refresh catalog), `s` (sync all projects) and `S` (full sync
457
549
  all) — one at a time, ignored while another is running.
458
- - **Tasks** — `a` toggle today/all, `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End`
459
- move the selection, `r` refresh, `esc` clears a project filter set from
460
- Dashboard.
550
+ - **Tasks** — `a` toggle today/all,
551
+ `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection, `r` refresh,
552
+ `m` move (reassign) the selected task on the hub, `M` move every task of
553
+ the view that is unassigned on the hub (both open the picker, see above),
554
+ `esc` clears a project filter set from Dashboard.
461
555
  - **Catalog** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the client
462
556
  selection, `r` refresh from the hub.
463
557
  - **Sync** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection,
@@ -469,4 +563,6 @@ The TUI never writes to disk on its own — Dashboard, Tasks and read-only
469
563
  Catalog views write nothing at all; Catalog's `refresh`, Sync's
470
564
  `s`/`f`/`S` and Dashboard's Quick actions `c`/`s`/`S` write only through
471
565
  kankaku's own adapters (`CachedCatalog`, `SyncStateStore`, the hub
472
- itself), exactly as kankaku's own sync paths do.
566
+ itself), exactly as kankaku's own sync paths do. Tasks' `m`/`M` write
567
+ nothing to disk either (apart from the catalog cache refresh): they change
568
+ the hub's rows only, after the picker's Review step.
@@ -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,115 @@
1
+ /**
2
+ * The hub side of the Tasks screen's reassignment: read the `task_entries`
3
+ * rows of some kankaku tasks (by `task_id`, in chunks) and PATCH their
4
+ * `client`/`project`/`task` relations, both through the published
5
+ * `kankaku-pi/hub` client, so authentication, the 401 re-auth and the
6
+ * per-request time bound are the client's own. Sync stays create-only:
7
+ * this is the only path that changes an existing row's assignment, and it
8
+ * sends nothing but those three relations (never `legacy_client_label`,
9
+ * never a measurement field).
10
+ */
11
+ import { PocketBaseClient, PocketBaseError, escapeFilterValue } from "kankaku-pi/hub";
12
+ import { createCatalog } from "./hub.js";
13
+ /** Task ids per lookup request, per the hub contract's guidance (and the sync sink's own chunking). */
14
+ const LOOKUP_CHUNK_SIZE = 30;
15
+ /** Overall bound for `prepare` (row lookups plus the catalog refresh), on top of each request's own time bound. */
16
+ const PREPARE_TIMEOUT_MS = 20_000;
17
+ function relation(value) {
18
+ return typeof value === "string" ? value : "";
19
+ }
20
+ /**
21
+ * The hub rows of `taskIds`, keyed by kankaku task id. A task with no row
22
+ * is simply absent from the map. An empty relation reads `""`. A hub
23
+ * failure propagates as the client's `PocketBaseError`.
24
+ */
25
+ export async function fetchHubRows(client, taskIds, options = {}) {
26
+ const chunkSize = options.chunkSize ?? LOOKUP_CHUNK_SIZE;
27
+ const rows = new Map();
28
+ for (let start = 0; start < taskIds.length; start += chunkSize) {
29
+ const chunk = taskIds.slice(start, start + chunkSize);
30
+ const filter = chunk.map((taskId) => `task_id="${escapeFilterValue(taskId)}"`).join("||");
31
+ const items = await client.list("task_entries", { filter, perPage: chunkSize }, options.signal);
32
+ for (const item of items) {
33
+ const taskId = item["task_id"];
34
+ if (typeof taskId !== "string")
35
+ continue;
36
+ rows.set(taskId, {
37
+ rowId: item.id,
38
+ taskId,
39
+ clientId: relation(item["client"]),
40
+ projectId: relation(item["project"]),
41
+ hubTaskId: relation(item["task"]),
42
+ });
43
+ }
44
+ }
45
+ return rows;
46
+ }
47
+ /** A short, plain reason for a failed hub call: never a URL, a row id or a stack. */
48
+ export function describeHubError(error) {
49
+ if (!(error instanceof PocketBaseError))
50
+ return error instanceof Error ? error.message : String(error);
51
+ switch (error.kind) {
52
+ case "auth":
53
+ return "the hub rejected the credentials";
54
+ case "timeout":
55
+ return "the request timed out";
56
+ case "network":
57
+ return "the hub is unreachable";
58
+ case "http":
59
+ if (error.status === 400)
60
+ return "the hub rejected the change (400)";
61
+ if (error.status === 403)
62
+ return "not allowed to change this row (403)";
63
+ if (error.status === 404)
64
+ return "the row no longer exists on the hub (404)";
65
+ return `the hub answered ${error.status ?? "an error"}`;
66
+ }
67
+ }
68
+ /**
69
+ * One `PATCH /api/collections/task_entries/records/<rowId>` per `reassign`
70
+ * line, body `{ client, project, task }` (`""` for an empty relation).
71
+ * Rows are sent one after the other; a failure is recorded for its row and
72
+ * the rest still run. Returns one outcome per `reassign` line, in order.
73
+ */
74
+ export async function applyReassignment(client, plan) {
75
+ const outcomes = [];
76
+ for (const line of plan.lines) {
77
+ if (line.kind !== "reassign")
78
+ continue;
79
+ const body = { client: line.payload.client, project: line.payload.project, task: line.payload.task };
80
+ try {
81
+ await client.request("PATCH", `/api/collections/task_entries/records/${encodeURIComponent(line.rowId)}`, body);
82
+ outcomes.push({ taskId: line.taskId, status: "reassigned" });
83
+ }
84
+ catch (error) {
85
+ outcomes.push({ taskId: line.taskId, status: "failed", reason: describeHubError(error) });
86
+ }
87
+ }
88
+ return outcomes;
89
+ }
90
+ /** Build the {@link ReassignActions} for `credentials`: one client for the row lookups and the PATCHes, the disk-backed catalog for the refresh. */
91
+ export function createReassignActions(credentials, deps) {
92
+ const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
93
+ const catalog = createCatalog(credentials, deps);
94
+ return {
95
+ async prepare(taskIds) {
96
+ const signal = AbortSignal.timeout(PREPARE_TIMEOUT_MS);
97
+ try {
98
+ const rows = await fetchHubRows(client, taskIds, { signal });
99
+ const snapshot = await catalog.refresh(signal);
100
+ if (snapshot === undefined)
101
+ return { ok: false, message: "could not refresh the catalog from the hub" };
102
+ return { ok: true, rows, catalog: { clients: snapshot.clients, projects: snapshot.projects, tasks: snapshot.tasks ?? [] } };
103
+ }
104
+ catch (error) {
105
+ return { ok: false, message: describeHubError(error) };
106
+ }
107
+ },
108
+ async apply(plan) {
109
+ const outcomes = await applyReassignment(client, plan);
110
+ // Task statuses may have moved (opening a task marks it as doing); a failed refresh only leaves the cache as it was.
111
+ await catalog.refresh().catch(() => undefined);
112
+ return outcomes;
113
+ },
114
+ };
115
+ }
@@ -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
  }