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 .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Two consequences worth knowing: a corrupt or hand-edited `mops.lock` now fails a
mops verify # re-hash .mops/ against mops.lock, and mops.lock against the registry
```

Covers GitHub dependencies as well as registry ones.

Exits 1 with the offending files and a recovery hint. This is the replacement for anyone who relied on `mops install` failing when `.mops/` had been modified.

### `mops add <package>`
Expand Down
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- When the lockfile cannot answer — no lock, a stale one, a package new to the lock, or a version that lost a conflict — the registry's consensus reply is used, as before, and always before the bytes are staged into the cache. Verification is therefore always against either a committed local record or a subnet-agreed one, at the moment of admission, regardless of which command is installing.
- Registry file hashes are fetched at most once per process, so a package downloaded during an install costs nothing further when the lockfile is written.
- A hash mismatch now names its source. If the expectation came from `mops.lock`, the message says so and points at restoring or regenerating the lockfile, rather than suggesting a retry that cannot succeed.
- **GitHub dependencies are now covered by the lockfile.** `mops.lock` records the resolved commit and a content hash for every `repo = "..."` dependency, and an install verifies the fetched archive against them before it enters the cache. A ref carrying no commit — a bare `#main`, or a tag — is resolved once and pinned, so a moved tag or a force-push can no longer silently change what you build; the archive is always fetched by commit, never by ref. `mops verify` audits GitHub dependencies on disk too. This is **not** a lockfile format bump: the record is an optional `github` section, so projects without GitHub dependencies keep their existing lockfile. A lockfile written by an older CLI **for a project that has one** counts as stale — `mops install` regenerates it, and `--locked` fails until the result is committed. Note GitHub dependencies are often transitive, so this can apply to a project whose own `mops.toml` declares none.
- **Behaviour change**: plain `mops install` now fails when it has to *download* a package whose hashes disagree with the committed lockfile. It remains true that files already on disk under `.mops/` are not re-hashed by an install — `mops verify` is still the command that audits those.

### Fixed
Expand Down
102 changes: 97 additions & 5 deletions cli/commands/install/install-from-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { pipeline } from "node:stream";
import chalk from "chalk";
import { createLogUpdate } from "log-update";
import got from "got";
import { getRootDir, parseGithubURL, progressBar } from "../../mops.js";
import {
getGithubCommit,
getRootDir,
parseGithubURL,
progressBar,
} from "../../mops.js";
import { extractGithubZip } from "../../helpers/extract-github-zip.js";
import {
commitStagingDir,
Expand All @@ -15,15 +20,23 @@ import {
isDepCached,
sweepStaleStagingDirs,
} from "../../cache.js";
import {
describeGithubHashMismatch,
hashGithubDir,
readLockedGithubDep,
recordGithubDep,
} from "../../integrity.js";

export const downloadFromGithub = async (
repo: string,
dest: string,
onProgress: any,
// the commit to fetch, when the caller resolved one the repo url does not name
ref?: string,
) => {
const { branch, org, gitName, commitHash } = parseGithubURL(repo);

const zipFile = `https://github.com/${org}/${gitName}/archive/${commitHash || branch}.zip`;
const zipFile = `https://github.com/${org}/${gitName}/archive/${ref || commitHash || branch}.zip`;
const readStream = got.stream(zipFile);

const promise = new Promise((resolve, reject) => {
Expand Down Expand Up @@ -58,7 +71,7 @@ export const downloadFromGithub = async (
const tmpDir = mkdtempSync(path.join(parentTmp, ".staging-github-dl-"));
const tmpFile = path.resolve(
tmpDir,
`${gitName}@${(commitHash || branch).replaceAll("/", "___")}.zip`,
`${gitName}@${(ref || commitHash || branch).replaceAll("/", "___")}.zip`,
);
const cleanup = () => rmSync(tmpDir, { recursive: true, force: true });

Expand Down Expand Up @@ -96,11 +109,54 @@ export const installFromGithub = async (

let cacheName = getGithubDepCacheName(name, repo);
let cacheDir = getDepCacheDir(cacheName);
let { org, gitName, branch, commitHash } = parseGithubURL(repo);

let logUpdate = createLogUpdate(process.stdout, { showCursor: true });

if (isDepCached(cacheName)) {
let locked = readLockedGithubDep(name, repo);

// A ref that names no commit (`#main`, a tag) is only reproducible once it is
// pinned: take the lock's commit, else resolve it once through the GitHub API.
// Nothing asks the API when mops.toml or mops.lock already names a commit, so
// the steady state costs zero requests against the 60/h anonymous limit.
let resolved = commitHash || locked?.resolved || "";
if (!resolved) {
try {
resolved = (await getGithubCommit(`${org}/${gitName}`, branch)).sha;
} catch (err: any) {
// Nothing honest to record without a commit: install as before and leave
// mops.lock for a later run rather than pairing a hash with a guess.
logUpdate.clear();
console.warn(
chalk.yellow("Warning: ") +
`could not resolve ${repo} to a commit, so mops.lock cannot pin it: ${err.message}`,
);
}
}

let cached = isDepCached(cacheName);
let cachedHash = cached && locked ? hashGithubDir(cacheDir) : "";

// What a cache hit is worth depends on what is known about it:
// locked trust it only if the tree hashes to the locked hash
// not locked re-fetch, and hash that. A cache entry says nothing about how
// it was produced: one written by an older CLI does not
// necessarily match a fresh archive of the same commit, even
// when the cache name pins that commit. Hashing it would record
// a value no other machine reproduces, so the first CI run on
// the resulting lockfile would fail.
let useCache = cached;
if (cached && locked) {
useCache = cachedHash === locked.hash;
} else if (cached && resolved) {
useCache = false;
}

if (useCache) {
silent || logUpdate(`Installing ${repo} (cache)`);
if (resolved) {
recordGithubDep(name, { resolved, hash: cachedHash });
}
} else {
let progress = (step: number, total: number) => {
silent || logUpdate(`Installing ${repo} ${progressBar(step, total)}`);
Expand All @@ -112,10 +168,46 @@ export const installFromGithub = async (
// before download made empty dirs look cached to peers.
let stagingDir = createStagingDir(cacheDir);
try {
await downloadFromGithub(repo, stagingDir, progress);
await downloadFromGithub(repo, stagingDir, progress, resolved);

// Integrity is checked here, on the tree that just arrived and before the
// rename that publishes it, so a bad download cannot poison the cache.
let hash = hashGithubDir(stagingDir);
if (locked && hash !== locked.hash) {
rmSync(stagingDir, { recursive: true, force: true });
logUpdate.clear();
let lines = describeGithubHashMismatch(name, repo, locked, hash);
console.error(chalk.red("Error: ") + lines[0]);
for (let line of lines.slice(1)) {
console.error(line);
}
return false;
}

// The cache entry being replaced was keyed by a ref, not a commit, so it
// may hold another commit's content; rename cannot overwrite it.
if (cached) {
rmSync(cacheDir, { recursive: true, force: true });
}
commitStagingDir(stagingDir, cacheDir);
// The project copy is derived from the cache entry, and syncLocalCache
// only copies when it is absent — so a replaced entry has to invalidate it.
rmSync(path.join(getRootDir(), ".mops", cacheName), {
recursive: true,
force: true,
});
if (resolved) {
recordGithubDep(name, { resolved, hash });
}
} catch (err) {
rmSync(stagingDir, { recursive: true, force: true });
// The commit came from the lock, so a failed fetch of it is worth naming:
// a force-push or a deleted branch can garbage-collect it upstream.
if (locked && !commitHash) {
console.error(
`mops.lock pins ${name} to commit ${locked.resolved}. If that commit is gone from the repository, run \`mops update ${name}\` to re-pin it.`,
);
}
return false;
}
}
Expand Down
Loading
Loading