kankaku-tui 0.4.0 → 0.9.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 +12 -4
- package/dist/adapters/hub-manager/install.js +88 -22
- package/dist/adapters/hub-manager/package.js +2 -3
- package/dist/adapters/hub-manager/process.js +7 -1
- package/dist/adapters/setup/json-writer.js +31 -4
- package/dist/cli.js +1 -0
- package/dist/domain/setup-wizard.js +1 -1
- package/dist/ui/setup/wizard-screen.js +1 -1
- package/package.json +7 -2
- package/dist/ui/today-screen.js +0 -161
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ in. Built with [Ink](https://github.com/vadimdemedes/ink) on Node 24. Four
|
|
|
8
8
|
screens — Dashboard, Tasks, Catalog and Sync — share one tab bar, and each
|
|
9
9
|
has a plain-text subcommand for scripts and cron.
|
|
10
10
|
|
|
11
|
+
This package lives in the [kankaku monorepo](https://github.com/soyunninja/kankaku),
|
|
12
|
+
under `packages/tui`.
|
|
13
|
+
|
|
11
14
|
## Install everything
|
|
12
15
|
|
|
13
16
|
```
|
|
@@ -33,9 +36,10 @@ The wizard's steps, `enter` to advance and `esc` to go back throughout
|
|
|
33
36
|
agent schedules removing kankaku from it, not just skipping it. Codex
|
|
34
37
|
and OpenCode are listed but disabled, showing `no adapter yet` (found,
|
|
35
38
|
but kankaku can't write its config) or `not installed` (not found).
|
|
36
|
-
2. **Claude Code** — the `kankaku-claude` checkout path (
|
|
37
|
-
|
|
38
|
-
existing
|
|
39
|
+
2. **Claude Code** — the `kankaku-claude` checkout path (packages/claude
|
|
40
|
+
inside a kankaku monorepo checkout; only shown when Claude Code is
|
|
41
|
+
checked and not already configured), guessed from any existing
|
|
42
|
+
`statusLine`.
|
|
39
43
|
3. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
|
|
40
44
|
inline health check, reusing the current credentials as the default),
|
|
41
45
|
`install locally`, or `skip`. Installing locally looks for a
|
|
@@ -172,7 +176,11 @@ Commands (macOS and Linux only — PocketBase ships no other build):
|
|
|
172
176
|
— installs (or, run again, verifies) the hub and leaves it running. On
|
|
173
177
|
a real terminal, a missing owner email/password is prompted for
|
|
174
178
|
(masked); without a TTY, both flags are required. Idempotent: re-running
|
|
175
|
-
with everything already in place changes nothing.
|
|
179
|
+
with everything already in place changes nothing. If another process
|
|
180
|
+
already answers on the target port, `install`/`start`/`upgrade` refuse
|
|
181
|
+
with `port <N> is already in use by another process — pass --port <N>
|
|
182
|
+
or stop it` instead of provisioning accounts against it; pass a
|
|
183
|
+
different `--port` or free the port and retry.
|
|
176
184
|
- `kankaku hub start` / `kankaku hub stop` — start or stop the server
|
|
177
185
|
process; `stop` is a no-op when it isn't running.
|
|
178
186
|
- `kankaku hub status` — `local hub: running 0.2.0 (PocketBase 0.40.4) at
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* injected (`HubManagerDeps`) so tests never touch the network, spawn a
|
|
8
8
|
* real PocketBase, or run a real script.
|
|
9
9
|
*/
|
|
10
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { assetKeyFor, classifyStatus, generatePassword, hubLayout, parseHubConfig, serveArgs, } from "../../domain/local-hub-model.js";
|
|
13
13
|
import { locateHubPackage } from "./package.js";
|
|
@@ -45,6 +45,64 @@ function copyAppFiles(packageDir, appDir) {
|
|
|
45
45
|
function baseUrlFor(port) {
|
|
46
46
|
return `http://127.0.0.1:${port}`;
|
|
47
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
|
+
}
|
|
48
106
|
/**
|
|
49
107
|
* Installs (or, run again, verifies) the local hub under
|
|
50
108
|
* `~/.kankaku/hub`: locates the `kankaku-hub` package, creates the layout
|
|
@@ -121,16 +179,23 @@ export async function installHub(options, deps) {
|
|
|
121
179
|
steps.push({ step: "provision accounts", outcome: "unchanged" });
|
|
122
180
|
return { ok: true, steps, url: baseUrlFor(port) };
|
|
123
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
|
+
}
|
|
124
189
|
try {
|
|
125
190
|
const superuserPassword = generatePassword(deps.randomBytes);
|
|
126
191
|
await upsertSuperuser(layout.binary, layout.pbData, SUPERUSER_EMAIL, superuserPassword, deps.runner);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!healthy) {
|
|
131
|
-
steps.push({ step: "provision accounts", outcome: "error", detail: "the local hub did not become healthy within 20s" });
|
|
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) });
|
|
132
195
|
return { ok: false, steps };
|
|
133
196
|
}
|
|
197
|
+
steps.push({ step: "start hub", outcome: "done" });
|
|
198
|
+
const url = baseUrlFor(port);
|
|
134
199
|
const superuser = { email: SUPERUSER_EMAIL, password: superuserPassword };
|
|
135
200
|
await createUser(url, superuser, { email: options.ownerEmail, password: options.ownerPassword, role: "owner" }, deps.fetch);
|
|
136
201
|
const servicePassword = generatePassword(deps.randomBytes);
|
|
@@ -157,13 +222,15 @@ export async function startHub(deps) {
|
|
|
157
222
|
if (pid !== undefined && isAlive(pid)) {
|
|
158
223
|
return { ok: true, steps: [{ step: "start", outcome: "unchanged", detail: "already running" }], url: baseUrlFor(config.port) };
|
|
159
224
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
163
230
|
return {
|
|
164
|
-
ok: healthy,
|
|
165
|
-
steps: [{ step: "start", outcome: healthy ? "done" : "error", detail: healthy ? undefined :
|
|
166
|
-
url,
|
|
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),
|
|
167
234
|
};
|
|
168
235
|
}
|
|
169
236
|
/** Stops the installed hub via `SIGTERM` (bounded wait, `SIGKILL` as a last resort). A no-op (`unchanged`) when it wasn't running. */
|
|
@@ -246,20 +313,19 @@ export async function upgradeHub(deps) {
|
|
|
246
313
|
writeFileSync(layout.hubJson, JSON.stringify(newConfig, null, 2));
|
|
247
314
|
steps.push({ step: "write hub.json", outcome: "done" });
|
|
248
315
|
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
316
|
const url = baseUrlFor(newConfig.port);
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
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 };
|
|
254
325
|
}
|
|
255
326
|
/** The last `n` lines of `hub.log`, oldest first; `[]` when the hub has never logged anything. */
|
|
256
327
|
export function hubLogs(n, deps) {
|
|
257
328
|
const layout = hubLayout(deps.homeDir);
|
|
258
|
-
|
|
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();
|
|
329
|
+
const lines = readLogLines(layout);
|
|
264
330
|
return lines.slice(Math.max(lines.length - n, 0));
|
|
265
331
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Locates the installed `kankaku-hub` npm package (a real
|
|
3
|
-
*
|
|
4
|
-
* published) and reads/validates its `hub-manifest.json`.
|
|
2
|
+
* Locates the installed `kankaku-hub` npm package (a real registry
|
|
3
|
+
* dependency) and reads/validates its `hub-manifest.json`.
|
|
5
4
|
*/
|
|
6
5
|
import { createRequire } from "node:module";
|
|
7
6
|
import { readFileSync } from "node:fs";
|
|
@@ -87,7 +87,11 @@ export async function stopProcess(pidFile, deps) {
|
|
|
87
87
|
return killedForcibly ? "killed" : "stopped";
|
|
88
88
|
}
|
|
89
89
|
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
90
|
-
/**
|
|
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
|
+
*/
|
|
91
95
|
export async function waitForHealth(url, deps) {
|
|
92
96
|
let elapsedMs = 0;
|
|
93
97
|
for (;;) {
|
|
@@ -99,6 +103,8 @@ export async function waitForHealth(url, deps) {
|
|
|
99
103
|
catch {
|
|
100
104
|
// Not up yet; keep polling until the deadline.
|
|
101
105
|
}
|
|
106
|
+
if (deps.isAlive && !deps.isAlive())
|
|
107
|
+
return false;
|
|
102
108
|
if (elapsedMs >= deps.timeoutMs)
|
|
103
109
|
return false;
|
|
104
110
|
await deps.sleep(HEALTH_POLL_INTERVAL_MS);
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* atomic tmp+rename, and a one-time `<file>.bak` — mirrors kankaku's own
|
|
4
4
|
* `adapters/project-config.ts#writeProjectTargetIds`.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname } from "node:path";
|
|
8
|
+
const OWNER_FILE_MODE = 0o600;
|
|
8
9
|
/** Parse `filePath` as a JSON object, defaulting to `{}` on any absence, malformed JSON, or non-object document. Never throws. */
|
|
9
10
|
export function readJsonObjectOrEmpty(filePath) {
|
|
10
11
|
if (!existsSync(filePath))
|
|
@@ -19,18 +20,44 @@ export function readJsonObjectOrEmpty(filePath) {
|
|
|
19
20
|
return {};
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
/** Best-effort `chmodSync`; never throws (e.g. a read-only filesystem or an already-gone file). */
|
|
24
|
+
function tightenBestEffort(path) {
|
|
25
|
+
try {
|
|
26
|
+
chmodSync(path, OWNER_FILE_MODE);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Best-effort only.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
22
32
|
/**
|
|
23
33
|
* Copy `filePath`'s current bytes to `<filePath>.bak`, but only the first
|
|
24
34
|
* time: a no-op when `filePath` does not exist yet (nothing to back up) or
|
|
25
|
-
* `<filePath>.bak` already exists (never overwritten).
|
|
35
|
+
* `<filePath>.bak` already exists (its content is never overwritten).
|
|
36
|
+
*
|
|
37
|
+
* When `filePath` is owner-only (0600, e.g. `~/.kankaku/credentials.json`,
|
|
38
|
+
* which carries a live hub's service account password), the backup mirrors
|
|
39
|
+
* that mode instead of the platform's default (umask-derived, typically
|
|
40
|
+
* world-readable) mode — a previous hub's credentials must never end up
|
|
41
|
+
* more exposed in the backup than they were in the live file. An existing
|
|
42
|
+
* `.bak` from before this fix is tightened too, best-effort, whenever a
|
|
43
|
+
* 0600 source is encountered — mirroring kankaku's own "existing looser
|
|
44
|
+
* mode is tightened, best-effort, whenever encountered" rule.
|
|
26
45
|
*/
|
|
27
46
|
export function backupOnce(filePath) {
|
|
28
47
|
if (!existsSync(filePath))
|
|
29
48
|
return;
|
|
30
49
|
const backupPath = `${filePath}.bak`;
|
|
31
|
-
|
|
50
|
+
const sourceIsOwnerOnly = (statSync(filePath).mode & 0o777) === OWNER_FILE_MODE;
|
|
51
|
+
if (existsSync(backupPath)) {
|
|
52
|
+
if (sourceIsOwnerOnly)
|
|
53
|
+
tightenBestEffort(backupPath);
|
|
32
54
|
return;
|
|
33
|
-
|
|
55
|
+
}
|
|
56
|
+
// Created owner-only from the first byte when the source is: a chmod
|
|
57
|
+
// after the write would leave a window where the secret is readable.
|
|
58
|
+
writeFileSync(backupPath, readFileSync(filePath), sourceIsOwnerOnly ? { mode: OWNER_FILE_MODE } : {});
|
|
59
|
+
if (sourceIsOwnerOnly)
|
|
60
|
+
tightenBestEffort(backupPath);
|
|
34
61
|
}
|
|
35
62
|
/** Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename, creating the parent directory when missing. */
|
|
36
63
|
export function writeJsonAtomic(filePath, value, dirMode) {
|
package/dist/cli.js
CHANGED
|
@@ -173,7 +173,7 @@ export function next(state, facts) {
|
|
|
173
173
|
return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
|
|
174
174
|
case "claude": {
|
|
175
175
|
if (state.claudeCheckout.trim() === "") {
|
|
176
|
-
return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path" } };
|
|
176
|
+
return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path (packages/claude in a kankaku checkout)" } };
|
|
177
177
|
}
|
|
178
178
|
return { ...state, step: "hub", errors: clearError(state.errors, "claudeCheckout") };
|
|
179
179
|
}
|
|
@@ -178,7 +178,7 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
|
|
|
178
178
|
const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
|
|
179
179
|
const mainWidth = width;
|
|
180
180
|
const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
|
|
181
|
-
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 === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path:" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), 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 })] }), 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.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
|
|
181
|
+
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 === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path (packages/claude in a kankaku checkout):" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), 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 })] }), 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.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
|
|
182
182
|
setRootsText(value);
|
|
183
183
|
setState((s) => setRoots(s, value));
|
|
184
184
|
}, 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 })] }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kankaku-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Terminal app that shows today's kankaku work across every project on disk",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"ink": "^7.1.1",
|
|
28
|
-
"kankaku": "^0.
|
|
28
|
+
"kankaku": "^0.9.0",
|
|
29
29
|
"kankaku-hub": "^0.2.0",
|
|
30
30
|
"react": "^19.3.0"
|
|
31
31
|
},
|
|
@@ -35,5 +35,10 @@
|
|
|
35
35
|
"ink-testing-library": "^4",
|
|
36
36
|
"tsx": "^4",
|
|
37
37
|
"typescript": "^5.7.0"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/soyunninja/kankaku.git",
|
|
42
|
+
"directory": "packages/tui"
|
|
38
43
|
}
|
|
39
44
|
}
|
package/dist/ui/today-screen.js
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState } from "react";
|
|
3
|
-
import { Box, Text, useInput } from "ink";
|
|
4
|
-
import { formatMinutes } from "../domain/today-model.js";
|
|
5
|
-
import { SCREENS, hintsFor } from "../domain/nav-model.js";
|
|
6
|
-
import { Layout } from "./layout.js";
|
|
7
|
-
import { Panel } from "./components/panel.js";
|
|
8
|
-
import { Table } from "./components/table.js";
|
|
9
|
-
import { Sparkline } from "./components/sparkline.js";
|
|
10
|
-
import { renderBar } from "./components/bar.js";
|
|
11
|
-
const KEY_HINTS = [
|
|
12
|
-
{ key: "↑↓", label: "move" },
|
|
13
|
-
{ key: "enter", label: "open" },
|
|
14
|
-
{ key: "r", label: "refresh" },
|
|
15
|
-
{ key: "1-4", label: "screens" },
|
|
16
|
-
{ key: "q", label: "quit" },
|
|
17
|
-
];
|
|
18
|
-
/** `HH:MM`, local time, from an ISO timestamp. */
|
|
19
|
-
function formatTime(iso) {
|
|
20
|
-
const date = new Date(iso);
|
|
21
|
-
const hours = String(date.getHours()).padStart(2, "0");
|
|
22
|
-
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
23
|
-
return `${hours}:${minutes}`;
|
|
24
|
-
}
|
|
25
|
-
function formatCost(cost) {
|
|
26
|
-
return `$${cost.toFixed(2)}`;
|
|
27
|
-
}
|
|
28
|
-
/** The header bar's right-hand hub status, or `undefined` when the hub is not configured. */
|
|
29
|
-
function hubHeaderText(hub) {
|
|
30
|
-
if (hub.status !== "ready")
|
|
31
|
-
return undefined;
|
|
32
|
-
const domain = hub.catalog?.url.replace(/^https?:\/\//, "");
|
|
33
|
-
const domainPart = domain !== undefined ? `${domain} · ` : "";
|
|
34
|
-
const syncedPart = hub.lastSyncAt !== undefined ? `synced ${formatTime(hub.lastSyncAt)}` : "no sync yet";
|
|
35
|
-
return `hub ${hub.lastSyncOk ? "●" : "✕"} ${domainPart}${syncedPart}`;
|
|
36
|
-
}
|
|
37
|
-
function TodayPanel({ today, width, height }) {
|
|
38
|
-
const cacheHitPart = today.cacheHit !== undefined ? ` cache hit ${Math.round(today.cacheHit * 100)}%` : "";
|
|
39
|
-
return (_jsxs(Panel, { title: "Today", width: width, height: height, children: [_jsx(Text, { children: `work ${formatMinutes(today.workMs)}` }), _jsx(Text, { children: `wait ${formatMinutes(today.waitingMs)} cost ${formatCost(today.cost)}` }), _jsx(Text, { children: `tasks ${today.tasks}${cacheHitPart}` })] }));
|
|
40
|
-
}
|
|
41
|
-
function Last7DaysPanel({ points, width, height }) {
|
|
42
|
-
return (_jsxs(Panel, { title: "Last 7 days", width: width, height: height, children: [_jsxs(Text, { children: ["work ", _jsx(Sparkline, { values: points.map((point) => point.workMs) }), " cost ", _jsx(Sparkline, { values: points.map((point) => point.cost) })] }), _jsx(Text, { dimColor: true, children: points.map((point) => point.weekday).join(" ") })] }));
|
|
43
|
-
}
|
|
44
|
-
/** Combined width of every fixed-width column but `project`, plus their separating spaces, the row's own `› `/` ` marker prefix, and the panel's own left/right border columns. */
|
|
45
|
-
const PROJECT_FIXED_COLUMNS_WIDTH = 8 + 1 + 7 + 1 + 12 + 1 + 2 + 2;
|
|
46
|
-
/** Floor for the `project` column so a very narrow table still shows a sliver of the name instead of collapsing to nothing. */
|
|
47
|
-
const MIN_PROJECT_NAME_WIDTH = 4;
|
|
48
|
-
/**
|
|
49
|
-
* `work`/`cost`/`share` are fixed-width; `project` absorbs whatever width
|
|
50
|
-
* is left in `panelWidth` (the panel's own outer width, including its
|
|
51
|
-
* border columns) so the row's total text never exceeds the panel's
|
|
52
|
-
* actual content width — which would otherwise wrap inside the
|
|
53
|
-
* fixed-height, `overflow: hidden` panel and corrupt the scrolling
|
|
54
|
-
* window (see the matching fix in `tasks-screen.tsx`).
|
|
55
|
-
*/
|
|
56
|
-
function projectColumns(panelWidth) {
|
|
57
|
-
const nameWidth = Math.max(panelWidth - PROJECT_FIXED_COLUMNS_WIDTH, MIN_PROJECT_NAME_WIDTH);
|
|
58
|
-
return [
|
|
59
|
-
{ key: "name", header: "project", width: nameWidth },
|
|
60
|
-
{ key: "work", header: "work", width: 8, align: "right" },
|
|
61
|
-
{ key: "cost", header: "cost", width: 7, align: "right" },
|
|
62
|
-
{ key: "share", header: "share", width: 12 },
|
|
63
|
-
];
|
|
64
|
-
}
|
|
65
|
-
function projectCell(row, key) {
|
|
66
|
-
switch (key) {
|
|
67
|
-
case "name":
|
|
68
|
-
return row.name;
|
|
69
|
-
case "work":
|
|
70
|
-
return formatMinutes(row.workMs);
|
|
71
|
-
case "cost":
|
|
72
|
-
return formatCost(row.cost);
|
|
73
|
-
case "share":
|
|
74
|
-
return renderBar(row.share, 1, 10);
|
|
75
|
-
default:
|
|
76
|
-
return "";
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function ProjectsPanel({ rows, selectedIndex, width, height, maxRows, active }) {
|
|
80
|
-
return (_jsx(Panel, { title: "Projects", width: width, height: height, active: active, children: _jsx(Table, { columns: projectColumns(width), rows: rows, rowKey: (row) => row.name, cell: projectCell, selectedIndex: selectedIndex, emptyText: "no work recorded today", maxRows: maxRows }) }));
|
|
81
|
-
}
|
|
82
|
-
function HubPanel({ hub, width, height }) {
|
|
83
|
-
return (_jsx(Panel, { title: "Hub", width: width, height: height, children: hub.status === "unavailable" ? (_jsx(Text, { dimColor: true, children: "hub not configured" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `pending ${hub.pending} · stale ${hub.staleOutsideWindow}` }), _jsx(Text, { children: `last sync ${hub.lastSyncOk ? "ok" : "error"}${hub.lastSyncAt !== undefined ? ` ${formatTime(hub.lastSyncAt)}` : ""}` }), hub.catalog !== undefined && _jsx(Text, { children: `catalog ${hub.catalog.clientCount} clients · ${hub.catalog.projectCount} projects` })] })) }));
|
|
84
|
-
}
|
|
85
|
-
/** Below this main-content width, the two-column grid collapses to one column, stacking every panel. */
|
|
86
|
-
const GRID_BREAKPOINT = 70;
|
|
87
|
-
/** Rows the Today card's panel takes: 1 top border + 3 content lines + 1 bottom border. */
|
|
88
|
-
const TODAY_PANEL_ROWS = 5;
|
|
89
|
-
/** Rows the Last 7 days panel takes: 1 top border + 2 content lines + 1 bottom border. */
|
|
90
|
-
const LAST7_PANEL_ROWS = 4;
|
|
91
|
-
/** Rows the Hub panel takes at most: 1 top border + up to 3 content lines + 1 bottom border. */
|
|
92
|
-
const HUB_PANEL_ROWS = 5;
|
|
93
|
-
/** Rows the Projects panel's own chrome (border + table header) takes outside its data rows. */
|
|
94
|
-
const TABLE_CHROME_ROWS = 3;
|
|
95
|
-
/** How many data rows the Projects table can show for a given `mainHeight`, depending on whether the grid is two columns (`grid`) or stacked to one (`stacked`). */
|
|
96
|
-
function projectsMaxRows(mainHeight, layout) {
|
|
97
|
-
const fixedRows = layout === "grid" ? TODAY_PANEL_ROWS : TODAY_PANEL_ROWS + LAST7_PANEL_ROWS + HUB_PANEL_ROWS;
|
|
98
|
-
return Math.max(mainHeight - fixedRows - TABLE_CHROME_ROWS, 1);
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* The Projects panel's own explicit height for a given `mainHeight`/`layout`
|
|
102
|
-
* — `TABLE_CHROME_ROWS` plus however many data rows `projectsMaxRows`
|
|
103
|
-
* allows, so the panel itself never grows past what its own table renders
|
|
104
|
-
* (see the layout-stability rule this screen's panels all follow).
|
|
105
|
-
*/
|
|
106
|
-
function projectsPanelHeight(mainHeight, layout) {
|
|
107
|
-
return TABLE_CHROME_ROWS + projectsMaxRows(mainHeight, layout);
|
|
108
|
-
}
|
|
109
|
-
function DashboardGrid({ model, selectedIndex, mainWidth, mainHeight, focused, }) {
|
|
110
|
-
if (mainWidth < GRID_BREAKPOINT) {
|
|
111
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TodayPanel, { today: model.today, width: mainWidth, height: TODAY_PANEL_ROWS }), _jsx(Last7DaysPanel, { points: model.last7Days, width: mainWidth, height: LAST7_PANEL_ROWS }), _jsx(ProjectsPanel, { rows: model.projects, selectedIndex: selectedIndex, width: mainWidth, height: projectsPanelHeight(mainHeight, "stacked"), maxRows: projectsMaxRows(mainHeight, "stacked"), active: focused }), _jsx(HubPanel, { hub: model.hub, width: mainWidth, height: HUB_PANEL_ROWS })] }));
|
|
112
|
-
}
|
|
113
|
-
const leftWidth = Math.floor(mainWidth * 0.55);
|
|
114
|
-
const rightWidth = Math.max(mainWidth - leftWidth - 1, 1);
|
|
115
|
-
return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", width: leftWidth, children: [_jsx(TodayPanel, { today: model.today, width: leftWidth, height: TODAY_PANEL_ROWS }), _jsx(ProjectsPanel, { rows: model.projects, selectedIndex: selectedIndex, width: leftWidth, height: projectsPanelHeight(mainHeight, "grid"), maxRows: projectsMaxRows(mainHeight, "grid"), active: focused })] }), _jsx(Box, { width: 1 }), _jsxs(Box, { flexDirection: "column", width: rightWidth, children: [_jsx(Last7DaysPanel, { points: model.last7Days, width: rightWidth, height: LAST7_PANEL_ROWS }), _jsx(HubPanel, { hub: model.hub, width: rightWidth, height: HUB_PANEL_ROWS })] })] }));
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* The Today screen: a dashboard (Today card, Last 7 days sparklines,
|
|
119
|
-
* Projects table with share bars, Hub card) inside the shared
|
|
120
|
-
* header/sidebar/footer frame. `r` reloads through `load`; `↑↓` move the
|
|
121
|
-
* Projects selection; `enter` opens the selected project in Tasks via
|
|
122
|
-
* `onOpenProject`. Never writes anything to disk.
|
|
123
|
-
*/
|
|
124
|
-
export function TodayScreen({ load, roots, version, columns, rows, focused = true, onOpenProject }) {
|
|
125
|
-
const [model, setModel] = useState(load);
|
|
126
|
-
const [selected, setSelected] = useState(0);
|
|
127
|
-
const maxRowsRef = useRef(0);
|
|
128
|
-
useInput((input, key) => {
|
|
129
|
-
const lastIndex = Math.max(model.projects.length - 1, 0);
|
|
130
|
-
if (input === "r") {
|
|
131
|
-
setModel(load());
|
|
132
|
-
}
|
|
133
|
-
else if (key.downArrow) {
|
|
134
|
-
setSelected((index) => Math.min(index + 1, lastIndex));
|
|
135
|
-
}
|
|
136
|
-
else if (key.upArrow) {
|
|
137
|
-
setSelected((index) => Math.max(index - 1, 0));
|
|
138
|
-
}
|
|
139
|
-
else if (key.pageDown) {
|
|
140
|
-
setSelected((index) => Math.min(index + Math.max(maxRowsRef.current, 1), lastIndex));
|
|
141
|
-
}
|
|
142
|
-
else if (key.pageUp) {
|
|
143
|
-
setSelected((index) => Math.max(index - Math.max(maxRowsRef.current, 1), 0));
|
|
144
|
-
}
|
|
145
|
-
else if (key.home) {
|
|
146
|
-
setSelected(0);
|
|
147
|
-
}
|
|
148
|
-
else if (key.end) {
|
|
149
|
-
setSelected(lastIndex);
|
|
150
|
-
}
|
|
151
|
-
else if (key.return) {
|
|
152
|
-
const project = model.projects[selected];
|
|
153
|
-
if (project)
|
|
154
|
-
onOpenProject?.(project.name);
|
|
155
|
-
}
|
|
156
|
-
}, { isActive: focused });
|
|
157
|
-
return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: hubHeaderText(model.hub), sidebarItems: SCREENS, activeId: "today", sidebarStats: [`roots ${roots.length}`, `projects ${model.projects.length}`], keyHints: hintsFor("today", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
|
|
158
|
-
maxRowsRef.current = projectsMaxRows(mainHeight, mainWidth < GRID_BREAKPOINT ? "stacked" : "grid");
|
|
159
|
-
return _jsx(DashboardGrid, { model: model, selectedIndex: selected, mainWidth: mainWidth, mainHeight: mainHeight, focused: focused });
|
|
160
|
-
} }));
|
|
161
|
-
}
|