@openchamber/web 1.3.9 → 1.4.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/README.md +1 -0
- package/bin/cli.js +109 -15
- package/dist/assets/{ToolOutputDialog-BrFH3eLi.js → ToolOutputDialog-CBMeCbbW.js} +3 -3
- package/dist/assets/{index-CqiXpckC.js → index-CbGhBA-v.js} +2 -2
- package/dist/assets/index-Cx6NUrC-.css +1 -0
- package/dist/assets/main-BJvFwyGz.js +122 -0
- package/dist/assets/vendor--Jn2c0Clh.css +1 -0
- package/dist/assets/{vendor-.bun-B0k_58z8.js → vendor-.bun-CpaXrlrC.js} +630 -561
- package/dist/assets/{worker-Doc90iwx.js → worker-NULm4lOi.js} +18 -18
- package/dist/index.html +4 -4
- package/package.json +5 -5
- package/server/index.js +92 -32
- package/server/lib/cloudflare-tunnel.js +196 -0
- package/server/lib/git-service.js +120 -13
- package/dist/assets/index-Coco_yFR.css +0 -1
- package/dist/assets/main-DpLFDKL6.js +0 -119
- package/dist/assets/vendor--swUGvSZA.css +0 -1
package/dist/index.html
CHANGED
|
@@ -160,10 +160,10 @@
|
|
|
160
160
|
pointer-events: none;
|
|
161
161
|
}
|
|
162
162
|
</style>
|
|
163
|
-
<script type="module" crossorigin src="/assets/index-
|
|
164
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-.bun-
|
|
165
|
-
<link rel="stylesheet" crossorigin href="/assets/vendor--
|
|
166
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
163
|
+
<script type="module" crossorigin src="/assets/index-CbGhBA-v.js"></script>
|
|
164
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-.bun-CpaXrlrC.js">
|
|
165
|
+
<link rel="stylesheet" crossorigin href="/assets/vendor--Jn2c0Clh.css">
|
|
166
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cx6NUrC-.css">
|
|
167
167
|
</head>
|
|
168
168
|
<body class="h-full bg-background text-foreground">
|
|
169
169
|
<div id="root" class="h-full">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openchamber/web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./server/index.js",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"build:watch": "vite build --watch",
|
|
20
20
|
"type-check": "tsc --noEmit",
|
|
21
21
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
|
|
22
|
-
"start": "node
|
|
22
|
+
"start": "node bin/cli.js serve"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
|
@@ -37,15 +37,15 @@
|
|
|
37
37
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
38
38
|
"@remixicon/react": "^4.7.0",
|
|
39
39
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
40
|
-
"
|
|
41
|
-
"@xterm/xterm": "^5.3.0",
|
|
40
|
+
"ghostty-web": "0.3.0",
|
|
42
41
|
"class-variance-authority": "^0.7.1",
|
|
43
42
|
"clsx": "^2.1.1",
|
|
44
43
|
"cmdk": "^1.1.1",
|
|
45
44
|
"express": "^5.1.0",
|
|
46
45
|
"http-proxy-middleware": "^3.0.5",
|
|
47
46
|
"next-themes": "^0.4.6",
|
|
48
|
-
"
|
|
47
|
+
"bun-pty": "^0.4.5",
|
|
48
|
+
"node-pty": "^1.1.0",
|
|
49
49
|
"react": "^19.1.1",
|
|
50
50
|
"react-dom": "^19.1.1",
|
|
51
51
|
"react-markdown": "^10.1.0",
|
package/server/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import http from 'http';
|
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import os from 'os';
|
|
9
9
|
import { createUiAuth } from './lib/ui-auth.js';
|
|
10
|
+
import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
|
|
10
11
|
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = path.dirname(__filename);
|
|
@@ -578,6 +579,7 @@ let isOpenCodeReady = false;
|
|
|
578
579
|
let openCodeNotReadySince = 0;
|
|
579
580
|
let exitOnShutdown = true;
|
|
580
581
|
let uiAuthController = null;
|
|
582
|
+
let cloudflareTunnelController = null;
|
|
581
583
|
|
|
582
584
|
// Sync helper - call after modifying any HMR state variable
|
|
583
585
|
const syncToHmrState = () => {
|
|
@@ -713,21 +715,18 @@ function resolveBinaryFromPath(binaryName, searchPath) {
|
|
|
713
715
|
}
|
|
714
716
|
|
|
715
717
|
function getOpencodeSpawnConfig() {
|
|
716
|
-
const envPath = buildAugmentedPath();
|
|
717
|
-
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
718
|
-
|
|
719
718
|
if (OPENCODE_BINARY_ENV) {
|
|
720
|
-
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV,
|
|
719
|
+
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH);
|
|
721
720
|
if (explicit) {
|
|
722
721
|
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
|
|
723
|
-
return { command: explicit, env:
|
|
722
|
+
return { command: explicit, env: undefined };
|
|
724
723
|
}
|
|
725
724
|
console.warn(
|
|
726
725
|
`OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
|
|
727
726
|
);
|
|
728
727
|
}
|
|
729
728
|
|
|
730
|
-
return { command: 'opencode', env:
|
|
729
|
+
return { command: 'opencode', env: undefined };
|
|
731
730
|
}
|
|
732
731
|
|
|
733
732
|
const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
|
@@ -1151,7 +1150,8 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
1151
1150
|
process.env.OPENCHAMBER_UI_PASSWORD ||
|
|
1152
1151
|
process.env.OPENCODE_UI_PASSWORD ||
|
|
1153
1152
|
null;
|
|
1154
|
-
const
|
|
1153
|
+
const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true';
|
|
1154
|
+
const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel };
|
|
1155
1155
|
|
|
1156
1156
|
const consumeValue = (currentIndex, inlineValue) => {
|
|
1157
1157
|
if (typeof inlineValue === 'string') {
|
|
@@ -1188,6 +1188,11 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
1188
1188
|
options.uiPassword = typeof value === 'string' ? value : '';
|
|
1189
1189
|
continue;
|
|
1190
1190
|
}
|
|
1191
|
+
|
|
1192
|
+
if (optionName === 'try-cf-tunnel') {
|
|
1193
|
+
options.tryCfTunnel = true;
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1191
1196
|
}
|
|
1192
1197
|
|
|
1193
1198
|
return options;
|
|
@@ -1835,6 +1840,12 @@ async function gracefulShutdown(options = {}) {
|
|
|
1835
1840
|
uiAuthController = null;
|
|
1836
1841
|
}
|
|
1837
1842
|
|
|
1843
|
+
if (cloudflareTunnelController) {
|
|
1844
|
+
console.log('Stopping Cloudflare tunnel...');
|
|
1845
|
+
cloudflareTunnelController.stop();
|
|
1846
|
+
cloudflareTunnelController = null;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1838
1849
|
console.log('Graceful shutdown complete');
|
|
1839
1850
|
if (exitProcess) {
|
|
1840
1851
|
process.exit(0);
|
|
@@ -1843,7 +1854,9 @@ async function gracefulShutdown(options = {}) {
|
|
|
1843
1854
|
|
|
1844
1855
|
async function main(options = {}) {
|
|
1845
1856
|
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
|
1857
|
+
const tryCfTunnel = options.tryCfTunnel === true;
|
|
1846
1858
|
const attachSignals = options.attachSignals !== false;
|
|
1859
|
+
const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null;
|
|
1847
1860
|
if (typeof options.exitOnShutdown === 'boolean') {
|
|
1848
1861
|
exitOnShutdown = options.exitOnShutdown;
|
|
1849
1862
|
}
|
|
@@ -3387,12 +3400,12 @@ async function main(options = {}) {
|
|
|
3387
3400
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|
3388
3401
|
}
|
|
3389
3402
|
|
|
3390
|
-
const { path, branch, createBranch } = req.body;
|
|
3403
|
+
const { path, branch, createBranch, startPoint } = req.body;
|
|
3391
3404
|
if (!path || !branch) {
|
|
3392
3405
|
return res.status(400).json({ error: 'path and branch are required' });
|
|
3393
3406
|
}
|
|
3394
3407
|
|
|
3395
|
-
const result = await addWorktree(directory, path, branch, { createBranch });
|
|
3408
|
+
const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
|
|
3396
3409
|
res.json(result);
|
|
3397
3410
|
} catch (error) {
|
|
3398
3411
|
console.error('Failed to add worktree:', error);
|
|
@@ -3599,7 +3612,9 @@ async function main(options = {}) {
|
|
|
3599
3612
|
const isSymbolicLink = dirent.isSymbolicLink();
|
|
3600
3613
|
|
|
3601
3614
|
if (!isDirectory && isSymbolicLink) {
|
|
3602
|
-
|
|
3615
|
+
|
|
3616
|
+
try {
|
|
3617
|
+
|
|
3603
3618
|
const linkStats = await fsPromises.stat(entryPath);
|
|
3604
3619
|
isDirectory = linkStats.isDirectory();
|
|
3605
3620
|
} catch {
|
|
@@ -3677,23 +3692,39 @@ async function main(options = {}) {
|
|
|
3677
3692
|
}
|
|
3678
3693
|
});
|
|
3679
3694
|
|
|
3680
|
-
let
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
if (ptyLoadError) throw ptyLoadError;
|
|
3685
|
-
|
|
3686
|
-
try {
|
|
3687
|
-
ptyLib = await import('node-pty');
|
|
3688
|
-
console.log('node-pty loaded successfully');
|
|
3689
|
-
return ptyLib;
|
|
3690
|
-
} catch (error) {
|
|
3691
|
-
ptyLoadError = error;
|
|
3692
|
-
console.error('Failed to load node-pty:', error.message);
|
|
3693
|
-
console.error('Terminal functionality will not be available.');
|
|
3694
|
-
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
|
|
3695
|
-
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
|
|
3695
|
+
let ptyProviderPromise = null;
|
|
3696
|
+
const getPtyProvider = async () => {
|
|
3697
|
+
if (ptyProviderPromise) {
|
|
3698
|
+
return ptyProviderPromise;
|
|
3696
3699
|
}
|
|
3700
|
+
|
|
3701
|
+
ptyProviderPromise = (async () => {
|
|
3702
|
+
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
|
3703
|
+
|
|
3704
|
+
if (isBunRuntime) {
|
|
3705
|
+
try {
|
|
3706
|
+
const bunPty = await import('bun-pty');
|
|
3707
|
+
console.log('Using bun-pty for terminal sessions');
|
|
3708
|
+
return { spawn: bunPty.spawn, backend: 'bun-pty' };
|
|
3709
|
+
} catch (error) {
|
|
3710
|
+
console.warn('bun-pty unavailable, falling back to node-pty');
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
try {
|
|
3715
|
+
const nodePty = await import('node-pty');
|
|
3716
|
+
console.log('Using node-pty for terminal sessions');
|
|
3717
|
+
return { spawn: nodePty.spawn, backend: 'node-pty' };
|
|
3718
|
+
} catch (error) {
|
|
3719
|
+
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
|
|
3720
|
+
if (isBunRuntime) {
|
|
3721
|
+
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
|
|
3722
|
+
}
|
|
3723
|
+
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
|
|
3724
|
+
}
|
|
3725
|
+
})();
|
|
3726
|
+
|
|
3727
|
+
return ptyProviderPromise;
|
|
3697
3728
|
};
|
|
3698
3729
|
|
|
3699
3730
|
const terminalSessions = new Map();
|
|
@@ -3730,7 +3761,6 @@ async function main(options = {}) {
|
|
|
3730
3761
|
return res.status(400).json({ error: 'Invalid working directory' });
|
|
3731
3762
|
}
|
|
3732
3763
|
|
|
3733
|
-
const pty = await getPtyLib();
|
|
3734
3764
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
|
3735
3765
|
|
|
3736
3766
|
const sessionId = Math.random().toString(36).substring(2, 15) +
|
|
@@ -3739,6 +3769,7 @@ async function main(options = {}) {
|
|
|
3739
3769
|
const envPath = buildAugmentedPath();
|
|
3740
3770
|
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
3741
3771
|
|
|
3772
|
+
const pty = await getPtyProvider();
|
|
3742
3773
|
const ptyProcess = pty.spawn(shell, [], {
|
|
3743
3774
|
name: 'xterm-256color',
|
|
3744
3775
|
cols: cols || 80,
|
|
@@ -3753,6 +3784,7 @@ async function main(options = {}) {
|
|
|
3753
3784
|
|
|
3754
3785
|
const session = {
|
|
3755
3786
|
ptyProcess,
|
|
3787
|
+
ptyBackend: pty.backend,
|
|
3756
3788
|
cwd,
|
|
3757
3789
|
lastActivity: Date.now(),
|
|
3758
3790
|
clients: new Set(),
|
|
@@ -3786,12 +3818,14 @@ async function main(options = {}) {
|
|
|
3786
3818
|
res.setHeader('Connection', 'keep-alive');
|
|
3787
3819
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
3788
3820
|
|
|
3789
|
-
res.write('data: {"type":"connected"}\n\n');
|
|
3790
|
-
|
|
3791
3821
|
const clientId = Math.random().toString(36).substring(7);
|
|
3792
3822
|
session.clients.add(clientId);
|
|
3793
3823
|
session.lastActivity = Date.now();
|
|
3794
3824
|
|
|
3825
|
+
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
|
3826
|
+
const ptyBackend = session.ptyBackend || 'unknown';
|
|
3827
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
|
|
3828
|
+
|
|
3795
3829
|
const heartbeatInterval = setInterval(() => {
|
|
3796
3830
|
try {
|
|
3797
3831
|
|
|
@@ -3856,7 +3890,7 @@ async function main(options = {}) {
|
|
|
3856
3890
|
req.on('close', cleanup);
|
|
3857
3891
|
req.on('error', cleanup);
|
|
3858
3892
|
|
|
3859
|
-
console.log(`
|
|
3893
|
+
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
|
|
3860
3894
|
});
|
|
3861
3895
|
|
|
3862
3896
|
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|
@@ -3943,7 +3977,6 @@ async function main(options = {}) {
|
|
|
3943
3977
|
return res.status(400).json({ error: 'Invalid working directory' });
|
|
3944
3978
|
}
|
|
3945
3979
|
|
|
3946
|
-
const pty = await getPtyLib();
|
|
3947
3980
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
|
3948
3981
|
|
|
3949
3982
|
const newSessionId = Math.random().toString(36).substring(2, 15) +
|
|
@@ -3952,6 +3985,7 @@ async function main(options = {}) {
|
|
|
3952
3985
|
const envPath = buildAugmentedPath();
|
|
3953
3986
|
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
3954
3987
|
|
|
3988
|
+
const pty = await getPtyProvider();
|
|
3955
3989
|
const ptyProcess = pty.spawn(shell, [], {
|
|
3956
3990
|
name: 'xterm-256color',
|
|
3957
3991
|
cols: cols || 80,
|
|
@@ -3966,6 +4000,7 @@ async function main(options = {}) {
|
|
|
3966
4000
|
|
|
3967
4001
|
const session = {
|
|
3968
4002
|
ptyProcess,
|
|
4003
|
+
ptyBackend: pty.backend,
|
|
3969
4004
|
cwd,
|
|
3970
4005
|
lastActivity: Date.now(),
|
|
3971
4006
|
clients: new Set(),
|
|
@@ -4026,6 +4061,7 @@ async function main(options = {}) {
|
|
|
4026
4061
|
res.json({ success: true, killedCount });
|
|
4027
4062
|
});
|
|
4028
4063
|
|
|
4064
|
+
|
|
4029
4065
|
try {
|
|
4030
4066
|
// Check if we can reuse an existing OpenCode process from a previous HMR cycle
|
|
4031
4067
|
syncFromHmrState();
|
|
@@ -4086,13 +4122,35 @@ async function main(options = {}) {
|
|
|
4086
4122
|
reject(error);
|
|
4087
4123
|
};
|
|
4088
4124
|
server.once('error', onError);
|
|
4089
|
-
server.listen(port, () => {
|
|
4125
|
+
server.listen(port, async () => {
|
|
4090
4126
|
server.off('error', onError);
|
|
4091
4127
|
const addressInfo = server.address();
|
|
4092
4128
|
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
|
4093
4129
|
console.log(`OpenChamber server running on port ${activePort}`);
|
|
4094
4130
|
console.log(`Health check: http://localhost:${activePort}/health`);
|
|
4095
4131
|
console.log(`Web interface: http://localhost:${activePort}`);
|
|
4132
|
+
|
|
4133
|
+
if (tryCfTunnel) {
|
|
4134
|
+
console.log('\nInitializing Cloudflare Quick Tunnel...');
|
|
4135
|
+
const cfCheck = await checkCloudflaredAvailable();
|
|
4136
|
+
if (cfCheck.available) {
|
|
4137
|
+
try {
|
|
4138
|
+
const originUrl = `http://localhost:${activePort}`;
|
|
4139
|
+
cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
|
|
4140
|
+
printTunnelWarning();
|
|
4141
|
+
if (onTunnelReady) {
|
|
4142
|
+
const tunnelUrl = cloudflareTunnelController.getPublicUrl();
|
|
4143
|
+
if (tunnelUrl) {
|
|
4144
|
+
onTunnelReady(tunnelUrl);
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
} catch (error) {
|
|
4148
|
+
console.error(`Failed to start Cloudflare tunnel: ${error.message}`);
|
|
4149
|
+
console.log('Continuing without tunnel...');
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4096
4154
|
resolve();
|
|
4097
4155
|
});
|
|
4098
4156
|
});
|
|
@@ -4119,6 +4177,7 @@ async function main(options = {}) {
|
|
|
4119
4177
|
httpServer: server,
|
|
4120
4178
|
getPort: () => activePort,
|
|
4121
4179
|
getOpenCodePort: () => openCodePort,
|
|
4180
|
+
getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null,
|
|
4122
4181
|
isReady: () => isOpenCodeReady,
|
|
4123
4182
|
restartOpenCode: () => restartOpenCode(),
|
|
4124
4183
|
stop: (shutdownOptions = {}) =>
|
|
@@ -4133,6 +4192,7 @@ if (isCliExecution) {
|
|
|
4133
4192
|
exitOnShutdown = true;
|
|
4134
4193
|
main({
|
|
4135
4194
|
port: cliOptions.port,
|
|
4195
|
+
tryCfTunnel: cliOptions.tryCfTunnel,
|
|
4136
4196
|
attachSignals: true,
|
|
4137
4197
|
exitOnShutdown: true,
|
|
4138
4198
|
uiPassword: cliOptions.uiPassword
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
11
|
+
|
|
12
|
+
async function searchPathFor(command) {
|
|
13
|
+
const pathValue = process.env.PATH || '';
|
|
14
|
+
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
|
15
|
+
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
|
16
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
17
|
+
.split(';')
|
|
18
|
+
.map((ext) => ext.trim().toLowerCase())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
|
21
|
+
: [''];
|
|
22
|
+
|
|
23
|
+
for (const dir of segments) {
|
|
24
|
+
for (const ext of WINDOWS_EXTENSIONS) {
|
|
25
|
+
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
|
26
|
+
const candidate = path.join(dir, fileName);
|
|
27
|
+
try {
|
|
28
|
+
const stats = fs.statSync(candidate);
|
|
29
|
+
if (stats.isFile()) {
|
|
30
|
+
if (process.platform !== 'win32') {
|
|
31
|
+
try {
|
|
32
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function checkCloudflaredAvailable() {
|
|
48
|
+
const cfPath = await searchPathFor('cloudflared');
|
|
49
|
+
if (cfPath) {
|
|
50
|
+
try {
|
|
51
|
+
const result = spawnSync(cfPath, ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
52
|
+
if (result.status === 0) {
|
|
53
|
+
return { available: true, path: cfPath, version: result.stdout.trim() };
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { available: false, path: null, version: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function printCloudflareTunnelInstallHelp() {
|
|
63
|
+
const platform = process.platform;
|
|
64
|
+
let installCmd = '';
|
|
65
|
+
|
|
66
|
+
if (platform === 'darwin') {
|
|
67
|
+
installCmd = 'brew install cloudflared';
|
|
68
|
+
} else if (platform === 'win32') {
|
|
69
|
+
installCmd = 'winget install --id Cloudflare.cloudflared';
|
|
70
|
+
} else {
|
|
71
|
+
installCmd = 'Download from https://github.com/cloudflare/cloudflared/releases';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`
|
|
75
|
+
╔══════════════════════════════════════════════════════════════════╗
|
|
76
|
+
║ Cloudflare tunnel requires 'cloudflared' to be installed ║
|
|
77
|
+
╚══════════════════════════════════════════════════════════════════╝
|
|
78
|
+
|
|
79
|
+
Install instructions for your platform:
|
|
80
|
+
|
|
81
|
+
macOS: brew install cloudflared
|
|
82
|
+
Windows: winget install --id Cloudflare.cloudflared
|
|
83
|
+
Linux: Download from https://github.com/cloudflare/cloudflared/releases
|
|
84
|
+
|
|
85
|
+
Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startCloudflareTunnel({ originUrl, port }) {
|
|
90
|
+
const cfCheck = await checkCloudflaredAvailable();
|
|
91
|
+
|
|
92
|
+
if (!cfCheck.available) {
|
|
93
|
+
printCloudflareTunnelInstallHelp();
|
|
94
|
+
throw new Error('cloudflared is not installed');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(`Using cloudflared: ${cfCheck.path} (${cfCheck.version})`);
|
|
98
|
+
|
|
99
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-'));
|
|
100
|
+
|
|
101
|
+
const child = spawn('cloudflared', ['tunnel', '--url', originUrl], {
|
|
102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
103
|
+
env: {
|
|
104
|
+
...process.env,
|
|
105
|
+
HOME: tempDir,
|
|
106
|
+
CF_TELEMETRY_DISABLE: '1',
|
|
107
|
+
},
|
|
108
|
+
killSignal: 'SIGINT',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let publicUrl = null;
|
|
112
|
+
let tunnelReady = false;
|
|
113
|
+
|
|
114
|
+
const onData = (chunk, isStderr) => {
|
|
115
|
+
const text = chunk.toString('utf8');
|
|
116
|
+
|
|
117
|
+
if (!tunnelReady) {
|
|
118
|
+
const match = text.match(TRY_CF_URL_REGEX);
|
|
119
|
+
if (match) {
|
|
120
|
+
publicUrl = match[0];
|
|
121
|
+
tunnelReady = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.stderr.write(isStderr ? text : '');
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
child.stdout.on('data', (chunk) => onData(chunk, false));
|
|
129
|
+
child.stderr.on('data', (chunk) => onData(chunk, true));
|
|
130
|
+
|
|
131
|
+
child.on('error', (error) => {
|
|
132
|
+
console.error(`Cloudflared error: ${error.message}`);
|
|
133
|
+
cleanupTempDir();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const cleanupTempDir = () => {
|
|
137
|
+
try {
|
|
138
|
+
if (fs.existsSync(tempDir)) {
|
|
139
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore cleanup errors
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
const timeout = setTimeout(() => {
|
|
148
|
+
if (!publicUrl) {
|
|
149
|
+
reject(new Error('Tunnel URL not received within 30 seconds'));
|
|
150
|
+
}
|
|
151
|
+
}, 30000);
|
|
152
|
+
|
|
153
|
+
const checkReady = setInterval(() => {
|
|
154
|
+
if (publicUrl) {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
clearInterval(checkReady);
|
|
157
|
+
resolve(null);
|
|
158
|
+
}
|
|
159
|
+
}, 100);
|
|
160
|
+
|
|
161
|
+
child.on('exit', (code) => {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
clearInterval(checkReady);
|
|
164
|
+
cleanupTempDir();
|
|
165
|
+
if (code !== null && code !== 0) {
|
|
166
|
+
reject(new Error(`Cloudflared exited with code ${code}`));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
stop: () => {
|
|
173
|
+
try {
|
|
174
|
+
child.kill('SIGINT');
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
process: child,
|
|
180
|
+
getPublicUrl: () => publicUrl,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function printTunnelWarning() {
|
|
185
|
+
console.log(`
|
|
186
|
+
⚠️ Cloudflare Quick Tunnel Limitations:
|
|
187
|
+
|
|
188
|
+
• Maximum 200 concurrent requests
|
|
189
|
+
• Server-Sent Events (SSE) are NOT supported
|
|
190
|
+
• URLs are temporary and will expire when the tunnel stops
|
|
191
|
+
• Password protection is required for tunnel access
|
|
192
|
+
|
|
193
|
+
For production use, set up a named Cloudflare Tunnel:
|
|
194
|
+
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
|
|
195
|
+
`);
|
|
196
|
+
}
|