mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Pin ACP catalog entries to current versions
This commit is contained in:
@@ -204,6 +204,23 @@ For tighter loops, you can rebuild a single workspace:
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
and `uvx`) to exact package versions. Run the drift checker regularly — and
|
||||
before releases — so catalog installs do not sit on stale agent versions:
|
||||
|
||||
```bash
|
||||
npm run acp:version-drift # report stale/non-exact package pins
|
||||
npm run acp:version-drift:check # same, exits non-zero on drift
|
||||
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
|
||||
```
|
||||
|
||||
The checker updates only package-runner catalog entries. Providers that use a
|
||||
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
|
||||
are reported as skipped because their versions are owned by the user's local
|
||||
install.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
@@ -9,6 +9,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
|
||||
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
|
||||
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
|
||||
@@ -17,10 +17,10 @@ const CATALOG_DATA = [
|
||||
title: "Agoragentic",
|
||||
description:
|
||||
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
|
||||
version: "1.3.0",
|
||||
version: "1.3.2",
|
||||
iconId: "agoragentic-acp",
|
||||
installLink: "https://agoragentic.com",
|
||||
command: ["npx", "-y", "agoragentic-mcp@1.3.0", "--acp"],
|
||||
command: ["npx", "-y", "agoragentic-mcp@1.3.2", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "amp-acp",
|
||||
@@ -36,10 +36,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.26.0",
|
||||
version: "0.28.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.26.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.28.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -57,29 +57,29 @@ const CATALOG_DATA = [
|
||||
id: "claude-acp",
|
||||
title: "Claude Agent",
|
||||
description: "ACP wrapper for Anthropic's Claude",
|
||||
version: "0.32.0",
|
||||
version: "0.40.0",
|
||||
iconId: "claude-acp",
|
||||
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
|
||||
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.32.0"],
|
||||
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.40.0"],
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3",
|
||||
version: "3.0.15",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.15", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.95.0",
|
||||
version: "2.101.0",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.95.0", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.101.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codex-acp",
|
||||
@@ -130,10 +130,10 @@ const CATALOG_DATA = [
|
||||
id: "deepagents",
|
||||
title: "DeepAgents",
|
||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||
version: "0.1.7",
|
||||
version: "0.1.12",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.7"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.12"],
|
||||
},
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
@@ -157,29 +157,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.0.66",
|
||||
version: "0.0.76",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.0.66", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.0.76", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.3.36",
|
||||
version: "0.3.44",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.3.36", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.3.44", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.119.0",
|
||||
version: "0.139.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.119.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.139.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -189,38 +189,38 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.7.0",
|
||||
version: "0.7.15",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "fast-agent-acp@latest", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.7.15", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.41.1",
|
||||
version: "0.45.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.45.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "github-copilot-cli",
|
||||
title: "GitHub Copilot",
|
||||
description: "GitHub's AI pair programmer",
|
||||
version: "1.0.41",
|
||||
version: "1.0.59",
|
||||
iconId: "github-copilot-cli",
|
||||
installLink: "https://github.com/features/copilot/cli/",
|
||||
command: ["npx", "-y", "@github/copilot@1.0.41", "--acp"],
|
||||
command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "glm-acp-agent",
|
||||
title: "GLM Agent",
|
||||
description:
|
||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||
version: "1.1.2",
|
||||
version: "1.1.4",
|
||||
iconId: "glm-acp-agent",
|
||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||
command: ["npx", "-y", "glm-acp-agent@1.1.2"],
|
||||
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
@@ -294,7 +294,7 @@ const CATALOG_DATA = [
|
||||
version: "0.1.44",
|
||||
iconId: "minion-code",
|
||||
installLink: "https://github.com/femto/minion-code",
|
||||
command: ["uvx", "minion-code@latest", "acp"],
|
||||
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
|
||||
},
|
||||
{
|
||||
id: "mistral-vibe",
|
||||
@@ -309,10 +309,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.4",
|
||||
version: "1.1.14",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.4", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"],
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
@@ -327,10 +327,10 @@ const CATALOG_DATA = [
|
||||
id: "pi-acp",
|
||||
title: "pi ACP",
|
||||
description: "ACP adapter for pi coding agent",
|
||||
version: "0.0.26",
|
||||
version: "0.0.27",
|
||||
iconId: "pi-acp",
|
||||
installLink: "https://github.com/svkozak/pi-acp",
|
||||
command: ["npx", "-y", "pi-acp@0.0.26"],
|
||||
command: ["npx", "-y", "pi-acp@0.0.27"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -345,19 +345,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "0.2.6",
|
||||
version: "1.0.12",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@0.2.6", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.12", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.15.6",
|
||||
version: "0.17.0",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.15.6", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.17.0", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
452
scripts/check-acp-catalog-version-drift.mjs
Normal file
452
scripts/check-acp-catalog-version-drift.mjs
Normal file
@@ -0,0 +1,452 @@
|
||||
#!/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;
|
||||
}
|
||||
Reference in New Issue
Block a user