Add shared port registry workflow and improve scaffold tooling
This commit is contained in:
464
resources/scripts/update-port-registry.mjs
Normal file
464
resources/scripts/update-port-registry.mjs
Normal file
@@ -0,0 +1,464 @@
|
||||
#!/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;
|
||||
}
|
||||
Reference in New Issue
Block a user