ic-mops 2.13.1 → 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 +25 -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-stable.ts +27 -23
- package/commands/check.ts +11 -1
- 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-stable.d.ts +0 -1
- package/dist/commands/check-stable.js +21 -18
- package/dist/commands/check.js +7 -1
- 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/helpers/migrations.js +7 -5
- 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/check-stable.test.js +17 -0
- package/dist/tests/check.test.js +4 -3
- package/dist/tests/cli.test.js +136 -1
- package/dist/tests/helpers.js +5 -1
- package/dist/tests/migrate.test.js +2 -2
- package/dist/types.d.ts +1 -0
- 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/helpers/migrations.ts +7 -4
- package/integrity.ts +30 -9
- package/mops.ts +1 -1
- package/package.json +3 -3
- package/tests/__snapshots__/migrate.test.ts.snap +1 -2
- package/tests/check-fix.test.ts +46 -0
- package/tests/check-stable/migrations-chain/deployed.most +14 -0
- package/tests/check-stable/migrations-chain/migrations/20250101_000000_Init.mo +8 -0
- package/tests/check-stable/migrations-chain/migrations/20250201_000000_AddField.mo +9 -0
- package/tests/check-stable/migrations-chain/migrations/20250301_000000_AddD.mo +10 -0
- package/tests/check-stable/migrations-chain/migrations/20250401_000000_AddE.mo +11 -0
- package/tests/check-stable/migrations-chain/mops.toml +15 -0
- package/tests/check-stable/migrations-chain/src/main.mo +13 -0
- package/tests/check-stable.test.ts +20 -0
- package/tests/check.test.ts +4 -3
- package/tests/cli.test.ts +180 -1
- package/tests/helpers.ts +5 -1
- package/tests/install/update-bound-patch/mops.toml +2 -0
- package/tests/migrate.test.ts +2 -2
- package/types.ts +1 -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
package/dist/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": "bin/mops.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=18.0.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
32
|
+
"smol-toml": "1.6.1",
|
|
33
33
|
"@icp-sdk/core": "4.0.2",
|
|
34
34
|
"@noble/hashes": "1.8.0",
|
|
35
35
|
"as-table": "1.0.55",
|
|
@@ -93,6 +93,6 @@
|
|
|
93
93
|
"ts-jest": "29.4.5",
|
|
94
94
|
"tsx": "4.19.2",
|
|
95
95
|
"typescript": "5.9.2",
|
|
96
|
-
"wasm-pack": "0.
|
|
96
|
+
"wasm-pack": "0.15.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
|
@@ -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
|
function countCodes(stdout) {
|
|
@@ -36,6 +38,12 @@ describe("check --fix", () => {
|
|
|
36
38
|
expect(countCodes(afterResult.stdout)).toEqual(expectedAfterDiagnostics);
|
|
37
39
|
return runFilePath;
|
|
38
40
|
}
|
|
41
|
+
test("parseDiagnostics tolerates missing moc output", () => {
|
|
42
|
+
// execa with reject:false yields undefined stdout when moc fails to spawn
|
|
43
|
+
// or is killed (e.g. OOM); parsing must degrade to no diagnostics, not throw.
|
|
44
|
+
expect(parseDiagnostics(undefined)).toEqual([]);
|
|
45
|
+
expect(parseDiagnostics("")).toEqual([]);
|
|
46
|
+
});
|
|
39
47
|
test("M0223", async () => {
|
|
40
48
|
await testCheckFix("M0223.mo", { M0223: 1 });
|
|
41
49
|
});
|
|
@@ -103,4 +111,36 @@ describe("check --fix", () => {
|
|
|
103
111
|
expect(result.stderr).toMatch(/error/i);
|
|
104
112
|
expect(result.stdout).not.toMatch(/✓ run/);
|
|
105
113
|
});
|
|
114
|
+
test("concurrent --fix runs serialize and produce the same output", async () => {
|
|
115
|
+
const runFilePath = copyFixture("edit-suggestions.mo");
|
|
116
|
+
await cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
117
|
+
cwd: fixDir,
|
|
118
|
+
});
|
|
119
|
+
const expected = readFileSync(runFilePath, "utf-8");
|
|
120
|
+
copyFixture("edit-suggestions.mo");
|
|
121
|
+
// Hold the lock from the test process itself so both children
|
|
122
|
+
// deterministically hit ELOCKED, print "Waiting...", and queue.
|
|
123
|
+
const lockTarget = path.join(fixDir, ".mops", "fix.lock");
|
|
124
|
+
await mkdir(path.dirname(lockTarget), { recursive: true });
|
|
125
|
+
await writeFile(lockTarget, "", { flag: "a" });
|
|
126
|
+
const release = await lock(lockTarget, { stale: 30_000 });
|
|
127
|
+
const childA = cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
128
|
+
cwd: fixDir,
|
|
129
|
+
});
|
|
130
|
+
const childB = cli(["check", runFilePath, "--fix", "--", warningFlags], {
|
|
131
|
+
cwd: fixDir,
|
|
132
|
+
});
|
|
133
|
+
// Hold long enough for both children to spawn (`npm run mops` → bundler →
|
|
134
|
+
// CLI startup) and attempt the non-blocking acquire. 5s is conservative —
|
|
135
|
+
// local runs land well under 2s, the headroom is purely to absorb CI jitter.
|
|
136
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
137
|
+
await release();
|
|
138
|
+
const [a, b] = await Promise.all([childA, childB]);
|
|
139
|
+
expect(a.exitCode).toBe(0);
|
|
140
|
+
expect(b.exitCode).toBe(0);
|
|
141
|
+
expect(readFileSync(runFilePath, "utf-8")).toBe(expected);
|
|
142
|
+
// At least one child must have hit the held lock and queued; if neither
|
|
143
|
+
// did, the lock didn't actually serialize anything.
|
|
144
|
+
expect(a.stdout + b.stdout).toContain("Waiting for another");
|
|
145
|
+
});
|
|
106
146
|
});
|
|
@@ -77,4 +77,21 @@ describe("check-stable", () => {
|
|
|
77
77
|
expect(result.exitCode).toBe(1);
|
|
78
78
|
expect(result.stderr).toMatch(/File not found/);
|
|
79
79
|
});
|
|
80
|
+
// Regression: two concurrent `mops check-stable` runs on the same project used to clobber
|
|
81
|
+
// each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
|
|
82
|
+
// as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
|
|
83
|
+
test("concurrent runs do not clobber each other's scratch state", async () => {
|
|
84
|
+
const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
|
|
85
|
+
const results = await Promise.all(Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })));
|
|
86
|
+
for (const result of results) {
|
|
87
|
+
expect({
|
|
88
|
+
exitCode: result.exitCode,
|
|
89
|
+
stderr: result.stderr,
|
|
90
|
+
}).toEqual({
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
stderr: "",
|
|
93
|
+
});
|
|
94
|
+
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
95
|
+
}
|
|
96
|
+
}, 60_000);
|
|
80
97
|
});
|
package/dist/tests/check.test.js
CHANGED
|
@@ -86,18 +86,19 @@ describe("check", () => {
|
|
|
86
86
|
expect(result.exitCode).toBe(0);
|
|
87
87
|
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
88
88
|
});
|
|
89
|
-
test("deployed:
|
|
89
|
+
test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
|
|
90
90
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
|
|
91
91
|
const result = await cli(["check"], { cwd });
|
|
92
92
|
expect(result.exitCode).toBe(0);
|
|
93
93
|
expect(result.stdout).not.toMatch(/stable/i);
|
|
94
|
+
expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
|
|
94
95
|
});
|
|
95
|
-
test("deployed: errors when file missing
|
|
96
|
+
test("deployed: errors when file missing", async () => {
|
|
96
97
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
|
|
97
98
|
const result = await cli(["check"], { cwd });
|
|
98
99
|
expect(result.exitCode).toBe(1);
|
|
99
100
|
expect(result.stderr).toMatch(/Deployed file not found/);
|
|
100
|
-
expect(result.stderr).toMatch(/
|
|
101
|
+
expect(result.stderr).toMatch(/empty actor/);
|
|
101
102
|
});
|
|
102
103
|
test("--fix runs stable check after fixing", async () => {
|
|
103
104
|
const cwd = path.join(import.meta.dirname, "check/deployed-compatible");
|
package/dist/tests/cli.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, jest, test } from "@jest/globals";
|
|
2
|
-
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { cli, normalizePaths } from "./helpers";
|
|
5
6
|
describe("cli", () => {
|
|
@@ -116,6 +117,85 @@ describe("install", () => {
|
|
|
116
117
|
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
117
118
|
}
|
|
118
119
|
});
|
|
120
|
+
// Regression: when a file under `.mops/` was edited locally (an unsupported
|
|
121
|
+
// but common AI-agent workflow), `--lock update` would regenerate the
|
|
122
|
+
// lockfile from registry hashes, then fail the per-file check, and tell the
|
|
123
|
+
// user to "Run `mops install --lock update` to regenerate it" — the exact
|
|
124
|
+
// command that just failed. The post-regen message now says the local copy
|
|
125
|
+
// was modified and points at the actual fix.
|
|
126
|
+
test("--lock update flags a locally edited .mops/ file with a clear recovery hint", async () => {
|
|
127
|
+
const cwd = path.join(import.meta.dirname, "install/success");
|
|
128
|
+
const lockFile = path.join(cwd, "mops.lock");
|
|
129
|
+
const localDep = path.join(cwd, ".mops", "core@1.0.0", "mops.toml");
|
|
130
|
+
rmSync(lockFile, { force: true });
|
|
131
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
132
|
+
try {
|
|
133
|
+
const first = await cli(["install"], { cwd, env: { CI: undefined } });
|
|
134
|
+
expect(first.exitCode).toBe(0);
|
|
135
|
+
expect(existsSync(localDep)).toBe(true);
|
|
136
|
+
writeFileSync(localDep, readFileSync(localDep, "utf8") + "\n# tampered\n");
|
|
137
|
+
const result = await cli(["install", "--lock", "update"], {
|
|
138
|
+
cwd,
|
|
139
|
+
env: { CI: undefined },
|
|
140
|
+
});
|
|
141
|
+
expect(result.exitCode).toBe(1);
|
|
142
|
+
expect(result.stderr).toMatch(/\.mops\/core@1\.0\.0\/mops\.toml differs from the registry/);
|
|
143
|
+
expect(result.stderr).toMatch(/delete the `\.mops\/core@1\.0\.0` directory and run `mops install`/);
|
|
144
|
+
expect(result.stderr).not.toMatch(/Run `mops install --lock update`/);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
rmSync(lockFile, { force: true });
|
|
148
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Regression: parallel `mops install` runs against the same project used to
|
|
152
|
+
// race in two places — global cache writes (`.mops/<pkg>` populated mid-write)
|
|
153
|
+
// and local `.mops/<pkg>` copies — leaving zero-byte / partially-written
|
|
154
|
+
// files. We isolate the global cache via `XDG_CACHE_HOME` so the global-write
|
|
155
|
+
// path actually executes (cold-cache scenario).
|
|
156
|
+
test("parallel `mops install` produces a complete .mops tree (no zero-byte / staging dirs)", async () => {
|
|
157
|
+
const cwd = path.join(import.meta.dirname, "install/success");
|
|
158
|
+
const lockFile = path.join(cwd, "mops.lock");
|
|
159
|
+
const localCache = path.join(cwd, ".mops");
|
|
160
|
+
const xdgCache = mkdtempSync(path.join(tmpdir(), "mops-test-xdg-"));
|
|
161
|
+
rmSync(lockFile, { force: true });
|
|
162
|
+
rmSync(localCache, { recursive: true, force: true });
|
|
163
|
+
try {
|
|
164
|
+
const N = 5;
|
|
165
|
+
const env = { CI: undefined, XDG_CACHE_HOME: xdgCache };
|
|
166
|
+
const runs = await Promise.all(Array.from({ length: N }, () => cli(["install"], { cwd, env })));
|
|
167
|
+
for (const r of runs) {
|
|
168
|
+
if (r.exitCode !== 0) {
|
|
169
|
+
throw new Error(`mops install exited ${r.exitCode}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const walk = (dir) => {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
175
|
+
const p = path.join(dir, entry.name);
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
out.push(...walk(p));
|
|
178
|
+
}
|
|
179
|
+
else if (entry.isFile()) {
|
|
180
|
+
out.push(p);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
};
|
|
185
|
+
const files = walk(localCache);
|
|
186
|
+
const empties = files.filter((f) => statSync(f).size === 0);
|
|
187
|
+
expect(empties).toEqual([]);
|
|
188
|
+
const stagingLeftovers = readdirSync(localCache).filter((e) => e.startsWith(".staging-"));
|
|
189
|
+
expect(stagingLeftovers).toEqual([]);
|
|
190
|
+
const globalPkg = path.join(xdgCache, "mops", "packages", "core@1.0.0", "mops.toml");
|
|
191
|
+
expect(existsSync(globalPkg)).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
rmSync(lockFile, { force: true });
|
|
195
|
+
rmSync(localCache, { recursive: true, force: true });
|
|
196
|
+
rmSync(xdgCache, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
119
199
|
});
|
|
120
200
|
// `mops update` and `mops outdated` default to caret-bound resolution: stay
|
|
121
201
|
// within `0.x.y` (or `1.x.y`) and never cross majors. Fixture pins:
|
|
@@ -192,3 +272,58 @@ describe("update / outdated bounds", () => {
|
|
|
192
272
|
}
|
|
193
273
|
});
|
|
194
274
|
});
|
|
275
|
+
// `--patch` restricts updates to patch versions only, never crossing the minor
|
|
276
|
+
// bound. Fixture pins `core = "2.3.0"`; registry has 2.3.1 (patch) and 2.4.0,
|
|
277
|
+
// 2.5.0 (minor). Caret default lets minors through; --patch must not.
|
|
278
|
+
describe("update / outdated --patch bound", () => {
|
|
279
|
+
jest.setTimeout(120_000);
|
|
280
|
+
const cwd = path.join(import.meta.dirname, "install/update-bound-patch");
|
|
281
|
+
const tomlFile = path.join(cwd, "mops.toml");
|
|
282
|
+
const original = readFileSync(tomlFile, "utf8");
|
|
283
|
+
const cleanup = () => {
|
|
284
|
+
rmSync(path.join(cwd, "mops.lock"), { force: true });
|
|
285
|
+
rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
|
|
286
|
+
writeFileSync(tomlFile, original);
|
|
287
|
+
};
|
|
288
|
+
const coreVersion = (toml) => toml.match(/core = "(\d+\.\d+\.\d+)"/)?.[1];
|
|
289
|
+
test("mops update --patch restricts to patch bumps", async () => {
|
|
290
|
+
cleanup();
|
|
291
|
+
try {
|
|
292
|
+
await cli(["install"], { cwd, env: { CI: undefined } });
|
|
293
|
+
const result = await cli(["update", "--patch"], {
|
|
294
|
+
cwd,
|
|
295
|
+
env: { CI: undefined },
|
|
296
|
+
});
|
|
297
|
+
expect(result.exitCode).toBe(0);
|
|
298
|
+
const after = readFileSync(tomlFile, "utf8");
|
|
299
|
+
// Stays within 2.3.x — must not cross to 2.4.0 / 2.5.0
|
|
300
|
+
expect(coreVersion(after)).toMatch(/^2\.3\./);
|
|
301
|
+
expect(coreVersion(after)).not.toBe("2.3.0");
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
test("mops outdated --patch reports patch-only updates", async () => {
|
|
308
|
+
cleanup();
|
|
309
|
+
try {
|
|
310
|
+
await cli(["install"], { cwd, env: { CI: undefined } });
|
|
311
|
+
const patch = normalizePaths((await cli(["outdated", "--patch"], { cwd, env: { CI: undefined } }))
|
|
312
|
+
.stdout);
|
|
313
|
+
// Only 2.3.x updates surface, never 2.4.x / 2.5.x
|
|
314
|
+
expect(patch).toMatch(/core 2\.3\.0 -> 2\.3\./);
|
|
315
|
+
expect(patch).not.toMatch(/core 2\.3\.0 -> 2\.[4-9]/);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
cleanup();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
test.each([["update"], ["outdated"]])("mops %s rejects --major + --patch", async (cmd) => {
|
|
322
|
+
const result = await cli([cmd, "--major", "--patch"], {
|
|
323
|
+
cwd,
|
|
324
|
+
env: { CI: undefined },
|
|
325
|
+
});
|
|
326
|
+
expect(result.exitCode).not.toBe(0);
|
|
327
|
+
expect(result.stderr).toMatch(/option '--major' cannot be used with option '--patch'/);
|
|
328
|
+
});
|
|
329
|
+
});
|
package/dist/tests/helpers.js
CHANGED
|
@@ -26,7 +26,11 @@ export const normalizePaths = (text) => {
|
|
|
26
26
|
.replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
|
|
27
27
|
.replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
|
|
28
28
|
.replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
|
|
29
|
-
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
29
|
+
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
30
|
+
// Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
|
|
31
|
+
// (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
|
|
32
|
+
.replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
|
|
33
|
+
.replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"));
|
|
30
34
|
};
|
|
31
35
|
export const cliSnapshot = async (args, options, exitCode) => {
|
|
32
36
|
const result = await cli(args, options);
|
|
@@ -173,8 +173,8 @@ describe("migrate", () => {
|
|
|
173
173
|
expect(most).toMatchSnapshot();
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
|
-
describe("stable check
|
|
177
|
-
test("stable check fails
|
|
176
|
+
describe("stable check", () => {
|
|
177
|
+
test("stable check fails when deployed.most is incompatible", async () => {
|
|
178
178
|
const cwd = await makeTempFixture("basic");
|
|
179
179
|
await writeFile(path.join(cwd, "deployed.most"), "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n");
|
|
180
180
|
await cliSnapshot(["check"], { cwd }, 1);
|
package/dist/types.d.ts
CHANGED
package/dist/vessel.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
|
-
import { existsSync, mkdirSync, createWriteStream, readFileSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, createWriteStream, readFileSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream";
|
|
5
|
-
import { deleteSync } from "del";
|
|
6
5
|
import { execaCommand } from "execa";
|
|
7
6
|
import chalk from "chalk";
|
|
8
7
|
import { createLogUpdate } from "log-update";
|
|
9
8
|
import got from "got";
|
|
10
9
|
import decompress from "decompress";
|
|
11
|
-
import { parseGithubURL, progressBar } from "./mops.js";
|
|
12
|
-
import { getDepCacheDir, getGithubDepCacheName, isDepCached } from "./cache.js";
|
|
10
|
+
import { getRootDir, parseGithubURL, progressBar } from "./mops.js";
|
|
11
|
+
import { commitStagingDir, createStagingDir, getDepCacheDir, getGithubDepCacheName, isDepCached, sweepStaleStagingDirs, } from "./cache.js";
|
|
13
12
|
const dhallFileToJson = async (filePath, silent) => {
|
|
14
13
|
if (existsSync(filePath)) {
|
|
15
14
|
let cwd = new URL(path.dirname(import.meta.url)).pathname;
|
|
@@ -85,13 +84,18 @@ export const downloadFromGithub = async (repo, dest, onProgress) => {
|
|
|
85
84
|
}
|
|
86
85
|
// Prevent `onError` being called twice.
|
|
87
86
|
readStream.off("error", reject);
|
|
88
|
-
|
|
87
|
+
// Per-invocation download dir (was a shared `.mops/_tmp/` clobbered
|
|
88
|
+
// by concurrent github installs). `.staging-` prefix lets the sweeper
|
|
89
|
+
// pick up leftovers from a crashed download.
|
|
90
|
+
const parentTmp = path.resolve(getRootDir(), ".mops");
|
|
91
|
+
mkdirSync(parentTmp, { recursive: true });
|
|
92
|
+
const tmpDir = mkdtempSync(path.join(parentTmp, ".staging-github-dl-"));
|
|
89
93
|
const tmpFile = path.resolve(tmpDir, `${gitName}@${(commitHash || branch).replaceAll("/", "___")}.zip`);
|
|
94
|
+
const cleanup = () => rmSync(tmpDir, { recursive: true, force: true });
|
|
90
95
|
try {
|
|
91
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
92
96
|
pipeline(readStream, createWriteStream(tmpFile), (err) => {
|
|
93
97
|
if (err) {
|
|
94
|
-
|
|
98
|
+
cleanup();
|
|
95
99
|
reject(err);
|
|
96
100
|
}
|
|
97
101
|
else {
|
|
@@ -104,18 +108,18 @@ export const downloadFromGithub = async (repo, dest, onProgress) => {
|
|
|
104
108
|
};
|
|
105
109
|
decompress(tmpFile, dest, options)
|
|
106
110
|
.then((unzippedFiles) => {
|
|
107
|
-
|
|
111
|
+
cleanup();
|
|
108
112
|
resolve(unzippedFiles);
|
|
109
113
|
})
|
|
110
114
|
.catch((err) => {
|
|
111
|
-
|
|
115
|
+
cleanup();
|
|
112
116
|
reject(err);
|
|
113
117
|
});
|
|
114
118
|
}
|
|
115
119
|
});
|
|
116
120
|
}
|
|
117
121
|
catch (err) {
|
|
118
|
-
|
|
122
|
+
cleanup();
|
|
119
123
|
reject(err);
|
|
120
124
|
}
|
|
121
125
|
});
|
|
@@ -123,6 +127,7 @@ export const downloadFromGithub = async (repo, dest, onProgress) => {
|
|
|
123
127
|
return promise;
|
|
124
128
|
};
|
|
125
129
|
export const installFromGithub = async (name, repo, { verbose = false, dep = false, silent = false, ignoreTransitive = false, } = {}) => {
|
|
130
|
+
sweepStaleStagingDirs();
|
|
126
131
|
let cacheName = getGithubDepCacheName(name, repo);
|
|
127
132
|
let cacheDir = getDepCacheDir(cacheName);
|
|
128
133
|
let logUpdate = createLogUpdate(process.stdout, { showCursor: true });
|
|
@@ -135,12 +140,15 @@ export const installFromGithub = async (name, repo, { verbose = false, dep = fal
|
|
|
135
140
|
logUpdate(`${dep ? "Dependency" : "Installing"} ${repo} ${progressBar(step, total)}`);
|
|
136
141
|
};
|
|
137
142
|
progress(0, 1024 * 500);
|
|
138
|
-
|
|
143
|
+
// Stage download in a sibling dir; previously `mkdirSync(cacheDir)`
|
|
144
|
+
// before download made empty dirs look cached to peers.
|
|
145
|
+
let stagingDir = createStagingDir(cacheDir);
|
|
139
146
|
try {
|
|
140
|
-
await downloadFromGithub(repo,
|
|
147
|
+
await downloadFromGithub(repo, stagingDir, progress);
|
|
148
|
+
commitStagingDir(stagingDir, cacheDir);
|
|
141
149
|
}
|
|
142
150
|
catch (err) {
|
|
143
|
-
|
|
151
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
144
152
|
return false;
|
|
145
153
|
}
|
|
146
154
|
}
|
|
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/helpers/migrations.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
existsSync,
|
|
3
3
|
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
4
5
|
readdirSync,
|
|
5
6
|
symlinkSync,
|
|
6
7
|
writeFileSync,
|
|
@@ -130,7 +131,7 @@ function resolveMigrationChain(
|
|
|
130
131
|
if (!existsSync(chainDir) && !nextFile) {
|
|
131
132
|
cliError(
|
|
132
133
|
`Migration chain directory not found: ${chainDir}\n` +
|
|
133
|
-
"
|
|
134
|
+
"Create the directory and add a `.mo` migration file to initialize the chain.",
|
|
134
135
|
);
|
|
135
136
|
}
|
|
136
137
|
|
|
@@ -193,9 +194,11 @@ export async function prepareMigrationArgs(
|
|
|
193
194
|
return { migrationArgs, cleanup: async () => {} };
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
// Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
|
|
198
|
+
// processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
|
|
199
|
+
const baseDir = stagedMigrationsDir(chainDir, canisterName);
|
|
200
|
+
mkdirSync(dirname(baseDir), { recursive: true });
|
|
201
|
+
const tempDir = mkdtempSync(`${baseDir}-`);
|
|
199
202
|
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
200
203
|
|
|
201
204
|
for (const { file, dir } of included) {
|