bet-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +139 -0
- package/dist/commands/go.js +43 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/list.js +97 -0
- package/dist/commands/path.js +40 -0
- package/dist/commands/search.js +69 -0
- package/dist/commands/shell.js +19 -0
- package/dist/commands/update.js +140 -0
- package/dist/index.js +22 -0
- package/dist/lib/config.js +131 -0
- package/dist/lib/cron.js +73 -0
- package/dist/lib/git.js +28 -0
- package/dist/lib/ignore.js +11 -0
- package/dist/lib/metadata.js +37 -0
- package/dist/lib/projects.js +15 -0
- package/dist/lib/readme.js +70 -0
- package/dist/lib/scan.js +93 -0
- package/dist/lib/search.js +20 -0
- package/dist/lib/types.js +1 -0
- package/dist/ui/markdown.js +10 -0
- package/dist/ui/prompt.js +30 -0
- package/dist/ui/search.js +53 -0
- package/dist/ui/select.js +51 -0
- package/dist/ui/table.js +214 -0
- package/dist/utils/format.js +9 -0
- package/dist/utils/output.js +14 -0
- package/dist/utils/paths.js +19 -0
- package/package.json +51 -0
- package/src/commands/go.ts +50 -0
- package/src/commands/info.tsx +168 -0
- package/src/commands/list.ts +117 -0
- package/src/commands/path.ts +47 -0
- package/src/commands/search.ts +79 -0
- package/src/commands/shell.ts +22 -0
- package/src/commands/update.ts +170 -0
- package/src/index.ts +26 -0
- package/src/lib/config.ts +144 -0
- package/src/lib/cron.ts +96 -0
- package/src/lib/git.ts +31 -0
- package/src/lib/ignore.ts +11 -0
- package/src/lib/metadata.ts +41 -0
- package/src/lib/projects.ts +18 -0
- package/src/lib/readme.ts +83 -0
- package/src/lib/scan.ts +116 -0
- package/src/lib/search.ts +22 -0
- package/src/lib/types.ts +53 -0
- package/src/ui/prompt.tsx +63 -0
- package/src/ui/search.tsx +111 -0
- package/src/ui/select.tsx +119 -0
- package/src/ui/table.tsx +380 -0
- package/src/utils/format.ts +8 -0
- package/src/utils/output.ts +24 -0
- package/src/utils/paths.ts +20 -0
- package/tests/config.test.ts +106 -0
- package/tests/git.test.ts +73 -0
- package/tests/metadata.test.ts +55 -0
- package/tests/output.test.ts +81 -0
- package/tests/paths.test.ts +60 -0
- package/tests/projects.test.ts +67 -0
- package/tests/readme.test.ts +52 -0
- package/tests/scan.test.ts +67 -0
- package/tests/search.test.ts +45 -0
- package/tests/update.test.ts +30 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +26 -0
|
@@ -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
|
+
}
|
package/dist/ui/table.js
ADDED
|
@@ -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,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
|
+
}
|