synfull 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -13,11 +13,11 @@ synfull start
13
13
  synfull open
14
14
  ```
15
15
 
16
- The dashboard listens on **127.0.0.1:8000** only. The initial local login is username `admin`, password `admin`; Synfull will require you to set a new password of at least 12 characters before accessing the application. Do not expose the service publicly or through an untrusted proxy. npm installation alone does not register a background service or change system startup.
16
+ During interactive `synfull setup`, you can choose whether to bind to `0.0.0.0` for access from other devices on your LAN; localhost-only (`127.0.0.1`) remains the default, and non-interactive setup stays local-only. On Linux, setup also offers to add your account to the `docker` group so the Docker integration can access the daemon. This is optional and grants root-equivalent control of the computer; decline if you do not fully trust the account with administrator-level Docker access. If Synfull's systemd service is installed, setup restarts it after group membership changes; otherwise, an interactive user session may need a logout/login before new group membership applies. LAN binding exposes the login page and server to every reachable network interface: use a trusted LAN, configure the host firewall to allow only intended devices/subnets, change the initial `admin` / `admin` password locally before signing in remotely, and do not expose Synfull directly to the public internet. npm installation alone does not register a background service or change system startup.
17
17
 
18
18
  ### Commands
19
19
 
20
- - `synfull setup` prepares the isolated Python runtime and dependencies.
20
+ - `synfull setup` asks whether to allow LAN access and, on Linux, whether to grant the current account Docker-group access; it then prepares the isolated Python runtime and dependencies. Both choices are optional and are used by later starts/service registration. Docker group membership is root-equivalent. If an installed Synfull systemd service is present, setup restarts it after adding group access; otherwise log out and back in for a shell session to receive the new group membership.
21
21
  - `synfull start` runs in the background; `synfull run` keeps the server in the foreground.
22
22
  - `synfull status`, `synfull stop`, and `synfull open` manage a manually started instance.
23
23
  - `synfull install-service` explicitly enables automatic start and restart. On Windows this registers a per-user logon scheduled task. On Linux it creates and enables a system-wide systemd service and therefore requires `sudo`; the service runs as your invoking user, not root.
@@ -29,7 +29,7 @@ Configuration:
29
29
 
30
30
  - `SYNFULL_HOME` selects the CLI runtime/application directory.
31
31
  - `SYNFULL_STATE_DIR` selects persistent database and app state.
32
- - `SYNFULL_BIND_PORT` changes the localhost port (default `8000`). The listener is deliberately fixed to `127.0.0.1` in npm mode.
32
+ - `SYNFULL_BIND_PORT` changes the port (default `8000`). The bind host is selected during interactive setup and defaults to `127.0.0.1`; `SYNFULL_BIND_HOST=127.0.0.1` or `SYNFULL_BIND_HOST=0.0.0.0` can override it for a single run/service install.
33
33
 
34
34
  ## Development from source
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synfull",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local-first cross-platform host monitoring dashboard",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -5,6 +5,7 @@ const crypto = require('node:crypto')
5
5
  const fs = require('node:fs')
6
6
  const os = require('node:os')
7
7
  const path = require('node:path')
8
+ const readline = require('node:readline/promises')
8
9
  const { spawn, spawnSync } = require('node:child_process')
9
10
  const ROOT = path.resolve(__dirname, '..')
10
11
  const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
@@ -29,6 +30,7 @@ const STATE_DIR = process.env.SYNFULL_STATE_DIR || (WINDOWS
29
30
  const RUNTIME = path.join(APP_DIR, '.venv', WINDOWS ? 'Scripts' : 'bin', WINDOWS ? 'python.exe' : 'python')
30
31
  const PID_FILE = path.join(HOME, 'synfull.pid')
31
32
  const LOG_FILE = path.join(HOME, 'synfull.log')
33
+ const CLI_CONFIG = path.join(HOME, 'config.json')
32
34
  const CLI = path.resolve(__filename)
33
35
 
34
36
  function run(command, args, options = {}) {
@@ -47,6 +49,83 @@ function pythonCommand() {
47
49
  }
48
50
  throw new Error('Python 3.10+ was not found. Install Python and ensure it is on PATH, then run `synfull setup`.')
49
51
  }
52
+ function configuredBindHost() {
53
+ try {
54
+ const config = JSON.parse(fs.readFileSync(CLI_CONFIG, 'utf8'))
55
+ if (config.bindHost === '0.0.0.0') return '0.0.0.0'
56
+ } catch {}
57
+ return '127.0.0.1'
58
+ }
59
+ function selectedBindHost() {
60
+ const host = process.env.SYNFULL_BIND_HOST || configuredBindHost()
61
+ if (!['127.0.0.1', '0.0.0.0'].includes(host)) throw new Error('SYNFULL_BIND_HOST must be 127.0.0.1 or 0.0.0.0.')
62
+ return host
63
+ }
64
+ function saveBindHost(host) {
65
+ if (!['127.0.0.1', '0.0.0.0'].includes(host)) throw new Error('Bind host must be 127.0.0.1 or 0.0.0.0.')
66
+ fs.mkdirSync(HOME, { recursive: true })
67
+ fs.writeFileSync(CLI_CONFIG, `${JSON.stringify({ bindHost: host }, null, 2)}\n`, { mode: 0o600 })
68
+ }
69
+ async function configureBindHost(rl) {
70
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return
71
+ const answer = await rl.question('Allow other devices on your LAN to access Synfull by binding to 0.0.0.0? (y/N): ')
72
+ const host = /^y(es)?$/i.test(answer.trim()) ? '0.0.0.0' : '127.0.0.1'
73
+ saveBindHost(host)
74
+ console.log(host === '0.0.0.0'
75
+ ? 'LAN access enabled. Change the initial admin password from this machine before signing in from another device; use a trusted LAN and configure your firewall.'
76
+ : 'Synfull will only accept connections from this machine.')
77
+ }
78
+ async function configureDockerAccess(rl, options = {}) {
79
+ const platform = options.platform || process.platform
80
+ const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY)
81
+ const username = options.username || process.env.SUDO_USER || os.userInfo().username
82
+ const isRoot = options.isRoot ?? Boolean(process.getuid && process.getuid() === 0)
83
+ const spawnCommand = options.spawnSync || spawnSync
84
+ const runCommand = options.run || run
85
+ if (platform !== 'linux' || !interactive) return false
86
+ if (isRoot) {
87
+ console.log('Run `synfull setup` as your normal account (not sudo); setup will request sudo only if Docker group access is approved.')
88
+ return false
89
+ }
90
+ const dockerGroup = spawnCommand('getent', ['group', 'docker'], { encoding: 'utf8', windowsHide: true })
91
+ if (dockerGroup.status !== 0) {
92
+ console.log('No Linux docker group was found. Install/configure Docker Engine first; Synfull did not change account permissions.')
93
+ return false
94
+ }
95
+ console.log('Docker group membership grants root-equivalent control of this computer (including control over containers and host files).')
96
+ const answer = await rl.question(`Allow Synfull to use Docker by adding ${username} to the docker group? (y/N): `)
97
+ if (!/^y(es)?$/i.test(answer.trim())) {
98
+ console.log('Docker group access was not changed. The Docker integration will remain unavailable unless your account already has socket access.')
99
+ return false
100
+ }
101
+ const groups = spawnCommand('id', ['-nG', username], { encoding: 'utf8', windowsHide: true })
102
+ const alreadyMember = groups.status === 0 && groups.stdout.trim().split(/\s+/).includes('docker')
103
+ if (!alreadyMember) {
104
+ const command = process.getuid && process.getuid() === 0 ? 'usermod' : 'sudo'
105
+ const args = command === 'sudo' ? ['usermod', '-aG', 'docker', username] : ['-aG', 'docker', username]
106
+ runCommand(command, args)
107
+ console.log(`Added ${username} to the docker group.`)
108
+ } else {
109
+ console.log(`${username} is already in the docker group.`)
110
+ }
111
+ const serviceInstalled = spawnCommand('systemctl', ['cat', 'synfull-npm.service'], { stdio: 'ignore', windowsHide: true }).status === 0
112
+ if (!serviceInstalled) console.log('No Synfull systemd service is installed. Log out/in (or start a fresh session with Docker group membership) before running Synfull so it receives the new group permissions.')
113
+ return serviceInstalled
114
+ }
115
+ function restartSynfullService(runCommand = run) {
116
+ runCommand('sudo', ['systemctl', 'restart', 'synfull-npm.service'])
117
+ console.log('Restarted the Synfull system service so it picks up Docker group access.')
118
+ }
119
+ async function configureSetup() {
120
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false
121
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
122
+ try {
123
+ await configureBindHost(rl)
124
+ return await configureDockerAccess(rl)
125
+ } finally {
126
+ rl.close()
127
+ }
128
+ }
50
129
  function ensureRuntime() {
51
130
  if (!fs.existsSync(path.join(ROOT, 'backend', 'app')) || !fs.existsSync(path.join(ROOT, 'frontend', 'dist', 'index.html'))) {
52
131
  throw new Error('This npm package is missing its backend or built dashboard assets.')
@@ -64,9 +143,7 @@ function ensureRuntime() {
64
143
  const pipAvailable = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
65
144
  if (!pipAvailable) {
66
145
  const python = pythonCommand()
67
- const repair = spawnSync(python.command, [...python.prefix, '-m', 'venv', '--upgrade-deps', '--upgrade', path.join(APP_DIR, '.venv')], {
68
- encoding: 'utf8', windowsHide: true,
69
- })
146
+ const repair = spawnSync(python.command, [...python.prefix, '-m', 'venv', '--upgrade-deps', '--upgrade', path.join(APP_DIR, '.venv')], { encoding: 'utf8', windowsHide: true })
70
147
  if (repair.status !== 0) {
71
148
  const detail = (repair.stderr || repair.stdout || '').trim()
72
149
  if (detail) console.error(detail)
@@ -77,9 +154,7 @@ function ensureRuntime() {
77
154
  }
78
155
  }
79
156
  const pipReady = spawnSync(RUNTIME, ['-m', 'pip', '--version'], { stdio: 'ignore', windowsHide: true }).status === 0
80
- if (!pipReady) {
81
- throw new Error('The Python virtual environment still has no pip. Install the Python venv support package and recreate the runtime, then rerun `synfull setup`.')
82
- }
157
+ if (!pipReady) throw new Error('The Python virtual environment still has no pip. Install the Python venv support package and recreate the runtime, then rerun `synfull setup`.')
83
158
  const requirementsPath = path.join(APP_DIR, 'backend', 'requirements-runtime.txt')
84
159
  const requirementsHash = crypto.createHash('sha256').update(fs.readFileSync(requirementsPath)).digest('hex')
85
160
  const installedHashPath = path.join(APP_DIR, '.runtime-requirements-hash')
@@ -97,7 +172,7 @@ function ensureRuntime() {
97
172
  function serverEnvironment() {
98
173
  return {
99
174
  ...process.env,
100
- SYNFULL_BIND_HOST: '127.0.0.1',
175
+ SYNFULL_BIND_HOST: selectedBindHost(),
101
176
  SYNFULL_BIND_PORT: process.env.SYNFULL_BIND_PORT || '8000',
102
177
  SYNFULL_STATE_DIR: STATE_DIR,
103
178
  SYNFULL_BOOTSTRAP_DEFAULT_ADMIN: '1',
@@ -106,9 +181,12 @@ function serverEnvironment() {
106
181
  }
107
182
  function startServer(foreground) {
108
183
  ensureRuntime()
109
- const args = ['-m', 'uvicorn', 'app.main:app', '--app-dir', path.join(APP_DIR, 'backend'), '--host', '127.0.0.1', '--port', process.env.SYNFULL_BIND_PORT || '8000']
184
+ const env = serverEnvironment()
185
+ const bindHost = env.SYNFULL_BIND_HOST
186
+ const port = process.env.SYNFULL_BIND_PORT || '8000'
187
+ const args = ['-m', 'uvicorn', 'app.main:app', '--app-dir', path.join(APP_DIR, 'backend'), '--host', bindHost, '--port', port]
110
188
  if (foreground) {
111
- const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), stdio: 'inherit', windowsHide: true })
189
+ const child = spawn(RUNTIME, args, { cwd: APP_DIR, env, stdio: 'inherit', windowsHide: true })
112
190
  child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
113
191
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
114
192
  return
@@ -118,8 +196,7 @@ function startServer(foreground) {
118
196
  try { process.kill(pid, 0); console.log(`Synfull is already running (PID ${pid}).`); return } catch { fs.rmSync(PID_FILE, { force: true }) }
119
197
  }
120
198
  const output = fs.openSync(LOG_FILE, 'a')
121
- const detached = !WINDOWS
122
- const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), detached, stdio: ['ignore', output, output], windowsHide: true })
199
+ const child = spawn(RUNTIME, args, { cwd: APP_DIR, env, detached: !WINDOWS, stdio: ['ignore', output, output], windowsHide: true })
123
200
  fs.closeSync(output)
124
201
  child.once('error', error => {
125
202
  fs.rmSync(PID_FILE, { force: true })
@@ -128,7 +205,8 @@ function startServer(foreground) {
128
205
  })
129
206
  child.once('spawn', () => {
130
207
  fs.writeFileSync(PID_FILE, String(child.pid))
131
- console.log(`Synfull started at http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'} (PID ${child.pid}).`)
208
+ console.log(`Synfull started on ${bindHost}:${port} (PID ${child.pid}).`)
209
+ if (bindHost === '0.0.0.0') console.log(`LAN clients can use http://<server-lan-ip>:${port}; change the initial admin password locally first.`)
132
210
  })
133
211
  child.unref()
134
212
  console.log(`Logs: ${LOG_FILE}`)
@@ -153,43 +231,44 @@ function status() {
153
231
  pid = fs.readFileSync(PID_FILE, 'utf8').trim()
154
232
  try { process.kill(Number(pid), 0); running = true } catch { fs.rmSync(PID_FILE, { force: true }) }
155
233
  }
234
+ const bindHost = selectedBindHost()
156
235
  console.log(`Synfull ${VERSION}: ${running ? `running (PID ${pid})` : 'stopped'}`)
157
- console.log(`Dashboard: http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`)
236
+ console.log(`Dashboard: http://${bindHost === '0.0.0.0' ? '<server-lan-ip>' : '127.0.0.1'}:${process.env.SYNFULL_BIND_PORT || '8000'}`)
158
237
  console.log(`Data: ${STATE_DIR}`)
159
238
  }
160
239
  function windowsTaskXml() {
161
- const sid = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true }).stdout.match(/S-1-[0-9-]+/)
240
+ const identity = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true })
241
+ const sid = (identity.stdout || '').match(/S-1-[0-9-]+/)
162
242
  if (!sid) throw new Error('Could not determine the current Windows account SID.')
163
243
  const exe = process.execPath.replace(/&/g, '&amp;').replace(/</g, '&lt;')
164
244
  const cli = CLI.replace(/&/g, '&amp;').replace(/</g, '&lt;')
165
245
  return `<?xml version="1.0" encoding="UTF-16"?>\n<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${sid[0]}</UserId></LogonTrigger></Triggers><Principals><Principal id="Author"><UserId>${sid[0]}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals><Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Enabled>true</Enabled></Settings><Actions Context="Author"><Exec><Command>${exe}</Command><Arguments>"${cli}" run</Arguments><WorkingDirectory>${APP_DIR.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</WorkingDirectory></Exec></Actions></Task>`
166
246
  }
167
247
  function installStartup() {
168
- if (!WINDOWS && process.getuid && process.getuid() !== 0) {
169
- throw new Error('Installing the system-wide boot service requires administrator permission. Re-run with sudo; the service will still run as your original user.')
170
- }
171
- if (!WINDOWS) {
172
- const serviceUser = process.env.SUDO_USER || os.userInfo().username
173
- if (serviceUser === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
174
- }
248
+ if (!WINDOWS && process.getuid && process.getuid() !== 0) throw new Error('Installing the system-wide boot service requires administrator permission. Re-run with sudo; the service will still run as your original user.')
249
+ if (!WINDOWS && (process.env.SUDO_USER || os.userInfo().username) === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
250
+ saveBindHost(selectedBindHost())
175
251
  ensureRuntime()
252
+ const bindHost = selectedBindHost()
253
+ const port = process.env.SYNFULL_BIND_PORT || '8000'
176
254
  if (WINDOWS) {
177
255
  const xmlPath = path.join(os.tmpdir(), `synfull-task-${process.pid}.xml`)
178
256
  fs.writeFileSync(xmlPath, `\ufeff${windowsTaskXml()}`, 'utf16le')
179
257
  try { run('schtasks.exe', ['/Create', '/TN', 'Synfull', '/XML', xmlPath, '/F']) } finally { fs.rmSync(xmlPath, { force: true }) }
180
- console.log('Synfull will start at sign-in and restart after failures. It runs only on 127.0.0.1.')
258
+ console.log(`Synfull will start at sign-in and restart after failures. Bind address: ${bindHost}.`)
181
259
  } else {
182
260
  const serviceUser = process.env.SUDO_USER || os.userInfo().username
183
261
  fs.mkdirSync(STATE_DIR, { recursive: true })
184
262
  run('chown', ['-R', serviceUser, HOME, STATE_DIR])
185
263
  const escaped = value => value.replace(/\\/g, '\\\\').replace(/ /g, '\\x20').replace(/%/g, '%%')
186
- const unit = `[Unit]\nDescription=Synfull local host monitoring\nAfter=network.target\n\n[Service]\nType=simple\nUser=${serviceUser}\nWorkingDirectory=${escaped(APP_DIR)}\nEnvironment=SYNFULL_BIND_HOST=127.0.0.1\nEnvironment=SYNFULL_BIND_PORT=${process.env.SYNFULL_BIND_PORT || '8000'}\nEnvironment=SYNFULL_STATE_DIR=${escaped(STATE_DIR)}\nEnvironment=SYNFULL_BOOTSTRAP_DEFAULT_ADMIN=1\nEnvironment=PYTHONPATH=${escaped(path.join(APP_DIR, 'backend'))}\nExecStart=${escaped(RUNTIME)} -m uvicorn app.main:app --app-dir ${escaped(path.join(APP_DIR, 'backend'))} --host 127.0.0.1 --port ${process.env.SYNFULL_BIND_PORT || '8000'}\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n`
264
+ const unit = `[Unit]\nDescription=Synfull host monitoring\nAfter=network.target\n\n[Service]\nType=simple\nUser=${serviceUser}\nWorkingDirectory=${escaped(APP_DIR)}\nEnvironment=SYNFULL_BIND_HOST=${bindHost}\nEnvironment=SYNFULL_BIND_PORT=${port}\nEnvironment=SYNFULL_STATE_DIR=${escaped(STATE_DIR)}\nEnvironment=SYNFULL_BOOTSTRAP_DEFAULT_ADMIN=1\nEnvironment=PYTHONPATH=${escaped(path.join(APP_DIR, 'backend'))}\nExecStart=${escaped(RUNTIME)} -m uvicorn app.main:app --app-dir ${escaped(path.join(APP_DIR, 'backend'))} --host ${bindHost} --port ${port}\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n`
187
265
  const unitPath = '/etc/systemd/system/synfull-npm.service'
188
266
  fs.writeFileSync(unitPath, unit, { mode: 0o644 })
189
267
  try { run('systemctl', ['daemon-reload']); run('systemctl', ['enable', '--now', 'synfull-npm.service']) }
190
268
  catch (error) { fs.rmSync(unitPath, { force: true }); throw error }
191
- console.log('Synfull is installed as a systemd service and starts at boot. It listens only on 127.0.0.1.')
269
+ console.log(`Synfull is installed as a systemd service and starts at boot. Bind address: ${bindHost}.`)
192
270
  }
271
+ if (bindHost === '0.0.0.0') console.log('Allow access only from trusted LAN devices, configure your firewall, and change the initial admin password locally before remote sign-in.')
193
272
  }
194
273
  function uninstallStartup() {
195
274
  if (WINDOWS) run('schtasks.exe', ['/Delete', '/TN', 'Synfull', '/F'])
@@ -197,29 +276,32 @@ function uninstallStartup() {
197
276
  console.log('Synfull startup registration removed; application data was retained.')
198
277
  }
199
278
  function help() {
200
- console.log(`Synfull ${VERSION}\n\nUsage: synfull <command>\n\nCommands:\n setup Prepare local runtime and install Python dependencies\n start Start Synfull in the background\n run Run Synfull in the foreground (for service managers)\n stop Stop a background Synfull process\n status Show status and local URLs\n open Open the local dashboard\n install-service Register start-at-login (Windows) or start-at-boot (Linux)\n uninstall-service Remove startup registration without deleting data\n\nSynfull binds to localhost only by default. Override data/port with SYNFULL_STATE_DIR, SYNFULL_HOME, and SYNFULL_BIND_PORT.`)
279
+ console.log(`Synfull ${VERSION}\n\nUsage: synfull <command>\n\nCommands:\n setup Prepare local runtime and install Python dependencies\n start Start Synfull in the background\n run Run Synfull in the foreground (for service managers)\n stop Stop a background Synfull process\n status Show status and local URLs\n open Open the local dashboard\n install-service Register start-at-login (Windows) or start-at-boot (Linux)\n uninstall-service Remove startup registration without deleting data\n\nSetup asks whether to allow LAN connections; localhost-only is the default. Use a trusted LAN, open the port in your firewall only as needed, and change the initial admin password locally before remote sign-in.`)
201
280
  }
202
- function main() {
281
+ async function main() {
203
282
  const command = process.argv[2]
204
283
  if (command === '--version' || command === '-v') { console.log(VERSION); return }
205
284
  if (command === '--help' || command === '-h') { help(); return }
206
285
  if (!['win32', 'linux'].includes(process.platform)) throw new Error('Synfull npm CLI currently supports Windows and Linux.')
207
- if (command === 'setup') { ensureRuntime(); console.log(`Runtime ready. Data directory: ${STATE_DIR}`) }
286
+ if (command === 'setup') {
287
+ const restartServiceForDocker = await configureSetup()
288
+ ensureRuntime()
289
+ if (restartServiceForDocker) restartSynfullService()
290
+ console.log(`Runtime ready. Data directory: ${STATE_DIR}`)
291
+ }
208
292
  else if (command === 'start') startServer(false)
209
293
  else if (command === 'run') startServer(true)
210
294
  else if (command === 'stop') stopServer()
211
295
  else if (command === 'status') status()
212
296
  else if (command === 'open') {
213
- const url = `http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`
297
+ const host = selectedBindHost()
298
+ const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${process.env.SYNFULL_BIND_PORT || '8000'}`
214
299
  if (WINDOWS) spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
215
- else spawn(process.platform === 'linux' ? 'xdg-open' : 'open', [url], { detached: true, stdio: 'ignore' }).unref()
300
+ else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref()
216
301
  }
217
302
  else if (command === 'install-service') installStartup()
218
303
  else if (command === 'uninstall-service') uninstallStartup()
219
304
  else help()
220
305
  }
221
-
222
- if (require.main === module) {
223
- try { main() } catch (error) { console.error(`Synfull: ${error.message}`); process.exitCode = 1 }
224
- }
225
- module.exports = { ensureRuntime, serverEnvironment, APP_DIR, STATE_DIR, ROOT }
306
+ if (require.main === module) main().catch(error => { console.error(`Synfull: ${error.message}`); process.exitCode = 1 })
307
+ module.exports = { ensureRuntime, serverEnvironment, configuredBindHost, selectedBindHost, saveBindHost, configureDockerAccess, restartSynfullService, APP_DIR, STATE_DIR, ROOT }