dev_prune/setup.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Idempotent installation of dev-prune's integrations.
5//
6// dev-prune is only really installed once the parts that let it work without being
7// thought about are in place: the `devp` alias, the managed pair on the user's PATH,
8// the exported `SKILL.md` that AI assistants read (installed into the agent's own
9// skills directory where one exists), the Git hooks that keep the registry current,
10// and the OS scheduler that runs the passes. Each one here is created **only when it
11// is missing**, which is
12// what makes it safe to run on every install, reinstall and upgrade — and it does run
13// on each of those, through the version stamp written at the end of a completed pass.
14//
15// Nothing in here is fatal. A machine without `git`, a `core.hooksPath` that belongs to
16// husky, a locked-down scheduler: each is reported and stepped over, because none of
17// them should stop `devp init` from registering repositories.
18
19use std::fs;
20use std::path::PathBuf;
21
22use anyhow::Result;
23
24use crate::commands::hook::{self, HookState};
25use crate::commands::skill::EMBEDDED_SKILL_MD;
26use crate::config::Registry;
27use crate::constants;
28use crate::daemon;
29use crate::output;
30
31/// File in the config directory recording the version whose last integration pass
32/// completed. A missing or older stamp is what triggers the automatic pass, so a fresh
33/// install and an upgrade both self-heal exactly once.
34const STAMP_FILE: &str = "setup-stamp";
35
36pub use crate::constants::ENV_NO_AUTO_SETUP;
37
38/// Whether the suppression variable is set — by presence, so `=1`, `=true` and even an
39/// empty value all count.
40///
41/// The one predicate every consumer must share. The doctor note used to answer only for
42/// the literal `=1`, so a machine with `=true` had setup switched off with nothing
43/// anywhere saying so.
44pub fn no_auto_setup_requested() -> bool {
45 std::env::var_os(ENV_NO_AUTO_SETUP).is_some()
46}
47
48/// Whether every network call is switched off for this process — same by-presence rule
49/// as [`no_auto_setup_requested`], and for the same reason.
50pub fn offline_requested() -> bool {
51 std::env::var_os(crate::constants::ENV_OFFLINE).is_some()
52}
53
54/// What one integration did during a pass.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Outcome {
57 /// It was missing and is now in place.
58 Installed,
59 /// It was already in place and was left alone.
60 AlreadyPresent,
61 /// It could not be installed for a reason that is the user's call, not an error.
62 Skipped(String),
63 /// It failed. The pass continues; the reason is reported.
64 Failed(String),
65}
66
67/// The result of one integration pass.
68#[derive(Debug, Default)]
69pub struct SetupReport {
70 items: Vec<(&'static str, Outcome)>,
71}
72
73impl SetupReport {
74 fn push(&mut self, name: &'static str, outcome: Outcome) {
75 self.items.push((name, outcome));
76 }
77
78 /// Whether anything at all was created by this pass.
79 pub fn changed_anything(&self) -> bool {
80 self.items
81 .iter()
82 .any(|(_, o)| matches!(o, Outcome::Installed))
83 }
84
85 /// Whether anything needs the user's attention.
86 pub fn needs_attention(&self) -> bool {
87 self.items
88 .iter()
89 .any(|(_, o)| matches!(o, Outcome::Skipped(_) | Outcome::Failed(_)))
90 }
91
92 /// Print the report.
93 ///
94 /// `verbose` is for the explicit `devp setup`, where "already installed" is the
95 /// answer the user asked for. The automatic pass passes `false` and stays silent
96 /// about everything that was already fine.
97 pub fn print(&self, verbose: bool) {
98 for (name, outcome) in &self.items {
99 match outcome {
100 Outcome::Installed => output::print_success(&format!("{name}: installed.")),
101 Outcome::AlreadyPresent if verbose => {
102 output::print_info(&format!("{name}: already installed."));
103 }
104 Outcome::AlreadyPresent => {}
105 Outcome::Skipped(why) => {
106 output::print_warning(&format!("{name}: skipped — {why}"));
107 }
108 Outcome::Failed(why) => {
109 output::print_error(&format!("{name}: failed — {why}"));
110 }
111 }
112 }
113 }
114}
115
116/// Where the installers put the binary, and the one directory nothing else owns.
117///
118/// Public because it is also what the PATH step registers and what `uninstall` must
119/// take back out again.
120pub fn managed_bin_dir() -> Result<PathBuf> {
121 Ok(Registry::config_dir()?.join("bin"))
122}
123
124pub(crate) fn managed_exe_path() -> Result<PathBuf> {
125 let name = if cfg!(windows) {
126 "dev-prune.exe"
127 } else {
128 "dev-prune"
129 };
130 Ok(managed_bin_dir()?.join(name))
131}
132
133/// Absolute path to a copy of this binary that will still be there next week.
134///
135/// Anything that writes a path down for later — the OS scheduler, the git hooks — has to
136/// use this instead of [`std::env::current_exe`]. dev-prune ships through npm and PyPI as
137/// well as the installers, so the running executable is often somewhere a package manager
138/// owns and will delete: npm's `_npx` cache, uv's ephemeral tool environment, or
139/// `target/debug` during development. An entry recorded there breaks the moment that
140/// directory goes, and neither of these has anywhere to complain — the scheduled task
141/// fails silently every interval, and the hook discards its own output by design. The
142/// only symptom is that nothing ever happens again.
143///
144/// `<config>/bin` is where `install.sh` and `install.ps1` put the binary and nothing else
145/// deletes, so prefer the copy there. When there is none, put one there: the binary that
146/// is running right now is precisely the one that is going to be missing later.
147pub fn stable_exe_path() -> PathBuf {
148 let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("dev-prune"));
149 let Ok(managed) = managed_exe_path() else {
150 return current;
151 };
152 if managed == current {
153 return managed;
154 }
155 if managed.is_file() {
156 refresh_managed_copy_if_stale(¤t, &managed);
157 return managed;
158 }
159
160 // Only ever clone something that is actually this CLI. `current_exe()` under `cargo
161 // test` is the test harness, and copying that into the config directory would be both
162 // wrong and slow.
163 if !is_this_cli(¤t) {
164 return current;
165 }
166
167 let Some(parent) = managed.parent() else {
168 return current;
169 };
170 if fs::create_dir_all(parent).is_err() {
171 return current;
172 }
173 // Hard link where the filesystem allows it — that also keeps the bytes alive when the
174 // package manager deletes the directory the original came from.
175 if fs::hard_link(¤t, &managed).is_ok() {
176 return managed;
177 }
178
179 // The same hazard `ensure_alias` documents, through a narrower window: the check at the
180 // top of this function saw no managed copy, but another process created one — as a hard
181 // link to `current` — before the link above ran. `fs::copy` opens its destination with
182 // O_TRUNC, and truncating a hard link empties the shared inode, so the copy would
183 // destroy the very binary it is copying.
184 if managed.is_file() {
185 return managed;
186 }
187
188 // Stage beside and rename into place. A copy straight onto the final name has a
189 // window where the file exists but is incomplete — and this path is what the
190 // scheduler and hooks get registered against, so a process killed mid-copy would
191 // leave a torn binary that every later pass happily points at.
192 let staging = managed.with_extension("new");
193 if fs::copy(¤t, &staging).is_ok() && fs::rename(&staging, &managed).is_ok() {
194 return managed;
195 }
196 let _ = fs::remove_file(&staging);
197 // The rename loses only to a concurrent invocation that installed its own copy,
198 // which serves exactly as well.
199 if managed.is_file() { managed } else { current }
200}
201
202/// Whether this path names one of the CLI's own binaries, by file stem.
203fn is_this_cli(path: &std::path::Path) -> bool {
204 path.file_stem()
205 .and_then(|s| s.to_str())
206 .is_some_and(|stem| stem == "dev-prune" || stem == "devp")
207}
208
209/// Replace the managed copy when it is an older release than the binary running now.
210///
211/// The scheduler and the hooks point at the managed copy precisely because it outlives
212/// package-manager caches — which also means an upgrade through cargo, npm or uv changes
213/// the running binary but not the one the integrations run, and the machine quietly
214/// keeps pruning with the previous version forever.
215///
216/// Staleness is decided by asking the copy its version, not by mtime or content: an
217/// *older* binary running out of a stale npx cache must not overwrite a newer managed
218/// copy, and content inequality cannot say which of the two is the upgrade. A copy that
219/// cannot state a version at all is replaced too — whatever it is, it is not a working
220/// build of this CLI.
221fn refresh_managed_copy_if_stale(current: &std::path::Path, managed: &std::path::Path) {
222 if !is_this_cli(current) || same_contents(managed, current) {
223 return;
224 }
225 match (binary_version(managed), parse_version(constants::VERSION)) {
226 (Some(theirs), Some(ours)) if theirs >= ours => return,
227 _ => {}
228 }
229 // Write beside and rename into place, so a scheduler firing mid-copy never runs a
230 // torn binary. A managed copy that is itself running cannot be renamed over on
231 // Windows; the refresh simply waits for a pass when it is not.
232 let staging = managed.with_extension("new");
233 if fs::copy(current, &staging).is_ok() && fs::rename(&staging, managed).is_err() {
234 let _ = fs::remove_file(&staging);
235 }
236}
237
238/// The `major.minor.patch` a binary reports for itself, if it can.
239pub(crate) fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
240 let output = crate::spawn::command(exe).arg("--version").output().ok()?;
241 if !output.status.success() {
242 return None;
243 }
244 version_in_output(&String::from_utf8_lossy(&output.stdout))
245}
246
247/// The first `x.y.z` in a `--version` output, with or without the `v` this CLI prints.
248///
249/// Split out so it can be tested against real output. It has to accept `v1.7.0` because
250/// that is the only spelling `print_version_info` produces — the banner ends `v1.7.0`
251/// and the line under it reads `dev-prune (devp) v1.7.0`, and a bare `1.7.0` appears
252/// nowhere. Parsing the tokens without stripping that `v` answered `None` for every real
253/// dev-prune on the machine, and `doctor`'s "Other copies" check reads `None` as "not a
254/// dev-prune at all" — so it reported "none on PATH running a different version" however
255/// many stale copies were sitting there.
256fn version_in_output(text: &str) -> Option<(u64, u64, u64)> {
257 text.split_whitespace()
258 .find_map(|token| parse_version(token.strip_prefix('v').unwrap_or(token)))
259}
260
261/// Parse `x.y.z` into an orderable triple. Anything else — including the pre-release
262/// and build suffixes this project never publishes — answers `None`.
263pub(crate) fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
264 let mut parts = text.split('.');
265 let triple = (
266 parts.next()?.parse().ok()?,
267 parts.next()?.parse().ok()?,
268 parts.next()?.parse().ok()?,
269 );
270 parts.next().is_none().then_some(triple)
271}
272
273/// Keep `dev-prune` and `devp` beside each other, whichever of the two is running.
274///
275/// The pair is one binary under two names, and either one can be the survivor. An upgrade
276/// that could not replace a running `devp` leaves a stale alias; an antivirus quarantine,
277/// a half-finished uninstall or a `Remove-Item` aimed at the wrong name leaves only
278/// `devp`. So this restores *the other* name in whichever direction is missing, rather
279/// than only ever creating `devp` — running either one puts the pair back.
280pub fn ensure_alias() -> Outcome {
281 // WinGet, Scoop and Homebrew each install into a directory they version and replace
282 // whole on upgrade, and each ships both names in the package itself — so there is
283 // nothing to create here, and creating it would be actively wrong twice over. The
284 // twin would be orphaned by the next upgrade, still on PATH, still running the old
285 // release; and writing a second executable beside a freshly downloaded unsigned
286 // binary on its first run is a behavioural malware signature. WinGet's own
287 // post-install validation flags exactly that, which is how this was found.
288 if crate::channel::Channel::detect().replaces_its_directory() {
289 return Outcome::AlreadyPresent;
290 }
291 let Ok(current_exe) = std::env::current_exe() else {
292 return Outcome::Failed("could not locate the running executable".to_string());
293 };
294 let Some(parent_dir) = current_exe.parent() else {
295 return Outcome::Failed("the running executable has no parent directory".to_string());
296 };
297
298 ensure_twin_of(¤t_exe, parent_dir)
299}
300
301/// The half of [`ensure_alias`] that takes its paths as arguments, so tests can drive both
302/// directions without being the binary they are testing.
303fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
304 let running_as_alias = current_exe
305 .file_stem()
306 .and_then(|s| s.to_str())
307 .is_some_and(|stem| stem == "devp");
308
309 // `dev-prune` is the canonical name, and only it may overwrite its twin.
310 //
311 // Installers write `dev-prune` first and upgrades replace it first, so it is never the
312 // older of the two — a stale `devp` is worth replacing, because otherwise it silently
313 // runs the previous version. The reverse is not safe: an upgrade that replaced
314 // `dev-prune` and then failed on a running `devp` leaves exactly the state where the
315 // alias is the *older* binary, and refreshing from there would quietly reinstall the
316 // version the user just upgraded away from. So `devp` may only create a `dev-prune`
317 // that is missing outright.
318 let (twin_name, may_refresh) = if running_as_alias {
319 (
320 if cfg!(windows) {
321 "dev-prune.exe"
322 } else {
323 "dev-prune"
324 },
325 false,
326 )
327 } else {
328 (if cfg!(windows) { "devp.exe" } else { "devp" }, true)
329 };
330 let twin_exe = parent_dir.join(twin_name);
331
332 if twin_exe.exists() {
333 if !may_refresh || same_contents(&twin_exe, current_exe) {
334 return Outcome::AlreadyPresent;
335 }
336 // Replacing a running executable fails on Windows; that is fine, the alias is
337 // simply refreshed by the next invocation that is not itself `devp`.
338 if fs::remove_file(&twin_exe).is_err() {
339 return Outcome::Skipped(format!(
340 "`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
341 from a terminal that is not running it"
342 ));
343 }
344 }
345
346 if fs::hard_link(current_exe, &twin_exe).is_ok() {
347 return Outcome::Installed;
348 }
349
350 // The copy is the fallback for filesystems without hard links — but it must never
351 // run when the alias already exists, because the reason `hard_link` usually fails is
352 // that another process created it a moment ago, as a hard link to this very
353 // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
354 // link truncates the shared inode: the copy would empty the running binary and then
355 // copy zero bytes from it.
356 //
357 // That is not hypothetical. It is what turned every macOS CI run red. The 28
358 // integration tests launch at once, one wins the link, the losers fall through to
359 // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
360 // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
361 // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
362 // 27 unrelated assertion failures.
363 if twin_exe.exists() {
364 return Outcome::AlreadyPresent;
365 }
366
367 // Stage beside and rename into place: copied straight onto the final name, the
368 // alias would exist-but-be-incomplete for the length of the copy, and a `devp`
369 // typed in that window executes a torn binary.
370 let staging = twin_exe.with_extension("new");
371 if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
372 return Outcome::Installed;
373 }
374 let _ = fs::remove_file(&staging);
375 if twin_exe.exists() {
376 // A concurrent invocation won the rename; its alias serves exactly as well.
377 return Outcome::AlreadyPresent;
378 }
379 Outcome::Failed(format!(
380 "could not create `{}`",
381 output::clean_path(&twin_exe)
382 ))
383}
384
385/// Sameness test for two executables, cheap in the common case.
386///
387/// A hard link makes size and mtime equal by construction, so the usual layout answers
388/// without reading either file. When only the mtime differs — the alias came from the
389/// copy fallback, which does not preserve timestamps — the bytes themselves decide,
390/// because calling that pair "different" made every single invocation delete and
391/// recreate an alias whose content never changed.
392fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
393 let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
394 return false;
395 };
396 if ma.len() != mb.len() {
397 return false;
398 }
399 if ma.modified().ok() == mb.modified().ok() {
400 return true;
401 }
402 match (fs::read(a), fs::read(b)) {
403 (Ok(ca), Ok(cb)) => ca == cb,
404 _ => false,
405 }
406}
407
408/// Path that `devp skill` and this module export `SKILL.md` to.
409pub fn skill_path() -> Result<PathBuf> {
410 Ok(Registry::config_dir()?.join("SKILL.md"))
411}
412
413/// Export the bundled `SKILL.md` so AI assistants have something to read.
414///
415/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
416/// the skill must not leave the previous version's instructions on disk.
417pub fn ensure_skill_file() -> Outcome {
418 match Registry::config_dir() {
419 Ok(dir) => ensure_skill_file_in(&dir),
420 Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
421 }
422}
423
424fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
425 let target = config_dir.join("SKILL.md");
426
427 if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
428 return Outcome::AlreadyPresent;
429 }
430
431 let _ = fs::create_dir_all(config_dir);
432 match fs::write(&target, EMBEDDED_SKILL_MD) {
433 Ok(()) => Outcome::Installed,
434 Err(e) => Outcome::Failed(format!(
435 "could not write {}: {e}",
436 output::clean_path(&target)
437 )),
438 }
439}
440
441/// The per-skill directories of AI coding agents that are installed under `home`.
442///
443/// Detection only — an agent's home directory is created by the agent, never by this
444/// pass. Today that is Claude Code, whose Agent Skills live at
445/// `~/.claude/skills/<name>/SKILL.md`. Assistants without an on-disk skill format get
446/// the onboarding prompt from `devp skill` instead.
447fn agent_skill_roots_under(home: &std::path::Path) -> Vec<PathBuf> {
448 let mut roots = Vec::new();
449 let claude = home.join(constants::CLAUDE_HOME_DIR);
450 if claude.is_dir() {
451 roots.push(
452 claude
453 .join(constants::AGENT_SKILLS_SUBDIR)
454 .join(constants::APP_NAME),
455 );
456 }
457 roots
458}
459
460/// The agent skill directories on this machine. Empty when no agent is installed.
461pub fn agent_skill_roots() -> Vec<PathBuf> {
462 dirs::home_dir()
463 .map(|home| agent_skill_roots_under(&home))
464 .unwrap_or_default()
465}
466
467/// Install the skill into every detected agent's skills directory.
468pub fn ensure_agent_skills() -> Outcome {
469 ensure_agent_skills_at(&agent_skill_roots())
470}
471
472fn ensure_agent_skills_at(roots: &[PathBuf]) -> Outcome {
473 if roots.is_empty() {
474 return Outcome::Skipped(
475 "no AI agent skills directory was found — `devp skill` prints import prompts instead"
476 .to_string(),
477 );
478 }
479 let mut installed = false;
480 for root in roots {
481 match ensure_skill_file_in(root) {
482 Outcome::Installed => installed = true,
483 Outcome::AlreadyPresent => {}
484 other => return other,
485 }
486 }
487 if installed {
488 Outcome::Installed
489 } else {
490 Outcome::AlreadyPresent
491 }
492}
493
494/// Make the managed pair reachable from a fresh shell.
495///
496/// This is the step that lets `pip install dev-prune` in a virtualenv survive the
497/// virtualenv: the binaries pip placed vanish with the environment, but the managed
498/// copy under `<config>/bin` does not, and after this step it is the one a new
499/// terminal finds. See [`crate::pathenv`] for what "reachable" means per platform.
500pub fn ensure_command_on_path() -> Outcome {
501 let managed = stable_exe_path();
502 let is_managed_copy =
503 managed_exe_path().is_ok_and(|expected| expected == managed) && managed.is_file();
504 if !is_managed_copy {
505 // No managed copy exists and none could be created — under `cargo test` the
506 // running executable is the harness, and cloning that would be wrong. There is
507 // nothing durable to put on PATH.
508 return Outcome::Skipped("no managed copy of the binary exists to put on PATH".to_string());
509 }
510 let Some(bin_dir) = managed.parent() else {
511 return Outcome::Failed("the managed binary has no parent directory".to_string());
512 };
513 // `devp` has to sit beside it, or the PATH entry only ever finds `dev-prune`.
514 if let Outcome::Failed(why) = ensure_twin_of(&managed, bin_dir) {
515 return Outcome::Failed(why);
516 }
517 crate::pathenv::ensure_reachable(bin_dir)
518}
519
520/// Write the icon assets and register `*.devprune.json` with the OS file manager.
521///
522/// Part of the automatic pass rather than a separate errand, because "the config file has
523/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
524/// under the config directory and the user's own XDG data directory, `devp uninstall`
525/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
526/// so there is nothing here that needs to be asked about first.
527///
528/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
529/// dropped into the user's data directory are not a background process and not a change
530/// in behaviour; `auto_setup` already covers "install nothing at all".
531fn ensure_icons() -> Outcome {
532 if crate::commands::icon::is_registered() {
533 return Outcome::AlreadyPresent;
534 }
535 match crate::commands::icon::sync_app_directory() {
536 Ok(()) => Outcome::Installed,
537 Err(e) => Outcome::Failed(format!("{e:#}")),
538 }
539}
540
541/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
542///
543/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
544/// reason to skip, because dev-prune can install in front and forward every hook back.
545pub fn ensure_hooks(chain: bool) -> Outcome {
546 if !hook::git_available() {
547 return Outcome::Skipped(format!(
548 "\n {}",
549 hook::GIT_MISSING_HELP.replace('\n', "\n ")
550 ));
551 }
552
553 match hook::state() {
554 // "Installed" is not the question — "installed and pointing at a binary that
555 // still exists" is. A hook backgrounds itself and discards its own output, so
556 // one left pointing at a deleted npm cache dies silently on every commit and
557 // nothing ever registers again; this pass is the only thing that ever looks.
558 // Two reasons to rewrite a working install: it names a binary that is gone, or
559 // it predates the passthrough shims and is silently shadowing every repository's
560 // own `.git/hooks`. Neither reports itself — a hook discards its own output by
561 // design — so the upgrade pass is the only thing that will ever notice.
562 Ok(HookState::Active) if hook_target_is_dead() || hook::shims_incomplete() => {
563 match hook::install() {
564 Ok(()) => Outcome::Installed,
565 Err(e) => Outcome::Failed(format!("{e:#}")),
566 }
567 }
568 Ok(HookState::Active) => Outcome::AlreadyPresent,
569 // Drift is repaired here rather than reported: the setup pass already runs on
570 // install, on update and on a schedule, and a chain the user opted into is a
571 // chain they want kept current.
572 Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
573 match hook::install_with(true) {
574 Ok(()) => Outcome::Installed,
575 Err(e) => Outcome::Failed(format!("{e:#}")),
576 }
577 }
578 Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
579 Ok(()) => Outcome::Installed,
580 Err(e) => Outcome::Failed(format!("{e:#}")),
581 },
582 Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
583 Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
584 Ok(()) => Outcome::Installed,
585 Err(e) => Outcome::Failed(format!("{e:#}")),
586 },
587 Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
588 "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n \
589 Git allows only one hooks directory, so dev-prune will not take the slot.\n \
590 `devp hook install --chain` installs in front of it instead — dev-prune registers \
591 the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
592 the original setting back (`devp config set auto_hooks_chain true` makes that \
593 the standing answer). Or skip it: `devp link .` does the same job by hand."
594 )),
595 Ok(HookState::Absent) => match hook::install() {
596 Ok(()) => Outcome::Installed,
597 Err(e) => Outcome::Failed(format!("{e:#}")),
598 },
599 Err(e) => Outcome::Failed(format!("{e:#}")),
600 }
601}
602
603/// Whether the installed hooks name a binary that no longer exists.
604fn hook_target_is_dead() -> bool {
605 hook::registered_exe_path().is_some_and(|exe| !exe.exists())
606}
607
608/// Install the OS scheduler if it is not already registered.
609pub fn ensure_daemon(interval_days: u64) -> Outcome {
610 match daemon::daemon_status() {
611 // A task whose binary has been deleted keeps reporting itself `Ready` and dies
612 // the instant it fires, every interval, with nowhere to complain. Re-register
613 // it against the stable path instead of counting the corpse as present.
614 Ok(daemon::DaemonStatus::Installed)
615 if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
616 {
617 match daemon::install_daemon(interval_days) {
618 Ok(()) => Outcome::Installed,
619 Err(e) => Outcome::Failed(format!("{e:#}")),
620 }
621 }
622 // A task registered by a version that only knew the interactive logon flashes a
623 // console window at whoever is logged in every time it fires — the single most
624 // trust-destroying thing a background tool can do. Re-register it hidden; a
625 // machine whose scheduler refuses the hidden logon remembers the refusal and is
626 // not asked again.
627 Ok(daemon::DaemonStatus::Installed) if daemon::wants_hidden_upgrade() => {
628 match daemon::install_daemon(interval_days) {
629 Ok(()) => Outcome::Installed,
630 Err(e) => Outcome::Failed(format!("{e:#}")),
631 }
632 }
633 // A settled, hidden task still needs its windowless twin kept current: the twin
634 // is a copy of the binary, so an upgrade that replaced the binary would otherwise
635 // leave the daemon firing the previous release. No-op on the other platforms, and
636 // when no twin is in use.
637 Ok(daemon::DaemonStatus::Installed) => {
638 daemon::refresh_hidden_twin();
639 Outcome::AlreadyPresent
640 }
641 Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
642 Ok(()) => Outcome::Installed,
643 Err(e) => Outcome::Failed(format!("{e:#}")),
644 },
645 // `Unknown` means the query itself could not be answered — the scheduler may
646 // well be there. Installing over it would fail on every command from now on, so
647 // this reports and steps over instead of guessing. The platform backends are
648 // written to keep this case narrow: anything they can answer definitely, they do.
649 Ok(daemon::DaemonStatus::Unknown(why)) => {
650 Outcome::Skipped(format!("scheduler state could not be read — {why}"))
651 }
652 Err(e) => Outcome::Failed(format!("{e:#}")),
653 }
654}
655
656/// Whether unattended installation is permitted at all.
657///
658/// Both switches exist because these integrations write outside dev-prune's own config
659/// directory — a scheduled task, a global git setting — and there are places that must
660/// never happen unasked: container images, CI, and this project's own test suite.
661pub fn auto_setup_enabled(registry: &Registry) -> bool {
662 !no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
663}
664
665/// The reason this looks like a machine nobody is sitting at, if it does.
666///
667/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
668/// the first run — which is exactly the run that installs things, so in a container or a
669/// CI job the damage is done by the time there is anywhere to set them. Detecting the
670/// environment is the only opt-out that works on the first run, which is the only run
671/// that matters here.
672///
673/// Deliberately conservative: every signal below is one that CI providers and container
674/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
675/// genuinely wants the integrations in CI can still ask in so many words with
676/// `devp setup`, which never consults this.
677pub fn unattended_environment() -> Option<&'static str> {
678 // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
679 // and most others. `CI=true` is the closest thing this space has to a standard.
680 for var in [
681 "CI",
682 "CONTINUOUS_INTEGRATION",
683 "BUILD_NUMBER",
684 "GITHUB_ACTIONS",
685 ] {
686 if let Some(value) = std::env::var_os(var) {
687 // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
688 // the word rather than the presence is what the user plainly meant.
689 let value = value.to_string_lossy();
690 if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
691 return Some("this looks like a CI runner");
692 }
693 }
694 }
695
696 // Docker writes this marker into every container it builds from a Dockerfile;
697 // Podman and other OCI runtimes write the `container` variable instead.
698 #[cfg(unix)]
699 if std::path::Path::new("/.dockerenv").exists() {
700 return Some("this looks like a container");
701 }
702 if std::env::var_os("container").is_some() {
703 return Some("this looks like a container");
704 }
705
706 None
707}
708
709/// Run an integration pass unless unattended installation is switched off.
710///
711/// Every caller that the user did not name explicitly goes through this. `devp setup`
712/// calls [`ensure_integrations`] directly: asking for it in so many words is consent.
713pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
714 auto_setup_enabled(registry).then(|| ensure_integrations(registry))
715}
716
717/// Run one integration pass, installing whatever is missing.
718///
719/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
720/// turning one off turns it off for every future pass as well as this one.
721pub fn ensure_integrations(registry: &Registry) -> SetupReport {
722 let mut report = SetupReport::default();
723
724 report.push("dev-prune/devp pair", ensure_alias());
725 report.push("Command on PATH", ensure_command_on_path());
726 report.push("SKILL.md", ensure_skill_file());
727 // Only reported when an agent is actually installed: a machine without one would
728 // otherwise see a "skipped" warning about software it never had, on every install.
729 if !agent_skill_roots().is_empty() {
730 report.push("AI agent skills", ensure_agent_skills());
731 }
732 report.push("File icons", ensure_icons());
733
734 if registry.settings.auto_hooks {
735 report.push(
736 "Git hooks",
737 ensure_hooks(registry.settings.auto_hooks_chain),
738 );
739 } else {
740 report.push(
741 "Git hooks",
742 Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
743 );
744 }
745
746 if registry.settings.auto_daemon {
747 report.push(
748 "Background scheduler",
749 ensure_daemon(registry.settings.check_interval_days),
750 );
751 } else {
752 report.push(
753 "Background scheduler",
754 Outcome::Skipped(
755 "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
756 ),
757 );
758 }
759
760 report
761}
762
763// ── VS Code extension ────────────────────────────────────────────────────────
764
765/// Marker recording that the extension question was asked (or found already answered by
766/// an existing install). One file, no content: the offer is made once ever, whatever
767/// the answer was — a declined install must not be re-litigated on every upgrade.
768const VSCODE_OFFER_STAMP: &str = "vscode-ext-offered";
769
770/// A VS Code-compatible editor found on PATH.
771struct EditorCli {
772 /// The command to invoke — on Windows the `.cmd` launcher, because the entry on
773 /// PATH is a batch file, not an `.exe`, and `Command::new("code")` would miss it.
774 cli: String,
775 /// The editor's name as a person knows it, for the prompt and per-editor results.
776 label: &'static str,
777}
778
779/// Every VS Code-compatible editor on PATH, in the order listed here.
780///
781/// All of these forks keep the upstream CLI protocol (`--version`, `--list-extensions`,
782/// `--install-extension`), so one code path drives them all. What differs is the
783/// registry each one resolves an extension ID against: VS Code uses the Microsoft
784/// Marketplace, VSCodium/Windsurf/Positron/Kiro use OpenVSX, Cursor runs its own
785/// mirror. An ID install can therefore fail on a fork whose registry does not carry
786/// the extension yet — which is why the installer falls back to the `.vsix` from the
787/// GitHub release, the artifact every registry copy is built from.
788fn detect_vscode_editors() -> Vec<EditorCli> {
789 const CANDIDATES: &[(&str, &str)] = &[
790 ("code", "VS Code"),
791 ("code-insiders", "VS Code Insiders"),
792 ("codium", "VSCodium"),
793 ("codium-insiders", "VSCodium Insiders"),
794 ("cursor", "Cursor"),
795 ("windsurf", "Windsurf"),
796 ("positron", "Positron"),
797 ("kiro", "Kiro"),
798 ];
799 CANDIDATES
800 .iter()
801 .filter_map(|(name, label)| {
802 let cli = if cfg!(windows) {
803 format!("{name}.cmd")
804 } else {
805 (*name).to_string()
806 };
807 let responds = crate::spawn::command(&cli)
808 .arg("--version")
809 .stdin(std::process::Stdio::null())
810 .stdout(std::process::Stdio::null())
811 .stderr(std::process::Stdio::null())
812 .status()
813 .map(|s| s.success())
814 .unwrap_or(false);
815 responds.then_some(EditorCli { cli, label })
816 })
817 .collect()
818}
819
820fn vscode_extension_installed(cli: &str) -> bool {
821 crate::spawn::command(cli)
822 .arg("--list-extensions")
823 .stdin(std::process::Stdio::null())
824 .output()
825 .map(|out| {
826 String::from_utf8_lossy(&out.stdout).lines().any(|line| {
827 line.trim()
828 .eq_ignore_ascii_case(constants::VSCODE_EXTENSION_ID)
829 })
830 })
831 .unwrap_or(false)
832}
833
834/// Download the `.vsix` attached to the latest GitHub release into the config directory.
835///
836/// The release asset is the source of truth for the extension — the Marketplace and
837/// OpenVSX listings are built from it — so when an editor's registry cannot resolve the
838/// ID (a fork whose registry does not carry the extension), installing the release file
839/// directly gets the same bits through a channel every fork supports. Editors update a
840/// `.vsix`-installed extension from their registry once a newer listed version appears,
841/// so this install self-heals into the normal update flow.
842fn download_release_vsix() -> Option<std::path::PathBuf> {
843 use std::time::Duration;
844
845 if offline_requested() {
846 return None;
847 }
848
849 let fetch = |url: &str| {
850 ureq::get(url)
851 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
852 .header("Accept", "application/vnd.github+json")
853 .config()
854 .timeout_global(Some(Duration::from_secs(30)))
855 .build()
856 .call()
857 };
858
859 let body = fetch(constants::LATEST_RELEASE_API_URL)
860 .ok()?
861 .body_mut()
862 .read_to_string()
863 .ok()?;
864 let json: serde_json::Value = serde_json::from_str(&body).ok()?;
865 let asset = json.get("assets")?.as_array()?.iter().find_map(|asset| {
866 let name = asset.get("name")?.as_str()?;
867 if !name.ends_with(".vsix") {
868 return None;
869 }
870 let url = asset.get("browser_download_url")?.as_str()?;
871 Some((name.to_string(), url.to_string()))
872 })?;
873
874 let bytes = fetch(&asset.1).ok()?.body_mut().read_to_vec().ok()?;
875 // The config directory, not the shared system temp dir: on a multi-user machine
876 // `%TEMP%`-style paths are predictable and writable by others, and the editor is
877 // about to execute what this file contains. The caller deletes it after installing.
878 let dir = Registry::config_dir().ok()?;
879 fs::create_dir_all(&dir).ok()?;
880 let path = dir.join(&asset.0);
881 fs::write(&path, bytes).ok()?;
882 Some(path)
883}
884
885/// `<cli> --install-extension <arg>`, surfacing the editor's own output.
886fn run_install(cli: &str, arg: &str) -> bool {
887 crate::spawn::command(cli)
888 .args(["--install-extension", arg])
889 .stdin(std::process::Stdio::null())
890 .status()
891 .map(|s| s.success())
892 .unwrap_or(false)
893}
894
895/// Offer to install the editor extension, once ever, when a VS Code-family editor is
896/// present.
897///
898/// This asks rather than installs because the editor is not dev-prune's territory the
899/// way its own config directory is. Every gate below is a way of making sure a person
900/// is actually there to answer: no marker yet, not a CI runner or container, both ends
901/// of the terminal attached. When no editor is found nothing is written, so installing
902/// one later and re-running `devp setup` still gets the one offer.
903pub fn offer_vscode_extension() {
904 use std::io::{IsTerminal, Write};
905
906 let Ok(config_dir) = Registry::config_dir() else {
907 return;
908 };
909 if config_dir.join(VSCODE_OFFER_STAMP).exists() {
910 return;
911 }
912 if no_auto_setup_requested()
913 || unattended_environment().is_some()
914 || !std::io::stdin().is_terminal()
915 || !std::io::stdout().is_terminal()
916 {
917 return;
918 }
919 let editors = detect_vscode_editors();
920 if editors.is_empty() {
921 return;
922 }
923
924 let write_marker = || {
925 let _ = fs::create_dir_all(&config_dir);
926 let _ = fs::write(config_dir.join(VSCODE_OFFER_STAMP), "");
927 };
928
929 let missing: Vec<&EditorCli> = editors
930 .iter()
931 .filter(|e| !vscode_extension_installed(&e.cli))
932 .collect();
933 if missing.is_empty() {
934 write_marker();
935 return;
936 }
937
938 let names = missing
939 .iter()
940 .map(|e| e.label)
941 .collect::<Vec<_>>()
942 .join(", ");
943 println!();
944 println!("{names} detected — install the dev-prune extension?");
945 println!(" It validates .devprune.json and shows reclaimable space in the status bar.");
946 // The listings and the source, before the question rather than after it. This is
947 // the one prompt that defaults to yes, so the material someone would need in order
948 // to say no has to be on screen at the moment they answer — not in a doc they would
949 // have to go and look for.
950 println!(" Marketplace: {}", constants::VSCODE_MARKETPLACE_URL);
951 println!(" Open VSX: {}", constants::OPENVSX_URL);
952 println!(" Source: {}", constants::REPO_URL);
953 print!(" Install it? [Y/n] ");
954 let _ = std::io::stdout().flush();
955 let mut answer = String::new();
956 if std::io::stdin().read_line(&mut answer).is_err() {
957 return;
958 }
959 write_marker();
960
961 // A bare Enter accepts. Unlike the uninstall sweep — which deletes — the worst case
962 // here is an extension the person removes in two clicks, and the gates above have
963 // already established that a human with a VS Code-family editor is watching.
964 if !matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes") {
965 output::print_info(&format!(
966 "Skipped. Install it any time with `{} --install-extension {}`, or from {}.",
967 missing[0].cli,
968 constants::VSCODE_EXTENSION_ID,
969 constants::VSCODE_MARKETPLACE_URL
970 ));
971 return;
972 }
973
974 // Fetched at most once, shared by every editor whose registry install fails.
975 let mut release_vsix: Option<Option<std::path::PathBuf>> = None;
976 for editor in &missing {
977 // The editor's own registry first: that install is the one the editor keeps
978 // up to date by itself.
979 if run_install(&editor.cli, constants::VSCODE_EXTENSION_ID) {
980 output::print_success(&format!("{}: extension installed.", editor.label));
981 continue;
982 }
983 // A fork whose registry does not carry the extension — install the `.vsix`
984 // from the GitHub release instead.
985 let vsix = release_vsix.get_or_insert_with(download_release_vsix);
986 match vsix {
987 Some(path) if run_install(&editor.cli, &path.to_string_lossy()) => {
988 output::print_success(&format!(
989 "{}: extension installed from the GitHub release .vsix.",
990 editor.label
991 ));
992 }
993 _ => {
994 output::print_warning(&format!(
995 "{}: could not install it from here. Search the Extensions view for \"dev-prune\", or run `{} --install-extension {}` yourself.",
996 editor.label,
997 editor.cli,
998 constants::VSCODE_EXTENSION_ID
999 ));
1000 }
1001 }
1002 }
1003 if let Some(Some(path)) = &release_vsix {
1004 let _ = fs::remove_file(path);
1005 }
1006}
1007
1008/// Record that a pass completed for this version.
1009fn write_stamp_in(config_dir: &std::path::Path) {
1010 let _ = fs::create_dir_all(config_dir);
1011 let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
1012}
1013
1014fn write_stamp() {
1015 if let Ok(dir) = Registry::config_dir() {
1016 write_stamp_in(&dir);
1017 }
1018}
1019
1020fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
1021 !fs::read_to_string(config_dir.join(STAMP_FILE))
1022 .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
1023}
1024
1025/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
1026pub fn setup_is_due() -> bool {
1027 Registry::config_dir()
1028 .map(|dir| setup_is_due_in(&dir))
1029 .unwrap_or(false)
1030}
1031
1032/// Whether there is a human at this invocation who could see what was done and undo it.
1033///
1034/// The one question that gates everything dev-prune installs without being asked. CI
1035/// variables and containers answer it directly; a redirected stdin or stdout answers it
1036/// too, because output nobody reads is the same as no output — and an integration
1037/// installed silently is one nobody knows to remove.
1038fn a_person_is_present() -> bool {
1039 use std::io::IsTerminal;
1040 unattended_environment().is_none()
1041 && std::io::stdin().is_terminal()
1042 && std::io::stdout().is_terminal()
1043}
1044
1045/// The unattended pass, run at most once per installed version.
1046///
1047/// Called at the top of every command that a human typed. It is deliberately not called
1048/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
1049/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
1050pub fn auto_setup_if_due() {
1051 if !setup_is_due() {
1052 first_run_config_review();
1053 return;
1054 }
1055 // The same question `first_run_config_review` asks, asked one step earlier. It used
1056 // to be asked only about the *prompt*, never about the pass that installs a PATH
1057 // entry, a scheduled task and a git hook — so a binary run once by an automated
1058 // system, with its output captured, silently acquired persistence on that machine.
1059 // Nothing here is skipped permanently: the stamp is not written, so the first run a
1060 // person can actually see does the pass and reports it.
1061 if !a_person_is_present() {
1062 return;
1063 }
1064
1065 let Ok(registry) = Registry::load() else {
1066 return;
1067 };
1068 let Some(report) = ensure_integrations_if_enabled(®istry) else {
1069 // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
1070 // this on every single command.
1071 write_stamp();
1072 crate::commands::config::skip_config_review();
1073 return;
1074 };
1075 if report.changed_anything() || report.needs_attention() {
1076 output::print_header("dev-prune setup");
1077 report.print(false);
1078 if report.changed_anything() {
1079 output::print_info(
1080 "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
1081 );
1082 }
1083 println!();
1084 }
1085 write_stamp();
1086 first_run_config_review();
1087}
1088
1089/// Put the defaults in front of the user on a fresh install, and any setting an upgrade
1090/// added that they have never been shown.
1091///
1092/// Separate from the integration stamp on purpose. The integrations are re-checked after
1093/// every upgrade; the settings are not, except for the ones that did not exist last time
1094/// — being asked to reconfirm `idle_days` on each new version would be a nuisance, and a
1095/// nuisance is something people learn to dismiss without reading.
1096///
1097/// Every condition here is a way of asking "is there a person reading this?", because the
1098/// alternative to asking is a prompt written into a log nobody will read, on a run that
1099/// then blocks forever waiting for an answer.
1100fn first_run_config_review() {
1101 if !crate::commands::config::config_review_is_due() {
1102 return;
1103 }
1104
1105 if !a_person_is_present() {
1106 crate::commands::config::skip_config_review();
1107 return;
1108 }
1109
1110 // Any error here is the wizard's own reporting; the command the user actually typed
1111 // still runs. A failed walkthrough must not become a failed `devp status`.
1112 if let Err(e) = crate::commands::config::run_wizard(false) {
1113 output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1114 }
1115 // Marked regardless of how it ended, including a deliberate quit. This is the one
1116 // caller that runs uninvited, and an unasked-for walkthrough that reappears on every
1117 // subsequent command is worse than one somebody dismissed once on purpose.
1118 crate::commands::config::skip_config_review();
1119 // Same first run, same person already answering questions — the one moment the
1120 // extension offer is a courtesy rather than an interruption.
1121 offer_vscode_extension();
1122 println!();
1123}
1124
1125/// Invalidate the stamp so the next human-run command performs a pass.
1126///
1127/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
1128/// integrations is not immediately undone by the next command.
1129pub fn suppress_next_auto_setup() {
1130 write_stamp();
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136
1137 #[test]
1138 fn a_report_with_only_present_items_is_silent() {
1139 let mut report = SetupReport::default();
1140 report.push("a", Outcome::AlreadyPresent);
1141 assert!(!report.changed_anything());
1142 assert!(!report.needs_attention());
1143 }
1144
1145 #[test]
1146 fn skipped_and_failed_both_ask_for_attention() {
1147 let mut skipped = SetupReport::default();
1148 skipped.push("a", Outcome::Skipped("no git".into()));
1149 assert!(skipped.needs_attention());
1150 assert!(!skipped.changed_anything());
1151
1152 let mut failed = SetupReport::default();
1153 failed.push("a", Outcome::Failed("boom".into()));
1154 assert!(failed.needs_attention());
1155 }
1156
1157 #[test]
1158 fn an_install_counts_as_a_change() {
1159 let mut report = SetupReport::default();
1160 report.push("a", Outcome::Installed);
1161 assert!(report.changed_anything());
1162 }
1163
1164 #[test]
1165 fn the_skill_export_lands_in_the_config_directory() {
1166 let dir = tempfile::TempDir::new().unwrap();
1167 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1168 // A second pass finds byte-identical content and leaves it alone.
1169 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
1170 let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1171 assert_eq!(written, EMBEDDED_SKILL_MD);
1172 }
1173
1174 #[test]
1175 fn a_stale_skill_export_is_rewritten() {
1176 // An upgrade must not leave the previous version's instructions on disk.
1177 let dir = tempfile::TempDir::new().unwrap();
1178 fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
1179 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1180 let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1181 assert_eq!(written, EMBEDDED_SKILL_MD);
1182 }
1183
1184 #[test]
1185 fn agent_skills_install_only_into_agent_homes_that_exist() {
1186 let home = tempfile::TempDir::new().unwrap();
1187 assert!(
1188 agent_skill_roots_under(home.path()).is_empty(),
1189 "a machine without an agent must detect nothing"
1190 );
1191
1192 fs::create_dir_all(home.path().join(constants::CLAUDE_HOME_DIR)).unwrap();
1193 let roots = agent_skill_roots_under(home.path());
1194 assert_eq!(roots.len(), 1);
1195
1196 assert_eq!(ensure_agent_skills_at(&roots), Outcome::Installed);
1197 let installed = home
1198 .path()
1199 .join(constants::CLAUDE_HOME_DIR)
1200 .join(constants::AGENT_SKILLS_SUBDIR)
1201 .join(constants::APP_NAME)
1202 .join("SKILL.md");
1203 assert_eq!(fs::read_to_string(&installed).unwrap(), EMBEDDED_SKILL_MD);
1204
1205 // A second pass finds it current and leaves it alone.
1206 assert_eq!(ensure_agent_skills_at(&roots), Outcome::AlreadyPresent);
1207 }
1208
1209 #[test]
1210 fn no_detected_agent_is_a_skip_not_a_failure() {
1211 assert!(matches!(ensure_agent_skills_at(&[]), Outcome::Skipped(_)));
1212 }
1213
1214 #[test]
1215 fn the_stamp_gates_the_unattended_pass() {
1216 let dir = tempfile::TempDir::new().unwrap();
1217 assert!(setup_is_due_in(dir.path()), "a fresh install is due");
1218 write_stamp_in(dir.path());
1219 assert!(
1220 !setup_is_due_in(dir.path()),
1221 "the same version is not due twice"
1222 );
1223 fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
1224 assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
1225 }
1226
1227 /// The alias must never be written with a copy while it already exists.
1228 ///
1229 /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
1230 /// the binary it was copied from. This reproduces the exact shape of that bug — link
1231 /// first, then ask for the alias again — and asserts the original still has its
1232 /// bytes. The real failure was silent: a zero-byte executable that macOS runs
1233 /// through `/bin/sh`, which exits 0 and prints nothing.
1234 #[test]
1235 fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
1236 let dir = tempfile::TempDir::new().unwrap();
1237 let binary = dir.path().join("dev-prune");
1238 let alias = dir.path().join("devp");
1239 fs::write(&binary, vec![b'M'; 4096]).unwrap();
1240
1241 if fs::hard_link(&binary, &alias).is_err() {
1242 return; // Filesystem without hard links; the hazard cannot arise.
1243 }
1244
1245 // What `ensure_alias` does when its `hard_link` loses the race: the alias is
1246 // already there, so it must stop rather than fall through to the copy.
1247 assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
1248 assert!(alias.exists(), "the guard's condition");
1249
1250 assert_eq!(
1251 fs::metadata(&binary).unwrap().len(),
1252 4096,
1253 "the running binary was truncated by refreshing its own alias"
1254 );
1255 }
1256
1257 /// The on-disk file name for one of the pair, on this platform.
1258 fn exe_name(stem: &str) -> String {
1259 if cfg!(windows) {
1260 format!("{stem}.exe")
1261 } else {
1262 stem.to_string()
1263 }
1264 }
1265
1266 #[test]
1267 fn dev_prune_creates_devp_beside_it() {
1268 let dir = tempfile::TempDir::new().unwrap();
1269 let canonical = dir.path().join(exe_name("dev-prune"));
1270 fs::write(&canonical, "the binary").unwrap();
1271
1272 assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
1273 let alias = dir.path().join(exe_name("devp"));
1274 assert!(alias.is_file(), "`devp` was not created");
1275 assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
1276 }
1277
1278 /// The pair has to be recoverable from either side.
1279 ///
1280 /// Deleting `dev-prune` and leaving `devp` is not hypothetical: an antivirus
1281 /// quarantine, a half-finished uninstall, or a `Remove-Item` aimed at one name all
1282 /// produce it. Before this, `devp setup` reported the alias already present and did
1283 /// nothing, because the only direction it knew how to repair was the other one.
1284 #[test]
1285 fn devp_restores_a_missing_dev_prune() {
1286 let dir = tempfile::TempDir::new().unwrap();
1287 let alias = dir.path().join(exe_name("devp"));
1288 fs::write(&alias, "the binary").unwrap();
1289
1290 assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
1291 let canonical = dir.path().join(exe_name("dev-prune"));
1292 assert!(canonical.is_file(), "`dev-prune` was not put back");
1293 assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
1294 }
1295
1296 /// `devp` may create `dev-prune`, never overwrite it.
1297 ///
1298 /// Repairing in both directions opens a downgrade: an upgrade replaces `dev-prune`
1299 /// first and can then fail on a `devp` that is running, which leaves the alias holding
1300 /// the *older* binary. If the alias were allowed to refresh its twin from there, the
1301 /// next `devp setup` would quietly reinstall the version the user just upgraded away
1302 /// from — and report it as a repair.
1303 #[test]
1304 fn devp_does_not_overwrite_an_existing_dev_prune() {
1305 let dir = tempfile::TempDir::new().unwrap();
1306 let alias = dir.path().join(exe_name("devp"));
1307 let canonical = dir.path().join(exe_name("dev-prune"));
1308 fs::write(&alias, "the previous version").unwrap();
1309 fs::write(&canonical, "the version just upgraded to").unwrap();
1310
1311 assert_eq!(
1312 ensure_twin_of(&alias, dir.path()),
1313 Outcome::AlreadyPresent,
1314 "`devp` must leave an existing `dev-prune` alone"
1315 );
1316 assert_eq!(
1317 fs::read_to_string(&canonical).unwrap(),
1318 "the version just upgraded to",
1319 "`devp` downgraded the binary it was supposed to leave alone"
1320 );
1321 }
1322
1323 #[test]
1324 fn versions_parse_strictly_or_not_at_all() {
1325 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1326 assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
1327 // Anything this project does not publish must answer None, because a None
1328 // means "replace the copy" and a mis-parse would order versions wrongly.
1329 assert_eq!(parse_version("1.2"), None);
1330 assert_eq!(parse_version("1.2.3.4"), None);
1331 assert_eq!(parse_version("1.2.3-rc1"), None);
1332 assert_eq!(parse_version("dev-prune"), None);
1333 // The version this binary was built with has to be parseable, or the refresh
1334 // logic can never decide anything.
1335 assert!(parse_version(constants::VERSION).is_some());
1336 }
1337
1338 #[test]
1339 fn the_version_this_cli_prints_is_one_this_cli_can_read_back() {
1340 // Not synthetic: this is the shape `print_version_info` writes, `v` and all,
1341 // down to the banner line that ends in the same token.
1342 let real = format!(
1343 "|_____| v{v}
1344
1345dev-prune (devp) v{v}
1346 Compiler: Rust 1.88+ (edition 2024)
1347",
1348 v = constants::VERSION
1349 );
1350 assert_eq!(
1351 version_in_output(&real),
1352 parse_version(constants::VERSION),
1353 "binary_version could not read this binary's own --version output"
1354 );
1355 // Something that is not this CLI still has to answer None, because doctor uses
1356 // that to mean "leave this file alone".
1357 assert_eq!(version_in_output("git version 2.51.0.windows.1"), None);
1358 assert_eq!(version_in_output("some other tool"), None);
1359 }
1360
1361 #[test]
1362 fn ordering_of_version_triples_matches_semver() {
1363 assert!(parse_version("1.1.0") > parse_version("1.0.9"));
1364 assert!(parse_version("2.0.0") > parse_version("1.99.99"));
1365 assert!(parse_version("1.0.10") > parse_version("1.0.9"));
1366 }
1367
1368 #[test]
1369 fn the_exported_skill_is_the_one_the_binary_was_built_with() {
1370 // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
1371 // Guard the two properties every consumer of it depends on.
1372 assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
1373 assert!(
1374 !EMBEDDED_SKILL_MD.contains("file:///"),
1375 "SKILL.md is written to every user's machine — it must not contain \
1376 absolute paths from the author's checkout"
1377 );
1378 }
1379}