From 1cdca589b475b7ebadc9dade391a85b46d7eadeb Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 24 Apr 2026 15:57:32 +0200 Subject: [PATCH 1/2] fix(cli): caret-bound `mops update` and `mops outdated` by default Add `--major` flag to opt back into cross-major updates. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 7 ++- AGENTS.md | 1 + cli/CHANGELOG.md | 1 + cli/cli.ts | 12 +++- cli/commands/available-updates.ts | 7 +++ cli/commands/outdated.ts | 8 ++- cli/commands/update.ts | 12 +++- cli/tests/cli.test.ts | 80 ++++++++++++++++++++++++ cli/tests/install/update-bound/mops.toml | 3 + docs/docs/cli/1-deps/03-mops-outdated.md | 11 +++- docs/docs/cli/1-deps/04-mops-update.md | 12 +++- 11 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 cli/tests/install/update-bound/mops.toml diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index b90659ac..18117b60 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -159,9 +159,10 @@ mops remove base ### Dependency Management ```bash -mops outdated # list outdated dependencies -mops update # update all to latest compatible -mops update core # update specific package +mops outdated # list outdated dependencies (caret-bound) +mops update # update all within caret bound (no major-version crossing) +mops update core # update specific package within caret bound +mops update --major # allow updates that cross major versions mops sync # add missing / remove unused packages ``` diff --git a/AGENTS.md b/AGENTS.md index e05535fa..3b0b2edf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This file provides guidance to AI coding agents when working with code in this r - **Keep docs in sync.** CLI command docs live in `docs/docs/cli/` and config reference in `docs/docs/09-mops.toml.md`. The same feature often appears in both — update all relevant pages. - **Update the changelog.** Add entries under `## Next` in `cli/CHANGELOG.md` for any user-facing CLI changes. - **Keep skills up to date.** When changing CLI commands or workflows, update `.agents/skills/mops-cli/SKILL.md` to match. +- **`base` is deprecated.** Use `core` for all new code, examples, and docs. - **Pre-commit hook** runs `lint-staged + npm run check` via husky — fix TypeScript/lint errors before committing. - **Snapshot testing strategy**: Use Jest snapshots (`cliSnapshot` / `toMatchSnapshot`) for the main use cases so the full CLI output is committed and reviewable. Corner-case and error-path tests should use targeted assertions (`toMatch`, `toBe`) without snapshots to avoid cluttering the snapshot file. diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index c1198531..191780aa 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,7 @@ # Mops CLI Changelog ## Next +- Fix `mops update` and `mops outdated` jumping across major versions (or pre-1.0 minor versions) — they are now caret-bound by default, matching `cargo update` and `npm update`. For example, `core = "2.0.0"` now updates within `2.x.y` instead of jumping to a future `3.0.0`. Use `--major` to opt into cross-major updates. ## 2.12.3 - Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515) diff --git a/cli/cli.ts b/cli/cli.ts index c02f5c1b..a0951d72 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -627,14 +627,22 @@ program program .command("outdated") .description("Print outdated dependencies specified in mops.toml") - .action(async () => { - await outdated(); + .option( + "--major", + "Allow updates that cross major versions (or pre-1.0 minor versions)", + ) + .action(async (options) => { + await outdated(options); }); // update program .command("update [pkg]") .description("Update dependencies specified in mops.toml") + .option( + "--major", + "Allow updates that cross major versions (or pre-1.0 minor versions)", + ) .addOption( new Option("--lock ", "Lockfile action").choices([ "update", diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index d4a8ae9a..3936ffeb 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -1,14 +1,18 @@ import process from "node:process"; import chalk from "chalk"; +import semver from "semver"; import { mainActor } from "../api/actors.js"; import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; +export type UpdateBound = "caret" | "major"; + // [pkg, oldVersion, newVersion] export async function getAvailableUpdates( config: Config, pkg?: string, + bound: UpdateBound = "caret", ): Promise> { let deps = Object.values(config.dependencies || {}); let devDeps = Object.values(config["dev-dependencies"] || {}); @@ -46,6 +50,9 @@ export async function getAvailableUpdates( pinnedVersion.split(".").length === 1 ? { minor: null } : { patch: null }; + } else if (bound === "caret") { + let major = semver.major(dep.version || "0.0.0"); + semverPart = major === 0 ? { patch: null } : { minor: null }; } return [name, dep.version || "", semverPart]; }), diff --git a/cli/commands/outdated.ts b/cli/commands/outdated.ts index 7faebf15..4cbf37b4 100644 --- a/cli/commands/outdated.ts +++ b/cli/commands/outdated.ts @@ -3,13 +3,17 @@ import { checkConfigFile, readConfig } from "../mops.js"; import { getAvailableUpdates } from "./available-updates.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; -export async function outdated() { +export async function outdated({ major }: { major?: boolean } = {}) { if (!checkConfigFile()) { return; } let config = readConfig(); - let available = await getAvailableUpdates(config); + let available = await getAvailableUpdates( + config, + undefined, + major ? "major" : "caret", + ); if (available.length === 0) { console.log(chalk.green("All dependencies are up to date!")); diff --git a/cli/commands/update.ts b/cli/commands/update.ts index 86620874..8d362d8c 100644 --- a/cli/commands/update.ts +++ b/cli/commands/update.ts @@ -14,9 +14,13 @@ type UpdateOptions = { verbose?: boolean; dev?: boolean; lock?: "update" | "ignore"; + major?: boolean; }; -export async function update(pkg?: string, { lock }: UpdateOptions = {}) { +export async function update( + pkg?: string, + { lock, major }: UpdateOptions = {}, +) { if (!checkConfigFile()) { return; } @@ -59,7 +63,11 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) { } // update mops packages - let available = await getAvailableUpdates(config, pkg); + let available = await getAvailableUpdates( + config, + pkg, + major ? "major" : "caret", + ); if (available.length === 0) { if (pkg) { diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index bb9c09b8..9bd2bb9f 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -129,3 +129,83 @@ describe("install", () => { } }); }); + +// `mops update` and `mops outdated` default to caret-bound resolution: stay +// within `0.x.y` (or `1.x.y`) and never cross majors. Fixture pins: +// base = "0.14.5" -> caret bumps within 0.14.x; --major jumps past it +// core = "1.0.0" -> caret stays put (no 1.x.y > 1.0.0); --major jumps to 2.x +describe("update / outdated bounds", () => { + jest.setTimeout(120_000); + + const cwd = path.join(import.meta.dirname, "install/update-bound"); + const tomlFile = path.join(cwd, "mops.toml"); + const original = readFileSync(tomlFile, "utf8"); + + const cleanup = () => { + rmSync(path.join(cwd, "mops.lock"), { force: true }); + rmSync(path.join(cwd, ".mops"), { recursive: true, force: true }); + writeFileSync(tomlFile, original); + }; + + const baseVersion = (toml: string) => + toml.match(/base = "(0\.\d+\.\d+)"/)?.[1]; + const coreMajor = (toml: string) => + parseInt(toml.match(/core = "(\d+)\./)?.[1] ?? "0"); + + test("mops update stays within the caret bound by default", async () => { + cleanup(); + try { + await cli(["install"], { cwd, env: { CI: undefined } }); + const result = await cli(["update"], { cwd, env: { CI: undefined } }); + expect(result.exitCode).toBe(0); + const after = readFileSync(tomlFile, "utf8"); + // base (pre-1.0): bumped within 0.14.x (patch bumps allowed) + expect(baseVersion(after)).toMatch(/^0\.14\./); + expect(baseVersion(after)).not.toBe("0.14.5"); + // core (1.x): no 1.x.y > 1.0.0 published, so no bump across majors + expect(coreMajor(after)).toBe(1); + } finally { + cleanup(); + } + }); + + test("mops update --major crosses the caret bound", async () => { + cleanup(); + try { + await cli(["install"], { cwd, env: { CI: undefined } }); + const result = await cli(["update", "--major"], { + cwd, + env: { CI: undefined }, + }); + expect(result.exitCode).toBe(0); + const after = readFileSync(tomlFile, "utf8"); + // base: jumps past 0.14.x (next minor or major) + const baseMinor = parseInt(after.match(/base = "0\.(\d+)\./)?.[1] ?? "0"); + expect(baseMinor).toBeGreaterThanOrEqual(15); + // core: jumps to 2.x or later + expect(coreMajor(after)).toBeGreaterThanOrEqual(2); + } finally { + cleanup(); + } + }); + + test("mops outdated honors --major flag", async () => { + cleanup(); + try { + await cli(["install"], { cwd, env: { CI: undefined } }); + const caret = await cli(["outdated"], { cwd, env: { CI: undefined } }); + const major = await cli(["outdated", "--major"], { + cwd, + env: { CI: undefined }, + }); + // caret-bound: only base shows up (within 0.14.x); core stays put + expect(caret.stdout).toMatch(/base 0\.14\.5 -> 0\.14\./); + expect(caret.stdout).not.toMatch(/core /); + // --major: both bump across their major bounds + expect(major.stdout).toMatch(/base 0\.14\.5 -> 0\.(1[5-9]|[2-9]\d)/); + expect(major.stdout).toMatch(/core 1\.0\.0 -> [2-9]/); + } finally { + cleanup(); + } + }); +}); diff --git a/cli/tests/install/update-bound/mops.toml b/cli/tests/install/update-bound/mops.toml new file mode 100644 index 00000000..af6114d9 --- /dev/null +++ b/cli/tests/install/update-bound/mops.toml @@ -0,0 +1,3 @@ +[dependencies] +base = "0.14.5" +core = "1.0.0" diff --git a/docs/docs/cli/1-deps/03-mops-outdated.md b/docs/docs/cli/1-deps/03-mops-outdated.md index 37f7346a..194ffbf0 100644 --- a/docs/docs/cli/1-deps/03-mops-outdated.md +++ b/docs/docs/cli/1-deps/03-mops-outdated.md @@ -5,7 +5,16 @@ sidebar_label: mops outdated # `mops outdated` -Print available dependency updates +Print available dependency updates within the caret bound (does not cross major versions, or pre-1.0 minor versions). ``` mops outdated +``` + +## Options + +### `--major` + +Also report updates that cross the caret bound. Mirrors [`mops update --major`](/cli/mops-update#--major). +``` +mops outdated --major ``` \ No newline at end of file diff --git a/docs/docs/cli/1-deps/04-mops-update.md b/docs/docs/cli/1-deps/04-mops-update.md index 571af95d..02bec7c8 100644 --- a/docs/docs/cli/1-deps/04-mops-update.md +++ b/docs/docs/cli/1-deps/04-mops-update.md @@ -5,7 +5,7 @@ sidebar_label: mops update # `mops update` -Update all dependencies +Update all dependencies to the highest semver-compatible version (caret-bound by default — does not cross major versions, or pre-1.0 minor versions). ``` mops update ``` @@ -17,13 +17,19 @@ mops update [pkg] ### Example -Update the `base` package to the latest version: +Update the `core` package to the highest compatible version: ``` -mops update base +mops update core ``` ## Options +### `--major` + +Allow updates that cross major versions (or pre-1.0 minor versions). For example, with `core = "2.0.0"` in `mops.toml`: +- `mops update core` → bumps within `2.x.y` (e.g. `2.5.0`) +- `mops update core --major` → may bump to `3.0.0` or later + ### `--lock` What to do with the [lockfile](/mops.lock). From eb52bb7ae138ef93e9b1e2cb5d088a34dfa33f6e Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 24 Apr 2026 16:25:02 +0200 Subject: [PATCH 2/2] review: tighten outdated test, sharpen --help text Apply pr-reviewer findings: - outdated test now strips ANSI and survives any future core@1.x release - --major addOption matches surrounding style - help text spells out the caret bound semantics - drop dead version fallbacks; align changelog with cargo-only semantics Made-with: Cursor --- cli/CHANGELOG.md | 2 +- cli/cli.ts | 16 +++++++++------ cli/commands/available-updates.ts | 5 +++-- cli/tests/cli.test.ts | 27 +++++++++++++++----------- docs/docs/cli/1-deps/04-mops-update.md | 4 ++-- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 191780aa..df1cae5f 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,7 +1,7 @@ # Mops CLI Changelog ## Next -- Fix `mops update` and `mops outdated` jumping across major versions (or pre-1.0 minor versions) — they are now caret-bound by default, matching `cargo update` and `npm update`. For example, `core = "2.0.0"` now updates within `2.x.y` instead of jumping to a future `3.0.0`. Use `--major` to opt into cross-major updates. +- Fix `mops update` and `mops outdated` jumping across major versions (or pre-1.0 minor versions) — they are now caret-bound by default, matching `cargo update`. For example, `core = "2.0.0"` now updates within `2.x.y` instead of jumping to a future `3.0.0`. Use `--major` to opt into cross-major updates. ## 2.12.3 - Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515) diff --git a/cli/cli.ts b/cli/cli.ts index a0951d72..cfe1c1f7 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -627,9 +627,11 @@ program program .command("outdated") .description("Print outdated dependencies specified in mops.toml") - .option( - "--major", - "Allow updates that cross major versions (or pre-1.0 minor versions)", + .addOption( + new Option( + "--major", + "Allow updates that cross the caret bound (major versions, or for 0.x.y packages, minor versions)", + ), ) .action(async (options) => { await outdated(options); @@ -639,9 +641,11 @@ program program .command("update [pkg]") .description("Update dependencies specified in mops.toml") - .option( - "--major", - "Allow updates that cross major versions (or pre-1.0 minor versions)", + .addOption( + new Option( + "--major", + "Allow updates that cross the caret bound (major versions, or for 0.x.y packages, minor versions)", + ), ) .addOption( new Option("--lock ", "Lockfile action").choices([ diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index 3936ffeb..a55f1e07 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -51,10 +51,11 @@ export async function getAvailableUpdates( ? { minor: null } : { patch: null }; } else if (bound === "caret") { - let major = semver.major(dep.version || "0.0.0"); + // Caret (cargo-style): ^0.x.y -> 0.x.* (patch only); ^1+ -> same major (minor+patch) + let major = semver.major(dep.version!); semverPart = major === 0 ? { patch: null } : { minor: null }; } - return [name, dep.version || "", semverPart]; + return [name, dep.version!, semverPart]; }), ); diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index 9bd2bb9f..0aff487f 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -1,7 +1,7 @@ import { describe, expect, jest, test } from "@jest/globals"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "path"; -import { cli } from "./helpers"; +import { cli, normalizePaths } from "./helpers"; describe("cli", () => { test("--version", async () => { @@ -193,17 +193,22 @@ describe("update / outdated bounds", () => { cleanup(); try { await cli(["install"], { cwd, env: { CI: undefined } }); - const caret = await cli(["outdated"], { cwd, env: { CI: undefined } }); - const major = await cli(["outdated", "--major"], { - cwd, - env: { CI: undefined }, - }); - // caret-bound: only base shows up (within 0.14.x); core stays put - expect(caret.stdout).toMatch(/base 0\.14\.5 -> 0\.14\./); - expect(caret.stdout).not.toMatch(/core /); + const caret = normalizePaths( + (await cli(["outdated"], { cwd, env: { CI: undefined } })).stdout, + ); + const major = normalizePaths( + (await cli(["outdated", "--major"], { cwd, env: { CI: undefined } })) + .stdout, + ); + // caret-bound: base bumps within 0.14.x; core (if reported) stays in 1.x + expect(caret).toMatch(/base 0\.14\.5 -> 0\.14\./); + const caretCore = caret.match(/core 1\.0\.0 -> (\d+)\./)?.[1]; + if (caretCore) { + expect(parseInt(caretCore)).toBe(1); + } // --major: both bump across their major bounds - expect(major.stdout).toMatch(/base 0\.14\.5 -> 0\.(1[5-9]|[2-9]\d)/); - expect(major.stdout).toMatch(/core 1\.0\.0 -> [2-9]/); + expect(major).toMatch(/base 0\.14\.5 -> 0\.(1[5-9]|[2-9]\d)/); + expect(major).toMatch(/core 1\.0\.0 -> [2-9]/); } finally { cleanup(); } diff --git a/docs/docs/cli/1-deps/04-mops-update.md b/docs/docs/cli/1-deps/04-mops-update.md index 02bec7c8..829d604f 100644 --- a/docs/docs/cli/1-deps/04-mops-update.md +++ b/docs/docs/cli/1-deps/04-mops-update.md @@ -26,9 +26,9 @@ mops update core ### `--major` -Allow updates that cross major versions (or pre-1.0 minor versions). For example, with `core = "2.0.0"` in `mops.toml`: +Allow updates that cross the caret bound — major versions, or for `0.x.y` packages, minor versions. For example, with `core = "2.0.0"` in `mops.toml`: - `mops update core` → bumps within `2.x.y` (e.g. `2.5.0`) -- `mops update core --major` → may bump to `3.0.0` or later +- `mops update core --major` → also allows `3.0.0` or later, once published ### `--lock`