@wangyaoshen/remux 0.3.8-dev.29e114b
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/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- package/vitest.config.js +10 -0
package/start.ps1
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env pwsh
|
|
2
|
+
# Start remux.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# .\start.ps1 Dev mode (hot reload, no tunnel)
|
|
6
|
+
# .\start.ps1 --prod Production (build + run with tunnel)
|
|
7
|
+
# .\start.ps1 --prod --password mypass Production with custom password
|
|
8
|
+
# .\start.ps1 --dev --tunnel Dev mode with tunnel enabled
|
|
9
|
+
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
Set-Location $PSScriptRoot
|
|
12
|
+
|
|
13
|
+
# Parse our flags, pass the rest through to the CLI.
|
|
14
|
+
$isProd = $false
|
|
15
|
+
$passthrough = @()
|
|
16
|
+
|
|
17
|
+
for ($i = 0; $i -lt $args.Count; $i++) {
|
|
18
|
+
if ($args[$i] -eq "--prod") {
|
|
19
|
+
$isProd = $true
|
|
20
|
+
} elseif ($args[$i] -eq "--dev") {
|
|
21
|
+
# explicit dev mode (default anyway)
|
|
22
|
+
} else {
|
|
23
|
+
$passthrough += $args[$i]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if ($isProd) {
|
|
28
|
+
# Production mode: always rebuild to ensure dist/ is current.
|
|
29
|
+
Write-Host "Building remux..." -ForegroundColor Yellow
|
|
30
|
+
npm run build
|
|
31
|
+
# Use a stable token so the URL doesn't change on restart.
|
|
32
|
+
if (-not $env:REMUX_TOKEN) {
|
|
33
|
+
$tokenFile = Join-Path (Join-Path $env:USERPROFILE ".remux") "token"
|
|
34
|
+
if (Test-Path $tokenFile) {
|
|
35
|
+
$env:REMUX_TOKEN = (Get-Content $tokenFile -Raw).Trim()
|
|
36
|
+
} else {
|
|
37
|
+
$env:REMUX_TOKEN = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ })
|
|
38
|
+
New-Item -ItemType Directory -Path (Split-Path $tokenFile) -Force | Out-Null
|
|
39
|
+
Set-Content $tokenFile $env:REMUX_TOKEN
|
|
40
|
+
}
|
|
41
|
+
Write-Host " Stable token saved to $tokenFile" -ForegroundColor DarkGray
|
|
42
|
+
}
|
|
43
|
+
Write-Host "Starting remux (production)..." -ForegroundColor Cyan
|
|
44
|
+
node dist/backend/cli-zellij.js @passthrough
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Dev mode: tsx watch (hot reload) + Vite frontend.
|
|
49
|
+
# Default: no tunnel, no password. Override with explicit flags.
|
|
50
|
+
$devArgs = @("--no-require-password")
|
|
51
|
+
|
|
52
|
+
# Only add --no-tunnel if user didn't explicitly pass --tunnel.
|
|
53
|
+
$hasTunnel = $passthrough -contains "--tunnel"
|
|
54
|
+
if (-not $hasTunnel) {
|
|
55
|
+
$devArgs += "--no-tunnel"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
$devArgs += $passthrough
|
|
59
|
+
|
|
60
|
+
Write-Host ""
|
|
61
|
+
Write-Host "Starting remux dev mode..." -ForegroundColor Cyan
|
|
62
|
+
Write-Host " Backend: tsx watch (auto-restart on changes)" -ForegroundColor DarkGray
|
|
63
|
+
Write-Host " Frontend: Vite HMR on http://localhost:5173" -ForegroundColor DarkGray
|
|
64
|
+
Write-Host ""
|
|
65
|
+
|
|
66
|
+
# Start Vite in background.
|
|
67
|
+
$viteJob = Start-Process -FilePath "node" -ArgumentList "node_modules/vite/bin/vite.js","--config","vite.config.ts" `
|
|
68
|
+
-NoNewWindow -PassThru -RedirectStandardOutput "$env:TEMP\remux-vite.log" -RedirectStandardError "$env:TEMP\remux-vite-err.log"
|
|
69
|
+
|
|
70
|
+
Write-Host "[vite] started (pid=$($viteJob.Id))" -ForegroundColor Green
|
|
71
|
+
Start-Sleep 2
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
Write-Host "[back] starting..." -ForegroundColor Blue
|
|
75
|
+
$env:VITE_DEV_MODE = "1"
|
|
76
|
+
npx tsx watch src/backend/cli-zellij.ts @devArgs
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (-not $viteJob.HasExited) {
|
|
80
|
+
Stop-Process -Id $viteJob.Id -Force -ErrorAction SilentlyContinue
|
|
81
|
+
Write-Host "[vite] stopped" -ForegroundColor Green
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Test the adapter framework (E10)
|
|
4
|
+
// Since adapters are ES modules in the bundle, test the logic patterns
|
|
5
|
+
|
|
6
|
+
describe("AdapterRegistry pattern", () => {
|
|
7
|
+
it("should register and query adapters", () => {
|
|
8
|
+
// Simulate registry behavior
|
|
9
|
+
const adapters = new Map();
|
|
10
|
+
|
|
11
|
+
const genericShell = {
|
|
12
|
+
id: "generic-shell",
|
|
13
|
+
name: "Shell",
|
|
14
|
+
mode: "passive",
|
|
15
|
+
capabilities: ["cwd", "last-command"],
|
|
16
|
+
getCurrentState: () => ({
|
|
17
|
+
adapterId: "generic-shell",
|
|
18
|
+
name: "Shell",
|
|
19
|
+
mode: "passive",
|
|
20
|
+
capabilities: ["cwd", "last-command"],
|
|
21
|
+
currentState: "idle",
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
adapters.set(genericShell.id, genericShell);
|
|
26
|
+
expect(adapters.get("generic-shell")).toBeDefined();
|
|
27
|
+
expect(adapters.get("generic-shell").name).toBe("Shell");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should emit events to listeners", () => {
|
|
31
|
+
const listeners = [];
|
|
32
|
+
const events = [];
|
|
33
|
+
|
|
34
|
+
listeners.push((event) => events.push(event));
|
|
35
|
+
|
|
36
|
+
// Simulate emit
|
|
37
|
+
const event = {
|
|
38
|
+
type: "state_change",
|
|
39
|
+
seq: 1,
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
data: { state: "running" },
|
|
42
|
+
adapterId: "claude-code",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
for (const listener of listeners) {
|
|
46
|
+
listener(event);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
expect(events.length).toBe(1);
|
|
50
|
+
expect(events[0].adapterId).toBe("claude-code");
|
|
51
|
+
expect(events[0].data.state).toBe("running");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should handle adapter errors without crashing", () => {
|
|
55
|
+
const errorAdapter = {
|
|
56
|
+
id: "broken",
|
|
57
|
+
mode: "passive",
|
|
58
|
+
onTerminalData: () => {
|
|
59
|
+
throw new Error("adapter crash");
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Should not throw
|
|
64
|
+
expect(() => {
|
|
65
|
+
try {
|
|
66
|
+
errorAdapter.onTerminalData("test", "data");
|
|
67
|
+
} catch {
|
|
68
|
+
// Registry catches this
|
|
69
|
+
}
|
|
70
|
+
}).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("OSC notification parsing", () => {
|
|
75
|
+
it("should parse OSC 9 notifications", () => {
|
|
76
|
+
const data = 'before\x1b]9;Build complete\x07after';
|
|
77
|
+
const osc9Re = /\x1b\]9;([^\x07\x1b]+)[\x07]/;
|
|
78
|
+
const match = data.match(osc9Re);
|
|
79
|
+
expect(match).not.toBeNull();
|
|
80
|
+
expect(match[1]).toBe("Build complete");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should parse OSC 777 notifications", () => {
|
|
84
|
+
const data = '\x1b]777;notify;Build;All tests passed\x07';
|
|
85
|
+
const osc777Re = /\x1b\]777;notify;([^;]*);([^\x07\x1b]*)[\x07]/;
|
|
86
|
+
const match = data.match(osc777Re);
|
|
87
|
+
expect(match).not.toBeNull();
|
|
88
|
+
expect(match[1]).toBe("Build");
|
|
89
|
+
expect(match[2]).toBe("All tests passed");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should handle data without OSC sequences", () => {
|
|
93
|
+
const data = "normal terminal output";
|
|
94
|
+
const osc9Re = /\x1b\]9;([^\x07\x1b]+)[\x07]/;
|
|
95
|
+
expect(data.match(osc9Re)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("Generic shell adapter patterns", () => {
|
|
100
|
+
it("should detect OSC 7 working directory", () => {
|
|
101
|
+
const data = "\x1b]7;file://hostname/Users/test\x07";
|
|
102
|
+
const osc7Re = /\x1b\]7;file:\/\/[^/]*([^\x07\x1b]+)/;
|
|
103
|
+
const match = data.match(osc7Re);
|
|
104
|
+
expect(match).not.toBeNull();
|
|
105
|
+
expect(decodeURIComponent(match[1])).toBe("/Users/test");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should detect OSC 133 command boundaries", () => {
|
|
109
|
+
const promptStart = "\x1b]133;A\x07";
|
|
110
|
+
const commandStart = "\x1b]133;B\x07";
|
|
111
|
+
const outputStart = "\x1b]133;C\x07";
|
|
112
|
+
const commandEnd = "\x1b]133;D;0\x07";
|
|
113
|
+
|
|
114
|
+
expect(commandStart.match(/\x1b\]133;B\x07/)).not.toBeNull();
|
|
115
|
+
expect(commandEnd.match(/\x1b\]133;D;?(\d*)\x07/)).not.toBeNull();
|
|
116
|
+
expect(commandEnd.match(/\x1b\]133;D;?(\d*)\x07/)[1]).toBe("0");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("Git service patterns", () => {
|
|
121
|
+
it("should parse worktree porcelain output", () => {
|
|
122
|
+
const output = `worktree /Users/test/remux
|
|
123
|
+
HEAD abc1234
|
|
124
|
+
branch refs/heads/main
|
|
125
|
+
|
|
126
|
+
worktree /Users/test/remux/.worktrees/feature
|
|
127
|
+
HEAD def5678
|
|
128
|
+
branch refs/heads/feat/new
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const worktrees = [];
|
|
132
|
+
let current = {};
|
|
133
|
+
|
|
134
|
+
for (const line of output.split("\n")) {
|
|
135
|
+
if (line.startsWith("worktree ")) {
|
|
136
|
+
if (current.path) worktrees.push({ ...current });
|
|
137
|
+
current = { path: line.replace("worktree ", "") };
|
|
138
|
+
} else if (line.startsWith("HEAD ")) {
|
|
139
|
+
current.head = line.replace("HEAD ", "").substring(0, 7);
|
|
140
|
+
} else if (line.startsWith("branch ")) {
|
|
141
|
+
current.branch = line.replace("branch refs/heads/", "");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (current.path) worktrees.push({ ...current });
|
|
145
|
+
|
|
146
|
+
expect(worktrees.length).toBe(2);
|
|
147
|
+
expect(worktrees[0].branch).toBe("main");
|
|
148
|
+
expect(worktrees[1].branch).toBe("feat/new");
|
|
149
|
+
expect(worktrees[1].head).toBe("def5678");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("Team mode patterns", () => {
|
|
154
|
+
it("should validate RBAC permissions", () => {
|
|
155
|
+
const ROLE_PERMISSIONS = {
|
|
156
|
+
owner: ["read", "write", "admin", "approve"],
|
|
157
|
+
admin: ["read", "write", "admin", "approve"],
|
|
158
|
+
member: ["read", "write", "approve"],
|
|
159
|
+
viewer: ["read"],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const hasPermission = (role, perm) =>
|
|
163
|
+
ROLE_PERMISSIONS[role]?.includes(perm) ?? false;
|
|
164
|
+
|
|
165
|
+
expect(hasPermission("owner", "admin")).toBe(true);
|
|
166
|
+
expect(hasPermission("viewer", "write")).toBe(false);
|
|
167
|
+
expect(hasPermission("member", "read")).toBe(true);
|
|
168
|
+
expect(hasPermission("member", "admin")).toBe(false);
|
|
169
|
+
expect(hasPermission("unknown", "read")).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth tests for Remux server.
|
|
3
|
+
* Tests: auto-generated token, password authentication, token validation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import http from "http";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const SERVER_JS = path.join(__dirname, "..", "server.js");
|
|
15
|
+
|
|
16
|
+
function httpGet(port, urlPath) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
http
|
|
19
|
+
.get(`http://localhost:${port}${urlPath}`, (res) => {
|
|
20
|
+
let body = "";
|
|
21
|
+
res.on("data", (d) => (body += d));
|
|
22
|
+
res.on("end", () =>
|
|
23
|
+
resolve({ status: res.statusCode, body, headers: res.headers }),
|
|
24
|
+
);
|
|
25
|
+
})
|
|
26
|
+
.on("error", reject);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function httpPost(port, urlPath, data) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const body = new URLSearchParams(data).toString();
|
|
33
|
+
const req = http.request(
|
|
34
|
+
{
|
|
35
|
+
hostname: "localhost",
|
|
36
|
+
port,
|
|
37
|
+
path: urlPath,
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
41
|
+
"Content-Length": Buffer.byteLength(body),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
(res) => {
|
|
45
|
+
let resBody = "";
|
|
46
|
+
res.on("data", (d) => (resBody += d));
|
|
47
|
+
res.on("end", () =>
|
|
48
|
+
resolve({ status: res.statusCode, body: resBody, headers: res.headers }),
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
req.on("error", reject);
|
|
53
|
+
req.write(body);
|
|
54
|
+
req.end();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function connectWs(port) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const ws = new WebSocket(`ws://localhost:${port}/ws`);
|
|
61
|
+
ws.on("open", () => resolve(ws));
|
|
62
|
+
ws.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Unwrap envelope if present. */
|
|
67
|
+
function unwrap(parsed) {
|
|
68
|
+
if (parsed && parsed.v === 1 && typeof parsed.type === "string") {
|
|
69
|
+
return { type: parsed.type, ...(parsed.payload || {}) };
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function waitForMsg(ws, type, timeout = 3000) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
ws.removeListener("message", handler);
|
|
78
|
+
reject(new Error(`timeout waiting for ${type}`));
|
|
79
|
+
}, timeout);
|
|
80
|
+
const handler = (raw) => {
|
|
81
|
+
try {
|
|
82
|
+
const msg = unwrap(JSON.parse(raw.toString()));
|
|
83
|
+
if (msg.type === type) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
ws.removeListener("message", handler);
|
|
86
|
+
resolve(msg);
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
};
|
|
90
|
+
ws.on("message", handler);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Start a server instance and wait for "Remux running" output.
|
|
95
|
+
* Returns { proc, port, stdout } where stdout contains all console output.
|
|
96
|
+
* Explicitly removes REMUX_TOKEN and REMUX_PASSWORD from parent env to avoid leaking. */
|
|
97
|
+
function startServer(env, port) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
let stdout = "";
|
|
100
|
+
const cleanEnv = { ...process.env };
|
|
101
|
+
delete cleanEnv.REMUX_TOKEN;
|
|
102
|
+
delete cleanEnv.REMUX_PASSWORD;
|
|
103
|
+
const proc = spawn("node", [SERVER_JS], {
|
|
104
|
+
env: { ...cleanEnv, ...env, PORT: String(port) },
|
|
105
|
+
stdio: "pipe",
|
|
106
|
+
});
|
|
107
|
+
const timeout = setTimeout(
|
|
108
|
+
() => reject(new Error("server start timeout")),
|
|
109
|
+
10000,
|
|
110
|
+
);
|
|
111
|
+
proc.stdout.on("data", (d) => {
|
|
112
|
+
stdout += d.toString();
|
|
113
|
+
if (stdout.includes("Remux running")) {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
// Extra delay for WASM init
|
|
116
|
+
setTimeout(() => resolve({ proc, port, stdout }), 2000);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
proc.stderr.on("data", (d) => {
|
|
120
|
+
stdout += d.toString();
|
|
121
|
+
});
|
|
122
|
+
proc.on("error", reject);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Auto-generated token ─────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("auto-generated token (no REMUX_TOKEN, no REMUX_PASSWORD)", () => {
|
|
129
|
+
let server;
|
|
130
|
+
|
|
131
|
+
beforeAll(async () => {
|
|
132
|
+
server = await startServer(
|
|
133
|
+
{ REMUX_INSTANCE_ID: "auth-test-auto-" + Date.now() },
|
|
134
|
+
19877,
|
|
135
|
+
);
|
|
136
|
+
}, 15000);
|
|
137
|
+
|
|
138
|
+
afterAll(() => server?.proc?.kill("SIGTERM"));
|
|
139
|
+
|
|
140
|
+
it("prints full URL with auto-generated token on startup", () => {
|
|
141
|
+
expect(server.stdout).toMatch(/http:\/\/localhost:\d+\?token=[a-f0-9]{32}/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects access without token", async () => {
|
|
145
|
+
const res = await httpGet(server.port, "/");
|
|
146
|
+
expect(res.status).toBe(403);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts access with auto-generated token from startup output", async () => {
|
|
150
|
+
const match = server.stdout.match(/token=([a-f0-9]{32})/);
|
|
151
|
+
expect(match).not.toBeNull();
|
|
152
|
+
const token = match[1];
|
|
153
|
+
|
|
154
|
+
const res = await httpGet(server.port, `/?token=${token}`);
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
expect(res.body).toContain("<title>Remux</title>");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("authenticates WebSocket with auto-generated token", async () => {
|
|
160
|
+
const match = server.stdout.match(/token=([a-f0-9]{32})/);
|
|
161
|
+
const token = match[1];
|
|
162
|
+
|
|
163
|
+
const ws = await connectWs(server.port);
|
|
164
|
+
ws.send(JSON.stringify({ type: "auth", token }));
|
|
165
|
+
const msg = await waitForMsg(ws, "auth_ok");
|
|
166
|
+
expect(msg.type).toBe("auth_ok");
|
|
167
|
+
ws.close();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── Password authentication ──────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("password authentication (REMUX_PASSWORD set)", () => {
|
|
174
|
+
let server;
|
|
175
|
+
const TEST_PASSWORD = "test-secret-pw-" + Date.now();
|
|
176
|
+
|
|
177
|
+
beforeAll(async () => {
|
|
178
|
+
server = await startServer(
|
|
179
|
+
{
|
|
180
|
+
REMUX_PASSWORD: TEST_PASSWORD,
|
|
181
|
+
REMUX_INSTANCE_ID: "auth-test-pw-" + Date.now(),
|
|
182
|
+
},
|
|
183
|
+
19878,
|
|
184
|
+
);
|
|
185
|
+
}, 15000);
|
|
186
|
+
|
|
187
|
+
afterAll(() => server?.proc?.kill("SIGTERM"));
|
|
188
|
+
|
|
189
|
+
it("shows password page when accessing root without token", async () => {
|
|
190
|
+
const res = await httpGet(server.port, "/");
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
expect(res.body).toContain("Remux — Login");
|
|
193
|
+
expect(res.body).toContain('type="password"');
|
|
194
|
+
expect(res.body).toContain('action="/auth"');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("rejects wrong password and redirects with error", async () => {
|
|
198
|
+
const res = await httpPost(server.port, "/auth", {
|
|
199
|
+
password: "wrong-password",
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(302);
|
|
202
|
+
expect(res.headers.location).toContain("error=1");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("correct password generates working token and redirects", async () => {
|
|
206
|
+
const res = await httpPost(server.port, "/auth", {
|
|
207
|
+
password: TEST_PASSWORD,
|
|
208
|
+
});
|
|
209
|
+
expect(res.status).toBe(302);
|
|
210
|
+
expect(res.headers.location).toMatch(/\?token=[a-f0-9]{32}/);
|
|
211
|
+
|
|
212
|
+
// Follow the redirect — the token should work
|
|
213
|
+
const tokenMatch = res.headers.location.match(/token=([a-f0-9]{32})/);
|
|
214
|
+
const token = tokenMatch[1];
|
|
215
|
+
|
|
216
|
+
const pageRes = await httpGet(server.port, `/?token=${token}`);
|
|
217
|
+
expect(pageRes.status).toBe(200);
|
|
218
|
+
expect(pageRes.body).toContain("<title>Remux</title>");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("password-generated token works for WebSocket auth", async () => {
|
|
222
|
+
// First get a token via password
|
|
223
|
+
const res = await httpPost(server.port, "/auth", {
|
|
224
|
+
password: TEST_PASSWORD,
|
|
225
|
+
});
|
|
226
|
+
const tokenMatch = res.headers.location.match(/token=([a-f0-9]{32})/);
|
|
227
|
+
const token = tokenMatch[1];
|
|
228
|
+
|
|
229
|
+
const ws = await connectWs(server.port);
|
|
230
|
+
ws.send(JSON.stringify({ type: "auth", token }));
|
|
231
|
+
const msg = await waitForMsg(ws, "auth_ok");
|
|
232
|
+
expect(msg.type).toBe("auth_ok");
|
|
233
|
+
ws.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects WebSocket auth with invalid token", async () => {
|
|
237
|
+
const ws = await connectWs(server.port);
|
|
238
|
+
ws.send(JSON.stringify({ type: "auth", token: "invalid-token" }));
|
|
239
|
+
const msg = await waitForMsg(ws, "auth_error");
|
|
240
|
+
expect(msg.reason).toBe("invalid token");
|
|
241
|
+
ws.close();
|
|
242
|
+
});
|
|
243
|
+
});
|