Skip to content

Commit f0743e0

Browse files
committed
feat(cli): add prompt and auto update modes, and defer to the installing package manager
Adds the two remaining rungs of the update ladder. `prompt` asks once, after a command that succeeded, on a real terminal only. `auto` installs at the end of the run, but only for a binary Perry itself installed: a Homebrew, npm, apt or winget copy prints that tool's own upgrade command instead of overwriting a file the package manager owns. `perry update --mode <mode>` writes the setting, and `perry doctor` reports it.
1 parent b9415d7 commit f0743e0

11 files changed

Lines changed: 1116 additions & 49 deletions

changelog.d/7749-update-config-surface.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ prompt_default = false # what Enter means in prompt mode
2727
```
2828

2929
`off` and `notify` are the two behaviours that already existed, and `notify` is
30-
the default, so a user who never opens the config sees no change. `prompt` and
31-
`auto` are accepted and documented here but not yet wired to an install — that
32-
is the next slice, deliberately separate, because replacing the binary a user
33-
is running deserves its own review.
30+
the default, so a user who never opens the config sees no change. `prompt` asks once,
31+
after a command that succeeded, on a real terminal only; `auto` installs at the
32+
end of the run, and only for a binary Perry itself installed. Those two arrived
33+
in a separate change from this one, because replacing the binary a user is
34+
running deserves its own review.
3435

3536
`PERRY_UPDATE_MODE` sets the same thing for one run.
3637

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
### Added
2+
3+
**`prompt` and `auto` update modes now do something, and refuse to do the wrong
4+
thing.** The previous slice made the modes configurable; this wires them to the
5+
existing signed self-updater, behind three refusals.
6+
7+
**A package-managed install is never replaced in place.** `perry update`
8+
overwrites the running executable, which is right for a tarball or `install.sh`
9+
install and wrong for every managed one: Homebrew, npm, apt and winget each keep
10+
their own record of what is installed and at what version, and overwriting the
11+
file underneath leaves that record lying. `prompt` and `auto` now detect the
12+
owner and name that owner's command instead:
13+
14+
| owner | what Perry says to run |
15+
|---|---|
16+
| Homebrew | `brew upgrade perryts/perry/perry` |
17+
| npm | `npm install -g @perryts/perry@latest` |
18+
| apt | `sudo apt update && sudo apt install --only-upgrade perry` |
19+
| winget | `winget upgrade PerryTS.Perry` |
20+
21+
npm gets an extra sentence, because it is the worst case: Perry ships as a
22+
wrapper package plus a per-platform binary package, so replacing the binary also
23+
desyncs it from the wrapper that launched it.
24+
25+
**Nothing is offered after a command that failed.** The user is looking at an
26+
error; a question about upgrading is noise at the worst possible moment, and an
27+
unattended install would bury the error under progress output. Both active modes
28+
fall back to a plain notice.
29+
30+
**An unwritable install directory is reported, not attempted.** `install.sh`
31+
targets `/usr/local/bin`, which is root-owned on a default macOS and most Linux
32+
boxes. That is now checked *before* anything is downloaded, so the outcome is
33+
one sentence naming `sudo perry update` rather than a half-finished install. Perry
34+
never escalates on its own.
35+
36+
**`perry update --mode <off|notify|prompt|auto>`** saves the setting and exits,
37+
so the one thing people are most likely to change does not require hand-editing
38+
TOML. It is a read-modify-write through the shared loader, so the rest of the
39+
file comes back out the way it went in.
40+
41+
**`perry doctor`** now reports the effective mode and, when there is one, the
42+
package manager that owns the binary — the two questions behind "why did it not
43+
update".
44+
45+
<details>
46+
<summary><b>Why the channel detection fails open</b></summary>
47+
48+
Every rule answers "is this definitely managed?", never "is this definitely
49+
unmanaged?", and an unrecognised layout resolves to self-managed.
50+
51+
That asymmetry is deliberate. Guessing "managed" wrongly would refuse to
52+
self-update a plain tarball install — the majority case, and the one with no
53+
other upgrade path. Guessing "self-managed" wrongly costs an in-place update on
54+
a machine that had a package manager available, which is recoverable by running
55+
that manager.
56+
57+
The paths are canonicalized before classification, because Homebrew's `perry` in
58+
`/usr/local/bin` is a symlink into the Cellar; classifying the link rather than
59+
its target would miss every Homebrew install there is.
60+
61+
apt requires **both** a dpkg file list and a dpkg-owned path, because dpkg does
62+
not own `/usr/local` — that is `install.sh`'s directory. The path alone would
63+
misclassify a hand-placed binary; the dpkg list alone would claim a tarball
64+
install on a machine that also has the `.deb` installed somewhere else. The check
65+
is a file-existence test rather than a `dpkg -S` subprocess, since this runs on
66+
the update path of every command.
67+
</details>
68+
69+
<details>
70+
<summary><b>Prompting needs stdin, not just stderr</b></summary>
71+
72+
The mode gate already requires stderr to be a terminal. That is not enough to
73+
ask a question: stdin can be a pipe while stderr is a tty, and reading from it
74+
would either block the command or take whatever the pipe happened to contain as
75+
consent. `prompt` degrades to a plain notice when stdin is not a terminal.
76+
77+
`auto` asks nothing, so it does not need stdin — but it does still require the
78+
command to have succeeded, an unmanaged install, and a writable directory.
79+
</details>
80+
81+
<details>
82+
<summary><b>Tests</b></summary>
83+
84+
24 new, all in the required per-pull-request job. The decision is a pure
85+
function of the mode plus four facts about the machine, so every refusal is
86+
asserted directly rather than left inside an `if` in the middle of a teardown
87+
path:
88+
89+
- both active modes downgrade to a notice after a failed command;
90+
- both refuse on all four managed channels, and name a command for each;
91+
- both report elevation rather than attempting an unwritable install;
92+
- `prompt` degrades without stdin while `auto` does not need it.
93+
94+
The channel table covers Homebrew under all three prefixes, npm for global, nvm
95+
and project-local layouts, apt with and without each half of its rule, both
96+
winget delivery shapes, and four unrecognised layouts that must fail open.
97+
Classification splits on both path separators rather than using
98+
`Path::components`, so the winget cases run on every host instead of only on
99+
Windows.
100+
101+
Verified end to end: writing `mode` into a real config file that already had a
102+
`license_key` and an unknown `[update] future_key` left both intact.
103+
104+
`cargo test -p perry`: 914 passed, 0 failed.
105+
</details>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
### Fixed
2+
3+
Five defects in the update surface, all found in review of
4+
[#7749](https://github.com/PerryTS/perry/pull/7749) after it had merged.
5+
6+
**The config warning escaped the rules that were meant to silence it.** The
7+
"unrecognized `[update] mode`" line was printed inside `UpdatePolicy::resolve`,
8+
before the precedence rules it sits behind had been applied — so it reached
9+
stderr during `--format json`, in CI, with a piped stderr, and under `--quiet`.
10+
Those rules exist to keep exactly those runs silent, and the one line whose job
11+
was to report a config problem was the one line ignoring them. It is now held on
12+
the policy and emitted at the single point where the run is known to be speaking
13+
at all.
14+
15+
**The notify interval throttled on time alone, so it swallowed the next
16+
release.** The documented contract is that the interval throttles repeats of
17+
*the same* update. Keyed only on a timestamp, it also suppressed a **different**
18+
version that arrived inside the window — so somebody setting a week-long
19+
interval to stop being nagged about one release would also have been denied the
20+
release that fixed it. The cache now records which version it announced, and a
21+
different version is announced regardless of the interval.
22+
23+
**The interval comparison was signed.** `Duration::as_secs() as i64` goes
24+
negative for a large enough configured value, and a negative interval reads as
25+
already-elapsed — so an absurd value would have notified on *every* run instead
26+
of suppressing. The comparison is unsigned.
27+
28+
**Two `perry` processes could corrupt the cache.** Every write used one shared
29+
`*.json.tmp`, so two writers each wrote it and each renamed it: the loser's
30+
rename landed a file the winner was still writing into. Each write now builds
31+
its own temporary name.
32+
33+
**A refresh could erase a notice recorded while its request was in flight.**
34+
`fetch_latest_version` read the notice state *before* issuing its request and
35+
wrote it back afterwards, overwriting anything recorded in between — telling the
36+
user about the same release twice. The read-modify-write pairs are now
37+
serialized by a lock file, and the refresh re-reads inside that lock immediately
38+
before replacing.
39+
40+
<details>
41+
<summary><b>Tests</b></summary>
42+
43+
Two new contract tests, both sabotage-verified — reverting either fix turns its
44+
test red:
45+
46+
- a different version is announced regardless of the interval, and never having
47+
announced anything counts as "not this version";
48+
- an enormous interval still suppresses rather than wrapping into notifying.
49+
50+
The existing interval tests are unchanged in intent: they now go through a
51+
helper that holds the announced version constant, so they still exercise only
52+
the interval arithmetic.
53+
54+
`cargo test -p perry`: 904 passed, 0 failed.
55+
</details>

crates/perry/src/commands/doctor.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,16 +286,41 @@ fn check_runtime_library() -> CheckResult {
286286
}
287287

288288
fn check_update_available() -> CheckResult {
289+
// Say which mode is in effect and who owns this binary. Both are things a
290+
// user asks `doctor` about precisely when updates are not behaving as they
291+
// expect — "why did it not install" is almost always one of the two.
292+
let policy = crate::update_policy::UpdatePolicy::resolve();
293+
let channel = crate::install_channel::detect();
294+
// Configured, not effective: `doctor --format json` and `doctor | less` both
295+
// suppress the update surface for their own run, so reporting the effective
296+
// mode would answer "off" to the very question being asked.
297+
let mut detail = format!(" (mode: {}", policy.configured_mode.label());
298+
if !policy.is_active() && policy.configured_mode != crate::update_policy::UpdateMode::Off {
299+
detail.push_str("; suppressed for this run");
300+
}
301+
if let Some(command) = channel.upgrade_command() {
302+
detail.push_str(&format!(
303+
"; installed by {} — upgrade with `{}`",
304+
channel.label(),
305+
command
306+
));
307+
}
308+
detail.push(')');
309+
let context = detail;
310+
289311
match update_checker::check_cached_status() {
290312
update_checker::UpdateStatus::UpdateAvailable { latest, .. } => CheckResult {
291313
name: "update status".to_string(),
292314
status: CheckStatus::Warning,
293-
details: Some(format!("v{} available — run `perry update`", latest)),
315+
details: Some(format!(
316+
"v{} available — run `perry update`{}",
317+
latest, context
318+
)),
294319
},
295320
update_checker::UpdateStatus::UpToDate => CheckResult {
296321
name: "update status".to_string(),
297322
status: CheckStatus::Ok,
298-
details: Some("up to date".to_string()),
323+
details: Some(format!("up to date{}", context)),
299324
},
300325
update_checker::UpdateStatus::CheckFailed => CheckResult {
301326
name: "update status".to_string(),

crates/perry/src/commands/publish/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ pub use args::PublishArgs;
3535
#[cfg(test)]
3636
pub(crate) use saved_config::IosSavedConfig; // consumed only by tests
3737
pub(crate) use saved_config::{
38-
check_beta_consent, config_path, is_interactive, load_config, prompt_input, report_beta_error,
38+
check_beta_consent, config_path, is_interactive, load_config, load_config_checked,
39+
prompt_input, report_beta_error, update_config_file,
3940
save_config, AndroidSavedConfig, AppleSavedConfig, HarmonyosSavedConfig, PerryConfig,
4041
};
4142
pub(crate) use tarball::create_project_tarball_with_filters;

crates/perry/src/commands/publish/saved_config.rs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,39 @@ pub(crate) fn config_path() -> PathBuf {
119119
}
120120

121121
pub(crate) fn load_config() -> PerryConfig {
122+
load_config_checked().unwrap_or_default()
123+
}
124+
125+
/// `load_config`, but able to say WHY it produced nothing.
126+
///
127+
/// `Err` means the file exists and does not parse — the case a plain
128+
/// `unwrap_or_default()` cannot tell apart from "no file yet". The difference
129+
/// matters at save time, because writing a default struct over a damaged but
130+
/// hand-recoverable config destroys the user's license key and tokens along with
131+
/// the syntax error they were about to fix.
132+
pub(crate) fn load_config_checked() -> std::result::Result<PerryConfig, String> {
122133
let path = config_path();
123-
if let Ok(content) = fs::read_to_string(&path) {
124-
toml::from_str(&content).unwrap_or_default()
125-
} else {
126-
PerryConfig::default()
127-
}
134+
let Ok(content) = fs::read_to_string(&path) else {
135+
// No file, or unreadable. Defaults are right here.
136+
return Ok(PerryConfig::default());
137+
};
138+
toml::from_str(&content).map_err(|error| error.to_string())
139+
}
140+
141+
/// Read, modify, write — refusing to write when the read failed.
142+
///
143+
/// Every setting-writer goes through this. Handing `load_config`'s default
144+
/// struct to `save_config` is how a damaged file becomes an erased one, and a
145+
/// caller cannot tell the two apart on its own.
146+
pub(crate) fn update_config_file(edit: impl FnOnce(&mut PerryConfig)) -> Result<()> {
147+
let mut config = load_config_checked().map_err(|error| {
148+
anyhow::anyhow!(
149+
"~/.perry/config.toml could not be parsed, so it was left untouched: \
150+
{error}. Fix the file (or delete it) and try again."
151+
)
152+
})?;
153+
edit(&mut config);
154+
save_config(&config)
128155
}
129156

130157
pub(crate) fn save_config(config: &PerryConfig) -> Result<()> {
@@ -326,3 +353,60 @@ check_interval_hours = 6
326353
assert!(written.contains("keep-me") && written.contains("[telemetry]"));
327354
}
328355
}
356+
357+
#[cfg(test)]
358+
mod tests {
359+
use super::*;
360+
361+
/// A config that does not parse must be left alone, not replaced by defaults.
362+
///
363+
/// This is the data-loss case. `load_config` cannot tell "no file yet" from
364+
/// "damaged file", so a writer built on it turns one stray character into an
365+
/// erased license key — the user's own tokens, gone while they were fixing a
366+
/// typo.
367+
#[test]
368+
fn a_damaged_config_is_never_overwritten_with_defaults() {
369+
let _lock = crate::test_env_lock::env_lock();
370+
let home = tempfile::tempdir().expect("tempdir");
371+
let saved = std::env::var_os("HOME");
372+
std::env::set_var("HOME", home.path());
373+
374+
let path = config_path();
375+
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
376+
let damaged = "license_key = \"keep-me\"\nthis is not toml\n";
377+
std::fs::write(&path, damaged).expect("write");
378+
379+
let error = update_config_file(|config| {
380+
config.update.get_or_insert_with(Default::default).mode =
381+
Some(crate::update_policy::UpdateMode::Notify);
382+
})
383+
.expect_err("a damaged config must refuse the write");
384+
assert!(
385+
format!("{error:#}").contains("left untouched"),
386+
"the message must say the file was not written: {error:#}"
387+
);
388+
assert_eq!(
389+
std::fs::read_to_string(&path).expect("read"),
390+
damaged,
391+
"the file was rewritten despite the refusal"
392+
);
393+
394+
// A missing file is still the "use defaults" case, so a first-time write
395+
// has to succeed.
396+
std::fs::remove_file(&path).expect("remove");
397+
update_config_file(|config| {
398+
config.update.get_or_insert_with(Default::default).mode =
399+
Some(crate::update_policy::UpdateMode::Notify);
400+
})
401+
.expect("a fresh config must be writable");
402+
assert!(
403+
std::fs::read_to_string(&path).expect("read").contains("mode"),
404+
"the setting was not persisted"
405+
);
406+
407+
match saved {
408+
Some(v) => std::env::set_var("HOME", v),
409+
None => std::env::remove_var("HOME"),
410+
}
411+
}
412+
}

crates/perry/src/commands/update.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ pub struct UpdateArgs {
1515
/// Ignore cache, always fetch from server
1616
#[arg(long)]
1717
pub force: bool,
18+
19+
/// Save how Perry should handle updates from now on, then exit.
20+
///
21+
/// This is the writable half of `[update] mode` in ~/.perry/config.toml —
22+
/// there to save people hand-editing TOML for the one setting they are
23+
/// most likely to want to change.
24+
#[arg(long, value_name = "off|notify|prompt|auto")]
25+
pub mode: Option<String>,
1826
}
1927

2028
pub fn run(
@@ -24,6 +32,10 @@ pub fn run(
2432
verbose: u8,
2533
quiet: bool,
2634
) -> Result<()> {
35+
if let Some(raw) = args.mode.as_deref() {
36+
return set_mode(raw);
37+
}
38+
2739
let current = env!("CARGO_PKG_VERSION");
2840

2941
let status = if !args.force && !update_checker::is_cache_stale() {
@@ -119,3 +131,34 @@ pub fn run(
119131

120132
Ok(())
121133
}
134+
135+
/// Persist `[update] mode`, and nothing else.
136+
///
137+
/// Read-modify-write through the shared loader so the rest of the file — the
138+
/// license key, the telemetry section, anything a newer Perry wrote — comes
139+
/// back out the way it went in.
140+
fn set_mode(raw: &str) -> Result<()> {
141+
let Some(mode) = crate::update_policy::UpdateMode::parse(raw) else {
142+
anyhow::bail!("unknown update mode `{raw}`. Valid values: off, notify, prompt, auto.");
143+
};
144+
if mode == crate::update_policy::UpdateMode::Unknown {
145+
anyhow::bail!("unknown update mode `{raw}`. Valid values: off, notify, prompt, auto.");
146+
}
147+
148+
crate::commands::publish::update_config_file(|config| {
149+
config.update.get_or_insert_with(Default::default).mode = Some(mode);
150+
})?;
151+
152+
let path = crate::commands::publish::config_path();
153+
println!("Update mode set to \"{raw}\" ({}).", path.display());
154+
if mode == crate::update_policy::UpdateMode::Auto {
155+
// Say the limits up front rather than letting someone discover them
156+
// the first time an update does not happen.
157+
println!(
158+
"Perry will install updates at the end of a successful run — except \
159+
on a package-manager-managed install, where it names that manager's \
160+
command instead."
161+
);
162+
}
163+
Ok(())
164+
}

0 commit comments

Comments
 (0)