kankaku-tui 0.2.0 → 0.3.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 +51 -18
- package/dist/adapters/setup/child-process-runner.js +33 -0
- package/dist/adapters/setup/claude.js +19 -0
- package/dist/adapters/setup/local-hub.js +86 -0
- package/dist/adapters/setup/pi.js +15 -0
- package/dist/cli.js +136 -12
- package/dist/domain/setup-wizard.js +234 -0
- package/dist/ports/script-runner.js +1 -0
- package/dist/ui/app.js +11 -2
- package/dist/ui/components/checklist.js +34 -0
- package/dist/ui/components/radio.js +29 -0
- package/dist/ui/components/text-input.js +64 -0
- package/dist/ui/setup/wizard-screen.js +198 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,24 +15,57 @@ npm i -g kankaku-tui
|
|
|
15
15
|
kankaku setup
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
`kankaku setup`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
23
|
+
|
|
24
|
+
The wizard's steps, `enter` to advance and `esc` to go back throughout
|
|
25
|
+
(`esc` at the first step quits):
|
|
26
|
+
|
|
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
|
|
31
|
+
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
|
+
Claude Code is checked and not already configured), guessed from any
|
|
35
|
+
existing `statusLine`.
|
|
36
|
+
4. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
|
|
37
|
+
inline health check, reusing the current credentials as the default),
|
|
38
|
+
`install locally`, or `skip`. Installing locally looks for a
|
|
39
|
+
`kankaku-hub` checkout first; if found, its path is prefilled and
|
|
40
|
+
confirming it downloads PocketBase, starts the dev server in the
|
|
41
|
+
background and creates a **development** service account (its email
|
|
42
|
+
and password are fixed constants from `kankaku-hub`'s own dev script,
|
|
43
|
+
never meant for anything but a local machine) — the wizard writes that
|
|
44
|
+
account as this machine's hub credentials. Without a checkout, the
|
|
45
|
+
wizard shows the exact commands to run in another terminal and a `c`
|
|
46
|
+
"check again" action, plus `m` to acknowledge you'll install it
|
|
47
|
+
manually.
|
|
48
|
+
5. **Roots** — the comma-separated project roots, defaulting to the
|
|
49
|
+
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
|
|
52
|
+
touches. `enter` applies it.
|
|
53
|
+
7. **Apply** — runs each change and shows its result
|
|
54
|
+
(`wrote`/`unchanged`/`removed`/`started`/`error: …`) as it happens.
|
|
55
|
+
8. **Done** — a summary, then `enter` opens the Dashboard in place — no
|
|
56
|
+
restart.
|
|
57
|
+
|
|
58
|
+
`kankaku setup --yes` and `kankaku setup --dry-run` stay exactly as
|
|
59
|
+
before: non-interactive, driven by argv/env only, never opening the
|
|
60
|
+
wizard (even on a TTY). `--yes` accepts every question's own default
|
|
61
|
+
without asking; `--dry-run` prints the plan — each step's state
|
|
62
|
+
(`done`/`todo`/`unavailable`) and the exact file it would change —
|
|
63
|
+
without writing anything. Nothing is ever written without either an
|
|
64
|
+
explicit answer (in the wizard or the `--yes`/readline flow) or `--yes`
|
|
65
|
+
itself. Before the first change to any file, kankaku setup creates a
|
|
66
|
+
`<file>.bak` next to it; re-running `kankaku setup` in any form is always
|
|
67
|
+
safe, since it only ever writes what is still missing or what you
|
|
68
|
+
explicitly change.
|
|
36
69
|
|
|
37
70
|
`kankaku setup` ends with, and `kankaku doctor` prints on its own, the
|
|
38
71
|
same read-only report: one line per agent, one for the hub, one for
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real `ScriptRunner`: `node:child_process` over the given cmd/args.
|
|
3
|
+
* `run` collects stdout/stderr and waits for exit; `spawnDetached` starts a
|
|
4
|
+
* detached, unref'd process whose combined output is appended to `logFile`,
|
|
5
|
+
* for `local-hub.ts#installLocalHub`'s `dev.sh`.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { openSync } from "node:fs";
|
|
9
|
+
export function createChildProcessRunner() {
|
|
10
|
+
return {
|
|
11
|
+
run(cmd, args, opts) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(cmd, args, { cwd: opts.cwd });
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
child.stdout?.on("data", (chunk) => {
|
|
17
|
+
stdout += chunk.toString();
|
|
18
|
+
});
|
|
19
|
+
child.stderr?.on("data", (chunk) => {
|
|
20
|
+
stderr += chunk.toString();
|
|
21
|
+
});
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
spawnDetached(cmd, args, opts) {
|
|
27
|
+
const fd = openSync(opts.logFile, "a");
|
|
28
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, detached: true, stdio: ["ignore", fd, fd] });
|
|
29
|
+
child.unref();
|
|
30
|
+
return { pid: child.pid ?? -1 };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -23,3 +23,22 @@ export function writeStatusLine(settingsPath, checkoutPath) {
|
|
|
23
23
|
writeJsonAtomic(settingsPath, { ...existing, statusLine: { type: "command", command } });
|
|
24
24
|
return { changed: true };
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Removes `statusLine` only when its `command` contains `"kankaku"` (i.e.
|
|
28
|
+
* it looks like kankaku's own statusline, not some other tool's), leaving
|
|
29
|
+
* every other key untouched. A no-op, with no backup and no write, when
|
|
30
|
+
* there is no `statusLine` or it belongs to something else.
|
|
31
|
+
*/
|
|
32
|
+
export function removeStatusLine(settingsPath) {
|
|
33
|
+
const existing = readJsonObjectOrEmpty(settingsPath);
|
|
34
|
+
const currentStatusLine = existing["statusLine"];
|
|
35
|
+
const currentCommand = currentStatusLine && typeof currentStatusLine === "object" && !Array.isArray(currentStatusLine)
|
|
36
|
+
? currentStatusLine["command"]
|
|
37
|
+
: undefined;
|
|
38
|
+
if (typeof currentCommand !== "string" || !currentCommand.includes("kankaku"))
|
|
39
|
+
return { changed: false };
|
|
40
|
+
backupOnce(settingsPath);
|
|
41
|
+
const { statusLine: _statusLine, ...rest } = existing;
|
|
42
|
+
writeJsonAtomic(settingsPath, rest);
|
|
43
|
+
return { changed: true };
|
|
44
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level-1 local hub install for the setup wizard's "install locally" hub
|
|
3
|
+
* option: finds a `kankaku-hub` checkout, runs its own dev scripts through
|
|
4
|
+
* the injected `ScriptRunner` (never spawning the real hub in tests), and
|
|
5
|
+
* returns the service account `scripts/create-dev-accounts.sh` creates.
|
|
6
|
+
* The service account's email/password are that script's own hardcoded
|
|
7
|
+
* constants (`SERVICE_EMAIL`/`SERVICE_PASSWORD`), read from
|
|
8
|
+
* `kankaku-hub/scripts/create-dev-accounts.sh`, not environment-overridable
|
|
9
|
+
* there — kept identical here so the credentials this writes always match
|
|
10
|
+
* what the script actually created.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
const HUB_URL = "http://127.0.0.1:8090";
|
|
15
|
+
const SERVICE_EMAIL = "kankaku-sync@kankaku.local";
|
|
16
|
+
const SERVICE_PASSWORD = "kankaku-dev-sync";
|
|
17
|
+
const DEFAULT_CHECKOUT_DIR = "~/desarrollo/soyun.ninja/kankaku-hub";
|
|
18
|
+
/** The real `kankaku-hub` remote (`git -C <repo> remote get-url origin`), in its https form so no SSH key is required for a one-off clone. */
|
|
19
|
+
const HUB_CLONE_URL = "https://github.com/soyunninja/kankaku_hub.git";
|
|
20
|
+
const HEALTH_POLL_TIMEOUT_MS = 20000;
|
|
21
|
+
const HEALTH_POLL_INTERVAL_MS = 500;
|
|
22
|
+
/** The first `candidates` entry that looks like a `kankaku-hub` checkout: has `scripts/dev.sh` and `pocketbase/pb_migrations`. */
|
|
23
|
+
export function findHubCheckout(candidates) {
|
|
24
|
+
return candidates.find((candidate) => existsSync(join(candidate, "scripts", "dev.sh")) && existsSync(join(candidate, "pocketbase", "pb_migrations")));
|
|
25
|
+
}
|
|
26
|
+
async function pollHealth(url, deps) {
|
|
27
|
+
const deadline = deps.now() + HEALTH_POLL_TIMEOUT_MS;
|
|
28
|
+
for (;;) {
|
|
29
|
+
try {
|
|
30
|
+
const response = await deps.fetch(url);
|
|
31
|
+
if (response.ok)
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Not up yet; keep polling until the deadline.
|
|
36
|
+
}
|
|
37
|
+
if (deps.now() >= deadline)
|
|
38
|
+
return false;
|
|
39
|
+
await deps.sleep(HEALTH_POLL_INTERVAL_MS);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Installs the local hub from `checkout`: downloads PocketBase (skipped
|
|
44
|
+
* when `pocketbase/bin/pocketbase` already exists), starts `scripts/dev.sh`
|
|
45
|
+
* detached (pid at `~/.kankaku/hub/pid`, log at `~/.kankaku/hub/dev.log`),
|
|
46
|
+
* polls `<HUB_URL>/api/health` for up to ~20s, then runs
|
|
47
|
+
* `scripts/create-dev-accounts.sh`. Returns the local hub's URL and service
|
|
48
|
+
* account on success, or `ok: false` with an `error` at the first failing
|
|
49
|
+
* step — never throws.
|
|
50
|
+
*/
|
|
51
|
+
export async function installLocalHub(checkout, deps) {
|
|
52
|
+
const pocketbaseBinary = join(checkout, "pocketbase", "bin", "pocketbase");
|
|
53
|
+
if (!existsSync(pocketbaseBinary)) {
|
|
54
|
+
const download = await deps.runner.run(join(checkout, "scripts", "pb-download.sh"), [], { cwd: checkout });
|
|
55
|
+
if (download.code !== 0) {
|
|
56
|
+
return { ok: false, url: HUB_URL, error: `pb-download.sh failed: ${download.stderr || download.stdout || `exit code ${download.code}`}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const hubDir = join(deps.homeDir, ".kankaku", "hub");
|
|
60
|
+
mkdirSync(hubDir, { recursive: true });
|
|
61
|
+
const logFile = join(hubDir, "dev.log");
|
|
62
|
+
const { pid } = deps.runner.spawnDetached(join(checkout, "scripts", "dev.sh"), [], { cwd: checkout, logFile });
|
|
63
|
+
writeFileSync(join(hubDir, "pid"), String(pid));
|
|
64
|
+
const healthy = await pollHealth(`${HUB_URL}/api/health`, deps);
|
|
65
|
+
if (!healthy) {
|
|
66
|
+
return { ok: false, url: HUB_URL, error: "the local hub did not become healthy within 20s" };
|
|
67
|
+
}
|
|
68
|
+
const accounts = await deps.runner.run(join(checkout, "scripts", "create-dev-accounts.sh"), [], { cwd: checkout });
|
|
69
|
+
if (accounts.code !== 0) {
|
|
70
|
+
return { ok: false, url: HUB_URL, error: `create-dev-accounts.sh failed: ${accounts.stderr || accounts.stdout || `exit code ${accounts.code}`}` };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, url: HUB_URL, serviceEmail: SERVICE_EMAIL, servicePassword: SERVICE_PASSWORD };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The exact command lines to show when no `kankaku-hub` checkout was
|
|
76
|
+
* found: clone it (unless `checkout` names one already on disk), then run
|
|
77
|
+
* its three dev scripts in order.
|
|
78
|
+
*/
|
|
79
|
+
export function manualCommands(checkout) {
|
|
80
|
+
const dir = checkout ?? DEFAULT_CHECKOUT_DIR;
|
|
81
|
+
const lines = [];
|
|
82
|
+
if (!checkout)
|
|
83
|
+
lines.push(`git clone ${HUB_CLONE_URL} ${dir}`);
|
|
84
|
+
lines.push(`cd ${dir}`, "scripts/pb-download.sh", "scripts/dev.sh &", "scripts/create-dev-accounts.sh");
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
@@ -19,3 +19,18 @@ export function addKankakuPackage(settingsPath) {
|
|
|
19
19
|
writeJsonAtomic(settingsPath, { ...existing, packages: [...packages, "npm:kankaku"] });
|
|
20
20
|
return { changed: true };
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes every `packages` entry `isKankakuPackage` identifies as kankaku,
|
|
24
|
+
* leaving every other entry and key untouched. A no-op, with no backup and
|
|
25
|
+
* no write, when none is present.
|
|
26
|
+
*/
|
|
27
|
+
export function removeKankakuPackage(settingsPath) {
|
|
28
|
+
const existing = readJsonObjectOrEmpty(settingsPath);
|
|
29
|
+
const packages = isStringArray(existing["packages"]) ? existing["packages"] : [];
|
|
30
|
+
const filtered = packages.filter((entry) => !isKankakuPackage(entry));
|
|
31
|
+
if (filtered.length === packages.length)
|
|
32
|
+
return { changed: false };
|
|
33
|
+
backupOnce(settingsPath);
|
|
34
|
+
writeJsonAtomic(settingsPath, { ...existing, packages: filtered });
|
|
35
|
+
return { changed: true };
|
|
36
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { homedir, hostname } from "node:os";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { existsSync, realpathSync } from "node:fs";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { render } from "ink";
|
|
@@ -21,10 +21,12 @@ import { buildSyncRows } from "./domain/sync-model.js";
|
|
|
21
21
|
import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
|
|
22
22
|
import { detectAgents, formatDoctorLines, formatSetupPlanLines, planSetup } from "./domain/setup-plan.js";
|
|
23
23
|
import { readAgentFacts } from "./adapters/setup/agents.js";
|
|
24
|
-
import { addKankakuPackage } from "./adapters/setup/pi.js";
|
|
25
|
-
import { writeStatusLine } from "./adapters/setup/claude.js";
|
|
24
|
+
import { addKankakuPackage, removeKankakuPackage } from "./adapters/setup/pi.js";
|
|
25
|
+
import { removeStatusLine, writeStatusLine } from "./adapters/setup/claude.js";
|
|
26
26
|
import { checkHubHealth, credentialsPath, writeHubCredentials } from "./adapters/setup/hub.js";
|
|
27
27
|
import { tuiConfigPath, writeTuiConfig } from "./adapters/setup/tui-config.js";
|
|
28
|
+
import { findHubCheckout, installLocalHub, manualCommands } from "./adapters/setup/local-hub.js";
|
|
29
|
+
import { createChildProcessRunner } from "./adapters/setup/child-process-runner.js";
|
|
28
30
|
import { createReadlinePrompter } from "./adapters/setup/readline-prompter.js";
|
|
29
31
|
const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run]|doctor] [--roots a,b] [--theme name]\n";
|
|
30
32
|
/** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
|
|
@@ -225,6 +227,113 @@ async function runDoctorCommand(deps) {
|
|
|
225
227
|
const { agents, hub, tui } = await gatherSetupFacts(deps);
|
|
226
228
|
deps.stdout(formatDoctorLines(agents, hub, tui).join("\n"));
|
|
227
229
|
}
|
|
230
|
+
/** Plausible `kankaku-hub` checkout locations to probe for the wizard's Hub step: a sibling of this project's parent, and the documented default under the home directory. */
|
|
231
|
+
function localHubCandidates(deps) {
|
|
232
|
+
return [join(deps.homeDir, "desarrollo", "soyun.ninja", "kankaku-hub"), join(dirname(deps.cwd), "kankaku-hub")];
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Gather the plain facts the setup wizard needs (`domain/setup-wizard.ts#createWizardState`):
|
|
236
|
+
* every detected agent, current hub credentials (reused as the "existing
|
|
237
|
+
* hub" step's defaults) and current `tui.json` roots. Entirely read-only,
|
|
238
|
+
* with no network call — the hub's health is checked interactively, from
|
|
239
|
+
* the wizard's own Hub step, never upfront.
|
|
240
|
+
*/
|
|
241
|
+
function gatherWizardFacts(deps) {
|
|
242
|
+
const agentFacts = readAgentFacts(deps.homeDir);
|
|
243
|
+
const hubResolution = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
244
|
+
const credPath = credentialsPath(deps.homeDir);
|
|
245
|
+
const localCheckoutGuess = findHubCheckout(localHubCandidates(deps)) ?? join(deps.homeDir, "desarrollo", "soyun.ninja", "kankaku-hub");
|
|
246
|
+
const hub = hubResolution.ok
|
|
247
|
+
? {
|
|
248
|
+
credentialsPresent: true,
|
|
249
|
+
url: hubResolution.credentials.url,
|
|
250
|
+
email: hubResolution.credentials.email,
|
|
251
|
+
password: hubResolution.credentials.password,
|
|
252
|
+
credentialsPath: credPath,
|
|
253
|
+
localCheckoutGuess,
|
|
254
|
+
}
|
|
255
|
+
: { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath, localCheckoutGuess };
|
|
256
|
+
const tuiPath = tuiConfigPath(deps.homeDir);
|
|
257
|
+
const roots = {
|
|
258
|
+
current: existsSync(tuiPath) ? readTuiConfig(deps.homeDir, deps.cwd).roots : undefined,
|
|
259
|
+
defaultRoots: [dirname(deps.cwd)],
|
|
260
|
+
path: tuiPath,
|
|
261
|
+
};
|
|
262
|
+
return { agentFacts, hub, roots };
|
|
263
|
+
}
|
|
264
|
+
/** Runs `adapters/setup/local-hub.ts#installLocalHub` for real, sharing one `ScriptRunner`/timer setup between the wizard's `installLocalHub` action and `apply`'s own `install-local-hub` handling. */
|
|
265
|
+
async function performLocalHubInstall(checkout, deps) {
|
|
266
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
267
|
+
const result = await installLocalHub(checkout, {
|
|
268
|
+
runner: createChildProcessRunner(),
|
|
269
|
+
homeDir: deps.homeDir,
|
|
270
|
+
fetch: fetchOverride ?? fetch,
|
|
271
|
+
now,
|
|
272
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
273
|
+
});
|
|
274
|
+
if (!result.ok)
|
|
275
|
+
throw new Error(result.error ?? "installing the local hub failed");
|
|
276
|
+
return { url: result.url, serviceEmail: result.serviceEmail, servicePassword: result.servicePassword };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Apply one planned `WizardAction` for real, through the matching
|
|
280
|
+
* `adapters/setup/*` writer. Several kinds need a value `planFromWizard`
|
|
281
|
+
* never put in the action itself (`file` is always the target path, not
|
|
282
|
+
* what to write) — `state` carries it: `state.claudeCheckout` for
|
|
283
|
+
* `write-claude`, `state.hub` for `write-hub`, `state.roots` for
|
|
284
|
+
* `write-roots`. `install-local-hub` runs the real installer, then writes
|
|
285
|
+
* its returned dev-account credentials as the hub, labelling them as dev
|
|
286
|
+
* defaults in the result detail. Never throws: any adapter failure becomes
|
|
287
|
+
* an `"error"` outcome instead.
|
|
288
|
+
*/
|
|
289
|
+
async function applyWizardAction(action, state, deps) {
|
|
290
|
+
try {
|
|
291
|
+
switch (action.kind) {
|
|
292
|
+
case "install-pi": {
|
|
293
|
+
const result = addKankakuPackage(action.file);
|
|
294
|
+
return { action, outcome: result.changed ? "wrote" : "unchanged" };
|
|
295
|
+
}
|
|
296
|
+
case "remove-pi": {
|
|
297
|
+
const result = removeKankakuPackage(action.file);
|
|
298
|
+
return { action, outcome: result.changed ? "removed" : "unchanged" };
|
|
299
|
+
}
|
|
300
|
+
case "write-claude": {
|
|
301
|
+
const result = writeStatusLine(action.file, state.claudeCheckout);
|
|
302
|
+
return { action, outcome: result.changed ? "wrote" : "unchanged" };
|
|
303
|
+
}
|
|
304
|
+
case "remove-claude": {
|
|
305
|
+
const result = removeStatusLine(action.file);
|
|
306
|
+
return { action, outcome: result.changed ? "removed" : "unchanged" };
|
|
307
|
+
}
|
|
308
|
+
case "write-hub": {
|
|
309
|
+
const result = writeHubCredentials(deps.homeDir, { url: state.hub.url, email: state.hub.email, password: state.hub.password });
|
|
310
|
+
return { action, outcome: result.changed ? "wrote" : "unchanged" };
|
|
311
|
+
}
|
|
312
|
+
case "install-local-hub": {
|
|
313
|
+
const installed = await performLocalHubInstall(action.file, deps);
|
|
314
|
+
writeHubCredentials(deps.homeDir, { url: installed.url, email: installed.serviceEmail, password: installed.servicePassword });
|
|
315
|
+
return { action, outcome: "started", detail: `running at ${installed.url} (dev defaults: ${installed.serviceEmail} / ${installed.servicePassword})` };
|
|
316
|
+
}
|
|
317
|
+
case "write-roots": {
|
|
318
|
+
const result = writeTuiConfig(deps.homeDir, state.roots);
|
|
319
|
+
return { action, outcome: result.changed ? "wrote" : "unchanged" };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
return { action, outcome: "error", detail: error instanceof Error ? error.message : String(error) };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Build the setup wizard's `WizardActions` for the interactive app: every write goes through the same real `adapters/setup/*` writers `kankaku setup --yes` uses. Used only by `renderApp`. */
|
|
328
|
+
function buildWizardActions(deps) {
|
|
329
|
+
return {
|
|
330
|
+
apply: (action, state) => applyWizardAction(action, state, deps),
|
|
331
|
+
checkHealth: (url) => checkHubHealth(url, { fetch: deps.fetch }),
|
|
332
|
+
findHubCheckout: () => findHubCheckout(localHubCandidates(deps)),
|
|
333
|
+
manualCommands: (checkout) => manualCommands(checkout),
|
|
334
|
+
installLocalHub: (checkout) => performLocalHubInstall(checkout, deps),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
228
337
|
/**
|
|
229
338
|
* Extract a plausible kankaku-claude checkout path from an existing
|
|
230
339
|
* `statusLine.command` of the shape `node "<checkout>/src/statusline.ts"`,
|
|
@@ -257,6 +366,10 @@ function makeAsker(yes, prompter) {
|
|
|
257
366
|
* report as `kankaku doctor`. `--dry-run` prints the plan and writes
|
|
258
367
|
* nothing — no prompt is asked and no default is applied.
|
|
259
368
|
*/
|
|
369
|
+
/** Tell the user what setup just did to a file: nothing is written silently. */
|
|
370
|
+
function announceWrite(deps, file, result) {
|
|
371
|
+
deps.stdout(result.changed ? `wrote ${file}` : `unchanged ${file}`);
|
|
372
|
+
}
|
|
260
373
|
async function runSetupCommand(args, deps) {
|
|
261
374
|
const dryRun = args.includes("--dry-run");
|
|
262
375
|
const yes = args.includes("--yes");
|
|
@@ -281,12 +394,12 @@ async function runSetupCommand(args, deps) {
|
|
|
281
394
|
const guessed = guessClaudeCheckout(readAgentFacts(deps.homeDir).claudeCode?.statusLineCommand) ?? "";
|
|
282
395
|
const checkoutPath = await ask.text("Path to your kankaku-claude checkout", guessed);
|
|
283
396
|
if (checkoutPath !== "")
|
|
284
|
-
writeStatusLine(step.file, checkoutPath);
|
|
397
|
+
announceWrite(deps, step.file, writeStatusLine(step.file, checkoutPath));
|
|
285
398
|
continue;
|
|
286
399
|
}
|
|
287
400
|
const doIt = await ask.confirm(`Install kankaku in ${step.title} (${step.file})?`, true);
|
|
288
401
|
if (doIt)
|
|
289
|
-
addKankakuPackage(step.file);
|
|
402
|
+
announceWrite(deps, step.file, addKankakuPackage(step.file));
|
|
290
403
|
}
|
|
291
404
|
if (byId["hub"].state === "todo" && !initial.hub.credentialsPresent) {
|
|
292
405
|
const doIt = await ask.confirm("Configure hub credentials now?", false);
|
|
@@ -295,7 +408,7 @@ async function runSetupCommand(args, deps) {
|
|
|
295
408
|
if (url !== "") {
|
|
296
409
|
const email = await ask.text("Email", "");
|
|
297
410
|
const password = await ask.secret("Password");
|
|
298
|
-
writeHubCredentials(deps.homeDir, { url, email, password });
|
|
411
|
+
announceWrite(deps, credentialsPath(deps.homeDir), writeHubCredentials(deps.homeDir, { url, email, password }));
|
|
299
412
|
}
|
|
300
413
|
}
|
|
301
414
|
}
|
|
@@ -309,7 +422,7 @@ async function runSetupCommand(args, deps) {
|
|
|
309
422
|
.map((root) => root.trim())
|
|
310
423
|
.filter((root) => root.length > 0);
|
|
311
424
|
if (roots.length > 0)
|
|
312
|
-
writeTuiConfig(deps.homeDir, roots);
|
|
425
|
+
announceWrite(deps, tuiConfigPath(deps.homeDir), writeTuiConfig(deps.homeDir, roots));
|
|
313
426
|
}
|
|
314
427
|
}
|
|
315
428
|
const finalHub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
@@ -318,7 +431,8 @@ async function runSetupCommand(args, deps) {
|
|
|
318
431
|
if (doRefresh) {
|
|
319
432
|
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
320
433
|
const catalog = createCatalog(finalHub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
321
|
-
await refreshCatalogAdapter(catalog);
|
|
434
|
+
const snapshot = await refreshCatalogAdapter(catalog);
|
|
435
|
+
deps.stdout(formatCatalogRefreshLines(snapshot).join("\n"));
|
|
322
436
|
}
|
|
323
437
|
}
|
|
324
438
|
await runDoctorCommand(deps);
|
|
@@ -347,7 +461,10 @@ export async function runCli(argv, deps) {
|
|
|
347
461
|
const { roots, rest } = parseRoots(argvAfterTheme, deps);
|
|
348
462
|
const [command] = rest;
|
|
349
463
|
if (command === undefined) {
|
|
350
|
-
|
|
464
|
+
// First-run hint: no `tui.json` yet means this machine has never been
|
|
465
|
+
// set up — open straight into the wizard instead of an empty Dashboard.
|
|
466
|
+
const startInWizard = !existsSync(tuiConfigPath(deps.homeDir));
|
|
467
|
+
deps.renderApp(roots, theme, { startInWizard });
|
|
351
468
|
return;
|
|
352
469
|
}
|
|
353
470
|
if (command === "today") {
|
|
@@ -368,7 +485,13 @@ export async function runCli(argv, deps) {
|
|
|
368
485
|
return;
|
|
369
486
|
}
|
|
370
487
|
if (command === "setup") {
|
|
371
|
-
|
|
488
|
+
const setupArgs = rest.slice(1);
|
|
489
|
+
const interactiveTTY = !setupArgs.includes("--yes") && !setupArgs.includes("--dry-run") && (deps.isTTY?.() ?? false);
|
|
490
|
+
if (interactiveTTY) {
|
|
491
|
+
deps.renderApp(roots, theme, { startInWizard: true });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
await runSetupCommand(setupArgs, deps);
|
|
372
495
|
return;
|
|
373
496
|
}
|
|
374
497
|
if (command === "doctor") {
|
|
@@ -499,9 +622,10 @@ if (isMain) {
|
|
|
499
622
|
exit: (code) => {
|
|
500
623
|
process.exit(code);
|
|
501
624
|
},
|
|
625
|
+
isTTY: () => process.stdout.isTTY === true,
|
|
502
626
|
prompter: createReadlinePrompter(process.stdin, process.stdout),
|
|
503
|
-
renderApp: (roots, theme) => {
|
|
504
|
-
render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots) }) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
627
|
+
renderApp: (roots, theme, options) => {
|
|
628
|
+
render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: { facts: gatherWizardFacts(realDeps), actions: buildWizardActions(realDeps) }, startInWizard: options?.startInWizard }) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
505
629
|
},
|
|
506
630
|
};
|
|
507
631
|
void runCli(process.argv.slice(2), realDeps);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure domain for `kankaku setup`'s interactive wizard: one `WizardState`
|
|
3
|
+
* carrying the current step, the user's in-progress answers, the computed
|
|
4
|
+
* plan and the applied results, plus reducers that transition it. No I/O —
|
|
5
|
+
* callers (`src/ui/setup/*`, `src/cli.tsx`) read the real files and pass
|
|
6
|
+
* plain facts in via `WizardFacts`, exactly like `domain/setup-plan.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { detectAgents } from "./setup-plan.js";
|
|
9
|
+
const CLAUDE_STATUS_LINE_RE = /^node "(.+)\/src\/statusline\.ts"$/;
|
|
10
|
+
/** The inverse of `adapters/setup/claude.ts#statusLineCommand`: recovers the checkout path from an existing `statusLine.command`, or `""` when it isn't kankaku's. */
|
|
11
|
+
export function guessClaudeCheckout(statusLineCommand) {
|
|
12
|
+
if (!statusLineCommand)
|
|
13
|
+
return "";
|
|
14
|
+
const match = CLAUDE_STATUS_LINE_RE.exec(statusLineCommand);
|
|
15
|
+
return match ? match[1] : "";
|
|
16
|
+
}
|
|
17
|
+
function clearError(errors, key) {
|
|
18
|
+
if (!(key in errors))
|
|
19
|
+
return errors;
|
|
20
|
+
const rest = { ...errors };
|
|
21
|
+
delete rest[key];
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
const HUB_ERROR_KEYS = ["url", "email", "password", "localCheckout"];
|
|
25
|
+
function clearHubErrors(errors) {
|
|
26
|
+
return HUB_ERROR_KEYS.reduce((acc, key) => clearError(acc, key), errors);
|
|
27
|
+
}
|
|
28
|
+
/** Build the wizard's initial state from plain, already-read facts: agent detection, current hub credentials and current `tui.json` roots. */
|
|
29
|
+
export function createWizardState(facts) {
|
|
30
|
+
const agents = detectAgents(facts.agentFacts);
|
|
31
|
+
const selected = Object.fromEntries(agents.map((agent) => [agent.id, agent.configured]));
|
|
32
|
+
return {
|
|
33
|
+
step: "detect",
|
|
34
|
+
agents,
|
|
35
|
+
selected,
|
|
36
|
+
claudeCheckout: guessClaudeCheckout(facts.agentFacts.claudeCode?.statusLineCommand),
|
|
37
|
+
hub: {
|
|
38
|
+
mode: facts.hub.credentialsPresent ? "existing" : "skip",
|
|
39
|
+
url: facts.hub.url ?? "",
|
|
40
|
+
email: facts.hub.email ?? "",
|
|
41
|
+
password: facts.hub.password ?? "",
|
|
42
|
+
localCheckout: facts.hub.localCheckoutGuess,
|
|
43
|
+
localManual: false,
|
|
44
|
+
},
|
|
45
|
+
roots: facts.roots.current ?? facts.roots.defaultRoots,
|
|
46
|
+
errors: {},
|
|
47
|
+
plan: [],
|
|
48
|
+
results: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Whether the Claude Code step should be shown: Claude is selected and not already configured. */
|
|
52
|
+
function claudeStepNeeded(state) {
|
|
53
|
+
const claude = state.agents.find((agent) => agent.id === "claude-code");
|
|
54
|
+
return state.selected["claude-code"] === true && claude !== undefined && !claude.configured;
|
|
55
|
+
}
|
|
56
|
+
export function toggleAgent(state, id) {
|
|
57
|
+
if (id === "codex" || id === "opencode")
|
|
58
|
+
return state;
|
|
59
|
+
return { ...state, selected: { ...state.selected, [id]: !state.selected[id] } };
|
|
60
|
+
}
|
|
61
|
+
export function setClaudeCheckout(state, value) {
|
|
62
|
+
return { ...state, claudeCheckout: value, errors: clearError(state.errors, "claudeCheckout") };
|
|
63
|
+
}
|
|
64
|
+
export function setHubMode(state, mode) {
|
|
65
|
+
return { ...state, hub: { ...state.hub, mode }, errors: clearHubErrors(state.errors) };
|
|
66
|
+
}
|
|
67
|
+
export function setHubField(state, field, value) {
|
|
68
|
+
return { ...state, hub: { ...state.hub, [field]: value }, errors: clearError(state.errors, field) };
|
|
69
|
+
}
|
|
70
|
+
export function setHubHealth(state, ok) {
|
|
71
|
+
return { ...state, hub: { ...state.hub, healthOk: ok } };
|
|
72
|
+
}
|
|
73
|
+
/** Parses a comma-separated list of roots: trims each entry and drops empties. */
|
|
74
|
+
export function setRoots(state, text) {
|
|
75
|
+
const roots = text
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((entry) => entry.trim())
|
|
78
|
+
.filter((entry) => entry.length > 0);
|
|
79
|
+
return { ...state, roots, errors: clearError(state.errors, "roots") };
|
|
80
|
+
}
|
|
81
|
+
export function setLocalCheckout(state, value) {
|
|
82
|
+
return { ...state, hub: { ...state.hub, localCheckout: value }, errors: clearError(state.errors, "localCheckout") };
|
|
83
|
+
}
|
|
84
|
+
export function setHubLocalManual(state, manual) {
|
|
85
|
+
return { ...state, hub: { ...state.hub, localManual: manual }, errors: clearError(state.errors, "localCheckout") };
|
|
86
|
+
}
|
|
87
|
+
function validateHub(hub) {
|
|
88
|
+
if (hub.mode === "existing") {
|
|
89
|
+
const errors = {};
|
|
90
|
+
if (!/^https?:\/\//.test(hub.url))
|
|
91
|
+
errors.url = "enter a http(s) URL";
|
|
92
|
+
if (!hub.email.includes("@"))
|
|
93
|
+
errors.email = "enter a valid email";
|
|
94
|
+
if (hub.password.trim() === "")
|
|
95
|
+
errors.password = "enter a password";
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
if (hub.mode === "local") {
|
|
99
|
+
if (hub.localCheckout.trim() === "" && !hub.localManual) {
|
|
100
|
+
return { localCheckout: "enter a checkout path, or acknowledge installing manually" };
|
|
101
|
+
}
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
function arraysEqual(a, b) {
|
|
107
|
+
return a.length === b.length && a.every((entry, index) => entry === b[index]);
|
|
108
|
+
}
|
|
109
|
+
const PI_HOME_AGENTS = [
|
|
110
|
+
{ id: "pi", title: "pi" },
|
|
111
|
+
{ id: "gentle-shell", title: "gentle-shell" },
|
|
112
|
+
];
|
|
113
|
+
/** Build the ordered plan from the current wizard answers, diffed against `facts`' current on-disk state. */
|
|
114
|
+
export function planFromWizard(state, facts) {
|
|
115
|
+
const actions = [];
|
|
116
|
+
for (const { id, title } of PI_HOME_AGENTS) {
|
|
117
|
+
const agent = state.agents.find((candidate) => candidate.id === id);
|
|
118
|
+
if (!agent || !agent.adapterAvailable)
|
|
119
|
+
continue;
|
|
120
|
+
const selected = state.selected[id] === true;
|
|
121
|
+
if (selected === agent.configured)
|
|
122
|
+
continue;
|
|
123
|
+
actions.push(selected
|
|
124
|
+
? { kind: "install-pi", file: agent.detail, label: `install kankaku in ${title} (${agent.detail})` }
|
|
125
|
+
: { kind: "remove-pi", file: agent.detail, label: `remove kankaku from ${title} (${agent.detail})` });
|
|
126
|
+
}
|
|
127
|
+
const claude = state.agents.find((agent) => agent.id === "claude-code");
|
|
128
|
+
if (claude && claude.adapterAvailable) {
|
|
129
|
+
const selected = state.selected["claude-code"] === true;
|
|
130
|
+
if (selected !== claude.configured) {
|
|
131
|
+
actions.push(selected
|
|
132
|
+
? { kind: "write-claude", file: claude.detail, label: `set the Claude Code status line (${claude.detail})` }
|
|
133
|
+
: { kind: "remove-claude", file: claude.detail, label: `remove the kankaku status line from Claude Code (${claude.detail})` });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (state.hub.mode === "existing") {
|
|
137
|
+
const changed = !facts.hub.credentialsPresent ||
|
|
138
|
+
state.hub.url !== (facts.hub.url ?? "") ||
|
|
139
|
+
state.hub.email !== (facts.hub.email ?? "") ||
|
|
140
|
+
state.hub.password !== (facts.hub.password ?? "");
|
|
141
|
+
if (changed) {
|
|
142
|
+
actions.push({ kind: "write-hub", file: facts.hub.credentialsPath, label: `write hub credentials to ${facts.hub.credentialsPath}` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (state.hub.mode === "local" && state.hub.localCheckout.trim() !== "") {
|
|
146
|
+
actions.push({ kind: "install-local-hub", file: state.hub.localCheckout, label: `install a local hub from ${state.hub.localCheckout}` });
|
|
147
|
+
}
|
|
148
|
+
const sameRoots = facts.roots.current !== undefined && arraysEqual(facts.roots.current, state.roots);
|
|
149
|
+
if (!sameRoots) {
|
|
150
|
+
actions.push({ kind: "write-roots", file: facts.roots.path, label: `write roots to ${facts.roots.path}` });
|
|
151
|
+
}
|
|
152
|
+
return actions;
|
|
153
|
+
}
|
|
154
|
+
/** Advance from the current step: validates it, sets `errors` and stays when invalid, otherwise moves on. */
|
|
155
|
+
export function next(state, facts) {
|
|
156
|
+
switch (state.step) {
|
|
157
|
+
case "detect":
|
|
158
|
+
return { ...state, step: "agents", errors: {} };
|
|
159
|
+
case "agents":
|
|
160
|
+
return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
|
|
161
|
+
case "claude": {
|
|
162
|
+
if (state.claudeCheckout.trim() === "") {
|
|
163
|
+
return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path" } };
|
|
164
|
+
}
|
|
165
|
+
return { ...state, step: "hub", errors: clearError(state.errors, "claudeCheckout") };
|
|
166
|
+
}
|
|
167
|
+
case "hub": {
|
|
168
|
+
const hubErrors = validateHub(state.hub);
|
|
169
|
+
if (Object.keys(hubErrors).length > 0) {
|
|
170
|
+
return { ...state, errors: { ...clearHubErrors(state.errors), ...hubErrors } };
|
|
171
|
+
}
|
|
172
|
+
return { ...state, step: "roots", errors: clearHubErrors(state.errors) };
|
|
173
|
+
}
|
|
174
|
+
case "roots": {
|
|
175
|
+
if (state.roots.length === 0) {
|
|
176
|
+
return { ...state, errors: { ...state.errors, roots: "enter at least one root directory" } };
|
|
177
|
+
}
|
|
178
|
+
const reviewState = { ...state, step: "review", errors: clearError(state.errors, "roots") };
|
|
179
|
+
return { ...reviewState, plan: planFromWizard(reviewState, facts) };
|
|
180
|
+
}
|
|
181
|
+
case "review":
|
|
182
|
+
return { ...state, step: "apply" };
|
|
183
|
+
case "apply":
|
|
184
|
+
return state.results.length >= state.plan.length ? { ...state, step: "done" } : state;
|
|
185
|
+
case "done":
|
|
186
|
+
return state;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Move back to the previous step, mirroring `next`'s claude-step skip. Terminal/first steps (`detect`, `apply`, `done`) are no-ops. */
|
|
190
|
+
export function back(state) {
|
|
191
|
+
switch (state.step) {
|
|
192
|
+
case "detect":
|
|
193
|
+
return state;
|
|
194
|
+
case "agents":
|
|
195
|
+
return { ...state, step: "detect", errors: {} };
|
|
196
|
+
case "claude":
|
|
197
|
+
return { ...state, step: "agents", errors: {} };
|
|
198
|
+
case "hub":
|
|
199
|
+
return { ...state, step: claudeStepNeeded(state) ? "claude" : "agents", errors: {} };
|
|
200
|
+
case "roots":
|
|
201
|
+
return { ...state, step: "hub", errors: {} };
|
|
202
|
+
case "review":
|
|
203
|
+
return { ...state, step: "roots", errors: {} };
|
|
204
|
+
case "apply":
|
|
205
|
+
return state;
|
|
206
|
+
case "done":
|
|
207
|
+
return state;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function applyResult(state, result) {
|
|
211
|
+
return { ...state, results: [...state.results, result] };
|
|
212
|
+
}
|
|
213
|
+
/** The footer key hints for a given step. */
|
|
214
|
+
export function hintsForStep(step) {
|
|
215
|
+
const quit = { key: "q", label: "quit" };
|
|
216
|
+
switch (step) {
|
|
217
|
+
case "detect":
|
|
218
|
+
return [{ key: "enter", label: "next" }, quit];
|
|
219
|
+
case "agents":
|
|
220
|
+
return [{ key: "space", label: "toggle" }, { key: "↑↓", label: "move" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
|
|
221
|
+
case "claude":
|
|
222
|
+
return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
|
|
223
|
+
case "hub":
|
|
224
|
+
return [{ key: "↑↓", label: "choose" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
|
|
225
|
+
case "roots":
|
|
226
|
+
return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
|
|
227
|
+
case "review":
|
|
228
|
+
return [{ key: "enter", label: "apply" }, { key: "esc", label: "back" }, quit];
|
|
229
|
+
case "apply":
|
|
230
|
+
return [quit];
|
|
231
|
+
case "done":
|
|
232
|
+
return [{ key: "enter", label: "open dashboard" }, quit];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { DashboardScreen } from "./dashboard-screen.js";
|
|
|
6
6
|
import { TasksScreen } from "./tasks-screen.js";
|
|
7
7
|
import { CatalogScreen } from "./catalog-screen.js";
|
|
8
8
|
import { SyncScreen } from "./sync-screen.js";
|
|
9
|
+
import { SetupWizard } from "./setup/wizard-screen.js";
|
|
9
10
|
/**
|
|
10
11
|
* The app shell: owns navigation state (`domain/nav-model.ts`) and renders
|
|
11
12
|
* whichever screen is active, each already wrapped in the shared
|
|
@@ -24,9 +25,10 @@ import { SyncScreen } from "./sync-screen.js";
|
|
|
24
25
|
* filter — this hook only predicts whether that will happen, to decide
|
|
25
26
|
* whether it should also move focus.
|
|
26
27
|
*/
|
|
27
|
-
export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions }) {
|
|
28
|
+
export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions, wizard, startInWizard }) {
|
|
28
29
|
const { exit } = useApp();
|
|
29
30
|
const [nav, setNav] = useState(INITIAL_NAV_STATE);
|
|
31
|
+
const [showWizard, setShowWizard] = useState(startInWizard === true && wizard !== undefined);
|
|
30
32
|
useInput((input, key) => {
|
|
31
33
|
if (input === "q") {
|
|
32
34
|
exit();
|
|
@@ -59,7 +61,14 @@ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashb
|
|
|
59
61
|
}
|
|
60
62
|
return state;
|
|
61
63
|
});
|
|
62
|
-
}
|
|
64
|
+
},
|
|
65
|
+
// While the wizard is active, it owns every keystroke itself (including
|
|
66
|
+
// `q`, gated there by whether a text field currently has focus) — this
|
|
67
|
+
// app-wide handler must stay out of the way entirely.
|
|
68
|
+
{ isActive: !showWizard });
|
|
63
69
|
const focused = nav.focus === "main";
|
|
70
|
+
if (showWizard && wizard) {
|
|
71
|
+
return _jsx(SetupWizard, { facts: wizard.facts, actions: wizard.actions, onDone: () => setShowWizard(false), onQuit: () => exit(), version: version });
|
|
72
|
+
}
|
|
64
73
|
return (_jsxs(_Fragment, { children: [nav.screen === "dashboard" && (_jsx(DashboardScreen, { load: loadToday, actions: dashboardActions, roots: roots, version: version, focused: focused, onOpenProject: (project) => setNav((state) => openProjectInTasks(state, project)) })), nav.screen === "tasks" && (_jsx(TasksScreen, { load: loadTasks, roots: roots, version: version, focused: focused, ...(nav.projectFilter !== undefined ? { projectFilter: nav.projectFilter } : {}), onClearFilter: () => setNav((state) => clearProjectFilter(state)) })), nav.screen === "catalog" && _jsx(CatalogScreen, { ...catalog, roots: roots, version: version, focused: focused }), nav.screen === "sync" && _jsx(SyncScreen, { ...sync, roots: roots, version: version, focused: focused })] }));
|
|
65
74
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
/**
|
|
5
|
+
* A checkbox list: `[x] label` checked, `[ ] label` unchecked, `[-] label
|
|
6
|
+
* (note)` disabled. Space toggles the item at `cursor` (a no-op when
|
|
7
|
+
* disabled); ↑/↓ call `onMove(-1|1)` — clamping is the caller's job, same
|
|
8
|
+
* as `Table`'s `selectedIndex`. The cursor row gets a visible `› ` marker,
|
|
9
|
+
* never colour alone.
|
|
10
|
+
*/
|
|
11
|
+
export function Checklist({ items, cursor, onToggle, onMove, focused }) {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (key.upArrow) {
|
|
15
|
+
onMove(-1);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (key.downArrow) {
|
|
19
|
+
onMove(1);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (input === " ") {
|
|
23
|
+
const item = items[cursor];
|
|
24
|
+
if (item && !item.disabled)
|
|
25
|
+
onToggle(item.id);
|
|
26
|
+
}
|
|
27
|
+
}, { isActive: focused });
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, index) => {
|
|
29
|
+
const marker = item.disabled ? "[-]" : item.checked ? "[x]" : "[ ]";
|
|
30
|
+
const note = item.disabled && item.note ? ` (${item.note})` : "";
|
|
31
|
+
const cursorMark = index === cursor ? "› " : " ";
|
|
32
|
+
return (_jsx(Text, { color: item.disabled ? theme.dim : theme.text, children: `${cursorMark}${marker} ${item.label}${note}` }, item.id));
|
|
33
|
+
}) }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { useTheme } from "../theme.js";
|
|
4
|
+
/**
|
|
5
|
+
* A single-select radio list: `(•) label` for the selected option, `( )
|
|
6
|
+
* label` otherwise. ↑/↓ select the previous/next option directly (clamped
|
|
7
|
+
* at the ends). The currently highlighted row also gets a visible `› `
|
|
8
|
+
* marker, matching `Checklist`'s and `Table`'s own convention.
|
|
9
|
+
*/
|
|
10
|
+
export function Radio({ options, value, onChange, focused }) {
|
|
11
|
+
const theme = useTheme();
|
|
12
|
+
const index = options.findIndex((option) => option.value === value);
|
|
13
|
+
useInput((_input, key) => {
|
|
14
|
+
if (key.upArrow) {
|
|
15
|
+
const targetIndex = Math.max(index - 1, 0);
|
|
16
|
+
const previous = options[targetIndex];
|
|
17
|
+
if (previous && targetIndex !== index)
|
|
18
|
+
onChange(previous.value);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (key.downArrow) {
|
|
22
|
+
const targetIndex = Math.min(index + 1, options.length - 1);
|
|
23
|
+
const next = options[targetIndex];
|
|
24
|
+
if (next && targetIndex !== index)
|
|
25
|
+
onChange(next.value);
|
|
26
|
+
}
|
|
27
|
+
}, { isActive: focused });
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", children: options.map((option, i) => (_jsx(Text, { color: theme.text, children: `${i === index ? "› " : " "}${option.value === value ? "(•)" : "( )"} ${option.label}` }, option.value))) }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Text, useInput } from "ink";
|
|
4
|
+
import { useTheme } from "../theme.js";
|
|
5
|
+
/**
|
|
6
|
+
* A single-line, fully controlled text input: printable characters insert
|
|
7
|
+
* at the cursor, Backspace/Delete remove the character before/after it,
|
|
8
|
+
* ←/→ and Home/End move it, Enter calls `onSubmit`. Only active while
|
|
9
|
+
* `focused` (`useInput`'s own `isActive`, matching every other kankaku-tui
|
|
10
|
+
* component). The cursor is a visible `▏` marker, shown only while
|
|
11
|
+
* focused — never colour alone (see AGENTS.md's ink-testing-library note).
|
|
12
|
+
*/
|
|
13
|
+
export function TextInput({ value, onChange, onSubmit, placeholder = "", masked = false, focused }) {
|
|
14
|
+
const theme = useTheme();
|
|
15
|
+
const [cursor, setCursor] = useState(value.length);
|
|
16
|
+
const position = Math.min(Math.max(cursor, 0), value.length);
|
|
17
|
+
useInput((input, key) => {
|
|
18
|
+
if (key.return) {
|
|
19
|
+
onSubmit?.(value);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (key.leftArrow) {
|
|
23
|
+
setCursor(Math.max(position - 1, 0));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.rightArrow) {
|
|
27
|
+
setCursor(Math.min(position + 1, value.length));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (key.home) {
|
|
31
|
+
setCursor(0);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (key.end) {
|
|
35
|
+
setCursor(value.length);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.backspace) {
|
|
39
|
+
if (position === 0)
|
|
40
|
+
return;
|
|
41
|
+
onChange(value.slice(0, position - 1) + value.slice(position));
|
|
42
|
+
setCursor(position - 1);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (key.delete) {
|
|
46
|
+
if (position >= value.length)
|
|
47
|
+
return;
|
|
48
|
+
onChange(value.slice(0, position) + value.slice(position + 1));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.tab || key.escape || key.ctrl || key.meta)
|
|
52
|
+
return;
|
|
53
|
+
if (!input)
|
|
54
|
+
return;
|
|
55
|
+
onChange(value.slice(0, position) + input + value.slice(position));
|
|
56
|
+
setCursor(position + input.length);
|
|
57
|
+
}, { isActive: focused });
|
|
58
|
+
const cursorMark = focused ? "▏" : "";
|
|
59
|
+
if (value.length === 0) {
|
|
60
|
+
return (_jsxs(Text, { children: [cursorMark, _jsx(Text, { color: theme.dim, children: placeholder })] }));
|
|
61
|
+
}
|
|
62
|
+
const displayed = masked ? "•".repeat(value.length) : value;
|
|
63
|
+
return (_jsxs(Text, { children: [displayed.slice(0, position), cursorMark, displayed.slice(position)] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { applyResult, back, createWizardState, hintsForStep, next, planFromWizard, setClaudeCheckout, setHubField, setHubHealth, setHubLocalManual, setHubMode, setLocalCheckout, setRoots, toggleAgent, } from "../../domain/setup-wizard.js";
|
|
5
|
+
import { Layout } from "../layout.js";
|
|
6
|
+
import { Panel } from "../components/panel.js";
|
|
7
|
+
import { Table } from "../components/table.js";
|
|
8
|
+
import { Checklist } from "../components/checklist.js";
|
|
9
|
+
import { Radio } from "../components/radio.js";
|
|
10
|
+
import { TextInput } from "../components/text-input.js";
|
|
11
|
+
import { useTheme } from "../theme.js";
|
|
12
|
+
/** The seven steps shown in the sidebar; `apply` has no entry of its own (it is the Review step's own execution, not a step the user navigates to). */
|
|
13
|
+
const SIDEBAR_STEPS = ["detect", "agents", "claude", "hub", "roots", "review", "done"];
|
|
14
|
+
const STEP_TITLES = {
|
|
15
|
+
detect: "Detect",
|
|
16
|
+
agents: "Agents",
|
|
17
|
+
claude: "Claude",
|
|
18
|
+
hub: "Hub",
|
|
19
|
+
roots: "Roots",
|
|
20
|
+
review: "Review",
|
|
21
|
+
apply: "Apply",
|
|
22
|
+
done: "Done",
|
|
23
|
+
};
|
|
24
|
+
const AGENT_TITLES = {
|
|
25
|
+
pi: "pi",
|
|
26
|
+
"gentle-shell": "gentle-shell",
|
|
27
|
+
"claude-code": "Claude Code",
|
|
28
|
+
codex: "Codex",
|
|
29
|
+
opencode: "OpenCode",
|
|
30
|
+
};
|
|
31
|
+
/** The sidebar's own active id: `apply` has no sidebar row, so it maps to `review` (the step it is executing). */
|
|
32
|
+
function sidebarActiveId(step) {
|
|
33
|
+
return step === "apply" ? "review" : step;
|
|
34
|
+
}
|
|
35
|
+
function wizardSidebarItems(step) {
|
|
36
|
+
const activeIndex = SIDEBAR_STEPS.indexOf(sidebarActiveId(step));
|
|
37
|
+
return SIDEBAR_STEPS.map((entry, index) => ({
|
|
38
|
+
id: entry,
|
|
39
|
+
key: String(index + 1),
|
|
40
|
+
label: index < activeIndex ? `✓ ${STEP_TITLES[entry]}` : STEP_TITLES[entry],
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
function agentStateLabel(agent) {
|
|
44
|
+
if (!agent.adapterAvailable)
|
|
45
|
+
return "no adapter";
|
|
46
|
+
if (!agent.present)
|
|
47
|
+
return "not found";
|
|
48
|
+
return agent.configured ? "configured" : "present";
|
|
49
|
+
}
|
|
50
|
+
const DETECT_FIXED_COLUMNS_WIDTH = 14 + 1 + 12 + 1 + 2 + 2;
|
|
51
|
+
function detectColumns(panelWidth) {
|
|
52
|
+
const detailWidth = Math.max(panelWidth - DETECT_FIXED_COLUMNS_WIDTH, 10);
|
|
53
|
+
return [
|
|
54
|
+
{ key: "agent", header: "agent", width: 14 },
|
|
55
|
+
{ key: "state", header: "state", width: 12 },
|
|
56
|
+
{ key: "detail", header: "detail", width: detailWidth },
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
function detectCell(row, key) {
|
|
60
|
+
switch (key) {
|
|
61
|
+
case "agent":
|
|
62
|
+
return AGENT_TITLES[row.id];
|
|
63
|
+
case "state":
|
|
64
|
+
return agentStateLabel(row);
|
|
65
|
+
case "detail":
|
|
66
|
+
return row.detail;
|
|
67
|
+
default:
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const REVIEW_FIXED_COLUMNS_WIDTH = 30 + 1 + 2 + 2;
|
|
72
|
+
function reviewColumns(panelWidth) {
|
|
73
|
+
const labelWidth = Math.max(panelWidth - REVIEW_FIXED_COLUMNS_WIDTH, 20);
|
|
74
|
+
return [
|
|
75
|
+
{ key: "label", header: "action", width: labelWidth },
|
|
76
|
+
{ key: "file", header: "file", width: 30 },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
function reviewCell(row, key) {
|
|
80
|
+
return key === "file" ? row.file : row.label;
|
|
81
|
+
}
|
|
82
|
+
function applyResultLine(result) {
|
|
83
|
+
if (result.outcome === "error")
|
|
84
|
+
return `error: ${result.detail ?? result.action.label}`;
|
|
85
|
+
const detail = result.detail ? ` (${result.detail})` : "";
|
|
86
|
+
return `${result.outcome} ${result.action.file}${detail}`;
|
|
87
|
+
}
|
|
88
|
+
function agentChecklistItems(agents, selected) {
|
|
89
|
+
return agents.map((agent) => {
|
|
90
|
+
const disabled = agent.id === "codex" || agent.id === "opencode";
|
|
91
|
+
return {
|
|
92
|
+
id: agent.id,
|
|
93
|
+
label: AGENT_TITLES[agent.id],
|
|
94
|
+
checked: selected[agent.id] === true,
|
|
95
|
+
disabled,
|
|
96
|
+
note: disabled ? "no adapter yet" : undefined,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const HUB_MODE_OPTIONS = [
|
|
101
|
+
{ value: "existing", label: "use an existing hub" },
|
|
102
|
+
{ value: "local", label: "install locally" },
|
|
103
|
+
{ value: "skip", label: "skip" },
|
|
104
|
+
];
|
|
105
|
+
/** Mirrors `adapters/setup/local-hub.ts`'s own `HUB_URL`: where a manually-started local hub is expected to answer. */
|
|
106
|
+
const LOCAL_HUB_URL = "http://127.0.0.1:8090";
|
|
107
|
+
function healthLine(healthOk, checking) {
|
|
108
|
+
if (checking)
|
|
109
|
+
return "checking…";
|
|
110
|
+
if (healthOk === undefined)
|
|
111
|
+
return "";
|
|
112
|
+
return healthOk ? "health ok" : "health failed";
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `kankaku setup`'s interactive wizard: one full-screen step at a time
|
|
116
|
+
* inside the shared `Layout` frame, driven by `domain/setup-wizard.ts`'s
|
|
117
|
+
* pure reducers. Enter advances (validating first), Esc goes back (quits
|
|
118
|
+
* from Detect, via `onQuit`); `q` quits from anywhere a text field isn't
|
|
119
|
+
* currently capturing keystrokes.
|
|
120
|
+
*/
|
|
121
|
+
export function SetupWizard({ facts, actions, onDone, onQuit, version, columns, rows }) {
|
|
122
|
+
const theme = useTheme();
|
|
123
|
+
const [state, setState] = useState(() => createWizardState(facts));
|
|
124
|
+
const [agentCursor, setAgentCursor] = useState(0);
|
|
125
|
+
const [hubFocus, setHubFocus] = useState(0);
|
|
126
|
+
const [healthChecking, setHealthChecking] = useState(false);
|
|
127
|
+
const [rootsText, setRootsText] = useState(() => state.roots.join(", "));
|
|
128
|
+
const applyStartedRef = useRef(false);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (state.step === "hub")
|
|
131
|
+
setHubFocus(0);
|
|
132
|
+
}, [state.step]);
|
|
133
|
+
const foundCheckout = state.hub.mode === "local" ? actions.findHubCheckout() : undefined;
|
|
134
|
+
const hasCheckoutField = state.hub.mode === "local" && (state.hub.localCheckout.trim() !== "" || foundCheckout !== undefined);
|
|
135
|
+
const checkoutValue = state.hub.localCheckout.trim() !== "" ? state.hub.localCheckout : (foundCheckout ?? "");
|
|
136
|
+
const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" && hasCheckoutField ? 2 : 1;
|
|
137
|
+
const textInputFocused = state.step === "claude" || state.step === "roots" || (state.step === "hub" && hubFocus >= 1 && hubFocus < hubFieldCount);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (state.step !== "apply" || applyStartedRef.current)
|
|
140
|
+
return;
|
|
141
|
+
applyStartedRef.current = true;
|
|
142
|
+
const snapshot = state;
|
|
143
|
+
void (async () => {
|
|
144
|
+
for (const action of snapshot.plan) {
|
|
145
|
+
const result = await actions.apply(action, snapshot);
|
|
146
|
+
setState((s) => applyResult(s, result));
|
|
147
|
+
}
|
|
148
|
+
})();
|
|
149
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
|
+
}, [state.step]);
|
|
151
|
+
useInput((input, key) => {
|
|
152
|
+
if (key.tab && state.step === "hub") {
|
|
153
|
+
setHubFocus((current) => (current + 1) % hubFieldCount);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (key.return) {
|
|
157
|
+
if (state.step === "done") {
|
|
158
|
+
onDone();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
setState((s) => next(s, facts));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (key.escape) {
|
|
165
|
+
if (state.step === "detect") {
|
|
166
|
+
onQuit();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setState((s) => back(s));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!textInputFocused && input === "q") {
|
|
173
|
+
onQuit();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (state.step === "hub" && !textInputFocused) {
|
|
177
|
+
if (input === "c") {
|
|
178
|
+
const url = state.hub.mode === "existing" ? state.hub.url : LOCAL_HUB_URL;
|
|
179
|
+
setHealthChecking(true);
|
|
180
|
+
void actions.checkHealth(url).then((ok) => {
|
|
181
|
+
setState((s) => setHubHealth(s, ok));
|
|
182
|
+
setHealthChecking(false);
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (input === "m" && state.hub.mode === "local" && !hasCheckoutField) {
|
|
187
|
+
setState((s) => setHubLocalManual(s, true));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const stepTitle = STEP_TITLES[state.step];
|
|
193
|
+
const hints = hintsForStep(state.step);
|
|
194
|
+
return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku setup${version ? ` ${version}` : ""}`, sidebarItems: wizardSidebarItems(state.step), activeId: sidebarActiveId(state.step), sidebarStats: [], keyHints: hints, focus: "main", children: ({ mainWidth, mainHeight }) => (_jsxs(Panel, { title: `Setup · ${stepTitle}`, width: mainWidth, height: mainHeight, active: true, children: [state.step === "detect" && (_jsx(Table, { columns: detectColumns(mainWidth), rows: state.agents, rowKey: (row) => row.id, cell: detectCell, emptyText: "no agents detected" })), state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected), 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" && hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "local checkout path:" }), _jsx(TextInput, { value: checkoutValue, onChange: (value) => setState((s) => setLocalCheckout(s, value)), focused: hubFocus === 1 })] })), state.hub.mode === "local" && !hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no kankaku-hub checkout found; run these commands, then check again:" }), actions.manualCommands(state.hub.localCheckout || undefined).map((line) => (_jsx(Text, { children: ` ${line}` }, line))), _jsx(Text, { children: `[ check again ] c ${healthLine(state.hub.healthOk, healthChecking)}` }), _jsx(Text, { children: `[ m ] acknowledge manual install${state.hub.localManual ? " (acknowledged)" : ""}` }), state.errors.localCheckout && _jsx(Text, { color: theme.error, children: state.errors.localCheckout })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
|
|
195
|
+
setRootsText(value);
|
|
196
|
+
setState((s) => setRoots(s, value));
|
|
197
|
+
}, 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" })] }))] })) }));
|
|
198
|
+
}
|