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
3 changes: 2 additions & 1 deletion .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@ mops remove base
### Dependency Management

```bash
mops outdated # list outdated dependencies (caret-bound)
mops outdated # list outdated deps (caret-bound); exit 1 if any, 2 if the check failed
mops outdated core # check a single package
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
Expand Down
4 changes: 4 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Together those remove roughly 1.5 s of fixed cost from a warm-cache `mops install && mops update` in a fresh directory, none of it dependent on how many packages a project has.
- File metadata and the first chunk of each file are now fetched concurrently rather than chained, halving per-file round trips for the single-chunk case that covers essentially every Motoko source file.
- Chunk concatenation is no longer quadratic. **Breaking for programmatic consumers of the `ic-mops` package**: `downloadFile` and `downloadPackageFiles` now return `Uint8Array` instead of `Array<number>`.
- `mops outdated` and `mops update` no longer make a registry call when a project has no registry dependencies to check.

### Integrity

Expand All @@ -26,6 +27,8 @@

- **A local `path` dependency's own `mops.toml` no longer goes unnoticed.** Adding or bumping a dependency inside a local package left `mops.lock` judged fresh, so `mops install` exited 0, installed nothing, and never passed the new dependency to the compiler — the package then failed to build against a dependency mops had reported as installed. `mops.lock` now records a hash of the `[dependencies]` of every path dependency it reaches, transitively, so editing any of them makes the lockfile stale.
- **Changing `MOPS_ENV` no longer leaves `mops.lock` pinned to the previous environment.** `{MOPS_ENV}` paths are stored expanded in the lockfile, but the freshness check compared the unexpanded string, so a full `mops install` under a new environment exited 0 and kept building against the old environment's directories. `mops install` now re-resolves, `mops sources` reports the current environment, and `mops install --locked` fails rather than silently using the wrong paths. Note that a committed lockfile now only satisfies `--locked` for the `MOPS_ENV` it was generated under.
- **`mops outdated` is now usable as a CI gate.** It exited `0` whether or not anything was outdated. It now exits `1` when updates are available and `2` when the check itself could not be completed (no `mops.toml`, unknown package, registry or GitHub lookup error), so a partial report can never be mistaken for a clean bill of health. `1` for "found something" matches `npm outdated` and `pnpm outdated`.
- **`mops outdated` and `mops update` no longer disagree.** `outdated` skipped GitHub dependencies while `mops update` updates them, so it could print "All dependencies are up to date!" for a project where `mops update` would rewrite a GitHub pin. GitHub dependencies whose branch has moved past the pinned commit are now reported, using the same rule `mops update` applies.
- **`mops sync` no longer destroys a pinned alias dependency.** Given `map = "9.0.1"` and `"map@8.1.0" = "8.1.0"`, sync compared imports (`map@8.1.0`) against alias-stripped manifest keys (`map`), so it reported the alias as both missing and unused — adding it overwrote `map` with `8.1.0`, and a single run could remove the dependency entirely. Aliases are now matched verbatim and added under their own key.
- `mops sync` adds packages imported only from `test`, `tests`, `bench` or `benchmark` directories to `[dev-dependencies]` rather than `[dependencies]`. Already-declared packages are never moved between sections.
- `mops sync` removes an unused package from **both** sections when it is declared in both; previously it was only removed from `[dependencies]`, leaving a dangling entry that the next run reported again.
Expand All @@ -36,6 +39,7 @@

### Added

- `mops outdated [pkg]` accepts a package name, matching `mops update [pkg]`.
- `mops sync --dry-run` prints what would be added and removed without touching `mops.toml`, the local cache or `mops.lock`.
- `mops cache clean --global` cleans only the global cache and keeps the project's `.mops` directory.
- `mops.lock` gains an optional `localDepsHash` field, written only for projects that declare a local `path` dependency. Those projects have their lockfile regenerated once on the next `mops install`, and `mops install --locked` fails until the regenerated lockfile is committed. Projects without path dependencies are unaffected — the field is omitted entirely and existing lockfiles stay valid.
Expand Down
14 changes: 11 additions & 3 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ program

// outdated
program
.command("outdated")
.command("outdated [pkg]")
.description(
"Print outdated dependencies in mops.toml within the caret bound (does not cross major versions, or pre-1.0 minor versions)",
)
Expand All @@ -829,8 +829,16 @@ program
"Restrict updates to patch versions only (e.g. 1.2.3 -> 1.2.4, never 1.2.3 -> 1.3.0)",
),
)
.action(async (options) => {
await outdated(options);
.addHelpText(
"after",
"\nGitHub dependencies are checked against their branch head (one GitHub API call each).\n" +
"\nExit codes:\n" +
" 0 everything is up to date\n" +
" 1 updates are available\n" +
" 2 the check failed (no mops.toml, unknown [pkg], registry or GitHub lookup error)",
)
.action(async (pkg, options) => {
await outdated(pkg, options);
});

// update
Expand Down
84 changes: 84 additions & 0 deletions cli/commands/available-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@ import process from "node:process";
import chalk from "chalk";
import semver from "semver";
import { mainActor } from "../api/actors.js";
import { getGithubCommit, parseGithubURL } from "../mops.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 = "patch" | "caret" | "major";

export type AvailableUpdatesOptions = {
// `mops outdated` reports registry failures itself, so it can exit with its own
// "lookup failed" code instead of the shared exit(1).
throwOnError?: boolean;
};

// [pkg, oldVersion, newVersion]
export async function getAvailableUpdates(
config: Config,
pkg?: string,
bound: UpdateBound = "caret",
{ throwOnError }: AvailableUpdatesOptions = {},
): Promise<Array<[string, string, string]>> {
let deps = Object.values(config.dependencies || {});
let devDeps = Object.values(config["dev-dependencies"] || {});
Expand All @@ -26,6 +34,11 @@ export async function getAvailableUpdates(
getDepPinnedVersion(dep.name).split(".").length !== 3,
);

// Nothing to resolve: skip the registry round-trip entirely.
if (depsToUpdate.length === 0) {
return [];
}

let getCurrentVersion = (pkg: string, updateVersion: string) => {
for (let dep of allDeps) {
if (getDepName(dep.name) === pkg && dep.version) {
Expand Down Expand Up @@ -62,6 +75,9 @@ export async function getAvailableUpdates(
);

if ("err" in res) {
if (throwOnError) {
throw new Error(res.err);
}
console.log(chalk.red("Error:"), res.err);
process.exit(1);
}
Expand All @@ -70,3 +86,71 @@ export async function getAvailableUpdates(
.filter((dep) => dep[1] !== getCurrentVersion(dep[0], dep[1]))
.map((dep) => [dep[0], getCurrentVersion(dep[0], dep[1]), dep[1]]);
}

export type GithubUpdate = {
name: string;
repo: string; // "org/name"
branch: string;
current: string; // pinned commit hash, empty when mops.toml pins only a branch
latest: string;
};

export type GithubUpdateError = {
name: string;
message: string;
};

export type GithubUpdates = {
updates: GithubUpdate[];
errors: GithubUpdateError[];
};

// `mops update` re-resolves GitHub branches, so anything reporting available updates
// must check them too or it would call a dep up to date that `update` would move.
export async function getAvailableGithubUpdates(
config: Config,
pkg?: string,
): Promise<GithubUpdates> {
let deps = Object.values(config.dependencies || {});
let devDeps = Object.values(config["dev-dependencies"] || {});
let githubDeps = [...deps, ...devDeps].filter((dep) => dep.repo);
if (pkg) {
githubDeps = githubDeps.filter((dep) => dep.name === pkg);
}

// One unauthenticated GitHub API call per repo dep (60/h/IP), so run them
// concurrently and only for deps actually declared by repo.
let results = await Promise.all(
githubDeps.map(
async (dep): Promise<GithubUpdate | GithubUpdateError | undefined> => {
let { org, gitName, branch, commitHash } = parseGithubURL(
dep.repo || "",
);
try {
let commit = await getGithubCommit(`${org}/${gitName}`, branch);
if (commit.sha === commitHash) {
return undefined;
}
return {
name: dep.name,
repo: `${org}/${gitName}`,
branch,
current: commitHash,
latest: commit.sha,
};
} catch (err: any) {
return { name: dep.name, message: err.message };
}
},
),
);

return {
updates: results.filter(
(res): res is GithubUpdate => !!res && "latest" in res,
),
errors: results.filter(
(res): res is GithubUpdateError => !!res && "message" in res,
),
};
}
83 changes: 71 additions & 12 deletions cli/commands/outdated.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,64 @@
import process from "node:process";
import chalk from "chalk";
import { checkConfigFile, readConfig } from "../mops.js";
import { getAvailableUpdates } from "./available-updates.js";
import {
GithubUpdates,
UpdateBound,
getAvailableGithubUpdates,
getAvailableUpdates,
} from "./available-updates.js";
import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js";

export async function outdated({
major,
patch,
}: { major?: boolean; patch?: boolean } = {}) {
// grep/diff convention: 1 = "found something", 2 = "failed to look". A CI gate can
// fail on any non-zero code and still tell a stale dependency from a broken lookup.
const EXIT_OUTDATED = 1;
const EXIT_ERROR = 2;

export async function outdated(
pkg?: string,
{ major, patch }: { major?: boolean; patch?: boolean } = {},
) {
if (!checkConfigFile()) {
process.exitCode = EXIT_ERROR;
return;
}
let config = readConfig();

let available = await getAvailableUpdates(
config,
undefined,
major ? "major" : patch ? "patch" : "caret",
);
if (
pkg &&
!config.dependencies?.[pkg] &&
!config["dev-dependencies"]?.[pkg]
) {
console.log(chalk.red(`Package "${pkg}" is not installed!`));
process.exitCode = EXIT_ERROR;
return;
}

let bound: UpdateBound = major ? "major" : patch ? "patch" : "caret";
let available: Array<[string, string, string]>;
let github: GithubUpdates;

try {
[available, github] = await Promise.all([
getAvailableUpdates(config, pkg, bound, { throwOnError: true }),
getAvailableGithubUpdates(config, pkg),
]);
} catch (err: any) {
console.log(chalk.red("Error:"), err.message || err);
process.exitCode = EXIT_ERROR;
return;
}

if (available.length === 0) {
console.log(chalk.green("All dependencies are up to date!"));
if (available.length === 0 && github.updates.length === 0) {
if (github.errors.length === 0) {
console.log(
chalk.green(
pkg
? `Package "${pkg}" is up to date!`
: "All dependencies are up to date!",
),
);
}
} else {
console.log("Available updates:");
let allDeps = [
Expand All @@ -38,5 +77,25 @@ export async function outdated({

console.log(`${name} ${chalk.yellow(dep[1])} -> ${chalk.green(dep[2])}`);
}
for (let dep of github.updates) {
let current = dep.current ? dep.current.slice(0, 7) : "unpinned";
console.log(
`${dep.name} ${chalk.yellow(current)} -> ${chalk.green(dep.latest.slice(0, 7))} ` +
chalk.dim(`(github: ${dep.repo}#${dep.branch})`),
);
}
}

for (let err of github.errors) {
console.log(
chalk.red("Error: ") + `Failed to check ${err.name}: ${err.message}`,
);
}

// An incomplete report must not pass for a clean bill of health.
if (github.errors.length) {
process.exitCode = EXIT_ERROR;
} else if (available.length || github.updates.length) {
process.exitCode = EXIT_OUTDATED;
}
}
Loading
Loading