1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
//! `aube patch <pkg>@<version>` — extract a package from `node_modules`
//! into a temporary edit directory so the user can modify its files
//! and then run `aube patch-commit <dir>` to capture the diff.
//!
//! Mirrors `pnpm patch`. Two directories are created under a unique
//! temp parent: `source/` (the original, immutable, used as the diff
//! base) and `user/` (the writable copy printed to the user). A
//! `.aube_patch_state.json` sidecar carries the package identity so
//! `patch-commit` can locate the source dir given just the user dir.
use crate::patches::copy_dir_all;
use clap::Args;
use miette::{IntoDiagnostic, Result, miette};
use std::path::{Path, PathBuf};
#[derive(Debug, Args)]
pub struct PatchArgs {
/// Package spec, `<name>@<version>`.
///
/// The package must already be installed in `node_modules` (we
/// copy from the linked virtual store, not from the registry, so
/// the layout matches what install would later patch).
pub package: String,
/// Directory to extract the writable copy into.
///
/// When omitted, `aube` picks a fresh temp dir under the system
/// tmpdir.
#[arg(long, value_name = "DIR")]
pub edit_dir: Option<PathBuf>,
/// Ignore any existing patch entry for this package.
///
/// Extracts a pristine copy from `node_modules` rather than
/// re-applying the existing patch first. Accepted for pnpm parity;
/// aube already extracts from the *linked* (post-patch) tree, so
/// this flag is effectively informational here.
#[arg(long)]
pub ignore_existing: bool,
}
pub async fn run(args: PatchArgs) -> Result<()> {
// Mirror install's resolution: workspace-first so `aube patch`
// from a workspace member finds the shared `.aube/` store at the
// workspace root (where install actually wrote it). Falls back to
// the project root for non-workspace trees.
//
// Yaml-only workspace roots are handled downstream:
// `upsert_patched_dependency` routes through `config_write_target`
// and lands the entry in the workspace yaml when no
// `package.json` exists at the resolved root.
let cwd = crate::dirs::workspace_or_project_root()?;
let (name, version) = parse_spec(&args.package)?;
// Locate the source files. The package must be installed —
// `aube` extracts from the linked tree
// (`<virtualStoreDir>/<dep_path>/...`) so the user edits exactly
// what the runtime would see. Honors the `virtualStoreDir`
// override via `resolve_virtual_store_dir_for_cwd`.
let pnpm_dir = super::resolve_virtual_store_dir_for_cwd(&cwd);
let vstore_max_len = super::resolve_virtual_store_dir_max_length_for_cwd(&cwd);
let pkg_dir = find_pnpm_entry(&pnpm_dir, &name, &version, vstore_max_len)?;
// Build the edit + source dirs. Defaults live under
// `<tmp>/aube-patch-<name>-<version>-<pid>/` so concurrent
// `aube patch` runs in different terminals don't collide.
let parent = match args.edit_dir {
Some(p) => p,
None => default_edit_parent(&name, &version)?,
};
let source_dir = parent.join("source");
let user_dir = parent.join("user");
if source_dir.exists() {
std::fs::remove_dir_all(&source_dir)
.into_diagnostic()
.map_err(|e| miette!("failed to clear {}: {e}", source_dir.display()))?;
}
if user_dir.exists() {
std::fs::remove_dir_all(&user_dir)
.into_diagnostic()
.map_err(|e| miette!("failed to clear {}: {e}", user_dir.display()))?;
}
copy_dir_all(&pkg_dir, &source_dir)?;
copy_dir_all(&pkg_dir, &user_dir)?;
let state = serde_json::json!({
"name": name,
"version": version,
"project": cwd.display().to_string(),
});
std::fs::write(
parent.join(".aube_patch_state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.into_diagnostic()
.map_err(|e| miette!("failed to write patch state: {e}"))?;
println!(
"You can now edit the following folder: {}",
user_dir.display()
);
println!(
"Once you're done with your changes, run \"aube patch-commit '{}'\"",
user_dir.display()
);
Ok(())
}
/// Find the linked package directory for `<name>@<version>` under
/// `.aube/`. Plain installs land at
/// `.aube/<encoded dep_path>/node_modules/<name>`, where the encoded
/// dep_path is produced by `dep_path_to_filename` — so a bare
/// `<name>@<version>` hits on an exact match, and peer-dep variants
/// show up with a flattened `_peer@ver` suffix. We scan the `.aube/`
/// entries (now flat, even for scoped packages) and match on either
/// the bare name or the peer-decorated prefix. When the same
/// `(name, version)` resolves under multiple peer contexts the choice
/// is arbitrary — pnpm has the same limitation; the user can
/// disambiguate via `--edit-dir`.
fn find_pnpm_entry(
pnpm_dir: &Path,
name: &str,
version: &str,
vstore_max_len: usize,
) -> Result<PathBuf> {
use aube_lockfile::dep_path_filename::dep_path_to_filename;
let exact_encoded = dep_path_to_filename(&format!("{name}@{version}"), vstore_max_len);
let exact_dir = pnpm_dir
.join(&exact_encoded)
.join("node_modules")
.join(name);
if exact_dir.exists() {
return Ok(exact_dir);
}
// Peer-dep variants: `<encoded exact>_peer@ver...`. `exact_encoded`
// is already filesystem-safe (slashes turned into `+`) so we can
// match on it directly against the flat `.aube/` entries.
//
// The prefix match only works as long as `exact_encoded` was NOT
// itself run through the hash branch of `dep_path_to_filename` —
// i.e. the bare `name@version` must be ≤ `max_length` and all
// lowercase. If either condition fails, `exact_encoded` becomes a
// `truncated_<32 hex>` shape and the peer-dep variants hash a
// different input, so they no longer share the first
// `exact_encoded` bytes. In practice npm's registry prohibits
// uppercase names and bare `name@version` stays well under 120
// bytes, so this doesn't bite real packages — but keep the
// invariant in mind if `max_length` ever drops toward the bare
// `name@version` length.
let peer_prefix = format!("{exact_encoded}_");
if let Ok(entries) = std::fs::read_dir(pnpm_dir) {
for entry in entries.flatten() {
let leaf = entry.file_name();
let leaf_str = leaf.to_string_lossy();
if leaf_str.starts_with(&peer_prefix) {
let candidate = entry.path().join("node_modules").join(name);
if candidate.exists() {
return Ok(candidate);
}
}
}
}
Err(miette!(
"package {name}@{version} is not installed (looked under {}). Run `aube install` first.",
pnpm_dir.display()
))
}
fn parse_spec(input: &str) -> Result<(String, String)> {
let (name, ver) = crate::commands::split_name_spec(input);
let ver = ver.ok_or_else(|| {
miette!("`aube patch` requires `<name>@<version>` (got {input:?}); a bare name is ambiguous because the same package can be installed at multiple versions")
})?;
Ok((name.to_string(), ver.to_string()))
}
fn default_edit_parent(name: &str, version: &str) -> Result<PathBuf> {
let safe_name = name.replace('/', "+");
let dir = std::env::temp_dir().join(format!(
"aube-patch-{safe_name}-{version}-{}",
std::process::id()
));
std::fs::create_dir_all(&dir)
.into_diagnostic()
.map_err(|e| miette!("failed to create {}: {e}", dir.display()))?;
Ok(dir)
}
/// Read the `.aube_patch_state.json` sidecar that `aube patch` writes
/// next to a user-edit dir. `patch-commit` calls this to recover the
/// package identity (name, version) and the matching source dir.
pub fn read_state(edit_dir: &Path) -> Result<PatchState> {
let parent = edit_dir
.parent()
.ok_or_else(|| miette!("edit dir {} has no parent", edit_dir.display()))?;
let state_path = parent.join(".aube_patch_state.json");
let raw = std::fs::read_to_string(&state_path)
.into_diagnostic()
.map_err(|e| {
miette!(
"{} is not a directory created by `aube patch` (no state sidecar: {e})",
edit_dir.display()
)
})?;
let v: serde_json::Value = serde_json::from_str(&raw)
.into_diagnostic()
.map_err(|e| miette!("corrupt patch state at {}: {e}", state_path.display()))?;
let name = v
.get("name")
.and_then(|x| x.as_str())
.ok_or_else(|| miette!("patch state missing `name`"))?
.to_string();
let version = v
.get("version")
.and_then(|x| x.as_str())
.ok_or_else(|| miette!("patch state missing `version`"))?
.to_string();
let project = v.get("project").and_then(|x| x.as_str()).map(PathBuf::from);
Ok(PatchState {
name,
version,
project,
source_dir: parent.join("source"),
user_dir: edit_dir.to_path_buf(),
})
}
#[derive(Debug)]
pub struct PatchState {
pub name: String,
pub version: String,
pub project: Option<PathBuf>,
pub source_dir: PathBuf,
pub user_dir: PathBuf,
}