Refactor web CLI into focused modules (#1837)

This commit is contained in:
Bohdan Triapitsyn
2026-06-26 19:28:44 +03:00
committed by GitHub
parent 00821700de
commit 45df19c3b2
25 changed files with 6714 additions and 5814 deletions
+105 -5814
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -8,12 +8,16 @@ import { spawn } from 'child_process';
import { pathToFileURL } from 'url';
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
import { requestJson } from './lib/cli-http.js';
import { inspectTunnelAttachability } from './lib/cli-lifecycle.js';
import {
assertAuthenticatedNetworkExposure,
commands,
discoverOpenChamberInstanceOnPort,
discoverLifecycleInstances,
discoverRunningInstances,
discoverUnconfirmedRegistryInstanceOnPort,
ensureTunnelProfilesMigrated,
getInstanceFilePath,
getPidFilePath,
isOpenchamberCmdline,
@@ -298,6 +302,98 @@ describe('serve host resolution', () => {
});
});
describe('compatibility exports', () => {
it('allows tunnel profile migration before command options are initialized', async () => {
await withTempOpenChamberDataDir(async () => {
const store = ensureTunnelProfilesMigrated();
expect(store).toEqual({ version: 1, profiles: [] });
});
});
it('includes ngrok in fallback tunnel providers when no server is reachable', async () => {
await withTempOpenChamberDataDir(async () => {
const output = await captureStdout(async () => {
await commands.tunnel({ json: true }, 'providers');
});
const body = JSON.parse(output);
expect(body.source).toBe('fallback');
expect(body.providers.map((entry) => entry.provider)).toContain('ngrok');
});
});
it('supports ngrok quick dry-run with an explicit port', async () => {
await withTempOpenChamberDataDir(async () => {
const output = await captureStdout(async () => {
await commands.tunnel({
json: true,
dryRun: true,
explicitPort: true,
port: 3003,
provider: 'ngrok',
mode: 'quick',
}, 'start');
});
const body = JSON.parse(output);
expect(body).toEqual(expect.objectContaining({
ok: true,
dryRun: true,
provider: 'ngrok',
mode: 'quick',
}));
});
});
});
describe('CLI HTTP helpers', () => {
it('retries UI-authenticated API requests with the stored instance password', async () => {
await withTempOpenChamberDataDir(async () => {
const port = 45678;
fs.writeFileSync(await getInstanceFilePath(port), JSON.stringify({ port, uiPassword: 'secret' }, null, 2));
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
if (String(url).endsWith('/auth/session')) {
expect(JSON.parse(options.body)).toEqual({ password: 'secret' });
return {
ok: true,
headers: { get: (name) => name.toLowerCase() === 'set-cookie' ? 'oc_ui_session=session-token; Path=/; HttpOnly' : null },
json: async () => ({ authenticated: true }),
};
}
if (options.headers?.Cookie === 'oc_ui_session=session-token') {
return createMockJsonResponse({ ok: true });
}
return {
ok: false,
status: 401,
json: async () => ({ error: 'UI authentication required', locked: true }),
};
};
try {
const { response, body } = await requestJson(port, '/api/openchamber/tunnel/start', {
method: 'POST',
body: JSON.stringify({ provider: 'ngrok', mode: 'quick' }),
});
expect(response.ok).toBe(true);
expect(body).toEqual({ ok: true });
expect(calls.map((call) => new URL(call.url).pathname)).toEqual([
'/api/openchamber/tunnel/start',
'/auth/session',
'/api/openchamber/tunnel/start',
]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
});
describe('cli entry detection', () => {
const modulePath = '/tmp/openchamber/bin/cli.js';
const moduleUrl = pathToFileURL(modulePath).href;
@@ -387,6 +483,49 @@ describe('isOpenchamberProcessRunning', () => {
});
describe('lifecycle instance discovery', () => {
it('does not attribute a desktop runtime response to a different explicit port', async () => {
await withTempOpenChamberDataDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2));
const instance = await discoverOpenChamberInstanceOnPort(3003, {
fetchImpl: async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 }),
});
expect(instance).toBeNull();
});
});
it('attributes a desktop runtime response to its configured desktop port', async () => {
await withTempOpenChamberDataDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2));
const instance = await discoverOpenChamberInstanceOnPort(57123, {
fetchImpl: async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 }),
});
expect(instance).toEqual(expect.objectContaining({
port: 57123,
pid: 934,
runtime: 'desktop',
}));
});
});
it('does not mark tunnel attachability as desktop for a different explicit port', async () => {
await withTempOpenChamberDataDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ desktopLocalPort: 57123 }, null, 2));
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => createMockJsonResponse({ runtime: 'desktop', pid: 934 });
try {
const attachability = await inspectTunnelAttachability(3004, { requireHealthy: false });
expect(attachability.reason).not.toBe('desktop');
} finally {
globalThis.fetch = originalFetch;
}
});
});
it('keeps pid and instance files when live port probe confirms a cmdline mismatch', async () => {
await withTempOpenChamberDataDir(async () => {
const port = 45123;
+121
View File
@@ -0,0 +1,121 @@
# CLI Module Map
This directory contains the non-entrypoint implementation for the OpenChamber CLI. `packages/web/bin/cli.js` should stay thin: it owns bootstrap, command wiring, top-level dispatch, signal/cancel handling, and compatibility exports. Domain logic belongs in these modules.
## Entrypoint Boundary
- `../cli.js`
- Owns process bootstrap, package/version lookup, command table wiring, signal handlers, top-level error handling, and legacy exports used by tests or external consumers.
- Injects runtime dependencies into command factories, such as `serveCommand`, `stopCommand`, package-manager loading, cancel cleanup, and foreground server state setters.
- Should not grow command-specific behavior. If a new branch needs more than dispatch/wiring, move it here into a command or helper module instead.
## Command Modules
Command modules implement user-facing commands and preserve output contracts across interactive, non-TTY, `--quiet`, and `--json` modes. They should use `../cli-output.js` for presentation helpers and keep safety validation in command logic, not prompts.
- `commands-serve.js`
- Implements `openchamber serve`.
- Owns OpenCode CLI checks, port resolution, log rotation, PID/instance registry writes, foreground/background server launch, startup summaries, and foreground shutdown behavior.
- `commands-lifecycle.js`
- Implements `openchamber stop` and `openchamber restart`.
- Owns lifecycle stop/restart semantics, desktop-managed port rejection, unmanaged instance shutdown attempts, PID/instance cleanup, and restart reuse of stored instance options.
- `commands-status.js`
- Implements `openchamber status`.
- Formats discovered instances and tunnel readiness/status for human, quiet, and JSON output.
- `commands-logs.js`
- Implements `openchamber logs`.
- Resolves log files, tails recent lines, and follows log output.
- `commands-startup.js`
- Implements `openchamber startup`.
- Handles startup subcommand dispatch and presentation around the lower-level startup service helpers.
- `commands-connect-url.js`
- Implements `openchamber connect-url`.
- Finds or starts a local instance and prints the browser/connect URL according to the selected output mode.
- `commands-update.js`
- Implements `openchamber update`.
- Loads the package-manager helper, performs update flow, and coordinates restart behavior after updates.
- `commands-tunnel.js`
- Implements `openchamber tunnel` and its subcommands: `profile`, `providers`, `ready`, `doctor`, `status`, `start`, `stop`, and `completion`.
- Owns tunnel-specific command flow, interactive prompt decisions, managed-local/managed-remote startup, QR display rules, tunnel start/stop API calls, and tunnel profile command handling.
- Receives `serveCommand` and `stopCommand` by dependency injection. Do not reach back into `cli.js` command globals from this module.
## Shared Helper Modules
These modules hold reusable, non-presentational logic for commands.
- `cli-args.js`
- Argument parsing, defaults, help text, completion script generation, and typo suggestions.
- `cli-errors.js`
- CLI exit codes and typed tunnel CLI errors.
- `cli-paths.js`
- Data, run, log, settings, tunnel profile, and managed-local config paths.
- `cli-process.js`
- PID files, instance registry files, process identity checks, runtime metadata checks, and process termination helpers.
- `cli-lifecycle.js`
- Instance discovery, live health probing, attachability checks, provider discovery, and status aggregation used by lifecycle/status/tunnel commands.
- `cli-http.js`
- HTTP helpers for health checks, shutdown requests, JSON API calls, tunnel provider fetches, and system info fetches.
- `cli-network.js`
- Host resolution, URL building, LAN detection, unsafe browser port validation, and UI password/network exposure checks.
- `cli-ports.js`
- Port availability checks and available-port resolution.
- `cli-log-files.js`
- Log rotation, tail reads, and file-follow streaming.
- `cli-executables.js`
- Executable path resolution and PATH lookup helpers.
- `cli-startup.js`
- Native startup service detection, install/uninstall/status helpers, and platform-specific startup command execution.
- `cli-tunnel-profiles.js`
- Tunnel profile normalization, token resolution/redaction, profile storage, migration, file-permission warnings, and managed-remote pair persistence.
- `cli-tunnel-utils.js`
- Tunnel-specific command string builders, TTL parsing/formatting, and replay command helpers.
- `cli-tunnel-capabilities.js`
- Built-in tunnel provider capability fallbacks used when a live server cannot provide tunnel metadata.
## Placement Rules
- Add new CLI commands as `commands-*.js` modules and wire them from `cli.js`.
- Add reusable logic to the narrow helper module that owns the domain. Create a new helper module before mixing unrelated domains into an existing one.
- Keep command modules responsible for user-visible behavior and mode-specific output. Keep helper modules mostly output-free unless the helper exists specifically for CLI rendering.
- Preserve output contracts when moving code:
- `--json` emits JSON only.
- `--quiet` emits concise essential output.
- Prompts are gated by `canPrompt(options)`.
- Validation and policy run in every mode.
- Prefer dependency injection from `cli.js` for cross-command behavior, especially when one command needs another command's implementation.
- Do not import `cli.js` from modules in this directory. The dependency direction is `cli.js` -> command modules -> helper modules.
## Verification
For CLI behavior changes, run the focused CLI suite from `packages/web`:
```sh
bun run test -- bin/cli.test.js
```
Before finalizing source changes that affect CLI behavior, also run:
```sh
bun run type-check
bun run lint
```
+727
View File
@@ -0,0 +1,727 @@
import { TunnelCliError, EXIT_CODE } from './cli-errors.js';
const DEFAULT_PORT = 3000;
const DEFAULT_TAIL_LINES = 200;
function levenshteinDistance(a, b) {
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
function findClosestMatch(input, candidates, maxDistance = 3) {
if (typeof input !== 'string' || input.length === 0 || !Array.isArray(candidates)) {
return null;
}
const normalized = input.toLowerCase();
let bestCandidate = null;
let bestDistance = maxDistance + 1;
for (const candidate of candidates) {
const distance = levenshteinDistance(normalized, candidate.toLowerCase());
if (distance < bestDistance) {
bestDistance = distance;
bestCandidate = candidate;
}
}
return bestDistance <= maxDistance ? bestCandidate : null;
}
function splitOptionToken(arg) {
if (!arg.startsWith('-')) return null;
if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
return {
name: eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2),
inlineValue: eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined,
long: true,
};
}
return {
name: arg.slice(1),
inlineValue: undefined,
long: false,
};
}
function parseArgs(argv = process.argv.slice(2)) {
const args = Array.isArray(argv) ? [...argv] : [];
const options = {
port: DEFAULT_PORT,
host: undefined,
uiPassword: process.env.OPENCHAMBER_UI_PASSWORD || undefined,
json: false,
all: false,
follow: true,
lines: DEFAULT_TAIL_LINES,
provider: undefined,
mode: undefined,
profile: undefined,
name: undefined,
configPath: undefined,
token: undefined,
tokenFile: undefined,
tokenStdin: false,
hostname: undefined,
server: undefined,
connectTtl: undefined,
sessionTtl: undefined,
qr: false,
explicitQr: false,
force: false,
showSecrets: false,
dryRun: false,
plain: false,
quiet: false,
explicitPort: false,
explicitUiPassword: false,
envSnapshot: true,
foreground: false,
lan: false,
apiOnly: false,
};
const removedFlagErrors = [];
const positional = [];
let helpRequested = false;
let versionRequested = false;
const consumeValue = (index, inlineValue) => {
if (typeof inlineValue === 'string' && inlineValue.length > 0) {
return { value: inlineValue, nextIndex: index };
}
const candidate = args[index + 1];
if (typeof candidate === 'string' && !candidate.startsWith('-')) {
return { value: candidate, nextIndex: index + 1 };
}
return { value: undefined, nextIndex: index };
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const parsedToken = splitOptionToken(arg);
if (!parsedToken) {
positional.push(arg);
continue;
}
const { name, inlineValue, long } = parsedToken;
switch (name) {
case 'port':
case 'p': {
const { value: consumedValue, nextIndex: consumedIndex } = consumeValue(i, inlineValue);
let value = consumedValue;
let nextIndex = consumedIndex;
// Support explicit negative numeric values like `-p -1` so we can report
// a clear range validation error instead of "Unknown option".
if (value === undefined && typeof inlineValue !== 'string') {
const candidate = args[i + 1];
if (typeof candidate === 'string' && /^-\d+$/.test(candidate)) {
value = candidate;
nextIndex = i + 1;
}
}
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --port.', EXIT_CODE.USAGE_ERROR);
}
if (!/^-?\d+$/.test(value.trim())) {
throw new TunnelCliError(`Invalid port value: ${value}`, EXIT_CODE.USAGE_ERROR);
}
const parsed = parseInt(value, 10);
if (parsed < 1 || parsed > 65535) {
throw new TunnelCliError(`Invalid port value: ${parsed}`, EXIT_CODE.USAGE_ERROR);
}
options.port = parsed;
options.explicitPort = true;
break;
}
case 'host': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --host.', EXIT_CODE.USAGE_ERROR);
}
options.host = value.trim();
break;
}
case 'lan':
options.lan = true;
break;
case 'ui-password': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.uiPassword = typeof value === 'string' ? value : '';
options.explicitUiPassword = true;
break;
}
case 'provider': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.provider = typeof value === 'string' ? value : options.provider;
break;
}
case 'mode': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.mode = typeof value === 'string' ? value : options.mode;
break;
}
case 'profile': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.profile = typeof value === 'string' ? value : options.profile;
break;
}
case 'name': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.name = typeof value === 'string' ? value : options.name;
break;
}
case 'config': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.configPath = typeof value === 'string' ? value : null;
break;
}
case 'token': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.token = typeof value === 'string' ? value : options.token;
break;
}
case 'token-file': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.tokenFile = typeof value === 'string' ? value : options.tokenFile;
break;
}
case 'token-stdin':
options.tokenStdin = true;
break;
case 'hostname': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.hostname = typeof value === 'string' ? value : options.hostname;
break;
}
case 'server':
case 'server-url': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TunnelCliError('Missing value for --server.', EXIT_CODE.USAGE_ERROR);
}
options.server = value.trim();
break;
}
case 'connect-ttl': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.connectTtl = typeof value === 'string' ? value : options.connectTtl;
break;
}
case 'session-ttl': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
options.sessionTtl = typeof value === 'string' ? value : options.sessionTtl;
break;
}
case 'json':
options.json = true;
break;
case 'all':
options.all = true;
break;
case 'no-follow':
options.follow = false;
break;
case 'no-env-snapshot':
options.envSnapshot = false;
break;
case 'lines': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
const parsed = parseInt(value ?? '', 10);
if (Number.isFinite(parsed) && parsed > 0) {
options.lines = parsed;
}
break;
}
case 'qr':
options.qr = true;
options.explicitQr = true;
break;
case 'no-qr':
options.qr = false;
options.explicitQr = true;
break;
case 'force':
options.force = true;
break;
case 'show-secrets':
options.showSecrets = true;
break;
case 'dry-run':
options.dryRun = true;
break;
case 'plain':
options.plain = true;
break;
case 'quiet':
case 'q':
options.quiet = true;
break;
case 'help':
case 'h':
helpRequested = true;
break;
case 'version':
case 'v':
versionRequested = true;
break;
case 'foreground':
case 'no-daemon':
options.foreground = true;
break;
case 'api-only':
options.apiOnly = true;
break;
case 'daemon':
case 'd':
// Legacy no-op: daemon mode is already the default, but older clients
// may still pass this when starting a remote server.
break;
case 'try-cf-tunnel':
removedFlagErrors.push('`--try-cf-tunnel` was removed. Use: openchamber tunnel start --provider cloudflare --mode quick');
break;
case 'tunnel-qr':
removedFlagErrors.push('`--tunnel-qr` was removed. Use: openchamber tunnel start ... --qr');
break;
case 'tunnel-password-url':
removedFlagErrors.push('`--tunnel-password-url` was removed. Use UI password auth directly after tunnel start.');
break;
case 'tunnel-provider':
case 'tunnel-mode':
case 'tunnel-config':
case 'tunnel-token':
case 'tunnel-hostname':
case 'tunnel':
removedFlagErrors.push(`\`--${name}\` was removed from top-level serve flow. Use: openchamber tunnel start ...`);
break;
default:
if (!long && name.length === 1) {
removedFlagErrors.push(`Unknown option: -${name}`);
} else {
removedFlagErrors.push(`Unknown option: --${name}`);
}
break;
}
}
const command = positional[0] || 'serve';
const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
const startupAction = command === 'startup' ? (positional[1] || 'status') : null;
if (options.lan && typeof options.host !== 'string') {
options.host = '0.0.0.0';
}
if (command !== 'tunnel' && typeof options.hostname === 'string' && typeof options.host !== 'string') {
options.host = options.hostname;
}
return {
command,
subcommand,
tunnelAction,
startupAction,
options,
removedFlagErrors,
helpRequested,
versionRequested,
};
}
function showHelp() {
console.log(`
OpenChamber - Web interface for the OpenCode AI coding agent
USAGE:
openchamber [COMMAND] [OPTIONS]
COMMANDS:
serve Start the web server (daemon default)
stop Stop running instance(s)
restart Stop and start the server
status Show server status
tunnel Tunnel lifecycle commands
startup Manage launch at system startup
logs Tail OpenChamber logs
connect-url Generate URL/QR for connecting another client
update Check for and install updates
OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT})
--host Bind address (default: 127.0.0.1)
--hostname Alias for --host outside tunnel commands
--lan Bind to 0.0.0.0 for LAN access
--server <url> Public/server URL for connect-url links
--ui-password Protect browser UI with single password
--api-only Start API routes only, without serving browser UI assets
--foreground Run server in foreground (use with systemd/process managers)
--no-daemon Alias for --foreground
-h, --help Show help
-v, --version Show version
ENVIRONMENT:
OPENCHAMBER_HOST Bind address (e.g. 0.0.0.0 for all interfaces)
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
OPENCHAMBER_API_ONLY Set to true/1 to start API routes only
OPENCHAMBER_DATA_DIR Override OpenChamber data directory
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096
OPENCODE_PORT Port of external OpenCode server to connect to
OPENCODE_SKIP_START Skip starting OpenCode, use external server
OPENCHAMBER_OPENCODE_HOSTNAME Bind hostname for managed OpenCode server (default: 127.0.0.1)
EXAMPLES:
openchamber # Start in daemon mode on default port 3000 (or free port)
openchamber --port 8080 # Start on port 8080 (daemon)
openchamber --lan --port 3002 # Start on LAN at 0.0.0.0:3002
openchamber serve --foreground # Start in foreground (for systemd Type=simple)
openchamber connect-url --port 3000 --qr
openchamber connect-url --server https://openchamber.example.com
openchamber startup enable # Start OpenChamber at user login
openchamber tunnel help # Show tunnel lifecycle help
openchamber logs # Follow logs for latest running instance
`);
}
function showStartupHelp() {
console.log(`
OpenChamber Startup Commands
USAGE:
openchamber startup <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
status Show startup integration status
enable Install and start native user startup integration
disable Stop and remove native user startup integration
OPTIONS:
-p, --port Web server port used by startup service
--host Bind address used by startup service
--ui-password Protect browser UI with single password
--api-only Start API routes only, without serving browser UI assets
--no-env-snapshot Do not save current environment for startup service
--json Output machine-readable JSON
-q, --quiet Suppress non-essential output
EXAMPLES:
openchamber startup enable
openchamber startup enable --port 3000
openchamber startup enable --port 3000 --api-only --host 0.0.0.0
openchamber startup status --json
`);
}
function showConnectUrlHelp() {
console.log(`
OpenChamber Connect URL
USAGE:
openchamber connect-url [OPTIONS]
DESCRIPTION:
Generate an openchamber:// connection link for adding this server to another
OpenChamber app. If no server is running on the selected port, it starts one.
OPTIONS:
-p, --port <port> Server port to use or start (default: ${DEFAULT_PORT})
--host <address> Bind address when starting the server
--hostname <address> Alias for --host
--lan Bind to 0.0.0.0 for LAN access when starting
--server <url> Public URL saved into the connection link
--server-url <url> Alias for --server
--name <label> Label saved with the remote client token
--ui-password <value> Protect browser access when UI routes are enabled
--api-only Start in headless/API-only mode when starting
--qr Print a QR code for the connection link
--json Output machine-readable JSON
-q, --quiet Print only the connection link
-h, --help Show this help
EXAMPLES:
openchamber connect-url --port 3000 --qr
openchamber connect-url --port 3000 --api-only --lan --server http://workstation.local:3000 --qr
openchamber connect-url --server https://openchamber.example.com --name Workstation
`);
}
function showTunnelHelp() {
console.log(`
Tunnel Lifecycle Commands
USAGE:
openchamber tunnel <SUBCOMMAND> [OPTIONS]
SUBCOMMANDS:
help Show this tunnel help
providers Show available tunnel providers and capabilities
ready Check tunnel readiness for a provider
doctor Run deep tunnel diagnostics
status Show tunnel status
start Start a tunnel
stop Stop active tunnel (keep server running)
profile Manage saved managed-remote profiles
COMMON OPTIONS:
-p, --port Target OpenChamber instance port
--host Bind address when auto-starting an instance
--lan Bind to 0.0.0.0 when auto-starting an instance
--ui-password Protect browser UI when auto-starting an instance
--api-only Start API routes only when auto-starting an instance
--json Output machine-readable JSON
--all Apply to all running instances (doctor default, stop)
START OPTIONS:
--provider <id> Tunnel provider id (default: cloudflare)
--mode <id> Tunnel mode (default: quick)
--profile <name> Start tunnel from saved profile name
--config [path] Managed-local config path (optional)
--token <token> Managed-remote token (visible in process list)
--token-file <path> Read token from file (recommended)
--token-stdin Read token from stdin
--hostname <hostname> Managed-remote hostname
--connect-ttl <value> Connect-link TTL (e.g. 30m, 24h, 1d)
--session-ttl <value> Session TTL (e.g. 8h, 24h, 1d)
--qr Print QR code for resulting tunnel URL
--no-qr Disable QR output
--dry-run Validate inputs without applying changes
OUTPUT OPTIONS:
--show-secrets Show full tokens in output (default: redacted)
--plain Disable colors and decorations
-q, --quiet Suppress non-essential output
--json Output machine-readable JSON
BEHAVIOR NOTES:
- One active tunnel per OpenChamber instance.
- Starting a different mode/provider replaces the current tunnel and revokes old connect links/sessions.
- Connect links are one-time; generating a new link revokes the previous unused link.
PROFILE USAGE:
openchamber tunnel profile list [--provider <id>] [--json]
openchamber tunnel profile show --name <name> [--provider <id>] [--json]
openchamber tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token <token> [--force] [--json]
openchamber tunnel profile add --provider <id> --mode managed-remote --name <name> --hostname <host> --token-file <path> [--force] [--json]
openchamber tunnel profile remove --name <name> [--provider <id>] [--json]
SHELL COMPLETION:
openchamber tunnel completion bash Generate Bash completion script
openchamber tunnel completion zsh Generate Zsh completion script
openchamber tunnel completion fish Generate Fish completion script
EXAMPLES:
openchamber tunnel providers
openchamber tunnel ready --provider cloudflare
openchamber tunnel doctor --provider cloudflare
openchamber tunnel status
openchamber tunnel start --qr
openchamber tunnel start --profile prod-main
openchamber tunnel start --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml
openchamber tunnel start --dry-run --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com
echo "$TOKEN" | openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token-stdin
openchamber tunnel profile list --provider cloudflare
openchamber tunnel profile list --json --show-secrets
openchamber tunnel stop --port 3000
`);
}
function generateCompletionScript(shell) {
const normalized = typeof shell === 'string' ? shell.trim().toLowerCase() : '';
if (normalized === 'bash') {
return `# Bash completion for openchamber tunnel
# Add to ~/.bashrc: eval "$(openchamber tunnel completion bash)"
_openchamber_tunnel() {
local cur prev commands tunnel_commands profile_commands common_flags start_flags
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
commands="serve stop restart status tunnel logs update"
tunnel_commands="help providers ready doctor status start stop profile completion"
profile_commands="list show add remove"
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"
if [[ \${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[1]}" == "tunnel" ]]; then
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "\${tunnel_commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "profile" && \${COMP_CWORD} -eq 3 ]]; then
COMPREPLY=( $(compgen -W "\${profile_commands}" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "completion" && \${COMP_CWORD} -eq 3 ]]; then
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
return 0
fi
if [[ "\${COMP_WORDS[2]}" == "start" ]]; then
COMPREPLY=( $(compgen -W "\${start_flags} \${common_flags}" -- "\${cur}") )
return 0
fi
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
return 0
fi
COMPREPLY=( $(compgen -W "\${common_flags}" -- "\${cur}") )
return 0
}
complete -F _openchamber_tunnel openchamber
`;
}
if (normalized === 'zsh') {
return `#compdef openchamber
# Zsh completion for openchamber tunnel
# Add to ~/.zshrc: eval "$(openchamber tunnel completion zsh)"
_openchamber() {
local -a commands tunnel_commands profile_commands
commands=(
'serve:Start the web server'
'stop:Stop running instance(s)'
'restart:Stop and start the server'
'status:Show server status'
'tunnel:Tunnel lifecycle commands'
'logs:Tail OpenChamber logs'
'update:Check for and install updates'
)
tunnel_commands=(
'help:Show tunnel help'
'providers:Show available providers'
'ready:Check tunnel readiness'
'doctor:Run tunnel diagnostics'
'status:Show tunnel status'
'start:Start a tunnel'
'stop:Stop active tunnel'
'profile:Manage saved profiles'
'completion:Generate shell completion'
)
profile_commands=(
'list:List profiles'
'show:Show profile details'
'add:Add a profile'
'remove:Remove a profile'
)
_arguments -C \\
'1:command:->command' \\
'*::arg:->args'
case \$state in
command)
_describe 'command' commands
;;
args)
case \$words[1] in
tunnel)
if (( CURRENT == 2 )); then
_describe 'tunnel command' tunnel_commands
elif [[ \$words[2] == "profile" ]] && (( CURRENT == 3 )); then
_describe 'profile action' profile_commands
elif [[ \$words[2] == "completion" ]] && (( CURRENT == 3 )); then
_values 'shell' bash zsh fish
fi
;;
esac
;;
esac
}
compdef _openchamber openchamber
`;
}
if (normalized === 'fish') {
return `# Fish completion for openchamber tunnel
# 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_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 '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 'tunnel' -d 'Tunnel lifecycle commands'
complete -c openchamber -n '__fish_use_subcommand' -a 'logs' -d 'Tail logs'
complete -c openchamber -n '__fish_use_subcommand' -a 'update' -d 'Check for updates'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'help' -d 'Show tunnel help'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'providers' -d 'Show providers'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'ready' -d 'Check readiness'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'doctor' -d 'Run diagnostics'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'status' -d 'Show tunnel status'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'start' -d 'Start a tunnel'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'stop' -d 'Stop tunnel'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'profile' -d 'Manage profiles'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and not __fish_seen_subcommand_from help providers ready doctor status start stop profile completion' -a 'completion' -d 'Generate completions'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l provider -d 'Provider id'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l mode -d 'Tunnel mode'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l profile -d 'Profile name'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l config -d 'Config path'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token -d 'Token'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-file -d 'Token file path'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l token-stdin -d 'Read token from stdin'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l hostname -d 'Hostname'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l dry-run -d 'Validate without applying'
complete -c openchamber -n '__fish_seen_subcommand_from tunnel; and __fish_seen_subcommand_from start' -l qr -d 'Show QR code'
`;
}
return null;
}
export {
DEFAULT_PORT,
DEFAULT_TAIL_LINES,
parseArgs,
showHelp,
showStartupHelp,
showConnectUrlHelp,
showTunnelHelp,
generateCompletionScript,
findClosestMatch,
};
+18
View File
@@ -0,0 +1,18 @@
const EXIT_CODE = {
SUCCESS: 0,
GENERAL_ERROR: 1,
USAGE_ERROR: 2,
MISSING_DEPENDENCY: 3,
AUTH_CONFIG_ERROR: 4,
NETWORK_RUNTIME_ERROR: 5,
};
class TunnelCliError extends Error {
constructor(message, exitCode = EXIT_CODE.GENERAL_ERROR) {
super(message);
this.name = 'TunnelCliError';
this.exitCode = exitCode;
}
}
export { EXIT_CODE, TunnelCliError };
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
const WINDOWS_EXTENSIONS = process.platform === 'win32'
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
.split(';')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
: [''];
function isExecutable(filePath) {
try {
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
return false;
}
if (process.platform === 'win32') {
return true;
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function resolveExplicitBinary(candidate) {
if (!candidate) {
return null;
}
if (candidate.includes(path.sep) || path.isAbsolute(candidate)) {
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
return isExecutable(resolved) ? resolved : null;
}
return null;
}
function searchPathFor(command) {
const pathValue = process.env.PATH || '';
const segments = pathValue.split(path.delimiter).filter(Boolean);
for (const dir of segments) {
for (const ext of WINDOWS_EXTENSIONS) {
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
const candidate = path.join(dir, fileName);
if (isExecutable(candidate)) {
return candidate;
}
}
}
return null;
}
export { isExecutable, resolveExplicitBinary, searchPathFor };
+216
View File
@@ -0,0 +1,216 @@
import { buildLocalUrl } from './cli-network.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
const UI_SESSION_COOKIE_NAME = 'oc_ui_session';
function extractUiSessionCookie(response) {
const setCookie = response?.headers?.get?.('set-cookie');
if (typeof setCookie !== 'string' || setCookie.length === 0) {
return null;
}
const match = setCookie.match(new RegExp(`(?:^|,\\s*)(${UI_SESSION_COOKIE_NAME}=[^;]+)`));
return match?.[1] || null;
}
async function resolveUiPasswordForPort(port, options = {}) {
if (typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0) {
return options.uiPassword;
}
const instanceOptions = readInstanceOptions(await getInstanceFilePath(port));
return typeof instanceOptions?.uiPassword === 'string' && instanceOptions.uiPassword.trim().length > 0
? instanceOptions.uiPassword
: null;
}
async function createUiSessionCookie(port, password, timeoutMs) {
if (typeof password !== 'string' || password.length === 0) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(buildLocalUrl(port, '/auth/session'), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
signal: controller.signal,
});
if (!response.ok) {
return null;
}
return extractUiSessionCookie(response);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function requestServerShutdown(port, hostOverride) {
if (!Number.isFinite(port) || port <= 0) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown', hostOverride), {
method: 'POST',
signal: controller.signal,
});
return resp.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function requestJson(port, endpoint, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? Math.trunc(options.timeoutMs)
: 4000;
const fetchOptions = { ...options };
delete fetchOptions.timeoutMs;
delete fetchOptions.uiPassword;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const requestUrl = buildLocalUrl(port, endpoint);
const requestHeaders = {
Accept: 'application/json',
...(fetchOptions.body ? { 'Content-Type': 'application/json' } : {}),
...(fetchOptions.headers || {}),
};
const response = await fetch(requestUrl, {
...fetchOptions,
headers: requestHeaders,
signal: controller.signal,
});
const body = await response.json().catch(() => null);
if (response.status === 401 && body?.error === 'UI authentication required') {
const uiPassword = await resolveUiPasswordForPort(port, options);
const cookie = await createUiSessionCookie(port, uiPassword, timeoutMs);
if (cookie) {
const retryResponse = await fetch(requestUrl, {
...fetchOptions,
headers: {
...requestHeaders,
Cookie: cookie,
},
signal: controller.signal,
});
const retryBody = await retryResponse.json().catch(() => null);
return { response: retryResponse, body: retryBody };
}
}
return { response, body };
} catch (error) {
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
throw new Error(`Request to ${endpoint} timed out after ${timeoutMs}ms.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function isServerHealthReady(port, timeoutMs = 1000) {
if (!Number.isFinite(port) || port <= 0) {
return false;
}
const requestTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : 1000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeout);
try {
const response = await fetch(buildLocalUrl(port, '/health'), {
headers: { Accept: 'text/plain' },
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function waitForServerHealth(port, {
timeoutMs = 60000,
intervalMs = 250,
onTick,
} = {}) {
const start = Date.now();
const deadline = start + timeoutMs;
while (Date.now() < deadline) {
const elapsedMs = Date.now() - start;
if (typeof onTick === 'function') {
onTick({ elapsedMs, timeoutMs });
}
if (await isServerHealthReady(port, Math.min(1000, intervalMs * 2))) {
if (typeof onTick === 'function') {
onTick({ elapsedMs: Math.min(Date.now() - start, timeoutMs), timeoutMs, complete: true });
}
return true;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (typeof onTick === 'function') {
onTick({ elapsedMs: timeoutMs, timeoutMs, timedOut: true });
}
return false;
}
async function fetchTunnelProvidersFromPort(port, fetchImpl = globalThis.fetch) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/openchamber/tunnel/providers'));
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || !Array.isArray(body.providers)) return null;
return body.providers;
} catch {
return null;
}
}
async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch, hostOverride) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/system/info', hostOverride), {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || typeof body.runtime !== 'string') return null;
return {
runtime: body.runtime,
pid: Number.isFinite(body.pid) ? body.pid : null,
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export {
requestServerShutdown,
requestJson,
isServerHealthReady,
waitForServerHealth,
fetchTunnelProvidersFromPort,
fetchSystemInfoFromPort,
};
+421
View File
@@ -0,0 +1,421 @@
import fs from 'fs';
import path from 'path';
import { DEFAULT_PORT } from './cli-args.js';
import { getRunDir, readDesktopLocalPortFromSettings } from './cli-paths.js';
import { resolveApiHost, buildLocalUrl } from './cli-network.js';
import { fetchTunnelProvidersFromPort, fetchSystemInfoFromPort, isServerHealthReady } from './cli-http.js';
import { isPortAvailable } from './cli-ports.js';
import {
getPidFilePath,
getInstanceFilePath,
readPidFile,
removePidFile,
readInstanceOptions,
removeInstanceFile,
getOpenchamberProcessState,
hasOpenchamberRuntimeInfo,
} from './cli-process.js';
import { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES } from './cli-tunnel-capabilities.js';
function createLivePortInstance(port, info, host) {
if (!hasOpenchamberRuntimeInfo(info)) return null;
return {
port,
pid: Number.isFinite(info.pid) ? info.pid : null,
pidFilePath: path.join(getRunDir(), `openchamber-${port}.pid`),
instanceFilePath: path.join(getRunDir(), `openchamber-${port}.json`),
mtime: 0,
startedAt: 0,
launchMode: 'daemon',
runtime: info.runtime,
source: 'probe',
host: typeof host === 'string' && host.length > 0 ? host : undefined,
};
}
function normalizeProbeHost(host) {
return typeof host === 'string' && host.trim().length > 0 ? host.trim() : undefined;
}
function isWildcardProbeHost(host) {
const normalized = normalizeProbeHost(host);
return normalized === '0.0.0.0' || normalized === '::' || normalized === '[::]';
}
function isLoopbackProbeHost(host) {
const normalized = normalizeProbeHost(host);
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]';
}
function isConcreteProbeHost(host) {
const normalized = normalizeProbeHost(host);
return Boolean(normalized && !isWildcardProbeHost(normalized) && !isLoopbackProbeHost(normalized));
}
function getSystemInfoProbeHosts(...hosts) {
const out = [];
const hasConcreteAuthoritativeHost = hosts.some(isConcreteProbeHost);
const pushHost = (host, requiresPidMatch = false) => {
const normalized = normalizeProbeHost(host);
const key = resolveApiHost(normalized);
if (!out.some((entry) => resolveApiHost(entry.host) === key)) {
out.push({ host: normalized, requiresPidMatch });
}
};
for (const host of hosts) {
if (normalizeProbeHost(host)) {
pushHost(host, false);
}
}
pushHost(undefined, hasConcreteAuthoritativeHost);
pushHost('127.0.0.1', hasConcreteAuthoritativeHost);
return out;
}
async function fetchSystemInfoFromPortCandidates(port, fetchImpl, hosts, expectedPid) {
for (const { host, requiresPidMatch } of hosts) {
const info = await fetchSystemInfoFromPort(port, fetchImpl, host);
if (hasOpenchamberRuntimeInfo(info)) {
if (requiresPidMatch && info.pid !== expectedPid) {
continue;
}
return { info, host };
}
}
return { info: null, host: null };
}
async function resolveDoctorPortStatuses(options = {}) {
const runningEntries = await discoverRunningInstances();
const desktopEntry = await discoverDesktopInstance();
const statuses = [];
if (options.explicitPort) {
const requestedPort = options.port;
const runningMatch = runningEntries.find((entry) => entry.port === requestedPort);
if (runningMatch) {
statuses.push({
port: requestedPort,
available: true,
status: 'success',
line: `port ${requestedPort} available for tunneling`,
detail: 'Double-check this same port is configured in your provider dashboard/config.',
});
return { statuses, availableEntries: [runningMatch] };
}
if (desktopEntry && desktopEntry.port === requestedPort) {
statuses.push({
port: requestedPort,
available: false,
status: 'warning',
line: `port ${requestedPort} not available (desktop runtime)`,
detail: 'Use a CLI instance port from `openchamber serve` for tunneling.',
});
return { statuses, availableEntries: [] };
}
statuses.push({
port: requestedPort,
available: false,
status: 'error',
line: `port ${requestedPort} not available (no running instance)`,
detail: `Start one with \`openchamber serve --port ${requestedPort}\`.`,
});
return { statuses, availableEntries: [] };
}
for (const entry of runningEntries) {
statuses.push({
port: entry.port,
available: true,
status: 'success',
line: `port ${entry.port} available for tunneling`,
detail: 'Double-check this same port is configured in your provider dashboard/config.',
});
}
if (desktopEntry && !runningEntries.some((entry) => entry.port === desktopEntry.port)) {
statuses.push({
port: desktopEntry.port,
available: false,
status: 'warning',
line: `port ${desktopEntry.port} not available (desktop runtime)`,
detail: 'Use a CLI instance port from `openchamber serve` for tunneling.',
});
}
if (runningEntries.length === 0) {
statuses.push({
port: null,
available: false,
status: 'warning',
line: 'no CLI ports available for tunneling',
detail: 'Start one with `openchamber serve`.',
});
}
return { statuses, availableEntries: runningEntries };
}
async function discoverRunningInstances(options = {}) {
const instances = [];
const runDir = getRunDir();
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
const getProcessState = typeof options.getOpenchamberProcessState === 'function'
? options.getOpenchamberProcessState
: (pid) => getOpenchamberProcessState(pid, options);
try {
const files = fs.readdirSync(runDir);
const pidFiles = files.filter((file) => file.startsWith('openchamber-') && file.endsWith('.pid'));
for (const file of pidFiles) {
const port = parseInt(file.replace('openchamber-', '').replace('.pid', ''), 10);
if (!Number.isFinite(port) || port <= 0) continue;
const pidFilePath = path.join(runDir, file);
const pid = readPidFile(pidFilePath);
if (!pid) {
removePidFile(pidFilePath);
removeInstanceFile(path.join(runDir, `openchamber-${port}.json`));
continue;
}
const instanceFilePath = path.join(runDir, `openchamber-${port}.json`);
const storedOptions = readInstanceOptions(instanceFilePath);
const processState = getProcessState(pid);
if (processState === 'dead') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
continue;
}
// A live PID-file is only the right instance if the recorded port also
// confirms OpenChamber. Cmdline identity alone can match a recycled PID
// from another OpenChamber process on a different port. Try all plausible
// hosts first; if matched/unknown identity still can't be confirmed, keep
// the registry files but don't claim the instance is running.
const { info: liveInfo, host: confirmedHost } = await fetchSystemInfoFromPortCandidates(
port,
fetchImpl,
getSystemInfoProbeHosts(storedOptions?.host, options.host),
pid,
);
const livePid = Number.isFinite(liveInfo?.pid) ? liveInfo.pid : null;
if (!hasOpenchamberRuntimeInfo(liveInfo)) {
if (processState === 'mismatched') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
}
continue;
}
if (liveInfo.runtime === 'desktop') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
continue;
}
let mtime = 0;
let startedAt = 0;
try {
mtime = fs.statSync(pidFilePath).mtimeMs;
} catch {
}
if (Number.isFinite(storedOptions?.startedAt)) {
startedAt = storedOptions.startedAt;
}
const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon';
instances.push({
port,
pid: livePid || (processState === 'matched' ? pid : null),
pidFilePath,
instanceFilePath,
mtime,
startedAt,
launchMode,
runtime: liveInfo.runtime,
source: 'registry+probe',
host: typeof confirmedHost === 'string' && confirmedHost.length > 0
? confirmedHost
: (typeof storedOptions?.host === 'string' && storedOptions.host.length > 0 ? storedOptions.host : undefined),
});
}
} catch {
}
instances.sort((a, b) => a.port - b.port);
return instances;
}
async function discoverOpenChamberInstanceOnPort(port, options = {}) {
if (!Number.isFinite(port) || port <= 0) return null;
const runningInstances = Array.isArray(options.runningInstances)
? options.runningInstances
: await discoverRunningInstances(options);
const registryMatch = runningInstances.find((entry) => entry.port === port);
if (registryMatch) return registryMatch;
const info = await fetchSystemInfoFromPort(
port,
typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch,
options.host,
);
if (info?.runtime === 'desktop' && !isDesktopRuntimeForPort(info, port)) {
return null;
}
return createLivePortInstance(port, info, options.host);
}
async function discoverLifecycleInstances(options = {}, deps = {}) {
const runningInstances = await discoverRunningInstances({ ...deps, host: options.host });
if (!options.explicitPort) {
return runningInstances;
}
const found = runningInstances.find((entry) => entry.port === options.port);
if (found) return [found];
const liveInstance = await discoverOpenChamberInstanceOnPort(options.port, {
...deps,
host: options.host,
runningInstances,
});
return liveInstance ? [liveInstance] : [];
}
async function discoverUnconfirmedRegistryInstanceOnPort(port, options = {}) {
if (!Number.isFinite(port) || port <= 0) return null;
const pidFilePath = await getPidFilePath(port);
const pid = readPidFile(pidFilePath);
if (!pid) return null;
const instanceFilePath = await getInstanceFilePath(port);
const storedOptions = readInstanceOptions(instanceFilePath);
const processState = getOpenchamberProcessState(pid);
if (processState === 'dead') {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
return null;
}
if (processState !== 'matched') {
return null;
}
const host = storedOptions?.host || options.host;
if (await isPortAvailable(port, host)) {
removePidFile(pidFilePath);
removeInstanceFile(instanceFilePath);
return null;
}
return {
port,
pid,
pidFilePath,
instanceFilePath,
mtime: 0,
startedAt: Number.isFinite(storedOptions?.startedAt) ? storedOptions.startedAt : 0,
launchMode: storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon',
runtime: 'cli',
source: 'registry-unconfirmed',
host: typeof host === 'string' && host.length > 0 ? host : undefined,
};
}
function getLatestInstance(instances) {
if (!instances.length) return null;
return [...instances].sort((a, b) => {
const startedDelta = (b.startedAt || 0) - (a.startedAt || 0);
if (startedDelta !== 0) return startedDelta;
const mtimeDelta = (b.mtime || 0) - (a.mtime || 0);
if (mtimeDelta !== 0) return mtimeDelta;
return b.port - a.port;
})[0];
}
function isDesktopRuntimeForPort(info, port) {
if (info?.runtime !== 'desktop') {
return false;
}
const desktopPort = readDesktopLocalPortFromSettings();
return !desktopPort || desktopPort === port;
}
async function inspectTunnelAttachability(port, { requireHealthy = true } = {}) {
const info = await fetchSystemInfoFromPort(port);
if (!info || typeof info.runtime !== 'string') {
return { attachable: false, reason: 'unreachable' };
}
if (isDesktopRuntimeForPort(info, port)) {
return { attachable: false, reason: 'desktop', info };
}
if (requireHealthy) {
const healthy = await isServerHealthReady(port, 1200);
if (!healthy) {
return { attachable: false, reason: 'unhealthy', info };
}
}
return { attachable: true, reason: 'ok', info };
}
async function discoverDesktopInstance(fetchImpl = globalThis.fetch) {
const port = readDesktopLocalPortFromSettings();
if (!port) {
return null;
}
const info = await fetchSystemInfoFromPort(port, fetchImpl);
if (!info || info.runtime !== 'desktop') {
return null;
}
return {
port,
pid: info.pid,
runtime: info.runtime,
};
}
async function resolveTunnelProviders(options = {}, deps = {}) {
const readPorts = typeof deps.readPorts === 'function'
? deps.readPorts
: async () => (await discoverRunningInstances()).map((entry) => entry.port);
const fetchImpl = typeof deps.fetchImpl === 'function' ? deps.fetchImpl : globalThis.fetch;
const candidatePorts = [];
if (Number.isFinite(options.port) && options.port > 0) {
candidatePorts.push(options.port);
}
const discoveredPorts = await Promise.resolve(readPorts());
if (Array.isArray(discoveredPorts)) {
candidatePorts.push(...discoveredPorts);
}
if (!candidatePorts.includes(DEFAULT_PORT)) {
candidatePorts.push(DEFAULT_PORT);
}
for (const port of candidatePorts) {
const providers = await fetchTunnelProvidersFromPort(port, fetchImpl);
if (providers) {
return { providers, source: `api:${port}` };
}
}
return { providers: DEFAULT_TUNNEL_PROVIDER_CAPABILITIES, source: 'fallback' };
}
export {
resolveDoctorPortStatuses,
discoverRunningInstances,
discoverOpenChamberInstanceOnPort,
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
getLatestInstance,
isDesktopRuntimeForPort,
inspectTunnelAttachability,
discoverDesktopInstance,
resolveTunnelProviders,
};
+93
View File
@@ -0,0 +1,93 @@
import fs from 'fs';
const DEFAULT_TAIL_LINES = 200;
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
const LOG_ROTATE_KEEP = 5;
function rotateLogFile(logPath) {
try {
const stats = fs.statSync(logPath);
if (stats.size < LOG_ROTATE_MAX_BYTES) {
return;
}
} catch {
return;
}
for (let i = LOG_ROTATE_KEEP - 1; i >= 1; i--) {
const src = `${logPath}.${i}`;
const dst = `${logPath}.${i + 1}`;
if (fs.existsSync(src)) {
try {
fs.renameSync(src, dst);
} catch {
}
}
}
try {
if (fs.existsSync(logPath)) {
fs.renameSync(logPath, `${logPath}.1`);
}
} catch {
}
}
function readTailLines(filePath, lineCount = DEFAULT_TAIL_LINES) {
if (!fs.existsSync(filePath)) {
return [];
}
const raw = fs.readFileSync(filePath, 'utf8');
const lines = raw.split(/\r?\n/);
if (lines.length && lines[lines.length - 1] === '') {
lines.pop();
}
return lines.slice(Math.max(0, lines.length - lineCount));
}
function followFile(filePath, onLine) {
let position = 0;
try {
position = fs.statSync(filePath).size;
} catch {
position = 0;
}
let remainder = '';
const interval = setInterval(() => {
try {
const stats = fs.statSync(filePath);
if (stats.size < position) {
position = 0;
}
if (stats.size === position) {
return;
}
const fd = fs.openSync(filePath, 'r');
try {
const length = stats.size - position;
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, position);
position = stats.size;
const chunk = remainder + buffer.toString('utf8');
const parts = chunk.split(/\r?\n/);
remainder = parts.pop() || '';
for (const line of parts) {
onLine(line);
}
} finally {
fs.closeSync(fd);
}
} catch {
}
}, 400);
return () => {
clearInterval(interval);
};
}
export { rotateLogFile, readTailLines, followFile };
+155
View File
@@ -0,0 +1,155 @@
import dgram from 'dgram';
import os from 'os';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
getUnauthenticatedLanErrorMessage,
isNetworkExposedBindHost,
isUnsafeUnauthenticatedLanAllowed,
} from '../../server/lib/security/bind-host.js';
// Browser-unsafe ports (Fetch/Chromium restricted ports).
const UNSAFE_BROWSER_PORTS = new Set([
0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69,
77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119,
123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
6665, 6666, 6667, 6668, 6669, 6697, 10080,
]);
function isUnsafeBrowserPort(port) {
return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port));
}
function resolveConfiguredBindHost(hostOverride) {
const configured = typeof hostOverride === 'string' && hostOverride.trim()
? hostOverride.trim()
: typeof process.env.OPENCHAMBER_HOST === 'string'
? process.env.OPENCHAMBER_HOST.trim()
: '';
return configured || '127.0.0.1';
}
function resolveServeHost(hostOverride) {
return resolveConfiguredBindHost(hostOverride);
}
function resolveApiHost(hostOverride) {
const configured = resolveConfiguredBindHost(hostOverride);
if (!configured) {
return '127.0.0.1';
}
// Wildcard bind hosts are not valid destination hosts.
if (configured === '0.0.0.0') {
return '127.0.0.1';
}
if (configured === '::' || configured === '[::]') {
return '::1';
}
// Strip brackets if user provided [::1]
if (configured.startsWith('[') && configured.endsWith(']')) {
return configured.slice(1, -1);
}
return configured;
}
function formatHostForUrl(host) {
if (typeof host !== 'string') return '127.0.0.1';
// Bracket IPv6 for URL usage.
return host.includes(':') ? `[${host}]` : host;
}
function buildLocalUrl(port, endpoint = '', hostOverride) {
const host = formatHostForUrl(resolveApiHost(hostOverride));
const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
return `http://${host}:${port}${pathPart}`;
}
async function detectLanIPv4Address() {
const ip = await new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const finish = (value) => {
try { socket.close(); } catch {}
resolve(value);
};
socket.once('error', () => finish(null));
try {
socket.connect(80, '8.8.8.8', (error) => {
if (error) return finish(null);
try {
const addr = socket.address();
finish(addr && typeof addr.address === 'string' ? addr.address : null);
} catch {
finish(null);
}
});
} catch {
finish(null);
}
});
if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip;
for (const entries of Object.values(os.networkInterfaces() || {})) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal && entry.address) {
return entry.address;
}
}
}
return null;
}
function formatUnsafePortWarning(port) {
return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for OpenChamber UI at ${buildLocalUrl(port, '/')}.`;
}
function assertSafeBrowserPort(port, { context = 'This action' } = {}) {
if (!isUnsafeBrowserPort(port)) {
return;
}
throw new TunnelCliError(
`${context} cannot use port ${port}. ${formatUnsafePortWarning(port)} Use a safe port such as 3000, 5173, 8080, or a high ephemeral port.`,
EXIT_CODE.USAGE_ERROR,
);
}
function hasUiPasswordConfigured(password) {
return typeof password === 'string' && password.trim().length > 0;
}
function assertAuthenticatedNetworkExposure({ host, uiPassword }) {
const bindHost = resolveConfiguredBindHost(host);
if (hasUiPasswordConfigured(uiPassword)) {
return;
}
if (!isNetworkExposedBindHost(bindHost)) {
return;
}
if (isUnsafeUnauthenticatedLanAllowed(process.env)) {
return;
}
throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR);
}
export {
isUnsafeBrowserPort,
resolveConfiguredBindHost,
resolveServeHost,
resolveApiHost,
formatHostForUrl,
buildLocalUrl,
detectLanIPv4Address,
formatUnsafePortWarning,
assertSafeBrowserPort,
hasUiPasswordConfigured,
assertAuthenticatedNetworkExposure,
};
+117
View File
@@ -0,0 +1,117 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json';
const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json';
const TUNNEL_CLI_STATE_FILE_NAME = 'tunnel-cli-state.json';
function getDataDir() {
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
return path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
}
return path.join(os.homedir(), '.config', 'openchamber');
}
function getLogsDir() {
return path.join(getDataDir(), 'logs');
}
function getSettingsFilePath() {
return path.join(getDataDir(), 'settings.json');
}
function readDesktopLocalPortFromSettings() {
try {
const raw = fs.readFileSync(getSettingsFilePath(), 'utf8');
const parsed = JSON.parse(raw);
const value = parsed?.desktopLocalPort;
if (Number.isFinite(value) && value > 0 && value <= 65535) {
return value;
}
return null;
} catch {
return null;
}
}
function ensureLogsDir() {
fs.mkdirSync(getLogsDir(), { recursive: true });
}
function getLogFilePath(port) {
return path.join(getLogsDir(), `openchamber-${port}.log`);
}
function getTunnelProfilesFilePath() {
return path.join(getDataDir(), TUNNEL_PROFILES_FILE_NAME);
}
function getLegacyCloudflareManagedRemoteFilePath() {
return path.join(getDataDir(), LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME);
}
function getTunnelCliStateFilePath() {
return path.join(getDataDir(), TUNNEL_CLI_STATE_FILE_NAME);
}
function readTunnelCliState() {
const filePath = getTunnelCliStateFilePath();
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return parsed;
} catch {
return {};
}
}
function readLastManagedLocalConfigPath() {
const state = readTunnelCliState();
if (typeof state.lastManagedLocalConfigPath !== 'string') {
return '';
}
return state.lastManagedLocalConfigPath.trim();
}
function writeLastManagedLocalConfigPath(configPath) {
if (typeof configPath !== 'string' || configPath.trim().length === 0) {
return;
}
const filePath = getTunnelCliStateFilePath();
const current = readTunnelCliState();
const next = {
...current,
lastManagedLocalConfigPath: configPath.trim(),
updatedAt: Date.now(),
};
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2), 'utf8');
}
function getRunDir() {
const dir = path.join(getDataDir(), 'run');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
}
export {
getDataDir,
getLogsDir,
getSettingsFilePath,
readDesktopLocalPortFromSettings,
ensureLogsDir,
getLogFilePath,
getTunnelProfilesFilePath,
getLegacyCloudflareManagedRemoteFilePath,
getTunnelCliStateFilePath,
readTunnelCliState,
readLastManagedLocalConfigPath,
writeLastManagedLocalConfigPath,
getRunDir,
};
+51
View File
@@ -0,0 +1,51 @@
import net from 'net';
import { DEFAULT_PORT } from './cli-args.js';
import { fetchSystemInfoFromPort } from './cli-http.js';
async function isPortAvailable(port, host) {
if (!Number.isFinite(port) || port <= 0) {
return false;
}
return await new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.on('error', () => resolve(false));
server.listen({ port, host }, () => {
server.close(() => resolve(true));
});
});
}
async function resolveAvailablePort(desiredPort, explicitPort = false, onNotice) {
const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT;
if (explicitPort) {
return startPort;
}
if (await isPortAvailable(startPort)) {
return startPort;
}
const occupant = await fetchSystemInfoFromPort(startPort);
let message;
if (occupant?.runtime === 'desktop') {
message = `Port ${startPort} is used by OpenChamber Desktop; using a free port`;
} else if (occupant?.runtime) {
message = `Port ${startPort} is used by an existing OpenChamber instance; using a free port`;
} else {
message = `Port ${startPort} in use; using a free port`;
}
if (typeof onNotice === 'function' && message) {
onNotice({
level: 'warning',
code: 'PORT_REASSIGNED',
message,
});
} else if (message) {
console.warn(message);
}
return 0;
}
export { isPortAvailable, resolveAvailablePort };
+294
View File
@@ -0,0 +1,294 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { getRunDir } from './cli-paths.js';
async function getPidFilePath(port) {
return path.join(getRunDir(), `openchamber-${port}.pid`);
}
async function getInstanceFilePath(port) {
return path.join(getRunDir(), `openchamber-${port}.json`);
}
function readPidFile(pidFilePath) {
try {
const content = fs.readFileSync(pidFilePath, 'utf8').trim();
const pid = parseInt(content, 10);
return Number.isFinite(pid) ? pid : null;
} catch {
return null;
}
}
function writePidFile(pidFilePath, pid, onNotice) {
try {
fs.writeFileSync(pidFilePath, String(pid), { mode: 0o600 });
} catch (error) {
const message = `Could not write PID file: ${error.message}`;
if (typeof onNotice === 'function') {
onNotice({ level: 'warning', code: 'PID_FILE_WRITE_FAILED', message });
} else {
console.warn(`Warning: ${message}`);
}
}
}
function removePidFile(pidFilePath) {
try {
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
} catch {
}
}
function readInstanceOptions(instanceFilePath) {
try {
return JSON.parse(fs.readFileSync(instanceFilePath, 'utf8'));
} catch {
return null;
}
}
function writeInstanceOptions(instanceFilePath, options, onNotice) {
try {
const toStore = {
port: options.port,
host: typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined,
launchMode: options.launchMode === 'foreground' ? 'foreground' : 'daemon',
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
hasUiPassword: typeof options.uiPassword === 'string',
apiOnly: options.apiOnly === true,
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
};
fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2), { mode: 0o600 });
} catch (error) {
const message = `Could not write instance file: ${error.message}`;
if (typeof onNotice === 'function') {
onNotice({ level: 'warning', code: 'INSTANCE_FILE_WRITE_FAILED', message });
} else {
console.warn(`Warning: ${message}`);
}
}
}
function removeInstanceFile(instanceFilePath) {
try {
if (fs.existsSync(instanceFilePath)) {
fs.unlinkSync(instanceFilePath);
}
} catch {
}
}
// Liveness only — "is *some* process alive with this PID". Use this when the
// PID is known to be ours (a child we just spawned, or a process we are
// stopping). Do NOT use it to validate a PID read from a pid file: after an
// ungraceful shutdown the pid file is stale and the kernel may have recycled
// that PID to an unrelated process — see isOpenchamberProcessRunning.
function isProcessRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
// Best-effort command line for a live PID, used for identity verification.
// Returns the cmdline string, '' when the process has no readable cmdline, or
// null when identity can't be determined on this platform (caller falls back to
// liveness — so behaviour is unchanged where we can't check).
function readProcessCmdline(pid) {
try {
if (process.platform === 'linux') {
// /proc/<pid>/cmdline is a NUL-delimited argv list.
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
}
if (process.platform === 'darwin') {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
});
const out = (result.stdout || '').trim();
return out.length > 0 ? out : null;
}
} catch {
return null;
}
// Windows / other: a process's full command line isn't cheaply available, so
// we can't verify identity — fall back to liveness-only.
return null;
}
function isOpenchamberCmdline(cmdline) {
if (typeof cmdline !== 'string' || cmdline.length === 0) {
return false;
}
// Every install path contains the "openchamber" segment — the npm package
// (@openchamber/web) and the source checkout both do, for the foreground
// (bin/cli.js) and daemon (server/index.js) entrypoints alike. Matching the
// path segment (not a generic "cli.js") keeps a recycled stranger such as
// "npm-cli.js" or "agentmemory" from being mistaken for us.
return cmdline.toLowerCase().includes('openchamber');
}
// Liveness + identity — "is the OpenChamber instance recorded in a pid file
// still the process running under this PID". Use this (not isProcessRunning)
// when validating a PID read from a pid file. After an ungraceful shutdown
// removePidFile never runs, so the stale PID can be recycled to an unrelated
// process; a liveness-only check then reports us as "already running" and aborts
// startup, which loops forever under systemd Restart=always (issue #1721).
// Where identity can't be determined (Windows, unreadable /proc or ps), we fall
// back to liveness so there are no false negatives on those platforms.
function isOpenchamberProcessRunning(pid) {
const state = getOpenchamberProcessState(pid);
return state === 'matched' || state === 'unknown';
}
function getOpenchamberProcessState(pid, options = {}) {
const checkProcessRunning = typeof options.isProcessRunning === 'function'
? options.isProcessRunning
: isProcessRunning;
if (!Number.isFinite(pid) || pid <= 0 || !checkProcessRunning(pid)) {
return 'dead';
}
const readCmdline = typeof options.readProcessCmdline === 'function'
? options.readProcessCmdline
: readProcessCmdline;
const cmdline = readCmdline(pid);
if (cmdline === null) {
return 'unknown';
}
return isOpenchamberCmdline(cmdline) ? 'matched' : 'mismatched';
}
function hasOpenchamberRuntimeInfo(info) {
return Boolean(info && typeof info.runtime === 'string' && info.runtime.length > 0);
}
function waitForProcessExit(pid, timeoutMs) {
if (!Number.isFinite(pid) || pid <= 0) {
return Promise.resolve(true);
}
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const check = () => {
if (!isProcessRunning(pid)) {
resolve(true);
return;
}
if (Date.now() >= deadline) {
resolve(false);
return;
}
setTimeout(check, 150);
};
check();
});
}
async function terminateProcessTree(pid, options = {}) {
if (!Number.isFinite(pid) || pid <= 0) {
return true;
}
const gracefulTimeoutMs = Number.isFinite(options.gracefulTimeoutMs) && options.gracefulTimeoutMs >= 0
? Math.trunc(options.gracefulTimeoutMs)
: 2500;
const forceTimeoutMs = Number.isFinite(options.forceTimeoutMs) && options.forceTimeoutMs >= 0
? Math.trunc(options.forceTimeoutMs)
: 3000;
if (process.platform === 'win32') {
try {
process.kill(pid);
} catch {
}
if (await waitForProcessExit(pid, 800)) {
return true;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/t'], {
stdio: 'ignore',
timeout: 3000,
windowsHide: true,
});
} catch {
}
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
return true;
}
try {
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], {
stdio: 'ignore',
timeout: 5000,
windowsHide: true,
});
} catch {
}
return waitForProcessExit(pid, forceTimeoutMs);
}
try {
process.kill(pid, 'SIGTERM');
} catch {
}
if (await waitForProcessExit(pid, gracefulTimeoutMs)) {
return true;
}
try {
process.kill(pid, 'SIGKILL');
} catch {
}
return waitForProcessExit(pid, forceTimeoutMs);
}
async function stopInstanceProcess(pid, options = {}) {
if (!Number.isFinite(pid) || pid <= 0) {
return true;
}
const shutdownWaitMs = Number.isFinite(options.shutdownWaitMs) && options.shutdownWaitMs >= 0
? Math.trunc(options.shutdownWaitMs)
: 5000;
if (await waitForProcessExit(pid, shutdownWaitMs)) {
return true;
}
return terminateProcessTree(pid, options);
}
export {
getPidFilePath,
getInstanceFilePath,
readPidFile,
writePidFile,
removePidFile,
readInstanceOptions,
writeInstanceOptions,
removeInstanceFile,
isProcessRunning,
readProcessCmdline,
isOpenchamberCmdline,
isOpenchamberProcessRunning,
getOpenchamberProcessState,
hasOpenchamberRuntimeInfo,
waitForProcessExit,
terminateProcessTree,
stopInstanceProcess,
};
+371
View File
@@ -0,0 +1,371 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { DEFAULT_PORT } from './cli-args.js';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { getDataDir } from './cli-paths.js';
import { hasUiPasswordConfigured } from './cli-network.js';
import { searchPathFor } from './cli-executables.js';
const STARTUP_SERVICE_ID = 'dev.openchamber.web';
function getStartupServicePaths() {
if (process.platform === 'darwin') {
return {
platform: 'macos',
servicePath: path.join(os.homedir(), 'Library', 'LaunchAgents', `${STARTUP_SERVICE_ID}.plist`),
};
}
if (process.platform === 'linux') {
return {
platform: 'linux',
servicePath: path.join(os.homedir(), '.config', 'systemd', 'user', 'openchamber.service'),
};
}
if (process.platform === 'win32') {
return { platform: 'windows', servicePath: STARTUP_SERVICE_ID };
}
return { platform: process.platform, servicePath: null };
}
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function systemdEscapeArg(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function startupShellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function systemdUnitPath(value) {
return String(value).replace(/\\/g, '\\\\').replace(/ /g, '\\x20');
}
function powershellQuote(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function startupEnvFileQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function systemdEnvFileQuote(value) {
return `"${String(value)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')}"`;
}
function getStartupEnvFilePath() {
return path.join(getDataDir(), 'startup.env');
}
function getMacosStartupWrapperPath() {
return path.join(getDataDir(), 'bin', 'OpenChamber');
}
function collectStartupEnv(options = {}) {
const env = options.envSnapshot === false ? {} : Object.fromEntries(
Object.entries(process.env)
.filter(([key, value]) => shouldPersistStartupEnv(key, value))
.map(([key, value]) => [key, String(value)])
);
if (options.envSnapshot !== false) {
const opencodeBinary = process.env.OPENCODE_BINARY || searchPathFor('opencode');
if (typeof opencodeBinary === 'string' && opencodeBinary.trim().length > 0) {
env.OPENCODE_BINARY = opencodeBinary.trim();
}
}
const uiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
if (uiPassword) {
env.OPENCHAMBER_UI_PASSWORD = uiPassword;
}
if (options.apiOnly === true) {
env.OPENCHAMBER_API_ONLY = 'true';
}
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
env.OPENCHAMBER_DATA_DIR = path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
}
return env;
}
function shouldPersistStartupEnv(key, value) {
if (typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
if (typeof value !== 'string') return false;
if (/[\r\n]/.test(value)) return false;
// These are shell/session implementation details, not app configuration.
const volatileKeys = new Set([
'_',
'BASH_ENV',
'COLUMNS',
'CONDA_DEFAULT_ENV',
'CONDA_PREFIX',
'CONDA_PROMPT_MODIFIER',
'CONDA_SHLVL',
'ENV',
'HISTFILE',
'HISTFILESIZE',
'HISTSIZE',
'LINES',
'OLDPWD',
'PROMPT',
'PROMPT_COMMAND',
'PS1',
'PS2',
'PS3',
'PS4',
'PWD',
'PYENV_VERSION',
'SHLVL',
'TERM',
'TERM_PROGRAM',
'TERM_PROGRAM_VERSION',
'TTY',
'VIRTUAL_ENV',
'VIRTUAL_ENV_PROMPT',
]);
return !volatileKeys.has(key);
}
function writeStartupEnvFile(options = {}, fileOptions = {}) {
const envFilePath = getStartupEnvFilePath();
const lines = [];
const env = collectStartupEnv(options);
const quoteValue = typeof fileOptions.quoteValue === 'function' ? fileOptions.quoteValue : startupEnvFileQuote;
for (const [key, value] of Object.entries(env)) {
lines.push(`${key}=${quoteValue(value)}`);
}
fs.mkdirSync(path.dirname(envFilePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(envFilePath, lines.length > 0 ? `${lines.join('\n')}\n` : '', { mode: 0o600 });
return envFilePath;
}
function removeStartupEnvFile() {
try { fs.unlinkSync(getStartupEnvFilePath()); } catch {}
}
function resolveCliEntrypoint() {
const entry = typeof process.argv[1] === 'string' && process.argv[1].trim().length > 0
? process.argv[1]
: path.join(__dirname, 'cli.js');
try {
return fs.realpathSync(entry);
} catch {
return path.resolve(entry);
}
}
function buildStartupArgs(options = {}) {
const args = [resolveCliEntrypoint(), 'serve', '--foreground', '--port', String(options.port || DEFAULT_PORT)];
if (typeof options.host === 'string' && options.host.length > 0) {
args.push('--host', options.host);
}
if (options.apiOnly === true) {
args.push('--api-only');
}
return args;
}
function writeMacosStartupWrapper(options = {}) {
const wrapperPath = getMacosStartupWrapperPath();
const args = buildStartupArgs(options).map(startupShellQuote).join(' ');
const content = `#!/bin/sh
exec ${startupShellQuote(process.execPath)} ${args}
`;
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(wrapperPath, content, { mode: 0o700 });
return wrapperPath;
}
function buildMacosLaunchAgent(options = {}) {
const wrapperPath = writeMacosStartupWrapper(options);
const args = [wrapperPath];
const env = collectStartupEnv(options);
const logDir = path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber');
const argXml = args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
const envXml = Object.entries(env).length > 0
? ` <key>EnvironmentVariables</key>\n <dict>\n${Object.entries(env).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}\n </dict>\n`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${STARTUP_SERVICE_ID}</string>
<key>ProgramArguments</key>
<array>
${argXml}
</array>
${envXml} <key>ProcessType</key>
<string>Background</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>${escapeXml(os.homedir())}</string>
<key>StandardOutPath</key>
<string>${escapeXml(path.join(logDir, 'startup.log'))}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(path.join(logDir, 'startup.err.log'))}</string>
</dict>
</plist>
`;
}
function buildSystemdUserService(options = {}) {
const args = buildStartupArgs(options).map((arg) => `"${systemdEscapeArg(arg)}"`).join(' ');
const envFilePath = getStartupEnvFilePath();
return `[Unit]
Description=OpenChamber web server
After=network-online.target
[Service]
Type=simple
EnvironmentFile=-${systemdEscapeArg(envFilePath)}
ExecStart="${systemdEscapeArg(process.execPath)}" ${args}
WorkingDirectory=${systemdUnitPath(os.homedir())}
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`;
}
function runStartupCommand(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: options.stdio || 'pipe',
windowsHide: true,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0 && options.allowFailure !== true) {
const detail = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
return result;
}
function getStartupStatus() {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
return { supported: false, platform: paths.platform, enabled: false, servicePath: null };
}
if (paths.platform === 'windows') {
const result = runStartupCommand('schtasks.exe', ['/Query', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
return { supported: true, platform: paths.platform, enabled: result.status === 0, active: null, servicePath: paths.servicePath };
}
if (paths.platform === 'linux') {
const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
const activeState = (activeResult.stdout || '').trim() || 'inactive';
return {
supported: true,
platform: paths.platform,
enabled: enabledResult.status === 0 || fs.existsSync(paths.servicePath),
active: activeState === 'active',
activeState,
servicePath: paths.servicePath,
};
}
return {
supported: true,
platform: paths.platform,
enabled: fs.existsSync(paths.servicePath),
active: null,
servicePath: paths.servicePath,
};
}
function enableStartupService(options = {}) {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
}
if (paths.platform === 'macos') {
removeStartupEnvFile();
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
fs.mkdirSync(path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber'), { recursive: true, mode: 0o700 });
fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(options), { mode: 0o600 });
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
runStartupCommand('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, paths.servicePath]);
runStartupCommand('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${STARTUP_SERVICE_ID}`], { allowFailure: true });
return getStartupStatus();
}
if (paths.platform === 'linux') {
writeStartupEnvFile(options, { quoteValue: systemdEnvFileQuote });
fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
fs.writeFileSync(paths.servicePath, buildSystemdUserService(options), { mode: 0o600 });
runStartupCommand('systemctl', ['--user', 'daemon-reload']);
runStartupCommand('systemctl', ['--user', 'enable', '--now', 'openchamber.service']);
return getStartupStatus();
}
const envFilePath = writeStartupEnvFile(options);
const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
const powerShellCommand = [
`$envFile=${powershellQuote(envFilePath)}`,
`if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
`& ${powershellQuote(process.execPath)} ${startupArgs}`,
].join('; ');
const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
runStartupCommand('schtasks.exe', [
'/Create',
'/TN', STARTUP_SERVICE_ID,
'/SC', 'ONLOGON',
'/RL', 'LIMITED',
'/F',
'/TR', taskArgs,
]);
runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
return getStartupStatus();
}
function disableStartupService() {
const paths = getStartupServicePaths();
if (!paths.servicePath) {
throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
}
if (paths.platform === 'macos') {
runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
try { fs.unlinkSync(paths.servicePath); } catch {}
return getStartupStatus();
}
if (paths.platform === 'linux') {
runStartupCommand('systemctl', ['--user', 'disable', '--now', 'openchamber.service'], { allowFailure: true });
try { fs.unlinkSync(paths.servicePath); } catch {}
runStartupCommand('systemctl', ['--user', 'daemon-reload'], { allowFailure: true });
return getStartupStatus();
}
runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
return getStartupStatus();
}
export {
getStartupServicePaths,
getStartupStatus,
enableStartupService,
disableStartupService,
};
@@ -0,0 +1,9 @@
import { cloudflareTunnelProviderCapabilities } from '../../server/lib/tunnels/providers/cloudflare.js';
import { ngrokTunnelProviderCapabilities } from '../../server/lib/tunnels/providers/ngrok.js';
const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [
cloudflareTunnelProviderCapabilities,
ngrokTunnelProviderCapabilities,
];
export { DEFAULT_TUNNEL_PROVIDER_CAPABILITIES };
+368
View File
@@ -0,0 +1,368 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import {
getTunnelProfilesFilePath,
getLegacyCloudflareManagedRemoteFilePath,
} from './cli-paths.js';
const TUNNEL_PROFILES_VERSION = 1;
const MAX_TOKEN_FILE_BYTES = 8 * 1024;
function normalizeProfileProvider(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function normalizeProfileMode(value) {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase();
}
function normalizeProfileName(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeProfileHostname(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeProfileToken(value) {
if (typeof value !== 'string') return '';
return value.trim();
}
function suggestProfileNameFromHostname(hostname) {
const normalizedHost = normalizeProfileHostname(hostname);
if (!normalizedHost) return 'prod-main';
const firstLabel = normalizedHost.split('.')[0] || normalizedHost;
const sanitized = firstLabel.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return sanitized || 'prod-main';
}
function maskToken(token) {
if (typeof token !== 'string' || token.length === 0) {
return '***';
}
if (token.length <= 4) {
return '*'.repeat(token.length);
}
return `${'*'.repeat(Math.max(4, token.length - 4))}${token.slice(-4)}`;
}
function readTokenFromFileSafely(tokenFilePath) {
const absolutePath = path.resolve(tokenFilePath);
let realPath;
try {
realPath = fs.realpathSync(absolutePath);
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Token file '${absolutePath}' not found.`);
}
if (error?.code === 'EACCES') {
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
}
throw error;
}
let stats;
try {
stats = fs.statSync(realPath);
} catch (error) {
if (error?.code === 'EACCES') {
throw new Error(`Token file '${absolutePath}' is not readable. Check file permissions.`);
}
throw error;
}
if (!stats.isFile()) {
throw new Error(`Token file '${absolutePath}' must be a regular file.`);
}
if (stats.size <= 0) {
throw new Error(`Token file '${absolutePath}' is empty.`);
}
if (stats.size > MAX_TOKEN_FILE_BYTES) {
throw new Error(`Token file '${absolutePath}' is too large (max ${MAX_TOKEN_FILE_BYTES} bytes).`);
}
const raw = fs.readFileSync(realPath, 'utf8');
if (raw.includes('\u0000')) {
throw new Error(`Token file '${absolutePath}' appears to be binary. Use a plain text token file.`);
}
const value = raw.trim();
if (!value) {
throw new Error(`Token file '${absolutePath}' is empty.`);
}
return value;
}
function resolveToken(options) {
const sources = [
options.tokenStdin ? 'stdin' : null,
options.tokenFile ? 'file' : null,
options.token ? 'flag' : null,
].filter(Boolean);
if (sources.length > 1) {
throw new Error(`Multiple token sources specified (${sources.join(', ')}). Use only one of --token, --token-file, or --token-stdin.`);
}
if (options.tokenStdin) {
const fd = fs.openSync('/dev/stdin', 'r');
try {
const buf = Buffer.alloc(65536);
const bytesRead = fs.readSync(fd, buf, 0, buf.length, null);
const value = buf.slice(0, bytesRead).toString('utf8').trim();
if (!value) {
throw new Error('No token received from stdin.');
}
return value;
} finally {
fs.closeSync(fd);
}
}
if (options.tokenFile) {
return readTokenFromFileSafely(options.tokenFile);
}
return typeof options.token === 'string' ? options.token.trim() : undefined;
}
function redactProfileForOutput(profile, showSecrets = false) {
if (!profile || typeof profile !== 'object') {
return profile;
}
return {
...profile,
token: showSecrets ? profile.token : maskToken(profile.token),
};
}
function redactProfilesForOutput(profiles, showSecrets = false) {
if (!Array.isArray(profiles)) {
return profiles;
}
return profiles.map((entry) => redactProfileForOutput(entry, showSecrets));
}
function formatProfileTokenStatus(profile, showSecrets = false) {
const token = typeof profile?.token === 'string' ? profile.token.trim() : '';
if (!token) {
return 'token:missing';
}
if (showSecrets) {
return `token:${token}`;
}
return 'token:present';
}
function sanitizeTunnelProfilesData(data) {
const parsed = data && typeof data === 'object' ? data : {};
const list = Array.isArray(parsed.profiles) ? parsed.profiles : [];
const seen = new Set();
const profiles = [];
for (const entry of list) {
if (!entry || typeof entry !== 'object') continue;
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
const provider = normalizeProfileProvider(entry.provider);
const mode = normalizeProfileMode(entry.mode);
const name = normalizeProfileName(entry.name);
const hostname = normalizeProfileHostname(entry.hostname);
const token = normalizeProfileToken(entry.token);
if (!provider || !mode || !name || !hostname || !token) continue;
const key = `${provider}::${name.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
profiles.push({
id,
name,
provider,
mode,
hostname,
token,
createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
});
}
return { version: TUNNEL_PROFILES_VERSION, profiles };
}
function warnIfUnsafeFilePermissions(filePath, { shouldWarn = true } = {}) {
if (process.platform === 'win32') {
return;
}
if (!shouldWarn) {
return;
}
try {
const stats = fs.statSync(filePath);
const perms = stats.mode & 0o777;
if (perms & 0o077) {
const octal = perms.toString(8).padStart(3, '0');
console.warn(
`Warning: Profile file '${filePath}' has permissions ${octal} (should be 600). ` +
`Other users may be able to read tunnel tokens. Fix with: chmod 600 '${filePath}'`
);
}
} catch {
// File may not exist yet — not an error
}
}
function readTunnelProfilesFromDisk(options = {}) {
const filePath = getTunnelProfilesFilePath();
try {
warnIfUnsafeFilePermissions(filePath, options);
const raw = fs.readFileSync(filePath, 'utf8');
return sanitizeTunnelProfilesData(JSON.parse(raw));
} catch {
return { version: TUNNEL_PROFILES_VERSION, profiles: [] };
}
}
function writeTunnelProfilesToDisk(data) {
const filePath = getTunnelProfilesFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(sanitizeTunnelProfilesData(data), null, 2), { encoding: 'utf8', mode: 0o600 });
}
function writeManagedRemotePairsToDiskFromProfiles(profilesData) {
const profiles = sanitizeTunnelProfilesData(profilesData).profiles;
const cloudflareManagedRemote = profiles.filter(
(entry) => entry.provider === 'cloudflare' && entry.mode === 'managed-remote'
);
const tunnels = cloudflareManagedRemote.map((entry) => ({
id: entry.id,
name: entry.name,
hostname: entry.hostname,
token: entry.token,
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
}));
const filePath = getLegacyCloudflareManagedRemoteFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ version: 1, tunnels }, null, 2), { encoding: 'utf8', mode: 0o600 });
}
function readLegacyManagedRemoteEntries() {
try {
const raw = fs.readFileSync(getLegacyCloudflareManagedRemoteFilePath(), 'utf8');
const parsed = JSON.parse(raw);
const tunnels = Array.isArray(parsed?.tunnels) ? parsed.tunnels : [];
return tunnels
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const id = typeof entry.id === 'string' && entry.id.trim().length > 0 ? entry.id.trim() : crypto.randomUUID();
const name = normalizeProfileName(entry.name);
const hostname = normalizeProfileHostname(entry.hostname);
const token = normalizeProfileToken(entry.token);
if (!name || !hostname || !token) return null;
return {
id,
name,
provider: 'cloudflare',
mode: 'managed-remote',
hostname,
token,
createdAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : Date.now(),
};
})
.filter(Boolean);
} catch {
return [];
}
}
function makeUniqueProfileName(provider, desiredName, existingProfiles) {
const normalizedDesired = normalizeProfileName(desiredName);
if (!normalizedDesired) {
return '';
}
const existingNames = new Set(
existingProfiles
.filter((entry) => entry.provider === provider)
.map((entry) => entry.name.toLowerCase())
);
if (!existingNames.has(normalizedDesired.toLowerCase())) {
return normalizedDesired;
}
let index = 2;
while (true) {
const candidate = `${normalizedDesired}-${index}`;
if (!existingNames.has(candidate.toLowerCase())) {
return candidate;
}
index += 1;
}
}
function ensureTunnelProfilesMigrated(options = {}) {
const current = readTunnelProfilesFromDisk(options);
if (current.profiles.length > 0) {
return current;
}
const legacyEntries = readLegacyManagedRemoteEntries();
if (legacyEntries.length === 0) {
return current;
}
const migratedProfiles = [];
for (const entry of legacyEntries) {
const name = makeUniqueProfileName(entry.provider, entry.name, migratedProfiles);
migratedProfiles.push({ ...entry, name });
}
const migrated = sanitizeTunnelProfilesData({ version: TUNNEL_PROFILES_VERSION, profiles: migratedProfiles });
writeTunnelProfilesToDisk(migrated);
writeManagedRemotePairsToDiskFromProfiles(migrated);
return migrated;
}
function resolveProfileByName(profiles, profileName, provider) {
const normalizedName = normalizeProfileName(profileName).toLowerCase();
const normalizedProvider = normalizeProfileProvider(provider);
const matches = profiles.filter((entry) => {
if (entry.name.toLowerCase() !== normalizedName) return false;
if (!normalizedProvider) return true;
return entry.provider === normalizedProvider;
});
if (matches.length === 0) {
return { profile: null, error: `No tunnel profile found for name '${profileName}'. Run 'openchamber tunnel profile list'.` };
}
if (matches.length > 1) {
return { profile: null, error: `Profile name '${profileName}' exists for multiple providers. Use --provider <id>.` };
}
return { profile: matches[0], error: null };
}
export {
normalizeProfileProvider,
normalizeProfileMode,
normalizeProfileName,
normalizeProfileHostname,
normalizeProfileToken,
suggestProfileNameFromHostname,
maskToken,
resolveToken,
redactProfileForOutput,
redactProfilesForOutput,
formatProfileTokenStatus,
sanitizeTunnelProfilesData,
warnIfUnsafeFilePermissions,
readTunnelProfilesFromDisk,
writeTunnelProfilesToDisk,
writeManagedRemotePairsToDiskFromProfiles,
ensureTunnelProfilesMigrated,
resolveProfileByName,
};
+304
View File
@@ -0,0 +1,304 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { canPrompt, select as clackSelect, text as clackText, cancel as clackCancel, isCancel as clackIsCancel } from '../cli-output.js';
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000;
const CONNECT_TTL_PICKER_OPTIONS = [
{ value: String(3 * 60 * 1000), label: '3m' },
{ value: String(TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS), label: '30m' },
{ value: String(2 * 60 * 60 * 1000), label: '2h' },
{ value: String(8 * 60 * 60 * 1000), label: '8h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: '__custom__', label: 'Custom' },
];
const SESSION_TTL_PICKER_OPTIONS = [
{ value: String(60 * 60 * 1000), label: '1h' },
{ value: String(TUNNEL_SESSION_TTL_DEFAULT_MS), label: '8h' },
{ value: String(12 * 60 * 60 * 1000), label: '12h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: String(7 * 24 * 60 * 60 * 1000), label: '1w' },
{ value: String(30 * 24 * 60 * 60 * 1000), label: '30d' },
{ value: '__custom__', label: 'Custom' },
];
function parseHumanDurationToMs(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
const normalized = trimmed.replace(/\s+/g, '');
const pattern = /(\d+)(ms|s|m|h|d)/g;
let cursor = 0;
let total = 0;
let match;
while ((match = pattern.exec(normalized)) !== null) {
if (match.index !== cursor) {
return null;
}
cursor = pattern.lastIndex;
const amount = Number.parseInt(match[1], 10);
const unit = match[2];
const unitMs = unit === 'ms'
? 1
: unit === 's'
? 1000
: unit === 'm'
? 60 * 1000
: unit === 'h'
? 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
total += amount * unitMs;
}
if (cursor !== normalized.length) {
return null;
}
return total;
}
function parseTtlMsOrThrow(rawValue, {
flagName,
minMs,
maxMs,
} = {}) {
const parsed = parseHumanDurationToMs(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new TunnelCliError(
`Invalid value for ${flagName}. Use a positive duration like 30m, 24h, 1d, or milliseconds.`,
EXIT_CODE.USAGE_ERROR,
);
}
if (parsed < minMs || parsed > maxMs) {
throw new TunnelCliError(
`${flagName} must be between ${minMs}ms and ${maxMs}ms.`,
EXIT_CODE.USAGE_ERROR,
);
}
return parsed;
}
function formatDurationForCli(ms) {
if (!Number.isFinite(ms) || ms <= 0) {
return null;
}
const value = Math.round(ms);
if (value % (24 * 60 * 60 * 1000) === 0) return `${value / (24 * 60 * 60 * 1000)}d`;
if (value % (60 * 60 * 1000) === 0) return `${value / (60 * 60 * 1000)}h`;
if (value % (60 * 1000) === 0) return `${value / (60 * 1000)}m`;
if (value % 1000 === 0) return `${value / 1000}s`;
return `${value}ms`;
}
function shellQuote(value) {
const text = String(value);
if (/^[A-Za-z0-9._\-/:=]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, `'"'"'`)}'`;
}
function buildTunnelStartReplayCommand({
port,
provider,
mode,
profileName,
configPath,
hostname,
connectTtlMs,
sessionTtlMs,
qr,
noQr,
includeTokenPlaceholder,
tokenViaStdin,
tokenFileProvided,
}) {
const parts = ['openchamber', 'tunnel', 'start'];
if (Number.isFinite(port) && port > 0) {
parts.push('--port', String(port));
}
if (profileName) {
parts.push('--profile', shellQuote(profileName));
}
if (provider) {
parts.push('--provider', shellQuote(provider));
}
if (mode) {
parts.push('--mode', shellQuote(mode));
}
if (typeof configPath === 'string' && configPath.trim().length > 0) {
parts.push('--config', shellQuote(configPath));
}
if (typeof hostname === 'string' && hostname.trim().length > 0) {
parts.push('--hostname', shellQuote(hostname));
}
const connectTtl = formatDurationForCli(connectTtlMs);
if (connectTtl) {
parts.push('--connect-ttl', connectTtl);
}
const sessionTtl = formatDurationForCli(sessionTtlMs);
if (sessionTtl) {
parts.push('--session-ttl', sessionTtl);
}
if (qr) parts.push('--qr');
if (noQr) parts.push('--no-qr');
if (includeTokenPlaceholder) {
if (tokenViaStdin) {
parts.push('--token-stdin');
} else if (tokenFileProvided) {
parts.push('--token-file', '<redacted>');
} else {
parts.push('--token', '<redacted>');
}
}
return parts.join(' ');
}
function buildTunnelProfileAddCommand({ provider, hostname }) {
const parts = [
'openchamber',
'tunnel',
'profile',
'add',
'--provider',
shellQuote(provider || 'cloudflare'),
'--mode',
'managed-remote',
'--name',
'<name>',
'--hostname',
shellQuote(hostname || '<hostname>'),
'--token',
'<token>',
];
return parts.join(' ');
}
async function resolveTunnelTtlOverrides(options) {
let connectTtlRaw = typeof options.connectTtl === 'string' ? options.connectTtl : undefined;
let sessionTtlRaw = typeof options.sessionTtl === 'string' ? options.sessionTtl : undefined;
const shouldPrompt = !connectTtlRaw
&& !sessionTtlRaw
&& canPrompt(options);
if (shouldPrompt) {
const connectChoice = await clackSelect({
message: 'Select connect-link TTL',
options: CONNECT_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(connectChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (connectChoice === '__custom__') {
const enteredConnect = await clackText({
message: 'Enter connect-link TTL (e.g. 30m, 2h, 1d)',
placeholder: '30m',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredConnect)) {
clackCancel('Tunnel start cancelled.');
return null;
}
connectTtlRaw = enteredConnect.trim();
} else {
connectTtlRaw = connectChoice;
}
const sessionChoice = await clackSelect({
message: 'Select session TTL',
options: SESSION_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(sessionChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (sessionChoice === '__custom__') {
const enteredSession = await clackText({
message: 'Enter session TTL (e.g. 8h, 24h, 1d)',
placeholder: '8h',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredSession)) {
clackCancel('Tunnel start cancelled.');
return null;
}
sessionTtlRaw = enteredSession.trim();
} else {
sessionTtlRaw = sessionChoice;
}
}
const connectTtlMs = connectTtlRaw !== undefined
? parseTtlMsOrThrow(connectTtlRaw, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
})
: undefined;
const sessionTtlMs = sessionTtlRaw !== undefined
? parseTtlMsOrThrow(sessionTtlRaw, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
})
: undefined;
return {
connectTtlMs,
sessionTtlMs,
};
}
export {
parseHumanDurationToMs,
parseTtlMsOrThrow,
formatDurationForCli,
shellQuote,
buildTunnelStartReplayCommand,
buildTunnelProfileAddCommand,
resolveTunnelTtlOverrides,
};
@@ -0,0 +1,173 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import {
assertSafeBrowserPort,
resolveConfiguredBindHost,
buildLocalUrl,
detectLanIPv4Address,
formatHostForUrl,
} from './cli-network.js';
import { discoverRunningInstances } from './cli-lifecycle.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
import {
intro as clackIntro,
outro as clackOutro,
log as clackLog,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
async function resolveConnectUrlServerUrl(options) {
let hostOverride = options.host;
if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) {
const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port));
if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) {
hostOverride = storedOptions.host.trim();
}
}
const bindHost = resolveConfiguredBindHost(hostOverride);
if (!isWildcardBindHost(bindHost)) {
return {
serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''),
source: 'configured-host',
};
}
const lanAddress = await detectLanIPv4Address();
if (!lanAddress) {
return {
serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''),
source: 'loopback-fallback',
};
}
return {
serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`,
source: 'lan-detected',
};
}
function isWildcardBindHost(host) {
return host === '0.0.0.0' || host === '::' || host === '[::]';
}
function normalizeServerUrlForConnection(value) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
parsed.hash = '';
return parsed.toString().replace(/\/+$/, '');
} catch {
return null;
}
}
function getOpenChamberDataDir() {
return process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
}
function buildClientConnectionPayload({ serverUrl, token, label }) {
const params = new URLSearchParams();
params.set('v', '1');
params.set('server', serverUrl.trim().replace(/\/+$/, ''));
params.set('token', token.trim());
if (label?.trim()) params.set('label', label.trim());
return `openchamber://connect?${params.toString()}`;
}
async function displayTunnelQrCode(url) {
try {
const qrcode = await import('qrcode-terminal');
console.log('\n📱 Scan this QR code to access the tunnel:\n');
qrcode.default.generate(url, { small: true });
console.log('');
} catch (error) {
console.warn(`Warning: Could not generate QR code: ${error.message}`);
}
}
function createConnectUrlCommand({ serveCommand }) {
return async function connectUrlCommand(options = {}) {
assertSafeBrowserPort(options.port, { context: 'OpenChamber connect-url' });
const explicitServerUrl = options.server ? normalizeServerUrlForConnection(options.server) : null;
if (options.server && !explicitServerUrl) {
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
}
const running = await discoverRunningInstances();
const serverState = running.some((entry) => entry.port === options.port)
? { port: options.port, autoStarted: false }
: await (async () => {
await serveCommand({
port: options.port,
explicitPort: true,
host: options.host,
uiPassword: options.uiPassword,
apiOnly: options.apiOnly,
suppressUnsafePortWarning: true,
suppressUiPasswordWarning: true,
suppressStartupSummary: true,
suppressQuietOutput: true,
});
return { port: options.port, autoStarted: true };
})();
const resolvedServerUrl = explicitServerUrl
? { serverUrl: explicitServerUrl, source: 'explicit' }
: await resolveConnectUrlServerUrl(options);
const serverUrl = resolvedServerUrl.serverUrl;
const label = options.name || `OpenChamber ${serverUrl}`;
const runtime = createRemoteClientAuthRuntime({
fsPromises: fs.promises,
path,
crypto,
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
});
const result = await runtime.createClient({ label });
const connectUrl = buildClientConnectionPayload({ serverUrl, token: result.token, label });
if (isJsonMode(options)) {
printJson({ serverUrl, connectUrl, token: result.token, client: result.client, autoStarted: serverState.autoStarted });
return;
}
if (isQuietMode(options)) {
process.stdout.write(`${connectUrl}\n`);
return;
}
clackIntro('OpenChamber connect URL');
if (serverState.autoStarted) {
logStatus('success', `started OpenChamber on port ${options.port}`);
}
logStatus('success', connectUrl);
clackLog.info(`Server URL: ${serverUrl}`);
if (resolvedServerUrl.source === 'lan-detected') {
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
} else if (resolvedServerUrl.source === 'loopback-fallback') {
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
}
clackLog.info('Copy this connection link into another OpenChamber client. The token is shown only once.');
if (options.qr === true) {
await displayTunnelQrCode(connectUrl);
}
clackOutro('connect URL generated');
};
}
export { createConnectUrlCommand };
+396
View File
@@ -0,0 +1,396 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { requestServerShutdown } from './cli-http.js';
import { isPortAvailable } from './cli-ports.js';
import {
discoverLifecycleInstances,
discoverUnconfirmedRegistryInstanceOnPort,
} from './cli-lifecycle.js';
import {
readInstanceOptions,
removePidFile,
removeInstanceFile,
isProcessRunning,
stopInstanceProcess,
} from './cli-process.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
async function stopCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const suppressQuietOutput = options?.suppressQuietOutput === true;
const jsonResults = [];
const printQuietStopResults = () => {
if (suppressQuietOutput) return;
if (!isQuietMode(options) || isJsonMode(options)) return;
if (jsonResults.length === 0) {
process.stdout.write('none\n');
return;
}
for (const result of jsonResults) {
if (result.stopped) {
process.stdout.write(`stopped ${result.port}\n`);
} else {
const reason = result.reason || 'failed';
process.stderr.write(`failed ${result.port} ${reason}\n`);
}
}
};
const finish = (text) => {
if (!showOutput) return;
clackOutro(text);
};
if (showOutput) {
clackIntro('OpenChamber Stop');
}
let runningInstances = await discoverLifecycleInstances(options);
if (options.explicitPort) {
if (runningInstances.length === 0) {
const unconfirmedInstance = await discoverUnconfirmedRegistryInstanceOnPort(options.port, options);
if (unconfirmedInstance) {
runningInstances = [unconfirmedInstance];
}
}
if (runningInstances.length === 0) {
jsonResults.push({ port: options.port, stopped: false, reason: 'not-found' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', `no OpenChamber instance found on port ${options.port}`);
finish('nothing to stop');
}
printQuietStopResults();
return;
}
const explicitInstance = runningInstances[0];
if (explicitInstance.runtime === 'desktop') {
jsonResults.push({ port: options.port, runtime: 'desktop', stopped: false, reason: 'desktop-managed' });
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults, messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message: `Port ${options.port} is managed by OpenChamber Desktop and cannot be stopped with this command.` }] });
}
if (showOutput) {
logStatus('warning', `port ${options.port} is managed by OpenChamber Desktop`, 'cannot be stopped with this command');
finish('no changes applied');
}
printQuietStopResults();
return;
}
if (explicitInstance.source === 'probe') {
const unmanagedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unmanagedStopSpin) {
logStatus('info', `found unmanaged OpenChamber instance on port ${options.port}`, 'attempting shutdown');
}
unmanagedStopSpin?.start(`Stopping unmanaged OpenChamber on port ${options.port}...`);
const requested = await requestServerShutdown(options.port, options.host);
if (Number.isFinite(explicitInstance.pid) && isProcessRunning(explicitInstance.pid)) {
await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
}
const stopped = await isPortAvailable(options.port, options.host);
if (stopped) {
unmanagedStopSpin?.stop(`Stopped unmanaged OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unmanagedStopSpin) {
logStatus('success', `stopped OpenChamber on port ${options.port}`);
finish('stop complete');
}
printQuietStopResults();
} else if (requested) {
unmanagedStopSpin?.stop(`Shutdown requested on port ${options.port} (still occupied)`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'shutdown-requested-port-busy' });
if (isJsonMode(options)) {
printJson({
status: 'warning',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'warning', code: 'SHUTDOWN_PARTIAL', message: `Shutdown was requested for port ${options.port}, but the port is still occupied.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('warning', `shutdown requested on port ${options.port}`, 'port is still occupied');
finish('partial stop');
}
printQuietStopResults();
} else {
unmanagedStopSpin?.error(`Could not stop OpenChamber on port ${options.port}`);
jsonResults.push({ port: options.port, runtime: 'unmanaged', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber on port ${options.port}.` }],
});
}
if (showOutput && !unmanagedStopSpin) {
logStatus('error', `could not stop OpenChamber on port ${options.port}`);
finish('failed');
}
printQuietStopResults();
}
return;
}
if (explicitInstance.source === 'registry-unconfirmed') {
const unconfirmedStopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !unconfirmedStopSpin) {
logStatus('info', `found unconfirmed OpenChamber pid ${explicitInstance.pid} on port ${options.port}`, 'HTTP shutdown endpoint is unreachable; stopping by PID');
}
unconfirmedStopSpin?.start(`Stopping unconfirmed OpenChamber on port ${options.port}...`);
const stopped = await stopInstanceProcess(explicitInstance.pid, {
shutdownWaitMs: 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
}).catch(() => false);
if (stopped || !isProcessRunning(explicitInstance.pid)) {
removePidFile(explicitInstance.pidFilePath);
removeInstanceFile(explicitInstance.instanceFilePath);
unconfirmedStopSpin?.stop(`Stopped OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: true });
if (isJsonMode(options)) {
printJson({ stoppedCount: 1, results: jsonResults });
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('success', `stopped pid ${explicitInstance.pid}`);
finish('stop complete');
}
printQuietStopResults();
return;
}
unconfirmedStopSpin?.error(`Could not stop OpenChamber PID ${explicitInstance.pid}`);
jsonResults.push({ port: options.port, pid: explicitInstance.pid, runtime: 'unconfirmed', stopped: false, reason: 'stop-failed' });
if (isJsonMode(options)) {
printJson({
status: 'error',
stoppedCount: 0,
results: jsonResults,
messages: [{ level: 'error', code: 'STOP_FAILED', message: `Could not stop OpenChamber PID ${explicitInstance.pid}.` }],
});
}
if (showOutput && !unconfirmedStopSpin) {
logStatus('error', `could not stop pid ${explicitInstance.pid}`);
finish('failed');
}
printQuietStopResults();
return;
}
} else if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ stoppedCount: 0, results: jsonResults });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances found');
finish('nothing to stop');
}
printQuietStopResults();
return;
}
for (const instance of runningInstances) {
const stopSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !stopSpin) {
logStatus('info', `stopping port ${instance.port} (PID: ${instance.pid})`);
}
stopSpin?.start(`Stopping OpenChamber on port ${instance.port}...`);
try {
const requested = await requestServerShutdown(instance.port, instance.host || options.host);
const stopped = await stopInstanceProcess(instance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
});
if (!stopped && isProcessRunning(instance.pid)) {
throw new Error(`Timed out stopping pid ${instance.pid}`);
}
removePidFile(instance.pidFilePath);
removeInstanceFile(instance.instanceFilePath);
stopSpin?.stop(`Stopped OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: true });
if (showOutput && !stopSpin) {
logStatus('success', `stopped port ${instance.port}`);
}
} catch (error) {
stopSpin?.error(`Failed to stop OpenChamber on port ${instance.port}`);
jsonResults.push({ port: instance.port, pid: instance.pid, stopped: false, reason: error instanceof Error ? error.message : String(error) });
if (showOutput) {
logStatus('error', `error stopping port ${instance.port}`, error.message);
} else if (!isJsonMode(options) && !isQuietMode(options)) {
console.error(`Error stopping port ${instance.port}: ${error.message}`);
}
}
}
if (isJsonMode(options)) {
const stoppedCount = jsonResults.filter((entry) => entry.stopped).length;
const hasFailure = jsonResults.some((entry) => !entry.stopped);
printJson({
status: hasFailure ? 'warning' : 'ok',
stoppedCount,
results: jsonResults,
});
return;
}
finish(`${runningInstances.length} instance(s)`);
printQuietStopResults();
}
async function restartCommand(options, serveCommand) {
const commandContext = this && typeof this === 'object' ? this : {};
const runStop = typeof commandContext.stop === 'function'
? commandContext.stop.bind(commandContext)
: stopCommand;
const runServe = typeof commandContext.serve === 'function'
? commandContext.serve.bind(commandContext)
: serveCommand;
const showOutput = shouldRenderHumanOutput(options);
const restarted = [];
if (showOutput) {
clackIntro('OpenChamber Restart');
}
let runningInstances = await discoverLifecycleInstances(options);
if (runningInstances.length === 0) {
if (isJsonMode(options)) {
printJson({ restartedCount: 0, results: restarted });
}
if (showOutput) {
logStatus('info', 'No running OpenChamber instances to restart');
clackOutro('nothing to restart');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
for (const instance of runningInstances) {
if (instance.runtime === 'desktop') {
const message = `Port ${instance.port} is managed by OpenChamber Desktop and cannot be restarted with this command.`;
if (isJsonMode(options)) {
printJson({
status: 'warning',
restartedCount: 0,
results: [{ fromPort: instance.port, runtime: 'desktop', ok: false, reason: 'desktop-managed' }],
messages: [{ level: 'warning', code: 'DESKTOP_MANAGED_PORT', message }],
});
return;
}
if (showOutput) {
logStatus('warning', `port ${instance.port} is managed by OpenChamber Desktop`, 'cannot be restarted with this command');
clackOutro('no changes applied');
} else if (isQuietMode(options)) {
process.stdout.write('restarted 0\n');
}
return;
}
const storedOptions = instance.instanceFilePath
? (readInstanceOptions(instance.instanceFilePath) || { port: instance.port })
: { port: instance.port };
const instanceHost = storedOptions.host || instance.host || options.host;
const launchMode = instance.launchMode || 'daemon';
const isForeground = launchMode === 'foreground';
const restartPort = options.explicitPort ? options.port : instance.port;
const restartSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !restartSpin) {
logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
}
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
try {
await runStop({
explicitPort: true,
port: instance.port,
host: instanceHost,
quiet: 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));
const restartedPort = await runServe({
port: restartPort,
host: instanceHost,
explicitPort: true,
uiPassword: options.explicitUiPassword ? options.uiPassword : (storedOptions.uiPassword || options.uiPassword),
apiOnly: storedOptions.apiOnly === true || options.apiOnly === true,
suppressStartupSummary: true,
quiet: true,
suppressUiPasswordWarning: true,
suppressQuietOutput: true,
});
restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
if (showOutput && !restartSpin) {
logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
restartSpin?.error(`Failed to restart OpenChamber on port ${instance.port}`);
if (showOutput && !restartSpin) {
logStatus('error', `failed to restart port ${instance.port}`, message);
}
throw error;
}
}
if (isJsonMode(options)) {
printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
return;
}
if (showOutput) {
clackOutro(`${runningInstances.length} instance(s) restarted`);
} else if (isQuietMode(options)) {
process.stdout.write(`restarted ${restarted.length}\n`);
}
}
function createLifecycleCommands({ serveCommand }) {
return {
stop: stopCommand,
restart(options) {
return restartCommand.call(this, options, serveCommand);
},
};
}
export { createLifecycleCommands };
+110
View File
@@ -0,0 +1,110 @@
import { getLogFilePath } from './cli-paths.js';
import { readTailLines, followFile } from './cli-log-files.js';
import { discoverRunningInstances, getLatestInstance } from './cli-lifecycle.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
shouldRenderHumanOutput,
printJson,
logStatus,
} from '../cli-output.js';
async function logsCommand(options) {
const showFrames = shouldRenderHumanOutput(options);
const shouldPrefixLines = options.all || !showFrames;
let targets = [];
const running = await discoverRunningInstances();
if (options.all) {
targets = running;
if (targets.length === 0) {
throw new Error('No running OpenChamber instance found.');
}
} else if (options.explicitPort) {
const found = running.find((entry) => entry.port === options.port);
if (!found) {
throw new Error(`No running OpenChamber instance found on port ${options.port}.`);
}
targets = [found];
} else {
const latest = getLatestInstance(running);
if (!latest) {
throw new Error('No running OpenChamber instance found.');
}
targets = [latest];
if (shouldRenderHumanOutput(options)) {
logStatus('info', `no port specified; using latest started instance on port ${latest.port}`);
}
}
if (isJsonMode(options)) {
if (options.follow) {
throw new Error('`openchamber logs --json` requires `--no-follow` for deterministic JSON output.');
}
const entries = targets.map((target) => {
const logPath = getLogFilePath(target.port);
return {
port: target.port,
logPath,
lines: readTailLines(logPath, options.lines),
};
});
printJson({ entries });
return;
}
if (showFrames) {
clackIntro('OpenChamber Logs');
}
for (const target of targets) {
const logPath = getLogFilePath(target.port);
const lines = readTailLines(logPath, options.lines);
if (showFrames) {
logStatus('info', `port ${target.port}`, logPath);
}
for (const line of lines) {
if (shouldPrefixLines) {
console.log(`[${target.port}] ${line}`);
} else {
console.log(line);
}
}
}
if (showFrames) {
clackOutro(options.follow ? 'following (Ctrl+C to stop)' : 'tail complete');
}
if (!options.follow) {
return;
}
const unsubs = targets.map((target) => {
const logPath = getLogFilePath(target.port);
return followFile(logPath, (line) => {
if (shouldPrefixLines) {
console.log(`[${target.port}] ${line}`);
} else {
console.log(line);
}
});
});
await new Promise((resolve) => {
const onSignal = () => {
for (const unsub of unsubs) {
unsub();
}
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
resolve();
};
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
});
}
export { logsCommand };
+396
View File
@@ -0,0 +1,396 @@
import fs from 'fs';
import { pathToFileURL } from 'url';
import { spawn } from 'child_process';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, hasUiPasswordConfigured, assertAuthenticatedNetworkExposure } from './cli-network.js';
import { fetchSystemInfoFromPort } from './cli-http.js';
import { isPortAvailable, resolveAvailablePort } from './cli-ports.js';
import { ensureLogsDir, getLogFilePath } from './cli-paths.js';
import { rotateLogFile } from './cli-log-files.js';
import { discoverOpenChamberInstanceOnPort, isDesktopRuntimeForPort } from './cli-lifecycle.js';
import { getPidFilePath, getInstanceFilePath, writePidFile, writeInstanceOptions, removePidFile, removeInstanceFile, isProcessRunning, terminateProcessTree } from './cli-process.js';
import { isNetworkExposedBindHost } from '../../server/lib/security/bind-host.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
const DAEMON_READY_TIMEOUT_MS = 30000;
function createServeCommand({
serverPath,
bunBin,
checkOpenCodeCLI,
getPreferredServerRuntime,
setForegroundServerActive,
setForegroundShutdown,
}) {
async function serveCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const jsonMessages = [];
const emitNotice = (notice) => {
if (!notice || typeof notice !== 'object' || typeof notice.message !== 'string') return;
const level = notice.level === 'error' ? 'error' : (notice.level === 'warning' ? 'warning' : 'info');
if (isJsonMode(options)) {
jsonMessages.push({
level,
code: notice.code,
message: notice.message,
});
return;
}
if (showOutput) {
logStatus(level, notice.message);
return;
}
if (!isQuietMode(options)) {
const prefix = level === 'warning' ? 'Warning' : level === 'error' ? 'Error' : 'Info';
const line = `${prefix}: ${notice.message}`;
if (level === 'error') {
console.error(line);
} else {
console.warn(line);
}
}
};
const explicitPort = options.explicitPort === true;
const effectiveHost = resolveServeHost(options.host);
const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice);
if (targetPort !== 0 && !options.suppressUnsafePortWarning) {
assertSafeBrowserPort(targetPort, { context: 'OpenChamber serve' });
}
if (targetPort !== 0) {
const existingInstance = await discoverOpenChamberInstanceOnPort(targetPort, { host: effectiveHost });
if (existingInstance?.runtime === 'desktop') {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
if (existingInstance) {
const pidSuffix = Number.isFinite(existingInstance.pid) ? ` (PID: ${existingInstance.pid})` : '';
if (existingInstance.source === 'probe') {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`OpenChamber is already running on port ${targetPort}${pidSuffix}`);
}
if (explicitPort && !(await isPortAvailable(targetPort, effectiveHost))) {
const systemInfo = await fetchSystemInfoFromPort(targetPort, globalThis.fetch, effectiveHost);
if (isDesktopRuntimeForPort(systemInfo, targetPort)) {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
const systemInfoRuntimeMatchesPort = systemInfo?.runtime !== 'desktop' || isDesktopRuntimeForPort(systemInfo, targetPort);
if (systemInfo?.runtime && systemInfoRuntimeMatchesPort) {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`Port ${targetPort} is already in use by another process.`);
}
}
const opencodeBinary = await checkOpenCodeCLI(emitNotice);
const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? bunBin : process.execPath;
ensureLogsDir();
const initialLogPort = targetPort === 0 ? 'auto' : String(targetPort);
const initialLogPath = getLogFilePath(initialLogPort);
rotateLogFile(initialLogPath);
const logFd = fs.openSync(initialLogPath, 'a');
const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
assertAuthenticatedNetworkExposure({
host: effectiveHost,
uiPassword: effectiveUiPassword,
});
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
const bindHost = effectiveHost;
const networkExposed = isNetworkExposedBindHost(bindHost);
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set';
const warningDetail = networkExposed
? `server is bound to ${bindHost} and reachable on your network with no UI auth. `
+ 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN.'
: 'browser UI is unsecured. Use --ui-password or OPENCHAMBER_UI_PASSWORD.';
if (showOutput) {
logStatus('warning', warningLine, warningDetail);
} else if (isJsonMode(options)) {
emitNotice({
level: 'warning',
code: 'UI_PASSWORD_MISSING',
message: `${warningLine}; ${warningDetail}`,
});
} else if (!isQuietMode(options)) {
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;
}
process.env.OPENCHAMBER_HOST = effectiveHost;
process.env.OPENCHAMBER_RUNTIME = 'web';
// 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 { startWebUiServer } = await import(pathToFileURL(serverPath).href);
const controller = await startWebUiServer({
port: targetPort,
host: effectiveHost,
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
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,
host: effectiveHost,
launchMode: 'foreground',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, 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();
setForegroundServerActive(false);
setForegroundShutdown(null);
const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
process.exit(exitCode);
};
// Expose shutdown to the global SIGINT handler.
setForegroundShutdown(shutdownForegroundServer);
setForegroundServerActive(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)];
serverArgs.push('--host', effectiveHost);
if (options.apiOnly === true) {
serverArgs.push('--api-only');
}
const serveSpin = showOutput ? createSpinner(options) : null;
const child = spawn(runtimeBin, serverArgs, {
detached: true,
windowsHide: true,
stdio: ['ignore', logFd, logFd, 'ipc'],
env: {
...process.env,
OPENCHAMBER_PORT: String(targetPort),
OPENCHAMBER_RUNTIME: 'web',
OPENCODE_BINARY: opencodeBinary,
OPENCHAMBER_HOST: effectiveHost,
...(effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
...(options.apiOnly === true ? { OPENCHAMBER_API_ONLY: 'true' } : {}),
...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
},
});
child.unref();
serveSpin?.start(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort}...`);
let resolvedPort;
try {
resolvedPort = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(`OpenChamber daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`));
}, DAEMON_READY_TIMEOUT_MS);
child.on('message', (msg) => {
if (settled) return;
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
settled = true;
clearTimeout(timeout);
resolve(msg.port);
}
});
child.on('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
});
child.on('exit', (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error(`OpenChamber daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`));
});
});
} catch (error) {
await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 });
throw error;
}
try {
if (typeof child.disconnect === 'function' && child.connected) {
child.disconnect();
}
} catch {
}
try {
fs.closeSync(logFd);
} catch {
}
const resolvedLogPath = getLogFilePath(resolvedPort);
if (initialLogPath !== resolvedLogPath && !fs.existsSync(resolvedLogPath)) {
try {
fs.renameSync(initialLogPath, resolvedLogPath);
} catch {
}
}
if (!isProcessRunning(child.pid)) {
serveSpin?.error('Failed to start OpenChamber');
throw new Error('Failed to start server in daemon mode');
}
const pidFilePath = await getPidFilePath(resolvedPort);
const instanceFilePath = await getInstanceFilePath(resolvedPort);
writePidFile(pidFilePath, child.pid, emitNotice);
writeInstanceOptions(instanceFilePath, {
port: resolvedPort,
host: effectiveHost,
launchMode: 'daemon',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, emitNotice);
const serveResult = {
port: resolvedPort,
pid: child.pid,
url: buildLocalUrl(resolvedPort, '/'),
logs: `openchamber logs -p ${resolvedPort}`,
launchMode: 'daemon',
};
if (isJsonMode(options)) {
printJson({ ...serveResult, messages: jsonMessages });
return resolvedPort;
}
if (isQuietMode(options)) {
if (options.suppressQuietOutput) {
return resolvedPort;
}
process.stdout.write(`${resolvedPort}\n`);
return resolvedPort;
}
serveSpin?.clear();
if (!options.suppressStartupSummary && showOutput) {
clackIntro('OpenChamber Started');
logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`);
logStatus('info', `visit: ${serveResult.url}`);
logStatus('info', `logs: ${serveResult.logs}`);
clackOutro('daemon running');
}
return resolvedPort;
}
return serveCommand;
}
export { createServeCommand };
+64
View File
@@ -0,0 +1,64 @@
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { getStartupStatus, enableStartupService, disableStartupService } from './cli-startup.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
async function startupCommand(options, action = 'status') {
const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
if (!['status', 'enable', 'disable'].includes(normalized)) {
throw new TunnelCliError(
`Unknown startup subcommand '${action}'. Use 'openchamber startup --help'.`,
EXIT_CODE.USAGE_ERROR
);
}
let status;
if (normalized === 'enable') {
status = enableStartupService(options);
} else if (normalized === 'disable') {
status = disableStartupService();
} else {
status = getStartupStatus();
}
const result = { action: normalized, ...status };
if (!result.supported) {
throw new TunnelCliError(
`Startup integration is not supported on ${result.platform}.`,
EXIT_CODE.USAGE_ERROR
);
}
if (normalized === 'enable' && result.activeState === 'failed') {
throw new TunnelCliError(
'Startup service was installed but failed to start. Run `journalctl --user -u openchamber.service -n 80 --no-pager` for details.',
EXIT_CODE.GENERAL_ERROR
);
}
if (isJsonMode(options)) {
printJson(result);
return;
}
if (isQuietMode(options)) {
process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
return;
}
clackIntro('OpenChamber Startup');
logStatus(result.enabled ? 'success' : 'info', `startup ${result.enabled ? 'enabled' : 'disabled'}`, result.servicePath || undefined);
if (typeof result.activeState === 'string') {
logStatus(result.active ? 'success' : result.activeState === 'failed' ? 'error' : 'warning', `service ${result.activeState}`);
}
if (normalized === 'enable') {
logStatus('info', 'service command', 'openchamber serve --foreground');
}
clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
}
export { startupCommand };
+114
View File
@@ -0,0 +1,114 @@
import { readInstanceOptions } from './cli-process.js';
import { discoverLifecycleInstances, discoverDesktopInstance } from './cli-lifecycle.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
printJson,
logStatus,
} from '../cli-output.js';
async function statusCommand(options = {}) {
const [runningInstances, desktopInstance] = options.explicitPort
? [await discoverLifecycleInstances(options), null]
: await Promise.all([
discoverLifecycleInstances(options),
discoverDesktopInstance(),
]);
const toPasswordProtectionLabel = (value) => {
if (value === true) return 'yes';
if (value === false) return 'no';
return 'unknown';
};
const desktopOnly = desktopInstance && !runningInstances.some((entry) => entry.port === desktopInstance.port)
? {
runtime: 'desktop',
port: desktopInstance.port,
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
launchMode: null,
passwordProtected: null,
}
: null;
const cliInstances = runningInstances
.filter((instance) => instance.runtime !== 'desktop')
.map((instance) => {
const storedOptions = instance.instanceFilePath ? (readInstanceOptions(instance.instanceFilePath) || {}) : {};
const passwordProtected = storedOptions.hasUiPassword === true
|| (typeof storedOptions.uiPassword === 'string' && storedOptions.uiPassword.trim().length > 0);
return {
runtime: instance.source === 'probe' ? 'unmanaged' : 'cli',
port: instance.port,
pid: instance.pid,
launchMode: instance.launchMode || 'daemon',
passwordProtected: instance.source === 'probe' ? null : passwordProtected,
};
});
const explicitDesktop = options.explicitPort
? runningInstances.find((entry) => entry.runtime === 'desktop')
: null;
const instances = desktopOnly ? [...cliInstances, desktopOnly] : cliInstances;
if (explicitDesktop) {
instances.push({
runtime: 'desktop',
port: explicitDesktop.port,
pid: Number.isFinite(explicitDesktop.pid) ? explicitDesktop.pid : null,
launchMode: null,
passwordProtected: null,
});
}
const runningCount = instances.length;
if (isJsonMode(options)) {
printJson({
state: runningCount > 0 ? 'running' : 'stopped',
runningCount,
instances,
});
return;
}
if (isQuietMode(options)) {
if (runningCount === 0) {
process.stdout.write('stopped\n');
return;
}
for (const instance of instances) {
process.stdout.write(
`port ${instance.port} mode:${instance.launchMode || 'n/a'} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
);
}
return;
}
clackIntro('OpenChamber Status');
if (runningCount === 0) {
logStatus('warning', 'stopped');
clackOutro('no running instances');
return;
}
for (const instance of instances) {
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
const modeDetail = instance.launchMode ? `mode: ${instance.launchMode}` : '';
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
const detail = modeDetail ? `${modeDetail}; ${protectionDetail}` : protectionDetail;
if (instance.runtime === 'desktop') {
logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, detail);
} else {
logStatus('success', `port ${instance.port}${pidSuffix}`, detail);
}
}
clackOutro(`${runningCount} running runtime(s)`);
}
export { statusCommand };
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { requestServerShutdown } from './cli-http.js';
import { discoverRunningInstances } from './cli-lifecycle.js';
import {
readInstanceOptions,
removePidFile,
stopInstanceProcess,
} from './cli-process.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
function createUpdateCommand({ importFromFilePath, packageManagerPath, serveCommand }) {
return async function updateCommand(options = {}) {
const showOutput = shouldRenderHumanOutput(options);
const updateSpin = createSpinner(options);
const {
checkForUpdates,
executeUpdate,
detectPackageManager,
getCurrentVersion,
} = await importFromFilePath(packageManagerPath);
const runningInstances = await discoverRunningInstances();
const currentVersion = getCurrentVersion();
if (showOutput) {
clackIntro('OpenChamber Update');
}
if (showOutput && !updateSpin) {
logStatus('info', `current version: ${currentVersion}`);
}
updateSpin?.start('Checking for updates...');
const updateInfo = await checkForUpdates();
if (updateInfo.error) {
updateSpin?.error('Update check failed');
if (showOutput) {
clackOutro('update failed');
}
throw new Error(updateInfo.error);
}
if (!updateInfo.available) {
if (isJsonMode(options)) {
printJson({
currentVersion,
latestVersion: updateInfo.version || currentVersion,
updated: false,
});
return;
}
if (showOutput && !updateSpin) {
logStatus('success', 'you are running the latest version');
}
updateSpin?.stop('Already up to date');
if (showOutput) {
clackOutro('no update needed');
} else if (isQuietMode(options)) {
process.stdout.write(`up-to-date ${currentVersion}\n`);
}
return;
}
if (showOutput && !updateSpin) {
logStatus('info', `updating ${updateInfo.currentVersion || currentVersion} -> ${updateInfo.version || 'latest'}`);
}
updateSpin?.message(`Updating to ${updateInfo.version || 'latest'}...`);
if (runningInstances.length > 0) {
updateSpin?.message(`Stopping ${runningInstances.length} running instance(s)...`);
for (const instance of runningInstances) {
try {
const requested = await requestServerShutdown(instance.port, instance.host);
await stopInstanceProcess(instance.pid, {
shutdownWaitMs: requested ? 5000 : 0,
gracefulTimeoutMs: 2500,
forceTimeoutMs: 3000,
});
removePidFile(instance.pidFilePath);
} catch {
}
}
}
const pm = detectPackageManager();
const result = executeUpdate(pm, { silent: isJsonMode(options) || isQuietMode(options) });
if (!result.success) {
updateSpin?.error('Update failed');
if (showOutput) {
clackOutro('update failed');
}
throw new Error(`Update failed with exit code ${result.exitCode}`);
}
if (runningInstances.length > 0) {
updateSpin?.message(`Restarting ${runningInstances.length} instance(s)...`);
for (const instance of runningInstances) {
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
await serveCommand({
port: storedOptions.port || instance.port,
host: storedOptions.host,
explicitPort: true,
uiPassword: storedOptions.uiPassword,
suppressStartupSummary: true,
suppressUiPasswordWarning: true,
quiet: true,
});
}
}
if (showOutput && !updateSpin) {
logStatus('success', `updated to ${updateInfo.version || 'latest'}`);
}
updateSpin?.stop(`Updated to ${updateInfo.version || 'latest'}`);
if (isJsonMode(options)) {
printJson({
currentVersion,
latestVersion: updateInfo.version || 'latest',
updated: true,
restartedCount: runningInstances.length,
});
return;
}
if (showOutput) {
clackOutro('update complete');
} else if (isQuietMode(options)) {
process.stdout.write(`updated ${updateInfo.version || 'latest'}\n`);
}
};
}
export { createUpdateCommand };