kankaku 1.0.1 → 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 +52 -19
- 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/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 +83 -19
- package/dist/domain/local-hub-model.js +40 -0
- package/dist/domain/setup-wizard.js +23 -4
- package/dist/domain/version-info.js +5 -0
- package/dist/ui/setup/wizard-screen.js +31 -4
- 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
|
|
|
@@ -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,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
|
-
/**
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
334
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 })] }),
|
|
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
|
|
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
|
|
42
|
-
"kankaku-claude": "^1.0
|
|
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
|
|
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.
|
|
62
|
-
"@earendil-works/pi-tui": "^0.
|
|
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
|
}
|