movehat 0.2.2 → 0.2.4

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 (74) hide show
  1. package/dist/cli.js +4 -0
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/compile.d.ts.map +1 -1
  4. package/dist/commands/compile.js +19 -10
  5. package/dist/commands/compile.js.map +1 -1
  6. package/dist/commands/test.js +12 -19
  7. package/dist/commands/test.js.map +1 -1
  8. package/dist/core/Publisher.d.ts.map +1 -1
  9. package/dist/core/Publisher.js +20 -14
  10. package/dist/core/Publisher.js.map +1 -1
  11. package/dist/core/config.d.ts.map +1 -1
  12. package/dist/core/config.js +8 -5
  13. package/dist/core/config.js.map +1 -1
  14. package/dist/core/deployments.d.ts.map +1 -1
  15. package/dist/core/deployments.js +4 -2
  16. package/dist/core/deployments.js.map +1 -1
  17. package/dist/fork/manager.d.ts +1 -1
  18. package/dist/fork/manager.js +11 -11
  19. package/dist/fork/manager.js.map +1 -1
  20. package/dist/fork/server.d.ts.map +1 -1
  21. package/dist/fork/server.js +21 -15
  22. package/dist/fork/server.js.map +1 -1
  23. package/dist/fork/test.d.ts.map +1 -1
  24. package/dist/fork/test.js +3 -2
  25. package/dist/fork/test.js.map +1 -1
  26. package/dist/harness/codeObject.js +11 -8
  27. package/dist/harness/codeObject.js.map +1 -1
  28. package/dist/harness/script.d.ts.map +1 -1
  29. package/dist/harness/script.js +9 -6
  30. package/dist/harness/script.js.map +1 -1
  31. package/dist/helpers/setupLocalTesting.js +5 -5
  32. package/dist/helpers/setupLocalTesting.js.map +1 -1
  33. package/dist/node/LocalNodeManager.d.ts +1 -1
  34. package/dist/node/LocalNodeManager.d.ts.map +1 -1
  35. package/dist/node/LocalNodeManager.js +61 -23
  36. package/dist/node/LocalNodeManager.js.map +1 -1
  37. package/dist/node/__tests__/LocalNodeManager.test.js +110 -11
  38. package/dist/node/__tests__/LocalNodeManager.test.js.map +1 -1
  39. package/dist/ui/__tests__/logger.test.d.ts +2 -0
  40. package/dist/ui/__tests__/logger.test.d.ts.map +1 -0
  41. package/dist/ui/__tests__/logger.test.js +75 -0
  42. package/dist/ui/__tests__/logger.test.js.map +1 -0
  43. package/dist/ui/formatters.d.ts +0 -16
  44. package/dist/ui/formatters.d.ts.map +1 -1
  45. package/dist/ui/formatters.js +1 -1
  46. package/dist/ui/formatters.js.map +1 -1
  47. package/dist/ui/logger.d.ts +41 -0
  48. package/dist/ui/logger.d.ts.map +1 -1
  49. package/dist/ui/logger.js +49 -0
  50. package/dist/ui/logger.js.map +1 -1
  51. package/dist/ui/spinner.d.ts +25 -0
  52. package/dist/ui/spinner.d.ts.map +1 -1
  53. package/dist/ui/spinner.js +44 -0
  54. package/dist/ui/spinner.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli.ts +4 -0
  57. package/src/commands/compile.ts +24 -15
  58. package/src/commands/test.ts +12 -19
  59. package/src/core/Publisher.ts +49 -34
  60. package/src/core/config.ts +9 -6
  61. package/src/core/deployments.ts +5 -4
  62. package/src/fork/manager.ts +11 -11
  63. package/src/fork/server.ts +21 -15
  64. package/src/fork/test.ts +3 -2
  65. package/src/harness/codeObject.ts +8 -5
  66. package/src/harness/script.ts +7 -4
  67. package/src/helpers/setupLocalTesting.ts +5 -5
  68. package/src/node/LocalNodeManager.ts +64 -25
  69. package/src/node/__tests__/LocalNodeManager.test.ts +140 -14
  70. package/src/types/config.ts +1 -1
  71. package/src/ui/__tests__/logger.test.ts +89 -0
  72. package/src/ui/formatters.ts +1 -1
  73. package/src/ui/logger.ts +62 -0
  74. package/src/ui/spinner.ts +47 -0
@@ -86,9 +86,9 @@ export function saveDeployment(deployment: DeploymentInfo): void {
86
86
  `Deployment saved: deployments/${deployment.network}/${deployment.moduleName}.json`
87
87
  );
88
88
  } catch (error) {
89
- console.error(
90
- `Failed to save deployment for ${deployment.moduleName} on ${deployment.network} at ${filePath}:`,
91
- error
89
+ const msg = error instanceof Error ? error.message : String(error);
90
+ logger.error(
91
+ `Failed to save deployment for ${deployment.moduleName} on ${deployment.network} at ${filePath}: ${msg}`
92
92
  );
93
93
  throw error;
94
94
  }
@@ -110,7 +110,8 @@ export function loadDeployment(network: string, moduleName: string): DeploymentI
110
110
  const content = readFileSync(filePath, "utf-8");
111
111
  return JSON.parse(content) as DeploymentInfo;
112
112
  } catch (error) {
113
- console.error(`Failed to load deployment for ${moduleName} on ${network}:`, error);
113
+ const msg = error instanceof Error ? error.message : String(error);
114
+ logger.error(`Failed to load deployment for ${moduleName} on ${network}: ${msg}`);
114
115
  return null;
115
116
  }
116
117
  }
@@ -89,7 +89,7 @@ export class ForkManager {
89
89
 
90
90
  this.storage.saveMetadata(this.metadata);
91
91
 
92
- console.log(`✓ Fork initialized at ledger version ${ledgerInfo.ledger_version}`);
92
+ logger.success(`Fork initialized at ledger version ${ledgerInfo.ledger_version}`);
93
93
  }
94
94
 
95
95
  /**
@@ -123,7 +123,7 @@ export class ForkManager {
123
123
  throw new Error('Fork not initialized. Call initialize() or load() first.');
124
124
  }
125
125
 
126
- console.log(` Fetching account ${normalizedAddress} from network...`);
126
+ logger.info(`Fetching account ${normalizedAddress} from network...`, 2);
127
127
  const accountData = await this.apiClient.getAccount(normalizedAddress);
128
128
 
129
129
  accountState = {
@@ -132,7 +132,7 @@ export class ForkManager {
132
132
  };
133
133
 
134
134
  this.storage.saveAccount(normalizedAddress, accountState);
135
- console.log(`Cached account ${normalizedAddress}`);
135
+ logger.success(`Cached account ${normalizedAddress}`, 2);
136
136
  }
137
137
 
138
138
  return accountState;
@@ -148,14 +148,14 @@ export class ForkManager {
148
148
  throw new Error('Fork not initialized. Call initialize() or load() first.');
149
149
  }
150
150
 
151
- console.log(` Fetching resource ${resourceType} for ${normalizedAddress}...`);
151
+ logger.info(`Fetching resource ${resourceType} for ${normalizedAddress}...`, 2);
152
152
 
153
153
  try {
154
154
  const resourceData = await this.apiClient.getAccountResource(normalizedAddress, resourceType);
155
155
  resource = resourceData.data;
156
156
 
157
157
  this.storage.saveResource(normalizedAddress, resourceType, resource);
158
- console.log(`Cached resource ${resourceType}`);
158
+ logger.success(`Cached resource ${resourceType}`, 2);
159
159
  } catch (error) {
160
160
  const msg = error instanceof Error ? error.message : String(error);
161
161
  if (msg.includes('404')) {
@@ -178,7 +178,7 @@ export class ForkManager {
178
178
  throw new Error('Fork not initialized. Call initialize() or load() first.');
179
179
  }
180
180
 
181
- console.log(` Fetching all resources for ${normalizedAddress}...`);
181
+ logger.info(`Fetching all resources for ${normalizedAddress}...`, 2);
182
182
  const resourcesList = await this.apiClient.getAccountResources(normalizedAddress);
183
183
 
184
184
  resources = {};
@@ -187,7 +187,7 @@ export class ForkManager {
187
187
  }
188
188
 
189
189
  this.storage.saveAllResources(normalizedAddress, resources);
190
- console.log(`Cached ${Object.keys(resources).length} resources`);
190
+ logger.success(`Cached ${Object.keys(resources).length} resources`, 2);
191
191
  }
192
192
 
193
193
  return resources;
@@ -196,7 +196,7 @@ export class ForkManager {
196
196
  async setResource(address: string, resourceType: string, data: unknown): Promise<void> {
197
197
  const normalizedAddress = normalizeAddress(address);
198
198
  this.storage.saveResource(normalizedAddress, resourceType, data);
199
- console.log(`Updated resource ${resourceType} for ${normalizedAddress}`);
199
+ logger.success(`Updated resource ${resourceType} for ${normalizedAddress}`, 2);
200
200
  }
201
201
 
202
202
  /** Adds to the existing balance rather than replacing it. */
@@ -258,7 +258,7 @@ export class ForkManager {
258
258
  this.storage.saveAccount(normalizedAddress, account);
259
259
  }
260
260
 
261
- console.log(`Funded ${normalizedAddress} with ${amount} coins`);
261
+ logger.success(`Funded ${normalizedAddress} with ${amount} coins`, 2);
262
262
  }
263
263
 
264
264
  listAccounts(): string[] {
@@ -275,7 +275,7 @@ export class ForkManager {
275
275
  * @example
276
276
  * await forkManager.fundMultipleAccounts(
277
277
  * ["0x123...", "0x456..."],
278
- * 100_000_000 // 100 APT
278
+ * 100_000_000 // 1 MOVE
279
279
  * );
280
280
  */
281
281
  async fundMultipleAccounts(
@@ -335,7 +335,7 @@ export class ForkManager {
335
335
  };
336
336
 
337
337
  this.storage.saveAccount(normalizedAddress, newAccount);
338
- console.log(`Created new account ${normalizedAddress}`);
338
+ logger.success(`Created new account ${normalizedAddress}`, 2);
339
339
 
340
340
  return newAccount;
341
341
  }
@@ -1,6 +1,7 @@
1
1
  import http from 'http';
2
2
  import { URL } from 'url';
3
3
  import { ForkManager } from './manager.js';
4
+ import { logger } from '../ui/index.js';
4
5
 
5
6
  export interface ForkServerOptions {
6
7
  /**
@@ -66,16 +67,17 @@ export class ForkServer {
66
67
  this.forkManager.load();
67
68
  const metadata = this.forkManager.getMetadata();
68
69
 
69
- console.log(`\nStarting Fork Server...`);
70
- console.log(` Network: ${metadata.network}`);
71
- console.log(` Chain ID: ${metadata.chainId}`);
72
- console.log(` Ledger Version: ${metadata.ledgerVersion}`);
73
- console.log(` Forked at: ${metadata.createdAt}`);
70
+ logger.newline();
71
+ logger.phase("Fork Server");
72
+ logger.kv("Network", metadata.network, 2);
73
+ logger.kv("Chain ID", String(metadata.chainId), 2);
74
+ logger.kv("Ledger Version", String(metadata.ledgerVersion), 2);
75
+ logger.kv("Forked at", metadata.createdAt, 2);
74
76
 
75
77
  this.server = http.createServer((req, res) => {
76
78
  this.handleRequest(req, res).catch((error) => {
77
79
  // Log full error server-side for diagnostics
78
- console.error(`Error handling request:`, error);
80
+ logger.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`);
79
81
 
80
82
  // Only send response if headers haven't been sent yet
81
83
  if (!res.headersSent) {
@@ -124,13 +126,15 @@ export class ForkServer {
124
126
  : isIpv6
125
127
  ? `[${this.host}]`
126
128
  : this.host;
127
- console.log(`\nFork Server listening on http://${displayHost}:${this.port}`);
128
- console.log(` Bound interface: ${this.host}`);
129
- console.log(` Ledger Info: http://${displayHost}:${this.port}/v1/`);
129
+ logger.newline();
130
+ logger.success(`Fork Server listening on http://${displayHost}:${this.port}`);
131
+ logger.kv("Bound interface", this.host, 2);
132
+ logger.kv("Ledger Info", `http://${displayHost}:${this.port}/v1/`, 2);
130
133
  if (this.host === '0.0.0.0') {
131
- console.warn(` Server is bound to 0.0.0.0 — fork state is reachable from the LAN.`);
134
+ logger.warning("Server is bound to 0.0.0.0 — fork state is reachable from the LAN.", 2);
132
135
  }
133
- console.log(`\nPress Ctrl+C to stop`);
136
+ logger.newline();
137
+ logger.info("Press Ctrl+C to stop");
134
138
  resolve();
135
139
  });
136
140
  });
@@ -143,7 +147,8 @@ export class ForkServer {
143
147
  return new Promise((resolve) => {
144
148
  if (this.server) {
145
149
  this.server.close(() => {
146
- console.log('\nFork Server stopped');
150
+ logger.newline();
151
+ logger.success("Fork Server stopped");
147
152
  resolve();
148
153
  });
149
154
  } else {
@@ -172,8 +177,9 @@ export class ForkServer {
172
177
  const url = new URL(req.url || '/', `http://localhost:${this.port}`);
173
178
  const pathname = url.pathname;
174
179
 
175
- // Log request
176
- console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}`);
180
+ // Log request — plain so the fork-server access log retains its
181
+ // grep-friendly line shape (timestamp + method + path, no symbol).
182
+ logger.plain(`[${new Date().toISOString()}] ${req.method} ${pathname}`);
177
183
 
178
184
  this.applyCors(req, res);
179
185
 
@@ -216,7 +222,7 @@ export class ForkServer {
216
222
  }
217
223
  } catch (error) {
218
224
  // Log full error server-side for diagnostics
219
- console.error('Error handling request:', error);
225
+ logger.error(`Error handling request: ${error instanceof Error ? error.message : String(error)}`);
220
226
 
221
227
  // Send generic error to client (don't expose internal details)
222
228
  this.sendError(res, 500, 'Internal server error');
package/src/fork/test.ts CHANGED
@@ -2,6 +2,7 @@ import { join } from 'path';
2
2
  import { existsSync } from 'fs';
3
3
  import { runCli } from '../utils/runCli.js';
4
4
  import type { ChildProcessAdapter } from '../utils/childProcessAdapter.js';
5
+ import { logger } from '../ui/index.js';
5
6
 
6
7
  export interface SnapshotOptions {
7
8
  path?: string;
@@ -40,7 +41,7 @@ export async function snapshot(options: SnapshotOptions = {}): Promise<string> {
40
41
  const name = options.name || `snapshot-${Date.now()}`;
41
42
  const snapshotPath = options.path || join(process.cwd(), '.movehat', 'snapshots', name);
42
43
 
43
- console.log(`📸 Creating snapshot: ${name}...`);
44
+ logger.info(`Creating snapshot: ${name}...`);
44
45
 
45
46
  try {
46
47
  // Initialize fork/snapshot using aptos CLI.
@@ -68,7 +69,7 @@ export async function snapshot(options: SnapshotOptions = {}): Promise<string> {
68
69
  throw new Error('Snapshot directory was not created');
69
70
  }
70
71
 
71
- console.log(`Snapshot created at ${snapshotPath}`);
72
+ logger.success(`Snapshot created at ${snapshotPath}`, 2);
72
73
  return snapshotPath;
73
74
  } catch (error) {
74
75
  const msg = error instanceof Error ? error.message : String(error);
@@ -20,7 +20,7 @@ import {
20
20
  } from "../errors.js";
21
21
  import { runCli } from "../utils/runCli.js";
22
22
  import { parseTxHash } from "../utils/parseCliOutput.js";
23
- import { logger } from "../ui/index.js";
23
+ import { logger, isVerbose } from "../ui/index.js";
24
24
  import {
25
25
  writeTempKeyFile,
26
26
  removeKeyFile,
@@ -206,7 +206,7 @@ async function executeMovementMoveObject(
206
206
  },
207
207
  { adapter: opts.adapter }
208
208
  );
209
- if (buildResult.stdout) console.log(buildResult.stdout.trim());
209
+ if (isVerbose() && buildResult.stdout) logger.info(buildResult.stdout.trim(), 2);
210
210
 
211
211
  // Format the private key into AIP-80 shape so the Movement CLI
212
212
  // doesn't emit its raw-hex deprecation warning. `formatPrivateKey`
@@ -265,8 +265,11 @@ async function executeMovementMoveObject(
265
265
  { adapter: opts.adapter }
266
266
  );
267
267
  deployOut = result.stdout;
268
- if (result.stdout) console.log(result.stdout.trim());
269
- if (result.stderr) console.error(result.stderr.trim());
268
+ // Both streams gated behind isVerbose(); see §9 — stream channel
269
+ // is not by itself a failure signal. Real failures throw via
270
+ // CliExecutionError and are surfaced from the catch below.
271
+ if (isVerbose() && result.stdout) logger.info(result.stdout.trim(), 2);
272
+ if (isVerbose() && result.stderr) logger.info(result.stderr.trim(), 2);
270
273
  } finally {
271
274
  // Unlink via the observable helper — emit a warning if the file
272
275
  // could not be removed AND still exists on disk (private key
@@ -336,7 +339,7 @@ async function executeMovementMoveObject(
336
339
  throw error;
337
340
  }
338
341
  if (error instanceof CliExecutionError) {
339
- if (error.stdoutPreview) console.log(error.stdoutPreview);
342
+ if (error.stdoutPreview) logger.info(error.stdoutPreview, 2);
340
343
  logger.error(
341
344
  `Failed to ${subcommand === "deploy-object" ? "deploy" : "upgrade"} module: ${error.message}\n${error.stderr}`
342
345
  );
@@ -10,7 +10,7 @@ import { validatePathSafety } from "../core/shell.js";
10
10
  import { CliExecutionError } from "../errors.js";
11
11
  import { runCli } from "../utils/runCli.js";
12
12
  import { parseTxHash } from "../utils/parseCliOutput.js";
13
- import { logger } from "../ui/index.js";
13
+ import { logger, isVerbose } from "../ui/index.js";
14
14
  import {
15
15
  writeTempKeyFile,
16
16
  removeKeyFile,
@@ -132,8 +132,11 @@ export async function runMoveScript(
132
132
  { adapter: options.adapter }
133
133
  );
134
134
  scriptOut = result.stdout;
135
- if (result.stdout) console.log(result.stdout.trim());
136
- if (result.stderr) console.error(result.stderr.trim());
135
+ // Both streams gated behind isVerbose(); Movement CLI uses
136
+ // stderr for progress messages too. Real failures throw via
137
+ // CliExecutionError and are surfaced from the catch below.
138
+ if (isVerbose() && result.stdout) logger.info(result.stdout.trim(), 2);
139
+ if (isVerbose() && result.stderr) logger.info(result.stderr.trim(), 2);
137
140
  } finally {
138
141
  // Observable cleanup — emit a warning if the unlink failed and
139
142
  // the file is still on disk (private key would persist silently
@@ -167,7 +170,7 @@ export async function runMoveScript(
167
170
  return out;
168
171
  } catch (error) {
169
172
  if (error instanceof CliExecutionError) {
170
- if (error.stdoutPreview) console.log(error.stdoutPreview);
173
+ if (error.stdoutPreview) logger.info(error.stdoutPreview, 2);
171
174
  logger.error(`Failed to run Move script: ${error.message}\n${error.stderr}`);
172
175
  } else {
173
176
  const err = error instanceof Error ? error : new Error(String(error));
@@ -95,9 +95,9 @@ export async function setupLocalTesting(
95
95
  const accountLabels = options.accountLabels || ["deployer", "alice", "bob"];
96
96
 
97
97
  logger.newline();
98
- logger.step("Setting up local testing environment...");
99
- logger.plain(` Mode: ${mode}`);
100
- logger.plain(` Accounts: ${accountLabels.join(", ")}`);
98
+ logger.phase("Setting up local testing environment");
99
+ logger.kv("Mode", mode, 2);
100
+ logger.kv("Accounts", accountLabels.join(", "), 2);
101
101
  logger.newline();
102
102
 
103
103
  if (mode === 'local-node') {
@@ -239,7 +239,7 @@ async function setupWithLocalNode(
239
239
  logger.plain(` RPC: ${nodeInfo.rpcUrl}/v1`);
240
240
  logger.plain(` Faucet: ${nodeInfo.faucetUrl}`);
241
241
  logger.plain(` Accounts: ${Array.from(accountLabels).join(", ")}`);
242
- logger.plain(` Balance per account: ${defaultBalance / 100_000_000} APT`);
242
+ logger.plain(` Balance per account: ${defaultBalance / 100_000_000} MOVE`);
243
243
  logger.newline();
244
244
 
245
245
  return { runtime, localNode };
@@ -376,7 +376,7 @@ async function setupWithFork(
376
376
  logger.plain(` Mode: fork (read-only)`);
377
377
  logger.plain(` RPC: http://localhost:${forkPort}/v1`);
378
378
  logger.plain(` Accounts: ${Array.from(accountLabels).join(", ")}`);
379
- logger.plain(` Balance per account: ${defaultBalance / 100_000_000} APT`);
379
+ logger.plain(` Balance per account: ${defaultBalance / 100_000_000} MOVE`);
380
380
  logger.newline();
381
381
 
382
382
  return { runtime, forkServer, forkManager };
@@ -6,7 +6,16 @@ import {
6
6
  type ChildProcessAdapter,
7
7
  type SpawnedProcess,
8
8
  } from "../utils/childProcessAdapter.js";
9
- import { logger } from "../ui/index.js";
9
+ import { logger, isVerbose, colors, symbols } from "../ui/index.js";
10
+ import { withTimedSpinner, withSpinner } from "../ui/spinner.js";
11
+
12
+ /**
13
+ * Substrings that always surface from the movement subprocess regardless
14
+ * of verbosity. These are signals the user must see to debug a stuck
15
+ * startup (panic, fatal, address-in-use). Tested in
16
+ * __tests__/LocalNodeManager.test.ts to guard against silent regressions.
17
+ */
18
+ const CRITICAL_NODE_OUTPUT = /panic|fatal|address already in use|EADDRINUSE/i;
10
19
 
11
20
  export interface LocalNodeOptions {
12
21
  testDir?: string; // Directory for node data (default: .movehat/local-node)
@@ -86,7 +95,7 @@ export class LocalNodeManager {
86
95
  */
87
96
  async start(): Promise<LocalNodeInfo> {
88
97
  if (this.spawned) {
89
- console.log("Local node already running");
98
+ logger.info("Local node already running");
90
99
  return this.getNodeInfo();
91
100
  }
92
101
 
@@ -98,11 +107,11 @@ export class LocalNodeManager {
98
107
 
99
108
  try {
100
109
  logger.newline();
101
- logger.step("Starting local Movement node...");
102
- logger.plain(` Test directory: ${this.options.testDir}`);
103
- logger.plain(` RPC port: ${this.options.apiPort}`);
104
- logger.plain(` Faucet port: ${this.options.faucetPort}`);
105
- logger.plain(` Ready port: ${this.options.readyPort}`);
110
+ logger.step("Starting local Movement node");
111
+ logger.kv("Test directory", this.options.testDir, 2);
112
+ logger.kv("RPC port", String(this.options.apiPort), 2);
113
+ logger.kv("Faucet port", String(this.options.faucetPort), 2);
114
+ logger.kv("Ready port", String(this.options.readyPort), 2);
106
115
  logger.newline();
107
116
 
108
117
  // Clean state if force restart
@@ -133,19 +142,43 @@ export class LocalNodeManager {
133
142
  stdio: this.options.silent ? "ignore" : "pipe",
134
143
  });
135
144
 
136
- // Handle process output
145
+ // Subprocess output handling (see §9 Console UX in CLAUDE.md):
146
+ // - stdout chatter is hidden by default; gated by isVerbose()
147
+ // - lines matching CRITICAL_NODE_OUTPUT always surface as warnings
148
+ // so the user is never silenced through a real failure
149
+ // - stderr is always surfaced (real signal), modulo benign WARN
150
+ // lines that the movement binary emits during normal startup
137
151
  if (!this.options.silent && this.spawned.stdout && this.spawned.stderr) {
138
152
  this.spawned.stdout.on("data", (data: Buffer) => {
139
153
  const output = data.toString().trim();
140
- if (output) {
141
- console.log(`[Node] ${output}`);
154
+ if (!output) return;
155
+ if (CRITICAL_NODE_OUTPUT.test(output)) {
156
+ logger.warning(output);
157
+ return;
158
+ }
159
+ if (isVerbose()) {
160
+ for (const line of output.split("\n")) {
161
+ if (line) console.log(` ${colors.muted(symbols.pointer + " " + line)}`);
162
+ }
142
163
  }
143
164
  });
144
165
 
145
166
  this.spawned.stderr.on("data", (data: Buffer) => {
146
167
  const output = data.toString().trim();
147
- if (output && !output.includes("WARN")) {
148
- console.error(`[Node Error] ${output}`);
168
+ if (!output) return;
169
+ // Movement CLI uses stderr for both progress messages
170
+ // ("Applying post startup steps...", "Compiling...") and
171
+ // real errors. Stream channel alone isn't a reliable
172
+ // signal — gate routine lines behind verbosity, escalate
173
+ // anything matching CRITICAL_NODE_OUTPUT regardless.
174
+ if (CRITICAL_NODE_OUTPUT.test(output)) {
175
+ logger.error(output);
176
+ return;
177
+ }
178
+ if (isVerbose()) {
179
+ for (const line of output.split("\n")) {
180
+ if (line) console.error(` ${colors.muted(symbols.pointer + " " + line)}`);
181
+ }
149
182
  }
150
183
  });
151
184
  }
@@ -161,11 +194,13 @@ export class LocalNodeManager {
161
194
  this.spawned = null;
162
195
  });
163
196
 
164
- // Wait for node to be ready
165
- logger.step("Waiting for node to be ready...");
166
- await this.waitForReady(60000); // 60 second timeout
197
+ // Wait for node to be ready — wrapped in withTimedSpinner so the
198
+ // user sees live elapsed-time feedback while subprocess chatter is
199
+ // hidden in non-verbose mode.
200
+ await withTimedSpinner("Waiting for node to be ready", () =>
201
+ this.waitForReady(60000)
202
+ );
167
203
 
168
- logger.success("Local Movement node is ready!");
169
204
  logger.newline();
170
205
 
171
206
  this.starting = false;
@@ -277,7 +312,7 @@ export class LocalNodeManager {
277
312
  * Fund an account using the local faucet
278
313
  *
279
314
  * @param account Account instance or address to fund
280
- * @param amount Amount in octas (default: 100_000_000 = 100 APT)
315
+ * @param amount Amount in octas (default: 100_000_000 = 1 MOVE)
281
316
  */
282
317
  async fundAccount(account: Account | string, amount: number = 100_000_000): Promise<void> {
283
318
  if (!this.isRunning()) {
@@ -303,7 +338,9 @@ export class LocalNodeManager {
303
338
  }
304
339
 
305
340
  const result = await response.json();
306
- logger.success(`Funded ${address} with ${amount} octas`, 2);
341
+ if (isVerbose()) {
342
+ logger.success(`Funded ${address} with ${amount} octas`, 2);
343
+ }
307
344
 
308
345
  return result;
309
346
  } catch (error) {
@@ -317,13 +354,15 @@ export class LocalNodeManager {
317
354
  */
318
355
  async fundAccounts(accounts: (Account | string)[], amount: number = 100_000_000): Promise<void> {
319
356
  logger.newline();
320
- logger.step(`Funding ${accounts.length} accounts from local faucet...`);
321
-
322
- for (const account of accounts) {
323
- await this.fundAccount(account, amount);
324
- }
325
-
326
- logger.success("All accounts funded successfully");
357
+ await withSpinner(
358
+ `Funding ${accounts.length} accounts from local faucet`,
359
+ async () => {
360
+ for (const account of accounts) {
361
+ await this.fundAccount(account, amount);
362
+ }
363
+ },
364
+ `Funded ${accounts.length} accounts (${(amount / 1e8).toFixed(0)} MOVE each)`,
365
+ );
327
366
  logger.newline();
328
367
  }
329
368
 
@@ -181,7 +181,7 @@ describe("LocalNodeManager — start / stop / lifecycle", () => {
181
181
  expect(proc.stderr!.listenerCount("data")).toBe(0);
182
182
  });
183
183
 
184
- it("start in non-silent mode wires stdout/stderr listeners that log events", async () => {
184
+ it("start in non-silent mode wires stdout/stderr listeners that respect §9 filtering", async () => {
185
185
  const { adapter, spawned } = buildFakeAdapter();
186
186
  stubFetchAlwaysOk();
187
187
  const mgr = new LocalNodeManager({ adapter, testDir: tmpDir, silent: false });
@@ -191,19 +191,9 @@ describe("LocalNodeManager — start / stop / lifecycle", () => {
191
191
  const proc = spawned[0]!;
192
192
  expect(proc.stdout!.listenerCount("data")).toBeGreaterThan(0);
193
193
  expect(proc.stderr!.listenerCount("data")).toBeGreaterThan(0);
194
-
195
- // Drive a line through stdout the manager's listener forwards to console.log.
196
- proc.stdout!.emit("data", Buffer.from("local node ready\n"));
197
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("local node ready"));
198
-
199
- // stderr non-WARN line goes through console.error.
200
- proc.stderr!.emit("data", Buffer.from("real error\n"));
201
- expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("real error"));
202
-
203
- // stderr WARN line should be filtered (no error log).
204
- errSpy.mockClear();
205
- proc.stderr!.emit("data", Buffer.from("WARN something\n"));
206
- expect(errSpy).not.toHaveBeenCalledWith(expect.stringContaining("WARN"));
194
+ // Detailed filter behavior — what passes through vs what's gated
195
+ // behind isVerbose() lives in the "§9 console UX" describe block
196
+ // below. This test only locks the listener-wiring contract.
207
197
  });
208
198
 
209
199
  it("force-restart cleans the test directory before spawn", async () => {
@@ -451,3 +441,139 @@ describe("LocalNodeManager — fundAccount", () => {
451
441
  expect(fetchFn.mock.calls.length).toBeGreaterThanOrEqual(4);
452
442
  });
453
443
  });
444
+
445
+ describe("LocalNodeManager — subprocess output filtering (§9 console UX)", () => {
446
+ let tmpDir: string;
447
+ let logSpy: ReturnType<typeof vi.spyOn>;
448
+ let errSpy: ReturnType<typeof vi.spyOn>;
449
+ let warnSpy: ReturnType<typeof vi.spyOn>;
450
+
451
+ beforeEach(() => {
452
+ tmpDir = mkdtempSync(join(tmpdir(), "movehat-localnode-filter-"));
453
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
454
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
455
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
456
+ // Ensure quiet mode is the default for each test; the verbose tests
457
+ // set MOVEHAT_VERBOSE explicitly inside their own scope.
458
+ delete process.env.MOVEHAT_VERBOSE;
459
+ });
460
+
461
+ afterEach(() => {
462
+ vi.restoreAllMocks();
463
+ vi.unstubAllGlobals();
464
+ delete process.env.MOVEHAT_VERBOSE;
465
+ if (existsSync(tmpDir)) {
466
+ rmSync(tmpDir, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ it("hides routine stdout chatter from the movement node in quiet mode", async () => {
471
+ const { adapter, spawned } = buildFakeAdapter();
472
+ stubFetchAlwaysOk();
473
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
474
+ await mgr.start();
475
+
476
+ const proc = spawned[0]!;
477
+ logSpy.mockClear();
478
+
479
+ // Push routine chatter — exactly the noise we used to spam to stdout.
480
+ proc.stdout!.emit("data", Buffer.from("Loading aptos framework module"));
481
+ proc.stdout!.emit("data", Buffer.from("Compiling Move bytecode"));
482
+ proc.stdout!.emit("data", Buffer.from("aptos_account: account created"));
483
+
484
+ // None of these should have reached stdout (no `[Node]` prefix, no
485
+ // muted gray `›` prefix). The only console.log calls that may arise
486
+ // are from the spinner mock falling back to plain text — but since
487
+ // ora auto-disables in non-TTY (vitest is non-TTY), even those are
488
+ // suppressed.
489
+ const stdoutCalls = logSpy.mock.calls.flat().join(" ");
490
+ expect(stdoutCalls).not.toContain("Loading aptos framework module");
491
+ expect(stdoutCalls).not.toContain("Compiling Move bytecode");
492
+ expect(stdoutCalls).not.toContain("aptos_account: account created");
493
+ });
494
+
495
+ it("always surfaces panic / fatal lines as warnings, even in quiet mode", async () => {
496
+ const { adapter, spawned } = buildFakeAdapter();
497
+ stubFetchAlwaysOk();
498
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
499
+ await mgr.start();
500
+
501
+ const proc = spawned[0]!;
502
+ warnSpy.mockClear();
503
+
504
+ proc.stdout!.emit("data", Buffer.from("thread 'main' panicked at 'state corrupted'"));
505
+
506
+ const warnCalls = warnSpy.mock.calls.flat().join(" ");
507
+ expect(warnCalls).toContain("panicked");
508
+ });
509
+
510
+ it("surfaces 'address already in use' (port conflict) regardless of verbosity", async () => {
511
+ const { adapter, spawned } = buildFakeAdapter();
512
+ stubFetchAlwaysOk();
513
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
514
+ await mgr.start();
515
+
516
+ const proc = spawned[0]!;
517
+ warnSpy.mockClear();
518
+
519
+ proc.stdout!.emit(
520
+ "data",
521
+ Buffer.from("error: address already in use: 127.0.0.1:8080"),
522
+ );
523
+
524
+ const warnCalls = warnSpy.mock.calls.flat().join(" ");
525
+ expect(warnCalls).toContain("address already in use");
526
+ });
527
+
528
+ it("surfaces stdout chatter with gray prefix in verbose mode", async () => {
529
+ process.env.MOVEHAT_VERBOSE = "1";
530
+ const { adapter, spawned } = buildFakeAdapter();
531
+ stubFetchAlwaysOk();
532
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
533
+ await mgr.start();
534
+
535
+ const proc = spawned[0]!;
536
+ logSpy.mockClear();
537
+
538
+ proc.stdout!.emit("data", Buffer.from("Loading aptos framework module"));
539
+
540
+ const stdoutCalls = logSpy.mock.calls.flat().join(" ");
541
+ expect(stdoutCalls).toContain("Loading aptos framework module");
542
+ });
543
+
544
+ it("always surfaces critical stderr (panic/EADDRINUSE) via logger.error", async () => {
545
+ const { adapter, spawned } = buildFakeAdapter();
546
+ stubFetchAlwaysOk();
547
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
548
+ await mgr.start();
549
+
550
+ const proc = spawned[0]!;
551
+ errSpy.mockClear();
552
+
553
+ proc.stderr!.emit("data", Buffer.from("thread panicked: failed to bind socket"));
554
+
555
+ const stderrCalls = errSpy.mock.calls.flat().join(" ");
556
+ expect(stderrCalls).toContain("panicked");
557
+ });
558
+
559
+ it("hides routine progress stderr in quiet mode (Movement CLI emits progress to stderr too)", async () => {
560
+ const { adapter, spawned } = buildFakeAdapter();
561
+ stubFetchAlwaysOk();
562
+ const mgr = new LocalNodeManager({ adapter, testDir: tmpDir });
563
+ await mgr.start();
564
+
565
+ const proc = spawned[0]!;
566
+ errSpy.mockClear();
567
+
568
+ // The movement subprocess emits progress messages to stderr that
569
+ // are NOT errors: "Applying post startup steps...", "Compiling,
570
+ // may take a little while...". Hiding stream channel from the
571
+ // user keeps the console clean.
572
+ proc.stderr!.emit("data", Buffer.from("Applying post startup steps..."));
573
+ proc.stderr!.emit("data", Buffer.from("[WARN] deprecated config field"));
574
+
575
+ const stderrCalls = errSpy.mock.calls.flat().join(" ");
576
+ expect(stderrCalls).not.toContain("Applying post startup steps");
577
+ expect(stderrCalls).not.toContain("deprecated config field");
578
+ });
579
+ });