@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/README.md +1 -0
- package/bin/cli.js +56 -15
- package/dist/assets/{ToolOutputDialog-BrFH3eLi.js → ToolOutputDialog-68nQ6kiw.js} +3 -3
- package/dist/assets/index-BK6z1XIy.css +1 -0
- package/dist/assets/{index-CqiXpckC.js → index-DbT28rmj.js} +2 -2
- package/dist/assets/main-DPWbnEr6.js +122 -0
- package/dist/assets/{vendor-.bun-B0k_58z8.js → vendor-.bun-BhxSBVb2.js} +213 -213
- package/dist/index.html +3 -3
- package/package.json +2 -2
- package/server/index.js +51 -11
- 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/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-
|
|
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-
|
|
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
|
+
"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
|
|
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,
|
|
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 {
|
|
@@ -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
|
|
277
|
-
ahead
|
|
278
|
-
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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);
|