1use crate::errors::{DnxError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::io::{BufRead, BufReader, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use tracing::{debug, warn};
14
15const WORKER_SCRIPT: &str = r#"
20'use strict';
21const path = require('path');
22const readline = require('readline');
23
24const pnpmfilePath = process.argv[2];
25let hooks;
26try {
27 hooks = require(path.resolve(pnpmfilePath));
28} catch (e) {
29 process.stderr.write('dnx-hook: failed to load ' + pnpmfilePath + ': ' + e.message + '\n');
30 process.exit(1);
31}
32
33const context = {
34 log: function(msg) {
35 process.stderr.write('dnx-hook: ' + msg + '\n');
36 }
37};
38
39const rl = readline.createInterface({ input: process.stdin, terminal: false });
40
41rl.on('line', async (line) => {
42 let request;
43 try {
44 request = JSON.parse(line);
45 } catch (e) {
46 process.stdout.write(JSON.stringify({ error: 'Invalid JSON: ' + e.message }) + '\n');
47 return;
48 }
49
50 try {
51 if (request.type === 'readPackage') {
52 if (hooks.hooks && typeof hooks.hooks.readPackage === 'function') {
53 let result = hooks.hooks.readPackage(request.pkg, context);
54 if (result && typeof result.then === 'function') {
55 result = await result;
56 }
57 process.stdout.write(JSON.stringify({ pkg: result || request.pkg }) + '\n');
58 } else {
59 process.stdout.write(JSON.stringify({ pkg: request.pkg }) + '\n');
60 }
61 } else if (request.type === 'afterAllResolved') {
62 if (hooks.hooks && typeof hooks.hooks.afterAllResolved === 'function') {
63 let result = hooks.hooks.afterAllResolved(request.lockfile, context);
64 if (result && typeof result.then === 'function') {
65 result = await result;
66 }
67 process.stdout.write(JSON.stringify({ lockfile: result || request.lockfile }) + '\n');
68 } else {
69 process.stdout.write(JSON.stringify({ lockfile: request.lockfile }) + '\n');
70 }
71 } else if (request.type === 'ping') {
72 process.stdout.write(JSON.stringify({ pong: true }) + '\n');
73 } else {
74 process.stdout.write(JSON.stringify({ error: 'Unknown request type' }) + '\n');
75 }
76 } catch (e) {
77 process.stdout.write(JSON.stringify({ error: e.message }) + '\n');
78 }
79});
80
81rl.on('close', () => process.exit(0));
82"#;
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct HookPackage {
91 pub name: String,
92 pub version: String,
93 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94 pub dependencies: HashMap<String, String>,
95 #[serde(
96 rename = "devDependencies",
97 default,
98 skip_serializing_if = "HashMap::is_empty"
99 )]
100 pub dev_dependencies: HashMap<String, String>,
101 #[serde(
102 rename = "peerDependencies",
103 default,
104 skip_serializing_if = "HashMap::is_empty"
105 )]
106 pub peer_dependencies: HashMap<String, String>,
107 #[serde(
108 rename = "optionalDependencies",
109 default,
110 skip_serializing_if = "HashMap::is_empty"
111 )]
112 pub optional_dependencies: HashMap<String, String>,
113}
114
115pub enum Hooks {
117 Active(HookRunner),
119 Noop,
121}
122
123impl Hooks {
124 pub fn detect(project_root: &Path, pnpmfile_config: Option<&str>) -> Self {
130 match pnpmfile_config {
131 Some("false") => {
132 debug!("Hooks disabled by configuration");
133 Hooks::Noop
134 }
135 Some(path) => {
136 let pnpmfile_path = if Path::new(path).is_absolute() {
137 PathBuf::from(path)
138 } else {
139 project_root.join(path)
140 };
141 if pnpmfile_path.exists() {
142 match HookRunner::new(&pnpmfile_path) {
143 Ok(runner) => {
144 debug!("Hooks loaded from {}", pnpmfile_path.display());
145 Hooks::Active(runner)
146 }
147 Err(e) => {
148 warn!("Failed to start hook runner: {}", e);
149 Hooks::Noop
150 }
151 }
152 } else {
153 warn!("Configured pnpmfile not found: {}", pnpmfile_path.display());
154 Hooks::Noop
155 }
156 }
157 None => {
158 let default_path = project_root.join(".pnpmfile.cjs");
159 if default_path.exists() {
160 match HookRunner::new(&default_path) {
161 Ok(runner) => {
162 debug!("Hooks loaded from .pnpmfile.cjs");
163 Hooks::Active(runner)
164 }
165 Err(e) => {
166 warn!("Failed to start hook runner: {}", e);
167 Hooks::Noop
168 }
169 }
170 } else {
171 Hooks::Noop
172 }
173 }
174 }
175 }
176
177 pub fn read_package(&mut self, pkg: HookPackage) -> Result<HookPackage> {
180 match self {
181 Hooks::Active(runner) => runner.read_package(pkg),
182 Hooks::Noop => Ok(pkg),
183 }
184 }
185
186 pub fn after_all_resolved(&mut self, lockfile: &str) -> Result<String> {
189 match self {
190 Hooks::Active(runner) => runner.after_all_resolved(lockfile),
191 Hooks::Noop => Ok(lockfile.to_string()),
192 }
193 }
194
195 pub fn is_active(&self) -> bool {
197 matches!(self, Hooks::Active(_))
198 }
199}
200
201pub struct HookRunner {
206 child: Child,
207 stdin: Option<std::process::ChildStdin>,
208 reader: BufReader<std::process::ChildStdout>,
209}
210
211#[derive(Serialize)]
213#[serde(tag = "type")]
214enum HookRequest {
215 #[serde(rename = "readPackage")]
216 ReadPackage { pkg: Box<HookPackage> },
217 #[serde(rename = "afterAllResolved")]
218 AfterAllResolved { lockfile: String },
219 #[serde(rename = "ping")]
220 Ping,
221}
222
223#[derive(Deserialize)]
225struct ReadPackageResponse {
226 pkg: Option<HookPackage>,
227 error: Option<String>,
228}
229
230#[derive(Deserialize)]
231struct AfterAllResolvedResponse {
232 lockfile: Option<String>,
233 error: Option<String>,
234}
235
236#[derive(Deserialize)]
237struct PingResponse {
238 pong: Option<bool>,
239 error: Option<String>,
240}
241
242impl HookRunner {
243 pub fn new(pnpmfile_path: &Path) -> Result<Self> {
245 let temp_dir = std::env::temp_dir();
247 let worker_path = temp_dir.join("dnx-hook-worker.js");
248 std::fs::write(&worker_path, WORKER_SCRIPT)
249 .map_err(|e| DnxError::Hook(format!("Failed to write hook worker script: {}", e)))?;
250
251 let mut child = Command::new("node")
252 .arg(&worker_path)
253 .arg(pnpmfile_path)
254 .stdin(Stdio::piped())
255 .stdout(Stdio::piped())
256 .stderr(Stdio::inherit()) .spawn()
258 .map_err(|e| {
259 DnxError::Hook(format!(
260 "Failed to spawn Node.js for hooks (is Node.js installed?): {}",
261 e
262 ))
263 })?;
264
265 let stdin = child
266 .stdin
267 .take()
268 .ok_or_else(|| DnxError::Hook("Failed to capture hook worker stdin".to_string()))?;
269 let stdout = child
270 .stdout
271 .take()
272 .ok_or_else(|| DnxError::Hook("Failed to capture hook worker stdout".to_string()))?;
273 let reader = BufReader::new(stdout);
274
275 let mut runner = Self {
276 child,
277 stdin: Some(stdin),
278 reader,
279 };
280
281 runner.ping()?;
283
284 Ok(runner)
285 }
286
287 fn ping(&mut self) -> Result<()> {
288 let request = serde_json::to_string(&HookRequest::Ping)
289 .map_err(|e| DnxError::Hook(format!("Failed to serialize ping: {}", e)))?;
290
291 self.send_line(&request)?;
292 let response_line = self.read_line()?;
293
294 let response: PingResponse = serde_json::from_str(&response_line)
295 .map_err(|e| DnxError::Hook(format!("Failed to parse ping response: {}", e)))?;
296
297 if let Some(err) = response.error {
298 return Err(DnxError::Hook(format!("Hook worker error: {}", err)));
299 }
300
301 if response.pong != Some(true) {
302 return Err(DnxError::Hook(
303 "Hook worker did not respond to ping".to_string(),
304 ));
305 }
306
307 Ok(())
308 }
309
310 fn read_package(&mut self, pkg: HookPackage) -> Result<HookPackage> {
311 let request = serde_json::to_string(&HookRequest::ReadPackage {
312 pkg: Box::new(pkg.clone()),
313 })
314 .map_err(|e| DnxError::Hook(format!("Failed to serialize readPackage: {}", e)))?;
315
316 self.send_line(&request)?;
317 let response_line = self.read_line()?;
318
319 let response: ReadPackageResponse = serde_json::from_str(&response_line)
320 .map_err(|e| DnxError::Hook(format!("Failed to parse readPackage response: {}", e)))?;
321
322 if let Some(err) = response.error {
323 return Err(DnxError::Hook(format!("readPackage hook error: {}", err)));
324 }
325
326 Ok(response.pkg.unwrap_or(pkg))
327 }
328
329 fn after_all_resolved(&mut self, lockfile: &str) -> Result<String> {
330 let request = serde_json::to_string(&HookRequest::AfterAllResolved {
331 lockfile: lockfile.to_string(),
332 })
333 .map_err(|e| DnxError::Hook(format!("Failed to serialize afterAllResolved: {}", e)))?;
334
335 self.send_line(&request)?;
336 let response_line = self.read_line()?;
337
338 let response: AfterAllResolvedResponse =
339 serde_json::from_str(&response_line).map_err(|e| {
340 DnxError::Hook(format!("Failed to parse afterAllResolved response: {}", e))
341 })?;
342
343 if let Some(err) = response.error {
344 return Err(DnxError::Hook(format!(
345 "afterAllResolved hook error: {}",
346 err
347 )));
348 }
349
350 Ok(response.lockfile.unwrap_or_else(|| lockfile.to_string()))
351 }
352
353 fn send_line(&mut self, line: &str) -> Result<()> {
354 let stdin = self
355 .stdin
356 .as_mut()
357 .ok_or_else(|| DnxError::Hook("Hook worker stdin is closed".to_string()))?;
358 writeln!(stdin, "{}", line)
359 .map_err(|e| DnxError::Hook(format!("Failed to write to hook worker: {}", e)))?;
360 stdin
361 .flush()
362 .map_err(|e| DnxError::Hook(format!("Failed to flush hook worker stdin: {}", e)))?;
363 Ok(())
364 }
365
366 fn read_line(&mut self) -> Result<String> {
367 let mut line = String::new();
368 self.reader
369 .read_line(&mut line)
370 .map_err(|e| DnxError::Hook(format!("Failed to read from hook worker: {}", e)))?;
371 if line.is_empty() {
372 return Err(DnxError::Hook(
373 "Hook worker process exited unexpectedly".to_string(),
374 ));
375 }
376 Ok(line.trim().to_string())
377 }
378}
379
380impl Drop for HookRunner {
381 fn drop(&mut self) {
382 self.stdin.take();
384
385 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
388 loop {
389 match self.child.try_wait() {
390 Ok(Some(_)) => break, Ok(None) => {
392 if std::time::Instant::now() >= deadline {
394 warn!("Hook worker did not exit in 2s, killing");
395 let _ = self.child.kill();
396 let _ = self.child.wait();
397 break;
398 }
399 std::thread::sleep(std::time::Duration::from_millis(50));
400 }
401 Err(_) => break, }
403 }
404 }
405}