movehat 0.2.7 → 0.2.8

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/dist/cli.js CHANGED
@@ -66,7 +66,7 @@ program
66
66
  });
67
67
  program
68
68
  .command('compile')
69
- .description('Compile Move smart contracts using Movement CLI')
69
+ .description('Compile Move smart contracts (auto-detects named addresses and updates Move.toml)')
70
70
  .action(compileCommand);
71
71
  program
72
72
  .command('run <script>')
@@ -34,9 +34,11 @@ export default async function forkFundCommand(options) {
34
34
  // Verify
35
35
  const resourceType = `0x1::coin::CoinStore<${coinType}>`;
36
36
  const coinStore = await forkManager.getResource(options.account, resourceType);
37
+ const { assertCoinStore } = await import('../../fork/validation.js');
38
+ const validated = assertCoinStore(coinStore);
37
39
  logger.newline();
38
40
  logger.success("Account funded successfully!");
39
- logger.plain(` New balance: ${coinStore.coin.value}`);
41
+ logger.plain(` New balance: ${validated.coin.value}`);
40
42
  logger.newline();
41
43
  }
42
44
  catch (error) {
@@ -2,6 +2,7 @@ import { join } from 'path';
2
2
  import { existsSync } from 'fs';
3
3
  import { loadUserConfig } from '../../core/config.js';
4
4
  import { ForkServer } from '../../fork/server.js';
5
+ import { logger } from '../../ui/index.js';
5
6
  /**
6
7
  * Fork serve command: Start a local RPC server serving the fork
7
8
  */
@@ -26,9 +27,11 @@ export default async function forkServeCommand(options) {
26
27
  }
27
28
  // Verify fork exists
28
29
  if (!existsSync(join(forkPath, 'metadata.json'))) {
29
- console.error(`\nError: Fork not found at ${forkPath}`);
30
- console.error(`\nCreate a fork first with:`);
31
- console.error(` movehat fork create --network <network> --name <name>`);
30
+ logger.newline();
31
+ logger.error(`Fork not found at ${forkPath}`);
32
+ logger.newline();
33
+ logger.error("Create a fork first with:");
34
+ logger.error(" movehat fork create --network <network> --name <name>");
32
35
  process.exit(1);
33
36
  }
34
37
  // Get port (already validated by Commander's parsePort in cli.ts)
@@ -38,7 +41,8 @@ export default async function forkServeCommand(options) {
38
41
  const server = new ForkServer(forkPath, port, host);
39
42
  // Handle graceful shutdown (use 'once' to prevent duplicate shutdowns)
40
43
  const shutdown = async () => {
41
- console.log('\n\nShutting down...');
44
+ logger.newline();
45
+ logger.step("Shutting down...");
42
46
  await server.stop();
43
47
  process.exit(0);
44
48
  };
@@ -59,7 +63,8 @@ export default async function forkServeCommand(options) {
59
63
  }
60
64
  catch (error) {
61
65
  const msg = error instanceof Error ? error.message : String(error);
62
- console.error(`\nError starting fork server:`, msg);
66
+ logger.newline();
67
+ logger.error(`Error starting fork server: ${msg}`);
63
68
  process.exit(1);
64
69
  }
65
70
  }
@@ -1,7 +1,9 @@
1
1
  import { runMoveTests } from "../helpers/move-tests.js";
2
+ import { logger } from "../ui/index.js";
2
3
  export default async function testMoveCommand(options = {}) {
3
4
  try {
4
- console.log("Running Move unit tests...\n");
5
+ logger.step("Running Move unit tests...");
6
+ logger.newline();
5
7
  await runMoveTests({
6
8
  filter: options.filter,
7
9
  ignoreWarnings: options.ignoreWarnings,
@@ -10,10 +12,11 @@ export default async function testMoveCommand(options = {}) {
10
12
  process.exit(0);
11
13
  }
12
14
  catch (err) {
13
- console.error("\n✗ Move tests failed");
15
+ logger.newline();
16
+ logger.error("Move tests failed");
14
17
  const msg = err instanceof Error ? err.message : String(err);
15
18
  if (msg) {
16
- console.error(` ${msg}`);
19
+ logger.error(` ${msg}`);
17
20
  }
18
21
  process.exit(1);
19
22
  }
@@ -55,7 +55,7 @@ export declare class MovementApiClient {
55
55
  /**
56
56
  * Get a specific account resource
57
57
  */
58
- getAccountResource(address: string, resourceType: string): Promise<any>;
58
+ getAccountResource(address: string, resourceType: string): Promise<AccountResource>;
59
59
  /**
60
60
  * Get all resources for an account
61
61
  */
package/dist/fork/api.js CHANGED
@@ -2,6 +2,7 @@ import https from 'https';
2
2
  import http from 'http';
3
3
  import { URL } from 'url';
4
4
  import { normalizeAddressShort } from '../utils/address.js';
5
+ import { assertLedgerInfo, assertAccountData, assertAccountResource, assertAccountResourceArray, } from './validation.js';
5
6
  const DEFAULT_TIMEOUT_MS = 30_000;
6
7
  const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
7
8
  /**
@@ -191,14 +192,16 @@ export class MovementApiClient {
191
192
  * Get ledger information
192
193
  */
193
194
  async getLedgerInfo() {
194
- return this.get(this.apiPath('/'));
195
+ const raw = await this.get(this.apiPath('/'));
196
+ return assertLedgerInfo(raw);
195
197
  }
196
198
  /**
197
199
  * Get account information
198
200
  */
199
201
  async getAccount(address) {
200
202
  const normalizedAddress = normalizeAddressShort(address);
201
- return this.get(this.apiPath(`/accounts/${normalizedAddress}`));
203
+ const raw = await this.get(this.apiPath(`/accounts/${normalizedAddress}`));
204
+ return assertAccountData(raw);
202
205
  }
203
206
  /**
204
207
  * Get a specific account resource
@@ -207,14 +210,16 @@ export class MovementApiClient {
207
210
  const normalizedAddress = normalizeAddressShort(address);
208
211
  // URL encode the resource type
209
212
  const encodedType = encodeURIComponent(resourceType);
210
- return this.get(this.apiPath(`/accounts/${normalizedAddress}/resource/${encodedType}`));
213
+ const raw = await this.get(this.apiPath(`/accounts/${normalizedAddress}/resource/${encodedType}`));
214
+ return assertAccountResource(raw);
211
215
  }
212
216
  /**
213
217
  * Get all resources for an account
214
218
  */
215
219
  async getAccountResources(address) {
216
220
  const normalizedAddress = normalizeAddressShort(address);
217
- return this.get(this.apiPath(`/accounts/${normalizedAddress}/resources`));
221
+ const raw = await this.get(this.apiPath(`/accounts/${normalizedAddress}/resources`));
222
+ return assertAccountResourceArray(raw);
218
223
  }
219
224
  /**
220
225
  * Execute a Move view function via the upstream node's POST /v1/view.
@@ -36,8 +36,8 @@ export declare class ForkManager {
36
36
  load(): void;
37
37
  getMetadata(): ForkMetadata;
38
38
  getAccount(address: string): Promise<AccountState>;
39
- getResource(address: string, resourceType: string): Promise<any>;
40
- getAllResources(address: string): Promise<Record<string, any>>;
39
+ getResource(address: string, resourceType: string): Promise<unknown>;
40
+ getAllResources(address: string): Promise<Record<string, unknown>>;
41
41
  /**
42
42
  * Stateless passthrough of `POST /v1/view` to the upstream RPC.
43
43
  *
@@ -3,6 +3,7 @@ import { MovementApiClient } from './api.js';
3
3
  import { ForkStorage } from './storage.js';
4
4
  import { normalizeAddress } from '../utils/address.js';
5
5
  import { logger } from '../ui/index.js';
6
+ import { assertCoinStore } from './validation.js';
6
7
  /**
7
8
  * Derive a deterministic 32-byte hex placeholder for the `authentication_key`
8
9
  * of a fork-funded account. The real auth_key is `sha3_256(public_key || 0x00)`
@@ -182,17 +183,12 @@ export class ForkManager {
182
183
  async fundAccount(address, amount, coinType = '0x1::aptos_coin::AptosCoin') {
183
184
  const normalizedAddress = normalizeAddress(address);
184
185
  const resourceType = `0x1::coin::CoinStore<${coinType}>`;
185
- // Try to get existing coin store. The coin store is a CoinStore<T>
186
- // resource whose `data` is Movement-side untyped JSON; we shape it
187
- // locally as a structural object with `coin.value: string`.
188
- // any: full CoinStore schema lives at the Movement REST boundary —
189
- // proper validation deferred to the boundary-validation follow-up of #57.
190
186
  let coinStore;
191
187
  try {
192
- coinStore = await this.getResource(normalizedAddress, resourceType);
188
+ const raw = await this.getResource(normalizedAddress, resourceType);
189
+ coinStore = assertCoinStore(raw);
193
190
  }
194
191
  catch (error) {
195
- // Only catch "not found" errors, rethrow others (network, API, etc.)
196
192
  const msg = error instanceof Error ? error.message : String(error);
197
193
  if (!msg.includes('not found')) {
198
194
  throw error;
@@ -220,7 +216,7 @@ export class ForkManager {
220
216
  frozen: false,
221
217
  };
222
218
  }
223
- const currentBalance = BigInt(coinStore.coin.value ?? '0');
219
+ const currentBalance = BigInt(coinStore.coin.value);
224
220
  const newBalance = currentBalance + BigInt(amount);
225
221
  coinStore.coin.value = newBalance.toString();
226
222
  await this.setResource(normalizedAddress, resourceType, coinStore);
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { isHexAddress } from '../utils/address.js';
4
+ import { assertForkMetadata, assertAccountStateRecord } from './validation.js';
4
5
  /**
5
6
  * Sanitize address to create a safe filename. Validates the address through
6
7
  * the shared `isHexAddress` helper (length 1–64 hex chars, optional `0x`),
@@ -108,7 +109,7 @@ export class ForkStorage {
108
109
  if (!existsSync(metadataPath)) {
109
110
  throw new Error(`Fork metadata not found at ${metadataPath}`);
110
111
  }
111
- return readJsonFile(metadataPath, 'fork metadata');
112
+ return assertForkMetadata(readJsonFile(metadataPath, 'fork metadata'));
112
113
  }
113
114
  /**
114
115
  * Get account state
@@ -118,7 +119,7 @@ export class ForkStorage {
118
119
  if (!existsSync(accountsPath)) {
119
120
  return null;
120
121
  }
121
- const accounts = readJsonFile(accountsPath, 'fork accounts');
122
+ const accounts = assertAccountStateRecord(readJsonFile(accountsPath, 'fork accounts'));
122
123
  return accounts[address] || null;
123
124
  }
124
125
  /**
@@ -128,7 +129,7 @@ export class ForkStorage {
128
129
  const accountsPath = join(this.forkPath, 'accounts.json');
129
130
  let accounts = {};
130
131
  if (existsSync(accountsPath)) {
131
- accounts = readJsonFile(accountsPath, 'fork accounts');
132
+ accounts = assertAccountStateRecord(readJsonFile(accountsPath, 'fork accounts'));
132
133
  }
133
134
  accounts[address] = state;
134
135
  writePrivateFile(accountsPath, JSON.stringify(accounts, null, 2));
@@ -193,7 +194,7 @@ export class ForkStorage {
193
194
  if (!existsSync(accountsPath)) {
194
195
  return [];
195
196
  }
196
- const accounts = readJsonFile(accountsPath, 'fork accounts');
197
+ const accounts = assertAccountStateRecord(readJsonFile(accountsPath, 'fork accounts'));
197
198
  return Object.keys(accounts);
198
199
  }
199
200
  /**
@@ -59,7 +59,7 @@ export declare function getForkInfo(path: string): Promise<ForkInfo>;
59
59
  */
60
60
  export declare function viewForkResource(sessionPath: string, account: string, resourceType: string, options?: {
61
61
  adapter?: ChildProcessAdapter;
62
- }): Promise<any>;
62
+ }): Promise<unknown>;
63
63
  /**
64
64
  * Compare a resource between current network state and a fork
65
65
  * Useful for verifying state changes after tests
@@ -0,0 +1,9 @@
1
+ import type { LedgerInfo, AccountData, AccountResource, ForkMetadata, AccountState, CoinStore } from "../types/fork.js";
2
+ export declare function assertLedgerInfo(v: unknown): LedgerInfo;
3
+ export declare function assertAccountData(v: unknown): AccountData;
4
+ export declare function assertAccountResource(v: unknown): AccountResource;
5
+ export declare function assertAccountResourceArray(v: unknown): AccountResource[];
6
+ export declare function assertForkMetadata(v: unknown): ForkMetadata;
7
+ export declare function assertAccountState(v: unknown): AccountState;
8
+ export declare function assertAccountStateRecord(v: unknown): Record<string, AccountState>;
9
+ export declare function assertCoinStore(v: unknown): CoinStore;
@@ -0,0 +1,88 @@
1
+ function isObject(v) {
2
+ return v !== null && typeof v === "object" && !Array.isArray(v);
3
+ }
4
+ function hasString(o, key) {
5
+ return typeof o[key] === "string";
6
+ }
7
+ export function assertLedgerInfo(v) {
8
+ if (!isObject(v) ||
9
+ typeof v.chain_id !== "number" ||
10
+ !hasString(v, "epoch") ||
11
+ !hasString(v, "ledger_version") ||
12
+ !hasString(v, "oldest_ledger_version") ||
13
+ !hasString(v, "ledger_timestamp") ||
14
+ !hasString(v, "node_role") ||
15
+ !hasString(v, "oldest_block_height") ||
16
+ !hasString(v, "block_height")) {
17
+ throw new Error("Invalid LedgerInfo: missing or incorrectly typed fields");
18
+ }
19
+ return v;
20
+ }
21
+ export function assertAccountData(v) {
22
+ if (!isObject(v) ||
23
+ !hasString(v, "sequence_number") ||
24
+ !hasString(v, "authentication_key")) {
25
+ throw new Error("Invalid AccountData: expected object with 'sequence_number' and 'authentication_key' strings");
26
+ }
27
+ return v;
28
+ }
29
+ export function assertAccountResource(v) {
30
+ if (!isObject(v) || !hasString(v, "type") || !("data" in v)) {
31
+ throw new Error("Invalid AccountResource: expected object with 'type' string and 'data' field");
32
+ }
33
+ return v;
34
+ }
35
+ export function assertAccountResourceArray(v) {
36
+ if (!Array.isArray(v)) {
37
+ throw new Error("Invalid resources response: expected array");
38
+ }
39
+ for (let i = 0; i < v.length; i++) {
40
+ if (!isObject(v[i]) || !hasString(v[i], "type") || !("data" in v[i])) {
41
+ throw new Error(`Invalid AccountResource at index ${i}: expected object with 'type' and 'data'`);
42
+ }
43
+ }
44
+ return v;
45
+ }
46
+ export function assertForkMetadata(v) {
47
+ if (!isObject(v) ||
48
+ !hasString(v, "network") ||
49
+ !hasString(v, "nodeUrl") ||
50
+ typeof v.chainId !== "number" ||
51
+ !hasString(v, "ledgerVersion") ||
52
+ !hasString(v, "timestamp") ||
53
+ !hasString(v, "epoch") ||
54
+ !hasString(v, "blockHeight") ||
55
+ !hasString(v, "createdAt")) {
56
+ throw new Error("Invalid ForkMetadata: missing or incorrectly typed fields");
57
+ }
58
+ return v;
59
+ }
60
+ export function assertAccountState(v) {
61
+ if (!isObject(v) ||
62
+ !hasString(v, "sequenceNumber") ||
63
+ !hasString(v, "authenticationKey")) {
64
+ throw new Error("Invalid AccountState: expected object with 'sequenceNumber' and 'authenticationKey' strings");
65
+ }
66
+ return v;
67
+ }
68
+ export function assertAccountStateRecord(v) {
69
+ if (!isObject(v)) {
70
+ throw new Error("Invalid account state record: expected object");
71
+ }
72
+ for (const [key, val] of Object.entries(v)) {
73
+ if (!isObject(val) ||
74
+ !hasString(val, "sequenceNumber") ||
75
+ !hasString(val, "authenticationKey")) {
76
+ throw new Error(`Invalid AccountState for key "${key}": missing 'sequenceNumber' or 'authenticationKey'`);
77
+ }
78
+ }
79
+ return v;
80
+ }
81
+ export function assertCoinStore(v) {
82
+ if (!isObject(v) ||
83
+ !isObject(v.coin) ||
84
+ !hasString(v.coin, "value")) {
85
+ throw new Error("Invalid CoinStore: expected object with 'coin.value' string");
86
+ }
87
+ return v;
88
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Account } from "@aptos-labs/ts-sdk";
2
2
  import type { MovehatRuntime } from "../types/runtime.js";
3
- import type { LocalNodeManager } from "../node/LocalNodeManager.js";
3
+ import type { NodeProvider } from "../node/NodeProvider.js";
4
4
  import type { ForkServer } from "../fork/server.js";
5
5
  import type { ForkManager } from "../fork/manager.js";
6
6
  import type { LocalTestOptions } from "../types/config.js";
@@ -50,12 +50,13 @@ export declare class Harness {
50
50
  */
51
51
  readonly accounts: Readonly<Record<string, Account>>;
52
52
  /** @internal */
53
- readonly localNode?: LocalNodeManager;
53
+ readonly localNode?: NodeProvider;
54
54
  /** @internal */
55
55
  readonly forkServer?: ForkServer;
56
56
  /** @internal */
57
57
  readonly forkManager?: ForkManager;
58
58
  private _poisoned;
59
+ private readonly ownsLocalNode;
59
60
  private constructor();
60
61
  /** True once `cleanup()` has been awaited at least once. */
61
62
  get poisoned(): boolean;
@@ -54,10 +54,12 @@ export class Harness {
54
54
  /** @internal */
55
55
  forkManager;
56
56
  _poisoned = false;
57
+ ownsLocalNode;
57
58
  constructor(init) {
58
59
  this.mode = init.mode;
59
60
  this.runtime = init.runtime;
60
61
  this.accounts = init.runtime.accountManager.getLabeledAccounts();
62
+ this.ownsLocalNode = init.ownsLocalNode ?? true;
61
63
  if (init.localNode)
62
64
  this.localNode = init.localNode;
63
65
  if (init.forkServer)
@@ -81,6 +83,7 @@ export class Harness {
81
83
  const init = {
82
84
  mode: "local",
83
85
  runtime: ctx.runtime,
86
+ ownsLocalNode: !options.localNode,
84
87
  };
85
88
  if (ctx.localNode)
86
89
  init.localNode = ctx.localNode;
@@ -156,7 +159,7 @@ export class Harness {
156
159
  if (this._poisoned)
157
160
  return;
158
161
  this._poisoned = true;
159
- if (this.localNode) {
162
+ if (this.localNode && this.ownsLocalNode) {
160
163
  await this.localNode.stop().catch(() => { });
161
164
  }
162
165
  if (this.forkServer) {
@@ -11,6 +11,7 @@ export { AccountManager } from "../core/AccountManager.js";
11
11
  export type { StoredAccount } from "../core/AccountManager.js";
12
12
  export { LocalNodeManager } from "../node/LocalNodeManager.js";
13
13
  export type { LocalNodeOptions, LocalNodeInfo } from "../node/LocalNodeManager.js";
14
+ export { MoveliteManager, findMoveliteBinary } from "../node/MoveliteManager.js";
14
15
  export { setupLocalTesting } from "./setupLocalTesting.js";
15
16
  export type { LocalTestingContext } from "./setupLocalTesting.js";
16
17
  export { setupTestFixture, setupMinimalFixture, } from "./testFixtures.js";
@@ -6,5 +6,6 @@ export { saveDeployment, loadDeployment, getAllDeployments, getDeployedAddress,
6
6
  export { snapshot, getForkInfo, viewForkResource, compareForkState, listSnapshots, } from "../fork/test.js";
7
7
  export { AccountManager } from "../core/AccountManager.js";
8
8
  export { LocalNodeManager } from "../node/LocalNodeManager.js";
9
+ export { MoveliteManager, findMoveliteBinary } from "../node/MoveliteManager.js";
9
10
  export { setupLocalTesting } from "./setupLocalTesting.js";
10
11
  export { setupTestFixture, setupMinimalFixture, } from "./testFixtures.js";
@@ -2,6 +2,7 @@ import { existsSync } from "fs";
2
2
  import { resolve } from "path";
3
3
  import { loadUserConfig } from "../core/config.js";
4
4
  import { runCli } from "../utils/runCli.js";
5
+ import { logger } from "../ui/index.js";
5
6
  /**
6
7
  * Run Move unit tests using Movement CLI
7
8
  * @param options Test options including filter, warnings, and skip behavior
@@ -12,8 +13,9 @@ export async function runMoveTests(options = {}) {
12
13
  const moveDir = resolve(process.cwd(), userConfig.moveDir || "./move");
13
14
  if (!existsSync(moveDir)) {
14
15
  if (options.skipIfMissing) {
15
- console.log("No Move directory found (./move not found)");
16
- console.log(" Skipping Move tests...\n");
16
+ logger.info("No Move directory found (./move not found)");
17
+ logger.plain(" Skipping Move tests...");
18
+ logger.newline();
17
19
  return;
18
20
  }
19
21
  else {
@@ -42,12 +44,13 @@ export async function runMoveTests(options = {}) {
42
44
  catch (error) {
43
45
  // Spawn-time failure (ENOENT, etc.). The original code logged a
44
46
  // Movement-CLI-install hint here; keep that.
45
- console.error(`Failed to run Move tests: ${error.message}`);
46
- console.error(" Make sure Movement CLI is installed");
47
+ logger.error(`Failed to run Move tests: ${error.message}`);
48
+ logger.error(" Make sure Movement CLI is installed");
47
49
  throw error;
48
50
  }
49
51
  if (result.exitCode === 0) {
50
- console.log("\n✓ Move tests passed");
52
+ logger.newline();
53
+ logger.success("Move tests passed");
51
54
  return;
52
55
  }
53
56
  throw new Error("Move tests failed");
@@ -1,7 +1,7 @@
1
1
  import type { MovehatRuntime } from "../types/runtime.js";
2
2
  import { ForkManager } from "../fork/manager.js";
3
3
  import { ForkServer } from "../fork/server.js";
4
- import { LocalNodeManager } from "../node/LocalNodeManager.js";
4
+ import type { NodeProvider } from "../node/NodeProvider.js";
5
5
  import type { LocalTestOptions } from "../types/config.js";
6
6
  /**
7
7
  * Context returned by {@link setupLocalTesting}.
@@ -17,7 +17,7 @@ import type { LocalTestOptions } from "../types/config.js";
17
17
  export interface LocalTestingContext {
18
18
  runtime: MovehatRuntime;
19
19
  /** @internal */
20
- localNode?: LocalNodeManager;
20
+ localNode?: NodeProvider;
21
21
  /** @internal */
22
22
  forkServer?: ForkServer;
23
23
  /** @internal */
@@ -4,6 +4,7 @@ import { initRuntime } from "../runtime.js";
4
4
  import { ForkManager } from "../fork/manager.js";
5
5
  import { ForkServer } from "../fork/server.js";
6
6
  import { LocalNodeManager } from "../node/LocalNodeManager.js";
7
+ import { MoveliteManager, findMoveliteBinary } from "../node/MoveliteManager.js";
7
8
  import { AccountManager } from "../core/AccountManager.js";
8
9
  import { logger } from "../ui/index.js";
9
10
  const BUILTIN_FORK_RPCS = {
@@ -64,11 +65,13 @@ export async function setupLocalTesting(options = {}) {
64
65
  logger.kv("Accounts", accountLabels.join(", "), 2);
65
66
  logger.newline();
66
67
  if (mode === 'local-node') {
67
- const { runtime, localNode } = await setupWithLocalNode(options, accountLabels, autoFund, defaultBalance);
68
+ const { runtime, localNode, ownsNode } = await setupWithLocalNode(options, accountLabels, autoFund, defaultBalance);
68
69
  return {
69
70
  runtime,
70
71
  localNode,
71
72
  teardown: async () => {
73
+ if (!ownsNode)
74
+ return;
72
75
  logger.newline();
73
76
  logger.step("Stopping local testing environment...");
74
77
  await localNode.stop();
@@ -97,21 +100,41 @@ export async function setupLocalTesting(options = {}) {
97
100
  * Setup using local Movement node (full blockchain)
98
101
  */
99
102
  async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalance) {
100
- const nodeTestDir = options.nodeTestDir || join(process.cwd(), ".movehat", "local-node");
101
- const nodeForceRestart = options.nodeForceRestart !== false;
102
- const nodeFaucetPort = options.nodeFaucetPort || 8081;
103
- const nodeApiPort = options.nodeApiPort || 8080;
104
- const nodeReadyPort = options.nodeReadyPort || 8070;
105
- const nodeSilent = options.nodeSilent ?? false;
106
- const localNode = new LocalNodeManager({
107
- testDir: nodeTestDir,
108
- forceRestart: nodeForceRestart,
109
- faucetPort: nodeFaucetPort,
110
- apiPort: nodeApiPort,
111
- readyPort: nodeReadyPort,
112
- silent: nodeSilent,
113
- });
114
- const nodeInfo = await localNode.start();
103
+ let localNode;
104
+ let ownsNode;
105
+ let nodeInfo;
106
+ if (options.localNode) {
107
+ localNode = options.localNode;
108
+ ownsNode = false;
109
+ if (!localNode.isRunning()) {
110
+ throw new Error("localNode was provided but isRunning() is false. " +
111
+ "Start the node before passing it to setupLocalTesting.");
112
+ }
113
+ nodeInfo = localNode.getNodeInfo();
114
+ }
115
+ else if (options.useMovelite !== false && findMoveliteBinary()) {
116
+ localNode = new MoveliteManager(findMoveliteBinary());
117
+ nodeInfo = await localNode.start();
118
+ ownsNode = true;
119
+ }
120
+ else {
121
+ const nodeTestDir = options.nodeTestDir || join(process.cwd(), ".movehat", "local-node");
122
+ const nodeForceRestart = options.nodeForceRestart !== false;
123
+ const nodeFaucetPort = options.nodeFaucetPort || 8081;
124
+ const nodeApiPort = options.nodeApiPort || 8080;
125
+ const nodeReadyPort = options.nodeReadyPort || 8070;
126
+ const nodeSilent = options.nodeSilent ?? false;
127
+ localNode = new LocalNodeManager({
128
+ testDir: nodeTestDir,
129
+ forceRestart: nodeForceRestart,
130
+ faucetPort: nodeFaucetPort,
131
+ apiPort: nodeApiPort,
132
+ readyPort: nodeReadyPort,
133
+ silent: nodeSilent,
134
+ });
135
+ ownsNode = true;
136
+ nodeInfo = await localNode.start();
137
+ }
115
138
  // Once the node is up, every later step (account creation, funding,
116
139
  // runtime init, autoDeploy) is fallible. If any of them throws we
117
140
  // must stop the node we just started — otherwise the child process
@@ -188,12 +211,12 @@ async function setupWithLocalNode(options, accountLabels, autoFund, defaultBalan
188
211
  logger.plain(` Accounts: ${Array.from(accountLabels).join(", ")}`);
189
212
  logger.plain(` Balance per account: ${defaultBalance / 100_000_000} MOVE`);
190
213
  logger.newline();
191
- return { runtime, localNode };
214
+ return { runtime, localNode, ownsNode };
192
215
  }
193
216
  catch (error) {
194
- // Best-effort cleanup. Swallow the stop() error so the original
195
- // setup failure surfaces unchanged.
196
- await localNode.stop().catch(() => { });
217
+ if (ownsNode) {
218
+ await localNode.stop().catch(() => { });
219
+ }
197
220
  throw error;
198
221
  }
199
222
  }
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import { isNewerVersion } from "./semver-utils.js";
5
5
  import { fetchLatestVersion } from "./npm-registry.js";
6
- import { box, colors, formatCommand } from "../ui/index.js";
6
+ import { box, colors, formatCommand, logger } from "../ui/index.js";
7
7
  const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
8
8
  const CACHE_DIR = join(homedir(), ".movehat");
9
9
  const CACHE_FILE = join(CACHE_DIR, "version-cache.json");
@@ -72,7 +72,9 @@ export function checkForUpdates(currentVersion, packageName) {
72
72
  const updateMessage = box(`${colors.brandBright('Update Available!')}\n\n` +
73
73
  `${currentVersion} ${colors.dim('→')} ${colors.success(cache.latestVersion)}\n\n` +
74
74
  `${formatCommand('movehat update')}`, { borderColor: 'warning', padding: 1 });
75
- console.error('\n' + updateMessage + '\n');
75
+ logger.newline();
76
+ logger.warning(updateMessage);
77
+ logger.newline();
76
78
  }
77
79
  // Update cache in background if needed (doesn't block)
78
80
  if (!cache || Date.now() - cache.lastChecked > CACHE_DURATION) {
@@ -0,0 +1,18 @@
1
+ import type { Account } from "@aptos-labs/ts-sdk";
2
+ import type { LocalNodeInfo } from "./LocalNodeManager.js";
3
+ export declare class MoveliteManager {
4
+ private process;
5
+ private port;
6
+ private killed;
7
+ private readonly binaryPath;
8
+ constructor(binaryPath: string, port?: number);
9
+ start(): Promise<LocalNodeInfo>;
10
+ private waitForReady;
11
+ fundAccount(address: string, amount: number): Promise<void>;
12
+ fundAccounts(accounts: Account[], balance: number): Promise<void>;
13
+ stop(): Promise<void>;
14
+ private isPortInUse;
15
+ isRunning(): boolean;
16
+ getNodeInfo(): LocalNodeInfo;
17
+ }
18
+ export declare function findMoveliteBinary(): string | null;
@@ -0,0 +1,152 @@
1
+ import { execSync, spawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { createRequire } from "module";
5
+ import { logger } from "../ui/index.js";
6
+ export class MoveliteManager {
7
+ process = null;
8
+ port;
9
+ killed = false;
10
+ binaryPath;
11
+ constructor(binaryPath, port = 8090) {
12
+ this.binaryPath = binaryPath;
13
+ this.port = port;
14
+ }
15
+ async start() {
16
+ const binary = this.binaryPath;
17
+ if (!binary) {
18
+ throw new Error("movelite binary not found");
19
+ }
20
+ if (await this.isPortInUse(this.port)) {
21
+ this.port = this.port + 1;
22
+ if (await this.isPortInUse(this.port)) {
23
+ throw new Error(`Ports ${this.port - 1} and ${this.port} are in use`);
24
+ }
25
+ }
26
+ logger.step("Starting movelite...");
27
+ this.process = spawn(binary, ["start", "--port", String(this.port), "--no-auth"], {
28
+ stdio: "pipe",
29
+ detached: false,
30
+ });
31
+ this.process.on("exit", () => {
32
+ this.process = null;
33
+ });
34
+ await this.waitForReady();
35
+ logger.success(`movelite ready on port ${this.port}`);
36
+ return this.getNodeInfo();
37
+ }
38
+ async waitForReady() {
39
+ const url = `http://127.0.0.1:${this.port}/v1`;
40
+ const timeout = 15_000;
41
+ const start = Date.now();
42
+ while (Date.now() - start < timeout) {
43
+ try {
44
+ const res = await fetch(url);
45
+ if (res.ok)
46
+ return;
47
+ }
48
+ catch {
49
+ // not ready yet
50
+ }
51
+ await new Promise((r) => setTimeout(r, 200));
52
+ }
53
+ throw new Error(`movelite did not become ready within ${timeout}ms`);
54
+ }
55
+ async fundAccount(address, amount) {
56
+ const res = await fetch(`http://127.0.0.1:${this.port}/mint?address=${address}&amount=${amount}`, { method: "POST" });
57
+ if (!res.ok) {
58
+ throw new Error(`Failed to fund account: ${res.status}`);
59
+ }
60
+ }
61
+ async fundAccounts(accounts, balance) {
62
+ for (const account of accounts) {
63
+ await this.fundAccount(account.accountAddress.toString(), balance);
64
+ }
65
+ }
66
+ async stop() {
67
+ if (!this.process || this.killed)
68
+ return;
69
+ this.killed = true;
70
+ this.process.kill("SIGTERM");
71
+ await new Promise((resolve) => {
72
+ const timer = setTimeout(() => {
73
+ if (this.process)
74
+ this.process.kill("SIGKILL");
75
+ resolve();
76
+ }, 5_000);
77
+ if (this.process) {
78
+ this.process.on("exit", () => {
79
+ clearTimeout(timer);
80
+ resolve();
81
+ });
82
+ }
83
+ else {
84
+ clearTimeout(timer);
85
+ resolve();
86
+ }
87
+ });
88
+ this.process = null;
89
+ }
90
+ async isPortInUse(port) {
91
+ try {
92
+ const res = await fetch(`http://127.0.0.1:${port}/v1`);
93
+ return res.ok;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ isRunning() {
100
+ return this.process !== null && !this.killed;
101
+ }
102
+ getNodeInfo() {
103
+ return {
104
+ rpcUrl: `http://127.0.0.1:${this.port}`,
105
+ faucetUrl: `http://127.0.0.1:${this.port}`,
106
+ readyUrl: `http://127.0.0.1:${this.port}/v1`,
107
+ testDir: "",
108
+ };
109
+ }
110
+ }
111
+ export function findMoveliteBinary() {
112
+ if (process.env.MOVELITE_PATH) {
113
+ return existsSync(process.env.MOVELITE_PATH)
114
+ ? process.env.MOVELITE_PATH
115
+ : null;
116
+ }
117
+ const platforms = {
118
+ "darwin-arm64": "movelite-darwin-arm64",
119
+ "darwin-x64": "movelite-darwin-x64",
120
+ "linux-x64": "movelite-linux-x64",
121
+ "linux-arm64": "movelite-linux-arm64",
122
+ };
123
+ const key = `${process.platform}-${process.arch}`;
124
+ const pkg = platforms[key];
125
+ if (pkg) {
126
+ try {
127
+ // Resolve through the `movelite` shim (movehat's direct dependency),
128
+ // then resolve the platform package from movelite's own context. The
129
+ // platform package is movelite's dependency, not movehat's, so a direct
130
+ // resolve from here fails under pnpm/yarn strict layouts.
131
+ const req = createRequire(import.meta.url);
132
+ const movelitePkg = req.resolve("movelite/package.json");
133
+ const moveliteReq = createRequire(movelitePkg);
134
+ const pkgPath = moveliteReq.resolve(`${pkg}/package.json`);
135
+ const binPath = join(pkgPath, "..", "bin", "movelite");
136
+ if (existsSync(binPath))
137
+ return binPath;
138
+ }
139
+ catch {
140
+ // package not installed
141
+ }
142
+ }
143
+ try {
144
+ const found = execSync("which movelite", { encoding: "utf-8" }).trim();
145
+ if (found && existsSync(found))
146
+ return found;
147
+ }
148
+ catch {
149
+ // not in PATH
150
+ }
151
+ return null;
152
+ }
@@ -0,0 +1,9 @@
1
+ import type { Account } from "@aptos-labs/ts-sdk";
2
+ import type { LocalNodeInfo } from "./LocalNodeManager.js";
3
+ export interface NodeProvider {
4
+ start(): Promise<LocalNodeInfo>;
5
+ stop(): Promise<void>;
6
+ isRunning(): boolean;
7
+ getNodeInfo(): LocalNodeInfo;
8
+ fundAccounts(accounts: Account[], balance: number): Promise<void>;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,6 +2,7 @@
2
2
  "node-option": ["import=tsx"],
3
3
  "extensions": ["ts"],
4
4
  "spec": ["tests/**/*.test.ts"],
5
+ "require": ["tests/setup.ts"],
5
6
  "timeout": 30000,
6
7
  "color": true,
7
8
  "reporter": "spec"
@@ -2,6 +2,7 @@
2
2
  import { describe, it, before, after } from "mocha";
3
3
  import { expect } from "chai";
4
4
  import { Harness } from "movehat";
5
+ import { getSharedNode } from "./setup.js";
5
6
 
6
7
  describe("Counter Contract", () => {
7
8
  let harness;
@@ -9,16 +10,12 @@ describe("Counter Contract", () => {
9
10
  let deployer, alice, bob;
10
11
 
11
12
  before(async function () {
12
- this.timeout(60000); // Allow time for local node startup + deployment
13
-
14
- // Hardhat-style Harness the primary public API.
15
- // createLocal spins up a Movement local node, funds the labeled
16
- // accounts from the local faucet, and (via autoDeploy) builds +
17
- // publishes the named modules so they're ready to use.
18
- //
19
- // The setupTestFixture helper still works if you prefer the older
20
- // pattern; see `import { setupTestFixture } from "movehat/helpers"`.
13
+ this.timeout(60000);
14
+
15
+ // Reuses the shared Movement node started by root hooks (tests/setup.ts).
16
+ // Each spec still gets its own accounts + deployments for isolation.
21
17
  harness = await Harness.createLocal({
18
+ localNode: getSharedNode(),
22
19
  accountLabels: ["deployer", "alice", "bob"],
23
20
  autoDeploy: ["counter"],
24
21
  });
@@ -0,0 +1,34 @@
1
+ // @ts-nocheck - This is a template file, dependencies are installed in user projects
2
+ import { LocalNodeManager } from "movehat/helpers";
3
+
4
+ let sharedNode;
5
+
6
+ /**
7
+ * Returns the shared local Movement node started by root hooks.
8
+ * Pass the result as `{ localNode: getSharedNode() }` to
9
+ * Harness.createLocal() or setupTestFixture().
10
+ */
11
+ export function getSharedNode() {
12
+ if (!sharedNode || !sharedNode.isRunning()) {
13
+ throw new Error(
14
+ "Shared node not available. " +
15
+ 'Ensure tests/setup.ts is listed in .mocharc.json under "require".'
16
+ );
17
+ }
18
+ return sharedNode;
19
+ }
20
+
21
+ export const mochaHooks = {
22
+ async beforeAll() {
23
+ this.timeout(60000);
24
+ sharedNode = new LocalNodeManager({ forceRestart: true });
25
+ await sharedNode.start();
26
+ },
27
+
28
+ async afterAll() {
29
+ if (sharedNode) {
30
+ await sharedNode.stop();
31
+ sharedNode = undefined;
32
+ }
33
+ },
34
+ };
@@ -41,6 +41,8 @@ export type LocalTestingMode = 'local-node' | 'fork';
41
41
  */
42
42
  export interface LocalTestOptions {
43
43
  mode?: LocalTestingMode;
44
+ localNode?: import("../node/LocalNodeManager.js").LocalNodeManager;
45
+ useMovelite?: boolean;
44
46
  nodeTestDir?: string;
45
47
  nodeForceRestart?: boolean;
46
48
  nodeFaucetPort?: number;
@@ -40,3 +40,27 @@ export interface AccountResource {
40
40
  */
41
41
  data: unknown;
42
42
  }
43
+ export interface CoinStore {
44
+ coin: {
45
+ value: string;
46
+ };
47
+ deposit_events: {
48
+ counter: string;
49
+ guid: {
50
+ id: {
51
+ addr: string;
52
+ creation_num: string;
53
+ };
54
+ };
55
+ };
56
+ withdraw_events: {
57
+ counter: string;
58
+ guid: {
59
+ id: {
60
+ addr: string;
61
+ creation_num: string;
62
+ };
63
+ };
64
+ };
65
+ frozen: boolean;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movehat",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "Hardhat-like development framework for Movement L1 smart contracts",
6
6
  "bin": {
@@ -70,6 +70,9 @@
70
70
  "prompts": "^2.4.2",
71
71
  "tsx": "^4.7.0"
72
72
  },
73
+ "optionalDependencies": {
74
+ "movelite": "^0.1.0"
75
+ },
73
76
  "devDependencies": {
74
77
  "@types/js-yaml": "^4.0.9",
75
78
  "@types/node": "^24.10.1",