Skip to main content

runx_cli/
registry.rs

1// rust-style-allow: large-file - native registry CLI keeps local and hosted
2// search/read/resolve/install/publish command wiring together so the command
3// matrix and output envelope stay auditable during the hosted-registry cutover.
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::ExitCode;
9
10use runx_runtime::ConfigError;
11use runx_runtime::registry::{
12    AcquireOptions, FileRegistryStore, IngestSkillOptions, InstallCandidate,
13    InstallLocalSkillOptions, LocalRegistryClient, PublishSkillMarkdownOptions, RegistryClient,
14    RegistryManifestSourceAuthority, RegistryPackageFile, RegistryResolveOptions,
15    RegistrySearchOptions, RegistrySkillResolution, TrustTier, TrustedRegistryManifestKey,
16    install_local_skill, publish_skill_markdown, read_registry_skill, resolve_registry_skill,
17    search_registry_with_options,
18};
19use runx_runtime::scaffold::{InitGeneratedValues, ensure_runx_install_state};
20
21mod output;
22mod package;
23mod remote_publish;
24mod target;
25
26pub(crate) use target::{
27    RegistryTarget, destination_root, official_skills_cache_root, registry_skills_cache_root,
28    registry_source_description, resolve_registry_target,
29};
30
31#[derive(Debug, Eq, PartialEq)]
32pub enum RegistryAction {
33    Search,
34    Read,
35    Resolve,
36    Install,
37    Publish,
38}
39
40#[derive(Debug, Eq, PartialEq)]
41pub struct RegistryPlan {
42    pub action: RegistryAction,
43    pub subject: String,
44    pub registry: Option<String>,
45    pub registry_dir: Option<PathBuf>,
46    pub version: Option<String>,
47    pub expected_digest: Option<String>,
48    pub destination: Option<PathBuf>,
49    pub owner: Option<String>,
50    pub profile: Option<PathBuf>,
51    pub trust_tier: Option<TrustTier>,
52    pub limit: Option<usize>,
53    pub upsert: bool,
54    pub json: bool,
55}
56
57pub fn run_native_registry(plan: RegistryPlan) -> ExitCode {
58    let json = plan.json;
59    match run_registry(plan) {
60        Ok(output) => crate::cli_io::write_stdout_code(&output.stdout, output.exit_code),
61        Err(error) => {
62            if json {
63                return crate::cli_io::write_stdout_code(
64                    &crate::router::json_failure_output(&error.message, error.code()),
65                    error.exit_code,
66                );
67            }
68            let _ignored = crate::cli_io::write_stderr(&format!("\n  ✗  {}\n\n", error.message));
69            ExitCode::from(error.exit_code)
70        }
71    }
72}
73
74struct RegistryCliOutput {
75    stdout: String,
76    exit_code: u8,
77}
78
79fn run_registry(plan: RegistryPlan) -> Result<RegistryCliOutput, RegistryCliError> {
80    let env = env_map();
81    let cwd = env::current_dir().map_err(|error| internal_error(error.to_string()))?;
82    let target = resolve_registry_target(&plan, &env, &cwd);
83    match plan.action {
84        RegistryAction::Search => run_search(plan, target),
85        RegistryAction::Read => run_read(plan, target),
86        RegistryAction::Resolve => run_resolve(plan, target),
87        RegistryAction::Install => run_install(plan, target, &env, &cwd),
88        RegistryAction::Publish => run_publish(plan, target, &env, &cwd),
89    }
90}
91
92fn run_search(
93    plan: RegistryPlan,
94    target: RegistryTarget,
95) -> Result<RegistryCliOutput, RegistryCliError> {
96    let source = target.label();
97    let query = plan.subject;
98    let results = match target {
99        RegistryTarget::Remote { registry_url } => RegistryClient::new(&registry_url)?
100            .search_with_limit(&query, plan.limit.unwrap_or(20))?,
101        RegistryTarget::Local {
102            registry_path,
103            registry_url,
104            ..
105        } => search_registry_with_options(
106            &FileRegistryStore::new(registry_path),
107            &query,
108            RegistrySearchOptions {
109                limit: plan.limit,
110                registry_url,
111            },
112        )?,
113    };
114    let human = output::render_search(&query, source, &results);
115    output::write_output(
116        plan.json,
117        &output::RegistryEnvelope {
118            status: "success",
119            registry: output::RegistryPayload::Search {
120                source,
121                query: query.clone(),
122                results,
123            },
124        },
125        || human,
126    )
127}
128
129fn run_read(
130    plan: RegistryPlan,
131    target: RegistryTarget,
132) -> Result<RegistryCliOutput, RegistryCliError> {
133    let source = target.label();
134    let skill = match target {
135        RegistryTarget::Remote { registry_url } => RegistryClient::new(&registry_url)?
136            .read(&plan.subject, plan.version.as_deref())?
137            .ok_or_else(|| not_found(&plan.subject))?,
138        RegistryTarget::Local {
139            registry_path,
140            registry_url,
141            ..
142        } => read_registry_skill(
143            &FileRegistryStore::new(registry_path),
144            &plan.subject,
145            plan.version.as_deref(),
146            registry_url.as_deref(),
147        )?
148        .ok_or_else(|| not_found(&plan.subject))?,
149    };
150    let human = output::render_read(source, &plan.subject, &skill);
151    output::write_output(
152        plan.json,
153        &output::RegistryEnvelope {
154            status: "success",
155            registry: output::RegistryPayload::Read {
156                source,
157                r#ref: plan.subject,
158                skill: Box::new(skill),
159            },
160        },
161        || human,
162    )
163}
164
165fn run_resolve(
166    plan: RegistryPlan,
167    target: RegistryTarget,
168) -> Result<RegistryCliOutput, RegistryCliError> {
169    let source = target.label();
170    let resolution = match target {
171        RegistryTarget::Remote { registry_url } => {
172            let client = RegistryClient::new(&registry_url)?;
173            let resolved = client
174                .resolve_ref(&plan.subject, plan.version.as_deref())?
175                .ok_or_else(|| not_found(&plan.subject))?;
176            let detail = client
177                .read(&resolved.skill_id, resolved.version.as_deref())?
178                .ok_or_else(|| not_found(&resolved.skill_id))?;
179            output::RemoteOrLocalResolution::Remote(Box::new(detail))
180        }
181        RegistryTarget::Local {
182            registry_path,
183            registry_url,
184            ..
185        } => output::RemoteOrLocalResolution::Local(Box::new(
186            resolve_registry_skill(
187                &FileRegistryStore::new(registry_path),
188                &plan.subject,
189                RegistryResolveOptions {
190                    version: plan.version,
191                    registry_url,
192                },
193            )?
194            .ok_or_else(|| not_found(&plan.subject))?,
195        )),
196    };
197    let human = output::render_resolve(source, &plan.subject, &resolution);
198    output::write_output(
199        plan.json,
200        &output::RegistryEnvelope {
201            status: "success",
202            registry: output::RegistryPayload::Resolve {
203                source,
204                r#ref: plan.subject,
205                resolution,
206            },
207        },
208        || human,
209    )
210}
211
212fn run_install(
213    plan: RegistryPlan,
214    target: RegistryTarget,
215    env: &BTreeMap<String, String>,
216    cwd: &Path,
217) -> Result<RegistryCliOutput, RegistryCliError> {
218    let source = target.label();
219    let source_authority = target.manifest_source_authority();
220    let (candidate, acquisition) = install_candidate(&plan, target, env, cwd)?;
221    let install = install_local_skill(
222        &candidate,
223        &InstallLocalSkillOptions {
224            destination_root: destination_root(&plan, env, cwd),
225            expected_digest: plan.expected_digest,
226            trusted_manifest_keys: trusted_manifest_keys_from_env_for_source(
227                env,
228                source_authority,
229            )?,
230        },
231    )?;
232    let receipt_metadata = runx_runtime::registry_install_receipt_metadata(
233        runx_runtime::RegistryInstallMetadataInput {
234            candidate: &candidate,
235            install: &install,
236            acquisition: acquisition.as_ref(),
237        },
238    );
239    let human = output::render_install(
240        source,
241        &plan.subject,
242        &install,
243        candidate.signed_manifest.as_ref(),
244    );
245    output::write_output(
246        plan.json,
247        &output::RegistryEnvelope {
248            status: "success",
249            registry: output::RegistryPayload::Install {
250                source,
251                r#ref: plan.subject,
252                install: Box::new(install),
253                receipt_metadata,
254            },
255        },
256        || human,
257    )
258}
259
260// rust-style-allow: long-function - local and hosted publish share the same
261// package-read and harness gate before diverging at the storage boundary.
262fn run_publish(
263    plan: RegistryPlan,
264    target: RegistryTarget,
265    env: &BTreeMap<String, String>,
266    cwd: &Path,
267) -> Result<RegistryCliOutput, RegistryCliError> {
268    match target {
269        RegistryTarget::Remote { registry_url } => {
270            let package = package::read_skill_package(
271                &plan.subject,
272                plan.profile.as_deref(),
273                env,
274                cwd,
275                true,
276            )?;
277            let harness = package::run_publish_harness(
278                package.harness_path.as_deref(),
279                &package.harness_fixture_paths,
280            );
281            if let Some(temp_dir) = package.harness_temp_dir.as_ref() {
282                let _ignored = fs::remove_dir_all(temp_dir);
283            }
284            let harness = harness?;
285            let result = remote_publish::publish_remote_skill_package(
286                &registry_url,
287                &plan,
288                &package,
289                &harness,
290                env,
291                cwd,
292            )?;
293            output::write_output(
294                plan.json,
295                &output::RegistryEnvelope {
296                    status: "success",
297                    registry: output::RegistryPayload::Publish {
298                        publish: output::PublishPayload::Hosted(Box::new(result)),
299                    },
300                },
301                || "\n  registry publish  success\n\n".to_owned(),
302            )
303        }
304        RegistryTarget::Local {
305            registry_path,
306            registry_url,
307            ..
308        } => {
309            let package = package::read_skill_package(
310                &plan.subject,
311                plan.profile.as_deref(),
312                env,
313                cwd,
314                true,
315            )?;
316            let harness = package::run_publish_harness(
317                package.harness_path.as_deref(),
318                &package.harness_fixture_paths,
319            );
320            if let Some(temp_dir) = package.harness_temp_dir.as_ref() {
321                let _ignored = fs::remove_dir_all(temp_dir);
322            }
323            let harness = harness?;
324            let result = publish_skill_markdown(
325                &LocalRegistryClient::new(FileRegistryStore::new(registry_path)),
326                &package.markdown,
327                PublishSkillMarkdownOptions {
328                    ingest: IngestSkillOptions {
329                        owner: plan.owner,
330                        version: plan.version,
331                        profile_document: package.profile_document,
332                        package_files: package.package_files.into_iter().map(Into::into).collect(),
333                        trust_tier: plan.trust_tier,
334                        upsert: plan.upsert,
335                        ..IngestSkillOptions::default()
336                    },
337                    registry_url,
338                    harness,
339                },
340            )?;
341            output::write_output(
342                plan.json,
343                &output::RegistryEnvelope {
344                    status: "success",
345                    registry: output::RegistryPayload::Publish {
346                        publish: output::PublishPayload::Local(Box::new(result)),
347                    },
348                },
349                || "\n  registry publish  success\n\n".to_owned(),
350            )
351        }
352    }
353}
354
355pub(crate) fn install_candidate(
356    plan: &RegistryPlan,
357    target: RegistryTarget,
358    env: &BTreeMap<String, String>,
359    cwd: &Path,
360) -> Result<
361    (
362        InstallCandidate,
363        Option<runx_runtime::registry::AcquiredRegistrySkill>,
364    ),
365    RegistryCliError,
366> {
367    let source_authority = target.manifest_source_authority();
368    match target {
369        RegistryTarget::Remote { registry_url } => {
370            let installation_id = remote_installation_id(env, cwd)?;
371            let acquired = RegistryClient::new(&registry_url)?.acquire(
372                &plan.subject,
373                AcquireOptions {
374                    installation_id: &installation_id,
375                    version: plan.version.as_deref(),
376                    channel: Some("cli"),
377                },
378            )?;
379            Ok((
380                candidate_from_acquired(&plan.subject, &acquired, source_authority),
381                Some(acquired),
382            ))
383        }
384        RegistryTarget::Local {
385            registry_path,
386            registry_url,
387            ..
388        } => {
389            let resolution = resolve_registry_skill(
390                &FileRegistryStore::new(registry_path),
391                &plan.subject,
392                RegistryResolveOptions {
393                    version: plan.version.clone(),
394                    registry_url,
395                },
396            )?
397            .ok_or_else(|| not_found(&plan.subject))?;
398            Ok((
399                candidate_from_resolution(&plan.subject, resolution, source_authority),
400                None,
401            ))
402        }
403    }
404}
405
406fn remote_installation_id(
407    env: &BTreeMap<String, String>,
408    cwd: &Path,
409) -> Result<String, RegistryCliError> {
410    if let Some(installation_id) = env
411        .get("RUNX_INSTALLATION_ID")
412        .map(String::as_str)
413        .map(str::trim)
414        .filter(|value| !value.is_empty())
415    {
416        return Ok(installation_id.to_owned());
417    }
418
419    let generated = InitGeneratedValues::generate();
420    let home = runx_runtime::resolve_runx_global_home_dir(env, cwd);
421    let state = ensure_runx_install_state(&home, &generated.installation_id, &generated.created_at)
422        .map_err(|error| usage_error(error.to_string()))?;
423    Ok(state.state.installation_id)
424}
425
426fn candidate_from_resolution(
427    registry_ref: &str,
428    resolution: RegistrySkillResolution,
429    source_authority: RegistryManifestSourceAuthority,
430) -> InstallCandidate {
431    InstallCandidate {
432        markdown: resolution.markdown,
433        profile_document: resolution.profile_document,
434        package_files: resolution.package_files,
435        package_digest: resolution.package_digest,
436        source: resolution.source,
437        source_label: resolution.source_label,
438        r#ref: registry_ref.to_owned(),
439        skill_id: Some(resolution.skill_id),
440        version: Some(resolution.version),
441        signed_manifest: resolution.signed_manifest,
442        profile_digest: resolution.profile_digest,
443        runner_names: resolution.runner_names,
444        trust_tier: Some(resolution.trust_tier),
445        manifest_source_authority: Some(source_authority),
446    }
447}
448
449fn candidate_from_acquired(
450    registry_ref: &str,
451    acquired: &runx_runtime::registry::AcquiredRegistrySkill,
452    source_authority: RegistryManifestSourceAuthority,
453) -> InstallCandidate {
454    InstallCandidate {
455        markdown: acquired.markdown.clone(),
456        profile_document: acquired.profile_document.clone(),
457        package_files: acquired.package_files.clone(),
458        package_digest: acquired.package_digest.clone(),
459        source: "runx-registry".to_owned(),
460        source_label: "runx registry".to_owned(),
461        r#ref: registry_ref.to_owned(),
462        skill_id: Some(acquired.skill_id.clone()),
463        version: Some(acquired.version.clone()),
464        signed_manifest: acquired.signed_manifest.clone(),
465        profile_digest: acquired.profile_digest.clone(),
466        runner_names: acquired.runner_names.clone(),
467        trust_tier: Some(acquired.trust_tier.clone()),
468        manifest_source_authority: Some(source_authority),
469    }
470}
471
472impl From<package::HostedSkillPackageFile> for RegistryPackageFile {
473    fn from(file: package::HostedSkillPackageFile) -> Self {
474        Self {
475            path: file.path,
476            content: file.content,
477        }
478    }
479}
480
481fn resolve_path(
482    path: &Path,
483    env: &BTreeMap<String, String>,
484    cwd: &Path,
485    prefer_existing: bool,
486) -> PathBuf {
487    if path.is_absolute() {
488        return path.to_path_buf();
489    }
490    runx_runtime::resolve_path_from_user_input(
491        &path.display().to_string(),
492        env,
493        cwd,
494        prefer_existing,
495    )
496}
497
498pub(crate) fn trusted_manifest_keys_from_env_for_source(
499    env: &BTreeMap<String, String>,
500    source_authority: RegistryManifestSourceAuthority,
501) -> Result<Vec<TrustedRegistryManifestKey>, RegistryCliError> {
502    runx_runtime::registry::trusted_registry_manifest_keys_from_env_with_source(
503        env,
504        Some(source_authority),
505    )
506    .map_err(trust_env_error)
507}
508
509fn trust_env_error(
510    error: runx_runtime::registry::RegistryManifestTrustEnvError,
511) -> RegistryCliError {
512    match error {
513        runx_runtime::registry::RegistryManifestTrustEnvError::InvalidKey => {
514            internal_error(error.to_string())
515        }
516        runx_runtime::registry::RegistryManifestTrustEnvError::MissingKeyId => {
517            usage_error(error.to_string())
518        }
519        runx_runtime::registry::RegistryManifestTrustEnvError::MissingOwner => {
520            usage_error(error.to_string())
521        }
522        runx_runtime::registry::RegistryManifestTrustEnvError::MissingSource => {
523            usage_error(error.to_string())
524        }
525    }
526}
527
528pub(crate) fn env_map() -> BTreeMap<String, String> {
529    crate::cli_io::env_map()
530}
531
532pub(crate) struct RegistryCliError {
533    message: String,
534    exit_code: u8,
535}
536
537impl std::fmt::Display for RegistryCliError {
538    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        formatter.write_str(&self.message)
540    }
541}
542
543impl std::fmt::Debug for RegistryCliError {
544    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        formatter
546            .debug_struct("RegistryCliError")
547            .field("message", &self.message)
548            .field("exit_code", &self.exit_code)
549            .finish()
550    }
551}
552
553impl std::error::Error for RegistryCliError {}
554
555impl RegistryCliError {
556    pub(crate) fn into_message(self) -> String {
557        self.message
558    }
559
560    fn code(&self) -> &'static str {
561        if self.exit_code == 64 {
562            "invalid_args"
563        } else {
564            "registry_error"
565        }
566    }
567}
568
569fn usage_error(message: impl Into<String>) -> RegistryCliError {
570    RegistryCliError {
571        message: message.into(),
572        exit_code: 64,
573    }
574}
575
576fn internal_error(message: impl Into<String>) -> RegistryCliError {
577    RegistryCliError {
578        message: message.into(),
579        exit_code: 1,
580    }
581}
582
583fn not_found(registry_ref: &str) -> RegistryCliError {
584    RegistryCliError {
585        message: format!("registry skill not found: {registry_ref}"),
586        exit_code: 1,
587    }
588}
589
590impl From<runx_runtime::registry::RegistryClientError> for RegistryCliError {
591    fn from(error: runx_runtime::registry::RegistryClientError) -> Self {
592        internal_error(error.to_string())
593    }
594}
595
596impl From<runx_runtime::registry::RegistryResolveError> for RegistryCliError {
597    fn from(error: runx_runtime::registry::RegistryResolveError) -> Self {
598        internal_error(error.to_string())
599    }
600}
601
602impl From<runx_runtime::registry::LocalRegistryError> for RegistryCliError {
603    fn from(error: runx_runtime::registry::LocalRegistryError) -> Self {
604        internal_error(error.to_string())
605    }
606}
607
608impl From<runx_runtime::registry::InstallError> for RegistryCliError {
609    fn from(error: runx_runtime::registry::InstallError) -> Self {
610        let error_kind = match &error {
611            runx_runtime::registry::InstallError::UnsignedManifest(_) => Some("unsigned_manifest"),
612            runx_runtime::registry::InstallError::UnknownManifestKey { .. } => Some("unknown_key"),
613            runx_runtime::registry::InstallError::InvalidManifestSignature { .. } => {
614                Some("invalid_signature")
615            }
616            runx_runtime::registry::InstallError::DigestMismatch { .. } => Some("digest_mismatch"),
617            _ => None,
618        };
619        match error_kind {
620            Some(kind) => internal_error(format!("registry install {kind}: {error}")),
621            None => internal_error(error.to_string()),
622        }
623    }
624}
625
626impl From<ConfigError> for RegistryCliError {
627    fn from(error: ConfigError) -> Self {
628        internal_error(error.to_string())
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use runx_runtime::registry::{InstallLocalSkillResult, InstallStatus, TrustTier};
636
637    #[test]
638    fn registry_install_render_shows_direct_skill_run_command() {
639        let rendered = output::render_install(
640            "local",
641            "acme/echo@1.2.3",
642            &InstallLocalSkillResult {
643                status: InstallStatus::Installed,
644                destination: PathBuf::from("/tmp/runx/skills/acme/echo/SKILL.md"),
645                skill_name: "echo".to_owned(),
646                source: "local".to_owned(),
647                source_label: "local registry".to_owned(),
648                skill_id: Some("acme/echo".to_owned()),
649                version: Some("1.2.3".to_owned()),
650                digest: "sha256:abc".to_owned(),
651                profile_digest: None,
652                profile_state_path: None,
653                runner_names: Vec::new(),
654                trust_tier: Some(TrustTier::Community),
655            },
656            None,
657        );
658
659        assert!(rendered.contains("next             runx skill acme/echo@1.2.3"));
660    }
661}