Skip to content
Closed
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
@@ -1,6 +1,8 @@
# Mops CLI Changelog

## Next
- Recognize `[experimental]` section in `mops.toml` for opt-in unstable behavior. The section accepts a `flags = [...]` array; flags listed there enable in-progress features whose behavior may change without notice. Older mops CLIs silently ignore the section.
- Add `caret-versions` experimental flag. When enabled, bare versions in the root project's `[dependencies]` and `[dev-dependencies]` resolve as Cargo-style caret ranges — `core = "1.2.3"` picks the highest `1.x.y`, `core = "0.2.3"` the highest `0.2.x`. `mops update` and `mops outdated` honor the same bound. The resolved version is pinned in `mops.lock`.

## 2.12.3
- Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515)
Expand Down
6 changes: 6 additions & 0 deletions cli/commands/available-updates.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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";
import { isExperimentEnabled } from "../experimental.js";

// [pkg, oldVersion, newVersion]
export async function getAvailableUpdates(
Expand Down Expand Up @@ -35,6 +37,7 @@ export async function getAvailableUpdates(
return "";
};

let caretMode = isExperimentEnabled(config, "caret-versions");
let actor = await mainActor();
let res = await actor.getHighestSemverBatch(
depsToUpdate.map((dep) => {
Expand All @@ -46,6 +49,9 @@ export async function getAvailableUpdates(
pinnedVersion.split(".").length === 1
? { minor: null }
: { patch: null };
} else if (caretMode) {
let major = semver.major(dep.version || "0.0.0");
semverPart = major === 0 ? { patch: null } : { minor: null };
}
return [name, dep.version || "", semverPart];
}),
Expand Down
6 changes: 6 additions & 0 deletions cli/experimental.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Config } from "./types.js";

// Opted into via [experimental] flags = [...] in mops.toml. Unstable behavior.
export function isExperimentEnabled(config: Config, flag: string): boolean {
return config.experimental?.flags?.includes(flag) ?? false;
}
43 changes: 33 additions & 10 deletions cli/integrity.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import process from "node:process";
import fs from "node:fs";
import path from "node:path";
import { sha256 } from "@noble/hashes/sha256";
import { sha256 } from "@noble/hashes/sha2";
import { bytesToHex } from "@noble/hashes/utils";
import { getDependencyType, getRootDir, readConfig } from "./mops.js";
import { mainActor } from "./api/actors.js";
Expand All @@ -27,6 +27,8 @@ type LockFileV2 = {
type LockFileV3 = {
version: 3;
mopsTomlDepsHash: string;
// separate from mopsTomlDepsHash so non-experimental projects stay untouched
experimentalHash?: string;
hashes: Record<string, Record<string, string>>;
deps: Record<string, string>;
};
Expand Down Expand Up @@ -107,6 +109,15 @@ function getMopsTomlDepsHash(): string {
return bytesToHex(sha256(JSON.stringify(sortedDeps)));
}

// "" when no flags are set, so the field is omitted from the lockfile
function getExperimentalHash(): string {
let flags = readConfig().experimental?.flags ?? [];
if (flags.length === 0) {
return "";
}
return bytesToHex(sha256(JSON.stringify([...flags].sort())));
}

// compare hashes of local files with hashes from the registry
export async function checkRemote() {
let fileHashesFromRegistry = await getFileHashesFromRegistry();
Expand Down Expand Up @@ -144,17 +155,15 @@ export function readLockFile(): LockFile | null {
return null;
}

// check if lock file exists and integrity of mopsTomlDepsHash
// check if lock file exists and integrity of mopsTomlDepsHash + experimentalHash
export function checkLockFileLight(): boolean {
let existingLockFileJson = readLockFile();
if (existingLockFileJson) {
let mopsTomlDepsHash = getMopsTomlDepsHash();
if (
existingLockFileJson.version === 3 &&
mopsTomlDepsHash === existingLockFileJson.mopsTomlDepsHash
) {
return true;
}
if (existingLockFileJson && existingLockFileJson.version === 3) {
let depsMatch =
getMopsTomlDepsHash() === existingLockFileJson.mopsTomlDepsHash;
let experimentalMatch =
getExperimentalHash() === (existingLockFileJson.experimentalHash ?? "");
return depsMatch && experimentalMatch;
}
return false;
}
Expand All @@ -173,9 +182,11 @@ export async function updateLockFile({

let fileHashes = await getFileHashesFromRegistry();

let experimentalHash = getExperimentalHash();
let lockFileJson: LockFileV3 = {
version: 3,
mopsTomlDepsHash: getMopsTomlDepsHash(),
...(experimentalHash && { experimentalHash }),
deps: resolvedDeps,
hashes: fileHashes.reduce(
(acc, [packageId, fileHashes]) => {
Expand Down Expand Up @@ -256,6 +267,18 @@ export async function checkLockFile(force = false) {
}
}

// V3: check [experimental] hash
if (lockFileJson.version === 3) {
let lockedExperimentalHash = lockFileJson.experimentalHash ?? "";
if (lockedExperimentalHash !== getExperimentalHash()) {
console.error("Integrity check failed");
console.error("Mismatched [experimental] flags hash");
console.error(`Locked hash: ${lockedExperimentalHash || "(none)"}`);
console.error(`Actual hash: ${getExperimentalHash() || "(none)"}`);
process.exit(1);
}
}

// V3: check locked deps (including GitHub and local packages)
if (lockFileJson.version === 3) {
let lockedDeps = { ...lockFileJson.deps };
Expand Down
67 changes: 66 additions & 1 deletion cli/resolve-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import process from "node:process";
import path from "node:path";
import { existsSync } from "node:fs";
import chalk from "chalk";
import semver from "semver";
import {
checkConfigFile,
getRootDir,
Expand All @@ -10,9 +11,19 @@ import {
} from "./mops.js";
import { VesselConfig, readVesselConfig } from "./vessel.js";
import { Config, Dependency } from "./types.js";
import { getDepCacheDir, getDepCacheName } from "./cache.js";
import {
getDepCacheDir,
getDepCacheName,
getMopsDepCacheName,
isDepCached,
} from "./cache.js";
import { getPackageId } from "./helpers/get-package-id.js";
import { getDepName } from "./helpers/get-dep-name.js";
import { checkLockFileLight, readLockFile } from "./integrity.js";
import { isExperimentEnabled } from "./experimental.js";
import { mainActor } from "./api/actors.js";
import { SemverPart } from "./declarations/main/main.did.js";
import { installMopsDep } from "./commands/install/install-mops-dep.js";

export async function resolvePackages({
conflicts = "ignore" as "warning" | "error" | "ignore",
Expand Down Expand Up @@ -178,6 +189,11 @@ export async function resolvePackages({
};

let config = readConfig();

if (isExperimentEnabled(config, "caret-versions")) {
await applyCaretVersions(config);
}

await collectDeps(config, rootDir, true);

// show conflicts
Expand Down Expand Up @@ -231,3 +247,52 @@ export async function resolvePackages({
.filter(([, version]) => version !== ""),
);
}

// Experiment "caret-versions": treat bare versions in the root project's
// [dependencies] / [dev-dependencies] as Cargo-style caret ranges.
// 1.x.y -> highest within major 1; 0.x.y -> highest within 0.x. Aliased
// deps and non-mops deps (github / local) are passed through unchanged.
async function applyCaretVersions(config: Config): Promise<void> {
let rootDeps: Dependency[] = [
...Object.values(config.dependencies || {}),
...Object.values(config["dev-dependencies"] || {}),
];
let candidates = rootDeps.filter(
(dep) =>
dep.version &&
!dep.repo &&
!dep.path &&
getDepName(dep.name) === dep.name,
);
if (candidates.length === 0) {
return;
}

let actor = await mainActor();
let res = await actor.getHighestSemverBatch(
candidates.map((dep) => {
let part: SemverPart =
semver.major(dep.version!) === 0 ? { patch: null } : { minor: null };
return [dep.name, dep.version!, part];
}),
);
if ("err" in res) {
console.error(chalk.red("Error:"), `caret-versions failed: ${res.err}`);
process.exit(1);
}
let resolved = new Map(res.ok);
for (let dep of candidates) {
let upgraded = resolved.get(dep.name);
if (!upgraded || upgraded === dep.version) {
continue;
}
// collectDeps reads transitive mops.toml from the cache, so prefetch
if (!isDepCached(getMopsDepCacheName(dep.name, upgraded))) {
let ok = await installMopsDep(dep.name, upgraded);
if (!ok) {
process.exit(1);
}
}
dep.version = upgraded;
}
}
79 changes: 79 additions & 0 deletions cli/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,85 @@ describe("install", () => {
}
});

// `caret-versions` experiment: bare versions resolve as Cargo-style caret
// ranges. Tested against `core = "1.0.0"` because `core` has 1.0.0 as the
// only 1.x release, so the caret cap is observable: it stays at 1.0.0
// while no-flag `mops update` jumps the major to 2.x.
describe("caret-versions experiment", () => {
const cwd = path.join(import.meta.dirname, "install/caret");
const tomlFile = path.join(cwd, "mops.toml");
const tomlWithFlag = readFileSync(tomlFile, "utf8");
const tomlWithoutFlag = '[dependencies]\ncore = "1.0.0"\n';

const cleanup = () => {
rmSync(path.join(cwd, "mops.lock"), { force: true });
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
writeFileSync(tomlFile, tomlWithFlag);
};

test("install is reproducible: lock pinned, second install no-op", async () => {
cleanup();
try {
const first = await cli(["install"], { cwd, env: { CI: undefined } });
expect(first.exitCode).toBe(0);
const lock1 = readFileSync(path.join(cwd, "mops.lock"), "utf8");
const second = await cli(["install"], { cwd, env: { CI: undefined } });
expect(second.exitCode).toBe(0);
expect(readFileSync(path.join(cwd, "mops.lock"), "utf8")).toBe(lock1);
} finally {
cleanup();
}
});

test("mops update stays within the caret bound", async () => {
cleanup();
try {
await cli(["install"], { cwd, env: { CI: undefined } });
const result = await cli(["update", "core"], {
cwd,
env: { CI: undefined },
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toMatch(/up to date/);
expect(readFileSync(tomlFile, "utf8")).toBe(tomlWithFlag);
} finally {
cleanup();
}
});

test("toggling the flag invalidates the lock (CI mode fails loudly)", async () => {
cleanup();
try {
await cli(["install"], { cwd, env: { CI: undefined } });
writeFileSync(tomlFile, tomlWithoutFlag);
const result = await cli(["install", "--lock", "check"], { cwd });
expect(result.exitCode).toBe(1);
expect(result.stderr).toMatch(/Mismatched \[experimental\] flags hash/);
} finally {
cleanup();
}
});

test("mops update without the flag crosses the major (control)", async () => {
cleanup();
try {
writeFileSync(tomlFile, tomlWithoutFlag);
await cli(["install"], { cwd, env: { CI: undefined } });
const result = await cli(["update", "core"], {
cwd,
env: { CI: undefined },
});
expect(result.exitCode).toBe(0);
const major = readFileSync(tomlFile, "utf8").match(
/core = "(\d+)\./,
)?.[1];
expect(parseInt(major ?? "0")).toBeGreaterThanOrEqual(2);
} finally {
cleanup();
}
});
});

// Regression: `install --lock update` used to early-return if mops.toml's
// deps hash was unchanged, even when the lockfile's per-file hashes were
// stale/corrupt. The subsequent checkLockFile would then fail and exit 1,
Expand Down
24 changes: 24 additions & 0 deletions cli/tests/experimental.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from "@jest/globals";
import { isExperimentEnabled } from "../experimental";
import { Config } from "../types";

describe("isExperimentEnabled", () => {
test("returns false when no [experimental] section", () => {
expect(isExperimentEnabled({}, "any-flag")).toBe(false);
});

test("returns false when flags array is empty", () => {
let config: Config = { experimental: { flags: [] } };
expect(isExperimentEnabled(config, "any-flag")).toBe(false);
});

test("returns false when the flag is not listed", () => {
let config: Config = { experimental: { flags: ["other-flag"] } };
expect(isExperimentEnabled(config, "wanted-flag")).toBe(false);
});

test("returns true when the flag is listed", () => {
let config: Config = { experimental: { flags: ["wanted-flag"] } };
expect(isExperimentEnabled(config, "wanted-flag")).toBe(true);
});
});
5 changes: 5 additions & 0 deletions cli/tests/install/caret/mops.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[experimental]
flags = ["caret-versions"]

[dependencies]
core = "1.0.0"
3 changes: 3 additions & 0 deletions cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export type Config = {
extends?: string[] | true;
extra?: Record<string, string[]>;
};
experimental?: {
flags?: string[];
};
};

export type MigrationsConfig = {
Expand Down
Loading
Loading