tmux-ide 2.6.0 → 2.6.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 (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,336 @@
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, readFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { 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
+ /** Worktree flow config (`tmux-ide worktree`). */
137
+ export interface AppWorktrees {
138
+ /**
139
+ * Base directory for worktree checkouts. Empty (default) → a sibling
140
+ * `<repo>-worktrees` dir next to each repo. A non-empty value overrides that
141
+ * base; a relative path is resolved against the repo.
142
+ */
143
+ dir: string;
144
+ }
145
+
146
+ /** The whole config. */
147
+ export interface AppConfig {
148
+ keys: AppKeys;
149
+ theme: AppTheme;
150
+ updater: AppUpdater;
151
+ notifications: AppNotifications;
152
+ restore: AppRestore;
153
+ updates: AppUpdates;
154
+ welcome: AppWelcome;
155
+ integrations: AppIntegrations;
156
+ worktrees: AppWorktrees;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Defaults
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** The built-in config — every field's fallback. */
164
+ export const DEFAULT_APP_CONFIG: AppConfig = {
165
+ keys: {
166
+ popup: "M-p",
167
+ home: "M-h",
168
+ cheatsheet: "M-k",
169
+ menu: "M-m",
170
+ sidebar: "M-b",
171
+ panels: { explorer: "M-e", changes: "M-g", config: "M-," },
172
+ },
173
+ theme: {
174
+ accent: "colour75",
175
+ muted: "colour240",
176
+ fg: "colour250",
177
+ status: {
178
+ blocked: "colour203",
179
+ working: "colour221",
180
+ done: "colour111",
181
+ idle: "colour114",
182
+ unknown: "colour244",
183
+ },
184
+ glyphs: { active: "●", inactive: "○" },
185
+ },
186
+ updater: { tickMs: 2000, snapshotEvery: 15 },
187
+ notifications: { toast: true, macos: false },
188
+ restore: { resumeAgents: false },
189
+ updates: { check: true },
190
+ welcome: { show: true },
191
+ integrations: { offer: true },
192
+ worktrees: { dir: "" },
193
+ };
194
+
195
+ /** The default theme tokens — the fallback threaded into the pure builders. */
196
+ export const DEFAULT_THEME: AppTheme = DEFAULT_APP_CONFIG.theme;
197
+
198
+ /** The default key binds — the fallback for the bind command builders. */
199
+ export const DEFAULT_KEYS: AppKeys = DEFAULT_APP_CONFIG.keys;
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Pure parse (deep partial merge over defaults)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** A plain object, or `{}` for anything that isn't one (arrays included). */
206
+ function asObject(value: unknown): Record<string, unknown> {
207
+ return value && typeof value === "object" && !Array.isArray(value)
208
+ ? (value as Record<string, unknown>)
209
+ : {};
210
+ }
211
+
212
+ /** A non-empty string, else the default. */
213
+ function pickString(value: unknown, fallback: string): string {
214
+ return typeof value === "string" && value.length > 0 ? value : fallback;
215
+ }
216
+
217
+ /** A boolean, else the default. */
218
+ function pickBool(value: unknown, fallback: boolean): boolean {
219
+ return typeof value === "boolean" ? value : fallback;
220
+ }
221
+
222
+ /** A positive integer, else the default. */
223
+ function pickPosInt(value: unknown, fallback: number): number {
224
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
225
+ }
226
+
227
+ /**
228
+ * PURE — merge an unknown (typically parsed JSON) over {@link DEFAULT_APP_CONFIG}.
229
+ * Every block/leaf is validated independently: a missing or mistyped field falls
230
+ * back to its default, so partial and garbage configs both resolve to a complete,
231
+ * well-typed {@link AppConfig}. Never throws.
232
+ */
233
+ export function parseAppConfig(input: unknown): AppConfig {
234
+ const D = DEFAULT_APP_CONFIG;
235
+ const root = asObject(input);
236
+ const keys = asObject(root.keys);
237
+ const panels = asObject(keys.panels);
238
+ const theme = asObject(root.theme);
239
+ const status = asObject(theme.status);
240
+ const glyphs = asObject(theme.glyphs);
241
+ const updater = asObject(root.updater);
242
+ const notifications = asObject(root.notifications);
243
+ const restore = asObject(root.restore);
244
+ const updates = asObject(root.updates);
245
+ const welcome = asObject(root.welcome);
246
+ const integrations = asObject(root.integrations);
247
+ const worktrees = asObject(root.worktrees);
248
+ return {
249
+ keys: {
250
+ popup: pickString(keys.popup, D.keys.popup),
251
+ home: pickString(keys.home, D.keys.home),
252
+ cheatsheet: pickString(keys.cheatsheet, D.keys.cheatsheet),
253
+ menu: pickString(keys.menu, D.keys.menu),
254
+ sidebar: pickString(keys.sidebar, D.keys.sidebar),
255
+ panels: {
256
+ explorer: pickString(panels.explorer, D.keys.panels.explorer),
257
+ changes: pickString(panels.changes, D.keys.panels.changes),
258
+ config: pickString(panels.config, D.keys.panels.config),
259
+ },
260
+ },
261
+ theme: {
262
+ accent: pickString(theme.accent, D.theme.accent),
263
+ muted: pickString(theme.muted, D.theme.muted),
264
+ fg: pickString(theme.fg, D.theme.fg),
265
+ status: {
266
+ blocked: pickString(status.blocked, D.theme.status.blocked),
267
+ working: pickString(status.working, D.theme.status.working),
268
+ done: pickString(status.done, D.theme.status.done),
269
+ idle: pickString(status.idle, D.theme.status.idle),
270
+ unknown: pickString(status.unknown, D.theme.status.unknown),
271
+ },
272
+ glyphs: {
273
+ active: pickString(glyphs.active, D.theme.glyphs.active),
274
+ inactive: pickString(glyphs.inactive, D.theme.glyphs.inactive),
275
+ },
276
+ },
277
+ updater: {
278
+ tickMs: pickPosInt(updater.tickMs, D.updater.tickMs),
279
+ snapshotEvery: pickPosInt(updater.snapshotEvery, D.updater.snapshotEvery),
280
+ },
281
+ notifications: {
282
+ toast: pickBool(notifications.toast, D.notifications.toast),
283
+ macos: pickBool(notifications.macos, D.notifications.macos),
284
+ },
285
+ restore: { resumeAgents: pickBool(restore.resumeAgents, D.restore.resumeAgents) },
286
+ updates: { check: pickBool(updates.check, D.updates.check) },
287
+ welcome: { show: pickBool(welcome.show, D.welcome.show) },
288
+ integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
289
+ worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
290
+ };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // io
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * Absolute path to the config file: `TMUX_IDE_CONFIG` when set (tests / per-run
299
+ * overrides), else `~/.tmux-ide/config.json`.
300
+ */
301
+ export function appConfigPath(): string {
302
+ return process.env.TMUX_IDE_CONFIG ?? join(homedir(), ".tmux-ide", "config.json");
303
+ }
304
+
305
+ /**
306
+ * io — read + parse the config. A missing or malformed file resolves to the full
307
+ * defaults ({@link parseAppConfig} never throws). Reads fresh each call; use
308
+ * {@link getAppConfig} for the cached per-process read.
309
+ */
310
+ export function loadAppConfig(): AppConfig {
311
+ const path = appConfigPath();
312
+ if (!existsSync(path)) return parseAppConfig(undefined);
313
+ try {
314
+ return parseAppConfig(JSON.parse(readFileSync(path, "utf-8")));
315
+ } catch {
316
+ // malformed file — full defaults
317
+ return parseAppConfig(undefined);
318
+ }
319
+ }
320
+
321
+ let cached: AppConfig | null = null;
322
+
323
+ /**
324
+ * The process-cached config. Resolved once on first use — config changes take
325
+ * effect on the next process (e.g. a re-adopt spawns fresh CLI invocations), so
326
+ * caching is safe and keeps the hot updater tick from re-reading the file.
327
+ */
328
+ export function getAppConfig(): AppConfig {
329
+ if (!cached) cached = loadAppConfig();
330
+ return cached;
331
+ }
332
+
333
+ /** Drop the cache so the next {@link getAppConfig} re-reads. Test-only. */
334
+ export function _resetForTests(): void {
335
+ cached = null;
336
+ }
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import type { Context, Next, MiddlewareHandler } from "hono";
3
+ import { AuthService } from "./auth-service.ts";
4
+ import type { AuthConfig } from "./types.ts";
5
+
6
+ /**
7
+ * Hono middleware that checks Authorization Bearer header.
8
+ *
9
+ * - Bypasses /health and /api/auth/* routes.
10
+ * - When method is "none" (default), passes through everything (backward compatible).
11
+ * - Otherwise validates the JWT and sets c.set("userId", ...).
12
+ */
13
+ export function authMiddleware(authService: AuthService, config: AuthConfig): MiddlewareHandler {
14
+ return async (c: Context, next: Next) => {
15
+ // Backward compatible: no auth required when method is "none"
16
+ if (config.method === "none") {
17
+ return next();
18
+ }
19
+
20
+ const path = new URL(c.req.url).pathname;
21
+
22
+ // Bypass health and auth endpoints
23
+ if (path === "/health" || path.startsWith("/api/auth/")) {
24
+ return next();
25
+ }
26
+
27
+ const authHeader = c.req.header("Authorization");
28
+ if (!authHeader?.startsWith("Bearer ")) {
29
+ return c.json({ error: "Missing or invalid Authorization header" }, 401);
30
+ }
31
+
32
+ const token = authHeader.slice(7);
33
+ const result = authService.verifyToken(token);
34
+ if (!result.valid) {
35
+ return c.json({ error: "Invalid or expired token" }, 401);
36
+ }
37
+
38
+ c.set("userId", result.userId);
39
+ return next();
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Validate a JWT token from a query parameter (for WebSocket upgrade).
45
+ * Returns the userId if valid, null otherwise.
46
+ */
47
+ export function validateWsToken(
48
+ authService: AuthService,
49
+ config: AuthConfig,
50
+ token: string | null,
51
+ ): string | null {
52
+ if (config.method === "none") return "anonymous";
53
+ if (!token) return null;
54
+ const result = authService.verifyToken(token);
55
+ return result.valid ? (result.userId ?? null) : null;
56
+ }
@@ -0,0 +1,2 @@
1
+ // Schema extracted to @tmux-ide/contracts (T056). Re-export shim.
2
+ export { AuthConfigSchema, type AuthConfig } from "@tmux-ide/contracts";
@@ -0,0 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export function generateAuthToken(): string {
4
+ return randomBytes(32).toString("base64url");
5
+ }