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
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Next

- `mops self update` no longer crosses major versions on its own, since a new major contains breaking changes. It prints the release-notes link and asks for confirmation in a terminal; in non-interactive environments it skips the update with a notice and exits successfully, so scripted updates keep working and stay on their major. Pass `--major` to update across majors. Updates within the same major are unchanged.

- Fix `moc-wrapper` caching a failed compiler lookup. In a project with no `[toolchain] moc` and no `dfx` on `PATH`, it wrote an empty `.mops/moc-<host>-<hash>` file and then ran the empty string, so every later invocation failed with `--version: command not found` instead of naming the problem. It now leaves no cache entry when the lookup fails and reports `could not resolve moc`, pointing at `mops toolchain use moc <version>`. Projects that pin `[toolchain] moc`, and anyone with dfx installed, are unaffected.

## 2.20.0
Expand Down
8 changes: 6 additions & 2 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,8 +990,12 @@ const selfCommand = new Command("self").description("Mops CLI management");
selfCommand
.command("update")
.description("Update mops CLI to the latest version")
.action(async () => {
await self.update();
.option(
"--major",
"Allow updating across major versions without confirmation (major releases contain breaking changes)",
)
.action(async (options: { major?: boolean }) => {
await self.update(options);
});

selfCommand
Expand Down
61 changes: 59 additions & 2 deletions cli/commands/self.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import process from "node:process";
import child_process, { execSync } from "node:child_process";
import chalk from "chalk";
import prompts from "prompts";
import { version, globalConfigDir } from "../mops.js";
import { cleanCache } from "../cache.js";
import { toolchain } from "./toolchain/index.js";
import { classifySelfUpdate } from "../helpers/self-update-kind.js";

let url = "https://x344g-ziaaa-aaaap-abl7a-cai.icp0.io";

Expand Down Expand Up @@ -32,14 +34,69 @@ export async function getLatestVersion() {
return (await res.text()).trim();
}

export async function update() {
// A new major means breaking changes, so crossing one is a decision, not a
// routine refresh — confirmed in a terminal, `--major` everywhere else.
async function confirmMajorUpdate(latest: string): Promise<boolean> {
console.log(
chalk.yellow(
`Version ${latest} is a new major release with breaking changes:`,
),
);
console.log(
`https://github.com/caffeinelabs/mops/releases/tag/cli-v${latest}`,
);

// Not an error: a script running `mops self update` must keep succeeding
// (and staying on its major) after the new major ships, not turn red until
// someone edits it.
if (!process.stdout.isTTY) {
console.log(
`Skipping the major update. Run ${chalk.green("mops self update --major")} to update.`,
);
return false;
}

let { confirm } = await prompts(
{
type: "confirm",
name: "confirm",
message: `Update to ${latest}?`,
initial: false,
},
{
onCancel() {
console.log("aborted");
process.exit(0);
},
},
);
return confirm;
}

export async function update({ major = false } = {}) {
let latest = await getLatestVersion();
let current = version();
let kind = classifySelfUpdate(current, latest);

if (latest === current) {
if (kind === "up-to-date") {
console.log(chalk.green("You are up to date. Version: " + current));
} else {
// An unparseable tag means the release server is serving something
// broken — refuse rather than npm-install whatever it said.
if (kind === "invalid") {
console.error(
chalk.red("Error: ") +
`expected a version from ${url}/tags/latest, got ${JSON.stringify(latest)}.`,
);
process.exit(1);
}

console.log("Current version: " + chalk.yellow(current));

if (kind === "major" && !major && !(await confirmMajorUpdate(latest))) {
return;
}

console.log("Updating to version: " + chalk.green(latest));

let pm = detectPackageManager();
Expand Down
20 changes: 20 additions & 0 deletions cli/helpers/self-update-kind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import semver from "semver";

export type SelfUpdateKind = "up-to-date" | "same-major" | "major" | "invalid";

// A new major means breaking changes, so `mops self update` treats crossing
// one as a decision to confirm, not a routine refresh.
export function classifySelfUpdate(
current: string,
latest: string,
): SelfUpdateKind {
if (latest === current) {
return "up-to-date";
}
if (!semver.valid(latest)) {
return "invalid";
}
return semver.major(latest) === semver.major(current)
? "same-major"
: "major";
}
5 changes: 5 additions & 0 deletions cli/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export default {
"<rootDir>/bundle/",
"<rootDir>/commands/"
],
// Source files import each other with .js suffixes (ESM), which jest cannot
// resolve back to the .ts sources without this mapping.
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
transform: {
"^.+\\.tsx?$": ["ts-jest", { useESM: true }],
},
Expand Down
37 changes: 37 additions & 0 deletions cli/tests/self-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "@jest/globals";
import { classifySelfUpdate } from "../helpers/self-update-kind.js";

// The network fetch and the npm install around this are not testable here;
// the decision table is, and it is what gates the major-update confirmation.
describe("classifySelfUpdate", () => {
test("identical versions are up to date", () => {
expect(classifySelfUpdate("2.20.0", "2.20.0")).toBe("up-to-date");
});

test("minor and patch updates stay in the same major", () => {
expect(classifySelfUpdate("2.20.0", "2.21.0")).toBe("same-major");
expect(classifySelfUpdate("2.20.0", "2.20.1")).toBe("same-major");
});

test("a new major requires confirmation", () => {
expect(classifySelfUpdate("2.20.0", "3.0.0")).toBe("major");
});

test("a major downgrade also requires confirmation", () => {
expect(classifySelfUpdate("3.0.0", "2.20.0")).toBe("major");
});

test("a prerelease of the next major counts as a major", () => {
expect(classifySelfUpdate("2.20.0", "3.0.0-beta.1")).toBe("major");
});

test("prerelease to release of the same major does not prompt", () => {
expect(classifySelfUpdate("3.0.0-beta.1", "3.0.0")).toBe("same-major");
});

test("a non-version tag is rejected", () => {
expect(classifySelfUpdate("2.20.0", "")).toBe("invalid");
expect(classifySelfUpdate("2.20.0", "<html>error</html>")).toBe("invalid");
expect(classifySelfUpdate("2.20.0", "latest")).toBe("invalid");
});
});
14 changes: 13 additions & 1 deletion docs/docs/cli/6-self/01-mops-self-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@ Update the Mops CLI to the latest version.

```
mops self update
```
```

When the latest version is a new **major** release, it contains breaking changes, so `mops self update` asks for confirmation and links the release notes instead of updating right away.

## `--major`

Skip the confirmation and update across major versions. This is also the only way to cross a major non-interactively — in a non-terminal environment (CI, scripts), `mops self update` prints the notice and exits successfully **without updating** unless `--major` is passed, so a scripted update never absorbs a major silently and never starts failing when one is released:

```
mops self update --major
```

Updates within the same major (new minor or patch versions) never prompt.
Loading