@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +52 -0
- package/pkg/core/appname.ts +206 -0
- package/pkg/core/config.ts +34 -0
- package/pkg/core/killport.ts +273 -0
- package/pkg/core/launch.ts +434 -0
- package/pkg/core/manifest.ts +123 -0
- package/pkg/tui/app.tsx +33 -0
- package/pkg/tui/components/Confirm.tsx +53 -0
- package/pkg/tui/components/Header.tsx +37 -0
- package/pkg/tui/components/Help.tsx +83 -0
- package/pkg/tui/components/ListSelect.tsx +436 -0
- package/pkg/tui/components/TextPrompt.tsx +83 -0
- package/pkg/tui/components/index.ts +7 -0
- package/pkg/tui/devkit.tsx +79 -0
- package/pkg/tui/killport.tsx +369 -0
- package/pkg/tui/launch.tsx +971 -0
- package/pkg/tui/theme-context.tsx +30 -0
- package/pkg/tui/theme.ts +97 -0
- package/pkg/tui/tools.tsx +27 -0
- package/pkg/tui/winmouse.ts +70 -0
- package/tsconfig.json +18 -0
|
@@ -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
|
+
}
|
package/pkg/tui/app.tsx
ADDED
|
@@ -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
|
+
}
|