movehat 0.2.5 → 0.2.6

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.
@@ -1,4 +1,21 @@
1
1
  import type { RunResult } from "../utils/childProcessAdapter.js";
2
+ /**
3
+ * Resolve the tsx CLI entrypoint (`<tsx-pkg>/dist/cli.mjs`) used by
4
+ * `movehat run` to execute user scripts. Prefers movehat's bundled tsx
5
+ * by default; falls back to the cwd's tsx only if bundled is missing
6
+ * (defensive — should not happen for normal installs). Set
7
+ * `MOVEHAT_TSX_FROM_CWD=1` or pass `preferCwd: true` to flip the
8
+ * order — power-users who pinned a different tsx in their project.
9
+ *
10
+ * Security: the default order closes #52 (untrusted cwd could ship a
11
+ * malicious `node_modules/tsx/dist/cli.mjs`).
12
+ *
13
+ * Exported so the resolution logic can be unit-tested without
14
+ * spawning a real process.
15
+ */
16
+ export declare function resolveTsxCliPath(opts?: {
17
+ preferCwd?: boolean;
18
+ }): string | null;
2
19
  /**
3
20
  * Apply the exit policy for a child whose output was inherited by the
4
21
  * parent. When the child dies via signal, re-raise it on the parent so
@@ -4,6 +4,45 @@ import { fileURLToPath } from "url";
4
4
  import { createRequire } from "module";
5
5
  import { runCli } from "../utils/runCli.js";
6
6
  import { logger } from "../ui/index.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const requireFromHere = createRequire(import.meta.url);
9
+ /**
10
+ * Resolve the tsx CLI entrypoint (`<tsx-pkg>/dist/cli.mjs`) used by
11
+ * `movehat run` to execute user scripts. Prefers movehat's bundled tsx
12
+ * by default; falls back to the cwd's tsx only if bundled is missing
13
+ * (defensive — should not happen for normal installs). Set
14
+ * `MOVEHAT_TSX_FROM_CWD=1` or pass `preferCwd: true` to flip the
15
+ * order — power-users who pinned a different tsx in their project.
16
+ *
17
+ * Security: the default order closes #52 (untrusted cwd could ship a
18
+ * malicious `node_modules/tsx/dist/cli.mjs`).
19
+ *
20
+ * Exported so the resolution logic can be unit-tested without
21
+ * spawning a real process.
22
+ */
23
+ export function resolveTsxCliPath(opts) {
24
+ const preferCwd = opts?.preferCwd ?? process.env.MOVEHAT_TSX_FROM_CWD === "1";
25
+ const bundledRoot = __dirname;
26
+ const cwdRoot = process.cwd();
27
+ const lookupOrder = preferCwd
28
+ ? [cwdRoot, bundledRoot]
29
+ : [bundledRoot, cwdRoot];
30
+ for (const root of lookupOrder) {
31
+ try {
32
+ const tsxEntry = requireFromHere.resolve("tsx", { paths: [root] });
33
+ // require.resolve("tsx") returns .../tsx/dist/loader.mjs; walk up
34
+ // to the package root and into dist/cli.mjs.
35
+ const packageRoot = dirname(dirname(tsxEntry));
36
+ const cliPath = join(packageRoot, "dist", "cli.mjs");
37
+ if (existsSync(cliPath))
38
+ return cliPath;
39
+ }
40
+ catch {
41
+ // Lookup failed at this root; try the next one.
42
+ }
43
+ }
44
+ return null;
45
+ }
7
46
  /**
8
47
  * Apply the exit policy for a child whose output was inherited by the
9
48
  * parent. When the child dies via signal, re-raise it on the parent so
@@ -50,39 +89,14 @@ export default async function runCommand(scriptPath) {
50
89
  logger.plain(` Network: ${network}`);
51
90
  }
52
91
  logger.newline();
53
- // Find tsx binary - try multiple locations for compatibility
54
- // Uses require.resolve for cross-platform compatibility (works on Windows, macOS, Linux)
55
- const __filename = fileURLToPath(import.meta.url);
56
- const __dirname = dirname(__filename);
57
- // Create require function for ESM (needed to use require.resolve in ESM modules)
58
- const require = createRequire(import.meta.url);
59
- let tsxPath;
60
- try {
61
- // Try to resolve tsx package from user's project first
62
- const tsxPackagePath = require.resolve("tsx", { paths: [process.cwd()] });
63
- // require.resolve("tsx") returns .../tsx/dist/loader.mjs
64
- // We need to go up to the tsx package root, then into dist/cli.mjs
65
- const tsxPackageRoot = dirname(dirname(tsxPackagePath));
66
- tsxPath = join(tsxPackageRoot, "dist", "cli.mjs");
67
- // Verify the file exists
68
- if (!existsSync(tsxPath)) {
69
- throw new Error("cli.mjs not found");
70
- }
71
- }
72
- catch {
73
- try {
74
- // Fallback to movehat's own tsx
75
- const tsxPackagePath = require.resolve("tsx", { paths: [__dirname] });
76
- const tsxPackageRoot = dirname(dirname(tsxPackagePath));
77
- tsxPath = join(tsxPackageRoot, "dist", "cli.mjs");
78
- if (!existsSync(tsxPath)) {
79
- throw new Error("cli.mjs not found");
80
- }
81
- }
82
- catch {
83
- tsxPath = "";
84
- }
92
+ // Find tsx binary — bundled-first per #52 (supply-chain hardening).
93
+ // Cwd resolution stays available behind an opt-in env var for users
94
+ // who pinned a different tsx in their project.
95
+ if (process.env.MOVEHAT_TSX_FROM_CWD === "1") {
96
+ logger.warning("MOVEHAT_TSX_FROM_CWD=1: resolving tsx from cwd first. " +
97
+ "Only use this in trusted project directories.");
85
98
  }
99
+ const tsxPath = resolveTsxCliPath();
86
100
  if (!tsxPath) {
87
101
  logger.error("tsx binary not found");
88
102
  logger.plain(" Make sure 'tsx' is installed in your project:");
@@ -13,6 +13,45 @@ import { logger } from "../ui/index.js";
13
13
  // same value here. No corruption, no in-flight-promise memoization
14
14
  // needed.
15
15
  const configCache = new Map();
16
+ // In-flight load deduplication (#47). When two callers hit a cold cache
17
+ // for the same config file concurrently, both would invoke
18
+ // `register()` from `tsx/esm/api` and race on the unregister cleanup.
19
+ // The dedup map ensures only ONE load runs per (path, mtime) burst —
20
+ // concurrent callers await the same Promise. Cleared after the load
21
+ // settles so a later edit (new mtime) can re-trigger a cold load.
22
+ const inFlightLoads = new Map();
23
+ // Hostnames recognized as safe targets for the deterministic test key
24
+ // auto-injection. Any URL whose hostname is not in this set causes the
25
+ // test-key path to be skipped even if the network NAME is 'testnet' or
26
+ // 'local' (#40 — name-only gating was spoofable when users named a
27
+ // production-pointing network 'testnet').
28
+ const TEST_ENDPOINT_HOSTS = new Set([
29
+ "testnet.movementnetwork.xyz",
30
+ "localhost",
31
+ "127.0.0.1",
32
+ "::1",
33
+ ]);
34
+ function isKnownTestEndpoint(url) {
35
+ try {
36
+ return TEST_ENDPOINT_HOSTS.has(new URL(url).hostname.toLowerCase());
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ // Render a URL for log output without exposing userinfo (`user:pass@`)
43
+ // or query strings (`?apiKey=…`). Returns the protocol + host + pathname
44
+ // only, which is enough for the operator to identify the endpoint
45
+ // without leaking embedded credentials to CI logs.
46
+ function sanitizeUrlForLog(url) {
47
+ try {
48
+ const parsed = new URL(url);
49
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
50
+ }
51
+ catch {
52
+ return "<invalid-url>";
53
+ }
54
+ }
16
55
  /**
17
56
  * Loads the user's movehat.config.{ts,js} from the current working directory.
18
57
  *
@@ -47,32 +86,58 @@ export async function loadUserConfig() {
47
86
  if (cached && cached.mtimeMs === mtimeMs) {
48
87
  return cached.config;
49
88
  }
50
- let configModule;
51
- if (configPath.endsWith('.ts')) {
52
- const { register } = await import('tsx/esm/api');
53
- const unregister = register();
54
- try {
55
- const configUrl = pathToFileURL(configPath).href;
56
- configModule = await import(configUrl + '?mtime=' + mtimeMs);
57
- }
58
- finally {
59
- unregister();
60
- }
89
+ // Dedup concurrent cold-cache loads of the same file (#47).
90
+ // Without this, two callers race on tsx's register/unregister and
91
+ // the second may run its `await import()` after the first calls
92
+ // unregister(), removing the loader mid-flight. All callers go
93
+ // through the same `return await loadPromise` below so the outer
94
+ // try/catch wraps a consistent error regardless of who started the
95
+ // load.
96
+ let loadPromise = inFlightLoads.get(configPath);
97
+ if (!loadPromise) {
98
+ loadPromise = doLoadConfig(configPath, mtimeMs);
99
+ inFlightLoads.set(configPath, loadPromise);
100
+ // Clean up after settles. `.catch(() => undefined)` after the
101
+ // finally chain swallows the cleanup-promise rejection so it
102
+ // does not become an unhandled rejection — the actual awaiters
103
+ // still see the original rejection via `return await loadPromise`.
104
+ void loadPromise
105
+ .finally(() => {
106
+ if (inFlightLoads.get(configPath) === loadPromise) {
107
+ inFlightLoads.delete(configPath);
108
+ }
109
+ })
110
+ .catch(() => undefined);
61
111
  }
62
- else {
112
+ return await loadPromise;
113
+ }
114
+ catch (error) {
115
+ throw new Error(`Failed to load configuration file '${configPath}': ${error}`);
116
+ }
117
+ }
118
+ async function doLoadConfig(configPath, mtimeMs) {
119
+ let configModule;
120
+ if (configPath.endsWith('.ts')) {
121
+ const { register } = await import('tsx/esm/api');
122
+ const unregister = register();
123
+ try {
63
124
  const configUrl = pathToFileURL(configPath).href;
64
125
  configModule = await import(configUrl + '?mtime=' + mtimeMs);
65
126
  }
66
- const userConfig = configModule.default;
67
- if (!userConfig.networks || Object.keys(userConfig.networks).length === 0) {
68
- throw new Error("No networks defined in configuration. Add at least one network in the 'networks' field.");
127
+ finally {
128
+ unregister();
69
129
  }
70
- configCache.set(configPath, { mtimeMs, config: userConfig });
71
- return userConfig;
72
130
  }
73
- catch (error) {
74
- throw new Error(`Failed to load configuration file '${configPath}': ${error}`);
131
+ else {
132
+ const configUrl = pathToFileURL(configPath).href;
133
+ configModule = await import(configUrl + '?mtime=' + mtimeMs);
75
134
  }
135
+ const userConfig = configModule.default;
136
+ if (!userConfig.networks || Object.keys(userConfig.networks).length === 0) {
137
+ throw new Error("No networks defined in configuration. Add at least one network in the 'networks' field.");
138
+ }
139
+ configCache.set(configPath, { mtimeMs, config: userConfig });
140
+ return userConfig;
76
141
  }
77
142
  /**
78
143
  * Clear the in-memory config cache. Test-only escape hatch.
@@ -82,6 +147,7 @@ export async function loadUserConfig() {
82
147
  */
83
148
  export function _resetConfigCache() {
84
149
  configCache.clear();
150
+ inFlightLoads.clear();
85
151
  }
86
152
  /**
87
153
  * Resolve configuration for a specific network
@@ -136,15 +202,30 @@ export async function resolveNetworkConfig(userConfig, networkName) {
136
202
  if (accounts.length === 0 && process.env.PRIVATE_KEY) {
137
203
  accounts = [process.env.PRIVATE_KEY];
138
204
  }
139
- // 4. Validate we have at least one account (unless using testnet/local)
205
+ // 4. Validate we have at least one account (unless using testnet/local
206
+ // AND the URL is a recognized test endpoint — name alone is not enough
207
+ // per #40, since a user can name a network 'testnet' but point it at
208
+ // a production RPC and would otherwise inherit the deterministic test
209
+ // key with a production endpoint).
140
210
  if (accounts.length === 0 || !accounts[0]) {
141
- // Special case: Auto-generate test accounts for testing networks
142
- // testnet = public Movement test network (recommended)
143
- // local = local fork server
144
- if (selectedNetwork === "testnet" || selectedNetwork === "local") {
211
+ const isTestNetworkName = selectedNetwork === "testnet" || selectedNetwork === "local";
212
+ const isTestEndpoint = isTestNetworkName && isKnownTestEndpoint(networkConfig.url);
213
+ if (isTestNetworkName && !isTestEndpoint) {
214
+ // Name matches the reserved convention but URL is not a recognized
215
+ // test endpoint — block the deterministic test-key injection. Falls
216
+ // through to the standard "no accounts" throw below so the user gets
217
+ // actionable guidance.
218
+ logger.warning(`Network '${selectedNetwork}' uses a name reserved for testnet/local ` +
219
+ `but '${sanitizeUrlForLog(networkConfig.url)}' is not a recognized test endpoint. ` +
220
+ `Skipping auto-injection of the deterministic test key to protect ` +
221
+ `against accidental production use. Set PRIVATE_KEY explicitly, ` +
222
+ `configure 'accounts' in movehat.config.ts, or rename this network.`);
223
+ }
224
+ if (isTestEndpoint) {
145
225
  // Security: Using a deterministic test account (like Hardhat's default accounts)
146
226
  // This is SAFE because:
147
- // 1. Only used for testnet/local (never mainnet - that throws error below)
227
+ // 1. Only used for testnet/local against a known test endpoint
228
+ // (network NAME + URL allowlist enforced above; bypassed otherwise)
148
229
  // 2. Perfect for transaction simulation (no real funds)
149
230
  // 3. Deterministic = consistent test results
150
231
  const testPrivateKey = "0x0000000000000000000000000000000000000000000000000000000000000001";
@@ -155,8 +236,9 @@ export async function resolveNetworkConfig(userConfig, networkName) {
155
236
  logger.newline();
156
237
  }
157
238
  else {
158
- // For any other network (especially mainnet), REQUIRE explicit configuration
159
- // This prevents accidentally using the test key on production networks
239
+ // For any other network (mainnet, or testnet/local with a non-test
240
+ // URL), REQUIRE explicit configuration. This prevents accidentally
241
+ // using the test key on production networks.
160
242
  throw new Error(`Network '${selectedNetwork}' has no accounts configured.\n` +
161
243
  `\n` +
162
244
  `SECURITY: This network requires explicit account configuration.\n` +
@@ -6,9 +6,12 @@
6
6
  /**
7
7
  * Extract the transaction hash from a `movement` CLI subcommand's stdout.
8
8
  *
9
- * Tries the context-bearing pattern first (`transaction hash: 0x…`,
10
- * `txn hash: 0x…`, `hash: 0x…`) and falls back to any 64-char hex
11
- * literal in the buffer. Returns `undefined` if no candidate matches.
9
+ * Only the context-bearing pattern is accepted (`transaction hash: 0x…`,
10
+ * `txn hash: 0x…`, `hash: 0x…`). When the context is absent we log a
11
+ * warning and return `undefined` so callers decide whether to throw —
12
+ * the previous behavior of falling back to "any 64-hex literal" was
13
+ * fragile: a padded module address or state root printed before the
14
+ * actual txhash would silently corrupt the cached deployment record.
12
15
  *
13
16
  * Shared by `core/Publisher.ts` (publish), `harness/codeObject.ts`
14
17
  * (deploy-object / upgrade-object), and `harness/script.ts`
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * @internal — not exported from `src/index.ts`.
5
5
  */
6
+ import { logger } from '../ui/index.js';
6
7
  /**
7
8
  * Extract the transaction hash from a `movement` CLI subcommand's stdout.
8
9
  *
9
- * Tries the context-bearing pattern first (`transaction hash: 0x…`,
10
- * `txn hash: 0x…`, `hash: 0x…`) and falls back to any 64-char hex
11
- * literal in the buffer. Returns `undefined` if no candidate matches.
10
+ * Only the context-bearing pattern is accepted (`transaction hash: 0x…`,
11
+ * `txn hash: 0x…`, `hash: 0x…`). When the context is absent we log a
12
+ * warning and return `undefined` so callers decide whether to throw —
13
+ * the previous behavior of falling back to "any 64-hex literal" was
14
+ * fragile: a padded module address or state root printed before the
15
+ * actual txhash would silently corrupt the cached deployment record.
12
16
  *
13
17
  * Shared by `core/Publisher.ts` (publish), `harness/codeObject.ts`
14
18
  * (deploy-object / upgrade-object), and `harness/script.ts`
@@ -20,6 +24,7 @@ export function parseTxHash(stdout) {
20
24
  const withContext = stdout.match(/(?:transaction\s*(?:hash)?|txn\s*(?:hash)?|hash):\s*(0x[a-fA-F0-9]{64})\b/i);
21
25
  if (withContext?.[1])
22
26
  return withContext[1];
23
- const fallback = stdout.match(/\b(0x[a-fA-F0-9]{64})\b/);
24
- return fallback?.[1];
27
+ logger.warning(`parseTxHash: no contextual 'transaction|txn|hash: 0x…' match in ${stdout.length}-byte CLI output. ` +
28
+ `Returning undefined; the caller decides whether to error.`);
29
+ return undefined;
25
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movehat",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "description": "Hardhat-like development framework for Movement L1 smart contracts",
6
6
  "bin": {