Skip to content

Commit 2e1b346

Browse files
authored
fix(cli): caret-bound mops update and mops outdated by default (#521)
## Problem `mops update` and `mops outdated` jumped across major versions by default. Pinning `core = "2.0.0"` and running `mops update` would happily move you to a future `3.0.0` — silently breaking the project. This is the opposite of what `cargo update` and `npm update` do, and the opposite of what users expect from "update". ## Fix Both commands are now caret-bound by default: - `0.x.y` → bumps within `0.x.y` (patch only — pre-1.0 minors are treated as breaking, matching cargo/npm) - `1.x.y` and above → bumps within `<major>.x.y` (minor + patch) Old behavior is one flag away: `--major` opts back into cross-major updates (same flag for both commands for consistency). Hard-pinned (`pkg@x.y.z`) and prefix-pinned (`pkg@x` / `pkg@x.y`) deps are unchanged — pinning already encodes the bound the user wants. ## Why "fix" and not "breaking" The previous default was a footgun, not a contract. Anyone who actually wanted cross-major bumps was either burned by it or never noticed. The escape hatch (`--major`) is one flag away. ## Test plan - [x] `mops update` (default) bumps `base 0.14.5` within `0.14.x` and leaves `core 1.0.0` alone (no `1.x.y > 1.0.0` exists) - [x] `mops update --major` bumps both past their major bounds - [x] `mops outdated` mirrors the same behavior with `--major` parity - [x] Lint + typecheck clean - [x] `--help` for both commands shows `--major` ## Docs - `docs/docs/cli/1-deps/04-mops-update.md` — documents `--major` and the new default - `docs/docs/cli/1-deps/03-mops-outdated.md` — same - `cli/CHANGELOG.md` — single-line fix entry under `## Next` - `.agents/skills/mops-cli/SKILL.md` — updated cheat-sheet - `AGENTS.md` — added a note that `base` is deprecated; new examples should use `core` (caught while writing this PR's docs) Made with [Cursor](https://cursor.com)
1 parent d2db922 commit 2e1b346

11 files changed

Lines changed: 153 additions & 15 deletions

File tree

.agents/skills/mops-cli/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,10 @@ mops remove base
159159
### Dependency Management
160160

161161
```bash
162-
mops outdated # list outdated dependencies
163-
mops update # update all to latest compatible
164-
mops update core # update specific package
162+
mops outdated # list outdated dependencies (caret-bound)
163+
mops update # update all within caret bound (no major-version crossing)
164+
mops update core # update specific package within caret bound
165+
mops update --major # allow updates that cross major versions
165166
mops sync # add missing / remove unused packages
166167
```
167168

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This file provides guidance to AI coding agents when working with code in this r
99
- **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.
1010
- **Update the changelog.** Add entries under `## Next` in `cli/CHANGELOG.md` for any user-facing CLI changes.
1111
- **Keep skills up to date.** When changing CLI commands or workflows, update `.agents/skills/mops-cli/SKILL.md` to match.
12+
- **`base` is deprecated.** Use `core` for all new code, examples, and docs.
1213
- **Pre-commit hook** runs `lint-staged + npm run check` via husky — fix TypeScript/lint errors before committing.
1314
- **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.
1415

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Mops CLI Changelog
22

33
## Next
4+
- 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.
45

56
## 2.12.3
67
- Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515)

cli/cli.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -627,14 +627,26 @@ program
627627
program
628628
.command("outdated")
629629
.description("Print outdated dependencies specified in mops.toml")
630-
.action(async () => {
631-
await outdated();
630+
.addOption(
631+
new Option(
632+
"--major",
633+
"Allow updates that cross the caret bound (major versions, or for 0.x.y packages, minor versions)",
634+
),
635+
)
636+
.action(async (options) => {
637+
await outdated(options);
632638
});
633639

634640
// update
635641
program
636642
.command("update [pkg]")
637643
.description("Update dependencies specified in mops.toml")
644+
.addOption(
645+
new Option(
646+
"--major",
647+
"Allow updates that cross the caret bound (major versions, or for 0.x.y packages, minor versions)",
648+
),
649+
)
638650
.addOption(
639651
new Option("--lock <action>", "Lockfile action").choices([
640652
"update",

cli/commands/available-updates.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import process from "node:process";
22
import chalk from "chalk";
3+
import semver from "semver";
34
import { mainActor } from "../api/actors.js";
45
import { Config } from "../types.js";
56
import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js";
67
import { SemverPart } from "../declarations/main/main.did.js";
78

9+
export type UpdateBound = "caret" | "major";
10+
811
// [pkg, oldVersion, newVersion]
912
export async function getAvailableUpdates(
1013
config: Config,
1114
pkg?: string,
15+
bound: UpdateBound = "caret",
1216
): Promise<Array<[string, string, string]>> {
1317
let deps = Object.values(config.dependencies || {});
1418
let devDeps = Object.values(config["dev-dependencies"] || {});
@@ -46,8 +50,12 @@ export async function getAvailableUpdates(
4650
pinnedVersion.split(".").length === 1
4751
? { minor: null }
4852
: { patch: null };
53+
} else if (bound === "caret") {
54+
// Caret (cargo-style): ^0.x.y -> 0.x.* (patch only); ^1+ -> same major (minor+patch)
55+
let major = semver.major(dep.version!);
56+
semverPart = major === 0 ? { patch: null } : { minor: null };
4957
}
50-
return [name, dep.version || "", semverPart];
58+
return [name, dep.version!, semverPart];
5159
}),
5260
);
5361

cli/commands/outdated.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ import { checkConfigFile, readConfig } from "../mops.js";
33
import { getAvailableUpdates } from "./available-updates.js";
44
import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js";
55

6-
export async function outdated() {
6+
export async function outdated({ major }: { major?: boolean } = {}) {
77
if (!checkConfigFile()) {
88
return;
99
}
1010
let config = readConfig();
1111

12-
let available = await getAvailableUpdates(config);
12+
let available = await getAvailableUpdates(
13+
config,
14+
undefined,
15+
major ? "major" : "caret",
16+
);
1317

1418
if (available.length === 0) {
1519
console.log(chalk.green("All dependencies are up to date!"));

cli/commands/update.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ type UpdateOptions = {
1414
verbose?: boolean;
1515
dev?: boolean;
1616
lock?: "update" | "ignore";
17+
major?: boolean;
1718
};
1819

19-
export async function update(pkg?: string, { lock }: UpdateOptions = {}) {
20+
export async function update(
21+
pkg?: string,
22+
{ lock, major }: UpdateOptions = {},
23+
) {
2024
if (!checkConfigFile()) {
2125
return;
2226
}
@@ -59,7 +63,11 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) {
5963
}
6064

6165
// update mops packages
62-
let available = await getAvailableUpdates(config, pkg);
66+
let available = await getAvailableUpdates(
67+
config,
68+
pkg,
69+
major ? "major" : "caret",
70+
);
6371

6472
if (available.length === 0) {
6573
if (pkg) {

cli/tests/cli.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, jest, test } from "@jest/globals";
22
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
33
import path from "path";
4-
import { cli } from "./helpers";
4+
import { cli, normalizePaths } from "./helpers";
55

66
describe("cli", () => {
77
test("--version", async () => {
@@ -129,3 +129,88 @@ describe("install", () => {
129129
}
130130
});
131131
});
132+
133+
// `mops update` and `mops outdated` default to caret-bound resolution: stay
134+
// within `0.x.y` (or `1.x.y`) and never cross majors. Fixture pins:
135+
// base = "0.14.5" -> caret bumps within 0.14.x; --major jumps past it
136+
// core = "1.0.0" -> caret stays put (no 1.x.y > 1.0.0); --major jumps to 2.x
137+
describe("update / outdated bounds", () => {
138+
jest.setTimeout(120_000);
139+
140+
const cwd = path.join(import.meta.dirname, "install/update-bound");
141+
const tomlFile = path.join(cwd, "mops.toml");
142+
const original = readFileSync(tomlFile, "utf8");
143+
144+
const cleanup = () => {
145+
rmSync(path.join(cwd, "mops.lock"), { force: true });
146+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
147+
writeFileSync(tomlFile, original);
148+
};
149+
150+
const baseVersion = (toml: string) =>
151+
toml.match(/base = "(0\.\d+\.\d+)"/)?.[1];
152+
const coreMajor = (toml: string) =>
153+
parseInt(toml.match(/core = "(\d+)\./)?.[1] ?? "0");
154+
155+
test("mops update stays within the caret bound by default", async () => {
156+
cleanup();
157+
try {
158+
await cli(["install"], { cwd, env: { CI: undefined } });
159+
const result = await cli(["update"], { cwd, env: { CI: undefined } });
160+
expect(result.exitCode).toBe(0);
161+
const after = readFileSync(tomlFile, "utf8");
162+
// base (pre-1.0): bumped within 0.14.x (patch bumps allowed)
163+
expect(baseVersion(after)).toMatch(/^0\.14\./);
164+
expect(baseVersion(after)).not.toBe("0.14.5");
165+
// core (1.x): no 1.x.y > 1.0.0 published, so no bump across majors
166+
expect(coreMajor(after)).toBe(1);
167+
} finally {
168+
cleanup();
169+
}
170+
});
171+
172+
test("mops update --major crosses the caret bound", async () => {
173+
cleanup();
174+
try {
175+
await cli(["install"], { cwd, env: { CI: undefined } });
176+
const result = await cli(["update", "--major"], {
177+
cwd,
178+
env: { CI: undefined },
179+
});
180+
expect(result.exitCode).toBe(0);
181+
const after = readFileSync(tomlFile, "utf8");
182+
// base: jumps past 0.14.x (next minor or major)
183+
const baseMinor = parseInt(after.match(/base = "0\.(\d+)\./)?.[1] ?? "0");
184+
expect(baseMinor).toBeGreaterThanOrEqual(15);
185+
// core: jumps to 2.x or later
186+
expect(coreMajor(after)).toBeGreaterThanOrEqual(2);
187+
} finally {
188+
cleanup();
189+
}
190+
});
191+
192+
test("mops outdated honors --major flag", async () => {
193+
cleanup();
194+
try {
195+
await cli(["install"], { cwd, env: { CI: undefined } });
196+
const caret = normalizePaths(
197+
(await cli(["outdated"], { cwd, env: { CI: undefined } })).stdout,
198+
);
199+
const major = normalizePaths(
200+
(await cli(["outdated", "--major"], { cwd, env: { CI: undefined } }))
201+
.stdout,
202+
);
203+
// caret-bound: base bumps within 0.14.x; core (if reported) stays in 1.x
204+
expect(caret).toMatch(/base 0\.14\.5 -> 0\.14\./);
205+
const caretCore = caret.match(/core 1\.0\.0 -> (\d+)\./)?.[1];
206+
if (caretCore) {
207+
expect(parseInt(caretCore)).toBe(1);
208+
}
209+
// --major: both bump across their major bounds
210+
expect(major).toMatch(/base 0\.14\.5 -> 0\.(1[5-9]|[2-9]\d)/);
211+
expect(major).toMatch(/core 1\.0\.0 -> [2-9]/);
212+
} finally {
213+
cleanup();
214+
}
215+
});
216+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[dependencies]
2+
base = "0.14.5"
3+
core = "1.0.0"

docs/docs/cli/1-deps/03-mops-outdated.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@ sidebar_label: mops outdated
55

66
# `mops outdated`
77

8-
Print available dependency updates
8+
Print available dependency updates within the caret bound (does not cross major versions, or pre-1.0 minor versions).
99
```
1010
mops outdated
11+
```
12+
13+
## Options
14+
15+
### `--major`
16+
17+
Also report updates that cross the caret bound. Mirrors [`mops update --major`](/cli/mops-update#--major).
18+
```
19+
mops outdated --major
1120
```

0 commit comments

Comments
 (0)