Skip to main content

hm_dsl_engine/
ts_engine.rs

1use 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        // The scoped package lives under an intermediate `@harmont/` scope dir
93        // (`.hm/node_modules/@harmont/hm`). After removing the symlink, prune the
94        // now-empty scope dir (best-effort), then the node_modules dir.
95        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/// The operation the embedded JS runner should perform.
105///
106/// Modeled as a closed enum so the dispatch is exhaustive and typo-proof at
107/// compile time; the wire string lives in exactly one place (the per-variant
108/// `Display` derivation below).
109#[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    /// Create engine, detecting the preferred JS runtime (`bun` or `node`).
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if neither `bun` nor `node` is found on `PATH`.
131    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)] // method for consistency with engine API
141    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                // Stale symlink from previous run — remove so we can recreate
163                let _ = std::fs::remove_file(local_pkg);
164                true
165            }
166            Ok(_) => {
167                // Real directory (npm-installed package) — leave it alone
168                false
169            }
170            Err(_) => {
171                // Doesn't exist — create symlink
172                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        // Node ESM resolves bare specifiers relative to the importing file,
182        // ignoring NODE_PATH.  User .ts files live under <project>/.hm/,
183        // so we place a node_modules/@harmont/hm symlink there so
184        // `import '@harmont/hm'` resolves.  Cleaned up after the subprocess
185        // finishes.
186        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            // Create the `@harmont/` scope dir (and node_modules) before
194            // symlinking the scoped package into it.
195            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                // Fallback: copy files for non-unix platforms.
210                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        // Simulate npm-installed package (real directory)
305        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        // Create a dangling symlink
325        #[cfg(unix)]
326        std::os::unix::fs::symlink("/nonexistent", &pkg).unwrap();
327
328        assert!(SubprocessTsEngine::should_create_symlink(&pkg));
329    }
330}