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.
- package/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- 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
|
+
}
|