diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index c1198531..419bd8e2 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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) diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index d4a8ae9a..5e28e065 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -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( @@ -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) => { @@ -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]; }), diff --git a/cli/experimental.ts b/cli/experimental.ts new file mode 100644 index 00000000..d02769c9 --- /dev/null +++ b/cli/experimental.ts @@ -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; +} diff --git a/cli/integrity.ts b/cli/integrity.ts index 123a20e9..0c528623 100644 --- a/cli/integrity.ts +++ b/cli/integrity.ts @@ -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"; @@ -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>; deps: Record; }; @@ -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(); @@ -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; } @@ -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]) => { @@ -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 }; diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 1885919c..8f007fc6 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -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, @@ -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", @@ -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 @@ -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 { + 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; + } +} diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index bb9c09b8..e59336b4 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -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, diff --git a/cli/tests/experimental.test.ts b/cli/tests/experimental.test.ts new file mode 100644 index 00000000..862b5549 --- /dev/null +++ b/cli/tests/experimental.test.ts @@ -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); + }); +}); diff --git a/cli/tests/install/caret/mops.toml b/cli/tests/install/caret/mops.toml new file mode 100644 index 00000000..6599940e --- /dev/null +++ b/cli/tests/install/caret/mops.toml @@ -0,0 +1,5 @@ +[experimental] +flags = ["caret-versions"] + +[dependencies] +core = "1.0.0" diff --git a/cli/types.ts b/cli/types.ts index f82ca3a8..7518eeb3 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -33,6 +33,9 @@ export type Config = { extends?: string[] | true; extra?: Record; }; + experimental?: { + flags?: string[]; + }; }; export type MigrationsConfig = { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 530a6e66..92a480db 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -244,6 +244,44 @@ Use only if your package will not work with older versions of the `moc`. | -------------------- | ------------------------------------------------ | | moc | Motoko compiler version (e.g. `0.11.0` which means `>=0.11.0`) | +## [experimental] + +Opt into in-progress CLI features. Behavior behind any flag listed here may change or be removed without notice. + +| Field | Description | +| ----- | ----------- | +| flags | Array of experimental flag names to enable | + +Example: +```toml +[experimental] +flags = ["caret-versions"] +``` + +Unknown flags are silently ignored, so adding a flag your CLI version doesn't recognize is harmless. + +### Available flags + +#### `caret-versions` + +Treat bare versions in the root project's `[dependencies]` and `[dev-dependencies]` as caret ranges (Cargo-style): + +- `core = "1.2.3"` resolves to the highest published `1.x.y` where `x.y >= 2.3` (`>=1.2.3, <2.0.0`) +- `core = "0.2.3"` resolves to the highest published `0.2.x` where `x >= 3` (`>=0.2.3, <0.3.0`) + +The `mops.toml` syntax does not change — explicit `^x.y.z` is **not** accepted. Bare versions just resolve differently. The resolved version is pinned in `mops.lock`; subsequent runs use the lockfile. + +Workflow: +- `mops install` is reproducible: it never bumps a dep on its own — newly published compatible versions are not pulled in. +- `mops install --lock update` re-resolves and writes the highest caret-compatible versions into `mops.lock` (`mops.toml` unchanged). +- `mops update [pkg]` rewrites `mops.toml` (and the lock) up to the highest caret-compatible version. Without the flag, `mops update` ignores the caret bound and can cross majors. `mops outdated` follows the same caret bound. + +Caveats: +- Transitive dependencies (deps of your deps) keep today's resolution behavior. +- Aliased dependencies (e.g. `core@1 = "1.2.3"`) are not affected. +- Older mops CLIs that don't recognize this flag still read the lockfile and use the resolved (upgraded) versions transparently — they just won't re-resolve when the flag toggles. + + ## Advanced Configuration For additional configuration options including registry endpoint overrides, see [Environment Variables](/cli/environment-variables). \ No newline at end of file