Refactor web CLI into focused modules (#1837)
This commit is contained in:
committed by
GitHub
parent
00821700de
commit
45df19c3b2
+105
-5814
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user