@hicoders/devkit 1.0.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.
@@ -0,0 +1,434 @@
1
+ // launch core — a config-driven project launcher. Discovers runnable projects
2
+ // and starts a chosen project's commands together.
3
+ //
4
+ // Pure logic (filesystem scan, config persistence, process spawning), no
5
+ // terminal UI. The TUI (pkg/tui/launch.tsx) renders a picker on top of these
6
+ // functions and calls startCommands() once commands are chosen.
7
+ //
8
+ // Projects come from two sources, both persisted in ~/.devkit.json:
9
+ // - SCAN ROOTS: folders auto-scanned every run; each subfolder with a runnable
10
+ // manifest becomes a project (e.g. a directory that holds all your apps).
11
+ // Live — new subprojects appear without any manual step.
12
+ // - MANUAL PROJECTS: explicitly added (via auto-detect or by hand), each with
13
+ // a name and a set of commands.
14
+ //
15
+ // Each project owns commands; one or more may be flagged `isDefault` (Enter
16
+ // starts all defaults together — e.g. backend + frontend). The UI's `a` key
17
+ // lets the user run any ad-hoc subset for a single run.
18
+
19
+ import { readdirSync, statSync } from "node:fs";
20
+ import { join, basename } from "node:path";
21
+ import { spawn, type ChildProcess } from "node:child_process";
22
+ import { loadConfig, saveConfig } from "./config";
23
+ import { detectCommandsInDir, manifestName } from "./manifest";
24
+
25
+ export interface Command {
26
+ id: string; // stable within a project
27
+ label: string; // e.g. "dev", "frontend:dev", "run"
28
+ command: string; // full shell command, e.g. "bun run dev", "go run ."
29
+ cwd: string; // absolute working directory
30
+ isDefault?: boolean; // run on Enter (one or more may be default)
31
+ }
32
+
33
+ export interface Project {
34
+ id: string;
35
+ name: string;
36
+ commands: Command[];
37
+ /** Runtime-only origin: "manual" or the scan-root path it was discovered in. */
38
+ source?: string;
39
+ /** Runtime-only: whether the project is pinned (see LaunchConfig.pinned). */
40
+ pinned?: boolean;
41
+ }
42
+
43
+ export interface ScanRoot {
44
+ path: string;
45
+ exclude?: string[];
46
+ }
47
+
48
+ export interface LaunchConfig {
49
+ projects: Project[];
50
+ scanRoots: ScanRoot[];
51
+ /** Ids of pinned (favorite) projects — floated to a ★ PINNED section on top. */
52
+ pinned?: string[];
53
+ /** Custom project order (ids); used as the sort key in "manual" sort mode. */
54
+ order?: string[];
55
+ /** How the list is sorted within each section. */
56
+ sortMode?: SortMode;
57
+ /** Per-project id → command labels last launched, so a run can be repeated. */
58
+ lastRun?: Record<string, string[]>;
59
+ /** Per-project id → epoch ms of the last launch (for "recent" sort mode). */
60
+ lastRunAt?: Record<string, number>;
61
+ }
62
+
63
+ /** "manual" = user's custom order (alphabetical until reordered); "recent" =
64
+ * most-recently-launched first. */
65
+ export type SortMode = "manual" | "recent";
66
+
67
+ // ---- config persistence ----
68
+ //
69
+ // All projects and scan roots live in ~/.devkit.json (under the `launch` key) —
70
+ // nothing is hard-coded here. A fresh install starts empty; the user configures
71
+ // scan roots and projects from the UI (which persists to that JSON file) or by
72
+ // editing the file directly.
73
+
74
+ function emptyConfig(): LaunchConfig {
75
+ return {
76
+ projects: [],
77
+ scanRoots: [],
78
+ pinned: [],
79
+ order: [],
80
+ sortMode: "manual",
81
+ lastRun: {},
82
+ lastRunAt: {},
83
+ };
84
+ }
85
+
86
+ export function loadLaunch(): LaunchConfig {
87
+ const c = loadConfig().launch;
88
+ if (!c) return emptyConfig();
89
+ return {
90
+ projects: c.projects ?? [],
91
+ scanRoots: c.scanRoots ?? [],
92
+ pinned: c.pinned ?? [],
93
+ order: c.order ?? [],
94
+ sortMode: c.sortMode ?? "manual",
95
+ lastRun: c.lastRun ?? {},
96
+ lastRunAt: c.lastRunAt ?? {},
97
+ };
98
+ }
99
+
100
+ export function saveLaunch(cfg: LaunchConfig): void {
101
+ saveConfig({ launch: cfg });
102
+ }
103
+
104
+ export function addProject(p: Project): void {
105
+ const cfg = loadLaunch();
106
+ cfg.projects.push(stripSource(p));
107
+ saveLaunch(cfg);
108
+ }
109
+
110
+ export function updateProject(p: Project): void {
111
+ const cfg = loadLaunch();
112
+ cfg.projects = cfg.projects.map((x) => (x.id === p.id ? stripSource(p) : x));
113
+ saveLaunch(cfg);
114
+ }
115
+
116
+ export function removeProject(id: string): void {
117
+ const cfg = loadLaunch();
118
+ cfg.projects = cfg.projects.filter((x) => x.id !== id);
119
+ saveLaunch(cfg);
120
+ }
121
+
122
+ export function addScanRoot(root: ScanRoot): void {
123
+ const cfg = loadLaunch();
124
+ if (!cfg.scanRoots.some((r) => r.path === root.path)) cfg.scanRoots.push(root);
125
+ saveLaunch(cfg);
126
+ }
127
+
128
+ export function removeScanRoot(path: string): void {
129
+ const cfg = loadLaunch();
130
+ cfg.scanRoots = cfg.scanRoots.filter((r) => r.path !== path);
131
+ saveLaunch(cfg);
132
+ }
133
+
134
+ /** The folder a scanned project was discovered in, or null for a manual one. */
135
+ export function scanFolder(p: Project): string | null {
136
+ return p.id.startsWith("scan:") ? basename(p.id.slice("scan:".length)) : null;
137
+ }
138
+
139
+ /** Stop a scan root from re-discovering `folder` (its immediate subfolder). */
140
+ export function excludeFromScan(rootPath: string, folder: string): void {
141
+ const cfg = loadLaunch();
142
+ const root = cfg.scanRoots.find((r) => r.path === rootPath);
143
+ if (!root) return;
144
+ root.exclude = root.exclude ?? [];
145
+ if (!root.exclude.includes(folder)) root.exclude.push(folder);
146
+ saveLaunch(cfg);
147
+ }
148
+
149
+ /** Un-hide: drop `folder` from a scan root's exclude list so it's scanned again. */
150
+ export function includeInScan(rootPath: string, folder: string): void {
151
+ const cfg = loadLaunch();
152
+ const root = cfg.scanRoots.find((r) => r.path === rootPath);
153
+ if (!root?.exclude) return;
154
+ root.exclude = root.exclude.filter((f) => f !== folder);
155
+ saveLaunch(cfg);
156
+ }
157
+
158
+ /**
159
+ * Turn a discovered (scanned) project into a persisted manual one so it can be
160
+ * edited: copy it into `projects` with a fresh id and exclude its original
161
+ * folder from the scan root so it isn't listed twice. Returns the manual copy.
162
+ */
163
+ export function adoptProject(p: Project): Project {
164
+ const manual: Project = {
165
+ id: newId(),
166
+ name: p.name,
167
+ commands: p.commands.map((c) => ({ ...c })),
168
+ };
169
+ const cfg = loadLaunch();
170
+ cfg.projects.push(manual);
171
+ const folder = scanFolder(p);
172
+ if (p.source && folder) {
173
+ const root = cfg.scanRoots.find((r) => r.path === p.source);
174
+ if (root) {
175
+ root.exclude = root.exclude ?? [];
176
+ if (!root.exclude.includes(folder)) root.exclude.push(folder);
177
+ }
178
+ }
179
+ saveLaunch(cfg);
180
+ return manual;
181
+ }
182
+
183
+ /** Pin or unpin a project (toggles its id in the persisted `pinned` list). */
184
+ export function togglePin(id: string): void {
185
+ const cfg = loadLaunch();
186
+ const pinned = new Set(cfg.pinned ?? []);
187
+ pinned.has(id) ? pinned.delete(id) : pinned.add(id);
188
+ cfg.pinned = [...pinned];
189
+ saveLaunch(cfg);
190
+ }
191
+
192
+ /** Which display section a project belongs to (used to constrain reordering). */
193
+ function sectionKey(p: Project): string {
194
+ return p.pinned ? "pinned" : (p.source ?? "manual");
195
+ }
196
+
197
+ /** Persist the list sort mode. */
198
+ export function setSortMode(mode: SortMode): void {
199
+ const cfg = loadLaunch();
200
+ cfg.sortMode = mode;
201
+ saveLaunch(cfg);
202
+ }
203
+
204
+ /**
205
+ * Move a project one step up (dir -1) or down (dir +1) within its own section,
206
+ * persisting the new order. Stops at section boundaries (won't jump a project
207
+ * out of PINNED / PROJECTS / a scan group). Takes the current display order
208
+ * (`ordered`, from orderProjects) so it never re-scans the filesystem; the saved
209
+ * `order` is that sequence with the swap applied, which self-prunes stale ids.
210
+ */
211
+ export function moveProject(id: string, dir: -1 | 1, ordered: Project[]): void {
212
+ const idx = ordered.findIndex((p) => p.id === id);
213
+ if (idx < 0) return;
214
+ const j = idx + dir;
215
+ if (j < 0 || j >= ordered.length) return;
216
+ if (sectionKey(ordered[idx]!) !== sectionKey(ordered[j]!)) return; // don't cross sections
217
+ const next = [...ordered];
218
+ [next[idx], next[j]] = [next[j]!, next[idx]!];
219
+ const cfg = loadLaunch();
220
+ cfg.order = next.map((p) => p.id);
221
+ saveLaunch(cfg);
222
+ }
223
+
224
+ /** Record the command labels + time last launched for a project (repeat/recent). */
225
+ export function recordLastRun(id: string, labels: string[]): void {
226
+ const cfg = loadLaunch();
227
+ cfg.lastRun = { ...(cfg.lastRun ?? {}), [id]: labels };
228
+ cfg.lastRunAt = { ...(cfg.lastRunAt ?? {}), [id]: Date.now() };
229
+ saveLaunch(cfg);
230
+ }
231
+
232
+ /** Command labels last launched for a project, or [] if never run. */
233
+ export function lastRunLabels(id: string, cfg: LaunchConfig = loadLaunch()): string[] {
234
+ return cfg.lastRun?.[id] ?? [];
235
+ }
236
+
237
+ // Never persist the runtime-only `source` / `pinned` markers on a project.
238
+ function stripSource(p: Project): Project {
239
+ const { source, pinned, ...rest } = p;
240
+ void source;
241
+ void pinned;
242
+ return rest;
243
+ }
244
+
245
+ // ---- ids + command construction ----
246
+
247
+ export function newId(): string {
248
+ return crypto.randomUUID();
249
+ }
250
+
251
+ export function newCommand(
252
+ label: string,
253
+ command: string,
254
+ cwd: string,
255
+ isDefault = false,
256
+ ): Command {
257
+ return { id: newId(), label, command, cwd, isDefault };
258
+ }
259
+
260
+ /** The commands Enter should start: the defaults, or all if none are flagged. */
261
+ export function defaultCommands(p: Project): Command[] {
262
+ const def = p.commands.filter((c) => c.isDefault);
263
+ return def.length ? def : p.commands;
264
+ }
265
+
266
+ // ---- detection ----
267
+
268
+ // Backend services start before frontends when both are present.
269
+ const rank = (label: string) => (/server|service|backend|api/i.test(label) ? 0 : 1);
270
+
271
+ function subPackageCommands(dir: string): Command[] {
272
+ const out: Command[] = [];
273
+ let subs: string[] = [];
274
+ try {
275
+ subs = readdirSync(dir);
276
+ } catch {
277
+ return out;
278
+ }
279
+ for (const sub of subs) {
280
+ if (sub === "node_modules") continue;
281
+ const subDir = join(dir, sub);
282
+ try {
283
+ if (!statSync(subDir).isDirectory()) continue;
284
+ } catch {
285
+ continue;
286
+ }
287
+ for (const c of detectCommandsInDir(subDir)) {
288
+ out.push(newCommand(`${sub}:${c.label}`, c.command, c.cwd));
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+
294
+ /**
295
+ * All candidate commands for a folder the user points the auto-add flow at: the
296
+ * folder's own manifest commands plus one level of sub-packages (labels
297
+ * namespaced as `<sub>:<label>`). Backend-before-frontend ordered.
298
+ */
299
+ export function detectProjectCommands(dir: string): Command[] {
300
+ const own = detectCommandsInDir(dir).map((c) => newCommand(c.label, c.command, c.cwd));
301
+ const cmds = own.length ? own : subPackageCommands(dir);
302
+ cmds.sort((a, b) => rank(a.label) - rank(b.label));
303
+ return cmds;
304
+ }
305
+
306
+ /** A sensible default name for a folder (manifest name, else the folder name). */
307
+ export function suggestName(dir: string): string {
308
+ return manifestName(dir) ?? basename(dir.replace(/[\\/]+$/, "")) ?? dir;
309
+ }
310
+
311
+ // Per working directory, flag the dev (else start, else first) command default,
312
+ // so Enter mirrors the old behavior of starting each package's dev script.
313
+ function markDefaults(cmds: Command[]): void {
314
+ const byCwd = new Map<string, Command[]>();
315
+ for (const c of cmds) {
316
+ const arr = byCwd.get(c.cwd);
317
+ if (arr) arr.push(c);
318
+ else byCwd.set(c.cwd, [c]);
319
+ }
320
+ const base = (c: Command) => c.label.split(":").pop()!;
321
+ for (const group of byCwd.values()) {
322
+ const pick =
323
+ group.find((c) => base(c) === "dev") ?? group.find((c) => base(c) === "start") ?? group[0];
324
+ if (pick) pick.isDefault = true;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Scan one root: every immediate subfolder that has runnable commands (directly,
330
+ * or via its own sub-packages) becomes a project. Mirrors the old egp discover()
331
+ * Case 1 / Case 2 logic, generalized to every manifest type.
332
+ */
333
+ export function scanRoot(root: ScanRoot): Project[] {
334
+ const exclude = new Set([...(root.exclude ?? []), "node_modules"]);
335
+ const projects: Project[] = [];
336
+ let entries: string[] = [];
337
+ try {
338
+ entries = readdirSync(root.path);
339
+ } catch {
340
+ return projects;
341
+ }
342
+ for (const entry of entries) {
343
+ if (exclude.has(entry)) continue;
344
+ const dir = join(root.path, entry);
345
+ try {
346
+ if (!statSync(dir).isDirectory()) continue;
347
+ } catch {
348
+ continue;
349
+ }
350
+
351
+ const own = detectCommandsInDir(dir).map((c) => newCommand(c.label, c.command, c.cwd));
352
+ const cmds = own.length ? own : subPackageCommands(dir);
353
+ if (!cmds.length) continue;
354
+
355
+ cmds.sort((a, b) => rank(a.label) - rank(b.label));
356
+ markDefaults(cmds);
357
+ projects.push({ id: `scan:${dir}`, name: entry, commands: cmds, source: root.path });
358
+ }
359
+ projects.sort((a, b) => a.name.localeCompare(b.name));
360
+ return projects;
361
+ }
362
+
363
+ /**
364
+ * EXPENSIVE: read the filesystem. Returns the raw set of projects — manual ones
365
+ * plus everything discovered under the scan roots, each tagged with `source` but
366
+ * NOT yet pinned/sorted. Call this only on load / rescan, then feed the result
367
+ * to orderProjects() (cheap) for display; that keeps pin/reorder/sort instant.
368
+ */
369
+ export function scanProjects(cfg: LaunchConfig = loadLaunch()): Project[] {
370
+ const manual = cfg.projects.map((p) => ({ ...p, source: "manual" as const }));
371
+ const scanned = cfg.scanRoots.flatMap((r) => scanRoot(r));
372
+ return [...manual, ...scanned];
373
+ }
374
+
375
+ /**
376
+ * CHEAP: pure ordering of an already-scanned project list. Pinned projects come
377
+ * first (a ★ PINNED section), then manual projects, then each scan root's
378
+ * projects — contiguous so the UI can draw section headers. A pinned project
379
+ * appears only in the pinned group. Within every group the order follows
380
+ * `cfg.sortMode`: "manual" = custom `order` then alphabetical; "recent" = most
381
+ * recently launched first.
382
+ */
383
+ export function orderProjects(projects: Project[], cfg: LaunchConfig = loadLaunch()): Project[] {
384
+ const order = cfg.order ?? [];
385
+ const at = cfg.lastRunAt ?? {};
386
+ const rankOf = (id: string) => {
387
+ const i = order.indexOf(id);
388
+ return i === -1 ? Infinity : i;
389
+ };
390
+ const cmp = (a: Project, b: Project) =>
391
+ cfg.sortMode === "recent"
392
+ ? (at[b.id] ?? 0) - (at[a.id] ?? 0) || a.name.localeCompare(b.name)
393
+ : rankOf(a.id) - rankOf(b.id) || a.name.localeCompare(b.name);
394
+
395
+ const all = projects.map((p) => ({ ...p }));
396
+ const pins = new Set(cfg.pinned ?? []);
397
+ for (const p of all) p.pinned = pins.has(p.id);
398
+
399
+ const pinned = all.filter((p) => p.pinned).sort(cmp);
400
+ const manualRest = all.filter((p) => !p.pinned && p.source === "manual").sort(cmp);
401
+ const scannedRest = cfg.scanRoots.flatMap((r) =>
402
+ all.filter((p) => !p.pinned && p.source === r.path).sort(cmp),
403
+ );
404
+ return [...pinned, ...manualRest, ...scannedRest];
405
+ }
406
+
407
+ /** Convenience: scan + order in one call (used by the CLI path). */
408
+ export function allProjects(cfg: LaunchConfig = loadLaunch()): Project[] {
409
+ return orderProjects(scanProjects(cfg), cfg);
410
+ }
411
+
412
+ export function findProject(name: string): Project | null {
413
+ const q = name.toLowerCase();
414
+ return allProjects().find((p) => p.name.toLowerCase() === q) ?? null;
415
+ }
416
+
417
+ // ---- running ----
418
+
419
+ /**
420
+ * Spawn each command (full shell string) in its cwd, inheriting stdio so logs
421
+ * stream to the terminal. Wires SIGINT to kill them all. The caller must tear
422
+ * down any TUI renderer first.
423
+ */
424
+ export function startCommands(cmds: Command[]): ChildProcess[] {
425
+ const children = cmds.map((c) =>
426
+ spawn(c.command, { cwd: c.cwd, stdio: "inherit", shell: true }),
427
+ );
428
+ const killAll = () => children.forEach((c) => c.kill());
429
+ process.on("SIGINT", () => {
430
+ killAll();
431
+ process.exit(0);
432
+ });
433
+ return children;
434
+ }
@@ -0,0 +1,123 @@
1
+ // manifest — read project manifests to derive a project's name and its runnable
2
+ // commands. Shared by pkg/core/appname.ts (name lookup for a running process)
3
+ // and pkg/core/launch.ts (command discovery for the launcher).
4
+ //
5
+ // Supported manifests: package.json (scripts), Cargo.toml, go.mod, pyproject.toml.
6
+ // Pure logic, no UI. Detection is synchronous (cheap file reads) so the launcher
7
+ // can scan many folders in one pass.
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { basename, join } from "node:path";
11
+
12
+ /** A runnable command detected from a folder's manifest. */
13
+ export interface DetectedCommand {
14
+ label: string; // e.g. "dev", "build", "run"
15
+ command: string; // full shell command, e.g. "bun run dev", "cargo run"
16
+ cwd: string; // the folder the command runs in
17
+ }
18
+
19
+ /** Pull `name = "..."` from a TOML `[section]` (section is a regex fragment). */
20
+ export function tomlName(toml: string, section: string): string | null {
21
+ const sec = toml.match(new RegExp(`\\[${section}\\]([\\s\\S]*?)(?:\\n\\[|$)`));
22
+ const m = sec?.[1].match(/^\s*name\s*=\s*["']([^"']+)["']/m);
23
+ return m ? m[1]! : null;
24
+ }
25
+
26
+ /** Keys of a TOML table `[section]` (the `foo` in `foo = "..."`). */
27
+ function tomlTableKeys(toml: string, section: string): string[] {
28
+ const sec = toml.match(new RegExp(`\\[${section}\\]([\\s\\S]*?)(?:\\n\\[|$)`));
29
+ if (!sec) return [];
30
+ const keys: string[] = [];
31
+ for (const line of sec[1]!.split("\n")) {
32
+ const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
33
+ if (m) keys.push(m[1]!);
34
+ }
35
+ return keys;
36
+ }
37
+
38
+ function read(path: string): string | null {
39
+ try {
40
+ return readFileSync(path, "utf8");
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Package manager for a JS project, inferred from its lockfile (default bun). */
47
+ export function detectPm(dir: string): "bun" | "pnpm" | "yarn" | "npm" {
48
+ if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) return "bun";
49
+ if (existsSync(join(dir, "pnpm-lock.yaml"))) return "pnpm";
50
+ if (existsSync(join(dir, "yarn.lock"))) return "yarn";
51
+ if (existsSync(join(dir, "package-lock.json"))) return "npm";
52
+ return "bun";
53
+ }
54
+
55
+ /** Project name from a folder's manifest, or the folder name as a fallback. */
56
+ export function manifestName(dir: string): string | null {
57
+ const pj = read(join(dir, "package.json"));
58
+ if (pj !== null) {
59
+ try {
60
+ const n = JSON.parse(pj)?.name;
61
+ if (typeof n === "string" && n) return n;
62
+ } catch {
63
+ /* malformed — fall through to folder name */
64
+ }
65
+ return basename(dir);
66
+ }
67
+ const cargo = read(join(dir, "Cargo.toml"));
68
+ if (cargo !== null) return tomlName(cargo, "package") ?? basename(dir);
69
+ const py = read(join(dir, "pyproject.toml"));
70
+ if (py !== null) return tomlName(py, "project") ?? tomlName(py, "tool\\.poetry") ?? basename(dir);
71
+ const gomod = read(join(dir, "go.mod"));
72
+ if (gomod !== null) {
73
+ const m = gomod.match(/^\s*module\s+(\S+)/m);
74
+ return m ? m[1]!.split("/").pop()! : basename(dir);
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Runnable commands found directly in `dir` (not its sub-packages):
81
+ * - package.json `scripts` → `<pm> run <name>` for each
82
+ * - Cargo.toml → `cargo run`
83
+ * - go.mod → `go run .`
84
+ * - pyproject.toml → `poetry run <name>` for each [project.scripts] /
85
+ * [tool.poetry.scripts] entry
86
+ * Returns [] when the folder has no recognized, runnable manifest.
87
+ */
88
+ export function detectCommandsInDir(dir: string): DetectedCommand[] {
89
+ const out: DetectedCommand[] = [];
90
+
91
+ const pj = read(join(dir, "package.json"));
92
+ if (pj !== null) {
93
+ try {
94
+ const scripts = JSON.parse(pj)?.scripts as Record<string, string> | undefined;
95
+ if (scripts) {
96
+ const pm = detectPm(dir);
97
+ for (const name of Object.keys(scripts)) {
98
+ out.push({ label: name, command: `${pm} run ${name}`, cwd: dir });
99
+ }
100
+ }
101
+ } catch {
102
+ /* malformed package.json — no scripts */
103
+ }
104
+ }
105
+
106
+ if (existsSync(join(dir, "Cargo.toml"))) {
107
+ out.push({ label: "run", command: "cargo run", cwd: dir });
108
+ }
109
+ if (existsSync(join(dir, "go.mod"))) {
110
+ out.push({ label: "run", command: "go run .", cwd: dir });
111
+ }
112
+
113
+ const py = read(join(dir, "pyproject.toml"));
114
+ if (py !== null) {
115
+ const keys = [
116
+ ...tomlTableKeys(py, "project\\.scripts"),
117
+ ...tomlTableKeys(py, "tool\\.poetry\\.scripts"),
118
+ ];
119
+ for (const key of keys) out.push({ label: key, command: `poetry run ${key}`, cwd: dir });
120
+ }
121
+
122
+ return out;
123
+ }
@@ -0,0 +1,33 @@
1
+ // Renderer bootstrap shared by every devkit screen.
2
+ //
3
+ // `mountScreen` mounts a React screen, hands it a `done(value)` callback, and
4
+ // resolves once the screen calls it — fully tearing the renderer down first so
5
+ // the terminal is restored (important before spawning a child tool or handing
6
+ // off to dev-server stdio). This lets the `devkit` hub run screens in a loop.
7
+
8
+ import { createCliRenderer, type CliRendererConfig } from "@opentui/core";
9
+ import { createRoot } from "@opentui/react";
10
+ import type { ReactNode } from "react";
11
+ import { enableWindowsMouse } from "./winmouse";
12
+ import { ThemeProvider } from "./theme-context";
13
+
14
+ export async function mountScreen<T>(
15
+ render: (done: (value: T) => void) => ReactNode,
16
+ config: CliRendererConfig = {},
17
+ ): Promise<T> {
18
+ // Must run before the renderer's setRawMode (works around Bun #25663 on Windows).
19
+ enableWindowsMouse();
20
+ const renderer = await createCliRenderer({ exitOnCtrlC: true, ...config });
21
+ const root = createRoot(renderer);
22
+ return new Promise<T>((resolve) => {
23
+ let settled = false;
24
+ const done = (value: T) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ root.unmount();
28
+ renderer.destroy();
29
+ resolve(value);
30
+ };
31
+ root.render(<ThemeProvider>{render(done)}</ThemeProvider>);
32
+ });
33
+ }
@@ -0,0 +1,53 @@
1
+ // Confirm — a small y/N bar. Owns its own keyboard handler; render it only when
2
+ // a decision is pending and set the underlying list to `active={false}`.
3
+
4
+ import { useKeyboard } from "@opentui/react";
5
+ import { useTheme } from "../theme-context";
6
+
7
+ export function Confirm({
8
+ message,
9
+ active = true,
10
+ onConfirm,
11
+ onCancel,
12
+ }: {
13
+ message: string;
14
+ active?: boolean;
15
+ onConfirm: () => void;
16
+ onCancel: () => void;
17
+ }) {
18
+ const theme = useTheme();
19
+ useKeyboard((key) => {
20
+ if (!active) return;
21
+ if (key.name === "y") onConfirm();
22
+ else if (key.name === "n" || key.name === "escape") onCancel();
23
+ });
24
+
25
+ return (
26
+ <box
27
+ style={{
28
+ flexDirection: "column",
29
+ border: true,
30
+ borderStyle: "rounded",
31
+ borderColor: theme.yellow,
32
+ padding: 1,
33
+ marginTop: 1,
34
+ }}
35
+ >
36
+ <text fg={theme.yellow}>{message}</text>
37
+ <box style={{ flexDirection: "row", marginTop: 1 }}>
38
+ <box
39
+ style={{ backgroundColor: theme.selBg, paddingLeft: 1, paddingRight: 1, marginRight: 2 }}
40
+ onMouseDown={() => active && onConfirm()}
41
+ >
42
+ <text fg={theme.green}>[ y · Yes ]</text>
43
+ </box>
44
+ <box
45
+ style={{ backgroundColor: theme.selBg, paddingLeft: 1, paddingRight: 1 }}
46
+ onMouseDown={() => active && onCancel()}
47
+ >
48
+ <text fg={theme.red}>[ n · No ]</text>
49
+ </box>
50
+ </box>
51
+ </box>
52
+ );
53
+ }
@@ -0,0 +1,37 @@
1
+ // Header — consistent title + hint block shown at the top of each screen.
2
+
3
+ import { useTheme } from "../theme-context";
4
+
5
+ export function Header({
6
+ title,
7
+ subtitle,
8
+ hint,
9
+ }: {
10
+ title: string;
11
+ subtitle?: string;
12
+ hint?: string;
13
+ }) {
14
+ const theme = useTheme();
15
+ // Each line is its own row <box> and the gap is a height:1 box (not
16
+ // marginBottom) — a column box with margin/padding that directly stacks bare
17
+ // <text> children overlaps them on some terminals. See Help for the rationale.
18
+ return (
19
+ <box style={{ flexDirection: "column" }}>
20
+ <box style={{ flexDirection: "row" }}>
21
+ <text>
22
+ <span fg={theme.accent}>* </span>
23
+ <span fg={theme.fg}>{title}</span>
24
+ {subtitle ? <span fg={theme.dim}>{` ${subtitle}`}</span> : null}
25
+ </text>
26
+ </box>
27
+ {hint ? (
28
+ <box style={{ flexDirection: "row" }}>
29
+ <text fg={theme.dim}>{hint}</text>
30
+ </box>
31
+ ) : null}
32
+ <box style={{ height: 1, flexDirection: "row" }}>
33
+ <text> </text>
34
+ </box>
35
+ </box>
36
+ );
37
+ }