node-app-build 6.12.2

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! `harness-state.json` — the index probes read to find the running stack.
//! Written by `harness up`; consumed by every other subcommand.
//!
//! One state file PER CHECKOUT, keyed by monorepo path exactly like the
//! per-instance dev dirs in `dev::host::monorepo`. A single global file made
//! two checkouts silently share one index: the second `up` overwrote the
//! first's entry, after which the first checkout's `status`/`pay`/`logs` read
//! the other stack's URLs and `down` SIGTERMed the other checkout's supervisor.

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BitcoindState {
    pub mode: String,
    pub rpc_url: String,
    pub rpc_user: String,
    pub container_id: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct InstanceState {
    pub name: String,
    pub session_path: PathBuf,
    pub base_url: String,
    pub ldk_addr: String,
    pub node_id: String,
    pub pid: Option<u32>,
    /// The instance's client-node lane ports (http/https/p2p/ui) as
    /// ACTUALLY allocated by `ports::allocate_lane_ports`, when
    /// `--client-node` was used for this instance. `None` for a plain
    /// (non-client-node) instance.
    ///
    /// This is the source of truth once a second concurrent client-node
    /// harness forces a shift past the documented base (spec D7 revision) —
    /// `base_url`/`ldk_addr` above already carry the live, actually-bound
    /// address (derived from the running daemon's own session, never
    /// recomputed), so this field mainly exists to make the CHOICE visible
    /// as a labeled unit (all four ports together) rather than operators
    /// having to reverse-engineer it from `base_url`'s port number alone.
    #[serde(default)]
    pub client_node_ports: Option<super::ports::PortSet>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ChannelState {
    pub from: String,
    pub to: String,
    pub capacity_sats: u64,
    pub status: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct HarnessState {
    pub created_at: String,
    /// Checkout this stack was brought up from. Identifies the harness in
    /// diagnostics when more than one exists on the box.
    #[serde(default)]
    pub monorepo_path: PathBuf,
    pub bitcoind: BitcoindState,
    pub instances: Vec<InstanceState>,
    pub channel: Option<ChannelState>,
    /// PID of the foreground `harness up` supervisor process. `harness down`
    /// (Task 7) signals this PID to trigger clean teardown. `None` for state
    /// written outside a supervisor context.
    #[serde(default)]
    pub supervisor_pid: Option<u32>,
    /// Name of the instance (`alice`/`bob`) serving the client-node PWA lane
    /// with auto-approval, when `--client-node` was passed to `harness up`.
    /// `None` when the harness was brought up without client-node mode.
    #[serde(default)]
    pub client_node_instance: Option<String>,
    /// Whether `--operation-mode` was passed to `harness up` for the selected
    /// instance. Plain flag only — no device IDs, tokens, delegation
    /// credentials, or approval progress are ever persisted here.
    #[serde(default)]
    pub operation_mode: bool,
    /// Outcome of the PWA dist build this `up` run joined before spawning
    /// any daemon (spec D6 / Task 2): `Some(true)` — `yarn/npm build` ran
    /// and succeeded; `Some(false)` — skipped because `system/pwa/package.json`
    /// was not present in this checkout; `None` — this state predates the
    /// join (old `harness-state.json`) or `up` never reached the point
    /// where a monorepo host reports it. A build FAILURE never reaches
    /// this field at all: `ensure_running` aborts `up` before state is
    /// ever written, which is the actual fix — see
    /// `dev::host::monorepo::pwa_dist_build_outcome`.
    #[serde(default)]
    pub pwa_dist_built: Option<bool>,
}

/// Root holding one subdirectory per checkout that has a harness state.
fn harness_root() -> Result<PathBuf> {
    let base = match std::env::var("XDG_CACHE_HOME") {
        Ok(c) if !c.is_empty() => PathBuf::from(c),
        _ => {
            let home = std::env::var_os("HOME")
                .ok_or_else(|| anyhow!("$HOME not set"))?;
            PathBuf::from(home).join(".cache")
        }
    };
    Ok(base.join("node-app").join("harness"))
}

/// State file for a specific checkout.
pub fn state_path(monorepo_path: &Path) -> Result<PathBuf> {
    let mut h = DefaultHasher::new();
    monorepo_path
        .canonicalize()
        .unwrap_or_else(|_| monorepo_path.to_path_buf())
        .hash(&mut h);
    Ok(harness_root()?
        .join(format!("monorepo-{:x}", h.finish()))
        .join("harness-state.json"))
}

/// The per-checkout lock guarding `harness up`.
pub fn lock_path(monorepo_path: &Path) -> Result<PathBuf> {
    Ok(state_path(monorepo_path)?.with_file_name("harness.lock"))
}

/// Exclusive ownership of one checkout's harness, released on drop.
///
/// Harness state is keyed by checkout (`state_path`), so two `harness up` runs against the same
/// tree share one `harness-state.json` — and `up --clean` deletes it. A second run therefore
/// silently erased the first's state, which orphaned the first run's daemons AND made
/// `harness down` a no-op for them: `down` reads `supervisor_pid` out of exactly that file, so
/// with the file gone it reports "no running harness" while two node-servers keep holding their
/// ports. Observed live with two agent sessions driving the same checkout.
///
/// A lock file rather than an advisory `flock`: the owner must survive being inspected by a
/// DIFFERENT process (`down`, a later `up`) that needs to answer "is the holder still alive?",
/// which a PID in a file answers portably and a held fd does not.
pub struct HarnessLock {
    path: PathBuf,
}

impl HarnessLock {
    /// Take the lock for `monorepo_path`, or explain who holds it.
    ///
    /// A lock whose PID is no longer alive is stale — a supervisor that was SIGKILLed, or a box
    /// that rebooted — and is reclaimed rather than left to wedge the checkout forever.
    pub fn acquire(monorepo_path: &Path) -> Result<Self> {
        let path = lock_path(monorepo_path)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create harness state dir {}", parent.display()))?;
        }

        loop {
            match std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&path)
            {
                Ok(mut file) => {
                    use std::io::Write;
                    let _ = write!(file, "{}", std::process::id());
                    return Ok(Self { path });
                }
                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                    let holder = std::fs::read_to_string(&path)
                        .ok()
                        .and_then(|s| s.trim().parse::<u32>().ok());
                    match holder {
                        Some(pid) if pid_is_alive(pid) => {
                            anyhow::bail!(
                                "another `harness up` (PID {pid}) already owns this checkout.\n\
                                 Harness state is per-checkout, so a second run would delete the \
                                 first's state and orphan its daemons.\n\
                                 Stop it with `node-app harness down`, or run from a separate \
                                 checkout/worktree."
                            );
                        }
                        // Stale (dead holder) or unreadable — reclaim it and retry the create.
                        _ => {
                            let _ = std::fs::remove_file(&path);
                            continue;
                        }
                    }
                }
                Err(e) => {
                    return Err(e).with_context(|| format!("create lock {}", path.display()));
                }
            }
        }
    }

}

impl Drop for HarnessLock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

/// Whether `pid` names a live process. `kill(pid, 0)` reports existence without delivering a
/// signal; a non-zero return is ESRCH (gone) or EPERM (alive but not ours — still alive).
pub fn pid_is_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        // SAFETY: `kill` with signal 0 delivers nothing; any PID value is safe to pass.
        unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
    }
    #[cfg(not(unix))]
    {
        let _ = pid;
        false
    }
}

/// Every harness state file currently on the box.
fn all_state_paths() -> Result<Vec<PathBuf>> {
    let root = harness_root()?;
    let Ok(entries) = std::fs::read_dir(&root) else {
        return Ok(Vec::new());
    };
    let mut found: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path().join("harness-state.json"))
        .filter(|p| p.is_file())
        .collect();
    // A stack brought up by a pre-namespacing binary left its index at the old
    // global path. Keep finding it, or upgrading the CLI would strand a running
    // harness that `down` could no longer see.
    let legacy = root.join("harness-state.json");
    if legacy.is_file() {
        found.push(legacy);
    }
    found.sort();
    Ok(found)
}

/// Resolve which harness a probe should talk to.
///
/// Probes take no `--monorepo-path`, so the current directory picks the stack —
/// which is what an operator means by running a probe "in" a checkout. Falling
/// back to a lone harness elsewhere keeps probes usable from any cwd, and
/// listing the candidates when there are several is the point of the whole
/// change: never silently guess between two running stacks.
pub fn resolve_state_path() -> Result<PathBuf> {
    let cwd = std::env::current_dir().context("resolve current directory")?;
    let preferred = state_path(&cwd)?;
    if preferred.is_file() {
        return Ok(preferred);
    }

    let candidates = all_state_paths()?;
    match candidates.len() {
        0 => Err(anyhow!(
            "no harness state for {} — run `node-app harness up` first",
            cwd.display()
        )),
        1 => {
            let only = candidates.into_iter().next().expect("len checked");
            eprintln!(
                "harness: no stack for {}; using the only running harness ({})",
                cwd.display(),
                state_owner(&only)
            );
            Ok(only)
        }
        _ => {
            let listed = candidates
                .iter()
                .map(|p| format!("  {}", state_owner(p)))
                .collect::<Vec<_>>()
                .join("\n");
            Err(anyhow!(
                "no harness state for {}, and several harnesses are up:\n{listed}\n\
                 cd into the checkout whose harness you mean, then re-run.",
                cwd.display()
            ))
        }
    }
}

/// Human-readable owner of a state file: the checkout it was brought up from,
/// or the file's own path when the state predates `monorepo_path` (legacy).
fn state_owner(path: &Path) -> String {
    let owner = std::fs::read(path)
        .ok()
        .and_then(|bytes| serde_json::from_slice::<HarnessState>(&bytes).ok())
        .map(|state| state.monorepo_path);
    match owner {
        Some(p) if !p.as_os_str().is_empty() => p.display().to_string(),
        _ => path.display().to_string(),
    }
}

impl HarnessState {
    pub fn load() -> Result<Self> {
        let path = resolve_state_path()?;
        let bytes = std::fs::read(&path)
            .with_context(|| format!("read harness state at {}", path.display()))?;
        serde_json::from_slice(&bytes).context("parse harness-state.json")
    }

    /// Load the state for a SPECIFIC checkout, bypassing `resolve_state_path`'s
    /// CWD-based guessing. Port-ownership attribution (`harness::ports`) must
    /// check the invoking `MonorepoHost`'s own `monorepo_path` — which need
    /// not be the process's current directory (`--monorepo-path` can point
    /// anywhere) — so it calls this instead of `load()`.
    pub fn load_for(monorepo_path: &Path) -> Result<Self> {
        let path = state_path(monorepo_path)?;
        let bytes = std::fs::read(&path)
            .with_context(|| format!("read harness state at {}", path.display()))?;
        serde_json::from_slice(&bytes).context("parse harness-state.json")
    }

    pub fn save(&self) -> Result<PathBuf> {
        let path = state_path(&self.monorepo_path)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).with_context(|| {
                format!("create harness state dir {}", parent.display())
            })?;
        }
        let json = serde_json::to_string_pretty(self).context("serialize harness state")?;
        std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
        Ok(path)
    }

    pub fn instance(&self, name: &str) -> Result<&InstanceState> {
        self.instances
            .iter()
            .find(|i| i.name == name)
            .ok_or_else(|| anyhow!("unknown instance '{name}'; known: {}", self.known_names()))
    }

    fn known_names(&self) -> String {
        self.instances
            .iter()
            .map(|i| i.name.as_str())
            .collect::<Vec<_>>()
            .join(", ")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Two checkouts must never resolve to the same index file — that is what
    /// let a second `up` clobber the first's entry and made `harness down`
    /// signal the wrong supervisor.
    #[test]
    fn state_path_is_per_checkout() {
        let a = state_path(Path::new("/tmp/checkout-a")).unwrap();
        let b = state_path(Path::new("/tmp/checkout-b")).unwrap();
        assert_ne!(a, b);
        assert_eq!(a.file_name().unwrap(), "harness-state.json");
        assert_eq!(a.parent().unwrap().parent(), b.parent().unwrap().parent());
        // Stable across calls, so probes find what `up` wrote.
        assert_eq!(a, state_path(Path::new("/tmp/checkout-a")).unwrap());
    }

    #[test]
    fn state_serializes_and_round_trips() {
        let state = HarnessState {
            created_at: "2026-07-01T00:00:00Z".into(),
            monorepo_path: "/tmp/monorepo".into(),
            bitcoind: BitcoindState {
                mode: "docker".into(),
                rpc_url: "http://127.0.0.1:18443".into(),
                rpc_user: "polaruser".into(),
                container_id: Some("abc123".into()),
            },
            instances: vec![InstanceState {
                name: "alice".into(),
                session_path: "/tmp/alice-agent-session.json".into(),
                base_url: "http://127.0.0.1:3001".into(),
                ldk_addr: "127.0.0.1:9937".into(),
                node_id: "03aa".into(),
                pid: Some(4242),
                client_node_ports: None,
            }],
            channel: None,
            supervisor_pid: Some(9999),
            client_node_instance: Some("alice".into()),
            operation_mode: true,
            pwa_dist_built: Some(true),
        };
        let json = serde_json::to_string(&state).unwrap();
        let back: HarnessState = serde_json::from_str(&json).unwrap();
        assert_eq!(back.instance("alice").unwrap().base_url, "http://127.0.0.1:3001");
        assert!(back.instance("bob").is_err());
        assert_eq!(back.supervisor_pid, Some(9999));
        assert_eq!(back.client_node_instance, Some("alice".into()));
        assert!(back.operation_mode);
        assert_eq!(back.pwa_dist_built, Some(true));
    }

    /// An old `harness-state.json` written before this field existed must
    /// still parse — `#[serde(default)]` is what makes that true; this
    /// test is what catches a future edit accidentally dropping it.
    #[test]
    fn missing_pwa_dist_built_defaults_to_none_for_legacy_state() {
        let legacy_json = r#"{
            "created_at": "2026-07-01T00:00:00Z",
            "monorepo_path": "/tmp/monorepo",
            "bitcoind": {
                "mode": "docker",
                "rpc_url": "http://127.0.0.1:18443",
                "rpc_user": "polaruser",
                "container_id": null
            },
            "instances": [],
            "channel": null
        }"#;
        let state: HarnessState = serde_json::from_str(legacy_json).unwrap();
        assert_eq!(state.pwa_dist_built, None);
    }
}