Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
16 changes: 14 additions & 2 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <action>", "Lockfile action").choices([
"update",
Expand Down
10 changes: 9 additions & 1 deletion cli/commands/available-updates.ts
Original file line number Diff line number Diff line change
@@ -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<Array<[string, string, string]>> {
let deps = Object.values(config.dependencies || {});
let devDeps = Object.values(config["dev-dependencies"] || {});
Expand Down Expand Up @@ -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];
}),
);

Expand Down
8 changes: 6 additions & 2 deletions cli/commands/outdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!"));
Expand Down
12 changes: 10 additions & 2 deletions cli/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
87 changes: 86 additions & 1 deletion cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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();
}
});
});
3 changes: 3 additions & 0 deletions cli/tests/install/update-bound/mops.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[dependencies]
base = "0.14.5"
core = "1.0.0"
11 changes: 10 additions & 1 deletion docs/docs/cli/1-deps/03-mops-outdated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
12 changes: 9 additions & 3 deletions docs/docs/cli/1-deps/04-mops-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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).
Expand Down
Loading