| 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | import yargs from 'yargs/yargs'; |
| 4 | import { hideBin } from 'yargs/helpers'; |
| 5 | import ipRegex from 'ip-regex'; |
| 6 | import envPaths from 'env-paths'; |
| 7 | import { color, getConfigValue, stringToBool } from './util.js'; |
| 8 | import { initConfig } from './config-init.js'; |
| 9 | |
| 10 | /** |
| 11 | * @typedef {object} CommandLineArguments Parsed command line arguments |
| 12 | * @property {string} configPath Path to the config file |
| 13 | * @property {string} dataRoot Data root directory |
| 14 | * @property {number} port Port number |
| 15 | * @property {boolean} listen If SillyTavern is listening on all network interfaces |
| 16 | * @property {string} listenAddressIPv6 IPv6 address to listen to |
| 17 | * @property {string} listenAddressIPv4 IPv4 address to listen to |
| 18 | * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed) |
| 19 | * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed) |
| 20 | * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS |
| 21 | * @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable. |
| 22 | * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser |
| 23 | * @property {string} browserLaunchHostname Browser launch hostname |
| 24 | * @property {number} browserLaunchPort Browser launch port override (-1 is use server port) |
| 25 | * @property {boolean} browserLaunchAvoidLocalhost If avoid using 'localhost' for browser launch in auto mode |
| 26 | * @property {boolean} enableCorsProxy If enable CORS proxy |
| 27 | * @property {boolean} disableCsrf If disable CSRF protection |
| 28 | * @property {boolean} ssl If enable SSL |
| 29 | * @property {string} certPath Path to certificate |
| 30 | * @property {string} keyPath Path to private key |
| 31 | * @property {string} keyPassphrase SSL private key passphrase |
| 32 | * @property {boolean} whitelistMode If enable whitelist mode |
| 33 | * @property {boolean} basicAuthMode If enable basic authentication |
| 34 | * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive globally |
| 35 | * @property {boolean} requestProxyEnabled If enable outgoing request proxy |
| 36 | * @property {string} requestProxyUrl Request proxy URL |
| 37 | * @property {string[]} requestProxyBypass Request proxy bypass list |
| 38 | * @property {function(): URL} getIPv4ListenUrl Get IPv4 listen URL |
| 39 | * @property {function(): URL} getIPv6ListenUrl Get IPv6 listen URL |
| 40 | * @property {function(import('./server-startup.js').ServerStartupResult): Promise<string>} getBrowserLaunchHostname Get browser launch hostname |
| 41 | * @property {function(string): URL} getBrowserLaunchUrl Get browser launch URL |
| 42 | */ |
| 43 | |
| 44 | /** |
| 45 | * Provides a command line arguments parser. |
| 46 | */ |
| 47 | export class CommandLineParser { |
| 48 | /** |
| 49 | * Gets the default configuration values. |
| 50 | * @param {boolean} isGlobal If the configuration is global or not |
| 51 | * @returns {CommandLineArguments} Default configuration values |
| 52 | */ |
| 53 | getDefaultConfig(isGlobal) { |
| 54 | const appPaths = envPaths('SillyTavern', { suffix: '' }); |
| 55 | const configPath = isGlobal ? path.join(appPaths.data, 'config.yaml') : './config.yaml'; |
| 56 | const dataPath = isGlobal ? path.join(appPaths.data, 'data') : './data'; |
| 57 | return Object.freeze({ |
| 58 | configPath: configPath, |
| 59 | dataRoot: dataPath, |
| 60 | port: 8000, |
| 61 | listen: false, |
| 62 | listenAddressIPv6: '[::]', |
| 63 | listenAddressIPv4: '0.0.0.0', |
| 64 | enableIPv4: true, |
| 65 | enableIPv6: false, |
| 66 | dnsPreferIPv6: false, |
| 67 | heartbeatInterval: 0, |
| 68 | browserLaunchEnabled: false, |
| 69 | browserLaunchHostname: 'auto', |
| 70 | browserLaunchPort: -1, |
| 71 | browserLaunchAvoidLocalhost: false, |
| 72 | enableCorsProxy: false, |
| 73 | disableCsrf: false, |
| 74 | ssl: false, |
| 75 | certPath: 'certs/cert.pem', |
| 76 | keyPath: 'certs/privkey.pem', |
| 77 | keyPassphrase: '', |
| 78 | whitelistMode: true, |
| 79 | basicAuthMode: false, |
| 80 | enableKeepAlive: false, |
| 81 | requestProxyEnabled: false, |
| 82 | requestProxyUrl: '', |
| 83 | requestProxyBypass: [], |
| 84 | getIPv4ListenUrl: function () { |
| 85 | throw new Error('getIPv4ListenUrl is not implemented'); |
| 86 | }, |
| 87 | getIPv6ListenUrl: function () { |
| 88 | throw new Error('getIPv6ListenUrl is not implemented'); |
| 89 | }, |
| 90 | getBrowserLaunchHostname: async function () { |
| 91 | throw new Error('getBrowserLaunchHostname is not implemented'); |
| 92 | }, |
| 93 | getBrowserLaunchUrl: function () { |
| 94 | throw new Error('getBrowserLaunchUrl is not implemented'); |
| 95 | }, |
| 96 | }); |
| 97 | } |
| 98 | |
| 99 | constructor() { |
| 100 | this.booleanAutoOptions = [true, false, 'auto']; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Parses command line arguments. |
| 105 | * Arguments that are not provided will be filled with config values. |
| 106 | * @param {string[]} args Process startup arguments. |
| 107 | * @returns {CommandLineArguments} Parsed command line arguments. |
| 108 | */ |
| 109 | parse(args) { |
| 110 | const cliArguments = yargs(hideBin(args)) |
| 111 | .usage('Usage: <your-start-script> [options]\nOptions that are not provided will be filled with config values.') |
| 112 | .option('global', { |
| 113 | type: 'boolean', |
| 114 | default: null, |
| 115 | describe: 'Use global data and config paths instead of the server directory', |
| 116 | }) |
| 117 | .option('configPath', { |
| 118 | type: 'string', |
| 119 | default: null, |
| 120 | describe: 'Path to the config file (only for standalone mode)', |
| 121 | }) |
| 122 | .option('enableIPv6', { |
| 123 | type: 'string', |
| 124 | default: null, |
| 125 | describe: 'Enables IPv6 protocol', |
| 126 | }) |
| 127 | .option('enableIPv4', { |
| 128 | type: 'string', |
| 129 | default: null, |
| 130 | describe: 'Enables IPv4 protocol', |
| 131 | }) |
| 132 | .option('port', { |
| 133 | type: 'number', |
| 134 | default: null, |
| 135 | describe: 'Sets the server listening port', |
| 136 | }) |
| 137 | .option('dnsPreferIPv6', { |
| 138 | type: 'boolean', |
| 139 | default: null, |
| 140 | describe: 'Prefers IPv6 for DNS\nYou should probably have the enabled if you\'re on an IPv6 only network', |
| 141 | }) |
| 142 | .option('browserLaunchEnabled', { |
| 143 | type: 'boolean', |
| 144 | default: null, |
| 145 | describe: 'Automatically launch SillyTavern in the browser', |
| 146 | }) |
| 147 | .option('browserLaunchHostname', { |
| 148 | type: 'string', |
| 149 | default: null, |
| 150 | describe: 'Sets the browser launch hostname, best left on \'auto\'.\nUse values like \'localhost\', \'st.example.com\'', |
| 151 | }) |
| 152 | .option('browserLaunchPort', { |
| 153 | type: 'number', |
| 154 | default: null, |
| 155 | describe: 'Overrides the port for browser launch with open your browser with this port and ignore what port the server is running on. -1 is use server port', |
| 156 | }) |
| 157 | .option('browserLaunchAvoidLocalhost', { |
| 158 | type: 'boolean', |
| 159 | default: null, |
| 160 | describe: 'Avoids using \'localhost\' for browser launch in auto mode.\nUse if you don\'t have \'localhost\' in your hosts file', |
| 161 | }) |
| 162 | .option('listen', { |
| 163 | type: 'boolean', |
| 164 | default: null, |
| 165 | describe: 'Whether to listen on all network interfaces', |
| 166 | }) |
| 167 | .option('listenAddressIPv6', { |
| 168 | type: 'string', |
| 169 | default: null, |
| 170 | describe: 'Specific IPv6 address to listen to', |
| 171 | }) |
| 172 | .option('listenAddressIPv4', { |
| 173 | type: 'string', |
| 174 | default: null, |
| 175 | describe: 'Specific IPv4 address to listen to', |
| 176 | }) |
| 177 | .option('corsProxy', { |
| 178 | type: 'boolean', |
| 179 | default: null, |
| 180 | describe: 'Enables CORS proxy', |
| 181 | }) |
| 182 | .option('disableCsrf', { |
| 183 | type: 'boolean', |
| 184 | default: null, |
| 185 | describe: 'Disables CSRF protection - NOT RECOMMENDED', |
| 186 | }) |
| 187 | .option('ssl', { |
| 188 | type: 'boolean', |
| 189 | default: null, |
| 190 | describe: 'Enables SSL', |
| 191 | }) |
| 192 | .option('certPath', { |
| 193 | type: 'string', |
| 194 | default: null, |
| 195 | describe: 'Path to SSL certificate file', |
| 196 | }) |
| 197 | .option('keyPath', { |
| 198 | type: 'string', |
| 199 | default: null, |
| 200 | describe: 'Path to SSL private key file', |
| 201 | }) |
| 202 | .option('keyPassphrase', { |
| 203 | type: 'string', |
| 204 | default: null, |
| 205 | describe: 'Passphrase for the SSL private key', |
| 206 | }) |
| 207 | .option('whitelist', { |
| 208 | type: 'boolean', |
| 209 | default: null, |
| 210 | describe: 'Enables whitelist mode', |
| 211 | }) |
| 212 | .option('dataRoot', { |
| 213 | type: 'string', |
| 214 | default: null, |
| 215 | describe: 'Root directory for data storage (only for standalone mode)', |
| 216 | }) |
| 217 | .option('basicAuthMode', { |
| 218 | type: 'boolean', |
| 219 | default: null, |
| 220 | describe: 'Enables basic authentication', |
| 221 | }) |
| 222 | .option('enableKeepAlive', { |
| 223 | type: 'boolean', |
| 224 | default: null, |
| 225 | describe: 'Enable HTTP/HTTPS keep-alive globally', |
| 226 | }) |
| 227 | .option('requestProxyEnabled', { |
| 228 | type: 'boolean', |
| 229 | default: null, |
| 230 | describe: 'Enables a use of proxy for outgoing requests', |
| 231 | }) |
| 232 | .option('requestProxyUrl', { |
| 233 | type: 'string', |
| 234 | default: null, |
| 235 | describe: 'Request proxy URL (HTTP or SOCKS protocols)', |
| 236 | }) |
| 237 | .option('requestProxyBypass', { |
| 238 | type: 'array', |
| 239 | describe: 'Request proxy bypass list (space separated list of hosts)', |
| 240 | }) |
| 241 | .option('heartbeatInterval', { |
| 242 | type: 'number', |
| 243 | default: null, |
| 244 | describe: 'Interval in seconds to write a heartbeat file. 0 to disable.', |
| 245 | }) |
| 246 | /* DEPRECATED options */ |
| 247 | .option('autorun', { |
| 248 | type: 'boolean', |
| 249 | default: null, |
| 250 | describe: 'DEPRECATED: Use "browserLaunchEnabled" instead.', |
| 251 | }) |
| 252 | .option('autorunHostname', { |
| 253 | type: 'string', |
| 254 | default: null, |
| 255 | describe: 'DEPRECATED: Use "browserLaunchHostname" instead.', |
| 256 | }) |
| 257 | .option('autorunPortOverride', { |
| 258 | type: 'number', |
| 259 | default: null, |
| 260 | describe: 'DEPRECATED: Use "browserLaunchPort" instead.', |
| 261 | }) |
| 262 | .option('avoidLocalhost', { |
| 263 | type: 'boolean', |
| 264 | default: null, |
| 265 | describe: 'DEPRECATED: Use "browserLaunchAvoidLocalhost" instead.', |
| 266 | }) |
| 267 | .parseSync(); |
| 268 | |
| 269 | const isGlobal = globalThis.FORCE_GLOBAL_MODE ?? cliArguments.global ?? false; |
| 270 | const defaultConfig = this.getDefaultConfig(isGlobal); |
| 271 | |
| 272 | if (isGlobal && cliArguments.configPath) { |
| 273 | console.warn(color.yellow('Warning: "--configPath" argument is ignored in global mode')); |
| 274 | } |
| 275 | |
| 276 | if (isGlobal && cliArguments.dataRoot) { |
| 277 | console.warn(color.yellow('Warning: "--dataRoot" argument is ignored in global mode')); |
| 278 | } |
| 279 | |
| 280 | const configPath = isGlobal |
| 281 | ? defaultConfig.configPath |
| 282 | : (cliArguments.configPath ?? defaultConfig.configPath); |
| 283 | if (isGlobal && !fs.existsSync(path.dirname(configPath))) { |
| 284 | fs.mkdirSync(path.dirname(configPath), { recursive: true }); |
| 285 | } |
| 286 | initConfig(configPath); |
| 287 | |
| 288 | const dataRoot = isGlobal |
| 289 | ? defaultConfig.dataRoot |
| 290 | : (cliArguments.dataRoot ?? getConfigValue('dataRoot', defaultConfig.dataRoot)); |
| 291 | try { |
| 292 | if (!fs.existsSync(dataRoot)) { |
| 293 | fs.mkdirSync(dataRoot, { recursive: true }); |
| 294 | } |
| 295 | } catch (err) { |
| 296 | console.warn(color.yellow(`Warning: Failed to create data root directory at ${dataRoot}. Please make sure the path is correct and writable.`), err); |
| 297 | } |
| 298 | |
| 299 | /** @type {CommandLineArguments} */ |
| 300 | const result = { |
| 301 | configPath: configPath, |
| 302 | dataRoot: dataRoot, |
| 303 | port: cliArguments.port ?? getConfigValue('port', defaultConfig.port, 'number'), |
| 304 | listen: cliArguments.listen ?? getConfigValue('listen', defaultConfig.listen, 'boolean'), |
| 305 | listenAddressIPv6: cliArguments.listenAddressIPv6 ?? getConfigValue('listenAddress.ipv6', defaultConfig.listenAddressIPv6), |
| 306 | listenAddressIPv4: cliArguments.listenAddressIPv4 ?? getConfigValue('listenAddress.ipv4', defaultConfig.listenAddressIPv4), |
| 307 | enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4, |
| 308 | enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6, |
| 309 | dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'), |
| 310 | heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'), |
| 311 | browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'), |
| 312 | browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname), |
| 313 | browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'), |
| 314 | browserLaunchAvoidLocalhost: cliArguments.browserLaunchAvoidLocalhost ?? cliArguments.avoidLocalhost ?? getConfigValue('browserLaunch.avoidLocalhost', defaultConfig.browserLaunchAvoidLocalhost, 'boolean'), |
| 315 | enableCorsProxy: cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', defaultConfig.enableCorsProxy, 'boolean'), |
| 316 | disableCsrf: cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', defaultConfig.disableCsrf, 'boolean'), |
| 317 | ssl: cliArguments.ssl ?? getConfigValue('ssl.enabled', defaultConfig.ssl, 'boolean'), |
| 318 | certPath: cliArguments.certPath ?? getConfigValue('ssl.certPath', defaultConfig.certPath), |
| 319 | keyPath: cliArguments.keyPath ?? getConfigValue('ssl.keyPath', defaultConfig.keyPath), |
| 320 | keyPassphrase: cliArguments.keyPassphrase ?? getConfigValue('ssl.keyPassphrase', defaultConfig.keyPassphrase), |
| 321 | whitelistMode: cliArguments.whitelist ?? getConfigValue('whitelistMode', defaultConfig.whitelistMode, 'boolean'), |
| 322 | basicAuthMode: cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', defaultConfig.basicAuthMode, 'boolean'), |
| 323 | enableKeepAlive: cliArguments.enableKeepAlive ?? getConfigValue('enableKeepAlive', defaultConfig.enableKeepAlive, 'boolean'), |
| 324 | requestProxyEnabled: cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', defaultConfig.requestProxyEnabled, 'boolean'), |
| 325 | requestProxyUrl: cliArguments.requestProxyUrl ?? getConfigValue('requestProxy.url', defaultConfig.requestProxyUrl), |
| 326 | requestProxyBypass: cliArguments.requestProxyBypass ?? getConfigValue('requestProxy.bypass', defaultConfig.requestProxyBypass), |
| 327 | getIPv4ListenUrl: function () { |
| 328 | const isValid = ipRegex.v4({ exact: true }).test(this.listenAddressIPv4); |
| 329 | return new URL( |
| 330 | (this.ssl ? 'https://' : 'http://') + |
| 331 | (this.listen ? (isValid ? this.listenAddressIPv4 : '0.0.0.0') : '127.0.0.1') + |
| 332 | (':' + this.port), |
| 333 | ); |
| 334 | }, |
| 335 | getIPv6ListenUrl: function () { |
| 336 | const isValid = ipRegex.v6({ exact: true }).test(this.listenAddressIPv6); |
| 337 | return new URL( |
| 338 | (this.ssl ? 'https://' : 'http://') + |
| 339 | (this.listen ? (isValid ? this.listenAddressIPv6 : '[::]') : '[::1]') + |
| 340 | (':' + this.port), |
| 341 | ); |
| 342 | }, |
| 343 | getBrowserLaunchHostname: async function ({ useIPv6, useIPv4 }) { |
| 344 | if (this.browserLaunchHostname === 'auto') { |
| 345 | if (useIPv6 && useIPv4) { |
| 346 | return this.browserLaunchAvoidLocalhost ? '[::1]' : 'localhost'; |
| 347 | } |
| 348 | |
| 349 | if (useIPv6) { |
| 350 | return '[::1]'; |
| 351 | } |
| 352 | |
| 353 | if (useIPv4) { |
| 354 | return '127.0.0.1'; |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | return this.browserLaunchHostname; |
| 359 | }, |
| 360 | getBrowserLaunchUrl: function (hostname) { |
| 361 | const browserLaunchPort = (this.browserLaunchPort >= 0) ? this.browserLaunchPort : this.port; |
| 362 | return new URL( |
| 363 | (this.ssl ? 'https://' : 'http://') + |
| 364 | (hostname) + |
| 365 | (':') + |
| 366 | (browserLaunchPort), |
| 367 | ); |
| 368 | }, |
| 369 | }; |
| 370 | |
| 371 | if (!this.booleanAutoOptions.includes(result.enableIPv6)) { |
| 372 | console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv6); |
| 373 | result.enableIPv6 = defaultConfig.enableIPv6; |
| 374 | } |
| 375 | |
| 376 | if (!this.booleanAutoOptions.includes(result.enableIPv4)) { |
| 377 | console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', this.booleanAutoOptions, '\n setting to:', defaultConfig.enableIPv4); |
| 378 | result.enableIPv4 = defaultConfig.enableIPv4; |
| 379 | } |
| 380 | |
| 381 | return result; |
| 382 | } |
| 383 | } |