commit 4232c5e9c6dca190027ed09be88d56f9d3d34468
parent b3094a4922f880ddc75d50e558ccf1f15632549b
Author: triesap <tyson@radroots.org>
Date: Tue, 23 Jun 2026 01:36:40 +0000
xtask: move automation to tools
- move the SDK xtask workspace member from crates/xtask to tools/xtask
- keep the cargo xtask alias and radroots_sdk_xtask package identity stable
- update local path dependencies for the new tool location
- preserve existing generation, check, and package validation commands
Diffstat:
11 files changed, 1924 insertions(+), 0 deletions(-)
diff --git a/tools/sdk_xtask_import/Cargo.toml b/tools/sdk_xtask_import/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "radroots_sdk_xtask"
+description = "Workspace automation for Radroots SDK crates"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+publish = false
+
+[[bin]]
+name = "radroots_sdk_xtask"
+path = "src/main.rs"
+
+[dependencies]
+radroots_sdk_binding_model = { path = "../../crates/binding_model" }
+radroots_core_bindings = { path = "../../crates/core_bindings" }
+radroots_events_bindings = { path = "../../crates/events_bindings" }
+radroots_events_indexed_bindings = { path = "../../crates/events_indexed_bindings" }
+radroots_identity_bindings = { path = "../../crates/identity_bindings" }
+radroots_replica_db_schema_bindings = { path = "../../crates/replica_db_schema_bindings" }
+radroots_types_bindings = { path = "../../crates/types_bindings" }
+radroots_trade_bindings = { path = "../../crates/trade_bindings" }
+serde_json = "1"
+serde = { workspace = true, features = ["derive"] }
+toml = "0.8"
diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs
@@ -0,0 +1,453 @@
+use std::{collections::BTreeSet, fs, path::Path};
+
+use crate::{
+ contracts::validate_sdk_contracts,
+ fs::workspace_root,
+ output::package_outputs,
+ package_matrix::{
+ FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix,
+ wasm_package_specs,
+ },
+};
+
+pub fn check() -> Result<(), String> {
+ validate_package_matrix()?;
+ let root = workspace_root()?;
+ validate_sdk_contracts(&root)?;
+ check_forbidden_packages(&root)?;
+ check_binding_crate_sources(&root)?;
+ for spec in package_specs() {
+ let package_dir = root.join(spec.package_dir);
+ let package_json_path = package_dir.join("package.json");
+ let index_path = package_dir.join("src/index.ts");
+ check_package_json(&package_json_path, spec.package_name)?;
+ if !index_path.is_file() {
+ return Err(format!("missing package index: {}", index_path.display()));
+ }
+ }
+ for spec in wasm_package_specs() {
+ check_wasm_package_surface(&root, *spec)?;
+ }
+ for output in package_outputs() {
+ for expected in output.files() {
+ let path = root
+ .join(output.spec.package_dir)
+ .join(expected.relative_path);
+ let actual = fs::read_to_string(&path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ if actual != expected.contents {
+ return Err(format!("stale generated output: {}", path.display()));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn check_binding_crate_sources(root: &Path) -> Result<(), String> {
+ for spec in package_specs() {
+ let crate_src_dir = root.join(spec.crate_dir).join("src");
+ let typescript_dir = crate_src_dir.join("typescript");
+ if typescript_dir.exists() {
+ return Err(format!(
+ "forbidden crate TypeScript source directory exists: {}",
+ typescript_dir.display()
+ ));
+ }
+ check_no_typescript_files(&crate_src_dir)?;
+ }
+ for spec in wasm_package_specs() {
+ check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?;
+ }
+ Ok(())
+}
+
+fn check_no_typescript_files(dir: &Path) -> Result<(), String> {
+ for entry in
+ fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?
+ {
+ let entry =
+ entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
+ let path = entry.path();
+ let file_type = entry
+ .file_type()
+ .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?;
+ if file_type.is_dir() {
+ check_no_typescript_files(&path)?;
+ } else if file_type.is_file()
+ && path.extension().and_then(|extension| extension.to_str()) == Some("ts")
+ {
+ return Err(format!(
+ "forbidden crate TypeScript source file exists: {}",
+ path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn check_forbidden_packages(root: &Path) -> Result<(), String> {
+ for forbidden in FORBIDDEN_PACKAGE_NAMES {
+ let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned();
+ let forbidden_dir = root.join("packages").join(package_leaf);
+ if forbidden_dir.exists() {
+ return Err(format!(
+ "forbidden package directory exists: {}",
+ forbidden_dir.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> {
+ let raw = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ let json = serde_json::from_str::<serde_json::Value>(&raw)
+ .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
+ let actual_name = json
+ .get("name")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| format!("package.json missing name: {}", path.display()))?;
+ if actual_name != expected_name {
+ return Err(format!(
+ "package name mismatch in {}: expected {expected_name}, found {actual_name}",
+ path.display()
+ ));
+ }
+ let private = json
+ .get("private")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ if !private {
+ return Err(format!("package must be private: {}", path.display()));
+ }
+ Ok(())
+}
+
+pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> {
+ let package_dir = root.join(spec.package_dir);
+ let package_json_path = package_dir.join("package.json");
+ check_package_json(&package_json_path, spec.package_name)?;
+ let raw = fs::read_to_string(&package_json_path)
+ .map_err(|error| format!("failed to read {}: {error}", package_json_path.display()))?;
+ let json = serde_json::from_str::<serde_json::Value>(&raw)
+ .map_err(|error| format!("failed to parse {}: {error}", package_json_path.display()))?;
+ let dist_manifest = package_dir.join("dist").join("package.json");
+ if dist_manifest.exists() {
+ return Err(format!(
+ "generated package manifest is forbidden: {}",
+ dist_manifest.display()
+ ));
+ }
+ for relative in package_surface_paths(&json, &package_json_path)? {
+ let normalized = relative.trim_start_matches("./");
+ let path = package_dir.join(normalized);
+ if !path.is_file() {
+ return Err(format!(
+ "missing package export artifact for {}: {}",
+ spec.package_name,
+ path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn package_surface_paths(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+) -> Result<BTreeSet<String>, String> {
+ let mut paths = BTreeSet::new();
+ collect_required_package_path(json, package_json_path, "main", &mut paths)?;
+ collect_required_package_path(json, package_json_path, "types", &mut paths)?;
+ let exports = json.get("exports").ok_or_else(|| {
+ format!(
+ "package.json missing exports: {}",
+ package_json_path.display()
+ )
+ })?;
+ match exports {
+ serde_json::Value::String(path) => {
+ validate_package_surface_path(path, package_json_path, "exports")?;
+ paths.insert(path.clone());
+ }
+ serde_json::Value::Object(map) => {
+ if map.keys().any(|key| key != ".") {
+ return Err(format!(
+ "package.json only supports root exports: {}",
+ package_json_path.display()
+ ));
+ }
+ let root_export = map.get(".").ok_or_else(|| {
+ format!(
+ "package.json missing root export: {}",
+ package_json_path.display()
+ )
+ })?;
+ collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?;
+ }
+ _ => {
+ return Err(format!(
+ "package.json exports must be a string or object: {}",
+ package_json_path.display()
+ ));
+ }
+ }
+ Ok(paths)
+}
+
+fn collect_required_package_path(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+ field: &'static str,
+ paths: &mut BTreeSet<String>,
+) -> Result<(), String> {
+ let value = json
+ .get(field)
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing {field}: {}",
+ package_json_path.display()
+ )
+ })?;
+ validate_package_surface_path(value, package_json_path, field)?;
+ paths.insert(value.to_owned());
+ Ok(())
+}
+
+fn collect_export_paths(
+ value: &serde_json::Value,
+ package_json_path: &Path,
+ field: &str,
+ paths: &mut BTreeSet<String>,
+) -> Result<(), String> {
+ match value {
+ serde_json::Value::String(path) => {
+ validate_package_surface_path(path, package_json_path, field)?;
+ paths.insert(path.clone());
+ Ok(())
+ }
+ serde_json::Value::Object(map) => {
+ for (key, value) in map {
+ collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?;
+ }
+ Ok(())
+ }
+ _ => Err(format!(
+ "package.json {field} must name file paths: {}",
+ package_json_path.display()
+ )),
+ }
+}
+
+fn validate_package_surface_path(
+ value: &str,
+ package_json_path: &Path,
+ field: &str,
+) -> Result<(), String> {
+ if value.trim().is_empty()
+ || value.trim() != value
+ || !value.starts_with("./dist/")
+ || value.contains('\\')
+ || value.split('/').any(|segment| segment == "..")
+ {
+ return Err(format!(
+ "package.json {field} must be a relative dist path: {}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ fs,
+ path::PathBuf,
+ time::{SystemTime, UNIX_EPOCH},
+ };
+
+ use crate::package_matrix::{WasmPackageSpec, validate_package_matrix};
+
+ use super::{
+ check_binding_crate_sources, check_no_typescript_files, check_wasm_package_surface,
+ };
+
+ #[test]
+ fn package_skeleton_is_valid() {
+ validate_package_matrix().expect("package matrix validates");
+ }
+
+ #[test]
+ fn rejects_crate_typescript_directories() {
+ let root = test_root("typescript_dir");
+ let typescript_dir = root
+ .join("crates")
+ .join("core_bindings")
+ .join("src")
+ .join("typescript");
+ fs::create_dir_all(&typescript_dir).expect("create forbidden directory");
+
+ let error = check_binding_crate_sources(&root).expect_err("forbidden directory rejected");
+
+ assert!(error.contains("forbidden crate TypeScript source directory"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn rejects_crate_typescript_files() {
+ let root = test_root("typescript_file");
+ let src_dir = root.join("crates/core_bindings/src");
+ fs::create_dir_all(&src_dir).expect("create crate source directory");
+ fs::write(src_dir.join("types.ts"), "export type A = string;\n")
+ .expect("write forbidden file");
+
+ let error = check_no_typescript_files(&src_dir).expect_err("forbidden file rejected");
+
+ assert!(error.contains("forbidden crate TypeScript source file"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn wasm_package_surface_requires_exported_dist_files() {
+ let root = test_root("wasm_surface");
+ let package_dir = root.join("packages").join("example-wasm");
+ fs::create_dir_all(package_dir.join("dist")).expect("create dist");
+ fs::write(
+ package_dir.join("package.json"),
+ r#"{
+ "name": "@radroots/example-wasm",
+ "private": true,
+ "main": "./dist/example.js",
+ "types": "./dist/example.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/example.d.ts",
+ "import": "./dist/example.js",
+ "default": "./dist/example.js"
+ }
+ }
+}"#,
+ )
+ .expect("write package json");
+ fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js");
+ let spec = WasmPackageSpec {
+ key: "example",
+ crate_name: "radroots_example_wasm",
+ crate_dir: "crates/example_wasm",
+ package_name: "@radroots/example-wasm",
+ package_dir: "packages/example-wasm",
+ out_name: "example",
+ out_dir: "../../packages/example-wasm/dist",
+ };
+
+ let missing =
+ check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail");
+ assert!(missing.contains("example.d.ts"));
+ fs::write(
+ package_dir.join("dist").join("example.d.ts"),
+ "export {};\n",
+ )
+ .expect("write d.ts");
+ check_wasm_package_surface(&root, spec).expect("surface is complete");
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn wasm_package_surface_rejects_generated_package_manifest() {
+ let root = test_root("wasm_dist_package_manifest");
+ let package_dir = root.join("packages").join("example-wasm");
+ fs::create_dir_all(package_dir.join("dist")).expect("create dist");
+ fs::write(
+ package_dir.join("package.json"),
+ r#"{
+ "name": "@radroots/example-wasm",
+ "private": true,
+ "main": "./dist/example.js",
+ "types": "./dist/example.d.ts",
+ "exports": "./dist/example.js"
+}"#,
+ )
+ .expect("write package json");
+ fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js");
+ fs::write(
+ package_dir.join("dist").join("example.d.ts"),
+ "export {};\n",
+ )
+ .expect("write d.ts");
+ fs::write(package_dir.join("dist").join("package.json"), "{}\n")
+ .expect("write forbidden manifest");
+ let spec = WasmPackageSpec {
+ key: "example",
+ crate_name: "radroots_example_wasm",
+ crate_dir: "crates/example_wasm",
+ package_name: "@radroots/example-wasm",
+ package_dir: "packages/example-wasm",
+ out_name: "example",
+ out_dir: "../../packages/example-wasm/dist",
+ };
+
+ let error =
+ check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected");
+ assert!(error.contains("generated package manifest is forbidden"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn wasm_package_surface_rejects_subpath_exports() {
+ let root = test_root("wasm_subpath_exports");
+ let package_dir = root.join("packages").join("example-wasm");
+ fs::create_dir_all(package_dir.join("dist")).expect("create dist");
+ fs::write(
+ package_dir.join("package.json"),
+ r#"{
+ "name": "@radroots/example-wasm",
+ "private": true,
+ "main": "./dist/example.js",
+ "types": "./dist/example.d.ts",
+ "exports": {
+ ".": "./dist/example.js",
+ "./extra": "./dist/extra.js"
+ }
+}"#,
+ )
+ .expect("write package json");
+ fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js");
+ fs::write(
+ package_dir.join("dist").join("example.d.ts"),
+ "export {};\n",
+ )
+ .expect("write d.ts");
+ let spec = WasmPackageSpec {
+ key: "example",
+ crate_name: "radroots_example_wasm",
+ crate_dir: "crates/example_wasm",
+ package_name: "@radroots/example-wasm",
+ package_dir: "packages/example-wasm",
+ out_name: "example",
+ out_dir: "../../packages/example-wasm/dist",
+ };
+
+ let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected");
+ assert!(error.contains("only supports root exports"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ fn test_root(name: &str) -> PathBuf {
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time after epoch")
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!(
+ "radroots_sdk_xtask_check_{name}_{}_{}",
+ std::process::id(),
+ stamp
+ ));
+ let _ = fs::remove_dir_all(&root);
+ root
+ }
+}
diff --git a/tools/sdk_xtask_import/src/contracts.rs b/tools/sdk_xtask_import/src/contracts.rs
@@ -0,0 +1,411 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::{Path, PathBuf},
+};
+
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ExportContract {
+ language: LanguageContract,
+ packages: BTreeMap<String, String>,
+ artifacts: Option<ExportArtifacts>,
+ runtime: RuntimeContract,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PackageContract {
+ language: LanguageContract,
+ sdk: SdkPackageContract,
+ rollout: RolloutContract,
+ operations: BTreeMap<String, String>,
+ shared_types: BTreeMap<String, String>,
+ artifacts: Option<SdkArtifacts>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct LanguageContract {
+ id: String,
+ repository: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RuntimeContract {
+ networking: String,
+ signing: String,
+ deterministic_codec: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ExportArtifacts {
+ models_dir: String,
+ constants_dir: String,
+ wasm_dist_dir: Option<String>,
+ manifest_file: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SdkPackageContract {
+ package: String,
+ module_format: Option<String>,
+ deterministic_codec: String,
+ signing: String,
+ networking: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RolloutContract {
+ stage: String,
+ order: u32,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct SdkArtifacts {
+ models_dir: String,
+ runtime_dir: String,
+ wasm_dist_dir: String,
+ manifest_file: String,
+}
+
+pub fn validate_sdk_contracts(root: &Path) -> Result<(), String> {
+ let exports = load_contract_dir::<ExportContract>(&root.join("contracts").join("exports"))?;
+ let packages = load_contract_dir::<PackageContract>(&root.join("contracts").join("packages"))?;
+ if exports.is_empty() {
+ return Err("contracts/exports must define at least one language".to_owned());
+ }
+ if packages.is_empty() {
+ return Err("contracts/packages must define at least one language".to_owned());
+ }
+
+ let mut export_packages = BTreeMap::new();
+ let mut export_languages = BTreeSet::new();
+ for export in &exports {
+ validate_language(&export.language, "exports")?;
+ validate_non_empty_map(&export.packages, "exports packages")?;
+ validate_runtime(
+ &export.runtime.networking,
+ &export.runtime.signing,
+ &export.runtime.deterministic_codec,
+ &format!("exports {}", export.language.id),
+ )?;
+ let artifacts = export
+ .artifacts
+ .as_ref()
+ .ok_or_else(|| format!("exports {} artifacts are required", export.language.id))?;
+ validate_non_empty(&artifacts.models_dir, "exports artifacts.models_dir")?;
+ validate_non_empty(&artifacts.constants_dir, "exports artifacts.constants_dir")?;
+ validate_non_empty(&artifacts.manifest_file, "exports artifacts.manifest_file")?;
+ if export.language.id == "ts" {
+ validate_non_empty(
+ artifacts.wasm_dist_dir.as_deref().unwrap_or(""),
+ "exports ts artifacts.wasm_dist_dir",
+ )?;
+ }
+ if !export_languages.insert(export.language.id.clone()) {
+ return Err(format!("duplicate exports language {}", export.language.id));
+ }
+ let packages = export
+ .packages
+ .values()
+ .cloned()
+ .collect::<BTreeSet<String>>();
+ if packages.len() != 1 {
+ return Err(format!(
+ "exports {} must resolve to one curated package",
+ export.language.id
+ ));
+ }
+ export_packages.insert(export.language.id.clone(), packages);
+ }
+
+ let mut package_languages = BTreeSet::new();
+ let mut operation_keys: Option<BTreeSet<String>> = None;
+ let mut shared_type_keys: Option<BTreeSet<String>> = None;
+ let mut rollout_orders = BTreeMap::new();
+ for package in &packages {
+ validate_language(&package.language, "packages")?;
+ validate_non_empty(&package.sdk.package, "packages sdk.package")?;
+ validate_runtime(
+ &package.sdk.networking,
+ &package.sdk.signing,
+ &package.sdk.deterministic_codec,
+ &format!("packages {}", package.language.id),
+ )?;
+ if let Some(module_format) = package.sdk.module_format.as_deref() {
+ validate_non_empty(module_format, "packages sdk.module_format")?;
+ }
+ validate_rollout(&package.language.id, &package.rollout)?;
+ validate_non_empty_map(&package.operations, "packages operations")?;
+ validate_non_empty_map(&package.shared_types, "packages shared_types")?;
+ if package.language.id == "ts" {
+ let artifacts = package
+ .artifacts
+ .as_ref()
+ .ok_or_else(|| "packages ts artifacts are required".to_owned())?;
+ validate_non_empty(&artifacts.models_dir, "packages ts artifacts.models_dir")?;
+ validate_non_empty(&artifacts.runtime_dir, "packages ts artifacts.runtime_dir")?;
+ validate_non_empty(
+ &artifacts.wasm_dist_dir,
+ "packages ts artifacts.wasm_dist_dir",
+ )?;
+ validate_non_empty(
+ &artifacts.manifest_file,
+ "packages ts artifacts.manifest_file",
+ )?;
+ }
+ if !package_languages.insert(package.language.id.clone()) {
+ return Err(format!(
+ "duplicate packages language {}",
+ package.language.id
+ ));
+ }
+ let Some(packages_for_language) = export_packages.get(&package.language.id) else {
+ return Err(format!(
+ "packages {} is missing a matching export contract",
+ package.language.id
+ ));
+ };
+ let expected = [package.sdk.package.clone()]
+ .into_iter()
+ .collect::<BTreeSet<_>>();
+ if packages_for_language != &expected {
+ return Err(format!(
+ "exports {} must resolve to package {}",
+ package.language.id, package.sdk.package
+ ));
+ }
+ let current_operations = package.operations.keys().cloned().collect::<BTreeSet<_>>();
+ match &operation_keys {
+ Some(expected) if expected != ¤t_operations => {
+ return Err(format!(
+ "packages {} operations must match the shared operation set",
+ package.language.id
+ ));
+ }
+ None => operation_keys = Some(current_operations),
+ _ => {}
+ }
+ let current_shared_types = package
+ .shared_types
+ .keys()
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ match &shared_type_keys {
+ Some(expected) if expected != ¤t_shared_types => {
+ return Err(format!(
+ "packages {} shared_types must match the shared type set",
+ package.language.id
+ ));
+ }
+ None => shared_type_keys = Some(current_shared_types),
+ _ => {}
+ }
+ rollout_orders.insert(package.language.id.clone(), package.rollout.order);
+ }
+
+ if export_languages != package_languages {
+ return Err("contracts/exports and contracts/packages languages must match".to_owned());
+ }
+ if rollout_orders.get("ts") != Some(&1) {
+ return Err("packages ts rollout.order must be 1".to_owned());
+ }
+ Ok(())
+}
+
+fn load_contract_dir<T>(dir: &Path) -> Result<Vec<T>, String>
+where
+ T: for<'de> Deserialize<'de>,
+{
+ let read_dir =
+ fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))?;
+ let mut entries = read_dir
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?;
+ entries.sort_by_key(|entry| entry.file_name());
+ let mut contracts = Vec::new();
+ for entry in entries {
+ let path = entry.path();
+ if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
+ continue;
+ }
+ contracts.push(parse_toml(&path)?);
+ }
+ Ok(contracts)
+}
+
+fn parse_toml<T>(path: &PathBuf) -> Result<T, String>
+where
+ T: for<'de> Deserialize<'de>,
+{
+ let raw = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display()))
+}
+
+fn validate_language(language: &LanguageContract, family: &str) -> Result<(), String> {
+ validate_non_empty(&language.id, &format!("{family} language.id"))?;
+ validate_non_empty(
+ &language.repository,
+ &format!("{family} language.repository"),
+ )
+}
+
+fn validate_runtime(
+ networking: &str,
+ signing: &str,
+ deterministic_codec: &str,
+ family: &str,
+) -> Result<(), String> {
+ validate_non_empty(networking, &format!("{family} networking"))?;
+ validate_non_empty(signing, &format!("{family} signing"))?;
+ validate_non_empty(
+ deterministic_codec,
+ &format!("{family} deterministic_codec"),
+ )
+}
+
+fn validate_rollout(language: &str, rollout: &RolloutContract) -> Result<(), String> {
+ validate_non_empty(&rollout.stage, "packages rollout.stage")?;
+ if !matches!(rollout.stage.as_str(), "active" | "next" | "deferred") {
+ return Err(format!("packages {language} rollout.stage is invalid"));
+ }
+ if rollout.order == 0 {
+ return Err(format!(
+ "packages {language} rollout.order must be greater than zero"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_non_empty(value: &str, field: &str) -> Result<(), String> {
+ if value.trim().is_empty() || value.trim() != value {
+ return Err(format!("{field} must be non-empty"));
+ }
+ Ok(())
+}
+
+fn validate_non_empty_map(map: &BTreeMap<String, String>, field: &str) -> Result<(), String> {
+ if map.is_empty() {
+ return Err(format!("{field} must not be empty"));
+ }
+ for (key, value) in map {
+ validate_non_empty(key, field)?;
+ validate_non_empty(value, field)?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ fs,
+ time::{SystemTime, UNIX_EPOCH},
+ };
+
+ use super::validate_sdk_contracts;
+
+ #[test]
+ fn current_sdk_contracts_validate() {
+ let root = crate::fs::workspace_root().expect("workspace root");
+ validate_sdk_contracts(&root).expect("sdk contracts validate");
+ }
+
+ #[test]
+ fn rejects_mismatched_language_sets() {
+ let root = test_root("language_mismatch");
+ write_contract(
+ &root,
+ "contracts/exports/ts.toml",
+ EXPORT_TS.replace("@radroots/sdk", "@radroots/sdk").as_str(),
+ );
+ let error = validate_sdk_contracts(&root).expect_err("missing packages should fail");
+ assert!(error.contains("contracts/packages"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn rejects_package_export_mismatch() {
+ let root = test_root("package_mismatch");
+ write_contract(&root, "contracts/exports/ts.toml", EXPORT_TS);
+ write_contract(
+ &root,
+ "contracts/packages/ts.toml",
+ PACKAGE_TS
+ .replace("@radroots/sdk", "@radroots/other")
+ .as_str(),
+ );
+ let error = validate_sdk_contracts(&root).expect_err("mismatch should fail");
+ assert!(error.contains("exports ts must resolve"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ fn test_root(name: &str) -> std::path::PathBuf {
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time")
+ .as_nanos();
+ std::env::temp_dir().join(format!("radroots_sdk_contracts_{name}_{stamp}"))
+ }
+
+ fn write_contract(root: &std::path::Path, relative: &str, contents: &str) {
+ let path = root.join(relative);
+ fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
+ fs::write(path, contents).expect("write contract");
+ }
+
+ const EXPORT_TS: &str = r#"[language]
+id = "ts"
+repository = "sdk-typescript"
+
+[packages]
+"radroots_core" = "@radroots/sdk"
+
+[artifacts]
+models_dir = "src/generated"
+constants_dir = "src/generated"
+wasm_dist_dir = "dist"
+manifest_file = "export-manifest.json"
+
+[runtime]
+networking = "native"
+signing = "native"
+deterministic_codec = "wasm"
+"#;
+
+ const PACKAGE_TS: &str = r#"[language]
+id = "ts"
+repository = "sdk-typescript"
+
+[sdk]
+package = "@radroots/sdk"
+module_format = "esm"
+deterministic_codec = "wasm"
+signing = "native"
+networking = "native"
+
+[rollout]
+stage = "active"
+order = 1
+
+[operations]
+"profile.build_draft" = "profile.buildDraft"
+
+[shared_types]
+"WireEventParts" = "WireEventParts"
+
+[artifacts]
+models_dir = "src/generated"
+runtime_dir = "src/runtime"
+wasm_dist_dir = "dist"
+manifest_file = "export-manifest.json"
+"#;
+}
diff --git a/tools/sdk_xtask_import/src/fs.rs b/tools/sdk_xtask_import/src/fs.rs
@@ -0,0 +1,46 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+pub fn workspace_root() -> Result<PathBuf, String> {
+ let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ manifest_dir
+ .parent()
+ .and_then(Path::parent)
+ .map(Path::to_path_buf)
+ .ok_or_else(|| {
+ format!(
+ "cannot resolve workspace root from {}",
+ manifest_dir.display()
+ )
+ })
+}
+
+#[allow(dead_code)]
+pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> {
+ if let Ok(existing) = fs::read_to_string(path) {
+ if existing == contents {
+ return Ok(false);
+ }
+ }
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ }
+ fs::write(path, contents)
+ .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
+ Ok(true)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::workspace_root;
+
+ #[test]
+ fn resolves_workspace_root() {
+ let root = workspace_root().expect("workspace root resolves");
+ assert!(root.join("Cargo.toml").is_file());
+ assert!(root.join("packages").is_dir());
+ }
+}
diff --git a/tools/sdk_xtask_import/src/generate.rs b/tools/sdk_xtask_import/src/generate.rs
@@ -0,0 +1,16 @@
+use crate::{fs::workspace_root, output::package_outputs, package_matrix::validate_package_matrix};
+
+pub fn generate_ts() -> Result<(), String> {
+ validate_package_matrix()?;
+ let root = workspace_root()?;
+ for output in package_outputs() {
+ for generated_file in output.files() {
+ let path = root
+ .join(output.spec.package_dir)
+ .join(generated_file.relative_path);
+ crate::fs::write_if_changed(&path, &generated_file.contents)?;
+ }
+ println!("generated TypeScript package {}", output.spec.package_name);
+ }
+ Ok(())
+}
diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs
@@ -0,0 +1,88 @@
+mod check;
+mod contracts;
+mod fs;
+mod generate;
+mod manifest;
+mod output;
+mod package_matrix;
+mod ts;
+mod wasm;
+
+enum CommandAction<'a> {
+ GenerateTs,
+ GenerateWasm(&'a [String]),
+ Check,
+}
+
+fn main() {
+ if let Err(error) = run(std::env::args().skip(1)) {
+ eprintln!("{error}");
+ std::process::exit(1);
+ }
+}
+
+fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
+ let args = args.into_iter().collect::<Vec<_>>();
+ match command_action(&args)? {
+ CommandAction::GenerateTs => generate::generate_ts(),
+ CommandAction::GenerateWasm(rest) => wasm::generate(rest),
+ CommandAction::Check => check::check(),
+ }
+}
+
+fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
+ match args {
+ [command, target] if command == "generate" && target == "ts" => {
+ Ok(CommandAction::GenerateTs)
+ }
+ [command, target, rest @ ..] if command == "generate" && target == "wasm" => {
+ Ok(CommandAction::GenerateWasm(rest))
+ }
+ [command] if command == "check" => Ok(CommandAction::Check),
+ [] => Err(usage()),
+ _ => Err(usage()),
+ }
+}
+
+fn usage() -> String {
+ "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask check"
+ .to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{CommandAction, command_action};
+
+ #[test]
+ fn accepts_generate_ts() {
+ let args = ["generate".to_owned(), "ts".to_owned()];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::GenerateTs
+ ));
+ }
+
+ #[test]
+ fn accepts_generate_wasm() {
+ let args = ["generate".to_owned(), "wasm".to_owned()];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::GenerateWasm(rest) if rest.is_empty()
+ ));
+ }
+
+ #[test]
+ fn accepts_check() {
+ let args = ["check".to_owned()];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::Check
+ ));
+ }
+
+ #[test]
+ fn rejects_unknown_command() {
+ let args = ["generate".to_owned(), "swift".to_owned()];
+ assert!(command_action(&args).is_err());
+ }
+}
diff --git a/tools/sdk_xtask_import/src/manifest.rs b/tools/sdk_xtask_import/src/manifest.rs
@@ -0,0 +1,36 @@
+use serde_json::json;
+
+use crate::package_matrix::PackageSpec;
+
+pub fn manifest_file_name() -> &'static str {
+ "sdk-manifest.json"
+}
+
+pub fn package_manifest(spec: PackageSpec) -> serde_json::Value {
+ json!({
+ "package": spec.package_name,
+ "crate": spec.crate_name,
+ "generator": "radroots_sdk_xtask",
+ "generated": false
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{
+ manifest::{manifest_file_name, package_manifest},
+ package_matrix::package_specs,
+ };
+
+ #[test]
+ fn manifest_name_is_stable() {
+ assert_eq!(manifest_file_name(), "sdk-manifest.json");
+ }
+
+ #[test]
+ fn manifest_records_package_and_crate() {
+ let manifest = package_manifest(package_specs()[0]);
+ assert_eq!(manifest["package"], package_specs()[0].package_name);
+ assert_eq!(manifest["crate"], package_specs()[0].crate_name);
+ }
+}
diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs
@@ -0,0 +1,269 @@
+use crate::{
+ manifest::manifest_file_name,
+ manifest::package_manifest,
+ package_matrix::{PackageSpec, package_specs},
+ ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file},
+};
+use radroots_sdk_binding_model::TsModule;
+
+pub struct PackageOutput {
+ pub spec: PackageSpec,
+ pub types_ts: Option<TsSource>,
+ pub types_imports_ts: Option<&'static str>,
+ pub constants_ts: Option<TsSource>,
+ pub kinds_ts: Option<TsSource>,
+}
+
+pub struct GeneratedFile {
+ pub relative_path: String,
+ pub contents: String,
+}
+
+pub enum TsSource {
+ Module(TsModule),
+}
+
+impl TsSource {
+ fn render(&self) -> String {
+ match self {
+ Self::Module(module) => module.render(),
+ }
+ }
+}
+
+impl PackageOutput {
+ pub fn files(&self) -> Vec<GeneratedFile> {
+ let mut files = Vec::new();
+ if let Some(types_ts) = &self.types_ts {
+ files.push(GeneratedFile {
+ relative_path: format!("src/generated/{}", generated_types_file()),
+ contents: render_ts(types_ts, self.types_imports_ts),
+ });
+ }
+ if let Some(constants_ts) = &self.constants_ts {
+ files.push(GeneratedFile {
+ relative_path: format!("src/generated/{}", generated_constants_file()),
+ contents: render_ts(constants_ts, None),
+ });
+ }
+ if let Some(kinds_ts) = &self.kinds_ts {
+ files.push(GeneratedFile {
+ relative_path: format!("src/generated/{}", generated_kinds_file()),
+ contents: render_ts(kinds_ts, None),
+ });
+ }
+ files.push(GeneratedFile {
+ relative_path: format!("src/generated/{}", manifest_file_name()),
+ contents: render_manifest(self.spec),
+ });
+ files.push(GeneratedFile {
+ relative_path: "src/index.ts".to_owned(),
+ contents: render_index(self),
+ });
+ files
+ }
+}
+
+pub fn package_outputs() -> Vec<PackageOutput> {
+ vec![
+ PackageOutput {
+ spec: spec_by_key("core"),
+ types_ts: Some(TsSource::Module(radroots_core_bindings::types_module())),
+ types_imports_ts: None,
+ constants_ts: None,
+ kinds_ts: None,
+ },
+ PackageOutput {
+ spec: spec_by_key("events"),
+ types_ts: Some(TsSource::Module(radroots_events_bindings::types_module())),
+ types_imports_ts: Some(EVENTS_TYPES_IMPORTS_TS),
+ constants_ts: Some(TsSource::Module(
+ radroots_events_bindings::constants_module(),
+ )),
+ kinds_ts: Some(TsSource::Module(radroots_events_bindings::kinds_module())),
+ },
+ PackageOutput {
+ spec: spec_by_key("events_indexed"),
+ types_ts: Some(TsSource::Module(
+ radroots_events_indexed_bindings::types_module(),
+ )),
+ types_imports_ts: None,
+ constants_ts: None,
+ kinds_ts: None,
+ },
+ PackageOutput {
+ spec: spec_by_key("identity"),
+ types_ts: None,
+ types_imports_ts: None,
+ constants_ts: Some(TsSource::Module(
+ radroots_identity_bindings::constants_module(),
+ )),
+ kinds_ts: None,
+ },
+ PackageOutput {
+ spec: spec_by_key("replica_db_schema"),
+ types_ts: Some(TsSource::Module(
+ radroots_replica_db_schema_bindings::types_module(),
+ )),
+ types_imports_ts: Some(REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS),
+ constants_ts: None,
+ kinds_ts: None,
+ },
+ PackageOutput {
+ spec: spec_by_key("trade"),
+ types_ts: Some(TsSource::Module(radroots_trade_bindings::types_module())),
+ types_imports_ts: Some(TRADE_TYPES_IMPORTS_TS),
+ constants_ts: None,
+ kinds_ts: None,
+ },
+ PackageOutput {
+ spec: spec_by_key("types"),
+ types_ts: Some(TsSource::Module(radroots_types_bindings::types_module())),
+ types_imports_ts: None,
+ constants_ts: None,
+ kinds_ts: None,
+ },
+ ]
+}
+
+fn spec_by_key(key: &str) -> PackageSpec {
+ package_specs()
+ .iter()
+ .copied()
+ .find(|spec| spec.key == key)
+ .unwrap_or_else(|| panic!("missing package spec for {key}"))
+}
+
+fn render_ts(source: &TsSource, imports: Option<&str>) -> String {
+ let body = source.render();
+ let imports = imports.unwrap_or("");
+ let mut rendered = format!("{}{}{}", generated_header(), imports, body.trim_start());
+ if !rendered.ends_with('\n') {
+ rendered.push('\n');
+ }
+ rendered
+}
+
+const EVENTS_TYPES_IMPORTS_TS: &str = r#"import type {
+ RadrootsCoreCurrency,
+ RadrootsCoreDecimal,
+ RadrootsCoreDiscount,
+ RadrootsCoreDiscountValue,
+ RadrootsCoreMoney,
+ RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice,
+ RadrootsCoreUnit,
+} from "@radroots/core-bindings";
+
+"#;
+
+const REPLICA_DB_SCHEMA_TYPES_IMPORTS_TS: &str = r#"import type {
+ IResult,
+ IResultList,
+ IResultPass,
+} from "@radroots/types-bindings";
+
+"#;
+
+const TRADE_TYPES_IMPORTS_TS: &str = r#"import type {
+ RadrootsCoreCurrency,
+ RadrootsCoreDecimal,
+ RadrootsCoreDiscount,
+ RadrootsCoreDiscountValue,
+ RadrootsCoreMoney,
+ RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice,
+ RadrootsCoreUnit,
+} from "@radroots/core-bindings";
+import type {
+ RadrootsListingImage,
+ RadrootsNostrEventPtr,
+ RadrootsPlotRef,
+ RadrootsResourceAreaRef,
+ RadrootsTradeMessagePayload,
+ RadrootsTradeOrderEconomicLine,
+ RadrootsTradeOrderItem,
+} from "@radroots/events-bindings";
+
+"#;
+
+fn render_manifest(spec: PackageSpec) -> String {
+ let mut value = package_manifest(spec);
+ value["generated"] = serde_json::Value::Bool(true);
+ format!(
+ "{}\n",
+ serde_json::to_string_pretty(&value).expect("manifest json serializes")
+ )
+}
+
+fn render_index(output: &PackageOutput) -> String {
+ let mut lines = Vec::new();
+ if output.types_ts.is_some() {
+ lines.push("export * from \"./generated/types.js\";");
+ }
+ if output.constants_ts.is_some() {
+ lines.push("export * from \"./generated/constants.js\";");
+ }
+ if output.kinds_ts.is_some() {
+ lines.push("export * from \"./generated/kinds.js\";");
+ }
+ if lines.is_empty() {
+ lines.push("export {};");
+ }
+ format!("{}\n", lines.join("\n"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{TsSource, package_outputs, render_ts};
+ use radroots_sdk_binding_model::{module, string, type_alias};
+
+ #[test]
+ fn renders_sdk_header() {
+ let output = render_ts(
+ &TsSource::Module(module(vec![type_alias("A", string())])),
+ None,
+ );
+ assert!(output.starts_with("// @generated by cargo xtask generate ts"));
+ assert!(output.contains("export type A = string;"));
+ }
+
+ #[test]
+ fn renders_import_prelude_after_header() {
+ let output = render_ts(
+ &TsSource::Module(module(vec![type_alias("A", string())])),
+ Some("import type { B } from \"b\";\n\n"),
+ );
+ assert!(output.starts_with(
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type"
+ ));
+ assert!(output.contains("export type A = string;"));
+ }
+
+ #[test]
+ fn renders_model_sources() {
+ let output = render_ts(
+ &TsSource::Module(module(vec![type_alias("A", string())])),
+ None,
+ );
+ assert_eq!(
+ output,
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\nexport type A = string;\n"
+ );
+ }
+
+ #[test]
+ fn includes_core_and_types_outputs() {
+ let package_names = package_outputs()
+ .into_iter()
+ .map(|output| output.spec.package_name)
+ .collect::<Vec<_>>();
+ assert!(package_names.contains(&"@radroots/core-bindings"));
+ assert!(package_names.contains(&"@radroots/events-bindings"));
+ assert!(package_names.contains(&"@radroots/events-indexed-bindings"));
+ assert!(package_names.contains(&"@radroots/identity-bindings"));
+ assert!(package_names.contains(&"@radroots/replica-db-schema-bindings"));
+ assert!(package_names.contains(&"@radroots/trade-bindings"));
+ assert!(package_names.contains(&"@radroots/types-bindings"));
+ }
+}
diff --git a/tools/sdk_xtask_import/src/package_matrix.rs b/tools/sdk_xtask_import/src/package_matrix.rs
@@ -0,0 +1,213 @@
+use std::collections::BTreeSet;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PackageSpec {
+ pub key: &'static str,
+ pub crate_name: &'static str,
+ pub crate_dir: &'static str,
+ pub package_name: &'static str,
+ pub package_dir: &'static str,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct WasmPackageSpec {
+ pub key: &'static str,
+ pub crate_name: &'static str,
+ pub crate_dir: &'static str,
+ pub package_name: &'static str,
+ pub package_dir: &'static str,
+ pub out_name: &'static str,
+ pub out_dir: &'static str,
+}
+
+pub const PACKAGE_SPECS: [PackageSpec; 7] = [
+ PackageSpec {
+ key: "core",
+ crate_name: "radroots_core_bindings",
+ crate_dir: "crates/core_bindings",
+ package_name: "@radroots/core-bindings",
+ package_dir: "packages/core-bindings",
+ },
+ PackageSpec {
+ key: "events",
+ crate_name: "radroots_events_bindings",
+ crate_dir: "crates/events_bindings",
+ package_name: "@radroots/events-bindings",
+ package_dir: "packages/events-bindings",
+ },
+ PackageSpec {
+ key: "events_indexed",
+ crate_name: "radroots_events_indexed_bindings",
+ crate_dir: "crates/events_indexed_bindings",
+ package_name: "@radroots/events-indexed-bindings",
+ package_dir: "packages/events-indexed-bindings",
+ },
+ PackageSpec {
+ key: "identity",
+ crate_name: "radroots_identity_bindings",
+ crate_dir: "crates/identity_bindings",
+ package_name: "@radroots/identity-bindings",
+ package_dir: "packages/identity-bindings",
+ },
+ PackageSpec {
+ key: "replica_db_schema",
+ crate_name: "radroots_replica_db_schema_bindings",
+ crate_dir: "crates/replica_db_schema_bindings",
+ package_name: "@radroots/replica-db-schema-bindings",
+ package_dir: "packages/replica-db-schema-bindings",
+ },
+ PackageSpec {
+ key: "trade",
+ crate_name: "radroots_trade_bindings",
+ crate_dir: "crates/trade_bindings",
+ package_name: "@radroots/trade-bindings",
+ package_dir: "packages/trade-bindings",
+ },
+ PackageSpec {
+ key: "types",
+ crate_name: "radroots_types_bindings",
+ crate_dir: "crates/types_bindings",
+ package_name: "@radroots/types-bindings",
+ package_dir: "packages/types-bindings",
+ },
+];
+
+pub const WASM_PACKAGE_SPECS: [WasmPackageSpec; 3] = [
+ WasmPackageSpec {
+ key: "events_codec",
+ crate_name: "radroots_events_codec_wasm",
+ crate_dir: "crates/events_codec_wasm",
+ package_name: "@radroots/events-codec-wasm",
+ package_dir: "packages/events-codec-wasm",
+ out_name: "radroots_events_codec_wasm",
+ out_dir: "../../packages/events-codec-wasm/dist",
+ },
+ WasmPackageSpec {
+ key: "replica_db",
+ crate_name: "radroots_replica_db_wasm",
+ crate_dir: "crates/replica_db_wasm",
+ package_name: "@radroots/replica-db-wasm",
+ package_dir: "packages/replica-db-wasm",
+ out_name: "radroots_replica_db_wasm",
+ out_dir: "../../packages/replica-db-wasm/dist",
+ },
+ WasmPackageSpec {
+ key: "replica_sync",
+ crate_name: "radroots_replica_sync_wasm",
+ crate_dir: "crates/replica_sync_wasm",
+ package_name: "@radroots/replica-sync-wasm",
+ package_dir: "packages/replica-sync-wasm",
+ out_name: "radroots_replica_sync_wasm",
+ out_dir: "../../packages/replica-sync-wasm/dist",
+ },
+];
+
+pub const FORBIDDEN_PACKAGE_NAMES: [&str; 2] =
+ ["@radroots/tangle-db-schema-bindings", "@radroots/contracts"];
+
+pub fn package_specs() -> &'static [PackageSpec] {
+ &PACKAGE_SPECS
+}
+
+pub fn wasm_package_specs() -> &'static [WasmPackageSpec] {
+ &WASM_PACKAGE_SPECS
+}
+
+pub fn validate_package_matrix() -> Result<(), String> {
+ let mut crate_names = BTreeSet::new();
+ let mut package_names = BTreeSet::new();
+ let mut package_dirs = BTreeSet::new();
+ for spec in package_specs() {
+ if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) {
+ return Err(format!(
+ "forbidden package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !crate_names.insert(spec.crate_name) {
+ return Err(format!("duplicate crate in matrix: {}", spec.crate_name));
+ }
+ if !package_names.insert(spec.package_name) {
+ return Err(format!(
+ "duplicate package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !package_dirs.insert(spec.package_dir) {
+ return Err(format!(
+ "duplicate package directory in matrix: {}",
+ spec.package_dir
+ ));
+ }
+ }
+ for spec in wasm_package_specs() {
+ if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) {
+ return Err(format!(
+ "forbidden package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !crate_names.insert(spec.crate_name) {
+ return Err(format!("duplicate crate in matrix: {}", spec.crate_name));
+ }
+ if !package_names.insert(spec.package_name) {
+ return Err(format!(
+ "duplicate package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !package_dirs.insert(spec.package_dir) {
+ return Err(format!(
+ "duplicate package directory in matrix: {}",
+ spec.package_dir
+ ));
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs,
+ };
+
+ #[test]
+ fn package_matrix_is_valid() {
+ validate_package_matrix().expect("package matrix is valid");
+ }
+
+ #[test]
+ fn approved_package_count_is_stable() {
+ assert_eq!(package_specs().len(), 7);
+ assert_eq!(wasm_package_specs().len(), 3);
+ }
+
+ #[test]
+ fn forbidden_names_are_absent() {
+ for spec in package_specs() {
+ assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name));
+ }
+ for spec in wasm_package_specs() {
+ assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name));
+ }
+ }
+
+ #[test]
+ fn replica_schema_package_uses_current_name() {
+ assert!(
+ package_specs()
+ .iter()
+ .any(|spec| spec.package_name == "@radroots/replica-db-schema-bindings")
+ );
+ }
+
+ #[test]
+ fn wasm_packages_use_sdk_package_names() {
+ assert!(
+ wasm_package_specs()
+ .iter()
+ .any(|spec| spec.package_name == "@radroots/replica-db-wasm")
+ );
+ }
+}
diff --git a/tools/sdk_xtask_import/src/ts.rs b/tools/sdk_xtask_import/src/ts.rs
@@ -0,0 +1,37 @@
+pub fn generated_header() -> &'static str {
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n"
+}
+
+pub fn generated_types_file() -> &'static str {
+ "types.ts"
+}
+
+pub fn generated_constants_file() -> &'static str {
+ "constants.ts"
+}
+
+pub fn generated_kinds_file() -> &'static str {
+ "kinds.ts"
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ generated_constants_file, generated_header, generated_kinds_file, generated_types_file,
+ };
+
+ #[test]
+ fn generated_header_matches_contract() {
+ assert_eq!(
+ generated_header(),
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n"
+ );
+ }
+
+ #[test]
+ fn generated_file_names_are_stable() {
+ assert_eq!(generated_types_file(), "types.ts");
+ assert_eq!(generated_constants_file(), "constants.ts");
+ assert_eq!(generated_kinds_file(), "kinds.ts");
+ }
+}
diff --git a/tools/sdk_xtask_import/src/wasm.rs b/tools/sdk_xtask_import/src/wasm.rs
@@ -0,0 +1,328 @@
+use std::{
+ collections::BTreeSet,
+ env, fs,
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+use crate::{
+ check::check_wasm_package_surface,
+ fs::workspace_root,
+ package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs},
+};
+
+const WASM_TARGET: &str = "wasm32-unknown-unknown";
+
+pub fn generate(args: &[String]) -> Result<(), String> {
+ validate_package_matrix()?;
+ let specs = selected_specs(args)?;
+ let root = workspace_root()?;
+ let toolchain = resolve_wasm_toolchain()?;
+ for spec in specs {
+ let dist_dir = root.join(spec.package_dir).join("dist");
+ if dist_dir.exists() {
+ fs::remove_dir_all(&dist_dir)
+ .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?;
+ }
+ let mut command = Command::new(&toolchain.wasm_pack);
+ command.current_dir(&root);
+ for arg in wasm_pack_args(spec) {
+ command.arg(arg);
+ }
+ if let Some(parent) = toolchain.rustc.parent() {
+ prepend_path(&mut command, parent);
+ }
+ command.env("RUSTC", &toolchain.rustc);
+ command.env("CARGO", &toolchain.cargo);
+ let status = command.status().map_err(|error| {
+ format!(
+ "failed to start wasm-pack for {} while generating {}: {error}",
+ spec.key, spec.package_name
+ )
+ })?;
+ if !status.success() {
+ return Err(format!(
+ "wasm-pack failed for {} while generating {} with status {status}; rerun `cargo xtask generate wasm --package {}` after fixing the wasm toolchain",
+ spec.key, spec.package_name, spec.key
+ ));
+ }
+ check_wasm_package_surface(&root, spec)?;
+ println!("generated wasm package {}", spec.package_name);
+ }
+ Ok(())
+}
+
+struct WasmToolchain {
+ wasm_pack: PathBuf,
+ rustc: PathBuf,
+ cargo: PathBuf,
+}
+
+fn resolve_wasm_toolchain() -> Result<WasmToolchain, String> {
+ let wasm_pack = resolve_required_path_tool("wasm-pack")?;
+ let rustc = resolve_required_rust_tool("rustc", "RUSTC")?;
+ let cargo = resolve_required_rust_tool("cargo", "CARGO")?;
+ ensure_wasm_target_installed()?;
+ Ok(WasmToolchain {
+ wasm_pack,
+ rustc,
+ cargo,
+ })
+}
+
+fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> {
+ vec![
+ "build",
+ spec.crate_dir,
+ "--release",
+ "--target",
+ "web",
+ "--out-dir",
+ spec.out_dir,
+ "--out-name",
+ spec.out_name,
+ "--no-pack",
+ ]
+}
+
+fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> {
+ match args {
+ [] => Ok(wasm_package_specs().to_vec()),
+ [flag, key] if flag == "--package" => wasm_package_specs()
+ .iter()
+ .copied()
+ .find(|spec| spec.key == key)
+ .map(|spec| vec![spec])
+ .ok_or_else(|| format!("unknown wasm package: {key}")),
+ _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()),
+ }
+}
+
+fn resolve_required_path_tool(name: &str) -> Result<PathBuf, String> {
+ let path = env::var_os("PATH").ok_or_else(|| {
+ format!("missing {name}: PATH is not set; install {name} and expose it on PATH")
+ })?;
+ resolve_path_tool_from_path(name, &path)
+}
+
+fn resolve_path_tool_from_path(name: &str, path: &std::ffi::OsStr) -> Result<PathBuf, String> {
+ let matches = executable_matches(name, path);
+ match matches.as_slice() {
+ [] => Err(format!(
+ "missing {name}: install {name} and rerun `cargo xtask generate wasm`"
+ )),
+ [tool] => Ok(tool.clone()),
+ _ => Err(format!(
+ "ambiguous {name}: found {}; remove duplicate {name} entries from PATH before running `cargo xtask generate wasm`",
+ matches
+ .iter()
+ .map(|path| path.display().to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ )),
+ }
+}
+
+fn executable_matches(name: &str, path: &std::ffi::OsStr) -> Vec<PathBuf> {
+ let mut seen = BTreeSet::new();
+ let mut matches = Vec::new();
+ for dir in env::split_paths(path) {
+ let candidate = dir.join(name);
+ if !is_executable_file(&candidate) {
+ continue;
+ }
+ let key = fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone());
+ if seen.insert(key) {
+ matches.push(candidate);
+ }
+ }
+ matches
+}
+
+fn is_executable_file(path: &Path) -> bool {
+ if !path.is_file() {
+ return false;
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ path.metadata()
+ .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
+ .unwrap_or(false)
+ }
+ #[cfg(not(unix))]
+ {
+ true
+ }
+}
+
+fn resolve_required_rust_tool(name: &str, env_var: &str) -> Result<PathBuf, String> {
+ if let Some(path) = explicit_tool_path(env_var) {
+ return Ok(PathBuf::from(path));
+ }
+ rustup_tool(name).ok_or_else(|| {
+ format!(
+ "missing rustup resolution for {name}: set {env_var} explicitly or install rustup with the {WASM_TARGET} target"
+ )
+ })
+}
+
+fn explicit_tool_path(env_var: &str) -> Option<String> {
+ let value = env::var(env_var).ok()?;
+ let trimmed = value.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_owned())
+}
+
+fn rustup_tool(name: &str) -> Option<PathBuf> {
+ let output = Command::new("rustup")
+ .arg("which")
+ .arg(name)
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ let path = String::from_utf8(output.stdout).ok()?;
+ let trimmed = path.trim();
+ (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
+}
+
+fn ensure_wasm_target_installed() -> Result<(), String> {
+ let output = Command::new("rustup")
+ .arg("target")
+ .arg("list")
+ .arg("--installed")
+ .output()
+ .map_err(|error| {
+ format!(
+ "failed to verify {WASM_TARGET} target with rustup: {error}; install rustup or set RUSTC/CARGO from a toolchain that supports {WASM_TARGET}"
+ )
+ })?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!(
+ "failed to verify {WASM_TARGET} target with rustup: {}; run `rustup target add {WASM_TARGET}`",
+ stderr.trim()
+ ));
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ if !target_list_contains(&stdout, WASM_TARGET) {
+ return Err(format!(
+ "missing Rust target {WASM_TARGET}: run `rustup target add {WASM_TARGET}`"
+ ));
+ }
+ Ok(())
+}
+
+fn target_list_contains(output: &str, target: &str) -> bool {
+ output.lines().any(|line| line.trim() == target)
+}
+
+fn prepend_path(command: &mut Command, prefix: &Path) {
+ let existing = env::var_os("PATH").unwrap_or_default();
+ let mut paths = vec![prefix.to_path_buf()];
+ paths.extend(env::split_paths(&existing));
+ if let Ok(joined) = env::join_paths(paths) {
+ command.env("PATH", joined);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ env, fs,
+ path::PathBuf,
+ time::{SystemTime, UNIX_EPOCH},
+ };
+
+ use crate::package_matrix::wasm_package_specs;
+
+ use super::{
+ resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains,
+ wasm_pack_args,
+ };
+
+ #[test]
+ fn selects_all_specs_by_default() {
+ assert_eq!(selected_specs(&[]).expect("all specs").len(), 3);
+ }
+
+ #[test]
+ fn selects_one_spec_by_key() {
+ let specs = selected_specs(&["--package".to_owned(), "replica_db".to_owned()])
+ .expect("replica db spec");
+ assert_eq!(specs[0].package_name, "@radroots/replica-db-wasm");
+ }
+
+ #[test]
+ fn rejects_unknown_spec_key() {
+ assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err());
+ }
+
+ #[test]
+ fn wasm_pack_arguments_disable_package_manifest_generation() {
+ let args = wasm_pack_args(wasm_package_specs()[0]);
+ assert!(args.contains(&"--no-pack"));
+ }
+
+ #[test]
+ fn path_tool_resolution_reports_missing_tools() {
+ let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new(""))
+ .expect_err("missing");
+ assert!(error.contains("missing wasm-pack"));
+ }
+
+ #[test]
+ fn path_tool_resolution_reports_ambiguous_tools() {
+ let root = test_root("ambiguous_wasm_pack");
+ let first = root.join("first");
+ let second = root.join("second");
+ fs::create_dir_all(&first).expect("create first dir");
+ fs::create_dir_all(&second).expect("create second dir");
+ write_executable(first.join("wasm-pack"));
+ write_executable(second.join("wasm-pack"));
+ let path = env::join_paths([first, second]).expect("join path");
+
+ let error =
+ resolve_path_tool_from_path("wasm-pack", &path).expect_err("ambiguous wasm-pack");
+
+ assert!(error.contains("ambiguous wasm-pack"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn target_list_parser_requires_exact_target() {
+ assert!(target_list_contains(
+ "aarch64-apple-darwin\nwasm32-unknown-unknown\n",
+ "wasm32-unknown-unknown"
+ ));
+ assert!(!target_list_contains(
+ "wasm32-unknown-emscripten\n",
+ "wasm32-unknown-unknown"
+ ));
+ }
+
+ #[test]
+ fn rustup_tool_resolution_is_non_panicking() {
+ let _ = rustup_tool("rustc");
+ }
+
+ fn write_executable(path: PathBuf) {
+ fs::write(&path, "#!/bin/sh\n").expect("write executable");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut permissions = fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&path, permissions).expect("set executable permissions");
+ }
+ }
+
+ fn test_root(name: &str) -> PathBuf {
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system time")
+ .as_nanos();
+ env::temp_dir().join(format!("radroots_sdk_xtask_{name}_{stamp}"))
+ }
+}