use std::time::Duration;
use serde::Deserialize;
use crate::contract::schema::Adapter;
use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
pub struct NodeAdapter {
adapter: Adapter,
}
impl NodeAdapter {
#[must_use]
pub fn new(adapter: Adapter) -> Self {
debug_assert!(matches!(
adapter,
Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish
));
Self { adapter }
}
}
impl ReleaseAdapter for NodeAdapter {
fn adapter(&self) -> Adapter {
self.adapter
}
fn is_ci_delegated(&self) -> bool {
matches!(self.adapter, Adapter::ReleasePlease)
}
fn dry_run(
&self,
_ctx: &EffectCtx<'_>,
_t: &AdapterTarget,
) -> Result<DryRunReport, AdapterError> {
let (planned_commands, notes) = match self.adapter {
Adapter::Changesets => (
vec![PlannedCommand::new("changeset", &["status", "--verbose"])],
vec![],
),
Adapter::ReleasePlease => (
vec![PlannedCommand::new(
"release-please",
&["release-pr", "--dry-run"],
)],
vec!["release-please publishes on merge via CI, not from this host".to_string()],
),
_ => (
vec![PlannedCommand::new("npm", &["publish", "--dry-run"])],
vec![],
),
};
Ok(DryRunReport {
adapter: self.adapter,
planned_commands,
notes,
})
}
fn build(
&self,
ctx: &EffectCtx<'_>,
_t: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError> {
let cmd = PlannedCommand::new("npm", &["pack", "--json"]);
let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
let stdout = outputs[0].stdout.trim();
let Some(artifacts) = parse_pack_filenames(stdout).filter(|names| !names.is_empty()) else {
return Err(AdapterError::Command {
command: cmd.rendered(),
code: None,
stderr: format!(
"`npm pack --json` produced no parseable tarball filename \
(npm ≥ 7.20.3 is required for --json); output was: {stdout}"
),
});
};
Ok(BuildArtifacts {
adapter: self.adapter,
artifacts,
notes: vec![],
})
}
fn publish(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError> {
if matches!(self.adapter, Adapter::ReleasePlease) {
return Err(AdapterError::Unsupported {
adapter: self.adapter,
operation: "publish",
});
}
let cmds = match self.adapter {
Adapter::Changesets => vec![PlannedCommand::new("changeset", &["publish"])],
_ => vec![PlannedCommand::new("npm", &["publish"])],
};
run_all(ctx, &cmds)?;
let remote_url = Some(format!(
"https://www.npmjs.com/package/{}/v/{}",
t.package, t.version
));
Ok(make_receipt(ctx, t, None, remote_url))
}
fn timeout(&self) -> Duration {
Duration::from_secs(600)
}
}
#[derive(Deserialize)]
struct NpmPackEntry {
filename: String,
}
fn parse_pack_filenames(stdout: &str) -> Option<Vec<String>> {
if stdout.is_empty() {
return None;
}
let entries: Vec<NpmPackEntry> = serde_json::from_str(stdout).ok()?;
Some(entries.into_iter().map(|e| e.filename).collect())
}