synfull 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#0b1220" />
7
+ <title>Synfull</title>
8
+ <script type="module" crossorigin src="/assets/index-ERXSRoTY.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B40UafPr.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "synfull",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Local-first cross-platform host monitoring dashboard",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "preferGlobal": true,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "bin": {
12
+ "synfull": "scripts/synfull.cjs"
13
+ },
14
+ "files": [
15
+ "backend/app/**/*.py",
16
+ "backend/requirements-runtime.txt",
17
+ "frontend/dist",
18
+ "scripts/synfull.cjs",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "os": [
26
+ "win32",
27
+ "linux"
28
+ ],
29
+ "scripts": {
30
+ "build": "npm --prefix frontend run build",
31
+ "test": "npm run test:backend && npm run test:frontend",
32
+ "test:backend": "python -m pytest backend/tests",
33
+ "test:frontend": "npm --prefix frontend run lint",
34
+ "prepack": "npm --prefix frontend ci && npm --prefix frontend run build"
35
+ },
36
+ "keywords": [
37
+ "monitoring",
38
+ "system-monitor",
39
+ "dashboard",
40
+ "linux",
41
+ "windows"
42
+ ]
43
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const crypto = require('node:crypto')
5
+ const fs = require('node:fs')
6
+ const os = require('node:os')
7
+ const path = require('node:path')
8
+ const { spawn, spawnSync } = require('node:child_process')
9
+ const ROOT = path.resolve(__dirname, '..')
10
+ const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
11
+ const WINDOWS = process.platform === 'win32'
12
+ function targetAccountHome() {
13
+ if (WINDOWS || !process.env.SUDO_USER) return os.homedir()
14
+ const account = spawnSync('getent', ['passwd', process.env.SUDO_USER], { encoding: 'utf8' })
15
+ return account.status === 0 && account.stdout.trim().split(':')[5] || os.homedir()
16
+ }
17
+ function effectiveHome() {
18
+ if (process.env.SYNFULL_HOME) return process.env.SYNFULL_HOME
19
+ const userHome = targetAccountHome()
20
+ return WINDOWS
21
+ ? path.join(process.env.LOCALAPPDATA || path.join(userHome, 'AppData', 'Local'), 'Synfull')
22
+ : path.join(process.env.XDG_DATA_HOME || path.join(userHome, '.local', 'share'), 'synfull')
23
+ }
24
+ const HOME = effectiveHome()
25
+ const APP_DIR = path.join(HOME, 'app')
26
+ const STATE_DIR = process.env.SYNFULL_STATE_DIR || (WINDOWS
27
+ ? path.join(process.env.LOCALAPPDATA || path.join(targetAccountHome(), 'AppData', 'Local'), 'Synfull')
28
+ : path.join(process.env.XDG_STATE_HOME || path.join(targetAccountHome(), '.local', 'state'), 'synfull'))
29
+ const RUNTIME = path.join(APP_DIR, '.venv', WINDOWS ? 'Scripts' : 'bin', WINDOWS ? 'python.exe' : 'python')
30
+ const PID_FILE = path.join(HOME, 'synfull.pid')
31
+ const LOG_FILE = path.join(HOME, 'synfull.log')
32
+ const CLI = path.resolve(__filename)
33
+
34
+ function run(command, args, options = {}) {
35
+ const result = spawnSync(command, args, { stdio: 'inherit', windowsHide: true, ...options })
36
+ if (result.error) throw result.error
37
+ if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? 'unknown'}`)
38
+ }
39
+ function pythonCommand() {
40
+ const candidates = WINDOWS ? [['py', ['-3']], ['python', []], ['python3', []]] : [['python3', []], ['python', []]]
41
+ for (const [command, prefix] of candidates) {
42
+ const probe = spawnSync(command, [...prefix, '-c', 'import sys; print("%d.%d" % sys.version_info[:2])'], { encoding: 'utf8', windowsHide: true })
43
+ if (probe.status === 0) {
44
+ const [major, minor] = probe.stdout.trim().split('.').map(Number)
45
+ if (major > 3 || (major === 3 && minor >= 10)) return { command, prefix }
46
+ }
47
+ }
48
+ throw new Error('Python 3.10+ was not found. Install Python and ensure it is on PATH, then run `synfull setup`.')
49
+ }
50
+ function ensureRuntime() {
51
+ if (!fs.existsSync(path.join(ROOT, 'backend', 'app')) || !fs.existsSync(path.join(ROOT, 'frontend', 'dist', 'index.html'))) {
52
+ throw new Error('This npm package is missing its backend or built dashboard assets.')
53
+ }
54
+ fs.mkdirSync(HOME, { recursive: true })
55
+ fs.mkdirSync(STATE_DIR, { recursive: true })
56
+ fs.mkdirSync(APP_DIR, { recursive: true })
57
+ for (const name of ['backend', 'frontend']) {
58
+ fs.cpSync(path.join(ROOT, name), path.join(APP_DIR, name), { recursive: true, force: true })
59
+ }
60
+ if (!fs.existsSync(RUNTIME)) {
61
+ const python = pythonCommand()
62
+ run(python.command, [...python.prefix, '-m', 'venv', path.join(APP_DIR, '.venv')])
63
+ }
64
+ const requirementsPath = path.join(APP_DIR, 'backend', 'requirements-runtime.txt')
65
+ const requirementsHash = crypto.createHash('sha256').update(fs.readFileSync(requirementsPath)).digest('hex')
66
+ const installedHashPath = path.join(APP_DIR, '.runtime-requirements-hash')
67
+ const installedHash = fs.existsSync(installedHashPath) ? fs.readFileSync(installedHashPath, 'utf8').trim() : ''
68
+ const dependenciesReady = spawnSync(RUNTIME, ['-c', 'import uvicorn, fastapi, pydantic_settings, sqlalchemy, psutil, docker'], { stdio: 'ignore', windowsHide: true }).status === 0
69
+ if (!dependenciesReady || installedHash !== requirementsHash) {
70
+ run(RUNTIME, ['-m', 'pip', 'install', '--disable-pip-version-check', '-r', requirementsPath])
71
+ fs.writeFileSync(installedHashPath, requirementsHash)
72
+ }
73
+ run(RUNTIME, ['-c', 'import uvicorn, fastapi, pydantic_settings, sqlalchemy, psutil, docker; import app.main'], {
74
+ cwd: APP_DIR,
75
+ env: { ...serverEnvironment(), PYTHONPATH: path.join(APP_DIR, 'backend') },
76
+ })
77
+ }
78
+ function serverEnvironment() {
79
+ return {
80
+ ...process.env,
81
+ SYNFULL_BIND_HOST: '127.0.0.1',
82
+ SYNFULL_BIND_PORT: process.env.SYNFULL_BIND_PORT || '8000',
83
+ SYNFULL_STATE_DIR: STATE_DIR,
84
+ SYNFULL_BOOTSTRAP_DEFAULT_ADMIN: '1',
85
+ PYTHONPATH: path.join(APP_DIR, 'backend'),
86
+ }
87
+ }
88
+ function startServer(foreground) {
89
+ ensureRuntime()
90
+ 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']
91
+ if (foreground) {
92
+ const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), stdio: 'inherit', windowsHide: true })
93
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
94
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill(signal))
95
+ return
96
+ }
97
+ if (fs.existsSync(PID_FILE)) {
98
+ const pid = Number(fs.readFileSync(PID_FILE, 'utf8'))
99
+ try { process.kill(pid, 0); console.log(`Synfull is already running (PID ${pid}).`); return } catch { fs.rmSync(PID_FILE, { force: true }) }
100
+ }
101
+ const output = fs.openSync(LOG_FILE, 'a')
102
+ const detached = !WINDOWS
103
+ const child = spawn(RUNTIME, args, { cwd: APP_DIR, env: serverEnvironment(), detached, stdio: ['ignore', output, output], windowsHide: true })
104
+ fs.closeSync(output)
105
+ child.once('error', error => {
106
+ fs.rmSync(PID_FILE, { force: true })
107
+ console.error(`Synfull failed to start: ${error.message}`)
108
+ process.exitCode = 1
109
+ })
110
+ child.once('spawn', () => {
111
+ fs.writeFileSync(PID_FILE, String(child.pid))
112
+ console.log(`Synfull started at http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'} (PID ${child.pid}).`)
113
+ })
114
+ child.unref()
115
+ console.log(`Logs: ${LOG_FILE}`)
116
+ console.log('First login: admin / admin; Synfull will require a new password before proceeding.')
117
+ }
118
+ function stopServer() {
119
+ if (!fs.existsSync(PID_FILE)) { console.log('Synfull is not running.'); return }
120
+ const pid = Number(fs.readFileSync(PID_FILE, 'utf8'))
121
+ if (WINDOWS) {
122
+ const result = spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
123
+ if (result.status !== 0) console.log('Synfull process was already stopped.')
124
+ } else {
125
+ try { process.kill(-pid, 'SIGTERM') } catch { try { process.kill(pid, 'SIGTERM') } catch { console.log('Synfull process was already stopped.') } }
126
+ }
127
+ fs.rmSync(PID_FILE, { force: true })
128
+ console.log('Synfull stopped.')
129
+ }
130
+ function status() {
131
+ let running = false
132
+ let pid = ''
133
+ if (fs.existsSync(PID_FILE)) {
134
+ pid = fs.readFileSync(PID_FILE, 'utf8').trim()
135
+ try { process.kill(Number(pid), 0); running = true } catch { fs.rmSync(PID_FILE, { force: true }) }
136
+ }
137
+ console.log(`Synfull ${VERSION}: ${running ? `running (PID ${pid})` : 'stopped'}`)
138
+ console.log(`Dashboard: http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`)
139
+ console.log(`Data: ${STATE_DIR}`)
140
+ }
141
+ function windowsTaskXml() {
142
+ const sid = spawnSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8', windowsHide: true }).stdout.match(/S-1-[0-9-]+/)
143
+ if (!sid) throw new Error('Could not determine the current Windows account SID.')
144
+ const exe = process.execPath.replace(/&/g, '&amp;').replace(/</g, '&lt;')
145
+ const cli = CLI.replace(/&/g, '&amp;').replace(/</g, '&lt;')
146
+ 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>`
147
+ }
148
+ function installStartup() {
149
+ if (!WINDOWS && process.getuid && process.getuid() !== 0) {
150
+ 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.')
151
+ }
152
+ if (!WINDOWS) {
153
+ const serviceUser = process.env.SUDO_USER || os.userInfo().username
154
+ if (serviceUser === 'root') throw new Error('Do not install Synfull as root. Re-run `sudo synfull install-service` from your normal account.')
155
+ }
156
+ ensureRuntime()
157
+ if (WINDOWS) {
158
+ const xmlPath = path.join(os.tmpdir(), `synfull-task-${process.pid}.xml`)
159
+ fs.writeFileSync(xmlPath, `\ufeff${windowsTaskXml()}`, 'utf16le')
160
+ try { run('schtasks.exe', ['/Create', '/TN', 'Synfull', '/XML', xmlPath, '/F']) } finally { fs.rmSync(xmlPath, { force: true }) }
161
+ console.log('Synfull will start at sign-in and restart after failures. It runs only on 127.0.0.1.')
162
+ } else {
163
+ const serviceUser = process.env.SUDO_USER || os.userInfo().username
164
+ fs.mkdirSync(STATE_DIR, { recursive: true })
165
+ run('chown', ['-R', serviceUser, HOME, STATE_DIR])
166
+ const escaped = value => value.replace(/\\/g, '\\\\').replace(/ /g, '\\x20').replace(/%/g, '%%')
167
+ 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`
168
+ const unitPath = '/etc/systemd/system/synfull-npm.service'
169
+ fs.writeFileSync(unitPath, unit, { mode: 0o644 })
170
+ try { run('systemctl', ['daemon-reload']); run('systemctl', ['enable', '--now', 'synfull-npm.service']) }
171
+ catch (error) { fs.rmSync(unitPath, { force: true }); throw error }
172
+ console.log('Synfull is installed as a systemd service and starts at boot. It listens only on 127.0.0.1.')
173
+ }
174
+ }
175
+ function uninstallStartup() {
176
+ if (WINDOWS) run('schtasks.exe', ['/Delete', '/TN', 'Synfull', '/F'])
177
+ else { run('systemctl', ['disable', '--now', 'synfull-npm.service']); fs.rmSync('/etc/systemd/system/synfull-npm.service', { force: true }); run('systemctl', ['daemon-reload']) }
178
+ console.log('Synfull startup registration removed; application data was retained.')
179
+ }
180
+ function help() {
181
+ 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.`)
182
+ }
183
+ function main() {
184
+ const command = process.argv[2]
185
+ if (command === '--version' || command === '-v') { console.log(VERSION); return }
186
+ if (command === '--help' || command === '-h') { help(); return }
187
+ if (!['win32', 'linux'].includes(process.platform)) throw new Error('Synfull npm CLI currently supports Windows and Linux.')
188
+ if (command === 'setup') { ensureRuntime(); console.log(`Runtime ready. Data directory: ${STATE_DIR}`) }
189
+ else if (command === 'start') startServer(false)
190
+ else if (command === 'run') startServer(true)
191
+ else if (command === 'stop') stopServer()
192
+ else if (command === 'status') status()
193
+ else if (command === 'open') {
194
+ const url = `http://127.0.0.1:${process.env.SYNFULL_BIND_PORT || '8000'}`
195
+ if (WINDOWS) spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
196
+ else spawn(process.platform === 'linux' ? 'xdg-open' : 'open', [url], { detached: true, stdio: 'ignore' }).unref()
197
+ }
198
+ else if (command === 'install-service') installStartup()
199
+ else if (command === 'uninstall-service') uninstallStartup()
200
+ else help()
201
+ }
202
+
203
+ if (require.main === module) {
204
+ try { main() } catch (error) { console.error(`Synfull: ${error.message}`); process.exitCode = 1 }
205
+ }
206
+ module.exports = { ensureRuntime, serverEnvironment, APP_DIR, STATE_DIR, ROOT }