kankaku-tui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,24 +16,27 @@ kankaku setup
16
16
  ```
17
17
 
18
18
  On a real terminal, `kankaku setup` opens as a full-screen wizard — one
19
- step at a time, in the same sidebar/panel look as the rest of the app.
20
- `kankaku` with no arguments does the same the very first time (no
21
- `~/.kankaku/tui.json` yet); after that first run it opens straight into
22
- the Dashboard as usual.
19
+ step at a time, in the same header/panel/footer look as the rest of the
20
+ app (there's no sidebar in the wizard itself: the panel's own title
21
+ tracks progress instead, e.g. `Setup · Agents 1/5`, numbering only the
22
+ steps this run will actually show). `kankaku` with no arguments does the
23
+ same the very first time (no `~/.kankaku/tui.json` yet); after that first
24
+ run it opens straight into the Dashboard as usual.
23
25
 
24
26
  The wizard's steps, `enter` to advance and `esc` to go back throughout
25
- (`esc` at the first step quits):
27
+ (`esc` at the first step, Agents, quits):
26
28
 
27
- 1. **Detect** — which agents kankaku found on this machine, and their
28
- current state.
29
- 2. **Agents** — a checklist (`space` toggles): pi, gentle-shell, Claude
30
- Code. Pre-checked means already configured; unchecking a configured
29
+ 1. **Agents** — a checklist (`space` toggles) that also carries detection
30
+ for every agent kankaku knows about: pi, gentle-shell and Claude Code
31
+ each show `configured (<path, shortened with ~>)` or `not configured`;
32
+ pre-checked means already configured, and unchecking a configured
31
33
  agent schedules removing kankaku from it, not just skipping it. Codex
32
- and OpenCode are listed but disabled — no adapter yet.
33
- 3. **Claude Code** — the `kankaku-claude` checkout path (only shown when
34
+ and OpenCode are listed but disabled, showing `no adapter yet` (found,
35
+ but kankaku can't write its config) or `not installed` (not found).
36
+ 2. **Claude Code** — the `kankaku-claude` checkout path (only shown when
34
37
  Claude Code is checked and not already configured), guessed from any
35
38
  existing `statusLine`.
36
- 4. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
39
+ 3. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
37
40
  inline health check, reusing the current credentials as the default),
38
41
  `install locally`, or `skip`. Installing locally looks for a
39
42
  `kankaku-hub` checkout first; if found, its path is prefilled and
@@ -45,14 +48,14 @@ The wizard's steps, `enter` to advance and `esc` to go back throughout
45
48
  wizard shows the exact commands to run in another terminal and a `c`
46
49
  "check again" action, plus `m` to acknowledge you'll install it
47
50
  manually.
48
- 5. **Roots** — the comma-separated project roots, defaulting to the
51
+ 4. **Roots** — the comma-separated project roots, defaulting to the
49
52
  current `tui.json` (or the parent of the current directory the first
50
- time).
51
- 6. **Review** — the plan: one line per change, with the exact file it
53
+ time). See "Configuration" below for how deep each root is searched.
54
+ 5. **Review** — the plan: one line per change, with the exact file it
52
55
  touches. `enter` applies it.
53
- 7. **Apply** — runs each change and shows its result
56
+ 6. **Apply** — runs each change and shows its result
54
57
  (`wrote`/`unchanged`/`removed`/`started`/`error: …`) as it happens.
55
- 8. **Done** — a summary, then `enter` opens the Dashboard in place — no
58
+ 7. **Done** — a summary, then `enter` opens the Dashboard in place — no
56
59
  restart.
57
60
 
58
61
  `kankaku setup --yes` and `kankaku setup --dry-run` stay exactly as
@@ -73,12 +76,16 @@ same read-only report: one line per agent, one for the hub, one for
73
76
 
74
77
  ## Install
75
78
 
76
- Until the next kankaku release, this package depends on the sibling
77
- `kankaku` checkout via `file:../kankaku`, so both repos must sit next to
78
- each other on disk. Run `npm install` inside `kankaku-tui/`.
79
+ ```
80
+ npm install -g kankaku-tui
81
+ ```
79
82
 
80
- Later, once published: `npm install -g kankaku-tui`. For now, run it from
81
- this repo with `npm run dev`.
83
+ Then run `kankaku setup` (or just `kankaku` the first time) to configure
84
+ the coding agents on this machine, the hub and your project roots. The
85
+ package depends on the published `kankaku` library (`kankaku/domain`,
86
+ `kankaku/hub`) and on `kankaku-hub` for the local hub installer; nothing
87
+ else needs to be checked out. To work on this repo itself, run
88
+ `npm install` inside it and `npm run dev`.
82
89
 
83
90
  ## Configuration
84
91
 
@@ -90,10 +97,23 @@ this repo with `npm run dev`.
90
97
  }
91
98
  ```
92
99
 
93
- Each root is either a project itself (it has its own `.kankaku/worklog.jsonl`)
94
- or a directory containing one or more projects as direct subdirectories.
95
- `~` expands to the home directory. Missing or malformed config falls back
96
- to the current working directory as the only root.
100
+ ### Projects across roots
101
+
102
+ Each root is searched recursively for projects, up to 5 directory levels
103
+ below it by default: a directory is a project once it has its own
104
+ `.kankaku/worklog.jsonl` — including the root itself — and the search
105
+ still continues below it, so a stray worklog in a parent directory (a pi
106
+ session run once in `~/desarrollo`) never hides the projects beneath;
107
+ every directory is listed at most once. Subdirectories are searched one
108
+ level deeper, skipping `node_modules`, `.git` and any hidden
109
+ (dot-prefixed) directory. This lets one root cover a whole workspace, e.g.
110
+ `~/desarrollo` finding every project under `~/desarrollo/<client>/<project>`
111
+ without listing each one. Projects are deduped by real (symlink-resolved)
112
+ path and sorted by name — the directory's basename, or the last two path
113
+ segments joined with `/` when two discovered projects share a basename
114
+ (e.g. `clientA/shared` and `clientB/shared`). `~` expands to the home
115
+ directory. Missing or malformed config falls back to the current working
116
+ directory as the only root.
97
117
 
98
118
  ### Hub credentials (Catalog and Sync)
99
119
 
@@ -127,6 +147,51 @@ Every sync uploaded from here is stamped `plugin: kankaku-tui`; a task's
127
147
  kankaku's `hub-entry.ts`), else falls back to `agent: unknown` — this app
128
148
  never guesses which coding agent produced someone else's worklog.
129
149
 
150
+ ### Local hub
151
+
152
+ Instead of pointing at someone else's PocketBase, `kankaku hub install`
153
+ sets up and runs your own hub on this machine, under `~/.kankaku/hub/`:
154
+
155
+ - `bin/pocketbase` — the PocketBase binary for this OS/CPU, downloaded
156
+ from the `kankaku-hub` npm package's manifest and SHA256-verified.
157
+ - `pb_data/` — the hub's own database; never touched by an upgrade.
158
+ - `app/<version>/` — a fresh copy of that package version's migrations,
159
+ hooks and static files (never a symlink, so `npm update` can't change a
160
+ running hub out from under it); `current` names the active version.
161
+ - `hub.json` — the installed port and versions.
162
+ - `accounts.json` (owner-only, `0600`) — the PocketBase superuser email
163
+ and generated password, and the owner account's email. The owner logs
164
+ into the hub's own web admin UI with that owner account.
165
+ - `~/.kankaku/credentials.json` — the generated `service` account
166
+ (`kankaku-sync@kankaku.local`) this app and kankaku's own sync already
167
+ read, exactly like a remote hub's credentials.
168
+
169
+ Commands (macOS and Linux only — PocketBase ships no other build):
170
+
171
+ - `kankaku hub install [--port N] [--owner-email E] [--owner-password P]`
172
+ — installs (or, run again, verifies) the hub and leaves it running. On
173
+ a real terminal, a missing owner email/password is prompted for
174
+ (masked); without a TTY, both flags are required. Idempotent: re-running
175
+ with everything already in place changes nothing.
176
+ - `kankaku hub start` / `kankaku hub stop` — start or stop the server
177
+ process; `stop` is a no-op when it isn't running.
178
+ - `kankaku hub status` — `local hub: running 0.2.0 (PocketBase 0.40.4) at
179
+ http://127.0.0.1:8090 · pb_data 1.2 MB`, `stopped`, or `not installed`.
180
+ - `kankaku hub upgrade` — copies a fresh `app/<version>/` from the
181
+ currently installed `kankaku-hub` package, downloads a new PocketBase
182
+ binary only if that version changed, and restarts — `pb_data` is never
183
+ touched.
184
+ - `kankaku hub logs [-n N]` — the last `N` (default 50) lines of
185
+ `hub.log`.
186
+
187
+ The Dashboard's Hub card shows `local hub · running`/`stopped` when the
188
+ configured hub is this machine's own local install, with a matching `h`
189
+ quick action to start or stop it. The setup wizard's Hub step's
190
+ `install locally` option runs this same installer (asking for the owner
191
+ email/password inline); the older checkout-based dev install
192
+ (`kankaku-hub`'s own `scripts/dev.sh`) is still available for hub
193
+ developers via `kankaku setup --from-checkout <dir>`.
194
+
130
195
  ## Usage
131
196
 
132
197
  - `kankaku` — opens the interactive TUI on the Dashboard screen.
@@ -141,9 +206,13 @@ never guesses which coding agent produced someone else's worklog.
141
206
  pending count and last sync per project, no network; with no argument,
142
207
  syncs the pending window; `all` does a full resync. Defaults to every
143
208
  discovered project, sequentially; `--project <dir>` restricts to one.
144
- - `kankaku setup [--yes] [--dry-run]` — see "Install everything" above.
209
+ - `kankaku setup [--yes] [--dry-run] [--from-checkout <dir>]` — see
210
+ "Install everything" above; `--from-checkout` is the hub-developer-only
211
+ checkout-based local hub install, see "Local hub" above.
145
212
  - `kankaku doctor` — the same read-only report `kankaku setup` ends with,
146
213
  without prompting or writing anything.
214
+ - `kankaku hub install|start|stop|status|upgrade|logs` — the local hub's
215
+ lifecycle; see "Local hub" above.
147
216
 
148
217
  `--roots` (on `today`/`tasks`) overrides the configured roots for that run.
149
218
 
@@ -0,0 +1,52 @@
1
+ /** Runs `pocketbase superuser upsert <email> <password> --dir <pbData>` through `runner`. Throws on a non-zero exit, including stderr/stdout in the message. */
2
+ export async function upsertSuperuser(binary, pbData, email, password, runner) {
3
+ const result = await runner.run(binary, ["superuser", "upsert", email, password, "--dir", pbData], { cwd: pbData });
4
+ if (result.code !== 0) {
5
+ throw new Error(`pocketbase superuser upsert failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
6
+ }
7
+ }
8
+ /**
9
+ * Authenticates as `superuser` against the `_superusers` collection, looks
10
+ * up `user.email` in `users`, and creates it (role `owner` or `service`,
11
+ * `emailVisibility: true`, `verified: true`) when absent. Idempotent: a
12
+ * user that already exists is left untouched and reported as `"exists"`.
13
+ */
14
+ export async function createUser(baseUrl, superuser, user, doFetch) {
15
+ const url = baseUrl.replace(/\/+$/, "");
16
+ const authResponse = await doFetch(`${url}/api/collections/_superusers/auth-with-password`, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/json" },
19
+ body: JSON.stringify({ identity: superuser.email, password: superuser.password }),
20
+ });
21
+ if (!authResponse.ok) {
22
+ throw new Error(`superuser authentication failed: HTTP ${authResponse.status}`);
23
+ }
24
+ const auth = (await authResponse.json());
25
+ const filter = encodeURIComponent(`email="${user.email}"`);
26
+ const lookupResponse = await doFetch(`${url}/api/collections/users/records?filter=${filter}`, {
27
+ headers: { authorization: auth.token },
28
+ });
29
+ if (!lookupResponse.ok) {
30
+ throw new Error(`user lookup failed: HTTP ${lookupResponse.status}`);
31
+ }
32
+ const lookup = (await lookupResponse.json());
33
+ if (lookup.items && lookup.items.length > 0) {
34
+ return { outcome: "exists" };
35
+ }
36
+ const createResponse = await doFetch(`${url}/api/collections/users/records`, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json", authorization: auth.token },
39
+ body: JSON.stringify({
40
+ email: user.email,
41
+ password: user.password,
42
+ passwordConfirm: user.password,
43
+ role: user.role,
44
+ emailVisibility: true,
45
+ verified: true,
46
+ }),
47
+ });
48
+ if (!createResponse.ok) {
49
+ throw new Error(`user creation failed: HTTP ${createResponse.status}`);
50
+ }
51
+ return { outcome: "created" };
52
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Downloads a PocketBase release asset (from `HubManifest.pocketbase.assets`),
3
+ * verifies its SHA256 against the manifest, and extracts the `pocketbase`
4
+ * binary from the zip into `targetBinary`.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { extractSingleEntry } from "./zip.js";
10
+ const BINARY_MODE = 0o755;
11
+ /**
12
+ * Streams `asset.url` to a temp file next to `targetBinary`, verifies its
13
+ * SHA256 against `asset.sha256`, extracts the single `pocketbase` entry
14
+ * from the zip, and writes it to `targetBinary` (mode 0755) via temp file
15
+ * + rename. Throws `checksum mismatch` and deletes the temp download when
16
+ * the hash does not match; never leaves a partial or temp file behind on
17
+ * any failure.
18
+ */
19
+ export async function downloadPocketBase(asset, targetBinary, deps) {
20
+ const mkdir = deps.mkdir ?? ((dir) => mkdirSync(dir, { recursive: true }));
21
+ const targetDir = dirname(targetBinary);
22
+ mkdir(targetDir);
23
+ const response = await deps.fetch(asset.url);
24
+ if (!response.ok) {
25
+ throw new Error(`failed to download ${asset.url}: HTTP ${response.status}`);
26
+ }
27
+ const zipBytes = new Uint8Array(await response.arrayBuffer());
28
+ const zipTmpPath = `${targetBinary}.${process.pid}.download.tmp`;
29
+ writeFileSync(zipTmpPath, zipBytes);
30
+ try {
31
+ const hash = createHash("sha256").update(zipBytes).digest("hex");
32
+ if (hash.toLowerCase() !== asset.sha256.toLowerCase()) {
33
+ throw new Error(`checksum mismatch for ${asset.file}: expected ${asset.sha256}, got ${hash}`);
34
+ }
35
+ const binaryBytes = extractSingleEntry(zipBytes, "pocketbase");
36
+ const binaryTmpPath = `${targetBinary}.${process.pid}.tmp`;
37
+ try {
38
+ writeFileSync(binaryTmpPath, binaryBytes, { mode: BINARY_MODE });
39
+ renameSync(binaryTmpPath, targetBinary);
40
+ }
41
+ catch (error) {
42
+ rmSync(binaryTmpPath, { force: true });
43
+ throw error;
44
+ }
45
+ }
46
+ finally {
47
+ rmSync(zipTmpPath, { force: true });
48
+ }
49
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Orchestrates the local hub's full lifecycle: install (idempotent),
3
+ * start, stop, status and upgrade — composing the pure
4
+ * `domain/local-hub-model.ts` with the other `hub-manager/*` adapters
5
+ * (`package.ts`, `download.ts`, `process.ts`, `accounts.ts`) and
6
+ * `adapters/setup/hub.ts#writeHubCredentials`. Every piece of I/O is
7
+ * injected (`HubManagerDeps`) so tests never touch the network, spawn a
8
+ * real PocketBase, or run a real script.
9
+ */
10
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { assetKeyFor, classifyStatus, generatePassword, hubLayout, parseHubConfig, serveArgs, } from "../../domain/local-hub-model.js";
13
+ import { locateHubPackage } from "./package.js";
14
+ import { downloadPocketBase } from "./download.js";
15
+ import { isAlive, readPid, stopProcess, waitForHealth } from "./process.js";
16
+ import { createUser, upsertSuperuser } from "./accounts.js";
17
+ import { writeHubCredentials } from "../setup/hub.js";
18
+ const OWNER_DIR_MODE = 0o700;
19
+ const OWNER_FILE_MODE = 0o600;
20
+ /** The PocketBase superuser account this package provisions on first install; distinct from the owner/service application users. */
21
+ const SUPERUSER_EMAIL = "admin@kankaku.local";
22
+ const SERVICE_EMAIL = "kankaku-sync@kankaku.local";
23
+ export const DEFAULT_HUB_PORT = 8090;
24
+ const HEALTH_TIMEOUT_MS = 20000;
25
+ const STOP_TIMEOUT_MS = 5000;
26
+ function message(error) {
27
+ return error instanceof Error ? error.message : String(error);
28
+ }
29
+ function readConfigOrUndefined(hubJsonPath) {
30
+ if (!existsSync(hubJsonPath))
31
+ return undefined;
32
+ try {
33
+ return parseHubConfig(JSON.parse(readFileSync(hubJsonPath, "utf8")));
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /** Fresh-copies `pocketbase/pb_migrations`, `pocketbase/pb_hooks` and `public` from the package into `appDir` — never a symlink, so `npm update` cannot change a running hub. */
40
+ function copyAppFiles(packageDir, appDir) {
41
+ cpSync(join(packageDir, "pocketbase", "pb_migrations"), join(appDir, "pocketbase", "pb_migrations"), { recursive: true });
42
+ cpSync(join(packageDir, "pocketbase", "pb_hooks"), join(appDir, "pocketbase", "pb_hooks"), { recursive: true });
43
+ cpSync(join(packageDir, "public"), join(appDir, "public"), { recursive: true });
44
+ }
45
+ function baseUrlFor(port) {
46
+ return `http://127.0.0.1:${port}`;
47
+ }
48
+ /**
49
+ * Installs (or, run again, verifies) the local hub under
50
+ * `~/.kankaku/hub`: locates the `kankaku-hub` package, creates the layout
51
+ * directories (root created 0700, never chmod'd again once it exists —
52
+ * mirrors kankaku's own R2 rule), downloads the PocketBase binary for
53
+ * this platform when missing or out of date (SHA256-verified), copies the
54
+ * package's migrations/hooks/public into `app/<version>/`, writes
55
+ * `hub.json`, and — only the first time, when `accounts.json` doesn't
56
+ * exist yet — provisions the superuser and the owner/service application
57
+ * users, then leaves the server running. Idempotent: re-running with
58
+ * everything already present reports every step `unchanged` and touches
59
+ * neither the process nor the accounts. Never throws; a failing step
60
+ * stops the sequence and is reported as `error`.
61
+ */
62
+ export async function installHub(options, deps) {
63
+ const port = options.port ?? DEFAULT_HUB_PORT;
64
+ const steps = [];
65
+ let located;
66
+ try {
67
+ located = deps.locatePackage();
68
+ }
69
+ catch (error) {
70
+ return { ok: false, steps: [{ step: "locate kankaku-hub package", outcome: "error", detail: message(error) }] };
71
+ }
72
+ const manifest = located.manifest;
73
+ const layout = hubLayout(deps.homeDir);
74
+ const rootExisted = existsSync(layout.root);
75
+ if (!rootExisted)
76
+ mkdirSync(layout.root, { recursive: true, mode: OWNER_DIR_MODE });
77
+ mkdirSync(layout.bin, { recursive: true });
78
+ mkdirSync(layout.pbData, { recursive: true });
79
+ steps.push({ step: "create ~/.kankaku/hub", outcome: rootExisted ? "unchanged" : "done" });
80
+ const existingConfig = readConfigOrUndefined(layout.hubJson);
81
+ const needsBinary = !existsSync(layout.binary) || existingConfig?.pocketbaseVersion !== manifest.pocketbase.version;
82
+ if (needsBinary) {
83
+ try {
84
+ const asset = manifest.pocketbase.assets[assetKeyFor(deps.platform, deps.arch)];
85
+ await downloadPocketBase(asset, layout.binary, { fetch: deps.fetch });
86
+ steps.push({ step: "download pocketbase", outcome: "done", detail: manifest.pocketbase.version });
87
+ }
88
+ catch (error) {
89
+ steps.push({ step: "download pocketbase", outcome: "error", detail: message(error) });
90
+ return { ok: false, steps };
91
+ }
92
+ }
93
+ else {
94
+ steps.push({ step: "download pocketbase", outcome: "unchanged" });
95
+ }
96
+ const appDir = layout.appDir(manifest.version);
97
+ const appDirExisted = existsSync(appDir);
98
+ if (!appDirExisted) {
99
+ try {
100
+ copyAppFiles(located.dir, appDir);
101
+ }
102
+ catch (error) {
103
+ steps.push({ step: "install app files", outcome: "error", detail: message(error) });
104
+ return { ok: false, steps };
105
+ }
106
+ }
107
+ writeFileSync(layout.currentFile, manifest.version);
108
+ steps.push({ step: "install app files", outcome: appDirExisted ? "unchanged" : "done", detail: manifest.version });
109
+ const newConfig = {
110
+ port,
111
+ appVersion: manifest.version,
112
+ pocketbaseVersion: manifest.pocketbase.version,
113
+ installedAt: existingConfig?.installedAt ?? new Date(deps.now()).toISOString(),
114
+ };
115
+ const hubJsonChanged = !existingConfig || existingConfig.port !== newConfig.port || existingConfig.appVersion !== newConfig.appVersion || existingConfig.pocketbaseVersion !== newConfig.pocketbaseVersion;
116
+ if (hubJsonChanged)
117
+ writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
118
+ steps.push({ step: "write hub.json", outcome: hubJsonChanged ? "done" : "unchanged" });
119
+ const accountsExist = existsSync(layout.accountsJson);
120
+ if (accountsExist) {
121
+ steps.push({ step: "provision accounts", outcome: "unchanged" });
122
+ return { ok: true, steps, url: baseUrlFor(port) };
123
+ }
124
+ try {
125
+ const superuserPassword = generatePassword(deps.randomBytes);
126
+ await upsertSuperuser(layout.binary, layout.pbData, SUPERUSER_EMAIL, superuserPassword, deps.runner);
127
+ deps.startDetached(layout.binary, serveArgs(layout, manifest.version, port), { logFile: layout.logFile, pidFile: layout.pidFile });
128
+ const url = baseUrlFor(port);
129
+ const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
130
+ if (!healthy) {
131
+ steps.push({ step: "provision accounts", outcome: "error", detail: "the local hub did not become healthy within 20s" });
132
+ return { ok: false, steps };
133
+ }
134
+ const superuser = { email: SUPERUSER_EMAIL, password: superuserPassword };
135
+ await createUser(url, superuser, { email: options.ownerEmail, password: options.ownerPassword, role: "owner" }, deps.fetch);
136
+ const servicePassword = generatePassword(deps.randomBytes);
137
+ await createUser(url, superuser, { email: SERVICE_EMAIL, password: servicePassword, role: "service" }, deps.fetch);
138
+ const accounts = { superuserEmail: SUPERUSER_EMAIL, superuserPassword, ownerEmail: options.ownerEmail };
139
+ writeFileSync(layout.accountsJson, JSON.stringify(accounts, null, 2));
140
+ chmodSync(layout.accountsJson, OWNER_FILE_MODE);
141
+ writeHubCredentials(deps.homeDir, { url, email: SERVICE_EMAIL, password: servicePassword });
142
+ steps.push({ step: "provision accounts", outcome: "done" });
143
+ return { ok: true, steps, url };
144
+ }
145
+ catch (error) {
146
+ steps.push({ step: "provision accounts", outcome: "error", detail: message(error) });
147
+ return { ok: false, steps };
148
+ }
149
+ }
150
+ /** Starts the installed hub: a no-op (`unchanged`) if already running, otherwise spawns it and waits for health. */
151
+ export async function startHub(deps) {
152
+ const layout = hubLayout(deps.homeDir);
153
+ const config = readConfigOrUndefined(layout.hubJson);
154
+ if (!config)
155
+ return { ok: false, steps: [{ step: "start", outcome: "error", detail: "the local hub is not installed" }] };
156
+ const pid = readPid(layout.pidFile);
157
+ if (pid !== undefined && isAlive(pid)) {
158
+ return { ok: true, steps: [{ step: "start", outcome: "unchanged", detail: "already running" }], url: baseUrlFor(config.port) };
159
+ }
160
+ deps.startDetached(layout.binary, serveArgs(layout, config.appVersion, config.port), { logFile: layout.logFile, pidFile: layout.pidFile });
161
+ const url = baseUrlFor(config.port);
162
+ const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
163
+ return {
164
+ ok: healthy,
165
+ steps: [{ step: "start", outcome: healthy ? "done" : "error", detail: healthy ? undefined : "did not become healthy within 20s" }],
166
+ url,
167
+ };
168
+ }
169
+ /** Stops the installed hub via `SIGTERM` (bounded wait, `SIGKILL` as a last resort). A no-op (`unchanged`) when it wasn't running. */
170
+ export async function stopHub(deps) {
171
+ const layout = hubLayout(deps.homeDir);
172
+ const result = await stopProcess(layout.pidFile, { timeoutMs: STOP_TIMEOUT_MS, sleep: deps.sleep });
173
+ return { ok: true, steps: [{ step: "stop", outcome: result === "not-running" ? "unchanged" : "done", detail: result }] };
174
+ }
175
+ /** Classifies the installed hub's current status (`hubLayout`, pid liveness and, only while a process is alive, one health check). Never throws. */
176
+ export async function hubStatus(deps) {
177
+ const layout = hubLayout(deps.homeDir);
178
+ const config = readConfigOrUndefined(layout.hubJson);
179
+ const pid = readPid(layout.pidFile);
180
+ const pidAlive = pid !== undefined && isAlive(pid);
181
+ let health = "skipped";
182
+ if (pidAlive && config) {
183
+ try {
184
+ const response = await deps.fetch(`${baseUrlFor(config.port)}/api/health`);
185
+ health = response.ok ? "ok" : "failed";
186
+ }
187
+ catch {
188
+ health = "failed";
189
+ }
190
+ }
191
+ return classifyStatus({ installed: config !== undefined, config, pidAlive, health });
192
+ }
193
+ /**
194
+ * Upgrades the local hub to the currently installed `kankaku-hub`
195
+ * package's version: copies a fresh `app/<new version>/` (when not
196
+ * already present), downloads a new PocketBase binary only if its
197
+ * version changed, restarts the process, and leaves `pb_data` untouched.
198
+ * A no-op (`unchanged`) when the installed package is already the
199
+ * running version.
200
+ */
201
+ export async function upgradeHub(deps) {
202
+ const layout = hubLayout(deps.homeDir);
203
+ const config = readConfigOrUndefined(layout.hubJson);
204
+ if (!config)
205
+ return { ok: false, steps: [{ step: "upgrade", outcome: "error", detail: "the local hub is not installed" }] };
206
+ let located;
207
+ try {
208
+ located = deps.locatePackage();
209
+ }
210
+ catch (error) {
211
+ return { ok: false, steps: [{ step: "locate kankaku-hub package", outcome: "error", detail: message(error) }] };
212
+ }
213
+ const manifest = located.manifest;
214
+ if (manifest.version === config.appVersion && manifest.pocketbase.version === config.pocketbaseVersion) {
215
+ return { ok: true, steps: [{ step: "upgrade", outcome: "unchanged", detail: "already up to date" }], url: baseUrlFor(config.port) };
216
+ }
217
+ const steps = [];
218
+ const appDir = layout.appDir(manifest.version);
219
+ const appDirExisted = existsSync(appDir);
220
+ if (!appDirExisted) {
221
+ try {
222
+ copyAppFiles(located.dir, appDir);
223
+ }
224
+ catch (error) {
225
+ steps.push({ step: "install app files", outcome: "error", detail: message(error) });
226
+ return { ok: false, steps };
227
+ }
228
+ }
229
+ writeFileSync(layout.currentFile, manifest.version);
230
+ steps.push({ step: "install app files", outcome: appDirExisted ? "unchanged" : "done", detail: manifest.version });
231
+ if (manifest.pocketbase.version !== config.pocketbaseVersion) {
232
+ try {
233
+ const asset = manifest.pocketbase.assets[assetKeyFor(deps.platform, deps.arch)];
234
+ await downloadPocketBase(asset, layout.binary, { fetch: deps.fetch });
235
+ steps.push({ step: "download pocketbase", outcome: "done", detail: manifest.pocketbase.version });
236
+ }
237
+ catch (error) {
238
+ steps.push({ step: "download pocketbase", outcome: "error", detail: message(error) });
239
+ return { ok: false, steps };
240
+ }
241
+ }
242
+ else {
243
+ steps.push({ step: "download pocketbase", outcome: "unchanged" });
244
+ }
245
+ const newConfig = { ...config, appVersion: manifest.version, pocketbaseVersion: manifest.pocketbase.version };
246
+ writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
247
+ steps.push({ step: "write hub.json", outcome: "done" });
248
+ await stopProcess(layout.pidFile, { timeoutMs: STOP_TIMEOUT_MS, sleep: deps.sleep });
249
+ deps.startDetached(layout.binary, serveArgs(layout, newConfig.appVersion, newConfig.port), { logFile: layout.logFile, pidFile: layout.pidFile });
250
+ const url = baseUrlFor(newConfig.port);
251
+ const healthy = await waitForHealth(`${url}/api/health`, { fetch: deps.fetch, sleep: deps.sleep, timeoutMs: HEALTH_TIMEOUT_MS });
252
+ steps.push({ step: "restart", outcome: healthy ? "done" : "error", detail: healthy ? undefined : "did not become healthy within 20s" });
253
+ return { ok: healthy, steps, url };
254
+ }
255
+ /** The last `n` lines of `hub.log`, oldest first; `[]` when the hub has never logged anything. */
256
+ export function hubLogs(n, deps) {
257
+ const layout = hubLayout(deps.homeDir);
258
+ if (!existsSync(layout.logFile))
259
+ return [];
260
+ const content = readFileSync(layout.logFile, "utf8");
261
+ const lines = content.split("\n");
262
+ if (lines.length > 0 && lines[lines.length - 1] === "")
263
+ lines.pop();
264
+ return lines.slice(Math.max(lines.length - n, 0));
265
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Locates the installed `kankaku-hub` npm package (a real dependency —
3
+ * `file:../kankaku-hub-worktrees/npm-package` until the package is
4
+ * published) and reads/validates its `hub-manifest.json`.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { parseHubManifest } from "../../domain/local-hub-model.js";
10
+ const defaultRequire = createRequire(import.meta.url);
11
+ /**
12
+ * Resolves `kankaku-hub/package.json` through `resolve` (defaulting to
13
+ * `require.resolve`), then reads and validates the `hub-manifest.json`
14
+ * next to it. Throws a clear error when the package cannot be resolved,
15
+ * its manifest file is missing, or the manifest fails validation.
16
+ */
17
+ export function locateHubPackage(resolve = (specifier) => defaultRequire.resolve(specifier)) {
18
+ let packageJsonPath;
19
+ try {
20
+ packageJsonPath = resolve("kankaku-hub/package.json");
21
+ }
22
+ catch (error) {
23
+ throw new Error(`kankaku-hub package is not installed: ${error instanceof Error ? error.message : String(error)}`);
24
+ }
25
+ const dir = dirname(packageJsonPath);
26
+ const manifestPath = join(dir, "hub-manifest.json");
27
+ let raw;
28
+ try {
29
+ raw = readFileSync(manifestPath, "utf8");
30
+ }
31
+ catch (error) {
32
+ throw new Error(`could not read ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ const manifest = parseHubManifest(JSON.parse(raw));
35
+ return { dir, manifest };
36
+ }