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) {
223 return;
224 }
225 // Before the sameness check below, not after it: a managed console copy that is
226 // already current says nothing about the twin, and a machine upgraded by a release
227 // that did not refresh the twin is exactly that state.
228 #[cfg(windows)]
229 refresh_managed_twin_if_stale(current, managed);
230
231 if same_contents(managed, current) {
232 return;
233 }
234 match (binary_version(managed), parse_version(constants::VERSION)) {
235 (Some(theirs), Some(ours)) if theirs >= ours => return,
236 _ => {}
237 }
238 // Write beside and rename into place, so a scheduler firing mid-copy never runs a
239 // torn binary. A managed copy that is itself running cannot be renamed over on
240 // Windows; the refresh simply waits for a pass when it is not.
241 let staging = managed.with_extension("new");
242 if fs::copy(current, &staging).is_ok() && fs::rename(&staging, managed).is_ok() {
243 return;
244 }
245 // Unconditionally, because `fs::copy` creates and truncates its destination before it
246 // writes a byte: a copy that fails partway — disk full, the volume going away, a
247 // permission revoked mid-write — leaves a half-written `dev-prune.new` behind. Nothing
248 // else ever removes it. `uninstall`'s sweep knows `*.exe.old`, the debris an update
249 // leaves, and has never known `*.new`, so the file would sit there until someone
250 // noticed it by hand.
251 let _ = fs::remove_file(&staging);
252}
253
254/// Move the managed `devpw.exe` forward from the one shipped beside the running binary.
255///
256/// The scheduled task runs the managed twin, not the managed console copy, so refreshing
257/// only the console copy left the daemon on whatever release first placed the twin, while
258/// every command typed at the prompt was current. The daemon's own refresh only runs from the
259/// once-per-version setup pass, and only with a settled task, so this closes the gap at
260/// the moment the console copy is checked.
261///
262/// Deciding staleness by asking the twin `--version` is not an option: `devpw.exe` is
263/// linked for the GUI subsystem and the call waits on it forever. The build stamp is read
264/// off the disk instead, by the same placement code the daemon uses, which also refuses
265/// to replace a newer twin with an older shipped one. Only an existing twin is refreshed;
266/// placing the first one is the scheduler install's decision.
267#[cfg(windows)]
268fn refresh_managed_twin_if_stale(current: &std::path::Path, managed: &std::path::Path) {
269 let shipped = current.with_file_name(constants::WINDOWS_WINDOWLESS_BIN);
270 let twin = managed.with_file_name(constants::WINDOWS_WINDOWLESS_BIN);
271 if shipped.is_file() && twin.is_file() {
272 let _ = daemon::place_windowless_twin(&shipped, &twin);
273 }
274}
275
276/// The `major.minor.patch` a binary reports for itself, if it can.
277pub(crate) fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
278 let output = crate::spawn::command(exe).arg("--version").output().ok()?;
279 if !output.status.success() {
280 return None;
281 }
282 version_in_output(&String::from_utf8_lossy(&output.stdout))
283}
284
285/// The first `x.y.z` in a `--version` output, with or without the `v` this CLI prints.
286///
287/// Split out so it can be tested against real output. It has to accept `v1.7.0` because
288/// that is the only spelling `print_version_info` produces — the banner ends `v1.7.0`
289/// and the line under it reads `dev-prune (devp) v1.7.0`, and a bare `1.7.0` appears
290/// nowhere. Parsing the tokens without stripping that `v` answered `None` for every real
291/// dev-prune on the machine, and `doctor`'s "Other copies" check reads `None` as "not a
292/// dev-prune at all" — so it reported "none on PATH running a different version" however
293/// many stale copies were sitting there.
294fn version_in_output(text: &str) -> Option<(u64, u64, u64)> {
295 text.split_whitespace()
296 .find_map(|token| parse_version(token.strip_prefix('v').unwrap_or(token)))
297}
298
299/// Parse `x.y.z` into an orderable triple. Anything else — including the pre-release
300/// and build suffixes this project never publishes — answers `None`.
301pub(crate) fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
302 let mut parts = text.split('.');
303 let triple = (
304 parts.next()?.parse().ok()?,
305 parts.next()?.parse().ok()?,
306 parts.next()?.parse().ok()?,
307 );
308 parts.next().is_none().then_some(triple)
309}
310
311/// Keep `dev-prune` and `devp` beside each other, whichever of the two is running.
312///
313/// The pair is one binary under two names, and either one can be the survivor. An upgrade
314/// that could not replace a running `devp` leaves a stale alias; an antivirus quarantine,
315/// a half-finished uninstall or a `Remove-Item` aimed at the wrong name leaves only
316/// `devp`. So this restores *the other* name in whichever direction is missing, rather
317/// than only ever creating `devp` — running either one puts the pair back.
318pub fn ensure_alias() -> Outcome {
319 // WinGet, Scoop and Homebrew each install into a directory they version and replace
320 // whole on upgrade, and each ships both names in the package itself — so there is
321 // nothing to create here, and creating it would be actively wrong twice over. The
322 // twin would be orphaned by the next upgrade, still on PATH, still running the old
323 // release; and writing a second executable beside a freshly downloaded unsigned
324 // binary on its first run is a behavioural malware signature. WinGet's own
325 // post-install validation flags exactly that, which is how this was found.
326 if crate::channel::Channel::detect().replaces_its_directory() {
327 return Outcome::AlreadyPresent;
328 }
329 let Ok(current_exe) = std::env::current_exe() else {
330 return Outcome::Failed("could not locate the running executable".to_string());
331 };
332 let Some(parent_dir) = current_exe.parent() else {
333 return Outcome::Failed("the running executable has no parent directory".to_string());
334 };
335
336 ensure_twin_of(¤t_exe, parent_dir)
337}
338
339/// Whether a twin whose content differs from the running binary is the older of the two,
340/// and so safe to replace.
341///
342/// Content inequality says the pair differ, never which one is the upgrade. `dev-prune` is
343/// *usually* the newer — installers write it first and upgrades replace it first — but
344/// "usually" is not "always", and the direction gate in [`ensure_twin_of`] trusted it
345/// absolutely. An older `dev-prune` restored from a backup, or run out of a
346/// package-manager cache, would delete a newer `devp` and hard-link its own older content
347/// over it: a silent downgrade of the name the documentation tells people to type, reached
348/// through `devp doctor --fix` of all things. So ask the twin its version, exactly as
349/// [`refresh_managed_copy_if_stale`] does, and leave it alone unless it is genuinely
350/// behind. A copy that cannot state a version at all is not a working build of this CLI,
351/// so it is still replaced.
352///
353/// Takes the two versions rather than the two paths so the rule can be tested without a
354/// pair of real binaries that report different versions.
355fn twin_is_stale(theirs: Option<(u64, u64, u64)>, ours: Option<(u64, u64, u64)>) -> bool {
356 !matches!((theirs, ours), (Some(theirs), Some(ours)) if theirs >= ours)
357}
358
359/// The half of [`ensure_alias`] that takes its paths as arguments, so tests can drive both
360/// directions without being the binary they are testing.
361fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
362 let running_as_alias = current_exe
363 .file_stem()
364 .and_then(|s| s.to_str())
365 .is_some_and(|stem| stem == "devp");
366
367 // `dev-prune` is the canonical name, and only it may overwrite its twin.
368 //
369 // Installers write `dev-prune` first and upgrades replace it first, so a stale `devp`
370 // is worth replacing — otherwise it silently runs the previous version. The reverse is
371 // not safe as a rule: an upgrade that replaced `dev-prune` and then failed on a running
372 // `devp` leaves exactly the state where the alias is the *older* binary, and refreshing
373 // from there would quietly reinstall the version the user just upgraded away from. So
374 // `devp` may only create a `dev-prune` that is missing outright.
375 //
376 // Direction is necessary but not sufficient: see [`twin_is_stale`] for the case where
377 // `dev-prune` itself is the older of the pair.
378 let (twin_name, may_refresh) = if running_as_alias {
379 (
380 if cfg!(windows) {
381 "dev-prune.exe"
382 } else {
383 "dev-prune"
384 },
385 false,
386 )
387 } else {
388 (if cfg!(windows) { "devp.exe" } else { "devp" }, true)
389 };
390 let twin_exe = parent_dir.join(twin_name);
391
392 if twin_exe.exists() {
393 if !may_refresh || same_contents(&twin_exe, current_exe) {
394 return Outcome::AlreadyPresent;
395 }
396 if !twin_is_stale(binary_version(&twin_exe), parse_version(constants::VERSION)) {
397 return Outcome::AlreadyPresent;
398 }
399 // Replacing a running executable fails on Windows; that is fine, the alias is
400 // simply refreshed by the next invocation that is not itself `devp`.
401 if fs::remove_file(&twin_exe).is_err() {
402 return Outcome::Skipped(format!(
403 "`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
404 from a terminal that is not running it"
405 ));
406 }
407 }
408
409 if fs::hard_link(current_exe, &twin_exe).is_ok() {
410 return Outcome::Installed;
411 }
412
413 // The copy is the fallback for filesystems without hard links — but it must never
414 // run when the alias already exists, because the reason `hard_link` usually fails is
415 // that another process created it a moment ago, as a hard link to this very
416 // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
417 // link truncates the shared inode: the copy would empty the running binary and then
418 // copy zero bytes from it.
419 //
420 // That is not hypothetical. It is what turned every macOS CI run red. The 28
421 // integration tests launch at once, one wins the link, the losers fall through to
422 // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
423 // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
424 // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
425 // 27 unrelated assertion failures.
426 if twin_exe.exists() {
427 return Outcome::AlreadyPresent;
428 }
429
430 // Stage beside and rename into place: copied straight onto the final name, the
431 // alias would exist-but-be-incomplete for the length of the copy, and a `devp`
432 // typed in that window executes a torn binary.
433 let staging = twin_exe.with_extension("new");
434 if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
435 return Outcome::Installed;
436 }
437 let _ = fs::remove_file(&staging);
438 if twin_exe.exists() {
439 // A concurrent invocation won the rename; its alias serves exactly as well.
440 return Outcome::AlreadyPresent;
441 }
442 Outcome::Failed(format!(
443 "could not create `{}`",
444 output::clean_path(&twin_exe)
445 ))
446}
447
448/// Sameness test for two executables, cheap in the common case.
449///
450/// A mismatched length answers without reading either file. Equal length still reads
451/// both: an equal mtime used to short-circuit the check, on the assumption that only a
452/// hard link (or a copy that happened to preserve it) could produce one, but two
453/// unrelated files of the same length can share a modified-time by coincidence, and
454/// that shortcut treated them as identical without ever inspecting a byte.
455pub(crate) fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
456 let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
457 return false;
458 };
459 if ma.len() != mb.len() {
460 return false;
461 }
462 match (fs::read(a), fs::read(b)) {
463 (Ok(ca), Ok(cb)) => ca == cb,
464 _ => false,
465 }
466}
467
468/// Path that `devp skill` and this module export `SKILL.md` to.
469pub fn skill_path() -> Result<PathBuf> {
470 Ok(Registry::config_dir()?.join("SKILL.md"))
471}
472
473/// Export the bundled `SKILL.md` so AI assistants have something to read.
474///
475/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
476/// the skill must not leave the previous version's instructions on disk.
477pub fn ensure_skill_file() -> Outcome {
478 match Registry::config_dir() {
479 Ok(dir) => ensure_skill_file_in(&dir),
480 Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
481 }
482}
483
484fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
485 let target = config_dir.join("SKILL.md");
486
487 if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
488 return Outcome::AlreadyPresent;
489 }
490
491 let _ = fs::create_dir_all(config_dir);
492 match fs::write(&target, EMBEDDED_SKILL_MD) {
493 Ok(()) => Outcome::Installed,
494 Err(e) => Outcome::Failed(format!(
495 "could not write {}: {e}",
496 output::clean_path(&target)
497 )),
498 }
499}
500
501/// The per-skill directories of AI coding agents that are installed under `home`.
502///
503/// Detection only — an agent's home directory is created by the agent, never by this
504/// pass. Today that is Claude Code, whose Agent Skills live at
505/// `~/.claude/skills/<name>/SKILL.md`. Assistants without an on-disk skill format get
506/// the onboarding prompt from `devp skill` instead.
507fn agent_skill_roots_under(home: &std::path::Path) -> Vec<PathBuf> {
508 let mut roots = Vec::new();
509 let claude = home.join(constants::CLAUDE_HOME_DIR);
510 if claude.is_dir() {
511 roots.push(
512 claude
513 .join(constants::AGENT_SKILLS_SUBDIR)
514 .join(constants::APP_NAME),
515 );
516 }
517 roots
518}
519
520/// The agent skill directories on this machine. Empty when no agent is installed.
521pub fn agent_skill_roots() -> Vec<PathBuf> {
522 dirs::home_dir()
523 .map(|home| agent_skill_roots_under(&home))
524 .unwrap_or_default()
525}
526
527/// Install the skill into every detected agent's skills directory.
528pub fn ensure_agent_skills() -> Outcome {
529 ensure_agent_skills_at(&agent_skill_roots())
530}
531
532/// Every managed copy of `SKILL.md` that is not the one this binary carries.
533///
534/// The copies are refreshed by [`ensure_integrations`], which runs only while
535/// auto-setup is on. With it off, an upgrade leaves the previous release's instructions
536/// sitting in the agent's skills directory, and the agent goes on describing flags that
537/// no longer exist — confidently, because nothing told it otherwise.
538pub fn stale_skill_copies() -> Vec<PathBuf> {
539 let mut copies: Vec<PathBuf> = skill_path().into_iter().collect();
540 copies.extend(agent_skill_roots().into_iter().map(|r| r.join("SKILL.md")));
541 stale_among(copies)
542}
543
544/// The ones of `copies` that exist and differ from the embedded skill.
545///
546/// A path that is not there is not stale — it is a copy this machine never had, and
547/// nothing that refreshes may create it.
548fn stale_among(copies: Vec<PathBuf>) -> Vec<PathBuf> {
549 copies
550 .into_iter()
551 .filter(|p| fs::read_to_string(p).is_ok_and(|current| current != EMBEDDED_SKILL_MD))
552 .collect()
553}
554
555/// Rewrite copies that are already on disk, and only those.
556///
557/// This is the half of [`ensure_skill_copies`] that is safe to run on a machine which
558/// turned auto-setup off: replacing the contents of a file that machine already
559/// consented to is maintenance, not a new integration.
560fn refresh_stale(stale: Vec<PathBuf>) {
561 for path in stale {
562 let _ = fs::write(path, EMBEDDED_SKILL_MD);
563 }
564}
565
566/// Rewrite every managed copy of `SKILL.md` from the embedded one.
567///
568/// Both copies, not just the export: the one an assistant actually reads is the one in
569/// its own skills directory, so repairing the export alone would report success and
570/// leave the stale instructions in the only place that matters.
571pub fn ensure_skill_copies() -> Outcome {
572 let mut installed = false;
573 for outcome in [ensure_skill_file(), ensure_agent_skills()] {
574 match outcome {
575 Outcome::Installed => installed = true,
576 // No agent on this machine is not a failure to repair.
577 Outcome::AlreadyPresent | Outcome::Skipped(_) => {}
578 failed => return failed,
579 }
580 }
581 if installed {
582 Outcome::Installed
583 } else {
584 Outcome::AlreadyPresent
585 }
586}
587
588fn ensure_agent_skills_at(roots: &[PathBuf]) -> Outcome {
589 if roots.is_empty() {
590 return Outcome::Skipped(
591 "no AI agent skills directory was found — `devp skill` prints import prompts instead"
592 .to_string(),
593 );
594 }
595 let mut installed = false;
596 for root in roots {
597 match ensure_skill_file_in(root) {
598 Outcome::Installed => installed = true,
599 Outcome::AlreadyPresent => {}
600 other => return other,
601 }
602 }
603 if installed {
604 Outcome::Installed
605 } else {
606 Outcome::AlreadyPresent
607 }
608}
609
610/// Make the managed pair reachable from a fresh shell.
611///
612/// This is the step that lets `pip install dev-prune` in a virtualenv survive the
613/// virtualenv: the binaries pip placed vanish with the environment, but the managed
614/// copy under `<config>/bin` does not, and after this step it is the one a new
615/// terminal finds. See [`crate::pathenv`] for what "reachable" means per platform.
616pub fn ensure_command_on_path() -> Outcome {
617 let managed = stable_exe_path();
618 let is_managed_copy =
619 managed_exe_path().is_ok_and(|expected| expected == managed) && managed.is_file();
620 if !is_managed_copy {
621 // No managed copy exists and none could be created — under `cargo test` the
622 // running executable is the harness, and cloning that would be wrong. There is
623 // nothing durable to put on PATH.
624 return Outcome::Skipped("no managed copy of the binary exists to put on PATH".to_string());
625 }
626 let Some(bin_dir) = managed.parent() else {
627 return Outcome::Failed("the managed binary has no parent directory".to_string());
628 };
629 // `devp` has to sit beside it, or the PATH entry only ever finds `dev-prune`.
630 if let Outcome::Failed(why) = ensure_twin_of(&managed, bin_dir) {
631 return Outcome::Failed(why);
632 }
633 crate::pathenv::ensure_reachable(bin_dir)
634}
635
636/// Write the icon assets and register `*.devprune.json` with the OS file manager.
637///
638/// Part of the automatic pass rather than a separate errand, because "the config file has
639/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
640/// under the config directory and the user's own XDG data directory, `devp uninstall`
641/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
642/// so there is nothing here that needs to be asked about first.
643///
644/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
645/// dropped into the user's data directory are not a background process and not a change
646/// in behaviour; `auto_setup` already covers "install nothing at all".
647fn ensure_icons() -> Outcome {
648 if crate::commands::icon::is_registered() {
649 return Outcome::AlreadyPresent;
650 }
651 match crate::commands::icon::sync_app_directory() {
652 Ok(()) => Outcome::Installed,
653 Err(e) => Outcome::Failed(format!("{e:#}")),
654 }
655}
656
657/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
658///
659/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
660/// reason to skip, because dev-prune can install in front and forward every hook back.
661pub fn ensure_hooks(chain: bool) -> Outcome {
662 if !hook::git_available() {
663 return Outcome::Skipped(format!(
664 "\n {}",
665 hook::GIT_MISSING_HELP.replace('\n', "\n ")
666 ));
667 }
668
669 match hook::state() {
670 // "Installed" is not the question — "installed and pointing at a binary that
671 // still exists" is. A hook backgrounds itself and discards its own output, so
672 // one left pointing at a deleted npm cache dies silently on every commit and
673 // nothing ever registers again; this pass is the only thing that ever looks.
674 // Two reasons to rewrite a working install: it names a binary that is gone, or
675 // it predates the passthrough shims and is silently shadowing every repository's
676 // own `.git/hooks`. Neither reports itself — a hook discards its own output by
677 // design — so the upgrade pass is the only thing that will ever notice.
678 Ok(HookState::Active) if hook_target_is_dead() || hook::shims_incomplete() => {
679 match hook::install() {
680 Ok(()) => Outcome::Installed,
681 Err(e) => Outcome::Failed(format!("{e:#}")),
682 }
683 }
684 Ok(HookState::Active) => Outcome::AlreadyPresent,
685 // Drift is repaired here rather than reported: the setup pass already runs on
686 // install, on update and on a schedule, and a chain the user opted into is a
687 // chain they want kept current.
688 Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
689 match hook::install_with(true) {
690 Ok(()) => Outcome::Installed,
691 Err(e) => Outcome::Failed(format!("{e:#}")),
692 }
693 }
694 Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
695 Ok(()) => Outcome::Installed,
696 Err(e) => Outcome::Failed(format!("{e:#}")),
697 },
698 Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
699 Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
700 Ok(()) => Outcome::Installed,
701 Err(e) => Outcome::Failed(format!("{e:#}")),
702 },
703 Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
704 "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n \
705 Git allows only one hooks directory, so dev-prune will not take the slot.\n \
706 `devp hook install --chain` installs in front of it instead — dev-prune registers \
707 the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
708 the original setting back (`devp config set auto_hooks_chain true` makes that \
709 the standing answer). Or skip it: `devp link .` does the same job by hand."
710 )),
711 Ok(HookState::Absent) => match hook::install() {
712 Ok(()) => Outcome::Installed,
713 Err(e) => Outcome::Failed(format!("{e:#}")),
714 },
715 Err(e) => Outcome::Failed(format!("{e:#}")),
716 }
717}
718
719/// Whether the installed hooks name a binary that no longer exists.
720fn hook_target_is_dead() -> bool {
721 hook::registered_exe_path().is_some_and(|exe| !exe.exists())
722}
723
724/// Install the OS scheduler if it is not already registered.
725pub fn ensure_daemon(interval_days: u64) -> Outcome {
726 match daemon::daemon_status() {
727 // A task whose binary has been deleted keeps reporting itself `Ready` and dies
728 // the instant it fires, every interval, with nowhere to complain. Re-register
729 // it against the stable path instead of counting the corpse as present.
730 Ok(daemon::DaemonStatus::Installed)
731 if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
732 {
733 match daemon::install_daemon(interval_days) {
734 Ok(()) => Outcome::Installed,
735 Err(e) => Outcome::Failed(format!("{e:#}")),
736 }
737 }
738 // A task registered by a version that only knew the interactive logon flashes a
739 // console window at whoever is logged in every time it fires — the single most
740 // trust-destroying thing a background tool can do. Re-register it windowless;
741 // a machine whose scheduler refuses the sessionless logon remembers the refusal
742 // and is not asked again.
743 Ok(daemon::DaemonStatus::Installed) if daemon::wants_windowless_upgrade() => {
744 match daemon::install_daemon(interval_days) {
745 Ok(()) => Outcome::Installed,
746 Err(e) => Outcome::Failed(format!("{e:#}")),
747 }
748 }
749 // A task registered by a version that took Windows' power defaults never runs
750 // on a laptop that lives on battery, and a missed trigger is skipped rather
751 // than caught up. Patch the settings in place — an XML round-trip, not a
752 // reinstall, so the trigger time the user already has stays put. A scheduler
753 // that refuses remembers the refusal and is not asked again.
754 Ok(daemon::DaemonStatus::Installed) if daemon::wants_power_upgrade() => {
755 match daemon::apply_power_settings() {
756 Ok(()) => Outcome::Installed,
757 Err(e) => Outcome::Failed(format!("{e:#}")),
758 }
759 }
760 // A settled task still needs its windowless twin kept current: the twin
761 // is a copy of the binary, so an upgrade that replaced the binary would otherwise
762 // leave the daemon firing the previous release. No-op on the other platforms, and
763 // when no twin is in use.
764 Ok(daemon::DaemonStatus::Installed) => {
765 daemon::refresh_windowless_twin();
766 // The refresh above sources from the devpw beside the running binary, which
767 // is the *previous* delivery's copy after an upgrade the old console
768 // performed, and the scheduled pass itself runs devpw, so nothing devpw
769 // does can ever replace devpw. This per-version pass is the first place new
770 // console code runs without being asked, which makes it the one hook that
771 // can close that loop: reconcile the twin against the release itself. It
772 // stands down under `DEV_PRUNE_OFFLINE` and `version_lock`, and only ever
773 // moves an existing, stale twin forward.
774 match crate::commands::update::repair_windowless_twins() {
775 Outcome::Installed => Outcome::Installed,
776 Outcome::Failed(why) => Outcome::Failed(why),
777 _ => Outcome::AlreadyPresent,
778 }
779 }
780 Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
781 Ok(()) => Outcome::Installed,
782 Err(e) => Outcome::Failed(format!("{e:#}")),
783 },
784 // `Unknown` means the query itself could not be answered — the scheduler may
785 // well be there. Installing over it would fail on every command from now on, so
786 // this reports and steps over instead of guessing. The platform backends are
787 // written to keep this case narrow: anything they can answer definitely, they do.
788 Ok(daemon::DaemonStatus::Unknown(why)) => {
789 Outcome::Skipped(format!("scheduler state could not be read — {why}"))
790 }
791 Err(e) => Outcome::Failed(format!("{e:#}")),
792 }
793}
794
795/// Whether unattended installation is permitted at all.
796///
797/// Both switches exist because these integrations write outside dev-prune's own config
798/// directory — a scheduled task, a global git setting — and there are places that must
799/// never happen unasked: container images, CI, and this project's own test suite.
800pub fn auto_setup_enabled(registry: &Registry) -> bool {
801 !no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
802}
803
804/// The reason this looks like a machine nobody is sitting at, if it does.
805///
806/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
807/// the first run — which is exactly the run that installs things, so in a container or a
808/// CI job the damage is done by the time there is anywhere to set them. Detecting the
809/// environment is the only opt-out that works on the first run, which is the only run
810/// that matters here.
811///
812/// Deliberately conservative: every signal below is one that CI providers and container
813/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
814/// genuinely wants the integrations in CI can still ask in so many words with
815/// `devp setup`, which never consults this.
816pub fn unattended_environment() -> Option<&'static str> {
817 // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
818 // and most others. `CI=true` is the closest thing this space has to a standard.
819 for var in [
820 "CI",
821 "CONTINUOUS_INTEGRATION",
822 "BUILD_NUMBER",
823 "GITHUB_ACTIONS",
824 ] {
825 if let Some(value) = std::env::var_os(var) {
826 // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
827 // the word rather than the presence is what the user plainly meant.
828 let value = value.to_string_lossy();
829 if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
830 return Some("this looks like a CI runner");
831 }
832 }
833 }
834
835 // Docker writes this marker into every container it builds from a Dockerfile;
836 // Podman and other OCI runtimes write the `container` variable instead.
837 #[cfg(unix)]
838 if std::path::Path::new("/.dockerenv").exists() {
839 return Some("this looks like a container");
840 }
841 if std::env::var_os("container").is_some() {
842 return Some("this looks like a container");
843 }
844
845 None
846}
847
848/// Whether this integration pass was asked for by name.
849///
850/// The only thing it decides is the `devp` twin. Writing a second executable beside the
851/// first is a self-installation, and doing it *unasked*, on the first run of a freshly
852/// downloaded unsigned binary, alongside registering a scheduled task, is a behavioural
853/// malware signature — it is what earned this package a `Validation-Defender-Error` on
854/// microsoft/winget-pkgs#422665. Asked for in so many words, the same write is an
855/// ordinary install step. Nothing else in the pass changes.
856#[derive(Clone, Copy, PartialEq, Eq)]
857pub enum Consent {
858 /// `devp setup`, or `devp doctor --fix`.
859 Explicit,
860 /// The pass that runs on its own when `auto_setup` is on.
861 Unattended,
862}
863
864/// Run an integration pass unless unattended installation is switched off.
865///
866/// Every caller that the user did not name explicitly goes through this. `devp setup`
867/// calls [`ensure_integrations`] with [`Consent::Explicit`]: asking for it in so many
868/// words is consent.
869pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
870 auto_setup_enabled(registry).then(|| ensure_integrations(registry, Consent::Unattended))
871}
872
873/// Run one integration pass, installing whatever is missing.
874///
875/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
876/// turning one off turns it off for every future pass as well as this one.
877pub fn ensure_integrations(registry: &Registry, consent: Consent) -> SetupReport {
878 let mut report = SetupReport::default();
879
880 // Only when asked. This is the twin *beside the running binary*, which on an
881 // unattended pass means beside whatever the delivery vehicle happened to be: npm's
882 // cache, a venv's `Scripts`, a Downloads folder. Every channel already ships both
883 // names as real files — the archives, the npm and PyPI packages, and two `[[bin]]`
884 // targets for `cargo install` — so there is normally nothing to create, and the one
885 // case left over is a manual install that skipped `dev-prune setup`, which `devp
886 // doctor` reports with a one-command fix.
887 //
888 // The pair that actually matters is not this one. `ensure_command_on_path` below
889 // keeps `dev-prune` and `devp` together in the managed `bin` directory, which is
890 // the directory on the user's PATH and the one `devp uninstall` knows about, and it
891 // runs on every pass.
892 if consent == Consent::Explicit {
893 report.push("dev-prune/devp pair", ensure_alias());
894 }
895 report.push("Command on PATH", ensure_command_on_path());
896 report.push("SKILL.md", ensure_skill_file());
897 // Only reported when an agent is actually installed: a machine without one would
898 // otherwise see a "skipped" warning about software it never had, on every install.
899 if !agent_skill_roots().is_empty() {
900 report.push("AI agent skills", ensure_agent_skills());
901 }
902 report.push("File icons", ensure_icons());
903
904 if registry.settings.auto_hooks {
905 report.push(
906 "Git hooks",
907 ensure_hooks(registry.settings.auto_hooks_chain),
908 );
909 } else {
910 report.push(
911 "Git hooks",
912 Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
913 );
914 }
915
916 if registry.settings.auto_daemon {
917 report.push(
918 "Background scheduler",
919 ensure_daemon(registry.settings.check_interval_days),
920 );
921 } else {
922 report.push(
923 "Background scheduler",
924 Outcome::Skipped(
925 "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
926 ),
927 );
928 }
929
930 report
931}
932
933// ── VS Code extension ────────────────────────────────────────────────────────
934
935/// Marker recording that the extension question was asked (or found already answered by
936/// an existing install). One file, no content: the offer is made once ever, whatever
937/// the answer was — a declined install must not be re-litigated on every upgrade.
938const VSCODE_OFFER_STAMP: &str = "vscode-ext-offered";
939
940/// A VS Code-compatible editor found on PATH.
941struct EditorCli {
942 /// The command to invoke — on Windows the `.cmd` launcher, because the entry on
943 /// PATH is a batch file, not an `.exe`, and `Command::new("code")` would miss it.
944 cli: String,
945 /// The editor's name as a person knows it, for the prompt and per-editor results.
946 label: &'static str,
947}
948
949/// Every VS Code-compatible editor on PATH, in the order listed here.
950///
951/// All of these forks keep the upstream CLI protocol (`--version`, `--list-extensions`,
952/// `--install-extension`), so one code path drives them all. What differs is the
953/// registry each one resolves an extension ID against: VS Code uses the Microsoft
954/// Marketplace, VSCodium/Windsurf/Positron/Kiro/Trae use OpenVSX, Cursor and
955/// Antigravity run their own mirrors. An ID install can therefore fail on a fork whose
956/// registry does not carry the extension yet — which is why the installer falls back
957/// to the `.vsix` from the GitHub release, the artifact every registry copy is built
958/// from.
959///
960/// The list is candidate CLI names, not a claim that any of them is installed: each is
961/// asked for its `--version` and dropped if it does not answer. Adding a fork therefore
962/// costs one failed spawn on a machine without it, and is what stops the extension
963/// offer from being a VS Code-only courtesy on an editor that is a VS Code build with a
964/// different name on the window.
965fn detect_vscode_editors() -> Vec<EditorCli> {
966 const CANDIDATES: &[(&str, &str)] = &[
967 ("code", "VS Code"),
968 ("code-insiders", "VS Code Insiders"),
969 ("codium", "VSCodium"),
970 ("codium-insiders", "VSCodium Insiders"),
971 ("cursor", "Cursor"),
972 ("windsurf", "Windsurf"),
973 ("antigravity", "Antigravity"),
974 ("trae", "Trae"),
975 ("positron", "Positron"),
976 ("kiro", "Kiro"),
977 ];
978 CANDIDATES
979 .iter()
980 .filter_map(|(name, label)| {
981 let cli = if cfg!(windows) {
982 format!("{name}.cmd")
983 } else {
984 (*name).to_string()
985 };
986 let responds = crate::spawn::command(&cli)
987 .arg("--version")
988 .stdin(std::process::Stdio::null())
989 .stdout(std::process::Stdio::null())
990 .stderr(std::process::Stdio::null())
991 .status()
992 .map(|s| s.success())
993 .unwrap_or(false);
994 responds.then_some(EditorCli { cli, label })
995 })
996 .collect()
997}
998
999fn vscode_extension_installed(cli: &str) -> bool {
1000 crate::spawn::command(cli)
1001 .arg("--list-extensions")
1002 .stdin(std::process::Stdio::null())
1003 .output()
1004 .map(|out| {
1005 String::from_utf8_lossy(&out.stdout).lines().any(|line| {
1006 line.trim()
1007 .eq_ignore_ascii_case(constants::VSCODE_EXTENSION_ID)
1008 })
1009 })
1010 .unwrap_or(false)
1011}
1012
1013/// Download the `.vsix` from the newest extension release into the config directory.
1014///
1015/// The release asset is the source of truth for the extension — the Marketplace and
1016/// OpenVSX listings are published from that exact file — so when an editor's registry
1017/// cannot resolve the ID (a fork whose registry does not carry the extension),
1018/// installing the release file directly gets the same bits through a channel every fork
1019/// supports. Editors update a `.vsix`-installed extension from their registry once a
1020/// newer listed version appears, so this install self-heals into the normal update flow.
1021///
1022/// Deliberately not `releases/latest`. The extension has its own tags and its own
1023/// release page, and those releases are marked "not latest" so they cannot displace the
1024/// binary release that `devp update` reads. The consequence is that the newest one has
1025/// to be found by walking the listing for a [`VSCODE_RELEASE_TAG_PREFIX`] tag.
1026///
1027/// [`VSCODE_RELEASE_TAG_PREFIX`]: crate::constants::VSCODE_RELEASE_TAG_PREFIX
1028fn download_release_vsix() -> Option<std::path::PathBuf> {
1029 use std::time::Duration;
1030
1031 if offline_requested() {
1032 return None;
1033 }
1034
1035 let fetch = |url: &str| {
1036 ureq::get(url)
1037 .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
1038 .header("Accept", "application/vnd.github+json")
1039 .config()
1040 .timeout_global(Some(Duration::from_secs(30)))
1041 .build()
1042 .call()
1043 };
1044
1045 let body = fetch(constants::RELEASES_LIST_API_URL)
1046 .ok()?
1047 .body_mut()
1048 .read_to_string()
1049 .ok()?;
1050 let json: serde_json::Value = serde_json::from_str(&body).ok()?;
1051 // GitHub returns this listing newest-first, so the first extension release found is
1052 // the current one. Draft releases carry no downloadable asset, and a pre-release of
1053 // the extension is one deliberately not being offered to people who did not ask.
1054 let asset = json.as_array()?.iter().find_map(|release| {
1055 let tag = release.get("tag_name")?.as_str()?;
1056 if !tag.starts_with(constants::VSCODE_RELEASE_TAG_PREFIX) {
1057 return None;
1058 }
1059 if release.get("draft")?.as_bool()? || release.get("prerelease")?.as_bool()? {
1060 return None;
1061 }
1062 release.get("assets")?.as_array()?.iter().find_map(|asset| {
1063 let name = asset.get("name")?.as_str()?;
1064 if !name.ends_with(".vsix") {
1065 return None;
1066 }
1067 let url = asset.get("browser_download_url")?.as_str()?;
1068 Some((name.to_string(), url.to_string()))
1069 })
1070 })?;
1071
1072 let bytes = fetch(&asset.1).ok()?.body_mut().read_to_vec().ok()?;
1073 // The config directory, not the shared system temp dir: on a multi-user machine
1074 // `%TEMP%`-style paths are predictable and writable by others, and the editor is
1075 // about to execute what this file contains. The caller deletes it after installing.
1076 let dir = Registry::config_dir().ok()?;
1077 fs::create_dir_all(&dir).ok()?;
1078 let path = dir.join(&asset.0);
1079 fs::write(&path, bytes).ok()?;
1080 Some(path)
1081}
1082
1083/// `<cli> --install-extension <arg>`, surfacing the editor's own output.
1084fn run_install(cli: &str, arg: &str) -> bool {
1085 crate::spawn::command(cli)
1086 .args(["--install-extension", arg])
1087 .stdin(std::process::Stdio::null())
1088 .status()
1089 .map(|s| s.success())
1090 .unwrap_or(false)
1091}
1092
1093/// Offer to install the editor extension, once ever, when a VS Code-family editor is
1094/// present.
1095///
1096/// This asks rather than installs because the editor is not dev-prune's territory the
1097/// way its own config directory is. Every gate below is a way of making sure a person
1098/// is actually there to answer: no marker yet, not a CI runner or container, both ends
1099/// of the terminal attached. When no editor is found nothing is written, so installing
1100/// one later and re-running `devp setup` still gets the one offer.
1101pub fn offer_vscode_extension() {
1102 use std::io::{IsTerminal, Write};
1103
1104 let Ok(config_dir) = Registry::config_dir() else {
1105 return;
1106 };
1107 if config_dir.join(VSCODE_OFFER_STAMP).exists() {
1108 return;
1109 }
1110 if no_auto_setup_requested()
1111 || unattended_environment().is_some()
1112 || !std::io::stdin().is_terminal()
1113 || !std::io::stdout().is_terminal()
1114 {
1115 return;
1116 }
1117 let editors = detect_vscode_editors();
1118 if editors.is_empty() {
1119 return;
1120 }
1121
1122 let write_marker = || {
1123 let _ = fs::create_dir_all(&config_dir);
1124 let _ = fs::write(config_dir.join(VSCODE_OFFER_STAMP), "");
1125 };
1126
1127 let missing: Vec<&EditorCli> = editors
1128 .iter()
1129 .filter(|e| !vscode_extension_installed(&e.cli))
1130 .collect();
1131 if missing.is_empty() {
1132 write_marker();
1133 return;
1134 }
1135
1136 let names = missing
1137 .iter()
1138 .map(|e| e.label)
1139 .collect::<Vec<_>>()
1140 .join(", ");
1141 println!();
1142 println!("{names} detected — install the dev-prune extension?");
1143 println!(" It validates .devprune.json and shows reclaimable space in the status bar.");
1144 // The listings and the source, before the question rather than after it. This is
1145 // the one prompt that defaults to yes, so the material someone would need in order
1146 // to say no has to be on screen at the moment they answer — not in a doc they would
1147 // have to go and look for.
1148 println!(" Marketplace: {}", constants::VSCODE_MARKETPLACE_URL);
1149 println!(" Open VSX: {}", constants::OPENVSX_URL);
1150 println!(" Source: {}", constants::REPO_URL);
1151 print!(" Install it? [Y/n] ");
1152 let _ = std::io::stdout().flush();
1153 let mut answer = String::new();
1154 if std::io::stdin().read_line(&mut answer).is_err() {
1155 return;
1156 }
1157 write_marker();
1158
1159 // A bare Enter accepts. Unlike the uninstall sweep — which deletes — the worst case
1160 // here is an extension the person removes in two clicks, and the gates above have
1161 // already established that a human with a VS Code-family editor is watching.
1162 if !matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes") {
1163 output::print_info(&format!(
1164 "Skipped. Install it any time with `{} --install-extension {}`, or from {}.",
1165 missing[0].cli,
1166 constants::VSCODE_EXTENSION_ID,
1167 constants::VSCODE_MARKETPLACE_URL
1168 ));
1169 return;
1170 }
1171
1172 // Fetched at most once, shared by every editor whose registry install fails.
1173 let mut release_vsix: Option<Option<std::path::PathBuf>> = None;
1174 for editor in &missing {
1175 // The editor's own registry first: that install is the one the editor keeps
1176 // up to date by itself.
1177 if run_install(&editor.cli, constants::VSCODE_EXTENSION_ID) {
1178 output::print_success(&format!("{}: extension installed.", editor.label));
1179 continue;
1180 }
1181 // A fork whose registry does not carry the extension — install the `.vsix`
1182 // from the GitHub release instead.
1183 let vsix = release_vsix.get_or_insert_with(download_release_vsix);
1184 match vsix {
1185 Some(path) if run_install(&editor.cli, &path.to_string_lossy()) => {
1186 output::print_success(&format!(
1187 "{}: extension installed from the GitHub release .vsix.",
1188 editor.label
1189 ));
1190 }
1191 _ => {
1192 output::print_warning(&format!(
1193 "{}: could not install it from here. Search the Extensions view for \"dev-prune\", or run `{} --install-extension {}` yourself.",
1194 editor.label,
1195 editor.cli,
1196 constants::VSCODE_EXTENSION_ID
1197 ));
1198 }
1199 }
1200 }
1201 if let Some(Some(path)) = &release_vsix {
1202 let _ = fs::remove_file(path);
1203 }
1204}
1205
1206/// Record that a pass completed for this version.
1207fn write_stamp_in(config_dir: &std::path::Path) {
1208 let _ = fs::create_dir_all(config_dir);
1209 let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
1210}
1211
1212fn write_stamp() {
1213 if let Ok(dir) = Registry::config_dir() {
1214 write_stamp_in(&dir);
1215 }
1216}
1217
1218fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
1219 !fs::read_to_string(config_dir.join(STAMP_FILE))
1220 .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
1221}
1222
1223/// What this machine has said about dev-prune installing things for itself.
1224///
1225/// Three answers, not two: "never asked" is the state a question can still be put in,
1226/// and collapsing it into either answer is how tools end up installing on a silence or
1227/// nagging on a refusal.
1228#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1229pub enum SetupConsent {
1230 Granted,
1231 Declined,
1232 NeverAsked,
1233}
1234
1235const CONSENT_GRANTED: &str = "granted";
1236const CONSENT_DECLINED: &str = "declined";
1237
1238/// The release that introduced the consent question. A stamp older than this could
1239/// only have been written by a pass that had already installed the integrations; a
1240/// newer one is also written by the opted-out path, and so proves nothing.
1241const FIRST_CONSENT_VERSION: &str = "1.18.0";
1242
1243fn consent_state_in(config_dir: &std::path::Path) -> SetupConsent {
1244 match fs::read_to_string(config_dir.join(constants::SETUP_CONSENT_FILE)) {
1245 Ok(answer) if answer.trim() == CONSENT_GRANTED => return SetupConsent::Granted,
1246 Ok(answer) if answer.trim() == CONSENT_DECLINED => return SetupConsent::Declined,
1247 _ => {}
1248 }
1249 // A pre-1.18 stamp means the old flow already installed the integrations and the
1250 // person kept them — consent in deed if not in word, and re-asking would prompt
1251 // every existing user once for something they already have.
1252 match fs::read_to_string(config_dir.join(STAMP_FILE)) {
1253 Ok(stamp)
1254 if crate::commands::update::compare_versions(stamp.trim(), FIRST_CONSENT_VERSION)
1255 == Some(std::cmp::Ordering::Less) =>
1256 {
1257 SetupConsent::Granted
1258 }
1259 _ => SetupConsent::NeverAsked,
1260 }
1261}
1262
1263pub fn consent_state() -> SetupConsent {
1264 Registry::config_dir()
1265 .map(|dir| consent_state_in(&dir))
1266 .unwrap_or(SetupConsent::NeverAsked)
1267}
1268
1269fn record_consent_in(config_dir: &std::path::Path, answer: &str) {
1270 let _ = fs::create_dir_all(config_dir);
1271 let _ = fs::write(config_dir.join(constants::SETUP_CONSENT_FILE), answer);
1272}
1273
1274/// `devp setup` records this too: asking for the pass in so many words is also the
1275/// durable answer to the question the first run would otherwise ask.
1276pub fn record_consent_granted() {
1277 if let Ok(dir) = Registry::config_dir() {
1278 record_consent_in(&dir, CONSENT_GRANTED);
1279 }
1280}
1281
1282fn record_consent_declined() {
1283 if let Ok(dir) = Registry::config_dir() {
1284 record_consent_in(&dir, CONSENT_DECLINED);
1285 }
1286}
1287
1288/// Put the question back the way a fresh machine has it. `devp uninstall` calls this:
1289/// keeping a "granted" that outlives the things it granted would make the next upgrade
1290/// reinstall everything the uninstall just removed.
1291pub fn clear_setup_consent() {
1292 if let Ok(dir) = Registry::config_dir() {
1293 let _ = fs::remove_file(dir.join(constants::SETUP_CONSENT_FILE));
1294 }
1295}
1296
1297/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
1298pub fn setup_is_due() -> bool {
1299 Registry::config_dir()
1300 .map(|dir| setup_is_due_in(&dir))
1301 .unwrap_or(false)
1302}
1303
1304/// Whether there is a human at this invocation who could see what was done and undo it.
1305///
1306/// The one question that gates everything dev-prune installs without being asked. CI
1307/// variables and containers answer it directly; a redirected stdin or stdout answers it
1308/// too, because output nobody reads is the same as no output — and an integration
1309/// installed silently is one nobody knows to remove.
1310fn a_person_is_present() -> bool {
1311 use std::io::IsTerminal;
1312 unattended_environment().is_none()
1313 && std::io::stdin().is_terminal()
1314 && std::io::stdout().is_terminal()
1315}
1316
1317/// The per-version pass — and, since 1.18.0, never before this machine has said yes.
1318///
1319/// Called at the top of every command that a human typed. It is deliberately not called
1320/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
1321/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
1322pub fn auto_setup_if_due() {
1323 if !setup_is_due() {
1324 first_run_config_review();
1325 return;
1326 }
1327 // The same question `first_run_config_review` asks, asked one step earlier. It used
1328 // to be asked only about the *prompt*, never about the pass that installs a PATH
1329 // entry, a scheduled task and a git hook — so a binary run once by an automated
1330 // system, with its output captured, silently acquired persistence on that machine.
1331 // Nothing here is skipped permanently: the stamp is not written, so the first run a
1332 // person can actually see does the pass and reports it.
1333 if !a_person_is_present() {
1334 return;
1335 }
1336
1337 review_project_venv_install();
1338
1339 let Ok(registry) = Registry::load() else {
1340 return;
1341 };
1342 match consent_state() {
1343 SetupConsent::Granted => {
1344 // Made durable even when it was inferred from a pre-1.18 stamp, so the
1345 // inference runs once rather than on every later upgrade.
1346 record_consent_granted();
1347 run_consented_pass(®istry);
1348 first_run_config_review();
1349 }
1350 SetupConsent::Declined => {
1351 // The answer was no, and staying no costs nothing to honour: stamp so this
1352 // version's pass is settled, install nothing, and leave `devp setup` as
1353 // the standing way to change the answer. The settings review is still owed
1354 // when an upgrade adds a setting — declining the integrations was never a
1355 // vote on config defaults.
1356 write_stamp();
1357 first_run_config_review();
1358 }
1359 SetupConsent::NeverAsked => {
1360 if !auto_setup_enabled(®istry) {
1361 // Suppressed. Stamp anyway, so a machine that opted out does not
1362 // re-decide this on every single command; the consent marker stays
1363 // unwritten, so lifting the opt-out later asks rather than installs.
1364 //
1365 // The one thing opting out must not do is freeze the instructions AI
1366 // agents read at whichever version installed them, so any existing
1367 // copies are still brought up to date. See `run_consented_pass` for
1368 // the fuller reasoning.
1369 refresh_stale(stale_skill_copies());
1370 write_stamp();
1371 crate::commands::config::skip_config_review();
1372 return;
1373 }
1374 ask_first_run_consent();
1375 }
1376 }
1377}
1378
1379/// One integration pass for a machine that has already said yes, reported if it did
1380/// anything.
1381fn run_consented_pass(registry: &Registry) {
1382 let Some(report) = ensure_integrations_if_enabled(registry) else {
1383 // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
1384 // this on every single command.
1385 //
1386 // The one thing opting out must not do is freeze the instructions AI agents
1387 // read at whichever version installed them. The stamp below is what would
1388 // freeze them: it is rewritten for every new version without a pass ever
1389 // running, so an existing `SKILL.md` here would never be reconsidered again,
1390 // and the agent would go on describing flags that were removed two releases
1391 // ago — confidently, because nothing told it otherwise.
1392 refresh_stale(stale_skill_copies());
1393 write_stamp();
1394 crate::commands::config::skip_config_review();
1395 return;
1396 };
1397 if report.changed_anything() || report.needs_attention() {
1398 output::print_header("dev-prune setup");
1399 report.print(false);
1400 if report.changed_anything() {
1401 output::print_info(
1402 "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
1403 );
1404 }
1405 println!();
1406 }
1407 write_stamp();
1408}
1409
1410/// Ask, on the first attended run, before installing anything at all.
1411///
1412/// The old order — install, then open the walkthrough — put this binary's fingerprint
1413/// (an unasked self-copy into a managed `bin`, plus a scheduled task, on the first run
1414/// of an unsigned download) squarely on the behaviour ML malware classifiers key on;
1415/// the 1.17.0 release exe was flagged as Trojan:Win32/Wacatac.B!ml for exactly that.
1416/// Asked first, the same installs are the answer to a question — and a sandbox that
1417/// runs the binary bare now sits at a prompt instead of recording persistence.
1418fn ask_first_run_consent() {
1419 use crate::commands::config::FirstRunDecision;
1420 match crate::commands::config::first_run_wizard() {
1421 Err(e) => {
1422 // The wizard's own failure must not decide the question either way:
1423 // nothing recorded, nothing stamped, asked again on the next command.
1424 output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1425 }
1426 Ok(FirstRunDecision::Accepted) => {
1427 record_consent_granted();
1428 // Reloaded, not reused: the walkthrough that just closed may have flipped
1429 // `auto_daemon` or `auto_hooks`, and this pass exists to honour that.
1430 let Ok(registry) = Registry::load() else {
1431 return;
1432 };
1433 run_consented_pass(®istry);
1434 crate::commands::config::skip_config_review();
1435 offer_vscode_extension();
1436 println!();
1437 }
1438 Ok(FirstRunDecision::Declined) => {
1439 record_consent_declined();
1440 write_stamp();
1441 crate::commands::config::skip_config_review();
1442 output::print_info(
1443 "Nothing was installed. `devp setup` installs the integrations whenever \
1444 you want them; `devp config wizard` reopens the settings.",
1445 );
1446 println!();
1447 }
1448 Ok(FirstRunDecision::NoAnswer) => {
1449 // EOF is not an answer — it is nobody there after all. Every marker stays
1450 // unwritten, so the first run with a person on the other end is asked.
1451 }
1452 }
1453}
1454
1455/// Put the defaults in front of the user on a fresh install, and any setting an upgrade
1456/// added that they have never been shown.
1457///
1458/// Separate from the integration stamp on purpose. The integrations are re-checked after
1459/// every upgrade; the settings are not, except for the ones that did not exist last time
1460/// — being asked to reconfirm `idle_days` on each new version would be a nuisance, and a
1461/// nuisance is something people learn to dismiss without reading.
1462///
1463/// Every condition here is a way of asking "is there a person reading this?", because the
1464/// alternative to asking is a prompt written into a log nobody will read, on a run that
1465/// then blocks forever waiting for an answer.
1466fn first_run_config_review() {
1467 if !crate::commands::config::config_review_is_due() {
1468 return;
1469 }
1470
1471 // Deliberately not stamped here. The stamp records that somebody was *shown* the
1472 // declaration, and a cron run, a CI job or a container has nobody to show it to.
1473 // Writing it anyway meant the first unattended `devp` on a machine silently spent
1474 // the one screen that says what dev-prune will not delete — so the person who
1475 // installed it never saw it, and nothing ever offered again. Left unstamped, the
1476 // walkthrough waits for the first run with a human on the other end of it, which is
1477 // the only run it was ever for.
1478 if !a_person_is_present() {
1479 return;
1480 }
1481
1482 // Any error here is the wizard's own reporting; the command the user actually typed
1483 // still runs. A failed walkthrough must not become a failed `devp status`.
1484 if let Err(e) =
1485 crate::commands::config::run_wizard(false, crate::commands::config::Opened::OnItsOwn)
1486 {
1487 output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1488 }
1489 // Marked regardless of how it ended, including a deliberate quit. This is the one
1490 // caller that runs uninvited, and an unasked-for walkthrough that reappears on every
1491 // subsequent command is worse than one somebody dismissed once on purpose.
1492 crate::commands::config::skip_config_review();
1493 // Same first run, same person already answering questions — the one moment the
1494 // extension offer is a courtesy rather than an interruption.
1495 offer_vscode_extension();
1496 println!();
1497}
1498
1499/// Invalidate the stamp so the next human-run command performs a pass.
1500///
1501/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
1502/// integrations is not immediately undone by the next command.
1503pub fn suppress_next_auto_setup() {
1504 write_stamp();
1505}
1506
1507/// Say something, once, when this copy is running from inside a project's virtualenv.
1508///
1509/// This is the remedy at the source. By the time a prune pass refuses the environment,
1510/// the user is several days and one confusing error away from the moment they typed
1511/// `pip install dev-prune` with a project activated; saying it here, on the first run
1512/// after that install, is the only chance to explain it while the cause is still in
1513/// living memory. The refusal in the venv adapter stays as the failsafe, for a copy
1514/// installed before this check existed or by somebody who dismissed it.
1515///
1516/// Silent when `requirements.txt` already lists the tool. That is somebody who meant it,
1517/// and being told about a decision you made on purpose is what teaches people to stop
1518/// reading output.
1519fn review_project_venv_install() {
1520 let Ok(exe) = std::env::current_exe() else {
1521 return;
1522 };
1523 let Some(found) = crate::channel::project_venv_install(&exe) else {
1524 return;
1525 };
1526
1527 let requirements = found.project.join("requirements.txt");
1528 let recorded = crate::adapters::venv::requirement_names(&requirements, &mut Vec::new())
1529 .is_some_and(|names| names.iter().any(|n| crate::adapters::venv::is_dev_prune(n)));
1530 if recorded {
1531 return;
1532 }
1533
1534 output::print_header(&format!(
1535 "{} is installed inside this project's virtual environment",
1536 constants::APP_NAME
1537 ));
1538 println!(" running from {}", exe.display());
1539 println!(" environment {}", found.venv.display());
1540 println!(" project {}", found.project.display());
1541 println!();
1542 output::print_info(
1543 "A tool install belongs outside a project: it outlives the environment, every \
1544 repository shares it, and it never has to appear in an application's \
1545 requirements file to stay out of the way.",
1546 );
1547
1548 if requirements.is_file() {
1549 println!();
1550 output::print_info(
1551 "Until that is fixed, a prune pass will decline this project's environment \
1552 — a package `requirements.txt` does not account for is a package nothing \
1553 can rebuild.",
1554 );
1555 println!();
1556 if record_in_requirements(&requirements) {
1557 return;
1558 }
1559 }
1560
1561 println!();
1562 println!(" Remove this copy and install it as a tool instead:");
1563 println!(" pip uninstall {}", constants::APP_NAME);
1564 println!(" uv tool install {}", constants::APP_NAME);
1565 println!(" # or: pipx install {}", constants::APP_NAME);
1566 report_other_copy(&exe);
1567 println!();
1568}
1569
1570/// Offer the other repair: declare the tool a dependency of this project, on purpose.
1571///
1572/// Default no, for the reason `devp restore` defaults no on a substituted interpreter —
1573/// this is the branch that writes into a file in somebody's repository, so a reflexive
1574/// Enter must not be what agrees to it. Returns whether the file was written, which is
1575/// also whether the removal instructions are still worth printing.
1576fn record_in_requirements(requirements: &std::path::Path) -> bool {
1577 use std::io::{IsTerminal, Write};
1578 if !std::io::stdin().is_terminal() {
1579 return false;
1580 }
1581 eprint!(
1582 "Record {} in requirements.txt instead, as a deliberate dev dependency? [y/N]: ",
1583 constants::APP_NAME
1584 );
1585 if std::io::stderr().flush().is_err() {
1586 return false;
1587 }
1588 let mut input = String::new();
1589 if std::io::stdin().read_line(&mut input).is_err() {
1590 return false;
1591 }
1592 if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
1593 return false;
1594 }
1595
1596 let Ok(existing) = fs::read_to_string(requirements) else {
1597 output::print_warning("Could not read requirements.txt, so nothing was changed.");
1598 return false;
1599 };
1600 // Requirements files without a trailing newline are common, and appending to one
1601 // blind would glue the pin onto the last requirement.
1602 let separator = if existing.is_empty() || existing.ends_with('\n') {
1603 ""
1604 } else {
1605 "\n"
1606 };
1607 let line = format!(
1608 "{separator}{}=={}\n",
1609 constants::APP_NAME,
1610 constants::VERSION
1611 );
1612 match std::fs::OpenOptions::new()
1613 .append(true)
1614 .open(requirements)
1615 .and_then(|mut f| f.write_all(line.as_bytes()))
1616 {
1617 Ok(()) => {
1618 output::print_success(&format!(
1619 "Added `{}=={}` to {}. The environment is prunable now.",
1620 constants::APP_NAME,
1621 constants::VERSION,
1622 requirements.display()
1623 ));
1624 true
1625 }
1626 Err(e) => {
1627 output::print_warning(&format!("Could not write requirements.txt ({e})."));
1628 false
1629 }
1630 }
1631}
1632
1633/// Name the copy that is already installed properly, if there is one.
1634///
1635/// "Uninstall this" reads very differently depending on whether it leaves the user with
1636/// no tool at all or with the one they already had — and on a machine where this mistake
1637/// happens there usually is one, because the working copy is what they were reaching for
1638/// in the first place.
1639fn report_other_copy(exe: &std::path::Path) {
1640 let names: [&str; 2] = if cfg!(windows) {
1641 ["dev-prune.exe", "devp.exe"]
1642 } else {
1643 ["dev-prune", "devp"]
1644 };
1645 let home = dirs::home_dir();
1646 let other = crate::channel::install_dirs(home.as_deref())
1647 .into_iter()
1648 .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
1649 .find(|candidate| candidate.is_file() && candidate != exe);
1650
1651 if let Some(other) = other {
1652 println!();
1653 output::print_info(&format!(
1654 "You already have a copy outside this project, at `{}`, so removing this one \
1655 still leaves you a working `devp`.",
1656 other.display()
1657 ));
1658 }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664
1665 #[test]
1666 fn a_report_with_only_present_items_is_silent() {
1667 let mut report = SetupReport::default();
1668 report.push("a", Outcome::AlreadyPresent);
1669 assert!(!report.changed_anything());
1670 assert!(!report.needs_attention());
1671 }
1672
1673 #[test]
1674 fn skipped_and_failed_both_ask_for_attention() {
1675 let mut skipped = SetupReport::default();
1676 skipped.push("a", Outcome::Skipped("no git".into()));
1677 assert!(skipped.needs_attention());
1678 assert!(!skipped.changed_anything());
1679
1680 let mut failed = SetupReport::default();
1681 failed.push("a", Outcome::Failed("boom".into()));
1682 assert!(failed.needs_attention());
1683 }
1684
1685 #[test]
1686 fn an_install_counts_as_a_change() {
1687 let mut report = SetupReport::default();
1688 report.push("a", Outcome::Installed);
1689 assert!(report.changed_anything());
1690 }
1691
1692 #[test]
1693 fn the_skill_export_lands_in_the_config_directory() {
1694 let dir = tempfile::TempDir::new().unwrap();
1695 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1696 // A second pass finds byte-identical content and leaves it alone.
1697 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
1698 let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1699 assert_eq!(written, EMBEDDED_SKILL_MD);
1700 }
1701
1702 #[test]
1703 fn a_stale_skill_export_is_rewritten() {
1704 // An upgrade must not leave the previous version's instructions on disk.
1705 let dir = tempfile::TempDir::new().unwrap();
1706 fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
1707 assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1708 let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1709 assert_eq!(written, EMBEDDED_SKILL_MD);
1710 }
1711
1712 #[test]
1713 fn agent_skills_install_only_into_agent_homes_that_exist() {
1714 let home = tempfile::TempDir::new().unwrap();
1715 assert!(
1716 agent_skill_roots_under(home.path()).is_empty(),
1717 "a machine without an agent must detect nothing"
1718 );
1719
1720 fs::create_dir_all(home.path().join(constants::CLAUDE_HOME_DIR)).unwrap();
1721 let roots = agent_skill_roots_under(home.path());
1722 assert_eq!(roots.len(), 1);
1723
1724 assert_eq!(ensure_agent_skills_at(&roots), Outcome::Installed);
1725 let installed = home
1726 .path()
1727 .join(constants::CLAUDE_HOME_DIR)
1728 .join(constants::AGENT_SKILLS_SUBDIR)
1729 .join(constants::APP_NAME)
1730 .join("SKILL.md");
1731 assert_eq!(fs::read_to_string(&installed).unwrap(), EMBEDDED_SKILL_MD);
1732
1733 // A second pass finds it current and leaves it alone.
1734 assert_eq!(ensure_agent_skills_at(&roots), Outcome::AlreadyPresent);
1735 }
1736
1737 #[test]
1738 fn refreshing_rewrites_the_copies_that_exist_and_creates_none() {
1739 let dir = tempfile::TempDir::new().unwrap();
1740 let present = dir.path().join("SKILL.md");
1741 fs::write(&present, "# the instructions from two releases ago").unwrap();
1742 let never_installed = dir.path().join("no-agent-here").join("SKILL.md");
1743
1744 let stale = stale_among(vec![present.clone(), never_installed.clone()]);
1745 assert_eq!(stale, vec![present.clone()]);
1746
1747 refresh_stale(stale);
1748 assert_eq!(fs::read_to_string(&present).unwrap(), EMBEDDED_SKILL_MD);
1749 assert!(
1750 !never_installed.exists(),
1751 "a machine that never had a copy must not acquire one from a refresh"
1752 );
1753 }
1754
1755 #[test]
1756 fn no_detected_agent_is_a_skip_not_a_failure() {
1757 assert!(matches!(ensure_agent_skills_at(&[]), Outcome::Skipped(_)));
1758 }
1759
1760 #[test]
1761 fn the_stamp_gates_the_unattended_pass() {
1762 let dir = tempfile::TempDir::new().unwrap();
1763 assert!(setup_is_due_in(dir.path()), "a fresh install is due");
1764 write_stamp_in(dir.path());
1765 assert!(
1766 !setup_is_due_in(dir.path()),
1767 "the same version is not due twice"
1768 );
1769 fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
1770 assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
1771 }
1772
1773 /// The alias must never be written with a copy while it already exists.
1774 ///
1775 /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
1776 /// the binary it was copied from. This reproduces the exact shape of that bug — link
1777 /// first, then ask for the alias again — and asserts the original still has its
1778 /// bytes. The real failure was silent: a zero-byte executable that macOS runs
1779 /// through `/bin/sh`, which exits 0 and prints nothing.
1780 #[test]
1781 fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
1782 let dir = tempfile::TempDir::new().unwrap();
1783 let binary = dir.path().join("dev-prune");
1784 let alias = dir.path().join("devp");
1785 fs::write(&binary, vec![b'M'; 4096]).unwrap();
1786
1787 if fs::hard_link(&binary, &alias).is_err() {
1788 return; // Filesystem without hard links; the hazard cannot arise.
1789 }
1790
1791 // What `ensure_alias` does when its `hard_link` loses the race: the alias is
1792 // already there, so it must stop rather than fall through to the copy.
1793 assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
1794 assert!(alias.exists(), "the guard's condition");
1795
1796 assert_eq!(
1797 fs::metadata(&binary).unwrap().len(),
1798 4096,
1799 "the running binary was truncated by refreshing its own alias"
1800 );
1801 }
1802
1803 /// The on-disk file name for one of the pair, on this platform.
1804 fn exe_name(stem: &str) -> String {
1805 if cfg!(windows) {
1806 format!("{stem}.exe")
1807 } else {
1808 stem.to_string()
1809 }
1810 }
1811
1812 #[test]
1813 fn dev_prune_creates_devp_beside_it() {
1814 let dir = tempfile::TempDir::new().unwrap();
1815 let canonical = dir.path().join(exe_name("dev-prune"));
1816 fs::write(&canonical, "the binary").unwrap();
1817
1818 assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
1819 let alias = dir.path().join(exe_name("devp"));
1820 assert!(alias.is_file(), "`devp` was not created");
1821 assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
1822 }
1823
1824 /// The pair has to be recoverable from either side.
1825 ///
1826 /// Deleting `dev-prune` and leaving `devp` is not hypothetical: an antivirus
1827 /// quarantine, a half-finished uninstall, or a `Remove-Item` aimed at one name all
1828 /// produce it. Before this, `devp setup` reported the alias already present and did
1829 /// nothing, because the only direction it knew how to repair was the other one.
1830 #[test]
1831 fn devp_restores_a_missing_dev_prune() {
1832 let dir = tempfile::TempDir::new().unwrap();
1833 let alias = dir.path().join(exe_name("devp"));
1834 fs::write(&alias, "the binary").unwrap();
1835
1836 assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
1837 let canonical = dir.path().join(exe_name("dev-prune"));
1838 assert!(canonical.is_file(), "`dev-prune` was not put back");
1839 assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
1840 }
1841
1842 /// `devp` may create `dev-prune`, never overwrite it.
1843 ///
1844 /// Repairing in both directions opens a downgrade: an upgrade replaces `dev-prune`
1845 /// first and can then fail on a `devp` that is running, which leaves the alias holding
1846 /// the *older* binary. If the alias were allowed to refresh its twin from there, the
1847 /// next `devp setup` would quietly reinstall the version the user just upgraded away
1848 /// from — and report it as a repair.
1849 #[test]
1850 fn devp_does_not_overwrite_an_existing_dev_prune() {
1851 let dir = tempfile::TempDir::new().unwrap();
1852 let alias = dir.path().join(exe_name("devp"));
1853 let canonical = dir.path().join(exe_name("dev-prune"));
1854 fs::write(&alias, "the previous version").unwrap();
1855 fs::write(&canonical, "the version just upgraded to").unwrap();
1856
1857 assert_eq!(
1858 ensure_twin_of(&alias, dir.path()),
1859 Outcome::AlreadyPresent,
1860 "`devp` must leave an existing `dev-prune` alone"
1861 );
1862 assert_eq!(
1863 fs::read_to_string(&canonical).unwrap(),
1864 "the version just upgraded to",
1865 "`devp` downgraded the binary it was supposed to leave alone"
1866 );
1867 }
1868
1869 /// The same downgrade, coming the other way — the direction the gate above lets
1870 /// through.
1871 ///
1872 /// `devp` is blocked from refreshing `dev-prune` outright, but `dev-prune` refreshing
1873 /// `devp` was gated on nothing but the two files differing, on the assumption that the
1874 /// canonical name is always the newer one. Restore a `dev-prune` from a backup, or run
1875 /// one out of a package-manager cache, and it is not — and `devp doctor --fix` runs
1876 /// `ensure_alias` from whichever binary is executing, so an older `dev-prune` would
1877 /// delete a newer `devp` and link its own content over it while reporting a repair.
1878 #[test]
1879 fn a_twin_that_is_not_behind_is_left_alone() {
1880 assert!(
1881 !twin_is_stale(Some((1, 12, 0)), Some((1, 11, 0))),
1882 "a newer twin must not be overwritten"
1883 );
1884 assert!(
1885 !twin_is_stale(Some((1, 11, 0)), Some((1, 11, 0))),
1886 "an equal twin has nothing to refresh"
1887 );
1888 assert!(
1889 twin_is_stale(Some((1, 10, 0)), Some((1, 11, 0))),
1890 "a genuinely older twin is what this refresh is for"
1891 );
1892 // Neither side able to state a version leaves the old content-only rule, which is
1893 // the only answer available: whatever the file is, it is not a working build of
1894 // this CLI, and leaving it would keep a broken `devp` on PATH forever.
1895 assert!(twin_is_stale(None, Some((1, 11, 0))));
1896 assert!(twin_is_stale(Some((1, 11, 0)), None));
1897 }
1898
1899 #[test]
1900 fn versions_parse_strictly_or_not_at_all() {
1901 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1902 assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
1903 // Anything this project does not publish must answer None, because a None
1904 // means "replace the copy" and a mis-parse would order versions wrongly.
1905 assert_eq!(parse_version("1.2"), None);
1906 assert_eq!(parse_version("1.2.3.4"), None);
1907 assert_eq!(parse_version("1.2.3-rc1"), None);
1908 assert_eq!(parse_version("dev-prune"), None);
1909 // The version this binary was built with has to be parseable, or the refresh
1910 // logic can never decide anything.
1911 assert!(parse_version(constants::VERSION).is_some());
1912 }
1913
1914 #[test]
1915 fn the_version_this_cli_prints_is_one_this_cli_can_read_back() {
1916 // Not synthetic: this is the shape `print_version_info` writes, `v` and all,
1917 // down to the banner line that ends in the same token.
1918 let real = format!(
1919 "|_____| v{v}
1920
1921dev-prune (devp) v{v}
1922 Compiler: Rust 1.88+ (edition 2024)
1923",
1924 v = constants::VERSION
1925 );
1926 assert_eq!(
1927 version_in_output(&real),
1928 parse_version(constants::VERSION),
1929 "binary_version could not read this binary's own --version output"
1930 );
1931 // Something that is not this CLI still has to answer None, because doctor uses
1932 // that to mean "leave this file alone".
1933 assert_eq!(version_in_output("git version 2.51.0.windows.1"), None);
1934 assert_eq!(version_in_output("some other tool"), None);
1935 }
1936
1937 #[test]
1938 fn ordering_of_version_triples_matches_semver() {
1939 assert!(parse_version("1.1.0") > parse_version("1.0.9"));
1940 assert!(parse_version("2.0.0") > parse_version("1.99.99"));
1941 assert!(parse_version("1.0.10") > parse_version("1.0.9"));
1942 }
1943
1944 #[test]
1945 fn the_exported_skill_is_the_one_the_binary_was_built_with() {
1946 // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
1947 // Guard the two properties every consumer of it depends on.
1948 assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
1949 assert!(
1950 !EMBEDDED_SKILL_MD.contains("file:///"),
1951 "SKILL.md is written to every user's machine — it must not contain \
1952 absolute paths from the author's checkout"
1953 );
1954 }
1955
1956 #[test]
1957 fn a_fresh_machine_has_never_been_asked() {
1958 let dir = tempfile::TempDir::new().unwrap();
1959 assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
1960 }
1961
1962 #[test]
1963 fn a_recorded_answer_is_read_back() {
1964 let dir = tempfile::TempDir::new().unwrap();
1965 record_consent_in(dir.path(), CONSENT_GRANTED);
1966 assert_eq!(consent_state_in(dir.path()), SetupConsent::Granted);
1967 record_consent_in(dir.path(), CONSENT_DECLINED);
1968 assert_eq!(consent_state_in(dir.path()), SetupConsent::Declined);
1969 }
1970
1971 #[test]
1972 fn a_garbled_marker_means_the_question_is_still_open() {
1973 // Better to ask twice than to install on the strength of a corrupt file.
1974 let dir = tempfile::TempDir::new().unwrap();
1975 record_consent_in(dir.path(), "maybe?");
1976 assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
1977 }
1978
1979 #[test]
1980 fn a_pre_consent_stamp_counts_as_granted() {
1981 // The old flow only ever stamped after installing, so a 1.17 stamp is proof
1982 // the integrations are already on this machine.
1983 let dir = tempfile::TempDir::new().unwrap();
1984 fs::write(dir.path().join(STAMP_FILE), "1.17.0\n").unwrap();
1985 assert_eq!(consent_state_in(dir.path()), SetupConsent::Granted);
1986 }
1987
1988 #[test]
1989 fn a_post_consent_stamp_proves_nothing() {
1990 // From 1.18.0 on, the opted-out path writes the stamp too — treating it as a
1991 // yes would silently grant consent on the exact machines that withheld it.
1992 let dir = tempfile::TempDir::new().unwrap();
1993 // Not `constants::VERSION`: until the release that ships this bumps it past
1994 // FIRST_CONSENT_VERSION, the current version is itself a pre-consent one.
1995 for stamp in [FIRST_CONSENT_VERSION, "1.18.1", "2.0.0", "garbage"] {
1996 fs::write(dir.path().join(STAMP_FILE), stamp).unwrap();
1997 assert_eq!(
1998 consent_state_in(dir.path()),
1999 SetupConsent::NeverAsked,
2000 "stamp {stamp:?} must not imply consent"
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn an_explicit_answer_outranks_the_stamp() {
2007 let dir = tempfile::TempDir::new().unwrap();
2008 fs::write(dir.path().join(STAMP_FILE), "1.17.0").unwrap();
2009 record_consent_in(dir.path(), CONSENT_DECLINED);
2010 assert_eq!(consent_state_in(dir.path()), SetupConsent::Declined);
2011 }
2012
2013 #[test]
2014 fn clearing_consent_reopens_the_question() {
2015 let dir = tempfile::TempDir::new().unwrap();
2016 record_consent_in(dir.path(), CONSENT_GRANTED);
2017 fs::remove_file(dir.path().join(constants::SETUP_CONSENT_FILE)).unwrap();
2018 assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
2019 }
2020
2021 /// A delivery directory and a managed directory, each with a console binary and,
2022 /// where a version is given, a `devpw.exe` carrying that build stamp.
2023 #[cfg(windows)]
2024 fn twin_dirs(
2025 shipped: Option<&str>,
2026 managed: Option<&str>,
2027 ) -> (tempfile::TempDir, PathBuf, PathBuf) {
2028 let root = tempfile::TempDir::new().unwrap();
2029 let stamped = |v: &str| format!("{}{v}/end", constants::VERSION_STAMP_MARK);
2030 let mut exes = Vec::new();
2031 for (dir, version) in [("cargo-bin", shipped), ("managed", managed)] {
2032 let dir = root.path().join(dir);
2033 fs::create_dir_all(&dir).unwrap();
2034 fs::write(dir.join("dev-prune.exe"), "console").unwrap();
2035 if let Some(v) = version {
2036 fs::write(dir.join(constants::WINDOWS_WINDOWLESS_BIN), stamped(v)).unwrap();
2037 }
2038 exes.push(dir.join("dev-prune.exe"));
2039 }
2040 let managed = exes.pop().unwrap();
2041 let current = exes.pop().unwrap();
2042 (root, current, managed)
2043 }
2044
2045 #[cfg(windows)]
2046 fn managed_twin(managed: &std::path::Path) -> Option<String> {
2047 let bytes = fs::read(managed.with_file_name(constants::WINDOWS_WINDOWLESS_BIN)).ok()?;
2048 crate::commands::trust::version_from_stamp(&bytes)
2049 }
2050
2051 #[cfg(windows)]
2052 #[test]
2053 fn a_stale_managed_twin_moves_forward_with_the_delivery() {
2054 let (_root, current, managed) = twin_dirs(Some("1.23.0"), Some("1.18.0"));
2055 refresh_managed_twin_if_stale(¤t, &managed);
2056 assert_eq!(managed_twin(&managed).as_deref(), Some("1.23.0"));
2057 }
2058
2059 #[cfg(windows)]
2060 #[test]
2061 fn a_newer_managed_twin_is_not_downgraded() {
2062 let (_root, current, managed) = twin_dirs(Some("1.18.0"), Some("1.23.0"));
2063 refresh_managed_twin_if_stale(¤t, &managed);
2064 assert_eq!(managed_twin(&managed).as_deref(), Some("1.23.0"));
2065 }
2066
2067 #[cfg(windows)]
2068 #[test]
2069 fn a_missing_managed_twin_is_not_created() {
2070 let (_root, current, managed) = twin_dirs(Some("1.23.0"), None);
2071 refresh_managed_twin_if_stale(¤t, &managed);
2072 assert_eq!(managed_twin(&managed), None);
2073 }
2074
2075 #[cfg(windows)]
2076 #[test]
2077 fn an_already_current_console_copy_still_refreshes_the_twin() {
2078 let (_root, current, managed) = twin_dirs(Some("1.23.0"), Some("1.18.0"));
2079 assert!(same_contents(¤t, &managed));
2080 refresh_managed_copy_if_stale(¤t, &managed);
2081 assert_eq!(managed_twin(&managed).as_deref(), Some("1.23.0"));
2082 }
2083}