@openchamber/web 1.3.9 → 1.4.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/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-CqiXpckC.js"></script>
164
- <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-B0k_58z8.js">
163
+ <script type="module" crossorigin src="/assets/index-DbT28rmj.js"></script>
164
+ <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-BhxSBVb2.js">
165
165
  <link rel="stylesheet" crossorigin href="/assets/vendor--swUGvSZA.css">
166
- <link rel="stylesheet" crossorigin href="/assets/index-Coco_yFR.css">
166
+ <link rel="stylesheet" crossorigin href="/assets/index-BK6z1XIy.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.9",
3
+ "version": "1.4.0",
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 server/index.js"
22
+ "start": "node bin/cli.js serve"
23
23
  },
24
24
  "dependencies": {
25
25
  "@fontsource/ibm-plex-mono": "^5.2.7",
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, envPath);
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: resolvedEnv };
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: resolvedEnv };
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 options = { port: DEFAULT_PORT, uiPassword: envPassword };
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
- try {
3615
+
3616
+ try {
3617
+
3603
3618
  const linkStats = await fsPromises.stat(entryPath);
3604
3619
  isDirectory = linkStats.isDirectory();
3605
3620
  } catch {
@@ -4026,6 +4041,7 @@ async function main(options = {}) {
4026
4041
  res.json({ success: true, killedCount });
4027
4042
  });
4028
4043
 
4044
+
4029
4045
  try {
4030
4046
  // Check if we can reuse an existing OpenCode process from a previous HMR cycle
4031
4047
  syncFromHmrState();
@@ -4086,13 +4102,35 @@ async function main(options = {}) {
4086
4102
  reject(error);
4087
4103
  };
4088
4104
  server.once('error', onError);
4089
- server.listen(port, () => {
4105
+ server.listen(port, async () => {
4090
4106
  server.off('error', onError);
4091
4107
  const addressInfo = server.address();
4092
4108
  activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
4093
4109
  console.log(`OpenChamber server running on port ${activePort}`);
4094
4110
  console.log(`Health check: http://localhost:${activePort}/health`);
4095
4111
  console.log(`Web interface: http://localhost:${activePort}`);
4112
+
4113
+ if (tryCfTunnel) {
4114
+ console.log('\nInitializing Cloudflare Quick Tunnel...');
4115
+ const cfCheck = await checkCloudflaredAvailable();
4116
+ if (cfCheck.available) {
4117
+ try {
4118
+ const originUrl = `http://localhost:${activePort}`;
4119
+ cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
4120
+ printTunnelWarning();
4121
+ if (onTunnelReady) {
4122
+ const tunnelUrl = cloudflareTunnelController.getPublicUrl();
4123
+ if (tunnelUrl) {
4124
+ onTunnelReady(tunnelUrl);
4125
+ }
4126
+ }
4127
+ } catch (error) {
4128
+ console.error(`Failed to start Cloudflare tunnel: ${error.message}`);
4129
+ console.log('Continuing without tunnel...');
4130
+ }
4131
+ }
4132
+ }
4133
+
4096
4134
  resolve();
4097
4135
  });
4098
4136
  });
@@ -4119,6 +4157,7 @@ async function main(options = {}) {
4119
4157
  httpServer: server,
4120
4158
  getPort: () => activePort,
4121
4159
  getOpenCodePort: () => openCodePort,
4160
+ getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null,
4122
4161
  isReady: () => isOpenCodeReady,
4123
4162
  restartOpenCode: () => restartOpenCode(),
4124
4163
  stop: (shutdownOptions = {}) =>
@@ -4133,6 +4172,7 @@ if (isCliExecution) {
4133
4172
  exitOnShutdown = true;
4134
4173
  main({
4135
4174
  port: cliOptions.port,
4175
+ tryCfTunnel: cliOptions.tryCfTunnel,
4136
4176
  attachSignals: true,
4137
4177
  exitOnShutdown: true,
4138
4178
  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
+ }
@@ -271,15 +271,62 @@ export async function getStatus(directory) {
271
271
  };
272
272
  }
273
273
 
274
+ const selectBaseRefForUnpublished = async () => {
275
+ const candidates = [];
276
+
277
+ const originHead = await git
278
+ .raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'])
279
+ .then((value) => String(value || '').trim())
280
+ .catch(() => '');
281
+
282
+ if (originHead) {
283
+ // "refs/remotes/origin/main" -> "origin/main"
284
+ candidates.push(originHead.replace(/^refs\/remotes\//, ''));
285
+ }
286
+
287
+ candidates.push('origin/main', 'origin/master', 'main', 'master');
288
+
289
+ for (const ref of candidates) {
290
+ const exists = await git
291
+ .raw(['rev-parse', '--verify', ref])
292
+ .then((value) => String(value || '').trim())
293
+ .catch(() => '');
294
+ if (exists) return ref;
295
+ }
296
+
297
+ return null;
298
+ };
299
+
300
+ let tracking = status.tracking || null;
301
+ let ahead = status.ahead;
302
+ let behind = status.behind;
303
+
304
+ // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
305
+ // We still want to show the number of unpublished commits to the user.
306
+ if (!tracking && status.current) {
307
+ const baseRef = await selectBaseRefForUnpublished();
308
+ if (baseRef) {
309
+ const countRaw = await git
310
+ .raw(['rev-list', '--count', `${baseRef}..HEAD`])
311
+ .then((value) => String(value || '').trim())
312
+ .catch(() => '');
313
+ const count = parseInt(countRaw, 10);
314
+ if (Number.isFinite(count)) {
315
+ ahead = count;
316
+ behind = 0;
317
+ }
318
+ }
319
+ }
320
+
274
321
  return {
275
322
  current: status.current,
276
- tracking: status.tracking,
277
- ahead: status.ahead,
278
- behind: status.behind,
279
- files: status.files.map(f => ({
323
+ tracking,
324
+ ahead,
325
+ behind,
326
+ files: status.files.map((f) => ({
280
327
  path: f.path,
281
328
  index: f.index,
282
- working_dir: f.working_dir
329
+ working_dir: f.working_dir,
283
330
  })),
284
331
  isClean: status.isClean(),
285
332
  diffStats,
@@ -512,22 +559,79 @@ export async function pull(directory, options = {}) {
512
559
  export async function push(directory, options = {}) {
513
560
  const git = simpleGit(normalizeDirectoryPath(directory));
514
561
 
515
- try {
516
- const result = await git.push(
517
- options.remote || 'origin',
518
- options.branch,
519
- options.options || {}
562
+ const buildUpstreamOptions = (raw) => {
563
+ if (Array.isArray(raw)) {
564
+ return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
565
+ }
566
+
567
+ if (raw && typeof raw === 'object') {
568
+ return { ...raw, '--set-upstream': null };
569
+ }
570
+
571
+ return ['--set-upstream'];
572
+ };
573
+
574
+ const looksLikeMissingUpstream = (error) => {
575
+ const message = String(error?.message || error?.stderr || '').toLowerCase();
576
+ return (
577
+ message.includes('has no upstream') ||
578
+ message.includes('no upstream') ||
579
+ message.includes('set-upstream') ||
580
+ message.includes('set upstream') ||
581
+ (message.includes('upstream') && message.includes('push') && message.includes('-u'))
520
582
  );
583
+ };
521
584
 
585
+ const normalizePushResult = (result) => {
522
586
  return {
523
587
  success: true,
524
588
  pushed: result.pushed,
525
589
  repo: result.repo,
526
- ref: result.ref
590
+ ref: result.ref,
527
591
  };
592
+ };
593
+
594
+ const remote = options.remote || 'origin';
595
+
596
+ // If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
597
+ // When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
598
+ if (!options.branch) {
599
+ try {
600
+ const status = await git.status();
601
+ if (status.current && !status.tracking) {
602
+ const result = await git.push(remote, status.current, buildUpstreamOptions(options.options));
603
+ return normalizePushResult(result);
604
+ }
605
+ } catch (error) {
606
+ // If we can't read status, fall back to the regular push path below.
607
+ console.warn('Failed to read git status before push:', error);
608
+ }
609
+ }
610
+
611
+ try {
612
+ const result = await git.push(remote, options.branch, options.options || {});
613
+ return normalizePushResult(result);
528
614
  } catch (error) {
529
- console.error('Failed to push:', error);
530
- throw error;
615
+ // Last-resort fallback: retry with upstream if the error suggests it's missing.
616
+ if (!looksLikeMissingUpstream(error)) {
617
+ console.error('Failed to push:', error);
618
+ throw error;
619
+ }
620
+
621
+ try {
622
+ const status = await git.status();
623
+ const branch = options.branch || status.current;
624
+ if (!branch) {
625
+ console.error('Failed to push: missing branch name for upstream setup:', error);
626
+ throw error;
627
+ }
628
+
629
+ const result = await git.push(remote, branch, buildUpstreamOptions(options.options));
630
+ return normalizePushResult(result);
631
+ } catch (fallbackError) {
632
+ console.error('Failed to push (including upstream fallback):', fallbackError);
633
+ throw fallbackError;
634
+ }
531
635
  }
532
636
  }
533
637
 
@@ -726,6 +830,7 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
726
830
 
727
831
  try {
728
832
  const args = ['worktree', 'add'];
833
+ const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : '';
729
834
 
730
835
  if (options.createBranch) {
731
836
  args.push('-b', branch);
@@ -735,6 +840,8 @@ export async function addWorktree(directory, worktreePath, branch, options = {})
735
840
 
736
841
  if (!options.createBranch) {
737
842
  args.push(branch);
843
+ } else if (startPoint) {
844
+ args.push(startPoint);
738
845
  }
739
846
 
740
847
  await git.raw(args);