Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "prompthive"
version = "0.6.0"
version = "0.6.1"
edition = "2021"
rust-version = "1.89"
authors = ["Jory Pestorious <jory@pestorious.com>"]
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ To build from a [GitHub source release](https://github.com/joryeugene/prompthive

`ph update` attempts to update a crates.io installation in your Cargo home. It first rebuilds the installed version in a temporary directory and checks its bytes; a different toolchain or build environment may prevent a match. When an update is available, it asks you to confirm that the displayed installation is yours to update. Close other installers first: the check cannot prevent another program from changing the same file at the same time. If it cannot verify the installation, use the original installer instead. The command uses your existing Cargo executable and configuration, requires network access, and preserves the recorded target and feature choices. Windows and Cargo roots outside your home require a manual update.

If `ph update` fails on an older Cargo installation, use `cargo install prompthive --locked` to install the latest published version. The fix is included in 0.6.1 once it is published; 0.6.0 still has the issue. For a custom installation, supply the same `--root`, `--target`, and feature options used originally. If you installed Prompthive through another package manager, use that installer instead.

The default build includes the terminal picker. For a smaller build without it, add `--no-default-features`; exact-name commands and pipelines remain available.

On Linux, clipboard copying needs `wl-copy` on Wayland, or `xclip` or `xsel` on X11, plus a working desktop session. Prompthive starts no clipboard daemon of its own. Use a pipe or `-q` without a clipboard. An explicit copy request fails clearly when copying is unavailable.

### Update recovery

If an update fails after installation starts, stop other installers and keep the recovery directory shown by `ph`. It contains the previous executable and Cargo tracking files. Compare the current files with those copies before deciding how to recover. Do not blindly overwrite the executable or Cargo tracking files: the current files may contain changes from other installations. Use your original installer or seek help if you are unsure how to reconcile them.

## Five actions to remember

Start with `ph` in a terminal. Type to search, inspect the full name and preview, press Enter to use, or Escape to cancel. Press Tab to select an action, then use left and right to choose use, show the original text, or edit the saved prompt. Tab returns to search; left and right move the search cursor there. If a chosen template needs declared values, fill only the missing fields, inspect the final preview, and press Enter to finish. Prompts with all values already available keep the single Enter-to-use path. No selection is copied before you accept it. With no saved prompts, the picker offers a creation example.
Expand Down
53 changes: 37 additions & 16 deletions scripts/qa/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,39 @@ def meta(version=None, source=registry):
pathlib.Path(os.environ['FAKE_READY']).write_text('ready')
import time; time.sleep(60)
if mode=='failure': sys.exit(7)
if mode=='default_failure' and initial and '--target' not in args: sys.exit(7)
if mode=='default_failure_before' and not stage: sys.exit(7)
if initial:
(root/'bin').mkdir(parents=True,exist_ok=True)
if mode=='mismatch': (root/'bin/ph').write_bytes(b'unrelated')
if mode=='mismatch' or (mode.startswith('default_') and '--target' in args): (root/'bin/ph').write_bytes(b'unrelated')
else: shutil.copy2(os.environ['FAKE_ORIGINAL'],root/'bin/ph')
(root/'.crates2.json').write_text(os.environ['FAKE_META'])
data=json.loads(os.environ['FAKE_NEW_META'] if mode=='explicit_wrong_version' else os.environ['FAKE_META'])
if mode=='default_wrong_target' and '--target' not in args:
next(iter(data['installs'].values()))['target']='aarch64-unknown-linux-gnu'
if mode=='explicit_wrong_target':
next(iter(data['installs'].values()))['target']='aarch64-unknown-linux-gnu'
(root/'.crates2.json').write_text(json.dumps(data))
else:
if mode in ('updated','declined','false_success','advance','wrong_bytes','failure_after','race','signal') and (stage or mode not in ('false_success','wrong_bytes','failure_after','race','signal')):
if mode in ('updated','declined','false_success','advance','wrong_bytes','failure_after','race','signal','default_updated','default_stage_target','default_final_target','default_failure_before','default_failure_after') and (stage or mode not in ('false_success','wrong_bytes','failure_after','race','signal','default_failure_after')):
replacement=root/'bin/new-ph'
replacement.write_text('#!/bin/sh\nprintf "ph %s\\n"\n' % os.environ['FAKE_NEXT'])
replacement.chmod(0o700)
replacement.replace(root/'bin/ph')
(root/'.crates2.json').write_text(os.environ['FAKE_NEW_META'])
data=json.loads(os.environ['FAKE_NEW_META'])
if (mode=='default_stage_target' and stage) or (mode=='default_final_target' and not stage):
next(iter(data['installs'].values()))['target']='aarch64-unknown-linux-gnu'
(root/'.crates2.json').write_text(json.dumps(data))
elif mode=='false_success' and not stage:
(root/'.crates2.json').write_text(os.environ['FAKE_NEW_META'])
elif mode in ('wrong_bytes','failure_after','race') and not stage:
elif mode in ('wrong_bytes','failure_after','race','default_failure_after') and not stage:
replacement=root/'bin/new-ph'
replacement.write_text('#!/bin/sh\nprintf "ph %s\\n"\n# unexpected bytes\n' % os.environ['FAKE_NEXT'])
replacement.chmod(0o700)
replacement.replace(root/'bin/ph')
(root/'.crates2.json').write_text(os.environ['FAKE_NEW_META'])
if mode in ('failure_after','race','signal'): sys.exit(7)
if mode in ('failure_after','race','signal','default_failure_after'): sys.exit(7)
'''
for mode in ('updated','current','mismatch','failure','false_success','wrong_source','declined','non_tty','advance','wrong_bytes','failure_after','race','signal','alias'):
for mode in ('default_updated','default_current','default_wrong_target','default_stage_target','default_final_target','default_failure','default_failure_before','default_failure_after','explicit_wrong_version','explicit_wrong_target','updated','current','mismatch','failure','false_success','wrong_source','declined','non_tty','advance','wrong_bytes','failure_after','race','signal','alias'):
with tempfile.TemporaryDirectory(prefix='ph-update-test-') as tmp:
base=pathlib.Path(tmp)
if mode=='alias':
Expand Down Expand Up @@ -97,24 +107,35 @@ def meta(version=None, source=registry):
if slave is not None: os.close(slave)
log=[json.loads(line) for line in (home/'log').read_text().splitlines()] if (home/'log').exists() else []
assert result.stdout==b'',(mode,result.stdout)
if mode in ('updated','advance','alias'):
assert result.returncode==0 and f'updated: ph {next_version}'.encode() in result.stderr and len(log)==3,(mode,result.stderr,log)
elif mode=='current':
assert result.returncode==0 and f'ph {current} is already current'.encode() in result.stderr and len(log)==2,(mode,result.stderr,log)
if mode in ('updated','advance','alias','default_updated'):
assert b'If unsure, answer no and use your original installer.' in result.stderr,(mode,result.stderr)
assert result.returncode==0 and f'updated: ph {next_version}'.encode() in result.stderr and len(log)==(4 if mode=='default_updated' else 3),(mode,result.stderr,log)
elif mode in ('current','default_current'):
assert result.returncode==0 and f'ph {current} is already current'.encode() in result.stderr and len(log)==(3 if mode=='default_current' else 2),(mode,result.stderr,log)
else:
assert result.returncode!=0,(mode,result.stderr,log)
if mode in ('mismatch','failure','wrong_source','declined','non_tty'):
if mode in ('mismatch','failure','wrong_source','declined','non_tty','default_wrong_target','default_stage_target','default_failure','default_failure_before','explicit_wrong_version','explicit_wrong_target'):
assert exe.read_bytes()==original
assert len(log)==(0 if mode=='wrong_source' else 1 if mode in ('mismatch','failure') else 2 if mode in ('declined','non_tty') else 3),(mode,log)
if mode in ('updated','advance','alias'):
assert len(log)==(0 if mode=='wrong_source' else 1 if mode in ('failure','explicit_wrong_version','explicit_wrong_target') else 2 if mode in ('mismatch','declined','non_tty','default_wrong_target','default_failure') else 4 if mode in ('default_final_target','default_failure_before','default_failure_after') else 3),(mode,log)
if mode in ('default_failure','default_failure_before','default_failure_after'):
assert b'Cargo failed' in result.stderr and b'check any Cargo output above and your Cargo setup, or use the original installer' in result.stderr,(mode,result.stderr)
if mode=='default_stage_target':
assert b'Cargo selected a different compilation target; no update was started' in result.stderr,(mode,result.stderr)
if mode in ('explicit_wrong_version','explicit_wrong_target'):
assert b'Cargo rebuilt a different version or target; no update was started' in result.stderr,(mode,result.stderr)
if mode in ('updated','advance','alias','default_updated','default_final_target'):
assert log[-1][log[-1].index('--version')+1] == f'={next_version}'
if mode=='advance': assert (home/'registry').read_text()=='99.0.2'
if mode=='race': assert exe.read_bytes()==b'external installer replacement'
if mode=='signal': assert exe.read_bytes()==b'partial installation'
backups=list(root.glob('ph-update-backup-*'))
if mode in ('false_success','wrong_bytes','failure_after','race','signal'):
assert len(backups)==1 and (backups[0]/'ph').read_bytes()==original and (backups[0]/'.crates2.json').read_bytes()==json.dumps(initial).encode() and (backups[0]/'.crates.toml').read_bytes()==b'[v1]\nfixture = []\n' and (b'The installation may have changed' in result.stderr if mode!='signal' else b'recovery copy:' in result.stderr),(mode,result.stderr,backups)
if mode in ('false_success','wrong_bytes','failure_after','race','signal','default_final_target','default_failure_before','default_failure_after'):
assert len(backups)==1 and (backups[0]/'ph').read_bytes()==original and (backups[0]/'.crates2.json').read_bytes()==json.dumps(initial).encode() and (backups[0]/'.crates.toml').read_bytes()==b'[v1]\nfixture = []\n' and (b'The installation may have changed' in result.stderr and b'see Update recovery in the README before replacing files' in result.stderr if mode!='signal' else b'recovery copy:' in result.stderr),(mode,result.stderr,backups)
else: assert not backups,(mode,backups)
for args in log:
assert '--force' not in args and '--locked' in args and '--no-default-features' in args and args[args.index('--features')+1]=='tui' and args[args.index('--registry')+1]=='crates-io',(mode,args)
if mode.startswith('default_') or mode=='mismatch':
assert '--target' in log[0] and all('--target' not in args for args in log[1:]),(mode,log)
else:
assert all('--target' in args for args in log),(mode,log)
print(f'PASS {mode}: cargo invocations={len(log)}, exit={result.returncode}, stdout empty')
76 changes: 61 additions & 15 deletions src/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,24 +116,24 @@ fn digest(path: &Path) -> Result<[u8; 32]> {
Ok(hash.finalize().into())
}

fn installed_version(root: &Path) -> Result<String> {
fn installed_release(root: &Path) -> Result<(String, String)> {
let data: Metadata = serde_json::from_str(&metadata(root)?).context(HELP)?;
let mut owners = data
.installs
.iter()
.filter(|(_, item)| item.bins.iter().any(|bin| bin == "ph"));
let (key, _) = owners.next().context(HELP)?;
let (key, item) = owners.next().context(HELP)?;
if owners.next().is_some() {
bail!(HELP);
}
let version = key
.strip_prefix("prompthive ")
.and_then(|v| v.strip_suffix(&format!(" ({REGISTRY})")))
.context(HELP)?;
if !safe_arg(version) {
if !safe_arg(version) || !safe_arg(&item.target) {
bail!(HELP);
}
Ok(version.to_owned())
Ok((version.to_owned(), item.target.clone()))
}

fn cargo(args: &[OsString]) -> Result<()> {
Expand All @@ -143,7 +143,7 @@ fn cargo(args: &[OsString]) -> Result<()> {
.status()
.context("could not start Cargo")?;
if !status.success() {
bail!("Cargo failed ({status}); inspect its output above");
bail!("Cargo failed ({status}); check any Cargo output above and your Cargo setup, or use the original installer");
}
Ok(())
}
Expand Down Expand Up @@ -184,15 +184,24 @@ pub fn run() -> Result<()> {
let root = exe.parent().and_then(Path::parent).context(HELP)?;
let original_metadata = metadata(root)?;
let original_tracking = tracking_file(root, ".crates.toml")?;
let (_, args) = plan(&exe, &original_metadata)?;
let (_, mut args) = plan(&exe, &original_metadata)?;
let target_index = args
.iter()
.position(|arg| arg == "--target")
.context(HELP)?;
let expected_target = args
.get(target_index + 1)
.and_then(|arg| arg.to_str())
.context(HELP)?
.to_owned();
let before = digest(&exe).context(HELP)?;

// Cargo's metadata alone is not proof of the current bytes. Rebuild the
// installed version separately and proceed only for an exact byte match.
// Different toolchains/build paths may not reproduce; those users get a
// manual-update fallback rather than a forced replacement.
eprintln!("checking this Cargo installation with a temporary rebuild...");
let staging = tempfile::Builder::new()
let mut staging = tempfile::Builder::new()
.prefix("ph-update-check-")
.tempdir()?;
let mut check_args = args.clone();
Expand All @@ -208,16 +217,46 @@ pub fn run() -> Result<()> {
]);
cargo(&check_args)?;
let staged = staging.path().join("bin").join("ph");
if installed_version(staging.path())? != env!("CARGO_PKG_VERSION") || digest(&staged)? != before
{
bail!("the installed binary does not match a reproducible Cargo build; update with the original installer");
let (version, target) = installed_release(staging.path())?;
if version != env!("CARGO_PKG_VERSION") || target != expected_target {
bail!("Cargo rebuilt a different version or target; no update was started");
}
if digest(&staged)? != before {
// Cargo records the target but not whether --target was supplied.
// A default-target build can differ even for the same host triple.
eprintln!("checking the installation with Cargo's default target...");
let fallback = tempfile::Builder::new()
.prefix("ph-update-check-")
.tempdir()?;
let mut default_args = args.clone();
default_args.drain(target_index..target_index + 2);
let mut default_check = default_args.clone();
default_check[root_index] = fallback.path().as_os_str().to_owned();
default_check.extend([
"--version".into(),
format!("={}", env!("CARGO_PKG_VERSION")).into(),
]);
cargo(&default_check)?;
let (version, target) = installed_release(fallback.path())?;
if version != env!("CARGO_PKG_VERSION")
|| target != expected_target
|| digest(&fallback.path().join("bin").join("ph"))? != before
{
bail!("the installed binary does not match a reproducible Cargo build; update with the original installer");
}
args = default_args;
staging = fallback;
}
let staged = staging.path().join("bin").join("ph");
// Build the prospective version in the same temporary root first. Cargo,
// its configured registry and build environment are trusted inputs.
let mut newest_args = args.clone();
newest_args[root_index] = staging.path().as_os_str().to_owned();
cargo(&newest_args)?;
let target_version = installed_version(staging.path())?;
let (target_version, staged_target) = installed_release(staging.path())?;
if staged_target != expected_target {
bail!("Cargo selected a different compilation target; no update was started");
}
let target_digest = digest(&staged)?;
let staged_version = Command::new(&staged)
.arg("--version")
Expand All @@ -238,7 +277,11 @@ pub fn run() -> Result<()> {
if !std::io::stdin().is_terminal() {
bail!("an interactive terminal is required to confirm ownership before replacing this executable");
}
eprint!("Cargo metadata cannot prove who manages {}. Close other installers first. Update your Cargo-managed installation to ph {target_version}? [y/N] ", exe.display());
eprintln!("Cargo metadata cannot prove who manages {}.", exe.display());
eprintln!(
"Close other installers first. If unsure, answer no and use your original installer."
);
eprint!("Update this Cargo-managed installation to ph {target_version}? [y/N] ");
std::io::stderr().flush()?;
let mut answer = String::new();
std::io::stdin().lock().take(16).read_line(&mut answer)?;
Expand Down Expand Up @@ -271,9 +314,12 @@ pub fn run() -> Result<()> {
install_args.extend(["--version".into(), format!("={target_version}").into()]);
let result = (|| -> Result<String> {
cargo(&install_args)?;
let version = installed_version(root)
let (version, installed_target) = installed_release(root)
.context("Cargo finished, but its installed version could not be verified")?;
if version != target_version || digest(&exe)? != target_digest {
if version != target_version
|| installed_target != expected_target
|| digest(&exe)? != target_digest
{
bail!(
"Cargo finished, but the executable could not be verified against the staged build"
);
Expand All @@ -284,7 +330,7 @@ pub fn run() -> Result<()> {
Ok(version) => version,
Err(error) => {
let path = backup.keep();
bail!("{error:#}. The installation may have changed. A copy of the previous executable and Cargo metadata is saved at {}; inspect it before recovery; nothing was automatically restored", path.display());
bail!("{error:#}. The installation may have changed. A copy of the previous executable and Cargo metadata is saved at {}; nothing was automatically restored; see Update recovery in the README before replacing files", path.display());
}
};
if version == env!("CARGO_PKG_VERSION") {
Expand Down
Loading