diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 5a430184..37dfa48c 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -143,10 +143,12 @@ mops toolchain use lintoko 0.10.0 # pin specific version mops toolchain use pocket-ic 12.0.0 # pin for replica tests / benchmarks (pin a specific version; `latest` may resolve to one the bundled pic-js client doesn't support) mops toolchain update moc # update to latest (requires existing [toolchain] entry) mops toolchain update # update all tools to latest +mops toolchain info # show release info (latest, pinned, history) +mops toolchain info --versions # list all stable releases (scripting) mops toolchain bin moc # print path to binary ``` -**Agent note**: `toolchain use ` without a version opens an interactive picker — do not use in scripts or agents. Always pass a version or `latest`. `toolchain update` only works when the tool already has a `[toolchain]` entry. +**Agent note**: `toolchain use ` without a version opens an interactive picker — do not use in scripts or agents. Always pass a version or `latest`. `toolchain update` only works when the tool already has a `[toolchain]` entry. `toolchain info --versions` works without `mops.toml`; set `GITHUB_TOKEN` when listing tools with long release histories. ### Enhanced migrations diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 74c2196a..afdae270 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next +- `mops toolchain info --versions` lists all stable GitHub release versions for a toolchain tool (moc, lintoko, wasmtime, pocket-ic), one per line — for scripting and cache warming. +- `mops toolchain info ` shows latest stable release, pinned version, and recent version history. + ## 2.16.1 - Fix `mops bench` crashing on moc 0.15+ with the default `--gc copying`: `--copying-gc` is rejected under enhanced orthogonal persistence, which became the default persistence mode in 2.16.0. The default GC is now `incremental` (moc's default and the only collector available under EOP). Selecting a legacy collector (`copying`, `compacting`, `generational`) now implies `--legacy-persistence`, since moc only accepts them there. diff --git a/cli/cli.ts b/cli/cli.ts index 712531ed..22725174 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -854,6 +854,18 @@ toolchainCommand await toolchain.update(tool); }); +toolchainCommand + .command("info") + .description("Show release information about a toolchain tool") + .addArgument(new Argument("", "tool to look up").choices(TOOLCHAINS)) + .option( + "--versions", + "List all stable release versions, one per line (oldest to newest)", + ) + .action(async (tool: Tool, options) => { + await toolchain.info(tool, options); + }); + toolchainCommand .command("bin") .description("Get path to the tool binary") diff --git a/cli/commands/toolchain/index.ts b/cli/commands/toolchain/index.ts index 544408f2..47641c2a 100644 --- a/cli/commands/toolchain/index.ts +++ b/cli/commands/toolchain/index.ts @@ -21,6 +21,16 @@ import * as pocketIc from "./pocket-ic.js"; import * as wasmtime from "./wasmtime.js"; import * as lintoko from "./lintoko.js"; import { FILE_PATH_REGEX } from "../../constants.js"; +import * as toolchainUtils from "./toolchain-utils.js"; +import type { ReleaseInfo } from "./release-tags.js"; + +function label(text: string): string { + return chalk.bold(text.padEnd(16)); +} + +export interface ToolchainInfoOptions { + versions?: boolean; +} function getToolUtils(tool: Tool) { if (tool === "moc") { @@ -250,25 +260,23 @@ async function promptVersion(tool: Tool): Promise { type: "select", name: "version", message: `Select ${tool} version`, - choices: releases.map( - ( - release: { published_at: string | number | Date; tag_name: string }, - i: any, - ) => { - let date = new Date(release.published_at).toLocaleDateString( - undefined, - { year: "numeric", month: "short", day: "numeric" }, - ); - return { - title: - release.tag_name + - chalk.gray( - ` ${date}${currentIndex === i ? chalk.italic(" (current)") : ""}`, - ), - value: release.tag_name, - }; - }, - ), + choices: releases.map((release: ReleaseInfo, i) => { + let date = release.published_at + ? new Date(release.published_at).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : ""; + return { + title: + release.tag_name + + chalk.gray( + ` ${date}${currentIndex === i ? chalk.italic(" (current)") : ""}`, + ), + value: release.tag_name, + }; + }), initial: currentIndex == -1 ? 0 : currentIndex, }); @@ -347,6 +355,56 @@ async function update(tool?: Tool) { } } +async function info(tool: Tool, options: ToolchainInfoOptions = {}) { + let toolUtils = getToolUtils(tool); + + if (options.versions) { + let versions = await toolchainUtils.getAllReleaseTags(toolUtils.repo); + for (let ver of versions) { + console.log(ver); + } + return; + } + + let [versions, latest] = await Promise.all([ + toolchainUtils.getAllReleaseTags(toolUtils.repo), + toolUtils.getLatestReleaseTag(), + ]); + + let configFile = getClosestConfigFile(); + let pinned = configFile + ? readConfig(configFile).toolchain?.[tool] + : undefined; + + console.log(""); + console.log(chalk.green.bold(tool)); + + if (latest) { + console.log(chalk.yellow(`latest: ${latest}`)); + } + + if (pinned) { + console.log(`${label("pinned")}${pinned}`); + } + + console.log(""); + console.log( + `${label("repository")}${chalk.cyan(`https://github.com/${toolUtils.repo}`)}`, + ); + + if (versions.length > 0) { + let versionsDisplay = versions.slice(-10).reverse().join(", "); + let extra = + versions.length > 10 + ? ` ${chalk.gray(`(+${versions.length - 10} more)`)}` + : ""; + console.log(""); + console.log(`${label("versions")}${versionsDisplay}${extra}`); + } + + console.log(""); +} + // return current version from mops.toml async function bin(tool: Tool, { fallback = false } = {}): Promise { let hasConfig = getClosestConfigFile(); @@ -399,6 +457,7 @@ export let toolchain = { use, update, bin, + info, installAll, checkToolchainInited, }; diff --git a/cli/commands/toolchain/release-tags.ts b/cli/commands/toolchain/release-tags.ts new file mode 100644 index 00000000..fb886570 --- /dev/null +++ b/cli/commands/toolchain/release-tags.ts @@ -0,0 +1,26 @@ +import { SemVer } from "semver"; + +export type ReleaseInfo = { + tag_name: string; + published_at: string | null; + prerelease: boolean; + draft: boolean; +}; + +export let sortReleaseTags = (tags: string[]): string[] => { + return [...tags].sort((a, b) => { + try { + return new SemVer(a).compare(new SemVer(b)); + } catch { + return a.localeCompare(b); + } + }); +}; + +export let stableReleaseTags = (releases: ReleaseInfo[]): string[] => { + return sortReleaseTags( + releases + .filter((release) => !release.draft && !release.prerelease) + .map((release) => release.tag_name), + ); +}; diff --git a/cli/commands/toolchain/toolchain-utils.ts b/cli/commands/toolchain/toolchain-utils.ts index 05b3e9cb..e4a2f3b0 100644 --- a/cli/commands/toolchain/toolchain-utils.ts +++ b/cli/commands/toolchain/toolchain-utils.ts @@ -11,6 +11,10 @@ import { Octokit } from "octokit"; import { extract as extractTar } from "tar"; import { getRootDir } from "../../mops.js"; +import { stableReleaseTags, type ReleaseInfo } from "./release-tags.js"; + +export type { ReleaseInfo } from "./release-tags.js"; +export { sortReleaseTags, stableReleaseTags } from "./release-tags.js"; export const TOOLCHAINS = ["moc", "wasmtime", "pocket-ic", "lintoko"]; @@ -75,19 +79,78 @@ export let downloadAndExtract = async ( deleteSync([tmpDir], { force: true }); }; +export let getAllReleases = async (repo: string): Promise => { + let octokit = new Octokit(); + let releases: ReleaseInfo[] = []; + + for (let page = 1; ; page++) { + let res = await octokit.request(`GET /repos/${repo}/releases`, { + per_page: 100, + page, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (res.status !== 200) { + console.error("Releases fetch error"); + process.exit(1); + } + if (res.data.length === 0) { + break; + } + for (let release of res.data) { + releases.push({ + tag_name: release.tag_name.replace(/^v/, ""), + published_at: release.published_at, + prerelease: release.prerelease, + draft: release.draft, + }); + } + if (res.data.length < 100) { + break; + } + } + + return releases; +}; + +export let getAllReleaseTags = async (repo: string): Promise => { + return stableReleaseTags(await getAllReleases(repo)); +}; + export let getLatestReleaseTag = async (repo: string): Promise => { - let releases = await getReleases(repo); - let release = releases.find( - (release: any) => !release.prerelease && !release.draft, - ); - if (!release?.tag_name) { - console.error(`Failed to fetch latest release tag for ${repo}`); - process.exit(1); + let octokit = new Octokit(); + + for (let page = 1; ; page++) { + let res = await octokit.request(`GET /repos/${repo}/releases`, { + per_page: 100, + page, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (res.status !== 200) { + console.error("Releases fetch error"); + process.exit(1); + } + if (res.data.length === 0) { + break; + } + for (let release of res.data) { + if (!release.draft && !release.prerelease) { + return release.tag_name.replace(/^v/, ""); + } + } + if (res.data.length < 100) { + break; + } } - return release.tag_name.replace(/^v/, ""); + + console.error(`Failed to fetch latest release tag for ${repo}`); + process.exit(1); }; -export let getReleases = async (repo: string) => { +export let getReleases = async (repo: string): Promise => { let octokit = new Octokit(); let res = await octokit.request(`GET /repos/${repo}/releases`, { per_page: 10, @@ -96,13 +159,20 @@ export let getReleases = async (repo: string) => { }, }); if (res.status !== 200) { - console.log("Releases fetch error"); + console.error("Releases fetch error"); process.exit(1); } - return res.data.map((release: any) => { - return { - ...release, + return res.data.map( + (release: { + tag_name: string; + published_at: string | null; + prerelease: boolean; + draft: boolean; + }): ReleaseInfo => ({ tag_name: release.tag_name.replace(/^v/, ""), - }; - }); + published_at: release.published_at, + prerelease: release.prerelease, + draft: release.draft, + }), + ); }; diff --git a/cli/tests/release-tags.test.ts b/cli/tests/release-tags.test.ts new file mode 100644 index 00000000..cddf33c8 --- /dev/null +++ b/cli/tests/release-tags.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "@jest/globals"; +import { + sortReleaseTags, + stableReleaseTags, + type ReleaseInfo, +} from "../commands/toolchain/release-tags"; + +describe("toolchain-utils release tags", () => { + test("sortReleaseTags orders semver ascending", () => { + expect(sortReleaseTags(["1.2.0", "0.9.0", "1.10.0", "1.0.0"])).toEqual([ + "0.9.0", + "1.0.0", + "1.2.0", + "1.10.0", + ]); + }); + + test("stableReleaseTags excludes drafts and prereleases", () => { + let releases: ReleaseInfo[] = [ + { + tag_name: "1.1.0", + published_at: "2024-01-03T00:00:00Z", + prerelease: false, + draft: false, + }, + { + tag_name: "1.2.0-beta", + published_at: "2024-01-02T00:00:00Z", + prerelease: true, + draft: false, + }, + { + tag_name: "1.0.0", + published_at: "2024-01-01T00:00:00Z", + prerelease: false, + draft: true, + }, + ]; + + expect(stableReleaseTags(releases)).toEqual(["1.1.0"]); + }); +}); diff --git a/docs/docs/cli/5-toolchain/01-toolchain-overview.md b/docs/docs/cli/5-toolchain/01-toolchain-overview.md index b25e2f7a..2531d60c 100644 --- a/docs/docs/cli/5-toolchain/01-toolchain-overview.md +++ b/docs/docs/cli/5-toolchain/01-toolchain-overview.md @@ -65,5 +65,6 @@ lintoko = "../custom-lintoko/bin/lintoko" - [`mops toolchain init`](/cli/mops-toolchain-init) - [`mops toolchain use`](/cli/mops-toolchain-use) - [`mops toolchain update`](/cli/mops-toolchain-update) +- [`mops toolchain info`](/cli/mops-toolchain-info) - [`mops toolchain bin`](/cli/mops-toolchain-bin) - [`mops toolchain reset`](/cli/mops-toolchain-reset) \ No newline at end of file diff --git a/docs/docs/cli/5-toolchain/07-mops-toolchain-info.md b/docs/docs/cli/5-toolchain/07-mops-toolchain-info.md new file mode 100644 index 00000000..77b101c2 --- /dev/null +++ b/docs/docs/cli/5-toolchain/07-mops-toolchain-info.md @@ -0,0 +1,50 @@ +--- +slug: /cli/mops-toolchain-info +sidebar_label: mops toolchain info +--- + +# `mops toolchain info` + +Show release information about a toolchain tool from GitHub. + +``` +mops toolchain info +``` + +`` is one of `moc`, `lintoko`, `wasmtime`, or `pocket-ic`. + +Works without a `mops.toml` — useful for scripts that warm tool caches. + +### Examples + +Show info for `moc`: +``` +mops toolchain info moc +``` + +Show info for `lintoko`: +``` +mops toolchain info lintoko +``` + +## Options + +### `--versions` + +Print all stable release versions, one per line (oldest to newest). Useful for scripting. + +``` +mops toolchain info moc --versions +``` + +Prereleases and drafts are excluded. Stable versions match what `mops toolchain update` and `mops toolchain use latest` resolve to (first stable GitHub release). Interactive `mops toolchain use` without a version may also list prereleases in its picker. + +For aggressive cache warming that lists many tools or full release histories, set `GITHUB_TOKEN` to avoid GitHub API rate limits. + +## Output + +Displays: +- Latest stable release +- Pinned version from `[toolchain]` in `mops.toml` (when run inside a project) +- GitHub repository link +- Recent version history