feat(cli): add --foreground flag for systemd and process manager deployments (#695)
* feat(cli): add --foreground flag for systemd and process manager deployments Adds --foreground / --no-daemon to `openchamber serve` which runs the server inline in the CLI process instead of spawning a detached daemon child. Required for systemd Type=simple (and other process managers) that track the direct child — the always-daemon behavior introduced in #640 broke this use case. Also documents OPENCHAMBER_HOST (bind address) in --help, which was implemented but never exposed to users. * docs: add systemd service guide for VPN/LAN deployments Documents how to run OpenCode and OpenChamber as separate systemd user services for persistent access over Tailscale or LAN, using the new --foreground flag and OPENCODE_HOST to wire them together. * fix(cli): address foreground mode parity issues from PR review - Fix Ctrl+C handling: CLI SIGINT handler now defers to server in foreground mode; dedicated signal handlers perform graceful shutdown and clean exit - Restore lifecycle parity: foreground instances write PID/instance files so status, stop, and restart can discover them - Add deterministic --foreground --json output: emits stable startup JSON with port, pid, url, and foreground flag before blocking * fix(cli): tighten inline foreground behavior for restart UX and JSON-only output * fix(cli): pass --host to foreground server, reject --json, add --quiet output - Pass options.host through to startWebUiServer() in foreground mode so the bind address is respected (fixes localhost-only regression from #750) - Reject --foreground --json with a clear usage error; --json is only supported in background (daemon) mode - Emit resolved port on stdout in --quiet foreground mode, matching daemon parity - Update systemd docs to include --host 0.0.0.0 for LAN/VPN access now that the default bind is 127.0.0.1 * fix(cli): remove duplicate OPENCHAMBER_HOST entry from help text * fix(cli): emit restart summary before foreground serve() blocks restart --json (and --quiet / human) with a foreground instance would hang forever without output because serve() blocks and the post-loop summary was unreachable. Emit the final output after stop succeeds but before the blocking serve call — foreground is always sorted last so all daemon results are already collected. * fix(cli): restart stops foreground instances without re-attaching Foreground instances are managed by a process manager (systemd, Docker, etc.) that will restart them automatically. The restart command now just stops the foreground instance, records the result, and exits — no serve() call, no blocking. This makes restart --json and all other output modes work correctly for foreground instances.
This commit is contained in:
@@ -134,6 +134,72 @@ OPENCHAMBER_OPENCODE_HOSTNAME=0.0.0.0 openchamber --port 3000
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>systemd service (VPN / LAN access)</summary>
|
||||||
|
|
||||||
|
Run OpenChamber and OpenCode as separate persistent services — useful when you want to access your
|
||||||
|
dev machine over a VPN (e.g. Tailscale) or LAN without a Cloudflare tunnel.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- OpenCode runs as its own service, binding only to `localhost`.
|
||||||
|
- OpenChamber connects to it via `OPENCODE_HOST` and `--host 0.0.0.0` makes it reachable on your VPN IP.
|
||||||
|
- `--foreground` keeps the CLI process alive so systemd can track and restart it.
|
||||||
|
|
||||||
|
**`~/.config/systemd/user/opencode.service`**
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=OpenCode Server
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=opencode serve --port 4095
|
||||||
|
Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/YOU/.local/bin:/home/YOU/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why set `PATH` and `SSH_AUTH_SOCK`?**
|
||||||
|
> systemd user services start with a minimal environment — no shell profile is sourced.
|
||||||
|
> Without an explicit `PATH`, OpenCode won't find tools installed via Homebrew, npm, or `~/.local/bin`.
|
||||||
|
> Without `SSH_AUTH_SOCK`, git operations over SSH (push, pull, clone) will fail because the agent socket isn't inherited.
|
||||||
|
> Adjust the `PATH` to match your own tool installation paths.
|
||||||
|
> `%t` expands to `$XDG_RUNTIME_DIR` (e.g. `/run/user/1000`), where most SSH agents write their socket.
|
||||||
|
|
||||||
|
**`~/.config/systemd/user/openchamber.service`**
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=OpenChamber Web Server
|
||||||
|
After=opencode.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=openchamber serve --port 3000 --host 0.0.0.0 --ui-password your-password --foreground
|
||||||
|
Environment="OPENCODE_HOST=http://localhost:4095"
|
||||||
|
Environment="OPENCODE_SKIP_START=true"
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now opencode openchamber
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenChamber will be reachable at `http://<your-vpn-hostname>:3000` from any device on your VPN.
|
||||||
|
|
||||||
|
> **Note:** `--host 0.0.0.0` is required to listen on all interfaces. The default
|
||||||
|
> bind address is `127.0.0.1` (localhost only). Use `--host <ip>` or
|
||||||
|
> `OPENCHAMBER_HOST=<ip>` to bind to a specific interface instead.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker</summary>
|
<summary>Docker</summary>
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,61 @@ openchamber stop # Stop background server
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>systemd service (VPN / LAN access)</summary>
|
||||||
|
|
||||||
|
Use `--foreground` to keep the CLI process alive so systemd (or any other process manager) can track and restart it. Combine with `OPENCODE_HOST` to connect to an OpenCode instance running as a separate service.
|
||||||
|
|
||||||
|
**`~/.config/systemd/user/opencode.service`**
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=OpenCode Server
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=opencode serve --port 4095
|
||||||
|
Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/YOU/.local/bin:/home/YOU/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"
|
||||||
|
Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why set `PATH` and `SSH_AUTH_SOCK`?**
|
||||||
|
> systemd user services start with a minimal environment — no shell profile is sourced.
|
||||||
|
> Without an explicit `PATH`, OpenCode won't find tools installed via Homebrew, npm, or `~/.local/bin`.
|
||||||
|
> Without `SSH_AUTH_SOCK`, git operations over SSH (push, pull, clone) will fail.
|
||||||
|
> `%t` expands to `$XDG_RUNTIME_DIR` (e.g. `/run/user/1000`), where most SSH agents write their socket.
|
||||||
|
|
||||||
|
**`~/.config/systemd/user/openchamber.service`**
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=OpenChamber Web Server
|
||||||
|
After=opencode.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=openchamber serve --port 3000 --host 0.0.0.0 --ui-password your-password --foreground
|
||||||
|
Environment="OPENCODE_HOST=http://localhost:4095"
|
||||||
|
Environment="OPENCODE_SKIP_START=true"
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now opencode openchamber
|
||||||
|
```
|
||||||
|
|
||||||
|
`--host 0.0.0.0` is required to listen on all interfaces (the default is `127.0.0.1`). Use `--host <ip>` or `OPENCHAMBER_HOST=<ip>` to bind to a specific interface instead.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## What makes the web version special
|
## What makes the web version special
|
||||||
|
|
||||||
- **Remote access** - Cloudflare tunnel with QR onboarding. Scan from your phone, start coding.
|
- **Remote access** - Cloudflare tunnel with QR onboarding. Scan from your phone, start coding.
|
||||||
|
|||||||
Regular → Executable
+174
-10
@@ -61,6 +61,8 @@ const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [cloudflareTunnelProviderCapabiliti
|
|||||||
|
|
||||||
let onCancelCleanup = null;
|
let onCancelCleanup = null;
|
||||||
let activeCommandOptions = null;
|
let activeCommandOptions = null;
|
||||||
|
let foregroundServerActive = false;
|
||||||
|
let foregroundShutdown = null;
|
||||||
|
|
||||||
function setCancelCleanup(handler) {
|
function setCancelCleanup(handler) {
|
||||||
onCancelCleanup = typeof handler === 'function' ? handler : null;
|
onCancelCleanup = typeof handler === 'function' ? handler : null;
|
||||||
@@ -596,6 +598,7 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|||||||
quiet: false,
|
quiet: false,
|
||||||
explicitPort: false,
|
explicitPort: false,
|
||||||
explicitUiPassword: false,
|
explicitUiPassword: false,
|
||||||
|
foreground: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const removedFlagErrors = [];
|
const removedFlagErrors = [];
|
||||||
@@ -788,6 +791,10 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|||||||
case 'v':
|
case 'v':
|
||||||
versionRequested = true;
|
versionRequested = true;
|
||||||
break;
|
break;
|
||||||
|
case 'foreground':
|
||||||
|
case 'no-daemon':
|
||||||
|
options.foreground = true;
|
||||||
|
break;
|
||||||
case 'daemon':
|
case 'daemon':
|
||||||
case 'd':
|
case 'd':
|
||||||
removedFlagErrors.push('`--daemon` was removed. OpenChamber now always runs in daemon mode.');
|
removedFlagErrors.push('`--daemon` was removed. OpenChamber now always runs in daemon mode.');
|
||||||
@@ -854,6 +861,8 @@ OPTIONS:
|
|||||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||||
--host Bind address (default: 127.0.0.1)
|
--host Bind address (default: 127.0.0.1)
|
||||||
--ui-password Protect browser UI with single password
|
--ui-password Protect browser UI with single password
|
||||||
|
--foreground Run server in foreground (use with systemd/process managers)
|
||||||
|
--no-daemon Alias for --foreground
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
-v, --version Show version
|
-v, --version Show version
|
||||||
|
|
||||||
@@ -869,6 +878,7 @@ ENVIRONMENT:
|
|||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
openchamber # Start in daemon mode on default port 3000 (or free port)
|
openchamber # Start in daemon mode on default port 3000 (or free port)
|
||||||
openchamber --port 8080 # Start on port 8080 (daemon)
|
openchamber --port 8080 # Start on port 8080 (daemon)
|
||||||
|
openchamber serve --foreground # Start in foreground (for systemd Type=simple)
|
||||||
openchamber tunnel help # Show tunnel lifecycle help
|
openchamber tunnel help # Show tunnel lifecycle help
|
||||||
openchamber logs # Follow logs for latest running instance
|
openchamber logs # Follow logs for latest running instance
|
||||||
`);
|
`);
|
||||||
@@ -966,7 +976,7 @@ _openchamber_tunnel() {
|
|||||||
commands="serve stop restart status tunnel logs update"
|
commands="serve stop restart status tunnel logs update"
|
||||||
tunnel_commands="help providers ready doctor status start stop profile completion"
|
tunnel_commands="help providers ready doctor status start stop profile completion"
|
||||||
profile_commands="list show add remove"
|
profile_commands="list show add remove"
|
||||||
common_flags="--port --json --all --help --version --plain --quiet"
|
common_flags="--port --foreground --no-daemon --json --all --help --version --plain --quiet"
|
||||||
start_flags="--provider --mode --profile --config --token --token-file --token-stdin --hostname --connect-ttl --session-ttl --qr --no-qr --dry-run --show-secrets"
|
start_flags="--provider --mode --profile --config --token --token-file --token-stdin --hostname --connect-ttl --session-ttl --qr --no-qr --dry-run --show-secrets"
|
||||||
|
|
||||||
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
||||||
@@ -1072,6 +1082,8 @@ compdef _openchamber openchamber
|
|||||||
# Save to ~/.config/fish/completions/openchamber.fish
|
# Save to ~/.config/fish/completions/openchamber.fish
|
||||||
|
|
||||||
complete -c openchamber -n '__fish_use_subcommand' -a 'serve' -d 'Start the web server'
|
complete -c openchamber -n '__fish_use_subcommand' -a 'serve' -d 'Start the web server'
|
||||||
|
complete -c openchamber -n '__fish_seen_subcommand_from serve' -l foreground -d 'Run in foreground (for systemd/process managers)'
|
||||||
|
complete -c openchamber -n '__fish_seen_subcommand_from serve' -l no-daemon -d 'Run in foreground (alias for --foreground)'
|
||||||
complete -c openchamber -n '__fish_use_subcommand' -a 'stop' -d 'Stop running instance(s)'
|
complete -c openchamber -n '__fish_use_subcommand' -a 'stop' -d 'Stop running instance(s)'
|
||||||
complete -c openchamber -n '__fish_use_subcommand' -a 'restart' -d 'Stop and start the server'
|
complete -c openchamber -n '__fish_use_subcommand' -a 'restart' -d 'Stop and start the server'
|
||||||
complete -c openchamber -n '__fish_use_subcommand' -a 'status' -d 'Show server status'
|
complete -c openchamber -n '__fish_use_subcommand' -a 'status' -d 'Show server status'
|
||||||
@@ -1738,6 +1750,7 @@ function writeInstanceOptions(instanceFilePath, options, onNotice) {
|
|||||||
try {
|
try {
|
||||||
const toStore = {
|
const toStore = {
|
||||||
port: options.port,
|
port: options.port,
|
||||||
|
launchMode: options.launchMode === 'foreground' ? 'foreground' : 'daemon',
|
||||||
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
|
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
|
||||||
hasUiPassword: typeof options.uiPassword === 'string',
|
hasUiPassword: typeof options.uiPassword === 'string',
|
||||||
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
|
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
|
||||||
@@ -1988,7 +2001,8 @@ async function discoverRunningInstances() {
|
|||||||
if (Number.isFinite(storedOptions?.startedAt)) {
|
if (Number.isFinite(storedOptions?.startedAt)) {
|
||||||
startedAt = storedOptions.startedAt;
|
startedAt = storedOptions.startedAt;
|
||||||
}
|
}
|
||||||
instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt });
|
const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon';
|
||||||
|
instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt, launchMode });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
@@ -2742,6 +2756,124 @@ const commands = {
|
|||||||
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
|
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Foreground mode: run server inline so the CLI process is the server process.
|
||||||
|
// Required for process managers like systemd (Type=simple) that track the
|
||||||
|
// direct child rather than a detached grandchild.
|
||||||
|
// IMPORTANT: foreground MUST remain inline (in-process). Do not convert to
|
||||||
|
// child-process orchestration — that causes shell job-control suspension.
|
||||||
|
if (options.foreground) {
|
||||||
|
if (isJsonMode(options)) {
|
||||||
|
throw new TunnelCliError(
|
||||||
|
'--json is not supported with --foreground. Use --json with background (daemon) mode instead.',
|
||||||
|
EXIT_CODE.USAGE_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate resolved values into env before importing the server module.
|
||||||
|
if (opencodeBinary) {
|
||||||
|
process.env.OPENCODE_BINARY = opencodeBinary;
|
||||||
|
}
|
||||||
|
if (effectiveUiPassword) {
|
||||||
|
process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In --quiet mode, redirect stdout/stderr to the log file so that
|
||||||
|
// server runtime output (console.log calls) does not pollute the
|
||||||
|
// deterministic CLI output contract. In plain human mode, close the
|
||||||
|
// log fd and let output go to the inherited terminal as before.
|
||||||
|
const suppressServerOutput = isQuietMode(options);
|
||||||
|
// Keep a reference to the real stdout.write so CLI output (port, JSON)
|
||||||
|
// can bypass the log-file redirect.
|
||||||
|
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||||
|
if (suppressServerOutput) {
|
||||||
|
const logStream = fs.createWriteStream(null, { fd: logFd });
|
||||||
|
process.stdout.write = (chunk, encoding, callback) => {
|
||||||
|
return logStream.write(chunk, encoding, callback);
|
||||||
|
};
|
||||||
|
process.stderr.write = (chunk, encoding, callback) => {
|
||||||
|
return logStream.write(chunk, encoding, callback);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Close the log fd – in foreground human mode stdout/stderr are
|
||||||
|
// inherited from the parent (e.g. journald/terminal).
|
||||||
|
try {
|
||||||
|
fs.closeSync(logFd);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isQuietMode(options)) {
|
||||||
|
console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveHost = typeof options.host === 'string' && options.host.length > 0
|
||||||
|
? options.host : undefined;
|
||||||
|
|
||||||
|
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
|
||||||
|
const controller = await startWebUiServer({
|
||||||
|
port: targetPort,
|
||||||
|
host: effectiveHost,
|
||||||
|
uiPassword: effectiveUiPassword,
|
||||||
|
attachSignals: false,
|
||||||
|
exitOnShutdown: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedPort = controller.getPort();
|
||||||
|
|
||||||
|
// Write PID / instance files so status, stop, and restart can discover
|
||||||
|
// this foreground instance the same way they discover daemon instances.
|
||||||
|
const fgPidFilePath = await getPidFilePath(resolvedPort);
|
||||||
|
const fgInstanceFilePath = await getInstanceFilePath(resolvedPort);
|
||||||
|
writePidFile(fgPidFilePath, process.pid, emitNotice);
|
||||||
|
writeInstanceOptions(fgInstanceFilePath, {
|
||||||
|
port: resolvedPort,
|
||||||
|
launchMode: 'foreground',
|
||||||
|
uiPassword: effectiveUiPassword,
|
||||||
|
}, emitNotice);
|
||||||
|
|
||||||
|
if (isQuietMode(options)) {
|
||||||
|
if (!options.suppressQuietOutput) {
|
||||||
|
realStdoutWrite(`${resolvedPort}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up PID / instance files.
|
||||||
|
const cleanupFiles = () => {
|
||||||
|
removePidFile(fgPidFilePath);
|
||||||
|
removeInstanceFile(fgInstanceFilePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('exit', cleanupFiles);
|
||||||
|
|
||||||
|
// Idempotent graceful shutdown with deterministic exit codes.
|
||||||
|
let shutdownInProgress = false;
|
||||||
|
const shutdownForegroundServer = async (signal = 'SIGTERM') => {
|
||||||
|
if (shutdownInProgress) return;
|
||||||
|
shutdownInProgress = true;
|
||||||
|
try {
|
||||||
|
await controller.stop({ exitProcess: false });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
cleanupFiles();
|
||||||
|
foregroundServerActive = false;
|
||||||
|
foregroundShutdown = null;
|
||||||
|
const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
|
||||||
|
process.exit(exitCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose shutdown to the global SIGINT handler.
|
||||||
|
foregroundShutdown = shutdownForegroundServer;
|
||||||
|
foregroundServerActive = true;
|
||||||
|
|
||||||
|
// Register signal handlers (additive, no removeAllListeners).
|
||||||
|
process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); });
|
||||||
|
process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); });
|
||||||
|
process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); });
|
||||||
|
|
||||||
|
// Block forever – the process stays alive until signalled.
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const serverArgs = [serverPath, '--port', String(targetPort)];
|
const serverArgs = [serverPath, '--port', String(targetPort)];
|
||||||
const effectiveHost = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
|
const effectiveHost = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
|
||||||
if (effectiveHost) {
|
if (effectiveHost) {
|
||||||
@@ -2822,6 +2954,7 @@ const commands = {
|
|||||||
writePidFile(pidFilePath, child.pid, emitNotice);
|
writePidFile(pidFilePath, child.pid, emitNotice);
|
||||||
writeInstanceOptions(instanceFilePath, {
|
writeInstanceOptions(instanceFilePath, {
|
||||||
port: resolvedPort,
|
port: resolvedPort,
|
||||||
|
launchMode: 'daemon',
|
||||||
uiPassword: effectiveUiPassword,
|
uiPassword: effectiveUiPassword,
|
||||||
}, emitNotice);
|
}, emitNotice);
|
||||||
|
|
||||||
@@ -2830,6 +2963,7 @@ const commands = {
|
|||||||
pid: child.pid,
|
pid: child.pid,
|
||||||
url: buildLocalUrl(resolvedPort, '/'),
|
url: buildLocalUrl(resolvedPort, '/'),
|
||||||
logs: `openchamber logs -p ${resolvedPort}`,
|
logs: `openchamber logs -p ${resolvedPort}`,
|
||||||
|
launchMode: 'daemon',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isJsonMode(options)) {
|
if (isJsonMode(options)) {
|
||||||
@@ -3091,9 +3225,14 @@ const commands = {
|
|||||||
|
|
||||||
for (const instance of runningInstances) {
|
for (const instance of runningInstances) {
|
||||||
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
|
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
|
||||||
|
const launchMode = instance.launchMode || 'daemon';
|
||||||
|
const isForeground = launchMode === 'foreground';
|
||||||
|
|
||||||
|
const restartPort = options.explicitPort ? options.port : instance.port;
|
||||||
|
|
||||||
const restartSpin = showOutput ? createSpinner(options) : null;
|
const restartSpin = showOutput ? createSpinner(options) : null;
|
||||||
if (showOutput && !restartSpin) {
|
if (showOutput && !restartSpin) {
|
||||||
logStatus('info', `restarting port ${instance.port}`);
|
logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
|
||||||
}
|
}
|
||||||
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
|
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
|
||||||
try {
|
try {
|
||||||
@@ -3103,9 +3242,24 @@ const commands = {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
suppressQuietOutput: true,
|
suppressQuietOutput: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Foreground instances are managed by a process manager (systemd,
|
||||||
|
// Docker, etc.) that will restart them automatically after stop.
|
||||||
|
// Do not call serve() here — just record the stop as a successful
|
||||||
|
// restart and let the process manager handle the actual restart.
|
||||||
|
if (isForeground) {
|
||||||
|
restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
|
||||||
|
restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
|
||||||
|
if (showOutput && !restartSpin) {
|
||||||
|
logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const restartedPort = await this.serve({
|
const restartedPort = await this.serve({
|
||||||
port: options.explicitPort ? options.port : (storedOptions.port || instance.port),
|
port: restartPort,
|
||||||
explicitPort: true,
|
explicitPort: true,
|
||||||
uiPassword: options.explicitUiPassword ? options.uiPassword : storedOptions.uiPassword,
|
uiPassword: options.explicitUiPassword ? options.uiPassword : storedOptions.uiPassword,
|
||||||
suppressStartupSummary: true,
|
suppressStartupSummary: true,
|
||||||
@@ -3113,10 +3267,10 @@ const commands = {
|
|||||||
suppressUiPasswordWarning: true,
|
suppressUiPasswordWarning: true,
|
||||||
suppressQuietOutput: true,
|
suppressQuietOutput: true,
|
||||||
});
|
});
|
||||||
restarted.push({ fromPort: instance.port, toPort: restartedPort, ok: true });
|
restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
|
||||||
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
|
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
|
||||||
if (showOutput && !restartSpin) {
|
if (showOutput && !restartSpin) {
|
||||||
logStatus('success', `port ${restartedPort} restarted`);
|
logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -3129,7 +3283,7 @@ const commands = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isJsonMode(options)) {
|
if (isJsonMode(options)) {
|
||||||
printJson({ restartedCount: restarted.length, results: restarted });
|
printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3157,6 +3311,7 @@ const commands = {
|
|||||||
runtime: 'desktop',
|
runtime: 'desktop',
|
||||||
port: desktopInstance.port,
|
port: desktopInstance.port,
|
||||||
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
|
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
|
||||||
|
launchMode: null,
|
||||||
passwordProtected: null,
|
passwordProtected: null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -3170,6 +3325,7 @@ const commands = {
|
|||||||
runtime: 'cli',
|
runtime: 'cli',
|
||||||
port: instance.port,
|
port: instance.port,
|
||||||
pid: instance.pid,
|
pid: instance.pid,
|
||||||
|
launchMode: instance.launchMode || 'daemon',
|
||||||
passwordProtected,
|
passwordProtected,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3194,7 +3350,7 @@ const commands = {
|
|||||||
|
|
||||||
for (const instance of instances) {
|
for (const instance of instances) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`port ${instance.port} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
|
`port ${instance.port} mode:${instance.launchMode || 'n/a'} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3210,11 +3366,13 @@ const commands = {
|
|||||||
|
|
||||||
for (const instance of instances) {
|
for (const instance of instances) {
|
||||||
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
|
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
|
||||||
|
const modeDetail = instance.launchMode ? `mode: ${instance.launchMode}` : '';
|
||||||
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
|
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
|
||||||
|
const detail = modeDetail ? `${modeDetail}; ${protectionDetail}` : protectionDetail;
|
||||||
if (instance.runtime === 'desktop') {
|
if (instance.runtime === 'desktop') {
|
||||||
logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, protectionDetail);
|
logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, detail);
|
||||||
} else {
|
} else {
|
||||||
logStatus('success', `port ${instance.port}${pidSuffix}`, protectionDetail);
|
logStatus('success', `port ${instance.port}${pidSuffix}`, detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4646,6 +4804,12 @@ if (isCliExecution) {
|
|||||||
if (isHandlingSigint) {
|
if (isHandlingSigint) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (foregroundServerActive) {
|
||||||
|
if (typeof foregroundShutdown === 'function') {
|
||||||
|
void foregroundShutdown('SIGINT');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
isHandlingSigint = true;
|
isHandlingSigint = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
clackCancel('Operation cancelled.');
|
clackCancel('Operation cancelled.');
|
||||||
|
|||||||
Reference in New Issue
Block a user