kankaku-tui 0.3.1 → 0.4.1
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 +63 -6
- package/dist/adapters/hub-manager/accounts.js +52 -0
- package/dist/adapters/hub-manager/download.js +49 -0
- package/dist/adapters/hub-manager/install.js +331 -0
- package/dist/adapters/hub-manager/package.js +36 -0
- package/dist/adapters/hub-manager/process.js +113 -0
- package/dist/adapters/hub-manager/zip.js +83 -0
- package/dist/adapters/setup/json-writer.js +31 -4
- package/dist/cli.js +253 -34
- package/dist/domain/dashboard-model.js +3 -2
- package/dist/domain/local-hub-model.js +179 -0
- package/dist/domain/quick-actions.js +8 -2
- package/dist/domain/setup-wizard.js +14 -15
- package/dist/ui/dashboard-screen.js +46 -21
- package/dist/ui/setup/wizard-screen.js +5 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -76,12 +76,16 @@ same read-only report: one line per agent, one for the hub, one for
|
|
|
76
76
|
|
|
77
77
|
## Install
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
```
|
|
80
|
+
npm install -g kankaku-tui
|
|
81
|
+
```
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
this
|
|
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`.
|
|
85
89
|
|
|
86
90
|
## Configuration
|
|
87
91
|
|
|
@@ -143,6 +147,55 @@ Every sync uploaded from here is stamped `plugin: kankaku-tui`; a task's
|
|
|
143
147
|
kankaku's `hub-entry.ts`), else falls back to `agent: unknown` — this app
|
|
144
148
|
never guesses which coding agent produced someone else's worklog.
|
|
145
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. If another process
|
|
176
|
+
already answers on the target port, `install`/`start`/`upgrade` refuse
|
|
177
|
+
with `port <N> is already in use by another process — pass --port <N>
|
|
178
|
+
or stop it` instead of provisioning accounts against it; pass a
|
|
179
|
+
different `--port` or free the port and retry.
|
|
180
|
+
- `kankaku hub start` / `kankaku hub stop` — start or stop the server
|
|
181
|
+
process; `stop` is a no-op when it isn't running.
|
|
182
|
+
- `kankaku hub status` — `local hub: running 0.2.0 (PocketBase 0.40.4) at
|
|
183
|
+
http://127.0.0.1:8090 · pb_data 1.2 MB`, `stopped`, or `not installed`.
|
|
184
|
+
- `kankaku hub upgrade` — copies a fresh `app/<version>/` from the
|
|
185
|
+
currently installed `kankaku-hub` package, downloads a new PocketBase
|
|
186
|
+
binary only if that version changed, and restarts — `pb_data` is never
|
|
187
|
+
touched.
|
|
188
|
+
- `kankaku hub logs [-n N]` — the last `N` (default 50) lines of
|
|
189
|
+
`hub.log`.
|
|
190
|
+
|
|
191
|
+
The Dashboard's Hub card shows `local hub · running`/`stopped` when the
|
|
192
|
+
configured hub is this machine's own local install, with a matching `h`
|
|
193
|
+
quick action to start or stop it. The setup wizard's Hub step's
|
|
194
|
+
`install locally` option runs this same installer (asking for the owner
|
|
195
|
+
email/password inline); the older checkout-based dev install
|
|
196
|
+
(`kankaku-hub`'s own `scripts/dev.sh`) is still available for hub
|
|
197
|
+
developers via `kankaku setup --from-checkout <dir>`.
|
|
198
|
+
|
|
146
199
|
## Usage
|
|
147
200
|
|
|
148
201
|
- `kankaku` — opens the interactive TUI on the Dashboard screen.
|
|
@@ -157,9 +210,13 @@ never guesses which coding agent produced someone else's worklog.
|
|
|
157
210
|
pending count and last sync per project, no network; with no argument,
|
|
158
211
|
syncs the pending window; `all` does a full resync. Defaults to every
|
|
159
212
|
discovered project, sequentially; `--project <dir>` restricts to one.
|
|
160
|
-
- `kankaku setup [--yes] [--dry-run]` — see
|
|
213
|
+
- `kankaku setup [--yes] [--dry-run] [--from-checkout <dir>]` — see
|
|
214
|
+
"Install everything" above; `--from-checkout` is the hub-developer-only
|
|
215
|
+
checkout-based local hub install, see "Local hub" above.
|
|
161
216
|
- `kankaku doctor` — the same read-only report `kankaku setup` ends with,
|
|
162
217
|
without prompting or writing anything.
|
|
218
|
+
- `kankaku hub install|start|stop|status|upgrade|logs` — the local hub's
|
|
219
|
+
lifecycle; see "Local hub" above.
|
|
163
220
|
|
|
164
221
|
`--roots` (on `today`/`tasks`) overrides the configured roots for that run.
|
|
165
222
|
|
|
@@ -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,331 @@
|
|
|
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, unlinkSync, 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
|
+
/** `hub.log`'s lines, oldest first, `[]` when unreadable or empty. Shared by `hubLogs` (public, by line count) and `healthFailureDetail` (the last line only). */
|
|
49
|
+
function readLogLines(layout) {
|
|
50
|
+
if (!existsSync(layout.logFile))
|
|
51
|
+
return [];
|
|
52
|
+
const content = readFileSync(layout.logFile, "utf8");
|
|
53
|
+
const lines = content.split("\n");
|
|
54
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
55
|
+
lines.pop();
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
function portInUseMessage(port) {
|
|
59
|
+
return `port ${port} is already in use by another process — pass --port <N> or stop it`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Before spawning: if `GET /api/health` on `port` answers at all (ok or
|
|
63
|
+
* not — any response means something is already listening), the port is
|
|
64
|
+
* held by a process that is not ours. Callers only reach this once they
|
|
65
|
+
* have already established that no pid of ours is alive, so any response
|
|
66
|
+
* here means a foreign process. Returns the detail message to report, or
|
|
67
|
+
* `undefined` when the port is free.
|
|
68
|
+
*/
|
|
69
|
+
async function ensurePortFree(port, deps) {
|
|
70
|
+
try {
|
|
71
|
+
await deps.fetch(`${baseUrlFor(port)}/api/health`);
|
|
72
|
+
return portInUseMessage(port);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Spawns the hub detached and waits for it to become healthy, stopping
|
|
80
|
+
* immediately (rather than waiting out the full timeout) if the process
|
|
81
|
+
* dies first. On an early exit, removes the pid file it just wrote so a
|
|
82
|
+
* dead process is never left claiming to be running.
|
|
83
|
+
*/
|
|
84
|
+
async function spawnAndAwaitHealth(layout, appVersion, port, deps) {
|
|
85
|
+
const pid = deps.startDetached(layout.binary, serveArgs(layout, appVersion, port), { logFile: layout.logFile, pidFile: layout.pidFile });
|
|
86
|
+
const healthy = await waitForHealth(`${baseUrlFor(port)}/api/health`, {
|
|
87
|
+
fetch: deps.fetch,
|
|
88
|
+
sleep: deps.sleep,
|
|
89
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
90
|
+
isAlive: () => deps.isAlive(pid),
|
|
91
|
+
});
|
|
92
|
+
if (healthy)
|
|
93
|
+
return { pid, healthy: true, exitedEarly: false };
|
|
94
|
+
const exitedEarly = !deps.isAlive(pid);
|
|
95
|
+
if (exitedEarly && existsSync(layout.pidFile))
|
|
96
|
+
unlinkSync(layout.pidFile);
|
|
97
|
+
return { pid, healthy: false, exitedEarly };
|
|
98
|
+
}
|
|
99
|
+
/** The error detail for a failed `spawnAndAwaitHealth`: the last `hub.log` line when the process exited early and one is readable, otherwise a plain timeout message. */
|
|
100
|
+
function healthFailureDetail(layout, exitedEarly) {
|
|
101
|
+
if (!exitedEarly)
|
|
102
|
+
return "the local hub did not become healthy within 20s";
|
|
103
|
+
const lastLine = readLogLines(layout).at(-1);
|
|
104
|
+
return lastLine ? `the hub exited during startup: ${lastLine}` : "the hub exited during startup";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Installs (or, run again, verifies) the local hub under
|
|
108
|
+
* `~/.kankaku/hub`: locates the `kankaku-hub` package, creates the layout
|
|
109
|
+
* directories (root created 0700, never chmod'd again once it exists —
|
|
110
|
+
* mirrors kankaku's own R2 rule), downloads the PocketBase binary for
|
|
111
|
+
* this platform when missing or out of date (SHA256-verified), copies the
|
|
112
|
+
* package's migrations/hooks/public into `app/<version>/`, writes
|
|
113
|
+
* `hub.json`, and — only the first time, when `accounts.json` doesn't
|
|
114
|
+
* exist yet — provisions the superuser and the owner/service application
|
|
115
|
+
* users, then leaves the server running. Idempotent: re-running with
|
|
116
|
+
* everything already present reports every step `unchanged` and touches
|
|
117
|
+
* neither the process nor the accounts. Never throws; a failing step
|
|
118
|
+
* stops the sequence and is reported as `error`.
|
|
119
|
+
*/
|
|
120
|
+
export async function installHub(options, deps) {
|
|
121
|
+
const port = options.port ?? DEFAULT_HUB_PORT;
|
|
122
|
+
const steps = [];
|
|
123
|
+
let located;
|
|
124
|
+
try {
|
|
125
|
+
located = deps.locatePackage();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return { ok: false, steps: [{ step: "locate kankaku-hub package", outcome: "error", detail: message(error) }] };
|
|
129
|
+
}
|
|
130
|
+
const manifest = located.manifest;
|
|
131
|
+
const layout = hubLayout(deps.homeDir);
|
|
132
|
+
const rootExisted = existsSync(layout.root);
|
|
133
|
+
if (!rootExisted)
|
|
134
|
+
mkdirSync(layout.root, { recursive: true, mode: OWNER_DIR_MODE });
|
|
135
|
+
mkdirSync(layout.bin, { recursive: true });
|
|
136
|
+
mkdirSync(layout.pbData, { recursive: true });
|
|
137
|
+
steps.push({ step: "create ~/.kankaku/hub", outcome: rootExisted ? "unchanged" : "done" });
|
|
138
|
+
const existingConfig = readConfigOrUndefined(layout.hubJson);
|
|
139
|
+
const needsBinary = !existsSync(layout.binary) || existingConfig?.pocketbaseVersion !== manifest.pocketbase.version;
|
|
140
|
+
if (needsBinary) {
|
|
141
|
+
try {
|
|
142
|
+
const asset = manifest.pocketbase.assets[assetKeyFor(deps.platform, deps.arch)];
|
|
143
|
+
await downloadPocketBase(asset, layout.binary, { fetch: deps.fetch });
|
|
144
|
+
steps.push({ step: "download pocketbase", outcome: "done", detail: manifest.pocketbase.version });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
steps.push({ step: "download pocketbase", outcome: "error", detail: message(error) });
|
|
148
|
+
return { ok: false, steps };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
steps.push({ step: "download pocketbase", outcome: "unchanged" });
|
|
153
|
+
}
|
|
154
|
+
const appDir = layout.appDir(manifest.version);
|
|
155
|
+
const appDirExisted = existsSync(appDir);
|
|
156
|
+
if (!appDirExisted) {
|
|
157
|
+
try {
|
|
158
|
+
copyAppFiles(located.dir, appDir);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
steps.push({ step: "install app files", outcome: "error", detail: message(error) });
|
|
162
|
+
return { ok: false, steps };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
writeFileSync(layout.currentFile, manifest.version);
|
|
166
|
+
steps.push({ step: "install app files", outcome: appDirExisted ? "unchanged" : "done", detail: manifest.version });
|
|
167
|
+
const newConfig = {
|
|
168
|
+
port,
|
|
169
|
+
appVersion: manifest.version,
|
|
170
|
+
pocketbaseVersion: manifest.pocketbase.version,
|
|
171
|
+
installedAt: existingConfig?.installedAt ?? new Date(deps.now()).toISOString(),
|
|
172
|
+
};
|
|
173
|
+
const hubJsonChanged = !existingConfig || existingConfig.port !== newConfig.port || existingConfig.appVersion !== newConfig.appVersion || existingConfig.pocketbaseVersion !== newConfig.pocketbaseVersion;
|
|
174
|
+
if (hubJsonChanged)
|
|
175
|
+
writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
|
|
176
|
+
steps.push({ step: "write hub.json", outcome: hubJsonChanged ? "done" : "unchanged" });
|
|
177
|
+
const accountsExist = existsSync(layout.accountsJson);
|
|
178
|
+
if (accountsExist) {
|
|
179
|
+
steps.push({ step: "provision accounts", outcome: "unchanged" });
|
|
180
|
+
return { ok: true, steps, url: baseUrlFor(port) };
|
|
181
|
+
}
|
|
182
|
+
// Reported as its own step: a hub that cannot start is not an accounts
|
|
183
|
+
// problem, and the accounts step must never claim to have run.
|
|
184
|
+
const portConflict = await ensurePortFree(port, deps);
|
|
185
|
+
if (portConflict) {
|
|
186
|
+
steps.push({ step: "start hub", outcome: "error", detail: portConflict });
|
|
187
|
+
return { ok: false, steps };
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const superuserPassword = generatePassword(deps.randomBytes);
|
|
191
|
+
await upsertSuperuser(layout.binary, layout.pbData, SUPERUSER_EMAIL, superuserPassword, deps.runner);
|
|
192
|
+
const spawnResult = await spawnAndAwaitHealth(layout, manifest.version, port, deps);
|
|
193
|
+
if (!spawnResult.healthy) {
|
|
194
|
+
steps.push({ step: "start hub", outcome: "error", detail: healthFailureDetail(layout, spawnResult.exitedEarly) });
|
|
195
|
+
return { ok: false, steps };
|
|
196
|
+
}
|
|
197
|
+
steps.push({ step: "start hub", outcome: "done" });
|
|
198
|
+
const url = baseUrlFor(port);
|
|
199
|
+
const superuser = { email: SUPERUSER_EMAIL, password: superuserPassword };
|
|
200
|
+
await createUser(url, superuser, { email: options.ownerEmail, password: options.ownerPassword, role: "owner" }, deps.fetch);
|
|
201
|
+
const servicePassword = generatePassword(deps.randomBytes);
|
|
202
|
+
await createUser(url, superuser, { email: SERVICE_EMAIL, password: servicePassword, role: "service" }, deps.fetch);
|
|
203
|
+
const accounts = { superuserEmail: SUPERUSER_EMAIL, superuserPassword, ownerEmail: options.ownerEmail };
|
|
204
|
+
writeFileSync(layout.accountsJson, JSON.stringify(accounts, null, 2));
|
|
205
|
+
chmodSync(layout.accountsJson, OWNER_FILE_MODE);
|
|
206
|
+
writeHubCredentials(deps.homeDir, { url, email: SERVICE_EMAIL, password: servicePassword });
|
|
207
|
+
steps.push({ step: "provision accounts", outcome: "done" });
|
|
208
|
+
return { ok: true, steps, url };
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
steps.push({ step: "provision accounts", outcome: "error", detail: message(error) });
|
|
212
|
+
return { ok: false, steps };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Starts the installed hub: a no-op (`unchanged`) if already running, otherwise spawns it and waits for health. */
|
|
216
|
+
export async function startHub(deps) {
|
|
217
|
+
const layout = hubLayout(deps.homeDir);
|
|
218
|
+
const config = readConfigOrUndefined(layout.hubJson);
|
|
219
|
+
if (!config)
|
|
220
|
+
return { ok: false, steps: [{ step: "start", outcome: "error", detail: "the local hub is not installed" }] };
|
|
221
|
+
const pid = readPid(layout.pidFile);
|
|
222
|
+
if (pid !== undefined && isAlive(pid)) {
|
|
223
|
+
return { ok: true, steps: [{ step: "start", outcome: "unchanged", detail: "already running" }], url: baseUrlFor(config.port) };
|
|
224
|
+
}
|
|
225
|
+
const portConflict = await ensurePortFree(config.port, deps);
|
|
226
|
+
if (portConflict) {
|
|
227
|
+
return { ok: false, steps: [{ step: "start", outcome: "error", detail: portConflict }] };
|
|
228
|
+
}
|
|
229
|
+
const spawnResult = await spawnAndAwaitHealth(layout, config.appVersion, config.port, deps);
|
|
230
|
+
return {
|
|
231
|
+
ok: spawnResult.healthy,
|
|
232
|
+
steps: [{ step: "start", outcome: spawnResult.healthy ? "done" : "error", detail: spawnResult.healthy ? undefined : healthFailureDetail(layout, spawnResult.exitedEarly) }],
|
|
233
|
+
url: baseUrlFor(config.port),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Stops the installed hub via `SIGTERM` (bounded wait, `SIGKILL` as a last resort). A no-op (`unchanged`) when it wasn't running. */
|
|
237
|
+
export async function stopHub(deps) {
|
|
238
|
+
const layout = hubLayout(deps.homeDir);
|
|
239
|
+
const result = await stopProcess(layout.pidFile, { timeoutMs: STOP_TIMEOUT_MS, sleep: deps.sleep });
|
|
240
|
+
return { ok: true, steps: [{ step: "stop", outcome: result === "not-running" ? "unchanged" : "done", detail: result }] };
|
|
241
|
+
}
|
|
242
|
+
/** Classifies the installed hub's current status (`hubLayout`, pid liveness and, only while a process is alive, one health check). Never throws. */
|
|
243
|
+
export async function hubStatus(deps) {
|
|
244
|
+
const layout = hubLayout(deps.homeDir);
|
|
245
|
+
const config = readConfigOrUndefined(layout.hubJson);
|
|
246
|
+
const pid = readPid(layout.pidFile);
|
|
247
|
+
const pidAlive = pid !== undefined && isAlive(pid);
|
|
248
|
+
let health = "skipped";
|
|
249
|
+
if (pidAlive && config) {
|
|
250
|
+
try {
|
|
251
|
+
const response = await deps.fetch(`${baseUrlFor(config.port)}/api/health`);
|
|
252
|
+
health = response.ok ? "ok" : "failed";
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
health = "failed";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return classifyStatus({ installed: config !== undefined, config, pidAlive, health });
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Upgrades the local hub to the currently installed `kankaku-hub`
|
|
262
|
+
* package's version: copies a fresh `app/<new version>/` (when not
|
|
263
|
+
* already present), downloads a new PocketBase binary only if its
|
|
264
|
+
* version changed, restarts the process, and leaves `pb_data` untouched.
|
|
265
|
+
* A no-op (`unchanged`) when the installed package is already the
|
|
266
|
+
* running version.
|
|
267
|
+
*/
|
|
268
|
+
export async function upgradeHub(deps) {
|
|
269
|
+
const layout = hubLayout(deps.homeDir);
|
|
270
|
+
const config = readConfigOrUndefined(layout.hubJson);
|
|
271
|
+
if (!config)
|
|
272
|
+
return { ok: false, steps: [{ step: "upgrade", outcome: "error", detail: "the local hub is not installed" }] };
|
|
273
|
+
let located;
|
|
274
|
+
try {
|
|
275
|
+
located = deps.locatePackage();
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
return { ok: false, steps: [{ step: "locate kankaku-hub package", outcome: "error", detail: message(error) }] };
|
|
279
|
+
}
|
|
280
|
+
const manifest = located.manifest;
|
|
281
|
+
if (manifest.version === config.appVersion && manifest.pocketbase.version === config.pocketbaseVersion) {
|
|
282
|
+
return { ok: true, steps: [{ step: "upgrade", outcome: "unchanged", detail: "already up to date" }], url: baseUrlFor(config.port) };
|
|
283
|
+
}
|
|
284
|
+
const steps = [];
|
|
285
|
+
const appDir = layout.appDir(manifest.version);
|
|
286
|
+
const appDirExisted = existsSync(appDir);
|
|
287
|
+
if (!appDirExisted) {
|
|
288
|
+
try {
|
|
289
|
+
copyAppFiles(located.dir, appDir);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
steps.push({ step: "install app files", outcome: "error", detail: message(error) });
|
|
293
|
+
return { ok: false, steps };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
writeFileSync(layout.currentFile, manifest.version);
|
|
297
|
+
steps.push({ step: "install app files", outcome: appDirExisted ? "unchanged" : "done", detail: manifest.version });
|
|
298
|
+
if (manifest.pocketbase.version !== config.pocketbaseVersion) {
|
|
299
|
+
try {
|
|
300
|
+
const asset = manifest.pocketbase.assets[assetKeyFor(deps.platform, deps.arch)];
|
|
301
|
+
await downloadPocketBase(asset, layout.binary, { fetch: deps.fetch });
|
|
302
|
+
steps.push({ step: "download pocketbase", outcome: "done", detail: manifest.pocketbase.version });
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
steps.push({ step: "download pocketbase", outcome: "error", detail: message(error) });
|
|
306
|
+
return { ok: false, steps };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
steps.push({ step: "download pocketbase", outcome: "unchanged" });
|
|
311
|
+
}
|
|
312
|
+
const newConfig = { ...config, appVersion: manifest.version, pocketbaseVersion: manifest.pocketbase.version };
|
|
313
|
+
writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
|
|
314
|
+
steps.push({ step: "write hub.json", outcome: "done" });
|
|
315
|
+
await stopProcess(layout.pidFile, { timeoutMs: STOP_TIMEOUT_MS, sleep: deps.sleep });
|
|
316
|
+
const url = baseUrlFor(newConfig.port);
|
|
317
|
+
const portConflict = await ensurePortFree(newConfig.port, deps);
|
|
318
|
+
if (portConflict) {
|
|
319
|
+
steps.push({ step: "restart", outcome: "error", detail: portConflict });
|
|
320
|
+
return { ok: false, steps };
|
|
321
|
+
}
|
|
322
|
+
const spawnResult = await spawnAndAwaitHealth(layout, newConfig.appVersion, newConfig.port, deps);
|
|
323
|
+
steps.push({ step: "restart", outcome: spawnResult.healthy ? "done" : "error", detail: spawnResult.healthy ? undefined : healthFailureDetail(layout, spawnResult.exitedEarly) });
|
|
324
|
+
return { ok: spawnResult.healthy, steps, url };
|
|
325
|
+
}
|
|
326
|
+
/** The last `n` lines of `hub.log`, oldest first; `[]` when the hub has never logged anything. */
|
|
327
|
+
export function hubLogs(n, deps) {
|
|
328
|
+
const layout = hubLayout(deps.homeDir);
|
|
329
|
+
const lines = readLogLines(layout);
|
|
330
|
+
return lines.slice(Math.max(lines.length - n, 0));
|
|
331
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached process control for the local hub's PocketBase server: start,
|
|
3
|
+
* pid tracking, liveness, stop (SIGTERM with a bounded wait, SIGKILL as a
|
|
4
|
+
* last resort) and health polling.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
/**
|
|
9
|
+
* Spawns `binary args` detached (survives the caller exiting), appending
|
|
10
|
+
* its combined stdout/stderr to `logFile`, and writes its pid to
|
|
11
|
+
* `pidFile`. Returns the pid.
|
|
12
|
+
*/
|
|
13
|
+
export function startDetached(binary, args, deps) {
|
|
14
|
+
const fd = openSync(deps.logFile, "a");
|
|
15
|
+
const child = spawn(binary, args, { detached: true, stdio: ["ignore", fd, fd] });
|
|
16
|
+
child.unref();
|
|
17
|
+
const pid = child.pid ?? -1;
|
|
18
|
+
writeFileSync(deps.pidFile, String(pid));
|
|
19
|
+
return pid;
|
|
20
|
+
}
|
|
21
|
+
/** Reads the pid written by `startDetached`, or `undefined` when `pidFile` is missing or holds no valid positive integer. */
|
|
22
|
+
export function readPid(pidFile) {
|
|
23
|
+
if (!existsSync(pidFile))
|
|
24
|
+
return undefined;
|
|
25
|
+
const raw = readFileSync(pidFile, "utf8").trim();
|
|
26
|
+
const pid = Number(raw);
|
|
27
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
28
|
+
}
|
|
29
|
+
/** `true` when a process with `pid` is reachable. An `EPERM` (a different owner, still on this machine) counts as alive; `ESRCH` (no such process) does not. */
|
|
30
|
+
export function isAlive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return error.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const POLL_INTERVAL_MS = 100;
|
|
40
|
+
const POST_KILL_POLL_ATTEMPTS = 10;
|
|
41
|
+
/**
|
|
42
|
+
* Reads the pid from `pidFile`. If no live process is found, removes a
|
|
43
|
+
* stale pid file (if any) and returns `"not-running"`. Otherwise sends
|
|
44
|
+
* SIGTERM and polls `isAlive` every 100ms up to `deps.timeoutMs`; if the
|
|
45
|
+
* process is still alive at the deadline, sends SIGKILL. Always removes
|
|
46
|
+
* `pidFile` before returning.
|
|
47
|
+
*/
|
|
48
|
+
export async function stopProcess(pidFile, deps) {
|
|
49
|
+
const pid = readPid(pidFile);
|
|
50
|
+
if (pid === undefined || !isAlive(pid)) {
|
|
51
|
+
if (existsSync(pidFile))
|
|
52
|
+
unlinkSync(pidFile);
|
|
53
|
+
return "not-running";
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, "SIGTERM");
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// The process exited between the isAlive check and here.
|
|
60
|
+
if (existsSync(pidFile))
|
|
61
|
+
unlinkSync(pidFile);
|
|
62
|
+
return "not-running";
|
|
63
|
+
}
|
|
64
|
+
let elapsedMs = 0;
|
|
65
|
+
let killedForcibly = false;
|
|
66
|
+
while (isAlive(pid)) {
|
|
67
|
+
if (elapsedMs >= deps.timeoutMs) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, "SIGKILL");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Already gone.
|
|
73
|
+
}
|
|
74
|
+
killedForcibly = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
78
|
+
elapsedMs += POLL_INTERVAL_MS;
|
|
79
|
+
}
|
|
80
|
+
if (killedForcibly) {
|
|
81
|
+
for (let attempt = 0; isAlive(pid) && attempt < POST_KILL_POLL_ATTEMPTS; attempt++) {
|
|
82
|
+
await deps.sleep(POLL_INTERVAL_MS);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (existsSync(pidFile))
|
|
86
|
+
unlinkSync(pidFile);
|
|
87
|
+
return killedForcibly ? "killed" : "stopped";
|
|
88
|
+
}
|
|
89
|
+
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
90
|
+
/**
|
|
91
|
+
* Polls `GET url` every 500ms until it responds ok, `deps.timeoutMs`
|
|
92
|
+
* elapses, or (when `deps.isAlive` is given) the process dies. Returns
|
|
93
|
+
* whether it became healthy. Never throws.
|
|
94
|
+
*/
|
|
95
|
+
export async function waitForHealth(url, deps) {
|
|
96
|
+
let elapsedMs = 0;
|
|
97
|
+
for (;;) {
|
|
98
|
+
try {
|
|
99
|
+
const response = await deps.fetch(url);
|
|
100
|
+
if (response.ok)
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Not up yet; keep polling until the deadline.
|
|
105
|
+
}
|
|
106
|
+
if (deps.isAlive && !deps.isAlive())
|
|
107
|
+
return false;
|
|
108
|
+
if (elapsedMs >= deps.timeoutMs)
|
|
109
|
+
return false;
|
|
110
|
+
await deps.sleep(HEALTH_POLL_INTERVAL_MS);
|
|
111
|
+
elapsedMs += HEALTH_POLL_INTERVAL_MS;
|
|
112
|
+
}
|
|
113
|
+
}
|