Add shared port registry workflow and improve scaffold tooling

This commit is contained in:
2026-05-19 21:22:34 -04:00
parent 107f8a2691
commit 3b668c9ced
33 changed files with 2235 additions and 2 deletions

View File

@@ -5,4 +5,15 @@ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Payload = [Console]::In.ReadToEnd()
$Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log')
$PortRegistryScript = Join-Path $PSScriptRoot 'update-port-registry.mjs'
$NodeCommand = Get-Command node -ErrorAction SilentlyContinue
if ($NodeCommand -and (Test-Path -LiteralPath $PortRegistryScript)) {
try {
$Payload | & $NodeCommand.Source $PortRegistryScript | Out-Null
} catch {
"$(Get-Date -AsUTC -Format o) update-port-registry failed" | Add-Content -LiteralPath (Join-Path $StateDir 'project-ports-errors.log')
}
}
'{"continue": true}'

View File

@@ -3,8 +3,17 @@
set -euo pipefail
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}"
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
port_registry_script="$script_dir/update-port-registry.mjs"
mkdir -p -- "$state_dir"
event_payload="$(cat)"
printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log"
if command -v node >/dev/null 2>&1 && [[ -f "$port_registry_script" ]]; then
if ! printf '%s' "$event_payload" | node "$port_registry_script"; then
printf '%s update-port-registry failed\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$state_dir/project-ports-errors.log"
fi
fi
printf '{"continue": true}\n'

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const TEMPLATE_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'templates',
'discord-oauth-vue3-vite',
'src',
'server',
'discord-oauth',
);
function usage() {
console.error(`Usage: node resources/scripts/scaffold-discord-oauth-vue3-vite.mjs [options]
Required:
--project-root <path> Target project path.
Optional:
--mode <dry-run|apply> Default: dry-run
--frontend-origin <url> Default: http://localhost:5173
--allowlist-discord-ids <csv> Default: empty
--scopes <csv> Default: identify,email
--force Overwrite generated files.
--help Show this help text.
`);
}
function parseArgs(argv) {
const options = {
mode: 'dry-run',
frontendOrigin: 'http://localhost:5173',
allowlistDiscordIds: '',
scopes: 'identify,email',
force: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help') {
usage();
process.exit(0);
}
if (arg === '--project-root') {
options.projectRoot = argv[index + 1];
index += 1;
continue;
}
if (arg === '--mode') {
options.mode = argv[index + 1];
index += 1;
continue;
}
if (arg === '--frontend-origin') {
options.frontendOrigin = argv[index + 1];
index += 1;
continue;
}
if (arg === '--allowlist-discord-ids') {
options.allowlistDiscordIds = argv[index + 1];
index += 1;
continue;
}
if (arg === '--scopes') {
options.scopes = argv[index + 1];
index += 1;
continue;
}
if (arg === '--force') {
options.force = true;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!options.projectRoot) {
throw new Error('--project-root is required.');
}
if (!['dry-run', 'apply'].includes(options.mode)) {
throw new Error('--mode must be dry-run or apply.');
}
options.projectRoot = path.resolve(options.projectRoot);
options.targetRoot = path.join(options.projectRoot, 'src', 'server', 'discord-oauth');
options.allowlistDiscordIdsJson = JSON.stringify(
options.allowlistDiscordIds
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
options.scopesJson = JSON.stringify(
options.scopes
.split(',')
.map((entry) => entry.trim())
.filter(Boolean),
);
return options;
}
function ensureDir(dirPath, mode) {
if (mode === 'dry-run') {
return;
}
fs.mkdirSync(dirPath, { recursive: true });
}
function isTextFile(filePath) {
return /\.(?:js|json|md|txt|mjs|sh)$/.test(filePath);
}
function replacePlaceholders(content, options) {
return content
.replaceAll('__FRONTEND_ORIGIN__', options.frontendOrigin)
.replaceAll('__ALLOWLIST_DISCORD_IDS_JSON__', options.allowlistDiscordIdsJson)
.replaceAll('__SCOPES_JSON__', options.scopesJson);
}
function walkFiles(rootDir, callback) {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
walkFiles(fullPath, callback);
continue;
}
callback(fullPath);
}
}
function scaffold(options) {
if (!fs.existsSync(TEMPLATE_ROOT)) {
throw new Error(`Template root not found: ${TEMPLATE_ROOT}`);
}
const results = [];
walkFiles(TEMPLATE_ROOT, (sourcePath) => {
const relativePath = path.relative(TEMPLATE_ROOT, sourcePath);
const targetPath = path.join(options.targetRoot, relativePath);
const exists = fs.existsSync(targetPath);
if (exists && !options.force) {
results.push({ action: 'skipped', filePath: targetPath });
return;
}
const action = exists ? 'updated' : 'created';
if (options.mode === 'apply') {
ensureDir(path.dirname(targetPath), options.mode);
const rawContent = fs.readFileSync(sourcePath);
const content = isTextFile(sourcePath)
? replacePlaceholders(rawContent.toString('utf8'), options)
: rawContent;
fs.writeFileSync(targetPath, content);
}
results.push({ action, filePath: targetPath });
});
return results;
}
function main() {
const options = parseArgs(process.argv.slice(2));
console.log(`Target bundle: ${path.relative(options.projectRoot, options.targetRoot)}`);
console.log(`Mode: ${options.mode}`);
const results = scaffold(options);
for (const result of results) {
console.log(`${result.action.toUpperCase()}: ${path.relative(options.projectRoot, result.filePath)}`);
}
if (options.mode === 'dry-run') {
console.log('Dry-run only. Re-run with --mode apply to write files.');
}
}
main();

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
node "$script_dir/scaffold-discord-oauth-vue3-vite.mjs" "$@"

View 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;
}

View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const scriptPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"update-port-registry.mjs",
);
function runSync({ stateDir, projectPath, payload }) {
return spawnSync(
process.execPath,
[scriptPath, "--state-dir", stateDir, "--project-path", projectPath],
{
input: JSON.stringify(payload),
encoding: "utf8",
},
);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
test("creates local snapshot and machine registry", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-"));
const stateDir = path.join(tempDir, "state");
const projectPath = path.join(tempDir, "workspace-a");
fs.mkdirSync(projectPath, { recursive: true });
const result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T12:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const localSnapshotPath = path.join(projectPath, ".local", "project-ports.json");
assert.equal(fs.existsSync(localSnapshotPath), true);
const localSnapshot = readJson(localSnapshotPath);
assert.equal(Array.isArray(localSnapshot.ports), true);
const registryPath = path.join(stateDir, "project-ports-registry.json");
const registry = readJson(registryPath);
assert.equal(Object.keys(registry.projects).length, 1);
assert.equal(Object.keys(registry.conflicts).length, 0);
});
test("reports conflict and recommends changing newest project", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-"));
const stateDir = path.join(tempDir, "state");
const projectA = path.join(tempDir, "workspace-a");
const projectB = path.join(tempDir, "workspace-b");
fs.mkdirSync(path.join(projectA, ".local"), { recursive: true });
fs.mkdirSync(path.join(projectB, ".local"), { recursive: true });
fs.writeFileSync(
path.join(projectA, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-a",
projectPath: projectA,
ports: [{ service: "web", port: 3000 }],
},
null,
2,
),
);
fs.writeFileSync(
path.join(projectB, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-b",
projectPath: projectB,
ports: [{ service: "web", port: 3000 }],
},
null,
2,
),
);
let result = runSync({
stateDir,
projectPath: projectA,
payload: { timestamp: "2026-05-19T12:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
result = runSync({
stateDir,
projectPath: projectB,
payload: { timestamp: "2026-05-19T13:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const report = spawnSync(
process.execPath,
[scriptPath, "--state-dir", stateDir, "--report"],
{ encoding: "utf8" },
);
assert.equal(report.status, 0, report.stderr);
const parsedReport = JSON.parse(report.stdout);
assert.equal(parsedReport.conflictCount, 1);
assert.equal(
parsedReport.conflicts["3000"].recommendedProjectToChangeName,
"workspace-b",
);
});
test("keeps firstSeenAt stable across re-sync", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-"));
const stateDir = path.join(tempDir, "state");
const projectPath = path.join(tempDir, "workspace-a");
fs.mkdirSync(path.join(projectPath, ".local"), { recursive: true });
fs.writeFileSync(
path.join(projectPath, ".local", "project-ports.json"),
JSON.stringify(
{
version: 1,
projectName: "workspace-a",
projectPath,
ports: [{ service: "api", port: 4100 }],
},
null,
2,
),
);
let result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T10:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
result = runSync({
stateDir,
projectPath,
payload: { timestamp: "2026-05-19T14:00:00.000Z" },
});
assert.equal(result.status, 0, result.stderr);
const registry = readJson(path.join(stateDir, "project-ports-registry.json"));
const project = registry.projects[projectPath];
assert.equal(project.firstSeenAt, "2026-05-19T10:00:00.000Z");
assert.equal(project.lastSeenAt, "2026-05-19T14:00:00.000Z");
});