use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use aube_lockfile::LockfileGraph;
use aube_registry::VersionMetadata;
use aube_resolver::ReadPackageHook;
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
pub const PNPMFILE_NAME: &str = ".pnpmfile.cjs";
pub fn detect(cwd: &Path) -> Option<PathBuf> {
let p = cwd.join(PNPMFILE_NAME);
if p.is_file() { Some(p) } else { None }
}
#[derive(Serialize, Deserialize, Default)]
struct LockfileWire {
importers: BTreeMap<String, Vec<DirectDepWire>>,
packages: BTreeMap<String, PackageWire>,
}
#[derive(Serialize, Deserialize, Clone)]
struct DirectDepWire {
name: String,
version: String,
}
#[derive(Serialize, Deserialize, Clone, Default)]
struct PackageWire {
name: String,
version: String,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default, rename = "peerDependencies")]
peer_dependencies: BTreeMap<String, String>,
}
fn to_wire(graph: &LockfileGraph) -> LockfileWire {
let importers = graph
.importers
.iter()
.map(|(path, deps)| {
let wire = deps
.iter()
.map(|d| DirectDepWire {
name: d.name.clone(),
version: d.dep_path.clone(),
})
.collect();
(path.clone(), wire)
})
.collect();
let packages = graph
.packages
.iter()
.map(|(key, pkg)| {
(
key.clone(),
PackageWire {
name: pkg.name.clone(),
version: pkg.version.clone(),
dependencies: pkg.dependencies.clone(),
peer_dependencies: pkg.peer_dependencies.clone(),
},
)
})
.collect();
LockfileWire {
importers,
packages,
}
}
fn apply(wire: LockfileWire, graph: &mut LockfileGraph) {
for (path, wire_deps) in &wire.importers {
if let Some(graph_deps) = graph.importers.get(path) {
let same = graph_deps.len() == wire_deps.len()
&& graph_deps
.iter()
.zip(wire_deps.iter())
.all(|(g, w)| g.name == w.name && g.dep_path == w.version);
if !same {
tracing::warn!(
"[pnpmfile] afterAllResolved mutated importers[{path}]; \
aube ignores importer edits because they would require \
re-running the resolver",
);
}
} else {
tracing::warn!(
"[pnpmfile] afterAllResolved added importers[{path}]; \
aube ignores new importer entries",
);
}
}
for (key, pkg) in wire.packages {
if let Some(locked) = graph.packages.get_mut(&key) {
if pkg.name != locked.name || pkg.version != locked.version {
tracing::warn!(
"[pnpmfile] afterAllResolved rewrote name/version for {key} \
(to {}@{}); aube ignores identity edits on existing packages",
pkg.name,
pkg.version,
);
}
if locked.dependencies != pkg.dependencies {
locked.dependencies = pkg.dependencies;
}
if locked.peer_dependencies != pkg.peer_dependencies {
locked.peer_dependencies = pkg.peer_dependencies;
}
} else {
tracing::warn!(
"[pnpmfile] afterAllResolved added a new package entry {key}; \
aube ignores newly-introduced packages from the hook",
);
}
}
}
const SHIM: &str = r#"
const path = require('path');
const pnpmfile = process.env.AUBE_PNPMFILE;
const hookName = process.env.AUBE_HOOK;
let chunks = [];
process.stdin.on('data', (c) => chunks.push(c));
process.stdin.on('end', async () => {
try {
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const mod = require(path.resolve(pnpmfile));
const hooks = (mod && mod.hooks) || {};
const fn = hooks[hookName];
let result = input;
if (typeof fn === 'function') {
const ctx = {
log: (...args) => console.error('[pnpmfile]', ...args),
};
const out = await fn(input, ctx);
if (out && typeof out === 'object') result = out;
}
process.stdout.write(JSON.stringify(result));
} catch (err) {
console.error('[pnpmfile] hook failed:', (err && err.stack) || err);
process.exit(1);
}
});
"#;
pub async fn run_after_all_resolved(pnpmfile: &Path, graph: &mut LockfileGraph) -> Result<()> {
let input = to_wire(graph);
let input_json = serde_json::to_vec(&input)
.into_diagnostic()
.wrap_err("failed to serialize lockfile for pnpmfile hook")?;
tracing::debug!(
"running pnpmfile hook afterAllResolved ({})",
pnpmfile.display()
);
let mut cmd = tokio::process::Command::new("node");
cmd.arg("-e")
.arg(SHIM)
.env("AUBE_PNPMFILE", pnpmfile)
.env("AUBE_HOOK", "afterAllResolved")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
let mut child = cmd
.spawn()
.into_diagnostic()
.wrap_err("failed to spawn `node` for pnpmfile hook — is node installed and on PATH?")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| miette!("failed to open stdin for pnpmfile node child"))?;
stdin
.write_all(&input_json)
.await
.into_diagnostic()
.wrap_err("failed to write lockfile JSON to pnpmfile hook")?;
stdin
.shutdown()
.await
.into_diagnostic()
.wrap_err("failed to close stdin for pnpmfile hook")?;
}
let output = child
.wait_with_output()
.await
.into_diagnostic()
.wrap_err("pnpmfile hook child process failed")?;
if !output.status.success() {
return Err(miette!(
"pnpmfile hook `afterAllResolved` exited with status {}",
output.status
));
}
let wire: LockfileWire = serde_json::from_slice(&output.stdout)
.into_diagnostic()
.wrap_err("pnpmfile hook returned invalid JSON from afterAllResolved")?;
apply(wire, graph);
Ok(())
}
const READ_PACKAGE_SHIM: &str = r#"
const path = require('path');
const readline = require('readline');
const pnpmfile = process.env.AUBE_PNPMFILE;
const mod = require(path.resolve(pnpmfile));
const hooks = (mod && mod.hooks) || {};
const readPackage = hooks.readPackage;
const ctx = {
log: (...args) => console.error('[pnpmfile]', ...args),
};
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
process.stdout.on('error', (e) => {
if (e && e.code === 'EPIPE') process.exit(0);
});
async function main() {
for await (const line of rl) {
if (!line) continue;
let id = null;
try {
const req = JSON.parse(line);
id = req.id;
let result = req.pkg;
if (typeof readPackage === 'function') {
const out = await readPackage(req.pkg, ctx);
if (out && typeof out === 'object') result = out;
}
process.stdout.write(JSON.stringify({ id, pkg: result }) + '\n');
} catch (err) {
const msg = (err && err.stack) || String(err);
process.stdout.write(JSON.stringify({ id, error: String(msg) }) + '\n');
}
}
}
main().catch((err) => {
console.error('[pnpmfile] readPackage host crashed:', (err && err.stack) || err);
process.exit(1);
});
"#;
pub struct ReadPackageHost {
#[allow(dead_code)]
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
line_buf: String,
}
#[derive(Serialize)]
struct ReadPackageRequest<'a> {
id: u64,
pkg: &'a VersionMetadata,
}
#[derive(Deserialize)]
struct ReadPackageResponse {
#[serde(default)]
id: Option<u64>,
#[serde(default)]
pkg: Option<VersionMetadata>,
#[serde(default)]
error: Option<String>,
}
impl ReadPackageHost {
pub async fn spawn(pnpmfile: &Path) -> Result<Option<Self>> {
if !has_read_package_hook(pnpmfile).await? {
return Ok(None);
}
tracing::debug!(
"spawning pnpmfile readPackage host ({})",
pnpmfile.display()
);
let mut cmd = tokio::process::Command::new("node");
cmd.arg("-e")
.arg(READ_PACKAGE_SHIM)
.env("AUBE_PNPMFILE", pnpmfile)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
let mut child = cmd.spawn().into_diagnostic().wrap_err(
"failed to spawn `node` for pnpmfile readPackage hook — is node installed and on PATH?",
)?;
let stdin = child
.stdin
.take()
.ok_or_else(|| miette!("failed to open stdin for pnpmfile readPackage host"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| miette!("failed to open stdout for pnpmfile readPackage host"))?;
Ok(Some(Self {
child,
stdin,
stdout: BufReader::new(stdout),
next_id: 0,
line_buf: String::new(),
}))
}
async fn call(&mut self, pkg: VersionMetadata) -> Result<VersionMetadata, String> {
self.next_id = self.next_id.wrapping_add(1);
let id = self.next_id;
let req = ReadPackageRequest { id, pkg: &pkg };
let mut line = serde_json::to_string(&req)
.map_err(|e| format!("serialize readPackage request: {e}"))?;
line.push('\n');
self.stdin
.write_all(line.as_bytes())
.await
.map_err(|e| format!("write to readPackage host: {e}"))?;
self.line_buf.clear();
let n = self
.stdout
.read_line(&mut self.line_buf)
.await
.map_err(|e| format!("read from readPackage host: {e}"))?;
if n == 0 {
return Err(
"readPackage host closed stdout unexpectedly (check stderr for the hook stack trace)"
.to_string(),
);
}
let resp: ReadPackageResponse = serde_json::from_str(self.line_buf.trim_end())
.map_err(|e| format!("parse readPackage response: {e}"))?;
if let Some(resp_id) = resp.id
&& resp_id != id
{
return Err(format!(
"readPackage response id mismatch: sent {id}, got {resp_id} \
(did the pnpmfile print to stdout at require time?)"
));
}
if let Some(err) = resp.error {
return Err(err);
}
resp.pkg
.ok_or_else(|| "readPackage response missing `pkg`".to_string())
}
}
impl ReadPackageHook for ReadPackageHost {
fn read_package<'a>(
&'a mut self,
pkg: VersionMetadata,
) -> Pin<Box<dyn std::future::Future<Output = Result<VersionMetadata, String>> + Send + 'a>>
{
Box::pin(self.call(pkg))
}
}
async fn has_read_package_hook(pnpmfile: &Path) -> Result<bool> {
let contents = tokio::fs::read_to_string(pnpmfile)
.await
.into_diagnostic()
.wrap_err_with(|| format!("failed to read pnpmfile at {}", pnpmfile.display()))?;
Ok(contents.contains("readPackage"))
}