bet-cli 0.1.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 +190 -0
- package/README.md +139 -0
- package/dist/commands/go.js +43 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/list.js +97 -0
- package/dist/commands/path.js +40 -0
- package/dist/commands/search.js +69 -0
- package/dist/commands/shell.js +19 -0
- package/dist/commands/update.js +140 -0
- package/dist/index.js +22 -0
- package/dist/lib/config.js +131 -0
- package/dist/lib/cron.js +73 -0
- package/dist/lib/git.js +28 -0
- package/dist/lib/ignore.js +11 -0
- package/dist/lib/metadata.js +37 -0
- package/dist/lib/projects.js +15 -0
- package/dist/lib/readme.js +70 -0
- package/dist/lib/scan.js +93 -0
- package/dist/lib/search.js +20 -0
- package/dist/lib/types.js +1 -0
- package/dist/ui/markdown.js +10 -0
- package/dist/ui/prompt.js +30 -0
- package/dist/ui/search.js +53 -0
- package/dist/ui/select.js +51 -0
- package/dist/ui/table.js +214 -0
- package/dist/utils/format.js +9 -0
- package/dist/utils/output.js +14 -0
- package/dist/utils/paths.js +19 -0
- package/package.json +51 -0
- package/src/commands/go.ts +50 -0
- package/src/commands/info.tsx +168 -0
- package/src/commands/list.ts +117 -0
- package/src/commands/path.ts +47 -0
- package/src/commands/search.ts +79 -0
- package/src/commands/shell.ts +22 -0
- package/src/commands/update.ts +170 -0
- package/src/index.ts +26 -0
- package/src/lib/config.ts +144 -0
- package/src/lib/cron.ts +96 -0
- package/src/lib/git.ts +31 -0
- package/src/lib/ignore.ts +11 -0
- package/src/lib/metadata.ts +41 -0
- package/src/lib/projects.ts +18 -0
- package/src/lib/readme.ts +83 -0
- package/src/lib/scan.ts +116 -0
- package/src/lib/search.ts +22 -0
- package/src/lib/types.ts +53 -0
- package/src/ui/prompt.tsx +63 -0
- package/src/ui/search.tsx +111 -0
- package/src/ui/select.tsx +119 -0
- package/src/ui/table.tsx +380 -0
- package/src/utils/format.ts +8 -0
- package/src/utils/output.ts +24 -0
- package/src/utils/paths.ts +20 -0
- package/tests/config.test.ts +106 -0
- package/tests/git.test.ts +73 -0
- package/tests/metadata.test.ts +55 -0
- package/tests/output.test.ts +81 -0
- package/tests/paths.test.ts +60 -0
- package/tests/projects.test.ts +67 -0
- package/tests/readme.test.ts +52 -0
- package/tests/scan.test.ts +67 -0
- package/tests/search.test.ts +45 -0
- package/tests/update.test.ts +30 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +26 -0
package/src/lib/cron.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { getConfigPath } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const CRON_MARKER = "# bet:update hourly";
|
|
7
|
+
const WRAPPER_SCRIPT_NAME = "bet-update-cron.sh";
|
|
8
|
+
const LOG_FILE_NAME = "cron-update.log";
|
|
9
|
+
|
|
10
|
+
export type InstallHourlyUpdateCronOptions = {
|
|
11
|
+
/** Absolute path to the Node binary (e.g. process.execPath). */
|
|
12
|
+
nodePath: string;
|
|
13
|
+
/** Absolute path to the bet CLI entry script (e.g. dist/index.js). */
|
|
14
|
+
entryScriptPath: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writes a wrapper script and installs/updates a per-user crontab entry
|
|
19
|
+
* so that `bet update` runs every hour. Idempotent: re-running replaces
|
|
20
|
+
* the existing bet cron block.
|
|
21
|
+
*/
|
|
22
|
+
export async function installHourlyUpdateCron(
|
|
23
|
+
options: InstallHourlyUpdateCronOptions,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const { nodePath, entryScriptPath } = options;
|
|
26
|
+
const configDir = path.dirname(getConfigPath());
|
|
27
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
const wrapperPath = path.join(configDir, WRAPPER_SCRIPT_NAME);
|
|
30
|
+
const logPath = path.join(configDir, LOG_FILE_NAME);
|
|
31
|
+
|
|
32
|
+
const scriptBody = [
|
|
33
|
+
"#!/bin/sh",
|
|
34
|
+
`"${nodePath}" "${entryScriptPath}" update >> "${logPath}" 2>&1`,
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
await fs.writeFile(wrapperPath, scriptBody + "\n", "utf8");
|
|
38
|
+
await fs.chmod(wrapperPath, 0o755);
|
|
39
|
+
|
|
40
|
+
const scheduleLine = `0 * * * * ${wrapperPath}`;
|
|
41
|
+
const betBlock = [CRON_MARKER, scheduleLine].join("\n");
|
|
42
|
+
|
|
43
|
+
const crontabL = spawnSync("crontab", ["-l"], {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let existingCrontab = "";
|
|
49
|
+
if (crontabL.status === 0 && crontabL.stdout) {
|
|
50
|
+
existingCrontab = crontabL.stdout;
|
|
51
|
+
}
|
|
52
|
+
if (crontabL.status !== 0 && crontabL.stderr && !crontabL.stderr.includes("no crontab")) {
|
|
53
|
+
throw new Error(`crontab -l failed: ${crontabL.stderr}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lines = existingCrontab.split("\n");
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
let skipNext = false;
|
|
59
|
+
let betBlockReplaced = false;
|
|
60
|
+
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (skipNext) {
|
|
63
|
+
skipNext = false;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (line === CRON_MARKER) {
|
|
67
|
+
if (!betBlockReplaced) {
|
|
68
|
+
out.push(betBlock);
|
|
69
|
+
betBlockReplaced = true;
|
|
70
|
+
}
|
|
71
|
+
skipNext = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out.push(line);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!betBlockReplaced) {
|
|
78
|
+
if (out.length > 0 && !out[out.length - 1].endsWith("\n") && out[out.length - 1] !== "") {
|
|
79
|
+
out.push("");
|
|
80
|
+
}
|
|
81
|
+
out.push(betBlock);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const newCrontab = out.join("\n").replace(/\n*$/, "") + "\n";
|
|
85
|
+
|
|
86
|
+
const crontabWrite = spawnSync("crontab", ["-"], {
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
input: newCrontab,
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (crontabWrite.status !== 0) {
|
|
93
|
+
const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
|
|
94
|
+
throw new Error(`crontab install failed: ${err}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
async function runGit(cwd: string, args: string[]): Promise<string | null> {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
});
|
|
11
|
+
return stdout.trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getFirstCommitDate(cwd: string): Promise<string | undefined> {
|
|
18
|
+
const output = await runGit(cwd, ['log', '--reverse', '--format=%cI', '-n', '1']);
|
|
19
|
+
return output || undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getDirtyStatus(cwd: string): Promise<boolean | undefined> {
|
|
23
|
+
const output = await runGit(cwd, ['status', '--porcelain']);
|
|
24
|
+
if (output === null) return undefined;
|
|
25
|
+
return output.length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function isInsideGitRepo(cwd: string): Promise<boolean> {
|
|
29
|
+
const output = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
30
|
+
return output === 'true';
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { DEFAULT_IGNORES } from './ignore.js';
|
|
3
|
+
import { ProjectAutoMetadata } from './types.js';
|
|
4
|
+
import { getDirtyStatus, getFirstCommitDate } from './git.js';
|
|
5
|
+
import { readReadmeDescription } from './readme.js';
|
|
6
|
+
|
|
7
|
+
export async function computeMetadata(projectPath: string, hasGit: boolean): Promise<ProjectAutoMetadata> {
|
|
8
|
+
const nowIso = new Date().toISOString();
|
|
9
|
+
|
|
10
|
+
const entries = await fg('**/*', {
|
|
11
|
+
cwd: projectPath,
|
|
12
|
+
dot: true,
|
|
13
|
+
onlyFiles: true,
|
|
14
|
+
followSymbolicLinks: false,
|
|
15
|
+
ignore: DEFAULT_IGNORES,
|
|
16
|
+
stats: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let oldest: number | undefined;
|
|
20
|
+
let newest: number | undefined;
|
|
21
|
+
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const stats = entry.stats;
|
|
24
|
+
if (!stats) continue;
|
|
25
|
+
const mtime = stats.mtimeMs;
|
|
26
|
+
if (oldest === undefined || mtime < oldest) oldest = mtime;
|
|
27
|
+
if (newest === undefined || mtime > newest) newest = mtime;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const description = await readReadmeDescription(projectPath);
|
|
31
|
+
const startedAt = hasGit ? await getFirstCommitDate(projectPath) : undefined;
|
|
32
|
+
const dirty = hasGit ? await getDirtyStatus(projectPath) : undefined;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
description,
|
|
36
|
+
startedAt: startedAt ?? (oldest ? new Date(oldest).toISOString() : undefined),
|
|
37
|
+
lastModifiedAt: newest ? new Date(newest).toISOString() : undefined,
|
|
38
|
+
lastIndexedAt: nowIso,
|
|
39
|
+
dirty,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Config, Project } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function listProjects(config: Config): Project[] {
|
|
4
|
+
const projects = Object.values(config.projects);
|
|
5
|
+
return projects.sort((a, b) => {
|
|
6
|
+
if (a.rootName !== b.rootName) return a.rootName.localeCompare(b.rootName);
|
|
7
|
+
return a.slug.localeCompare(b.slug);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function projectLabel(project: Project): string {
|
|
12
|
+
return `${project.rootName}/${project.slug}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function findBySlug(projects: Project[], slug: string): Project[] {
|
|
16
|
+
const normalized = slug.trim().toLowerCase();
|
|
17
|
+
return projects.filter((project) => project.slug.toLowerCase() === normalized);
|
|
18
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const README_NAMES = ["README.md", "readme.md", "Readme.md", "README.MD"];
|
|
5
|
+
|
|
6
|
+
export async function readReadmePath(
|
|
7
|
+
projectPath: string,
|
|
8
|
+
): Promise<string | undefined> {
|
|
9
|
+
for (const name of README_NAMES) {
|
|
10
|
+
const candidate = path.join(projectPath, name);
|
|
11
|
+
try {
|
|
12
|
+
await fs.access(candidate);
|
|
13
|
+
return candidate;
|
|
14
|
+
} catch {
|
|
15
|
+
// ignore
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readReadmeContent(
|
|
22
|
+
projectPath: string,
|
|
23
|
+
): Promise<string | undefined> {
|
|
24
|
+
const readmePath = await readReadmePath(projectPath);
|
|
25
|
+
if (!readmePath) return undefined;
|
|
26
|
+
|
|
27
|
+
let content = await fs.readFile(readmePath, "utf8");
|
|
28
|
+
// remove html tags
|
|
29
|
+
content = content.replace(/<[^>]*>?/g, "");
|
|
30
|
+
|
|
31
|
+
// only graph the first 300 characters, and add ... if there are more
|
|
32
|
+
const truncated = content.slice(0, 500);
|
|
33
|
+
if (content.length > 500) {
|
|
34
|
+
return `${truncated}...`;
|
|
35
|
+
}
|
|
36
|
+
return truncated;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readReadmeDescription(
|
|
40
|
+
projectPath: string,
|
|
41
|
+
): Promise<string | undefined> {
|
|
42
|
+
const readmePath = await readReadmePath(projectPath);
|
|
43
|
+
if (!readmePath) return undefined;
|
|
44
|
+
|
|
45
|
+
const raw = await fs.readFile(readmePath, "utf8");
|
|
46
|
+
const lines = raw.split(/\r?\n/);
|
|
47
|
+
|
|
48
|
+
let title: string | undefined;
|
|
49
|
+
let paragraph: string[] = [];
|
|
50
|
+
let inCodeBlock = false;
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
|
|
55
|
+
if (trimmed.startsWith("```")) {
|
|
56
|
+
inCodeBlock = !inCodeBlock;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (inCodeBlock) continue;
|
|
61
|
+
|
|
62
|
+
if (!title && trimmed.startsWith("#")) {
|
|
63
|
+
title = trimmed.replace(/^#+\s*/, "").trim();
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (title) {
|
|
68
|
+
if (trimmed.length === 0) {
|
|
69
|
+
if (paragraph.length > 0) break;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (trimmed.startsWith("#")) {
|
|
73
|
+
if (paragraph.length > 0) break;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
paragraph.push(trimmed);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const description = paragraph.join(" ").trim();
|
|
81
|
+
if (description) return description;
|
|
82
|
+
return title || undefined;
|
|
83
|
+
}
|
package/src/lib/scan.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { ProjectCandidate } from "./types.js";
|
|
4
|
+
import { DEFAULT_IGNORES } from "./ignore.js";
|
|
5
|
+
import { isSubpath } from "../utils/paths.js";
|
|
6
|
+
import { isInsideGitRepo } from "./git.js";
|
|
7
|
+
|
|
8
|
+
const README_PATTERNS = [
|
|
9
|
+
"**/README.md",
|
|
10
|
+
"**/readme.md",
|
|
11
|
+
"**/Readme.md",
|
|
12
|
+
"**/README.MD",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function resolveProjectRoot(matchPath: string): string {
|
|
16
|
+
const container = path.dirname(matchPath);
|
|
17
|
+
// if (
|
|
18
|
+
// path.basename(container) === "src" ||
|
|
19
|
+
// path.basename(container) === "app"
|
|
20
|
+
// ) {
|
|
21
|
+
// return path.dirname(container);
|
|
22
|
+
// }
|
|
23
|
+
return container;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addCandidate(
|
|
27
|
+
map: Map<string, ProjectCandidate>,
|
|
28
|
+
projectPath: string,
|
|
29
|
+
root: string,
|
|
30
|
+
flags: Partial<Pick<ProjectCandidate, "hasGit" | "hasReadme">>,
|
|
31
|
+
): void {
|
|
32
|
+
const existing = map.get(projectPath);
|
|
33
|
+
const base: ProjectCandidate = existing ?? {
|
|
34
|
+
path: projectPath,
|
|
35
|
+
root,
|
|
36
|
+
hasGit: false,
|
|
37
|
+
hasReadme: false,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const next: ProjectCandidate = {
|
|
41
|
+
...base,
|
|
42
|
+
...flags,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (existing && root.length > existing.root.length) {
|
|
46
|
+
next.root = root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
map.set(projectPath, next);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function scanRoots(roots: string[]): Promise<ProjectCandidate[]> {
|
|
53
|
+
const candidates = new Map<string, ProjectCandidate>();
|
|
54
|
+
|
|
55
|
+
for (const root of roots) {
|
|
56
|
+
// Exclude .git/** from ignores when scanning for .git directories
|
|
57
|
+
const gitIgnores = DEFAULT_IGNORES.filter(
|
|
58
|
+
(pattern) => pattern !== "**/.git/**",
|
|
59
|
+
);
|
|
60
|
+
const gitMatches = await fg("**/.git", {
|
|
61
|
+
cwd: root,
|
|
62
|
+
dot: true,
|
|
63
|
+
onlyDirectories: false,
|
|
64
|
+
onlyFiles: false,
|
|
65
|
+
followSymbolicLinks: false,
|
|
66
|
+
ignore: gitIgnores,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
for (const match of gitMatches) {
|
|
70
|
+
const absMatch = path.join(root, match);
|
|
71
|
+
const projectRoot = resolveProjectRoot(absMatch);
|
|
72
|
+
if (!projectRoot.startsWith(root)) continue;
|
|
73
|
+
addCandidate(candidates, projectRoot, root, { hasGit: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const readmeMatches = await fg(README_PATTERNS, {
|
|
77
|
+
cwd: root,
|
|
78
|
+
dot: true,
|
|
79
|
+
onlyFiles: true,
|
|
80
|
+
followSymbolicLinks: false,
|
|
81
|
+
ignore: DEFAULT_IGNORES,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const match of readmeMatches) {
|
|
85
|
+
const absMatch = path.join(root, match);
|
|
86
|
+
const projectRoot = resolveProjectRoot(absMatch);
|
|
87
|
+
if (!projectRoot.startsWith(root)) continue;
|
|
88
|
+
addCandidate(candidates, projectRoot, root, { hasReadme: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const list = Array.from(candidates.values());
|
|
93
|
+
list.sort((a, b) => a.path.length - b.path.length);
|
|
94
|
+
|
|
95
|
+
const filtered: ProjectCandidate[] = [];
|
|
96
|
+
for (const candidate of list) {
|
|
97
|
+
const nested = filtered.some((kept) =>
|
|
98
|
+
isSubpath(candidate.path, kept.path),
|
|
99
|
+
);
|
|
100
|
+
if (!nested) {
|
|
101
|
+
filtered.push(candidate);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check git status for all candidates (including those inside parent repos)
|
|
106
|
+
for (const candidate of filtered) {
|
|
107
|
+
if (!candidate.hasGit) {
|
|
108
|
+
const hasGit = await isInsideGitRepo(candidate.path);
|
|
109
|
+
if (hasGit) {
|
|
110
|
+
candidate.hasGit = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return filtered;
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Fuse from 'fuse.js';
|
|
2
|
+
import { Project } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function searchProjects(projects: Project[], query: string): Project[] {
|
|
5
|
+
if (!query.trim()) return projects;
|
|
6
|
+
const fuse = new Fuse(projects, {
|
|
7
|
+
keys: [
|
|
8
|
+
'slug',
|
|
9
|
+
'name',
|
|
10
|
+
'path',
|
|
11
|
+
'rootName',
|
|
12
|
+
'root',
|
|
13
|
+
'user.tags',
|
|
14
|
+
'user.description',
|
|
15
|
+
'auto.description'
|
|
16
|
+
],
|
|
17
|
+
includeScore: true,
|
|
18
|
+
threshold: 0.4,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return fuse.search(query).map((result) => result.item);
|
|
22
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type ProjectAutoMetadata = {
|
|
2
|
+
description?: string;
|
|
3
|
+
startedAt?: string;
|
|
4
|
+
lastModifiedAt?: string;
|
|
5
|
+
lastIndexedAt: string;
|
|
6
|
+
dirty?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ProjectUserMetadata = {
|
|
10
|
+
description?: string;
|
|
11
|
+
onEnter?: string;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type Project = {
|
|
16
|
+
id: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
root: string;
|
|
21
|
+
rootName: string;
|
|
22
|
+
hasGit: boolean;
|
|
23
|
+
hasReadme: boolean;
|
|
24
|
+
auto: ProjectAutoMetadata;
|
|
25
|
+
user?: ProjectUserMetadata;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RootConfig = {
|
|
29
|
+
path: string;
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AppConfig = {
|
|
34
|
+
version: number;
|
|
35
|
+
roots: RootConfig[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ProjectsConfig = {
|
|
39
|
+
projects: Record<string, Project>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type Config = {
|
|
43
|
+
version: number;
|
|
44
|
+
roots: RootConfig[];
|
|
45
|
+
projects: Record<string, Project>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ProjectCandidate = {
|
|
49
|
+
path: string;
|
|
50
|
+
root: string;
|
|
51
|
+
hasGit: boolean;
|
|
52
|
+
hasReadme: boolean;
|
|
53
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import { SelectEntry, SelectList, SelectRow } from './select.js';
|
|
4
|
+
import { SearchSelect } from './search.js';
|
|
5
|
+
|
|
6
|
+
export async function promptSelect<T>(
|
|
7
|
+
items: SelectRow<T>[],
|
|
8
|
+
options: { title?: string; maxRows?: number } = {}
|
|
9
|
+
): Promise<SelectEntry<T> | undefined> {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const { unmount } = render(
|
|
12
|
+
<SelectList
|
|
13
|
+
title={options.title}
|
|
14
|
+
items={items}
|
|
15
|
+
maxRows={options.maxRows}
|
|
16
|
+
onSelect={(item) => {
|
|
17
|
+
unmount();
|
|
18
|
+
resolve(item);
|
|
19
|
+
}}
|
|
20
|
+
onCancel={() => {
|
|
21
|
+
unmount();
|
|
22
|
+
resolve(undefined);
|
|
23
|
+
}}
|
|
24
|
+
/>,
|
|
25
|
+
{
|
|
26
|
+
stdout: process.stderr,
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function promptSearch<T>(
|
|
33
|
+
items: SelectEntry<T>[],
|
|
34
|
+
options: {
|
|
35
|
+
title?: string;
|
|
36
|
+
initialQuery?: string;
|
|
37
|
+
maxRows?: number;
|
|
38
|
+
filter: (items: SelectEntry<T>[], query: string) => SelectEntry<T>[];
|
|
39
|
+
}
|
|
40
|
+
): Promise<SelectEntry<T> | undefined> {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const { unmount } = render(
|
|
43
|
+
<SearchSelect
|
|
44
|
+
title={options.title}
|
|
45
|
+
allItems={items}
|
|
46
|
+
filter={options.filter}
|
|
47
|
+
maxRows={options.maxRows}
|
|
48
|
+
initialQuery={options.initialQuery}
|
|
49
|
+
onSelect={(item) => {
|
|
50
|
+
unmount();
|
|
51
|
+
resolve(item);
|
|
52
|
+
}}
|
|
53
|
+
onCancel={() => {
|
|
54
|
+
unmount();
|
|
55
|
+
resolve(undefined);
|
|
56
|
+
}}
|
|
57
|
+
/>,
|
|
58
|
+
{
|
|
59
|
+
stdout: process.stderr,
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { SelectEntry } from './select.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_ROWS = 18;
|
|
7
|
+
|
|
8
|
+
export type SearchSelectProps<T> = {
|
|
9
|
+
title?: string;
|
|
10
|
+
allItems: SelectEntry<T>[];
|
|
11
|
+
filter: (items: SelectEntry<T>[], query: string) => SelectEntry<T>[];
|
|
12
|
+
onSelect: (item: SelectEntry<T>) => void;
|
|
13
|
+
onCancel?: () => void;
|
|
14
|
+
maxRows?: number;
|
|
15
|
+
initialQuery?: string;
|
|
16
|
+
showCount?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function SearchSelect<T>({
|
|
20
|
+
title,
|
|
21
|
+
allItems,
|
|
22
|
+
filter,
|
|
23
|
+
onSelect,
|
|
24
|
+
onCancel,
|
|
25
|
+
maxRows = DEFAULT_MAX_ROWS,
|
|
26
|
+
initialQuery = '',
|
|
27
|
+
showCount = true,
|
|
28
|
+
}: SearchSelectProps<T>): React.ReactElement {
|
|
29
|
+
const [cursor, setCursor] = useState(0);
|
|
30
|
+
const [query, setQuery] = useState(initialQuery);
|
|
31
|
+
|
|
32
|
+
const items = useMemo(() => filter(allItems, query), [allItems, filter, query]);
|
|
33
|
+
|
|
34
|
+
useInput((input, key) => {
|
|
35
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
36
|
+
onCancel?.();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (key.return) {
|
|
41
|
+
const entry = items[cursor];
|
|
42
|
+
if (entry) onSelect(entry);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (key.upArrow || input === 'k') {
|
|
47
|
+
setCursor((prev) => (prev - 1 + items.length) % Math.max(items.length, 1));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (key.downArrow || input === 'j') {
|
|
52
|
+
setCursor((prev) => (prev + 1) % Math.max(items.length, 1));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (input === '\b' || input === '\x7f') {
|
|
57
|
+
setQuery((prev) => prev.slice(0, -1));
|
|
58
|
+
setCursor(0);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (input) {
|
|
63
|
+
setQuery((prev) => prev + input);
|
|
64
|
+
setCursor(0);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (items.length === 0) {
|
|
69
|
+
return (
|
|
70
|
+
<Box flexDirection="column">
|
|
71
|
+
{title && <Text>{chalk.bold(title)}</Text>}
|
|
72
|
+
<Text>{`Search: ${query}`}</Text>
|
|
73
|
+
<Text>No results.</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const selectedRowIndex = Math.min(cursor, items.length - 1);
|
|
79
|
+
const totalRows = items.length;
|
|
80
|
+
const effectiveMaxRows = Math.max(3, maxRows);
|
|
81
|
+
const windowStart = Math.min(
|
|
82
|
+
Math.max(0, selectedRowIndex - Math.floor(effectiveMaxRows / 2)),
|
|
83
|
+
Math.max(0, totalRows - effectiveMaxRows)
|
|
84
|
+
);
|
|
85
|
+
const windowEnd = Math.min(totalRows, windowStart + effectiveMaxRows);
|
|
86
|
+
const windowed = items.slice(windowStart, windowEnd);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box flexDirection="column">
|
|
90
|
+
{title && <Text>{chalk.bold(title)}</Text>}
|
|
91
|
+
<Text>{`Search: ${query}`}</Text>
|
|
92
|
+
{showCount && <Text>{chalk.dim(`${items.length} result(s)`)}</Text>}
|
|
93
|
+
{windowed.map((row, idx) => {
|
|
94
|
+
const absoluteIndex = windowStart + idx;
|
|
95
|
+
const selected = absoluteIndex === selectedRowIndex;
|
|
96
|
+
return (
|
|
97
|
+
<Box key={`item-${absoluteIndex}`}>
|
|
98
|
+
<Text>
|
|
99
|
+
{selected ? chalk.cyan.bold('› ') : ' '}
|
|
100
|
+
{selected ? chalk.cyan.bold(row.label) : row.label}
|
|
101
|
+
</Text>
|
|
102
|
+
{row.hint ? <Text>{chalk.dim(` ${row.hint}`)}</Text> : null}
|
|
103
|
+
</Box>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
<Box marginTop={1}>
|
|
107
|
+
<Text>{chalk.dim('Type to filter. Use ↑/↓ or j/k. Enter to select. Esc to cancel.')}</Text>
|
|
108
|
+
</Box>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|