Add shared port registry workflow and improve scaffold tooling
This commit is contained in:
@@ -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}'
|
||||
|
||||
@@ -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'
|
||||
|
||||
193
resources/scripts/scaffold-discord-oauth-vue3-vite.mjs
Executable file
193
resources/scripts/scaffold-discord-oauth-vue3-vite.mjs
Executable 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();
|
||||
6
resources/scripts/scaffold-discord-oauth-vue3-vite.sh
Executable file
6
resources/scripts/scaffold-discord-oauth-vue3-vite.sh
Executable 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" "$@"
|
||||
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;
|
||||
}
|
||||
157
resources/scripts/update-port-registry.test.mjs
Normal file
157
resources/scripts/update-port-registry.test.mjs
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user