Files
bw-copilot-resources/resources/scripts/update-port-registry.mjs

465 lines
13 KiB
JavaScript

#!/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<string, ProjectRecord>, ports: Record<string, Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>>, conflicts: Record<string, {incumbentProjectPath: string, incumbentProjectName: string, recommendedProjectToChangePath: string, recommendedProjectToChangeName: string, entries: Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>}>}} MachineRegistry
*/
function printUsage() {
console.log(
"Usage: node resources/scripts/update-port-registry.mjs [--report] [--state-dir <dir>] [--project-path <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<string, ProjectRecord>} 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;
}