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.
- package/dist/commands/run.d.ts +17 -0
- package/dist/commands/run.js +46 -32
- package/dist/core/config.js +109 -27
- package/dist/utils/parseCliOutput.d.ts +6 -3
- package/dist/utils/parseCliOutput.js +10 -5
- package/package.json +1 -1
package/dist/commands/run.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/run.js
CHANGED
|
@@ -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 -
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:");
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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 (
|
|
159
|
-
//
|
|
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
|
-
*
|
|
10
|
-
* `txn hash: 0x…`, `hash: 0x…`)
|
|
11
|
-
*
|
|
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
|
-
*
|
|
10
|
-
* `txn hash: 0x…`, `hash: 0x…`)
|
|
11
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
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
|
}
|