Skip to content

Commit 15619e6

Browse files
committed
fix(deployer): address CodeRabbit review feedback
Harden the deployer tool and CLI against the issues raised in the #86 review. Each fix verified against current code and covered by tests. * cli/logger: route `--json` pino logs to STDERR (fd 2) so STDOUT carries only the single result object. * cli/prompt: write the passphrase prompt + newline to STDERR, and settle the promise on stdin `end`/`error` so non-interactive input (closed/newline-less pipe) can no longer hang the CLI. * config/schema: mark the file/module ref union members `.strict()` so an ambiguous `{ file, module }` is rejected instead of silently dropping a key. * deployments: write the ledger atomically (temp file + rename) so a crash mid-write can't truncate `*.json`. * loaders/artifact: bind compiled assets to `dirname(entry)` rather than a hardcoded `contract/`, fixing the top-level-`index.js` case. * loaders/constructor-meta: allow an empty named-args object for a no-arg constructor (resolves to `[]`); `reorderNamedArgs` still rejects unexpected keys. * loaders/contract-resolve: use `path.isAbsolute` instead of `startsWith('/')` for OS-correct absolute-path detection. * providers/network: drop `mainnet` from the allow-list while the deployer is testnet/preview-only. * providers/proof-server: reject partial-numeric and out-of-range `PROOF_SERVER_PORT` values (full `\d+` match + 1..65535 bounds). * wallet/handler: use `path.dirname`/`basename` instead of manual `/` splitting so cache paths work on Windows. * wallet/keystore: validate shape in `fromJSON` and throw `WalletError` (not a raw `TypeError`) on malformed keystore JSON. Refs: #86
1 parent 04b80fc commit 15619e6

18 files changed

Lines changed: 196 additions & 62 deletions

packages/cli/src/logger.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { join } from 'node:path';
33
import pino, { type Logger } from 'pino';
44

55
/**
6-
* Pino factory for the three CLI modes: `--json` (raw JSON, no transports),
7-
* default (pretty `info+`), `--verbose` (pretty `info+` to stdout AND
8-
* `debug+` mirrored to `.compact/logs/<ts>.log` so the transcript survives
9-
* spinner overwrites).
6+
* Pino factory for the three CLI modes: `--json` (raw JSON to STDERR, no
7+
* transports — STDOUT is reserved for the single result object), default
8+
* (pretty `info+`), `--verbose` (pretty `info+` to stdout AND `debug+`
9+
* mirrored to `.compact/logs/<ts>.log` so the transcript survives spinner
10+
* overwrites).
1011
*/
1112
export interface CreateLoggerOptions {
1213
verbose: boolean;
@@ -16,7 +17,11 @@ export interface CreateLoggerOptions {
1617

1718
export function createLogger(opts: CreateLoggerOptions): Logger {
1819
if (opts.json) {
19-
return pino({ level: opts.verbose ? 'debug' : 'info' });
20+
// fd 2 = STDERR; keeps STDOUT carrying only the final JSON result.
21+
return pino(
22+
{ level: opts.verbose ? 'debug' : 'info' },
23+
pino.destination(2),
24+
);
2025
}
2126

2227
if (opts.verbose) {

packages/cli/src/prompt.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { stdin, stdout } from 'node:process';
1+
import { stderr, stdin } from 'node:process';
22

3-
/** Prompt for a keystore passphrase with terminal echo suppressed; falls back to plain line-read off a TTY. */
3+
/**
4+
* Prompt for a keystore passphrase with terminal echo suppressed; falls back
5+
* to plain line-read off a TTY. Prompt text and the trailing newline go to
6+
* STDERR so `--json` (and any piped) callers keep a clean single-object STDOUT.
7+
*/
48
export async function promptPassphrase(label: string): Promise<string> {
5-
stdout.write(`Passphrase for ${label}: `);
9+
stderr.write(`Passphrase for ${label}: `);
610
return readMaskedLine();
711
}
812

@@ -15,7 +19,23 @@ function readMaskedLine(): Promise<string> {
1519
if (isTTY) stdin.setRawMode(false);
1620
stdin.pause();
1721
stdin.removeListener('data', onData);
18-
stdout.write('\n');
22+
stdin.removeListener('end', onEnd);
23+
stdin.removeListener('error', onError);
24+
stderr.write('\n');
25+
};
26+
27+
// Without these, a non-interactive stdin that closes without a trailing
28+
// newline (e.g. piped input, or a closed pipe) would never settle the
29+
// promise and the CLI would hang.
30+
const onEnd = () => {
31+
cleanup();
32+
if (buffer.length > 0) resolveFn(buffer);
33+
else rejectFn(new Error('Aborted'));
34+
};
35+
36+
const onError = (err: Error) => {
37+
cleanup();
38+
rejectFn(err);
1939
};
2040

2141
const onData = (chunk: Buffer) => {
@@ -44,5 +64,7 @@ function readMaskedLine(): Promise<string> {
4464
stdin.resume();
4565
stdin.setEncoding('utf8');
4666
stdin.on('data', onData);
67+
stdin.on('end', onEnd);
68+
stdin.on('error', onError);
4769
});
4870
}

packages/cli/test/logger.test.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
const { mockMkdirSync, mockPino, mockTransport, fakeLogger } = vi.hoisted(
4-
() => {
3+
const { mockMkdirSync, mockPino, mockTransport, mockDestination, fakeLogger } =
4+
vi.hoisted(() => {
55
const fakeLogger = {
66
trace: vi.fn(),
77
debug: vi.fn(),
@@ -15,10 +15,10 @@ const { mockMkdirSync, mockPino, mockTransport, fakeLogger } = vi.hoisted(
1515
mockMkdirSync: vi.fn(),
1616
mockPino: vi.fn(() => fakeLogger),
1717
mockTransport: vi.fn((cfg: unknown) => ({ __transport: cfg })),
18+
mockDestination: vi.fn((fd: number) => ({ __destination: fd })),
1819
fakeLogger,
1920
};
20-
},
21-
);
21+
});
2222

2323
vi.mock('node:fs', async (importOriginal) => {
2424
const actual = await importOriginal<typeof import('node:fs')>();
@@ -29,6 +29,8 @@ vi.mock('pino', () => {
2929
const pinoFn = (...args: unknown[]) => mockPino(...(args as [])) as unknown;
3030
(pinoFn as unknown as { transport: typeof mockTransport }).transport =
3131
mockTransport;
32+
(pinoFn as unknown as { destination: typeof mockDestination }).destination =
33+
mockDestination;
3234
return { default: pinoFn };
3335
});
3436

@@ -44,20 +46,29 @@ describe('createLogger', () => {
4446
});
4547

4648
describe('json mode', () => {
47-
it('should return logger at info level when verbose false', () => {
49+
it('should return logger at info level routed to STDERR when verbose false', () => {
4850
const logger = createLogger({ verbose: false, json: true });
4951

5052
expect(mockPino).toHaveBeenCalledTimes(1);
51-
expect(mockPino).toHaveBeenCalledWith({ level: 'info' });
53+
// fd 2 = STDERR; STDOUT stays reserved for the single JSON result.
54+
expect(mockDestination).toHaveBeenCalledWith(2);
55+
expect(mockPino).toHaveBeenCalledWith(
56+
{ level: 'info' },
57+
{ __destination: 2 },
58+
);
5259
expect(mockTransport).not.toHaveBeenCalled();
5360
expect(mockMkdirSync).not.toHaveBeenCalled();
5461
expect(logger).toBe(fakeLogger);
5562
});
5663

57-
it('should return logger at debug level when verbose true', () => {
64+
it('should return logger at debug level routed to STDERR when verbose true', () => {
5865
const logger = createLogger({ verbose: true, json: true });
5966

60-
expect(mockPino).toHaveBeenCalledWith({ level: 'debug' });
67+
expect(mockDestination).toHaveBeenCalledWith(2);
68+
expect(mockPino).toHaveBeenCalledWith(
69+
{ level: 'debug' },
70+
{ __destination: 2 },
71+
);
6172
expect(mockTransport).not.toHaveBeenCalled();
6273
expect(mockMkdirSync).not.toHaveBeenCalled();
6374
expect(logger).toBe(fakeLogger);

packages/cli/test/prompt.test.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EventEmitter } from 'node:events';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33

4-
const { mockStdin, mockStdout } = await vi.hoisted(async () => {
4+
const { mockStdin, mockStderr } = await vi.hoisted(async () => {
55
const { EventEmitter } = await import('node:events');
66
type FakeStdin = InstanceType<typeof EventEmitter> & {
77
isTTY: boolean;
@@ -21,13 +21,13 @@ const { mockStdin, mockStdout } = await vi.hoisted(async () => {
2121
stdin,
2222
) as FakeStdin['removeListener'];
2323

24-
const stdout = { write: vi.fn() };
25-
return { mockStdin: stdin, mockStdout: stdout };
24+
const stderr = { write: vi.fn() };
25+
return { mockStdin: stdin, mockStderr: stderr };
2626
});
2727

2828
vi.mock('node:process', () => ({
2929
stdin: mockStdin,
30-
stdout: mockStdout,
30+
stderr: mockStderr,
3131
}));
3232

3333
import { promptPassphrase } from '../src/prompt.ts';
@@ -39,7 +39,7 @@ function resetStdin(opts: { tty: boolean } = { tty: true }): void {
3939
(mockStdin.pause as ReturnType<typeof vi.fn>).mockClear();
4040
(mockStdin.resume as ReturnType<typeof vi.fn>).mockClear();
4141
(mockStdin.setEncoding as ReturnType<typeof vi.fn>).mockClear();
42-
mockStdout.write.mockClear();
42+
mockStderr.write.mockClear();
4343
}
4444

4545
describe('promptPassphrase', () => {
@@ -57,7 +57,7 @@ describe('promptPassphrase', () => {
5757
mockStdin.emit('data', Buffer.from('x\n'));
5858
await promise;
5959

60-
expect(mockStdout.write).toHaveBeenCalledWith(
60+
expect(mockStderr.write).toHaveBeenCalledWith(
6161
'Passphrase for Alice keystore: ',
6262
);
6363
expect(mockStdin.setRawMode).toHaveBeenCalledWith(true);
@@ -86,7 +86,7 @@ describe('promptPassphrase', () => {
8686
expect(pp).toBe('hunter2');
8787
expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false);
8888
expect(mockStdin.pause).toHaveBeenCalled();
89-
expect(mockStdout.write).toHaveBeenLastCalledWith('\n');
89+
expect(mockStderr.write).toHaveBeenLastCalledWith('\n');
9090
});
9191

9292
it('should resolve on LF (0x0a)', async () => {
@@ -147,4 +147,25 @@ describe('promptPassphrase', () => {
147147
await expect(promise).rejects.toThrow('Aborted');
148148
});
149149
});
150+
151+
describe('stream close path', () => {
152+
it('should reject with "Aborted" when stdin ends with an empty buffer', async () => {
153+
const promise = promptPassphrase('label');
154+
mockStdin.emit('end');
155+
await expect(promise).rejects.toThrow('Aborted');
156+
});
157+
158+
it('should resolve with the buffer when stdin ends without a trailing newline', async () => {
159+
const promise = promptPassphrase('label');
160+
mockStdin.emit('data', Buffer.from('piped-secret'));
161+
mockStdin.emit('end');
162+
await expect(promise).resolves.toBe('piped-secret');
163+
});
164+
165+
it('should reject when stdin emits an error', async () => {
166+
const promise = promptPassphrase('label');
167+
mockStdin.emit('error', new Error('stream boom'));
168+
await expect(promise).rejects.toThrow('stream boom');
169+
});
170+
});
150171
});

packages/deployer/src/config/schema.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,20 @@ describe('configSchema — contract args', () => {
208208
export: 'default',
209209
});
210210
});
211+
212+
it('should reject an ambiguous ref carrying both file and module', () => {
213+
expect(() =>
214+
configSchema.parse({
215+
...baseConfig,
216+
contracts: {
217+
Counter: {
218+
...validContract,
219+
args: { file: 'args.json', module: 'args.ts' },
220+
},
221+
},
222+
}),
223+
).toThrow();
224+
});
211225
});
212226

213227
describe('configSchema — required fields', () => {

packages/deployer/src/config/schema.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ const walletObjectSchema = z.object({
4040
});
4141
const walletSchema = walletObjectSchema.optional();
4242

43-
const fileRefSchema = z.object({ file: z.string().min(1) });
44-
const moduleRefSchema = z.object({
45-
module: z.string().min(1),
46-
export: z.string().default('default'),
47-
});
43+
const fileRefSchema = z.object({ file: z.string().min(1) }).strict();
44+
const moduleRefSchema = z
45+
.object({
46+
module: z.string().min(1),
47+
export: z.string().default('default'),
48+
})
49+
.strict();
4850
const fileOrModuleRefSchema = z.union([fileRefSchema, moduleRefSchema]);
4951

5052
const argsSchema = z.union([z.array(z.unknown()), fileOrModuleRefSchema]);

packages/deployer/src/deployments.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { randomUUID } from 'node:crypto';
12
import { existsSync } from 'node:fs';
2-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
3+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
34
import { dirname, isAbsolute, resolve } from 'node:path';
45

56
/**
@@ -108,6 +109,13 @@ async function readJson<T>(path: string, fallback: T): Promise<T> {
108109
return JSON.parse(raw) as T;
109110
}
110111

112+
// Write atomically: a crash mid-write would otherwise leave a truncated
113+
// `*.json`, breaking subsequent reads and losing durable deploy state.
114+
// Write to a sibling temp file, then rename it into place (atomic on the
115+
// same filesystem).
111116
async function writeJson(path: string, value: unknown): Promise<void> {
112-
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
117+
await mkdir(dirname(path), { recursive: true });
118+
const tmp = `${path}.${randomUUID()}.tmp`;
119+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
120+
await rename(tmp, path);
113121
}

packages/deployer/src/loaders/artifact.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync, readdirSync } from 'node:fs';
2-
import { isAbsolute, resolve } from 'node:path';
2+
import { dirname, isAbsolute, resolve } from 'node:path';
33
import { CompiledContract, type Contract } from '@midnight-ntwrk/compact-js';
44
import type { Types } from 'effect';
55
import {
@@ -62,13 +62,16 @@ export class Artifact {
6262
throw new ArtifactNotFoundError(artifactPath);
6363
}
6464

65-
const contractDir = resolve(artifactPath, 'contract');
66-
const entry = findEntry(contractDir, artifactPath);
65+
const entry = findEntry(resolve(artifactPath, 'contract'), artifactPath);
6766
if (!entry) {
6867
throw new ArtifactNotFoundError(
6968
`${artifactPath} (no contract/index.{cjs,js} or index.{cjs,js} found)`,
7069
);
7170
}
71+
// Bind compiled assets to the directory the entry actually lives in:
72+
// `findEntry` may resolve a top-level `index.{cjs,js}` rather than the
73+
// `contract/` subdir, in which case the hardcoded path would be wrong.
74+
const contractDir = dirname(entry);
7275

7376
const keysDir = resolve(artifactPath, 'keys');
7477
const zkirDir = resolve(artifactPath, 'zkir');

packages/deployer/src/loaders/constructor-meta.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,14 @@ describe('reorderNamedArgs', () => {
7272
/unknown constructor parameter\(s\): x/,
7373
);
7474
});
75+
76+
it('accepts an empty named object for a no-arg constructor', () => {
77+
expect(reorderNamedArgs({}, [])).toEqual([]);
78+
});
79+
80+
it('rejects a non-empty named object for a no-arg constructor', () => {
81+
expect(() => reorderNamedArgs({ a: 1 }, [])).toThrow(
82+
/unknown constructor parameter\(s\): a/,
83+
);
84+
});
7585
});

packages/deployer/src/loaders/constructor-meta.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,10 @@ export function loadConstructorParamNames(artifactPath: string): string[] {
1717
);
1818
}
1919
const source = readFileSync(dtsPath, 'utf8');
20-
const names = parseConstructorParamNames(source);
21-
if (names.length === 0) {
22-
throw new ConfigError(
23-
`Contract ${artifactPath} has a no-arg constructor; named args object should be empty`,
24-
);
25-
}
26-
return names;
20+
// A no-arg constructor yields `[]`. `reorderNamedArgs` then accepts an
21+
// empty named-args object and rejects any unexpected keys, so we don't
22+
// need to special-case the empty result here.
23+
return parseConstructorParamNames(source);
2724
}
2825

2926
/** Reorders a named-object args record into a positional tuple. */

0 commit comments

Comments
 (0)