hm_dsl_engine/
ts_engine.rs1use std::path::Path;
2use std::process::Stdio;
3
4use anyhow::{Context, Result, bail};
5use async_trait::async_trait;
6use tracing::debug;
7
8use crate::bundled_sources;
9use crate::{DslEngine, PipelineMeta};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum JsRuntime {
13 Bun,
14 Node,
15}
16
17impl JsRuntime {
18 fn detect() -> Result<(Self, std::path::PathBuf)> {
19 if let Ok(p) = which::which("bun") {
20 return Ok((Self::Bun, p));
21 }
22 if let Ok(p) = which::which("node") {
23 return Ok((Self::Node, p));
24 }
25 bail!(
26 "no JavaScript runtime found on PATH\n \
27 → install Bun (https://bun.sh) or Node.js 22+ (https://nodejs.org)"
28 )
29 }
30}
31
32const RUNNER_SCRIPT: &str = r#"
33import { readdirSync } from 'node:fs';
34import { join, resolve } from 'node:path';
35
36const projectDir = process.argv[2];
37const mode = process.argv[3]; // "list", "registry", or "render"
38const slug = process.argv[4] || null;
39const harmontDir = join(projectDir, '.hm');
40
41const tsFiles = readdirSync(harmontDir)
42 .filter(f => f.endsWith('.ts'))
43 .sort();
44
45if (tsFiles.length === 0) {
46 process.stderr.write(`error: no .ts files found in ${harmontDir}\n`);
47 process.exit(1);
48}
49
50const defs = [];
51for (const file of tsFiles) {
52 const filePath = resolve(harmontDir, file);
53 const mod = await import(filePath);
54 const d = mod.default ?? mod.pipelines;
55 if (Array.isArray(d)) defs.push(...d);
56 else if (d) defs.push(d);
57}
58
59const { renderEnvelope } = await import('@harmont/hm');
60const envelope = JSON.parse(renderEnvelope(defs, { basePath: projectDir }));
61
62if (mode === 'render') {
63 const match = envelope.pipelines.find(p => p.slug === slug);
64 if (!match) {
65 const avail = envelope.pipelines.map(p => p.slug).join(', ') || '(none)';
66 process.stderr.write(`error: pipeline '${slug}' not found\n -> available: ${avail}\n`);
67 process.exit(2);
68 }
69 process.stdout.write(JSON.stringify(match.definition));
70} else if (mode === 'registry') {
71 // Full discovery envelope, byte-for-byte parity with the Python
72 // `dump_registry_json()` shape: { schema_version, pipelines: [{ slug,
73 // name, allow_manual, triggers, definition }] }.
74 process.stdout.write(JSON.stringify(envelope));
75} else {
76 const metas = envelope.pipelines.map(p => ({ slug: p.slug, name: p.name }));
77 process.stdout.write(JSON.stringify(metas));
78}
79"#;
80
81const PACKAGE_JSON: &str = r#"{"name":"@harmont/hm","type":"module","exports":{".":"./index.mjs","./toolchains":"./toolchains.mjs"}}"#;
82
83struct SymlinkCleanup {
84 pkg: std::path::PathBuf,
85 nm: std::path::PathBuf,
86 remove_nm: bool,
87}
88
89impl Drop for SymlinkCleanup {
90 fn drop(&mut self) {
91 let _ = std::fs::remove_file(&self.pkg).or_else(|_| std::fs::remove_dir_all(&self.pkg));
92 if let Some(scope) = self.pkg.parent() {
96 let _ = std::fs::remove_dir(scope);
97 }
98 if self.remove_nm {
99 let _ = std::fs::remove_dir(&self.nm);
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)]
110enum RunMode {
111 #[display("list")]
112 List,
113 #[display("render")]
114 Render,
115 #[display("registry")]
116 Registry,
117}
118
119#[derive(Debug)]
120pub struct SubprocessTsEngine {
121 runtime: JsRuntime,
122 runtime_bin: std::path::PathBuf,
123}
124
125impl SubprocessTsEngine {
126 pub fn new() -> Result<Self> {
132 let (runtime, runtime_bin) = JsRuntime::detect()?;
133 debug!(?runtime, ?runtime_bin, "detected JS runtime");
134 Ok(Self {
135 runtime,
136 runtime_bin,
137 })
138 }
139
140 #[allow(clippy::unused_self)] fn setup_temp(&self) -> Result<tempfile::TempDir> {
142 let tmp = tempfile::tempdir().context("creating temp dir for harmont-ts")?;
143
144 let pkg_dir = tmp.path().join("node_modules/@harmont/hm");
145 std::fs::create_dir_all(&pkg_dir).context("creating node_modules/@harmont/hm")?;
146
147 std::fs::write(pkg_dir.join("package.json"), PACKAGE_JSON)?;
148 std::fs::write(pkg_dir.join("index.mjs"), bundled_sources::HARMONT_TS_INDEX)?;
149 std::fs::write(
150 pkg_dir.join("toolchains.mjs"),
151 bundled_sources::HARMONT_TS_TOOLCHAINS,
152 )?;
153
154 std::fs::write(tmp.path().join("runner.mjs"), RUNNER_SCRIPT)?;
155
156 Ok(tmp)
157 }
158
159 fn should_create_symlink(local_pkg: &Path) -> bool {
160 match local_pkg.symlink_metadata() {
161 Ok(meta) if meta.file_type().is_symlink() => {
162 let _ = std::fs::remove_file(local_pkg);
164 true
165 }
166 Ok(_) => {
167 false
169 }
170 Err(_) => {
171 true
173 }
174 }
175 }
176
177 async fn run(&self, project_dir: &Path, mode: RunMode, slug: Option<&str>) -> Result<String> {
178 let tmp = self.setup_temp()?;
179 let runner_path = tmp.path().join("runner.mjs");
180
181 let harmont_dir = project_dir.join(".hm");
187 let local_nm = harmont_dir.join("node_modules");
188 let local_pkg = local_nm.join("@harmont/hm");
189
190 let _cleanup: Option<SymlinkCleanup> = if Self::should_create_symlink(&local_pkg) {
191 let created_local_nm = !local_nm.exists();
192
193 if let Some(scope_dir) = local_pkg.parent() {
196 std::fs::create_dir_all(scope_dir)
197 .context("creating .hm/node_modules/@harmont for module resolution")?;
198 }
199
200 let src = tmp.path().join("node_modules/@harmont/hm");
201
202 #[cfg(unix)]
203 {
204 std::os::unix::fs::symlink(&src, &local_pkg)
205 .context("symlinking @harmont/hm package into .hm/node_modules")?;
206 }
207 #[cfg(not(unix))]
208 {
209 std::fs::create_dir_all(&local_pkg)?;
211 for entry in std::fs::read_dir(&src)? {
212 let entry = entry?;
213 std::fs::copy(entry.path(), local_pkg.join(entry.file_name()))?;
214 }
215 }
216
217 Some(SymlinkCleanup {
218 pkg: local_pkg.clone(),
219 nm: local_nm.clone(),
220 remove_nm: created_local_nm,
221 })
222 } else {
223 debug!(
224 ?local_pkg,
225 "npm-installed @harmont/hm found — skipping symlink"
226 );
227 None
228 };
229
230 let mut cmd = tokio::process::Command::new(&self.runtime_bin);
231
232 match self.runtime {
233 JsRuntime::Bun => {
234 cmd.arg("run").arg(&runner_path);
235 }
236 JsRuntime::Node => {
237 cmd.arg("--experimental-strip-types").arg(&runner_path);
238 }
239 }
240
241 cmd.arg(project_dir).arg(mode.to_string());
242
243 if let Some(s) = slug {
244 cmd.arg(s);
245 }
246
247 cmd.env("NODE_PATH", tmp.path().join("node_modules"))
248 .stdin(Stdio::null())
249 .stdout(Stdio::piped())
250 .stderr(Stdio::piped());
251
252 debug!(?cmd, "running JS subprocess");
253
254 let output = cmd.output().await.context("spawning JS runtime")?;
255
256 if !output.status.success() {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 let code = output.status.code().unwrap_or(-1);
259 bail!("{:?} exited with code {code}:\n{stderr}", self.runtime);
260 }
261
262 String::from_utf8(output.stdout).context("JS runtime stdout is not valid UTF-8")
263 }
264}
265
266#[async_trait]
267impl DslEngine for SubprocessTsEngine {
268 async fn list_pipelines(&self, project_dir: &Path) -> Result<Vec<PipelineMeta>> {
269 let stdout = self
270 .run(project_dir, RunMode::List, None)
271 .await
272 .context("listing pipelines via JS runtime")?;
273
274 debug!(raw_len = stdout.len(), "list_pipelines stdout");
275
276 serde_json::from_str(&stdout).context("decoding pipeline metadata from JS stdout")
277 }
278
279 async fn render_pipeline_json(&self, project_dir: &Path, slug: &str) -> Result<String> {
280 self.run(project_dir, RunMode::Render, Some(slug))
281 .await
282 .context("rendering pipeline via JS runtime")
283 }
284
285 async fn registry_json(&self, project_dir: &Path) -> Result<String> {
286 self.run(project_dir, RunMode::Registry, None)
287 .await
288 .context("dumping pipeline registry via JS runtime")
289 }
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used, clippy::expect_used)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn symlink_skipped_when_real_dir_exists() {
299 let tmp = tempfile::tempdir().unwrap();
300 let harmont_dir = tmp.path().join(".hm");
301 let nm = harmont_dir.join("node_modules");
302 let pkg = nm.join("@harmont/hm");
303
304 std::fs::create_dir_all(&pkg).unwrap();
306 std::fs::write(pkg.join("package.json"), "{}").unwrap();
307
308 assert!(!SubprocessTsEngine::should_create_symlink(&pkg));
309 }
310
311 #[test]
312 fn symlink_created_when_nothing_exists() {
313 let tmp = tempfile::tempdir().unwrap();
314 let pkg = tmp.path().join("node_modules/@harmont/hm");
315 assert!(SubprocessTsEngine::should_create_symlink(&pkg));
316 }
317
318 #[test]
319 fn symlink_created_when_stale_symlink_exists() {
320 let tmp = tempfile::tempdir().unwrap();
321 let pkg = tmp.path().join("node_modules/@harmont/hm");
322 std::fs::create_dir_all(pkg.parent().unwrap()).unwrap();
323
324 #[cfg(unix)]
326 std::os::unix::fs::symlink("/nonexistent", &pkg).unwrap();
327
328 assert!(SubprocessTsEngine::should_create_symlink(&pkg));
329 }
330}