added base st-guardian package

This commit is contained in:
John Overton
2026-03-12 20:03:33 -05:00
parent d678348773
commit af8ae77455
9 changed files with 2840 additions and 0 deletions
+3
View File
@@ -1,5 +1,6 @@
# dependencies
/node_modules
/st-guardian/node_modules
/.pnp
.pnp.js
@@ -34,6 +35,8 @@ next-env.d.ts
*.db
/db
*.db-shm
*.db-wal
# custom port configuration
scripts/port-config.sh
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from 'next/server';
import { withSysAdminAuth } from '../../utils/auth';
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
const getGuardianUrl = () => {
const port = process.env.ST_GUARDIAN_PORT || '3001';
return `http://127.0.0.1:${port}`;
};
const getGuardianKey = () => {
return process.env.ST_GUARDIAN_KEY || '';
};
async function getHandler(req: NextRequest): Promise<NextResponse<ApiResponse<any>>> {
const key = getGuardianKey();
if (!key) {
return NextResponse.json(
{ success: false, error: 'ST_GUARDIAN_KEY not configured in environment' },
{ status: 500 }
);
}
const url = new URL(req.url);
const endpoint = url.searchParams.get('endpoint') || 'status';
const guardianUrl = getGuardianUrl();
let targetPath: string;
switch (endpoint) {
case 'status':
targetPath = '/status';
break;
case 'update-status':
targetPath = '/update/status';
break;
case 'version':
targetPath = '/version';
break;
default:
targetPath = '/status';
}
try {
const response = await fetch(`${guardianUrl}${targetPath}`, {
headers: { 'X-Guardian-Key': key },
signal: AbortSignal.timeout(5000),
});
const data = await response.json();
return NextResponse.json({ success: true, data });
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Unable to connect to ST-Guardian service' },
{ status: 503 }
);
}
}
async function postHandler(req: NextRequest): Promise<NextResponse<ApiResponse<any>>> {
const key = getGuardianKey();
if (!key) {
return NextResponse.json(
{ success: false, error: 'ST_GUARDIAN_KEY not configured in environment' },
{ status: 500 }
);
}
const guardianUrl = getGuardianUrl();
try {
const response = await fetch(`${guardianUrl}/update`, {
method: 'POST',
headers: {
'X-Guardian-Key': key,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(10000),
});
const data = await response.json();
if (response.status === 409) {
return NextResponse.json(
{ success: false, error: 'An update is already in progress' },
{ status: 409 }
);
}
if (!response.ok) {
return NextResponse.json(
{ success: false, error: data.error || 'Update request failed' },
{ status: response.status }
);
}
return NextResponse.json({ success: true, data });
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Unable to connect to ST-Guardian service' },
{ status: 503 }
);
}
}
export const GET = withSysAdminAuth(getHandler);
export const POST = withSysAdminAuth(postHandler);
+149
View File
@@ -0,0 +1,149 @@
# ST-Guardian: Sprout Track Service Manager
Build a lightweight Node.js sidecar service called `st-guardian` for the Sprout Track application (Next.js). It acts as a reverse proxy, health monitor, maintenance page server, and update orchestrator.
## Architecture
- st-guardian runs on port 3001 (configurable via `ST_GUARDIAN_PORT`)
- Next.js (Sprout Track) runs on its default port 3000 (configurable via `ST_APP_PORT`)
- st-guardian sits in front of the app and proxies all non-management requests to Next.js
- In maintenance mode, st-guardian stops proxying and serves a static maintenance page instead
- Docker-aware: detects `/.dockerenv` and disables update/restart functionality, acts as passthrough proxy only
## Dependencies
- Keep dependencies minimal. Use `http-proxy` for proxying. No frameworks. Single entry point file if possible.
## Management Routes
These routes are intercepted by st-guardian before reaching the proxy. All management routes (except `/status` and `/health`) require authentication via `ST_GUARDIAN_KEY` env var, passed as `?key=` query param or `X-Guardian-Key` header.
### `GET /status`
- Public endpoint
- Returns JSON: app status (up/down/maintenance), current version (read from package.json), uptime, last update timestamp, guardian version
### `GET /health`
- Public endpoint
- Returns 200 if app is up and proxying, 503 if in maintenance mode or app is down
- Designed for external uptime monitors
### `POST /maintenance`
- Authenticated
- Enables maintenance mode: stops proxying, serves maintenance page
- Optional JSON body: `{ "message": "Custom maintenance message" }`
### `DELETE /maintenance`
- Authenticated
- Disables maintenance mode: resumes proxying to Next.js
### `POST /update`
- Authenticated
- Triggers the full update cycle by calling the existing deployment script at `scripts/deployment.sh`
- The deployment script already handles:
1. Creating a backup (`scripts/backup.sh`)
2. Stopping the service (`scripts/service.sh stop`)
3. Deleting the `.next` folder
4. Updating environment configuration (`scripts/env-update.sh`)
5. Updating the application (`scripts/update.sh`)
6. Rollback behavior: if any step fails, it attempts to restart the service before aborting
- Guardian's responsibilities around the deployment script:
1. Enable maintenance mode before calling the script
2. Shell out to `scripts/deployment.sh` and capture stdout/stderr in real time
3. When the script completes, health check the app (poll until responsive or timeout)
4. If healthy, disable maintenance mode
5. If unhealthy or script exited non-zero, stay in maintenance mode and log the error
- Optional JSON body: `{ "ref": "v2.1.0" }` to checkout a specific ref before running deployment
- Returns JSON with job status. Should support polling via `GET /update/status`
### `GET /update/status`
- Authenticated
- Returns current/last update job progress: step, status, logs, started_at, completed_at
### `GET /logs`
- Authenticated
- Returns recent stdout/stderr from the Next.js process
- Query param `?lines=100` to control how many lines (default 100)
## Maintenance Page
- Static HTML embedded in the script, no external dependencies
- Sprout Track branded: use the teal/green color scheme
- Shows a spinner or animation and message: "Sprout Track is under maintenance and will be back shortly."
- Support custom messages passed via the `POST /maintenance` body
- Auto-refreshes every 15 seconds, redirects back to the app when maintenance mode ends (poll `/health`)
- Must be lightweight and work without any assets from the Next.js app
## Health Monitoring
- Periodically ping Next.js (configurable interval via `ST_HEALTH_INTERVAL`, default 30 seconds)
- If Next.js is unresponsive for 3 consecutive checks, attempt automatic restart
- Log health events
- If auto-restart fails, flip to maintenance mode and log an error
## Process Management
- st-guardian uses the existing `scripts/service.sh` for process management (start/stop/status)
- Captures stdout/stderr from Next.js for the `/logs` endpoint
- Handles graceful shutdown: SIGTERM/SIGINT should call `scripts/service.sh stop` then exit guardian
- On Next.js crash, attempt restart via `scripts/service.sh start` up to 3 times within 5 minutes before giving up and entering maintenance mode
## Docker Awareness
- On startup, check for `/.dockerenv` file
- If detected, disable: `/update`, `/logs`, process management, auto-restart
- In Docker mode, guardian is proxy + maintenance page only
- Log a message on startup indicating which mode it's running in
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `ST_GUARDIAN_PORT` | 3001 | Port guardian listens on |
| `ST_APP_PORT` | 3000 | Port Next.js runs on |
| `ST_GUARDIAN_KEY` | (required for mgmt routes) | Auth key for management endpoints |
| `ST_HEALTH_INTERVAL` | 30000 | Health check interval in ms |
| `ST_SCRIPTS_DIR` | `./scripts` | Path to Sprout Track scripts directory (deployment.sh, service.sh, backup.sh, etc.) |
| `ST_APP_DIR` | `.` | Working directory for the Next.js app |
| `ST_LOG_BUFFER` | 500 | Number of log lines to retain in memory |
## Logging
- Log to stdout with timestamps
- Prefix logs with `[guardian]` to distinguish from Next.js output
- Log: startup mode (docker/standalone), proxy events, health check failures, maintenance mode changes, update steps
## File Structure
Keep it simple. Suggested:
```
st-guardian/
index.js # Main entry point
maintenance.html # Maintenance page template (or embedded string)
st-guardian-setup.sh # Service installer script
package.json
README.md
```
## Setup Script (`st-guardian-setup.sh`)
Create an interactive setup script that installs st-guardian as a systemd service. The script should:
1. Check that it's running as root/sudo
2. Prompt for configuration:
- `ST_GUARDIAN_KEY` (generate a random one if user hits enter)
- `ST_APP_DIR` (path to the Sprout Track installation, validate it exists)
- `ST_GUARDIAN_PORT` (default 3001)
- `ST_APP_PORT` (default 3000)
3. Detect Docker environment and warn if detected (guardian service doesn't make sense in Docker)
4. Install npm dependencies (`npm install --production`)
5. Create a systemd unit file at `/etc/systemd/system/st-guardian.service`:
- Set `WorkingDirectory` to the guardian directory
- Set all `ST_*` environment variables
- Set `Restart=always` with `RestartSec=5`
- Run as the current non-root user (not root)
- Set `After=network.target`
6. Reload systemd, enable the service, and start it
7. Print a summary: service status, management URL, the guardian key, and a reminder to update Nginx to point at the guardian port
The script should also support an `--uninstall` flag that stops the service, disables it, removes the unit file, and reloads systemd.
+293
View File
@@ -0,0 +1,293 @@
'use client';
import { useState } from 'react';
import { Button } from '@/src/components/ui/button';
import { Label } from '@/src/components/ui/label';
import { RefreshCw, Loader2, CheckCircle, XCircle, AlertTriangle, ArrowUpCircle } from 'lucide-react';
import { useLocalization } from '@/src/context/localization';
interface GuardianUpdateProps {
isLoading: boolean;
isSaving: boolean;
onError: (error: string) => void;
}
interface VersionInfo {
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean | null;
repository: string;
}
export function GuardianUpdate({ isLoading, isSaving, onError }: GuardianUpdateProps) {
const { t } = useLocalization();
const [checking, setChecking] = useState(false);
const [updating, setUpdating] = useState(false);
const [guardianReachable, setGuardianReachable] = useState<boolean | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [dockerMode, setDockerMode] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const getAuthHeaders = (): HeadersInit => {
const authToken = localStorage.getItem('authToken');
return authToken ? { 'Authorization': `Bearer ${authToken}` } : {};
};
const checkForUpdates = async () => {
try {
setChecking(true);
setShowConfirm(false);
// Check guardian status and version in parallel
const [statusRes, versionRes] = await Promise.all([
fetch('/api/guardian/update?endpoint=status', { headers: getAuthHeaders() }),
fetch('/api/guardian/update?endpoint=version', { headers: getAuthHeaders() }),
]);
const statusResult = await statusRes.json();
const versionResult = await versionRes.json();
if (!statusResult.success) {
setGuardianReachable(false);
if (statusResult.error) onError(statusResult.error);
return;
}
setGuardianReachable(true);
setDockerMode(statusResult.data?.dockerMode || false);
if (versionResult.success && versionResult.data) {
setVersionInfo(versionResult.data);
} else {
// Guardian reachable but version check failed (maybe GitHub rate limited)
setVersionInfo({
currentVersion: statusResult.data?.version || 'unknown',
latestVersion: null,
updateAvailable: null,
repository: 'https://github.com/Oak-and-Sprout/sprout-track',
});
}
} catch {
setGuardianReachable(false);
} finally {
setChecking(false);
}
};
const triggerUpdate = async () => {
try {
setUpdating(true);
setShowConfirm(false);
const response = await fetch('/api/guardian/update', {
method: 'POST',
headers: getAuthHeaders(),
});
const result = await response.json();
if (result.success) {
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
onError(result.error || t('Failed to trigger update'));
setUpdating(false);
}
} catch {
onError(t('Failed to connect to update service'));
setUpdating(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<RefreshCw className="h-5 w-5 text-teal-600" />
<Label className="text-lg font-semibold">
{t('System Updates')}
</Label>
</div>
<div className="space-y-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
{/* Initial state — not checked yet */}
{guardianReachable === null ? (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t('Check for available updates from GitHub')}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={checkForUpdates}
disabled={checking || isLoading || isSaving}
>
{checking ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('Checking...')}
</>
) : (
t('Check for Updates')
)}
</Button>
</div>
) : guardianReachable ? (
<>
{/* Version info */}
{versionInfo && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-gray-500 dark:text-gray-400">{t('Installed Version')}</div>
<div className="font-medium">v{versionInfo.currentVersion}</div>
<div className="text-gray-500 dark:text-gray-400">{t('Latest Version')}</div>
<div className="font-medium">
{versionInfo.latestVersion
? `v${versionInfo.latestVersion}`
: t('Unable to check')}
</div>
</div>
{/* Update available banner */}
{versionInfo.updateAvailable === true && (
<div className="flex items-center space-x-2 p-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800 rounded-lg">
<ArrowUpCircle className="h-5 w-5 text-teal-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-teal-800 dark:text-teal-300">
{t('Update available!')}
</p>
<p className="text-xs text-teal-700 dark:text-teal-400">
v{versionInfo.currentVersion} v{versionInfo.latestVersion}
</p>
</div>
</div>
)}
{/* Already up to date */}
{versionInfo.updateAvailable === false && (
<div className="flex items-center space-x-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0" />
<p className="text-sm font-medium text-green-800 dark:text-green-300">
{t('You are running the latest version')}
</p>
</div>
)}
{/* Could not check GitHub */}
{versionInfo.updateAvailable === null && versionInfo.latestVersion === null && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('Could not reach GitHub to check for updates. You can still trigger a manual update.')}
</p>
)}
</div>
)}
{/* Docker mode warning */}
{dockerMode ? (
<p className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-md">
{t('Updates are managed by Docker and cannot be triggered from the admin panel.')}
</p>
) : !showConfirm ? (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="outline"
className={`flex-1 ${
versionInfo?.updateAvailable
? 'border-teal-300 text-teal-700 hover:bg-teal-50 dark:border-teal-700 dark:text-teal-400 dark:hover:bg-teal-900/20'
: 'border-orange-300 text-orange-700 hover:bg-orange-50 dark:border-orange-700 dark:text-orange-400 dark:hover:bg-orange-900/20'
}`}
onClick={() => setShowConfirm(true)}
disabled={updating || isLoading || isSaving}
>
<RefreshCw className="h-4 w-4 mr-2" />
{versionInfo?.updateAvailable ? t('Update Now') : t('Force Update')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={checkForUpdates}
disabled={checking}
>
<RefreshCw className={`h-3 w-3 ${checking ? 'animate-spin' : ''}`} />
</Button>
</div>
) : (
/* Confirmation dialog */
<div className="space-y-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<div className="flex items-start space-x-2">
<AlertTriangle className="h-5 w-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-orange-800 dark:text-orange-300">
{t('Are you sure?')}
</p>
<p className="text-xs text-orange-700 dark:text-orange-400">
{t('This will restart the application. All users will be temporarily disconnected while the update is applied.')}
</p>
</div>
</div>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => setShowConfirm(false)}
disabled={updating}
>
{t('Cancel')}
</Button>
<Button
type="button"
size="sm"
className="flex-1 bg-orange-600 text-white hover:bg-orange-700"
onClick={triggerUpdate}
disabled={updating}
>
{updating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('Updating...')}
</>
) : (
t('Confirm Update')
)}
</Button>
</div>
</div>
)}
{updating && (
<p className="text-sm text-teal-600 dark:text-teal-400 text-center animate-pulse">
{t('Update triggered. Redirecting to maintenance page...')}
</p>
)}
</>
) : (
/* Guardian not reachable */
<div className="space-y-3">
<div className="flex items-center space-x-2">
<XCircle className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-500 dark:text-gray-400">
{t('Update service not available')}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('ST-Guardian is not running or not configured. Updates must be performed manually via the command line.')}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={checkForUpdates}
disabled={checking}
>
{t('Retry')}
</Button>
</div>
)}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
# ST-Guardian
Lightweight Node.js sidecar service for Sprout Track. Acts as a reverse proxy, health monitor, maintenance page server, and update orchestrator.
## Quick Start
### Systemd Installation (recommended for production)
```bash
sudo bash st-guardian-setup.sh
```
The setup script will prompt for configuration and install st-guardian as a systemd service.
### Manual / Development
```bash
cd st-guardian
npm install
ST_GUARDIAN_KEY=your-secret-key ST_APP_DIR=/path/to/sprout-track node index.js
```
## How It Works
- Listens on port 3001 (configurable) and proxies all requests to the Next.js app on port 3000
- Management routes (`/status`, `/health`, `/maintenance`, `/update`, `/logs`) are intercepted before reaching the proxy
- In maintenance mode, a branded status page is served instead of proxying
- Health checks ping the app periodically; if it goes down, guardian attempts automatic restart
- Docker-aware: when running in Docker, update/restart/log features are disabled (proxy + maintenance only)
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `ST_GUARDIAN_PORT` | `3001` | Port guardian listens on |
| `ST_APP_PORT` | `3000` | Port the Next.js app runs on |
| `ST_GUARDIAN_KEY` | *(required)* | API key for authenticated management endpoints |
| `ST_HEALTH_INTERVAL` | `30000` | Health check interval in milliseconds |
| `ST_SCRIPTS_DIR` | `./scripts` | Path to Sprout Track scripts directory |
| `ST_APP_DIR` | `.` | Path to the Sprout Track installation |
| `ST_LOG_BUFFER` | `500` | Max log lines retained for `/logs` and update output |
## API Reference
### Public Endpoints
**GET /status**
```bash
curl http://localhost:3001/status
```
Returns: `{ "status": "up|down|maintenance", "version": "...", "guardianVersion": "1.0.0", "uptime": 3600, "dockerMode": false }`
**GET /health**
```bash
curl http://localhost:3001/health
```
Returns 200 `{"status":"healthy"}` or 503 `{"status":"unhealthy"}`
### Authenticated Endpoints
Pass the key as a query parameter or header:
```bash
# Query parameter
curl -X POST "http://localhost:3001/maintenance?key=YOUR_KEY"
# Header
curl -X POST http://localhost:3001/maintenance -H "X-Guardian-Key: YOUR_KEY"
```
**POST /maintenance** — Enable maintenance mode
```bash
curl -X POST "http://localhost:3001/maintenance?key=KEY" \
-H "Content-Type: application/json" \
-d '{"message": "Updating to v2.0..."}'
```
**DELETE /maintenance** — Disable maintenance mode
```bash
curl -X DELETE "http://localhost:3001/maintenance?key=KEY"
```
**POST /update** — Trigger deployment
```bash
curl -X POST "http://localhost:3001/update?key=KEY"
```
Returns 202 with `{"pollUrl": "/update/status"}`. Automatically enters maintenance mode, runs `deployment.sh`, and exits maintenance when the app is healthy.
**GET /update/status** — Check update progress
```bash
curl "http://localhost:3001/update/status?key=KEY"
```
**GET /logs** — Read application logs
```bash
curl "http://localhost:3001/logs?key=KEY&lines=50"
```
## Docker Mode
When `/.dockerenv` is detected, the following endpoints return 403:
- `POST /update`
- `GET /logs`
Auto-restart and process management are also disabled. Guardian operates as a proxy and maintenance page server only.
## Uninstall
```bash
sudo bash st-guardian-setup.sh --uninstall
```
This stops the service, removes the systemd unit, and cleans up the sudoers configuration.
## Troubleshooting
- **"ST_GUARDIAN_KEY not configured"** — Set the `ST_GUARDIAN_KEY` environment variable
- **"SERVICE_NAME not found"** — Ensure the Sprout Track `.env` file has `SERVICE_NAME="your-service"`
- **Logs endpoint returns errors** — The user running guardian needs to be in the `systemd-journal` group
- **Service management fails** — Check that `/etc/sudoers.d/st-guardian` exists and grants the correct permissions
+1400
View File
File diff suppressed because it is too large Load Diff
+511
View File
@@ -0,0 +1,511 @@
{
"name": "st-guardian",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "st-guardian",
"version": "1.0.0",
"dependencies": {
"better-sqlite3": "^11.0.0",
"http-proxy": "^1.18.1"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.88.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz",
"integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "st-guardian",
"version": "1.0.0",
"description": "Lightweight reverse proxy, health monitor, and update orchestrator for Sprout Track",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"http-proxy": "^1.18.1"
}
}
+240
View File
@@ -0,0 +1,240 @@
#!/bin/bash
# ST-Guardian Setup Script
# Installs st-guardian as a systemd service for Sprout Track
set -e
GUARDIAN_DIR="$(cd "$(dirname "$0")" && pwd)"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
TEAL='\033[0;36m'
NC='\033[0m' # No Color
print_header() {
echo -e "${TEAL}"
echo "╔══════════════════════════════════════╗"
echo "║ ST-Guardian Setup ║"
echo "║ Sprout Track Service Manager ║"
echo "╚══════════════════════════════════════╝"
echo -e "${NC}"
}
# --uninstall flag
if [ "$1" = "--uninstall" ]; then
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Please run with sudo${NC}"
exit 1
fi
echo -e "${YELLOW}Uninstalling st-guardian...${NC}"
if systemctl is-active --quiet st-guardian 2>/dev/null; then
echo "Stopping st-guardian service..."
systemctl stop st-guardian
fi
if systemctl is-enabled --quiet st-guardian 2>/dev/null; then
echo "Disabling st-guardian service..."
systemctl disable st-guardian
fi
if [ -f /etc/systemd/system/st-guardian.service ]; then
echo "Removing systemd unit file..."
rm /etc/systemd/system/st-guardian.service
fi
if [ -f /etc/sudoers.d/st-guardian ]; then
echo "Removing sudoers configuration..."
rm /etc/sudoers.d/st-guardian
fi
systemctl daemon-reload
echo -e "${GREEN}st-guardian has been uninstalled.${NC}"
exit 0
fi
# Check root/sudo
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Please run with sudo${NC}"
echo "Usage: sudo bash $0"
exit 1
fi
print_header
# Detect Docker
if [ -f /.dockerenv ]; then
echo -e "${YELLOW}WARNING: Docker environment detected.${NC}"
echo "Running st-guardian as a systemd service inside Docker is not recommended."
echo "In Docker, st-guardian should be started directly (node index.js)."
read -p "Continue anyway? (y/N): " CONTINUE
if [ "$CONTINUE" != "y" ] && [ "$CONTINUE" != "Y" ]; then
echo "Aborted."
exit 0
fi
fi
# Detect the non-root user
if [ -n "$SUDO_USER" ]; then
RUN_USER="$SUDO_USER"
else
RUN_USER=$(logname 2>/dev/null || echo "")
fi
if [ -z "$RUN_USER" ] || [ "$RUN_USER" = "root" ]; then
echo -e "${YELLOW}Could not detect non-root user.${NC}"
read -p "Enter the user to run st-guardian as: " RUN_USER
if [ -z "$RUN_USER" ]; then
echo -e "${RED}Error: A non-root user is required.${NC}"
exit 1
fi
fi
echo -e "Service will run as user: ${GREEN}${RUN_USER}${NC}"
echo ""
# Prompt for ST_APP_DIR
DEFAULT_APP_DIR="$(dirname "$GUARDIAN_DIR")"
read -p "Sprout Track installation directory [$DEFAULT_APP_DIR]: " ST_APP_DIR
ST_APP_DIR="${ST_APP_DIR:-$DEFAULT_APP_DIR}"
# Validate
if [ ! -f "$ST_APP_DIR/package.json" ]; then
echo -e "${RED}Error: package.json not found in $ST_APP_DIR${NC}"
exit 1
fi
echo -e "App directory: ${GREEN}${ST_APP_DIR}${NC}"
# Prompt for ports
read -p "Guardian port [3001]: " ST_GUARDIAN_PORT
ST_GUARDIAN_PORT="${ST_GUARDIAN_PORT:-3001}"
read -p "App port [3000]: " ST_APP_PORT
ST_APP_PORT="${ST_APP_PORT:-3000}"
# Prompt for guardian key
read -p "Guardian API key (leave blank to auto-generate): " ST_GUARDIAN_KEY
if [ -z "$ST_GUARDIAN_KEY" ]; then
ST_GUARDIAN_KEY=$(openssl rand -hex 32)
echo -e "Generated key: ${GREEN}${ST_GUARDIAN_KEY}${NC}"
fi
# Prompt for health interval
read -p "Health check interval in ms [30000]: " ST_HEALTH_INTERVAL
ST_HEALTH_INTERVAL="${ST_HEALTH_INTERVAL:-30000}"
echo ""
echo "Configuration:"
echo " App directory: $ST_APP_DIR"
echo " Guardian port: $ST_GUARDIAN_PORT"
echo " App port: $ST_APP_PORT"
echo " Health interval: ${ST_HEALTH_INTERVAL}ms"
echo " Run as user: $RUN_USER"
echo ""
read -p "Proceed with installation? (Y/n): " CONFIRM
if [ "$CONFIRM" = "n" ] || [ "$CONFIRM" = "N" ]; then
echo "Aborted."
exit 0
fi
# Install npm dependencies
echo ""
echo -e "${TEAL}Installing dependencies...${NC}"
cd "$GUARDIAN_DIR"
sudo -u "$RUN_USER" npm install --omit=dev
# Read SERVICE_NAME from app .env for sudoers
SERVICE_NAME=""
if [ -f "$ST_APP_DIR/.env" ]; then
SERVICE_NAME=$(grep 'SERVICE_NAME' "$ST_APP_DIR/.env" | cut -d '"' -f 2)
fi
# Configure sudoers for service management
if [ -n "$SERVICE_NAME" ]; then
echo -e "${TEAL}Configuring sudo permissions for service management...${NC}"
SYSTEMCTL_PATH=$(which systemctl)
cat > /etc/sudoers.d/st-guardian << SUDOERS
# Allow st-guardian to manage the Sprout Track service
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} start ${SERVICE_NAME}
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} stop ${SERVICE_NAME}
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} restart ${SERVICE_NAME}
${RUN_USER} ALL=(ALL) NOPASSWD: ${SYSTEMCTL_PATH} status ${SERVICE_NAME}
SUDOERS
chmod 0440 /etc/sudoers.d/st-guardian
echo "Sudoers configured for service: $SERVICE_NAME"
else
echo -e "${YELLOW}WARNING: SERVICE_NAME not found in $ST_APP_DIR/.env${NC}"
echo "Sudoers configuration skipped. Service management may require manual sudo setup."
fi
# Add user to systemd-journal group for log access
if getent group systemd-journal > /dev/null 2>&1; then
echo -e "${TEAL}Adding $RUN_USER to systemd-journal group...${NC}"
usermod -aG systemd-journal "$RUN_USER"
fi
# Detect node path
NODE_PATH=$(which node)
# Create systemd unit file
echo -e "${TEAL}Creating systemd service...${NC}"
cat > /etc/systemd/system/st-guardian.service << UNIT
[Unit]
Description=ST-Guardian - Sprout Track Service Manager
After=network.target
[Service]
Type=simple
User=${RUN_USER}
WorkingDirectory=${GUARDIAN_DIR}
ExecStart=${NODE_PATH} index.js
Restart=always
RestartSec=5
Environment=ST_GUARDIAN_PORT=${ST_GUARDIAN_PORT}
Environment=ST_APP_PORT=${ST_APP_PORT}
Environment=ST_GUARDIAN_KEY=${ST_GUARDIAN_KEY}
Environment=ST_HEALTH_INTERVAL=${ST_HEALTH_INTERVAL}
Environment=ST_SCRIPTS_DIR=${ST_APP_DIR}/scripts
Environment=ST_APP_DIR=${ST_APP_DIR}
Environment=ST_LOG_BUFFER=500
[Install]
WantedBy=multi-user.target
UNIT
# Enable and start
echo -e "${TEAL}Starting st-guardian...${NC}"
systemctl daemon-reload
systemctl enable st-guardian
systemctl start st-guardian
# Wait a moment then check status
sleep 2
echo ""
echo -e "${GREEN}╔══════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ST-Guardian Installed! ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════╝${NC}"
echo ""
# Show status
systemctl status st-guardian --no-pager || true
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e " Status URL: ${TEAL}http://localhost:${ST_GUARDIAN_PORT}/status${NC}"
echo -e " Health URL: ${TEAL}http://localhost:${ST_GUARDIAN_PORT}/health${NC}"
echo ""
echo -e " Guardian Key: ${YELLOW}${ST_GUARDIAN_KEY}${NC}"
echo -e " ${RED}Save this key! It's required for management endpoints.${NC}"
echo ""
echo -e " ${YELLOW}Reminder:${NC} Update your Nginx/reverse proxy to point"
echo -e " at port ${ST_GUARDIAN_PORT} instead of ${ST_APP_PORT}."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "To uninstall: sudo bash $0 --uninstall"