movehat 0.2.1 → 0.2.2

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.
Files changed (134) hide show
  1. package/dist/__tests__/deployContract.test.js +56 -47
  2. package/dist/__tests__/deployContract.test.js.map +1 -1
  3. package/dist/__tests__/exports.test.d.ts +2 -0
  4. package/dist/__tests__/exports.test.d.ts.map +1 -0
  5. package/dist/__tests__/exports.test.js +30 -0
  6. package/dist/__tests__/exports.test.js.map +1 -0
  7. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts +4 -3
  8. package/dist/__tests__/fixtures/sigint-deploy-harness.d.ts.map +1 -1
  9. package/dist/__tests__/fixtures/sigint-deploy-harness.js +8 -7
  10. package/dist/__tests__/fixtures/sigint-deploy-harness.js.map +1 -1
  11. package/dist/__tests__/fork/api.test.js +5 -0
  12. package/dist/__tests__/fork/api.test.js.map +1 -1
  13. package/dist/__tests__/fork/api.timeout.test.d.ts +2 -0
  14. package/dist/__tests__/fork/api.timeout.test.d.ts.map +1 -0
  15. package/dist/__tests__/fork/api.timeout.test.js +98 -0
  16. package/dist/__tests__/fork/api.timeout.test.js.map +1 -0
  17. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts +2 -0
  18. package/dist/commands/__tests__/compile.toml-mutation.test.d.ts.map +1 -0
  19. package/dist/commands/__tests__/compile.toml-mutation.test.js +69 -0
  20. package/dist/commands/__tests__/compile.toml-mutation.test.js.map +1 -0
  21. package/dist/commands/__tests__/init.test.js +73 -11
  22. package/dist/commands/__tests__/init.test.js.map +1 -1
  23. package/dist/commands/init.d.ts +22 -0
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +55 -6
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/core/AccountManager.d.ts.map +1 -1
  28. package/dist/core/AccountManager.js +14 -2
  29. package/dist/core/AccountManager.js.map +1 -1
  30. package/dist/core/Publisher.d.ts.map +1 -1
  31. package/dist/core/Publisher.js +52 -68
  32. package/dist/core/Publisher.js.map +1 -1
  33. package/dist/core/__tests__/AccountManager.global-state.test.d.ts +2 -0
  34. package/dist/core/__tests__/AccountManager.global-state.test.d.ts.map +1 -0
  35. package/dist/core/__tests__/AccountManager.global-state.test.js +69 -0
  36. package/dist/core/__tests__/AccountManager.global-state.test.js.map +1 -0
  37. package/dist/core/__tests__/movementProfile.test.d.ts +2 -0
  38. package/dist/core/__tests__/movementProfile.test.d.ts.map +1 -0
  39. package/dist/core/__tests__/movementProfile.test.js +112 -0
  40. package/dist/core/__tests__/movementProfile.test.js.map +1 -0
  41. package/dist/core/config.js +6 -5
  42. package/dist/core/config.js.map +1 -1
  43. package/dist/core/movementProfile.d.ts +55 -22
  44. package/dist/core/movementProfile.d.ts.map +1 -1
  45. package/dist/core/movementProfile.js +77 -99
  46. package/dist/core/movementProfile.js.map +1 -1
  47. package/dist/fork/__tests__/server.cors.test.d.ts +2 -0
  48. package/dist/fork/__tests__/server.cors.test.d.ts.map +1 -0
  49. package/dist/fork/__tests__/server.cors.test.js +79 -0
  50. package/dist/fork/__tests__/server.cors.test.js.map +1 -0
  51. package/dist/fork/api.d.ts +9 -1
  52. package/dist/fork/api.d.ts.map +1 -1
  53. package/dist/fork/api.js +37 -7
  54. package/dist/fork/api.js.map +1 -1
  55. package/dist/fork/server.d.ts +20 -1
  56. package/dist/fork/server.d.ts.map +1 -1
  57. package/dist/fork/server.js +19 -9
  58. package/dist/fork/server.js.map +1 -1
  59. package/dist/harness/Harness.d.ts +6 -2
  60. package/dist/harness/Harness.d.ts.map +1 -1
  61. package/dist/harness/Harness.js +8 -2
  62. package/dist/harness/Harness.js.map +1 -1
  63. package/dist/harness/codeObject.d.ts.map +1 -1
  64. package/dist/harness/codeObject.js +30 -33
  65. package/dist/harness/codeObject.js.map +1 -1
  66. package/dist/harness/script.d.ts +3 -3
  67. package/dist/harness/script.d.ts.map +1 -1
  68. package/dist/harness/script.js +33 -29
  69. package/dist/harness/script.js.map +1 -1
  70. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts +2 -0
  71. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.d.ts.map +1 -0
  72. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js +172 -0
  73. package/dist/helpers/__tests__/setupLocalTesting.fork-network.test.js.map +1 -0
  74. package/dist/helpers/setupLocalTesting.d.ts.map +1 -1
  75. package/dist/helpers/setupLocalTesting.js +28 -2
  76. package/dist/helpers/setupLocalTesting.js.map +1 -1
  77. package/dist/index.d.ts +1 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/node/LocalNodeManager.d.ts +8 -0
  80. package/dist/node/LocalNodeManager.d.ts.map +1 -1
  81. package/dist/node/LocalNodeManager.js +10 -1
  82. package/dist/node/LocalNodeManager.js.map +1 -1
  83. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts +2 -0
  84. package/dist/node/__tests__/LocalNodeManager.api-port.test.d.ts.map +1 -0
  85. package/dist/node/__tests__/LocalNodeManager.api-port.test.js +55 -0
  86. package/dist/node/__tests__/LocalNodeManager.api-port.test.js.map +1 -0
  87. package/dist/node/__tests__/LocalNodeManager.test.js +4 -3
  88. package/dist/node/__tests__/LocalNodeManager.test.js.map +1 -1
  89. package/dist/templates/move/Move.toml +1 -1
  90. package/dist/templates/move/sources/Counter.move +31 -4
  91. package/dist/templates/scripts/deploy-counter.ts +10 -0
  92. package/dist/types/config.d.ts +8 -1
  93. package/dist/types/config.d.ts.map +1 -1
  94. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts +2 -0
  95. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.d.ts.map +1 -0
  96. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js +43 -0
  97. package/dist/utils/__tests__/childProcessAdapter.maxBuffer.test.js.map +1 -0
  98. package/dist/utils/childProcessAdapter.d.ts +7 -0
  99. package/dist/utils/childProcessAdapter.d.ts.map +1 -1
  100. package/dist/utils/childProcessAdapter.js +20 -2
  101. package/dist/utils/childProcessAdapter.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/__tests__/deployContract.test.ts +59 -50
  104. package/src/__tests__/exports.test.ts +32 -0
  105. package/src/__tests__/fixtures/sigint-deploy-harness.ts +8 -7
  106. package/src/__tests__/fork/api.test.ts +5 -0
  107. package/src/__tests__/fork/api.timeout.test.ts +150 -0
  108. package/src/commands/__tests__/compile.toml-mutation.test.ts +77 -0
  109. package/src/commands/__tests__/init.test.ts +96 -11
  110. package/src/commands/init.ts +77 -6
  111. package/src/core/AccountManager.ts +18 -1
  112. package/src/core/Publisher.ts +58 -77
  113. package/src/core/__tests__/AccountManager.global-state.test.ts +83 -0
  114. package/src/core/__tests__/movementProfile.test.ts +131 -0
  115. package/src/core/config.ts +9 -5
  116. package/src/core/movementProfile.ts +75 -127
  117. package/src/fork/__tests__/server.cors.test.ts +101 -0
  118. package/src/fork/api.ts +69 -10
  119. package/src/fork/server.ts +38 -9
  120. package/src/harness/Harness.ts +11 -2
  121. package/src/harness/codeObject.ts +37 -43
  122. package/src/harness/script.ts +40 -39
  123. package/src/helpers/__tests__/setupLocalTesting.fork-network.test.ts +212 -0
  124. package/src/helpers/setupLocalTesting.ts +36 -2
  125. package/src/index.ts +9 -1
  126. package/src/node/LocalNodeManager.ts +24 -2
  127. package/src/node/__tests__/LocalNodeManager.api-port.test.ts +62 -0
  128. package/src/node/__tests__/LocalNodeManager.test.ts +4 -3
  129. package/src/templates/move/Move.toml +1 -1
  130. package/src/templates/move/sources/Counter.move +31 -4
  131. package/src/templates/scripts/deploy-counter.ts +10 -0
  132. package/src/types/config.ts +8 -1
  133. package/src/utils/__tests__/childProcessAdapter.maxBuffer.test.ts +51 -0
  134. package/src/utils/childProcessAdapter.ts +32 -2
@@ -1,6 +1,4 @@
1
- import { homedir } from "os";
2
- import { join } from "path";
3
- import { randomUUID } from "crypto";
1
+ import { PrivateKey, PrivateKeyVariants } from "@aptos-labs/ts-sdk";
4
2
  import type { MovehatRuntime } from "../types/runtime.js";
5
3
  import type {
6
4
  DeployCodeObjectOptions,
@@ -14,7 +12,7 @@ import {
14
12
  validateSafeName,
15
13
  type DeploymentInfo,
16
14
  } from "../core/deployments.js";
17
- import { validatePathSafety, validateProfileSafety } from "../core/shell.js";
15
+ import { validatePathSafety } from "../core/shell.js";
18
16
  import {
19
17
  CliExecutionError,
20
18
  ModuleAlreadyDeployedError,
@@ -24,10 +22,9 @@ import { runCli } from "../utils/runCli.js";
24
22
  import { parseTxHash } from "../utils/parseCliOutput.js";
25
23
  import { logger } from "../ui/index.js";
26
24
  import {
27
- withYamlLock,
28
- addProfile,
29
- removeProfile,
30
- removeProfileSync,
25
+ writeTempKeyFile,
26
+ removeKeyFile,
27
+ removeKeyFileSyncBestEffort,
31
28
  ensureSignalHandler,
32
29
  cleanupCallbacks,
33
30
  } from "../core/movementProfile.js";
@@ -170,9 +167,7 @@ async function executeMovementMoveObject(
170
167
  }
171
168
 
172
169
  const dir = opts.packageDir || config.moveDir;
173
- const profile = `movehat-deploy-${randomUUID().slice(0, 8)}`;
174
170
  const safeDir = validatePathSafety(dir, "package directory");
175
- const safeProfile = validateProfileSafety(profile);
176
171
 
177
172
  logger.step(
178
173
  `${subcommand === "deploy-object" ? "Deploying" : "Upgrading"} module "${moduleName}" from ${dir}...`
@@ -213,30 +208,28 @@ async function executeMovementMoveObject(
213
208
  );
214
209
  if (buildResult.stdout) console.log(buildResult.stdout.trim());
215
210
 
216
- // Strip `ed25519-priv-` prefix if present Movement CLI expects the
217
- // raw hex.
218
- let cleanPrivateKey = config.privateKey;
219
- if (cleanPrivateKey.startsWith("ed25519-priv-")) {
220
- cleanPrivateKey = cleanPrivateKey.replace("ed25519-priv-", "");
221
- }
211
+ // Format the private key into AIP-80 shape so the Movement CLI
212
+ // doesn't emit its raw-hex deprecation warning. `formatPrivateKey`
213
+ // is idempotent for already-prefixed inputs.
214
+ const formattedPrivateKey = PrivateKey.formatPrivateKey(
215
+ config.privateKey,
216
+ PrivateKeyVariants.Ed25519,
217
+ );
222
218
 
223
- const movementConfigPath = join(homedir(), ".aptos", "config.yaml");
219
+ // Pass the private key via a 0o600 temp file (--private-key-file)
220
+ // and the on-chain address via --sender-account. This avoids the
221
+ // CLI's profile-yaml lookup entirely — no CWD / HOME / .aptos /
222
+ // .movement dance, no CLI-variant dependency.
223
+ const keyFilePath = writeTempKeyFile(formattedPrivateKey);
224
224
 
225
- // Register SIGINT-safe sync cleanup BEFORE writing the key (same
226
- // pattern as Publisher closes bug #36).
225
+ // Register SIGINT-safe sync cleanup BEFORE invoking the CLI so
226
+ // the private key never persists on disk after an abnormal exit.
227
+ // The signal-handler path uses the best-effort variant because the
228
+ // event loop is dead and we cannot logger.warning.
227
229
  ensureSignalHandler();
228
- const syncCleanup = () => removeProfileSync(movementConfigPath, profile);
230
+ const syncCleanup = () => removeKeyFileSyncBestEffort(keyFilePath);
229
231
  cleanupCallbacks.add(syncCleanup);
230
232
 
231
- await withYamlLock(() =>
232
- addProfile(movementConfigPath, profile, {
233
- private_key: cleanPrivateKey,
234
- public_key: account.publicKey.toString(),
235
- account: deployerAddress,
236
- rest_url: config.rpc,
237
- })
238
- );
239
-
240
233
  let deployOut = "";
241
234
  try {
242
235
  logger.step(
@@ -258,8 +251,10 @@ async function executeMovementMoveObject(
258
251
  safeDir,
259
252
  "--url",
260
253
  config.rpc,
261
- "--profile",
262
- safeProfile,
254
+ "--private-key-file",
255
+ keyFilePath,
256
+ "--sender-account",
257
+ deployerAddress,
263
258
  "--assume-yes",
264
259
  ...includedArtifacts,
265
260
  ...namedAddrArgs,
@@ -273,18 +268,17 @@ async function executeMovementMoveObject(
273
268
  if (result.stdout) console.log(result.stdout.trim());
274
269
  if (result.stderr) console.error(result.stderr.trim());
275
270
  } finally {
276
- // Best-effort profile removal. CRITICAL: catch + log instead of
277
- // re-throwing an await-in-finally that throws would clobber the
278
- // try block's success/error (the bug-#37 lesson from Publisher).
279
- await withYamlLock(() => removeProfile(movementConfigPath, profile)).catch(
280
- (err) => {
281
- const cleanupMsg = err instanceof Error ? err.message : String(err);
282
- logger.warning(
283
- `Failed to remove deploy profile "${profile}" from ${movementConfigPath}: ${cleanupMsg}. ` +
284
- `Run 'movement config delete-profile --profile ${profile}' to clean up manually.`
285
- );
286
- }
287
- );
271
+ // Unlink via the observable helper emit a warning if the file
272
+ // could not be removed AND still exists on disk (private key
273
+ // would persist silently otherwise). ENOENT and races are
274
+ // treated as benign success.
275
+ const cleanupErr = removeKeyFile(keyFilePath);
276
+ if (cleanupErr) {
277
+ logger.warning(
278
+ `Failed to remove temp key file '${keyFilePath}': ${cleanupErr.message}. ` +
279
+ `The file has mode 0o600 but should be removed manually: rm ${keyFilePath}`
280
+ );
281
+ }
288
282
  cleanupCallbacks.delete(syncCleanup);
289
283
  }
290
284
 
@@ -1,22 +1,20 @@
1
1
  import { existsSync } from "fs";
2
- import { homedir } from "os";
3
- import { extname, join } from "path";
4
- import { randomUUID } from "crypto";
2
+ import { extname } from "path";
3
+ import { PrivateKey, PrivateKeyVariants } from "@aptos-labs/ts-sdk";
5
4
  import type { MovehatRuntime } from "../types/runtime.js";
6
5
  import type {
7
6
  RunMoveScriptOptions,
8
7
  MoveScriptResult,
9
8
  } from "../types/harness.js";
10
- import { validatePathSafety, validateProfileSafety } from "../core/shell.js";
9
+ import { validatePathSafety } from "../core/shell.js";
11
10
  import { CliExecutionError } from "../errors.js";
12
11
  import { runCli } from "../utils/runCli.js";
13
12
  import { parseTxHash } from "../utils/parseCliOutput.js";
14
13
  import { logger } from "../ui/index.js";
15
14
  import {
16
- withYamlLock,
17
- addProfile,
18
- removeProfile,
19
- removeProfileSync,
15
+ writeTempKeyFile,
16
+ removeKeyFile,
17
+ removeKeyFileSyncBestEffort,
20
18
  ensureSignalHandler,
21
19
  cleanupCallbacks,
22
20
  } from "../core/movementProfile.js";
@@ -29,9 +27,9 @@ import {
29
27
  * - `.mv` compiled bytecode → `--compiled-script-path`
30
28
  *
31
29
  * Reuses Publisher's security model via the shared `movementProfile`
32
- * helpers: per-deploy unique profile, atomic 0o600 yaml writes under
33
- * the mutex, SIGINT-safe sync cleanup, `--profile` auth (key never
34
- * appears in `ps` output).
30
+ * helpers: per-invocation temp key file (0o600), SIGINT-safe sync
31
+ * cleanup, `--private-key-file` auth (key never appears in `ps`
32
+ * output or in the user's `~/.aptos/config.yaml`).
35
33
  *
36
34
  * Returns {@link MoveScriptResult}. `txHash` is guaranteed; `success`
37
35
  * and `vmStatus` are best-effort parsed from the CLI's Result JSON.
@@ -70,8 +68,6 @@ export async function runMoveScript(
70
68
  }
71
69
 
72
70
  const safeScriptPath = validatePathSafety(options.scriptPath, "script path");
73
- const profile = `movehat-script-${randomUUID().slice(0, 8)}`;
74
- const safeProfile = validateProfileSafety(profile);
75
71
 
76
72
  logger.step(
77
73
  `Running Move script '${options.scriptPath}' on ${config.network}...`
@@ -80,26 +76,28 @@ export async function runMoveScript(
80
76
  try {
81
77
  const deployerAddress = account.accountAddress.toString();
82
78
 
83
- let cleanPrivateKey = config.privateKey;
84
- if (cleanPrivateKey.startsWith("ed25519-priv-")) {
85
- cleanPrivateKey = cleanPrivateKey.replace("ed25519-priv-", "");
86
- }
79
+ // Format the private key into AIP-80 shape before writing to the
80
+ // temp key file. `formatPrivateKey` is idempotent for already-
81
+ // prefixed inputs.
82
+ const formattedPrivateKey = PrivateKey.formatPrivateKey(
83
+ config.privateKey,
84
+ PrivateKeyVariants.Ed25519,
85
+ );
87
86
 
88
- const movementConfigPath = join(homedir(), ".aptos", "config.yaml");
87
+ // Pass the private key via a 0o600 temp file (--private-key-file)
88
+ // and the on-chain address via --sender-account. Avoids the CLI's
89
+ // profile-yaml lookup chain entirely (no CWD / HOME / .aptos /
90
+ // .movement dance, no CLI-variant dependency).
91
+ const keyFilePath = writeTempKeyFile(formattedPrivateKey);
89
92
 
93
+ // SIGINT-safe sync cleanup BEFORE the CLI call so the private key
94
+ // never persists on disk after an abnormal exit. The signal-handler
95
+ // path uses the best-effort variant because the event loop is dead
96
+ // and we cannot logger.warning.
90
97
  ensureSignalHandler();
91
- const syncCleanup = () => removeProfileSync(movementConfigPath, profile);
98
+ const syncCleanup = () => removeKeyFileSyncBestEffort(keyFilePath);
92
99
  cleanupCallbacks.add(syncCleanup);
93
100
 
94
- await withYamlLock(() =>
95
- addProfile(movementConfigPath, profile, {
96
- private_key: cleanPrivateKey,
97
- public_key: account.publicKey.toString(),
98
- account: deployerAddress,
99
- rest_url: config.rpc,
100
- })
101
- );
102
-
103
101
  let scriptOut = "";
104
102
  try {
105
103
  const typeArgsFragment: string[] =
@@ -117,8 +115,10 @@ export async function runMoveScript(
117
115
  args: [
118
116
  "move",
119
117
  "run-script",
120
- "--profile",
121
- safeProfile,
118
+ "--private-key-file",
119
+ keyFilePath,
120
+ "--sender-account",
121
+ deployerAddress,
122
122
  "--url",
123
123
  config.rpc,
124
124
  "--assume-yes",
@@ -135,15 +135,16 @@ export async function runMoveScript(
135
135
  if (result.stdout) console.log(result.stdout.trim());
136
136
  if (result.stderr) console.error(result.stderr.trim());
137
137
  } finally {
138
- await withYamlLock(() => removeProfile(movementConfigPath, profile)).catch(
139
- (err) => {
140
- const cleanupMsg = err instanceof Error ? err.message : String(err);
141
- logger.warning(
142
- `Failed to remove script profile "${profile}" from ${movementConfigPath}: ${cleanupMsg}. ` +
143
- `Run 'movement config delete-profile --profile ${profile}' to clean up manually.`
144
- );
145
- }
146
- );
138
+ // Observable cleanup emit a warning if the unlink failed and
139
+ // the file is still on disk (private key would persist silently
140
+ // otherwise).
141
+ const cleanupErr = removeKeyFile(keyFilePath);
142
+ if (cleanupErr) {
143
+ logger.warning(
144
+ `Failed to remove temp key file '${keyFilePath}': ${cleanupErr.message}. ` +
145
+ `The file has mode 0o600 but should be removed manually: rm ${keyFilePath}`
146
+ );
147
+ }
147
148
  cleanupCallbacks.delete(syncCleanup);
148
149
  }
149
150
 
@@ -0,0 +1,212 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ /**
7
+ * F1 — Harness.createFork(network) must honor the requested network.
8
+ *
9
+ * Mocks ForkManager so we can capture which RPC URL the fresh-fork
10
+ * code path picks. Strategy mirrors the pattern in
11
+ * src/fork/__tests__/manager.test.ts: replace the manager module with
12
+ * a stub that records every call to `initialize`.
13
+ *
14
+ * The mock also implements `load()` + `getMetadata()` so the
15
+ * "fork-exists, wrong network" case (audit-f1 follow-up) can exercise
16
+ * the metadata-mismatch guard in the `else` branch of `setupWithFork`.
17
+ */
18
+
19
+ interface InitCall {
20
+ nodeUrl: string;
21
+ networkName?: string;
22
+ apiKey?: string;
23
+ }
24
+ const initializeCalls: InitCall[] = [];
25
+
26
+ vi.mock("../../fork/manager.js", async () => {
27
+ const fs = await import("node:fs");
28
+ return {
29
+ ForkManager: class {
30
+ forkPath: string;
31
+ metadata: { network: string; nodeUrl: string } | null = null;
32
+ constructor(forkPath: string) {
33
+ this.forkPath = forkPath;
34
+ }
35
+ async initialize(nodeUrl: string, networkName?: string, apiKey?: string) {
36
+ const entry: InitCall = { nodeUrl };
37
+ if (networkName !== undefined) entry.networkName = networkName;
38
+ if (apiKey !== undefined) entry.apiKey = apiKey;
39
+ initializeCalls.push(entry);
40
+ this.metadata = { network: networkName ?? "custom", nodeUrl };
41
+ }
42
+ load() {
43
+ const raw = fs.readFileSync(`${this.forkPath}/metadata.json`, "utf-8");
44
+ this.metadata = JSON.parse(raw);
45
+ }
46
+ getMetadata() {
47
+ if (!this.metadata) {
48
+ throw new Error("Fork not initialized");
49
+ }
50
+ return this.metadata;
51
+ }
52
+ setApiKey() {}
53
+ async resetState() {}
54
+ async fundAccount() {}
55
+ async fundMultipleAccounts() {}
56
+ },
57
+ };
58
+ });
59
+
60
+ vi.mock("../../fork/server.js", () => {
61
+ return {
62
+ ForkServer: class {
63
+ constructor(_p: string, _port: number) {}
64
+ async start() {}
65
+ async stop() {}
66
+ },
67
+ };
68
+ });
69
+
70
+ vi.mock("../../runtime.js", () => ({
71
+ initRuntime: vi.fn(async () => ({})),
72
+ }));
73
+
74
+ vi.mock("../../core/AccountManager.js", () => {
75
+ let _seq = 0;
76
+ return {
77
+ AccountManager: {
78
+ createBatch(labels: readonly string[]) {
79
+ const out: Record<string, { accountAddress: { toString(): string } }> = {};
80
+ for (const l of labels) {
81
+ _seq++;
82
+ const addr = "0x" + _seq.toString(16).padStart(64, "0");
83
+ out[l] = { accountAddress: { toString: () => addr } };
84
+ }
85
+ return out;
86
+ },
87
+ exportPrivateKeys(_labels: readonly string[]) {
88
+ return { deployer: "0x" + "1".repeat(64) };
89
+ },
90
+ },
91
+ };
92
+ });
93
+
94
+ // Imported after mocks so vi.hoisted ordering applies.
95
+ import { setupLocalTesting } from "../setupLocalTesting.js";
96
+ import { logger } from "../../ui/index.js";
97
+
98
+ describe("F1 — setupLocalTesting honors forkNetwork", () => {
99
+ let cwdBackup: string;
100
+ let tmpRoot: string;
101
+
102
+ beforeEach(() => {
103
+ initializeCalls.length = 0;
104
+ cwdBackup = process.cwd();
105
+ tmpRoot = mkdtempSync(join(tmpdir(), "movehat-f1-"));
106
+ process.chdir(tmpRoot);
107
+ vi.spyOn(logger, "step").mockImplementation(() => undefined);
108
+ vi.spyOn(logger, "success").mockImplementation(() => undefined);
109
+ vi.spyOn(logger, "plain").mockImplementation(() => undefined);
110
+ vi.spyOn(logger, "newline").mockImplementation(() => undefined);
111
+ vi.spyOn(logger, "warning").mockImplementation(() => undefined);
112
+ vi.spyOn(logger, "error").mockImplementation(() => undefined);
113
+ });
114
+
115
+ afterEach(() => {
116
+ process.chdir(cwdBackup);
117
+ rmSync(tmpRoot, { recursive: true, force: true });
118
+ vi.restoreAllMocks();
119
+ });
120
+
121
+ it("uses the mainnet RPC when forkNetwork = 'mainnet'", async () => {
122
+ await setupLocalTesting({
123
+ mode: "fork",
124
+ forkNetwork: "mainnet",
125
+ accountLabels: ["deployer"],
126
+ autoFund: false,
127
+ });
128
+
129
+ expect(initializeCalls).toHaveLength(1);
130
+ const call = initializeCalls[0]!;
131
+ expect(call.networkName).toBe("mainnet");
132
+ expect(call.nodeUrl).not.toMatch(/testnet/i);
133
+ expect(call.nodeUrl).toMatch(/mainnet/i);
134
+ });
135
+
136
+ it("uses the testnet RPC when forkNetwork = 'testnet'", async () => {
137
+ await setupLocalTesting({
138
+ mode: "fork",
139
+ forkNetwork: "testnet",
140
+ accountLabels: ["deployer"],
141
+ autoFund: false,
142
+ });
143
+
144
+ expect(initializeCalls).toHaveLength(1);
145
+ const call = initializeCalls[0]!;
146
+ expect(call.networkName).toBe("testnet");
147
+ expect(call.nodeUrl).toMatch(/testnet/i);
148
+ });
149
+
150
+ it("uses forkRpcUrl override when supplied for a custom network", async () => {
151
+ await setupLocalTesting({
152
+ mode: "fork",
153
+ forkNetwork: "custom",
154
+ forkRpcUrl: "https://my-custom-node.example/v1",
155
+ accountLabels: ["deployer"],
156
+ autoFund: false,
157
+ });
158
+
159
+ expect(initializeCalls).toHaveLength(1);
160
+ const call = initializeCalls[0]!;
161
+ expect(call.networkName).toBe("custom");
162
+ expect(call.nodeUrl).toBe("https://my-custom-node.example/v1");
163
+ });
164
+
165
+ it("rejects a non-built-in forkNetwork when no forkRpcUrl is provided", async () => {
166
+ await expect(
167
+ setupLocalTesting({
168
+ mode: "fork",
169
+ forkNetwork: "some-unknown-network",
170
+ accountLabels: ["deployer"],
171
+ autoFund: false,
172
+ })
173
+ ).rejects.toThrow(/forkRpcUrl/i);
174
+ expect(initializeCalls).toHaveLength(0);
175
+ });
176
+
177
+ it("rejects when an existing fork's saved network does not match the requested one (audit-f1 follow-up)", async () => {
178
+ // Pre-seed `.movehat/forks/test-local/metadata.json` with the
179
+ // wrong network so the `forkExists` branch fires and loads stale
180
+ // metadata. Without the metadata-mismatch guard, setupLocalTesting
181
+ // would silently serve a testnet snapshot while the caller thinks
182
+ // it's reading mainnet.
183
+ const forkDir = join(tmpRoot, ".movehat", "forks", "test-local");
184
+ mkdirSync(forkDir, { recursive: true });
185
+ writeFileSync(
186
+ join(forkDir, "metadata.json"),
187
+ JSON.stringify({
188
+ network: "testnet",
189
+ nodeUrl: "https://testnet.movementnetwork.xyz/v1",
190
+ chainId: 250,
191
+ ledgerVersion: "0",
192
+ timestamp: "0",
193
+ epoch: "0",
194
+ blockHeight: "0",
195
+ createdAt: new Date().toISOString(),
196
+ }),
197
+ );
198
+
199
+ await expect(
200
+ setupLocalTesting({
201
+ mode: "fork",
202
+ forkNetwork: "mainnet",
203
+ accountLabels: ["deployer"],
204
+ autoFund: false,
205
+ })
206
+ ).rejects.toThrow(/network mismatch|created for|requested/i);
207
+ // Must not have re-initialized — the existing dir is what poisons
208
+ // the load path, and silently reinitializing would clobber the
209
+ // user's saved snapshot.
210
+ expect(initializeCalls).toHaveLength(0);
211
+ });
212
+ });
@@ -9,6 +9,25 @@ import { AccountManager } from "../core/AccountManager.js";
9
9
  import { logger } from "../ui/index.js";
10
10
  import type { LocalTestOptions } from "../types/config.js";
11
11
 
12
+ const BUILTIN_FORK_RPCS: Record<string, string> = {
13
+ testnet: "https://testnet.movementnetwork.xyz/v1",
14
+ mainnet: "https://mainnet.movementnetwork.xyz/v1",
15
+ };
16
+
17
+ function resolveForkRpcUrl(
18
+ network: string,
19
+ override: string | undefined
20
+ ): string {
21
+ if (override !== undefined) return override;
22
+ const builtin = BUILTIN_FORK_RPCS[network];
23
+ if (builtin !== undefined) return builtin;
24
+ throw new Error(
25
+ `Cannot fork unknown network "${network}" without a forkRpcUrl. ` +
26
+ `Either pass forkRpcUrl in LocalTestOptions or use one of: ` +
27
+ `${Object.keys(BUILTIN_FORK_RPCS).join(", ")}.`
28
+ );
29
+ }
30
+
12
31
  /**
13
32
  * Context returned by {@link setupLocalTesting}.
14
33
  *
@@ -264,8 +283,8 @@ async function setupWithFork(
264
283
 
265
284
  if (!forkExists) {
266
285
  logger.step(`Creating fork from ${forkNetwork}...`);
267
- const testnetRpc = "https://testnet.movementnetwork.xyz/v1";
268
- await forkManager.initialize(testnetRpc, forkNetwork, options.forkApiKey);
286
+ const rpcUrl = resolveForkRpcUrl(forkNetwork, options.forkRpcUrl);
287
+ await forkManager.initialize(rpcUrl, forkNetwork, options.forkApiKey);
269
288
  logger.success(`Fork created at ${forkPath}`);
270
289
  logger.newline();
271
290
  } else {
@@ -278,6 +297,21 @@ async function setupWithFork(
278
297
  }
279
298
  forkManager.load();
280
299
 
300
+ // Guard against the audit-f1 follow-up case: the default forkName
301
+ // ("test-local") doesn't encode the network, so a fork created for
302
+ // testnet would silently serve mainnet requests. Refuse to load
303
+ // when the saved metadata's network doesn't match what the caller
304
+ // asked for — the user must either pass a network-specific
305
+ // `forkName` or delete the stale directory.
306
+ const savedNetwork = forkManager.getMetadata().network;
307
+ if (savedNetwork !== forkNetwork) {
308
+ throw new Error(
309
+ `Fork at ${forkPath} was created for network "${savedNetwork}" but ` +
310
+ `you requested "${forkNetwork}". Use a different forkName ` +
311
+ `(e.g. "${forkNetwork}-local") or delete ${forkPath} to recreate.`
312
+ );
313
+ }
314
+
281
315
  if (forkResetState) {
282
316
  logger.step("Resetting fork state...");
283
317
  await forkManager.resetState();
package/src/index.ts CHANGED
@@ -19,4 +19,12 @@ export type { ForkMetadata, AccountState, LedgerInfo, AccountData, AccountResour
19
19
  export { ModuleAlreadyDeployedError, PostPublishError } from "./errors.js";
20
20
 
21
21
  export { Harness, HarnessDisposedError } from "./harness/index.js";
22
- export type { HarnessMode } from "./harness/index.js";
22
+ export type { HarnessMode } from "./harness/index.js";
23
+ export type {
24
+ DeployCodeObjectOptions,
25
+ UpgradeCodeObjectOptions,
26
+ CodeObjectInfo,
27
+ RunViewFunctionOptions,
28
+ RunMoveScriptOptions,
29
+ MoveScriptResult,
30
+ } from "./types/harness.js";
@@ -12,7 +12,15 @@ export interface LocalNodeOptions {
12
12
  testDir?: string; // Directory for node data (default: .movehat/local-node)
13
13
  forceRestart?: boolean; // Clean state and start fresh
14
14
  faucetPort?: number; // Faucet port (default: 8081)
15
- apiPort?: number; // API/RPC port (default: 8080)
15
+ /**
16
+ * REST API port. Movement CLI (`movement node run-localnet`) does
17
+ * not accept a flag to change this — the node always binds 8080.
18
+ * Passing any other value triggers a warning at construction time
19
+ * and is replaced with 8080. Field is kept for source compatibility.
20
+ *
21
+ * @deprecated Movement CLI does not honor this. Omit it.
22
+ */
23
+ apiPort?: number;
16
24
  readyPort?: number; // Ready server port (default: 8070)
17
25
  silent?: boolean; // Suppress node output
18
26
  /**
@@ -22,6 +30,8 @@ export interface LocalNodeOptions {
22
30
  adapter?: ChildProcessAdapter;
23
31
  }
24
32
 
33
+ const MOVEMENT_API_PORT = 8080;
34
+
25
35
  export interface LocalNodeInfo {
26
36
  rpcUrl: string;
27
37
  faucetUrl: string;
@@ -47,11 +57,23 @@ export class LocalNodeManager {
47
57
 
48
58
  constructor(options: LocalNodeOptions = {}) {
49
59
  this.adapter = options.adapter ?? defaultChildProcessAdapter;
60
+ if (
61
+ options.apiPort !== undefined &&
62
+ options.apiPort !== MOVEMENT_API_PORT
63
+ ) {
64
+ // Movement CLI hardcodes the REST API port to 8080. Surfacing
65
+ // the requested port via getNodeInfo() would lie about where
66
+ // the node actually listens.
67
+ logger.warning(
68
+ `LocalNodeManager: apiPort=${options.apiPort} is not supported by ` +
69
+ `movement node run-localnet; forcing REST API port to 8080.`
70
+ );
71
+ }
50
72
  this.options = {
51
73
  testDir: options.testDir || join(process.cwd(), ".movehat", "local-node"),
52
74
  forceRestart: options.forceRestart ?? false,
53
75
  faucetPort: options.faucetPort || 8081,
54
- apiPort: options.apiPort || 8080,
76
+ apiPort: MOVEMENT_API_PORT,
55
77
  readyPort: options.readyPort || 8070,
56
78
  silent: options.silent ?? false,
57
79
  };
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { LocalNodeManager } from "../LocalNodeManager.js";
7
+ import { logger } from "../../ui/index.js";
8
+
9
+ /**
10
+ * F9 — `apiPort` must not lie about where the node listens.
11
+ *
12
+ * `movement node run-localnet` (Movement CLI 7.4.0) does NOT accept a
13
+ * flag to change the REST API port. It always binds 8080. Earlier
14
+ * versions of LocalNodeManager accepted `apiPort: 9000` from the
15
+ * caller, stored it, and surfaced `http://127.0.0.1:9000` from
16
+ * `getNodeInfo()` — but the actual node was still on 8080. That
17
+ * mismatch would silently surface as "Movement command failed" with
18
+ * no useful signal. F9 closes the gap by refusing to lie: the
19
+ * effective port is 8080 regardless of what the caller passes, with a
20
+ * warning when they pass anything else.
21
+ */
22
+
23
+ describe("F9 — LocalNodeManager apiPort is constrained to 8080", () => {
24
+ let tmpDir: string;
25
+ let warnSpy: ReturnType<typeof vi.spyOn>;
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(join(tmpdir(), "movehat-f9-"));
29
+ warnSpy = vi.spyOn(logger, "warning").mockImplementation(() => undefined);
30
+ vi.spyOn(logger, "step").mockImplementation(() => undefined);
31
+ vi.spyOn(logger, "plain").mockImplementation(() => undefined);
32
+ vi.spyOn(logger, "newline").mockImplementation(() => undefined);
33
+ vi.spyOn(logger, "success").mockImplementation(() => undefined);
34
+ vi.spyOn(logger, "error").mockImplementation(() => undefined);
35
+ });
36
+
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ rmSync(tmpDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it("ignores non-default apiPort, forces 8080, and warns", () => {
43
+ const mgr = new LocalNodeManager({ testDir: tmpDir, apiPort: 9000 });
44
+ expect(mgr.getNodeInfo().rpcUrl).toBe("http://127.0.0.1:8080");
45
+ expect(warnSpy).toHaveBeenCalledTimes(1);
46
+ const msg = warnSpy.mock.calls[0]?.[0] as string;
47
+ expect(msg).toMatch(/8080/);
48
+ expect(msg).toMatch(/apiPort|REST API port/i);
49
+ });
50
+
51
+ it("accepts apiPort: 8080 without warning", () => {
52
+ const mgr = new LocalNodeManager({ testDir: tmpDir, apiPort: 8080 });
53
+ expect(mgr.getNodeInfo().rpcUrl).toBe("http://127.0.0.1:8080");
54
+ expect(warnSpy).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it("accepts omitted apiPort without warning (default path)", () => {
58
+ const mgr = new LocalNodeManager({ testDir: tmpDir });
59
+ expect(mgr.getNodeInfo().rpcUrl).toBe("http://127.0.0.1:8080");
60
+ expect(warnSpy).not.toHaveBeenCalled();
61
+ });
62
+ });