@noorslvt/shev 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2050 -0
- package/Cargo.toml +32 -0
- package/LICENSE +201 -0
- package/README.md +429 -2
- package/assets/background.rgb +0 -0
- package/assets/readme/app-decision.png +0 -0
- package/assets/readme/app-home.png +0 -0
- package/assets/readme/hero.jpg +0 -0
- package/bin/shev-cli.cjs +2 -0
- package/bin/shev.cjs +2 -0
- package/install.sh +122 -0
- package/package.json +9 -4
- package/scripts/install.cjs +28 -0
- package/scripts/launch.cjs +64 -0
- package/scripts/launch.test.cjs +56 -0
- package/src/bin/shev.rs +4534 -0
- package/src/installation.rs +390 -0
- package/src/integrations.rs +563 -0
- package/src/lib.rs +3678 -0
- package/src/main.rs +18 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
//! Installation ownership and explicit, scoped removal. No shared toolchains are removed.
|
|
2
|
+
use crate::{config_path, home_dir, managed_home, restrict_permissions};
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use sha2::{Digest, Sha256};
|
|
5
|
+
use std::fs;
|
|
6
|
+
use std::io::{self, IsTerminal, Read, Write};
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
use std::process::ExitCode;
|
|
9
|
+
|
|
10
|
+
#[derive(Default, Serialize, Deserialize)]
|
|
11
|
+
struct Manifest {
|
|
12
|
+
#[serde(default)]
|
|
13
|
+
installation_id: Option<String>,
|
|
14
|
+
#[serde(default)]
|
|
15
|
+
binaries: Vec<OwnedFile>,
|
|
16
|
+
#[serde(default)]
|
|
17
|
+
histories: Vec<PathBuf>,
|
|
18
|
+
#[serde(default)]
|
|
19
|
+
profiles: Vec<ProfileEdit>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(Serialize, Deserialize)]
|
|
23
|
+
struct OwnedFile {
|
|
24
|
+
path: PathBuf,
|
|
25
|
+
sha256: String,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[derive(Serialize, Deserialize)]
|
|
29
|
+
struct ProfileEdit {
|
|
30
|
+
path: PathBuf,
|
|
31
|
+
block: String,
|
|
32
|
+
#[serde(default)]
|
|
33
|
+
created: bool,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn manifest_path() -> PathBuf {
|
|
37
|
+
config_path().with_file_name("install.json")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fn load_manifest() -> Result<Manifest, String> {
|
|
41
|
+
let path = manifest_path();
|
|
42
|
+
if !path.exists() {
|
|
43
|
+
return Ok(Manifest::default());
|
|
44
|
+
}
|
|
45
|
+
serde_json::from_str(&fs::read_to_string(path).map_err(|e| e.to_string())?)
|
|
46
|
+
.map_err(|_| "shev install manifest is invalid; refusing automatic cleanup".into())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn save_manifest(manifest: &Manifest) -> Result<(), String> {
|
|
50
|
+
let path = manifest_path();
|
|
51
|
+
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
|
|
52
|
+
fs::write(
|
|
53
|
+
&path,
|
|
54
|
+
serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?,
|
|
55
|
+
)
|
|
56
|
+
.map_err(|e| e.to_string())?;
|
|
57
|
+
restrict_permissions(&path);
|
|
58
|
+
Ok(())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn checksum(path: &Path) -> Result<String, String> {
|
|
62
|
+
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
|
|
63
|
+
let mut digest = Sha256::new();
|
|
64
|
+
let mut buffer = [0u8; 65536];
|
|
65
|
+
loop {
|
|
66
|
+
let count = file.read(&mut buffer).map_err(|e| e.to_string())?;
|
|
67
|
+
if count == 0 {
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
digest.update(&buffer[..count]);
|
|
71
|
+
}
|
|
72
|
+
Ok(format!("{:x}", digest.finalize()))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pub fn remember_history(path: &Path) -> Result<(), String> {
|
|
76
|
+
let path = if path.is_absolute() {
|
|
77
|
+
path.to_path_buf()
|
|
78
|
+
} else {
|
|
79
|
+
std::env::current_dir()
|
|
80
|
+
.map_err(|e| e.to_string())?
|
|
81
|
+
.join(path)
|
|
82
|
+
};
|
|
83
|
+
let mut manifest = load_manifest()?;
|
|
84
|
+
if !manifest.histories.contains(&path) {
|
|
85
|
+
manifest.histories.push(path);
|
|
86
|
+
save_manifest(&manifest)?;
|
|
87
|
+
}
|
|
88
|
+
Ok(())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn register(directory: &Path) -> Result<(), String> {
|
|
92
|
+
let exe = fs::canonicalize(std::env::current_exe().map_err(|e| e.to_string())?)
|
|
93
|
+
.map_err(|e| e.to_string())?;
|
|
94
|
+
let directory = fs::canonicalize(directory).map_err(|e| e.to_string())?;
|
|
95
|
+
if exe.parent() != Some(directory.as_path())
|
|
96
|
+
|| directory.file_name().and_then(|s| s.to_str()) != Some("bin")
|
|
97
|
+
{
|
|
98
|
+
return Err("installation registration must name this executable's bin directory".into());
|
|
99
|
+
}
|
|
100
|
+
let mut manifest = load_manifest()?;
|
|
101
|
+
manifest.installation_id = std::env::var("SHEV_INSTALL_ID").ok();
|
|
102
|
+
manifest
|
|
103
|
+
.binaries
|
|
104
|
+
.retain(|entry| entry.path.parent() != Some(directory.as_path()));
|
|
105
|
+
for name in ["shev", "shev-cli"] {
|
|
106
|
+
let path = directory.join(name);
|
|
107
|
+
if path.is_file() {
|
|
108
|
+
manifest.binaries.push(OwnedFile {
|
|
109
|
+
sha256: checksum(&path)?,
|
|
110
|
+
path,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if let (Ok(path), Ok(block)) = (
|
|
115
|
+
std::env::var("SHEV_PATH_PROFILE"),
|
|
116
|
+
std::env::var("SHEV_PATH_BLOCK"),
|
|
117
|
+
) {
|
|
118
|
+
if !block.starts_with("# >>> shev PATH >>>\n") || !block.ends_with("# <<< shev PATH <<<\n")
|
|
119
|
+
{
|
|
120
|
+
return Err("invalid installer PATH block".into());
|
|
121
|
+
}
|
|
122
|
+
let path = PathBuf::from(path);
|
|
123
|
+
if !manifest
|
|
124
|
+
.profiles
|
|
125
|
+
.iter()
|
|
126
|
+
.any(|edit| edit.path == path && edit.block == block)
|
|
127
|
+
{
|
|
128
|
+
manifest.profiles.push(ProfileEdit {
|
|
129
|
+
path,
|
|
130
|
+
block,
|
|
131
|
+
created: std::env::var("SHEV_PATH_PROFILE_CREATED").as_deref() == Ok("1"),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
save_manifest(&manifest)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn owned_roots() -> Result<Vec<PathBuf>, String> {
|
|
139
|
+
if let Some(home) = managed_home() {
|
|
140
|
+
if home.file_name().and_then(|s| s.to_str()) != Some("state")
|
|
141
|
+
|| fs::read_to_string(home.join(".shev-owned")).ok().as_deref()
|
|
142
|
+
!= Some("shev managed state\n")
|
|
143
|
+
{
|
|
144
|
+
return Err(
|
|
145
|
+
"SHEV_HOME is not marked as shev-owned state; refusing recursive removal".into(),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return Ok(vec![home]);
|
|
149
|
+
}
|
|
150
|
+
let config = config_path().parent().unwrap().to_path_buf();
|
|
151
|
+
let data = home_dir().join(".local/share/shev");
|
|
152
|
+
let cache = std::env::var_os("XDG_CACHE_HOME")
|
|
153
|
+
.map(PathBuf::from)
|
|
154
|
+
.unwrap_or_else(|| home_dir().join(".cache"))
|
|
155
|
+
.join("shev");
|
|
156
|
+
Ok(vec![config, data, cache])
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fn remove_owned_root(path: &Path) -> Result<(), String> {
|
|
160
|
+
if path == home_dir() || path.parent().is_none() || path.components().count() < 3 {
|
|
161
|
+
return Err("refusing to remove an unsafe installation root".into());
|
|
162
|
+
}
|
|
163
|
+
match fs::symlink_metadata(path) {
|
|
164
|
+
Ok(meta) if meta.file_type().is_symlink() || meta.is_file() => fs::remove_file(path),
|
|
165
|
+
Ok(_) => fs::remove_dir_all(path),
|
|
166
|
+
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
|
|
167
|
+
Err(e) => return Err(e.to_string()),
|
|
168
|
+
}
|
|
169
|
+
.map_err(|e| format!("could not remove {}: {e}", path.display()))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn remove_cargo_entry(bin_dir: &Path) -> Result<(), String> {
|
|
173
|
+
let Some(root) = bin_dir.parent() else {
|
|
174
|
+
return Ok(());
|
|
175
|
+
};
|
|
176
|
+
let toml_file = root.join(".crates.toml");
|
|
177
|
+
if let Ok(text) = fs::read_to_string(&toml_file)
|
|
178
|
+
&& let Ok(mut value) = text.parse::<toml::Value>()
|
|
179
|
+
&& let Some(installs) = value.get_mut("v1").and_then(toml::Value::as_table_mut)
|
|
180
|
+
{
|
|
181
|
+
installs.retain(|name, _| !name.starts_with("shev "));
|
|
182
|
+
fs::write(
|
|
183
|
+
toml_file,
|
|
184
|
+
toml::to_string(&value).map_err(|e| e.to_string())?,
|
|
185
|
+
)
|
|
186
|
+
.map_err(|e| e.to_string())?;
|
|
187
|
+
}
|
|
188
|
+
let json_file = root.join(".crates2.json");
|
|
189
|
+
if let Ok(text) = fs::read_to_string(&json_file)
|
|
190
|
+
&& let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&text)
|
|
191
|
+
&& let Some(installs) = value
|
|
192
|
+
.get_mut("installs")
|
|
193
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
194
|
+
{
|
|
195
|
+
installs.retain(|name, _| !name.starts_with("shev "));
|
|
196
|
+
fs::write(
|
|
197
|
+
json_file,
|
|
198
|
+
serde_json::to_string(&value).map_err(|e| e.to_string())?,
|
|
199
|
+
)
|
|
200
|
+
.map_err(|e| e.to_string())?;
|
|
201
|
+
}
|
|
202
|
+
Ok(())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn uninstall(yes: bool, dry_run: bool) -> Result<ExitCode, String> {
|
|
206
|
+
let mut manifest = load_manifest()?;
|
|
207
|
+
let roots = owned_roots()?;
|
|
208
|
+
// A directly cargo-installed binary may predate the ownership manifest.
|
|
209
|
+
if manifest.binaries.is_empty()
|
|
210
|
+
&& let Ok(exe) = std::env::current_exe()
|
|
211
|
+
&& exe
|
|
212
|
+
.parent()
|
|
213
|
+
.and_then(Path::file_name)
|
|
214
|
+
.and_then(|s| s.to_str())
|
|
215
|
+
== Some("bin")
|
|
216
|
+
&& matches!(
|
|
217
|
+
exe.file_name().and_then(|s| s.to_str()),
|
|
218
|
+
Some("shev" | "shev-cli")
|
|
219
|
+
)
|
|
220
|
+
{
|
|
221
|
+
manifest.binaries.push(OwnedFile {
|
|
222
|
+
sha256: checksum(&exe)?,
|
|
223
|
+
path: exe,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
println!(
|
|
227
|
+
"Remove shev, saved keys, models, recorded chat data, and unchanged shev MCP registrations:"
|
|
228
|
+
);
|
|
229
|
+
for path in &roots {
|
|
230
|
+
println!(" {}", path.display());
|
|
231
|
+
}
|
|
232
|
+
for binary in &manifest.binaries {
|
|
233
|
+
println!(" {} (only if unchanged)", binary.path.display());
|
|
234
|
+
}
|
|
235
|
+
for path in &manifest.histories {
|
|
236
|
+
println!(" {} (shev history entries only)", path.display());
|
|
237
|
+
}
|
|
238
|
+
for profile in &manifest.profiles {
|
|
239
|
+
println!(" shev PATH block in {}", profile.path.display());
|
|
240
|
+
}
|
|
241
|
+
if managed_home().is_none() && crate::legacy_config_path().exists() {
|
|
242
|
+
println!(
|
|
243
|
+
" {} (legacy shev key)",
|
|
244
|
+
crate::legacy_config_path().display()
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
let models = crate::models_dir();
|
|
248
|
+
if !roots.iter().any(|root| models.starts_with(root)) {
|
|
249
|
+
println!(
|
|
250
|
+
" marked shev model and unchanged runners under {} (unmarked/shared weights are kept)",
|
|
251
|
+
models.display()
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
println!("Shared runtimes, Codex/OpenCode, unrelated config, and source checkouts are kept.");
|
|
255
|
+
if dry_run {
|
|
256
|
+
return Ok(ExitCode::SUCCESS);
|
|
257
|
+
}
|
|
258
|
+
if !yes {
|
|
259
|
+
if !io::stdin().is_terminal() {
|
|
260
|
+
return Err("uninstall needs confirmation; use --yes for non-interactive removal or --dry-run to preview".into());
|
|
261
|
+
}
|
|
262
|
+
print!("Uninstall and delete this data? [y/N] ");
|
|
263
|
+
io::stdout().flush().map_err(|e| e.to_string())?;
|
|
264
|
+
let mut reply = String::new();
|
|
265
|
+
io::stdin()
|
|
266
|
+
.read_line(&mut reply)
|
|
267
|
+
.map_err(|e| e.to_string())?;
|
|
268
|
+
if !matches!(reply.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
|
|
269
|
+
println!("Cancelled.");
|
|
270
|
+
return Ok(ExitCode::from(2));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Preserve the manifest if cleanup fails, so a retry can finish safely.
|
|
274
|
+
println!("{}", crate::mcp_uninstall()?);
|
|
275
|
+
for path in &manifest.histories {
|
|
276
|
+
crate::clear_records(path)?;
|
|
277
|
+
if fs::read_to_string(path)
|
|
278
|
+
.ok()
|
|
279
|
+
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
|
280
|
+
== Some(serde_json::json!({}))
|
|
281
|
+
&& !fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
|
|
282
|
+
{
|
|
283
|
+
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for profile in &manifest.profiles {
|
|
287
|
+
if let Ok(text) = fs::read_to_string(&profile.path) {
|
|
288
|
+
// Never delete a modified profile or infer ownership from a marker alone.
|
|
289
|
+
if text.matches(&profile.block).count() == 1 {
|
|
290
|
+
let restored = text.replacen(&profile.block, "", 1);
|
|
291
|
+
if profile.created && restored.trim().is_empty() {
|
|
292
|
+
fs::remove_file(&profile.path).map_err(|e| e.to_string())?;
|
|
293
|
+
} else {
|
|
294
|
+
fs::write(&profile.path, restored).map_err(|e| e.to_string())?;
|
|
295
|
+
}
|
|
296
|
+
} else if text.contains("# >>> shev PATH >>>") {
|
|
297
|
+
println!("Kept modified PATH block in {}", profile.path.display());
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let mut bin_dirs = Vec::new();
|
|
302
|
+
for binary in &manifest.binaries {
|
|
303
|
+
if !binary.path.exists() {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if checksum(&binary.path)? != binary.sha256 {
|
|
307
|
+
println!("Kept modified binary: {}", binary.path.display());
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if let Some(parent) = binary.path.parent() {
|
|
311
|
+
bin_dirs.push(parent.to_path_buf());
|
|
312
|
+
}
|
|
313
|
+
fs::remove_file(&binary.path).map_err(|e| e.to_string())?;
|
|
314
|
+
}
|
|
315
|
+
bin_dirs.sort();
|
|
316
|
+
bin_dirs.dedup();
|
|
317
|
+
for directory in bin_dirs {
|
|
318
|
+
remove_cargo_entry(&directory)?;
|
|
319
|
+
}
|
|
320
|
+
// Custom model roots can be shared. Never infer ownership from a model name.
|
|
321
|
+
if !roots.iter().any(|root| models.starts_with(root)) {
|
|
322
|
+
let pack = models.join(crate::BONSAI_DIR_NAME);
|
|
323
|
+
if fs::read_to_string(pack.join(".shev-owned")).ok().as_deref() == Some("shev model\n") {
|
|
324
|
+
remove_owned_root(&pack)?;
|
|
325
|
+
} else if pack.exists() {
|
|
326
|
+
println!("Kept externally managed model: {}", pack.display());
|
|
327
|
+
}
|
|
328
|
+
for (name, source) in [
|
|
329
|
+
("shev_bonsai_runner.py", crate::BONSAI_RUNNER),
|
|
330
|
+
("shev_bonsai_worker.py", crate::BONSAI_WORKER),
|
|
331
|
+
] {
|
|
332
|
+
let path = models.join(name);
|
|
333
|
+
if fs::read_to_string(&path).ok().as_deref() == Some(source) {
|
|
334
|
+
fs::remove_file(path).map_err(|e| e.to_string())?;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if managed_home().is_none() {
|
|
339
|
+
let legacy = crate::legacy_config_path();
|
|
340
|
+
if legacy.exists() {
|
|
341
|
+
fs::remove_file(legacy).map_err(|e| e.to_string())?;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for path in roots.into_iter().rev() {
|
|
345
|
+
remove_owned_root(&path)?;
|
|
346
|
+
}
|
|
347
|
+
if let Some(parent) = managed_home().and_then(|path| path.parent().map(Path::to_path_buf))
|
|
348
|
+
&& parent.file_name().and_then(|name| name.to_str()) == Some("shev-npm")
|
|
349
|
+
{
|
|
350
|
+
let _ = fs::remove_dir(parent); // Only succeeds when the app's container is empty.
|
|
351
|
+
}
|
|
352
|
+
println!("shev uninstalled.");
|
|
353
|
+
Ok(ExitCode::SUCCESS)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// Runs before terminal setup in both binaries. Nothing is deleted without consent.
|
|
357
|
+
pub fn maintenance() -> Option<ExitCode> {
|
|
358
|
+
let args: Vec<_> = std::env::args().skip(1).collect();
|
|
359
|
+
let first = args.first()?.as_str();
|
|
360
|
+
let result = match first {
|
|
361
|
+
"--version" | "-V" => {
|
|
362
|
+
println!("shev {}", env!("CARGO_PKG_VERSION"));
|
|
363
|
+
Ok(ExitCode::SUCCESS)
|
|
364
|
+
}
|
|
365
|
+
"--register-install" if args.len() == 2 => {
|
|
366
|
+
register(Path::new(&args[1])).map(|()| ExitCode::SUCCESS)
|
|
367
|
+
}
|
|
368
|
+
"--uninstall" => {
|
|
369
|
+
if args[1..]
|
|
370
|
+
.iter()
|
|
371
|
+
.any(|arg| !matches!(arg.as_str(), "--yes" | "--dry-run"))
|
|
372
|
+
{
|
|
373
|
+
Err("usage: shev --uninstall [--dry-run] [--yes]".into())
|
|
374
|
+
} else {
|
|
375
|
+
uninstall(
|
|
376
|
+
args.iter().any(|a| a == "--yes"),
|
|
377
|
+
args.iter().any(|a| a == "--dry-run"),
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
_ => return None,
|
|
382
|
+
};
|
|
383
|
+
Some(match result {
|
|
384
|
+
Ok(code) => code,
|
|
385
|
+
Err(error) => {
|
|
386
|
+
eprintln!("error: {error}");
|
|
387
|
+
ExitCode::FAILURE
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
}
|