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 +120 -24
- package/dist/adapters/hub-manager/credentials.js +58 -0
- package/dist/adapters/hub-manager/install.js +93 -14
- package/dist/adapters/hub-manager/port-probe.js +38 -0
- package/dist/adapters/hub-reassign.js +115 -0
- package/dist/adapters/package-versions.js +24 -0
- package/dist/adapters/setup/hub.js +1 -1
- package/dist/adapters/setup/json-writer.js +8 -3
- package/dist/cli.js +100 -20
- package/dist/domain/local-hub-model.js +40 -0
- package/dist/domain/nav-model.js +8 -0
- package/dist/domain/reassign-model.js +101 -0
- package/dist/domain/reassign-picker.js +258 -0
- package/dist/domain/setup-wizard.js +23 -4
- package/dist/domain/version-info.js +5 -0
- package/dist/ports/reassign-actions.js +1 -0
- package/dist/ui/app.js +9 -4
- package/dist/ui/components/table.js +2 -2
- package/dist/ui/layout.js +3 -2
- package/dist/ui/reassign-panel.js +36 -0
- package/dist/ui/setup/wizard-screen.js +31 -4
- package/dist/ui/tasks-screen.js +167 -10
- package/package.json +3 -3
- package/vendor/kankaku-pi/package.json +3 -3
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
|
|
85
|
-
|
|
86
|
-
`kankaku hub install` (see "Local hub" below): no
|
|
87
|
-
checkout is needed
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
-
|
|
269
|
-
(`kankaku-sync@kankaku.local`)
|
|
270
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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,
|
|
459
|
-
move the selection, `r` refresh,
|
|
460
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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.
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
}
|