mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const CATALOG_PATH = new URL("../packages/app/src/data/acp-provider-catalog.ts", import.meta.url);
|
|
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+].*)?$/;
|
|
const HELP_TEXT = `Usage: npm run acp:version-drift [-- --json] [-- --fail-on-drift] [-- --no-network] [-- --update]
|
|
|
|
Checks package-runner ACP catalog entries for exact latest registry pins.
|
|
|
|
By default this is report-only. Add --fail-on-drift to exit non-zero when drift is found.
|
|
Add --update to rewrite the catalog package-runner entries to the latest exact versions.`;
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
json: false,
|
|
failOnDrift: false,
|
|
noNetwork: false,
|
|
update: false,
|
|
};
|
|
|
|
for (const arg of argv) {
|
|
if (arg === "--json") {
|
|
options.json = true;
|
|
} else if (arg === "--fail-on-drift") {
|
|
options.failOnDrift = true;
|
|
} else if (arg === "--no-network") {
|
|
options.noNetwork = true;
|
|
} else if (arg === "--update") {
|
|
options.update = true;
|
|
} else if (arg === "--help" || arg === "-h") {
|
|
console.log(HELP_TEXT);
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}\n\n${HELP_TEXT}`);
|
|
}
|
|
}
|
|
|
|
if (options.update && options.noNetwork) {
|
|
throw new Error("--update requires registry access; remove --no-network");
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function getCatalogBodyRange(source) {
|
|
const startToken = "const CATALOG_DATA = [";
|
|
const startIndex = source.indexOf(startToken);
|
|
if (startIndex === -1) {
|
|
throw new Error("Could not find CATALOG_DATA in ACP provider catalog");
|
|
}
|
|
|
|
const arrayStartIndex = source.indexOf("[", startIndex);
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (let index = arrayStartIndex; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
|
|
if (inString) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === "\\") {
|
|
escaped = true;
|
|
} else if (char === '"') {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inString = true;
|
|
} else if (char === "[") {
|
|
depth += 1;
|
|
} else if (char === "]") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return { start: arrayStartIndex + 1, end: index };
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error("Could not parse CATALOG_DATA array");
|
|
}
|
|
|
|
function extractEntryBlocks(source) {
|
|
const range = getCatalogBodyRange(source);
|
|
const blocks = [];
|
|
let blockStart = null;
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (let index = range.start; index < range.end; index += 1) {
|
|
const char = source[index];
|
|
|
|
if (inString) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === "\\") {
|
|
escaped = true;
|
|
} else if (char === '"') {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inString = true;
|
|
} else if (char === "{") {
|
|
if (depth === 0) {
|
|
blockStart = index;
|
|
}
|
|
depth += 1;
|
|
} else if (char === "}") {
|
|
depth -= 1;
|
|
if (depth === 0 && blockStart !== null) {
|
|
blocks.push({
|
|
start: blockStart,
|
|
end: index + 1,
|
|
block: source.slice(blockStart, index + 1),
|
|
});
|
|
blockStart = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
function getStringField(block, fieldName) {
|
|
const match = new RegExp(`${fieldName}:\\s*"([^"]+)"`).exec(block);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function getCommandField(block) {
|
|
const match = /command:\s*(\[[^\]]+\])/.exec(block);
|
|
if (!match?.[1]) {
|
|
return null;
|
|
}
|
|
return JSON.parse(match[1]);
|
|
}
|
|
|
|
function parseCatalogEntries(source) {
|
|
return extractEntryBlocks(source).map((entryBlock) => ({
|
|
id: getStringField(entryBlock.block, "id"),
|
|
title: getStringField(entryBlock.block, "title"),
|
|
version: getStringField(entryBlock.block, "version"),
|
|
command: getCommandField(entryBlock.block),
|
|
start: entryBlock.start,
|
|
end: entryBlock.end,
|
|
}));
|
|
}
|
|
|
|
function findNpmPackageSpec(command) {
|
|
const argsStartIndex = command[0] === "npm" && command[1] === "exec" ? 2 : 1;
|
|
for (let index = argsStartIndex; index < command.length; index += 1) {
|
|
const arg = command[index];
|
|
if (arg === "--") {
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
continue;
|
|
}
|
|
return { registry: "npm", spec: arg, index };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findUvxPackageSpec(command) {
|
|
const fromIndex = command.indexOf("--from");
|
|
if (fromIndex !== -1 && command[fromIndex + 1]) {
|
|
return { registry: "pypi", spec: command[fromIndex + 1], index: fromIndex + 1, usesFrom: true };
|
|
}
|
|
|
|
for (let index = 1; index < command.length; index += 1) {
|
|
const arg = command[index];
|
|
if (arg.startsWith("-")) {
|
|
continue;
|
|
}
|
|
return { registry: "pypi", spec: arg, index, usesFrom: false };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findPackageRunnerSpec(command) {
|
|
if (!Array.isArray(command) || command.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (command[0] === "npx" || command[0] === "npm") {
|
|
return findNpmPackageSpec(command);
|
|
}
|
|
if (command[0] === "uvx") {
|
|
return findUvxPackageSpec(command);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parsePackageSpec(spec) {
|
|
if (!spec) {
|
|
return null;
|
|
}
|
|
|
|
const pythonRequirementMatch = /^([A-Za-z0-9_.-]+)(==.+|>=.+)$/.exec(spec);
|
|
if (pythonRequirementMatch?.[1] && pythonRequirementMatch[2]) {
|
|
return {
|
|
packageName: pythonRequirementMatch[1],
|
|
selector: pythonRequirementMatch[2],
|
|
};
|
|
}
|
|
|
|
if (spec.startsWith("@")) {
|
|
const versionSeparatorIndex = spec.indexOf("@", 1);
|
|
if (versionSeparatorIndex === -1) {
|
|
return { packageName: spec, selector: null };
|
|
}
|
|
return {
|
|
packageName: spec.slice(0, versionSeparatorIndex),
|
|
selector: spec.slice(versionSeparatorIndex + 1),
|
|
};
|
|
}
|
|
|
|
const versionSeparatorIndex = spec.lastIndexOf("@");
|
|
if (versionSeparatorIndex === -1) {
|
|
return { packageName: spec, selector: null };
|
|
}
|
|
return {
|
|
packageName: spec.slice(0, versionSeparatorIndex),
|
|
selector: spec.slice(versionSeparatorIndex + 1),
|
|
};
|
|
}
|
|
|
|
function getPinnedVersion(selector) {
|
|
if (!selector) {
|
|
return null;
|
|
}
|
|
if (EXACT_VERSION_PATTERN.test(selector)) {
|
|
return selector;
|
|
}
|
|
if (selector.startsWith("==") && EXACT_VERSION_PATTERN.test(selector.slice(2))) {
|
|
return selector.slice(2);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function getLatestNpmVersion(packageName) {
|
|
const { stdout } = await execFileAsync("npm", ["view", packageName, "version"], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
maxBuffer: 1024 * 1024,
|
|
});
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function getLatestPypiVersion(packageName) {
|
|
const response = await fetch(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`, {
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`PyPI responded ${response.status}`);
|
|
}
|
|
const metadata = await response.json();
|
|
if (!metadata?.info?.version || typeof metadata.info.version !== "string") {
|
|
throw new Error("PyPI response did not include info.version");
|
|
}
|
|
return metadata.info.version;
|
|
}
|
|
|
|
async function getLatestVersion(registry, packageName) {
|
|
if (registry === "npm") {
|
|
return getLatestNpmVersion(packageName);
|
|
}
|
|
if (registry === "pypi") {
|
|
return getLatestPypiVersion(packageName);
|
|
}
|
|
throw new Error(`Unsupported package registry: ${registry}`);
|
|
}
|
|
|
|
function buildStatus({ selector, catalogVersion, latestVersion, registryError }) {
|
|
const pinnedVersion = getPinnedVersion(selector);
|
|
const reasons = [];
|
|
|
|
if (registryError) {
|
|
reasons.push("registry version lookup failed");
|
|
}
|
|
if (!selector) {
|
|
reasons.push("missing package version selector");
|
|
} else if (!pinnedVersion) {
|
|
reasons.push("command selector is not an exact version pin");
|
|
}
|
|
|
|
if (latestVersion) {
|
|
if (pinnedVersion && pinnedVersion !== latestVersion) {
|
|
reasons.push("command selector is not latest registry version");
|
|
}
|
|
if (catalogVersion !== latestVersion) {
|
|
reasons.push("catalog version is not latest registry version");
|
|
}
|
|
}
|
|
|
|
return reasons;
|
|
}
|
|
|
|
function buildUpdatedPackageSpec(spec, packageName, latestVersion) {
|
|
if (spec.registry === "pypi" && spec.usesFrom) {
|
|
return `${packageName}==${latestVersion}`;
|
|
}
|
|
return `${packageName}@${latestVersion}`;
|
|
}
|
|
|
|
function buildUpdatedCommand(command, spec, parsedSpec, latestVersion) {
|
|
const updatedCommand = [...command];
|
|
updatedCommand[spec.index] = buildUpdatedPackageSpec(spec, parsedSpec.packageName, latestVersion);
|
|
return updatedCommand;
|
|
}
|
|
|
|
async function inspectEntry(entry, options) {
|
|
const spec = findPackageRunnerSpec(entry.command);
|
|
const parsedSpec = parsePackageSpec(spec?.spec);
|
|
|
|
if (!spec || !parsedSpec) {
|
|
return {
|
|
id: entry.id,
|
|
title: entry.title,
|
|
command: entry.command,
|
|
checked: false,
|
|
status: "skipped",
|
|
reasons: ["not a supported package-runner command"],
|
|
};
|
|
}
|
|
|
|
let latestVersion = null;
|
|
let registryError = null;
|
|
if (!options.noNetwork) {
|
|
try {
|
|
latestVersion = await getLatestVersion(spec.registry, parsedSpec.packageName);
|
|
} catch (error) {
|
|
registryError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
|
|
const reasons = buildStatus({
|
|
selector: parsedSpec.selector,
|
|
catalogVersion: entry.version,
|
|
latestVersion,
|
|
registryError,
|
|
});
|
|
|
|
return {
|
|
id: entry.id,
|
|
title: entry.title,
|
|
registry: spec.registry,
|
|
packageName: parsedSpec.packageName,
|
|
selector: parsedSpec.selector,
|
|
catalogVersion: entry.version,
|
|
latestVersion,
|
|
currentCommand: entry.command,
|
|
updatedCommand: latestVersion
|
|
? buildUpdatedCommand(entry.command, spec, parsedSpec, latestVersion)
|
|
: null,
|
|
checked: true,
|
|
status: reasons.length === 0 ? "ok" : "drift",
|
|
reasons,
|
|
registryError,
|
|
start: entry.start,
|
|
end: entry.end,
|
|
};
|
|
}
|
|
|
|
function serializeCommand(command) {
|
|
return `[${command.map((arg) => JSON.stringify(arg)).join(", ")}]`;
|
|
}
|
|
|
|
function applyUpdates(source, results) {
|
|
let updatedSource = source;
|
|
const updates = results
|
|
.filter((result) => result.checked && result.latestVersion && result.updatedCommand)
|
|
.sort((left, right) => right.start - left.start);
|
|
|
|
for (const result of updates) {
|
|
const block = updatedSource.slice(result.start, result.end);
|
|
const updatedBlock = block
|
|
.replace(/version:\s*"[^"]+"/, `version: "${result.latestVersion}"`)
|
|
.replace(/command:\s*\[[^\]]+\]/, `command: ${serializeCommand(result.updatedCommand)}`);
|
|
updatedSource = `${updatedSource.slice(0, result.start)}${updatedBlock}${updatedSource.slice(result.end)}`;
|
|
}
|
|
|
|
return updatedSource;
|
|
}
|
|
|
|
function printReport(results, options) {
|
|
const reportResults = results.map(({ start: _start, end: _end, ...result }) => result);
|
|
if (options.json) {
|
|
console.log(JSON.stringify(reportResults, null, 2));
|
|
return;
|
|
}
|
|
|
|
const checked = reportResults.filter((result) => result.checked);
|
|
const drift = checked.filter((result) => result.status === "drift");
|
|
const skipped = reportResults.filter((result) => !result.checked);
|
|
|
|
console.log("ACP catalog version drift");
|
|
console.log("=========================");
|
|
console.log(`checked: ${checked.length}`);
|
|
console.log(`drift: ${drift.length}`);
|
|
console.log(`skipped: ${skipped.length}`);
|
|
|
|
if (drift.length > 0) {
|
|
console.log("\nDrift / stale pins:");
|
|
for (const result of drift) {
|
|
const latest = result.latestVersion ?? (options.noNetwork ? "not checked" : "unknown");
|
|
const recommended = result.updatedCommand?.join(" ") ?? "unavailable";
|
|
console.log(
|
|
`- ${result.id}: ${result.registry}:${result.packageName}@${result.selector ?? "<none>"} ` +
|
|
`(catalog ${result.catalogVersion ?? "n/a"}, latest ${latest}) -> ${recommended}`,
|
|
);
|
|
for (const reason of result.reasons) {
|
|
console.log(` - ${reason}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (skipped.length > 0) {
|
|
console.log("\nSkipped non-package-runner commands:");
|
|
for (const result of skipped) {
|
|
console.log(`- ${result.id}: ${result.command?.join(" ") ?? "<no command>"}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const source = await readFile(CATALOG_PATH, "utf8");
|
|
const entries = parseCatalogEntries(source);
|
|
const results = await Promise.all(entries.map((entry) => inspectEntry(entry, options)));
|
|
const hasDrift = results.some((result) => result.checked && result.status === "drift");
|
|
|
|
if (options.update) {
|
|
await writeFile(CATALOG_PATH, applyUpdates(source, results));
|
|
}
|
|
|
|
printReport(results, options);
|
|
|
|
if (options.failOnDrift && hasDrift) {
|
|
process.exitCode = 1;
|
|
}
|