mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feature: new dashboard workos auth for dashboard login, dashboard optional in site config, sync server for dashboard, workos and dashboard docs updated
This commit is contained in:
224
scripts/sync-server.ts
Normal file
224
scripts/sync-server.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Sync Server
|
||||
*
|
||||
* Local HTTP server for executing sync commands from the Dashboard UI.
|
||||
* Runs on localhost:3001 with optional token authentication.
|
||||
*
|
||||
* Usage:
|
||||
* npm run sync-server
|
||||
*
|
||||
* Security:
|
||||
* - Binds to localhost only (not accessible from network)
|
||||
* - Optional token auth via SYNC_TOKEN env var
|
||||
* - Whitelisted commands only
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import http from "http";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config();
|
||||
|
||||
const PORT = 3001;
|
||||
const HOST = "127.0.0.1"; // Localhost only for security
|
||||
|
||||
// Optional token for authentication (set in .env.local)
|
||||
const SYNC_TOKEN = process.env.SYNC_TOKEN || "";
|
||||
|
||||
// Whitelist of allowed sync commands
|
||||
const ALLOWED_COMMANDS: Record<string, { script: string; env?: Record<string, string> }> = {
|
||||
"sync": { script: "sync" },
|
||||
"sync:prod": { script: "sync:prod" },
|
||||
"sync:discovery": { script: "sync:discovery" },
|
||||
"sync:discovery:prod": { script: "sync:discovery:prod" },
|
||||
"sync:all": { script: "sync:all" },
|
||||
"sync:all:prod": { script: "sync:all:prod" },
|
||||
};
|
||||
|
||||
// CORS headers for local development
|
||||
function setCorsHeaders(res: http.ServerResponse) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Sync-Token");
|
||||
}
|
||||
|
||||
// Verify authentication token
|
||||
function verifyAuth(req: http.IncomingMessage): boolean {
|
||||
// If no token configured, allow all requests (dev mode)
|
||||
if (!SYNC_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = req.headers["x-sync-token"];
|
||||
return token === SYNC_TOKEN;
|
||||
}
|
||||
|
||||
// Parse JSON body from request
|
||||
async function parseBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
reject(new Error("Invalid JSON"));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Execute npm script and stream output
|
||||
function executeScript(
|
||||
scriptName: string,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const config = ALLOWED_COMMANDS[scriptName];
|
||||
if (!config) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `Unknown command: ${scriptName}` }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set headers for streaming response
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
|
||||
// Write initial message
|
||||
res.write(`> npm run ${config.script}\n\n`);
|
||||
|
||||
// Spawn the npm process
|
||||
const child = spawn("npm", ["run", config.script], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...config.env },
|
||||
shell: true,
|
||||
});
|
||||
|
||||
// Stream stdout
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
res.write(data.toString());
|
||||
});
|
||||
|
||||
// Stream stderr
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
res.write(data.toString());
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
res.write(`\n[Done] Command completed successfully.\n`);
|
||||
} else {
|
||||
res.write(`\n[Error] Command exited with code ${code}.\n`);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
child.on("error", (err) => {
|
||||
res.write(`\n[Error] Failed to execute command: ${err.message}\n`);
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Main request handler
|
||||
async function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): Promise<void> {
|
||||
setCorsHeaders(res);
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", `http://${HOST}:${PORT}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Health check endpoint
|
||||
if (path === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", commands: Object.keys(ALLOWED_COMMANDS) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute sync command
|
||||
if (path === "/api/sync" && req.method === "POST") {
|
||||
// Verify authentication
|
||||
if (!verifyAuth(req)) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
const command = body.command as string;
|
||||
|
||||
if (!command || !ALLOWED_COMMANDS[command]) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
error: "Invalid command",
|
||||
allowed: Object.keys(ALLOWED_COMMANDS)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
executeScript(command, res);
|
||||
} catch (error) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid request body" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 for other routes
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
// Create and start server
|
||||
const server = http.createServer((req, res) => {
|
||||
handleRequest(req, res).catch((error) => {
|
||||
console.error("Request error:", error);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`\n Sync Server running at http://${HOST}:${PORT}`);
|
||||
console.log(`\n Available commands:`);
|
||||
Object.keys(ALLOWED_COMMANDS).forEach((cmd) => {
|
||||
console.log(` - ${cmd}`);
|
||||
});
|
||||
if (SYNC_TOKEN) {
|
||||
console.log(`\n Token authentication: enabled`);
|
||||
} else {
|
||||
console.log(`\n Token authentication: disabled (set SYNC_TOKEN in .env.local to enable)`);
|
||||
}
|
||||
console.log(`\n Use with Dashboard at http://localhost:5173/dashboard\n`);
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\n Shutting down sync server...");
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user