465 lines
13 KiB
JavaScript
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;
|
|
}
|