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,119 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import chalk from 'chalk';
3
+ import { Box, Text, useInput } from 'ink';
4
+
5
+ export type SelectGroup = {
6
+ type: 'group';
7
+ label: string;
8
+ color?: string;
9
+ };
10
+
11
+ export type SelectEntry<T> = {
12
+ type: 'item';
13
+ label: string;
14
+ value: T;
15
+ hint?: string;
16
+ };
17
+
18
+ export type SelectRow<T> = SelectGroup | SelectEntry<T>;
19
+
20
+ type SelectListProps<T> = {
21
+ title?: string;
22
+ items: SelectRow<T>[];
23
+ onSelect: (item: SelectEntry<T>) => void;
24
+ onCancel?: () => void;
25
+ maxRows?: number;
26
+ };
27
+
28
+ const DEFAULT_MAX_ROWS = 18;
29
+
30
+ export function SelectList<T>({
31
+ title,
32
+ items,
33
+ onSelect,
34
+ onCancel,
35
+ maxRows = DEFAULT_MAX_ROWS,
36
+ }: SelectListProps<T>): React.ReactElement {
37
+ const selectableIndices = useMemo(
38
+ () => items.map((item, index) => (item.type === 'item' ? index : -1)).filter((idx) => idx >= 0),
39
+ [items]
40
+ );
41
+ const [cursor, setCursor] = useState(0);
42
+
43
+ useInput((input, key) => {
44
+ if (selectableIndices.length === 0) {
45
+ if (key.escape || (key.ctrl && input === 'c')) {
46
+ onCancel?.();
47
+ }
48
+ return;
49
+ }
50
+
51
+ if (key.upArrow || input === 'k') {
52
+ setCursor((prev) => (prev - 1 + selectableIndices.length) % selectableIndices.length);
53
+ } else if (key.downArrow || input === 'j') {
54
+ setCursor((prev) => (prev + 1) % selectableIndices.length);
55
+ } else if (key.return) {
56
+ const itemIndex = selectableIndices[cursor];
57
+ const item = items[itemIndex];
58
+ if (item && item.type === 'item') {
59
+ onSelect(item);
60
+ }
61
+ } else if (key.escape || (key.ctrl && input === 'c')) {
62
+ onCancel?.();
63
+ }
64
+ });
65
+
66
+ if (items.length === 0) {
67
+ return (
68
+ <Box flexDirection="column">
69
+ {title && <Text>{chalk.bold(title)}</Text>}
70
+ <Text>No results.</Text>
71
+ <Box marginTop={1}>
72
+ <Text>{chalk.dim('Press Esc to exit.')}</Text>
73
+ </Box>
74
+ </Box>
75
+ );
76
+ }
77
+
78
+ const selectedRowIndex = selectableIndices[cursor] ?? 0;
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
+ {windowed.map((row, idx) => {
92
+ const absoluteIndex = windowStart + idx;
93
+ const selected = row.type === 'item' && absoluteIndex === selectedRowIndex;
94
+
95
+ if (row.type === 'group') {
96
+ const colored = row.color ? chalk.hex(row.color)(`[${row.label}]`) : `[${row.label}]`;
97
+ return (
98
+ <Box key={`group-${absoluteIndex}`} marginTop={idx === 0 ? 0 : 1}>
99
+ <Text>{chalk.bold(colored)}</Text>
100
+ </Box>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <Box key={`item-${absoluteIndex}`}>
106
+ <Text>
107
+ {selected ? chalk.cyan.bold('› ') : ' '}
108
+ {selected ? chalk.cyan.bold(row.label) : row.label}
109
+ </Text>
110
+ {row.hint ? <Text>{chalk.dim(` ${row.hint}`)}</Text> : null}
111
+ </Box>
112
+ );
113
+ })}
114
+ <Box marginTop={1}>
115
+ <Text>{chalk.dim('Use ↑/↓ or j/k. Enter to select. Esc to cancel.')}</Text>
116
+ </Box>
117
+ </Box>
118
+ );
119
+ }
@@ -0,0 +1,380 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { sha1 } from "object-hash";
4
+
5
+ /* Table */
6
+
7
+ type Scalar = string | number | boolean | null | undefined;
8
+
9
+ type ScalarDict = {
10
+ [key: string]: Scalar;
11
+ };
12
+
13
+ export type CellProps = React.PropsWithChildren<{ column: number }>;
14
+
15
+ export type TableProps<T extends ScalarDict> = {
16
+ /**
17
+ * List of values (rows).
18
+ */
19
+ data: T[];
20
+ /**
21
+ * Columns that we should display in the table.
22
+ */
23
+ columns: (keyof T)[];
24
+ /**
25
+ * Cell padding.
26
+ */
27
+ padding: number;
28
+ /**
29
+ * Header component.
30
+ */
31
+ header: (props: React.PropsWithChildren<{}>) => React.ReactElement;
32
+ /**
33
+ * Component used to render a cell in the table.
34
+ */
35
+ cell: (props: CellProps) => React.ReactElement;
36
+ /**
37
+ * Component used to render the skeleton of the table.
38
+ */
39
+ skeleton: (props: React.PropsWithChildren<{}>) => React.ReactElement;
40
+ };
41
+
42
+ /* Table */
43
+
44
+ export default class Table<T extends ScalarDict> extends React.Component<
45
+ Pick<TableProps<T>, "data"> & Partial<TableProps<T>>
46
+ > {
47
+ /* Config */
48
+
49
+ /**
50
+ * Merges provided configuration with defaults.
51
+ */
52
+ getConfig(): TableProps<T> {
53
+ return {
54
+ data: this.props.data,
55
+ columns: this.props.columns || this.getDataKeys(),
56
+ padding: this.props.padding || 1,
57
+ header: this.props.header || Header,
58
+ cell: this.props.cell || Cell,
59
+ skeleton: this.props.skeleton || Skeleton,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Gets all keyes used in data by traversing through the data.
65
+ */
66
+ getDataKeys(): (keyof T)[] {
67
+ let keys = new Set<keyof T>();
68
+
69
+ // Collect all the keys.
70
+ for (const data of this.props.data) {
71
+ for (const key in data) {
72
+ keys.add(key);
73
+ }
74
+ }
75
+
76
+ return Array.from(keys);
77
+ }
78
+
79
+ /**
80
+ * Calculates the width of each column by finding
81
+ * the longest value in a cell of a particular column.
82
+ *
83
+ * Returns a list of column names and their widths.
84
+ */
85
+ getColumns(): Column<T>[] {
86
+ const { columns, padding } = this.getConfig();
87
+
88
+ const widths: Column<T>[] = columns.map((key) => {
89
+ const header = String(key).length;
90
+ /* Get the width of each cell in the column */
91
+ const data = this.props.data.map((data) => {
92
+ const value = data[key];
93
+
94
+ if (value == undefined || value == null) return 0;
95
+ return String(value).length;
96
+ });
97
+
98
+ const width = Math.max(...data, header) + padding * 2;
99
+
100
+ /* Construct a cell */
101
+ return {
102
+ column: key,
103
+ width: width,
104
+ key: String(key),
105
+ };
106
+ });
107
+
108
+ return widths;
109
+ }
110
+
111
+ /**
112
+ * Returns a (data) row representing the headings.
113
+ */
114
+ getHeadings(): Partial<T> {
115
+ const { columns } = this.getConfig();
116
+
117
+ const headings: Partial<T> = columns.reduce(
118
+ (acc, column) => ({ ...acc, [column]: column }),
119
+ {},
120
+ );
121
+
122
+ return headings;
123
+ }
124
+
125
+ /* Rendering utilities */
126
+
127
+ // The top most line in the table.
128
+ header = row<T>({
129
+ cell: this.getConfig().skeleton,
130
+ padding: this.getConfig().padding,
131
+ skeleton: {
132
+ component: this.getConfig().skeleton,
133
+ // chars
134
+ line: "─",
135
+ left: "┌",
136
+ right: "┐",
137
+ cross: "┬",
138
+ },
139
+ });
140
+
141
+ // The line with column names.
142
+ heading = row<T>({
143
+ cell: this.getConfig().header,
144
+ padding: this.getConfig().padding,
145
+ skeleton: {
146
+ component: this.getConfig().skeleton,
147
+ // chars
148
+ line: " ",
149
+ left: "│",
150
+ right: "│",
151
+ cross: "│",
152
+ },
153
+ });
154
+
155
+ // The line that separates rows.
156
+ separator = row<T>({
157
+ cell: this.getConfig().skeleton,
158
+ padding: this.getConfig().padding,
159
+ skeleton: {
160
+ component: this.getConfig().skeleton,
161
+ // chars
162
+ line: "─",
163
+ left: "├",
164
+ right: "┤",
165
+ cross: "┼",
166
+ },
167
+ });
168
+
169
+ // The row with the data.
170
+ data = row<T>({
171
+ cell: this.getConfig().cell,
172
+ padding: this.getConfig().padding,
173
+ skeleton: {
174
+ component: this.getConfig().skeleton,
175
+ // chars
176
+ line: " ",
177
+ left: "│",
178
+ right: "│",
179
+ cross: "│",
180
+ },
181
+ });
182
+
183
+ // The bottom most line of the table.
184
+ footer = row<T>({
185
+ cell: this.getConfig().skeleton,
186
+ padding: this.getConfig().padding,
187
+ skeleton: {
188
+ component: this.getConfig().skeleton,
189
+ // chars
190
+ line: "─",
191
+ left: "└",
192
+ right: "┘",
193
+ cross: "┴",
194
+ },
195
+ });
196
+
197
+ /* Render */
198
+
199
+ render() {
200
+ /* Data */
201
+ const columns = this.getColumns();
202
+ const headings = this.getHeadings();
203
+
204
+ /**
205
+ * Render the table line by line.
206
+ */
207
+ return (
208
+ <Box flexDirection="column">
209
+ {/* Header */}
210
+ {this.header({ key: "header", columns, data: {} })}
211
+ {this.heading({ key: "heading", columns, data: headings })}
212
+ {/* Data */}
213
+ {this.props.data.map((row, index) => {
214
+ // Calculate the hash of the row based on its value and position
215
+ const key = `row-${sha1(row)}-${index}`;
216
+
217
+ // Construct a row.
218
+ return (
219
+ <Box flexDirection="column" key={key}>
220
+ {this.separator({ key: `separator-${key}`, columns, data: {} })}
221
+ {this.data({ key: `data-${key}`, columns, data: row })}
222
+ </Box>
223
+ );
224
+ })}
225
+ {/* Footer */}
226
+ {this.footer({ key: "footer", columns, data: {} })}
227
+ </Box>
228
+ );
229
+ }
230
+ }
231
+
232
+ /* Helper components */
233
+
234
+ type RowConfig = {
235
+ /**
236
+ * Component used to render cells.
237
+ */
238
+ cell: (props: CellProps) => React.ReactElement;
239
+ /**
240
+ * Tells the padding of each cell.
241
+ */
242
+ padding: number;
243
+ /**
244
+ * Component used to render skeleton in the row.
245
+ */
246
+ skeleton: {
247
+ component: (props: React.PropsWithChildren<{}>) => React.ReactElement;
248
+ /**
249
+ * Characters used in skeleton.
250
+ * | |
251
+ * (left)-(line)-(cross)-(line)-(right)
252
+ * | |
253
+ */
254
+ left: string;
255
+ right: string;
256
+ cross: string;
257
+ line: string;
258
+ };
259
+ };
260
+
261
+ type RowProps<T extends ScalarDict> = {
262
+ key: string;
263
+ data: Partial<T>;
264
+ columns: Column<T>[];
265
+ };
266
+
267
+ type Column<T> = {
268
+ key: string;
269
+ column: keyof T;
270
+ width: number;
271
+ };
272
+
273
+ /**
274
+ * Constructs a Row element from the configuration.
275
+ */
276
+ function row<T extends ScalarDict>(
277
+ config: RowConfig,
278
+ ): (props: RowProps<T>) => React.ReactElement {
279
+ /* This is a component builder. We return a function. */
280
+
281
+ const skeleton = config.skeleton;
282
+
283
+ /* Row */
284
+ return (props) => (
285
+ <Box flexDirection="row">
286
+ {/* Left */}
287
+ <skeleton.component>{skeleton.left}</skeleton.component>
288
+ {/* Data */}
289
+ {...intersperse(
290
+ (i) => {
291
+ const key = `${props.key}-hseparator-${i}`;
292
+
293
+ // The horizontal separator.
294
+ return (
295
+ <skeleton.component key={key}>{skeleton.cross}</skeleton.component>
296
+ );
297
+ },
298
+
299
+ // Values.
300
+ props.columns.map((column, colI) => {
301
+ // content
302
+ const value = props.data[column.column];
303
+
304
+ if (value == undefined || value == null) {
305
+ const key = `${props.key}-empty-${column.key}`;
306
+
307
+ return (
308
+ <config.cell key={key} column={colI}>
309
+ {skeleton.line.repeat(column.width)}
310
+ </config.cell>
311
+ );
312
+ } else {
313
+ const key = `${props.key}-cell-${column.key}`;
314
+
315
+ // margins
316
+ const ml = config.padding;
317
+ const mr = column.width - String(value).length - config.padding;
318
+
319
+ return (
320
+ /* prettier-ignore */
321
+ <config.cell key={key} column={colI}>
322
+ {`${skeleton.line.repeat(ml)}${String(value)}${skeleton.line.repeat(mr)}`}
323
+ </config.cell>
324
+ );
325
+ }
326
+ }),
327
+ )}
328
+ {/* Right */}
329
+ <skeleton.component>{skeleton.right}</skeleton.component>
330
+ </Box>
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Renders the header of a table.
336
+ */
337
+ export function Header(props: React.PropsWithChildren<{}>) {
338
+ return (
339
+ <Text bold color="blue">
340
+ {props.children}
341
+ </Text>
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Renders a cell in the table.
347
+ */
348
+ export function Cell(props: CellProps) {
349
+ return <Text>{props.children}</Text>;
350
+ }
351
+
352
+ /**
353
+ * Redners the scaffold of the table.
354
+ */
355
+ export function Skeleton(props: React.PropsWithChildren<{}>) {
356
+ return <Text bold>{props.children}</Text>;
357
+ }
358
+
359
+ /* Utility functions */
360
+
361
+ /**
362
+ * Intersperses a list of elements with another element.
363
+ */
364
+ function intersperse<T, I>(
365
+ intersperser: (index: number) => I,
366
+ elements: T[],
367
+ ): (T | I)[] {
368
+ // Intersparse by reducing from left.
369
+ let interspersed: (T | I)[] = elements.reduce(
370
+ (acc, element, index) => {
371
+ // Only add element if it's the first one.
372
+ if (acc.length === 0) return [element];
373
+ // Add the intersparser as well otherwise.
374
+ return [...acc, intersperser(index), element];
375
+ },
376
+ [] as (T | I)[],
377
+ );
378
+
379
+ return interspersed;
380
+ }
@@ -0,0 +1,8 @@
1
+ export function formatDate(iso?: string): string {
2
+ if (!iso) return "unknown";
3
+ const date = new Date(iso);
4
+ if (Number.isNaN(date.getTime())) return iso;
5
+
6
+ // Pretty print the date in local time
7
+ return date.toLocaleString();
8
+ }
@@ -0,0 +1,24 @@
1
+ import { Project } from '../lib/types.js';
2
+
3
+ export type OutputMode = {
4
+ printOnly?: boolean;
5
+ noEnter?: boolean;
6
+ };
7
+
8
+ function shellQuote(value: string): string {
9
+ return JSON.stringify(value);
10
+ }
11
+
12
+ export function emitSelection(project: Project, mode: OutputMode = {}): void {
13
+ if (mode.printOnly || process.env.BET_EVAL !== '1') {
14
+ process.stdout.write(`${project.path}\n`);
15
+ return;
16
+ }
17
+
18
+ const lines: string[] = [`cd ${shellQuote(project.path)}`];
19
+ if (!mode.noEnter && project.user?.onEnter) {
20
+ lines.push(project.user.onEnter);
21
+ }
22
+
23
+ process.stdout.write(`__BET_EVAL__${lines.join('\n')}`);
24
+ }
@@ -0,0 +1,20 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export function expandHome(inputPath: string): string {
5
+ if (!inputPath) return inputPath;
6
+ if (inputPath === '~') return os.homedir();
7
+ if (inputPath.startsWith('~/')) {
8
+ return path.join(os.homedir(), inputPath.slice(2));
9
+ }
10
+ return inputPath;
11
+ }
12
+
13
+ export function normalizeAbsolute(inputPath: string): string {
14
+ return path.resolve(expandHome(inputPath));
15
+ }
16
+
17
+ export function isSubpath(child: string, parent: string): boolean {
18
+ const rel = path.relative(parent, child);
19
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
20
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { readConfig, resolveRoots, getConfigPath, getProjectsPath } from "../src/lib/config.js";
5
+ import type { RootConfig } from "../src/lib/types.js";
6
+
7
+ vi.mock("node:fs/promises", () => ({
8
+ default: {
9
+ readFile: vi.fn(),
10
+ writeFile: vi.fn(),
11
+ mkdir: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ describe("config", () => {
16
+ beforeEach(() => {
17
+ vi.mocked(fs.readFile).mockReset();
18
+ });
19
+
20
+ describe("readConfig", () => {
21
+ it("migrates legacy string[] roots to RootConfig[]", async () => {
22
+ const configPath = getConfigPath();
23
+ const projectsPath = getProjectsPath();
24
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
25
+ if (p === configPath) {
26
+ return Promise.resolve(
27
+ JSON.stringify({ version: 1, roots: ["/tmp/foo", "~/bar"] }),
28
+ );
29
+ }
30
+ if (p === projectsPath) {
31
+ return Promise.resolve(JSON.stringify({ projects: {} }));
32
+ }
33
+ return Promise.reject(new Error("ENOENT"));
34
+ });
35
+
36
+ const config = await readConfig();
37
+
38
+ expect(config.roots).toHaveLength(2);
39
+ expect(config.roots[0]).toEqual({
40
+ path: path.resolve("/tmp/foo"),
41
+ name: "foo",
42
+ });
43
+ expect(config.roots[1].path).toBe(
44
+ path.resolve(process.env.HOME || "", "bar"),
45
+ );
46
+ expect(config.roots[1].name).toBe("bar");
47
+ });
48
+
49
+ it("normalizes rootName on legacy projects that have group but no rootName", async () => {
50
+ const configPath = getConfigPath();
51
+ const projectsPath = getProjectsPath();
52
+ const resolvedRoot = path.resolve("/code");
53
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
54
+ if (p === configPath) {
55
+ return Promise.resolve(
56
+ JSON.stringify({
57
+ version: 1,
58
+ roots: [{ path: resolvedRoot, name: "my-code" }],
59
+ }),
60
+ );
61
+ }
62
+ if (p === projectsPath) {
63
+ return Promise.resolve(
64
+ JSON.stringify({
65
+ projects: {
66
+ "/code/app": {
67
+ id: "/code/app",
68
+ slug: "app",
69
+ name: "app",
70
+ path: "/code/app",
71
+ root: resolvedRoot,
72
+ group: "legacy-group",
73
+ hasGit: true,
74
+ hasReadme: true,
75
+ auto: { lastIndexedAt: "2020-01-01T00:00:00.000Z" },
76
+ },
77
+ },
78
+ }),
79
+ );
80
+ }
81
+ return Promise.reject(new Error("ENOENT"));
82
+ });
83
+
84
+ const config = await readConfig();
85
+
86
+ const project = config.projects["/code/app"];
87
+ expect(project).toBeDefined();
88
+ expect(project.rootName).toBe("my-code");
89
+ expect((project as { group?: string }).group).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe("resolveRoots", () => {
94
+ it("deduplicates by path and normalizes RootConfig", () => {
95
+ const input: RootConfig[] = [
96
+ { path: "/a/b", name: "b" },
97
+ { path: "/a/b", name: "other" },
98
+ { path: "/c", name: "c" },
99
+ ];
100
+ const result = resolveRoots(input);
101
+ expect(result).toHaveLength(2);
102
+ expect(result[0]).toEqual({ path: path.resolve("/a/b"), name: "b" });
103
+ expect(result[1]).toEqual({ path: path.resolve("/c"), name: "c" });
104
+ });
105
+ });
106
+ });