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,117 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import { readConfig } from '../lib/config.js';
4
+ import path from 'node:path';
5
+ import { listProjects } from '../lib/projects.js';
6
+ import type { Project, RootConfig } from '../lib/types.js';
7
+ import { emitSelection } from '../utils/output.js';
8
+ import { promptSelect } from '../ui/prompt.js';
9
+ import { SelectRow } from '../ui/select.js';
10
+
11
+ const MAX_ROWS = 18;
12
+ const GROUP_COLORS = ['#22d3ee', '#34d399', '#facc15', '#c084fc', '#60a5fa', '#f87171'];
13
+
14
+ function groupColor(index: number): string {
15
+ return GROUP_COLORS[index % GROUP_COLORS.length];
16
+ }
17
+
18
+ function relativePath(projectPath: string, root: string): string {
19
+ const rel = path.relative(root, projectPath);
20
+ return rel || path.basename(projectPath);
21
+ }
22
+
23
+ function formatLabel(slug: string, rootName: string, relPath: string): string {
24
+ return `${slug} [${rootName}] ${relPath}`;
25
+ }
26
+
27
+ function orderedGroupsByConfigRoots(
28
+ projects: Project[],
29
+ roots: RootConfig[],
30
+ ): { rootName: string; items: Project[] }[] {
31
+ const groupsByRoot = new Map<string, { rootName: string; items: Project[] }>();
32
+ for (const project of projects) {
33
+ const key = project.root;
34
+ if (!groupsByRoot.has(key)) {
35
+ groupsByRoot.set(key, { rootName: project.rootName, items: [] });
36
+ }
37
+ groupsByRoot.get(key)!.items.push(project);
38
+ }
39
+
40
+ const ordered: { rootName: string; items: Project[] }[] = [];
41
+ const seen = new Set<string>();
42
+
43
+ for (const rootConfig of roots) {
44
+ const group = groupsByRoot.get(rootConfig.path);
45
+ if (group) {
46
+ seen.add(rootConfig.path);
47
+ ordered.push({ rootName: rootConfig.name, items: group.items });
48
+ }
49
+ }
50
+
51
+ const remaining = [...groupsByRoot.entries()]
52
+ .filter(([rootPath]) => !seen.has(rootPath))
53
+ .sort((a, b) => a[1].rootName.localeCompare(b[1].rootName));
54
+ for (const [, group] of remaining) {
55
+ ordered.push(group);
56
+ }
57
+
58
+ return ordered;
59
+ }
60
+
61
+ export function registerList(program: Command): void {
62
+ program
63
+ .command('list')
64
+ .description('List projects')
65
+ .option('--plain', 'Print a non-interactive list')
66
+ .option('--json', 'Print JSON output')
67
+ .option('--print', 'Print selected path only')
68
+ .action(async (options: { plain?: boolean; json?: boolean; print?: boolean }) => {
69
+ const config = await readConfig();
70
+ const projects = listProjects(config);
71
+
72
+ if (options.json) {
73
+ process.stdout.write(JSON.stringify(projects, null, 2));
74
+ process.stdout.write('\n');
75
+ return;
76
+ }
77
+
78
+ if (!process.stdin.isTTY || options.plain) {
79
+ if (projects.length === 0) {
80
+ process.stdout.write('No projects indexed. Run bet update.\n');
81
+ return;
82
+ }
83
+ const orderedGroups = orderedGroupsByConfigRoots(projects, config.roots);
84
+ let groupIndex = 0;
85
+ for (const { rootName, items } of orderedGroups) {
86
+ const color = groupColor(groupIndex++);
87
+ const label = chalk.hex(color).bold(`[${rootName}]`);
88
+ process.stdout.write(`${label}\n`);
89
+ for (const project of items) {
90
+ const rel = relativePath(project.path, project.root);
91
+ process.stdout.write(` ${chalk.reset(formatLabel(project.slug, project.rootName, rel))}\n`);
92
+ }
93
+ }
94
+ return;
95
+ }
96
+
97
+ const orderedGroups = orderedGroupsByConfigRoots(projects, config.roots);
98
+ const rows: SelectRow<Project>[] = [];
99
+ let groupIndex = 0;
100
+ for (const { rootName, items } of orderedGroups) {
101
+ rows.push({ type: 'group', label: rootName, color: groupColor(groupIndex++) });
102
+ for (const project of items) {
103
+ const rel = relativePath(project.path, project.root);
104
+ rows.push({
105
+ type: 'item',
106
+ label: formatLabel(project.slug, project.rootName, rel),
107
+ value: project,
108
+ });
109
+ }
110
+ }
111
+
112
+ const selected = await promptSelect(rows, { title: 'Projects', maxRows: MAX_ROWS });
113
+ if (!selected) return;
114
+
115
+ emitSelection(selected.value, { printOnly: options.print });
116
+ });
117
+ }
@@ -0,0 +1,47 @@
1
+ import { Command } from 'commander';
2
+ import { readConfig } from '../lib/config.js';
3
+ import { findBySlug, listProjects, projectLabel } from '../lib/projects.js';
4
+ import { promptSelect } from '../ui/prompt.js';
5
+ import { SelectEntry } from '../ui/select.js';
6
+
7
+ export function registerPath(program: Command): void {
8
+ program
9
+ .command('path <slug>')
10
+ .description('Print the absolute path of a project by name')
11
+ .action(async (slug: string) => {
12
+ const config = await readConfig();
13
+ const projects = listProjects(config);
14
+ const matches = findBySlug(projects, slug);
15
+
16
+ if (matches.length === 0) {
17
+ process.stderr.write(`No project found for slug "${slug}".\n`);
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+
22
+ let project = matches[0];
23
+ if (matches.length > 1) {
24
+ if (!process.stdin.isTTY) {
25
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
26
+ for (const item of matches) {
27
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
28
+ }
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+
33
+ const items: SelectEntry<(typeof matches)[number]>[] = matches.map((item) => ({
34
+ label: projectLabel(item),
35
+ hint: item.path,
36
+ value: item,
37
+ type: 'item',
38
+ }));
39
+
40
+ const selected = await promptSelect(items, { title: `Select ${slug}` });
41
+ if (!selected) return;
42
+ project = selected.value;
43
+ }
44
+
45
+ process.stdout.write(`${project.path}\n`);
46
+ });
47
+ }
@@ -0,0 +1,79 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import { readConfig } from '../lib/config.js';
4
+ import path from 'node:path';
5
+ import { listProjects } from '../lib/projects.js';
6
+ import { searchProjects } from '../lib/search.js';
7
+ import { emitSelection } from '../utils/output.js';
8
+ import { promptSearch } from '../ui/prompt.js';
9
+ import { SelectEntry } from '../ui/select.js';
10
+
11
+ const MAX_ROWS = 18;
12
+
13
+ function relativePath(projectPath: string, root: string): string {
14
+ const rel = path.relative(root, projectPath);
15
+ return rel || path.basename(projectPath);
16
+ }
17
+
18
+ function formatLabel(slug: string, rootName: string, relPath: string): string {
19
+ return `${slug} [${rootName}] ${relPath}`;
20
+ }
21
+
22
+ export function registerSearch(program: Command): void {
23
+ program
24
+ .command('search [query]')
25
+ .description('Search projects')
26
+ .option('--plain', 'Print a non-interactive list')
27
+ .option('--json', 'Print JSON output')
28
+ .option('--print', 'Print selected path only')
29
+ .option('--limit <n>', 'Limit results', '50')
30
+ .action(async (query = '', options: { plain?: boolean; json?: boolean; print?: boolean; limit?: string }) => {
31
+ const config = await readConfig();
32
+ const projects = listProjects(config);
33
+ const matches = searchProjects(projects, query);
34
+ const limit = Number.parseInt(options.limit ?? '50', 10);
35
+ const results = Number.isNaN(limit) ? matches : matches.slice(0, limit);
36
+
37
+ if (options.json) {
38
+ process.stdout.write(JSON.stringify(results, null, 2));
39
+ process.stdout.write('\n');
40
+ return;
41
+ }
42
+
43
+ if (!process.stdin.isTTY || options.plain) {
44
+ if (results.length === 0) {
45
+ process.stdout.write('No matches.\n');
46
+ return;
47
+ }
48
+ for (const project of results) {
49
+ const rel = relativePath(project.path, project.root);
50
+ process.stdout.write(`${formatLabel(project.slug, project.rootName, rel)}\n`);
51
+ }
52
+ return;
53
+ }
54
+
55
+ const allItems: SelectEntry<typeof projects[number]>[] = projects.map((project) => {
56
+ const rel = relativePath(project.path, project.root);
57
+ return {
58
+ type: 'item',
59
+ label: formatLabel(project.slug, project.rootName, rel),
60
+ value: project,
61
+ };
62
+ });
63
+
64
+ const selected = await promptSearch(allItems, {
65
+ title: 'Search',
66
+ initialQuery: query,
67
+ maxRows: MAX_ROWS,
68
+ filter: (items, q) => {
69
+ if (!q.trim()) return items;
70
+ const matches = searchProjects(projects, q);
71
+ const matchSet = new Set(matches.map((project) => project.path));
72
+ return items.filter((item) => matchSet.has(item.value.path));
73
+ },
74
+ });
75
+ if (!selected) return;
76
+
77
+ emitSelection(selected.value, { printOnly: options.print });
78
+ });
79
+ }
@@ -0,0 +1,22 @@
1
+ import { Command } from 'commander';
2
+
3
+ const SHELL_SNIPPET = [
4
+ 'bet() {',
5
+ ' local out',
6
+ ' out="$(BET_EVAL=1 command bet "$@")" || return $?',
7
+ ' if [[ "$out" == __BET_EVAL__* ]]; then',
8
+ ' eval "${out#__BET_EVAL__}"',
9
+ ' elif [[ -n "$out" ]]; then',
10
+ ' printf "%s\\n" "$out"',
11
+ ' fi',
12
+ '}',
13
+ ].join('\n');
14
+
15
+ export function registerShell(program: Command): void {
16
+ program
17
+ .command('shell')
18
+ .description('Print shell integration for cd support')
19
+ .action(() => {
20
+ process.stdout.write(`${SHELL_SNIPPET}\n`);
21
+ });
22
+ }
@@ -0,0 +1,170 @@
1
+ import path from "node:path";
2
+ import readline from "node:readline";
3
+ import { Command } from "commander";
4
+ import { readConfig, resolveRoots, writeConfig } from "../lib/config.js";
5
+ import { normalizeAbsolute } from "../utils/paths.js";
6
+ import { installHourlyUpdateCron } from "../lib/cron.js";
7
+ import { scanRoots } from "../lib/scan.js";
8
+ import { computeMetadata } from "../lib/metadata.js";
9
+ import { Config, Project, RootConfig } from "../lib/types.js";
10
+ import { isInsideGitRepo } from "../lib/git.js";
11
+
12
+ function parseRoots(value?: string): string[] | undefined {
13
+ if (!value) return undefined;
14
+ return value
15
+ .split(",")
16
+ .map((root) => root.trim())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ function pathsToRootConfigs(paths: string[]): RootConfig[] {
21
+ return paths.map((p) => {
22
+ const abs = normalizeAbsolute(p);
23
+ return { path: abs, name: path.basename(abs) };
24
+ });
25
+ }
26
+
27
+ export function willOverrideRoots(
28
+ providedRootConfigs: RootConfig[] | undefined,
29
+ configRoots: RootConfig[],
30
+ ): boolean {
31
+ return !!(
32
+ providedRootConfigs !== undefined &&
33
+ configRoots.length > 0
34
+ );
35
+ }
36
+
37
+ function projectSlug(pathName: string): string {
38
+ const folderName = path.basename(pathName);
39
+ if (folderName === "src" || folderName === "app") {
40
+ return path.basename(path.dirname(pathName));
41
+ }
42
+ return folderName;
43
+ }
44
+
45
+ async function promptYesNo(question: string, defaultNo = true): Promise<boolean> {
46
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
47
+ return new Promise((resolve) => {
48
+ const defaultHint = defaultNo ? "y/N" : "Y/n";
49
+ rl.question(question + " [" + defaultHint + "] ", (answer) => {
50
+ rl.close();
51
+ const trimmed = answer.trim().toLowerCase();
52
+ if (!trimmed) {
53
+ resolve(!defaultNo);
54
+ return;
55
+ }
56
+ resolve(trimmed === "y" || trimmed === "yes");
57
+ });
58
+ });
59
+ }
60
+
61
+ export function registerUpdate(program: Command): void {
62
+ program
63
+ .command("update")
64
+ .description("Scan roots and update the project index")
65
+ .option("--roots <paths>", "Comma-separated list of roots to scan")
66
+ .option("--force", "Allow overriding configured roots when not in TTY")
67
+ .option("--cron", "Install an hourly cron job to run bet update")
68
+ .action(async (options: { roots?: string; force?: boolean; cron?: boolean }) => {
69
+ const config = await readConfig();
70
+ const providedPaths = parseRoots(options.roots);
71
+ const providedRootConfigs = providedPaths
72
+ ? pathsToRootConfigs(providedPaths)
73
+ : undefined;
74
+ const configRoots = config.roots.length > 0 ? config.roots : undefined;
75
+ const rootsToUse = providedRootConfigs ?? configRoots;
76
+
77
+ if (!rootsToUse || rootsToUse.length === 0) {
78
+ process.stderr.write(
79
+ "Error: No roots specified. Please provide roots using --roots option.\n" +
80
+ "Example: bet update --roots /path/to/your/code\n",
81
+ );
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ const willOverride = willOverrideRoots(providedRootConfigs, config.roots);
87
+
88
+ if (willOverride) {
89
+ process.stderr.write(
90
+ "Warning: --roots will override your configured roots.\n" +
91
+ " Configured: " +
92
+ configRoots!.map((r) => r.path).join(", ") +
93
+ "\n Provided: " +
94
+ providedRootConfigs!.map((r) => r.path).join(", ") +
95
+ "\n",
96
+ );
97
+ if (!process.stdin.isTTY) {
98
+ if (!options.force) {
99
+ process.stderr.write(
100
+ "Error: Refusing to override without confirmation. Run interactively or use --force.\n",
101
+ );
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ } else {
106
+ const confirmed = await promptYesNo("Continue?", true);
107
+ if (!confirmed) {
108
+ process.stderr.write("Aborted.\n");
109
+ return;
110
+ }
111
+ }
112
+ }
113
+
114
+ const rootsResolved = resolveRoots(rootsToUse);
115
+ const rootPaths = rootsResolved.map((r) => r.path);
116
+ const candidates = await scanRoots(rootPaths);
117
+ const projects: Record<string, Project> = {};
118
+
119
+ for (const candidate of candidates) {
120
+ const hasGit = await isInsideGitRepo(candidate.path);
121
+ const auto = await computeMetadata(candidate.path, hasGit);
122
+ const slug = projectSlug(candidate.path);
123
+ const existing = config.projects[candidate.path];
124
+ const rootConfig = rootsResolved.find((r) => r.path === candidate.root);
125
+ const rootName = rootConfig?.name ?? path.basename(candidate.root);
126
+
127
+ const project: Project = {
128
+ id: candidate.path,
129
+ slug,
130
+ name: slug,
131
+ path: candidate.path,
132
+ root: candidate.root,
133
+ rootName,
134
+ hasGit,
135
+ hasReadme: candidate.hasReadme,
136
+ auto,
137
+ user: existing?.user,
138
+ };
139
+
140
+ projects[candidate.path] = project;
141
+ }
142
+
143
+ const nextConfig: Config = {
144
+ version: config.version ?? 1,
145
+ roots: rootsResolved,
146
+ projects,
147
+ };
148
+
149
+ await writeConfig(nextConfig);
150
+
151
+ process.stdout.write(
152
+ "Indexed " +
153
+ Object.keys(projects).length +
154
+ " projects from " +
155
+ rootsResolved.length +
156
+ " root(s).\n",
157
+ );
158
+
159
+ if (options.cron) {
160
+ const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
161
+ ? process.argv[1]
162
+ : path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
163
+ await installHourlyUpdateCron({
164
+ nodePath: process.execPath,
165
+ entryScriptPath,
166
+ });
167
+ process.stdout.write("Installed hourly cron job for bet update.\n");
168
+ }
169
+ });
170
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
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
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("bet")
15
+ .description("Explore and jump between local projects.")
16
+ .version("0.1.0");
17
+
18
+ registerUpdate(program);
19
+ registerList(program);
20
+ registerSearch(program);
21
+ registerInfo(program);
22
+ registerGo(program);
23
+ registerPath(program);
24
+ registerShell(program);
25
+
26
+ program.parseAsync(process.argv);
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { AppConfig, Config, ProjectsConfig, RootConfig } from "./types.js";
5
+ import { normalizeAbsolute } from "../utils/paths.js";
6
+
7
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME
8
+ ? path.join(process.env.XDG_CONFIG_HOME, "bet")
9
+ : path.join(os.homedir(), ".config", "bet");
10
+
11
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
12
+ const PROJECTS_PATH = path.join(CONFIG_DIR, "projects.json");
13
+
14
+ const DEFAULT_APP_CONFIG: AppConfig = {
15
+ version: 1,
16
+ roots: [],
17
+ };
18
+
19
+ const DEFAULT_PROJECTS_CONFIG: ProjectsConfig = {
20
+ projects: {},
21
+ };
22
+
23
+ const DEFAULT_CONFIG: Config = {
24
+ ...DEFAULT_APP_CONFIG,
25
+ ...DEFAULT_PROJECTS_CONFIG,
26
+ };
27
+
28
+ export function getConfigPath(): string {
29
+ return CONFIG_PATH;
30
+ }
31
+
32
+ export function getProjectsPath(): string {
33
+ return PROJECTS_PATH;
34
+ }
35
+
36
+ function normalizeRoots(parsedRoots: unknown): RootConfig[] {
37
+ if (!Array.isArray(parsedRoots)) return [];
38
+ const result: RootConfig[] = [];
39
+ for (const r of parsedRoots) {
40
+ if (typeof r === "string") {
41
+ const abs = normalizeAbsolute(r);
42
+ result.push({ path: abs, name: path.basename(abs) });
43
+ } else if (r && typeof r === "object" && "path" in r && typeof (r as RootConfig).path === "string") {
44
+ const root = r as RootConfig;
45
+ const abs = normalizeAbsolute(root.path);
46
+ const name = (root.name?.trim() || path.basename(abs));
47
+ result.push({ path: abs, name });
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+
53
+ async function readAppConfig(): Promise<AppConfig> {
54
+ try {
55
+ const raw = await fs.readFile(CONFIG_PATH, "utf8");
56
+ const parsed = JSON.parse(raw) as { version?: number; roots?: unknown };
57
+ const roots = normalizeRoots(parsed.roots ?? []);
58
+ return {
59
+ ...DEFAULT_APP_CONFIG,
60
+ version: parsed.version ?? 1,
61
+ roots,
62
+ };
63
+ } catch (error) {
64
+ return { ...DEFAULT_APP_CONFIG };
65
+ }
66
+ }
67
+
68
+ async function readProjectsConfig(): Promise<ProjectsConfig> {
69
+ try {
70
+ const raw = await fs.readFile(PROJECTS_PATH, "utf8");
71
+ const parsed = JSON.parse(raw) as ProjectsConfig;
72
+ return {
73
+ projects: parsed.projects ?? {},
74
+ };
75
+ } catch (error) {
76
+ return { ...DEFAULT_PROJECTS_CONFIG };
77
+ }
78
+ }
79
+
80
+ function normalizeProjectRootName(project: { root: string; rootName?: string; group?: string }, roots: RootConfig[]): string {
81
+ if (project.rootName?.trim()) return project.rootName.trim();
82
+ const matched = roots.find((r) => r.path === project.root);
83
+ if (matched) return matched.name;
84
+ return path.basename(project.root);
85
+ }
86
+
87
+ export async function readConfig(): Promise<Config> {
88
+ const [appConfig, projectsConfig] = await Promise.all([
89
+ readAppConfig(),
90
+ readProjectsConfig(),
91
+ ]);
92
+ const projects = projectsConfig.projects;
93
+ const normalizedProjects: Record<string, import("./types.js").Project> = {};
94
+ for (const [id, p] of Object.entries(projects)) {
95
+ const rootName = normalizeProjectRootName(p, appConfig.roots);
96
+ const { group: _group, ...rest } = p as import("./types.js").Project & { group?: string };
97
+ normalizedProjects[id] = { ...rest, rootName } as import("./types.js").Project;
98
+ }
99
+ return {
100
+ ...appConfig,
101
+ projects: normalizedProjects,
102
+ };
103
+ }
104
+
105
+ async function writeAppConfig(appConfig: AppConfig): Promise<void> {
106
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
107
+ const formatted = JSON.stringify(appConfig, null, 2);
108
+ await fs.writeFile(CONFIG_PATH, formatted, "utf8");
109
+ }
110
+
111
+ async function writeProjectsConfig(
112
+ projectsConfig: ProjectsConfig,
113
+ ): Promise<void> {
114
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
115
+ const formatted = JSON.stringify(projectsConfig, null, 2);
116
+ await fs.writeFile(PROJECTS_PATH, formatted, "utf8");
117
+ }
118
+
119
+ export async function writeConfig(config: Config): Promise<void> {
120
+ const appConfig: AppConfig = {
121
+ version: config.version,
122
+ roots: config.roots,
123
+ };
124
+ const projectsConfig: ProjectsConfig = {
125
+ projects: config.projects,
126
+ };
127
+ await Promise.all([
128
+ writeAppConfig(appConfig),
129
+ writeProjectsConfig(projectsConfig),
130
+ ]);
131
+ }
132
+
133
+ export function resolveRoots(inputRoots: RootConfig[]): RootConfig[] {
134
+ const seen = new Set<string>();
135
+ const resolved: RootConfig[] = [];
136
+ for (const root of inputRoots) {
137
+ const abs = normalizeAbsolute(root.path);
138
+ if (!seen.has(abs)) {
139
+ seen.add(abs);
140
+ resolved.push({ path: abs, name: root.name?.trim() || path.basename(abs) });
141
+ }
142
+ }
143
+ return resolved;
144
+ }