tmux-ide 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * The one global config — `~/.tmux-ide/config.json` (overridable via
3
+ * `TMUX_IDE_CONFIG`).
4
+ *
5
+ * Everything that used to be hardcoded chrome behaviour lives here: the
6
+ * root-table key binds (`M-p`/`M-k`/`M-m`), the shared THEME TOKENS consumed by
7
+ * BOTH the tmux chrome (status bar / pane chips / actions menu / cheat sheet)
8
+ * AND the OpenTUI widgets, the updater cadence, and the notification/restore/
9
+ * update-check toggles. One palette, one keymap, one file — the foundation for
10
+ * the "one cohesive app" milestone.
11
+ *
12
+ * The tokens are SEMANTIC (accent/muted/fg + per-status colors + glyphs), not
13
+ * per-surface: a single `theme` block drives every surface, so re-theming the
14
+ * whole product is a one-file edit + a re-adopt (no code change).
15
+ *
16
+ * {@link parseAppConfig} is PURE — a deep partial merge over the defaults where
17
+ * any missing or mistyped field falls back to its default and it never throws.
18
+ * {@link loadAppConfig} is the thin io wrapper (missing/malformed file →
19
+ * defaults); {@link getAppConfig} caches that read for the process. The old
20
+ * per-concern readers ({@link ../tui/chrome/notify.ts}, {@link ../restore.ts})
21
+ * now delegate here.
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join } from "node:path";
26
+ import type { AgentStatus } from "../tui/detect/classify.ts";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Shape
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Root-table key binds (tmux key names) for the per-widget PANEL popups —
34
+ * every widget is a floating app dialog one keystroke away, esc to close (the
35
+ * "one-app" milestone). Only widgets that exist AND stand alone are here:
36
+ * `preview` is a companion that renders the explorer's selection (nothing to
37
+ * show on its own) and `setup` is the onboarding wizard, so neither gets a key.
38
+ */
39
+ export interface AppPanelKeys {
40
+ /** File explorer panel (default `M-e`). */
41
+ explorer: string;
42
+ /** Git changes panel (default `M-g`). */
43
+ changes: string;
44
+ /** Config editor panel (default `M-,`). */
45
+ config: string;
46
+ }
47
+
48
+ /** Root-table key binds (tmux key names) for the chrome popups + widget panels. */
49
+ export interface AppKeys {
50
+ /** Switcher popup (default `M-p`). */
51
+ popup: string;
52
+ /** Home cockpit popup — the full fleet home from any session (default `M-h`). */
53
+ home: string;
54
+ /** Cheat-sheet popup (default `M-k`). */
55
+ cheatsheet: string;
56
+ /** Actions menu (default `M-m`). */
57
+ menu: string;
58
+ /** Sidebar (app nav column) toggle (default `M-b`). */
59
+ sidebar: string;
60
+ /** Per-widget panel popups (default `M-e`/`M-g`/`M-,`). */
61
+ panels: AppPanelKeys;
62
+ }
63
+
64
+ /** Per-agent-status color token (tmux `colourN` / `#rrggbb`). */
65
+ export type AppThemeStatus = Record<AgentStatus, string>;
66
+
67
+ /** The two state glyphs — filled (active/present) and hollow (inactive/quiet). */
68
+ export interface AppThemeGlyphs {
69
+ active: string;
70
+ inactive: string;
71
+ }
72
+
73
+ /**
74
+ * The shared palette — semantic tokens, not per-surface styles. Chrome builders
75
+ * and widgets both map these into their own render layer, so the whole product
76
+ * reads as one system.
77
+ */
78
+ export interface AppTheme {
79
+ /** Primary/brand accent (default `colour75`). */
80
+ accent: string;
81
+ /** Muted/secondary foreground for dim text (default `colour240`). */
82
+ muted: string;
83
+ /** Default foreground (default `colour250`). */
84
+ fg: string;
85
+ /** Per-status colors (blocked/working/done/idle/unknown). */
86
+ status: AppThemeStatus;
87
+ /** The filled/hollow state glyphs. */
88
+ glyphs: AppThemeGlyphs;
89
+ }
90
+
91
+ /** Chrome updater cadence. */
92
+ export interface AppUpdater {
93
+ /** Milliseconds between ticks (default 2000). */
94
+ tickMs: number;
95
+ /** Write a disaster-recovery snapshot every N ticks (default 15). */
96
+ snapshotEvery: number;
97
+ }
98
+
99
+ /** Notification channel toggles. */
100
+ export interface AppNotifications {
101
+ toast: boolean;
102
+ macos: boolean;
103
+ }
104
+
105
+ /** Restore behaviour toggles. */
106
+ export interface AppRestore {
107
+ resumeAgents: boolean;
108
+ }
109
+
110
+ /** Update-check toggles (consumed later by the update-flow card). */
111
+ export interface AppUpdates {
112
+ check: boolean;
113
+ }
114
+
115
+ /** First-run welcome toggles. */
116
+ export interface AppWelcome {
117
+ /**
118
+ * Whether the first-run welcome card may show (default true). Set false to
119
+ * suppress it independently of the `~/.tmux-ide/welcomed` marker file (which
120
+ * records that it has already been shown once).
121
+ */
122
+ show: boolean;
123
+ }
124
+
125
+ /** Agent-integration auto-discovery config. */
126
+ export interface AppIntegrations {
127
+ /**
128
+ * Whether the first-adopt integration OFFER may show (default true). Set false
129
+ * to suppress the one-time "claude detected — install the integration?" popup
130
+ * independently of the `<home>/integration-offered` marker (which records that
131
+ * it has already been shown once).
132
+ */
133
+ offer: boolean;
134
+ }
135
+
136
+ /** The unified-app front-door decision (M22.6). */
137
+ export interface AppApp {
138
+ /**
139
+ * Whether bare `tmux-ide` (no ide.yml here, no `--team`) launches the unified
140
+ * app (`tmux-ide app`) instead of the classic team cockpit. Default false —
141
+ * the flip is opt-in until the default-entry decision is made. `tmux-ide team`
142
+ * (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
143
+ */
144
+ frontDoor: boolean;
145
+ }
146
+
147
+ /** Worktree flow config (`tmux-ide worktree`). */
148
+ export interface AppWorktrees {
149
+ /**
150
+ * Base directory for worktree checkouts. Empty (default) → a sibling
151
+ * `<repo>-worktrees` dir next to each repo. A non-empty value overrides that
152
+ * base; a relative path is resolved against the repo.
153
+ */
154
+ dir: string;
155
+ }
156
+
157
+ /** The whole config. */
158
+ export interface AppConfig {
159
+ keys: AppKeys;
160
+ theme: AppTheme;
161
+ updater: AppUpdater;
162
+ notifications: AppNotifications;
163
+ restore: AppRestore;
164
+ updates: AppUpdates;
165
+ welcome: AppWelcome;
166
+ integrations: AppIntegrations;
167
+ worktrees: AppWorktrees;
168
+ app: AppApp;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Defaults
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /** The built-in config — every field's fallback. */
176
+ export const DEFAULT_APP_CONFIG: AppConfig = {
177
+ keys: {
178
+ popup: "M-p",
179
+ home: "M-h",
180
+ cheatsheet: "M-k",
181
+ menu: "M-m",
182
+ sidebar: "M-b",
183
+ panels: { explorer: "M-e", changes: "M-g", config: "M-," },
184
+ },
185
+ theme: {
186
+ accent: "colour75",
187
+ muted: "colour240",
188
+ fg: "colour250",
189
+ status: {
190
+ blocked: "colour203",
191
+ working: "colour221",
192
+ done: "colour111",
193
+ idle: "colour114",
194
+ unknown: "colour244",
195
+ },
196
+ glyphs: { active: "●", inactive: "○" },
197
+ },
198
+ updater: { tickMs: 2000, snapshotEvery: 15 },
199
+ notifications: { toast: true, macos: false },
200
+ restore: { resumeAgents: false },
201
+ updates: { check: true },
202
+ welcome: { show: true },
203
+ integrations: { offer: true },
204
+ worktrees: { dir: "" },
205
+ app: { frontDoor: false },
206
+ };
207
+
208
+ /** The default theme tokens — the fallback threaded into the pure builders. */
209
+ export const DEFAULT_THEME: AppTheme = DEFAULT_APP_CONFIG.theme;
210
+
211
+ /** The default key binds — the fallback for the bind command builders. */
212
+ export const DEFAULT_KEYS: AppKeys = DEFAULT_APP_CONFIG.keys;
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Pure parse (deep partial merge over defaults)
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** A plain object, or `{}` for anything that isn't one (arrays included). */
219
+ function asObject(value: unknown): Record<string, unknown> {
220
+ return value && typeof value === "object" && !Array.isArray(value)
221
+ ? (value as Record<string, unknown>)
222
+ : {};
223
+ }
224
+
225
+ /** A non-empty string, else the default. */
226
+ function pickString(value: unknown, fallback: string): string {
227
+ return typeof value === "string" && value.length > 0 ? value : fallback;
228
+ }
229
+
230
+ /** A boolean, else the default. */
231
+ function pickBool(value: unknown, fallback: boolean): boolean {
232
+ return typeof value === "boolean" ? value : fallback;
233
+ }
234
+
235
+ /** A positive integer, else the default. */
236
+ function pickPosInt(value: unknown, fallback: number): number {
237
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
238
+ }
239
+
240
+ /**
241
+ * PURE — merge an unknown (typically parsed JSON) over {@link DEFAULT_APP_CONFIG}.
242
+ * Every block/leaf is validated independently: a missing or mistyped field falls
243
+ * back to its default, so partial and garbage configs both resolve to a complete,
244
+ * well-typed {@link AppConfig}. Never throws.
245
+ */
246
+ export function parseAppConfig(input: unknown): AppConfig {
247
+ const D = DEFAULT_APP_CONFIG;
248
+ const root = asObject(input);
249
+ const keys = asObject(root.keys);
250
+ const panels = asObject(keys.panels);
251
+ const theme = asObject(root.theme);
252
+ const status = asObject(theme.status);
253
+ const glyphs = asObject(theme.glyphs);
254
+ const updater = asObject(root.updater);
255
+ const notifications = asObject(root.notifications);
256
+ const restore = asObject(root.restore);
257
+ const updates = asObject(root.updates);
258
+ const welcome = asObject(root.welcome);
259
+ const integrations = asObject(root.integrations);
260
+ const worktrees = asObject(root.worktrees);
261
+ const app = asObject(root.app);
262
+ return {
263
+ keys: {
264
+ popup: pickString(keys.popup, D.keys.popup),
265
+ home: pickString(keys.home, D.keys.home),
266
+ cheatsheet: pickString(keys.cheatsheet, D.keys.cheatsheet),
267
+ menu: pickString(keys.menu, D.keys.menu),
268
+ sidebar: pickString(keys.sidebar, D.keys.sidebar),
269
+ panels: {
270
+ explorer: pickString(panels.explorer, D.keys.panels.explorer),
271
+ changes: pickString(panels.changes, D.keys.panels.changes),
272
+ config: pickString(panels.config, D.keys.panels.config),
273
+ },
274
+ },
275
+ theme: {
276
+ accent: pickString(theme.accent, D.theme.accent),
277
+ muted: pickString(theme.muted, D.theme.muted),
278
+ fg: pickString(theme.fg, D.theme.fg),
279
+ status: {
280
+ blocked: pickString(status.blocked, D.theme.status.blocked),
281
+ working: pickString(status.working, D.theme.status.working),
282
+ done: pickString(status.done, D.theme.status.done),
283
+ idle: pickString(status.idle, D.theme.status.idle),
284
+ unknown: pickString(status.unknown, D.theme.status.unknown),
285
+ },
286
+ glyphs: {
287
+ active: pickString(glyphs.active, D.theme.glyphs.active),
288
+ inactive: pickString(glyphs.inactive, D.theme.glyphs.inactive),
289
+ },
290
+ },
291
+ updater: {
292
+ tickMs: pickPosInt(updater.tickMs, D.updater.tickMs),
293
+ snapshotEvery: pickPosInt(updater.snapshotEvery, D.updater.snapshotEvery),
294
+ },
295
+ notifications: {
296
+ toast: pickBool(notifications.toast, D.notifications.toast),
297
+ macos: pickBool(notifications.macos, D.notifications.macos),
298
+ },
299
+ restore: { resumeAgents: pickBool(restore.resumeAgents, D.restore.resumeAgents) },
300
+ updates: { check: pickBool(updates.check, D.updates.check) },
301
+ welcome: { show: pickBool(welcome.show, D.welcome.show) },
302
+ integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
303
+ worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
304
+ app: { frontDoor: pickBool(app.frontDoor, D.app.frontDoor) },
305
+ };
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // io
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Absolute path to the config file: `TMUX_IDE_CONFIG` when set (tests / per-run
314
+ * overrides), else `~/.tmux-ide/config.json`.
315
+ */
316
+ export function appConfigPath(): string {
317
+ return process.env.TMUX_IDE_CONFIG ?? join(homedir(), ".tmux-ide", "config.json");
318
+ }
319
+
320
+ /**
321
+ * io — read + parse the config. A missing or malformed file resolves to the full
322
+ * defaults ({@link parseAppConfig} never throws). Reads fresh each call; use
323
+ * {@link getAppConfig} for the cached per-process read.
324
+ */
325
+ export function loadAppConfig(): AppConfig {
326
+ const path = appConfigPath();
327
+ if (!existsSync(path)) return parseAppConfig(undefined);
328
+ try {
329
+ return parseAppConfig(JSON.parse(readFileSync(path, "utf-8")));
330
+ } catch {
331
+ // malformed file — full defaults
332
+ return parseAppConfig(undefined);
333
+ }
334
+ }
335
+
336
+ let cached: AppConfig | null = null;
337
+
338
+ /**
339
+ * The process-cached config. Resolved once on first use — config changes take
340
+ * effect on the next process (e.g. a re-adopt spawns fresh CLI invocations), so
341
+ * caching is safe and keeps the hot updater tick from re-reading the file.
342
+ */
343
+ export function getAppConfig(): AppConfig {
344
+ if (!cached) cached = loadAppConfig();
345
+ return cached;
346
+ }
347
+
348
+ /** Drop the cache so the next {@link getAppConfig} re-reads. Test-only. */
349
+ export function _resetForTests(): void {
350
+ cached = null;
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Writes (M22.4 — the settings surface persists through here)
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /**
358
+ * io — the RAW parsed config file as the user wrote it (`{}` when missing or
359
+ * malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
360
+ * round-tripping through the parser would materialize every default into the
361
+ * hand-editable file and silently DROP fields the typed shape doesn't model yet
362
+ * (e.g. the notification polish fields `notifications.enabled` /
363
+ * `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
364
+ */
365
+ export function loadRawAppConfig(): Record<string, unknown> {
366
+ const path = appConfigPath();
367
+ if (!existsSync(path)) return {};
368
+ try {
369
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
370
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
371
+ ? (parsed as Record<string, unknown>)
372
+ : {};
373
+ } catch {
374
+ return {};
375
+ }
376
+ }
377
+
378
+ /** A partial config write: plain objects merge recursively, scalars/arrays
379
+ * replace, and an explicit `undefined` DELETES the key (how "reset to
380
+ * defaults" removes a block so the parser's defaults take over). */
381
+ export type AppConfigPatch = { [key: string]: unknown };
382
+
383
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
384
+ return !!value && typeof value === "object" && !Array.isArray(value);
385
+ }
386
+
387
+ /**
388
+ * PURE — merge `patch` into `raw` without touching either input. Objects merge
389
+ * key-by-key (recursively), anything else replaces, and a key whose patch value
390
+ * is `undefined` is REMOVED from the result. Unknown user fields survive — the
391
+ * merge only visits keys the patch names.
392
+ */
393
+ export function mergeConfigPatch(
394
+ raw: Record<string, unknown>,
395
+ patch: AppConfigPatch,
396
+ ): Record<string, unknown> {
397
+ const out: Record<string, unknown> = { ...raw };
398
+ for (const [key, value] of Object.entries(patch)) {
399
+ if (value === undefined) {
400
+ delete out[key];
401
+ } else if (isPlainObject(value) && isPlainObject(out[key])) {
402
+ out[key] = mergeConfigPatch(out[key] as Record<string, unknown>, value as AppConfigPatch);
403
+ } else if (isPlainObject(value)) {
404
+ out[key] = mergeConfigPatch({}, value as AppConfigPatch);
405
+ } else {
406
+ out[key] = value;
407
+ }
408
+ }
409
+ return out;
410
+ }
411
+
412
+ /**
413
+ * io — apply a patch to the config file ATOMICALLY (temp + rename, the same
414
+ * discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
415
+ * Busts the {@link getAppConfig} process cache and returns the new parsed
416
+ * config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
417
+ */
418
+ export function updateAppConfig(patch: AppConfigPatch): AppConfig {
419
+ const path = appConfigPath();
420
+ const merged = mergeConfigPatch(loadRawAppConfig(), patch);
421
+ mkdirSync(dirname(path), { recursive: true });
422
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
423
+ writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
424
+ renameSync(tmp, path);
425
+ cached = null;
426
+ return parseAppConfig(merged);
427
+ }
@@ -0,0 +1,53 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export interface AppSettings {
6
+ remoteAccess: {
7
+ enabled: boolean;
8
+ token: string | null;
9
+ };
10
+ }
11
+
12
+ const DEFAULT_SETTINGS: AppSettings = {
13
+ remoteAccess: {
14
+ enabled: false,
15
+ token: null,
16
+ },
17
+ };
18
+
19
+ function settingsDir(): string {
20
+ return process.env.TMUX_IDE_SETTINGS_DIR ?? join(homedir(), ".tmux-ide");
21
+ }
22
+
23
+ export function appSettingsPath(): string {
24
+ return join(settingsDir(), "app-settings.json");
25
+ }
26
+
27
+ function normalizeSettings(value: unknown): AppSettings {
28
+ if (!value || typeof value !== "object") return structuredClone(DEFAULT_SETTINGS);
29
+ const remote = (value as { remoteAccess?: unknown }).remoteAccess;
30
+ if (!remote || typeof remote !== "object") return structuredClone(DEFAULT_SETTINGS);
31
+ const enabled = (remote as { enabled?: unknown }).enabled === true;
32
+ const rawToken = (remote as { token?: unknown }).token;
33
+ const token = typeof rawToken === "string" && rawToken.length > 0 ? rawToken : null;
34
+ return { remoteAccess: { enabled, token } };
35
+ }
36
+
37
+ export function readAppSettings(): AppSettings {
38
+ const path = appSettingsPath();
39
+ if (!existsSync(path)) return structuredClone(DEFAULT_SETTINGS);
40
+ try {
41
+ return normalizeSettings(JSON.parse(readFileSync(path, "utf-8")));
42
+ } catch {
43
+ return structuredClone(DEFAULT_SETTINGS);
44
+ }
45
+ }
46
+
47
+ export function writeAppSettings(next: AppSettings): void {
48
+ const path = appSettingsPath();
49
+ mkdirSync(dirname(path), { recursive: true });
50
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
51
+ writeFileSync(tmp, `${JSON.stringify(normalizeSettings(next), null, 2)}\n`, "utf-8");
52
+ renameSync(tmp, path);
53
+ }
@@ -0,0 +1,227 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import * as crypto from "node:crypto";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { logger } from "../log.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ interface AuthChallenge {
13
+ challengeId: string;
14
+ challenge: Buffer;
15
+ timestamp: number;
16
+ userId: string;
17
+ }
18
+
19
+ export interface AuthResult {
20
+ success: boolean;
21
+ userId?: string;
22
+ token?: string;
23
+ error?: string;
24
+ }
25
+
26
+ interface SSHKeyAuth {
27
+ publicKey: string;
28
+ signature: string;
29
+ challengeId: string;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // JWT helpers (HMAC-SHA256, no external dependency)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function base64url(buf: Buffer | string): string {
37
+ const b = typeof buf === "string" ? Buffer.from(buf) : buf;
38
+ return b.toString("base64url");
39
+ }
40
+
41
+ function decodeBase64url(str: string): Buffer {
42
+ return Buffer.from(str, "base64url");
43
+ }
44
+
45
+ function signJwt(payload: Record<string, unknown>, secret: string, expiresInSec: number): string {
46
+ const header = { alg: "HS256", typ: "JWT" };
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const body = { ...payload, iat: now, exp: now + expiresInSec };
49
+
50
+ const segments = [base64url(JSON.stringify(header)), base64url(JSON.stringify(body))];
51
+ const sigInput = segments.join(".");
52
+ const sig = crypto.createHmac("sha256", secret).update(sigInput).digest();
53
+ segments.push(base64url(sig));
54
+ return segments.join(".");
55
+ }
56
+
57
+ function verifyJwt(
58
+ token: string,
59
+ secret: string,
60
+ ): { valid: true; payload: Record<string, unknown> } | { valid: false } {
61
+ const parts = token.split(".");
62
+ if (parts.length !== 3) return { valid: false };
63
+
64
+ const sigInput = parts[0] + "." + parts[1];
65
+ const expected = crypto.createHmac("sha256", secret).update(sigInput).digest();
66
+ const actual = decodeBase64url(parts[2]!);
67
+
68
+ if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
69
+ return { valid: false };
70
+ }
71
+
72
+ try {
73
+ const payload = JSON.parse(decodeBase64url(parts[1]!).toString()) as Record<string, unknown>;
74
+ if (typeof payload.exp === "number" && payload.exp < Math.floor(Date.now() / 1000)) {
75
+ return { valid: false };
76
+ }
77
+ return { valid: true, payload };
78
+ } catch {
79
+ return { valid: false };
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // AuthService
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const TOKEN_EXPIRY_SEC = 24 * 60 * 60; // 24 hours
88
+ const CHALLENGE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
89
+
90
+ export class AuthService {
91
+ private challenges = new Map<string, AuthChallenge>();
92
+ private jwtSecret: string;
93
+ private cleanupTimer: ReturnType<typeof setInterval>;
94
+
95
+ constructor(secret?: string) {
96
+ this.jwtSecret = secret ?? process.env.JWT_SECRET ?? crypto.randomBytes(64).toString("hex");
97
+ this.cleanupTimer = setInterval(() => this.cleanupExpiredChallenges(), 60_000);
98
+ }
99
+
100
+ dispose(): void {
101
+ clearInterval(this.cleanupTimer);
102
+ }
103
+
104
+ private cleanupExpiredChallenges(): void {
105
+ const now = Date.now();
106
+ for (const [id, ch] of this.challenges) {
107
+ if (now - ch.timestamp > CHALLENGE_TIMEOUT_MS) {
108
+ this.challenges.delete(id);
109
+ }
110
+ }
111
+ }
112
+
113
+ // ---- JWT ----------------------------------------------------------------
114
+
115
+ generateToken(userId: string): string {
116
+ return signJwt({ userId }, this.jwtSecret, TOKEN_EXPIRY_SEC);
117
+ }
118
+
119
+ verifyToken(token: string): { valid: boolean; userId?: string } {
120
+ const result = verifyJwt(token, this.jwtSecret);
121
+ if (!result.valid) return { valid: false };
122
+ return { valid: true, userId: result.payload.userId as string };
123
+ }
124
+
125
+ // ---- SSH challenge-response --------------------------------------------
126
+
127
+ createChallenge(userId: string): { challengeId: string; challenge: string } {
128
+ const challengeId = crypto.randomUUID();
129
+ const challenge = crypto.randomBytes(32);
130
+
131
+ this.challenges.set(challengeId, {
132
+ challengeId,
133
+ challenge,
134
+ timestamp: Date.now(),
135
+ userId,
136
+ });
137
+
138
+ return { challengeId, challenge: challenge.toString("base64") };
139
+ }
140
+
141
+ async authenticateWithSSHKey(auth: SSHKeyAuth): Promise<AuthResult> {
142
+ const challenge = this.challenges.get(auth.challengeId);
143
+ if (!challenge) {
144
+ return { success: false, error: "Invalid or expired challenge" };
145
+ }
146
+
147
+ const sigBuf = Buffer.from(auth.signature, "base64");
148
+ if (!this.verifySSHSignature(challenge.challenge, sigBuf, auth.publicKey)) {
149
+ return { success: false, error: "Invalid SSH key signature" };
150
+ }
151
+
152
+ const authorized = this.checkSSHKeyAuthorization(challenge.userId, auth.publicKey);
153
+ if (!authorized) {
154
+ return { success: false, error: "SSH key not authorized for this user" };
155
+ }
156
+
157
+ this.challenges.delete(auth.challengeId);
158
+ const token = this.generateToken(challenge.userId);
159
+ return { success: true, userId: challenge.userId, token };
160
+ }
161
+
162
+ // ---- SSH helpers -------------------------------------------------------
163
+
164
+ private verifySSHSignature(challenge: Buffer, signature: Buffer, publicKeyStr: string): boolean {
165
+ try {
166
+ const parts = publicKeyStr.trim().split(" ");
167
+ if (parts.length < 2) return false;
168
+
169
+ const keyType = parts[0];
170
+ const keyData = parts[1]!;
171
+
172
+ if (keyType !== "ssh-ed25519") {
173
+ logger.warn("auth", `Unsupported key type: ${keyType}`);
174
+ return false;
175
+ }
176
+
177
+ if (signature.length !== 64) return false;
178
+
179
+ const sshBuf = Buffer.from(keyData, "base64");
180
+ let offset = 0;
181
+ const algLen = sshBuf.readUInt32BE(offset);
182
+ offset += 4 + algLen;
183
+ const keyLen = sshBuf.readUInt32BE(offset);
184
+ offset += 4;
185
+ if (keyLen !== 32) return false;
186
+
187
+ const rawPub = sshBuf.subarray(offset, offset + 32);
188
+ const pubKey = crypto.createPublicKey({
189
+ key: Buffer.concat([
190
+ Buffer.from([0x30, 0x2a]),
191
+ Buffer.from([0x30, 0x05]),
192
+ Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]),
193
+ Buffer.from([0x03, 0x21, 0x00]),
194
+ rawPub,
195
+ ]),
196
+ format: "der",
197
+ type: "spki",
198
+ });
199
+
200
+ return crypto.verify(null, challenge, pubKey, signature);
201
+ } catch (err) {
202
+ logger.error("auth", "SSH signature verification failed", {
203
+ error: String(err),
204
+ });
205
+ return false;
206
+ }
207
+ }
208
+
209
+ private checkSSHKeyAuthorization(userId: string, publicKey: string): boolean {
210
+ try {
211
+ const home = userId === process.env.USER ? homedir() : `/home/${userId}`;
212
+ const authKeysPath = join(home, ".ssh", "authorized_keys");
213
+ if (!existsSync(authKeysPath)) return false;
214
+
215
+ const authorizedKeys = readFileSync(authKeysPath, "utf-8");
216
+ const parts = publicKey.trim().split(" ");
217
+ const keyData = parts.length > 1 ? parts[1]! : parts[0]!;
218
+ return authorizedKeys.includes(keyData);
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ getCurrentUser(): string {
225
+ return process.env.USER ?? process.env.USERNAME ?? "unknown";
226
+ }
227
+ }