| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // StatePaths are the absolute remote-side paths for one workspace's serve |
| 9 | // state. All are under ~/.reasonix/remote. |
| 10 | type StatePaths struct { |
| 11 | Dir string // ~/.reasonix/remote |
| 12 | StateJSON string |
| 13 | TokenFile string |
| 14 | LogFile string |
| 15 | PortFile string |
| 16 | PidFile string |
| 17 | LockDir string |
| 18 | LockOwner string |
| 19 | } |
| 20 | |
| 21 | // shellQuote wraps s in single quotes safe for POSIX sh, escaping embedded |
| 22 | // single quotes as '\”. This is the only quoting used for remote command |
| 23 | // operands; every interpolated path/workspace passes through it. |
| 24 | func shellQuote(s string) string { |
| 25 | return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" |
| 26 | } |
| 27 | |
| 28 | // LaunchCommand builds the `sh -c` script that starts a detached serve in |
| 29 | // workspace, writing the port/pid files and appending output to the log. The |
| 30 | // binary path and every operand are single-quote-escaped so hostile paths |
| 31 | // (spaces, quotes, `; rm -rf ~`) cannot break out. |
| 32 | // |
| 33 | // Detachment: `setsid` fully divorces the process from any session, but it is |
| 34 | // absent on stock macOS, so it is used only when present (`$SX`); `nohup` + |
| 35 | // backgrounding + `</dev/null` is sufficient over a non-interactive SSH exec. |
| 36 | // The log is created 0600 (umask 077 + explicit chmod) so a same-machine user |
| 37 | // cannot read serve output; serve is launched with `--port-file`, which |
| 38 | // suppresses its token share line, so the token never reaches the log. |
| 39 | // It echoes the shell's $! so the caller can record the pid immediately. |
| 40 | func LaunchCommand(bin, workspace string, p StatePaths) string { |
| 41 | return fmt.Sprintf( |
| 42 | "mkdir -p %s && cd %s && rm -f %s %s && umask 077 && : >>%s && chmod 600 %s && "+ |
| 43 | "SX=; command -v setsid >/dev/null 2>&1 && SX=setsid; "+ |
| 44 | "$SX nohup %s serve --addr 127.0.0.1:0 --auth token --token-file %s --port-file %s --pid-file %s </dev/null >>%s 2>&1 & echo $!", |
| 45 | shellQuote(p.Dir), |
| 46 | shellQuote(workspace), |
| 47 | shellQuote(p.PortFile), |
| 48 | shellQuote(p.PidFile), |
| 49 | shellQuote(p.LogFile), |
| 50 | shellQuote(p.LogFile), |
| 51 | shellQuote(bin), |
| 52 | shellQuote(p.TokenFile), |
| 53 | shellQuote(p.PortFile), |
| 54 | shellQuote(p.PidFile), |
| 55 | shellQuote(p.LogFile), |
| 56 | ) |
| 57 | } |
| 58 | |
| 59 | // StopCommand builds a script that TERMs the pid, waits up to ~5s, then KILLs |
| 60 | // if still alive. pid is validated numeric by the caller, and the caller has |
| 61 | // already confirmed (ServeAliveCommand) that the pid is our serve, so PID reuse |
| 62 | // cannot cause an unrelated process to be signalled. |
| 63 | func StopCommand(pid int, p StatePaths) string { |
| 64 | return fmt.Sprintf( |
| 65 | "T=%s; P=%s; ours() { A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+ |
| 66 | "case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) return 0;; *) return 1;; esac; }; "+ |
| 67 | "ours || exit 0; kill -TERM %d 2>/dev/null; "+ |
| 68 | "for i in 1 2 3 4 5; do kill -0 %d 2>/dev/null || exit 0; ours || exit 0; sleep 1; done; "+ |
| 69 | "ours && kill -KILL %d 2>/dev/null; exit 0", |
| 70 | shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid, pid, pid, |
| 71 | ) |
| 72 | } |
| 73 | |
| 74 | // ServeAliveCommand prints "1" only when pid is running AND its command line |
| 75 | // looks like a reasonix serve process. Checking the args (not just `kill -0`) |
| 76 | // prevents a recycled PID — now owned by an unrelated process — from being |
| 77 | // mistaken for the serve and later signalled by StopCommand. |
| 78 | func ServeAliveCommand(pid int, p StatePaths) string { |
| 79 | return fmt.Sprintf( |
| 80 | "T=%s; P=%s; kill -0 %d 2>/dev/null || { echo 0; exit 0; }; "+ |
| 81 | "A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+ |
| 82 | "case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) echo 1;; *) echo 0;; esac", |
| 83 | shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid, |
| 84 | ) |
| 85 | } |
| 86 | |
| 87 | // LogsCommand tails n lines of the log file (n<=0 => 200). |
| 88 | func LogsCommand(logFile string, n int) string { |
| 89 | if n <= 0 { |
| 90 | n = 200 |
| 91 | } |
| 92 | return fmt.Sprintf("tail -n %d %s 2>/dev/null || true", n, shellQuote(logFile)) |
| 93 | } |
| 94 | |
| 95 | // servePortFileMarker is what LocateCommand greps for in `serve --help` to |
| 96 | // decide the located binary supports --port-file/--token-file. It must match |
| 97 | // the flag name registered in runServe. |
| 98 | const servePortFileMarker = "port-file" |
| 99 | |
| 100 | // LocateCommand probes for a usable reasonix binary. It prints three lines: |
| 101 | // the resolved path (or empty), the `--version` output, and "portfile:yes" when |
| 102 | // `serve --help` advertises the --port-file flag. The bootstrap gates on the |
| 103 | // flag, not the version number, because --port-file/--token-file ship in this |
| 104 | // change: a version gate cannot know its own future release number, and any |
| 105 | // already-released binary would pass a numeric gate yet still lack the flags. |
| 106 | func LocateCommand(uploadedBin string) string { |
| 107 | return fmt.Sprintf( |
| 108 | "BIN=\"$(command -v reasonix 2>/dev/null)\"; "+ |
| 109 | "if [ -z \"$BIN\" ] && [ -x %s ]; then BIN=%s; fi; "+ |
| 110 | "if [ -z \"$BIN\" ]; then P=\"$(npm prefix -g 2>/dev/null)\"; if [ -n \"$P\" ] && [ -x \"$P/bin/reasonix\" ]; then BIN=\"$P/bin/reasonix\"; fi; fi; "+ |
| 111 | "echo \"$BIN\"; "+ |
| 112 | "if [ -n \"$BIN\" ]; then \"$BIN\" --version 2>/dev/null; "+ |
| 113 | "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo portfile:yes; else echo portfile:no; fi; fi", |
| 114 | shellQuote(uploadedBin), shellQuote(uploadedBin), shellQuote(servePortFileMarker), |
| 115 | ) |
| 116 | } |
| 117 |