@nemus-cli/nemus 0.2.1
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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/assets/README.md +53 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +66 -0
- package/assets/favicon-32.png +0 -0
- package/assets/favicon-64.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon.svg +23 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-wordmark.svg +35 -0
- package/assets/logo.svg +32 -0
- package/bin/workspace.js +130 -0
- package/dist/cli/ai-prompt.js +421 -0
- package/dist/cli/dashboard/AgentList.js +61 -0
- package/dist/cli/dashboard/DashboardSidebar.js +280 -0
- package/dist/cli/dashboard/HelpFooter.js +73 -0
- package/dist/cli/dashboard/StatusBadge.js +50 -0
- package/dist/cli/dashboard/index.js +11 -0
- package/dist/cli/dashboard/types.js +2 -0
- package/dist/cli/tui.js +48 -0
- package/dist/commands/ai-prompt.js +22 -0
- package/dist/commands/analyze-deps.js +138 -0
- package/dist/commands/archive.js +113 -0
- package/dist/commands/branch/create.js +119 -0
- package/dist/commands/branch/index.js +104 -0
- package/dist/commands/branch/merge.js +90 -0
- package/dist/commands/branch/rebase.js +84 -0
- package/dist/commands/branch/switch.js +165 -0
- package/dist/commands/cache/index.js +80 -0
- package/dist/commands/cache/manager.js +198 -0
- package/dist/commands/cleanup.js +153 -0
- package/dist/commands/configure-claude.js +55 -0
- package/dist/commands/configure.js +231 -0
- package/dist/commands/create.js +204 -0
- package/dist/commands/dashboard/hook-handler.js +162 -0
- package/dist/commands/dashboard/index.js +258 -0
- package/dist/commands/dashboard/launcher.js +40 -0
- package/dist/commands/dashboard/session-picker.js +106 -0
- package/dist/commands/dashboard/sidebar.js +39 -0
- package/dist/commands/dashboard/workspace-picker.js +95 -0
- package/dist/commands/delete.js +194 -0
- package/dist/commands/deprecated-aliases.js +44 -0
- package/dist/commands/diff.js +129 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/generate-docs.js +77 -0
- package/dist/commands/ghq-status.js +72 -0
- package/dist/commands/go.js +154 -0
- package/dist/commands/history.js +124 -0
- package/dist/commands/list.js +191 -0
- package/dist/commands/mcp/index.js +69 -0
- package/dist/commands/migrate.js +296 -0
- package/dist/commands/remove-repo.js +217 -0
- package/dist/commands/report-bug.js +112 -0
- package/dist/commands/run.js +147 -0
- package/dist/commands/save-context.js +171 -0
- package/dist/commands/sessions.js +129 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/suite/create.js +212 -0
- package/dist/commands/suite/delete.js +64 -0
- package/dist/commands/suite/export.js +126 -0
- package/dist/commands/suite/import.js +128 -0
- package/dist/commands/suite/index.js +94 -0
- package/dist/commands/suite/list.js +49 -0
- package/dist/commands/suite/use.js +227 -0
- package/dist/commands/sync.js +117 -0
- package/dist/commands/update.js +203 -0
- package/dist/mcp/install.js +550 -0
- package/dist/mcp/server.js +398 -0
- package/dist/mcp/tools.js +922 -0
- package/dist/pi-extensions/permission-sync.ts.template +41 -0
- package/dist/pi-extensions/permissions-gate.ts.template +69 -0
- package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/dist/pi-extensions/review-reminder.ts.template +58 -0
- package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
- package/dist/program.js +136 -0
- package/dist/scripts/workspace-table.py +394 -0
- package/dist/types/dashboard.js +11 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/agent-config.js +263 -0
- package/dist/utils/agent-state.js +178 -0
- package/dist/utils/banner.js +24 -0
- package/dist/utils/branch-operations.js +232 -0
- package/dist/utils/bug-report.js +355 -0
- package/dist/utils/cache.js +168 -0
- package/dist/utils/claude-integration.js +617 -0
- package/dist/utils/claude-sessions.js +260 -0
- package/dist/utils/cleanup-operations.js +120 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/command-helpers.js +44 -0
- package/dist/utils/config.js +150 -0
- package/dist/utils/context-file.js +33 -0
- package/dist/utils/dashboard-hooks.js +172 -0
- package/dist/utils/dependency-analyzer.js +234 -0
- package/dist/utils/diff-operations.js +80 -0
- package/dist/utils/doc-generator.js +184 -0
- package/dist/utils/ghq-integration.js +332 -0
- package/dist/utils/git-operations.js +237 -0
- package/dist/utils/git-status.js +187 -0
- package/dist/utils/github.js +68 -0
- package/dist/utils/health-checks.js +345 -0
- package/dist/utils/history.js +150 -0
- package/dist/utils/hooks.js +100 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/permission-sync.js +681 -0
- package/dist/utils/pi-extensions.js +224 -0
- package/dist/utils/progress.js +92 -0
- package/dist/utils/prompts.js +279 -0
- package/dist/utils/repo-resolver.js +260 -0
- package/dist/utils/retry.js +65 -0
- package/dist/utils/run-operations.js +30 -0
- package/dist/utils/suite.js +159 -0
- package/dist/utils/sync-operations.js +107 -0
- package/dist/utils/tmux-dashboard.js +184 -0
- package/dist/utils/validation.js +153 -0
- package/dist/utils/version-check.js +121 -0
- package/dist/utils/workspace-meta.js +185 -0
- package/install-shell-integration.sh +532 -0
- package/install.sh +401 -0
- package/package.json +90 -0
- package/remove-shell-block.awk +44 -0
- package/scripts/postinstall.js +85 -0
- package/skills/analyze-deps.md +23 -0
- package/skills/archive-workspace.md +26 -0
- package/skills/branch-create.md +18 -0
- package/skills/create-workspace.md +39 -0
- package/skills/delete-workspace.md +37 -0
- package/skills/list-org-repos.md +26 -0
- package/skills/list-suites.md +24 -0
- package/skills/list-workspaces.md +27 -0
- package/skills/refresh-workspace-docs.md +89 -0
- package/skills/remove-repo.md +25 -0
- package/skills/run-command.md +23 -0
- package/skills/save-context.md +68 -0
- package/skills/search-repos.md +31 -0
- package/skills/snapshot-list.md +18 -0
- package/skills/snapshot-save.md +12 -0
- package/skills/switch-branch.md +18 -0
- package/skills/update-cache.md +21 -0
- package/skills/update-workspace.md +66 -0
- package/skills/workspace-cleanup.md +18 -0
- package/skills/workspace-diff.md +23 -0
- package/skills/workspace-doctor.md +23 -0
- package/skills/workspace-info.md +45 -0
- package/skills/workspace-manager/SKILL.md +201 -0
- package/skills/workspace-manager/references/ai-prompt.md +44 -0
- package/skills/workspace-manager/references/analyze-deps.md +45 -0
- package/skills/workspace-manager/references/archive-workspace.md +38 -0
- package/skills/workspace-manager/references/branch-create.md +31 -0
- package/skills/workspace-manager/references/branch-merge.md +22 -0
- package/skills/workspace-manager/references/branch-rebase.md +22 -0
- package/skills/workspace-manager/references/cache-clear.md +18 -0
- package/skills/workspace-manager/references/cache-info.md +22 -0
- package/skills/workspace-manager/references/configure-claude.md +31 -0
- package/skills/workspace-manager/references/configure.md +35 -0
- package/skills/workspace-manager/references/create-workspace.md +45 -0
- package/skills/workspace-manager/references/dashboard.md +61 -0
- package/skills/workspace-manager/references/delete-workspace.md +33 -0
- package/skills/workspace-manager/references/generate-docs.md +36 -0
- package/skills/workspace-manager/references/ghq-status.md +28 -0
- package/skills/workspace-manager/references/go.md +33 -0
- package/skills/workspace-manager/references/history.md +41 -0
- package/skills/workspace-manager/references/list-org-repos.md +26 -0
- package/skills/workspace-manager/references/list-suites.md +21 -0
- package/skills/workspace-manager/references/list-workspaces.md +27 -0
- package/skills/workspace-manager/references/mcp.md +49 -0
- package/skills/workspace-manager/references/remove-repo.md +26 -0
- package/skills/workspace-manager/references/run-command.md +44 -0
- package/skills/workspace-manager/references/search-repos.md +24 -0
- package/skills/workspace-manager/references/sessions.md +27 -0
- package/skills/workspace-manager/references/suite-create.md +22 -0
- package/skills/workspace-manager/references/suite-delete.md +21 -0
- package/skills/workspace-manager/references/suite-export.md +19 -0
- package/skills/workspace-manager/references/suite-import.md +19 -0
- package/skills/workspace-manager/references/suite-use.md +27 -0
- package/skills/workspace-manager/references/switch-branch.md +25 -0
- package/skills/workspace-manager/references/update-cache.md +22 -0
- package/skills/workspace-manager/references/update-workspace.md +50 -0
- package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
- package/skills/workspace-manager/references/workspace-diff.md +27 -0
- package/skills/workspace-manager/references/workspace-doctor.md +27 -0
- package/skills/workspace-manager/references/workspace-info.md +33 -0
- package/skills/workspace-manager/references/workspace-status.md +43 -0
- package/skills/workspace-manager/references/workspace-sync.md +26 -0
- package/skills/workspace-status.md +26 -0
- package/skills/workspace-sync.md +23 -0
- package/src/cli/ai-prompt.test.ts +667 -0
- package/src/cli/ai-prompt.ts +421 -0
- package/src/cli/dashboard/AgentList.tsx +44 -0
- package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
- package/src/cli/dashboard/HelpFooter.tsx +22 -0
- package/src/cli/dashboard/StatusBadge.tsx +23 -0
- package/src/cli/dashboard/index.ts +5 -0
- package/src/cli/dashboard/types.ts +16 -0
- package/src/cli/tui.tsx +51 -0
- package/src/cli/workspace-cli.test.ts +30 -0
- package/src/commands/ai-prompt.ts +21 -0
- package/src/commands/analyze-deps.ts +119 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/branch/create.ts +94 -0
- package/src/commands/branch/index.ts +74 -0
- package/src/commands/branch/merge.ts +61 -0
- package/src/commands/branch/rebase.ts +54 -0
- package/src/commands/branch/switch.ts +150 -0
- package/src/commands/cache/index.ts +51 -0
- package/src/commands/cache/manager.ts +168 -0
- package/src/commands/cleanup.ts +132 -0
- package/src/commands/configure-claude.ts +55 -0
- package/src/commands/configure.ts +204 -0
- package/src/commands/create.ts +196 -0
- package/src/commands/dashboard/hook-handler.ts +138 -0
- package/src/commands/dashboard/index.ts +248 -0
- package/src/commands/dashboard/launcher.ts +42 -0
- package/src/commands/dashboard/session-picker.ts +73 -0
- package/src/commands/dashboard/sidebar.js +39 -0
- package/src/commands/dashboard/workspace-picker.ts +62 -0
- package/src/commands/delete.test.ts +250 -0
- package/src/commands/delete.ts +171 -0
- package/src/commands/deprecated-aliases.ts +55 -0
- package/src/commands/diff.ts +106 -0
- package/src/commands/doctor.ts +110 -0
- package/src/commands/generate-docs.ts +45 -0
- package/src/commands/ghq-status.ts +70 -0
- package/src/commands/go.ts +125 -0
- package/src/commands/history.ts +136 -0
- package/src/commands/list.test.ts +219 -0
- package/src/commands/list.ts +176 -0
- package/src/commands/mcp/index.ts +39 -0
- package/src/commands/migrate.ts +277 -0
- package/src/commands/remove-repo.ts +194 -0
- package/src/commands/report-bug.ts +88 -0
- package/src/commands/run.ts +125 -0
- package/src/commands/save-context.test.ts +113 -0
- package/src/commands/save-context.ts +141 -0
- package/src/commands/sessions.ts +99 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/suite/create.ts +240 -0
- package/src/commands/suite/delete.ts +65 -0
- package/src/commands/suite/export.ts +97 -0
- package/src/commands/suite/import.ts +100 -0
- package/src/commands/suite/index.ts +66 -0
- package/src/commands/suite/list.ts +54 -0
- package/src/commands/suite/use.ts +232 -0
- package/src/commands/sync.ts +97 -0
- package/src/commands/update.ts +197 -0
- package/src/mcp/install.ts +529 -0
- package/src/mcp/server.ts +579 -0
- package/src/mcp/tools.test.ts +545 -0
- package/src/mcp/tools.ts +1019 -0
- package/src/mcp/update-workspace.test.ts +220 -0
- package/src/pi-extensions/permission-sync.ts.template +41 -0
- package/src/pi-extensions/permissions-gate.ts.template +69 -0
- package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/src/pi-extensions/review-reminder.ts.template +58 -0
- package/src/pi-extensions/workspace-input-status.ts.template +919 -0
- package/src/program.ts +112 -0
- package/src/scripts/workspace-table.py +394 -0
- package/src/types/dashboard.ts +29 -0
- package/src/types/index.ts +109 -0
- package/src/types/ink.d.ts +171 -0
- package/src/utils/agent-config.test.ts +303 -0
- package/src/utils/agent-config.ts +264 -0
- package/src/utils/agent-state.test.ts +166 -0
- package/src/utils/agent-state.ts +135 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/branch-operations.ts +237 -0
- package/src/utils/bug-report.test.ts +174 -0
- package/src/utils/bug-report.ts +355 -0
- package/src/utils/cache.ts +150 -0
- package/src/utils/claude-integration.test.ts +381 -0
- package/src/utils/claude-integration.ts +649 -0
- package/src/utils/claude-sessions.test.ts +262 -0
- package/src/utils/claude-sessions.ts +251 -0
- package/src/utils/cleanup-operations.ts +94 -0
- package/src/utils/colors.ts +25 -0
- package/src/utils/command-helpers.ts +46 -0
- package/src/utils/config.test.ts +203 -0
- package/src/utils/config.ts +133 -0
- package/src/utils/context-file.ts +36 -0
- package/src/utils/dashboard-hooks.test.ts +168 -0
- package/src/utils/dashboard-hooks.ts +163 -0
- package/src/utils/dependency-analyzer.ts +243 -0
- package/src/utils/diff-operations.ts +59 -0
- package/src/utils/doc-generator.ts +187 -0
- package/src/utils/ghq-integration.test.ts +195 -0
- package/src/utils/ghq-integration.ts +317 -0
- package/src/utils/git-operations.test.ts +265 -0
- package/src/utils/git-operations.ts +233 -0
- package/src/utils/git-status.ts +171 -0
- package/src/utils/github.ts +74 -0
- package/src/utils/health-checks.ts +359 -0
- package/src/utils/history.ts +140 -0
- package/src/utils/hooks.ts +92 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/permission-sync.test.ts +593 -0
- package/src/utils/permission-sync.ts +720 -0
- package/src/utils/pi-extensions.test.ts +182 -0
- package/src/utils/pi-extensions.ts +226 -0
- package/src/utils/progress.ts +73 -0
- package/src/utils/prompts.test.ts +169 -0
- package/src/utils/prompts.ts +296 -0
- package/src/utils/repo-resolver.test.ts +313 -0
- package/src/utils/repo-resolver.ts +294 -0
- package/src/utils/retry.test.ts +124 -0
- package/src/utils/retry.ts +85 -0
- package/src/utils/run-operations.ts +38 -0
- package/src/utils/suite.test.ts +256 -0
- package/src/utils/suite.ts +144 -0
- package/src/utils/sync-operations.ts +87 -0
- package/src/utils/tmux-dashboard.ts +193 -0
- package/src/utils/validation.test.ts +139 -0
- package/src/utils/validation.ts +128 -0
- package/src/utils/version-check.test.ts +108 -0
- package/src/utils/version-check.ts +96 -0
- package/src/utils/workspace-meta.ts +173 -0
- package/sync-permissions.sh +137 -0
- package/tsconfig.json +20 -0
- package/uninstall.sh +162 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
// Mock fs/promises before importing the module
|
|
5
|
+
vi.mock('fs/promises', () => ({
|
|
6
|
+
readdir: vi.fn(),
|
|
7
|
+
access: vi.fn(),
|
|
8
|
+
stat: vi.fn(),
|
|
9
|
+
open: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('./config', () => ({
|
|
13
|
+
WORKSPACES_DIR: '/Users/test/workspaces',
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('./agent-config', () => ({
|
|
17
|
+
getActiveAgents: vi.fn().mockReturnValue([
|
|
18
|
+
{ type: 'claude', sessionProjectsDir: '/Users/test/.claude/projects' },
|
|
19
|
+
]),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import * as fs from 'fs/promises';
|
|
23
|
+
import {
|
|
24
|
+
getWorkspaceSessions,
|
|
25
|
+
pathToProjectDirName,
|
|
26
|
+
extractWorkspaceName,
|
|
27
|
+
relativeTime,
|
|
28
|
+
} from './claude-sessions';
|
|
29
|
+
|
|
30
|
+
describe('pathToProjectDirName', () => {
|
|
31
|
+
it('converts absolute path to Claude project dir format', () => {
|
|
32
|
+
expect(pathToProjectDirName('/Users/test/workspaces')).toBe('-Users-test-workspaces');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles paths with multiple segments', () => {
|
|
36
|
+
expect(pathToProjectDirName('/Users/yotam/Work/workspaces')).toBe('-Users-yotam-Work-workspaces');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('extractWorkspaceName', () => {
|
|
41
|
+
const prefix = '-Users-test-workspaces';
|
|
42
|
+
|
|
43
|
+
it('extracts simple workspace name', () => {
|
|
44
|
+
expect(extractWorkspaceName('-Users-test-workspaces-my-project', prefix)).toBe('my-project');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('extracts hyphenated workspace name', () => {
|
|
48
|
+
expect(extractWorkspaceName('-Users-test-workspaces-auto-virtual-card-fix', prefix)).toBe('auto-virtual-card-fix');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null for trailing dash (no workspace name)', () => {
|
|
52
|
+
expect(extractWorkspaceName('-Users-test-workspaces-', prefix)).toBe(null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null when projDir equals prefix', () => {
|
|
56
|
+
// prefix + 1 slices past the dir, giving empty string
|
|
57
|
+
expect(extractWorkspaceName('-Users-test-workspaces', prefix)).toBe(null);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('pathToProjectDirName (Pi)', () => {
|
|
62
|
+
it('converts absolute path to Pi session dir format', () => {
|
|
63
|
+
expect(pathToProjectDirName('/Users/test/workspaces', 'pi')).toBe('--Users-test-workspaces--');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('handles paths with multiple segments', () => {
|
|
67
|
+
expect(pathToProjectDirName('/Users/yotam/Work/workspaces', 'pi')).toBe('--Users-yotam-Work-workspaces--');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('extractWorkspaceName (Pi)', () => {
|
|
72
|
+
const prefix = '--Users-test-workspaces--';
|
|
73
|
+
|
|
74
|
+
it('extracts simple workspace name', () => {
|
|
75
|
+
expect(extractWorkspaceName('--Users-test-workspaces-my-project--', prefix, 'pi')).toBe('my-project');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('extracts hyphenated workspace name', () => {
|
|
79
|
+
expect(extractWorkspaceName('--Users-test-workspaces-auto-virtual-card-fix--', prefix, 'pi')).toBe('auto-virtual-card-fix');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns null for empty workspace name', () => {
|
|
83
|
+
expect(extractWorkspaceName('--Users-test-workspaces--', prefix, 'pi')).toBe(null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns null for malformed dir without trailing --', () => {
|
|
87
|
+
expect(extractWorkspaceName('--Users-test-workspaces-my-project', prefix, 'pi')).toBe(null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('extracts single-char workspace name', () => {
|
|
91
|
+
expect(extractWorkspaceName('--Users-test-workspaces-x--', prefix, 'pi')).toBe('x');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('relativeTime', () => {
|
|
96
|
+
it('returns "just now" for times less than a minute ago', () => {
|
|
97
|
+
expect(relativeTime(new Date(Date.now() - 30_000))).toBe('just now');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns minutes for times less than an hour ago', () => {
|
|
101
|
+
expect(relativeTime(new Date(Date.now() - 5 * 60_000))).toBe('5m ago');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns hours for times less than a day ago', () => {
|
|
105
|
+
expect(relativeTime(new Date(Date.now() - 3 * 3600_000))).toBe('3h ago');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns days for times less than a month ago', () => {
|
|
109
|
+
expect(relativeTime(new Date(Date.now() - 7 * 86400_000))).toBe('7d ago');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns months for times more than 30 days ago', () => {
|
|
113
|
+
expect(relativeTime(new Date(Date.now() - 60 * 86400_000))).toBe('2mo ago');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('getWorkspaceSessions', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns empty array when claude projects dir does not exist', async () => {
|
|
123
|
+
vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT'));
|
|
124
|
+
|
|
125
|
+
const sessions = await getWorkspaceSessions();
|
|
126
|
+
expect(sessions).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns empty array when no workspace project dirs exist', async () => {
|
|
130
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
131
|
+
'-Users-test-other-project' as any,
|
|
132
|
+
'-Users-test-standalone' as any,
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const sessions = await getWorkspaceSessions();
|
|
136
|
+
expect(sessions).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('skips workspaces that no longer exist on disk', async () => {
|
|
140
|
+
// Projects dir listing
|
|
141
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
142
|
+
'-Users-test-workspaces-deleted-workspace' as any,
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// Workspace does not exist on disk
|
|
146
|
+
vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT'));
|
|
147
|
+
|
|
148
|
+
const sessions = await getWorkspaceSessions();
|
|
149
|
+
expect(sessions).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns sessions sorted by last active time', async () => {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
|
|
155
|
+
// Projects dir listing - two workspace project dirs
|
|
156
|
+
vi.mocked(fs.readdir)
|
|
157
|
+
.mockResolvedValueOnce([
|
|
158
|
+
'-Users-test-workspaces-workspace-a' as any,
|
|
159
|
+
'-Users-test-workspaces-workspace-b' as any,
|
|
160
|
+
])
|
|
161
|
+
// workspace-a session files
|
|
162
|
+
.mockResolvedValueOnce([
|
|
163
|
+
'session-a.jsonl' as any,
|
|
164
|
+
])
|
|
165
|
+
// workspace-b session files
|
|
166
|
+
.mockResolvedValueOnce([
|
|
167
|
+
'session-b.jsonl' as any,
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
// Both workspaces exist
|
|
171
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
172
|
+
|
|
173
|
+
// File stats - workspace-b is newer
|
|
174
|
+
const olderTime = new Date(now - 3600_000); // 1 hour ago
|
|
175
|
+
const newerTime = new Date(now - 60_000); // 1 minute ago
|
|
176
|
+
|
|
177
|
+
vi.mocked(fs.stat)
|
|
178
|
+
.mockResolvedValueOnce({ mtime: olderTime } as any)
|
|
179
|
+
.mockResolvedValueOnce({ mtime: newerTime } as any);
|
|
180
|
+
|
|
181
|
+
// Mock file open/read for timestamp extraction
|
|
182
|
+
const mockHandleA = {
|
|
183
|
+
stat: vi.fn().mockResolvedValue({ size: 100 }),
|
|
184
|
+
read: vi.fn().mockImplementation((buffer: Buffer) => {
|
|
185
|
+
const content = JSON.stringify({ timestamp: olderTime.toISOString() }) + '\n';
|
|
186
|
+
buffer.write(content);
|
|
187
|
+
return Promise.resolve({ bytesRead: content.length });
|
|
188
|
+
}),
|
|
189
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
190
|
+
};
|
|
191
|
+
const mockHandleB = {
|
|
192
|
+
stat: vi.fn().mockResolvedValue({ size: 100 }),
|
|
193
|
+
read: vi.fn().mockImplementation((buffer: Buffer) => {
|
|
194
|
+
const content = JSON.stringify({ timestamp: newerTime.toISOString() }) + '\n';
|
|
195
|
+
buffer.write(content);
|
|
196
|
+
return Promise.resolve({ bytesRead: content.length });
|
|
197
|
+
}),
|
|
198
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
vi.mocked(fs.open)
|
|
202
|
+
.mockResolvedValueOnce(mockHandleA as any)
|
|
203
|
+
.mockResolvedValueOnce(mockHandleB as any);
|
|
204
|
+
|
|
205
|
+
const sessions = await getWorkspaceSessions();
|
|
206
|
+
|
|
207
|
+
expect(sessions).toHaveLength(2);
|
|
208
|
+
// Most recent first
|
|
209
|
+
expect(sessions[0].workspaceName).toBe('workspace-b');
|
|
210
|
+
expect(sessions[1].workspaceName).toBe('workspace-a');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('skips project dirs with no jsonl files', async () => {
|
|
214
|
+
vi.mocked(fs.readdir)
|
|
215
|
+
.mockResolvedValueOnce([
|
|
216
|
+
'-Users-test-workspaces-empty-workspace' as any,
|
|
217
|
+
])
|
|
218
|
+
// No jsonl files in the project dir
|
|
219
|
+
.mockResolvedValueOnce([
|
|
220
|
+
'some-uuid' as any, // directory, not a jsonl file
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
224
|
+
|
|
225
|
+
const sessions = await getWorkspaceSessions();
|
|
226
|
+
expect(sessions).toEqual([]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('falls back to mtime when jsonl has no timestamp', async () => {
|
|
230
|
+
const mtime = new Date(Date.now() - 7200_000); // 2 hours ago
|
|
231
|
+
|
|
232
|
+
vi.mocked(fs.readdir)
|
|
233
|
+
.mockResolvedValueOnce([
|
|
234
|
+
'-Users-test-workspaces-fallback-ws' as any,
|
|
235
|
+
])
|
|
236
|
+
.mockResolvedValueOnce([
|
|
237
|
+
'session-1.jsonl' as any,
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
241
|
+
vi.mocked(fs.stat).mockResolvedValueOnce({ mtime } as any);
|
|
242
|
+
|
|
243
|
+
// File open fails - no timestamp extractable
|
|
244
|
+
vi.mocked(fs.open).mockRejectedValueOnce(new Error('read error'));
|
|
245
|
+
|
|
246
|
+
const sessions = await getWorkspaceSessions();
|
|
247
|
+
|
|
248
|
+
expect(sessions).toHaveLength(1);
|
|
249
|
+
expect(sessions[0].workspaceName).toBe('fallback-ws');
|
|
250
|
+
expect(sessions[0].lastActiveAt).toEqual(mtime);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('excludes the workspaces root dir itself', async () => {
|
|
254
|
+
// The prefix dir itself should not match
|
|
255
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
256
|
+
'-Users-test-workspaces' as any, // This is the root, not a workspace
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
const sessions = await getWorkspaceSessions();
|
|
260
|
+
expect(sessions).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { WORKSPACES_DIR } from './config';
|
|
4
|
+
import { getActiveAgents, AgentPaths, ConcreteAgentType } from './agent-config';
|
|
5
|
+
|
|
6
|
+
export interface WorkspaceSession {
|
|
7
|
+
workspaceName: string;
|
|
8
|
+
workspacePath: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
lastActiveAt: Date;
|
|
11
|
+
/** Human-readable relative time, e.g. "2 hours ago" */
|
|
12
|
+
lastActiveLabel: string;
|
|
13
|
+
/** Which agent created this session */
|
|
14
|
+
agentType: ConcreteAgentType;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scan session directories for workspace-related session files.
|
|
19
|
+
* Checks Claude (~/.claude/projects/) and/or Pi (~/.pi/agent/sessions/) based on config.
|
|
20
|
+
* Returns sessions sorted by last activity (most recent first).
|
|
21
|
+
*
|
|
22
|
+
* Note: Pi session discovery has limitations. Pi uses a different naming convention
|
|
23
|
+
* (--path--with--dashes--) vs Claude's (-path-with-dashes). This scanner currently
|
|
24
|
+
* only reliably finds Claude sessions. Pi session discovery may require updates
|
|
25
|
+
* based on Pi's actual session directory structure.
|
|
26
|
+
*/
|
|
27
|
+
export async function getWorkspaceSessions(): Promise<WorkspaceSession[]> {
|
|
28
|
+
const agents = getActiveAgents();
|
|
29
|
+
const allSessions: WorkspaceSession[] = [];
|
|
30
|
+
|
|
31
|
+
// Scan each active agent's session directory
|
|
32
|
+
for (const agent of agents) {
|
|
33
|
+
const sessions = await scanProjectsDir(agent.sessionProjectsDir, agent.type);
|
|
34
|
+
allSessions.push(...sessions);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Deduplicate by workspace + session ID, sort by most recent
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
const deduped = allSessions.filter(s => {
|
|
40
|
+
const key = `${s.workspacePath}:${s.sessionId}`;
|
|
41
|
+
if (seen.has(key)) return false;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
deduped.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
|
|
47
|
+
return deduped;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Scan a single projects/sessions directory for workspace sessions.
|
|
52
|
+
*/
|
|
53
|
+
async function scanProjectsDir(projectsDir: string, agentType: ConcreteAgentType): Promise<WorkspaceSession[]> {
|
|
54
|
+
// Only Claude Code and Pi expose a scannable per-project session directory
|
|
55
|
+
// layout. OpenCode stores sessions in SQLite; Codex/Gemini use their own
|
|
56
|
+
// formats that aren't scanned here — skip them.
|
|
57
|
+
if (agentType !== 'claude' && agentType !== 'pi') return [];
|
|
58
|
+
|
|
59
|
+
const workspacesPrefix = pathToProjectDirName(WORKSPACES_DIR, agentType);
|
|
60
|
+
|
|
61
|
+
let projectDirs: string[];
|
|
62
|
+
try {
|
|
63
|
+
projectDirs = await fs.readdir(projectsDir);
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Filter to only workspace project directories
|
|
69
|
+
const workspaceProjectDirs = projectDirs.filter(dir => {
|
|
70
|
+
if (agentType === 'pi') {
|
|
71
|
+
// Pi dirs: "--{base}-{name}--", so strip trailing -- from prefix to match base + '-'
|
|
72
|
+
const prefixBase = workspacesPrefix.slice(0, -2); // remove trailing --
|
|
73
|
+
return dir.startsWith(prefixBase + '-') && dir !== workspacesPrefix;
|
|
74
|
+
}
|
|
75
|
+
// Claude: prefix + dash separator
|
|
76
|
+
return dir.startsWith(workspacesPrefix + '-') && dir !== workspacesPrefix;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const sessions: WorkspaceSession[] = [];
|
|
80
|
+
|
|
81
|
+
await Promise.all(workspaceProjectDirs.map(async (projDir) => {
|
|
82
|
+
const workspaceName = extractWorkspaceName(projDir, workspacesPrefix, agentType);
|
|
83
|
+
if (!workspaceName) return;
|
|
84
|
+
|
|
85
|
+
const workspacePath = path.join(WORKSPACES_DIR, workspaceName);
|
|
86
|
+
|
|
87
|
+
// Check workspace still exists on disk
|
|
88
|
+
try {
|
|
89
|
+
await fs.access(workspacePath);
|
|
90
|
+
} catch {
|
|
91
|
+
return; // Workspace deleted, skip
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fullProjDir = path.join(projectsDir, projDir);
|
|
95
|
+
const session = await getMostRecentSession(fullProjDir);
|
|
96
|
+
if (!session) return;
|
|
97
|
+
|
|
98
|
+
sessions.push({
|
|
99
|
+
workspaceName,
|
|
100
|
+
workspacePath,
|
|
101
|
+
sessionId: session.sessionId,
|
|
102
|
+
lastActiveAt: session.lastActiveAt,
|
|
103
|
+
lastActiveLabel: relativeTime(session.lastActiveAt),
|
|
104
|
+
agentType,
|
|
105
|
+
});
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// Sort by most recently active first
|
|
109
|
+
sessions.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime());
|
|
110
|
+
|
|
111
|
+
return sessions;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @internal exported for testing */
|
|
115
|
+
export function pathToProjectDirName(absPath: string, agentType: 'claude' | 'pi' = 'claude'): string {
|
|
116
|
+
if (agentType === 'pi') {
|
|
117
|
+
// Pi format: --Users-yotambloom-Work-workspaces-- (leading --, single - between segments, trailing --)
|
|
118
|
+
// Strip leading /, then replace remaining / with -
|
|
119
|
+
const inner = absPath.replace(/^\//, '').replace(/\//g, '-');
|
|
120
|
+
return '--' + inner + '--';
|
|
121
|
+
}
|
|
122
|
+
// Claude format: -Users-yotambloom-Work-workspaces (/ replaced by -)
|
|
123
|
+
return absPath.replace(/\//g, '-');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @internal exported for testing */
|
|
127
|
+
export function extractWorkspaceName(projDir: string, prefix: string, agentType: 'claude' | 'pi' = 'claude'): string | null {
|
|
128
|
+
if (agentType === 'pi') {
|
|
129
|
+
// Pi: prefix is "--Users-...-workspaces--", dir is "--Users-...-workspaces-{name}--"
|
|
130
|
+
// The prefix ends with --, but workspace name is between prefix (minus trailing --) + - and trailing --
|
|
131
|
+
const prefixBase = prefix.slice(0, -2); // remove trailing --
|
|
132
|
+
if (!projDir.startsWith(prefixBase + '-')) return null;
|
|
133
|
+
// Ensure dir is longer than just prefixBase + '--' (which would be the prefix itself)
|
|
134
|
+
if (projDir === prefix) return null;
|
|
135
|
+
const rest = projDir.slice(prefixBase.length + 1); // skip the - separator
|
|
136
|
+
// rest should be "{workspace-name}--" — reject malformed dirs that don't end with --
|
|
137
|
+
if (!rest.endsWith('--')) return null;
|
|
138
|
+
const name = rest.slice(0, -2);
|
|
139
|
+
return name || null;
|
|
140
|
+
}
|
|
141
|
+
// Claude: remove prefix + the separator dash
|
|
142
|
+
const rest = projDir.slice(prefix.length + 1);
|
|
143
|
+
if (!rest) return null;
|
|
144
|
+
return rest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface SessionInfo {
|
|
148
|
+
sessionId: string;
|
|
149
|
+
lastActiveAt: Date;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Find the most recently active session in a project directory.
|
|
154
|
+
* Reads .jsonl files and checks the last line's timestamp.
|
|
155
|
+
*/
|
|
156
|
+
async function getMostRecentSession(projDir: string): Promise<SessionInfo | null> {
|
|
157
|
+
let entries: string[];
|
|
158
|
+
try {
|
|
159
|
+
entries = await fs.readdir(projDir);
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const jsonlFiles = entries.filter(f => f.endsWith('.jsonl'));
|
|
165
|
+
if (jsonlFiles.length === 0) return null;
|
|
166
|
+
|
|
167
|
+
let bestSession: SessionInfo | null = null;
|
|
168
|
+
|
|
169
|
+
// For performance, check file modification times first to find candidates
|
|
170
|
+
const fileStats = await Promise.all(
|
|
171
|
+
jsonlFiles.map(async (f) => {
|
|
172
|
+
try {
|
|
173
|
+
const stat = await fs.stat(path.join(projDir, f));
|
|
174
|
+
return { file: f, mtime: stat.mtime };
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Sort by mtime descending, only check the most recent files
|
|
182
|
+
const sorted = fileStats
|
|
183
|
+
.filter((s): s is NonNullable<typeof s> => s !== null)
|
|
184
|
+
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
185
|
+
|
|
186
|
+
// Only read the most recent session file to get accurate timestamp
|
|
187
|
+
for (const { file, mtime } of sorted.slice(0, 1)) {
|
|
188
|
+
const sessionId = file.replace('.jsonl', '');
|
|
189
|
+
const timestamp = await getLastTimestamp(path.join(projDir, file));
|
|
190
|
+
bestSession = {
|
|
191
|
+
sessionId,
|
|
192
|
+
lastActiveAt: timestamp || mtime,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return bestSession;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the last few lines of a .jsonl file and extract the most recent timestamp.
|
|
201
|
+
*/
|
|
202
|
+
async function getLastTimestamp(filePath: string): Promise<Date | null> {
|
|
203
|
+
try {
|
|
204
|
+
const handle = await fs.open(filePath, 'r');
|
|
205
|
+
try {
|
|
206
|
+
const stat = await handle.stat();
|
|
207
|
+
// Read last 4KB to find the last line with a timestamp
|
|
208
|
+
const readSize = Math.min(4096, stat.size);
|
|
209
|
+
const buffer = Buffer.alloc(readSize);
|
|
210
|
+
await handle.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
|
|
211
|
+
const content = buffer.toString('utf-8');
|
|
212
|
+
|
|
213
|
+
// Find the last line with a timestamp
|
|
214
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
215
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(lines[i]);
|
|
218
|
+
if (parsed.timestamp) {
|
|
219
|
+
return new Date(parsed.timestamp);
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
await handle.close();
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @internal exported for testing */
|
|
235
|
+
export function relativeTime(date: Date): string {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const diff = now - date.getTime();
|
|
238
|
+
|
|
239
|
+
const minutes = Math.floor(diff / 60000);
|
|
240
|
+
if (minutes < 1) return 'just now';
|
|
241
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
242
|
+
|
|
243
|
+
const hours = Math.floor(minutes / 60);
|
|
244
|
+
if (hours < 24) return `${hours}h ago`;
|
|
245
|
+
|
|
246
|
+
const days = Math.floor(hours / 24);
|
|
247
|
+
if (days < 30) return `${days}d ago`;
|
|
248
|
+
|
|
249
|
+
const months = Math.floor(days / 30);
|
|
250
|
+
return `${months}mo ago`;
|
|
251
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
export interface CleanupResult {
|
|
9
|
+
operation: string;
|
|
10
|
+
filesRemoved: number;
|
|
11
|
+
spaceFreed: string;
|
|
12
|
+
success: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const calculateDirSize = async (dirPath: string): Promise<number> => {
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execAsync(`du -sk "${dirPath}"`);
|
|
18
|
+
const sizeInKB = parseInt(stdout.split('\t')[0], 10);
|
|
19
|
+
return sizeInKB;
|
|
20
|
+
} catch {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const removeNodeModules = async (repoPath: string): Promise<CleanupResult> => {
|
|
26
|
+
const nodeModulesPath = path.join(repoPath, 'node_modules');
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const sizeBefore = await calculateDirSize(nodeModulesPath);
|
|
30
|
+
await fs.rm(nodeModulesPath, { recursive: true, force: true });
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
operation: 'Remove node_modules',
|
|
34
|
+
filesRemoved: 1,
|
|
35
|
+
spaceFreed: `${(sizeBefore / 1024).toFixed(2)} MB`,
|
|
36
|
+
success: true,
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
operation: 'Remove node_modules',
|
|
41
|
+
filesRemoved: 0,
|
|
42
|
+
spaceFreed: '0 MB',
|
|
43
|
+
success: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const removeBuildArtifacts = async (repoPath: string): Promise<CleanupResult> => {
|
|
49
|
+
const artifacts = ['dist', 'build', '.next', 'coverage', 'out'];
|
|
50
|
+
let totalSize = 0;
|
|
51
|
+
let removed = 0;
|
|
52
|
+
|
|
53
|
+
for (const artifact of artifacts) {
|
|
54
|
+
const artifactPath = path.join(repoPath, artifact);
|
|
55
|
+
try {
|
|
56
|
+
const size = await calculateDirSize(artifactPath);
|
|
57
|
+
await fs.rm(artifactPath, { recursive: true, force: true });
|
|
58
|
+
totalSize += size;
|
|
59
|
+
removed++;
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore if doesn't exist
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
operation: 'Remove build artifacts',
|
|
67
|
+
filesRemoved: removed,
|
|
68
|
+
spaceFreed: `${(totalSize / 1024).toFixed(2)} MB`,
|
|
69
|
+
success: true,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const gitClean = async (repoPath: string, dryRun: boolean = false): Promise<CleanupResult> => {
|
|
74
|
+
try {
|
|
75
|
+
const cmd = dryRun ? 'git clean -fdxn' : 'git clean -fdx';
|
|
76
|
+
const { stdout } = await execAsync(cmd, { cwd: repoPath });
|
|
77
|
+
|
|
78
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
operation: 'Git clean',
|
|
82
|
+
filesRemoved: lines.length,
|
|
83
|
+
spaceFreed: 'Unknown',
|
|
84
|
+
success: true,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return {
|
|
88
|
+
operation: 'Git clean',
|
|
89
|
+
filesRemoved: 0,
|
|
90
|
+
spaceFreed: '0 MB',
|
|
91
|
+
success: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
reset: '\x1b[0m',
|
|
3
|
+
bright: '\x1b[1m',
|
|
4
|
+
dim: '\x1b[2m',
|
|
5
|
+
|
|
6
|
+
// Foreground colors
|
|
7
|
+
red: '\x1b[31m',
|
|
8
|
+
green: '\x1b[32m',
|
|
9
|
+
yellow: '\x1b[33m',
|
|
10
|
+
blue: '\x1b[34m',
|
|
11
|
+
magenta: '\x1b[35m',
|
|
12
|
+
cyan: '\x1b[36m',
|
|
13
|
+
white: '\x1b[37m',
|
|
14
|
+
gray: '\x1b[90m',
|
|
15
|
+
|
|
16
|
+
// Background colors
|
|
17
|
+
bgRed: '\x1b[41m',
|
|
18
|
+
bgGreen: '\x1b[42m',
|
|
19
|
+
bgYellow: '\x1b[43m',
|
|
20
|
+
bgBlue: '\x1b[44m',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const colorize = (text: string, color: keyof typeof colors): string => {
|
|
24
|
+
return `${colors[color]}${text}${colors.reset}`;
|
|
25
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { listWorkspaces } from './workspace-meta';
|
|
3
|
+
import { promptWorkspaceSelection } from './prompts';
|
|
4
|
+
|
|
5
|
+
export interface GlobalOpts {
|
|
6
|
+
forceRefresh: boolean;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract global options (--force-refresh, --yes) from the root program.
|
|
12
|
+
* Commander doesn't auto-inherit parent options to subcommands,
|
|
13
|
+
* so we walk up the parent chain to find them.
|
|
14
|
+
*/
|
|
15
|
+
export function getGlobalOpts(cmd: Command): GlobalOpts {
|
|
16
|
+
let root = cmd;
|
|
17
|
+
while (root.parent) root = root.parent;
|
|
18
|
+
const opts = root.opts();
|
|
19
|
+
return {
|
|
20
|
+
forceRefresh: opts.forceRefresh ?? false,
|
|
21
|
+
yes: opts.yes ?? false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve workspace name: use provided value, or prompt interactively.
|
|
27
|
+
* In non-interactive mode (no TTY), throws if no name provided.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveWorkspace(name?: string): Promise<string> {
|
|
30
|
+
if (name) return name;
|
|
31
|
+
if (!process.stdout.isTTY) {
|
|
32
|
+
throw new Error('Workspace name required in non-interactive mode. Use --workspace <name> or provide as argument.');
|
|
33
|
+
}
|
|
34
|
+
const workspaces = await listWorkspaces();
|
|
35
|
+
if (workspaces.length === 0) {
|
|
36
|
+
throw new Error('No workspaces found. Create one first with: nemus create');
|
|
37
|
+
}
|
|
38
|
+
return promptWorkspaceSelection(workspaces);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse comma-separated list from a flag value.
|
|
43
|
+
*/
|
|
44
|
+
export function parseList(value: string): string[] {
|
|
45
|
+
return value.split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
+
}
|