Skip to content
Open
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
4 changes: 3 additions & 1 deletion crates/nono-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,8 @@ pub fn print_profile_hint(program: &str, pack_ref: &str, silent: bool) {
return;
}

let pull_ref = crate::package_status::canonicalize_legacy_pack_ref(pack_ref)
.unwrap_or_else(|| pack_ref.to_string());
let t = theme::current();
eprintln!(
" {}",
Expand All @@ -1306,7 +1308,7 @@ pub fn print_profile_hint(program: &str, pack_ref: &str, silent: bool) {
" {}",
fg(
&format!(
"Try: nono run --profile {pack_ref} -- {program} (install first: nono pull {pack_ref})"
"Try: nono run --profile {pack_ref} -- {program} (install first: nono pull {pull_ref})"
),
t.subtext,
)
Expand Down
33 changes: 27 additions & 6 deletions crates/nono-cli/src/package_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ const CODEX_PACK: OfficialPackStatusTarget = OfficialPackStatusTarget {
const OFFICIAL_PACK_STATUS_TARGETS: &[OfficialPackStatusTarget] = &[CLAUDE_PACK, CODEX_PACK];

impl OfficialPackStatusTarget {
fn key(self) -> String {
format!("{}/{}", self.namespace, self.name)
}

/// Every namespace this pack has been published under, current one first.
fn namespaces(self) -> impl Iterator<Item = &'static str> {
std::iter::once(self.namespace).chain(self.legacy_namespaces.iter().copied())
Expand Down Expand Up @@ -62,6 +58,29 @@ pub(crate) fn official_claude_pack_namespaces() -> impl Iterator<Item = &'static
CLAUDE_PACK.namespaces()
}

/// If `pack_ref` (`<namespace>/<name>` or `<namespace>/<name>@version`)
/// addresses an official pack through a retired namespace, return the same
/// reference rewritten to the pack's current namespace.
///
/// Used to point remediation commands (`nono pull ...`) at a namespace that
/// still resolves, instead of echoing back one the registry no longer serves
/// packs under.
pub(crate) fn canonicalize_legacy_pack_ref(pack_ref: &str) -> Option<String> {
let (head, version) = match pack_ref.split_once('@') {
Some((h, v)) => (h, Some(v)),
None => (pack_ref, None),
};
let (namespace, name) = head.split_once('/')?;
let target = OFFICIAL_PACK_STATUS_TARGETS
.iter()
.find(|t| t.name == name && t.legacy_namespaces.contains(&namespace))?;
let canonical = format!("{}/{}", target.namespace, target.name);
Some(match version {
Some(v) => format!("{canonical}@{v}"),
None => canonical,
})
}

/// Enforce official-pack status for the profile this run selected.
///
/// `cli_extends` carries `--extends`, which behaves as if those bases were
Expand All @@ -86,8 +105,10 @@ pub(crate) fn enforce_for_active_profile(

fn enforce_official_pack_status(target: OfficialPackStatusTarget, silent: bool) -> Result<()> {
let lockfile = package::read_lockfile()?;
let key = target.key();
let Some(locked) = lockfile.packages.get(&key) else {
let Some((key, locked)) = target
.keys()
.find_map(|k| lockfile.packages.get(&k).map(|locked| (k, locked)))
else {
return Ok(());
};

Expand Down
6 changes: 4 additions & 2 deletions crates/nono-cli/src/profile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3015,11 +3015,13 @@ fn load_registry_profile(name_or_path: &str, cli_extends: &[String]) -> Result<P
})?;

if !manifest.has_profile_artifact() {
let key = package_ref.key();
let pull_ref = crate::package_status::canonicalize_legacy_pack_ref(&key)
.unwrap_or_else(|| key.clone());
return Err(NonoError::ProfileParse(format!(
"pack '{}' has no profile artifact and cannot be used with --profile.\n\
Use 'nono pull {}' to install it instead.",
package_ref.key(),
package_ref.key()
key, pull_ref
)));
}

Expand Down
135 changes: 126 additions & 9 deletions crates/nono-cli/src/profile_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,33 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
}
let (namespace, name) = (parts[0], parts[1]);

// pack_ref may use a namespace retired by a registry rename (e.g.
// `always-further/claude` -> `nolabs-ai/claude`); remediation should
// point at the namespace that still resolves.
let pull_ref = crate::package_status::canonicalize_legacy_pack_ref(pack_ref)
.unwrap_or_else(|| pack_ref.clone());

// No auto-migration — just flag it, even when the pack is otherwise fine.
if pull_ref != *pack_ref {
tracing::warn!(
Comment thread
SequeI marked this conversation as resolved.
"Pack '{}' is installed under a namespace that has been retired. \
Migrate with: nono remove {} && nono pull {}, then update any \
'extends'/'packs' entries in your profile from '{}' to '{}'.",
pack_ref,
pack_ref,
pull_ref,
pack_ref,
pull_ref
);
}

let install_dir = package::package_install_dir(namespace, name)?;
if !install_dir.exists() {
tracing::warn!(
"Pack '{}' declared by profile but not installed. \
Install it with: nono pull {}",
pack_ref,
pack_ref
pull_ref
);
continue;
}
Expand All @@ -134,7 +154,7 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
package: pack_ref.clone(),
reason: format!(
"pack '{}' has no lockfile entry - reinstall with: nono pull {} --force",
pack_ref, pack_ref
pack_ref, pull_ref
),
}
})?;
Expand All @@ -144,7 +164,7 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
if !artifact_path.exists() {
return Err(nono::NonoError::PackageInstall(format!(
"pack '{}' is missing artifact '{}'. Reinstall with: nono pull {} --force",
pack_ref, artifact_name, pack_ref
pack_ref, artifact_name, pull_ref
)));
}

Expand All @@ -165,7 +185,7 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
Expected: {}\n\
Found: {}\n\
Reinstall with: nono pull {} --force",
pack_ref, artifact_name, locked_artifact.sha256, hash, pack_ref
pack_ref, artifact_name, locked_artifact.sha256, hash, pull_ref
)));
}
}
Expand Down Expand Up @@ -206,7 +226,7 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
package: pack_ref.clone(),
reason: format!(
"pack '{}' is missing .nono-trust.bundle - reinstall with: nono pull {} --force",
pack_ref, pack_ref
pack_ref, pull_ref
),
});
}
Expand All @@ -219,10 +239,16 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate::
package: pack_ref.clone(),
reason: format!(
"pack '{}' has no signer identity in the lockfile - reinstall with: nono pull {} --force",
pack_ref, pack_ref
pack_ref, pull_ref
),
})?;
verify_stored_bundles(&install_dir, &bundle_path, pack_ref, Some(pinned_signer))?;
verify_stored_bundles(
&install_dir,
&bundle_path,
pack_ref,
&pull_ref,
Some(pinned_signer),
)?;
}

Ok(())
Expand All @@ -237,6 +263,7 @@ fn verify_stored_bundles(
install_dir: &Path,
bundle_path: &Path,
pack_ref: &str,
pull_ref: &str,
pinned_signer: Option<&str>,
) -> crate::Result<()> {
let bundle_content = std::fs::read_to_string(bundle_path).map_err(|e| {
Expand Down Expand Up @@ -339,7 +366,7 @@ fn verify_stored_bundles(
nono::NonoError::PackageInstall(format!(
"Sigstore verification failed for '{}' in pack '{}': {}\n\
Reinstall with: nono pull {} --force",
artifact_name, pack_ref, e, pack_ref
artifact_name, pack_ref, e, pull_ref
))
})?;

Expand Down Expand Up @@ -367,7 +394,7 @@ fn verify_stored_bundles(
reason: format!(
"signer identity mismatch for '{}': bundle was signed by '{}' \
but lockfile pins '{}'. Reinstall with: nono pull {} --force",
artifact_name, verified_uri, pinned, pack_ref
artifact_name, verified_uri, pinned, pull_ref
),
});
}
Expand Down Expand Up @@ -1556,6 +1583,64 @@ mod tests {
);
}

// #1634: a pack referenced under a retired namespace must be told to
// reinstall under the namespace that still resolves.
#[test]
fn test_missing_artifact_error_suggests_current_namespace_for_legacy_pack_ref() {
let result = with_config_env(|config_dir| {
let (_install_dir, mut artifacts) =
build_pack_with_scripts(config_dir, "always-further", "claude", &[]);
// Declare an artifact in the lockfile that was never written to
// disk, reproducing the "missing artifact" failure from the issue.
artifacts.insert(
"README.md".to_string(),
package::LockedArtifact {
sha256: "0".repeat(64),
artifact_type: package::ArtifactType::Profile,
},
);
write_test_lockfile(config_dir, &[("always-further/claude", artifacts)]);

verify_profile_packs(
&["always-further/claude".to_string()],
&profile::Profile::default(),
)
});

let message = result
.expect_err("missing artifact must be rejected")
.to_string();
assert!(
message.contains("nono pull nolabs-ai/claude --force"),
"expected remediation to suggest the current namespace, got: {message}"
);
assert!(
!message.contains("nono pull always-further/claude"),
"remediation must not suggest the retired namespace, got: {message}"
);
}

#[test]
fn test_canonicalize_legacy_pack_ref_only_rewrites_known_legacy_refs() {
assert_eq!(
crate::package_status::canonicalize_legacy_pack_ref("always-further/claude"),
Some("nolabs-ai/claude".to_string())
);
assert_eq!(
crate::package_status::canonicalize_legacy_pack_ref("always-further/claude@1.2.3"),
Some("nolabs-ai/claude@1.2.3".to_string())
);
// Already-canonical and unrelated refs pass through unchanged.
assert_eq!(
crate::package_status::canonicalize_legacy_pack_ref("nolabs-ai/claude"),
None
);
assert_eq!(
crate::package_status::canonicalize_legacy_pack_ref("acme/widget"),
None
);
}

// -------------------------------------------------------------------------
// Test 4: store-extends-store — each hook from its own pack's artifacts
//
Expand Down Expand Up @@ -1717,6 +1802,38 @@ mod tests {
);
}

// #1634: same fix, but through verify_stored_bundles's separate code path.
#[test]
fn test_missing_trust_bundle_error_suggests_current_namespace_for_legacy_pack_ref() {
let result = with_config_env(|config_dir| {
let artifact_content = r#"{"meta":{"name":"claude"}}"#;
let (_, artifacts) = build_pack_with_scripts(
config_dir,
"always-further",
"claude",
&[("package.json", artifact_content)],
);
write_test_lockfile(config_dir, &[("always-further/claude", artifacts)]);

verify_profile_packs(
&["always-further/claude".to_string()],
&profile::Profile::default(),
)
});

let message = result
.expect_err("locked pack without trust bundle must fail verification")
.to_string();
assert!(
message.contains("nono pull nolabs-ai/claude --force"),
"expected remediation to suggest the current namespace, got: {message}"
);
assert!(
!message.contains("nono pull always-further/claude"),
"remediation must not suggest the retired namespace, got: {message}"
);
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn active_command_policy_profile() -> profile::Profile {
profile::Profile {
Expand Down