2025-12-07 19:32:53 +02:00
#!/usr/bin/env node
import fs from 'fs' ;
2026-01-15 00:52:08 +02:00
import net from 'net' ;
2026-03-12 19:40:22 +02:00
import os from 'os' ;
import path from 'path' ;
import crypto from 'crypto' ;
2025-12-07 19:32:53 +02:00
import { spawn , spawnSync } from 'child_process' ;
2026-02-28 16:00:10 +02:00
import { fileURLToPath , pathToFileURL } from 'url' ;
2026-03-13 09:49:25 +02:00
import { isModuleCliExecution } from './cli-entry.js' ;
2026-03-12 19:40:22 +02:00
import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js' ;
import {
intro as clackIntro , outro as clackOutro , log as clackLog ,
box as clackBox , confirm as clackConfirm ,
select as clackSelect , text as clackText , password as clackPassword , cancel as clackCancel ,
isCancel as clackIsCancel ,
isJsonMode ,
isQuietMode ,
shouldRenderHumanOutput ,
canPrompt ,
createSpinner ,
createProgress ,
printJson ,
logStatus , formatProviderWithIcon as clackFormatProviderWithIcon ,
} from './cli-output.js' ;
2025-12-07 19:32:53 +02:00
const __filename = fileURLToPath ( import . meta . url );
const __dirname = path . dirname ( __filename );
const DEFAULT_PORT = 3000 ;
2026-03-12 19:40:22 +02:00
const DEFAULT_TAIL_LINES = 200 ;
const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024 ;
const LOG_ROTATE_KEEP = 5 ;
const TUNNEL_PROFILES_VERSION = 1 ;
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' ;
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 = 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 : '__custom__' , label : 'Custom' },
];
2025-12-07 19:32:53 +02:00
const PACKAGE_JSON = JSON . parse ( fs . readFileSync ( path . join ( __dirname , '..' , 'package.json' ), 'utf8' ));
2026-03-12 19:40:22 +02:00
const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [ cloudflareTunnelProviderCapabilities ];
let onCancelCleanup = null ;
let activeCommandOptions = null ;
function setCancelCleanup ( handler ) {
onCancelCleanup = typeof handler === 'function' ? handler : null ;
}
const HAS_PLAIN_FLAG = process . argv . includes ( '--plain' );
const STYLE_ENABLED = process . stdout . isTTY && process . env . NO_COLOR !== '1' && ! HAS_PLAIN_FLAG ;
const ANSI = {
bold : '\x1b[1m' ,
unbold : '\x1b[22m' ,
};
// 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 ,
]);
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 ;
}
}
function boldText ( text ) {
if ( ! STYLE_ENABLED ) return text ;
return ` ${ ANSI . bold }${ text }${ ANSI . unbold } ` ;
}
function getDefaultCloudflaredConfigPath () {
return path . join ( os . homedir (), '.cloudflared' , 'config.yml' );
}
function isReadableRegularFile ( filePath ) {
if ( typeof filePath !== 'string' || filePath . trim (). length === 0 ) {
return false ;
}
try {
const stat = fs . statSync ( filePath );
if ( ! stat . isFile ()) return false ;
fs . accessSync ( filePath , fs . constants . R_OK );
return true ;
} catch {
return false ;
}
}
function isUnsafeBrowserPort ( port ) {
return Number . isFinite ( port ) && UNSAFE_BROWSER_PORTS . has ( Math . trunc ( port ));
}
function resolveApiHost () {
const configured = typeof process . env . OPENCHAMBER_HOST === 'string'
? process . env . OPENCHAMBER_HOST . trim ()
: '' ;
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 = '' ) {
const host = formatHostForUrl ( resolveApiHost ());
const pathPart = endpoint . startsWith ( '/' ) ? endpoint : `/ ${ endpoint } ` ;
return `http:// ${ host } : ${ port }${ pathPart } ` ;
}
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 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 ,
};
}
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 importFromFilePath ( filePath ) {
return import ( pathToFileURL ( filePath ). href );
}
2025-12-07 19:32:53 +02:00
2026-01-02 21:57:53 +02:00
function getBunBinary () {
if ( typeof process . env . BUN_BINARY === 'string' && process . env . BUN_BINARY . trim (). length > 0 ) {
return process . env . BUN_BINARY . trim ();
}
if ( typeof process . env . BUN_INSTALL === 'string' && process . env . BUN_INSTALL . trim (). length > 0 ) {
return path . join ( process . env . BUN_INSTALL . trim (), 'bin' , 'bun' );
}
return 'bun' ;
}
2026-03-12 19:40:22 +02:00
function hasUiPasswordConfigured ( password ) {
return typeof password === 'string' && password . trim (). length > 0 ;
2026-03-01 01:21:01 +02:00
}
2026-03-12 19:40:22 +02:00
const BUN_BIN = getBunBinary ();
2026-01-02 21:57:53 +02:00
function isBunRuntime () {
return typeof globalThis . Bun !== 'undefined' ;
}
function isBunInstalled () {
try {
const result = spawnSync ( BUN_BIN , [ '--version' ], { stdio : 'ignore' , env : process . env });
return result . status === 0 ;
} catch {
return false ;
}
}
function getPreferredServerRuntime () {
return isBunInstalled () ? 'bun' : 'node' ;
}
2026-01-07 20:51:45 +01:00
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 ) {
2026-03-12 19:40:22 +02:00
console . warn ( `Warning: Could not generate QR code: ${ error . message } ` );
2026-01-07 20:51:45 +01:00
}
}
2026-03-12 19:40:22 +02:00
function isTruthyEnv ( value ) {
if ( typeof value !== 'string' ) return false ;
const normalized = value . trim (). toLowerCase ();
if ( ! normalized ) return false ;
return normalized !== '0' && normalized !== 'false' && normalized !== 'no' ;
}
function shouldDisplayTunnelQr ( options ) {
if ( options ? . json ) return false ;
if ( options ? . quiet ) return false ;
if ( options ? . explicitQr === true ) return options . qr === true ;
if ( ! process . stdout ? . isTTY ) return false ;
return ! isTruthyEnv ( process . env . CI );
}
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 ,
};
2026-01-07 20:51:45 +01:00
}
2026-03-12 19:40:22 +02:00
return {
name : arg . slice ( 1 ),
inlineValue : undefined ,
long : false ,
};
2026-01-07 20:51:45 +01:00
}
2026-03-12 19:40:22 +02:00
function parseArgs ( argv = process . argv . slice ( 2 )) {
const args = Array . isArray ( argv ) ? [... argv ] : [];
const options = {
port : DEFAULT_PORT ,
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 ,
connectTtl : undefined ,
sessionTtl : undefined ,
qr : false ,
explicitQr : false ,
force : false ,
showSecrets : false ,
dryRun : false ,
plain : false ,
quiet : false ,
explicitPort : false ,
explicitUiPassword : false ,
};
const removedFlagErrors = [];
const positional = [];
let helpRequested = false ;
let versionRequested = false ;
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const consumeValue = ( index , inlineValue ) => {
2025-12-07 19:32:53 +02:00
if ( typeof inlineValue === 'string' && inlineValue . length > 0 ) {
2026-03-12 19:40:22 +02:00
return { value : inlineValue , nextIndex : index };
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
const candidate = args [ index + 1 ];
2025-12-07 19:32:53 +02:00
if ( typeof candidate === 'string' && ! candidate . startsWith ( '-' )) {
2026-03-12 19:40:22 +02:00
return { value : candidate , nextIndex : index + 1 };
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
return { value : undefined , nextIndex : index };
2025-12-07 19:32:53 +02:00
};
for ( let i = 0 ; i < args . length ; i ++ ) {
const arg = args [ i ];
2026-03-12 19:40:22 +02:00
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 ;
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
i = nextIndex ;
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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 '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 '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 'daemon' :
case 'd' :
removedFlagErrors . push ( '`--daemon` was removed. OpenChamber now always runs in daemon mode.' );
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 ;
2025-12-07 19:32:53 +02:00
}
}
2026-03-12 19:40:22 +02:00
const command = positional [ 0 ] || 'serve' ;
const subcommand = command === 'tunnel' ? ( positional [ 1 ] || 'help' ) : null ;
const tunnelAction = command === 'tunnel' ? ( positional [ 2 ] || null ) : null ;
return {
command ,
subcommand ,
tunnelAction ,
options ,
removedFlagErrors ,
helpRequested ,
versionRequested ,
};
2025-12-07 19:32:53 +02:00
}
function showHelp () {
console . log ( `
2026-01-22 08:32:04 -08:00
OpenChamber - Web interface for the OpenCode AI coding agent
2025-12-07 19:32:53 +02:00
USAGE:
openchamber [COMMAND] [OPTIONS]
COMMANDS:
2026-03-12 19:40:22 +02:00
serve Start the web server (daemon default)
2025-12-07 19:32:53 +02:00
stop Stop running instance(s)
restart Stop and start the server
status Show server status
2026-03-12 19:40:22 +02:00
tunnel Tunnel lifecycle commands
logs Tail OpenChamber logs
2025-12-19 19:57:18 +02:00
update Check for and install updates
2025-12-07 19:32:53 +02:00
OPTIONS:
2026-01-07 20:51:45 +01:00
-p, --port Web server port (default: ${ DEFAULT_PORT } )
--ui-password Protect browser UI with single password
-h, --help Show help
-v, --version Show version
2025-12-07 19:32:53 +02:00
ENVIRONMENT:
2026-01-22 08:32:04 -08:00
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
2026-03-12 19:40:22 +02:00
OPENCHAMBER_DATA_DIR Override OpenChamber data directory
OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096
2026-01-22 08:32:04 -08:00
OPENCODE_PORT Port of external OpenCode server to connect to
OPENCODE_SKIP_START Skip starting OpenCode, use external server
2025-12-07 19:32:53 +02:00
EXAMPLES:
2026-03-12 19:40:22 +02:00
openchamber # Start in daemon mode on default port 3000 (or free port)
openchamber --port 8080 # Start on port 8080 (daemon)
openchamber tunnel help # Show tunnel lifecycle help
openchamber logs # Follow logs for latest running instance
2025-12-07 19:32:53 +02:00
` );
}
2026-03-12 19:40:22 +02:00
function showTunnelHelp () {
console . log ( `
Tunnel Lifecycle Commands
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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
--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 --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
` ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
compdef _openchamber openchamber
` ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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_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'
` ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
2025-12-07 19:32:53 +02:00
return null ;
}
2026-03-12 19:40:22 +02:00
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 ());
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
return path . join ( os . homedir (), '.config' , 'openchamber' );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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 ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
return null ;
} catch {
return null ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
function ensureLogsDir () {
fs . mkdirSync ( getLogsDir (), { recursive : true });
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
function getLogFilePath ( port ) {
return path . join ( getLogsDir (), `openchamber- ${ port } .log` );
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 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 ) } ` ;
}
const MAX_TOKEN_FILE_BYTES = 8 * 1024 ;
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.' );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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 ) {
if ( process . platform === 'win32' ) {
return ;
}
if ( isJsonMode ( activeCommandOptions ) || isQuietMode ( activeCommandOptions )) {
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 () {
const filePath = getTunnelProfilesFilePath ();
try {
warnIfUnsafeFilePermissions ( filePath );
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 () {
const current = readTunnelProfilesFromDisk ();
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 ;
});
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 };
}
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 {
2025-12-07 19:32:53 +02:00
}
}
2026-03-12 19:40:22 +02:00
}
try {
if ( fs . existsSync ( logPath )) {
fs . renameSync ( logPath , ` ${ logPath } .1` );
}
} catch {
}
}
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 ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
}
}
return null ;
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
async function checkOpenCodeCLI ( onNotice ) {
if ( process . env . OPENCODE_BINARY ) {
const override = resolveExplicitBinary ( process . env . OPENCODE_BINARY );
if ( override ) {
process . env . OPENCODE_BINARY = override ;
return override ;
}
const message = `OPENCODE_BINARY=" ${ process . env . OPENCODE_BINARY } " is not an executable file. Falling back to PATH lookup.` ;
if ( typeof onNotice === 'function' ) {
onNotice ({ level : 'warning' , code : 'OPENCODE_BINARY_INVALID' , message });
} else {
console . warn ( `Warning: ${ message } ` );
2025-12-07 19:32:53 +02:00
}
}
2026-03-12 19:40:22 +02:00
const resolvedFromPath = searchPathFor ( 'opencode' );
if ( resolvedFromPath ) {
process . env . OPENCODE_BINARY = resolvedFromPath ;
return resolvedFromPath ;
}
throw new Error (
`Unable to locate the opencode CLI on PATH ( ${ process . env . PATH || '<empty>' } ). ` +
'Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.'
);
2025-12-07 19:32:53 +02:00
}
2026-01-15 00:52:08 +02:00
async function isPortAvailable ( port ) {
2026-01-18 18:18:42 +02:00
if ( ! Number . isFinite ( port ) || port <= 0 ) {
return false ;
}
2026-01-15 00:52:08 +02:00
return await new Promise (( resolve ) => {
const server = net . createServer ();
server . unref ();
server . on ( 'error' , () => resolve ( false ));
2026-01-18 18:18:42 +02:00
server . listen ({ port }, () => {
2026-01-15 00:52:08 +02:00
server . close (() => resolve ( true ));
});
});
}
2026-03-12 19:40:22 +02:00
async function resolveAvailablePort ( desiredPort , explicitPort = false , onNotice ) {
2026-01-15 00:52:08 +02:00
const startPort = Number . isFinite ( desiredPort ) ? Math . trunc ( desiredPort ) : DEFAULT_PORT ;
2026-03-12 19:40:22 +02:00
if ( explicitPort ) {
2026-01-15 00:52:08 +02:00
return startPort ;
}
if ( await isPortAvailable ( startPort )) {
return startPort ;
}
2026-03-12 19:40:22 +02:00
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 );
}
2026-01-18 18:18:42 +02:00
return 0 ;
2026-01-15 00:52:08 +02:00
}
2026-03-12 19:40:22 +02:00
function getRunDir () {
const dir = path . join ( getDataDir (), 'run' );
fs . mkdirSync ( dir , { recursive : true , mode : 0o700 });
return dir ;
}
2025-12-07 19:32:53 +02:00
async function getPidFilePath ( port ) {
2026-03-12 19:40:22 +02:00
return path . join ( getRunDir (), `openchamber- ${ port } .pid` );
2025-12-07 19:32:53 +02:00
}
2025-12-19 19:57:18 +02:00
async function getInstanceFilePath ( port ) {
2026-03-12 19:40:22 +02:00
return path . join ( getRunDir (), `openchamber- ${ port } .json` );
2025-12-19 19:57:18 +02:00
}
2025-12-07 19:32:53 +02:00
function readPidFile ( pidFilePath ) {
try {
const content = fs . readFileSync ( pidFilePath , 'utf8' ). trim ();
2026-03-12 19:40:22 +02:00
const pid = parseInt ( content , 10 );
return Number . isFinite ( pid ) ? pid : null ;
} catch {
2025-12-07 19:32:53 +02:00
return null ;
}
}
2026-03-12 19:40:22 +02:00
function writePidFile ( pidFilePath , pid , onNotice ) {
2025-12-07 19:32:53 +02:00
try {
2026-03-12 19:40:22 +02:00
fs . writeFileSync ( pidFilePath , String ( pid ), { mode : 0o600 });
2025-12-07 19:32:53 +02:00
} catch ( error ) {
2026-03-12 19:40:22 +02:00
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 } ` );
}
2025-12-07 19:32:53 +02:00
}
}
function removePidFile ( pidFilePath ) {
try {
if ( fs . existsSync ( pidFilePath )) {
fs . unlinkSync ( pidFilePath );
}
2026-03-12 19:40:22 +02:00
} catch {
2025-12-07 19:32:53 +02:00
}
}
2025-12-19 19:57:18 +02:00
function readInstanceOptions ( instanceFilePath ) {
try {
2026-03-12 19:40:22 +02:00
return JSON . parse ( fs . readFileSync ( instanceFilePath , 'utf8' ));
} catch {
2025-12-19 19:57:18 +02:00
return null ;
}
}
2026-03-12 19:40:22 +02:00
function writeInstanceOptions ( instanceFilePath , options , onNotice ) {
2025-12-19 19:57:18 +02:00
try {
const toStore = {
port : options . port ,
2026-03-12 19:40:22 +02:00
uiPassword : typeof options . uiPassword === 'string' ? options . uiPassword : undefined ,
2025-12-19 19:57:18 +02:00
hasUiPassword : typeof options . uiPassword === 'string' ,
2026-03-12 19:40:22 +02:00
startedAt : Number . isFinite ( options . startedAt ) ? options . startedAt : Date . now (),
2025-12-19 19:57:18 +02:00
};
2026-03-12 19:40:22 +02:00
fs . writeFileSync ( instanceFilePath , JSON . stringify ( toStore , null , 2 ), { mode : 0o600 });
2025-12-19 19:57:18 +02:00
} catch ( error ) {
2026-03-12 19:40:22 +02:00
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 } ` );
}
2025-12-19 19:57:18 +02:00
}
}
function removeInstanceFile ( instanceFilePath ) {
try {
if ( fs . existsSync ( instanceFilePath )) {
fs . unlinkSync ( instanceFilePath );
}
2026-03-12 19:40:22 +02:00
} catch {
2025-12-19 19:57:18 +02:00
}
}
2025-12-07 19:32:53 +02:00
function isProcessRunning ( pid ) {
try {
process . kill ( pid , 0 );
return true ;
2026-03-12 19:40:22 +02:00
} catch {
2025-12-07 19:32:53 +02:00
return false ;
}
}
2026-02-17 18:01:57 +02:00
async function requestServerShutdown ( port ) {
if ( ! Number . isFinite ( port ) || port <= 0 ) return false ;
const controller = new AbortController ();
const timeout = setTimeout (() => controller . abort (), 1500 );
try {
2026-03-12 19:40:22 +02:00
const resp = await fetch ( buildLocalUrl ( port , '/api/system/shutdown' ), {
2026-02-17 18:01:57 +02:00
method : 'POST' ,
signal : controller . signal ,
});
return resp . ok ;
} catch {
return false ;
} finally {
clearTimeout ( timeout );
}
}
2026-03-12 19:40:22 +02:00
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 ;
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
const controller = new AbortController ();
const timeout = setTimeout (() => controller . abort (), timeoutMs );
try {
const response = await fetch ( buildLocalUrl ( port , endpoint ), {
... fetchOptions ,
headers : {
Accept : 'application/json' ,
...( fetchOptions . body ? { 'Content-Type' : 'application/json' } : {}),
...( fetchOptions . headers || {}),
},
signal : controller . signal ,
});
const body = await response . json (). catch (() => null );
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.` );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
throw error ;
} finally {
clearTimeout ( timeout );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 );
}
}
2025-12-31 05:43:18 -08:00
2026-03-12 19:40:22 +02:00
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 ;
2025-12-31 05:43:18 -08:00
}
2026-03-12 19:40:22 +02:00
await new Promise (( resolve ) => setTimeout ( resolve , intervalMs ));
}
if ( typeof onTick === 'function' ) {
onTick ({ elapsedMs : timeoutMs , timeoutMs , timedOut : true });
}
return false ;
}
function isValidTunnelDoctorResponse ( body ) {
if ( ! body || typeof body !== 'object' ) {
return false ;
}
if ( body . ok !== true ) {
return false ;
}
if ( ! Array . isArray ( body . providerChecks )) {
return false ;
}
if ( ! Array . isArray ( body . modes )) {
return false ;
}
return body . modes . every (( entry ) => {
if ( ! entry || typeof entry . mode !== 'string' ) return false ;
// Accept new shape: { ready: boolean, blockers: [] }
if ( typeof entry . ready === 'boolean' && Array . isArray ( entry . blockers )) return true ;
// Accept server shape: { checks: [], summary: { ready: boolean } }
if ( Array . isArray ( entry . checks ) && entry . summary && typeof entry . summary . ready === 'boolean' ) return true ;
return false ;
});
}
2025-12-31 05:43:18 -08:00
2026-03-12 19:40:22 +02:00
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 ] };
2025-12-31 05:43:18 -08:00
}
2026-03-12 19:40:22 +02:00
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 : [] };
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
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 () {
const instances = [];
const runDir = getRunDir ();
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 || ! isProcessRunning ( pid )) {
removePidFile ( pidFilePath );
removeInstanceFile ( path . join ( runDir , `openchamber- ${ port } .json` ));
continue ;
}
const instanceFilePath = path . join ( runDir , `openchamber- ${ port } .json` );
let mtime = 0 ;
let startedAt = 0 ;
try {
mtime = fs . statSync ( pidFilePath ). mtimeMs ;
} catch {
}
const storedOptions = readInstanceOptions ( instanceFilePath );
if ( Number . isFinite ( storedOptions ? . startedAt )) {
startedAt = storedOptions . startedAt ;
}
instances . push ({ port , pid , pidFilePath , instanceFilePath , mtime , startedAt });
}
} catch {
}
instances . sort (( a , b ) => a . port - b . port );
return instances ;
}
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 ];
}
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 ) {
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' ), {
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 );
}
}
async function inspectTunnelAttachability ( port , { requireHealthy = true } = {}) {
const info = await fetchSystemInfoFromPort ( port );
if ( ! info || typeof info . runtime !== 'string' ) {
return { attachable : false , reason : 'unreachable' };
}
if ( info . runtime === 'desktop' ) {
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' };
}
2026-01-02 21:57:53 +02:00
2026-03-12 19:40:22 +02:00
async function resolveTargetInstance ({
options ,
allowAutoStart ,
requireAll = false ,
rejectDesktopRuntime = false ,
}) {
let running = await discoverRunningInstances ();
if ( options . all && requireAll ) {
if ( running . length === 0 ) {
throw new Error ( 'No running OpenChamber instance found. Start one with `openchamber serve`.' );
}
return running ;
}
if ( options . explicitPort ) {
const found = running . find (( entry ) => entry . port === options . port );
if ( found ) {
if ( rejectDesktopRuntime ) {
const attachability = await inspectTunnelAttachability ( found . port , { requireHealthy : true });
if ( ! attachability . attachable ) {
if ( attachability . reason === 'desktop' ) {
throw new Error (
`Port ${ options . port } is used by OpenChamber Desktop app. Tunnel attach requires a CLI instance from \`openchamber serve\`.`
);
}
throw new Error (
`Port ${ options . port } is not an attachable OpenChamber tunnel instance. Ensure it is healthy and running OpenChamber CLI runtime.`
);
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
}
return found ;
}
if ( rejectDesktopRuntime ) {
const systemInfo = await fetchSystemInfoFromPort ( options . port );
if ( systemInfo ? . runtime === 'desktop' ) {
throw new Error (
`Port ${ options . port } is used by OpenChamber Desktop app. Tunnel attach requires a CLI instance from \`openchamber serve\`.`
);
}
}
if ( allowAutoStart ) {
await commands . serve ({
port : options . port ,
explicitPort : true ,
uiPassword : options . uiPassword ,
suppressUnsafePortWarning : true ,
suppressUiPasswordWarning : true ,
suppressStartupSummary : true ,
2025-12-07 19:32:53 +02:00
});
2026-03-12 19:40:22 +02:00
running = await discoverRunningInstances ();
const started = running . find (( entry ) => entry . port === options . port );
if ( started ) return { ... started , autoStarted : true };
}
throw new Error ( `No running OpenChamber instance found on port ${ options . port } .` );
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( rejectDesktopRuntime ) {
const attachableEntries = [];
let sawDesktop = false ;
for ( const entry of running ) {
const attachability = await inspectTunnelAttachability ( entry . port , { requireHealthy : true });
if ( attachability . reason === 'desktop' ) {
sawDesktop = true ;
}
if ( attachability . attachable ) {
attachableEntries . push ( entry );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( attachableEntries . length === 1 ) {
return attachableEntries [ 0 ];
}
2026-01-02 21:57:53 +02:00
2026-03-12 19:40:22 +02:00
if ( attachableEntries . length > 1 ) {
const ports = attachableEntries . map (( entry ) => entry . port ). join ( ', ' );
throw new Error ( `Multiple attachable OpenChamber instances found: ${ ports } . Use --port <port> or --all.` );
}
if ( allowAutoStart ) {
const startedPort = await commands . serve ({
... options ,
explicitPort : false ,
suppressUnsafePortWarning : true ,
suppressUiPasswordWarning : true ,
suppressStartupSummary : true ,
2026-01-18 18:18:42 +02:00
});
2026-03-12 19:40:22 +02:00
running = await discoverRunningInstances ();
const started = running . find (( entry ) => entry . port === startedPort ) || getLatestInstance ( running );
if ( started ) return { ... started , autoStarted : true };
}
if ( sawDesktop ) {
throw new Error ( 'Only OpenChamber Desktop instance(s) detected. Tunnel attach requires a CLI instance from `openchamber serve`.' );
}
throw new Error ( 'No attachable OpenChamber instance found. Start one with `openchamber serve`.' );
}
if ( running . length === 1 ) {
return running [ 0 ];
}
if ( running . length === 0 ) {
if ( allowAutoStart ) {
const startedPort = await commands . serve ({
... options ,
explicitPort : false ,
suppressUnsafePortWarning : true ,
suppressUiPasswordWarning : true ,
});
running = await discoverRunningInstances ();
const started = running . find (( entry ) => entry . port === startedPort ) || getLatestInstance ( running );
if ( started ) return { ... started , autoStarted : true };
}
throw new Error ( 'No running OpenChamber instance found. Start one with `openchamber serve`.' );
}
const ports = running . map (( entry ) => entry . port ). join ( ', ' );
throw new Error ( `Multiple OpenChamber instances found: ${ ports } . Use --port <port> or --all.` );
}
async function resolveTunnelReadEntries ( options ) {
const running = await discoverRunningInstances ();
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 } .` );
}
return [ found ];
}
if ( running . length === 0 ) {
throw new Error ( 'No running OpenChamber instance found. Start one with `openchamber serve`.' );
}
return running ;
}
function formatTunnelStatusLine ( statusBody , port ) {
const active = Boolean ( statusBody ? . active );
const provider = statusBody ? . provider || 'unknown' ;
const mode = statusBody ? . mode || 'unknown' ;
const url = statusBody ? . url || 'n/a' ;
return {
status : active ? 'success' : 'neutral' ,
line : `port ${ port } ${ active ? 'active' : 'inactive' } ( ${ clackFormatProviderWithIcon ( provider ) } / ${ mode } )` ,
detail : url ,
};
}
function formatModeRequirements ( mode ) {
const requires = Array . isArray ( mode ? . requires ) ? mode . requires . filter ( Boolean ) : [];
if (( mode ? . key || '' ) === 'managed-local' ) {
return 'config-path (or default cloudflared config)' ;
}
if ( requires . length === 0 ) {
return 'none' ;
}
return requires . join ( ', ' );
}
function annotateTunnelProvidersForOutput ( providers ) {
if ( ! Array . isArray ( providers )) return providers ;
return providers . map (( provider ) => {
const modes = Array . isArray ( provider ? . modes ) ? provider . modes : [];
return {
... provider ,
modes : modes . map (( mode ) => ({
... mode ,
displayRequires : formatModeRequirements ( mode ),
})),
};
});
}
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
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' );
2026-01-22 01:19:24 +02:00
try {
2026-03-12 19:40:22 +02:00
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 );
2026-01-22 01:19:24 +02:00
}
2026-03-12 19:40:22 +02:00
} finally {
fs . closeSync ( fd );
}
} catch {
}
}, 400 );
return () => {
clearInterval ( interval );
};
}
async function handleTunnelProfileSubcommand ( options , action ) {
const sub = typeof action === 'string' ? action . trim (). toLowerCase () : '' ;
const store = ensureTunnelProfilesMigrated ();
if ( ! sub ) {
if ( isJsonMode ( options )) {
printJson ({
command : 'tunnel profile' ,
subcommands : [ 'list' , 'show' , 'add' , 'remove' ],
});
return ;
}
if ( ! isQuietMode ( options )) {
clackIntro ( 'Tunnel Profile' );
logStatus ( 'info' , 'Available subcommands' , 'list, show, add, remove' );
clackLog . step ( 'List profiles: `openchamber tunnel profile list`' );
clackLog . step ( 'Show one profile: `openchamber tunnel profile show --name <name>`' );
clackLog . step ( 'Add profile: `openchamber tunnel profile add --provider cloudflare --mode managed-remote --name <name> --hostname <host> --token <token>`' );
clackLog . step ( 'Remove profile: `openchamber tunnel profile remove --name <name>`' );
clackOutro ( 'Choose a subcommand' );
}
return ;
}
if ( sub === 'list' ) {
const providerFilter = normalizeProfileProvider ( options . provider );
const profiles = providerFilter
? store . profiles . filter (( entry ) => entry . provider === providerFilter )
: store . profiles ;
if ( isJsonMode ( options )) {
printJson ({ profiles : redactProfilesForOutput ( profiles , options . showSecrets ) });
return ;
}
if ( isQuietMode ( options )) {
for ( const profile of profiles ) {
process . stdout . write ( ` ${ profile . name } ${ profile . provider } / ${ profile . mode } ${ profile . hostname } \n` );
2026-01-22 01:19:24 +02:00
}
2026-03-12 19:40:22 +02:00
return ;
}
2026-01-22 01:19:24 +02:00
2026-03-12 19:40:22 +02:00
clackIntro ( 'Tunnel Profiles' );
for ( const profile of profiles ) {
logStatus ( 'success' , ` ${ profile . name } ( ${ profile . provider } / ${ profile . mode } )` , ` ${ profile . hostname } ${ formatProfileTokenStatus ( profile , options . showSecrets ) } ` );
}
clackOutro ( ` ${ profiles . length } profile(s)` );
return ;
}
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
if ( sub === 'show' ) {
const name = normalizeProfileName ( options . name );
if ( ! name ) {
throw new Error ( '`tunnel profile show` requires --name <name>.' );
}
const { profile , error } = resolveProfileByName ( store . profiles , name , options . provider );
if ( ! profile ) {
throw new Error ( error );
}
if ( isJsonMode ( options )) {
printJson ({ profile : redactProfileForOutput ( profile , options . showSecrets ) });
return ;
}
if ( isQuietMode ( options )) {
process . stdout . write ( ` ${ profile . name } ${ profile . provider } / ${ profile . mode } ${ profile . hostname } ${ formatProfileTokenStatus ( profile , options . showSecrets ) } \n` );
return ;
}
clackIntro ( 'Tunnel Profile' );
logStatus ( 'success' , ` ${ profile . name } ( ${ profile . provider } / ${ profile . mode } )` , ` ${ profile . hostname } ${ formatProfileTokenStatus ( profile , options . showSecrets ) } ` );
clackOutro ( 'show complete' );
return ;
}
2026-01-02 21:57:53 +02:00
2026-03-12 19:40:22 +02:00
if ( sub === 'add' ) {
let provider = normalizeProfileProvider ( options . provider );
let mode = normalizeProfileMode ( options . mode );
let name = normalizeProfileName ( options . name );
let hostname = normalizeProfileHostname ( options . hostname );
const resolvedTokenValue = resolveToken ( options );
let token = normalizeProfileToken ( resolvedTokenValue );
if ( canPrompt ( options )) {
if ( ! provider ) {
const providerResult = await resolveTunnelProviders ( options , {
readPorts : async () => ( await discoverRunningInstances ()). map (( entry ) => entry . port ),
});
const providerOptions = ( Array . isArray ( providerResult . providers ) ? providerResult . providers : [])
. map (( entry ) => normalizeProfileProvider ( entry ? . provider ))
. filter ( Boolean )
. map (( providerId ) => ({ value : providerId , label : clackFormatProviderWithIcon ( providerId ) }));
if ( providerOptions . length === 1 ) {
provider = providerOptions [ 0 ]. value ;
} else if ( providerOptions . length > 1 ) {
const selectedProvider = await clackSelect ({
message : 'Select tunnel provider' ,
options : providerOptions ,
});
if ( clackIsCancel ( selectedProvider )) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
provider = normalizeProfileProvider ( selectedProvider );
2026-01-18 18:18:42 +02:00
}
2026-01-02 21:57:53 +02:00
}
2026-03-12 19:40:22 +02:00
if ( ! mode ) {
mode = 'managed-remote' ;
}
if ( ! name ) {
const enteredName = await clackText ({
message : 'Profile name (Enter to accept/edit)' ,
placeholder : 'prod-main' ,
initialValue : 'prod-main' ,
validate ( value ) {
return normalizeProfileName ( value ). length > 0 ? undefined : 'Profile name is required.' ;
},
});
if ( clackIsCancel ( enteredName )) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
name = normalizeProfileName ( enteredName );
}
const existingProfile = provider && name
? store . profiles . find (( entry ) => entry . provider === provider && entry . name . toLowerCase () === name . toLowerCase ())
: null ;
if ( ! hostname ) {
const enteredHostname = await clackText ({
message : 'Tunnel hostname (Enter to accept/edit)' ,
placeholder : existingProfile ? . hostname || 'app.example.com' ,
initialValue : existingProfile ? . hostname || 'app.example.com' ,
validate ( value ) {
return normalizeProfileHostname ( value ). length > 0 ? undefined : 'Hostname is required.' ;
},
});
if ( clackIsCancel ( enteredHostname )) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
hostname = normalizeProfileHostname ( enteredHostname );
}
if ( ! token && existingProfile ? . token ) {
const useExistingToken = await clackConfirm ({
message : `Reuse saved token for profile ' ${ existingProfile . name } '?` ,
initialValue : true ,
});
if ( clackIsCancel ( useExistingToken )) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
if ( useExistingToken ) {
token = existingProfile . token ;
}
}
2026-01-18 18:18:42 +02:00
}
2026-03-12 19:40:22 +02:00
if ( ! provider || ! mode || ! name || ! hostname ) {
throw new Error ( '`tunnel profile add` requires --provider, --mode managed-remote, --name, and --hostname.' );
2026-01-18 18:18:42 +02:00
}
2026-03-12 19:40:22 +02:00
if ( ! token ) {
if ( canPrompt ( options )) {
const entered = await clackPassword ({
message : `Enter tunnel token for profile ' ${ name } '` ,
});
if ( clackIsCancel ( entered ) || ! entered || ! entered . trim ()) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
token = normalizeProfileToken ( entered . trim ());
}
if ( ! token ) {
throw new Error ( '`tunnel profile add` requires a token (--token, --token-file, or --token-stdin).' );
}
2026-01-22 08:32:04 -08:00
}
2026-03-12 19:40:22 +02:00
if ( mode !== 'managed-remote' ) {
throw new Error ( '`tunnel profile add` currently supports only --mode managed-remote.' );
2026-01-18 18:18:42 +02:00
}
2026-03-12 19:40:22 +02:00
const existingIndex = store . profiles . findIndex (
( entry ) => entry . provider === provider && entry . name . toLowerCase () === name . toLowerCase ()
);
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
if ( existingIndex >= 0 && ! options . force && ! options . dryRun ) {
if ( canPrompt ( options )) {
const shouldOverwrite = await clackConfirm ({
message : `Profile ' ${ name } ' already exists for provider ' ${ provider } '. Overwrite?` ,
});
if ( clackIsCancel ( shouldOverwrite ) || ! shouldOverwrite ) {
clackCancel ( 'Profile add cancelled.' );
return ;
}
} else {
throw new Error ( `Profile ' ${ name } ' already exists for provider ' ${ provider } '. Use --force to overwrite.` );
}
}
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
if ( options . dryRun ) {
const dryRunResult = {
ok : true ,
dryRun : true ,
action : existingIndex >= 0 ? 'overwrite' : 'create' ,
profile : redactProfileForOutput ({ name , provider , mode , hostname , token }, options . showSecrets ),
};
if ( isJsonMode ( options )) {
printJson ( dryRunResult );
} else if ( ! isQuietMode ( options )) {
clackIntro ( 'Tunnel Profile Add (dry-run)' );
logStatus ( 'info' , `Would ${ existingIndex >= 0 ? 'overwrite' : 'create' } : ${ name } ( ${ provider } / ${ mode } )` , ` ${ hostname } ${ formatProfileTokenStatus ({ token } , options.showSecrets)}` );
clackOutro ( 'dry-run complete (no changes applied)' );
}
2026-01-18 18:18:42 +02:00
return ;
2025-12-07 19:32:53 +02:00
}
2026-01-18 18:18:42 +02:00
2026-03-12 19:40:22 +02:00
const next = [... store . profiles ];
const now = Date . now ();
if ( existingIndex >= 0 ) {
const current = next [ existingIndex ];
next [ existingIndex ] = {
... current ,
mode ,
hostname ,
token ,
updatedAt : now ,
};
} else {
next . push ({
id : crypto . randomUUID (),
name ,
provider ,
mode ,
hostname ,
token ,
createdAt : now ,
updatedAt : now ,
});
}
const persisted = { version : TUNNEL_PROFILES_VERSION , profiles : next };
writeTunnelProfilesToDisk ( persisted );
writeManagedRemotePairsToDiskFromProfiles ( persisted );
const added = persisted . profiles . find (( entry ) => entry . provider === provider && entry . name . toLowerCase () === name . toLowerCase ());
if ( isJsonMode ( options )) {
printJson ({ ok : true , profile : redactProfileForOutput ( added , options . showSecrets ) });
return ;
}
if ( isQuietMode ( options )) {
process . stdout . write ( `saved ${ added . name } ${ added . provider } / ${ added . mode } ${ added . hostname } \n` );
return ;
}
console . log ( '' );
clackIntro ( boldText ( 'Tunnel Profile Saved' ));
logStatus ( 'success' , ` ${ added . name } ( ${ added . provider } / ${ added . mode } )` , ` ${ added . hostname } ${ formatProfileTokenStatus ( added , options . showSecrets ) } ` );
clackOutro ( 'save complete' );
logStatus ( 'info' , '[START_PROFILE]' , `openchamber tunnel start --profile ${ added . name } ` );
clackOutro ( '' );
return ;
}
if ( sub === 'remove' ) {
const name = normalizeProfileName ( options . name );
if ( ! name ) {
throw new Error ( '`tunnel profile remove` requires --name <name>.' );
}
const { profile , error } = resolveProfileByName ( store . profiles , name , options . provider );
if ( ! profile ) {
throw new Error ( error );
}
const next = store . profiles . filter (( entry ) => entry . id !== profile . id );
const persisted = { version : TUNNEL_PROFILES_VERSION , profiles : next };
writeTunnelProfilesToDisk ( persisted );
writeManagedRemotePairsToDiskFromProfiles ( persisted );
if ( isJsonMode ( options )) {
printJson ({ ok : true , removed : redactProfileForOutput ( profile , options . showSecrets ) });
return ;
}
if ( isQuietMode ( options )) {
process . stdout . write ( `removed ${ profile . name } ${ profile . provider } / ${ profile . mode } ${ profile . hostname } \n` );
return ;
}
clackIntro ( 'Tunnel Profile Removed' );
logStatus ( 'success' , ` ${ profile . name } ( ${ profile . provider } / ${ profile . mode } )` , profile . hostname );
clackOutro ( 'remove complete' );
return ;
}
const knownProfileActions = [ 'list' , 'show' , 'add' , 'remove' ];
const suggestion = findClosestMatch ( sub , knownProfileActions );
const hint = suggestion ? ` Did you mean ' ${ suggestion } '?` : '' ;
throw new TunnelCliError (
`Unknown tunnel profile subcommand ' ${ sub } '. ${ hint } Use 'openchamber tunnel help'.` ,
EXIT_CODE . USAGE_ERROR
);
}
const commands = {
async serve ( 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 targetPort = await resolveAvailablePort ( options . port , explicitPort , emitNotice );
if ( targetPort !== 0 && ! options . suppressUnsafePortWarning ) {
assertSafeBrowserPort ( targetPort , { context : 'OpenChamber serve' });
}
if ( targetPort !== 0 ) {
const pidFilePath = await getPidFilePath ( targetPort );
const existingPid = readPidFile ( pidFilePath );
if ( existingPid && isProcessRunning ( existingPid )) {
throw new Error ( `OpenChamber is already running on port ${ targetPort } (PID: ${ existingPid } )` );
}
if ( explicitPort && ! ( await isPortAvailable ( targetPort ))) {
const systemInfo = await fetchSystemInfoFromPort ( targetPort );
if ( systemInfo ? . runtime === 'desktop' ) {
throw new Error (
`Port ${ targetPort } is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
if ( systemInfo ? . runtime ) {
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 serverPath = path . join ( __dirname , '..' , 'server' , 'index.js' );
const preferredRuntime = getPreferredServerRuntime ();
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : 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 ;
if ( ! effectiveUiPassword && ! options . suppressUiPasswordWarning ) {
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set' ;
const warningDetail = '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 } ` );
}
}
const serverArgs = [ serverPath , '--port' , String ( targetPort )];
const serveSpin = showOutput ? createSpinner ( options ) : null ;
const child = spawn ( runtimeBin , serverArgs , {
detached : true ,
2026-03-13 02:13:36 -07:00
windowsHide : true ,
2026-03-12 19:40:22 +02:00
stdio : [ 'ignore' , logFd , logFd , 'ipc' ],
env : {
... process . env ,
OPENCHAMBER_PORT : String ( targetPort ),
OPENCODE_BINARY : opencodeBinary ,
...( effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD : effectiveUiPassword } : {}),
...( 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 } ...` );
const resolvedPort = await new Promise (( resolve ) => {
let settled = false ;
const timeout = setTimeout (() => {
if ( settled ) return ;
settled = true ;
resolve ( targetPort );
}, 5000 );
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 ( 'exit' , () => {
if ( settled ) return ;
settled = true ;
clearTimeout ( timeout );
resolve ( targetPort );
});
});
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 ,
uiPassword : effectiveUiPassword ,
}, emitNotice );
const serveResult = {
port : resolvedPort ,
pid : child . pid ,
url : buildLocalUrl ( resolvedPort , '/' ),
logs : `openchamber logs -p ${ resolvedPort } ` ,
};
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 ;
},
async stop ( 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 discoverRunningInstances ();
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 ;
}
if ( options . explicitPort ) {
runningInstances = runningInstances . filter (( entry ) => entry . port === options . port );
if ( runningInstances . length === 0 ) {
const systemInfo = await fetchSystemInfoFromPort ( options . port );
if ( systemInfo ? . 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 ( systemInfo ? . runtime ) {
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 );
if ( Number . isFinite ( systemInfo . pid ) && isProcessRunning ( systemInfo . pid )) {
try {
process . kill ( systemInfo . pid , 'SIGTERM' );
let attempts = 0 ;
while ( isProcessRunning ( systemInfo . pid ) && attempts < 20 ) {
await new Promise (( resolve ) => setTimeout ( resolve , 250 ));
attempts ++ ;
}
if ( isProcessRunning ( systemInfo . pid )) {
process . kill ( systemInfo . pid , 'SIGKILL' );
}
} catch {
}
}
const stopped = await isPortAvailable ( options . port );
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 ;
}
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 ;
}
}
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 {
await requestServerShutdown ( instance . port );
process . kill ( instance . pid , 'SIGTERM' );
let attempts = 0 ;
while ( isProcessRunning ( instance . pid ) && attempts < 20 ) {
await new Promise (( resolve ) => setTimeout ( resolve , 250 ));
attempts ++ ;
}
if ( isProcessRunning ( instance . pid )) {
process . kill ( instance . pid , 'SIGKILL' );
}
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 restart ( options ) {
const showOutput = shouldRenderHumanOutput ( options );
const restarted = [];
if ( showOutput ) {
clackIntro ( 'OpenChamber Restart' );
}
let runningInstances = await discoverRunningInstances ();
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 ;
}
if ( options . explicitPort ) {
runningInstances = runningInstances . filter (( entry ) => entry . port === options . port );
if ( runningInstances . length === 0 ) {
if ( isJsonMode ( options )) {
printJson ({ restartedCount : 0 , results : restarted });
}
if ( showOutput ) {
logStatus ( 'warning' , `no OpenChamber instance found on port ${ options . port } ` );
clackOutro ( 'nothing to restart' );
} else if ( isQuietMode ( options )) {
process . stdout . write ( 'restarted 0\n' );
}
return ;
}
}
for ( const instance of runningInstances ) {
const storedOptions = readInstanceOptions ( instance . instanceFilePath ) || { port : instance . port };
const restartSpin = showOutput ? createSpinner ( options ) : null ;
if ( showOutput && ! restartSpin ) {
logStatus ( 'info' , `restarting port ${ instance . port } ` );
}
restartSpin ? . start ( `Restarting OpenChamber on port ${ instance . port } ...` );
try {
await this . stop ({
explicitPort : true ,
port : instance . port ,
quiet : true ,
suppressQuietOutput : true ,
});
await new Promise (( resolve ) => setTimeout ( resolve , 500 ));
const restartedPort = await this . serve ({
port : options . explicitPort ? options . port : ( storedOptions . port || instance . port ),
explicitPort : true ,
uiPassword : options . explicitUiPassword ? options . uiPassword : storedOptions . uiPassword ,
suppressStartupSummary : true ,
quiet : true ,
suppressUiPasswordWarning : true ,
suppressQuietOutput : true ,
});
restarted . push ({ fromPort : instance . port , toPort : restartedPort , ok : true });
restartSpin ? . stop ( `Restarted OpenChamber on port ${ restartedPort } ` );
if ( showOutput && ! restartSpin ) {
logStatus ( 'success' , `port ${ restartedPort } restarted` );
}
} 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 });
return ;
}
if ( showOutput ) {
clackOutro ( ` ${ runningInstances . length } instance(s) restarted` );
} else if ( isQuietMode ( options )) {
process . stdout . write ( `restarted ${ restarted . length } \n` );
}
},
async status ( options = {}) {
const [ runningInstances , desktopInstance ] = await Promise . all ([
discoverRunningInstances (),
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 ,
passwordProtected : null ,
}
: null ;
const cliInstances = runningInstances . map (( instance ) => {
const storedOptions = readInstanceOptions ( instance . instanceFilePath ) || {};
const passwordProtected = storedOptions . hasUiPassword === true
|| ( typeof storedOptions . uiPassword === 'string' && storedOptions . uiPassword . trim (). length > 0 );
return {
runtime : 'cli' ,
port : instance . port ,
pid : instance . pid ,
passwordProtected ,
};
});
const instances = desktopOnly ? [... cliInstances , desktopOnly ] : cliInstances ;
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 } 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 protectionDetail = `password: ${ toPasswordProtectionLabel ( instance . passwordProtected ) } ` ;
if ( instance . runtime === 'desktop' ) {
logStatus ( 'info' , `desktop app on port ${ instance . port }${ pidSuffix } ` , protectionDetail );
} else {
logStatus ( 'success' , `port ${ instance . port }${ pidSuffix } ` , protectionDetail );
}
}
clackOutro ( ` ${ runningCount } running runtime(s)` );
},
async tunnel ( options , subcommand , action ) {
switch ( subcommand ) {
case 'help' :
showTunnelHelp ();
return ;
case 'profile' :
await handleTunnelProfileSubcommand ( options , action );
return ;
case 'providers' : {
const result = await resolveTunnelProviders ( options , {
readPorts : async () => ( await discoverRunningInstances ()). map (( entry ) => entry . port ),
});
if ( isJsonMode ( options )) {
printJson ({ providers : annotateTunnelProvidersForOutput ( result . providers ), source : result . source });
return ;
}
if ( isQuietMode ( options )) {
for ( const provider of result . providers ) {
const modes = Array . isArray ( provider ? . modes ) ? provider . modes : [];
const providerId = provider ? . provider || 'unknown' ;
process . stdout . write ( `provider ${ providerId } modes ${ modes . length } \n` );
for ( const mode of modes ) {
const requires = formatModeRequirements ( mode ). replace ( /,\s+/g , ',' );
process . stdout . write ( `mode ${ mode ? . key || 'unknown' } requires ${ requires } \n` );
}
}
return ;
}
clackIntro ( 'Tunnel Providers' );
for ( const provider of result . providers ) {
const modes = Array . isArray ( provider ? . modes ) ? provider . modes : [];
clackLog . success ( ` ${ clackFormatProviderWithIcon ( provider . provider ) } — ${ modes . length } mode(s)` );
for ( const mode of modes ) {
const label = mode . label || mode . key ;
const requires = formatModeRequirements ( mode );
clackLog . step ( ` ${ mode . key } — ${ label } \n requires: ${ requires } ` );
}
}
clackOutro ( ` ${ result . providers . length } provider(s)` );
return ;
}
case 'ready' : {
const entries = await resolveTunnelReadEntries ( options );
const provider = typeof options . provider === 'string' && options . provider . trim (). length > 0
? options . provider . trim (). toLowerCase ()
: 'cloudflare' ;
const results = [];
for ( const entry of entries ) {
try {
const { response , body } = await requestJson ( entry . port , `/api/openchamber/tunnel/check?provider= ${ encodeURIComponent ( provider ) } ` );
if ( ! response . ok ) {
results . push ({ port : entry . port , error : body ? . error || `check ${ response . status } ` });
continue ;
}
results . push ({ port : entry . port , result : body });
} catch ( error ) {
results . push ({ port : entry . port , error : error instanceof Error ? error . message : String ( error ) });
}
}
if ( isJsonMode ( options )) {
printJson ({ instances : results });
return ;
}
if ( isQuietMode ( options )) {
for ( const result of results ) {
if ( result . error ) {
process . stderr . write ( `port ${ result . port } failed: ${ result . error } \n` );
continue ;
}
const providerId = result . result ? . provider || provider ;
if ( result . result ? . available ) {
process . stdout . write ( `port ${ result . port } ready ${ providerId } ${ result . result ? . version || 'unknown' } \n` );
} else {
process . stdout . write ( `port ${ result . port } not-ready ${ providerId } ${ result . result ? . message || 'not ready' } \n` );
}
}
return ;
}
clackIntro ( 'Tunnel Ready' );
for ( const result of results ) {
if ( result . error ) {
logStatus ( 'error' , `port ${ result . port } failed` , result . error );
continue ;
}
logStatus (
result . result ? . available ? 'success' : 'warning' ,
`port ${ result . port } provider ${ clackFormatProviderWithIcon ( result . result ? . provider || provider ) } ` ,
result . result ? . available
? `ready ( ${ result . result ? . version || 'unknown version' } )`
: ( result . result ? . message || 'not ready' ),
);
}
clackOutro ( ` ${ results . length } instance(s)` );
return ;
}
case 'status' : {
const entries = await resolveTunnelReadEntries ( options );
const results = [];
for ( const entry of entries ) {
try {
const { response , body } = await requestJson ( entry . port , '/api/openchamber/tunnel/status' );
if ( ! response . ok ) {
results . push ({ port : entry . port , error : body ? . error || `status ${ response . status } ` });
continue ;
}
results . push ({ port : entry . port , status : body });
} catch ( error ) {
results . push ({ port : entry . port , error : error instanceof Error ? error . message : String ( error ) });
}
}
if ( isJsonMode ( options )) {
printJson ({ instances : results });
return ;
}
if ( isQuietMode ( options )) {
for ( const result of results ) {
if ( result . error ) {
process . stderr . write ( `port ${ result . port } failed: ${ result . error } \n` );
continue ;
}
const active = Boolean ( result . status ? . active );
const provider = result . status ? . provider || 'unknown' ;
const mode = result . status ? . mode || 'unknown' ;
const url = result . status ? . url || 'n/a' ;
process . stdout . write ( `port ${ result . port } ${ active ? 'active' : 'inactive' } ${ provider } / ${ mode } ${ url } \n` );
}
return ;
}
clackIntro ( 'Tunnel Status' );
for ( const result of results ) {
if ( result . error ) {
logStatus ( 'error' , `port ${ result . port } failed` , result . error );
continue ;
}
const sl = formatTunnelStatusLine ( result . status , result . port );
logStatus ( sl . status , sl . line , sl . detail );
}
clackOutro ( ` ${ results . length } instance(s)` );
return ;
}
case 'doctor' : {
const doctorSpin = createSpinner ( options );
doctorSpin ? . start ( 'Running tunnel diagnostics...' );
// Phase 1: Port discovery
const { statuses : portStatuses , availableEntries } = await resolveDoctorPortStatuses ( options );
// Phase 2: Provider diagnostics via the doctor endpoint
doctorSpin ? . message ( 'Checking provider...' );
let providerOption = typeof options . provider === 'string' && options . provider . trim (). length > 0
? options . provider . trim (). toLowerCase ()
: '' ;
let doctorProfile = null ;
let doctorHostnameOverride = typeof options . hostname === 'string' ? options . hostname . trim () : '' ;
const explicitHostnameProvided = doctorHostnameOverride . length > 0 ;
const explicitTokenProvided = Boolean ( options . tokenStdin )
|| ( typeof options . token === 'string' && options . token . trim (). length > 0 )
|| ( typeof options . tokenFile === 'string' && options . tokenFile . trim (). length > 0 );
let doctorTokenValue = resolveToken ( options );
let hasSavedManagedRemoteProfile = false ;
const normalizedMode = typeof options . mode === 'string' ? options . mode . trim (). toLowerCase () : '' ;
if ( typeof options . profile === 'string' && options . profile . trim (). length > 0 ) {
const store = ensureTunnelProfilesMigrated ();
const resolved = resolveProfileByName ( store . profiles , options . profile , providerOption || options . provider );
if ( ! resolved . profile ) {
throw new Error ( resolved . error );
}
doctorProfile = resolved . profile ;
} else if ( ! doctorHostnameOverride && ! explicitTokenProvided && ( ! normalizedMode || normalizedMode === 'managed-remote' )) {
const store = ensureTunnelProfilesMigrated ();
const remoteProfiles = store . profiles . filter (( entry ) => {
if ( entry . mode !== 'managed-remote' ) return false ;
if ( ! providerOption ) return true ;
return entry . provider === providerOption ;
});
hasSavedManagedRemoteProfile = remoteProfiles . some (( entry ) => {
const savedHostname = normalizeProfileHostname ( entry . hostname );
const savedToken = normalizeProfileToken ( entry . token );
return Boolean ( savedHostname && savedToken );
});
if ( remoteProfiles . length === 1 ) {
doctorProfile = remoteProfiles [ 0 ];
}
}
if ( doctorProfile ) {
providerOption = providerOption || doctorProfile . provider ;
if ( ! doctorHostnameOverride && typeof doctorProfile . hostname === 'string' ) {
doctorHostnameOverride = doctorProfile . hostname . trim ();
}
if (( ! doctorTokenValue || doctorTokenValue . trim (). length === 0 ) && typeof doctorProfile . token === 'string' ) {
doctorTokenValue = doctorProfile . token . trim ();
}
}
let doctorResult = null ;
let doctorError = null ;
const diagnosticsEntries = [... availableEntries ]. sort (( a , b ) => b . mtime - a . mtime );
if ( diagnosticsEntries . length > 0 ) {
const query = new URLSearchParams ();
if ( providerOption ) query . set ( 'provider' , providerOption );
if ( typeof options . mode === 'string' && options . mode . trim (). length > 0 ) {
query . set ( 'mode' , options . mode . trim (). toLowerCase ());
}
if ( typeof options . configPath === 'string' ) query . set ( 'configPath' , options . configPath );
if ( doctorHostnameOverride . length > 0 ) {
query . set ( 'managedRemoteTunnelHostname' , doctorHostnameOverride );
}
if ( hasSavedManagedRemoteProfile ) {
query . set ( 'hasSavedManagedRemoteProfile' , '1' );
}
const doctorBody = {};
doctorBody . managedRemoteTunnelTokenProvided = explicitTokenProvided ;
doctorBody . managedRemoteTunnelHostnameProvided = explicitHostnameProvided ;
if ( typeof doctorTokenValue === 'string' && doctorTokenValue . trim (). length > 0 ) {
doctorBody . managedRemoteTunnelToken = doctorTokenValue ;
}
const failedPorts = [];
for ( const diagnosticsEntry of diagnosticsEntries ) {
try {
doctorSpin ? . message ( `Diagnosing provider on port ${ diagnosticsEntry . port } ...` );
const doctorFetchOptions = { timeoutMs : 10000 };
if ( Object . keys ( doctorBody ). length > 0 ) {
doctorFetchOptions . method = 'POST' ;
doctorFetchOptions . body = JSON . stringify ( doctorBody );
}
const { response , body } = await requestJson (
diagnosticsEntry . port ,
`/api/openchamber/tunnel/doctor? ${ query . toString () } ` ,
doctorFetchOptions ,
);
if ( response . ok && body ? . ok && isValidTunnelDoctorResponse ( body )) {
doctorResult = body ;
doctorError = null ;
break ;
}
const looksIncompatible = response . ok && ( ! body || typeof body !== 'object' || ! body . ok );
const fallbackError = looksIncompatible
? `port ${ diagnosticsEntry . port } : doctor endpoint unavailable or incompatible (restart this CLI instance)`
: `port ${ diagnosticsEntry . port } : ${ body ? . error || `doctor ${ response . status } ` } ` ;
failedPorts . push ( fallbackError );
} catch ( error ) {
const message = error instanceof Error ? error . message : String ( error );
failedPorts . push ( `port ${ diagnosticsEntry . port } : ${ message } ` );
}
}
if ( ! doctorResult ) {
doctorError = failedPorts . length > 0
? failedPorts [ 0 ]
: 'No compatible CLI instance found for tunnel doctor.' ;
}
}
doctorSpin ? . clear ();
// JSON output
if ( isJsonMode ( options )) {
const cliPorts = portStatuses
. filter (( s ) => s . available )
. map (( s ) => ({ port : s . port , type : 'cli' , available : true }));
const desktopPorts = portStatuses
. filter (( s ) => ! s . available )
. map (( s ) => ({ port : s . port , type : 'desktop' , available : false }));
printJson ({
ports : [... cliPorts , ... desktopPorts ],
provider : doctorResult ? {
id : doctorResult . provider ,
checks : doctorResult . providerChecks || [],
} : null ,
modes : doctorResult ? . modes || [],
error : doctorError || undefined ,
});
return ;
}
if ( isQuietMode ( options )) {
const cliPorts = portStatuses . filter (( s ) => s . available ). map (( s ) => s . port );
process . stdout . write ( `cli-ports ${ cliPorts . join ( ',' ) || 'none' } \n` );
if ( doctorError ) {
process . stderr . write ( `doctor-error ${ doctorError } \n` );
return ;
}
if ( ! doctorResult ) {
process . stdout . write ( 'doctor unavailable\n' );
return ;
}
const providerLabel = doctorResult . provider || providerOption || 'unknown' ;
process . stdout . write ( `provider ${ providerLabel } \n` );
const modes = Array . isArray ( doctorResult . modes ) ? doctorResult . modes : [];
for ( const modeEntry of modes ) {
const ready = modeEntry . ready === true || modeEntry . summary ? . ready === true ;
if ( ready ) {
process . stdout . write ( `mode ${ modeEntry . mode } ready\n` );
continue ;
}
const blockers = Array . isArray ( modeEntry . blockers )
? modeEntry . blockers
: ( Array . isArray ( modeEntry . checks )
? modeEntry . checks
. filter (( c ) => c ? . status === 'fail' && c ? . id !== 'startup_readiness' )
. map (( c ) => c . detail || c . label || c . id )
: []);
process . stdout . write ( `mode ${ modeEntry . mode } not-ready ${ blockers . length || 0 } \n` );
for ( const blocker of blockers ) {
process . stdout . write ( `blocker ${ modeEntry . mode } ${ String ( blocker ) } \n` );
}
}
return ;
}
// ── Section 1: Ports ──────────────────────────────────────
const cliPorts = portStatuses . filter (( s ) => s . available );
const unavailablePorts = portStatuses . filter (( s ) => ! s . available );
clackIntro ( boldText ( 'Ports' ));
for ( const entry of cliPorts ) {
logStatus ( 'success' , `port ${ entry . port } — CLI (available)` );
}
const desktopUnavailablePorts = [];
for ( const entry of unavailablePorts ) {
const isDesktop = typeof entry ? . line === 'string' && entry . line . includes ( 'desktop runtime' );
if ( isDesktop ) {
desktopUnavailablePorts . push ( entry . port );
logStatus ( 'error' , `port ${ entry . port } — Desktop (tunneling not supported)` );
continue ;
}
logStatus ( 'error' , `port ${ entry . port } — No running instance` );
}
if ( desktopUnavailablePorts . length > 0 ) {
clackLog . message ( 'Only CLI instances (openchamber serve) support tunneling.' );
}
if ( cliPorts . length === 0 && unavailablePorts . length === 0 ) {
logStatus ( 'warning' , 'No running instances found' , 'Start one with `openchamber serve`.' );
clackOutro ( 'No ports available' );
return ;
}
if ( cliPorts . length === 0 ) {
logStatus ( 'warning' , 'No CLI instances available for tunneling' , 'Start one with `openchamber serve`.' );
clackOutro ( 'No CLI ports available' );
return ;
}
clackOutro ( ` ${ cliPorts . length } CLI ${ cliPorts . length === 1 ? 'port' : 'ports' } available` );
console . log ( '' );
if ( doctorProfile ) {
logStatus ( 'info' , 'Using saved profile for managed-remote checks' , ` ${ doctorProfile . name } ( ${ doctorProfile . provider } / ${ doctorProfile . mode } )` );
console . log ( '' );
}
// ── Section 2: Provider ─────────────────────────────────
if ( doctorError ) {
clackIntro ( boldText ( 'Provider' ));
logStatus ( 'error' , 'Provider diagnostics failed' , doctorError );
clackOutro ( 'Failed' );
return ;
}
if ( ! doctorResult ) {
clackIntro ( boldText ( 'Provider' ));
logStatus ( 'warning' , 'Could not reach a running instance for diagnostics' );
clackOutro ( 'Unavailable' );
return ;
}
const providerLabel = clackFormatProviderWithIcon ( doctorResult . provider || 'unknown' );
clackIntro ( boldText ( `Provider: ${ providerLabel } ` ));
let providerPassCount = 0 ;
for ( const check of ( doctorResult . providerChecks || [])) {
const passed = check . status === 'pass' ;
if ( passed ) {
providerPassCount ++ ;
logStatus ( 'success' , ` ${ check . label }${ check . detail ? ` — ${ check . detail } ` : '' } ` );
} else {
logStatus ( 'error' , check . label , check . detail || undefined );
}
}
const depCheck = ( doctorResult . providerChecks || []). find (
( c ) => c . id === 'dependency' || c . id === 'provider_dependency' ,
);
if ( depCheck && depCheck . status !== 'pass' ) {
clackOutro ( '1 blocker — resolve before checking modes' );
return ;
}
clackOutro ( ` ${ providerPassCount } ${ providerPassCount === 1 ? 'check' : 'checks' } passed` );
console . log ( '' );
// ── Section 3: Modes ────────────────────────────────────
const DOCTOR_NOISE_CHECK_IDS = new Set ([ 'startup_readiness' , 'quick_mode_prerequisites' ]);
const modes = doctorResult . modes || [];
if ( modes . length === 0 ) {
return ;
}
clackIntro ( boldText ( 'Modes' ));
let totalBlockers = 0 ;
const troubleshootingHints = [];
for ( const modeEntry of modes ) {
const isReady = modeEntry . ready === true || modeEntry . summary ? . ready === true ;
if ( isReady ) {
const passDetail = Array . isArray ( modeEntry . checks )
? modeEntry . checks . find (( c ) => c ? . status === 'pass' && ! DOCTOR_NOISE_CHECK_IDS . has ( c ? . id )) ? . detail
: null ;
logStatus ( 'success' , ` ${ modeEntry . mode } — Ready ${ passDetail ? ` ( ${ passDetail } )` : '' } ` );
} else {
const blockers = Array . isArray ( modeEntry . blockers )
? modeEntry . blockers
: ( Array . isArray ( modeEntry . checks )
? modeEntry . checks
. filter (( c ) => c ? . status === 'fail' && c ? . id !== 'startup_readiness' )
. map (( c ) => c . detail || c . label || c . id )
: []);
totalBlockers += blockers . length ;
const blockerCount = blockers . length ;
const blockerWord = blockerCount === 1 ? 'blocker' : 'blockers' ;
logStatus ( 'error' , ` ${ modeEntry . mode } — Not ready ${ blockerCount > 0 ? ` ( ${ blockerCount } ${ blockerWord } )` : '' } ` );
for ( const blocker of blockers ) {
clackLog . message ( ` ${ blocker } ` );
}
const normalizedBlockers = blockers . map (( blocker ) => String ( blocker ). toLowerCase ());
const isManagedRemote = modeEntry . mode === 'managed-remote' ;
const hasTokenIssue = normalizedBlockers . some (( line ) => line . includes ( 'token' )
|| line . includes ( 'unauthorized' )
|| line . includes ( 'forbidden' )
|| line . includes ( 'authentication' )
|| line . includes ( 'auth' ));
const hasPortOrOriginIssue = normalizedBlockers . some (( line ) => line . includes ( 'port' )
|| line . includes ( 'localhost' )
|| line . includes ( '127.0.0.1' )
|| line . includes ( 'connection refused' )
|| line . includes ( 'dial tcp' ));
if ( isManagedRemote && ( hasPortOrOriginIssue || hasTokenIssue )) {
troubleshootingHints . push ({
key : 'managed-remote-port' ,
code : '[PORT_MISMATCH]' ,
lines : [
'Cloudflare target must match the active OpenChamber CLI port.' ,
'Example: `http://127.0.0.1:<port>`' ,
'If CLI picked a different port, update Cloudflare or run `openchamber serve --port <port>`.' ,
],
});
}
if ( isManagedRemote && hasTokenIssue ) {
troubleshootingHints . push ({
key : 'managed-remote-token' ,
code : '[QR_PREFETCH_TOKEN]' ,
lines : [
'Some QR readers pre-fetch scanned URLs.' ,
'Pre-fetch can consume one-time bootstrap tokens.' ,
'If validation fails, generate a fresh token/QR and use it immediately in one browser/device.' ,
],
});
}
}
}
clackOutro ( totalBlockers > 0 ? `Done ( ${ totalBlockers } ${ totalBlockers === 1 ? 'issue' : 'issues' } )` : 'All modes ready' );
const dedupedHints = [];
const seenHintKeys = new Set ();
for ( const hint of troubleshootingHints ) {
if ( ! seenHintKeys . has ( hint . key )) {
seenHintKeys . add ( hint . key );
dedupedHints . push ( hint );
}
}
if ( dedupedHints . length > 0 ) {
console . log ( '' );
clackIntro ( boldText ( 'Suggestion notes' ));
for ( const hint of dedupedHints ) {
const lines = Array . isArray ( hint . lines ) ? hint . lines : [];
const detail = lines . length > 0 ? lines . map (( line ) => ` ${ line } ` ). join ( '\n' ) : undefined ;
logStatus ( 'info' , hint . code || '[NOTE]' , detail );
}
clackOutro ( ` ${ dedupedHints . length } ${ dedupedHints . length === 1 ? 'suggestion' : 'suggestions' } ` );
}
return ;
}
case 'start' : {
let provider = typeof options . provider === 'string' && options . provider . trim (). length > 0
? options . provider . trim (). toLowerCase ()
: '' ;
let mode = typeof options . mode === 'string' && options . mode . trim (). length > 0
? options . mode . trim (). toLowerCase ()
: '' ;
let resolvedTokenValue = resolveToken ( options );
let token = typeof resolvedTokenValue === 'string' ? resolvedTokenValue : undefined ;
let hostname = typeof options . hostname === 'string' ? options . hostname : undefined ;
let selectedProfile = null ;
if ( options . explicitPort ) {
assertSafeBrowserPort ( options . port , { context : 'Tunnel start' });
}
if ( typeof options . profile === 'string' && options . profile . trim (). length > 0 ) {
const store = ensureTunnelProfilesMigrated ();
const resolved = resolveProfileByName ( store . profiles , options . profile , provider || options . provider );
if ( ! resolved . profile ) {
throw new Error ( resolved . error );
}
selectedProfile = resolved . profile ;
provider = provider || selectedProfile . provider ;
mode = mode || selectedProfile . mode ;
token = ( typeof token === 'string' && token . trim (). length > 0 ) ? token : selectedProfile . token ;
hostname = typeof options . hostname === 'string' && options . hostname . trim (). length > 0 ? options . hostname : selectedProfile . hostname ;
}
// Interactive profile selection when no profile/mode specified in TTY
if ( ! selectedProfile && ! mode && canPrompt ( options )) {
const store = ensureTunnelProfilesMigrated ();
if ( store . profiles . length > 0 ) {
const profileChoice = await clackSelect ({
message : 'Start from a saved profile or choose a mode?' ,
options : [
{ value : '__mode__' , label : 'Choose a mode manually' },
... store . profiles . map (( p ) => ({
value : p . id ,
label : ` ${ p . name } ( ${ p . provider } / ${ p . mode } )` ,
hint : p . hostname ,
})),
],
});
if ( clackIsCancel ( profileChoice )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
if ( profileChoice !== '__mode__' ) {
selectedProfile = store . profiles . find (( p ) => p . id === profileChoice );
if ( selectedProfile ) {
provider = provider || selectedProfile . provider ;
mode = mode || selectedProfile . mode ;
token = ( typeof token === 'string' && token . trim (). length > 0 ) ? token : selectedProfile . token ;
hostname = typeof options . hostname === 'string' && options . hostname . trim (). length > 0 ? options . hostname : selectedProfile . hostname ;
}
}
}
}
provider = provider || 'cloudflare' ;
// Interactive mode selection when mode not yet resolved in TTY
if ( ! mode && canPrompt ( options )) {
const providerCaps = DEFAULT_TUNNEL_PROVIDER_CAPABILITIES . find (
( cap ) => cap . provider === provider
);
const modes = providerCaps ? . modes || [];
if ( modes . length > 1 ) {
const modeChoice = await clackSelect ({
message : `Select tunnel mode for ${ clackFormatProviderWithIcon ( provider ) } ` ,
options : modes . map (( m ) => ({
value : m . key ,
label : ` ${ m . key } — ${ m . label } ` ,
hint : formatModeRequirements ( m ) !== 'none' ? `requires: ${ formatModeRequirements ( m ) } ` : undefined ,
})),
});
if ( clackIsCancel ( modeChoice )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
mode = modeChoice ;
}
2026-01-18 18:18:42 +02:00
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
mode = mode || 'quick' ;
if ( mode === 'managed-remote' ) {
if ( ! ( typeof hostname === 'string' && hostname . trim (). length > 0 )) {
if ( canPrompt ( options )) {
const profilesStore = ensureTunnelProfilesMigrated ();
const lastManagedRemoteProfile = [... profilesStore . profiles ]
. filter (( entry ) => entry . provider === provider && entry . mode === 'managed-remote' )
. sort (( a , b ) => ( b . updatedAt || 0 ) - ( a . updatedAt || 0 ))[ 0 ];
const suggestedHostname = normalizeProfileHostname ( lastManagedRemoteProfile ? . hostname ) || 'app.example.com' ;
const enteredHostname = await clackText ({
message : 'Enter managed-remote tunnel hostname' ,
placeholder : suggestedHostname ,
initialValue : suggestedHostname ,
validate ( value ) {
if ( typeof value !== 'string' || value . trim (). length === 0 ) {
return 'Hostname is required.' ;
}
return undefined ;
},
});
if ( clackIsCancel ( enteredHostname )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
hostname = enteredHostname . trim ();
} else {
throw new Error ( 'Managed-remote mode requires --hostname <hostname>.' );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( ! ( typeof token === 'string' && token . trim (). length > 0 )) {
if ( canPrompt ( options )) {
const entered = await clackPassword ({
message : 'Enter managed-remote tunnel token' ,
});
if ( clackIsCancel ( entered ) || ! entered || ! entered . trim ()) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
token = entered . trim ();
} else {
throw new Error ( 'Managed-remote mode requires a token (--token, --token-file, or --token-stdin).' );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( ! selectedProfile && canPrompt ( options )) {
const runChoice = await clackSelect ({
message : 'Run once, or save profile and run?' ,
options : [
{ value : 'run' , label : 'Run once' , hint : 'Do not save profile' },
{ value : 'save-run' , label : 'Save profile and run' , hint : 'Reuse with --profile later' },
],
});
if ( clackIsCancel ( runChoice )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( runChoice === 'save-run' ) {
const suggestedName = suggestProfileNameFromHostname ( hostname );
const enteredProfileName = await clackText ({
message : 'Profile name' ,
placeholder : suggestedName ,
initialValue : suggestedName ,
validate ( value ) {
return normalizeProfileName ( value ). length > 0 ? undefined : 'Profile name is required.' ;
},
});
if ( clackIsCancel ( enteredProfileName )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const desiredName = normalizeProfileName ( enteredProfileName );
const store = ensureTunnelProfilesMigrated ();
const existingIndex = store . profiles . findIndex (
( entry ) => entry . provider === provider && entry . name . toLowerCase () === desiredName . toLowerCase ()
);
if ( existingIndex >= 0 ) {
const shouldOverwrite = await clackConfirm ({
message : `Profile ' ${ desiredName } ' already exists. Overwrite and run?` ,
initialValue : true ,
});
if ( clackIsCancel ( shouldOverwrite ) || ! shouldOverwrite ) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const now = Date . now ();
const nextProfiles = [... store . profiles ];
if ( existingIndex >= 0 ) {
const current = nextProfiles [ existingIndex ];
nextProfiles [ existingIndex ] = {
... current ,
mode : 'managed-remote' ,
hostname ,
token ,
updatedAt : now ,
};
} else {
nextProfiles . push ({
id : crypto . randomUUID (),
name : desiredName ,
provider ,
mode : 'managed-remote' ,
hostname ,
token ,
createdAt : now ,
updatedAt : now ,
});
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const persisted = { version : TUNNEL_PROFILES_VERSION , profiles : nextProfiles };
writeTunnelProfilesToDisk ( persisted );
writeManagedRemotePairsToDiskFromProfiles ( persisted );
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
selectedProfile = persisted . profiles . find (
( entry ) => entry . provider === provider && entry . name . toLowerCase () === desiredName . toLowerCase ()
) || null ;
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( typeof options . token === 'string' && ! options . tokenFile && ! options . tokenStdin && canPrompt ( options )) {
clackBox (
'Token passed via --token is visible in your shell history and process list.\n' +
'Consider using --token-file or --token-stdin for better security.' ,
'Security Warning' ,
);
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( mode === 'managed-local' ) {
const hasConfigPath = typeof options . configPath === 'string' && options . configPath . trim (). length > 0 ;
if ( ! hasConfigPath && canPrompt ( options )) {
const lastConfigPath = readLastManagedLocalConfigPath ();
const defaultConfigPath = getDefaultCloudflaredConfigPath ();
const suggestedConfigPath = lastConfigPath || defaultConfigPath ;
const suggestedConfigFound = isReadableRegularFile ( suggestedConfigPath );
const defaultConfigFound = isReadableRegularFile ( defaultConfigPath );
if ( suggestedConfigFound || defaultConfigFound ) {
const foundPath = suggestedConfigFound ? suggestedConfigPath : defaultConfigPath ;
const configChoice = await clackSelect ({
message : 'Managed-local config' ,
options : [
{
value : 'default' ,
label : 'Use found config' ,
hint : foundPath ,
},
{
value : 'custom' ,
label : 'Enter config path' ,
},
],
});
if ( clackIsCancel ( configChoice )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
if ( configChoice === 'default' ) {
options . configPath = foundPath ;
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( ! ( typeof options . configPath === 'string' && options . configPath . trim (). length > 0 )) {
const enteredPath = await clackText ({
message : 'Enter managed-local config path' ,
placeholder : suggestedConfigPath ,
initialValue : suggestedConfigPath ,
validate ( value ) {
if ( typeof value !== 'string' || value . trim (). length === 0 ) {
return 'Config path is required.' ;
}
return undefined ;
},
});
if ( clackIsCancel ( enteredPath )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
options . configPath = enteredPath . trim ();
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( typeof options . configPath === 'string' && options . configPath . trim (). length > 0 ) {
writeLastManagedLocalConfigPath ( options . configPath );
}
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const ttlOverrides = await resolveTunnelTtlOverrides ( options );
if ( ttlOverrides === null ) {
return ;
}
const { connectTtlMs , sessionTtlMs } = ttlOverrides ;
if ( options . dryRun ) {
const dryRunResult = {
ok : true ,
dryRun : true ,
provider ,
mode ,
hostname : hostname || null ,
hasToken : typeof token === 'string' && token . trim (). length > 0 ,
profile : selectedProfile ? selectedProfile . name : null ,
configPath : options . configPath || null ,
connectTtlMs : connectTtlMs ?? null ,
sessionTtlMs : sessionTtlMs ?? null ,
};
if ( isJsonMode ( options )) {
printJson ( dryRunResult );
} else if ( ! isQuietMode ( options )) {
clackIntro ( 'Tunnel Start (dry-run)' );
logStatus ( 'info' , `Would start ${ clackFormatProviderWithIcon ( provider ) } / ${ mode } ` , hostname || '(ephemeral URL)' );
clackOutro ( 'dry-run complete (no changes applied)' );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( ! options . explicitPort && canPrompt ( options )) {
const runningInstances = await discoverRunningInstances ();
if ( runningInstances . length > 1 ) {
const safeInstances = runningInstances . filter (( entry ) => ! isUnsafeBrowserPort ( entry . port ));
if ( safeInstances . length === 0 ) {
throw new TunnelCliError (
'All discovered OpenChamber instance ports are browser-unsafe. Start or target a safe port (3000, 5173, 8080, or high ephemeral).' ,
EXIT_CODE . USAGE_ERROR ,
);
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const attachabilityResults = await Promise . all (
safeInstances . map ( async ( entry ) => ({
entry ,
attachability : await inspectTunnelAttachability ( entry . port , { requireHealthy : true }),
}))
);
const attachableSafeInstances = attachabilityResults
. filter (( item ) => item . attachability . attachable )
. map (( item ) => item . entry );
if ( attachableSafeInstances . length === 0 ) {
throw new TunnelCliError (
'No attachable OpenChamber CLI instances found on safe ports. Start one with `openchamber serve --port 3000`.' ,
EXIT_CODE . USAGE_ERROR ,
);
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const selectedPort = await clackSelect ({
message : 'Select OpenChamber instance port' ,
options : attachableSafeInstances . map (( entry ) => ({
value : entry . port ,
label : `port ${ entry . port } ` ,
})),
});
if ( clackIsCancel ( selectedPort )) {
clackCancel ( 'Tunnel start cancelled.' );
return ;
}
options . port = Number ( selectedPort );
options . explicitPort = true ;
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const instance = await resolveTargetInstance ({ options , allowAutoStart : true , rejectDesktopRuntime : true });
if ( instance ? . autoStarted && shouldRenderHumanOutput ( options )) {
logStatus (
'info' ,
`Using auto-started instance on port ${ instance . port } ` ,
`logs: openchamber logs -p ${ instance . port } ` ,
);
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( instance ? . autoStarted ) {
setCancelCleanup ( async () => {
try {
await commands . stop ({ explicitPort : true , port : instance . port });
} catch {
}
});
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( instance ? . autoStarted ) {
const healthProgress = await createProgress ( options , { max : 60 });
healthProgress ? . start ( `Waiting for OpenChamber on port ${ instance . port } to become healthy (up to 60s)...` );
let progressedSeconds = 0 ;
const healthy = await waitForServerHealth ( instance . port , {
timeoutMs : 60000 ,
intervalMs : 250 ,
onTick ({ elapsedMs , complete }) {
if ( ! healthProgress ) return ;
const elapsedSeconds = Math . min ( 60 , Math . floor ( elapsedMs / 1000 ));
const delta = elapsedSeconds - progressedSeconds ;
if ( delta > 0 ) {
healthProgress . advance ( delta );
progressedSeconds = elapsedSeconds ;
healthProgress . message ( `Waiting for OpenChamber health ( ${ progressedSeconds } s / 60s)...` );
}
if ( complete && progressedSeconds < 60 ) {
const remaining = 60 - progressedSeconds ;
if ( remaining > 0 ) {
healthProgress . advance ( remaining );
progressedSeconds = 60 ;
2025-12-07 19:32:53 +02:00
}
}
2026-03-12 19:40:22 +02:00
},
});
if ( ! healthy ) {
healthProgress ? . stop ( 'OpenChamber is still starting' );
throw new Error (
`OpenChamber on port ${ instance . port } is still starting after 60s. Startup time can vary by machine performance. ` +
`Wait another minute, then check health with \`curl -fsS ${ buildLocalUrl ( instance . port , '/health' ) } \`. ` +
`If health is OK, retry tunnel start with \`openchamber tunnel start --port ${ instance . port } \`. ` +
`For diagnostics run \`openchamber logs -p ${ instance . port } \`.`
);
}
healthProgress ? . stop ( `Instance ${ instance . port } is healthy` );
}
if ( selectedProfile && mode === 'managed-remote' ) {
const tokenSyncPayload = {
presetId : selectedProfile . id ,
presetName : selectedProfile . name ,
managedRemoteTunnelHostname : hostname ,
managedRemoteTunnelToken : token ,
};
const { response : presetResponse , body : presetBody } = await requestJson ( instance . port , '/api/openchamber/tunnel/managed-remote-token' , {
method : 'PUT' ,
body : JSON . stringify ( tokenSyncPayload ),
2025-12-07 19:32:53 +02:00
});
2026-03-12 19:40:22 +02:00
if ( ! presetResponse . ok || ! presetBody ? . ok ) {
throw new Error ( presetBody ? . error || `Failed to sync tunnel profile token ( ${ presetResponse . status } )` );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
const payload = {
provider ,
mode ,
...( typeof connectTtlMs === 'number' ? { connectTtlMs } : {}),
...( typeof sessionTtlMs === 'number' ? { sessionTtlMs } : {}),
...( options . configPath === null ? { configPath : null } : {}),
...( typeof options . configPath === 'string' ? { configPath : options . configPath } : {}),
...( typeof token === 'string' ? { token } : {}),
...( typeof hostname === 'string' ? { hostname } : {}),
...( selectedProfile ? {
managedRemoteTunnelPresetId : selectedProfile . id ,
managedRemoteTunnelPresetName : selectedProfile . name ,
} : {}),
};
const spin = createSpinner ( options );
spin ? . start ( `Starting ${ clackFormatProviderWithIcon ( provider ) } / ${ mode } tunnel...` );
let response ;
let body ;
try {
({ response , body } = await requestJson ( instance . port , '/api/openchamber/tunnel/start' , {
method : 'POST' ,
body : JSON . stringify ( payload ),
timeoutMs : 60000 ,
}));
2025-12-07 19:32:53 +02:00
} catch ( error ) {
2026-03-12 19:40:22 +02:00
if ( error instanceof Error && /\/api\/openchamber\/tunnel\/start/ . test ( error . message ) && /timed out/ . test ( error . message )) {
spin ? . error ( 'Tunnel start timed out' );
throw new Error (
`Tunnel start timed out after 60s. cloudflared may still be starting; check with \`openchamber tunnel status --port ${ instance . port } \`. Run \`openchamber logs -p ${ instance . port } \` for details.`
);
}
spin ? . error ( 'Tunnel start failed' );
const message = error instanceof Error ? error . message : String ( error );
throw new Error ( ` ${ message } Run \`openchamber logs -p ${ instance . port } \` for details.` );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
if ( ! response . ok || ! body ? . ok ) {
spin ? . error ( 'Tunnel start failed' );
const baseError = body ? . error || `Tunnel start failed ( ${ response . status } )` ;
const isCloudflareTimeout = /context deadline exceeded|Client\.Timeout exceeded while awaiting headers|failed to request quick Tunnel/i . test ( baseError );
const userError = isCloudflareTimeout
? `Cloudflare quick tunnel request timed out. ${ baseError } `
: baseError ;
throw new Error ( ` ${ userError } Run \`openchamber logs -p ${ instance . port } \` for details.` );
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
// Avoid duplicate "Tunnel started" lines: spinner completion is implied by
// the subsequent structured success section.
spin ? . clear ();
const replayCommand = buildTunnelStartReplayCommand ({
port : instance . port ,
provider ,
mode ,
profileName : selectedProfile ? . name ,
configPath : options . configPath ,
hostname ,
connectTtlMs ,
sessionTtlMs ,
qr : options . qr === true ,
noQr : options . noQr === true ,
includeTokenPlaceholder : ! selectedProfile && mode === 'managed-remote' && typeof token === 'string' && token . trim (). length > 0 ,
tokenViaStdin : options . tokenStdin === true ,
tokenFileProvided : typeof options . tokenFile === 'string' && options . tokenFile . trim (). length > 0 ,
});
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
if ( isJsonMode ( options )) {
printJson ({ port : instance . port , replayCommand , ... body });
} else if ( isQuietMode ( options )) {
const quietUrl = body . connectUrl || body . url || 'n/a' ;
process . stdout . write ( `port ${ instance . port } ${ quietUrl } \n` );
} else {
console . log ( '' );
clackIntro ( boldText ( 'Tunnel Started' ));
logStatus ( 'success' , `port ${ instance . port } ${ clackFormatProviderWithIcon ( body . provider ) } / ${ body . mode } ` );
logStatus ( 'success' , body . connectUrl || body . url || 'n/a' );
if ( body . replacedTunnel ) {
const revokedBootstrapCount = Number . isFinite ( body . revokedBootstrapCount ) ? body . revokedBootstrapCount : 0 ;
const invalidatedSessionCount = Number . isFinite ( body . invalidatedSessionCount ) ? body . invalidatedSessionCount : 0 ;
const previousMode = typeof body ? . replaced ? . mode === 'string' ? body . replaced . mode : 'unknown' ;
logStatus (
'warning' ,
`replaced previous ${ previousMode } tunnel` ,
`revoked ${ revokedBootstrapCount } , invalidated ${ invalidatedSessionCount } ` ,
);
}
clackOutro ( '' );
const optionalTips = [
{ line : 'Check status' , detail : 'openchamber tunnel status' },
{ line : 'Stop tunnel' , detail : 'openchamber tunnel stop' },
{ line : 'If needed, repeat with same settings' , detail : replayCommand },
];
if ( ! selectedProfile && mode === 'managed-remote' && typeof hostname === 'string' && hostname . trim (). length > 0 ) {
const profileSaveCommand = buildTunnelProfileAddCommand ({ provider , hostname });
optionalTips . push ({ line : 'Optional: save reusable profile (stores hostname + token locally)' , detail : profileSaveCommand });
optionalTips . push ({ line : 'Start from saved profile' , detail : 'openchamber tunnel start --profile <name>' });
}
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
console . log ( '' );
clackIntro ( 'Optional Tips' );
for ( const tip of optionalTips ) {
logStatus ( 'info' , tip . line , tip . detail );
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
clackOutro ( '' );
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
setCancelCleanup ( null );
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
if ( shouldDisplayTunnelQr ( options )) {
const url = body . connectUrl || body . url ;
if ( typeof url === 'string' && url . length > 0 ) {
await displayTunnelQrCode ( url );
}
}
2025-12-19 19:57:18 +02:00
return ;
}
2026-03-12 19:40:22 +02:00
case 'stop' : {
let entries ;
if ( options . all ) {
entries = await resolveTargetInstance ({ options , allowAutoStart : false , requireAll : true });
if ( entries . length > 1 && ! options . force && canPrompt ( options )) {
const shouldStop = await clackConfirm ({
message : `Stop tunnels on all ${ entries . length } instances?` ,
});
if ( clackIsCancel ( shouldStop ) || ! shouldStop ) {
clackCancel ( 'Tunnel stop cancelled.' );
return ;
}
}
} else {
entries = [ await resolveTargetInstance ({ options , allowAutoStart : false })];
}
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
const results = [];
for ( const entry of entries ) {
const tunnelStopSpin = shouldRenderHumanOutput ( options ) ? createSpinner ( options ) : null ;
tunnelStopSpin ? . start ( `Stopping tunnel on port ${ entry . port } ...` );
try {
const { response , body } = await requestJson ( entry . port , '/api/openchamber/tunnel/stop' , {
method : 'POST' ,
});
if ( ! response . ok ) {
tunnelStopSpin ? . error ( `Failed to stop tunnel on port ${ entry . port } ` );
results . push ({ port : entry . port , error : body ? . error || `stop ${ response . status } ` });
continue ;
}
tunnelStopSpin ? . stop ( `Stopped tunnel on port ${ entry . port } ` );
results . push ({ port : entry . port , result : body });
} catch ( error ) {
tunnelStopSpin ? . error ( `Failed to stop tunnel on port ${ entry . port } ` );
results . push ({ port : entry . port , error : error instanceof Error ? error . message : String ( error ) });
}
}
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
if ( isJsonMode ( options )) {
printJson ({ instances : results });
return ;
}
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
if ( isQuietMode ( options )) {
for ( const result of results ) {
if ( result . error ) {
process . stderr . write ( `port ${ result . port } failed: ${ result . error } \n` );
continue ;
}
process . stdout . write ( `port ${ result . port } stopped\n` );
}
return ;
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
clackIntro ( 'Tunnel Stop' );
for ( const result of results ) {
if ( result . error ) {
logStatus ( 'error' , `port ${ result . port } failed` , result . error );
continue ;
}
logStatus ( 'success' , `port ${ result . port } stopped` , `revoked ${ result . result ? . revokedBootstrapCount || 0 } , invalidated ${ result . result ? . invalidatedSessionCount || 0 } ` );
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
clackOutro ( ` ${ results . length } instance(s)` );
return ;
}
case 'completion' : {
const shell = action || 'bash' ;
const completionScript = generateCompletionScript ( shell );
if ( ! completionScript ) {
throw new TunnelCliError (
`Unsupported shell ' ${ shell } '. Supported: bash, zsh, fish.` ,
EXIT_CODE . USAGE_ERROR
);
}
process . stdout . write ( completionScript );
return ;
}
default : {
const knownTunnelSubcommands = [ 'help' , 'providers' , 'ready' , 'doctor' , 'status' , 'start' , 'stop' , 'profile' , 'completion' ];
const suggestion = findClosestMatch ( subcommand , knownTunnelSubcommands );
const hint = suggestion ? ` Did you mean ' ${ suggestion } '?` : '' ;
throw new TunnelCliError (
`Unknown tunnel subcommand ' ${ subcommand } '. ${ hint } Use 'openchamber tunnel help'.` ,
EXIT_CODE . USAGE_ERROR
);
2025-12-19 19:57:18 +02:00
}
}
2025-12-07 19:32:53 +02:00
},
2026-03-12 19:40:22 +02:00
async logs ( options ) {
const showFrames = shouldRenderHumanOutput ( options );
const shouldPrefixLines = options . all || ! showFrames ;
let targets = [];
const running = await discoverRunningInstances ();
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 } ` );
}
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 ;
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( showFrames ) {
clackIntro ( 'OpenChamber Logs' );
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
for ( const target of targets ) {
const logPath = getLogFilePath ( target . port );
const lines = readTailLines ( logPath , options . lines );
if ( showFrames ) {
logStatus ( 'info' , `port ${ target . port } ` , logPath );
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
for ( const line of lines ) {
if ( shouldPrefixLines ) {
console . log ( `[ ${ target . port } ] ${ line } ` );
} else {
console . log ( line );
2025-12-07 19:32:53 +02:00
}
}
2026-03-12 19:40:22 +02:00
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( showFrames ) {
clackOutro ( options . follow ? 'following (Ctrl+C to stop)' : 'tail complete' );
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
if ( ! options . follow ) {
2025-12-07 19:32:53 +02:00
return ;
}
2026-03-12 19:40:22 +02:00
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 );
}
});
});
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
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 );
});
2025-12-07 19:32:53 +02:00
},
2026-03-12 19:40:22 +02:00
async update ( options = {}) {
const showOutput = shouldRenderHumanOutput ( options );
const updateSpin = createSpinner ( options );
2025-12-19 19:57:18 +02:00
const packageManagerPath = path . join ( __dirname , '..' , 'server' , 'lib' , 'package-manager.js' );
const {
checkForUpdates ,
executeUpdate ,
detectPackageManager ,
getCurrentVersion ,
2026-03-01 01:21:01 +02:00
} = await importFromFilePath ( packageManagerPath );
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
const runningInstances = await discoverRunningInstances ();
const currentVersion = getCurrentVersion ();
if ( showOutput ) {
clackIntro ( 'OpenChamber Update' );
}
if ( showOutput && ! updateSpin ) {
logStatus ( 'info' , `current version: ${ currentVersion } ` );
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
updateSpin ? . start ( 'Checking for updates...' );
2025-12-19 19:57:18 +02:00
const updateInfo = await checkForUpdates ();
if ( updateInfo . error ) {
2026-03-12 19:40:22 +02:00
updateSpin ? . error ( 'Update check failed' );
if ( showOutput ) {
clackOutro ( 'update failed' );
}
throw new Error ( updateInfo . error );
2025-12-19 19:57:18 +02:00
}
if ( ! updateInfo . available ) {
2026-03-12 19:40:22 +02:00
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` );
}
2025-12-19 19:57:18 +02:00
return ;
}
2026-03-12 19:40:22 +02:00
if ( showOutput && ! updateSpin ) {
logStatus ( 'info' , `updating ${ updateInfo . currentVersion || currentVersion } -> ${ updateInfo . version || 'latest' } ` );
2025-12-19 19:57:18 +02:00
}
2026-03-12 19:40:22 +02:00
updateSpin ? . message ( `Updating to ${ updateInfo . version || 'latest' } ...` );
2025-12-19 19:57:18 +02:00
if ( runningInstances . length > 0 ) {
2026-03-12 19:40:22 +02:00
updateSpin ? . message ( `Stopping ${ runningInstances . length } running instance(s)...` );
2025-12-19 19:57:18 +02:00
for ( const instance of runningInstances ) {
try {
2026-02-17 18:01:57 +02:00
await requestServerShutdown ( instance . port );
2025-12-19 19:57:18 +02:00
process . kill ( instance . pid , 'SIGTERM' );
let attempts = 0 ;
while ( isProcessRunning ( instance . pid ) && attempts < 20 ) {
2026-03-12 19:40:22 +02:00
await new Promise (( resolve ) => setTimeout ( resolve , 250 ));
2025-12-19 19:57:18 +02:00
attempts ++ ;
}
if ( isProcessRunning ( instance . pid )) {
process . kill ( instance . pid , 'SIGKILL' );
}
removePidFile ( instance . pidFilePath );
2026-03-12 19:40:22 +02:00
} catch {
2025-12-19 19:57:18 +02:00
}
}
}
const pm = detectPackageManager ();
2026-03-12 19:40:22 +02:00
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 } ` );
}
2025-12-19 19:57:18 +02:00
2026-03-12 19:40:22 +02:00
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 this . serve ({
port : storedOptions . port || instance . port ,
explicitPort : true ,
uiPassword : storedOptions . uiPassword ,
suppressStartupSummary : true ,
suppressUiPasswordWarning : true ,
quiet : true ,
});
2025-12-19 19:57:18 +02:00
}
}
2026-03-12 19:40:22 +02:00
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` );
}
},
2025-12-07 19:32:53 +02:00
};
async function main () {
2026-03-12 19:40:22 +02:00
const parsed = parseArgs ();
const { command , subcommand , tunnelAction , options , removedFlagErrors , helpRequested , versionRequested } = parsed ;
activeCommandOptions = options ;
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
if ( versionRequested ) {
if ( isJsonMode ( options )) {
printJson ({ version : PACKAGE_JSON . version });
} else {
console . log ( PACKAGE_JSON . version );
}
return ;
2025-12-07 19:32:53 +02:00
}
2026-03-12 19:40:22 +02:00
if ( removedFlagErrors . length > 0 ) {
if ( isJsonMode ( options )) {
printJson ({
status : 'error' ,
error : {
message : removedFlagErrors [ 0 ],
details : removedFlagErrors ,
},
});
} else {
for ( const error of removedFlagErrors ) {
console . error ( `Error: ${ error } ` );
}
}
2025-12-07 19:32:53 +02:00
process . exit ( 1 );
}
2026-03-12 19:40:22 +02:00
if ( helpRequested ) {
if ( command === 'tunnel' ) {
showTunnelHelp ();
} else {
showHelp ();
}
return ;
}
if ( command === 'tunnel' ) {
await commands . tunnel ( options , subcommand , tunnelAction );
return ;
}
if ( ! commands [ command ]) {
const knownCommands = [ 'serve' , 'stop' , 'restart' , 'status' , 'tunnel' , 'logs' , 'update' ];
const suggestion = findClosestMatch ( command , knownCommands );
const hint = suggestion ? ` Did you mean ' ${ suggestion } '?` : '' ;
if ( isJsonMode ( options )) {
printJson ({
status : 'error' ,
error : {
message : `Unknown command ' ${ command } '. ${ hint } ` ,
},
messages : [{ level : 'info' , code : 'USAGE_HELP' , message : 'Use --help to see available commands' }],
});
} else {
console . error ( `Error: Unknown command ' ${ command } '. ${ hint } ` );
console . error ( 'Use --help to see available commands' );
}
process . exit ( EXIT_CODE . USAGE_ERROR );
}
await commands [ command ]( options );
2025-12-07 19:32:53 +02:00
}
2026-03-13 09:49:25 +02:00
const isCliExecution = isModuleCliExecution ();
2026-03-12 19:40:22 +02:00
if ( isCliExecution ) {
let isHandlingSigint = false ;
process . on ( 'SIGINT' , () => {
if ( isHandlingSigint ) {
return ;
}
isHandlingSigint = true ;
( async () => {
clackCancel ( 'Operation cancelled.' );
if ( onCancelCleanup ) {
try {
await onCancelCleanup ();
} catch {
} finally {
setCancelCleanup ( null );
}
}
process . exit ( 130 );
})();
});
process . on ( 'unhandledRejection' , ( reason , promise ) => {
if ( isJsonMode ( activeCommandOptions )) {
printJson ({
status : 'error' ,
error : {
message : `Unhandled rejection: ${ String ( reason ) } ` ,
},
});
} else {
console . error ( 'Unhandled Rejection at:' , promise , 'reason:' , reason );
}
process . exit ( 1 );
});
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
process . on ( 'uncaughtException' , ( error ) => {
if ( isJsonMode ( activeCommandOptions )) {
printJson ({
status : 'error' ,
error : {
message : `Uncaught exception: ${ error instanceof Error ? error . message : String ( error ) } ` ,
},
});
} else {
console . error ( 'Uncaught Exception:' , error );
}
process . exit ( 1 );
});
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
main (). catch (( error ) => {
const message = error instanceof Error ? error . message : String ( error );
if ( isJsonMode ( activeCommandOptions )) {
printJson ({
status : 'error' ,
error : {
message ,
},
});
} else if ( process . stdout ? . isTTY && ! HAS_PLAIN_FLAG ) {
clackIntro ( boldText ( 'Error' ));
logStatus ( 'error' , message );
clackOutro ( 'failed' );
} else {
console . error ( `Error: ${ message } ` );
}
const exitCode = error instanceof TunnelCliError ? error . exitCode : EXIT_CODE . GENERAL_ERROR ;
process . exit ( exitCode );
});
}
2025-12-07 19:32:53 +02:00
2026-03-12 19:40:22 +02:00
export {
commands ,
parseArgs ,
hasUiPasswordConfigured ,
shouldDisplayTunnelQr ,
isValidTunnelDoctorResponse ,
readDesktopLocalPortFromSettings ,
getPidFilePath ,
resolveTunnelProviders ,
fetchTunnelProvidersFromPort ,
fetchSystemInfoFromPort ,
discoverRunningInstances ,
ensureTunnelProfilesMigrated ,
resolveToken ,
redactProfileForOutput ,
redactProfilesForOutput ,
maskToken ,
findClosestMatch ,
generateCompletionScript ,
TunnelCliError ,
EXIT_CODE ,
warnIfUnsafeFilePermissions ,
};