plugin: add authoring cli
This commit is contained in:
parent
d1095f854a
commit
945ecdf64d
|
|
@ -45,6 +45,12 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
|
||||||
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
|
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
PluginTemplateResource {
|
||||||
|
path: "plugin.component.wasm",
|
||||||
|
contents: include_str!(
|
||||||
|
"../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm"
|
||||||
|
),
|
||||||
|
},
|
||||||
PluginTemplateResource {
|
PluginTemplateResource {
|
||||||
path: "README.md",
|
path: "README.md",
|
||||||
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
|
contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"),
|
||||||
|
|
@ -518,6 +524,23 @@ pub struct DiscoveredPluginPackage {
|
||||||
pub entries: BTreeSet<String>,
|
pub entries: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fully materialized package content used by local authoring checks and pack.
|
||||||
|
///
|
||||||
|
/// This is data-only metadata and bytes. Constructing it parses manifests and
|
||||||
|
/// validates package/archive shape, but it does not load, instantiate, or
|
||||||
|
/// execute Plugin code.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MaterializedPluginPackage {
|
||||||
|
pub package: DiscoveredPluginPackage,
|
||||||
|
pub files: BTreeMap<String, Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PackedPluginPackage {
|
||||||
|
pub output_path: PathBuf,
|
||||||
|
pub package: DiscoveredPluginPackage,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct PluginDiscoveryReport {
|
pub struct PluginDiscoveryReport {
|
||||||
pub packages: Vec<DiscoveredPluginPackage>,
|
pub packages: Vec<DiscoveredPluginPackage>,
|
||||||
|
|
@ -1362,7 +1385,146 @@ fn read_package(
|
||||||
.with_source(source)
|
.with_source(source)
|
||||||
.with_package(label)
|
.with_package(label)
|
||||||
})?;
|
})?;
|
||||||
let archive = parse_stored_zip(&bytes, label, source, limits)?;
|
materialize_archive(path, label, source, &bytes, limits)
|
||||||
|
.map(|materialized| materialized.package)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_plugin_package_file(
|
||||||
|
path: &Path,
|
||||||
|
source: PluginSourceKind,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
|
||||||
|
let label = package_label(path);
|
||||||
|
let metadata = fs::metadata(path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin package metadata could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.clone())
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Malformed,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package candidate is not a regular file",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label));
|
||||||
|
}
|
||||||
|
if metadata.len() > limits.max_package_size_bytes {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package exceeds the configured package size bound",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label));
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin package content could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.clone())
|
||||||
|
})?;
|
||||||
|
materialize_archive(path, &label, source, &bytes, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_plugin_directory(
|
||||||
|
path: &Path,
|
||||||
|
source: PluginSourceKind,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
|
||||||
|
let label = package_label(path);
|
||||||
|
let root = fs::canonicalize(path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.clone())
|
||||||
|
})?;
|
||||||
|
let metadata = fs::metadata(&root).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory metadata could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.clone())
|
||||||
|
})?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Malformed,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory input is not a directory",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = BTreeMap::new();
|
||||||
|
collect_directory_files(&root, &root, &label, source, limits, &mut files)?;
|
||||||
|
materialize_files(path, label, source, files, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_plugin_package_file(
|
||||||
|
materialized: &MaterializedPluginPackage,
|
||||||
|
output_path: &Path,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<PackedPluginPackage, PluginDiagnostic> {
|
||||||
|
write_stored_zip_file(output_path, &materialized.files, limits)?;
|
||||||
|
let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?;
|
||||||
|
Ok(PackedPluginPackage {
|
||||||
|
output_path: output_path.to_path_buf(),
|
||||||
|
package: package.package,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscoveredPluginPackage {
|
||||||
|
pub fn source(&self) -> PluginSourceKind {
|
||||||
|
self.identity.source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_archive(
|
||||||
|
path: &Path,
|
||||||
|
label: &str,
|
||||||
|
source: PluginSourceKind,
|
||||||
|
bytes: &[u8],
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
|
||||||
|
let archive = parse_stored_zip(bytes, label, source, limits)?;
|
||||||
|
materialize_files(path, label.to_string(), source, archive.files, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_files(
|
||||||
|
path: &Path,
|
||||||
|
label: String,
|
||||||
|
source: PluginSourceKind,
|
||||||
|
files: BTreeMap<String, Vec<u8>>,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<MaterializedPluginPackage, PluginDiagnostic> {
|
||||||
|
let archive = StoredArchive {
|
||||||
|
files: files.clone(),
|
||||||
|
};
|
||||||
let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| {
|
let manifest_bytes = archive.files.get("plugin.toml").ok_or_else(|| {
|
||||||
PluginDiagnostic::new(
|
PluginDiagnostic::new(
|
||||||
PluginDiagnosticKind::Missing,
|
PluginDiagnosticKind::Missing,
|
||||||
|
|
@ -1370,7 +1532,7 @@ fn read_package(
|
||||||
"plugin package is missing root plugin.toml",
|
"plugin package is missing root plugin.toml",
|
||||||
)
|
)
|
||||||
.with_source(source)
|
.with_source(source)
|
||||||
.with_package(label)
|
.with_package(label.clone())
|
||||||
})?;
|
})?;
|
||||||
if manifest_bytes.len() > limits.max_manifest_size_bytes {
|
if manifest_bytes.len() > limits.max_manifest_size_bytes {
|
||||||
return Err(PluginDiagnostic::new(
|
return Err(PluginDiagnostic::new(
|
||||||
|
|
@ -1388,7 +1550,7 @@ fn read_package(
|
||||||
"plugin.toml is not valid UTF-8",
|
"plugin.toml is not valid UTF-8",
|
||||||
)
|
)
|
||||||
.with_source(source)
|
.with_source(source)
|
||||||
.with_package(label)
|
.with_package(label.clone())
|
||||||
})?;
|
})?;
|
||||||
let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| {
|
let manifest: PluginPackageManifest = toml::from_str(manifest_text).map_err(|error| {
|
||||||
PluginDiagnostic::new(
|
PluginDiagnostic::new(
|
||||||
|
|
@ -1397,20 +1559,282 @@ fn read_package(
|
||||||
safe_toml_parse_message(&error),
|
safe_toml_parse_message(&error),
|
||||||
)
|
)
|
||||||
.with_source(source)
|
.with_source(source)
|
||||||
.with_package(label)
|
.with_package(label.clone())
|
||||||
})?;
|
})?;
|
||||||
validate_manifest(&manifest, &archive, label, source)?;
|
validate_manifest(&manifest, &archive, &label, source)?;
|
||||||
let digest = deterministic_digest(&archive.files);
|
let digest = deterministic_digest(&archive.files);
|
||||||
let identity = SourceQualifiedPluginId::new(source, manifest.id.clone());
|
let identity = SourceQualifiedPluginId::new(source, manifest.id.clone());
|
||||||
|
let package = DiscoveredPluginPackage {
|
||||||
Ok(DiscoveredPluginPackage {
|
|
||||||
identity,
|
identity,
|
||||||
package_path: path.to_path_buf(),
|
package_path: path.to_path_buf(),
|
||||||
package_label: label.to_string(),
|
package_label: label,
|
||||||
digest,
|
digest,
|
||||||
manifest,
|
manifest,
|
||||||
entries: archive.files.keys().cloned().collect(),
|
entries: archive.files.keys().cloned().collect(),
|
||||||
})
|
};
|
||||||
|
Ok(MaterializedPluginPackage { package, files })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_directory_files(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
label: &str,
|
||||||
|
source: PluginSourceKind,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
files: &mut BTreeMap<String, Vec<u8>>,
|
||||||
|
) -> Result<(), PluginDiagnostic> {
|
||||||
|
let mut entries = fs::read_dir(dir)
|
||||||
|
.map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory could not be listed: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?
|
||||||
|
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
|
||||||
|
for path in entries {
|
||||||
|
let metadata = fs::symlink_metadata(&path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory entry metadata could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Traversal,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory contains a symlink entry",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string()));
|
||||||
|
}
|
||||||
|
if metadata.is_dir() {
|
||||||
|
collect_directory_files(root, &path, label, source, limits, files)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !metadata.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if metadata.len() > limits.max_file_size_bytes {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory file exceeds the configured per-file bound",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string()));
|
||||||
|
}
|
||||||
|
let canonical = fs::canonicalize(&path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory file could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?;
|
||||||
|
if !canonical.starts_with(root) {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Traversal,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory file escapes the package root",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string()));
|
||||||
|
}
|
||||||
|
let relative = canonical.strip_prefix(root).map_err(|_| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Traversal,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory file escapes the package root",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?;
|
||||||
|
let normalized = relative
|
||||||
|
.components()
|
||||||
|
.map(|component| component.as_os_str().to_str())
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.and_then(|parts| normalize_archive_path(&parts.join("/")))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Traversal,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory contains an unsafe relative path",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?;
|
||||||
|
let content = fs::read(&path).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin directory file could not be read: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string())
|
||||||
|
})?;
|
||||||
|
files.insert(normalized, content);
|
||||||
|
if files.len() > limits.max_entries_per_package {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory contains more files than the configured bound",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string()));
|
||||||
|
}
|
||||||
|
let expanded_size = files
|
||||||
|
.values()
|
||||||
|
.map(|content| content.len() as u64)
|
||||||
|
.sum::<u64>();
|
||||||
|
if expanded_size > limits.max_expanded_size_bytes {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin directory expanded size exceeds the configured bound",
|
||||||
|
)
|
||||||
|
.with_source(source)
|
||||||
|
.with_package(label.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_stored_zip_file(
|
||||||
|
output_path: &Path,
|
||||||
|
files: &BTreeMap<String, Vec<u8>>,
|
||||||
|
limits: &PluginDiscoveryLimits,
|
||||||
|
) -> Result<(), PluginDiagnostic> {
|
||||||
|
if files.len() > limits.max_entries_per_package {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package contains more entries than the configured bound",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut central = Vec::new();
|
||||||
|
for (name, content) in files {
|
||||||
|
let name = normalize_archive_path(name).ok_or_else(|| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Traversal,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package entry path escapes the archive root",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if content.len() as u64 > limits.max_file_size_bytes {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package entry exceeds the configured per-file bound",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let local_offset = bytes.len() as u32;
|
||||||
|
write_u32_vec(&mut bytes, ZIP_LOCAL_FILE);
|
||||||
|
write_u16_vec(&mut bytes, 20);
|
||||||
|
write_u16_vec(&mut bytes, 0x0800);
|
||||||
|
write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
write_u32_vec(&mut bytes, 0);
|
||||||
|
write_u32_vec(&mut bytes, content.len() as u32);
|
||||||
|
write_u32_vec(&mut bytes, content.len() as u32);
|
||||||
|
write_u16_vec(&mut bytes, name.len() as u16);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
bytes.extend_from_slice(name.as_bytes());
|
||||||
|
bytes.extend_from_slice(content);
|
||||||
|
|
||||||
|
write_u32_vec(&mut central, ZIP_CENTRAL_DIRECTORY);
|
||||||
|
write_u16_vec(&mut central, 20);
|
||||||
|
write_u16_vec(&mut central, 20);
|
||||||
|
write_u16_vec(&mut central, 0x0800);
|
||||||
|
write_u16_vec(&mut central, ZIP_COMPRESSION_STORED);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u32_vec(&mut central, 0);
|
||||||
|
write_u32_vec(&mut central, content.len() as u32);
|
||||||
|
write_u32_vec(&mut central, content.len() as u32);
|
||||||
|
write_u16_vec(&mut central, name.len() as u16);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u16_vec(&mut central, 0);
|
||||||
|
write_u32_vec(&mut central, 0);
|
||||||
|
write_u32_vec(&mut central, local_offset);
|
||||||
|
central.extend_from_slice(name.as_bytes());
|
||||||
|
}
|
||||||
|
let central_offset = bytes.len() as u32;
|
||||||
|
bytes.extend_from_slice(¢ral);
|
||||||
|
write_u32_vec(&mut bytes, ZIP_EOCD);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
write_u16_vec(&mut bytes, files.len() as u16);
|
||||||
|
write_u16_vec(&mut bytes, files.len() as u16);
|
||||||
|
write_u32_vec(&mut bytes, central.len() as u32);
|
||||||
|
write_u32_vec(&mut bytes, central_offset);
|
||||||
|
write_u16_vec(&mut bytes, 0);
|
||||||
|
if bytes.len() as u64 > limits.max_package_size_bytes {
|
||||||
|
return Err(PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Bounds,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
"plugin package exceeds the configured package size bound",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(parent) = output_path
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
{
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin package output directory could not be created: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::write(output_path, bytes).map_err(|error| {
|
||||||
|
PluginDiagnostic::new(
|
||||||
|
PluginDiagnosticKind::Io,
|
||||||
|
PluginDiagnosticPhase::Discovery,
|
||||||
|
format!(
|
||||||
|
"plugin package output could not be written: {}",
|
||||||
|
safe_io_error(&error)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_u16_vec(out: &mut Vec<u8>, value: u16) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_u32_vec(out: &mut Vec<u8>, value: u32) {
|
||||||
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_manifest(
|
fn validate_manifest(
|
||||||
|
|
|
||||||
|
|
@ -429,10 +429,58 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
"yoi plugin requires `list` or `show <ref>`".to_string(),
|
"yoi plugin requires `new`, `check`, `pack`, `list`, or `show <ref>`".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
match subcommand.as_str() {
|
match subcommand.as_str() {
|
||||||
|
"new" => {
|
||||||
|
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
|
||||||
|
match positional.as_slice() {
|
||||||
|
[template, destination] => Ok(plugin_cli::PluginCliCommand::New {
|
||||||
|
template: template.clone(),
|
||||||
|
destination: PathBuf::from(destination),
|
||||||
|
args: plugin_args,
|
||||||
|
}),
|
||||||
|
[] | [_] => Err(ParseError(
|
||||||
|
"yoi plugin new requires a template and destination".to_string(),
|
||||||
|
)),
|
||||||
|
_ => Err(ParseError(
|
||||||
|
"yoi plugin new accepts exactly a template and destination".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"check" => {
|
||||||
|
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
|
||||||
|
match positional.as_slice() {
|
||||||
|
[input] => Ok(plugin_cli::PluginCliCommand::Check {
|
||||||
|
input: PathBuf::from(input),
|
||||||
|
args: plugin_args,
|
||||||
|
}),
|
||||||
|
[] => Err(ParseError(
|
||||||
|
"yoi plugin check requires a plugin directory or .yoi-plugin path".to_string(),
|
||||||
|
)),
|
||||||
|
_ => Err(ParseError(
|
||||||
|
"yoi plugin check accepts exactly one plugin directory or .yoi-plugin path"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pack" => {
|
||||||
|
let (plugin_args, positional, output) = parse_plugin_pack_args(rest)?;
|
||||||
|
match positional.as_slice() {
|
||||||
|
[input] => Ok(plugin_cli::PluginCliCommand::Pack {
|
||||||
|
input: PathBuf::from(input),
|
||||||
|
output,
|
||||||
|
args: plugin_args,
|
||||||
|
}),
|
||||||
|
[] => Err(ParseError(
|
||||||
|
"yoi plugin pack requires a plugin directory".to_string(),
|
||||||
|
)),
|
||||||
|
_ => Err(ParseError(
|
||||||
|
"yoi plugin pack accepts exactly one plugin directory".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
|
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
|
||||||
if !positional.is_empty() {
|
if !positional.is_empty() {
|
||||||
|
|
@ -513,8 +561,36 @@ fn parse_plugin_common_args(
|
||||||
Ok((parsed, positional))
|
Ok((parsed, positional))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_plugin_pack_args(
|
||||||
|
args: &[String],
|
||||||
|
) -> Result<(plugin_cli::PluginCliArgs, Vec<String>, Option<PathBuf>), ParseError> {
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
let mut output = None;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let arg = &args[index];
|
||||||
|
if arg == "--output" {
|
||||||
|
index += 1;
|
||||||
|
let Some(value) = args.get(index) else {
|
||||||
|
return Err(ParseError("--output requires a value".to_string()));
|
||||||
|
};
|
||||||
|
output = Some(PathBuf::from(value));
|
||||||
|
} else if let Some(value) = arg.strip_prefix("--output=") {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(ParseError("--output requires a value".to_string()));
|
||||||
|
}
|
||||||
|
output = Some(PathBuf::from(value));
|
||||||
|
} else {
|
||||||
|
normalized.push(arg.clone());
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
let (plugin_args, positional) = parse_plugin_common_args(&normalized)?;
|
||||||
|
Ok((plugin_args, positional, output))
|
||||||
|
}
|
||||||
|
|
||||||
fn plugin_usage() -> &'static str {
|
fn plugin_usage() -> &'static str {
|
||||||
"usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
"usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
|
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
|
||||||
|
|
@ -547,7 +623,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use manifest::plugin::{
|
use manifest::plugin::{
|
||||||
PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits,
|
MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind,
|
||||||
PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission,
|
PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport,
|
||||||
PluginResolution, PluginSourceKind, PluginSurface, ResolvedPlugin, ResolvedPluginRecord,
|
PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission,
|
||||||
SourceQualifiedPluginId, discover_plugins, resolve_enabled_plugins,
|
PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE,
|
||||||
|
ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins,
|
||||||
|
read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins,
|
||||||
|
write_plugin_package_file,
|
||||||
};
|
};
|
||||||
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
|
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
|
||||||
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
|
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
|
||||||
|
|
@ -35,17 +38,552 @@ pub(crate) enum PluginCliCommand {
|
||||||
reference: String,
|
reference: String,
|
||||||
args: PluginCliArgs,
|
args: PluginCliArgs,
|
||||||
},
|
},
|
||||||
|
New {
|
||||||
|
template: String,
|
||||||
|
destination: PathBuf,
|
||||||
|
args: PluginCliArgs,
|
||||||
|
},
|
||||||
|
Check {
|
||||||
|
input: PathBuf,
|
||||||
|
args: PluginCliArgs,
|
||||||
|
},
|
||||||
|
Pack {
|
||||||
|
input: PathBuf,
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
args: PluginCliArgs,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
||||||
|
if let PluginCliCommand::Check { input, args } = command {
|
||||||
|
let report = build_check_report(&input);
|
||||||
|
let rendered = render_check_report(&report, &args)?;
|
||||||
|
print!("{rendered}");
|
||||||
|
if report.status != "active" {
|
||||||
|
return Err("plugin check failed; see diagnostics above".into());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let rendered = match command {
|
let rendered = match command {
|
||||||
PluginCliCommand::List(args) => render_list(&args)?,
|
PluginCliCommand::List(args) => render_list(&args)?,
|
||||||
PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?,
|
PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?,
|
||||||
|
PluginCliCommand::New {
|
||||||
|
template,
|
||||||
|
destination,
|
||||||
|
args,
|
||||||
|
} => render_new(&template, &destination, &args)?,
|
||||||
|
PluginCliCommand::Check { .. } => unreachable!("handled above"),
|
||||||
|
PluginCliCommand::Pack {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
args,
|
||||||
|
} => render_pack(&input, output.as_deref(), &args)?,
|
||||||
};
|
};
|
||||||
print!("{rendered}");
|
print!("{rendered}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
|
||||||
|
if template != "rust-component-tool" {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported plugin template `{template}` (supported: rust-component-tool)"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
materialize_template(destination)?;
|
||||||
|
let report = NewReport {
|
||||||
|
command: "new",
|
||||||
|
template: "rust-component-tool",
|
||||||
|
destination: destination.display().to_string(),
|
||||||
|
files: RUST_COMPONENT_TOOL_TEMPLATE
|
||||||
|
.iter()
|
||||||
|
.map(|resource| resource.path.to_string())
|
||||||
|
.collect(),
|
||||||
|
safety: AuthoringSafetyReport::default(),
|
||||||
|
next_steps: vec![
|
||||||
|
"Review plugin.toml and generated Rust source.".to_string(),
|
||||||
|
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
|
||||||
|
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if args.json {
|
||||||
|
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
|
||||||
|
}
|
||||||
|
render_new_human(&report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materialize_template(destination: &Path) -> Result<()> {
|
||||||
|
if destination.exists() {
|
||||||
|
let metadata = fs::metadata(destination)?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"plugin template destination `{}` already exists and is not a directory",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if fs::read_dir(destination)?.next().is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"plugin template destination `{}` is not empty",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs::create_dir_all(destination)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
|
||||||
|
let relative = safe_template_relative_path(resource.path)?;
|
||||||
|
let path = destination.join(relative);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(path, resource.contents)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_template_relative_path(path: &str) -> Result<&Path> {
|
||||||
|
let relative = Path::new(path);
|
||||||
|
if relative.is_absolute()
|
||||||
|
|| relative
|
||||||
|
.components()
|
||||||
|
.any(|component| !matches!(component, std::path::Component::Normal(_)))
|
||||||
|
{
|
||||||
|
return Err(format!("embedded plugin template path `{path}` is unsafe").into());
|
||||||
|
}
|
||||||
|
Ok(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn render_check(input: &Path, args: &PluginCliArgs) -> Result<String> {
|
||||||
|
let report = build_check_report(input);
|
||||||
|
render_check_report(&report, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result<String> {
|
||||||
|
if args.json {
|
||||||
|
return Ok(format!("{}\n", serde_json::to_string_pretty(report)?));
|
||||||
|
}
|
||||||
|
render_check_human(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Result<String> {
|
||||||
|
let limits = PluginDiscoveryLimits::default();
|
||||||
|
let materialized = read_plugin_directory(input, PluginSourceKind::Project, &limits)
|
||||||
|
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
|
||||||
|
let output_path = output
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| default_package_output_path(input));
|
||||||
|
let packed = write_plugin_package_file(&materialized, &output_path, &limits)
|
||||||
|
.map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?;
|
||||||
|
let report = PackReport {
|
||||||
|
command: "pack",
|
||||||
|
status: "packed",
|
||||||
|
input_path: input.display().to_string(),
|
||||||
|
output_path: packed.output_path.display().to_string(),
|
||||||
|
package: PackageReport::from_materialized(&MaterializedPluginPackage {
|
||||||
|
package: packed.package,
|
||||||
|
files: materialized.files,
|
||||||
|
}),
|
||||||
|
safety: AuthoringSafetyReport::default(),
|
||||||
|
};
|
||||||
|
if args.json {
|
||||||
|
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
|
||||||
|
}
|
||||||
|
render_pack_human(&report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_package_output_path(input: &Path) -> PathBuf {
|
||||||
|
let name = input
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or("plugin");
|
||||||
|
input.with_file_name(format!("{name}.yoi-plugin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_check_report(input: &Path) -> CheckReport {
|
||||||
|
let limits = PluginDiscoveryLimits::default();
|
||||||
|
let input_kind = if input.is_dir() {
|
||||||
|
"directory"
|
||||||
|
} else {
|
||||||
|
"package"
|
||||||
|
};
|
||||||
|
let result = if input.is_dir() {
|
||||||
|
read_plugin_directory(input, PluginSourceKind::Project, &limits)
|
||||||
|
} else {
|
||||||
|
read_plugin_package_file(input, PluginSourceKind::Project, &limits)
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(materialized) => {
|
||||||
|
let static_inspection = inspect_materialized_package(&materialized);
|
||||||
|
let diagnostics = static_inspection_diagnostics(&static_inspection);
|
||||||
|
let status = if diagnostics.is_empty() {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"rejected"
|
||||||
|
};
|
||||||
|
let reference = package_reference(&materialized.package.identity);
|
||||||
|
CheckReport {
|
||||||
|
command: "check",
|
||||||
|
status,
|
||||||
|
input_path: input.display().to_string(),
|
||||||
|
input_kind,
|
||||||
|
package: Some(PackageReport::from_materialized(&materialized)),
|
||||||
|
diagnostics,
|
||||||
|
static_inspection: Some(StaticInspectionReport::from_inspection(
|
||||||
|
&static_inspection,
|
||||||
|
)),
|
||||||
|
safety: AuthoringSafetyReport::default(),
|
||||||
|
next_steps: check_next_steps(status, &reference),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(diagnostic) => CheckReport {
|
||||||
|
command: "check",
|
||||||
|
status: "rejected",
|
||||||
|
input_path: input.display().to_string(),
|
||||||
|
input_kind,
|
||||||
|
package: None,
|
||||||
|
diagnostics: vec![PluginDiagnosticReport::from_diagnostic(&diagnostic)],
|
||||||
|
static_inspection: None,
|
||||||
|
safety: AuthoringSafetyReport::default(),
|
||||||
|
next_steps: vec![
|
||||||
|
"Fix the reported package diagnostic and run `yoi plugin check` again.".to_string(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_materialized_package(
|
||||||
|
materialized: &MaterializedPluginPackage,
|
||||||
|
) -> PluginStaticInspection {
|
||||||
|
let requested_permissions = materialized.package.manifest.permissions.clone();
|
||||||
|
let record = ResolvedPluginRecord {
|
||||||
|
identity: materialized.package.identity.clone(),
|
||||||
|
source: materialized.package.identity.source,
|
||||||
|
package_path: materialized.package.package_path.clone(),
|
||||||
|
package_label: materialized.package.package_label.clone(),
|
||||||
|
digest: materialized.package.digest.clone(),
|
||||||
|
version: materialized.package.manifest.version.clone(),
|
||||||
|
manifest: materialized.package.manifest.clone(),
|
||||||
|
enabled_surfaces: materialized.package.manifest.surfaces.clone(),
|
||||||
|
grants: PluginGrantConfig {
|
||||||
|
id: Some(materialized.package.identity.to_string()),
|
||||||
|
version: Some(PluginExactVersion(
|
||||||
|
materialized.package.manifest.version.clone(),
|
||||||
|
)),
|
||||||
|
digest: Some(materialized.package.digest.clone()),
|
||||||
|
permissions: requested_permissions,
|
||||||
|
https: Vec::new(),
|
||||||
|
fs: Vec::new(),
|
||||||
|
},
|
||||||
|
config: None,
|
||||||
|
};
|
||||||
|
inspect_resolved_plugin_static(&record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_inspection_diagnostics(
|
||||||
|
inspection: &PluginStaticInspection,
|
||||||
|
) -> Vec<PluginDiagnosticReport> {
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
if let Some(message) = &inspection.runtime.diagnostic {
|
||||||
|
diagnostics.push(PluginDiagnosticReport {
|
||||||
|
kind: "malformed".to_string(),
|
||||||
|
phase: "resolution".to_string(),
|
||||||
|
message: bound_text(message.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for api in &inspection.host_apis {
|
||||||
|
if let Some(message) = &api.diagnostic {
|
||||||
|
diagnostics.push(PluginDiagnosticReport {
|
||||||
|
kind: "grant".to_string(),
|
||||||
|
phase: "resolution".to_string(),
|
||||||
|
message: bound_text(message.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for tool in &inspection.tools {
|
||||||
|
if let Some(message) = &tool.diagnostic {
|
||||||
|
diagnostics.push(PluginDiagnosticReport {
|
||||||
|
kind: "malformed".to_string(),
|
||||||
|
phase: "resolution".to_string(),
|
||||||
|
message: bound_text(message.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_next_steps(status: &str, reference: &str) -> Vec<String> {
|
||||||
|
if status == "active" {
|
||||||
|
vec![
|
||||||
|
"Package metadata is valid without executing Plugin code.".to_string(),
|
||||||
|
format!(
|
||||||
|
"To enable after review, add an explicit plugin enablement entry for `{reference}` with matching digest and grants."
|
||||||
|
),
|
||||||
|
"Run `yoi plugin pack <path>` to create a deterministic .yoi-plugin archive."
|
||||||
|
.to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
|
||||||
|
format!("{context} failed: {}", diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_new_human(report: &NewReport) -> Result<String> {
|
||||||
|
let mut out = String::new();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"created plugin template `{}` at {}",
|
||||||
|
report.template, report.destination
|
||||||
|
)?;
|
||||||
|
writeln!(out, "files:")?;
|
||||||
|
for file in &report.files {
|
||||||
|
writeln!(out, " - {file}")?;
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"safety: no network; embedded template only; no secrets generated"
|
||||||
|
)?;
|
||||||
|
writeln!(out, "next steps:")?;
|
||||||
|
for step in &report.next_steps {
|
||||||
|
writeln!(out, " - {step}")?;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_check_human(report: &CheckReport) -> Result<String> {
|
||||||
|
let mut out = String::new();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"plugin check: {} [{}] input_kind={}",
|
||||||
|
report.input_path, report.status, report.input_kind
|
||||||
|
)?;
|
||||||
|
if let Some(package) = &report.package {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}",
|
||||||
|
package.reference,
|
||||||
|
package.version,
|
||||||
|
package.digest,
|
||||||
|
package.entries.len(),
|
||||||
|
package.source,
|
||||||
|
join_or_none(&package.surfaces),
|
||||||
|
package.tools.len()
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config",
|
||||||
|
package.reference, package.digest
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if report.diagnostics.is_empty() {
|
||||||
|
writeln!(out, "diagnostics: none")?;
|
||||||
|
} else {
|
||||||
|
writeln!(out, "diagnostics:")?;
|
||||||
|
for diagnostic in &report.diagnostics {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" - kind={} phase={} message={}",
|
||||||
|
diagnostic.kind, diagnostic.phase, diagnostic.message
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"safety: no Plugin execution; no enablement config mutation; no secrets generated"
|
||||||
|
)?;
|
||||||
|
writeln!(out, "next steps:")?;
|
||||||
|
for step in &report.next_steps {
|
||||||
|
writeln!(out, " - {step}")?;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pack_human(report: &PackReport) -> Result<String> {
|
||||||
|
let mut out = String::new();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"plugin pack: {} [{}]",
|
||||||
|
report.output_path, report.status
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"package: {} version={} digest={} entries={}",
|
||||||
|
report.package.reference,
|
||||||
|
report.package.version,
|
||||||
|
report.package.digest,
|
||||||
|
report.package.entries.len()
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation"
|
||||||
|
)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AuthoringSafetyReport {
|
||||||
|
no_network: bool,
|
||||||
|
no_plugin_execution: bool,
|
||||||
|
no_enablement_config_mutation: bool,
|
||||||
|
no_secrets_generated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthoringSafetyReport {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
no_network: true,
|
||||||
|
no_plugin_execution: true,
|
||||||
|
no_enablement_config_mutation: true,
|
||||||
|
no_secrets_generated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct NewReport {
|
||||||
|
command: &'static str,
|
||||||
|
template: &'static str,
|
||||||
|
destination: String,
|
||||||
|
files: Vec<String>,
|
||||||
|
safety: AuthoringSafetyReport,
|
||||||
|
next_steps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CheckReport {
|
||||||
|
command: &'static str,
|
||||||
|
status: &'static str,
|
||||||
|
input_path: String,
|
||||||
|
input_kind: &'static str,
|
||||||
|
package: Option<PackageReport>,
|
||||||
|
diagnostics: Vec<PluginDiagnosticReport>,
|
||||||
|
static_inspection: Option<StaticInspectionReport>,
|
||||||
|
safety: AuthoringSafetyReport,
|
||||||
|
next_steps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PackReport {
|
||||||
|
command: &'static str,
|
||||||
|
status: &'static str,
|
||||||
|
input_path: String,
|
||||||
|
output_path: String,
|
||||||
|
package: PackageReport,
|
||||||
|
safety: AuthoringSafetyReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PackageReport {
|
||||||
|
reference: String,
|
||||||
|
package: String,
|
||||||
|
source: String,
|
||||||
|
version: String,
|
||||||
|
schema_version: u32,
|
||||||
|
digest: String,
|
||||||
|
package_path: String,
|
||||||
|
entries: Vec<String>,
|
||||||
|
surfaces: Vec<String>,
|
||||||
|
tools: Vec<String>,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageReport {
|
||||||
|
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self {
|
||||||
|
Self {
|
||||||
|
reference: package_reference(&materialized.package.identity),
|
||||||
|
package: materialized.package.manifest.id.clone(),
|
||||||
|
source: materialized.package.identity.source.to_string(),
|
||||||
|
version: materialized.package.manifest.version.clone(),
|
||||||
|
schema_version: materialized.package.manifest.schema_version,
|
||||||
|
digest: materialized.package.digest.clone(),
|
||||||
|
package_path: materialized.package.package_path.display().to_string(),
|
||||||
|
entries: materialized.package.entries.iter().cloned().collect(),
|
||||||
|
surfaces: materialized
|
||||||
|
.package
|
||||||
|
.manifest
|
||||||
|
.surfaces
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect(),
|
||||||
|
tools: materialized
|
||||||
|
.package
|
||||||
|
.manifest
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.name.clone())
|
||||||
|
.collect(),
|
||||||
|
permissions: materialized
|
||||||
|
.package
|
||||||
|
.manifest
|
||||||
|
.permissions
|
||||||
|
.iter()
|
||||||
|
.map(|permission| permission_name(permission.clone()).to_string())
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PluginDiagnosticReport {
|
||||||
|
kind: String,
|
||||||
|
phase: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginDiagnosticReport {
|
||||||
|
fn from_diagnostic(diagnostic: &PluginDiagnostic) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: diagnostic_kind(&diagnostic.kind).to_string(),
|
||||||
|
phase: diagnostic_phase(&diagnostic.phase).to_string(),
|
||||||
|
message: bound_text(diagnostic.message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_phase(phase: &PluginDiagnosticPhase) -> &'static str {
|
||||||
|
match phase {
|
||||||
|
PluginDiagnosticPhase::Discovery => "discovery",
|
||||||
|
PluginDiagnosticPhase::Manifest => "manifest",
|
||||||
|
PluginDiagnosticPhase::Resolution => "resolution",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_reference(identity: &SourceQualifiedPluginId) -> String {
|
||||||
|
identity.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission_name(permission: PluginPermission) -> String {
|
||||||
|
permission.label()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StaticInspectionReport {
|
||||||
|
status: String,
|
||||||
|
diagnostics: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticInspectionReport {
|
||||||
|
fn from_inspection(inspection: &PluginStaticInspection) -> Self {
|
||||||
|
let diagnostics = static_inspection_diagnostics(inspection).len();
|
||||||
|
let status = if diagnostics == 0 {
|
||||||
|
"active"
|
||||||
|
} else {
|
||||||
|
"rejected"
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
status: status.to_string(),
|
||||||
|
diagnostics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_list(args: &PluginCliArgs) -> Result<String> {
|
fn render_list(args: &PluginCliArgs) -> Result<String> {
|
||||||
let snapshot = build_snapshot(args)?;
|
let snapshot = build_snapshot(args)?;
|
||||||
if args.json {
|
if args.json {
|
||||||
|
|
@ -1254,6 +1792,188 @@ mod tests {
|
||||||
assert!(show_output.contains("eligible=false"));
|
assert!(show_output.contains("eligible=false"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_new_creates_template_files_and_refuses_non_empty_destination() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let destination = dir.path().join("my-plugin");
|
||||||
|
|
||||||
|
let output = render_new(
|
||||||
|
"rust-component-tool",
|
||||||
|
&destination,
|
||||||
|
&PluginCliArgs::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.contains("created plugin template"));
|
||||||
|
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
|
||||||
|
assert!(
|
||||||
|
destination.join(resource.path).is_file(),
|
||||||
|
"missing {}",
|
||||||
|
resource.path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let check_json = render_check(
|
||||||
|
&destination,
|
||||||
|
&PluginCliArgs {
|
||||||
|
json: true,
|
||||||
|
..PluginCliArgs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap();
|
||||||
|
assert_eq!(check_value["status"], "active");
|
||||||
|
let error = render_new(
|
||||||
|
"rust-component-tool",
|
||||||
|
&destination,
|
||||||
|
&PluginCliArgs::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("not empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_check_accepts_valid_directory_and_reports_json_shape() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let plugin = dir.path().join("plugin");
|
||||||
|
fs::create_dir_all(&plugin).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin.join("plugin.toml"),
|
||||||
|
plugin_manifest("echo", "echo", "object", &["echo"]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
|
||||||
|
|
||||||
|
let human = render_check(&plugin, &PluginCliArgs::default()).unwrap();
|
||||||
|
assert!(human.contains("[active]"));
|
||||||
|
assert!(human.contains("digest="));
|
||||||
|
assert!(human.contains("does not mutate config"));
|
||||||
|
|
||||||
|
let json = render_check(
|
||||||
|
&plugin,
|
||||||
|
&PluginCliArgs {
|
||||||
|
json: true,
|
||||||
|
..PluginCliArgs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(value["command"], "check");
|
||||||
|
assert_eq!(value["status"], "active");
|
||||||
|
assert_eq!(value["input_kind"], "directory");
|
||||||
|
assert_eq!(value["package"]["reference"], "project:echo");
|
||||||
|
assert_eq!(value["safety"]["no_plugin_execution"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let invalid = dir.path().join("invalid");
|
||||||
|
fs::create_dir_all(&invalid).unwrap();
|
||||||
|
fs::write(
|
||||||
|
invalid.join("plugin.toml"),
|
||||||
|
"schema_version = 1\nid = [\"bad\"]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let invalid_json = render_check(
|
||||||
|
&invalid,
|
||||||
|
&PluginCliArgs {
|
||||||
|
json: true,
|
||||||
|
..PluginCliArgs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let invalid_value: serde_json::Value = serde_json::from_str(&invalid_json).unwrap();
|
||||||
|
assert_eq!(invalid_value["status"], "rejected");
|
||||||
|
assert_eq!(invalid_value["diagnostics"][0]["phase"], "manifest");
|
||||||
|
|
||||||
|
let missing = dir.path().join("missing-runtime");
|
||||||
|
fs::create_dir_all(&missing).unwrap();
|
||||||
|
fs::write(
|
||||||
|
missing.join("plugin.toml"),
|
||||||
|
plugin_manifest_missing_runtime_entry("missing_runtime"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let missing_output = render_check(&missing, &PluginCliArgs::default()).unwrap();
|
||||||
|
assert!(missing_output.contains("rejected"));
|
||||||
|
assert!(missing_output.contains("path not present"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_check_rejects_unsafe_package_archive() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let package = dir.path().join("unsafe.yoi-plugin");
|
||||||
|
write_stored_zip(
|
||||||
|
&package,
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
"plugin.toml",
|
||||||
|
plugin_manifest("unsafe", "Echo", "object", &["Echo"]).as_bytes(),
|
||||||
|
),
|
||||||
|
("../escape.wasm", b"not wasm"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = render_check(&package, &PluginCliArgs::default()).unwrap();
|
||||||
|
assert!(output.contains("rejected"));
|
||||||
|
assert!(output.contains("escapes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_pack_is_deterministic_and_discoverable() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let plugin = dir.path().join("plugin");
|
||||||
|
fs::create_dir_all(&plugin).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin.join("plugin.toml"),
|
||||||
|
plugin_manifest("echo", "echo", "object", &["echo"]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap();
|
||||||
|
let first = dir.path().join("first.yoi-plugin");
|
||||||
|
let second = dir.path().join("second.yoi-plugin");
|
||||||
|
|
||||||
|
let first_json = render_pack(
|
||||||
|
&plugin,
|
||||||
|
Some(&first),
|
||||||
|
&PluginCliArgs {
|
||||||
|
json: true,
|
||||||
|
..PluginCliArgs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let second_json = render_pack(
|
||||||
|
&plugin,
|
||||||
|
Some(&second),
|
||||||
|
&PluginCliArgs {
|
||||||
|
json: true,
|
||||||
|
..PluginCliArgs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap());
|
||||||
|
let first_value: serde_json::Value = serde_json::from_str(&first_json).unwrap();
|
||||||
|
let second_value: serde_json::Value = serde_json::from_str(&second_json).unwrap();
|
||||||
|
assert_eq!(first_value["command"], "pack");
|
||||||
|
assert_eq!(first_value["status"], "packed");
|
||||||
|
assert_eq!(
|
||||||
|
first_value["package"]["digest"],
|
||||||
|
second_value["package"]["digest"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let workspace = dir.path().join("workspace");
|
||||||
|
fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap();
|
||||||
|
fs::copy(&first, workspace.join(".yoi/plugins/echo.yoi-plugin")).unwrap();
|
||||||
|
let discovery = discover_plugins(&PluginDiscoveryOptions {
|
||||||
|
workspace_root: workspace,
|
||||||
|
user_data_home: None,
|
||||||
|
limits: PluginDiscoveryLimits::default(),
|
||||||
|
});
|
||||||
|
assert_eq!(discovery.packages.len(), 1);
|
||||||
|
assert_eq!(discovery.packages[0].identity.to_string(), "project:echo");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambiguous_ref_is_bounded_error() {
|
fn ambiguous_ref_is_bounded_error() {
|
||||||
let snapshot = PluginInspectionSnapshot {
|
let snapshot = PluginInspectionSnapshot {
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@ Implemented foundation:
|
||||||
- first-party Rust PDK helpers for Component Model Tool guests;
|
- first-party Rust PDK helpers for Component Model Tool guests;
|
||||||
- embedded Rust Component Tool starter template;
|
- embedded Rust Component Tool starter template;
|
||||||
- `https` and `fs` host APIs for Tool runtime;
|
- `https` and `fs` host APIs for Tool runtime;
|
||||||
- read-only `yoi plugin list/show` inspection.
|
- read-only `yoi plugin list/show` inspection;
|
||||||
|
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
|
||||||
|
|
||||||
Still intentionally separate/future work:
|
Still intentionally separate/future work:
|
||||||
|
|
||||||
- `yoi plugin new/check/pack` authoring commands;
|
|
||||||
- multi-language SDK/PDK crates;
|
- multi-language SDK/PDK crates;
|
||||||
- Service / Ingress surfaces;
|
- Service / Ingress surfaces;
|
||||||
- WebSocket or inbound HTTP for bidirectional bridges;
|
- WebSocket or inbound HTTP for bidirectional bridges;
|
||||||
|
|
@ -54,6 +54,37 @@ A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it w
|
||||||
|
|
||||||
The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes.
|
The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes.
|
||||||
|
|
||||||
|
## Authoring CLI
|
||||||
|
|
||||||
|
Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets.
|
||||||
|
|
||||||
|
Create a Rust Component Tool starter from embedded resources:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yoi plugin new rust-component-tool ./my-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
`new` writes only inside the requested destination and refuses an existing non-empty destination. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin.
|
||||||
|
|
||||||
|
Validate a source directory or an existing `.yoi-plugin` archive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yoi plugin check ./my-plugin
|
||||||
|
yoi plugin check ./my-plugin --json
|
||||||
|
yoi plugin check ./my-plugin.yoi-plugin --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. Invalid checks print the same structured report and exit non-zero.
|
||||||
|
|
||||||
|
Pack a source directory into a deterministic stored `.yoi-plugin` archive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yoi plugin pack ./my-plugin
|
||||||
|
yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json
|
||||||
|
```
|
||||||
|
|
||||||
|
`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you.
|
||||||
|
|
||||||
## Manifest: `plugin.toml`
|
## Manifest: `plugin.toml`
|
||||||
|
|
||||||
A minimal Component Model Tool Plugin manifest looks like this:
|
A minimal Component Model Tool Plugin manifest looks like this:
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,12 @@ If this template is copied elsewhere before crates.io publication exists, pin a
|
||||||
yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }
|
yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-revision>" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred to later authoring-tooling work.
|
`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before enabling or executing the Plugin.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
1. Replace package/plugin ids, names, descriptions, and Tool schema.
|
1. Replace package/plugin ids, names, descriptions, and Tool schema.
|
||||||
2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic.
|
2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic.
|
||||||
3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment.
|
3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment, replacing the placeholder `plugin.component.wasm`.
|
||||||
4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive.
|
4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`.
|
||||||
5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package.
|
5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review.
|
||||||
|
|
||||||
The exact component build/pack command is not part of this template yet because deterministic `yoi plugin new/check/pack` authoring commands are a separate planned Ticket.
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.
|
||||||
Loading…
Reference in New Issue
Block a user