#!/usr/bin/env node // @ts-check import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const defaultStateDir = process.env.COPILOT_RESOURCES_STATE_DIR || path.join(os.homedir(), ".copilot-resources-state"); const machineRegistryName = "project-ports-registry.json"; const localSnapshotRelativePath = path.join(".local", "project-ports.json"); /** * @typedef {{service: string, protocol: string, port: number, notes: string}} PortEntry * @typedef {{version: number, projectName: string, projectPath: string, updatedAt: string, lastSeenAt: string, ports: PortEntry[]}} LocalSnapshot * @typedef {{projectPath: string, projectName: string, localSnapshotPath: string, firstSeenAt: string, lastSeenAt: string, ports: PortEntry[]}} ProjectRecord * @typedef {{version: number, updatedAt: string, projects: Record, ports: Record>, conflicts: Record}>}} MachineRegistry */ function printUsage() { console.log( "Usage: node resources/scripts/update-port-registry.mjs [--report] [--state-dir ] [--project-path ]", ); } function nowIso() { return new Date().toISOString(); } /** @param {string} dirPath */ function ensureDirectory(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } /** * @template T * @param {string} filePath * @param {T} fallbackValue * @returns {{value: T, parseError: Error | null}} */ function safeReadJson(filePath, fallbackValue) { if (!fs.existsSync(filePath)) { return { value: fallbackValue, parseError: null }; } try { return { value: JSON.parse(fs.readFileSync(filePath, "utf8")), parseError: null, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { value: fallbackValue, parseError: new Error( `Failed to parse JSON file at ${filePath}: ${message}`, ), }; } } /** @param {string} filePath @param {unknown} value */ function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } /** @param {string} value */ function normalizePath(value) { return path.resolve(value); } /** @param {unknown} value */ function normalizePortNumber(value) { const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number.parseInt(value.trim(), 10) : NaN; if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { return null; } return parsed; } /** @param {unknown} rawPorts @returns {PortEntry[]} */ function normalizePorts(rawPorts) { if (!Array.isArray(rawPorts)) { return []; } const dedupe = new Set(); const ports = []; for (const rawEntry of rawPorts) { if (!rawEntry || typeof rawEntry !== "object") { continue; } const port = normalizePortNumber(rawEntry.port); if (port === null) { continue; } const service = typeof rawEntry.service === "string" && rawEntry.service.trim() ? rawEntry.service.trim() : "default"; const protocol = typeof rawEntry.protocol === "string" && rawEntry.protocol.trim() ? rawEntry.protocol.trim().toLowerCase() : "tcp"; const dedupeKey = `${service}::${protocol}::${port}`; if (dedupe.has(dedupeKey)) { continue; } dedupe.add(dedupeKey); ports.push({ service, protocol, port, notes: typeof rawEntry.notes === "string" ? rawEntry.notes : "", }); } ports.sort( (left, right) => left.port - right.port || left.service.localeCompare(right.service), ); return ports; } /** @param {any} eventPayload @param {string | null} projectPathOverride */ function detectProjectContext(eventPayload, projectPathOverride) { const candidateTimestamp = typeof eventPayload?.timestamp === "string" && eventPayload.timestamp.trim() ? eventPayload.timestamp : nowIso(); const parsedTimestamp = Number.isNaN(Date.parse(candidateTimestamp)) ? nowIso() : new Date(candidateTimestamp).toISOString(); if (projectPathOverride) { const projectPath = normalizePath(projectPathOverride); return { projectPath, projectName: path.basename(projectPath), timestamp: parsedTimestamp, payloadCwd: projectPath, }; } const cwdFromPayload = typeof eventPayload?.cwd === "string" && eventPayload.cwd.trim() ? eventPayload.cwd : null; const projectPath = normalizePath(cwdFromPayload || process.cwd()); return { projectPath, projectName: path.basename(projectPath), timestamp: parsedTimestamp, payloadCwd: cwdFromPayload, }; } /** @param {{projectName: string, projectPath: string, timestamp: string}} context @returns {LocalSnapshot} */ function defaultLocalSnapshot({ projectName, projectPath, timestamp }) { return { version: 1, projectName, projectPath, updatedAt: timestamp, lastSeenAt: timestamp, ports: [], }; } /** @param {{projectPath: string, projectName: string, timestamp: string}} projectContext */ function loadAndSyncLocalSnapshot(projectContext) { const localSnapshotPath = path.join( projectContext.projectPath, localSnapshotRelativePath, ); ensureDirectory(path.dirname(localSnapshotPath)); const { value: localSnapshot, parseError } = safeReadJson( localSnapshotPath, defaultLocalSnapshot(projectContext), ); if (parseError) { throw parseError; } const syncedSnapshot = { version: 1, projectName: projectContext.projectName, projectPath: projectContext.projectPath, updatedAt: projectContext.timestamp, lastSeenAt: projectContext.timestamp, ports: normalizePorts(localSnapshot.ports), }; writeJson(localSnapshotPath, syncedSnapshot); return { localSnapshotPath, localSnapshot: syncedSnapshot, }; } /** @param {string} timestamp @returns {MachineRegistry} */ function defaultRegistry(timestamp) { return { version: 1, updatedAt: timestamp, projects: {}, ports: {}, conflicts: {}, }; } /** @param {Record} projects */ function buildPortIndexes(projects) { /** @type {MachineRegistry["ports"]} */ const ports = {}; for (const project of Object.values(projects)) { if (!project || typeof project !== "object") { continue; } for (const entry of normalizePorts(project.ports)) { const portKey = String(entry.port); if (!ports[portKey]) { ports[portKey] = []; } ports[portKey].push({ projectPath: project.projectPath, projectName: project.projectName, service: entry.service, protocol: entry.protocol, firstSeenAt: typeof project.firstSeenAt === "string" ? project.firstSeenAt : null, lastSeenAt: typeof project.lastSeenAt === "string" ? project.lastSeenAt : null, }); } } for (const entries of Object.values(ports)) { entries.sort((left, right) => { const leftSeen = left.firstSeenAt ? Date.parse(left.firstSeenAt) : Number.POSITIVE_INFINITY; const rightSeen = right.firstSeenAt ? Date.parse(right.firstSeenAt) : Number.POSITIVE_INFINITY; if (leftSeen !== rightSeen) { return leftSeen - rightSeen; } return left.projectPath.localeCompare(right.projectPath); }); } /** @type {MachineRegistry["conflicts"]} */ const conflicts = {}; for (const [port, entries] of Object.entries(ports)) { if (entries.length < 2) { continue; } const incumbent = entries[0]; const recommendedChange = entries[entries.length - 1]; conflicts[port] = { incumbentProjectPath: incumbent.projectPath, incumbentProjectName: incumbent.projectName, recommendedProjectToChangePath: recommendedChange.projectPath, recommendedProjectToChangeName: recommendedChange.projectName, entries, }; } return { ports, conflicts }; } /** * @param {{ * stateDir: string, * projectContext: {projectPath: string, projectName: string, timestamp: string}, * localSnapshotPath: string, * localSnapshot: LocalSnapshot * }} params */ function updateRegistry({ stateDir, projectContext, localSnapshotPath, localSnapshot, }) { ensureDirectory(stateDir); const registryPath = path.join(stateDir, machineRegistryName); const { value: registry, parseError } = safeReadJson( registryPath, defaultRegistry(projectContext.timestamp), ); if (parseError) { throw parseError; } const projects = registry.projects && typeof registry.projects === "object" ? registry.projects : {}; const existing = projects[projectContext.projectPath] && typeof projects[projectContext.projectPath] === "object" ? projects[projectContext.projectPath] : null; projects[projectContext.projectPath] = { projectPath: projectContext.projectPath, projectName: projectContext.projectName, localSnapshotPath, firstSeenAt: existing && typeof existing.firstSeenAt === "string" ? existing.firstSeenAt : projectContext.timestamp, lastSeenAt: projectContext.timestamp, ports: normalizePorts(localSnapshot.ports), }; const { ports, conflicts } = buildPortIndexes(projects); const updatedRegistry = { version: 1, updatedAt: projectContext.timestamp, projects, ports, conflicts, }; writeJson(registryPath, updatedRegistry); return { registryPath, registry: updatedRegistry }; } /** @param {string} stateDir @param {string} errorMessage */ function appendError(stateDir, errorMessage) { ensureDirectory(stateDir); const errorLine = `${nowIso()} ${errorMessage}`; fs.appendFileSync( path.join(stateDir, "project-ports-errors.log"), `${errorLine}\n`, "utf8", ); } function readStdin() { return fs.readFileSync(0, "utf8"); } /** * @param {string[]} argv * @returns {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}} */ function parseArgs(argv) { /** @type {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}} */ const options = { stateDir: defaultStateDir, report: false, projectPath: null, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { options.help = true; continue; } if (arg === "--report") { options.report = true; continue; } if (arg === "--state-dir") { options.stateDir = normalizePath(argv[index + 1]); index += 1; continue; } if (arg === "--project-path") { options.projectPath = argv[index + 1]; index += 1; continue; } } return options; } /** @param {string} stateDir */ function runReport(stateDir) { const registryPath = path.join(stateDir, machineRegistryName); const { value: registry, parseError } = safeReadJson( registryPath, defaultRegistry(nowIso()), ); if (parseError) { throw parseError; } const conflicts = registry.conflicts && typeof registry.conflicts === "object" ? registry.conflicts : {}; const summary = { registryPath, updatedAt: registry.updatedAt || null, projectCount: registry.projects && typeof registry.projects === "object" ? Object.keys(registry.projects).length : 0, conflictCount: Object.keys(conflicts).length, conflicts, }; console.log(JSON.stringify(summary, null, 2)); } function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printUsage(); return; } if (options.report) { runReport(options.stateDir); return; } let eventPayload = {}; const stdinContent = readStdin(); if (stdinContent.trim()) { try { eventPayload = JSON.parse(stdinContent); } catch { eventPayload = {}; } } const projectContext = detectProjectContext( eventPayload, options.projectPath, ); const { localSnapshotPath, localSnapshot } = loadAndSyncLocalSnapshot(projectContext); updateRegistry({ stateDir: options.stateDir, projectContext, localSnapshotPath, localSnapshot, }); } try { main(); } catch (error) { appendError( defaultStateDir, error instanceof Error ? error.message : String(error), ); process.exitCode = 1; }