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
1 change: 1 addition & 0 deletions 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 GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ The digest reports changes in watched Repositories.

**Is:** replacing the skilld CLI executable with a newer release of the skilld CLI.

**Use for:** the upgrade notice, the standalone background upgrade, and `SKILLD_NO_UPGRADE`.
**Use for:** the upgrade banner, the upgrade prompt, executable replacement, and `SKILLD_NO_UPGRADE`.

**Never:** update, self-update. `update` means a Skill update.

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,14 @@ The script installs one native binary to `~/.skilld/bin`.
It adds that directory to PATH: in your shell profile on macOS and Linux, in your user PATH on Windows.
On macOS and Linux, set `SKILLD_NO_MODIFY_PATH=1` to leave your shell profile alone.

The script install upgrades itself in the background after it verifies the signed release manifest.
skilld checks for CLI upgrades in the background once a day.
If a newer release is cached, the next terminal invocation offers to upgrade.
Choose `Upgrade now`, `Not now`, or `Don't remind me for this version`.
The script install verifies the signed release manifest before replacing the executable.
Restart skilld to use the new version.
An npm install prints the upgrade command instead.
Recognized global npm, pnpm, Yarn, and Bun installs run their package manager after you confirm.
Local and transient installs show upgrade guidance instead.
After an upgrade, run your command again.
Set `SKILLD_NO_UPGRADE=1` to turn off upgrade checks.
See [SECURITY.md](./SECURITY.md#how-the-cli-upgrades-itself) for each check.

Expand Down
7 changes: 5 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ The release manifest names the CLI version and the SHA-256 digest of each releas
The public release key is compiled into the CLI.
The private release key exists only as a GitHub Actions secret, in the one step that signs.

A standalone install upgrades itself in the background. Before it replaces the executable, the CLI checks:
A standalone install checks for releases in the background and asks before upgrading.
Before it replaces the executable, the CLI checks:

- the release manifest signature against the compiled release key
- that the signed version is the requested version and is newer than the running version
Expand All @@ -45,7 +46,9 @@ Set `SKILLD_NO_UPGRADE=1` to turn off upgrade checks.

`install.sh` checks the same signature when OpenSSL supports Ed25519.
Otherwise, `install.sh` and `install.ps1` trust the HTTPS download and the SHA-256 digest for the first install.
npm installs never upgrade themselves. They print the upgrade command instead.
Recognized global package installations run their package manager only after you confirm the displayed command.
The command installs the exact version shown in the prompt.
Local and transient package installations show upgrade guidance instead.

## Account credentials

Expand Down
90 changes: 8 additions & 82 deletions crates/skilld-command/src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ use skilld_core::is_release_version;

/// How long a latest-version check stays fresh.
pub const CHECK_INTERVAL_SECONDS: u64 = 24 * 60 * 60;
/// How long a failed standalone upgrade waits before it retries the same version.
pub const RETRY_INTERVAL_SECONDS: u64 = 60 * 60;
/// The largest release binary skilld downloads.
pub const MAX_BINARY_BYTES: usize = 64 * 1024 * 1024;
pub const RELEASE_MANIFEST_ASSET: &str = "skilld-release.txt";
Expand Down Expand Up @@ -49,16 +47,6 @@ impl PackageRunner {
_ => None,
}
}

const fn upgrade_command(self) -> &'static str {
match self {
Self::Npx => "npx skilld@latest",
Self::Npm => "npm install --global skilld",
Self::Pnpm => "pnpm add --global skilld",
Self::Yarn => "yarn global add skilld",
Self::Bun => "bun add --global skilld",
}
}
}

/// The upgrade state skilld keeps in its data directory.
Expand All @@ -71,11 +59,7 @@ pub struct UpgradeState {
#[serde(default)]
pub latest: Option<String>,
#[serde(default)]
pub attempted_version: Option<String>,
#[serde(default)]
pub attempted_at: Option<u64>,
/// The error code of the last failed check or upgrade, for diagnosis.
#[serde(default)]
/// The error code of the last failed check, for diagnosis.
pub last_error: Option<String>,
}

Expand All @@ -89,42 +73,11 @@ impl UpgradeState {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpgradeWorker {
Check,
Install { version: String },
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpgradeNotice {
Available {
version: String,
command: String,
},
Installing {
version: String,
last_error: Option<String>,
},
}

impl UpgradeNotice {
pub fn message(&self) -> String {
match self {
Self::Available { version, command } => {
format!("skilld {version} is available. Run {command} to upgrade.")
}
Self::Installing {
version,
last_error: Some(code),
} => format!(
"The last upgrade attempt failed: {code}. \
Retrying skilld {version} in the background. Restart skilld to use it."
),
Self::Installing {
version,
last_error: None,
} => format!(
"Upgrading skilld to {version} in the background. Restart skilld to use it."
),
}
}
pub struct UpgradeNotice {
pub version: String,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
Expand Down Expand Up @@ -155,38 +108,11 @@ pub fn plan_upgrade(
else {
return check;
};
match channel {
InstallChannel::Npm(runner) => UpgradePlan {
worker: check.worker,
notice: Some(UpgradeNotice::Available {
version: latest.to_owned(),
command: runner.upgrade_command().to_owned(),
}),
},
InstallChannel::Standalone => {
let retry = state.attempted_version.as_deref() == Some(latest);
let waiting = retry
&& state.attempted_at.is_some_and(|attempted| {
attempted <= now && now - attempted < RETRY_INTERVAL_SECONDS
});
if waiting {
return check;
}
UpgradePlan {
worker: Some(UpgradeWorker::Install {
version: latest.to_owned(),
}),
notice: Some(UpgradeNotice::Installing {
version: latest.to_owned(),
last_error: if retry {
state.last_error.clone()
} else {
None
},
}),
}
}
InstallChannel::Unmanaged => UpgradePlan::default(),
UpgradePlan {
worker: check.worker,
notice: Some(UpgradeNotice {
version: latest.to_owned(),
}),
}
}

Expand Down
123 changes: 26 additions & 97 deletions crates/skilld-command/tests/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ use skilld_command::upgrade::{
const DAY: u64 = 86_400;
const NOW: u64 = 1_800_000_000;

#[test]
fn a_known_standalone_upgrade_never_installs_without_an_answer() {
let plan = plan_upgrade(
"3.0.0",
&InstallChannel::Standalone,
&state(NOW - 60, Some("3.1.0")),
NOW,
);

assert_eq!(plan.worker, None);
assert!(plan.notice.is_some());
}

fn state(checked_at: u64, latest: Option<&str>) -> UpgradeState {
UpgradeState {
checked_at,
latest: latest.map(str::to_owned),
attempted_version: None,
attempted_at: None,
last_error: None,
}
}
Expand Down Expand Up @@ -51,109 +62,27 @@ fn a_stale_check_refreshes_in_the_background_without_a_notice() {
}

#[test]
fn an_npm_install_names_the_upgrade_command_for_its_runner() {
fn a_cached_newer_release_offers_an_upgrade_for_every_managed_channel() {
let fresh = state(NOW - 60, Some("3.1.0"));
for (runner, command) in [
(PackageRunner::Npx, "npx skilld@latest"),
(PackageRunner::Npm, "npm install --global skilld"),
(PackageRunner::Pnpm, "pnpm add --global skilld"),
(PackageRunner::Yarn, "yarn global add skilld"),
(PackageRunner::Bun, "bun add --global skilld"),
for channel in [
InstallChannel::Standalone,
InstallChannel::Npm(PackageRunner::Npx),
InstallChannel::Npm(PackageRunner::Npm),
InstallChannel::Npm(PackageRunner::Pnpm),
InstallChannel::Npm(PackageRunner::Yarn),
InstallChannel::Npm(PackageRunner::Bun),
] {
let plan = plan_upgrade("3.0.0", &InstallChannel::Npm(runner), &fresh, NOW);

let plan = plan_upgrade("3.0.0", &channel, &fresh, NOW);
assert_eq!(plan.worker, None);
let notice = plan.notice.expect("a newer version shows a notice");
assert_eq!(
notice,
UpgradeNotice::Available {
version: "3.1.0".to_owned(),
command: command.to_owned(),
}
);
assert_eq!(
notice.message(),
format!("skilld 3.1.0 is available. Run {command} to upgrade.")
plan.notice,
Some(UpgradeNotice {
version: "3.1.0".to_owned()
})
);
}
}

#[test]
fn a_standalone_install_upgrades_itself_and_asks_for_a_restart() {
let plan = plan_upgrade(
"3.0.0",
&InstallChannel::Standalone,
&state(NOW - 60, Some("3.1.0")),
NOW,
);

assert_eq!(
plan.worker,
Some(UpgradeWorker::Install {
version: "3.1.0".to_owned()
})
);
assert_eq!(
plan.notice.unwrap().message(),
"Upgrading skilld to 3.1.0 in the background. Restart skilld to use it."
);
}

#[test]
fn a_recent_failed_upgrade_waits_before_it_retries() {
let mut recent = state(NOW - 60, Some("3.1.0"));
recent.attempted_version = Some("3.1.0".to_owned());
recent.attempted_at = Some(NOW - 60);

assert_eq!(
plan_upgrade("3.0.0", &InstallChannel::Standalone, &recent, NOW),
UpgradePlan::default()
);

recent.attempted_at = Some(NOW - DAY);
assert!(matches!(
plan_upgrade("3.0.0", &InstallChannel::Standalone, &recent, NOW).worker,
Some(UpgradeWorker::Install { .. })
));
}

#[test]
fn a_retry_after_a_failed_upgrade_names_the_failure_in_its_notice() {
let mut failed = state(NOW - 60, Some("3.1.0"));
failed.attempted_version = Some("3.1.0".to_owned());
failed.attempted_at = Some(NOW - DAY);
failed.last_error = Some("UPGRADE_DOWNLOAD_FAILED".to_owned());

let plan = plan_upgrade("3.0.0", &InstallChannel::Standalone, &failed, NOW);

assert_eq!(
plan.notice.map(|notice| notice.message()),
Some(
"The last upgrade attempt failed: UPGRADE_DOWNLOAD_FAILED. \
Retrying skilld 3.1.0 in the background. Restart skilld to use it."
.to_owned()
)
);
}

#[test]
fn a_first_install_ignores_a_failure_from_another_version() {
let mut failed = state(NOW - 60, Some("3.2.0"));
failed.attempted_version = Some("3.1.0".to_owned());
failed.attempted_at = Some(NOW - DAY);
failed.last_error = Some("UPGRADE_DOWNLOAD_FAILED".to_owned());

let plan = plan_upgrade("3.0.0", &InstallChannel::Standalone, &failed, NOW);

assert_eq!(
plan.notice,
Some(UpgradeNotice::Installing {
version: "3.2.0".to_owned(),
last_error: None,
})
);
}

#[test]
fn versions_compare_by_number_and_never_downgrade() {
let channel = InstallChannel::Npm(PackageRunner::Npm);
Expand Down
2 changes: 2 additions & 0 deletions crates/skilld-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ path = "src/main.rs"
[dependencies]
gethostname.workspace = true

fs4.workspace = true

skilld-auth.workspace = true

skilld-command.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/skilld-native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod select_ui;
pub mod update_ui;
#[cfg(not(target_os = "wasi"))]
pub mod upgrade;
pub mod upgrade_ui;
pub mod weekly;

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down
Loading