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.
Files changed (66) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +139 -0
  3. package/dist/commands/go.js +43 -0
  4. package/dist/commands/info.js +92 -0
  5. package/dist/commands/list.js +97 -0
  6. package/dist/commands/path.js +40 -0
  7. package/dist/commands/search.js +69 -0
  8. package/dist/commands/shell.js +19 -0
  9. package/dist/commands/update.js +140 -0
  10. package/dist/index.js +22 -0
  11. package/dist/lib/config.js +131 -0
  12. package/dist/lib/cron.js +73 -0
  13. package/dist/lib/git.js +28 -0
  14. package/dist/lib/ignore.js +11 -0
  15. package/dist/lib/metadata.js +37 -0
  16. package/dist/lib/projects.js +15 -0
  17. package/dist/lib/readme.js +70 -0
  18. package/dist/lib/scan.js +93 -0
  19. package/dist/lib/search.js +20 -0
  20. package/dist/lib/types.js +1 -0
  21. package/dist/ui/markdown.js +10 -0
  22. package/dist/ui/prompt.js +30 -0
  23. package/dist/ui/search.js +53 -0
  24. package/dist/ui/select.js +51 -0
  25. package/dist/ui/table.js +214 -0
  26. package/dist/utils/format.js +9 -0
  27. package/dist/utils/output.js +14 -0
  28. package/dist/utils/paths.js +19 -0
  29. package/package.json +51 -0
  30. package/src/commands/go.ts +50 -0
  31. package/src/commands/info.tsx +168 -0
  32. package/src/commands/list.ts +117 -0
  33. package/src/commands/path.ts +47 -0
  34. package/src/commands/search.ts +79 -0
  35. package/src/commands/shell.ts +22 -0
  36. package/src/commands/update.ts +170 -0
  37. package/src/index.ts +26 -0
  38. package/src/lib/config.ts +144 -0
  39. package/src/lib/cron.ts +96 -0
  40. package/src/lib/git.ts +31 -0
  41. package/src/lib/ignore.ts +11 -0
  42. package/src/lib/metadata.ts +41 -0
  43. package/src/lib/projects.ts +18 -0
  44. package/src/lib/readme.ts +83 -0
  45. package/src/lib/scan.ts +116 -0
  46. package/src/lib/search.ts +22 -0
  47. package/src/lib/types.ts +53 -0
  48. package/src/ui/prompt.tsx +63 -0
  49. package/src/ui/search.tsx +111 -0
  50. package/src/ui/select.tsx +119 -0
  51. package/src/ui/table.tsx +380 -0
  52. package/src/utils/format.ts +8 -0
  53. package/src/utils/output.ts +24 -0
  54. package/src/utils/paths.ts +20 -0
  55. package/tests/config.test.ts +106 -0
  56. package/tests/git.test.ts +73 -0
  57. package/tests/metadata.test.ts +55 -0
  58. package/tests/output.test.ts +81 -0
  59. package/tests/paths.test.ts +60 -0
  60. package/tests/projects.test.ts +67 -0
  61. package/tests/readme.test.ts +52 -0
  62. package/tests/scan.test.ts +67 -0
  63. package/tests/search.test.ts +45 -0
  64. package/tests/update.test.ts +30 -0
  65. package/tsconfig.json +17 -0
  66. package/vitest.config.ts +26 -0
@@ -0,0 +1,19 @@
1
+ const SHELL_SNIPPET = [
2
+ 'bet() {',
3
+ ' local out',
4
+ ' out="$(BET_EVAL=1 command bet "$@")" || return $?',
5
+ ' if [[ "$out" == __BET_EVAL__* ]]; then',
6
+ ' eval "${out#__BET_EVAL__}"',
7
+ ' elif [[ -n "$out" ]]; then',
8
+ ' printf "%s\\n" "$out"',
9
+ ' fi',
10
+ '}',
11
+ ].join('\n');
12
+ export function registerShell(program) {
13
+ program
14
+ .command('shell')
15
+ .description('Print shell integration for cd support')
16
+ .action(() => {
17
+ process.stdout.write(`${SHELL_SNIPPET}\n`);
18
+ });
19
+ }
@@ -0,0 +1,140 @@
1
+ import path from "node:path";
2
+ import readline from "node:readline";
3
+ import { readConfig, resolveRoots, writeConfig } from "../lib/config.js";
4
+ import { normalizeAbsolute } from "../utils/paths.js";
5
+ import { installHourlyUpdateCron } from "../lib/cron.js";
6
+ import { scanRoots } from "../lib/scan.js";
7
+ import { computeMetadata } from "../lib/metadata.js";
8
+ import { isInsideGitRepo } from "../lib/git.js";
9
+ function parseRoots(value) {
10
+ if (!value)
11
+ return undefined;
12
+ return value
13
+ .split(",")
14
+ .map((root) => root.trim())
15
+ .filter(Boolean);
16
+ }
17
+ function pathsToRootConfigs(paths) {
18
+ return paths.map((p) => {
19
+ const abs = normalizeAbsolute(p);
20
+ return { path: abs, name: path.basename(abs) };
21
+ });
22
+ }
23
+ export function willOverrideRoots(providedRootConfigs, configRoots) {
24
+ return !!(providedRootConfigs !== undefined &&
25
+ configRoots.length > 0);
26
+ }
27
+ function projectSlug(pathName) {
28
+ const folderName = path.basename(pathName);
29
+ if (folderName === "src" || folderName === "app") {
30
+ return path.basename(path.dirname(pathName));
31
+ }
32
+ return folderName;
33
+ }
34
+ async function promptYesNo(question, defaultNo = true) {
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
36
+ return new Promise((resolve) => {
37
+ const defaultHint = defaultNo ? "y/N" : "Y/n";
38
+ rl.question(question + " [" + defaultHint + "] ", (answer) => {
39
+ rl.close();
40
+ const trimmed = answer.trim().toLowerCase();
41
+ if (!trimmed) {
42
+ resolve(!defaultNo);
43
+ return;
44
+ }
45
+ resolve(trimmed === "y" || trimmed === "yes");
46
+ });
47
+ });
48
+ }
49
+ export function registerUpdate(program) {
50
+ program
51
+ .command("update")
52
+ .description("Scan roots and update the project index")
53
+ .option("--roots <paths>", "Comma-separated list of roots to scan")
54
+ .option("--force", "Allow overriding configured roots when not in TTY")
55
+ .option("--cron", "Install an hourly cron job to run bet update")
56
+ .action(async (options) => {
57
+ const config = await readConfig();
58
+ const providedPaths = parseRoots(options.roots);
59
+ const providedRootConfigs = providedPaths
60
+ ? pathsToRootConfigs(providedPaths)
61
+ : undefined;
62
+ const configRoots = config.roots.length > 0 ? config.roots : undefined;
63
+ const rootsToUse = providedRootConfigs ?? configRoots;
64
+ if (!rootsToUse || rootsToUse.length === 0) {
65
+ process.stderr.write("Error: No roots specified. Please provide roots using --roots option.\n" +
66
+ "Example: bet update --roots /path/to/your/code\n");
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ const willOverride = willOverrideRoots(providedRootConfigs, config.roots);
71
+ if (willOverride) {
72
+ process.stderr.write("Warning: --roots will override your configured roots.\n" +
73
+ " Configured: " +
74
+ configRoots.map((r) => r.path).join(", ") +
75
+ "\n Provided: " +
76
+ providedRootConfigs.map((r) => r.path).join(", ") +
77
+ "\n");
78
+ if (!process.stdin.isTTY) {
79
+ if (!options.force) {
80
+ process.stderr.write("Error: Refusing to override without confirmation. Run interactively or use --force.\n");
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ }
85
+ else {
86
+ const confirmed = await promptYesNo("Continue?", true);
87
+ if (!confirmed) {
88
+ process.stderr.write("Aborted.\n");
89
+ return;
90
+ }
91
+ }
92
+ }
93
+ const rootsResolved = resolveRoots(rootsToUse);
94
+ const rootPaths = rootsResolved.map((r) => r.path);
95
+ const candidates = await scanRoots(rootPaths);
96
+ const projects = {};
97
+ for (const candidate of candidates) {
98
+ const hasGit = await isInsideGitRepo(candidate.path);
99
+ const auto = await computeMetadata(candidate.path, hasGit);
100
+ const slug = projectSlug(candidate.path);
101
+ const existing = config.projects[candidate.path];
102
+ const rootConfig = rootsResolved.find((r) => r.path === candidate.root);
103
+ const rootName = rootConfig?.name ?? path.basename(candidate.root);
104
+ const project = {
105
+ id: candidate.path,
106
+ slug,
107
+ name: slug,
108
+ path: candidate.path,
109
+ root: candidate.root,
110
+ rootName,
111
+ hasGit,
112
+ hasReadme: candidate.hasReadme,
113
+ auto,
114
+ user: existing?.user,
115
+ };
116
+ projects[candidate.path] = project;
117
+ }
118
+ const nextConfig = {
119
+ version: config.version ?? 1,
120
+ roots: rootsResolved,
121
+ projects,
122
+ };
123
+ await writeConfig(nextConfig);
124
+ process.stdout.write("Indexed " +
125
+ Object.keys(projects).length +
126
+ " projects from " +
127
+ rootsResolved.length +
128
+ " root(s).\n");
129
+ if (options.cron) {
130
+ const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
131
+ ? process.argv[1]
132
+ : path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
133
+ await installHourlyUpdateCron({
134
+ nodePath: process.execPath,
135
+ entryScriptPath,
136
+ });
137
+ process.stdout.write("Installed hourly cron job for bet update.\n");
138
+ }
139
+ });
140
+ }
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerUpdate } from "./commands/update.js";
4
+ import { registerList } from "./commands/list.js";
5
+ import { registerSearch } from "./commands/search.js";
6
+ import { registerInfo } from "./commands/info.js";
7
+ import { registerGo } from "./commands/go.js";
8
+ import { registerPath } from "./commands/path.js";
9
+ import { registerShell } from "./commands/shell.js";
10
+ const program = new Command();
11
+ program
12
+ .name("bet")
13
+ .description("Explore and jump between local projects.")
14
+ .version("0.1.0");
15
+ registerUpdate(program);
16
+ registerList(program);
17
+ registerSearch(program);
18
+ registerInfo(program);
19
+ registerGo(program);
20
+ registerPath(program);
21
+ registerShell(program);
22
+ program.parseAsync(process.argv);
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { normalizeAbsolute } from "../utils/paths.js";
5
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME
6
+ ? path.join(process.env.XDG_CONFIG_HOME, "bet")
7
+ : path.join(os.homedir(), ".config", "bet");
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
9
+ const PROJECTS_PATH = path.join(CONFIG_DIR, "projects.json");
10
+ const DEFAULT_APP_CONFIG = {
11
+ version: 1,
12
+ roots: [],
13
+ };
14
+ const DEFAULT_PROJECTS_CONFIG = {
15
+ projects: {},
16
+ };
17
+ const DEFAULT_CONFIG = {
18
+ ...DEFAULT_APP_CONFIG,
19
+ ...DEFAULT_PROJECTS_CONFIG,
20
+ };
21
+ export function getConfigPath() {
22
+ return CONFIG_PATH;
23
+ }
24
+ export function getProjectsPath() {
25
+ return PROJECTS_PATH;
26
+ }
27
+ function normalizeRoots(parsedRoots) {
28
+ if (!Array.isArray(parsedRoots))
29
+ return [];
30
+ const result = [];
31
+ for (const r of parsedRoots) {
32
+ if (typeof r === "string") {
33
+ const abs = normalizeAbsolute(r);
34
+ result.push({ path: abs, name: path.basename(abs) });
35
+ }
36
+ else if (r && typeof r === "object" && "path" in r && typeof r.path === "string") {
37
+ const root = r;
38
+ const abs = normalizeAbsolute(root.path);
39
+ const name = (root.name?.trim() || path.basename(abs));
40
+ result.push({ path: abs, name });
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ async function readAppConfig() {
46
+ try {
47
+ const raw = await fs.readFile(CONFIG_PATH, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ const roots = normalizeRoots(parsed.roots ?? []);
50
+ return {
51
+ ...DEFAULT_APP_CONFIG,
52
+ version: parsed.version ?? 1,
53
+ roots,
54
+ };
55
+ }
56
+ catch (error) {
57
+ return { ...DEFAULT_APP_CONFIG };
58
+ }
59
+ }
60
+ async function readProjectsConfig() {
61
+ try {
62
+ const raw = await fs.readFile(PROJECTS_PATH, "utf8");
63
+ const parsed = JSON.parse(raw);
64
+ return {
65
+ projects: parsed.projects ?? {},
66
+ };
67
+ }
68
+ catch (error) {
69
+ return { ...DEFAULT_PROJECTS_CONFIG };
70
+ }
71
+ }
72
+ function normalizeProjectRootName(project, roots) {
73
+ if (project.rootName?.trim())
74
+ return project.rootName.trim();
75
+ const matched = roots.find((r) => r.path === project.root);
76
+ if (matched)
77
+ return matched.name;
78
+ return path.basename(project.root);
79
+ }
80
+ export async function readConfig() {
81
+ const [appConfig, projectsConfig] = await Promise.all([
82
+ readAppConfig(),
83
+ readProjectsConfig(),
84
+ ]);
85
+ const projects = projectsConfig.projects;
86
+ const normalizedProjects = {};
87
+ for (const [id, p] of Object.entries(projects)) {
88
+ const rootName = normalizeProjectRootName(p, appConfig.roots);
89
+ const { group: _group, ...rest } = p;
90
+ normalizedProjects[id] = { ...rest, rootName };
91
+ }
92
+ return {
93
+ ...appConfig,
94
+ projects: normalizedProjects,
95
+ };
96
+ }
97
+ async function writeAppConfig(appConfig) {
98
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
99
+ const formatted = JSON.stringify(appConfig, null, 2);
100
+ await fs.writeFile(CONFIG_PATH, formatted, "utf8");
101
+ }
102
+ async function writeProjectsConfig(projectsConfig) {
103
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
104
+ const formatted = JSON.stringify(projectsConfig, null, 2);
105
+ await fs.writeFile(PROJECTS_PATH, formatted, "utf8");
106
+ }
107
+ export async function writeConfig(config) {
108
+ const appConfig = {
109
+ version: config.version,
110
+ roots: config.roots,
111
+ };
112
+ const projectsConfig = {
113
+ projects: config.projects,
114
+ };
115
+ await Promise.all([
116
+ writeAppConfig(appConfig),
117
+ writeProjectsConfig(projectsConfig),
118
+ ]);
119
+ }
120
+ export function resolveRoots(inputRoots) {
121
+ const seen = new Set();
122
+ const resolved = [];
123
+ for (const root of inputRoots) {
124
+ const abs = normalizeAbsolute(root.path);
125
+ if (!seen.has(abs)) {
126
+ seen.add(abs);
127
+ resolved.push({ path: abs, name: root.name?.trim() || path.basename(abs) });
128
+ }
129
+ }
130
+ return resolved;
131
+ }
@@ -0,0 +1,73 @@
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
+ const CRON_MARKER = "# bet:update hourly";
6
+ const WRAPPER_SCRIPT_NAME = "bet-update-cron.sh";
7
+ const LOG_FILE_NAME = "cron-update.log";
8
+ /**
9
+ * Writes a wrapper script and installs/updates a per-user crontab entry
10
+ * so that `bet update` runs every hour. Idempotent: re-running replaces
11
+ * the existing bet cron block.
12
+ */
13
+ export async function installHourlyUpdateCron(options) {
14
+ const { nodePath, entryScriptPath } = options;
15
+ const configDir = path.dirname(getConfigPath());
16
+ await fs.mkdir(configDir, { recursive: true });
17
+ const wrapperPath = path.join(configDir, WRAPPER_SCRIPT_NAME);
18
+ const logPath = path.join(configDir, LOG_FILE_NAME);
19
+ const scriptBody = [
20
+ "#!/bin/sh",
21
+ `"${nodePath}" "${entryScriptPath}" update >> "${logPath}" 2>&1`,
22
+ ].join("\n");
23
+ await fs.writeFile(wrapperPath, scriptBody + "\n", "utf8");
24
+ await fs.chmod(wrapperPath, 0o755);
25
+ const scheduleLine = `0 * * * * ${wrapperPath}`;
26
+ const betBlock = [CRON_MARKER, scheduleLine].join("\n");
27
+ const crontabL = spawnSync("crontab", ["-l"], {
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "pipe"],
30
+ });
31
+ let existingCrontab = "";
32
+ if (crontabL.status === 0 && crontabL.stdout) {
33
+ existingCrontab = crontabL.stdout;
34
+ }
35
+ if (crontabL.status !== 0 && crontabL.stderr && !crontabL.stderr.includes("no crontab")) {
36
+ throw new Error(`crontab -l failed: ${crontabL.stderr}`);
37
+ }
38
+ const lines = existingCrontab.split("\n");
39
+ const out = [];
40
+ let skipNext = false;
41
+ let betBlockReplaced = false;
42
+ for (const line of lines) {
43
+ if (skipNext) {
44
+ skipNext = false;
45
+ continue;
46
+ }
47
+ if (line === CRON_MARKER) {
48
+ if (!betBlockReplaced) {
49
+ out.push(betBlock);
50
+ betBlockReplaced = true;
51
+ }
52
+ skipNext = true;
53
+ continue;
54
+ }
55
+ out.push(line);
56
+ }
57
+ if (!betBlockReplaced) {
58
+ if (out.length > 0 && !out[out.length - 1].endsWith("\n") && out[out.length - 1] !== "") {
59
+ out.push("");
60
+ }
61
+ out.push(betBlock);
62
+ }
63
+ const newCrontab = out.join("\n").replace(/\n*$/, "") + "\n";
64
+ const crontabWrite = spawnSync("crontab", ["-"], {
65
+ encoding: "utf8",
66
+ input: newCrontab,
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ });
69
+ if (crontabWrite.status !== 0) {
70
+ const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
71
+ throw new Error(`crontab install failed: ${err}`);
72
+ }
73
+ }
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ async function runGit(cwd, args) {
5
+ try {
6
+ const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
7
+ encoding: 'utf8',
8
+ });
9
+ return stdout.trim();
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export async function getFirstCommitDate(cwd) {
16
+ const output = await runGit(cwd, ['log', '--reverse', '--format=%cI', '-n', '1']);
17
+ return output || undefined;
18
+ }
19
+ export async function getDirtyStatus(cwd) {
20
+ const output = await runGit(cwd, ['status', '--porcelain']);
21
+ if (output === null)
22
+ return undefined;
23
+ return output.length > 0;
24
+ }
25
+ export async function isInsideGitRepo(cwd) {
26
+ const output = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
27
+ return output === 'true';
28
+ }
@@ -0,0 +1,11 @@
1
+ export const DEFAULT_IGNORES = [
2
+ "**/node_modules/**",
3
+ "**/.git/**",
4
+ "**/dist/**",
5
+ "**/build/**",
6
+ "**/.next/**",
7
+ "**/target/**",
8
+ "**/vendor/**",
9
+ "**/.venv/**",
10
+ "**/venv/**",
11
+ ];
@@ -0,0 +1,37 @@
1
+ import fg from 'fast-glob';
2
+ import { DEFAULT_IGNORES } from './ignore.js';
3
+ import { getDirtyStatus, getFirstCommitDate } from './git.js';
4
+ import { readReadmeDescription } from './readme.js';
5
+ export async function computeMetadata(projectPath, hasGit) {
6
+ const nowIso = new Date().toISOString();
7
+ const entries = await fg('**/*', {
8
+ cwd: projectPath,
9
+ dot: true,
10
+ onlyFiles: true,
11
+ followSymbolicLinks: false,
12
+ ignore: DEFAULT_IGNORES,
13
+ stats: true,
14
+ });
15
+ let oldest;
16
+ let newest;
17
+ for (const entry of entries) {
18
+ const stats = entry.stats;
19
+ if (!stats)
20
+ continue;
21
+ const mtime = stats.mtimeMs;
22
+ if (oldest === undefined || mtime < oldest)
23
+ oldest = mtime;
24
+ if (newest === undefined || mtime > newest)
25
+ newest = mtime;
26
+ }
27
+ const description = await readReadmeDescription(projectPath);
28
+ const startedAt = hasGit ? await getFirstCommitDate(projectPath) : undefined;
29
+ const dirty = hasGit ? await getDirtyStatus(projectPath) : undefined;
30
+ return {
31
+ description,
32
+ startedAt: startedAt ?? (oldest ? new Date(oldest).toISOString() : undefined),
33
+ lastModifiedAt: newest ? new Date(newest).toISOString() : undefined,
34
+ lastIndexedAt: nowIso,
35
+ dirty,
36
+ };
37
+ }
@@ -0,0 +1,15 @@
1
+ export function listProjects(config) {
2
+ const projects = Object.values(config.projects);
3
+ return projects.sort((a, b) => {
4
+ if (a.rootName !== b.rootName)
5
+ return a.rootName.localeCompare(b.rootName);
6
+ return a.slug.localeCompare(b.slug);
7
+ });
8
+ }
9
+ export function projectLabel(project) {
10
+ return `${project.rootName}/${project.slug}`;
11
+ }
12
+ export function findBySlug(projects, slug) {
13
+ const normalized = slug.trim().toLowerCase();
14
+ return projects.filter((project) => project.slug.toLowerCase() === normalized);
15
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const README_NAMES = ["README.md", "readme.md", "Readme.md", "README.MD"];
4
+ export async function readReadmePath(projectPath) {
5
+ for (const name of README_NAMES) {
6
+ const candidate = path.join(projectPath, name);
7
+ try {
8
+ await fs.access(candidate);
9
+ return candidate;
10
+ }
11
+ catch {
12
+ // ignore
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ export async function readReadmeContent(projectPath) {
18
+ const readmePath = await readReadmePath(projectPath);
19
+ if (!readmePath)
20
+ return undefined;
21
+ let content = await fs.readFile(readmePath, "utf8");
22
+ // remove html tags
23
+ content = content.replace(/<[^>]*>?/g, "");
24
+ // only graph the first 300 characters, and add ... if there are more
25
+ const truncated = content.slice(0, 500);
26
+ if (content.length > 500) {
27
+ return `${truncated}...`;
28
+ }
29
+ return truncated;
30
+ }
31
+ export async function readReadmeDescription(projectPath) {
32
+ const readmePath = await readReadmePath(projectPath);
33
+ if (!readmePath)
34
+ return undefined;
35
+ const raw = await fs.readFile(readmePath, "utf8");
36
+ const lines = raw.split(/\r?\n/);
37
+ let title;
38
+ let paragraph = [];
39
+ let inCodeBlock = false;
40
+ for (const line of lines) {
41
+ const trimmed = line.trim();
42
+ if (trimmed.startsWith("```")) {
43
+ inCodeBlock = !inCodeBlock;
44
+ continue;
45
+ }
46
+ if (inCodeBlock)
47
+ continue;
48
+ if (!title && trimmed.startsWith("#")) {
49
+ title = trimmed.replace(/^#+\s*/, "").trim();
50
+ continue;
51
+ }
52
+ if (title) {
53
+ if (trimmed.length === 0) {
54
+ if (paragraph.length > 0)
55
+ break;
56
+ continue;
57
+ }
58
+ if (trimmed.startsWith("#")) {
59
+ if (paragraph.length > 0)
60
+ break;
61
+ continue;
62
+ }
63
+ paragraph.push(trimmed);
64
+ }
65
+ }
66
+ const description = paragraph.join(" ").trim();
67
+ if (description)
68
+ return description;
69
+ return title || undefined;
70
+ }
@@ -0,0 +1,93 @@
1
+ import path from "node:path";
2
+ import fg from "fast-glob";
3
+ import { DEFAULT_IGNORES } from "./ignore.js";
4
+ import { isSubpath } from "../utils/paths.js";
5
+ import { isInsideGitRepo } from "./git.js";
6
+ const README_PATTERNS = [
7
+ "**/README.md",
8
+ "**/readme.md",
9
+ "**/Readme.md",
10
+ "**/README.MD",
11
+ ];
12
+ function resolveProjectRoot(matchPath) {
13
+ const container = path.dirname(matchPath);
14
+ // if (
15
+ // path.basename(container) === "src" ||
16
+ // path.basename(container) === "app"
17
+ // ) {
18
+ // return path.dirname(container);
19
+ // }
20
+ return container;
21
+ }
22
+ function addCandidate(map, projectPath, root, flags) {
23
+ const existing = map.get(projectPath);
24
+ const base = existing ?? {
25
+ path: projectPath,
26
+ root,
27
+ hasGit: false,
28
+ hasReadme: false,
29
+ };
30
+ const next = {
31
+ ...base,
32
+ ...flags,
33
+ };
34
+ if (existing && root.length > existing.root.length) {
35
+ next.root = root;
36
+ }
37
+ map.set(projectPath, next);
38
+ }
39
+ export async function scanRoots(roots) {
40
+ const candidates = new Map();
41
+ for (const root of roots) {
42
+ // Exclude .git/** from ignores when scanning for .git directories
43
+ const gitIgnores = DEFAULT_IGNORES.filter((pattern) => pattern !== "**/.git/**");
44
+ const gitMatches = await fg("**/.git", {
45
+ cwd: root,
46
+ dot: true,
47
+ onlyDirectories: false,
48
+ onlyFiles: false,
49
+ followSymbolicLinks: false,
50
+ ignore: gitIgnores,
51
+ });
52
+ for (const match of gitMatches) {
53
+ const absMatch = path.join(root, match);
54
+ const projectRoot = resolveProjectRoot(absMatch);
55
+ if (!projectRoot.startsWith(root))
56
+ continue;
57
+ addCandidate(candidates, projectRoot, root, { hasGit: true });
58
+ }
59
+ const readmeMatches = await fg(README_PATTERNS, {
60
+ cwd: root,
61
+ dot: true,
62
+ onlyFiles: true,
63
+ followSymbolicLinks: false,
64
+ ignore: DEFAULT_IGNORES,
65
+ });
66
+ for (const match of readmeMatches) {
67
+ const absMatch = path.join(root, match);
68
+ const projectRoot = resolveProjectRoot(absMatch);
69
+ if (!projectRoot.startsWith(root))
70
+ continue;
71
+ addCandidate(candidates, projectRoot, root, { hasReadme: true });
72
+ }
73
+ }
74
+ const list = Array.from(candidates.values());
75
+ list.sort((a, b) => a.path.length - b.path.length);
76
+ const filtered = [];
77
+ for (const candidate of list) {
78
+ const nested = filtered.some((kept) => isSubpath(candidate.path, kept.path));
79
+ if (!nested) {
80
+ filtered.push(candidate);
81
+ }
82
+ }
83
+ // Check git status for all candidates (including those inside parent repos)
84
+ for (const candidate of filtered) {
85
+ if (!candidate.hasGit) {
86
+ const hasGit = await isInsideGitRepo(candidate.path);
87
+ if (hasGit) {
88
+ candidate.hasGit = true;
89
+ }
90
+ }
91
+ }
92
+ return filtered;
93
+ }
@@ -0,0 +1,20 @@
1
+ import Fuse from 'fuse.js';
2
+ export function searchProjects(projects, query) {
3
+ if (!query.trim())
4
+ return projects;
5
+ const fuse = new Fuse(projects, {
6
+ keys: [
7
+ 'slug',
8
+ 'name',
9
+ 'path',
10
+ 'rootName',
11
+ 'root',
12
+ 'user.tags',
13
+ 'user.description',
14
+ 'auto.description'
15
+ ],
16
+ includeScore: true,
17
+ threshold: 0.4,
18
+ });
19
+ return fuse.search(query).map((result) => result.item);
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { marked } from 'marked';
4
+ import TerminalRenderer from 'marked-terminal';
5
+ const renderer = new TerminalRenderer();
6
+ export function Markdown({ content }) {
7
+ marked.setOptions({ renderer });
8
+ const output = marked.parse(content).trim();
9
+ return _jsx(Text, { children: output });
10
+ }