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,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockExecFile = vi.hoisted(() => vi.fn());
|
|
4
|
+
|
|
5
|
+
vi.mock("node:child_process", () => ({
|
|
6
|
+
execFile: mockExecFile,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("node:util", () => ({
|
|
10
|
+
promisify: (fn: (...args: unknown[]) => Promise<{ stdout: string; stderr: string }>) => fn,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("git", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockExecFile.mockReset();
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("getFirstCommitDate", () => {
|
|
20
|
+
it("returns first commit date when git succeeds", async () => {
|
|
21
|
+
mockExecFile.mockResolvedValueOnce({ stdout: "2020-01-15T10:00:00Z\n", stderr: "" });
|
|
22
|
+
const { getFirstCommitDate } = await import("../src/lib/git.js");
|
|
23
|
+
const result = await getFirstCommitDate("/some/repo");
|
|
24
|
+
expect(result).toBe("2020-01-15T10:00:00Z");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns undefined when git fails", async () => {
|
|
28
|
+
mockExecFile.mockRejectedValueOnce(new Error("not a repo"));
|
|
29
|
+
const { getFirstCommitDate } = await import("../src/lib/git.js");
|
|
30
|
+
const result = await getFirstCommitDate("/not/repo");
|
|
31
|
+
expect(result).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("getDirtyStatus", () => {
|
|
36
|
+
it("returns true when porcelain output non-empty", async () => {
|
|
37
|
+
mockExecFile.mockResolvedValueOnce({ stdout: " M file\n", stderr: "" });
|
|
38
|
+
const { getDirtyStatus } = await import("../src/lib/git.js");
|
|
39
|
+
const result = await getDirtyStatus("/repo");
|
|
40
|
+
expect(result).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns false when porcelain output empty", async () => {
|
|
44
|
+
mockExecFile.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
|
45
|
+
const { getDirtyStatus } = await import("../src/lib/git.js");
|
|
46
|
+
const result = await getDirtyStatus("/repo");
|
|
47
|
+
expect(result).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns undefined when git fails", async () => {
|
|
51
|
+
mockExecFile.mockRejectedValueOnce(new Error("not a repo"));
|
|
52
|
+
const { getDirtyStatus } = await import("../src/lib/git.js");
|
|
53
|
+
const result = await getDirtyStatus("/repo");
|
|
54
|
+
expect(result).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("isInsideGitRepo", () => {
|
|
59
|
+
it("returns true when rev-parse outputs true", async () => {
|
|
60
|
+
mockExecFile.mockResolvedValueOnce({ stdout: "true\n", stderr: "" });
|
|
61
|
+
const { isInsideGitRepo } = await import("../src/lib/git.js");
|
|
62
|
+
const result = await isInsideGitRepo("/repo");
|
|
63
|
+
expect(result).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns false when rev-parse fails or outputs else", async () => {
|
|
67
|
+
mockExecFile.mockRejectedValueOnce(new Error("not a repo"));
|
|
68
|
+
const { isInsideGitRepo } = await import("../src/lib/git.js");
|
|
69
|
+
const result = await isInsideGitRepo("/repo");
|
|
70
|
+
expect(result).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { computeMetadata } from "../src/lib/metadata.js";
|
|
3
|
+
|
|
4
|
+
const mockFg = vi.fn();
|
|
5
|
+
vi.mock("fast-glob", () => ({
|
|
6
|
+
default: (...args: unknown[]) => mockFg(...args),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../src/lib/readme.js", () => ({
|
|
10
|
+
readReadmeDescription: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../src/lib/git.js", () => ({
|
|
14
|
+
getFirstCommitDate: vi.fn(),
|
|
15
|
+
getDirtyStatus: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe("metadata", () => {
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
mockFg.mockReset();
|
|
21
|
+
const readme = await import("../src/lib/readme.js");
|
|
22
|
+
const git = await import("../src/lib/git.js");
|
|
23
|
+
vi.mocked(readme.readReadmeDescription).mockResolvedValue(undefined);
|
|
24
|
+
vi.mocked(git.getFirstCommitDate).mockResolvedValue(undefined);
|
|
25
|
+
vi.mocked(git.getDirtyStatus).mockResolvedValue(undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns lastIndexedAt and uses file mtimes for started/lastModified when no git", async () => {
|
|
29
|
+
const now = new Date();
|
|
30
|
+
mockFg.mockResolvedValueOnce([
|
|
31
|
+
{ stats: { mtimeMs: now.getTime() - 10000 } },
|
|
32
|
+
{ stats: { mtimeMs: now.getTime() - 5000 } },
|
|
33
|
+
]);
|
|
34
|
+
const result = await computeMetadata("/some/project", false);
|
|
35
|
+
expect(result.lastIndexedAt).toBeDefined();
|
|
36
|
+
expect(result.startedAt).toBeDefined();
|
|
37
|
+
expect(result.lastModifiedAt).toBeDefined();
|
|
38
|
+
expect(result.description).toBeUndefined();
|
|
39
|
+
expect(result.dirty).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses getFirstCommitDate and getDirtyStatus when hasGit", async () => {
|
|
43
|
+
mockFg.mockResolvedValueOnce([]);
|
|
44
|
+
const git = await import("../src/lib/git.js");
|
|
45
|
+
vi.mocked(git.getFirstCommitDate).mockResolvedValue("2021-06-01T00:00:00Z");
|
|
46
|
+
vi.mocked(git.getDirtyStatus).mockResolvedValue(true);
|
|
47
|
+
const readme = await import("../src/lib/readme.js");
|
|
48
|
+
vi.mocked(readme.readReadmeDescription).mockResolvedValue("A cool project");
|
|
49
|
+
|
|
50
|
+
const result = await computeMetadata("/repo", true);
|
|
51
|
+
expect(result.startedAt).toBe("2021-06-01T00:00:00Z");
|
|
52
|
+
expect(result.dirty).toBe(true);
|
|
53
|
+
expect(result.description).toBe("A cool project");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { emitSelection } from "../src/utils/output.js";
|
|
3
|
+
import type { Project } from "../src/lib/types.js";
|
|
4
|
+
|
|
5
|
+
const makeProject = (overrides: Partial<Project>): Project => ({
|
|
6
|
+
id: "/root/a",
|
|
7
|
+
slug: "a",
|
|
8
|
+
name: "a",
|
|
9
|
+
path: "/path/to/project",
|
|
10
|
+
root: "/root",
|
|
11
|
+
group: "root",
|
|
12
|
+
hasGit: true,
|
|
13
|
+
hasReadme: true,
|
|
14
|
+
auto: { lastIndexedAt: new Date().toISOString() },
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("output", () => {
|
|
19
|
+
let writeSpy: ReturnType<typeof vi.spyOn>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
writeSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("writes path only when printOnly is true", () => {
|
|
30
|
+
const project = makeProject({ path: "/my/project" });
|
|
31
|
+
emitSelection(project, { printOnly: true });
|
|
32
|
+
expect(writeSpy).toHaveBeenCalledWith("/my/project\n");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("writes path only when BET_EVAL is not 1", () => {
|
|
36
|
+
const orig = process.env.BET_EVAL;
|
|
37
|
+
process.env.BET_EVAL = "0";
|
|
38
|
+
const project = makeProject({ path: "/my/project" });
|
|
39
|
+
emitSelection(project, {});
|
|
40
|
+
expect(writeSpy).toHaveBeenCalledWith("/my/project\n");
|
|
41
|
+
process.env.BET_EVAL = orig;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("writes __BET_EVAL__ cd snippet when BET_EVAL=1 and no onEnter", () => {
|
|
45
|
+
const orig = process.env.BET_EVAL;
|
|
46
|
+
process.env.BET_EVAL = "1";
|
|
47
|
+
const project = makeProject({ path: "/my/project" });
|
|
48
|
+
emitSelection(project, {});
|
|
49
|
+
const out = writeSpy.mock.calls.map((c) => c[0]).join("");
|
|
50
|
+
expect(out).toContain("__BET_EVAL__");
|
|
51
|
+
expect(out).toContain('cd "/my/project"');
|
|
52
|
+
process.env.BET_EVAL = orig;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("includes onEnter in snippet when BET_EVAL=1 and onEnter set", () => {
|
|
56
|
+
const orig = process.env.BET_EVAL;
|
|
57
|
+
process.env.BET_EVAL = "1";
|
|
58
|
+
const project = makeProject({
|
|
59
|
+
path: "/my/project",
|
|
60
|
+
user: { onEnter: "npm run dev" },
|
|
61
|
+
});
|
|
62
|
+
emitSelection(project, {});
|
|
63
|
+
const out = writeSpy.mock.calls.map((c) => c[0]).join("");
|
|
64
|
+
expect(out).toContain("__BET_EVAL__");
|
|
65
|
+
expect(out).toContain("npm run dev");
|
|
66
|
+
process.env.BET_EVAL = orig;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("omits onEnter when noEnter is true", () => {
|
|
70
|
+
const orig = process.env.BET_EVAL;
|
|
71
|
+
process.env.BET_EVAL = "1";
|
|
72
|
+
const project = makeProject({
|
|
73
|
+
path: "/my/project",
|
|
74
|
+
user: { onEnter: "npm run dev" },
|
|
75
|
+
});
|
|
76
|
+
emitSelection(project, { noEnter: true });
|
|
77
|
+
const out = writeSpy.mock.calls.map((c) => c[0]).join("");
|
|
78
|
+
expect(out).not.toContain("npm run dev");
|
|
79
|
+
process.env.BET_EVAL = orig;
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { expandHome, normalizeAbsolute, isSubpath } from "../src/utils/paths.js";
|
|
4
|
+
|
|
5
|
+
describe("paths", () => {
|
|
6
|
+
describe("expandHome", () => {
|
|
7
|
+
it("returns empty string unchanged", () => {
|
|
8
|
+
expect(expandHome("")).toBe("");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("expands ~ to absolute homedir", () => {
|
|
12
|
+
const result = expandHome("~");
|
|
13
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
14
|
+
expect(result.length).toBeGreaterThan(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("expands ~/path to homedir + path", () => {
|
|
18
|
+
const home = expandHome("~");
|
|
19
|
+
expect(expandHome("~/code")).toBe(path.join(home, "code"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns other paths unchanged", () => {
|
|
23
|
+
expect(expandHome("/abs/path")).toBe("/abs/path");
|
|
24
|
+
expect(expandHome("relative")).toBe("relative");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("normalizeAbsolute", () => {
|
|
29
|
+
it("resolves path with expanded home", () => {
|
|
30
|
+
const result = normalizeAbsolute("~/x");
|
|
31
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
32
|
+
expect(result).toBe(path.resolve(expandHome("~"), "x"));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("resolves relative path", () => {
|
|
36
|
+
const result = normalizeAbsolute(".");
|
|
37
|
+
expect(path.isAbsolute(result)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("isSubpath", () => {
|
|
42
|
+
it("returns true when child is under parent", () => {
|
|
43
|
+
expect(isSubpath("/a/b/c", "/a")).toBe(true);
|
|
44
|
+
expect(isSubpath("/a/b", "/a")).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns false when paths are the same", () => {
|
|
48
|
+
expect(isSubpath("/a", "/a")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns false when child is not under parent", () => {
|
|
52
|
+
expect(isSubpath("/a", "/a/b")).toBe(false);
|
|
53
|
+
expect(isSubpath("/x/y", "/a")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns false when child goes up from parent", () => {
|
|
57
|
+
expect(isSubpath("/a/../b", "/a")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
listProjects,
|
|
5
|
+
findBySlug,
|
|
6
|
+
projectLabel,
|
|
7
|
+
} from "../src/lib/projects.js";
|
|
8
|
+
import type { Config, Project } from "../src/lib/types.js";
|
|
9
|
+
|
|
10
|
+
const makeProject = (overrides: Partial<Project>): Project => ({
|
|
11
|
+
id: "/root/a",
|
|
12
|
+
slug: "a",
|
|
13
|
+
name: "a",
|
|
14
|
+
path: "/root/a",
|
|
15
|
+
root: "/root",
|
|
16
|
+
rootName: "root",
|
|
17
|
+
hasGit: true,
|
|
18
|
+
hasReadme: true,
|
|
19
|
+
auto: { lastIndexedAt: new Date().toISOString() },
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("projects", () => {
|
|
24
|
+
describe("listProjects", () => {
|
|
25
|
+
it("returns projects sorted by rootName then slug", () => {
|
|
26
|
+
const config: Config = {
|
|
27
|
+
version: 1,
|
|
28
|
+
roots: [],
|
|
29
|
+
projects: {
|
|
30
|
+
"/root/c": makeProject({ path: "/root/c", slug: "c", rootName: "root" }),
|
|
31
|
+
"/root/a": makeProject({ path: "/root/a", slug: "a", rootName: "root" }),
|
|
32
|
+
"/other/x": makeProject({ path: "/other/x", slug: "x", rootName: "other" }),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const list = listProjects(config);
|
|
36
|
+
expect(list.map((p) => p.slug)).toEqual(["x", "a", "c"]);
|
|
37
|
+
expect(list.map((p) => p.rootName)).toEqual(["other", "root", "root"]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("findBySlug", () => {
|
|
42
|
+
it("returns exact slug match (case-insensitive)", () => {
|
|
43
|
+
const projects: Project[] = [
|
|
44
|
+
makeProject({ slug: "MyProject", path: "/p1" }),
|
|
45
|
+
makeProject({ slug: "other", path: "/p2" }),
|
|
46
|
+
];
|
|
47
|
+
expect(findBySlug(projects, "myproject").map((p) => p.path)).toEqual(["/p1"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("trims slug", () => {
|
|
51
|
+
const projects: Project[] = [makeProject({ slug: "a", path: "/a" })];
|
|
52
|
+
expect(findBySlug(projects, " a ").length).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns empty when no match", () => {
|
|
56
|
+
const projects: Project[] = [makeProject({ slug: "x", path: "/x" })];
|
|
57
|
+
expect(findBySlug(projects, "y")).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("projectLabel", () => {
|
|
62
|
+
it("returns rootName/slug", () => {
|
|
63
|
+
const p = makeProject({ rootName: "g", slug: "s" });
|
|
64
|
+
expect(projectLabel(p)).toBe("g/s");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { readReadmeDescription } from "../src/lib/readme.js";
|
|
3
|
+
|
|
4
|
+
vi.mock("node:fs/promises", () => ({
|
|
5
|
+
default: {
|
|
6
|
+
access: vi.fn(),
|
|
7
|
+
readFile: vi.fn(),
|
|
8
|
+
},
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe("readme", () => {
|
|
12
|
+
describe("readReadmeDescription", () => {
|
|
13
|
+
it("returns title when only heading present", async () => {
|
|
14
|
+
const fs = await import("node:fs/promises");
|
|
15
|
+
vi.mocked(fs.default.access).mockResolvedValueOnce(undefined);
|
|
16
|
+
vi.mocked(fs.default.readFile).mockResolvedValueOnce("# My Project\n");
|
|
17
|
+
|
|
18
|
+
const result = await readReadmeDescription("/some/project");
|
|
19
|
+
expect(result).toBe("My Project");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns first paragraph after title", async () => {
|
|
23
|
+
const fs = await import("node:fs/promises");
|
|
24
|
+
vi.mocked(fs.default.access).mockResolvedValueOnce(undefined);
|
|
25
|
+
vi.mocked(fs.default.readFile).mockResolvedValueOnce(
|
|
26
|
+
"# Title\n\nFirst paragraph here.\n\nSecond.\n"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const result = await readReadmeDescription("/some/project");
|
|
30
|
+
expect(result).toBe("First paragraph here.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips content inside code fences", async () => {
|
|
34
|
+
const fs = await import("node:fs/promises");
|
|
35
|
+
vi.mocked(fs.default.access).mockResolvedValueOnce(undefined);
|
|
36
|
+
vi.mocked(fs.default.readFile).mockResolvedValueOnce(
|
|
37
|
+
"# Title\n\nReal paragraph.\n\n```\ncode block\n```\n\nAfter.\n"
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const result = await readReadmeDescription("/some/project");
|
|
41
|
+
expect(result).toBe("Real paragraph.");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns undefined when no readme", async () => {
|
|
45
|
+
const fs = await import("node:fs/promises");
|
|
46
|
+
vi.mocked(fs.default.access).mockRejectedValue(new Error("not found"));
|
|
47
|
+
|
|
48
|
+
const result = await readReadmeDescription("/no/readme");
|
|
49
|
+
expect(result).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { scanRoots } from "../src/lib/scan.js";
|
|
4
|
+
|
|
5
|
+
const mockFg = vi.fn();
|
|
6
|
+
vi.mock("fast-glob", () => ({
|
|
7
|
+
default: (...args: unknown[]) => mockFg(...args),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const mockIsInsideGitRepo = vi.fn();
|
|
11
|
+
vi.mock("../src/lib/git.js", () => ({
|
|
12
|
+
isInsideGitRepo: (cwd: string) => mockIsInsideGitRepo(cwd),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("scan", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockFg.mockReset();
|
|
18
|
+
mockIsInsideGitRepo.mockReset();
|
|
19
|
+
mockIsInsideGitRepo.mockResolvedValue(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns candidates from .git matches", async () => {
|
|
23
|
+
const root = path.resolve("/tmp/scan-root");
|
|
24
|
+
mockFg
|
|
25
|
+
.mockResolvedValueOnce(["proj/.git"])
|
|
26
|
+
.mockResolvedValueOnce([]);
|
|
27
|
+
const result = await scanRoots([root]);
|
|
28
|
+
expect(result.length).toBe(1);
|
|
29
|
+
expect(result[0].path).toBe(path.join(root, "proj"));
|
|
30
|
+
expect(result[0].hasGit).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns candidates from README matches", async () => {
|
|
34
|
+
const root = path.resolve("/tmp/scan-root");
|
|
35
|
+
mockFg
|
|
36
|
+
.mockResolvedValueOnce([])
|
|
37
|
+
.mockResolvedValueOnce(["other/README.md"]);
|
|
38
|
+
const result = await scanRoots([root]);
|
|
39
|
+
expect(result.length).toBe(1);
|
|
40
|
+
expect(result[0].path).toBe(path.join(root, "other"));
|
|
41
|
+
expect(result[0].hasReadme).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("filters nested projects", async () => {
|
|
45
|
+
const root = path.resolve("/tmp/scan-root");
|
|
46
|
+
mockFg
|
|
47
|
+
.mockResolvedValueOnce(["parent/.git", "parent/child/.git"])
|
|
48
|
+
.mockResolvedValueOnce([]);
|
|
49
|
+
const result = await scanRoots([root]);
|
|
50
|
+
expect(result.length).toBe(1);
|
|
51
|
+
expect(result[0].path).toBe(path.join(root, "parent"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("sets hasGit true when isInsideGitRepo returns true for candidate without .git", async () => {
|
|
55
|
+
const root = path.resolve("/tmp/scan-root");
|
|
56
|
+
mockFg
|
|
57
|
+
.mockResolvedValueOnce([])
|
|
58
|
+
.mockResolvedValueOnce(["nested/README.md"]);
|
|
59
|
+
const projectPath = path.join(root, "nested");
|
|
60
|
+
mockIsInsideGitRepo.mockImplementation((cwd: string) =>
|
|
61
|
+
Promise.resolve(cwd === projectPath)
|
|
62
|
+
);
|
|
63
|
+
const result = await scanRoots([root]);
|
|
64
|
+
expect(result.length).toBe(1);
|
|
65
|
+
expect(result[0].hasGit).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { searchProjects } from "../src/lib/search.js";
|
|
3
|
+
import type { Project } from "../src/lib/types.js";
|
|
4
|
+
|
|
5
|
+
const makeProject = (overrides: Partial<Project>): Project => ({
|
|
6
|
+
id: "/root/a",
|
|
7
|
+
slug: "a",
|
|
8
|
+
name: "a",
|
|
9
|
+
path: "/root/a",
|
|
10
|
+
root: "/root",
|
|
11
|
+
group: "root",
|
|
12
|
+
hasGit: true,
|
|
13
|
+
hasReadme: true,
|
|
14
|
+
auto: { lastIndexedAt: new Date().toISOString() },
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("search", () => {
|
|
19
|
+
const projects: Project[] = [
|
|
20
|
+
makeProject({ slug: "api-server", name: "API Server", path: "/code/api-server" }),
|
|
21
|
+
makeProject({ slug: "web-app", name: "Web App", path: "/code/web-app" }),
|
|
22
|
+
makeProject({ slug: "payments", name: "Payments", path: "/code/payments" }),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
it("returns all projects when query is blank", () => {
|
|
26
|
+
expect(searchProjects(projects, "")).toEqual(projects);
|
|
27
|
+
expect(searchProjects(projects, " ")).toEqual(projects);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns fuzzy matches for slug", () => {
|
|
31
|
+
const results = searchProjects(projects, "api");
|
|
32
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
33
|
+
expect(results.some((p) => p.slug === "api-server")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns matches for partial name", () => {
|
|
37
|
+
const results = searchProjects(projects, "payment");
|
|
38
|
+
expect(results.some((p) => p.slug === "payments")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns empty when no match", () => {
|
|
42
|
+
const results = searchProjects(projects, "xyznonexistent");
|
|
43
|
+
expect(results).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { willOverrideRoots } from "../src/commands/update.js";
|
|
3
|
+
import type { RootConfig } from "../src/lib/types.js";
|
|
4
|
+
|
|
5
|
+
const root = (path: string, name: string): RootConfig => ({ path, name });
|
|
6
|
+
|
|
7
|
+
describe("update", () => {
|
|
8
|
+
describe("willOverrideRoots", () => {
|
|
9
|
+
it("returns true when --roots provided and config has roots", () => {
|
|
10
|
+
expect(
|
|
11
|
+
willOverrideRoots(
|
|
12
|
+
[root("/a", "a")],
|
|
13
|
+
[root("/b", "b")],
|
|
14
|
+
),
|
|
15
|
+
).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns false when --roots not provided", () => {
|
|
19
|
+
expect(willOverrideRoots(undefined, [root("/b", "b")])).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns false when config has no roots", () => {
|
|
23
|
+
expect(willOverrideRoots([root("/a", "a")], [])).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns false when neither provided nor config roots", () => {
|
|
27
|
+
expect(willOverrideRoots(undefined, [])).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"jsx": "react-jsx"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"]
|
|
17
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["tests/**/*.test.ts"],
|
|
8
|
+
},
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
"@": resolve(__dirname, "src"),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
coverage: {
|
|
15
|
+
provider: "v8",
|
|
16
|
+
reporter: ["text", "html", "lcov"],
|
|
17
|
+
exclude: [
|
|
18
|
+
"dist/**",
|
|
19
|
+
"node_modules/**",
|
|
20
|
+
"src/index.ts",
|
|
21
|
+
"src/ui/**",
|
|
22
|
+
"**/*.test.ts",
|
|
23
|
+
"**/*.config.ts",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
});
|