diff --git a/native/vtz/src/pm/lockfile.rs b/native/vtz/src/pm/lockfile.rs index cc3e7f2..5c78053 100644 --- a/native/vtz/src/pm/lockfile.rs +++ b/native/vtz/src/pm/lockfile.rs @@ -33,6 +33,20 @@ pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> Result<(), std::io::E } } + if !entry.bin.is_empty() { + output.push_str(" bin:\n"); + for (bin_name, bin_path) in &entry.bin { + output.push_str(&format!(" \"{}\" \"{}\"\n", bin_name, bin_path)); + } + } + + if !entry.scripts.is_empty() { + output.push_str(" scripts:\n"); + for (script_name, script_cmd) in &entry.scripts { + output.push_str(&format!(" \"{}\" \"{}\"\n", script_name, script_cmd)); + } + } + output.push('\n'); } @@ -56,10 +70,12 @@ pub fn parse_lockfile(content: &str) -> Result = None; for line in content.lines() { // Skip comments and empty lines @@ -75,18 +91,23 @@ pub fn parse_lockfile(content: &str) -> Result Result Result { + if let Some((name, range)) = parse_quoted_pair(trimmed) { + current_entry + .dependencies + .insert(name.to_string(), range.to_string()); + } + } + Some("bin") => { + if let Some((name, path)) = parse_quoted_pair(trimmed) { + current_entry.bin.insert(name.to_string(), path.to_string()); + } + } + Some("scripts") => { + if let Some((name, cmd)) = parse_quoted_pair(trimmed) { + current_entry + .scripts + .insert(name.to_string(), cmd.to_string()); + } + } + _ => { + if let Some(rest) = trimmed.strip_prefix("version ") { + current_entry.version = unquote(rest).to_string(); + } else if let Some(rest) = trimmed.strip_prefix("resolved ") { + current_entry.resolved = unquote(rest).to_string(); + } else if let Some(rest) = trimmed.strip_prefix("integrity ") { + current_entry.integrity = unquote(rest).to_string(); + } else if trimmed == "optional true" { + current_entry.optional = true; + } else if trimmed == "overridden true" { + current_entry.overridden = true; + } } - } else if let Some(rest) = trimmed.strip_prefix("version ") { - current_entry.version = unquote(rest).to_string(); - } else if let Some(rest) = trimmed.strip_prefix("resolved ") { - current_entry.resolved = unquote(rest).to_string(); - } else if let Some(rest) = trimmed.strip_prefix("integrity ") { - current_entry.integrity = unquote(rest).to_string(); - } else if trimmed == "optional true" { - current_entry.optional = true; - } else if trimmed == "overridden true" { - current_entry.overridden = true; } } } @@ -196,6 +243,8 @@ mod tests { resolved: "https://registry.npmjs.org/react/-/react-18.3.1.tgz".to_string(), integrity: "sha512-abc123".to_string(), dependencies: deps, + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -210,6 +259,8 @@ mod tests { resolved: "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz".to_string(), integrity: "sha512-def456".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -257,6 +308,8 @@ mod tests { resolved: "url1".to_string(), integrity: "hash1".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -270,6 +323,8 @@ mod tests { resolved: "url2".to_string(), integrity: "hash2".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -298,6 +353,8 @@ mod tests { resolved: "url".to_string(), integrity: "hash".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -311,6 +368,8 @@ mod tests { resolved: "url".to_string(), integrity: "hash".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -381,6 +440,8 @@ mod tests { resolved: "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz".to_string(), integrity: "sha512-abc".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -396,6 +457,8 @@ mod tests { resolved: "link:packages/shared".to_string(), integrity: String::new(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -447,6 +510,8 @@ mod tests { resolved: "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz".to_string(), integrity: "sha512-abc".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: true, overridden: false, }, @@ -460,6 +525,8 @@ mod tests { resolved: "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz".to_string(), integrity: "sha512-def".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -512,6 +579,8 @@ fsevents@^2.3.0: resolved: "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz".to_string(), integrity: "sha512-abc".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: true, }, @@ -525,6 +594,8 @@ fsevents@^2.3.0: resolved: "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz".to_string(), integrity: "sha512-def".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -555,4 +626,120 @@ zod@^3.24.0: let lockfile = parse_lockfile(content).unwrap(); assert!(!lockfile.entries["zod@^3.24.0"].overridden); } + + #[test] + fn test_write_and_read_bin_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vertz.lock"); + + let mut lockfile = Lockfile::default(); + let mut bin = BTreeMap::new(); + bin.insert("esbuild".to_string(), "bin/esbuild".to_string()); + + lockfile.entries.insert( + "esbuild@^0.20.0".to_string(), + LockfileEntry { + name: "esbuild".to_string(), + range: "^0.20.0".to_string(), + version: "0.20.2".to_string(), + resolved: "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz".to_string(), + integrity: "sha512-abc".to_string(), + dependencies: BTreeMap::new(), + bin, + scripts: BTreeMap::new(), + optional: false, + overridden: false, + }, + ); + + write_lockfile(&path, &lockfile).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("bin:")); + assert!(content.contains("\"esbuild\" \"bin/esbuild\"")); + + let parsed = read_lockfile(&path).unwrap(); + let entry = &parsed.entries["esbuild@^0.20.0"]; + assert_eq!(entry.bin.len(), 1); + assert_eq!(entry.bin["esbuild"], "bin/esbuild"); + } + + #[test] + fn test_write_and_read_scripts_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vertz.lock"); + + let mut lockfile = Lockfile::default(); + let mut scripts = BTreeMap::new(); + scripts.insert( + "postinstall".to_string(), + "node scripts/build.js".to_string(), + ); + + lockfile.entries.insert( + "esbuild@^0.20.0".to_string(), + LockfileEntry { + name: "esbuild".to_string(), + range: "^0.20.0".to_string(), + version: "0.20.2".to_string(), + resolved: "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz".to_string(), + integrity: "sha512-abc".to_string(), + dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts, + optional: false, + overridden: false, + }, + ); + + write_lockfile(&path, &lockfile).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("scripts:")); + assert!(content.contains("\"postinstall\" \"node scripts/build.js\"")); + + let parsed = read_lockfile(&path).unwrap(); + let entry = &parsed.entries["esbuild@^0.20.0"]; + assert_eq!(entry.scripts.len(), 1); + assert_eq!(entry.scripts["postinstall"], "node scripts/build.js"); + } + + #[test] + fn test_parse_lockfile_with_bin_and_scripts() { + let content = r#"# vertz.lock v1 — DO NOT EDIT +# Run "vertz install" to regenerate + +esbuild@^0.20.0: + version "0.20.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz" + integrity "sha512-abc" + bin: + "esbuild" "bin/esbuild" + scripts: + "postinstall" "node scripts/build.js" + +"#; + let lockfile = parse_lockfile(content).unwrap(); + let entry = &lockfile.entries["esbuild@^0.20.0"]; + assert_eq!(entry.version, "0.20.2"); + assert_eq!(entry.bin["esbuild"], "bin/esbuild"); + assert_eq!(entry.scripts["postinstall"], "node scripts/build.js"); + } + + #[test] + fn test_lockfile_without_bin_scripts_defaults_empty() { + let content = r#"# vertz.lock v1 — DO NOT EDIT +# Run "vertz install" to regenerate + +zod@^3.24.0: + version "3.24.4" + resolved "url" + integrity "hash" + +"#; + let lockfile = parse_lockfile(content).unwrap(); + let entry = &lockfile.entries["zod@^3.24.0"]; + assert!(entry.bin.is_empty()); + assert!(entry.scripts.is_empty()); + } } diff --git a/native/vtz/src/pm/mod.rs b/native/vtz/src/pm/mod.rs index 49a74c9..f50fb67 100644 --- a/native/vtz/src/pm/mod.rs +++ b/native/vtz/src/pm/mod.rs @@ -2995,6 +2995,8 @@ mod tests { ), integrity: format!("sha512-fake-{}", name), dependencies, + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, } @@ -4731,6 +4733,8 @@ mod tests { resolved: "https://codeload.github.com/user/my-lib/tar.gz/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(), integrity: "sha512-fakehash".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, @@ -4774,6 +4778,8 @@ mod tests { resolved: "https://codeload.github.com/user/my-lib/tar.gz/a1b2c3d".to_string(), integrity: "sha512-fakehash".to_string(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, diff --git a/native/vtz/src/pm/resolver.rs b/native/vtz/src/pm/resolver.rs index cda0817..b3779fb 100644 --- a/native/vtz/src/pm/resolver.rs +++ b/native/vtz/src/pm/resolver.rs @@ -204,37 +204,35 @@ async fn resolve_recursive( return Ok(()); } - // Use lockfile version — still need metadata for transitive deps - let metadata = - get_or_fetch_metadata(name, state.registry, &mut state.metadata_cache).await?; - if let Some(version_meta) = metadata.versions.get(&entry.version) { - let resolved = ResolvedPackage { - name: name.to_string(), - version: entry.version.clone(), - tarball_url: version_meta.dist.tarball.clone(), - integrity: version_meta.dist.integrity.clone(), - dependencies: version_meta.dependencies.clone(), - bin: version_meta.bin.to_map(name), - nest_path: vec![], - }; - if !version_meta.scripts.is_empty() { - state - .graph - .scripts - .insert(graph_key.clone(), version_meta.scripts.clone()); - } - state.graph.packages.insert(graph_key, resolved); - - // Resolve transitive deps (skip transitive devDeps) - state.parent_chain.push(name.to_string()); - let deps: Vec<_> = version_meta.dependencies.clone().into_iter().collect(); - for (dep_name, dep_range) in &deps { - resolve_recursive(dep_name, dep_range, state).await?; - } - state.parent_chain.pop(); + // Use lockfile data directly — no registry fetch needed. + // The lockfile stores version, tarball URL, integrity, dependencies, + // bin, and scripts, which is everything we need. + let resolved = ResolvedPackage { + name: name.to_string(), + version: entry.version.clone(), + tarball_url: entry.resolved.clone(), + integrity: entry.integrity.clone(), + dependencies: entry.dependencies.clone(), + bin: entry.bin.clone(), + nest_path: vec![], + }; + if !entry.scripts.is_empty() { + state + .graph + .scripts + .insert(graph_key.clone(), entry.scripts.clone()); + } + state.graph.packages.insert(graph_key, resolved); - return Ok(()); + // Resolve transitive deps from lockfile entry + state.parent_chain.push(name.to_string()); + let deps: Vec<_> = entry.dependencies.clone().into_iter().collect(); + for (dep_name, dep_range) in &deps { + resolve_recursive(dep_name, dep_range, state).await?; } + state.parent_chain.pop(); + + return Ok(()); } } @@ -414,6 +412,8 @@ pub fn graph_to_lockfile( .values() .find(|p| p.name == *name && p.nest_path.is_empty()) { + let graph_key = ResolvedGraph::key(name, &pkg.version); + let scripts = graph.scripts.get(&graph_key).cloned().unwrap_or_default(); lockfile.entries.insert( key, LockfileEntry { @@ -423,6 +423,8 @@ pub fn graph_to_lockfile( resolved: pkg.tarball_url.clone(), integrity: pkg.integrity.clone(), dependencies: pkg.dependencies.clone(), + bin: pkg.bin.clone(), + scripts, optional: optional_names.contains(name), overridden: false, }, @@ -454,6 +456,12 @@ pub fn graph_to_lockfile( }; if let Some(dep_pkg) = dep_pkg { + let dep_graph_key = ResolvedGraph::key(&dep_pkg.name, &dep_pkg.version); + let dep_scripts = graph + .scripts + .get(&dep_graph_key) + .cloned() + .unwrap_or_default(); entry.insert(LockfileEntry { name: dep_name.clone(), range: dep_range.clone(), @@ -461,6 +469,8 @@ pub fn graph_to_lockfile( resolved: dep_pkg.tarball_url.clone(), integrity: dep_pkg.integrity.clone(), dependencies: dep_pkg.dependencies.clone(), + bin: dep_pkg.bin.clone(), + scripts: dep_scripts, optional: false, overridden: false, }); @@ -481,6 +491,8 @@ pub fn graph_to_lockfile( resolved: format!("link:{}", ws.path), integrity: String::new(), dependencies: BTreeMap::new(), + bin: BTreeMap::new(), + scripts: BTreeMap::new(), optional: false, overridden: false, }, diff --git a/native/vtz/src/pm/types.rs b/native/vtz/src/pm/types.rs index c702086..e1307ed 100644 --- a/native/vtz/src/pm/types.rs +++ b/native/vtz/src/pm/types.rs @@ -141,6 +141,11 @@ pub struct LockfileEntry { pub resolved: String, pub integrity: String, pub dependencies: BTreeMap, + /// Binary executables exposed by this package (name → relative path) + pub bin: BTreeMap, + /// Package scripts (e.g., postinstall). Stored so lockfile-only resolution + /// can detect packages that need script execution without fetching metadata. + pub scripts: BTreeMap, pub optional: bool, /// Whether this version was forced by an override pub overridden: bool,