@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,355 @@
1
+ /**
2
+ * Bug reporting — capture runtime errors and file them as GitHub issues
3
+ * against the project repo.
4
+ *
5
+ * Design goals (see PR discussion):
6
+ * - Sanitize: never leak home dir, usernames, emails, or token-like strings.
7
+ * - Deduplicate: one issue per distinct error signature (search before create).
8
+ * - Safe: only file genuine tool errors; skip clearly-environmental ones unless
9
+ * forced. Auto-filing is opt-in via config (autoReportBugs).
10
+ */
11
+
12
+ import { execFileSync } from 'child_process';
13
+ import * as os from 'os';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { createHash } from 'crypto';
17
+ import { CACHE_DIR } from './config';
18
+
19
+ /**
20
+ * Repo that bug issues are filed against. Overridable via NEMUS_BUG_REPORT_REPO
21
+ * so forks/downstreams route reports to their own tracker.
22
+ */
23
+ export const BUG_REPORT_REPO = process.env.NEMUS_BUG_REPORT_REPO || 'me-public/nemus';
24
+
25
+ /** Where the most recent error is captured for `w report-bug`. */
26
+ export const LAST_ERROR_FILE = path.join(CACHE_DIR, 'last-error.json');
27
+
28
+ /** Label applied to auto-filed bug issues (used for dedup search too). */
29
+ export const BUG_LABEL = 'auto-reported';
30
+
31
+ export interface CapturedError {
32
+ /** The command the user ran, e.g. "nemus update --workspace x". */
33
+ command: string;
34
+ message: string;
35
+ stack?: string;
36
+ /** ISO timestamp. */
37
+ timestamp: string;
38
+ /** Tool version. */
39
+ version?: string;
40
+ }
41
+
42
+ export interface BugReportEnv {
43
+ version: string;
44
+ node: string;
45
+ os: string;
46
+ arch: string;
47
+ }
48
+
49
+ /**
50
+ * Redact personally-identifiable / sensitive substrings from text:
51
+ * - absolute home dir -> ~
52
+ * - emails -> <email>
53
+ * - token-ish strings -> <redacted>
54
+ */
55
+ export function sanitize(text: string): string {
56
+ if (typeof text !== 'string' || !text) return text || '';
57
+ let out = text;
58
+
59
+ const home = os.homedir();
60
+ if (home) {
61
+ // Replace the literal home path everywhere it appears.
62
+ out = out.split(home).join('~');
63
+ }
64
+ // /Users/<name>/ and /home/<name>/ (in case home didn't match, e.g. other users)
65
+ out = out.replace(/\/(Users|home)\/[^/\s"']+/g, '/$1/<user>');
66
+ // Emails
67
+ out = out.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '<email>');
68
+ // Bearer / token-like long alnum sequences (>= 24 chars with mixed case/digits)
69
+ out = out.replace(/\b(?=[A-Za-z0-9_-]*[A-Za-z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{24,}\b/g, '<redacted>');
70
+ // GitHub tokens
71
+ out = out.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<redacted>');
72
+
73
+ // Defense-in-depth: neutralize shell metacharacters so that even if the
74
+ // text is ever interpolated into a shell (it shouldn't be — we use argv),
75
+ // command substitution can't fire. Also keeps issue bodies clean.
76
+ out = out.replace(/\$\(/g, '$​(').replace(/`/g, 'ˋ').replace(/\$\{/g, '$​{');
77
+
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Compute a stable signature for an error so duplicates collapse to one issue.
83
+ * Uses the normalized message (numbers/paths stripped) + top stack frame.
84
+ */
85
+ export function errorSignature(message: string, stack?: string): string {
86
+ const normMsg = sanitize(message || '')
87
+ .replace(/\d+/g, 'N') // collapse line numbers, counts, ports
88
+ .replace(/\s+/g, ' ')
89
+ .trim()
90
+ .slice(0, 200);
91
+
92
+ // First stack frame that points into our own code (most stable identifier).
93
+ let frame = '';
94
+ if (stack) {
95
+ const m = sanitize(stack).match(/at\s+([^\s(]+)\s*\(?([^\s)]+):\d+:\d+\)?/);
96
+ if (m) frame = `${m[1]} ${m[2].replace(/\d+/g, 'N')}`;
97
+ }
98
+
99
+ return createHash('sha1').update(`${normMsg}||${frame}`).digest('hex').slice(0, 12);
100
+ }
101
+
102
+ /** Gather non-sensitive environment info for the report. */
103
+ export function gatherEnv(version: string): BugReportEnv {
104
+ return {
105
+ version,
106
+ node: process.version,
107
+ os: `${os.type()} ${os.release()}`,
108
+ arch: os.arch(),
109
+ };
110
+ }
111
+
112
+ /** Persist the most recent error so `w report-bug` can pick it up later. */
113
+ export function captureLastError(err: CapturedError): void {
114
+ try {
115
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
116
+ // Sanitize before persisting so secrets/paths/emails never sit on disk
117
+ // (the issue body re-sanitizes too — sanitize is idempotent enough here).
118
+ const safe: CapturedError = {
119
+ ...err,
120
+ command: sanitize(err.command),
121
+ message: sanitize(err.message),
122
+ stack: err.stack ? sanitize(err.stack) : undefined,
123
+ };
124
+ fs.writeFileSync(LAST_ERROR_FILE, JSON.stringify(safe, null, 2), 'utf-8');
125
+ } catch {
126
+ /* non-critical */
127
+ }
128
+ }
129
+
130
+ /** Read the most recent captured error, if any. */
131
+ export function readLastError(): CapturedError | null {
132
+ try {
133
+ const raw = JSON.parse(fs.readFileSync(LAST_ERROR_FILE, 'utf-8'));
134
+ // Validate required fields so a malformed/old file can't crash the reporter.
135
+ if (!raw || typeof raw !== 'object' || typeof raw.message !== 'string' || !raw.message) {
136
+ return null;
137
+ }
138
+ return {
139
+ command: typeof raw.command === 'string' ? raw.command : 'unknown',
140
+ message: raw.message,
141
+ stack: typeof raw.stack === 'string' ? raw.stack : undefined,
142
+ timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : new Date().toISOString(),
143
+ version: typeof raw.version === 'string' ? raw.version : undefined,
144
+ };
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Heuristic: is this error clearly environmental (not a code bug)? */
151
+ export function looksEnvironmental(message: string): boolean {
152
+ return /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|ECONNRESET|EACCES|EPERM|ENOSPC|network|rate.?limit|not authenticated|gh auth|sso|credential|token expired|command not found|No such file or directory/i
153
+ .test(message);
154
+ }
155
+
156
+ /** Build the GitHub issue title for a captured error. */
157
+ export function buildIssueTitle(err: CapturedError): string {
158
+ const sig = errorSignature(err.message, err.stack);
159
+ const shortMsg = sanitize(err.message).split('\n')[0].slice(0, 80);
160
+ return `[auto] ${shortMsg} (${sig})`;
161
+ }
162
+
163
+ /** Build the GitHub issue body (fully sanitized). */
164
+ export function buildIssueBody(err: CapturedError, env: BugReportEnv): string {
165
+ const sig = errorSignature(err.message, err.stack);
166
+ const stack = err.stack ? sanitize(err.stack).slice(0, 4000) : '(no stack captured)';
167
+ return [
168
+ `**Auto-reported by workspace-manager.**`,
169
+ ``,
170
+ `**Signature:** \`${sig}\``,
171
+ `**Command:** \`${sanitize(err.command)}\``,
172
+ `**When:** ${err.timestamp}`,
173
+ ``,
174
+ `### Error`,
175
+ '```',
176
+ sanitize(err.message).slice(0, 1000),
177
+ '```',
178
+ ``,
179
+ `### Stack`,
180
+ '```',
181
+ stack,
182
+ '```',
183
+ ``,
184
+ `### Environment`,
185
+ `| | |`,
186
+ `|---|---|`,
187
+ `| workspace-manager | ${env.version} |`,
188
+ `| node | ${env.node} |`,
189
+ `| os | ${env.os} |`,
190
+ `| arch | ${env.arch} |`,
191
+ ``,
192
+ `<sub>Paths, usernames, emails, and token-like strings are redacted automatically.</sub>`,
193
+ ].join('\n');
194
+ }
195
+
196
+ /**
197
+ * Build the argv for listing candidate duplicate issues. Exported for testing
198
+ * so we can assert no shell metacharacters / values are passed inertly.
199
+ */
200
+ export function buildIssueListArgs(): string[] {
201
+ return [
202
+ 'issue', 'list',
203
+ '--repo', BUG_REPORT_REPO,
204
+ '--state', 'open',
205
+ '--label', BUG_LABEL,
206
+ '--json', 'url,title',
207
+ '--limit', '200',
208
+ ];
209
+ }
210
+
211
+ /**
212
+ * Result of a duplicate lookup. `ok: false` means the lookup itself failed
213
+ * (gh error/timeout) — callers must NOT assume "no duplicate" in that case.
214
+ */
215
+ export type DedupResult = { ok: true; url: string | null } | { ok: false };
216
+
217
+ /**
218
+ * Find an existing open auto-reported issue with the same signature.
219
+ *
220
+ * Lists recent auto-reported issues and filters client-side on the
221
+ * parenthesized signature in the title. We do NOT rely on GitHub's
222
+ * free-text --search matching a hex substring, which is unreliable.
223
+ *
224
+ * Returns { ok:true, url } on a successful lookup (url=null = no duplicate),
225
+ * or { ok:false } if the lookup could not be performed.
226
+ */
227
+ export function findExistingIssue(signature: string): DedupResult {
228
+ try {
229
+ const out = execFileSync('gh', buildIssueListArgs(), {
230
+ encoding: 'utf-8',
231
+ timeout: 20_000,
232
+ stdio: ['ignore', 'pipe', 'ignore'],
233
+ });
234
+ const issues = JSON.parse(out) as Array<{ url: string; title: string }>;
235
+ // Match the signature only where we put it: parenthesized at the end of
236
+ // the title ("[auto] … (<sig>)"). Avoids matching an unrelated issue whose
237
+ // free text happens to contain the same 12-hex substring.
238
+ const match = issues.find((i) => i.title.includes(`(${signature})`));
239
+ return { ok: true, url: match ? match.url : null };
240
+ } catch {
241
+ return { ok: false };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Ensure the auto-report label exists (read-or-create, best-effort).
247
+ * Avoids re-writing the label on every issue by checking existence first.
248
+ */
249
+ function ensureLabel(): void {
250
+ try {
251
+ const out = execFileSync('gh', ['label', 'list', '--repo', BUG_REPORT_REPO, '--json', 'name'], {
252
+ encoding: 'utf-8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'],
253
+ });
254
+ const labels = JSON.parse(out) as Array<{ name: string }>;
255
+ if (labels.some((l) => l.name === BUG_LABEL)) return; // already exists
256
+ } catch {
257
+ // couldn't list — fall through and try to create
258
+ }
259
+ try {
260
+ execFileSync('gh', [
261
+ 'label', 'create', BUG_LABEL,
262
+ '--repo', BUG_REPORT_REPO,
263
+ '--color', 'B60205',
264
+ '--description', 'Auto-reported by workspace-manager',
265
+ ], { encoding: 'utf-8', timeout: 15_000, stdio: 'ignore' });
266
+ } catch {
267
+ /* may already exist or no perms — ignore */
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Build the argv for creating an issue. Exported for testing: a title/body
273
+ * containing shell metacharacters ($(…), backticks) must be passed as inert
274
+ * argv elements, never interpolated into a shell.
275
+ */
276
+ export function buildIssueCreateArgs(title: string, body: string): string[] {
277
+ return [
278
+ 'issue', 'create',
279
+ '--repo', BUG_REPORT_REPO,
280
+ '--title', title,
281
+ '--body', body,
282
+ '--label', BUG_LABEL,
283
+ ];
284
+ }
285
+
286
+ /**
287
+ * Create a bug issue. Returns the created issue URL, or null on failure.
288
+ *
289
+ * Uses execFileSync with an argv array — NO shell — so title/body (derived
290
+ * from arbitrary error text) can never trigger command substitution.
291
+ */
292
+ export function createIssue(title: string, body: string): string | null {
293
+ try {
294
+ ensureLabel();
295
+ const url = execFileSync('gh', buildIssueCreateArgs(title, body), {
296
+ encoding: 'utf-8',
297
+ timeout: 30_000,
298
+ stdio: ['ignore', 'pipe', 'pipe'],
299
+ }).trim();
300
+ const m = url.match(/https?:\/\/\S+/);
301
+ return m ? m[0] : url || null;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ export interface ReportResult {
308
+ status: 'created' | 'duplicate' | 'skipped' | 'failed';
309
+ url?: string;
310
+ reason?: string;
311
+ }
312
+
313
+ /**
314
+ * File (or dedupe) a bug report for a captured error.
315
+ *
316
+ * @param err The captured error.
317
+ * @param version Tool version string.
318
+ * @param opts.force Report even if the error looks environmental.
319
+ */
320
+ export function reportBug(
321
+ err: CapturedError,
322
+ version: string,
323
+ opts: { force?: boolean } = {},
324
+ ): ReportResult {
325
+ if (!opts.force && looksEnvironmental(err.message)) {
326
+ return {
327
+ status: 'skipped',
328
+ reason: 'Error looks environmental (network/auth/credentials), not a code bug. Use --force to report anyway.',
329
+ };
330
+ }
331
+
332
+ // gh must be available + authenticated.
333
+ try {
334
+ execFileSync('gh', ['auth', 'status'], { timeout: 15_000, stdio: 'ignore' });
335
+ } catch {
336
+ return { status: 'failed', reason: 'GitHub CLI (gh) not available or not authenticated.' };
337
+ }
338
+
339
+ const sig = errorSignature(err.message, err.stack);
340
+ const dedup = findExistingIssue(sig);
341
+ if (!dedup.ok) {
342
+ return {
343
+ status: 'failed',
344
+ reason: 'Could not check for existing reports (gh issue list failed). Not filing, to avoid creating a duplicate. Please retry.',
345
+ };
346
+ }
347
+ if (dedup.url) {
348
+ return { status: 'duplicate', url: dedup.url };
349
+ }
350
+
351
+ const env = gatherEnv(version);
352
+ const url = createIssue(buildIssueTitle(err), buildIssueBody(err, env));
353
+ if (!url) return { status: 'failed', reason: 'Failed to create the GitHub issue.' };
354
+ return { status: 'created', url };
355
+ }
@@ -0,0 +1,150 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { GitHubRepo } from '../types';
4
+ import { CACHE_DIR } from './config';
5
+ import { logInfo, logWarning } from './logger';
6
+ import { colorize } from './colors';
7
+
8
+ const REPOS_CACHE_FILE = path.join(CACHE_DIR, 'repos-cache.json');
9
+ const DEFAULT_TTL = 86400000; // 24 hours in milliseconds
10
+
11
+ interface CacheEntry {
12
+ data: GitHubRepo[];
13
+ timestamp: number;
14
+ ttl: number;
15
+ org?: string;
16
+ }
17
+
18
+ interface CacheOptions {
19
+ ttl?: number;
20
+ force?: boolean;
21
+ org?: string;
22
+ }
23
+
24
+ /**
25
+ * Ensure cache directory exists
26
+ */
27
+ async function ensureCacheDir(): Promise<void> {
28
+ try {
29
+ await fs.mkdir(CACHE_DIR, { recursive: true });
30
+ } catch (error) {
31
+ logWarning('Failed to create cache directory');
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Check if cache is valid (not expired)
37
+ */
38
+ function isCacheValid(entry: CacheEntry): boolean {
39
+ const now = Date.now();
40
+ const age = now - entry.timestamp;
41
+ return age < entry.ttl;
42
+ }
43
+
44
+ /**
45
+ * Get cache age in human-readable format
46
+ */
47
+ function getCacheAge(timestamp: number): string {
48
+ const ageMs = Date.now() - timestamp;
49
+ const ageMinutes = Math.floor(ageMs / 60000);
50
+ const ageHours = Math.floor(ageMinutes / 60);
51
+ const ageDays = Math.floor(ageHours / 24);
52
+
53
+ if (ageDays > 0) {
54
+ return `${ageDays} day${ageDays > 1 ? 's' : ''} ago`;
55
+ } else if (ageHours > 0) {
56
+ return `${ageHours} hour${ageHours > 1 ? 's' : ''} ago`;
57
+ } else if (ageMinutes > 0) {
58
+ return `${ageMinutes} minute${ageMinutes > 1 ? 's' : ''} ago`;
59
+ } else {
60
+ return 'just now';
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read cached repositories
66
+ */
67
+ export async function getCachedRepos(options: CacheOptions = {}): Promise<GitHubRepo[] | null> {
68
+ const { force = false, org } = options;
69
+
70
+ if (force) {
71
+ logInfo('Skipping cache (forced refresh)');
72
+ return null;
73
+ }
74
+
75
+ try {
76
+ const content = await fs.readFile(REPOS_CACHE_FILE, 'utf-8');
77
+ const entry: CacheEntry = JSON.parse(content);
78
+
79
+ if (org && entry.org !== org) {
80
+ logInfo(`Cache is for org "${entry.org || 'unknown'}", but current org is "${org}". Refreshing...`);
81
+ return null;
82
+ }
83
+
84
+ if (isCacheValid(entry)) {
85
+ const age = getCacheAge(entry.timestamp);
86
+ logInfo(`Using cached repositories (${colorize(String(entry.data.length), 'cyan')} repos, cached ${age})`);
87
+ return entry.data;
88
+ } else {
89
+ logInfo('Cache expired, fetching fresh data...');
90
+ return null;
91
+ }
92
+ } catch (error) {
93
+ // Cache file doesn't exist or is invalid
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Write repositories to cache
100
+ */
101
+ export async function setCachedRepos(repos: GitHubRepo[], ttl: number = DEFAULT_TTL, org?: string): Promise<void> {
102
+ try {
103
+ await ensureCacheDir();
104
+
105
+ const entry: CacheEntry = {
106
+ data: repos,
107
+ timestamp: Date.now(),
108
+ ttl,
109
+ org,
110
+ };
111
+
112
+ await fs.writeFile(REPOS_CACHE_FILE, JSON.stringify(entry, null, 2), 'utf-8');
113
+ logInfo(`Cached ${colorize(String(repos.length), 'cyan')} repositories (TTL: ${Math.floor(ttl / 60000)} minutes)`);
114
+ } catch (error) {
115
+ logWarning('Failed to cache repositories');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Clear the cache
121
+ */
122
+ export async function clearCache(): Promise<void> {
123
+ try {
124
+ await fs.unlink(REPOS_CACHE_FILE);
125
+ logInfo('Cache cleared successfully');
126
+ } catch (error) {
127
+ // Cache file doesn't exist, nothing to clear
128
+ logInfo('No cache to clear');
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get cache statistics
134
+ */
135
+ export async function getCacheStats(): Promise<{ exists: boolean; age?: string; repoCount?: number; size?: number }> {
136
+ try {
137
+ const content = await fs.readFile(REPOS_CACHE_FILE, 'utf-8');
138
+ const entry: CacheEntry = JSON.parse(content);
139
+ const stats = await fs.stat(REPOS_CACHE_FILE);
140
+
141
+ return {
142
+ exists: true,
143
+ age: getCacheAge(entry.timestamp),
144
+ repoCount: entry.data.length,
145
+ size: stats.size,
146
+ };
147
+ } catch {
148
+ return { exists: false };
149
+ }
150
+ }