@openchamber/web 1.0.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 +34 -0
- package/bin/cli.js +561 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +18 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MonacoDiffViewer-J2AIDXvs.js +1 -0
- package/dist/assets/ToolOutputDialog-B0y5ge-3.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-iDfKTtMQ.css +1 -0
- package/dist/assets/index-kNntYPVa.js +2 -0
- package/dist/assets/main-BEJ2XliY.css +1 -0
- package/dist/assets/main-Ba339xde.js +59 -0
- package/dist/assets/vendor--B3aGWKBE.css +32 -0
- package/dist/assets/vendor-.pnpm-B1ce5n1Z.js +3192 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/index.html +197 -0
- package/dist/logo-dark.svg +4 -0
- package/dist/logo-light.svg +4 -0
- package/dist/site.webmanifest +36 -0
- package/dist/vite.svg +1 -0
- package/package.json +92 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +18 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/logo-dark.svg +4 -0
- package/public/logo-light.svg +4 -0
- package/public/site.webmanifest +36 -0
- package/public/vite.svg +1 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +3038 -0
- package/server/lib/git-identity-storage.js +108 -0
- package/server/lib/git-service.js +899 -0
- package/server/lib/opencode-config.js +471 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/ui-auth.js +266 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @openchamber/web
|
|
2
|
+
|
|
3
|
+
Web interface for the [OpenCode](https://opencode.ai) AI coding agent.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm add -g @openchamber/web
|
|
9
|
+
|
|
10
|
+
openchamber # Start on port 3000
|
|
11
|
+
openchamber --port 8080 # Custom port
|
|
12
|
+
openchamber --daemon # Background mode
|
|
13
|
+
openchamber --ui-password secret # Password-protect UI
|
|
14
|
+
openchamber stop # Stop server
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- [OpenCode CLI](https://opencode.ai) installed and running (`opencode serve`)
|
|
20
|
+
- Node.js 20+
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Integrated terminal
|
|
25
|
+
- Git operations with identity management and AI commit message generation
|
|
26
|
+
- Beautiful themes (Flexoki Light/Dark)
|
|
27
|
+
- Mobile-optimized with edge-swipe gestures
|
|
28
|
+
- Rich permission cards with syntax-highlighted operation previews
|
|
29
|
+
- Smart tool visualization (inline diffs, file trees, results highlighting)
|
|
30
|
+
- Per-agent permission mode control
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { spawn, spawnSync } from 'child_process';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const DEFAULT_PORT = 3000;
|
|
12
|
+
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
function parseArgs() {
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
|
17
|
+
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword };
|
|
18
|
+
let command = 'serve';
|
|
19
|
+
|
|
20
|
+
const consumeValue = (currentIndex, inlineValue) => {
|
|
21
|
+
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
|
|
22
|
+
return { value: inlineValue, nextIndex: currentIndex };
|
|
23
|
+
}
|
|
24
|
+
const candidate = args[currentIndex + 1];
|
|
25
|
+
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
|
|
26
|
+
return { value: candidate, nextIndex: currentIndex + 1 };
|
|
27
|
+
}
|
|
28
|
+
return { value: undefined, nextIndex: currentIndex };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i];
|
|
33
|
+
|
|
34
|
+
if (arg.startsWith('-')) {
|
|
35
|
+
let optionName;
|
|
36
|
+
let inlineValue;
|
|
37
|
+
|
|
38
|
+
if (arg.startsWith('--')) {
|
|
39
|
+
const eqIndex = arg.indexOf('=');
|
|
40
|
+
optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
|
41
|
+
inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
|
42
|
+
} else {
|
|
43
|
+
optionName = arg.slice(1);
|
|
44
|
+
inlineValue = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
switch (optionName) {
|
|
48
|
+
case 'port':
|
|
49
|
+
case 'p': {
|
|
50
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
51
|
+
i = nextIndex;
|
|
52
|
+
const parsed = parseInt(value ?? '', 10);
|
|
53
|
+
options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'daemon':
|
|
57
|
+
case 'd':
|
|
58
|
+
options.daemon = true;
|
|
59
|
+
break;
|
|
60
|
+
case 'ui-password': {
|
|
61
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
62
|
+
i = nextIndex;
|
|
63
|
+
options.uiPassword = typeof value === 'string' ? value : '';
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'help':
|
|
67
|
+
case 'h':
|
|
68
|
+
showHelp();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
break;
|
|
71
|
+
case 'version':
|
|
72
|
+
case 'v':
|
|
73
|
+
console.log(PACKAGE_JSON.version);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
command = arg;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { command, options };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function showHelp() {
|
|
86
|
+
console.log(`
|
|
87
|
+
OpenChamber - Web interface for the OpenCode AI coding agent
|
|
88
|
+
|
|
89
|
+
USAGE:
|
|
90
|
+
openchamber [COMMAND] [OPTIONS]
|
|
91
|
+
|
|
92
|
+
COMMANDS:
|
|
93
|
+
serve Start the web server (default)
|
|
94
|
+
stop Stop running instance(s)
|
|
95
|
+
restart Stop and start the server
|
|
96
|
+
status Show server status
|
|
97
|
+
|
|
98
|
+
OPTIONS:
|
|
99
|
+
-p, --port Web server port (default: ${DEFAULT_PORT})
|
|
100
|
+
--ui-password Protect browser UI with single password
|
|
101
|
+
-d, --daemon Run in background (serve command)
|
|
102
|
+
-h, --help Show help
|
|
103
|
+
-v, --version Show version
|
|
104
|
+
|
|
105
|
+
ENVIRONMENT:
|
|
106
|
+
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
|
107
|
+
|
|
108
|
+
EXAMPLES:
|
|
109
|
+
openchamber # Start on default port 3000
|
|
110
|
+
openchamber --port 8080 # Start on port 8080
|
|
111
|
+
openchamber serve --daemon # Start in background
|
|
112
|
+
openchamber stop # Stop all running instances
|
|
113
|
+
openchamber stop --port 3000 # Stop specific instance
|
|
114
|
+
openchamber status # Check status
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
|
119
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
120
|
+
.split(';')
|
|
121
|
+
.map((ext) => ext.trim().toLowerCase())
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
|
124
|
+
: [''];
|
|
125
|
+
|
|
126
|
+
function isExecutable(filePath) {
|
|
127
|
+
try {
|
|
128
|
+
const stats = fs.statSync(filePath);
|
|
129
|
+
if (!stats.isFile()) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (process.platform === 'win32') {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
136
|
+
return true;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveExplicitBinary(candidate) {
|
|
143
|
+
if (!candidate) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
|
|
147
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
|
|
148
|
+
return isExecutable(resolved) ? resolved : null;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function searchPathFor(command) {
|
|
154
|
+
const pathValue = process.env.PATH || '';
|
|
155
|
+
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
|
156
|
+
for (const dir of segments) {
|
|
157
|
+
for (const ext of WINDOWS_EXTENSIONS) {
|
|
158
|
+
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
|
159
|
+
const candidate = path.join(dir, fileName);
|
|
160
|
+
if (isExecutable(candidate)) {
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function checkOpenCodeCLI() {
|
|
169
|
+
if (process.env.OPENCODE_BINARY) {
|
|
170
|
+
const override = resolveExplicitBinary(process.env.OPENCODE_BINARY);
|
|
171
|
+
if (override) {
|
|
172
|
+
process.env.OPENCODE_BINARY = override;
|
|
173
|
+
return override;
|
|
174
|
+
}
|
|
175
|
+
console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const resolvedFromPath = searchPathFor('opencode');
|
|
179
|
+
if (resolvedFromPath) {
|
|
180
|
+
process.env.OPENCODE_BINARY = resolvedFromPath;
|
|
181
|
+
return resolvedFromPath;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (process.platform !== 'win32') {
|
|
185
|
+
const shellCandidates = [];
|
|
186
|
+
if (process.env.SHELL) {
|
|
187
|
+
shellCandidates.push(process.env.SHELL);
|
|
188
|
+
}
|
|
189
|
+
shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh');
|
|
190
|
+
|
|
191
|
+
for (const shellPath of shellCandidates) {
|
|
192
|
+
if (!shellPath || !isExecutable(shellPath)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
+
});
|
|
200
|
+
if (result.status === 0) {
|
|
201
|
+
const candidate = result.stdout.trim().split(/\s+/).pop();
|
|
202
|
+
if (candidate && isExecutable(candidate)) {
|
|
203
|
+
const dir = path.dirname(candidate);
|
|
204
|
+
const currentPath = process.env.PATH || '';
|
|
205
|
+
const segments = currentPath.split(path.delimiter).filter(Boolean);
|
|
206
|
+
if (!segments.includes(dir)) {
|
|
207
|
+
segments.unshift(dir);
|
|
208
|
+
process.env.PATH = segments.join(path.delimiter);
|
|
209
|
+
}
|
|
210
|
+
process.env.OPENCODE_BINARY = candidate;
|
|
211
|
+
return candidate;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
try {
|
|
220
|
+
const result = spawnSync('where', ['opencode'], {
|
|
221
|
+
encoding: 'utf8',
|
|
222
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
223
|
+
});
|
|
224
|
+
if (result.status === 0) {
|
|
225
|
+
const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
|
|
226
|
+
if (candidate && isExecutable(candidate)) {
|
|
227
|
+
process.env.OPENCODE_BINARY = candidate;
|
|
228
|
+
return candidate;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
console.error('Error: Unable to locate the opencode CLI on PATH.');
|
|
237
|
+
console.error(`Current PATH: ${process.env.PATH || '<empty>'}`);
|
|
238
|
+
console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function getPidFilePath(port) {
|
|
243
|
+
const os = await import('os');
|
|
244
|
+
const tmpDir = os.tmpdir();
|
|
245
|
+
return path.join(tmpDir, `openchamber-${port}.pid`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readPidFile(pidFilePath) {
|
|
249
|
+
try {
|
|
250
|
+
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
|
|
251
|
+
const pid = parseInt(content);
|
|
252
|
+
if (isNaN(pid)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return pid;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function writePidFile(pidFilePath, pid) {
|
|
262
|
+
try {
|
|
263
|
+
fs.writeFileSync(pidFilePath, pid.toString());
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.warn(`Warning: Could not write PID file: ${error.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function removePidFile(pidFilePath) {
|
|
270
|
+
try {
|
|
271
|
+
if (fs.existsSync(pidFilePath)) {
|
|
272
|
+
fs.unlinkSync(pidFilePath);
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.warn(`Warning: Could not remove PID file: ${error.message}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isProcessRunning(pid) {
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, 0);
|
|
282
|
+
return true;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const commands = {
|
|
289
|
+
async serve(options) {
|
|
290
|
+
const pidFilePath = await getPidFilePath(options.port);
|
|
291
|
+
|
|
292
|
+
const existingPid = readPidFile(pidFilePath);
|
|
293
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
294
|
+
console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`);
|
|
295
|
+
console.error('Use "openchamber stop" to stop the existing instance');
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const opencodeBinary = await checkOpenCodeCLI();
|
|
300
|
+
|
|
301
|
+
const serverPath = path.join(__dirname, '..', 'server', 'index.js');
|
|
302
|
+
|
|
303
|
+
const serverArgs = [serverPath, '--port', options.port.toString()];
|
|
304
|
+
if (typeof options.uiPassword === 'string') {
|
|
305
|
+
serverArgs.push('--ui-password', options.uiPassword);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (options.daemon) {
|
|
309
|
+
|
|
310
|
+
const child = spawn(process.execPath, serverArgs, {
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: 'ignore',
|
|
313
|
+
env: {
|
|
314
|
+
...process.env,
|
|
315
|
+
OPENCHAMBER_PORT: options.port.toString(),
|
|
316
|
+
OPENCODE_BINARY: opencodeBinary,
|
|
317
|
+
...(typeof options.uiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: options.uiPassword } : {})
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
child.unref();
|
|
322
|
+
|
|
323
|
+
setTimeout(() => {
|
|
324
|
+
if (isProcessRunning(child.pid)) {
|
|
325
|
+
writePidFile(pidFilePath, child.pid);
|
|
326
|
+
console.log(`OpenChamber started in daemon mode on port ${options.port}`);
|
|
327
|
+
console.log(`PID: ${child.pid}`);
|
|
328
|
+
console.log(`Visit: http://localhost:${options.port}`);
|
|
329
|
+
} else {
|
|
330
|
+
console.error('Failed to start server in daemon mode');
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
}, 1000);
|
|
334
|
+
|
|
335
|
+
} else {
|
|
336
|
+
|
|
337
|
+
process.env.OPENCODE_BINARY = opencodeBinary;
|
|
338
|
+
if (typeof options.uiPassword === 'string') {
|
|
339
|
+
process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword;
|
|
340
|
+
}
|
|
341
|
+
const { startWebUiServer } = await import(serverPath);
|
|
342
|
+
await startWebUiServer({
|
|
343
|
+
port: options.port,
|
|
344
|
+
attachSignals: true,
|
|
345
|
+
exitOnShutdown: true,
|
|
346
|
+
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : null
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
async stop(options) {
|
|
352
|
+
const os = await import('os');
|
|
353
|
+
const tmpDir = os.tmpdir();
|
|
354
|
+
|
|
355
|
+
let runningInstances = [];
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const files = fs.readdirSync(tmpDir);
|
|
359
|
+
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
|
|
360
|
+
|
|
361
|
+
for (const file of pidFiles) {
|
|
362
|
+
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
|
|
363
|
+
if (!isNaN(port)) {
|
|
364
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
365
|
+
const pid = readPidFile(pidFilePath);
|
|
366
|
+
|
|
367
|
+
if (pid && isProcessRunning(pid)) {
|
|
368
|
+
runningInstances.push({ port, pid, pidFilePath });
|
|
369
|
+
} else {
|
|
370
|
+
|
|
371
|
+
removePidFile(pidFilePath);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (runningInstances.length === 0) {
|
|
380
|
+
console.log('No running OpenChamber instances found');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p');
|
|
385
|
+
|
|
386
|
+
if (portWasSpecified) {
|
|
387
|
+
const targetInstance = runningInstances.find(inst => inst.port === options.port);
|
|
388
|
+
|
|
389
|
+
if (!targetInstance) {
|
|
390
|
+
console.log(`No OpenChamber instance found running on port ${options.port}`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`);
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
process.kill(targetInstance.pid, 'SIGTERM');
|
|
398
|
+
|
|
399
|
+
let attempts = 0;
|
|
400
|
+
const maxAttempts = 10;
|
|
401
|
+
|
|
402
|
+
const checkShutdown = setInterval(() => {
|
|
403
|
+
attempts++;
|
|
404
|
+
if (!isProcessRunning(targetInstance.pid)) {
|
|
405
|
+
clearInterval(checkShutdown);
|
|
406
|
+
removePidFile(targetInstance.pidFilePath);
|
|
407
|
+
console.log('OpenChamber stopped successfully');
|
|
408
|
+
} else if (attempts >= maxAttempts) {
|
|
409
|
+
clearInterval(checkShutdown);
|
|
410
|
+
console.log('Force killing process...');
|
|
411
|
+
process.kill(targetInstance.pid, 'SIGKILL');
|
|
412
|
+
removePidFile(targetInstance.pidFilePath);
|
|
413
|
+
console.log('OpenChamber force stopped');
|
|
414
|
+
}
|
|
415
|
+
}, 500);
|
|
416
|
+
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error(`Error stopping process: ${error.message}`);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
|
|
423
|
+
console.log(`Stopping all OpenChamber instances (${runningInstances.length} found)...`);
|
|
424
|
+
|
|
425
|
+
for (const instance of runningInstances) {
|
|
426
|
+
console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`);
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
process.kill(instance.pid, 'SIGTERM');
|
|
430
|
+
|
|
431
|
+
let attempts = 0;
|
|
432
|
+
const maxAttempts = 10;
|
|
433
|
+
|
|
434
|
+
await new Promise((resolve) => {
|
|
435
|
+
const checkShutdown = setInterval(() => {
|
|
436
|
+
attempts++;
|
|
437
|
+
if (!isProcessRunning(instance.pid)) {
|
|
438
|
+
clearInterval(checkShutdown);
|
|
439
|
+
removePidFile(instance.pidFilePath);
|
|
440
|
+
console.log(` Port ${instance.port} stopped successfully`);
|
|
441
|
+
resolve(true);
|
|
442
|
+
} else if (attempts >= maxAttempts) {
|
|
443
|
+
clearInterval(checkShutdown);
|
|
444
|
+
console.log(` Force killing port ${instance.port}...`);
|
|
445
|
+
try {
|
|
446
|
+
process.kill(instance.pid, 'SIGKILL');
|
|
447
|
+
removePidFile(instance.pidFilePath);
|
|
448
|
+
console.log(` Port ${instance.port} force stopped`);
|
|
449
|
+
} catch (e) {
|
|
450
|
+
|
|
451
|
+
}
|
|
452
|
+
resolve(true);
|
|
453
|
+
}
|
|
454
|
+
}, 500);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error(` Error stopping port ${instance.port}: ${error.message}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
console.log('\nAll OpenChamber instances stopped');
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async restart(options) {
|
|
467
|
+
await commands.stop(options);
|
|
468
|
+
await commands.serve(options);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
async status(options) {
|
|
472
|
+
const os = await import('os');
|
|
473
|
+
const tmpDir = os.tmpdir();
|
|
474
|
+
|
|
475
|
+
let runningInstances = [];
|
|
476
|
+
let stoppedInstances = [];
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const files = fs.readdirSync(tmpDir);
|
|
480
|
+
const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid'));
|
|
481
|
+
|
|
482
|
+
for (const file of pidFiles) {
|
|
483
|
+
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''));
|
|
484
|
+
if (!isNaN(port)) {
|
|
485
|
+
const pidFilePath = path.join(tmpDir, file);
|
|
486
|
+
const pid = readPidFile(pidFilePath);
|
|
487
|
+
|
|
488
|
+
if (pid && isProcessRunning(pid)) {
|
|
489
|
+
runningInstances.push({ port, pid, pidFilePath });
|
|
490
|
+
} else {
|
|
491
|
+
|
|
492
|
+
removePidFile(pidFilePath);
|
|
493
|
+
stoppedInstances.push({ port });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch (error) {
|
|
498
|
+
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (runningInstances.length === 0) {
|
|
502
|
+
console.log('OpenChamber Status:');
|
|
503
|
+
console.log(' Status: Stopped');
|
|
504
|
+
if (stoppedInstances.length > 0) {
|
|
505
|
+
console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`);
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
console.log('OpenChamber Status:');
|
|
511
|
+
for (const [index, instance] of runningInstances.entries()) {
|
|
512
|
+
if (runningInstances.length > 1) {
|
|
513
|
+
console.log(`\nInstance ${index + 1}:`);
|
|
514
|
+
}
|
|
515
|
+
console.log(' Status: Running');
|
|
516
|
+
console.log(` PID: ${instance.pid}`);
|
|
517
|
+
console.log(` Port: ${instance.port}`);
|
|
518
|
+
console.log(` Visit: http://localhost:${instance.port}`);
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const { execSync } = await import('child_process');
|
|
522
|
+
const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim();
|
|
523
|
+
console.log(` Start Time: ${startTime}`);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
async function main() {
|
|
533
|
+
const { command, options } = parseArgs();
|
|
534
|
+
|
|
535
|
+
if (!commands[command]) {
|
|
536
|
+
console.error(`Error: Unknown command '${command}'`);
|
|
537
|
+
console.error('Use --help to see available commands');
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await commands[command](options);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
console.error(`Error executing command '${command}': ${error.message}`);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
550
|
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
551
|
+
process.exit(1);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
process.on('uncaughtException', (error) => {
|
|
555
|
+
console.error('Uncaught Exception:', error);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
main();
|
|
560
|
+
|
|
561
|
+
export { commands, parseArgs, getPidFilePath };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg width="180" height="180" viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g transform="translate(8.75, 8.75) scale(0.75)">
|
|
3
|
+
<!-- Letter O with white fill and thin black stroke -->
|
|
4
|
+
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
5
|
+
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
|
|
6
|
+
fill="white"
|
|
7
|
+
stroke="black"
|
|
8
|
+
stroke-width="1.1"
|
|
9
|
+
stroke-linejoin="round"/>
|
|
10
|
+
|
|
11
|
+
<!-- Letter C with white fill and thin black stroke -->
|
|
12
|
+
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
|
|
13
|
+
fill="white"
|
|
14
|
+
stroke="black"
|
|
15
|
+
stroke-width="1.1"
|
|
16
|
+
stroke-linejoin="round"/>
|
|
17
|
+
</g>
|
|
18
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|