@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.
Files changed (320) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/assets/README.md +53 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/banner.svg +66 -0
  7. package/assets/favicon-32.png +0 -0
  8. package/assets/favicon-64.png +0 -0
  9. package/assets/icon-256.png +0 -0
  10. package/assets/icon.svg +23 -0
  11. package/assets/logo-256.png +0 -0
  12. package/assets/logo-512.png +0 -0
  13. package/assets/logo-wordmark.svg +35 -0
  14. package/assets/logo.svg +32 -0
  15. package/bin/workspace.js +130 -0
  16. package/dist/cli/ai-prompt.js +421 -0
  17. package/dist/cli/dashboard/AgentList.js +61 -0
  18. package/dist/cli/dashboard/DashboardSidebar.js +280 -0
  19. package/dist/cli/dashboard/HelpFooter.js +73 -0
  20. package/dist/cli/dashboard/StatusBadge.js +50 -0
  21. package/dist/cli/dashboard/index.js +11 -0
  22. package/dist/cli/dashboard/types.js +2 -0
  23. package/dist/cli/tui.js +48 -0
  24. package/dist/commands/ai-prompt.js +22 -0
  25. package/dist/commands/analyze-deps.js +138 -0
  26. package/dist/commands/archive.js +113 -0
  27. package/dist/commands/branch/create.js +119 -0
  28. package/dist/commands/branch/index.js +104 -0
  29. package/dist/commands/branch/merge.js +90 -0
  30. package/dist/commands/branch/rebase.js +84 -0
  31. package/dist/commands/branch/switch.js +165 -0
  32. package/dist/commands/cache/index.js +80 -0
  33. package/dist/commands/cache/manager.js +198 -0
  34. package/dist/commands/cleanup.js +153 -0
  35. package/dist/commands/configure-claude.js +55 -0
  36. package/dist/commands/configure.js +231 -0
  37. package/dist/commands/create.js +204 -0
  38. package/dist/commands/dashboard/hook-handler.js +162 -0
  39. package/dist/commands/dashboard/index.js +258 -0
  40. package/dist/commands/dashboard/launcher.js +40 -0
  41. package/dist/commands/dashboard/session-picker.js +106 -0
  42. package/dist/commands/dashboard/sidebar.js +39 -0
  43. package/dist/commands/dashboard/workspace-picker.js +95 -0
  44. package/dist/commands/delete.js +194 -0
  45. package/dist/commands/deprecated-aliases.js +44 -0
  46. package/dist/commands/diff.js +129 -0
  47. package/dist/commands/doctor.js +141 -0
  48. package/dist/commands/generate-docs.js +77 -0
  49. package/dist/commands/ghq-status.js +72 -0
  50. package/dist/commands/go.js +154 -0
  51. package/dist/commands/history.js +124 -0
  52. package/dist/commands/list.js +191 -0
  53. package/dist/commands/mcp/index.js +69 -0
  54. package/dist/commands/migrate.js +296 -0
  55. package/dist/commands/remove-repo.js +217 -0
  56. package/dist/commands/report-bug.js +112 -0
  57. package/dist/commands/run.js +147 -0
  58. package/dist/commands/save-context.js +171 -0
  59. package/dist/commands/sessions.js +129 -0
  60. package/dist/commands/status.js +138 -0
  61. package/dist/commands/suite/create.js +212 -0
  62. package/dist/commands/suite/delete.js +64 -0
  63. package/dist/commands/suite/export.js +126 -0
  64. package/dist/commands/suite/import.js +128 -0
  65. package/dist/commands/suite/index.js +94 -0
  66. package/dist/commands/suite/list.js +49 -0
  67. package/dist/commands/suite/use.js +227 -0
  68. package/dist/commands/sync.js +117 -0
  69. package/dist/commands/update.js +203 -0
  70. package/dist/mcp/install.js +550 -0
  71. package/dist/mcp/server.js +398 -0
  72. package/dist/mcp/tools.js +922 -0
  73. package/dist/pi-extensions/permission-sync.ts.template +41 -0
  74. package/dist/pi-extensions/permissions-gate.ts.template +69 -0
  75. package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
  76. package/dist/pi-extensions/review-reminder.ts.template +58 -0
  77. package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
  78. package/dist/program.js +136 -0
  79. package/dist/scripts/workspace-table.py +394 -0
  80. package/dist/types/dashboard.js +11 -0
  81. package/dist/types/index.js +2 -0
  82. package/dist/utils/agent-config.js +263 -0
  83. package/dist/utils/agent-state.js +178 -0
  84. package/dist/utils/banner.js +24 -0
  85. package/dist/utils/branch-operations.js +232 -0
  86. package/dist/utils/bug-report.js +355 -0
  87. package/dist/utils/cache.js +168 -0
  88. package/dist/utils/claude-integration.js +617 -0
  89. package/dist/utils/claude-sessions.js +260 -0
  90. package/dist/utils/cleanup-operations.js +120 -0
  91. package/dist/utils/colors.js +26 -0
  92. package/dist/utils/command-helpers.js +44 -0
  93. package/dist/utils/config.js +150 -0
  94. package/dist/utils/context-file.js +33 -0
  95. package/dist/utils/dashboard-hooks.js +172 -0
  96. package/dist/utils/dependency-analyzer.js +234 -0
  97. package/dist/utils/diff-operations.js +80 -0
  98. package/dist/utils/doc-generator.js +184 -0
  99. package/dist/utils/ghq-integration.js +332 -0
  100. package/dist/utils/git-operations.js +237 -0
  101. package/dist/utils/git-status.js +187 -0
  102. package/dist/utils/github.js +68 -0
  103. package/dist/utils/health-checks.js +345 -0
  104. package/dist/utils/history.js +150 -0
  105. package/dist/utils/hooks.js +100 -0
  106. package/dist/utils/logger.js +40 -0
  107. package/dist/utils/permission-sync.js +681 -0
  108. package/dist/utils/pi-extensions.js +224 -0
  109. package/dist/utils/progress.js +92 -0
  110. package/dist/utils/prompts.js +279 -0
  111. package/dist/utils/repo-resolver.js +260 -0
  112. package/dist/utils/retry.js +65 -0
  113. package/dist/utils/run-operations.js +30 -0
  114. package/dist/utils/suite.js +159 -0
  115. package/dist/utils/sync-operations.js +107 -0
  116. package/dist/utils/tmux-dashboard.js +184 -0
  117. package/dist/utils/validation.js +153 -0
  118. package/dist/utils/version-check.js +121 -0
  119. package/dist/utils/workspace-meta.js +185 -0
  120. package/install-shell-integration.sh +532 -0
  121. package/install.sh +401 -0
  122. package/package.json +90 -0
  123. package/remove-shell-block.awk +44 -0
  124. package/scripts/postinstall.js +85 -0
  125. package/skills/analyze-deps.md +23 -0
  126. package/skills/archive-workspace.md +26 -0
  127. package/skills/branch-create.md +18 -0
  128. package/skills/create-workspace.md +39 -0
  129. package/skills/delete-workspace.md +37 -0
  130. package/skills/list-org-repos.md +26 -0
  131. package/skills/list-suites.md +24 -0
  132. package/skills/list-workspaces.md +27 -0
  133. package/skills/refresh-workspace-docs.md +89 -0
  134. package/skills/remove-repo.md +25 -0
  135. package/skills/run-command.md +23 -0
  136. package/skills/save-context.md +68 -0
  137. package/skills/search-repos.md +31 -0
  138. package/skills/snapshot-list.md +18 -0
  139. package/skills/snapshot-save.md +12 -0
  140. package/skills/switch-branch.md +18 -0
  141. package/skills/update-cache.md +21 -0
  142. package/skills/update-workspace.md +66 -0
  143. package/skills/workspace-cleanup.md +18 -0
  144. package/skills/workspace-diff.md +23 -0
  145. package/skills/workspace-doctor.md +23 -0
  146. package/skills/workspace-info.md +45 -0
  147. package/skills/workspace-manager/SKILL.md +201 -0
  148. package/skills/workspace-manager/references/ai-prompt.md +44 -0
  149. package/skills/workspace-manager/references/analyze-deps.md +45 -0
  150. package/skills/workspace-manager/references/archive-workspace.md +38 -0
  151. package/skills/workspace-manager/references/branch-create.md +31 -0
  152. package/skills/workspace-manager/references/branch-merge.md +22 -0
  153. package/skills/workspace-manager/references/branch-rebase.md +22 -0
  154. package/skills/workspace-manager/references/cache-clear.md +18 -0
  155. package/skills/workspace-manager/references/cache-info.md +22 -0
  156. package/skills/workspace-manager/references/configure-claude.md +31 -0
  157. package/skills/workspace-manager/references/configure.md +35 -0
  158. package/skills/workspace-manager/references/create-workspace.md +45 -0
  159. package/skills/workspace-manager/references/dashboard.md +61 -0
  160. package/skills/workspace-manager/references/delete-workspace.md +33 -0
  161. package/skills/workspace-manager/references/generate-docs.md +36 -0
  162. package/skills/workspace-manager/references/ghq-status.md +28 -0
  163. package/skills/workspace-manager/references/go.md +33 -0
  164. package/skills/workspace-manager/references/history.md +41 -0
  165. package/skills/workspace-manager/references/list-org-repos.md +26 -0
  166. package/skills/workspace-manager/references/list-suites.md +21 -0
  167. package/skills/workspace-manager/references/list-workspaces.md +27 -0
  168. package/skills/workspace-manager/references/mcp.md +49 -0
  169. package/skills/workspace-manager/references/remove-repo.md +26 -0
  170. package/skills/workspace-manager/references/run-command.md +44 -0
  171. package/skills/workspace-manager/references/search-repos.md +24 -0
  172. package/skills/workspace-manager/references/sessions.md +27 -0
  173. package/skills/workspace-manager/references/suite-create.md +22 -0
  174. package/skills/workspace-manager/references/suite-delete.md +21 -0
  175. package/skills/workspace-manager/references/suite-export.md +19 -0
  176. package/skills/workspace-manager/references/suite-import.md +19 -0
  177. package/skills/workspace-manager/references/suite-use.md +27 -0
  178. package/skills/workspace-manager/references/switch-branch.md +25 -0
  179. package/skills/workspace-manager/references/update-cache.md +22 -0
  180. package/skills/workspace-manager/references/update-workspace.md +50 -0
  181. package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
  182. package/skills/workspace-manager/references/workspace-diff.md +27 -0
  183. package/skills/workspace-manager/references/workspace-doctor.md +27 -0
  184. package/skills/workspace-manager/references/workspace-info.md +33 -0
  185. package/skills/workspace-manager/references/workspace-status.md +43 -0
  186. package/skills/workspace-manager/references/workspace-sync.md +26 -0
  187. package/skills/workspace-status.md +26 -0
  188. package/skills/workspace-sync.md +23 -0
  189. package/src/cli/ai-prompt.test.ts +667 -0
  190. package/src/cli/ai-prompt.ts +421 -0
  191. package/src/cli/dashboard/AgentList.tsx +44 -0
  192. package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
  193. package/src/cli/dashboard/HelpFooter.tsx +22 -0
  194. package/src/cli/dashboard/StatusBadge.tsx +23 -0
  195. package/src/cli/dashboard/index.ts +5 -0
  196. package/src/cli/dashboard/types.ts +16 -0
  197. package/src/cli/tui.tsx +51 -0
  198. package/src/cli/workspace-cli.test.ts +30 -0
  199. package/src/commands/ai-prompt.ts +21 -0
  200. package/src/commands/analyze-deps.ts +119 -0
  201. package/src/commands/archive.ts +114 -0
  202. package/src/commands/branch/create.ts +94 -0
  203. package/src/commands/branch/index.ts +74 -0
  204. package/src/commands/branch/merge.ts +61 -0
  205. package/src/commands/branch/rebase.ts +54 -0
  206. package/src/commands/branch/switch.ts +150 -0
  207. package/src/commands/cache/index.ts +51 -0
  208. package/src/commands/cache/manager.ts +168 -0
  209. package/src/commands/cleanup.ts +132 -0
  210. package/src/commands/configure-claude.ts +55 -0
  211. package/src/commands/configure.ts +204 -0
  212. package/src/commands/create.ts +196 -0
  213. package/src/commands/dashboard/hook-handler.ts +138 -0
  214. package/src/commands/dashboard/index.ts +248 -0
  215. package/src/commands/dashboard/launcher.ts +42 -0
  216. package/src/commands/dashboard/session-picker.ts +73 -0
  217. package/src/commands/dashboard/sidebar.js +39 -0
  218. package/src/commands/dashboard/workspace-picker.ts +62 -0
  219. package/src/commands/delete.test.ts +250 -0
  220. package/src/commands/delete.ts +171 -0
  221. package/src/commands/deprecated-aliases.ts +55 -0
  222. package/src/commands/diff.ts +106 -0
  223. package/src/commands/doctor.ts +110 -0
  224. package/src/commands/generate-docs.ts +45 -0
  225. package/src/commands/ghq-status.ts +70 -0
  226. package/src/commands/go.ts +125 -0
  227. package/src/commands/history.ts +136 -0
  228. package/src/commands/list.test.ts +219 -0
  229. package/src/commands/list.ts +176 -0
  230. package/src/commands/mcp/index.ts +39 -0
  231. package/src/commands/migrate.ts +277 -0
  232. package/src/commands/remove-repo.ts +194 -0
  233. package/src/commands/report-bug.ts +88 -0
  234. package/src/commands/run.ts +125 -0
  235. package/src/commands/save-context.test.ts +113 -0
  236. package/src/commands/save-context.ts +141 -0
  237. package/src/commands/sessions.ts +99 -0
  238. package/src/commands/status.ts +111 -0
  239. package/src/commands/suite/create.ts +240 -0
  240. package/src/commands/suite/delete.ts +65 -0
  241. package/src/commands/suite/export.ts +97 -0
  242. package/src/commands/suite/import.ts +100 -0
  243. package/src/commands/suite/index.ts +66 -0
  244. package/src/commands/suite/list.ts +54 -0
  245. package/src/commands/suite/use.ts +232 -0
  246. package/src/commands/sync.ts +97 -0
  247. package/src/commands/update.ts +197 -0
  248. package/src/mcp/install.ts +529 -0
  249. package/src/mcp/server.ts +579 -0
  250. package/src/mcp/tools.test.ts +545 -0
  251. package/src/mcp/tools.ts +1019 -0
  252. package/src/mcp/update-workspace.test.ts +220 -0
  253. package/src/pi-extensions/permission-sync.ts.template +41 -0
  254. package/src/pi-extensions/permissions-gate.ts.template +69 -0
  255. package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
  256. package/src/pi-extensions/review-reminder.ts.template +58 -0
  257. package/src/pi-extensions/workspace-input-status.ts.template +919 -0
  258. package/src/program.ts +112 -0
  259. package/src/scripts/workspace-table.py +394 -0
  260. package/src/types/dashboard.ts +29 -0
  261. package/src/types/index.ts +109 -0
  262. package/src/types/ink.d.ts +171 -0
  263. package/src/utils/agent-config.test.ts +303 -0
  264. package/src/utils/agent-config.ts +264 -0
  265. package/src/utils/agent-state.test.ts +166 -0
  266. package/src/utils/agent-state.ts +135 -0
  267. package/src/utils/banner.ts +24 -0
  268. package/src/utils/branch-operations.ts +237 -0
  269. package/src/utils/bug-report.test.ts +174 -0
  270. package/src/utils/bug-report.ts +355 -0
  271. package/src/utils/cache.ts +150 -0
  272. package/src/utils/claude-integration.test.ts +381 -0
  273. package/src/utils/claude-integration.ts +649 -0
  274. package/src/utils/claude-sessions.test.ts +262 -0
  275. package/src/utils/claude-sessions.ts +251 -0
  276. package/src/utils/cleanup-operations.ts +94 -0
  277. package/src/utils/colors.ts +25 -0
  278. package/src/utils/command-helpers.ts +46 -0
  279. package/src/utils/config.test.ts +203 -0
  280. package/src/utils/config.ts +133 -0
  281. package/src/utils/context-file.ts +36 -0
  282. package/src/utils/dashboard-hooks.test.ts +168 -0
  283. package/src/utils/dashboard-hooks.ts +163 -0
  284. package/src/utils/dependency-analyzer.ts +243 -0
  285. package/src/utils/diff-operations.ts +59 -0
  286. package/src/utils/doc-generator.ts +187 -0
  287. package/src/utils/ghq-integration.test.ts +195 -0
  288. package/src/utils/ghq-integration.ts +317 -0
  289. package/src/utils/git-operations.test.ts +265 -0
  290. package/src/utils/git-operations.ts +233 -0
  291. package/src/utils/git-status.ts +171 -0
  292. package/src/utils/github.ts +74 -0
  293. package/src/utils/health-checks.ts +359 -0
  294. package/src/utils/history.ts +140 -0
  295. package/src/utils/hooks.ts +92 -0
  296. package/src/utils/logger.ts +37 -0
  297. package/src/utils/permission-sync.test.ts +593 -0
  298. package/src/utils/permission-sync.ts +720 -0
  299. package/src/utils/pi-extensions.test.ts +182 -0
  300. package/src/utils/pi-extensions.ts +226 -0
  301. package/src/utils/progress.ts +73 -0
  302. package/src/utils/prompts.test.ts +169 -0
  303. package/src/utils/prompts.ts +296 -0
  304. package/src/utils/repo-resolver.test.ts +313 -0
  305. package/src/utils/repo-resolver.ts +294 -0
  306. package/src/utils/retry.test.ts +124 -0
  307. package/src/utils/retry.ts +85 -0
  308. package/src/utils/run-operations.ts +38 -0
  309. package/src/utils/suite.test.ts +256 -0
  310. package/src/utils/suite.ts +144 -0
  311. package/src/utils/sync-operations.ts +87 -0
  312. package/src/utils/tmux-dashboard.ts +193 -0
  313. package/src/utils/validation.test.ts +139 -0
  314. package/src/utils/validation.ts +128 -0
  315. package/src/utils/version-check.test.ts +108 -0
  316. package/src/utils/version-check.ts +96 -0
  317. package/src/utils/workspace-meta.ts +173 -0
  318. package/sync-permissions.sh +137 -0
  319. package/tsconfig.json +20 -0
  320. package/uninstall.sh +162 -0
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { validateWorkspaceName, sanitizeWorkspaceName, checkWorkspaceExists, safeWorkspacePath, assertSafeWorkspaceName, isSafePathSegment } from './validation';
6
+ import { WORKSPACES_DIR } from './config';
7
+
8
+ describe('validateWorkspaceName', () => {
9
+ it('returns true for valid names', () => {
10
+ expect(validateWorkspaceName('my-workspace')).toBe(true);
11
+ expect(validateWorkspaceName('test_workspace')).toBe(true);
12
+ expect(validateWorkspaceName('workspace123')).toBe(true);
13
+ expect(validateWorkspaceName('My-Workspace_01')).toBe(true);
14
+ });
15
+
16
+ it('rejects empty names', () => {
17
+ expect(validateWorkspaceName('')).toBe('Workspace name cannot be empty');
18
+ expect(validateWorkspaceName(' ')).toBe('Workspace name cannot be empty');
19
+ });
20
+
21
+ it('rejects names with invalid characters', () => {
22
+ const result = validateWorkspaceName('my workspace');
23
+ expect(result).toContain('alphanumeric');
24
+
25
+ expect(validateWorkspaceName('my@workspace')).toContain('alphanumeric');
26
+ expect(validateWorkspaceName('my/workspace')).toContain('alphanumeric');
27
+ expect(validateWorkspaceName('my.workspace')).toContain('alphanumeric');
28
+ });
29
+
30
+ it('rejects names that are too short', () => {
31
+ const result = validateWorkspaceName('ab');
32
+ expect(result).toContain('at least 3');
33
+ });
34
+
35
+ it('rejects names that are too long', () => {
36
+ const longName = 'a'.repeat(51);
37
+ const result = validateWorkspaceName(longName);
38
+ expect(result).toContain('less than 50');
39
+ });
40
+ });
41
+
42
+ describe('sanitizeWorkspaceName', () => {
43
+ it('trims whitespace', () => {
44
+ expect(sanitizeWorkspaceName(' my-workspace ')).toBe('my-workspace');
45
+ });
46
+
47
+ it('converts to lowercase', () => {
48
+ expect(sanitizeWorkspaceName('My-Workspace')).toBe('my-workspace');
49
+ });
50
+
51
+ it('trims and lowercases together', () => {
52
+ expect(sanitizeWorkspaceName(' MY-WORKSPACE ')).toBe('my-workspace');
53
+ });
54
+
55
+ it('converts spaces to hyphens', () => {
56
+ expect(sanitizeWorkspaceName('my workspace')).toBe('my-workspace');
57
+ expect(sanitizeWorkspaceName('my workspace')).toBe('my-workspace');
58
+ expect(sanitizeWorkspaceName(' My Workspace Name ')).toBe('my-workspace-name');
59
+ });
60
+ });
61
+
62
+ describe('checkWorkspaceExists', () => {
63
+ let tempDir: string;
64
+ const originalEnv = process.env.WORKSPACE_MANAGER_DIR;
65
+
66
+ beforeEach(async () => {
67
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-test-'));
68
+ process.env.WORKSPACE_MANAGER_DIR = tempDir;
69
+ });
70
+
71
+ afterEach(async () => {
72
+ process.env.WORKSPACE_MANAGER_DIR = originalEnv;
73
+ await fs.rm(tempDir, { recursive: true, force: true });
74
+ });
75
+
76
+ it('returns true when workspace directory exists', async () => {
77
+ await fs.mkdir(path.join(tempDir, 'existing-workspace'));
78
+ // checkWorkspaceExists uses WORKSPACES_DIR which is evaluated at import time,
79
+ // so we test the path logic directly
80
+ const workspacePath = path.join(tempDir, 'existing-workspace');
81
+ try {
82
+ await fs.access(workspacePath);
83
+ expect(true).toBe(true);
84
+ } catch {
85
+ expect.fail('Directory should exist');
86
+ }
87
+ });
88
+
89
+ it('returns false when workspace directory does not exist', async () => {
90
+ const workspacePath = path.join(tempDir, 'non-existent');
91
+ try {
92
+ await fs.access(workspacePath);
93
+ expect.fail('Directory should not exist');
94
+ } catch {
95
+ expect(true).toBe(true);
96
+ }
97
+ });
98
+ });
99
+
100
+ describe('safeWorkspacePath (path-traversal guard)', () => {
101
+ const base = path.resolve(WORKSPACES_DIR);
102
+
103
+ it('accepts normal workspace names and keeps them inside WORKSPACES_DIR', () => {
104
+ for (const name of ['payments', 'my-workspace', 'ws_2', 'ABC-123']) {
105
+ const p = safeWorkspacePath(name);
106
+ expect(p).toBe(path.join(base, name));
107
+ expect(p.startsWith(base + path.sep)).toBe(true);
108
+ }
109
+ });
110
+
111
+ it('rejects path traversal and separators', () => {
112
+ for (const evil of [
113
+ '../etc',
114
+ '../../etc/passwd',
115
+ '..',
116
+ '.',
117
+ 'foo/bar',
118
+ 'foo\\bar',
119
+ '/etc/passwd',
120
+ '~/secrets',
121
+ 'a/../../b',
122
+ 'foo.bar', // dots are not allowed in workspace names
123
+ '',
124
+ ]) {
125
+ expect(() => safeWorkspacePath(evil)).toThrow();
126
+ expect(isSafePathSegment(evil)).toBe(false);
127
+ }
128
+ });
129
+
130
+ it('rejects a NUL byte and overly long names', () => {
131
+ expect(() => safeWorkspacePath('foo\0bar')).toThrow();
132
+ expect(() => safeWorkspacePath('a'.repeat(65))).toThrow();
133
+ });
134
+
135
+ it('assertSafeWorkspaceName throws on non-string input', () => {
136
+ expect(() => assertSafeWorkspaceName(undefined as any)).toThrow();
137
+ expect(() => assertSafeWorkspaceName(123 as any)).toThrow();
138
+ });
139
+ });
@@ -0,0 +1,128 @@
1
+ import * as fs from 'fs/promises';
2
+ import { WORKSPACES_DIR } from './config';
3
+ import * as path from 'path';
4
+
5
+ const WORKSPACE_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ /**
8
+ * Allowlist for a single, safe path segment used as a workspace (or suite) name.
9
+ * Alphanumerics plus hyphen/underscore only — it cannot express a path
10
+ * separator, a Windows drive, a leading `~`, or a `.`/`..` traversal component,
11
+ * so a value that matches this can never escape its parent directory.
12
+ */
13
+ export const isSafePathSegment = (name: unknown): name is string =>
14
+ typeof name === 'string' && name.length > 0 && name.length <= 64 && WORKSPACE_NAME_REGEX.test(name);
15
+
16
+ /**
17
+ * Throw unless `name` is a safe workspace-name path segment. Use this in any
18
+ * handler that receives a workspace/suite name from an untrusted caller (e.g.
19
+ * the MCP server, whose Zod schemas otherwise accept any string) BEFORE the
20
+ * name is used to build a filesystem path.
21
+ */
22
+ export const assertSafeWorkspaceName = (name: unknown, label = 'workspace name'): void => {
23
+ if (!isSafePathSegment(name)) {
24
+ throw new Error(
25
+ `Invalid ${label} "${String(name)}": only letters, numbers, hyphens, and underscores are allowed ` +
26
+ `(no path separators, "..", or other special characters).`,
27
+ );
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Validate a workspace name and resolve it to an absolute path guaranteed to sit
33
+ * directly inside WORKSPACES_DIR. This is the single choke point every code path
34
+ * (especially the MCP handlers) must use instead of a raw
35
+ * `path.join(WORKSPACES_DIR, name)` — it closes the path-traversal vector where
36
+ * a caller could pass e.g. "../../etc" as a workspace name to read, write,
37
+ * delete, or run commands outside the workspaces sandbox.
38
+ *
39
+ * Two independent guards: the allowlist above (which cannot express traversal)
40
+ * and a resolved-path containment check (belt-and-suspenders, also catches any
41
+ * future change that loosens the allowlist).
42
+ */
43
+ export const safeWorkspacePath = (name: string): string => {
44
+ assertSafeWorkspaceName(name);
45
+ const base = path.resolve(WORKSPACES_DIR);
46
+ const resolved = path.resolve(base, name);
47
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
48
+ throw new Error(`Refusing to use a workspace path outside the workspaces directory: "${name}"`);
49
+ }
50
+ return resolved;
51
+ };
52
+
53
+ export const validateWorkspaceName = (name: string): boolean | string => {
54
+ if (!name || name.trim().length === 0) {
55
+ return 'Workspace name cannot be empty';
56
+ }
57
+
58
+ if (!WORKSPACE_NAME_REGEX.test(name)) {
59
+ return 'Workspace name must contain only alphanumeric characters, hyphens, and underscores (spaces are automatically converted to hyphens)';
60
+ }
61
+
62
+ if (name.length < 3) {
63
+ return 'Workspace name must be at least 3 characters long';
64
+ }
65
+
66
+ if (name.length > 50) {
67
+ return 'Workspace name must be less than 50 characters';
68
+ }
69
+
70
+ return true;
71
+ };
72
+
73
+ export const checkWorkspaceExists = async (name: string): Promise<boolean> => {
74
+ const workspacePath = path.join(WORKSPACES_DIR, name);
75
+ try {
76
+ await fs.access(workspacePath);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ export const sanitizeWorkspaceName = (name: string): string => {
84
+ return name.trim().toLowerCase().replace(/ +/g, '-');
85
+ };
86
+
87
+ /**
88
+ * Resolves a workspace name conflict by generating a unique alternative name.
89
+ *
90
+ * Strategy:
91
+ * 1. Try appending each provided candidate suffix (e.g. repo names): `<name>-<candidate>`
92
+ * 2. Fall back to numeric suffixes: `<name>-2`, `<name>-3`, …
93
+ *
94
+ * @param baseName - The sanitized workspace name that already exists.
95
+ * @param candidates - Optional list of preferred suffixes to try first (e.g. selected repo names).
96
+ * @returns A workspace name that does not yet exist on disk and passes validation.
97
+ */
98
+ export const resolveWorkspaceNameConflict = async (
99
+ baseName: string,
100
+ candidates: string[] = []
101
+ ): Promise<string> => {
102
+ // 1. Try candidate-based suffixes (e.g. repo names)
103
+ for (const candidate of candidates) {
104
+ const suffix = sanitizeWorkspaceName(candidate)
105
+ .replace(/[^a-z0-9_-]/g, '-') // extra safety — strip any bad chars after sanitise
106
+ .replace(/-+/g, '-') // collapse consecutive hyphens
107
+ .replace(/^-|-$/g, ''); // trim leading/trailing hyphens
108
+
109
+ if (!suffix) continue;
110
+
111
+ const attempt = `${baseName}-${suffix}`;
112
+ if (validateWorkspaceName(attempt) !== true) continue;
113
+
114
+ const exists = await checkWorkspaceExists(attempt);
115
+ if (!exists) return attempt;
116
+ }
117
+
118
+ // 2. Fall back to numeric suffixes
119
+ for (let i = 2; i <= 99; i++) {
120
+ const attempt = `${baseName}-${i}`;
121
+ if (validateWorkspaceName(attempt) !== true) continue;
122
+
123
+ const exists = await checkWorkspaceExists(attempt);
124
+ if (!exists) return attempt;
125
+ }
126
+
127
+ throw new Error(`Could not resolve workspace name conflict for "${baseName}" — all attempted names already exist.`);
128
+ };
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock fs/promises
4
+ vi.mock('fs/promises', () => ({
5
+ readFile: vi.fn(),
6
+ writeFile: vi.fn().mockResolvedValue(undefined),
7
+ mkdir: vi.fn().mockResolvedValue(undefined),
8
+ }));
9
+
10
+ // Mock child_process
11
+ const mockExecFile = vi.fn();
12
+ vi.mock('child_process', () => ({
13
+ execFile: (...args: unknown[]) => {
14
+ const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
15
+ mockExecFile(args[0], args[1], args[2])
16
+ .then((result: { stdout: string; stderr: string }) => callback(null, result))
17
+ .catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
18
+ },
19
+ }));
20
+
21
+ // Mock config
22
+ vi.mock('./config', () => ({
23
+ CACHE_DIR: '/tmp/test-cache',
24
+ getPackageVersion: () => '2.20.0',
25
+ }));
26
+
27
+ import { checkForUpdate } from './version-check';
28
+ import * as fs from 'fs/promises';
29
+
30
+ describe('checkForUpdate', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ it('returns null when current version matches latest', async () => {
36
+ // No cached check
37
+ vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
38
+ // npm returns same version
39
+ mockExecFile.mockResolvedValueOnce({ stdout: '2.20.0\n', stderr: '' });
40
+
41
+ const result = await checkForUpdate();
42
+ expect(result).toBeNull();
43
+ });
44
+
45
+ it('returns update message when newer version is available', async () => {
46
+ vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
47
+ mockExecFile.mockResolvedValueOnce({ stdout: '2.21.0\n', stderr: '' });
48
+
49
+ const result = await checkForUpdate();
50
+ expect(result).toContain('Update available');
51
+ expect(result).toContain('2.20.0');
52
+ expect(result).toContain('2.21.0');
53
+ });
54
+
55
+ it('returns null when npm check fails', async () => {
56
+ vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
57
+ mockExecFile.mockRejectedValueOnce(new Error('npm timeout'));
58
+
59
+ const result = await checkForUpdate();
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it('uses cached result when within 24h', async () => {
64
+ const recentCheck = JSON.stringify({
65
+ checkedAt: new Date().toISOString(),
66
+ latestVersion: '3.0.0',
67
+ });
68
+ vi.mocked(fs.readFile).mockResolvedValueOnce(recentCheck);
69
+
70
+ const result = await checkForUpdate();
71
+ expect(result).toContain('Update available');
72
+ // Should NOT have called npm
73
+ expect(mockExecFile).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('returns null from cache when version is current', async () => {
77
+ const recentCheck = JSON.stringify({
78
+ checkedAt: new Date().toISOString(),
79
+ latestVersion: '2.20.0',
80
+ });
81
+ vi.mocked(fs.readFile).mockResolvedValueOnce(recentCheck);
82
+
83
+ const result = await checkForUpdate();
84
+ expect(result).toBeNull();
85
+ expect(mockExecFile).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('re-checks npm when cache is stale (>24h)', async () => {
89
+ const staleCheck = JSON.stringify({
90
+ checkedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
91
+ latestVersion: '2.20.0',
92
+ });
93
+ vi.mocked(fs.readFile).mockResolvedValueOnce(staleCheck);
94
+ mockExecFile.mockResolvedValueOnce({ stdout: '2.22.0\n', stderr: '' });
95
+
96
+ const result = await checkForUpdate();
97
+ expect(result).toContain('2.22.0');
98
+ expect(mockExecFile).toHaveBeenCalled();
99
+ });
100
+
101
+ it('returns null when current is newer than latest (dev version)', async () => {
102
+ vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT'));
103
+ mockExecFile.mockResolvedValueOnce({ stdout: '2.19.0\n', stderr: '' });
104
+
105
+ const result = await checkForUpdate();
106
+ expect(result).toBeNull();
107
+ });
108
+ });
@@ -0,0 +1,96 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { execFile } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { CACHE_DIR, getPackageVersion } from './config';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ const VERSION_CHECK_FILE = path.join(CACHE_DIR, 'last-version-check.json');
10
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+
12
+ interface VersionCheckCache {
13
+ checkedAt: string;
14
+ latestVersion: string;
15
+ }
16
+
17
+ function compareVersions(current: string, latest: string): number {
18
+ const a = current.split('.').map(Number);
19
+ const b = latest.split('.').map(Number);
20
+ for (let i = 0; i < 3; i++) {
21
+ if ((a[i] || 0) < (b[i] || 0)) return -1;
22
+ if ((a[i] || 0) > (b[i] || 0)) return 1;
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ async function loadCache(): Promise<VersionCheckCache | null> {
28
+ try {
29
+ const content = await fs.readFile(VERSION_CHECK_FILE, 'utf-8');
30
+ return JSON.parse(content);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function saveCache(cache: VersionCheckCache): Promise<void> {
37
+ try {
38
+ await fs.mkdir(CACHE_DIR, { recursive: true });
39
+ await fs.writeFile(VERSION_CHECK_FILE, JSON.stringify(cache), 'utf-8');
40
+ } catch {
41
+ // Silently ignore cache write failures
42
+ }
43
+ }
44
+
45
+ async function fetchLatestVersion(): Promise<string | null> {
46
+ try {
47
+ const { stdout } = await execFileAsync('npm', ['view', '@nemus-cli/nemus', 'version'], {
48
+ timeout: 5000,
49
+ });
50
+ return stdout.trim();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Check if a newer version is available.
58
+ * Returns upgrade message string if an update is available, null otherwise.
59
+ * Caches the result for 24 hours to avoid spamming npm on every invocation.
60
+ * This is designed to be non-blocking and best-effort — failures are silent.
61
+ */
62
+ export async function checkForUpdate(): Promise<string | null> {
63
+ try {
64
+ const currentVersion = getPackageVersion();
65
+ const cache = await loadCache();
66
+
67
+ // If we checked recently, use cached result
68
+ if (cache) {
69
+ const elapsed = Date.now() - new Date(cache.checkedAt).getTime();
70
+ if (elapsed < CHECK_INTERVAL_MS) {
71
+ if (compareVersions(currentVersion, cache.latestVersion) < 0) {
72
+ return formatUpdateMessage(currentVersion, cache.latestVersion);
73
+ }
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Fetch latest version from npm (async with timeout)
79
+ const latestVersion = await fetchLatestVersion();
80
+ if (!latestVersion) return null;
81
+
82
+ await saveCache({ checkedAt: new Date().toISOString(), latestVersion });
83
+
84
+ if (compareVersions(currentVersion, latestVersion) < 0) {
85
+ return formatUpdateMessage(currentVersion, latestVersion);
86
+ }
87
+
88
+ return null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function formatUpdateMessage(current: string, latest: string): string {
95
+ return `\x1b[33m[nemus] Update available: ${current} -> ${latest}. Run: npm install -g @nemus-cli/nemus@latest\x1b[0m`;
96
+ }
@@ -0,0 +1,173 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { WorkspaceMetadata, RepositoryMetadata, CloneResult } from '../types';
4
+ import { logSuccess, logWarning, logError } from './logger';
5
+ import { WORKSPACES_DIR, META_FILENAME, getCloneUrl } from './config';
6
+ import { safeWorkspacePath } from './validation';
7
+
8
+ const ARCHIVE_EXPIRY_DAYS = 30;
9
+
10
+ export const createMetadata = (
11
+ workspaceName: string,
12
+ cloneResults: CloneResult[],
13
+ options?: { prompt?: string }
14
+ ): WorkspaceMetadata => {
15
+ const repositories: RepositoryMetadata[] = cloneResults.map(result => ({
16
+ name: result.repo.name,
17
+ directoryName: result.directoryName,
18
+ owner: result.repo.owner.login,
19
+ clonedAt: result.clonedAt || new Date().toISOString(),
20
+ cloneUrl: getCloneUrl(result.repo),
21
+ status: result.status,
22
+ error: result.error,
23
+ }));
24
+
25
+ return {
26
+ workspaceName,
27
+ createdAt: new Date().toISOString(),
28
+ repositories,
29
+ ...(options?.prompt ? { prompt: options.prompt } : {}),
30
+ };
31
+ };
32
+
33
+ export const saveMetadata = async (
34
+ workspacePath: string,
35
+ metadata: WorkspaceMetadata
36
+ ): Promise<void> => {
37
+ try {
38
+ const metaPath = path.join(workspacePath, META_FILENAME);
39
+ await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2), 'utf-8');
40
+ logSuccess('Saved workspace metadata');
41
+ } catch (error) {
42
+ logWarning('Failed to save workspace metadata');
43
+ if (error instanceof Error) {
44
+ logError(error.message);
45
+ }
46
+ }
47
+ };
48
+
49
+ export const loadMetadata = async (workspacePath: string): Promise<WorkspaceMetadata | null> => {
50
+ try {
51
+ const metaPath = path.join(workspacePath, META_FILENAME);
52
+ const content = await fs.readFile(metaPath, 'utf-8');
53
+ const metadata: WorkspaceMetadata = JSON.parse(content);
54
+
55
+ // Backward compatibility: default directoryName to name for old entries
56
+ for (const repo of metadata.repositories) {
57
+ if (!repo.directoryName) {
58
+ repo.directoryName = repo.name;
59
+ }
60
+ }
61
+
62
+ return metadata;
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ export const listWorkspaces = async (
69
+ includeArchived: boolean = false
70
+ ): Promise<Array<{ name: string; path: string; metadata: WorkspaceMetadata | null }>> => {
71
+ try {
72
+ await fs.access(WORKSPACES_DIR);
73
+ } catch {
74
+ return [];
75
+ }
76
+
77
+ try {
78
+ // Lazily purge expired archives on every list call
79
+ await purgeExpiredArchives();
80
+
81
+ const entries = await fs.readdir(WORKSPACES_DIR, { withFileTypes: true });
82
+ const workspaces = [];
83
+
84
+ for (const entry of entries) {
85
+ if (entry.isDirectory()) {
86
+ const workspacePath = path.join(WORKSPACES_DIR, entry.name);
87
+ const metadata = await loadMetadata(workspacePath);
88
+
89
+ // Filter based on archive status
90
+ if (!includeArchived && metadata?.archivedAt) {
91
+ continue;
92
+ }
93
+
94
+ workspaces.push({
95
+ name: entry.name,
96
+ path: workspacePath,
97
+ metadata,
98
+ });
99
+ }
100
+ }
101
+
102
+ return workspaces;
103
+ } catch (error) {
104
+ logError('Failed to list workspaces');
105
+ return [];
106
+ }
107
+ };
108
+
109
+ export const archiveWorkspace = async (workspaceName: string): Promise<void> => {
110
+ const workspacePath = safeWorkspacePath(workspaceName);
111
+ const metadata = await loadMetadata(workspacePath);
112
+
113
+ if (!metadata) {
114
+ throw new Error(`Workspace not found: ${workspaceName}`);
115
+ }
116
+
117
+ if (metadata.archivedAt) {
118
+ throw new Error(`Workspace is already archived: ${workspaceName}`);
119
+ }
120
+
121
+ metadata.archivedAt = new Date().toISOString();
122
+ await saveMetadata(workspacePath, metadata);
123
+ };
124
+
125
+ export const unarchiveWorkspace = async (workspaceName: string): Promise<void> => {
126
+ const workspacePath = safeWorkspacePath(workspaceName);
127
+ const metadata = await loadMetadata(workspacePath);
128
+
129
+ if (!metadata) {
130
+ throw new Error(`Workspace not found: ${workspaceName}`);
131
+ }
132
+
133
+ if (!metadata.archivedAt) {
134
+ throw new Error(`Workspace is not archived: ${workspaceName}`);
135
+ }
136
+
137
+ delete metadata.archivedAt;
138
+ await saveMetadata(workspacePath, metadata);
139
+ };
140
+
141
+ export const purgeExpiredArchives = async (): Promise<void> => {
142
+ try {
143
+ await fs.access(WORKSPACES_DIR);
144
+ } catch {
145
+ return;
146
+ }
147
+
148
+ try {
149
+ const entries = await fs.readdir(WORKSPACES_DIR, { withFileTypes: true });
150
+ const now = Date.now();
151
+
152
+ for (const entry of entries) {
153
+ if (!entry.isDirectory()) continue;
154
+
155
+ const workspacePath = path.join(WORKSPACES_DIR, entry.name);
156
+ const metadata = await loadMetadata(workspacePath);
157
+
158
+ if (!metadata?.archivedAt) continue;
159
+
160
+ const archivedTime = new Date(metadata.archivedAt).getTime();
161
+ const daysSinceArchive = (now - archivedTime) / (1000 * 60 * 60 * 24);
162
+
163
+ if (daysSinceArchive > ARCHIVE_EXPIRY_DAYS) {
164
+ await fs.rm(workspacePath, { recursive: true, force: true });
165
+ process.stderr.write(`Purged expired archive: ${entry.name}\n`);
166
+ }
167
+ }
168
+ } catch (error) {
169
+ if (error instanceof Error) {
170
+ process.stderr.write(`Error purging expired archives: ${error.message}\n`);
171
+ }
172
+ }
173
+ };