ic-mops 2.13.2 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/bun.lock +24 -12
- package/bundle/cli.tgz +0 -0
- package/cache.ts +101 -20
- package/cli.ts +40 -14
- package/commands/available-updates.ts +4 -2
- package/commands/bench.ts +3 -0
- package/commands/check.ts +11 -0
- package/commands/init.ts +15 -2
- package/commands/install/install-mops-dep.ts +14 -9
- package/commands/install/sync-local-cache.ts +7 -1
- package/commands/lint.ts +11 -0
- package/commands/outdated.ts +5 -2
- package/commands/publish.ts +0 -1
- package/commands/test/test.ts +14 -0
- package/commands/update.ts +3 -2
- package/commands/watch/tester.ts +5 -1
- package/dist/cache.d.ts +3 -0
- package/dist/cache.js +98 -18
- package/dist/cli.js +16 -12
- package/dist/commands/available-updates.d.ts +1 -1
- package/dist/commands/available-updates.js +4 -1
- package/dist/commands/bench.js +2 -0
- package/dist/commands/check.js +7 -0
- package/dist/commands/init.js +9 -2
- package/dist/commands/install/install-mops-dep.js +12 -9
- package/dist/commands/install/sync-local-cache.js +2 -1
- package/dist/commands/lint.js +7 -0
- package/dist/commands/outdated.d.ts +2 -1
- package/dist/commands/outdated.js +2 -2
- package/dist/commands/publish.js +0 -1
- package/dist/commands/test/test.d.ts +1 -1
- package/dist/commands/test/test.js +10 -5
- package/dist/commands/update.d.ts +2 -1
- package/dist/commands/update.js +2 -2
- package/dist/commands/watch/tester.js +4 -1
- package/dist/helpers/autofix-motoko.d.ts +1 -1
- package/dist/helpers/autofix-motoko.js +3 -0
- package/dist/helpers/deprecate-dfx-replica.d.ts +2 -0
- package/dist/helpers/deprecate-dfx-replica.js +20 -0
- package/dist/helpers/fix-lock.d.ts +1 -0
- package/dist/helpers/fix-lock.js +93 -0
- package/dist/integrity.d.ts +2 -2
- package/dist/integrity.js +22 -6
- package/dist/mops.js +1 -1
- package/dist/package.json +3 -3
- package/dist/tests/check-fix.test.js +40 -0
- package/dist/tests/cli.test.js +136 -1
- package/dist/vessel.js +21 -13
- package/dist/wasm/pkg/nodejs/package.json +1 -1
- package/dist/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
- package/dist/wasm/pkg/web/package.json +1 -1
- package/dist/wasm/pkg/web/wasm_bg.wasm +0 -0
- package/helpers/autofix-motoko.ts +4 -1
- package/helpers/deprecate-dfx-replica.ts +32 -0
- package/helpers/fix-lock.ts +101 -0
- package/integrity.ts +30 -9
- package/mops.ts +1 -1
- package/package.json +3 -3
- package/tests/check-fix.test.ts +46 -0
- package/tests/cli.test.ts +180 -1
- package/tests/install/update-bound-patch/mops.toml +2 -0
- package/vessel.ts +31 -14
- package/wasm/pkg/nodejs/package.json +1 -1
- package/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
- package/wasm/pkg/web/package.json +1 -1
- package/wasm/pkg/web/wasm_bg.wasm +0 -0
|
Binary file
|
|
Binary file
|
|
@@ -26,7 +26,10 @@ export interface MocDiagnostic {
|
|
|
26
26
|
notes: string[];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function parseDiagnostics(stdout: string): MocDiagnostic[] {
|
|
29
|
+
export function parseDiagnostics(stdout: string | undefined): MocDiagnostic[] {
|
|
30
|
+
if (!stdout) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
30
33
|
return stdout
|
|
31
34
|
.split("\n")
|
|
32
35
|
.filter((l) => l.trim())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export type ReplicaName = "dfx" | "pocket-ic" | "dfx-pocket-ic";
|
|
4
|
+
|
|
5
|
+
let alreadyWarned = false;
|
|
6
|
+
|
|
7
|
+
// Prints a deprecation warning (once per process) when `mops bench`/`mops test`/
|
|
8
|
+
// `mops watch` is about to use a dfx-backed replica. Removal is tracked in
|
|
9
|
+
// NEXT-MAJOR.md under "Drop dfx coupling".
|
|
10
|
+
export function warnIfDfxReplica(
|
|
11
|
+
replicaType: ReplicaName,
|
|
12
|
+
explicit: boolean,
|
|
13
|
+
): void {
|
|
14
|
+
if (alreadyWarned) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (replicaType !== "dfx" && replicaType !== "dfx-pocket-ic") {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
alreadyWarned = true;
|
|
21
|
+
let lead =
|
|
22
|
+
explicit && replicaType === "dfx"
|
|
23
|
+
? "`--replica dfx` is deprecated and will be removed in a future release."
|
|
24
|
+
: replicaType === "dfx-pocket-ic"
|
|
25
|
+
? "Falling back to dfx-bundled PocketIC because no `pocket-ic` version is set in `[toolchain]`. This fallback is deprecated and will be removed in a future release."
|
|
26
|
+
: "Using `dfx` replica because no `pocket-ic` version is set in `[toolchain]`. The `dfx` replica is deprecated and will be removed in a future release.";
|
|
27
|
+
console.log(
|
|
28
|
+
chalk.yellow(
|
|
29
|
+
`${lead}\nRun \`mops toolchain use pocket-ic 12.0.0\` to pin a PocketIC version and silence this warning.`,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { lock, unlockSync } from "proper-lockfile";
|
|
5
|
+
import { getRootDir } from "../mops.js";
|
|
6
|
+
|
|
7
|
+
// Serializes Motoko-source-writing commands (`check --fix`, `lint --fix`)
|
|
8
|
+
// across concurrent `mops` invocations in the same project. Two parallel
|
|
9
|
+
// `--fix` runs can otherwise apply stale moc byte offsets to a sibling's
|
|
10
|
+
// already-mutated file, corrupting source. Cargo-style: print a wait
|
|
11
|
+
// message after the first failed acquire, then retry with backoff.
|
|
12
|
+
//
|
|
13
|
+
// Re-entrant within a single process so `mops check --fix` (which calls
|
|
14
|
+
// `mops lint --fix` internally) doesn't deadlock against itself.
|
|
15
|
+
|
|
16
|
+
let depth = 0;
|
|
17
|
+
let acquiring = false;
|
|
18
|
+
|
|
19
|
+
export async function withFixLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
20
|
+
if (depth > 0) {
|
|
21
|
+
depth++;
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} finally {
|
|
25
|
+
depth--;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Sequential re-entry is handled by `depth`; concurrent top-level calls
|
|
30
|
+
// (`Promise.all([withFixLock(a), withFixLock(b)])`) would otherwise
|
|
31
|
+
// serialize awkwardly via proper-lockfile's ELOCKED retry path — with a
|
|
32
|
+
// confusing intra-process "Waiting..." log line. Fail loudly instead so
|
|
33
|
+
// the next caller finds out at the call site. Must be set synchronously
|
|
34
|
+
// before any await, so concurrent entries can't both pass the check.
|
|
35
|
+
if (acquiring) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"withFixLock cannot be called concurrently within the same process",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
acquiring = true;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const rootDir = getRootDir();
|
|
44
|
+
if (!rootDir) {
|
|
45
|
+
return await fn();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lockDir = join(rootDir, ".mops");
|
|
49
|
+
await mkdir(lockDir, { recursive: true });
|
|
50
|
+
const lockTarget = join(lockDir, "fix.lock");
|
|
51
|
+
await writeFile(lockTarget, "", { flag: "a" });
|
|
52
|
+
|
|
53
|
+
const stale = 300_000;
|
|
54
|
+
let release: () => Promise<void>;
|
|
55
|
+
try {
|
|
56
|
+
release = await lock(lockTarget, { stale, retries: 0 });
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
if (err?.code !== "ELOCKED") {
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
console.log(
|
|
62
|
+
chalk.gray("Waiting for another `mops --fix` run to finish..."),
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
release = await lock(lockTarget, {
|
|
66
|
+
stale,
|
|
67
|
+
retries: { retries: 240, minTimeout: 250, maxTimeout: 2_000 },
|
|
68
|
+
});
|
|
69
|
+
} catch (err2: any) {
|
|
70
|
+
// proper-lockfile creates `<lockTarget>.lock/` (a directory) for the actual lock,
|
|
71
|
+
// so manual recovery means removing that, not the empty marker file.
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Failed to acquire mops fix lock at ${lockTarget} — another --fix process may be stuck. Remove the ${lockTarget}.lock directory to recover.${err2?.message ? `\n${err2.message}` : ""}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// proper-lockfile registers its own signal-exit handler, but it doesn't reliably
|
|
79
|
+
// fire on process.exit(). This manual handler covers that gap. Double-unlock is
|
|
80
|
+
// harmless (the second call throws and is caught).
|
|
81
|
+
const exitCleanup = () => {
|
|
82
|
+
try {
|
|
83
|
+
unlockSync(lockTarget);
|
|
84
|
+
} catch {}
|
|
85
|
+
};
|
|
86
|
+
process.on("exit", exitCleanup);
|
|
87
|
+
|
|
88
|
+
depth = 1;
|
|
89
|
+
try {
|
|
90
|
+
return await fn();
|
|
91
|
+
} finally {
|
|
92
|
+
depth = 0;
|
|
93
|
+
process.removeListener("exit", exitCleanup);
|
|
94
|
+
try {
|
|
95
|
+
await release();
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
acquiring = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/integrity.ts
CHANGED
|
@@ -41,8 +41,8 @@ export async function checkIntegrity(lock?: "check" | "update" | "ignore") {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
if (lock === "update") {
|
|
44
|
-
await updateLockFile({ force });
|
|
45
|
-
await checkLockFile(force);
|
|
44
|
+
let regenerated = await updateLockFile({ force });
|
|
45
|
+
await checkLockFile(force, regenerated);
|
|
46
46
|
} else if (lock === "check") {
|
|
47
47
|
await checkLockFile(force);
|
|
48
48
|
}
|
|
@@ -159,14 +159,16 @@ export function checkLockFileLight(): boolean {
|
|
|
159
159
|
return false;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// returns true if the lock file was (re)written, false if it was skipped
|
|
163
|
+
// because the existing lock is still valid.
|
|
162
164
|
export async function updateLockFile({
|
|
163
165
|
force = false,
|
|
164
|
-
}: { force?: boolean } = {}) {
|
|
166
|
+
}: { force?: boolean } = {}): Promise<boolean> {
|
|
165
167
|
// if lock file exists and mops.toml hasn't changed, don't update it
|
|
166
168
|
// (unless forced: `--lock update` must unconditionally regenerate so users
|
|
167
169
|
// can recover from a corrupt lockfile without `rm mops.lock`)
|
|
168
170
|
if (!force && checkLockFileLight()) {
|
|
169
|
-
return;
|
|
171
|
+
return false;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
let resolvedDeps = await resolvePackages();
|
|
@@ -201,10 +203,13 @@ export async function updateLockFile({
|
|
|
201
203
|
console.log(" Applications: commit this file.");
|
|
202
204
|
console.log(" Libraries: add mops.lock to .gitignore.");
|
|
203
205
|
}
|
|
206
|
+
return true;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
// compare hashes of local files with hashes from the lock file
|
|
207
|
-
|
|
210
|
+
// `regenerated` indicates the lockfile was just rewritten from the registry
|
|
211
|
+
// (via `updateLockFile`), so any remaining hash mismatch must be a local edit.
|
|
212
|
+
export async function checkLockFile(force = false, regenerated = false) {
|
|
208
213
|
let supportedVersions = [1, 2, 3];
|
|
209
214
|
let rootDir = getRootDir();
|
|
210
215
|
let lockFile = path.join(rootDir, "mops.lock");
|
|
@@ -318,10 +323,26 @@ export async function checkLockFile(force = false) {
|
|
|
318
323
|
console.error(`Locked hash: ${lockedHash}`);
|
|
319
324
|
console.error(`Actual hash: ${localHash}`);
|
|
320
325
|
console.error("");
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
if (regenerated) {
|
|
327
|
+
// The lock was just rewritten from the registry, so the only way
|
|
328
|
+
// for a per-file hash to still differ is that .mops/<file> was
|
|
329
|
+
// edited locally. Point users at the actual fix.
|
|
330
|
+
let pkgDir = fileId.split("/")[0];
|
|
331
|
+
console.error(
|
|
332
|
+
`.mops/${fileId} differs from the registry — your local copy has been modified.`,
|
|
333
|
+
);
|
|
334
|
+
console.error(
|
|
335
|
+
`To restore from the global cache, delete the \`.mops/${pkgDir}\` directory and run \`mops install\`.`,
|
|
336
|
+
);
|
|
337
|
+
console.error(
|
|
338
|
+
"To keep custom changes, use a `repo` or `path` entry in mops.toml instead of editing .mops/ directly.",
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
console.error(
|
|
342
|
+
"If you have not modified files under .mops/, your lockfile may be stale or corrupt.",
|
|
343
|
+
);
|
|
344
|
+
console.error("Run `mops install --lock update` to regenerate it.");
|
|
345
|
+
}
|
|
325
346
|
process.exit(1);
|
|
326
347
|
}
|
|
327
348
|
}
|
package/mops.ts
CHANGED
|
@@ -2,7 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { Identity } from "@icp-sdk/core/agent";
|
|
5
|
-
import TOML from "
|
|
5
|
+
import TOML from "smol-toml";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import prompts from "prompts";
|
|
8
8
|
import { decodeFile } from "./pem.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ic-mops",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mops": "dist/bin/mops.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"
|
|
53
|
+
"smol-toml": "1.6.1",
|
|
54
54
|
"@icp-sdk/core": "4.0.2",
|
|
55
55
|
"@noble/hashes": "1.8.0",
|
|
56
56
|
"as-table": "1.0.55",
|
|
@@ -114,6 +114,6 @@
|
|
|
114
114
|
"ts-jest": "29.4.5",
|
|
115
115
|
"tsx": "4.19.2",
|
|
116
116
|
"typescript": "5.9.2",
|
|
117
|
-
"wasm-pack": "0.
|
|
117
|
+
"wasm-pack": "0.15.0"
|
|
118
118
|
}
|
|
119
119
|
}
|
package/tests/check-fix.test.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from "@jest/globals";
|
|
2
2
|
import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "path";
|
|
5
|
+
import { lock } from "proper-lockfile";
|
|
4
6
|
import { parseDiagnostics } from "../helpers/autofix-motoko";
|
|
5
7
|
import { cli, normalizePaths } from "./helpers";
|
|
6
8
|
|
|
@@ -60,6 +62,13 @@ describe("check --fix", () => {
|
|
|
60
62
|
return runFilePath;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
test("parseDiagnostics tolerates missing moc output", () => {
|
|
66
|
+
// execa with reject:false yields undefined stdout when moc fails to spawn
|
|
67
|
+
// or is killed (e.g. OOM); parsing must degrade to no diagnostics, not throw.
|
|
68
|
+
expect(parseDiagnostics(undefined)).toEqual([]);
|
|
69
|
+
expect(parseDiagnostics("")).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
test("M0223", async () => {
|
|
64
73
|
await testCheckFix("M0223.mo", { M0223: 1 });
|
|
65
74
|
});
|
|
@@ -156,4 +165,41 @@ describe("check --fix", () => {
|
|
|
156
165
|
expect(result.stderr).toMatch(/error/i);
|
|
157
166
|
expect(result.stdout).not.toMatch(/✓ run/);
|
|
158
167
|
});
|
|
168
|
+
|
|
169
|
+
test("concurrent --fix runs serialize and produce the same output", async () => {
|
|
170
|
+
const runFilePath = copyFixture("edit-suggestions.mo");
|
|
171
|
+
await cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
172
|
+
cwd: fixDir,
|
|
173
|
+
});
|
|
174
|
+
const expected = readFileSync(runFilePath, "utf-8");
|
|
175
|
+
copyFixture("edit-suggestions.mo");
|
|
176
|
+
|
|
177
|
+
// Hold the lock from the test process itself so both children
|
|
178
|
+
// deterministically hit ELOCKED, print "Waiting...", and queue.
|
|
179
|
+
const lockTarget = path.join(fixDir, ".mops", "fix.lock");
|
|
180
|
+
await mkdir(path.dirname(lockTarget), { recursive: true });
|
|
181
|
+
await writeFile(lockTarget, "", { flag: "a" });
|
|
182
|
+
const release = await lock(lockTarget, { stale: 30_000 });
|
|
183
|
+
|
|
184
|
+
const childA = cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
185
|
+
cwd: fixDir,
|
|
186
|
+
});
|
|
187
|
+
const childB = cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
188
|
+
cwd: fixDir,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Hold long enough for both children to spawn (`npm run mops` → bundler →
|
|
192
|
+
// CLI startup) and attempt the non-blocking acquire. 5s is conservative —
|
|
193
|
+
// local runs land well under 2s, the headroom is purely to absorb CI jitter.
|
|
194
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
195
|
+
await release();
|
|
196
|
+
|
|
197
|
+
const [a, b] = await Promise.all([childA, childB]);
|
|
198
|
+
expect(a.exitCode).toBe(0);
|
|
199
|
+
expect(b.exitCode).toBe(0);
|
|
200
|
+
expect(readFileSync(runFilePath, "utf-8")).toBe(expected);
|
|
201
|
+
// At least one child must have hit the held lock and queued; if neither
|
|
202
|
+
// did, the lock didn't actually serialize anything.
|
|
203
|
+
expect(a.stdout + b.stdout).toContain("Waiting for another");
|
|
204
|
+
});
|
|
159
205
|
});
|
package/tests/cli.test.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, jest, test } from "@jest/globals";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
3
12
|
import path from "path";
|
|
4
13
|
import { cli, normalizePaths } from "./helpers";
|
|
5
14
|
|
|
@@ -128,6 +137,108 @@ describe("install", () => {
|
|
|
128
137
|
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
129
138
|
}
|
|
130
139
|
});
|
|
140
|
+
|
|
141
|
+
// Regression: when a file under `.mops/` was edited locally (an unsupported
|
|
142
|
+
// but common AI-agent workflow), `--lock update` would regenerate the
|
|
143
|
+
// lockfile from registry hashes, then fail the per-file check, and tell the
|
|
144
|
+
// user to "Run `mops install --lock update` to regenerate it" — the exact
|
|
145
|
+
// command that just failed. The post-regen message now says the local copy
|
|
146
|
+
// was modified and points at the actual fix.
|
|
147
|
+
test("--lock update flags a locally edited .mops/ file with a clear recovery hint", async () => {
|
|
148
|
+
const cwd = path.join(import.meta.dirname, "install/success");
|
|
149
|
+
const lockFile = path.join(cwd, "mops.lock");
|
|
150
|
+
const localDep = path.join(cwd, ".mops", "core@1.0.0", "mops.toml");
|
|
151
|
+
rmSync(lockFile, { force: true });
|
|
152
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
153
|
+
try {
|
|
154
|
+
const first = await cli(["install"], { cwd, env: { CI: undefined } });
|
|
155
|
+
expect(first.exitCode).toBe(0);
|
|
156
|
+
expect(existsSync(localDep)).toBe(true);
|
|
157
|
+
|
|
158
|
+
writeFileSync(
|
|
159
|
+
localDep,
|
|
160
|
+
readFileSync(localDep, "utf8") + "\n# tampered\n",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await cli(["install", "--lock", "update"], {
|
|
164
|
+
cwd,
|
|
165
|
+
env: { CI: undefined },
|
|
166
|
+
});
|
|
167
|
+
expect(result.exitCode).toBe(1);
|
|
168
|
+
expect(result.stderr).toMatch(
|
|
169
|
+
/\.mops\/core@1\.0\.0\/mops\.toml differs from the registry/,
|
|
170
|
+
);
|
|
171
|
+
expect(result.stderr).toMatch(
|
|
172
|
+
/delete the `\.mops\/core@1\.0\.0` directory and run `mops install`/,
|
|
173
|
+
);
|
|
174
|
+
expect(result.stderr).not.toMatch(/Run `mops install --lock update`/);
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(lockFile, { force: true });
|
|
177
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Regression: parallel `mops install` runs against the same project used to
|
|
182
|
+
// race in two places — global cache writes (`.mops/<pkg>` populated mid-write)
|
|
183
|
+
// and local `.mops/<pkg>` copies — leaving zero-byte / partially-written
|
|
184
|
+
// files. We isolate the global cache via `XDG_CACHE_HOME` so the global-write
|
|
185
|
+
// path actually executes (cold-cache scenario).
|
|
186
|
+
test("parallel `mops install` produces a complete .mops tree (no zero-byte / staging dirs)", async () => {
|
|
187
|
+
const cwd = path.join(import.meta.dirname, "install/success");
|
|
188
|
+
const lockFile = path.join(cwd, "mops.lock");
|
|
189
|
+
const localCache = path.join(cwd, ".mops");
|
|
190
|
+
const xdgCache = mkdtempSync(path.join(tmpdir(), "mops-test-xdg-"));
|
|
191
|
+
rmSync(lockFile, { force: true });
|
|
192
|
+
rmSync(localCache, { recursive: true, force: true });
|
|
193
|
+
try {
|
|
194
|
+
const N = 5;
|
|
195
|
+
const env = { CI: undefined, XDG_CACHE_HOME: xdgCache };
|
|
196
|
+
const runs = await Promise.all(
|
|
197
|
+
Array.from({ length: N }, () => cli(["install"], { cwd, env })),
|
|
198
|
+
);
|
|
199
|
+
for (const r of runs) {
|
|
200
|
+
if (r.exitCode !== 0) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`mops install exited ${r.exitCode}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const walk = (dir: string): string[] => {
|
|
208
|
+
const out: string[] = [];
|
|
209
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
210
|
+
const p = path.join(dir, entry.name);
|
|
211
|
+
if (entry.isDirectory()) {
|
|
212
|
+
out.push(...walk(p));
|
|
213
|
+
} else if (entry.isFile()) {
|
|
214
|
+
out.push(p);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
};
|
|
219
|
+
const files = walk(localCache);
|
|
220
|
+
const empties = files.filter((f) => statSync(f).size === 0);
|
|
221
|
+
expect(empties).toEqual([]);
|
|
222
|
+
|
|
223
|
+
const stagingLeftovers = readdirSync(localCache).filter((e) =>
|
|
224
|
+
e.startsWith(".staging-"),
|
|
225
|
+
);
|
|
226
|
+
expect(stagingLeftovers).toEqual([]);
|
|
227
|
+
|
|
228
|
+
const globalPkg = path.join(
|
|
229
|
+
xdgCache,
|
|
230
|
+
"mops",
|
|
231
|
+
"packages",
|
|
232
|
+
"core@1.0.0",
|
|
233
|
+
"mops.toml",
|
|
234
|
+
);
|
|
235
|
+
expect(existsSync(globalPkg)).toBe(true);
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(lockFile, { force: true });
|
|
238
|
+
rmSync(localCache, { recursive: true, force: true });
|
|
239
|
+
rmSync(xdgCache, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
131
242
|
});
|
|
132
243
|
|
|
133
244
|
// `mops update` and `mops outdated` default to caret-bound resolution: stay
|
|
@@ -214,3 +325,71 @@ describe("update / outdated bounds", () => {
|
|
|
214
325
|
}
|
|
215
326
|
});
|
|
216
327
|
});
|
|
328
|
+
|
|
329
|
+
// `--patch` restricts updates to patch versions only, never crossing the minor
|
|
330
|
+
// bound. Fixture pins `core = "2.3.0"`; registry has 2.3.1 (patch) and 2.4.0,
|
|
331
|
+
// 2.5.0 (minor). Caret default lets minors through; --patch must not.
|
|
332
|
+
describe("update / outdated --patch bound", () => {
|
|
333
|
+
jest.setTimeout(120_000);
|
|
334
|
+
|
|
335
|
+
const cwd = path.join(import.meta.dirname, "install/update-bound-patch");
|
|
336
|
+
const tomlFile = path.join(cwd, "mops.toml");
|
|
337
|
+
const original = readFileSync(tomlFile, "utf8");
|
|
338
|
+
|
|
339
|
+
const cleanup = () => {
|
|
340
|
+
rmSync(path.join(cwd, "mops.lock"), { force: true });
|
|
341
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
342
|
+
writeFileSync(tomlFile, original);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const coreVersion = (toml: string) =>
|
|
346
|
+
toml.match(/core = "(\d+\.\d+\.\d+)"/)?.[1];
|
|
347
|
+
|
|
348
|
+
test("mops update --patch restricts to patch bumps", async () => {
|
|
349
|
+
cleanup();
|
|
350
|
+
try {
|
|
351
|
+
await cli(["install"], { cwd, env: { CI: undefined } });
|
|
352
|
+
const result = await cli(["update", "--patch"], {
|
|
353
|
+
cwd,
|
|
354
|
+
env: { CI: undefined },
|
|
355
|
+
});
|
|
356
|
+
expect(result.exitCode).toBe(0);
|
|
357
|
+
const after = readFileSync(tomlFile, "utf8");
|
|
358
|
+
// Stays within 2.3.x — must not cross to 2.4.0 / 2.5.0
|
|
359
|
+
expect(coreVersion(after)).toMatch(/^2\.3\./);
|
|
360
|
+
expect(coreVersion(after)).not.toBe("2.3.0");
|
|
361
|
+
} finally {
|
|
362
|
+
cleanup();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("mops outdated --patch reports patch-only updates", async () => {
|
|
367
|
+
cleanup();
|
|
368
|
+
try {
|
|
369
|
+
await cli(["install"], { cwd, env: { CI: undefined } });
|
|
370
|
+
const patch = normalizePaths(
|
|
371
|
+
(await cli(["outdated", "--patch"], { cwd, env: { CI: undefined } }))
|
|
372
|
+
.stdout,
|
|
373
|
+
);
|
|
374
|
+
// Only 2.3.x updates surface, never 2.4.x / 2.5.x
|
|
375
|
+
expect(patch).toMatch(/core 2\.3\.0 -> 2\.3\./);
|
|
376
|
+
expect(patch).not.toMatch(/core 2\.3\.0 -> 2\.[4-9]/);
|
|
377
|
+
} finally {
|
|
378
|
+
cleanup();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test.each([["update"], ["outdated"]])(
|
|
383
|
+
"mops %s rejects --major + --patch",
|
|
384
|
+
async (cmd) => {
|
|
385
|
+
const result = await cli([cmd, "--major", "--patch"], {
|
|
386
|
+
cwd,
|
|
387
|
+
env: { CI: undefined },
|
|
388
|
+
});
|
|
389
|
+
expect(result.exitCode).not.toBe(0);
|
|
390
|
+
expect(result.stderr).toMatch(
|
|
391
|
+
/option '--major' cannot be used with option '--patch'/,
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
});
|
package/vessel.ts
CHANGED
|
@@ -2,20 +2,28 @@ import process from "node:process";
|
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
rmSync,
|
|
5
7
|
createWriteStream,
|
|
6
8
|
readFileSync,
|
|
7
9
|
writeFileSync,
|
|
8
10
|
} from "node:fs";
|
|
9
11
|
import path from "node:path";
|
|
10
12
|
import { pipeline } from "node:stream";
|
|
11
|
-
import { deleteSync } from "del";
|
|
12
13
|
import { execaCommand } from "execa";
|
|
13
14
|
import chalk from "chalk";
|
|
14
15
|
import { createLogUpdate } from "log-update";
|
|
15
16
|
import got from "got";
|
|
16
17
|
import decompress from "decompress";
|
|
17
|
-
import { parseGithubURL, progressBar } from "./mops.js";
|
|
18
|
-
import {
|
|
18
|
+
import { getRootDir, parseGithubURL, progressBar } from "./mops.js";
|
|
19
|
+
import {
|
|
20
|
+
commitStagingDir,
|
|
21
|
+
createStagingDir,
|
|
22
|
+
getDepCacheDir,
|
|
23
|
+
getGithubDepCacheName,
|
|
24
|
+
isDepCached,
|
|
25
|
+
sweepStaleStagingDirs,
|
|
26
|
+
} from "./cache.js";
|
|
19
27
|
|
|
20
28
|
const dhallFileToJson = async (filePath: string, silent: boolean) => {
|
|
21
29
|
if (existsSync(filePath)) {
|
|
@@ -130,18 +138,23 @@ export const downloadFromGithub = async (
|
|
|
130
138
|
|
|
131
139
|
// Prevent `onError` being called twice.
|
|
132
140
|
readStream.off("error", reject);
|
|
133
|
-
|
|
141
|
+
|
|
142
|
+
// Per-invocation download dir (was a shared `.mops/_tmp/` clobbered
|
|
143
|
+
// by concurrent github installs). `.staging-` prefix lets the sweeper
|
|
144
|
+
// pick up leftovers from a crashed download.
|
|
145
|
+
const parentTmp = path.resolve(getRootDir(), ".mops");
|
|
146
|
+
mkdirSync(parentTmp, { recursive: true });
|
|
147
|
+
const tmpDir = mkdtempSync(path.join(parentTmp, ".staging-github-dl-"));
|
|
134
148
|
const tmpFile = path.resolve(
|
|
135
149
|
tmpDir,
|
|
136
150
|
`${gitName}@${(commitHash || branch).replaceAll("/", "___")}.zip`,
|
|
137
151
|
);
|
|
152
|
+
const cleanup = () => rmSync(tmpDir, { recursive: true, force: true });
|
|
138
153
|
|
|
139
154
|
try {
|
|
140
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
141
|
-
|
|
142
155
|
pipeline(readStream, createWriteStream(tmpFile), (err) => {
|
|
143
156
|
if (err) {
|
|
144
|
-
|
|
157
|
+
cleanup();
|
|
145
158
|
reject(err);
|
|
146
159
|
} else {
|
|
147
160
|
let options = {
|
|
@@ -153,17 +166,17 @@ export const downloadFromGithub = async (
|
|
|
153
166
|
};
|
|
154
167
|
decompress(tmpFile, dest, options)
|
|
155
168
|
.then((unzippedFiles) => {
|
|
156
|
-
|
|
169
|
+
cleanup();
|
|
157
170
|
resolve(unzippedFiles);
|
|
158
171
|
})
|
|
159
172
|
.catch((err) => {
|
|
160
|
-
|
|
173
|
+
cleanup();
|
|
161
174
|
reject(err);
|
|
162
175
|
});
|
|
163
176
|
}
|
|
164
177
|
});
|
|
165
178
|
} catch (err) {
|
|
166
|
-
|
|
179
|
+
cleanup();
|
|
167
180
|
reject(err);
|
|
168
181
|
}
|
|
169
182
|
});
|
|
@@ -182,6 +195,8 @@ export const installFromGithub = async (
|
|
|
182
195
|
ignoreTransitive = false,
|
|
183
196
|
} = {},
|
|
184
197
|
): Promise<boolean> => {
|
|
198
|
+
sweepStaleStagingDirs();
|
|
199
|
+
|
|
185
200
|
let cacheName = getGithubDepCacheName(name, repo);
|
|
186
201
|
let cacheDir = getDepCacheDir(cacheName);
|
|
187
202
|
|
|
@@ -199,12 +214,14 @@ export const installFromGithub = async (
|
|
|
199
214
|
|
|
200
215
|
progress(0, 1024 * 500);
|
|
201
216
|
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
// Stage download in a sibling dir; previously `mkdirSync(cacheDir)`
|
|
218
|
+
// before download made empty dirs look cached to peers.
|
|
219
|
+
let stagingDir = createStagingDir(cacheDir);
|
|
204
220
|
try {
|
|
205
|
-
await downloadFromGithub(repo,
|
|
221
|
+
await downloadFromGithub(repo, stagingDir, progress);
|
|
222
|
+
commitStagingDir(stagingDir, cacheDir);
|
|
206
223
|
} catch (err) {
|
|
207
|
-
|
|
224
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
208
225
|
return false;
|
|
209
226
|
}
|
|
210
227
|
}
|
|
Binary file
|
|
Binary file
|