@openchamber/web 1.11.6 → 1.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.js +443 -2
  3. package/dist/assets/{MarkdownRendererImpl-COdbjw73.js → MarkdownRendererImpl-DaF15QNC.js} +3 -3
  4. package/dist/assets/{MultiRunWindow-BKSHxjMq.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
  5. package/dist/assets/{OnboardingScreen-Chjg337p.js → OnboardingScreen-DTv6YJI1.js} +2 -2
  6. package/dist/assets/{SettingsWindow-C0lRRW8M.js → SettingsWindow-_c3TTL2z.js} +1 -1
  7. package/dist/assets/{TerminalView-Bvil3j1u.js → TerminalView-CuXkDROt.js} +3 -3
  8. package/dist/assets/es-CYoUf2D-.js +15 -0
  9. package/dist/assets/{index-B9LvUHdG.js → index-3WXrN3AX.js} +1 -1
  10. package/dist/assets/index-BREIbhcb.css +1 -0
  11. package/dist/assets/ko-2tM0fIna.js +15 -0
  12. package/dist/assets/main-BF3kWAJ9.js +239 -0
  13. package/dist/assets/{main-Blhx9Fp5.js → main-o8ZERrmU.js} +2 -2
  14. package/dist/assets/miniChat-BZQjpK23.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-DRJSYigo.js → modelPrefsAutoSave-wwnbqBk7.js} +110 -108
  16. package/dist/assets/pl-Dq8uAotM.js +15 -0
  17. package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-BxZRI73j.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
  19. package/dist/assets/uk-BZtz0wUV.js +15 -0
  20. package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
  21. package/dist/assets/zh-CN-j_nYMchE.js +15 -0
  22. package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
  23. package/dist/index.html +11 -28
  24. package/dist/mini-chat.html +4 -4
  25. package/package.json +1 -1
  26. package/server/lib/fs/routes.js +5 -0
  27. package/server/lib/fs/routes.test.js +61 -1
  28. package/server/lib/git/DOCUMENTATION.md +1 -0
  29. package/server/lib/git/routes.js +82 -1
  30. package/server/lib/git/service.js +338 -19
  31. package/server/lib/git/service.test.js +414 -8
  32. package/server/lib/opencode/env-runtime.js +52 -4
  33. package/server/lib/opencode/env-runtime.test.js +82 -6
  34. package/server/lib/opencode/openchamber-routes.js +9 -7
  35. package/server/lib/opencode/settings-helpers.js +3 -0
  36. package/server/lib/opencode/settings-runtime.js +39 -1
  37. package/server/lib/opencode/settings-runtime.test.js +39 -0
  38. package/server/lib/skills-catalog/source.js +1 -1
  39. package/dist/assets/es-BZIAUghG.js +0 -15
  40. package/dist/assets/index-UcCH2KN9.css +0 -1
  41. package/dist/assets/ko-DU9l-zox.js +0 -15
  42. package/dist/assets/main-d2-dY4er.js +0 -232
  43. package/dist/assets/miniChat-CJ7-rZFl.js +0 -2
  44. package/dist/assets/pl-CdqzokG-.js +0 -15
  45. package/dist/assets/pt-BR-Bknbr_Y3.js +0 -15
  46. package/dist/assets/uk-Be4E8ZNO.js +0 -15
  47. package/dist/assets/zh-CN-qpPiaZMg.js +0 -15
package/README.md CHANGED
@@ -24,6 +24,10 @@ Or install manually: `bun add -g @openchamber/web` (or npm, pnpm, yarn).
24
24
  openchamber # Start on port 3000
25
25
  openchamber --port 8080 # Custom port
26
26
  openchamber --ui-password secret # Password-protect UI
27
+ openchamber startup enable # Start at login as a native service
28
+ OPENCHAMBER_UI_PASSWORD=secret openchamber startup enable # Save service password env
29
+ openchamber startup status # Show startup service status
30
+ openchamber startup disable # Remove startup service
27
31
  openchamber tunnel help # Tunnel lifecycle commands
28
32
  openchamber tunnel providers # Show provider capabilities
29
33
  openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token <token>
@@ -39,6 +43,8 @@ openchamber stop # Stop server
39
43
  openchamber update # Update to latest version
40
44
  ```
41
45
 
46
+ `startup enable` snapshots your current environment into the native service so startup behaves like you launched `openchamber` from the same shell. This preserves provider tokens, PATH, SSH agent settings, and other CLI auth/config env vars. Use `--no-env-snapshot` for a minimal service env.
47
+
42
48
  ### Tunnel behavior notes
43
49
 
44
50
  - One active tunnel per running OpenChamber instance (port).
package/bin/cli.js CHANGED
@@ -32,6 +32,7 @@ const DEFAULT_TAIL_LINES = 200;
32
32
  const DAEMON_READY_TIMEOUT_MS = 30000;
33
33
  const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024;
34
34
  const LOG_ROTATE_KEEP = 5;
35
+ const STARTUP_SERVICE_ID = 'dev.openchamber.web';
35
36
  const TUNNEL_PROFILES_VERSION = 1;
36
37
  const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json';
37
38
  const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json';
@@ -601,6 +602,7 @@ function parseArgs(argv = process.argv.slice(2)) {
601
602
  quiet: false,
602
603
  explicitPort: false,
603
604
  explicitUiPassword: false,
605
+ envSnapshot: true,
604
606
  foreground: false,
605
607
  };
606
608
 
@@ -753,6 +755,9 @@ function parseArgs(argv = process.argv.slice(2)) {
753
755
  case 'no-follow':
754
756
  options.follow = false;
755
757
  break;
758
+ case 'no-env-snapshot':
759
+ options.envSnapshot = false;
760
+ break;
756
761
  case 'lines': {
757
762
  const { value, nextIndex } = consumeValue(i, inlineValue);
758
763
  i = nextIndex;
@@ -833,11 +838,13 @@ function parseArgs(argv = process.argv.slice(2)) {
833
838
  const command = positional[0] || 'serve';
834
839
  const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null;
835
840
  const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null;
841
+ const startupAction = command === 'startup' ? (positional[1] || 'status') : null;
836
842
 
837
843
  return {
838
844
  command,
839
845
  subcommand,
840
846
  tunnelAction,
847
+ startupAction,
841
848
  options,
842
849
  removedFlagErrors,
843
850
  helpRequested,
@@ -858,6 +865,7 @@ COMMANDS:
858
865
  restart Stop and start the server
859
866
  status Show server status
860
867
  tunnel Tunnel lifecycle commands
868
+ startup Manage launch at system startup
861
869
  logs Tail OpenChamber logs
862
870
  update Check for and install updates
863
871
 
@@ -883,11 +891,39 @@ EXAMPLES:
883
891
  openchamber # Start in daemon mode on default port 3000 (or free port)
884
892
  openchamber --port 8080 # Start on port 8080 (daemon)
885
893
  openchamber serve --foreground # Start in foreground (for systemd Type=simple)
894
+ openchamber startup enable # Start OpenChamber at user login
886
895
  openchamber tunnel help # Show tunnel lifecycle help
887
896
  openchamber logs # Follow logs for latest running instance
888
897
  `);
889
898
  }
890
899
 
900
+ function showStartupHelp() {
901
+ console.log(`
902
+ OpenChamber Startup Commands
903
+
904
+ USAGE:
905
+ openchamber startup <SUBCOMMAND> [OPTIONS]
906
+
907
+ SUBCOMMANDS:
908
+ status Show startup integration status
909
+ enable Install and start native user startup integration
910
+ disable Stop and remove native user startup integration
911
+
912
+ OPTIONS:
913
+ -p, --port Web server port used by startup service
914
+ --host Bind address used by startup service
915
+ --ui-password Protect browser UI with single password
916
+ --no-env-snapshot Do not save current environment for startup service
917
+ --json Output machine-readable JSON
918
+ -q, --quiet Suppress non-essential output
919
+
920
+ EXAMPLES:
921
+ openchamber startup enable
922
+ openchamber startup enable --port 3000
923
+ openchamber startup status --json
924
+ `);
925
+ }
926
+
891
927
  function showTunnelHelp() {
892
928
  console.log(`
893
929
  Tunnel Lifecycle Commands
@@ -1702,6 +1738,352 @@ function getRunDir() {
1702
1738
  return dir;
1703
1739
  }
1704
1740
 
1741
+ function getStartupServicePaths() {
1742
+ if (process.platform === 'darwin') {
1743
+ return {
1744
+ platform: 'macos',
1745
+ servicePath: path.join(os.homedir(), 'Library', 'LaunchAgents', `${STARTUP_SERVICE_ID}.plist`),
1746
+ };
1747
+ }
1748
+ if (process.platform === 'linux') {
1749
+ return {
1750
+ platform: 'linux',
1751
+ servicePath: path.join(os.homedir(), '.config', 'systemd', 'user', 'openchamber.service'),
1752
+ };
1753
+ }
1754
+ if (process.platform === 'win32') {
1755
+ return { platform: 'windows', servicePath: STARTUP_SERVICE_ID };
1756
+ }
1757
+ return { platform: process.platform, servicePath: null };
1758
+ }
1759
+
1760
+ function escapeXml(value) {
1761
+ return String(value)
1762
+ .replace(/&/g, '&amp;')
1763
+ .replace(/</g, '&lt;')
1764
+ .replace(/>/g, '&gt;')
1765
+ .replace(/"/g, '&quot;')
1766
+ .replace(/'/g, '&apos;');
1767
+ }
1768
+
1769
+ function systemdEscapeArg(value) {
1770
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1771
+ }
1772
+
1773
+ function startupShellQuote(value) {
1774
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
1775
+ }
1776
+
1777
+ function systemdUnitPath(value) {
1778
+ return String(value).replace(/\\/g, '\\\\').replace(/ /g, '\\x20');
1779
+ }
1780
+
1781
+ function powershellQuote(value) {
1782
+ return `'${String(value).replace(/'/g, "''")}'`;
1783
+ }
1784
+
1785
+ function startupEnvFileQuote(value) {
1786
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
1787
+ }
1788
+
1789
+ function systemdEnvFileQuote(value) {
1790
+ return `"${String(value)
1791
+ .replace(/\\/g, '\\\\')
1792
+ .replace(/"/g, '\\"')
1793
+ .replace(/`/g, '\\`')
1794
+ .replace(/\$/g, '\\$')}"`;
1795
+ }
1796
+
1797
+ function getStartupEnvFilePath() {
1798
+ return path.join(getDataDir(), 'startup.env');
1799
+ }
1800
+
1801
+ function getMacosStartupWrapperPath() {
1802
+ return path.join(getDataDir(), 'bin', 'OpenChamber');
1803
+ }
1804
+
1805
+ function collectStartupEnv(options = {}) {
1806
+ const env = options.envSnapshot === false ? {} : Object.fromEntries(
1807
+ Object.entries(process.env)
1808
+ .filter(([key, value]) => shouldPersistStartupEnv(key, value))
1809
+ .map(([key, value]) => [key, String(value)])
1810
+ );
1811
+
1812
+ if (options.envSnapshot !== false) {
1813
+ const opencodeBinary = process.env.OPENCODE_BINARY || searchPathFor('opencode');
1814
+ if (typeof opencodeBinary === 'string' && opencodeBinary.trim().length > 0) {
1815
+ env.OPENCODE_BINARY = opencodeBinary.trim();
1816
+ }
1817
+ }
1818
+ const uiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
1819
+ if (uiPassword) {
1820
+ env.OPENCHAMBER_UI_PASSWORD = uiPassword;
1821
+ }
1822
+ if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim().length > 0) {
1823
+ env.OPENCHAMBER_DATA_DIR = path.resolve(process.env.OPENCHAMBER_DATA_DIR.trim());
1824
+ }
1825
+ return env;
1826
+ }
1827
+
1828
+ function shouldPersistStartupEnv(key, value) {
1829
+ if (typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return false;
1830
+ if (typeof value !== 'string') return false;
1831
+ if (/[\r\n]/.test(value)) return false;
1832
+
1833
+ // These are shell/session implementation details, not app configuration.
1834
+ const volatileKeys = new Set([
1835
+ '_',
1836
+ 'BASH_ENV',
1837
+ 'COLUMNS',
1838
+ 'CONDA_DEFAULT_ENV',
1839
+ 'CONDA_PREFIX',
1840
+ 'CONDA_PROMPT_MODIFIER',
1841
+ 'CONDA_SHLVL',
1842
+ 'ENV',
1843
+ 'HISTFILE',
1844
+ 'HISTFILESIZE',
1845
+ 'HISTSIZE',
1846
+ 'LINES',
1847
+ 'OLDPWD',
1848
+ 'PROMPT',
1849
+ 'PROMPT_COMMAND',
1850
+ 'PS1',
1851
+ 'PS2',
1852
+ 'PS3',
1853
+ 'PS4',
1854
+ 'PWD',
1855
+ 'PYENV_VERSION',
1856
+ 'SHLVL',
1857
+ 'TERM',
1858
+ 'TERM_PROGRAM',
1859
+ 'TERM_PROGRAM_VERSION',
1860
+ 'TTY',
1861
+ 'VIRTUAL_ENV',
1862
+ 'VIRTUAL_ENV_PROMPT',
1863
+ ]);
1864
+ return !volatileKeys.has(key);
1865
+ }
1866
+
1867
+ function writeStartupEnvFile(options = {}, fileOptions = {}) {
1868
+ const envFilePath = getStartupEnvFilePath();
1869
+ const lines = [];
1870
+ const env = collectStartupEnv(options);
1871
+ const quoteValue = typeof fileOptions.quoteValue === 'function' ? fileOptions.quoteValue : startupEnvFileQuote;
1872
+ for (const [key, value] of Object.entries(env)) {
1873
+ lines.push(`${key}=${quoteValue(value)}`);
1874
+ }
1875
+ fs.mkdirSync(path.dirname(envFilePath), { recursive: true, mode: 0o700 });
1876
+ fs.writeFileSync(envFilePath, lines.length > 0 ? `${lines.join('\n')}\n` : '', { mode: 0o600 });
1877
+ return envFilePath;
1878
+ }
1879
+
1880
+ function removeStartupEnvFile() {
1881
+ try { fs.unlinkSync(getStartupEnvFilePath()); } catch {}
1882
+ }
1883
+
1884
+ function resolveCliEntrypoint() {
1885
+ const entry = typeof process.argv[1] === 'string' && process.argv[1].trim().length > 0
1886
+ ? process.argv[1]
1887
+ : path.join(__dirname, 'cli.js');
1888
+ try {
1889
+ return fs.realpathSync(entry);
1890
+ } catch {
1891
+ return path.resolve(entry);
1892
+ }
1893
+ }
1894
+
1895
+ function buildStartupArgs(options = {}) {
1896
+ const args = [resolveCliEntrypoint(), 'serve', '--foreground', '--port', String(options.port || DEFAULT_PORT)];
1897
+ if (typeof options.host === 'string' && options.host.length > 0) {
1898
+ args.push('--host', options.host);
1899
+ }
1900
+ return args;
1901
+ }
1902
+
1903
+ function writeMacosStartupWrapper(options = {}) {
1904
+ const wrapperPath = getMacosStartupWrapperPath();
1905
+ const args = buildStartupArgs(options).map(startupShellQuote).join(' ');
1906
+ const content = `#!/bin/sh
1907
+ exec ${startupShellQuote(process.execPath)} ${args}
1908
+ `;
1909
+ fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 });
1910
+ fs.writeFileSync(wrapperPath, content, { mode: 0o700 });
1911
+ return wrapperPath;
1912
+ }
1913
+
1914
+ function buildMacosLaunchAgent(options = {}) {
1915
+ const wrapperPath = writeMacosStartupWrapper(options);
1916
+ const args = [wrapperPath];
1917
+ const env = collectStartupEnv(options);
1918
+ const logDir = path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber');
1919
+ const argXml = args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
1920
+ const envXml = Object.entries(env).length > 0
1921
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n${Object.entries(env).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}\n </dict>\n`
1922
+ : '';
1923
+ return `<?xml version="1.0" encoding="UTF-8"?>
1924
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1925
+ <plist version="1.0">
1926
+ <dict>
1927
+ <key>Label</key>
1928
+ <string>${STARTUP_SERVICE_ID}</string>
1929
+ <key>ProgramArguments</key>
1930
+ <array>
1931
+ ${argXml}
1932
+ </array>
1933
+ ${envXml} <key>ProcessType</key>
1934
+ <string>Background</string>
1935
+ <key>RunAtLoad</key>
1936
+ <true/>
1937
+ <key>KeepAlive</key>
1938
+ <true/>
1939
+ <key>WorkingDirectory</key>
1940
+ <string>${escapeXml(os.homedir())}</string>
1941
+ <key>StandardOutPath</key>
1942
+ <string>${escapeXml(path.join(logDir, 'startup.log'))}</string>
1943
+ <key>StandardErrorPath</key>
1944
+ <string>${escapeXml(path.join(logDir, 'startup.err.log'))}</string>
1945
+ </dict>
1946
+ </plist>
1947
+ `;
1948
+ }
1949
+
1950
+ function buildSystemdUserService(options = {}) {
1951
+ const args = buildStartupArgs(options).map((arg) => `"${systemdEscapeArg(arg)}"`).join(' ');
1952
+ const envFilePath = getStartupEnvFilePath();
1953
+ return `[Unit]
1954
+ Description=OpenChamber web server
1955
+ After=network-online.target
1956
+
1957
+ [Service]
1958
+ Type=simple
1959
+ EnvironmentFile=-${systemdEscapeArg(envFilePath)}
1960
+ ExecStart="${systemdEscapeArg(process.execPath)}" ${args}
1961
+ WorkingDirectory=${systemdUnitPath(os.homedir())}
1962
+ Restart=always
1963
+ RestartSec=5
1964
+
1965
+ [Install]
1966
+ WantedBy=default.target
1967
+ `;
1968
+ }
1969
+
1970
+ function runStartupCommand(command, args, options = {}) {
1971
+ const result = spawnSync(command, args, {
1972
+ encoding: 'utf8',
1973
+ stdio: options.stdio || 'pipe',
1974
+ windowsHide: true,
1975
+ });
1976
+ if (result.error) {
1977
+ throw result.error;
1978
+ }
1979
+ if (result.status !== 0 && options.allowFailure !== true) {
1980
+ const detail = (result.stderr || result.stdout || '').trim();
1981
+ throw new Error(`${command} ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
1982
+ }
1983
+ return result;
1984
+ }
1985
+
1986
+ function getStartupStatus() {
1987
+ const paths = getStartupServicePaths();
1988
+ if (!paths.servicePath) {
1989
+ return { supported: false, platform: paths.platform, enabled: false, servicePath: null };
1990
+ }
1991
+ if (paths.platform === 'windows') {
1992
+ const result = runStartupCommand('schtasks.exe', ['/Query', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
1993
+ return { supported: true, platform: paths.platform, enabled: result.status === 0, active: null, servicePath: paths.servicePath };
1994
+ }
1995
+ if (paths.platform === 'linux') {
1996
+ const enabledResult = runStartupCommand('systemctl', ['--user', 'is-enabled', 'openchamber.service'], { allowFailure: true });
1997
+ const activeResult = runStartupCommand('systemctl', ['--user', 'is-active', 'openchamber.service'], { allowFailure: true });
1998
+ const activeState = (activeResult.stdout || '').trim() || 'inactive';
1999
+ return {
2000
+ supported: true,
2001
+ platform: paths.platform,
2002
+ enabled: enabledResult.status === 0 || fs.existsSync(paths.servicePath),
2003
+ active: activeState === 'active',
2004
+ activeState,
2005
+ servicePath: paths.servicePath,
2006
+ };
2007
+ }
2008
+ return {
2009
+ supported: true,
2010
+ platform: paths.platform,
2011
+ enabled: fs.existsSync(paths.servicePath),
2012
+ active: null,
2013
+ servicePath: paths.servicePath,
2014
+ };
2015
+ }
2016
+
2017
+ function enableStartupService(options = {}) {
2018
+ const paths = getStartupServicePaths();
2019
+ if (!paths.servicePath) {
2020
+ throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
2021
+ }
2022
+
2023
+ if (paths.platform === 'macos') {
2024
+ removeStartupEnvFile();
2025
+ fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
2026
+ fs.mkdirSync(path.join(os.homedir(), 'Library', 'Logs', 'OpenChamber'), { recursive: true, mode: 0o700 });
2027
+ fs.writeFileSync(paths.servicePath, buildMacosLaunchAgent(options), { mode: 0o600 });
2028
+ runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
2029
+ runStartupCommand('/bin/launchctl', ['bootstrap', `gui/${process.getuid()}`, paths.servicePath]);
2030
+ runStartupCommand('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${STARTUP_SERVICE_ID}`], { allowFailure: true });
2031
+ return getStartupStatus();
2032
+ }
2033
+
2034
+ if (paths.platform === 'linux') {
2035
+ writeStartupEnvFile(options, { quoteValue: systemdEnvFileQuote });
2036
+ fs.mkdirSync(path.dirname(paths.servicePath), { recursive: true, mode: 0o700 });
2037
+ fs.writeFileSync(paths.servicePath, buildSystemdUserService(options), { mode: 0o600 });
2038
+ runStartupCommand('systemctl', ['--user', 'daemon-reload']);
2039
+ runStartupCommand('systemctl', ['--user', 'enable', '--now', 'openchamber.service']);
2040
+ return getStartupStatus();
2041
+ }
2042
+
2043
+ const envFilePath = writeStartupEnvFile(options);
2044
+ const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', ');
2045
+ const powerShellCommand = [
2046
+ `$envFile=${powershellQuote(envFilePath)}`,
2047
+ `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`,
2048
+ `& ${powershellQuote(process.execPath)} ${startupArgs}`,
2049
+ ].join('; ');
2050
+ const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`;
2051
+ runStartupCommand('schtasks.exe', [
2052
+ '/Create',
2053
+ '/TN', STARTUP_SERVICE_ID,
2054
+ '/SC', 'ONLOGON',
2055
+ '/RL', 'LIMITED',
2056
+ '/F',
2057
+ '/TR', taskArgs,
2058
+ ]);
2059
+ runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
2060
+ return getStartupStatus();
2061
+ }
2062
+
2063
+ function disableStartupService() {
2064
+ const paths = getStartupServicePaths();
2065
+ if (!paths.servicePath) {
2066
+ throw new TunnelCliError(`Startup integration is not supported on ${paths.platform}.`, EXIT_CODE.USAGE_ERROR);
2067
+ }
2068
+
2069
+ if (paths.platform === 'macos') {
2070
+ runStartupCommand('/bin/launchctl', ['bootout', `gui/${process.getuid()}`, paths.servicePath], { allowFailure: true });
2071
+ try { fs.unlinkSync(paths.servicePath); } catch {}
2072
+ return getStartupStatus();
2073
+ }
2074
+
2075
+ if (paths.platform === 'linux') {
2076
+ runStartupCommand('systemctl', ['--user', 'disable', '--now', 'openchamber.service'], { allowFailure: true });
2077
+ try { fs.unlinkSync(paths.servicePath); } catch {}
2078
+ runStartupCommand('systemctl', ['--user', 'daemon-reload'], { allowFailure: true });
2079
+ return getStartupStatus();
2080
+ }
2081
+
2082
+ runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true });
2083
+ runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true });
2084
+ return getStartupStatus();
2085
+ }
2086
+
1705
2087
  async function getPidFilePath(port) {
1706
2088
  return path.join(getRunDir(), `openchamber-${port}.pid`);
1707
2089
  }
@@ -4719,6 +5101,58 @@ const commands = {
4719
5101
  });
4720
5102
  },
4721
5103
 
5104
+ async startup(options, action = 'status') {
5105
+ const normalized = typeof action === 'string' ? action.trim().toLowerCase() : 'status';
5106
+ if (!['status', 'enable', 'disable'].includes(normalized)) {
5107
+ throw new TunnelCliError(
5108
+ `Unknown startup subcommand '${action}'. Use 'openchamber startup --help'.`,
5109
+ EXIT_CODE.USAGE_ERROR
5110
+ );
5111
+ }
5112
+
5113
+ let status;
5114
+ if (normalized === 'enable') {
5115
+ status = enableStartupService(options);
5116
+ } else if (normalized === 'disable') {
5117
+ status = disableStartupService();
5118
+ } else {
5119
+ status = getStartupStatus();
5120
+ }
5121
+
5122
+ const result = { action: normalized, ...status };
5123
+ if (!result.supported) {
5124
+ throw new TunnelCliError(
5125
+ `Startup integration is not supported on ${result.platform}.`,
5126
+ EXIT_CODE.USAGE_ERROR
5127
+ );
5128
+ }
5129
+ if (normalized === 'enable' && result.activeState === 'failed') {
5130
+ throw new TunnelCliError(
5131
+ 'Startup service was installed but failed to start. Run `journalctl --user -u openchamber.service -n 80 --no-pager` for details.',
5132
+ EXIT_CODE.GENERAL_ERROR
5133
+ );
5134
+ }
5135
+ if (isJsonMode(options)) {
5136
+ printJson(result);
5137
+ return;
5138
+ }
5139
+
5140
+ if (isQuietMode(options)) {
5141
+ process.stdout.write(`startup ${result.enabled ? 'enabled' : 'disabled'} platform:${result.platform} supported:${result.supported ? 'yes' : 'no'}${result.servicePath ? ` path:${result.servicePath}` : ''}\n`);
5142
+ return;
5143
+ }
5144
+
5145
+ clackIntro('OpenChamber Startup');
5146
+ logStatus(result.enabled ? 'success' : 'info', `startup ${result.enabled ? 'enabled' : 'disabled'}`, result.servicePath || undefined);
5147
+ if (typeof result.activeState === 'string') {
5148
+ logStatus(result.active ? 'success' : result.activeState === 'failed' ? 'error' : 'warning', `service ${result.activeState}`);
5149
+ }
5150
+ if (normalized === 'enable') {
5151
+ logStatus('info', 'service command', 'openchamber serve --foreground');
5152
+ }
5153
+ clackOutro(normalized === 'status' ? 'status complete' : `${normalized} complete`);
5154
+ },
5155
+
4722
5156
  async update(options = {}) {
4723
5157
  const showOutput = shouldRenderHumanOutput(options);
4724
5158
  const updateSpin = createSpinner(options);
@@ -4843,7 +5277,7 @@ const commands = {
4843
5277
 
4844
5278
  async function main() {
4845
5279
  const parsed = parseArgs();
4846
- const { command, subcommand, tunnelAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
5280
+ const { command, subcommand, tunnelAction, startupAction, options, removedFlagErrors, helpRequested, versionRequested } = parsed;
4847
5281
  activeCommandOptions = options;
4848
5282
 
4849
5283
  if (versionRequested) {
@@ -4875,6 +5309,8 @@ async function main() {
4875
5309
  if (helpRequested) {
4876
5310
  if (command === 'tunnel') {
4877
5311
  showTunnelHelp();
5312
+ } else if (command === 'startup') {
5313
+ showStartupHelp();
4878
5314
  } else {
4879
5315
  showHelp();
4880
5316
  }
@@ -4886,8 +5322,13 @@ async function main() {
4886
5322
  return;
4887
5323
  }
4888
5324
 
5325
+ if (command === 'startup') {
5326
+ await commands.startup(options, startupAction);
5327
+ return;
5328
+ }
5329
+
4889
5330
  if (!commands[command]) {
4890
- const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'logs', 'update'];
5331
+ const knownCommands = ['serve', 'stop', 'restart', 'status', 'tunnel', 'startup', 'logs', 'update'];
4891
5332
  const suggestion = findClosestMatch(command, knownCommands);
4892
5333
  const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
4893
5334
  if (isJsonMode(options)) {