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..df1cae5f 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`. 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..cfe1c1f7 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -627,14 +627,26 @@ program program .command("outdated") .description("Print outdated dependencies specified in mops.toml") - .action(async () => { - await outdated(); + .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); }); // update program .command("update [pkg]") .description("Update dependencies specified in mops.toml") + .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([ "update", diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index d4a8ae9a..a55f1e07 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,8 +50,12 @@ export async function getAvailableUpdates( pinnedVersion.split(".").length === 1 ? { minor: null } : { patch: null }; + } else if (bound === "caret") { + // 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/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..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 () => { @@ -129,3 +129,88 @@ 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 = 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).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/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..829d604f 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 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` → also allows `3.0.0` or later, once published + ### `--lock` What to do with the [lockfile](/mops.lock).