feat: require unique --name parameter for remote servers

- Add mandatory --name parameter when using --hq-url
- Ensure remote server names are unique across the HQ
- Return 409 Conflict when duplicate name is registered
- Track remotes by both ID and name in RemoteRegistry
- Add /api/remotes endpoints for HQ mode
- Improve error messages for registration failures

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-20 11:41:54 +02:00
parent 8bb22f291f
commit 862947b744
3 changed files with 89 additions and 6 deletions

View file

@ -1,5 +1,4 @@
import { v4 as uuidv4 } from 'uuid';
import * as os from 'os';
export class HQClient {
private readonly hqUrl: string;
@ -10,10 +9,10 @@ export class HQClient {
private readonly hqPassword: string;
private registrationRetryTimeout: NodeJS.Timeout | null = null;
constructor(hqUrl: string, hqUsername: string, hqPassword: string) {
constructor(hqUrl: string, hqUsername: string, hqPassword: string, remoteName: string) {
this.hqUrl = hqUrl;
this.remoteId = uuidv4();
this.remoteName = `${os.hostname()}-${process.pid}`;
this.remoteName = remoteName;
this.token = uuidv4();
this.hqUsername = hqUsername;
this.hqPassword = hqPassword;
@ -36,11 +35,13 @@ export class HQClient {
});
if (!response.ok) {
throw new Error(`Registration failed: ${response.statusText}`);
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(`Registration failed: ${errorBody.error || response.statusText}`);
}
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
console.log(`Remote ID: ${this.remoteId}`);
console.log(`Remote name: ${this.remoteName}`);
console.log(`Token: ${this.token}`);
} catch (error) {
console.error('Failed to register with HQ:', error);

View file

@ -11,6 +11,7 @@ export interface RemoteServer {
export class RemoteRegistry {
private remotes: Map<string, RemoteServer> = new Map();
private remotesByName: Map<string, RemoteServer> = new Map();
private healthCheckInterval: NodeJS.Timeout | null = null;
private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds
private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check
@ -20,6 +21,11 @@ export class RemoteRegistry {
}
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
// Check if a remote with the same name already exists
if (this.remotesByName.has(remote.name)) {
throw new Error(`Remote with name '${remote.name}' is already registered`);
}
const now = new Date();
const registeredRemote: RemoteServer = {
...remote,
@ -29,6 +35,7 @@ export class RemoteRegistry {
};
this.remotes.set(remote.id, registeredRemote);
this.remotesByName.set(remote.name, registeredRemote);
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
// Immediately check health of new remote
@ -41,6 +48,7 @@ export class RemoteRegistry {
const remote = this.remotes.get(remoteId);
if (remote) {
console.log(`Remote unregistered: ${remote.name} (${remoteId})`);
this.remotesByName.delete(remote.name);
return this.remotes.delete(remoteId);
}
return false;

View file

@ -17,6 +17,9 @@ const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });
// Add JSON body parser middleware
app.use(express.json());
const PORT = process.env.PORT || 4020;
// ANSI color codes
@ -33,6 +36,7 @@ let isHQMode = false;
let hqUrl: string | null = null;
let hqUsername: string | null = null;
let hqPassword: string | null = null;
let remoteName: string | null = null;
// Check for command line arguments
for (let i = 0; i < args.length; i++) {
@ -53,6 +57,9 @@ for (let i = 0; i < args.length; i++) {
} else if (args[i] === '--hq-password' && i + 1 < args.length) {
hqPassword = args[i + 1];
i++; // Skip the password value in next iteration
} else if (args[i] === '--name' && i + 1 < args.length) {
remoteName = args[i + 1];
i++; // Skip the name value in next iteration
}
}
@ -84,6 +91,13 @@ if (hqUrl && (!hqUsername || !hqPassword)) {
process.exit(1);
}
// Validate remote name is provided when registering with HQ
if (hqUrl && !remoteName) {
console.error(`${RED}ERROR: Remote name required when --hq-url is specified${RESET}`);
console.error('Use --name to specify a unique name for this remote server');
process.exit(1);
}
// Validate HQ URL
if (hqUrl) {
try {
@ -153,9 +167,10 @@ if (isHQMode) {
console.log(`${GREEN}Running in HQ mode${RESET}`);
}
if (hqUrl && hqUsername && hqPassword) {
hqClient = new HQClient(hqUrl, hqUsername, hqPassword);
if (hqUrl && hqUsername && hqPassword && remoteName) {
hqClient = new HQClient(hqUrl, hqUsername, hqPassword, remoteName);
console.log(`${GREEN}Will register with HQ at: ${hqUrl}${RESET}`);
console.log(`${GREEN}Remote name: ${remoteName}${RESET}`);
}
// Ensure control directory exists
@ -248,6 +263,65 @@ app.use(express.static(path.join(__dirname, '..', 'public')));
// Hot reload functionality for development
const hotReloadClients = new Set<WebSocket>();
// === HQ/REMOTE MANAGEMENT ===
// Register a remote server (HQ mode only)
app.post('/api/remotes/register', (req, res) => {
if (!isHQMode || !remoteRegistry) {
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
}
const { id, name, url, token } = req.body;
if (!id || !name || !url || !token) {
return res.status(400).json({ error: 'Missing required fields: id, name, url, token' });
}
try {
const remote = remoteRegistry.register({
id,
name,
url,
token,
sessionCount: 0,
});
res.json({ success: true, remote });
} catch (error) {
if (error instanceof Error && error.message.includes('already registered')) {
return res.status(409).json({ error: error.message });
}
console.error('Failed to register remote:', error);
res.status(500).json({ error: 'Failed to register remote' });
}
});
// Unregister a remote server (HQ mode only)
app.delete('/api/remotes/:id', (req, res) => {
if (!isHQMode || !remoteRegistry) {
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
}
const { id } = req.params;
const success = remoteRegistry.unregister(id);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Remote not found' });
}
});
// List all remote servers (HQ mode only)
app.get('/api/remotes', (req, res) => {
if (!isHQMode || !remoteRegistry) {
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
}
const remotes = remoteRegistry.getAllRemotes();
res.json(remotes);
});
// === SESSION MANAGEMENT ===
// List all sessions