arbors 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 (49) hide show
  1. package/.claude-plugin/plugin.json +7 -0
  2. package/.oxlintrc.json +9 -0
  3. package/README.ja.md +131 -0
  4. package/README.ko.md +131 -0
  5. package/README.md +131 -0
  6. package/bin/arbors.ts +278 -0
  7. package/dist/arbors.js +1094 -0
  8. package/dist/arbors.js.map +1 -0
  9. package/dist/bun-EMN2NS2M.js +48 -0
  10. package/dist/bun-EMN2NS2M.js.map +1 -0
  11. package/dist/ja-F4DBSAAZ.js +38 -0
  12. package/dist/ja-F4DBSAAZ.js.map +1 -0
  13. package/dist/ko-MTIAHJOR.js +38 -0
  14. package/dist/ko-MTIAHJOR.js.map +1 -0
  15. package/dist/node-LCODN3HC.js +56 -0
  16. package/dist/node-LCODN3HC.js.map +1 -0
  17. package/package.json +54 -0
  18. package/pnpm-workspace.yaml +1 -0
  19. package/shell/arbors-wrapper.sh +21 -0
  20. package/shell/arbors-wrapper.zsh +21 -0
  21. package/skills/arbors-usage/SKILL.md +129 -0
  22. package/src/config.ts +66 -0
  23. package/src/git/exclude.ts +63 -0
  24. package/src/git/safety.ts +40 -0
  25. package/src/git/worktree.ts +171 -0
  26. package/src/i18n/en.ts +63 -0
  27. package/src/i18n/index.ts +37 -0
  28. package/src/i18n/ja.ts +40 -0
  29. package/src/i18n/ko.ts +40 -0
  30. package/src/project/registry.ts +108 -0
  31. package/src/project/setup.ts +74 -0
  32. package/src/runtime/adapter.ts +16 -0
  33. package/src/runtime/bun.ts +49 -0
  34. package/src/runtime/index.ts +17 -0
  35. package/src/runtime/node.ts +58 -0
  36. package/src/tui/App.tsx +87 -0
  37. package/src/tui/FuzzyList.tsx +111 -0
  38. package/src/tui/ProjectSelector.tsx +48 -0
  39. package/src/tui/WorktreeSelector.tsx +46 -0
  40. package/tests/config.test.ts +108 -0
  41. package/tests/exclude.test.ts +120 -0
  42. package/tests/i18n.test.ts +75 -0
  43. package/tests/registry.test.ts +136 -0
  44. package/tests/safety.test.ts +58 -0
  45. package/tests/setup-detection.test.ts +105 -0
  46. package/tests/setup.test.ts +87 -0
  47. package/tsconfig.json +22 -0
  48. package/tsup.config.ts +14 -0
  49. package/vitest.config.ts +8 -0
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mergeConfig, loadConfig } from "../src/config.js";
3
+ import type { ArborConfig } from "../src/config.js";
4
+
5
+ describe("mergeConfig", () => {
6
+ it("should override base values with override values", () => {
7
+ // Given: a base config and an override with different runtime
8
+ const base = { runtime: "bun", language: "ko" } as ArborConfig;
9
+ const override = { runtime: "node" } as Partial<ArborConfig>;
10
+
11
+ // When: merged
12
+ const result = mergeConfig(base, override);
13
+
14
+ // Then: override wins, base-only values preserved
15
+ expect(result.runtime).toBe("node");
16
+ expect(result.language).toBe("ko");
17
+ });
18
+
19
+ it("should not mutate the original base object", () => {
20
+ // Given: a base config
21
+ const base = { runtime: "bun", language: "en" } as ArborConfig;
22
+ const original = structuredClone(base);
23
+
24
+ // When: merged with an override
25
+ mergeConfig(base, { runtime: "node" } as Partial<ArborConfig>);
26
+
27
+ // Then: original is untouched
28
+ expect(base).toEqual(original);
29
+ });
30
+
31
+ it("should return base when override is empty", () => {
32
+ // Given: a base config and an empty override
33
+ const base = { runtime: "node", language: "ja" } as ArborConfig;
34
+
35
+ // When: merged with empty object
36
+ const result = mergeConfig(base, {});
37
+
38
+ // Then: result matches base
39
+ expect(result).toEqual(base);
40
+ });
41
+ });
42
+
43
+ describe("loadConfig", () => {
44
+ const mockReadFile = (files: Record<string, string>) => async (path: string) => {
45
+ if (Object.hasOwn(files, path)) return files[path];
46
+ throw new Error(`File not found: ${path}`);
47
+ };
48
+
49
+ const mockExists = (files: Record<string, string>) => async (path: string) =>
50
+ Object.hasOwn(files, path);
51
+
52
+ it("should return defaults when no config files exist", async () => {
53
+ // Given: no config files on disk
54
+ const readFile = mockReadFile({});
55
+ const exists = mockExists({});
56
+
57
+ // When: config is loaded
58
+ const config = await loadConfig(readFile, exists);
59
+
60
+ // Then: all values are defaults
61
+ expect(config.runtime).toBe("node");
62
+ expect(config.language).toBe("en");
63
+ expect(config.packageManager).toBe("auto");
64
+ expect(config.copyExcludes).toBe(true);
65
+ });
66
+
67
+ it("should merge project config over global config", async () => {
68
+ // Given: global sets runtime=bun, project sets runtime=node
69
+ const home = process.env.HOME ?? "/tmp";
70
+ const globalPath = `${home}/.arbors/config.json`;
71
+ const projectPath = "/project/.arbors/config.json";
72
+
73
+ const files: Record<string, string> = {
74
+ [globalPath]: JSON.stringify({ runtime: "bun", language: "ko" }),
75
+ [projectPath]: JSON.stringify({ runtime: "node" }),
76
+ };
77
+
78
+ const readFile = mockReadFile(files);
79
+ const exists = mockExists(files);
80
+
81
+ // When: config is loaded with a project root
82
+ const config = await loadConfig(readFile, exists, "/project");
83
+
84
+ // Then: project override wins, global-only values preserved
85
+ expect(config.runtime).toBe("node");
86
+ expect(config.language).toBe("ko");
87
+ });
88
+
89
+ it("should handle malformed config files gracefully", async () => {
90
+ // Given: a global config with invalid JSON
91
+ const home = process.env.HOME ?? "/tmp";
92
+ const globalPath = `${home}/.arbors/config.json`;
93
+
94
+ const files: Record<string, string> = {
95
+ [globalPath]: "not valid json{{{",
96
+ };
97
+
98
+ const readFile = mockReadFile(files);
99
+ const exists = mockExists(files);
100
+
101
+ // When: config is loaded
102
+ const config = await loadConfig(readFile, exists);
103
+
104
+ // Then: falls back to defaults
105
+ expect(config.runtime).toBe("node");
106
+ expect(config.language).toBe("en");
107
+ });
108
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { getExcludePatterns, findExcludedEntries, copyExcludedFiles } from "../src/git/exclude.js";
3
+ import type { RuntimeAdapter } from "../src/runtime/adapter.js";
4
+
5
+ const createMockAdapter = (overrides: Partial<RuntimeAdapter> = {}): RuntimeAdapter => ({
6
+ exec: vi.fn(async () => ({ stdout: "/repo", stderr: "", exitCode: 0 })),
7
+ glob: vi.fn(async () => []),
8
+ readFile: vi.fn(async () => ""),
9
+ writeFile: vi.fn(),
10
+ exists: vi.fn(async () => true),
11
+ copy: vi.fn(),
12
+ mkdir: vi.fn(),
13
+ ...overrides,
14
+ });
15
+
16
+ describe("getExcludePatterns", () => {
17
+ it("should parse patterns from exclude file", async () => {
18
+ // Given: an exclude file with patterns, comments, and blank lines
19
+ const adapter = createMockAdapter({
20
+ readFile: vi.fn(async () => "# comment\n.env\n\nnode_modules\n# another comment\n.secret\n"),
21
+ });
22
+
23
+ // When: patterns are extracted
24
+ const patterns = await getExcludePatterns(adapter);
25
+
26
+ // Then: only non-empty, non-comment lines are returned
27
+ expect(patterns).toEqual([".env", "node_modules", ".secret"]);
28
+ });
29
+
30
+ it("should return empty array when exclude file does not exist", async () => {
31
+ // Given: no exclude file
32
+ const adapter = createMockAdapter({
33
+ exists: vi.fn(async () => false),
34
+ });
35
+
36
+ // When: patterns are extracted
37
+ const patterns = await getExcludePatterns(adapter);
38
+
39
+ // Then: empty array
40
+ expect(patterns).toEqual([]);
41
+ });
42
+
43
+ it("should trim whitespace from patterns", async () => {
44
+ // Given: patterns with extra whitespace
45
+ const adapter = createMockAdapter({
46
+ readFile: vi.fn(async () => " .env \n node_modules \n"),
47
+ });
48
+
49
+ // When: patterns are extracted
50
+ const patterns = await getExcludePatterns(adapter);
51
+
52
+ // Then: patterns are trimmed
53
+ expect(patterns).toEqual([".env", "node_modules"]);
54
+ });
55
+ });
56
+
57
+ describe("findExcludedEntries", () => {
58
+ it("should glob each pattern and deduplicate results", async () => {
59
+ // Given: patterns that match overlapping entries
60
+ const adapter = createMockAdapter({
61
+ glob: vi.fn(async (pattern: string) => {
62
+ if (pattern === ".env") return [".env"];
63
+ if (pattern === ".env*") return [".env", ".env.local"];
64
+ return [];
65
+ }),
66
+ });
67
+
68
+ // When: excluded entries are found
69
+ const entries = await findExcludedEntries(adapter, [".env", ".env*"]);
70
+
71
+ // Then: duplicates are removed and sorted
72
+ expect(entries).toEqual([".env", ".env.local"]);
73
+ });
74
+
75
+ it("should strip leading slashes from patterns", async () => {
76
+ // Given: a pattern with a leading slash
77
+ const globFn = vi.fn(async () => ["secret.key"]);
78
+ const adapter = createMockAdapter({ glob: globFn });
79
+
80
+ // When: excluded entries are found
81
+ await findExcludedEntries(adapter, ["/secret.key"]);
82
+
83
+ // Then: glob is called without the leading slash
84
+ expect(globFn).toHaveBeenCalledWith("secret.key", "/repo");
85
+ });
86
+ });
87
+
88
+ describe("copyExcludedFiles", () => {
89
+ it("should copy each excluded file to the worktree", async () => {
90
+ // Given: exclude patterns that match two files
91
+ const copyFn = vi.fn();
92
+ const mkdirFn = vi.fn();
93
+ const adapter = createMockAdapter({
94
+ readFile: vi.fn(async () => ".env\n"),
95
+ glob: vi.fn(async () => [".env"]),
96
+ copy: copyFn,
97
+ mkdir: mkdirFn,
98
+ });
99
+
100
+ // When: excluded files are copied
101
+ const copied = await copyExcludedFiles(adapter, "/worktree");
102
+
103
+ // Then: files are copied from repo root to worktree
104
+ expect(copied).toEqual([".env"]);
105
+ expect(copyFn).toHaveBeenCalledWith("/repo/.env", "/worktree/.env");
106
+ });
107
+
108
+ it("should return empty array when no exclude patterns exist", async () => {
109
+ // Given: no patterns in exclude file
110
+ const adapter = createMockAdapter({
111
+ readFile: vi.fn(async () => ""),
112
+ });
113
+
114
+ // When: copy is attempted
115
+ const copied = await copyExcludedFiles(adapter, "/worktree");
116
+
117
+ // Then: nothing is copied
118
+ expect(copied).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { loadMessages } from "../src/i18n/index.js";
3
+
4
+ describe("i18n", () => {
5
+ beforeEach(() => {
6
+ vi.stubEnv("LANG", "");
7
+ vi.stubEnv("LANGUAGE", "");
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllEnvs();
12
+ });
13
+
14
+ it("should load English messages by default", async () => {
15
+ // Given: no language configured and no LANG env
16
+ // When: messages are loaded without config
17
+ const messages = await loadMessages();
18
+
19
+ // Then: English messages are returned
20
+ expect(messages.selectProject).toBe("Select a project:");
21
+ });
22
+
23
+ it("should load Korean messages when configured", async () => {
24
+ // Given: language is set to ko
25
+ // When: messages are loaded with ko config
26
+ const messages = await loadMessages("ko");
27
+
28
+ // Then: Korean messages are returned
29
+ expect(messages.selectProject).toBe("프로젝트를 선택하세요:");
30
+ });
31
+
32
+ it("should load Japanese messages when configured", async () => {
33
+ // Given: language is set to ja
34
+ // When: messages are loaded with ja config
35
+ const messages = await loadMessages("ja");
36
+
37
+ // Then: Japanese messages are returned
38
+ expect(messages.selectProject).toBe("プロジェクトを選択してください:");
39
+ });
40
+
41
+ it("should detect language from LANG env variable", async () => {
42
+ // Given: LANG is set to ko_KR.UTF-8
43
+ vi.stubEnv("LANG", "ko_KR.UTF-8");
44
+
45
+ // When: messages are loaded without explicit config
46
+ const messages = await loadMessages();
47
+
48
+ // Then: Korean is detected and loaded
49
+ expect(messages.selectProject).toBe("프로젝트를 선택하세요:");
50
+ });
51
+
52
+ it("should handle resultsFound with correct pluralization", async () => {
53
+ // Given: English messages
54
+ const messages = await loadMessages("en");
55
+
56
+ // When: resultsFound is called with 1 and 3
57
+ const singular = messages.resultsFound(1);
58
+ const plural = messages.resultsFound(3);
59
+
60
+ // Then: pluralization is correct
61
+ expect(singular).toBe("1 result found");
62
+ expect(plural).toBe("3 results found");
63
+ });
64
+
65
+ it("should fall back to English for unknown locale", async () => {
66
+ // Given: LANG is set to an unsupported language
67
+ vi.stubEnv("LANG", "fr_FR.UTF-8");
68
+
69
+ // When: messages are loaded
70
+ const messages = await loadMessages();
71
+
72
+ // Then: falls back to English
73
+ expect(messages.selectProject).toBe("Select a project:");
74
+ });
75
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ getProjects,
4
+ getWorktrees,
5
+ registerProject,
6
+ registerWorktree,
7
+ removeProject,
8
+ unregisterWorktree,
9
+ } from "../src/project/registry.js";
10
+ import type { RuntimeAdapter } from "../src/runtime/adapter.js";
11
+
12
+ const createMockAdapter = (): RuntimeAdapter & { files: Map<string, string> } => {
13
+ const files = new Map<string, string>();
14
+
15
+ return {
16
+ files,
17
+ exec: vi.fn(),
18
+ glob: vi.fn(),
19
+ readFile: vi.fn(async (path: string) => {
20
+ const content = files.get(path);
21
+ if (!content) throw new Error("Not found");
22
+ return content;
23
+ }),
24
+ writeFile: vi.fn(async (path: string, content: string) => {
25
+ files.set(path, content);
26
+ }),
27
+ exists: vi.fn(async (path: string) => files.has(path)),
28
+ copy: vi.fn(),
29
+ mkdir: vi.fn(),
30
+ };
31
+ };
32
+
33
+ describe("Project Registry", () => {
34
+ let adapter: ReturnType<typeof createMockAdapter>;
35
+
36
+ beforeEach(() => {
37
+ adapter = createMockAdapter();
38
+ });
39
+
40
+ it("should return empty list when no projects registered", async () => {
41
+ // Given: an empty registry (no db.json)
42
+ // When: projects are fetched
43
+ const projects = await getProjects(adapter);
44
+
45
+ // Then: empty array returned
46
+ expect(projects).toEqual([]);
47
+ });
48
+
49
+ it("should register and retrieve a project", async () => {
50
+ // Given: an empty registry
51
+ // When: a project is registered
52
+ await registerProject(adapter, "my-app", "/home/user/my-app");
53
+
54
+ // Then: it appears in the project list
55
+ const projects = await getProjects(adapter);
56
+ expect(projects).toHaveLength(1);
57
+ expect(projects[0].name).toBe("my-app");
58
+ expect(projects[0].path).toBe("/home/user/my-app");
59
+ });
60
+
61
+ it("should update lastAccessed when registering an existing project", async () => {
62
+ // Given: a registered project
63
+ await registerProject(adapter, "my-app", "/home/user/my-app");
64
+ const firstAccess = (await getProjects(adapter))[0].lastAccessed;
65
+
66
+ // When: the same path is registered again after a delay
67
+ await new Promise((r) => setTimeout(r, 10));
68
+ await registerProject(adapter, "my-app", "/home/user/my-app");
69
+
70
+ // Then: lastAccessed is updated
71
+ const projects = await getProjects(adapter);
72
+ expect(projects).toHaveLength(1);
73
+ expect(projects[0].lastAccessed).not.toBe(firstAccess);
74
+ });
75
+
76
+ it("should sort projects by most recently accessed", async () => {
77
+ // Given: two projects registered at different times
78
+ await registerProject(adapter, "old-app", "/old");
79
+ await new Promise((r) => setTimeout(r, 10));
80
+ await registerProject(adapter, "new-app", "/new");
81
+
82
+ // When: projects are fetched
83
+ const projects = await getProjects(adapter);
84
+
85
+ // Then: most recent first
86
+ expect(projects[0].name).toBe("new-app");
87
+ expect(projects[1].name).toBe("old-app");
88
+ });
89
+
90
+ it("should remove a project by path", async () => {
91
+ // Given: a registered project
92
+ await registerProject(adapter, "my-app", "/home/user/my-app");
93
+
94
+ // When: removed by path
95
+ await removeProject(adapter, "/home/user/my-app");
96
+
97
+ // Then: no projects remain
98
+ const projects = await getProjects(adapter);
99
+ expect(projects).toEqual([]);
100
+ });
101
+ });
102
+
103
+ describe("Worktree Registry", () => {
104
+ let adapter: ReturnType<typeof createMockAdapter>;
105
+
106
+ beforeEach(() => {
107
+ adapter = createMockAdapter();
108
+ });
109
+
110
+ it("should register and retrieve worktrees by project", async () => {
111
+ await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
112
+ await registerWorktree(adapter, "/wt/fix-bug", "fix/bug", "/repo");
113
+ await registerWorktree(adapter, "/wt/other", "other", "/other-repo");
114
+
115
+ const worktrees = await getWorktrees(adapter, "/repo");
116
+ expect(worktrees).toHaveLength(2);
117
+ expect(worktrees[0].branch).toBe("feature/login");
118
+ expect(worktrees[1].branch).toBe("fix/bug");
119
+ });
120
+
121
+ it("should not duplicate worktrees with the same path", async () => {
122
+ await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
123
+ await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
124
+
125
+ const worktrees = await getWorktrees(adapter, "/repo");
126
+ expect(worktrees).toHaveLength(1);
127
+ });
128
+
129
+ it("should unregister a worktree by path", async () => {
130
+ await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
131
+ await unregisterWorktree(adapter, "/wt/feature-login");
132
+
133
+ const worktrees = await getWorktrees(adapter, "/repo");
134
+ expect(worktrees).toEqual([]);
135
+ });
136
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateWorktreeName } from "../src/git/safety.js";
3
+
4
+ describe("validateWorktreeName", () => {
5
+ it("should accept valid alphanumeric names", () => {
6
+ // Given: valid worktree names
7
+ const names = ["feature-auth", "fix.login", "release_1.0", "v2"];
8
+
9
+ // When/Then: all should be valid
10
+ names.forEach((name) => {
11
+ expect(validateWorktreeName(name)).toBe(true);
12
+ });
13
+ });
14
+
15
+ it("should reject names starting with special characters", () => {
16
+ // Given: names starting with dot, dash, or underscore
17
+ const names = [".hidden", "-flag", "_private"];
18
+
19
+ // When/Then: all should be invalid
20
+ names.forEach((name) => {
21
+ expect(validateWorktreeName(name)).toBe(false);
22
+ });
23
+ });
24
+
25
+ it("should reject empty strings", () => {
26
+ // Given: an empty string
27
+ // When: validated
28
+ // Then: should be invalid
29
+ expect(validateWorktreeName("")).toBe(false);
30
+ });
31
+
32
+ it("should reject names with path traversal", () => {
33
+ // Given: a name containing ".."
34
+ // When: validated
35
+ // Then: should be invalid
36
+ expect(validateWorktreeName("foo..bar")).toBe(false);
37
+ });
38
+
39
+ it("should accept names with slashes for branch conventions", () => {
40
+ // Given: names with slashes (feature/xxx, fix/xxx patterns)
41
+ const names = ["feature/login", "fix/ACD-123", "release/1.0"];
42
+
43
+ // When/Then: all should be valid
44
+ names.forEach((name) => {
45
+ expect(validateWorktreeName(name)).toBe(true);
46
+ });
47
+ });
48
+
49
+ it("should reject names with spaces or special chars", () => {
50
+ // Given: names with spaces or special characters
51
+ const names = ["has space", "has@at"];
52
+
53
+ // When/Then: all should be invalid
54
+ names.forEach((name) => {
55
+ expect(validateWorktreeName(name)).toBe(false);
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { detectPackageManager, detectRuntimeManager } from "../src/project/setup.js";
3
+ import type { RuntimeAdapter } from "../src/runtime/adapter.js";
4
+
5
+ const createMockAdapter = (existingFiles: string[]): RuntimeAdapter => ({
6
+ exec: vi.fn(),
7
+ glob: vi.fn(),
8
+ readFile: vi.fn(),
9
+ writeFile: vi.fn(),
10
+ exists: vi.fn(async (path: string) => existingFiles.some((f) => path.endsWith(f))),
11
+ copy: vi.fn(),
12
+ mkdir: vi.fn(),
13
+ });
14
+
15
+ describe("detectPackageManager", () => {
16
+ it("should detect pnpm from lock file", async () => {
17
+ // Given: a directory with pnpm-lock.yaml
18
+ const adapter = createMockAdapter(["pnpm-lock.yaml"]);
19
+
20
+ // When: package manager is detected
21
+ const result = await detectPackageManager(adapter, "/project");
22
+
23
+ // Then: pnpm is detected
24
+ expect(result).toBe("pnpm");
25
+ });
26
+
27
+ it("should detect yarn from lock file", async () => {
28
+ // Given: a directory with yarn.lock
29
+ const adapter = createMockAdapter(["yarn.lock"]);
30
+
31
+ // When: detected
32
+ const result = await detectPackageManager(adapter, "/project");
33
+
34
+ // Then: yarn
35
+ expect(result).toBe("yarn");
36
+ });
37
+
38
+ it("should detect npm from lock file", async () => {
39
+ // Given: a directory with package-lock.json
40
+ const adapter = createMockAdapter(["package-lock.json"]);
41
+
42
+ // When: detected
43
+ const result = await detectPackageManager(adapter, "/project");
44
+
45
+ // Then: npm
46
+ expect(result).toBe("npm");
47
+ });
48
+
49
+ it("should return null when no lock file found", async () => {
50
+ // Given: a directory with no lock files
51
+ const adapter = createMockAdapter([]);
52
+
53
+ // When: detected
54
+ const result = await detectPackageManager(adapter, "/project");
55
+
56
+ // Then: null
57
+ expect(result).toBeNull();
58
+ });
59
+
60
+ it("should prioritize pnpm over yarn when both exist", async () => {
61
+ // Given: a directory with both pnpm and yarn lock files
62
+ const adapter = createMockAdapter(["pnpm-lock.yaml", "yarn.lock"]);
63
+
64
+ // When: detected
65
+ const result = await detectPackageManager(adapter, "/project");
66
+
67
+ // Then: pnpm wins (first in priority order)
68
+ expect(result).toBe("pnpm");
69
+ });
70
+ });
71
+
72
+ describe("detectRuntimeManager", () => {
73
+ it("should detect mise from mise.toml", async () => {
74
+ // Given: a directory with mise.toml
75
+ const adapter = createMockAdapter(["mise.toml"]);
76
+
77
+ // When: detected
78
+ const result = await detectRuntimeManager(adapter, "/project");
79
+
80
+ // Then: mise
81
+ expect(result).toBe("mise");
82
+ });
83
+
84
+ it("should detect nvm from .nvmrc", async () => {
85
+ // Given: a directory with .nvmrc
86
+ const adapter = createMockAdapter([".nvmrc"]);
87
+
88
+ // When: detected
89
+ const result = await detectRuntimeManager(adapter, "/project");
90
+
91
+ // Then: nvm
92
+ expect(result).toBe("nvm");
93
+ });
94
+
95
+ it("should return null when no runtime manager found", async () => {
96
+ // Given: a directory with no runtime config files
97
+ const adapter = createMockAdapter([]);
98
+
99
+ // When: detected
100
+ const result = await detectRuntimeManager(adapter, "/project");
101
+
102
+ // Then: null
103
+ expect(result).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { runSetup } from "../src/project/setup.js";
3
+ import type { RuntimeAdapter } from "../src/runtime/adapter.js";
4
+
5
+ const createMockAdapter = (existingFiles: string[], execLog: string[][] = []): RuntimeAdapter => ({
6
+ exec: vi.fn(async (cmd: string, args: string[]) => {
7
+ execLog.push([cmd, ...args]);
8
+ return { stdout: "", stderr: "", exitCode: 0 };
9
+ }),
10
+ glob: vi.fn(),
11
+ readFile: vi.fn(),
12
+ writeFile: vi.fn(),
13
+ exists: vi.fn(async (path: string) => existingFiles.some((f) => path.endsWith(f))),
14
+ copy: vi.fn(),
15
+ mkdir: vi.fn(),
16
+ });
17
+
18
+ describe("runSetup", () => {
19
+ it("should detect and run pnpm install", async () => {
20
+ // Given: a project with pnpm-lock.yaml
21
+ const execLog: string[][] = [];
22
+ const adapter = createMockAdapter(["pnpm-lock.yaml"], execLog);
23
+
24
+ // When: setup runs with auto detection
25
+ const result = await runSetup(adapter, "/project");
26
+
27
+ // Then: pnpm install is executed
28
+ expect(result.packageManager).toBe("pnpm");
29
+ expect(execLog).toContainEqual(["pnpm", "install"]);
30
+ });
31
+
32
+ it("should use config-specified package manager over auto-detect", async () => {
33
+ // Given: a project with pnpm lock but config says yarn
34
+ const execLog: string[][] = [];
35
+ const adapter = createMockAdapter(["pnpm-lock.yaml"], execLog);
36
+
37
+ // When: setup runs with yarn override
38
+ const result = await runSetup(adapter, "/project", "yarn");
39
+
40
+ // Then: yarn is used instead of pnpm
41
+ expect(result.packageManager).toBe("yarn");
42
+ expect(execLog).toContainEqual(["yarn", "install"]);
43
+ });
44
+
45
+ it("should detect and run mise install before package manager", async () => {
46
+ // Given: a project with mise.toml and pnpm-lock.yaml
47
+ const execLog: string[][] = [];
48
+ const adapter = createMockAdapter(["mise.toml", "pnpm-lock.yaml"], execLog);
49
+
50
+ // When: setup runs
51
+ const result = await runSetup(adapter, "/project");
52
+
53
+ // Then: mise runs first, then pnpm
54
+ expect(result.runtimeManager).toBe("mise");
55
+ expect(result.packageManager).toBe("pnpm");
56
+ const miseIndex = execLog.findIndex((c) => c[0] === "mise");
57
+ const pnpmIndex = execLog.findIndex((c) => c[0] === "pnpm");
58
+ expect(miseIndex).toBeLessThan(pnpmIndex);
59
+ });
60
+
61
+ it("should skip package manager when no lock file and auto mode", async () => {
62
+ // Given: a project with no lock files
63
+ const execLog: string[][] = [];
64
+ const adapter = createMockAdapter([], execLog);
65
+
66
+ // When: setup runs
67
+ const result = await runSetup(adapter, "/project");
68
+
69
+ // Then: no package manager is detected or run
70
+ expect(result.packageManager).toBeNull();
71
+ expect(execLog).toHaveLength(0);
72
+ });
73
+
74
+ it("should detect nvm and source nvm.sh before nvm install", async () => {
75
+ // Given: a project with .nvmrc
76
+ const execLog: string[][] = [];
77
+ const adapter = createMockAdapter([".nvmrc"], execLog);
78
+
79
+ // When: setup runs
80
+ const result = await runSetup(adapter, "/project");
81
+
82
+ // Then: nvm is detected and invoked via bash -c
83
+ expect(result.runtimeManager).toBe("nvm");
84
+ expect(execLog[0][0]).toBe("bash");
85
+ expect(execLog[0][1]).toBe("-c");
86
+ });
87
+ });