Files
openchamber/packages/web/bin/lib/cli-tunnel-utils.js
T

301 lines
8.2 KiB
JavaScript

import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { canPrompt, select as clackSelect, text as clackText, cancel as clackCancel, isCancel as clackIsCancel } from '../cli-output.js';
const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000;
const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000;
const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000;
const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000;
const CONNECT_TTL_PICKER_OPTIONS = [
{ value: String(3 * 60 * 1000), label: '3m' },
{ value: String(TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS), label: '30m' },
{ value: String(2 * 60 * 60 * 1000), label: '2h' },
{ value: String(8 * 60 * 60 * 1000), label: '8h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: '__custom__', label: 'Custom' },
];
const SESSION_TTL_PICKER_OPTIONS = [
{ value: String(60 * 60 * 1000), label: '1h' },
{ value: String(TUNNEL_SESSION_TTL_DEFAULT_MS), label: '8h' },
{ value: String(12 * 60 * 60 * 1000), label: '12h' },
{ value: String(24 * 60 * 60 * 1000), label: '24h' },
{ value: String(7 * 24 * 60 * 60 * 1000), label: '1w' },
{ value: String(30 * 24 * 60 * 60 * 1000), label: '30d' },
{ value: '__custom__', label: 'Custom' },
];
function parseHumanDurationToMs(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return Number.parseInt(trimmed, 10);
}
const normalized = trimmed.replace(/\s+/g, '');
const pattern = /(\d+)(ms|s|m|h|d)/g;
let cursor = 0;
let total = 0;
let match;
while ((match = pattern.exec(normalized)) !== null) {
if (match.index !== cursor) {
return null;
}
cursor = pattern.lastIndex;
const amount = Number.parseInt(match[1], 10);
const unit = match[2];
const unitMs = unit === 'ms'
? 1
: unit === 's'
? 1000
: unit === 'm'
? 60 * 1000
: unit === 'h'
? 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
total += amount * unitMs;
}
if (cursor !== normalized.length) {
return null;
}
return total;
}
function parseTtlMsOrThrow(rawValue, {
flagName,
minMs,
maxMs,
} = {}) {
const parsed = parseHumanDurationToMs(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new TunnelCliError(
`Invalid value for ${flagName}. Use a positive duration like 30m, 24h, 1d, or milliseconds.`,
EXIT_CODE.USAGE_ERROR,
);
}
if (parsed < minMs || parsed > maxMs) {
throw new TunnelCliError(
`${flagName} must be between ${minMs}ms and ${maxMs}ms.`,
EXIT_CODE.USAGE_ERROR,
);
}
return parsed;
}
function formatDurationForCli(ms) {
if (!Number.isFinite(ms) || ms <= 0) {
return null;
}
const value = Math.round(ms);
if (value % (24 * 60 * 60 * 1000) === 0) return `${value / (24 * 60 * 60 * 1000)}d`;
if (value % (60 * 60 * 1000) === 0) return `${value / (60 * 60 * 1000)}h`;
if (value % (60 * 1000) === 0) return `${value / (60 * 1000)}m`;
if (value % 1000 === 0) return `${value / 1000}s`;
return `${value}ms`;
}
function shellQuote(value) {
const text = String(value);
if (/^[A-Za-z0-9._\-/:=]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, `'"'"'`)}'`;
}
function buildTunnelStartReplayCommand({
port,
provider,
mode,
profileName,
configPath,
hostname,
connectTtlMs,
sessionTtlMs,
qr,
noQr,
includeTokenPlaceholder,
tokenViaStdin,
tokenFileProvided,
}) {
const parts = ['openchamber', 'tunnel', 'start'];
if (Number.isFinite(port) && port > 0) {
parts.push('--port', String(port));
}
if (profileName) {
parts.push('--profile', shellQuote(profileName));
}
if (provider) {
parts.push('--provider', shellQuote(provider));
}
if (mode) {
parts.push('--mode', shellQuote(mode));
}
if (typeof configPath === 'string' && configPath.trim().length > 0) {
parts.push('--config', shellQuote(configPath));
}
if (typeof hostname === 'string' && hostname.trim().length > 0) {
parts.push('--hostname', shellQuote(hostname));
}
const connectTtl = formatDurationForCli(connectTtlMs);
if (connectTtl) {
parts.push('--connect-ttl', connectTtl);
}
const sessionTtl = formatDurationForCli(sessionTtlMs);
if (sessionTtl) {
parts.push('--session-ttl', sessionTtl);
}
if (qr) parts.push('--qr');
if (noQr) parts.push('--no-qr');
if (includeTokenPlaceholder) {
if (tokenViaStdin) {
parts.push('--token-stdin');
} else if (tokenFileProvided) {
parts.push('--token-file', '<redacted>');
} else {
parts.push('--token', '<redacted>');
}
}
return parts.join(' ');
}
function buildTunnelProfileAddCommand({ provider, hostname }) {
const parts = [
'openchamber',
'tunnel',
'profile',
'add',
'--provider',
shellQuote(provider || 'cloudflare'),
'--mode',
'managed-remote',
'--name',
'<name>',
'--hostname',
shellQuote(hostname || '<hostname>'),
'--token',
'<token>',
];
return parts.join(' ');
}
async function resolveTunnelTtlOverrides(options) {
let connectTtlRaw = typeof options.connectTtl === 'string' ? options.connectTtl : undefined;
let sessionTtlRaw = typeof options.sessionTtl === 'string' ? options.sessionTtl : undefined;
const shouldPrompt = !connectTtlRaw
&& !sessionTtlRaw
&& canPrompt(options);
if (shouldPrompt) {
const connectChoice = await clackSelect({
message: 'Select connect-link TTL',
options: CONNECT_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(connectChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (connectChoice === '__custom__') {
const enteredConnect = await clackText({
message: 'Enter connect-link TTL (e.g. 30m, 2h, 1d)',
placeholder: '30m',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredConnect)) {
clackCancel('Tunnel start cancelled.');
return null;
}
connectTtlRaw = enteredConnect.trim();
} else {
connectTtlRaw = connectChoice;
}
const sessionChoice = await clackSelect({
message: 'Select session TTL',
options: SESSION_TTL_PICKER_OPTIONS,
});
if (clackIsCancel(sessionChoice)) {
clackCancel('Tunnel start cancelled.');
return null;
}
if (sessionChoice === '__custom__') {
const enteredSession = await clackText({
message: 'Enter session TTL (e.g. 8h, 24h, 1d)',
placeholder: '8h',
validate(value) {
try {
parseTtlMsOrThrow(value, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
});
return undefined;
} catch (error) {
return error instanceof Error ? error.message : 'Invalid TTL value';
}
},
});
if (clackIsCancel(enteredSession)) {
clackCancel('Tunnel start cancelled.');
return null;
}
sessionTtlRaw = enteredSession.trim();
} else {
sessionTtlRaw = sessionChoice;
}
}
const connectTtlMs = connectTtlRaw !== undefined
? parseTtlMsOrThrow(connectTtlRaw, {
flagName: '--connect-ttl',
minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS,
maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS,
})
: undefined;
const sessionTtlMs = sessionTtlRaw !== undefined
? parseTtlMsOrThrow(sessionTtlRaw, {
flagName: '--session-ttl',
minMs: TUNNEL_SESSION_TTL_MIN_MS,
maxMs: TUNNEL_SESSION_TTL_MAX_MS,
})
: undefined;
return {
connectTtlMs,
sessionTtlMs,
};
}
export {
buildTunnelStartReplayCommand,
buildTunnelProfileAddCommand,
resolveTunnelTtlOverrides,
};