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.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- 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/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -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 +427 -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/tui-binary.ts +165 -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 +382 -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 +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -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 +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -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 +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -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/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- 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
|
+
}
|