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,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { SelectList } from './select.js';
4
+ import { SearchSelect } from './search.js';
5
+ export async function promptSelect(items, options = {}) {
6
+ return new Promise((resolve) => {
7
+ const { unmount } = render(_jsx(SelectList, { title: options.title, items: items, maxRows: options.maxRows, onSelect: (item) => {
8
+ unmount();
9
+ resolve(item);
10
+ }, onCancel: () => {
11
+ unmount();
12
+ resolve(undefined);
13
+ } }), {
14
+ stdout: process.stderr,
15
+ });
16
+ });
17
+ }
18
+ export async function promptSearch(items, options) {
19
+ return new Promise((resolve) => {
20
+ const { unmount } = render(_jsx(SearchSelect, { title: options.title, allItems: items, filter: options.filter, maxRows: options.maxRows, initialQuery: options.initialQuery, onSelect: (item) => {
21
+ unmount();
22
+ resolve(item);
23
+ }, onCancel: () => {
24
+ unmount();
25
+ resolve(undefined);
26
+ } }), {
27
+ stdout: process.stderr,
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import chalk from 'chalk';
4
+ import { Box, Text, useInput } from 'ink';
5
+ const DEFAULT_MAX_ROWS = 18;
6
+ export function SearchSelect({ title, allItems, filter, onSelect, onCancel, maxRows = DEFAULT_MAX_ROWS, initialQuery = '', showCount = true, }) {
7
+ const [cursor, setCursor] = useState(0);
8
+ const [query, setQuery] = useState(initialQuery);
9
+ const items = useMemo(() => filter(allItems, query), [allItems, filter, query]);
10
+ useInput((input, key) => {
11
+ if (key.escape || (key.ctrl && input === 'c')) {
12
+ onCancel?.();
13
+ return;
14
+ }
15
+ if (key.return) {
16
+ const entry = items[cursor];
17
+ if (entry)
18
+ onSelect(entry);
19
+ return;
20
+ }
21
+ if (key.upArrow || input === 'k') {
22
+ setCursor((prev) => (prev - 1 + items.length) % Math.max(items.length, 1));
23
+ return;
24
+ }
25
+ if (key.downArrow || input === 'j') {
26
+ setCursor((prev) => (prev + 1) % Math.max(items.length, 1));
27
+ return;
28
+ }
29
+ if (input === '\b' || input === '\x7f') {
30
+ setQuery((prev) => prev.slice(0, -1));
31
+ setCursor(0);
32
+ return;
33
+ }
34
+ if (input) {
35
+ setQuery((prev) => prev + input);
36
+ setCursor(0);
37
+ }
38
+ });
39
+ if (items.length === 0) {
40
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { children: chalk.bold(title) }), _jsx(Text, { children: `Search: ${query}` }), _jsx(Text, { children: "No results." })] }));
41
+ }
42
+ const selectedRowIndex = Math.min(cursor, items.length - 1);
43
+ const totalRows = items.length;
44
+ const effectiveMaxRows = Math.max(3, maxRows);
45
+ const windowStart = Math.min(Math.max(0, selectedRowIndex - Math.floor(effectiveMaxRows / 2)), Math.max(0, totalRows - effectiveMaxRows));
46
+ const windowEnd = Math.min(totalRows, windowStart + effectiveMaxRows);
47
+ const windowed = items.slice(windowStart, windowEnd);
48
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { children: chalk.bold(title) }), _jsx(Text, { children: `Search: ${query}` }), showCount && _jsx(Text, { children: chalk.dim(`${items.length} result(s)`) }), windowed.map((row, idx) => {
49
+ const absoluteIndex = windowStart + idx;
50
+ const selected = absoluteIndex === selectedRowIndex;
51
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [selected ? chalk.cyan.bold('› ') : ' ', selected ? chalk.cyan.bold(row.label) : row.label] }), row.hint ? _jsx(Text, { children: chalk.dim(` ${row.hint}`) }) : null] }, `item-${absoluteIndex}`));
52
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: chalk.dim('Type to filter. Use ↑/↓ or j/k. Enter to select. Esc to cancel.') }) })] }));
53
+ }
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import chalk from 'chalk';
4
+ import { Box, Text, useInput } from 'ink';
5
+ const DEFAULT_MAX_ROWS = 18;
6
+ export function SelectList({ title, items, onSelect, onCancel, maxRows = DEFAULT_MAX_ROWS, }) {
7
+ const selectableIndices = useMemo(() => items.map((item, index) => (item.type === 'item' ? index : -1)).filter((idx) => idx >= 0), [items]);
8
+ const [cursor, setCursor] = useState(0);
9
+ useInput((input, key) => {
10
+ if (selectableIndices.length === 0) {
11
+ if (key.escape || (key.ctrl && input === 'c')) {
12
+ onCancel?.();
13
+ }
14
+ return;
15
+ }
16
+ if (key.upArrow || input === 'k') {
17
+ setCursor((prev) => (prev - 1 + selectableIndices.length) % selectableIndices.length);
18
+ }
19
+ else if (key.downArrow || input === 'j') {
20
+ setCursor((prev) => (prev + 1) % selectableIndices.length);
21
+ }
22
+ else if (key.return) {
23
+ const itemIndex = selectableIndices[cursor];
24
+ const item = items[itemIndex];
25
+ if (item && item.type === 'item') {
26
+ onSelect(item);
27
+ }
28
+ }
29
+ else if (key.escape || (key.ctrl && input === 'c')) {
30
+ onCancel?.();
31
+ }
32
+ });
33
+ if (items.length === 0) {
34
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { children: chalk.bold(title) }), _jsx(Text, { children: "No results." }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: chalk.dim('Press Esc to exit.') }) })] }));
35
+ }
36
+ const selectedRowIndex = selectableIndices[cursor] ?? 0;
37
+ const totalRows = items.length;
38
+ const effectiveMaxRows = Math.max(3, maxRows);
39
+ const windowStart = Math.min(Math.max(0, selectedRowIndex - Math.floor(effectiveMaxRows / 2)), Math.max(0, totalRows - effectiveMaxRows));
40
+ const windowEnd = Math.min(totalRows, windowStart + effectiveMaxRows);
41
+ const windowed = items.slice(windowStart, windowEnd);
42
+ return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { children: chalk.bold(title) }), windowed.map((row, idx) => {
43
+ const absoluteIndex = windowStart + idx;
44
+ const selected = row.type === 'item' && absoluteIndex === selectedRowIndex;
45
+ if (row.type === 'group') {
46
+ const colored = row.color ? chalk.hex(row.color)(`[${row.label}]`) : `[${row.label}]`;
47
+ return (_jsx(Box, { marginTop: idx === 0 ? 0 : 1, children: _jsx(Text, { children: chalk.bold(colored) }) }, `group-${absoluteIndex}`));
48
+ }
49
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [selected ? chalk.cyan.bold('› ') : ' ', selected ? chalk.cyan.bold(row.label) : row.label] }), row.hint ? _jsx(Text, { children: chalk.dim(` ${row.hint}`) }) : null] }, `item-${absoluteIndex}`));
50
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: chalk.dim('Use ↑/↓ or j/k. Enter to select. Esc to cancel.') }) })] }));
51
+ }
@@ -0,0 +1,214 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Box, Text } from "ink";
4
+ import { sha1 } from "object-hash";
5
+ /* Table */
6
+ export default class Table extends React.Component {
7
+ /* Config */
8
+ /**
9
+ * Merges provided configuration with defaults.
10
+ */
11
+ getConfig() {
12
+ return {
13
+ data: this.props.data,
14
+ columns: this.props.columns || this.getDataKeys(),
15
+ padding: this.props.padding || 1,
16
+ header: this.props.header || Header,
17
+ cell: this.props.cell || Cell,
18
+ skeleton: this.props.skeleton || Skeleton,
19
+ };
20
+ }
21
+ /**
22
+ * Gets all keyes used in data by traversing through the data.
23
+ */
24
+ getDataKeys() {
25
+ let keys = new Set();
26
+ // Collect all the keys.
27
+ for (const data of this.props.data) {
28
+ for (const key in data) {
29
+ keys.add(key);
30
+ }
31
+ }
32
+ return Array.from(keys);
33
+ }
34
+ /**
35
+ * Calculates the width of each column by finding
36
+ * the longest value in a cell of a particular column.
37
+ *
38
+ * Returns a list of column names and their widths.
39
+ */
40
+ getColumns() {
41
+ const { columns, padding } = this.getConfig();
42
+ const widths = columns.map((key) => {
43
+ const header = String(key).length;
44
+ /* Get the width of each cell in the column */
45
+ const data = this.props.data.map((data) => {
46
+ const value = data[key];
47
+ if (value == undefined || value == null)
48
+ return 0;
49
+ return String(value).length;
50
+ });
51
+ const width = Math.max(...data, header) + padding * 2;
52
+ /* Construct a cell */
53
+ return {
54
+ column: key,
55
+ width: width,
56
+ key: String(key),
57
+ };
58
+ });
59
+ return widths;
60
+ }
61
+ /**
62
+ * Returns a (data) row representing the headings.
63
+ */
64
+ getHeadings() {
65
+ const { columns } = this.getConfig();
66
+ const headings = columns.reduce((acc, column) => ({ ...acc, [column]: column }), {});
67
+ return headings;
68
+ }
69
+ /* Rendering utilities */
70
+ // The top most line in the table.
71
+ header = row({
72
+ cell: this.getConfig().skeleton,
73
+ padding: this.getConfig().padding,
74
+ skeleton: {
75
+ component: this.getConfig().skeleton,
76
+ // chars
77
+ line: "─",
78
+ left: "┌",
79
+ right: "┐",
80
+ cross: "┬",
81
+ },
82
+ });
83
+ // The line with column names.
84
+ heading = row({
85
+ cell: this.getConfig().header,
86
+ padding: this.getConfig().padding,
87
+ skeleton: {
88
+ component: this.getConfig().skeleton,
89
+ // chars
90
+ line: " ",
91
+ left: "│",
92
+ right: "│",
93
+ cross: "│",
94
+ },
95
+ });
96
+ // The line that separates rows.
97
+ separator = row({
98
+ cell: this.getConfig().skeleton,
99
+ padding: this.getConfig().padding,
100
+ skeleton: {
101
+ component: this.getConfig().skeleton,
102
+ // chars
103
+ line: "─",
104
+ left: "├",
105
+ right: "┤",
106
+ cross: "┼",
107
+ },
108
+ });
109
+ // The row with the data.
110
+ data = row({
111
+ cell: this.getConfig().cell,
112
+ padding: this.getConfig().padding,
113
+ skeleton: {
114
+ component: this.getConfig().skeleton,
115
+ // chars
116
+ line: " ",
117
+ left: "│",
118
+ right: "│",
119
+ cross: "│",
120
+ },
121
+ });
122
+ // The bottom most line of the table.
123
+ footer = row({
124
+ cell: this.getConfig().skeleton,
125
+ padding: this.getConfig().padding,
126
+ skeleton: {
127
+ component: this.getConfig().skeleton,
128
+ // chars
129
+ line: "─",
130
+ left: "└",
131
+ right: "┘",
132
+ cross: "┴",
133
+ },
134
+ });
135
+ /* Render */
136
+ render() {
137
+ /* Data */
138
+ const columns = this.getColumns();
139
+ const headings = this.getHeadings();
140
+ /**
141
+ * Render the table line by line.
142
+ */
143
+ return (_jsxs(Box, { flexDirection: "column", children: [this.header({ key: "header", columns, data: {} }), this.heading({ key: "heading", columns, data: headings }), this.props.data.map((row, index) => {
144
+ // Calculate the hash of the row based on its value and position
145
+ const key = `row-${sha1(row)}-${index}`;
146
+ // Construct a row.
147
+ return (_jsxs(Box, { flexDirection: "column", children: [this.separator({ key: `separator-${key}`, columns, data: {} }), this.data({ key: `data-${key}`, columns, data: row })] }, key));
148
+ }), this.footer({ key: "footer", columns, data: {} })] }));
149
+ }
150
+ }
151
+ /**
152
+ * Constructs a Row element from the configuration.
153
+ */
154
+ function row(config) {
155
+ /* This is a component builder. We return a function. */
156
+ const skeleton = config.skeleton;
157
+ /* Row */
158
+ return (props) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(skeleton.component, { children: skeleton.left }), ...intersperse((i) => {
159
+ const key = `${props.key}-hseparator-${i}`;
160
+ // The horizontal separator.
161
+ return (_jsx(skeleton.component, { children: skeleton.cross }, key));
162
+ },
163
+ // Values.
164
+ props.columns.map((column, colI) => {
165
+ // content
166
+ const value = props.data[column.column];
167
+ if (value == undefined || value == null) {
168
+ const key = `${props.key}-empty-${column.key}`;
169
+ return (_jsx(config.cell, { column: colI, children: skeleton.line.repeat(column.width) }, key));
170
+ }
171
+ else {
172
+ const key = `${props.key}-cell-${column.key}`;
173
+ // margins
174
+ const ml = config.padding;
175
+ const mr = column.width - String(value).length - config.padding;
176
+ return (
177
+ /* prettier-ignore */
178
+ _jsx(config.cell, { column: colI, children: `${skeleton.line.repeat(ml)}${String(value)}${skeleton.line.repeat(mr)}` }, key));
179
+ }
180
+ })), _jsx(skeleton.component, { children: skeleton.right })] }));
181
+ }
182
+ /**
183
+ * Renders the header of a table.
184
+ */
185
+ export function Header(props) {
186
+ return (_jsx(Text, { bold: true, color: "blue", children: props.children }));
187
+ }
188
+ /**
189
+ * Renders a cell in the table.
190
+ */
191
+ export function Cell(props) {
192
+ return _jsx(Text, { children: props.children });
193
+ }
194
+ /**
195
+ * Redners the scaffold of the table.
196
+ */
197
+ export function Skeleton(props) {
198
+ return _jsx(Text, { bold: true, children: props.children });
199
+ }
200
+ /* Utility functions */
201
+ /**
202
+ * Intersperses a list of elements with another element.
203
+ */
204
+ function intersperse(intersperser, elements) {
205
+ // Intersparse by reducing from left.
206
+ let interspersed = elements.reduce((acc, element, index) => {
207
+ // Only add element if it's the first one.
208
+ if (acc.length === 0)
209
+ return [element];
210
+ // Add the intersparser as well otherwise.
211
+ return [...acc, intersperser(index), element];
212
+ }, []);
213
+ return interspersed;
214
+ }
@@ -0,0 +1,9 @@
1
+ export function formatDate(iso) {
2
+ if (!iso)
3
+ return "unknown";
4
+ const date = new Date(iso);
5
+ if (Number.isNaN(date.getTime()))
6
+ return iso;
7
+ // Pretty print the date in local time
8
+ return date.toLocaleString();
9
+ }
@@ -0,0 +1,14 @@
1
+ function shellQuote(value) {
2
+ return JSON.stringify(value);
3
+ }
4
+ export function emitSelection(project, mode = {}) {
5
+ if (mode.printOnly || process.env.BET_EVAL !== '1') {
6
+ process.stdout.write(`${project.path}\n`);
7
+ return;
8
+ }
9
+ const lines = [`cd ${shellQuote(project.path)}`];
10
+ if (!mode.noEnter && project.user?.onEnter) {
11
+ lines.push(project.user.onEnter);
12
+ }
13
+ process.stdout.write(`__BET_EVAL__${lines.join('\n')}`);
14
+ }
@@ -0,0 +1,19 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export function expandHome(inputPath) {
4
+ if (!inputPath)
5
+ return inputPath;
6
+ if (inputPath === '~')
7
+ return os.homedir();
8
+ if (inputPath.startsWith('~/')) {
9
+ return path.join(os.homedir(), inputPath.slice(2));
10
+ }
11
+ return inputPath;
12
+ }
13
+ export function normalizeAbsolute(inputPath) {
14
+ return path.resolve(expandHome(inputPath));
15
+ }
16
+ export function isSubpath(child, parent) {
17
+ const rel = path.relative(parent, child);
18
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
19
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "bet-cli",
3
+ "description": "Explore and jump between local projects.",
4
+ "author": "Chris Mckenzie",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/kenzic/bet-cli"
9
+ },
10
+ "homepage": "https://github.com/kenzic/bet-cli",
11
+ "keywords": [
12
+ "cli",
13
+ "project",
14
+ "management",
15
+ "navigation"
16
+ ],
17
+ "version": "0.1.0",
18
+ "type": "module",
19
+ "bin": {
20
+ "bet": "dist/index.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dependencies": {
26
+ "@types/object-hash": "^3.0.6",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.0.0",
29
+ "fast-glob": "^3.3.2",
30
+ "fuse.js": "^7.0.0",
31
+ "ink": "^6.6.0",
32
+ "ink-markdown": "^1.0.4",
33
+ "ink-table": "^3.1.0",
34
+ "object-hash": "^3.0.0",
35
+ "react": "^19.2.4"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.13.1",
39
+ "@types/react": "^19.2.13",
40
+ "@vitest/coverage-v8": "^2.1.8",
41
+ "typescript": "^5.6.3",
42
+ "vitest": "^2.1.8"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "start": "node dist/index.js",
47
+ "test": "vitest run",
48
+ "test:coverage": "vitest run --coverage",
49
+ "test:watch": "vitest"
50
+ }
51
+ }
@@ -0,0 +1,50 @@
1
+ import { Command } from 'commander';
2
+ import { readConfig } from '../lib/config.js';
3
+ import { findBySlug, listProjects, projectLabel } from '../lib/projects.js';
4
+ import { emitSelection } from '../utils/output.js';
5
+ import { promptSelect } from '../ui/prompt.js';
6
+ import { SelectEntry } from '../ui/select.js';
7
+
8
+ export function registerGo(program: Command): void {
9
+ program
10
+ .command('go <slug>')
11
+ .description('Print a shell snippet to cd into a project')
12
+ .option('--print', 'Print selected path only')
13
+ .option('--no-enter', 'Do not run onEnter command')
14
+ .action(async (slug: string, options: { print?: boolean; enter?: boolean }) => {
15
+ const config = await readConfig();
16
+ const projects = listProjects(config);
17
+ const matches = findBySlug(projects, slug);
18
+
19
+ if (matches.length === 0) {
20
+ process.stderr.write(`No project found for slug "${slug}".\n`);
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+
25
+ let project = matches[0];
26
+ if (matches.length > 1) {
27
+ if (!process.stdin.isTTY) {
28
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
29
+ for (const item of matches) {
30
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
31
+ }
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ const items: SelectEntry<typeof matches[number]>[] = matches.map((item) => ({
37
+ label: projectLabel(item),
38
+ hint: item.path,
39
+ value: item,
40
+ type: 'item',
41
+ }));
42
+
43
+ const selected = await promptSelect(items, { title: `Select ${slug}` });
44
+ if (!selected) return;
45
+ project = selected.value;
46
+ }
47
+
48
+ emitSelection(project, { printOnly: options.print, noEnter: options.enter === false });
49
+ });
50
+ }
@@ -0,0 +1,168 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { readConfig } from "../lib/config.js";
4
+ import { render, Box, Text } from "ink";
5
+ import { findBySlug, listProjects, projectLabel } from "../lib/projects.js";
6
+ import { getDirtyStatus, isInsideGitRepo } from "../lib/git.js";
7
+ import { formatDate } from "../utils/format.js";
8
+ import { promptSelect } from "../ui/prompt.js";
9
+ import { SelectEntry } from "../ui/select.js";
10
+ import { readReadmeContent } from "../lib/readme.js";
11
+ import Table from "../ui/table.js";
12
+
13
+ const data: { [key: string]: string }[] = [];
14
+
15
+ export function registerInfo(program: Command): void {
16
+ program
17
+ .command("info <slug>")
18
+ .description("Show project details")
19
+ .option("--json", "Print JSON output")
20
+ .action(async (slug: string, options: { json?: boolean }) => {
21
+ const config = await readConfig();
22
+ const projects = listProjects(config);
23
+ const matches = findBySlug(projects, slug);
24
+
25
+ if (matches.length === 0) {
26
+ process.stderr.write(`No project found for slug "${slug}".\n`);
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+
31
+ let project = matches[0];
32
+
33
+ if (matches.length > 1) {
34
+ if (!process.stdin.isTTY) {
35
+ process.stderr.write(`Slug "${slug}" is ambiguous. Matches:\n`);
36
+ for (const item of matches) {
37
+ process.stderr.write(` ${projectLabel(item)} ${item.path}\n`);
38
+ }
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+
43
+ const items: SelectEntry<(typeof matches)[number]>[] = matches.map(
44
+ (item) => ({
45
+ label: projectLabel(item),
46
+ hint: item.path,
47
+ value: item,
48
+ type: "item",
49
+ }),
50
+ );
51
+
52
+ const selected = await promptSelect(items, { title: `Select ${slug}` });
53
+ if (!selected) return;
54
+ project = selected.value;
55
+ }
56
+
57
+ if (options.json) {
58
+ process.stdout.write(JSON.stringify(project, null, 2));
59
+ process.stdout.write("\n");
60
+ return;
61
+ }
62
+
63
+ const description =
64
+ project.user?.description ?? project.auto.description ?? "—";
65
+ // Compute git status live
66
+ const hasGit = await isInsideGitRepo(project.path);
67
+ const dirty = hasGit ? await getDirtyStatus(project.path) : undefined;
68
+
69
+ if (process.stdin.isTTY) {
70
+ const readme = await readReadmeContent(project.path);
71
+ const markdown = readme ?? description;
72
+
73
+ let Markdown: React.FC<{ children: string }> | null = null;
74
+ try {
75
+ const markdownModule = await import("ink-markdown");
76
+ Markdown = (markdownModule.default ??
77
+ markdownModule) as unknown as React.FC<{
78
+ children: string;
79
+ }>;
80
+ } catch {
81
+ Markdown = null;
82
+ }
83
+
84
+ const view = (
85
+ <Box flexDirection="column">
86
+ <Table data={data} />
87
+ <Text color="green" bold>
88
+ {project.slug}
89
+ </Text>
90
+ <Text dimColor>{project.path}</Text>
91
+ <Box marginTop={1} flexDirection="column">
92
+ <Text bold>{`Root: ${project.rootName}`}</Text>
93
+ <Text bold>{`Root path: ${project.root}`}</Text>
94
+ <Text bold>{`Git: ${hasGit ? "yes" : "no"}`}</Text>
95
+ <Text bold>{`README: ${project.hasReadme ? "yes" : "no"}`}</Text>
96
+ <Text
97
+ bold
98
+ >{`Started: ${formatDate(project.auto.startedAt)}`}</Text>
99
+ <Text
100
+ bold
101
+ >{`Last modified: ${formatDate(project.auto.lastModifiedAt)}`}</Text>
102
+ <Text
103
+ bold
104
+ >{`Last indexed: ${formatDate(project.auto.lastIndexedAt)}`}</Text>
105
+ <Text
106
+ bold
107
+ >{`Dirty: ${dirty === undefined ? "unknown" : dirty ? "yes" : "no"}`}</Text>
108
+ {project.user?.tags?.length ? (
109
+ <Text>{`Tags: ${project.user.tags.join(", ")}`}</Text>
110
+ ) : null}
111
+ {project.user?.onEnter ? (
112
+ <Text>{`On enter: ${project.user.onEnter}`}</Text>
113
+ ) : null}
114
+ </Box>
115
+ <Box marginTop={1} flexDirection="column">
116
+ <Text>{chalk.bold("Description")}</Text>
117
+ {Markdown ? (
118
+ <Markdown>{markdown}</Markdown>
119
+ ) : (
120
+ <Text>{markdown}</Text>
121
+ )}
122
+ </Box>
123
+ </Box>
124
+ );
125
+
126
+ const { unmount } = render(view, { stdout: process.stdout });
127
+ await new Promise((resolve) => setTimeout(resolve, 0));
128
+ unmount();
129
+ return;
130
+ }
131
+
132
+ process.stdout.write(`${chalk.bold(project.slug)}\n`);
133
+ process.stdout.write(`${chalk.dim(project.path)}\n\n`);
134
+
135
+ process.stdout.write(`${chalk.bold("Root:")} ${project.rootName}\n`);
136
+ process.stdout.write(`${chalk.bold("Root path:")} ${project.root}\n`);
137
+ process.stdout.write(`${chalk.bold("Git:")} ${hasGit ? "yes" : "no"}\n`);
138
+ process.stdout.write(
139
+ `${chalk.bold("README:")} ${project.hasReadme ? "yes" : "no"}\n\n`,
140
+ );
141
+
142
+ process.stdout.write(`${chalk.bold("Description:")} ${description}\n`);
143
+ process.stdout.write(
144
+ `${chalk.bold("Started:")} ${formatDate(project.auto.startedAt)}\n`,
145
+ );
146
+ process.stdout.write(
147
+ `${chalk.bold("Last modified:")} ${formatDate(project.auto.lastModifiedAt)}\n`,
148
+ );
149
+ process.stdout.write(
150
+ `${chalk.bold("Last indexed:")} ${formatDate(project.auto.lastIndexedAt)}\n`,
151
+ );
152
+ process.stdout.write(
153
+ `${chalk.bold("Dirty:")} ${dirty === undefined ? "unknown" : dirty ? "yes" : "no"}\n`,
154
+ );
155
+
156
+ if (project.user?.tags?.length) {
157
+ process.stdout.write(
158
+ `${chalk.bold("Tags:")} ${project.user.tags.join(", ")}\n`,
159
+ );
160
+ }
161
+
162
+ if (project.user?.onEnter) {
163
+ process.stdout.write(
164
+ `${chalk.bold("On enter:")} ${project.user.onEnter}\n`,
165
+ );
166
+ }
167
+ });
168
+ }