Skip to content

Commit

Permalink
Fix JavaScript workspace lockfile generation
Browse files Browse the repository at this point in the history
This patch fixes the lockfile generation for workspaces with npm, yarn,
and pnpm.

See #1236.
  • Loading branch information
cd-work committed Sep 19, 2023
1 parent 295be11 commit 5c34817
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 4 deletions.
4 changes: 3 additions & 1 deletion lockfile_generator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ pub enum Error {
InvalidManifest(PathBuf),
PipReportVersionMismatch(&'static str, String),
UnsupportedCommandVersion(&'static str, &'static str, String),
UnexpectedOutput(&'static str, String),
Anyhow(anyhow::Error),
NoLockfileGenerated,
}
Expand Down Expand Up @@ -184,8 +185,9 @@ impl Display for Error {
Self::UnsupportedCommandVersion(command, expected, received) => {
write!(f, "unsupported {command:?} version {received:?}, expected {expected:?}")
},
Self::NoLockfileGenerated => write!(f, "no lockfile was generated"),
Self::Anyhow(err) => write!(f, "{err}"),
Self::UnexpectedOutput(cmd, output) => write!(f, "unexpected output for {cmd:?}: {output}"),
Self::NoLockfileGenerated => write!(f, "no lockfile was generated"),
}
}
}
Expand Down
17 changes: 16 additions & 1 deletion lockfile_generator/src/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,22 @@ impl Generator for Npm {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("package-lock.json"))

let output = Command::new("npm").current_dir(project_path).args(["root"]).output()?;

// Ensure command was successful.
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::NonZeroExit(output.status.code(), stderr.into()));
}

// Parse root node_modules output.
let stdout = String::from_utf8(output.stdout).map_err(Error::InvalidUtf8)?;
let workspace_root = stdout
.strip_suffix("/node_modules\n")
.ok_or_else(|| Error::UnexpectedOutput("npm root", stdout.clone()))?;

Ok(PathBuf::from(workspace_root).join("package-lock.json"))
}

fn conflicting_files(&self, manifest_path: &Path) -> Result<Vec<PathBuf>> {
Expand Down
35 changes: 34 additions & 1 deletion lockfile_generator/src/pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,35 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

use crate::{Error, Generator, Result};

const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml";
const WORKSPACE_DIR_ENV_VAR: &str = "NPM_CONFIG_WORKSPACE_DIR";

pub struct Pnpm;

impl Generator for Pnpm {
// Based on PNPM's implementation:
// https://github.com/pnpm/pnpm/blob/98377afd3452d92183e4b643a8b122887c0406c3/workspace/find-workspace-dir/src/index.ts
fn lockfile_path(&self, manifest_path: &Path) -> Result<PathBuf> {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("pnpm-lock.yaml"))

// Get project root from env variable.
let workspace_dir_env = env::var_os(WORKSPACE_DIR_ENV_VAR)
.or_else(|| env::var_os(WORKSPACE_DIR_ENV_VAR.to_lowercase()))
.map(|workspace_dir| PathBuf::from(workspace_dir));

// Fallback to recursive search for `WORKSPACE_MANIFEST_FILENAME`.
let workspace_root = workspace_dir_env.or_else(|| find_workspace_root(project_path));

// Fallback to non-workspace location.
let root = workspace_root.unwrap_or_else(|| PathBuf::new());

Ok(root.join("pnpm-lock.yaml"))
}

fn command(&self, _manifest_path: &Path) -> Command {
Expand All @@ -25,3 +43,18 @@ impl Generator for Pnpm {
"pnpm"
}
}

/// Find PNPM workspace root.
fn find_workspace_root(mut path: &Path) -> Option<PathBuf> {
loop {
let dir = fs::read_dir(path).ok()?;

for dir_entry in dir.into_iter().flatten().map(|entry| entry.path()) {
if dir_entry.file_name().map_or(false, |name| name == WORKSPACE_MANIFEST_FILENAME) {
return Some(path.into());
}
}

path = path.parent()?;
}
}
41 changes: 40 additions & 1 deletion lockfile_generator/src/yarn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use serde::Deserialize;

use crate::{Error, Generator, Result};

pub struct Yarn;
Expand All @@ -13,7 +15,38 @@ impl Generator for Yarn {
let project_path = manifest_path
.parent()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
Ok(project_path.join("yarn.lock"))

let output = Command::new("yarn")
.current_dir(project_path)
.args(["workspaces", "list", "--json"])
.output()?;

// Ensure command was successful.
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::NonZeroExit(output.status.code(), stderr.into()));
}

// Convert output to actual valid json.
let stdout = String::from_utf8(output.stdout).map_err(Error::InvalidUtf8)?;
let workspaces_json = format!("[{}]", stdout.trim_end().replace('\n', ","));

// Parse workspace list.
let mut workspaces: Vec<Workspace> = serde_json::from_str(&workspaces_json)?;

// Sort by longest location path, to prefer longer matches.
workspaces.sort_by(|a, b| b.location.len().cmp(&a.location.len()));

// Find workspace root by stripping the first matching location path.
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?;
let workspace_root = workspaces
.into_iter()
.find_map(|project| project_str.strip_suffix(&project.location))
.unwrap_or(project_str);

Ok(PathBuf::from(workspace_root).join("yarn.lock"))
}

fn command(&self, _manifest_path: &Path) -> Command {
Expand Down Expand Up @@ -52,3 +85,9 @@ fn yarn_version(manifest_path: &Path) -> Result<String> {
Err(Error::NonZeroExit(output.status.code(), stderr.into()))
}
}

/// Yarn workspace project.
#[derive(Deserialize)]
struct Workspace {
location: String,
}

0 comments on commit 5c34817

Please sign in to comment.