commit a05eefdd45d08fcf15564813b8d734d80575cc87
parent eefc32cc7f76cd761e58b06fe3147c6fd9e298da
Author: triesap <tyson@radroots.org>
Date: Sun, 28 Jun 2026 02:26:24 +0000
packages: align npm publish payloads
- move TypeScript provenance metadata out of package source trees
- make public package manifests dist-only and release-ready
- enforce generated artifact and package metadata invariants in xtask
- remove wasm-pack ignore files before checking WASM package surfaces
Diffstat:
5 files changed, 511 insertions(+), 60 deletions(-)
diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs
@@ -12,6 +12,12 @@ use crate::{
wasm_declarations::declaration_files,
};
+const PACKAGE_VERSION: &str = "0.1.0";
+const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0";
+const PACKAGE_HOMEPAGE: &str = "https://radroots.org";
+const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git";
+const PUBLISH_ACCESS: &str = "public";
+
pub fn check() -> Result<(), String> {
validate_package_matrix()?;
let root = workspace_root()?;
@@ -22,7 +28,9 @@ pub fn check() -> Result<(), String> {
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)?;
+ let package_json =
+ check_package_json(&package_json_path, spec.package_name, spec.package_dir)?;
+ let _ = package_surface_paths(&package_json, &package_json_path)?;
if !index_path.is_file() {
return Err(format!("missing package index: {}", index_path.display()));
}
@@ -46,6 +54,13 @@ pub fn check() -> Result<(), String> {
return Err(format!("stale generated output: {}", path.display()));
}
}
+ let expected = output.provenance_file();
+ let path = root.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 provenance: {}", path.display()));
+ }
}
Ok(())
}
@@ -184,7 +199,11 @@ fn check_forbidden_packages(root: &Path) -> Result<(), String> {
Ok(())
}
-fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> {
+fn check_package_json(
+ path: &Path,
+ expected_name: &str,
+ expected_directory: &str,
+) -> Result<serde_json::Value, 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)
@@ -199,24 +218,28 @@ fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> {
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()));
+ if json.get("private").is_some() {
+ return Err(format!(
+ "public package must not set private: {}",
+ path.display()
+ ));
}
- Ok(())
+ require_string_field(&json, path, "version", PACKAGE_VERSION)?;
+ require_string_field(&json, path, "license", PACKAGE_LICENSE)?;
+ require_string_field(&json, path, "homepage", PACKAGE_HOMEPAGE)?;
+ require_string_field(&json, path, "type", "module")?;
+ require_bool_field(&json, path, "sideEffects", false)?;
+ check_publish_config(&json, path)?;
+ check_repository(&json, path, expected_directory)?;
+ check_package_files(&json, path)?;
+ check_workspace_dependencies(&json, path)?;
+ Ok(json)
}
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 json = check_package_json(&package_json_path, spec.package_name, spec.package_dir)?;
let dist_manifest = package_dir.join("dist").join("package.json");
if dist_manifest.exists() {
return Err(format!(
@@ -224,6 +247,7 @@ pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) ->
dist_manifest.display()
));
}
+ check_no_wasm_dist_ignore_files(&package_dir, spec)?;
let surface_paths = package_surface_paths(&json, &package_json_path)?;
check_public_wasm_declaration_inventory(&surface_paths, spec)?;
for relative in surface_paths {
@@ -237,10 +261,200 @@ pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) ->
));
}
}
+ check_wasm_runtime_files(&package_dir, spec)?;
check_wasm_declaration_files(&package_dir, spec)?;
Ok(())
}
+fn require_string_field(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+ field: &'static str,
+ expected: &str,
+) -> Result<(), String> {
+ let actual = json
+ .get(field)
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing {field}: {}",
+ package_json_path.display()
+ )
+ })?;
+ if actual != expected {
+ return Err(format!(
+ "package.json {field} mismatch in {}: expected {expected}, found {actual}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn require_bool_field(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+ field: &'static str,
+ expected: bool,
+) -> Result<(), String> {
+ let actual = json
+ .get(field)
+ .and_then(serde_json::Value::as_bool)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing {field}: {}",
+ package_json_path.display()
+ )
+ })?;
+ if actual != expected {
+ return Err(format!(
+ "package.json {field} mismatch in {}: expected {expected}, found {actual}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn check_publish_config(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> {
+ let access = json
+ .get("publishConfig")
+ .and_then(|value| value.get("access"))
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing publishConfig.access: {}",
+ package_json_path.display()
+ )
+ })?;
+ if access != PUBLISH_ACCESS {
+ return Err(format!(
+ "package.json publishConfig.access mismatch in {}: expected {PUBLISH_ACCESS}, found {access}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn check_repository(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+ expected_directory: &str,
+) -> Result<(), String> {
+ let repository = json.get("repository").ok_or_else(|| {
+ format!(
+ "package.json missing repository: {}",
+ package_json_path.display()
+ )
+ })?;
+ let repository_type = repository
+ .get("type")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing repository.type: {}",
+ package_json_path.display()
+ )
+ })?;
+ if repository_type != "git" {
+ return Err(format!(
+ "package.json repository.type mismatch in {}: expected git, found {repository_type}",
+ package_json_path.display()
+ ));
+ }
+ let repository_url = repository
+ .get("url")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing repository.url: {}",
+ package_json_path.display()
+ )
+ })?;
+ if repository_url != PACKAGE_REPOSITORY_URL {
+ return Err(format!(
+ "package.json repository.url mismatch in {}: expected {PACKAGE_REPOSITORY_URL}, found {repository_url}",
+ package_json_path.display()
+ ));
+ }
+ let repository_directory = repository
+ .get("directory")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing repository.directory: {}",
+ package_json_path.display()
+ )
+ })?;
+ if repository_directory != expected_directory {
+ return Err(format!(
+ "package.json repository.directory mismatch in {}: expected {expected_directory}, found {repository_directory}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn check_package_files(json: &serde_json::Value, package_json_path: &Path) -> Result<(), String> {
+ let files = json
+ .get("files")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| {
+ format!(
+ "package.json missing files: {}",
+ package_json_path.display()
+ )
+ })?;
+ let actual = files
+ .iter()
+ .map(|value| {
+ value.as_str().ok_or_else(|| {
+ format!(
+ "package.json files entries must be strings: {}",
+ package_json_path.display()
+ )
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ if actual != ["dist"] {
+ return Err(format!(
+ "package.json files must publish dist only: {}",
+ package_json_path.display()
+ ));
+ }
+ Ok(())
+}
+
+fn check_workspace_dependencies(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+) -> Result<(), String> {
+ let Some(dependencies) = json.get("dependencies") else {
+ return Ok(());
+ };
+ let dependencies = dependencies.as_object().ok_or_else(|| {
+ format!(
+ "package.json dependencies must be an object: {}",
+ package_json_path.display()
+ )
+ })?;
+ for (name, value) in dependencies {
+ if name.starts_with("@radroots/") {
+ let version = value.as_str().ok_or_else(|| {
+ format!(
+ "package.json dependency versions must be strings: {}",
+ package_json_path.display()
+ )
+ })?;
+ if version != "workspace:^" {
+ return Err(format!(
+ "package.json workspace dependency {name} must use workspace:^ in {}",
+ package_json_path.display()
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
fn check_public_wasm_declaration_inventory(
surface_paths: &BTreeSet<String>,
spec: WasmPackageSpec,
@@ -260,6 +474,45 @@ fn check_public_wasm_declaration_inventory(
Ok(())
}
+fn check_no_wasm_dist_ignore_files(
+ package_dir: &Path,
+ spec: WasmPackageSpec,
+) -> Result<(), String> {
+ for file_name in [".gitignore", ".npmignore"] {
+ let path = package_dir.join("dist").join(file_name);
+ if path.exists() {
+ return Err(format!(
+ "wasm dist ignore file would hide package payload from npm for {}: {}",
+ spec.package_name,
+ path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn check_wasm_runtime_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> {
+ for relative in wasm_runtime_files(spec) {
+ let path = package_dir.join(&relative);
+ if !path.is_file() {
+ return Err(format!(
+ "missing wasm package runtime artifact for {}: {}",
+ spec.package_name,
+ path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn wasm_runtime_files(spec: WasmPackageSpec) -> [String; 3] {
+ [
+ format!("dist/{}.js", spec.out_name),
+ format!("dist/{}_bg.wasm", spec.out_name),
+ format!("dist/{}_bg.wasm.d.ts", spec.out_name),
+ ]
+}
+
fn check_wasm_declaration_files(package_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> {
for expected in declaration_files(spec)? {
let path = package_dir.join(&expected.relative_path);
@@ -397,7 +650,8 @@ mod tests {
use super::{
check_binding_crate_sources, check_generated_package_artifact_inventory,
- check_no_typescript_files, check_package_index, check_wasm_package_surface,
+ check_no_typescript_files, check_package_index, check_package_json,
+ check_wasm_package_surface,
};
#[test]
@@ -481,25 +735,57 @@ mod tests {
}
#[test]
+ fn public_package_metadata_rejects_private_packages() {
+ let root = test_root("private_package_json");
+ let package_dir = root.join("packages").join("example");
+ fs::create_dir_all(&package_dir).expect("create package");
+ let package_json = package_json("example").replace(
+ r#""sideEffects": false,"#,
+ r#""private": true, "sideEffects": false,"#,
+ );
+ fs::write(package_dir.join("package.json"), package_json).expect("write package json");
+
+ let error = check_package_json(
+ &package_dir.join("package.json"),
+ "@radroots/example",
+ "packages/example",
+ )
+ .expect_err("private package rejected");
+
+ assert!(error.contains("must not set private"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn public_package_metadata_rejects_src_generated_payloads() {
+ let root = test_root("src_generated_package_payload");
+ let package_dir = root.join("packages").join("example");
+ fs::create_dir_all(&package_dir).expect("create package");
+ let package_json = package_json("example").replace(
+ r#""files": ["dist"]"#,
+ r#""files": ["dist", "src/generated"]"#,
+ );
+ fs::write(package_dir.join("package.json"), package_json).expect("write package json");
+
+ let error = check_package_json(
+ &package_dir.join("package.json"),
+ "@radroots/example",
+ "packages/example",
+ )
+ .expect_err("src generated package payload rejected");
+
+ assert!(error.contains("files must publish dist only"));
+ 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"
- }
- }
-}"#,
+ package_json("example-wasm").replace("./dist/index", "./dist/example"),
)
.expect("write package json");
fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js");
@@ -526,6 +812,7 @@ mod tests {
dto_declaration("example_bg.wasm.d.ts"),
)
.expect("write d.ts");
+ fs::write(package_dir.join("dist").join("example_bg.wasm"), b"\0asm").expect("write wasm");
let error = check_wasm_package_surface(&root, spec)
.expect_err("unknown declaration inventory rejected");
assert!(error.contains("missing wasm declaration inventory"));
@@ -540,13 +827,7 @@ mod tests {
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"
-}"#,
+ package_json("example-wasm").replace("./dist/index", "./dist/example"),
)
.expect("write package json");
fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js");
@@ -580,22 +861,91 @@ mod tests {
}
#[test]
+ fn wasm_package_surface_rejects_dist_ignore_files() {
+ let root = test_root("wasm_dist_ignore");
+ 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"),
+ package_json("example-wasm").replace("./dist/index", "./dist/example"),
+ )
+ .expect("write package json");
+ fs::write(package_dir.join("dist").join(".gitignore"), "*\n").expect("write ignore");
+ 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 ignore file rejected");
+ assert!(error.contains("would hide package payload"));
+
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn wasm_package_surface_requires_runtime_wasm_artifact() {
+ let root = test_root("wasm_runtime_artifact");
+ 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"),
+ package_json("example-wasm").replace("./dist/index", "./dist/example"),
+ )
+ .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"),
+ dto_declaration("example.d.ts"),
+ )
+ .expect("write d.ts");
+ fs::write(
+ package_dir.join("dist").join("example_bg.wasm.d.ts"),
+ dto_declaration("example_bg.wasm.d.ts"),
+ )
+ .expect("write wasm 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("missing wasm rejected");
+ assert!(error.contains("missing wasm package runtime artifact"));
+
+ 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": {
+ package_json("example-wasm")
+ .replace("./dist/index", "./dist/example")
+ .replace(
+ r#""exports": {
+ ".": {
+ "types": "./dist/example.d.ts",
+ "import": "./dist/example.js",
+ "default": "./dist/example.js"
+ }
+ }"#,
+ r#""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");
@@ -644,4 +994,36 @@ mod tests {
"// @generated by cargo xtask generate wasm via dto_bindgen\n// Do not edit by hand.\nexport type Generated = \"{name}\";\n"
)
}
+
+ fn package_json(name: &str) -> String {
+ format!(
+ r#"{{
+ "name": "@radroots/{name}",
+ "version": "0.1.0",
+ "description": "Example package",
+ "license": "MIT OR Apache-2.0",
+ "homepage": "https://radroots.org",
+ "repository": {{
+ "type": "git",
+ "url": "git+https://github.com/radrootslabs/sdk.git",
+ "directory": "packages/{name}"
+ }},
+ "publishConfig": {{
+ "access": "public"
+ }},
+ "type": "module",
+ "sideEffects": false,
+ "files": ["dist"],
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {{
+ ".": {{
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }}
+ }}
+}}"#
+ )
+ }
}
diff --git a/tools/sdk_xtask_import/src/generate.rs b/tools/sdk_xtask_import/src/generate.rs
@@ -10,6 +10,11 @@ pub fn generate_ts() -> Result<(), String> {
.join(generated_file.relative_path);
crate::fs::write_if_changed(&path, &generated_file.contents)?;
}
+ let provenance_file = output.provenance_file();
+ crate::fs::write_if_changed(
+ &root.join(&provenance_file.relative_path),
+ &provenance_file.contents,
+ )?;
println!("generated TypeScript package {}", output.spec.package_name);
}
Ok(())
diff --git a/tools/sdk_xtask_import/src/manifest.rs b/tools/sdk_xtask_import/src/manifest.rs
@@ -2,8 +2,8 @@ use serde_json::json;
use crate::package_matrix::PackageSpec;
-pub fn manifest_file_name() -> &'static str {
- "sdk-manifest.json"
+pub fn manifest_relative_path(spec: PackageSpec) -> String {
+ format!("contracts/provenance/typescript/{}.json", spec.key)
}
pub fn package_manifest(spec: PackageSpec) -> serde_json::Value {
@@ -18,13 +18,16 @@ pub fn package_manifest(spec: PackageSpec) -> serde_json::Value {
#[cfg(test)]
mod tests {
use crate::{
- manifest::{manifest_file_name, package_manifest},
+ manifest::{manifest_relative_path, package_manifest},
package_matrix::package_specs,
};
#[test]
- fn manifest_name_is_stable() {
- assert_eq!(manifest_file_name(), "sdk-manifest.json");
+ fn manifest_path_is_outside_package_source() {
+ assert_eq!(
+ manifest_relative_path(package_specs()[0]),
+ "contracts/provenance/typescript/core.json"
+ );
}
#[test]
diff --git a/tools/sdk_xtask_import/src/output.rs b/tools/sdk_xtask_import/src/output.rs
@@ -2,7 +2,7 @@ use dto_bindgen_backend_ts::{DtoTypesModule, TypeScriptImport, TypeScriptModule}
use crate::{
dto_roots,
- manifest::manifest_file_name,
+ manifest::manifest_relative_path,
manifest::package_manifest,
package_matrix::{PackageSpec, package_specs},
ts::{generated_constants_file, generated_header, generated_kinds_file, generated_types_file},
@@ -67,12 +67,15 @@ impl PackageOutput {
contents: render_ts(kinds_ts, None),
});
}
- files.push(GeneratedFile {
- relative_path: format!("src/generated/{}", manifest_file_name()),
- contents: render_manifest(self.spec),
- });
files
}
+
+ pub fn provenance_file(&self) -> GeneratedFile {
+ GeneratedFile {
+ relative_path: manifest_relative_path(self.spec),
+ contents: render_manifest(self.spec),
+ }
+ }
}
pub fn package_outputs() -> Result<Vec<PackageOutput>, String> {
@@ -271,15 +274,16 @@ mod tests {
.iter()
.find(|file| file.relative_path == "src/generated/types.ts")
.expect("types file");
- let manifest = files
- .iter()
- .find(|file| file.relative_path == "src/generated/sdk-manifest.json")
- .expect("manifest file");
+ let manifest = output.provenance_file();
assert_eq!(
types.contents,
"// @generated by cargo xtask generate ts\n// Do not edit by hand.\nimport type { LocalPrelude } from \"@radroots/local\";\nimport type { ExternalThing } from \"@radroots/external-bindings\";\n\nexport type SyntheticThing = { external: ExternalThing, };\n"
);
+ assert_eq!(
+ manifest.relative_path,
+ "contracts/provenance/typescript/core.json"
+ );
assert!(manifest.contents.contains("\"generated\": true"));
assert!(
!files
diff --git a/tools/sdk_xtask_import/src/wasm.rs b/tools/sdk_xtask_import/src/wasm.rs
@@ -1,6 +1,6 @@
use std::{
collections::BTreeSet,
- env, fs,
+ env, fs, io,
path::{Path, PathBuf},
process::Command,
};
@@ -47,6 +47,7 @@ pub fn generate(args: &[String]) -> Result<(), String> {
spec.key, spec.package_name, spec.key
));
}
+ remove_wasm_pack_gitignore(&dist_dir, spec)?;
write_declaration_files(&root, spec)?;
check_wasm_package_surface(&root, spec)?;
println!("generated wasm package {}", spec.package_name);
@@ -87,6 +88,35 @@ fn wasm_pack_args(spec: WasmPackageSpec) -> Vec<&'static str> {
]
}
+fn remove_wasm_pack_gitignore(dist_dir: &Path, spec: WasmPackageSpec) -> Result<(), String> {
+ let ignore_path = dist_dir.join(".gitignore");
+ let contents = match fs::read_to_string(&ignore_path) {
+ Ok(contents) => contents,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
+ Err(error) => {
+ return Err(format!(
+ "failed to read wasm-pack ignore file for {}: {}: {error}",
+ spec.package_name,
+ ignore_path.display()
+ ));
+ }
+ };
+ if contents.trim() != "*" {
+ return Err(format!(
+ "unexpected wasm-pack ignore file for {}: {}; refusing to remove it",
+ spec.package_name,
+ ignore_path.display()
+ ));
+ }
+ fs::remove_file(&ignore_path).map_err(|error| {
+ format!(
+ "failed to remove wasm-pack ignore file for {}: {}: {error}",
+ spec.package_name,
+ ignore_path.display()
+ )
+ })
+}
+
fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> {
match args {
[] => Ok(wasm_package_specs().to_vec()),
@@ -240,8 +270,8 @@ mod tests {
use crate::package_matrix::wasm_package_specs;
use super::{
- resolve_path_tool_from_path, rustup_tool, selected_specs, target_list_contains,
- wasm_pack_args,
+ remove_wasm_pack_gitignore, resolve_path_tool_from_path, rustup_tool, selected_specs,
+ target_list_contains, wasm_pack_args,
};
#[test]
@@ -268,6 +298,33 @@ mod tests {
}
#[test]
+ fn removes_wasm_pack_generated_gitignore() {
+ let root = test_root("wasm_pack_gitignore");
+ let dist_dir = root.join("dist");
+ fs::create_dir_all(&dist_dir).expect("create dist");
+ fs::write(dist_dir.join(".gitignore"), "*\n").expect("write ignore");
+
+ remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0]).expect("remove ignore");
+
+ assert!(!dist_dir.join(".gitignore").exists());
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn refuses_unexpected_wasm_pack_gitignore_contents() {
+ let root = test_root("custom_gitignore");
+ let dist_dir = root.join("dist");
+ fs::create_dir_all(&dist_dir).expect("create dist");
+ fs::write(dist_dir.join(".gitignore"), "!keep\n").expect("write ignore");
+
+ let error = remove_wasm_pack_gitignore(&dist_dir, wasm_package_specs()[0])
+ .expect_err("custom ignore rejected");
+
+ assert!(error.contains("unexpected wasm-pack ignore file"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
fn path_tool_resolution_reports_missing_tools() {
let error = resolve_path_tool_from_path("wasm-pack", std::ffi::OsStr::new(""))
.expect_err("missing");