@tiens.nguyen/gu-cli 1.0.789 → 1.0.791

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5183,90 +5183,6 @@ def _same_embed_model(a: str, b: str) -> bool:
5183
5183
  _RUNTOOL_PREFIX = "__RUNTOOL__::"
5184
5184
 
5185
5185
 
5186
- # ---- the command that is running, WHOLE (task #331) ------------------------------------------
5187
- # "Running → <cmd>" carries the first 70 characters, which is right for a history line and wrong
5188
- # for the one moment someone is watching: a heredoc or a long pipeline was cut at the source, so
5189
- # the terminal could not show the rest whatever its layout. Sent beside that line (which stays,
5190
- # for the web app and for older terminals) as ONE line of JSON — a command can span lines.
5191
- _RUNCMD_PREFIX = "__RUNCMD__::"
5192
- # How much it has printed so far: "__RUNOUT__::<lines>". From the wait loop, only when it changed.
5193
- _RUNOUT_PREFIX = "__RUNOUT__::"
5194
- _RUNCMD_MAX_CHARS = 4000
5195
-
5196
- # SECRETS. The 70-character cut hid most credentials by accident; showing the whole command must
5197
- # not undo that. Conservative and by SHAPE — a value after a name that says it is a secret, a
5198
- # bearer header, user:password@ in a URL, sshpass -p, and the key formats gu itself hands out.
5199
- # This is what goes to the SCREEN and the job stream; the command that runs is untouched.
5200
- # "pass" and "token" only as whole words: --passes=3, bypass=true and --max-tokens 100 are not secrets.
5201
- _SECRET_NAME = (r"(?:password|passwd|passphrase|pwd|(?<![a-z])pass(?![a-z])|secret|token(?![a-z])"
5202
- r"|api[_-]?key|access[_-]?key)")
5203
- _CMD_SECRET_RES = (
5204
- (re.compile(r"(?i)(\b[\w.-]*" + _SECRET_NAME + r"[\w.-]*\s*[=:]\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5205
- (re.compile(r"(?i)(--?[\w-]*" + _SECRET_NAME + r"[\w-]*[ =])(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5206
- (re.compile(r"(?i)(\bsshpass\s+-p\s*)(\"[^\"]*\"|'[^']*'|[^\s;&|]+)"), r"\1•••"),
5207
- (re.compile(r"(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s\"']+"), r"\1•••"),
5208
- (re.compile(r"(?i)(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)[^\s/@]+(@)"), r"\1•••\2"),
5209
- (re.compile(r"\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|wk_[A-Za-z0-9_-]{16,}|gu1\.[A-Za-z0-9_.-]{20,})"), "•••"),
5210
- )
5211
-
5212
-
5213
- def _mask_command_secrets(command: str) -> str:
5214
- """The command as it may be SHOWN: credentials replaced by •••, everything else as written."""
5215
- text = str(command or "")
5216
- for rx, sub in _CMD_SECRET_RES:
5217
- text = rx.sub(sub, text)
5218
- return text
5219
-
5220
-
5221
- def _command_label(command: str, limit: int = 70) -> str:
5222
- """A command as ONE LINE of a step label: masked, its first line only, cut to `limit`.
5223
-
5224
- A step is a line of the stream. A heredoc's newlines made "Command finished → python3 - <<'EOF'"
5225
- followed by every line of the script as a bullet of its own — and, on the way in, ended the
5226
- live status row the moment the "Running →" line was read (#332). " …" says there is more.
5227
- """
5228
- lines = [ln.strip() for ln in _mask_command_secrets(command).replace("\r", "").split("\n") if ln.strip()]
5229
- if not lines:
5230
- return ""
5231
- return lines[0][:limit] + (" …" if len(lines) > 1 else "")
5232
-
5233
-
5234
- def _running_command_line(command: str) -> str:
5235
- """The stream line that carries the whole command to the terminal (masked, capped)."""
5236
- shown = _mask_command_secrets(command)
5237
- if len(shown) > _RUNCMD_MAX_CHARS:
5238
- shown = shown[:_RUNCMD_MAX_CHARS] + "…"
5239
- return _RUNCMD_PREFIX + json.dumps({"cmd": shown}, ensure_ascii=False)
5240
-
5241
-
5242
- def _count_output_lines(path: str, state: dict) -> int:
5243
- """Lines a running command has written to its log so far. Reads only what is NEW since the
5244
- last call (`state` keeps the offset), so a chatty ten-minute build is not re-read every tick."""
5245
- try:
5246
- with open(path, "rb") as fh:
5247
- fh.seek(state.get("off", 0))
5248
- chunk = fh.read()
5249
- except OSError:
5250
- return state.get("lines", 0)
5251
- if chunk:
5252
- state["off"] = state.get("off", 0) + len(chunk)
5253
- state["lines"] = state.get("lines", 0) + chunk.count(b"\n")
5254
- state["partial"] = not chunk.endswith(b"\n")
5255
- # a last line with no newline yet is still a line someone could read
5256
- return state.get("lines", 0) + (1 if state.get("partial") else 0)
5257
-
5258
-
5259
- def _emit_command_progress(path: str, state: dict) -> None:
5260
- """Tell the terminal how much the running command has printed — only when that changed."""
5261
- try:
5262
- n = _count_output_lines(path, state)
5263
- if n != state.get("sent", 0):
5264
- state["sent"] = n
5265
- _emit({"type": "step", "text": f"{_RUNOUT_PREFIX}{n}"})
5266
- except Exception: # noqa: BLE001 — a progress figure must never fail a command
5267
- pass
5268
-
5269
-
5270
5186
  def _fmt_call(name: str, **kw) -> str:
5271
5187
  """A tool call rendered the way the model writes it. Skips empty/default arguments."""
5272
5188
  parts = []
@@ -6475,114 +6391,6 @@ def _and_chain_parts(command):
6475
6391
  return parts
6476
6392
 
6477
6393
 
6478
- def _parse_heredoc(command):
6479
- """A heredoc or here-string in `command` → what to run and what to feed it. (task #332)
6480
-
6481
- THE BUG THIS EXISTS FOR. run_command has no shell, so
6482
-
6483
- python3 - <<'EOF'
6484
- print("hello")
6485
- EOF
6486
-
6487
- became argv ["python3", "-", "<<EOF", "print(hello)", "EOF"]: python read its script from an
6488
- EMPTY stdin and exited 0 with no output. Nothing ran and the step looked like a success —
6489
- the #204 failure again, in the one shape #204's check did not cover (its redirect pattern
6490
- matches "<" and ">", not "<<"). Seen live: the model lost two steps rewriting the work as
6491
- `python3 -c`. Heredocs are how a strong model runs a multi-line script without a file, so
6492
- this is the common case, not an edge.
6493
-
6494
- SUPPORTED, still with no shell: ONE program fed a body.
6495
- PROG ARGS <<'TAG' … TAG the body becomes the program's stdin
6496
- PROG ARGS <<-TAG … TAG same, leading tabs stripped
6497
- PROG ARGS <<< 'text' a here-string: text + newline on stdin
6498
- REFUSED, each with the one change that fixes it — a guess costs a model step:
6499
- an unquoted TAG (or a double-quoted here-string) whose text has $ or ` — a shell would
6500
- expand those and we cannot; anything after the closing TAG; no closing TAG; two heredocs.
6501
-
6502
- Returns None when there is no heredoc (`echo "a << b"` and `python3 -c "print(1 << 3)"` are
6503
- not: the operator only counts OUTSIDE quotes), else {"command", "stdin"} or {"error"}.
6504
- Pure, so every shape is testable without spawning anything.
6505
- """
6506
- s = command or ""
6507
- q = None
6508
- i = 0
6509
- at = -1
6510
- while i < len(s):
6511
- c = s[i]
6512
- if q:
6513
- if c == q:
6514
- q = None
6515
- elif c == "\\" and q == '"':
6516
- i += 1
6517
- elif c in "'\"":
6518
- q = c
6519
- elif c == "\\":
6520
- i += 1
6521
- elif s.startswith("<<", i):
6522
- at = i
6523
- break
6524
- i += 1
6525
- if at < 0:
6526
- return None
6527
- _why = "Error: that command did NOT run — nothing was executed. "
6528
- before = s[:at]
6529
- if s.startswith("<<<", at):
6530
- # HERE-STRING: the next word is the input; anything after it stays an argument.
6531
- rest = s[at + 3:]
6532
- import shlex as _shlex
6533
- try:
6534
- words = _shlex.split(rest)
6535
- except ValueError as e:
6536
- return {"error": _why + f"The here-string is not closed: {e}."}
6537
- if not words:
6538
- return {"error": _why + "'<<<' needs the text to feed the program: PROG <<< 'text'."}
6539
- lead = rest.lstrip()
6540
- if not lead.startswith("'") and re.search(r"[$`]", words[0]):
6541
- return {"error": _why + "run_command has NO shell, so $variables and `commands` in a "
6542
- "here-string are not expanded. Put the text in SINGLE quotes to pass it "
6543
- "exactly as written, or write the final text yourself."}
6544
- tail = " ".join(_shlex.quote(w) for w in words[1:])
6545
- return {"command": (before.rstrip() + (" " + tail if tail else "")).strip(),
6546
- "stdin": words[0] + "\n"}
6547
- m = re.match(r"<<(-?)[ \t]*(?:'([^'\n]+)'|\"([^\"\n]+)\"|\\?([A-Za-z_][\w.-]*))", s[at:])
6548
- if not m:
6549
- return {"error": _why + "'<<' must be followed by a tag: PROG <<'EOF', the text, then "
6550
- "EOF on a line of its own."}
6551
- strip_tabs = bool(m.group(1))
6552
- tag = m.group(2) or m.group(3) or m.group(4)
6553
- quoted = bool(m.group(2) or m.group(3)) or s[at + m.end() - len(tag) - 1] == "\\"
6554
- after = s[at + m.end():]
6555
- line_rest, nl, body_and_more = after.partition("\n")
6556
- if "<<" in line_rest and _parse_heredoc(line_rest) is not None:
6557
- return {"error": _why + "Only one heredoc per command is supported. Write the second "
6558
- "text with create_file, or run the two commands in separate calls."}
6559
- lines = body_and_more.split("\n") if nl else []
6560
- end = -1
6561
- for k, ln in enumerate(lines):
6562
- if (ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") == tag:
6563
- end = k
6564
- break
6565
- if end < 0:
6566
- return {"error": _why + f"The heredoc is never closed: put {tag} on a line of its own "
6567
- "after the text."}
6568
- body_lines = [(ln.lstrip("\t") if strip_tabs else ln).rstrip("\r") for ln in lines[:end]]
6569
- trailing = "\n".join(lines[end + 1:]).strip()
6570
- if trailing:
6571
- return {"error": _why + f"There is more after the closing {tag}, and run_command runs "
6572
- "ONE command per call (only && chains continue). Run what follows in a "
6573
- "separate run_command call."}
6574
- cmdline = (before.rstrip() + " " + line_rest.strip()).strip()
6575
- body = "\n".join(body_lines) + ("\n" if body_lines else "")
6576
- if not quoted and re.search(r"[$`]", body):
6577
- return {"error": _why + "run_command has NO shell, so the $variables and `commands` in "
6578
- f"this heredoc would not be expanded. Quote the tag — <<'{tag}' — to pass the "
6579
- "text exactly as written (that is what a script wants), or write the final "
6580
- "text yourself."}
6581
- if not cmdline:
6582
- return {"error": _why + "The heredoc has no program to feed: PROG <<'EOF' … EOF."}
6583
- return {"command": cmdline, "stdin": body}
6584
-
6585
-
6586
6394
  def _shell_syntax_error(command, argv):
6587
6395
  """Why this command cannot do what it says, or None. (task #204)
6588
6396
 
@@ -7155,7 +6963,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
7155
6963
  _srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
7156
6964
  _emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
7157
6965
  else:
7158
- _emit({"type": "step", "text": f"Awaiting your approval to run: {_command_label(command, 70)}"})
6966
+ _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
7159
6967
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
7160
6968
  while _t.time() < deadline:
7161
6969
  _t.sleep(1.2)
@@ -7533,12 +7341,6 @@ def _ws_scrubbed_env() -> dict:
7533
7341
  # CI=1 makes watch-mode test runners (CRA's `npm test`, jest --watch) run ONCE and
7534
7342
  # exit instead of sitting in interactive watch mode until the timeout kills them.
7535
7343
  env["CI"] = "1"
7536
- # WINDOWS: a python child reads and writes in the ANSI code page unless told otherwise, so a
7537
- # script fed on stdin (a heredoc, #332) with "é" or "→" in it is mis-decoded, and printing
7538
- # one raises UnicodeEncodeError. The worker's own python is started with PYTHONUTF8=1 for
7539
- # exactly this reason; the whitelist above dropped it for the commands it runs.
7540
- if _IS_WINDOWS:
7541
- env["PYTHONUTF8"] = "1"
7542
7344
  return env
7543
7345
 
7544
7346
 
@@ -12699,7 +12501,7 @@ def run_agent_chat(cfg):
12699
12501
  """Run a command inside a workspace that has run permission (e.g. 'npm test', 'npm run build', 'npm start', 'mvn test', 'python3 app.py'). One-shot commands return their output when they exit. If the command turns out to be a SERVER (keeps running and listens on a port), it is left RUNNING and its URL is reported. If it is a DESKTOP/GUI app (opens a window — tkinter, pygame, PyQt…), it is LAUNCHED and left running: its window appears on the user's Mac; tell them it's open. Both are stoppable via stop_server. Also allows DEPLOY commands (ssh/scp/rsync) — use non-interactive KEY auth (-o BatchMode=yes); never put a password in the command (there is no stdin). Note: an interactive CLI that reads stdin (input()) cannot be driven here — it will fail with EOFError; make it non-interactive or give the user the command to run themselves.
12700
12502
 
12701
12503
  Args:
12702
- command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. To run a multi-line script without a file, feed ONE program a heredoc, tag quoted: "python3 - <<'EOF'\n...\nEOF". No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
12504
+ command: the command line. Chain dependent steps with && in ONE call ("cd app && npm ci && npm test"): they run in order, cd sets the folder, the first failure stops the rest. No other shell syntax (no pipes/redirects/;). sudo/su are blocked.
12703
12505
  workdir: directory to run in. Empty = first registered workspace.
12704
12506
  timeout_seconds: give up after this many seconds if the command neither exits nor starts a server (max 600).
12705
12507
  """
@@ -12744,24 +12546,7 @@ def run_agent_chat(cfg):
12744
12546
  "gu-local-worker workspace add <path> --allow-run)")
12745
12547
  _last_obs["text"] = msg
12746
12548
  return msg
12747
- # A HEREDOC / HERE-STRING (task #332): the text is the program's stdin, and the
12748
- # command line is what is left. `command` stays the whole text for the risk gate,
12749
- # the status row and the log; `_cmdline` is what is split and judged as argv.
12750
- _here = _parse_heredoc(command)
12751
- if _here and _here.get("error"):
12752
- _log(f"run_command refused heredoc: {_c(_clip(command, 120))}")
12753
- _last_obs["text"] = _here["error"]
12754
- return _here["error"]
12755
- _cmdline = _here["command"] if _here else (command or "")
12756
- _stdin_text = _here["stdin"] if _here else None
12757
- if _here and _and_chain_parts(_cmdline):
12758
- msg = ("Error: that command did NOT run — nothing was executed. A heredoc "
12759
- "cannot be part of an && chain here. Run the program that reads it in "
12760
- "its own run_command call, and pass the folder as the cwd argument "
12761
- "instead of `cd X &&`.")
12762
- _last_obs["text"] = msg
12763
- return msg
12764
- argv = shlex.split(_cmdline)
12549
+ argv = shlex.split(command or "")
12765
12550
  if not argv:
12766
12551
  msg = "Error: empty command."
12767
12552
  _last_obs["text"] = msg
@@ -12769,7 +12554,7 @@ def run_agent_chat(cfg):
12769
12554
  # AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
12770
12555
  # allowlist, risk gate, approval, server detection, all of it — in order, and
12771
12556
  # the first failure stops the rest. `cd DIR` sets the folder for what follows.
12772
- _chain = None if _here else _and_chain_parts(command)
12557
+ _chain = _and_chain_parts(command)
12773
12558
  if _chain:
12774
12559
  _chain_start = _t.time()
12775
12560
  _cur_wd = wd
@@ -12819,7 +12604,7 @@ def run_agent_chat(cfg):
12819
12604
  # executes the `cd` PROGRAM — which exits 0 with no output — and the step is
12820
12605
  # reported as a success that did nothing. Refusing costs one step; the observed
12821
12606
  # alternative cost two hour-long turns and a false report to the user.
12822
- _shell_err = _shell_syntax_error(_cmdline, argv)
12607
+ _shell_err = _shell_syntax_error(command, argv)
12823
12608
  if _shell_err:
12824
12609
  _log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
12825
12610
  _last_obs["text"] = _shell_err
@@ -12837,10 +12622,7 @@ def run_agent_chat(cfg):
12837
12622
  # Risk gate: privilege escalation / denylisted / destructive. When the
12838
12623
  # client can approve interactively (terminal), PAUSE and ask the user;
12839
12624
  # otherwise (web) keep hard-blocking the privilege/denylist cases.
12840
- # Judged on the COMMAND LINE, not on a heredoc's text: the text is data for the
12841
- # program (a script that prints "sudo …" is not a sudo), and what a script does
12842
- # is no more visible here than it is for `python3 -c`.
12843
- _hard_reason, _ask_reason = _ws_command_risk(_cmdline, argv, _WS_RUN_DENYLIST, wd)
12625
+ _hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST, wd)
12844
12626
  # Shift+Tab may have switched it since the turn started (#47).
12845
12627
  global _accept_edits
12846
12628
  if _interactive_approval and _job_id:
@@ -12856,7 +12638,7 @@ def run_agent_chat(cfg):
12856
12638
  # states it. "this needs root and sudo needs a password we cannot type" is a
12857
12639
  # different question from "sudo is passwordless here", and the user can only
12858
12640
  # answer the one they are shown.
12859
- _priv = _ws_priv_reason(_cmdline)
12641
+ _priv = _ws_priv_reason(command)
12860
12642
  _facts = _ws_access_facts() if _priv else None
12861
12643
  if _priv and _facts and not _facts["can_elevate"]:
12862
12644
  # REFUSE BEFORE SPAWNING (requirement 5). With no tty, `sudo x` dies with
@@ -12907,8 +12689,7 @@ def run_agent_chat(cfg):
12907
12689
  _emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
12908
12690
  _last_obs["text"] = msg
12909
12691
  return msg
12910
- _emit({"type": "step", "text": f"Running → {_command_label(command, 70)}"})
12911
- _emit({"type": "step", "text": _running_command_line(command)}) # whole (#331)
12692
+ _emit({"type": "step", "text": f"Running → {command[:70]}"})
12912
12693
  t = max(5, min(int(timeout_seconds or 180), 600))
12913
12694
  # Own process group (start_new_session) + output to a log file. This is
12914
12695
  # what lets a server OUTLIVE this job process, and what lets a timeout
@@ -12924,30 +12705,13 @@ def run_agent_chat(cfg):
12924
12705
  if _attrib_note:
12925
12706
  _log(f"run_command: {_attrib_note}")
12926
12707
  # Scrubbed env: repo scripts must never see worker secrets.
12927
- # The heredoc's text as stdin, from an unnamed temp file: no pipe to keep fed (a
12928
- # pipe needs a writer thread or it deadlocks past 64 KB), and nothing left on
12929
- # disk — the file goes when the last handle on it closes.
12930
- _stdin_fh = None
12931
- if _stdin_text is not None:
12932
- import tempfile
12933
- _stdin_fh = tempfile.TemporaryFile()
12934
- _stdin_fh.write(_stdin_text.encode("utf-8"))
12935
- _stdin_fh.seek(0)
12936
- try:
12937
- # stdin is passed ONLY for a heredoc — every other command keeps exactly
12938
- # the stdin it has always had.
12939
- proc = subprocess.Popen(
12940
- argv, cwd=wd, env=_ws_scrubbed_env(),
12941
- stdout=log_fh, stderr=subprocess.STDOUT,
12942
- **({"stdin": _stdin_fh} if _stdin_fh is not None else {}),
12943
- **_ws_spawn_kwargs(),
12944
- )
12945
- finally:
12946
- if _stdin_fh is not None:
12947
- _stdin_fh.close() # the child holds its own copy
12708
+ proc = subprocess.Popen(
12709
+ argv, cwd=wd, env=_ws_scrubbed_env(),
12710
+ stdout=log_fh, stderr=subprocess.STDOUT,
12711
+ **_ws_spawn_kwargs(),
12712
+ )
12948
12713
  _INFLIGHT_CMDS.add(proc.pid) # killed with the agent until it is classified
12949
12714
  start = _t.time()
12950
- _out_seen: dict = {} # how much of its output the terminal has been told about
12951
12715
 
12952
12716
  def _read_log() -> str:
12953
12717
  log_fh.flush()
@@ -12980,7 +12744,7 @@ def run_agent_chat(cfg):
12980
12744
  if rc == 0 else f"FAILED (exit {rc})"
12981
12745
  )
12982
12746
  _emit({"type": "step", "text":
12983
- f"Command {'finished' if rc == 0 else 'failed'} → {_command_label(command, 50)}"})
12747
+ f"Command {'finished' if rc == 0 else 'failed'} → {command[:50]}"})
12984
12748
  # SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
12985
12749
  # was indistinguishable from a command that never ran — which is exactly
12986
12750
  # what `cd X && Y` used to do — and the model read the silence as a
@@ -13054,8 +12818,6 @@ def run_agent_chat(cfg):
13054
12818
  f"{t}s — killed. Output so far:\n{tail}")
13055
12819
  _last_obs["text"] = msg
13056
12820
  return msg
13057
- log_fh.flush()
13058
- _emit_command_progress(log_path, _out_seen)
13059
12821
  _t.sleep(2)
13060
12822
  except Exception as ex: # noqa: BLE001
13061
12823
  msg = f"Error: run_command failed: {type(ex).__name__}: {ex}"
package/gu-cli.mjs CHANGED
@@ -716,8 +716,21 @@ const currentApiVersion = async () => {
716
716
  // that gap. Naming both, and the fix when they differ, is the whole point of the command.
717
717
  const installed = await currentVersion();
718
718
  const running = String(probe.body?.version ?? "").trim();
719
+ // "(mongo, persistence on)" IS THE API'S CONFIGURATION, NOT A LIVE DATABASE (task #329):
720
+ // /api/health answers without touching MongoDB, so this line said "up" on a machine whose
721
+ // mongod was gone while every real request timed out. The handshake is the live check.
722
+ let mongoDown = "";
723
+ if (probe.ok) {
724
+ try {
725
+ const { localMongoDown } = await import("./client-expert-run.mjs");
726
+ mongoDown = await localMongoDown(ce.localMongoUri());
727
+ } catch { /* unknown — report what the API said */ }
728
+ }
719
729
  console.log(
720
- probe.ok
730
+ mongoDown
731
+ ? `\n API is running on ${ce.LOCAL_API_BASE}` + (running ? ` (${running})` : "") +
732
+ `, but MongoDB is NOT answering at ${mongoDown}\n — every request will hang until it does. Run \`gu\`: it says how to start it.`
733
+ : probe.ok
721
734
  ? `\n API is up on ${ce.LOCAL_API_BASE} (mongo, persistence on)` +
722
735
  (running ? ` — running ${running}` : "")
723
736
  : `\n API is not usable: ${probe.why}\n Log: ${ce.API_LOG_FILE}`
@@ -745,7 +758,7 @@ const currentApiVersion = async () => {
745
758
  }
746
759
  } catch { /* no network, no manifest, no line — never an error here */ }
747
760
  console.log("");
748
- process.exit(probe.ok ? 0 : 1);
761
+ process.exit(probe.ok && !mongoDown ? 0 : 1);
749
762
  }
750
763
 
751
764
  if (sub === "upgrade") {
@@ -1528,7 +1541,7 @@ if (args[0] === "mongo") {
1528
1541
  if (!brew) fail("install", "Homebrew is not installed (https://brew.sh)");
1529
1542
  const hasMongod = (await mn.captureOutput("sh", ["-c", "command -v mongod || true"])).trim();
1530
1543
  if (!hasMongod) {
1531
- const ok = await mn.runBrewSteps(brew, mn.BREW_INSTALL_STEPS.slice(0, 2), { onLine: (l) => console.log(` ${l}`) });
1544
+ const ok = await mn.runBrewSteps(brew, mn.brewInstallSteps({ trust: await mn.brewHasTrust(brew) }).slice(0, -1), { onLine: (l) => console.log(` ${l}`) });
1532
1545
  if (!ok) fail("install", "brew install failed (see above)");
1533
1546
  }
1534
1547
  say("install", true);