Mined what ran just before a `notify-done --type deploy` without any of the classic deploy commands (159 of 725): mostly `docker compose … up --build`. Now also a deploy: compose `up --build` / `--force-recreate` (and via docker-compose-lab.sh), a plain `compose up` after a `compose build` in the same command, and rsync into zipgo's domains/ tree. A bare `up -d`, a lone build and docker build/run test containers stay out. Deploy notifies with no detected deploy: 159 → 99. The widget judges failure on the output's last lines — build logs quote "error" in passing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
13 KiB
Python
348 lines
13 KiB
Python
"""Is this shell command a deploy? — by *what it runs*, not what it mentions.
|
|
|
|
The deploy widget, the conversation's "deploying…" state and its ``deployed``
|
|
lifecycle stamp all hang off one question about a Bash tool call. A plain regex
|
|
over the command text answered it for anything that *named* a deploy script —
|
|
``cat deploy.sh``, ``grep -n … deploy.sh``, ``bash -n deploy.sh``,
|
|
``git add services/ai-agent/deploy.sh``, a commit message, a heredoc'd Python
|
|
patch of the script, a ``pgrep -f deploy.sh`` wait loop — which in the archive
|
|
was about four calls in ten. So the command is split into simple commands
|
|
(quotes, heredoc bodies and comments dropped), wrapper prefixes are peeled
|
|
(``cd … &&``, ``VAR=x``, ``timeout 600``, ``setsid nohup``, ``rtk``, ``bash
|
|
<script>``…), and only the program actually being *run* is matched.
|
|
|
|
What counts was read off the archive — the commands run just before a
|
|
``notify-done --type deploy``:
|
|
|
|
* a ``deploy*.sh`` script (the ai-agent blue-green, project ``scripts/deploy.sh``,
|
|
``deploy-test-site.sh`` previews…), ``npm|pnpm|yarn run deploy[:x]``,
|
|
``zipgo deploy``, ``make deploy*``, the ``deploy-html`` skill;
|
|
* ``docker compose … up --build`` / ``up --force-recreate`` — how a lab service
|
|
ships (also through ``scripts/docker-compose-lab.sh``) — and a plain
|
|
``compose up`` that follows a ``compose build`` in the same command. A bare
|
|
``up -d`` alone is mostly a first bring-up or a throwaway test stack, and
|
|
``docker build`` / ``docker run`` are test containers: neither counts;
|
|
* ``rsync`` into zipgo's ``…/domains/…`` tree on raspy2 (a hand-rolled static
|
|
deploy).
|
|
|
|
The frontend doesn't re-match: the answer rides on the tool card as ``deploy``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
# Prefixes that run the command that follows them → the flags of theirs that
|
|
# take a value as the next word.
|
|
_WRAPPERS: dict[str, set[str]] = {
|
|
"sudo": {"-u", "-g", "-C", "-D", "-h", "-p", "-U"},
|
|
"env": {"-u", "-C", "-S"},
|
|
"time": set(), "nohup": set(), "setsid": set(), "exec": {"-a"},
|
|
"command": set(), "caffeinate": {"-t", "-w"}, "!": set(), "rtk": set(),
|
|
"nice": {"-n"},
|
|
"stdbuf": {"-i", "-o", "-e"},
|
|
"timeout": {"-s", "-k", "--signal", "--kill-after"},
|
|
"flock": {"-w", "-E", "--timeout", "--wait", "--conflict-exit-code"},
|
|
}
|
|
# How many leading positional args a wrapper consumes (timeout's DURATION,
|
|
# flock's lock file).
|
|
_WRAPPER_ARGS = {"timeout": 1, "flock": 1}
|
|
# Shell keywords that introduce a command in the same segment.
|
|
_KEYWORDS = {"then", "do", "else", "elif", "if", "while", "until", "{", "time"}
|
|
# Shells that run a script given as their first non-flag argument.
|
|
_SHELLS = {"bash", "sh", "zsh", "dash", "source", "."}
|
|
|
|
_ASSIGN_RE = re.compile(r"^[A-Za-z_]\w*=")
|
|
_HEREDOC_RE = re.compile(r"(?<!<)<<-?(?!<)\s*(['\"]?)([A-Za-z_][\w.-]*)\1")
|
|
_SCRIPT_RE = re.compile(r"deploy[\w.-]*\.sh$")
|
|
_MAKE_TARGET_RE = re.compile(r"^deploy[\w.-]*$")
|
|
_NPM_SCRIPT_RE = re.compile(r"^deploy(?::[\w.-]+)?$")
|
|
# Compose's global options that take a value (before the subcommand), and
|
|
# `up`'s own (between `up` and the service names).
|
|
_COMPOSE_GLOBAL_VALUE = {"-f", "--file", "-p", "--project-name", "--profile",
|
|
"--project-directory", "--env-file", "--ansi",
|
|
"--progress", "--parallel", "--context", "-c", "-H",
|
|
"--host"}
|
|
_COMPOSE_UP_VALUE = {"--scale", "-t", "--timeout", "--wait-timeout", "--pull",
|
|
"--exit-code-from", "--attach", "--no-attach"}
|
|
# rsync into zipgo's static-site tree on a remote host (`host:…/domains/…`).
|
|
_RSYNC_DEST_RE = re.compile(r"^[\w.@-]+:.*/domains/")
|
|
# npm/pnpm/yarn/make flags that take a value as the next word.
|
|
_PM_VALUE_FLAGS = {"--prefix", "-C", "--dir", "--cwd", "-w", "--workspace",
|
|
"--filter", "-F", "-f", "--file", "--makefile", "-j"}
|
|
|
|
|
|
def _strip_heredocs(cmd: str) -> str:
|
|
"""Drop heredoc bodies (``<<'EOF' … EOF``) — they're data, not commands."""
|
|
lines = cmd.split("\n")
|
|
out: list[str] = []
|
|
pending: list[str] = []
|
|
for line in lines:
|
|
if pending:
|
|
if line.strip() == pending[0] or line.lstrip("\t") == pending[0]:
|
|
pending.pop(0)
|
|
continue
|
|
out.append(line)
|
|
pending = [m.group(2) for m in _HEREDOC_RE.finditer(line)]
|
|
return "\n".join(out)
|
|
|
|
|
|
def _split(cmd: str) -> list[list[tuple[str, bool]]]:
|
|
"""Split a command line into simple commands, each a list of words.
|
|
|
|
A word is ``(text, quoted)``: quoting is remembered so a quoted string is
|
|
never mistaken for a program (``echo "./deploy.sh"``) yet can still be
|
|
re-parsed where it *is* a command (``bash -c '…'``, ``ssh host '…'``).
|
|
Separators: ``&& || ; | & |&``, newlines, parentheses, backticks and
|
|
``$(``. ``2>&1``/``&>`` stay inside their redirect word. ``#`` at a word
|
|
start comments out the rest of the line.
|
|
"""
|
|
cmd = _strip_heredocs(cmd)
|
|
segs: list[list[tuple[str, bool]]] = []
|
|
words: list[tuple[str, bool]] = []
|
|
buf: list[str] = []
|
|
quoted = False
|
|
i, n = 0, len(cmd)
|
|
|
|
def end_word():
|
|
nonlocal buf, quoted
|
|
if buf or quoted:
|
|
words.append(("".join(buf), quoted))
|
|
buf, quoted = [], False
|
|
|
|
def end_seg():
|
|
nonlocal words
|
|
end_word()
|
|
if words:
|
|
segs.append(words)
|
|
words = []
|
|
|
|
while i < n:
|
|
c = cmd[i]
|
|
if c in "'\"":
|
|
j = cmd.find(c, i + 1)
|
|
if c == '"': # skip escaped quotes inside "…"
|
|
while j != -1 and cmd[j - 1] == "\\":
|
|
j = cmd.find(c, j + 1)
|
|
j = n if j == -1 else j
|
|
buf.append(cmd[i + 1:j])
|
|
quoted = True
|
|
i = j + 1
|
|
continue
|
|
if c == "\\" and i + 1 < n:
|
|
if cmd[i + 1] != "\n":
|
|
buf.append(cmd[i + 1])
|
|
i += 2
|
|
continue
|
|
if c == "#" and not buf and not quoted:
|
|
j = cmd.find("\n", i)
|
|
i = n if j == -1 else j
|
|
continue
|
|
if c in " \t":
|
|
end_word()
|
|
i += 1
|
|
continue
|
|
if c == "&":
|
|
# a redirect (`2>&1`, `>&2`, `&>log`, `&>>log`) stays in its word
|
|
if (buf and buf[-1] in "<>") or cmd[i + 1:i + 2] == ">":
|
|
buf.append(c)
|
|
i += 1
|
|
continue
|
|
end_seg()
|
|
i += 2 if cmd[i + 1:i + 2] == "&" else 1
|
|
continue
|
|
if c == "$" and cmd[i + 1:i + 2] == "(":
|
|
end_seg()
|
|
i += 2
|
|
continue
|
|
if c in "\n;|()`":
|
|
end_seg()
|
|
i += 1
|
|
continue
|
|
buf.append(c)
|
|
i += 1
|
|
end_seg()
|
|
return segs
|
|
|
|
|
|
def _is_redirect(w: str) -> bool:
|
|
return bool(re.match(r"^\d*(?:>>?|<<?|&>>?|>&|<&)", w))
|
|
|
|
|
|
def _peel(words: list[tuple[str, bool]]) -> list[tuple[str, bool]]:
|
|
"""Drop env assignments, wrappers (and their flags/args), shell keywords."""
|
|
i = 0
|
|
while i < len(words):
|
|
w, q = words[i]
|
|
if q:
|
|
break
|
|
if _ASSIGN_RE.match(w) or w in _KEYWORDS:
|
|
i += 1
|
|
continue
|
|
if w in _WRAPPERS:
|
|
name = w
|
|
i += 1
|
|
if name == "rtk" and i < len(words) and words[i][0] == "proxy":
|
|
i += 1
|
|
while i < len(words) and words[i][0].startswith("-") and not words[i][1]:
|
|
flag = words[i][0]
|
|
i += 2 if flag in _WRAPPERS[name] else 1
|
|
i += _WRAPPER_ARGS.get(name, 0)
|
|
continue
|
|
break
|
|
return words[i:]
|
|
|
|
|
|
def _plain(words: list[tuple[str, bool]]) -> list[str]:
|
|
"""The non-flag, non-redirect argument words."""
|
|
out, skip = [], False
|
|
for w, q in words:
|
|
if skip:
|
|
skip = False
|
|
continue
|
|
if not q and _is_redirect(w):
|
|
# `> file` (bare operator) consumes the next word as its target
|
|
skip = bool(re.fullmatch(r"\d*(?:>>?|<<?|&>>?)", w))
|
|
continue
|
|
out.append(w)
|
|
return out
|
|
|
|
|
|
def _pm_args(args: list[tuple[str, bool]]) -> list[str]:
|
|
"""npm/make positional args, skipping flags (and their values)."""
|
|
out: list[str] = []
|
|
i = 0
|
|
while i < len(args):
|
|
w, q = args[i]
|
|
if not q and _is_redirect(w):
|
|
break
|
|
if not q and w.startswith("-"):
|
|
i += 2 if (w in _PM_VALUE_FLAGS and "=" not in w) else 1
|
|
continue
|
|
out.append(w)
|
|
i += 1
|
|
return out
|
|
|
|
|
|
def _compose(args: list[tuple[str, bool]], state: dict) -> tuple[str, str] | None:
|
|
"""`docker compose …` args → a deploy if it ships new containers."""
|
|
i = 0
|
|
while i < len(args) and not args[i][1] and args[i][0].startswith("-"):
|
|
flag = args[i][0]
|
|
i += 2 if (flag in _COMPOSE_GLOBAL_VALUE and "=" not in flag) else 1
|
|
if i >= len(args):
|
|
return None
|
|
sub, rest = args[i][0], args[i + 1:]
|
|
if sub == "build":
|
|
state["built"] = True # a later `compose up` in this command ships it
|
|
return None
|
|
if sub != "up":
|
|
return None
|
|
flags, services = set(), []
|
|
j = 0
|
|
while j < len(rest):
|
|
w, q = rest[j]
|
|
if not q and _is_redirect(w):
|
|
break
|
|
if not q and w.startswith("-"):
|
|
flags.add(w.split("=", 1)[0])
|
|
j += 2 if (w in _COMPOSE_UP_VALUE and "=" not in w) else 1
|
|
continue
|
|
services.append(w)
|
|
j += 1
|
|
if "--build" in flags or "--force-recreate" in flags or state.get("built"):
|
|
return ("docker compose", " ".join(services))
|
|
return None
|
|
|
|
|
|
def _match_simple(words: list[tuple[str, bool]], depth: int,
|
|
state: dict) -> tuple[str, str] | None:
|
|
words = _peel(words)
|
|
if not words or words[0][1]:
|
|
return None
|
|
prog, args = words[0][0], words[1:]
|
|
base = prog.rsplit("/", 1)[-1]
|
|
|
|
if base in _SHELLS:
|
|
# the shell's own options come before the script / `-c` string
|
|
i = 0
|
|
while i < len(args) and not args[i][1] and args[i][0].startswith("-"):
|
|
opt = args[i][0]
|
|
if re.fullmatch(r"-[a-zA-Z]*n[a-zA-Z]*", opt):
|
|
return None # `bash -n deploy.sh`: a syntax check
|
|
if re.fullmatch(r"-[a-zA-Z]*c[a-zA-Z]*", opt): # `bash -lc '…'`
|
|
if i + 1 < len(args) and depth < 3:
|
|
return deploy_invocation(args[i + 1][0], depth + 1)
|
|
return None
|
|
i += 1
|
|
return _match_simple(args[i:], depth, state) if i < len(args) else None
|
|
|
|
if base == "ssh":
|
|
# the remote command is everything after the host
|
|
i = 0
|
|
while i < len(args) and args[i][0].startswith("-") and not args[i][1]:
|
|
i += 2 if args[i][0] in ("-p", "-i", "-o", "-l", "-F", "-J") else 1
|
|
remote = " ".join(w for w, _ in args[i + 1:])
|
|
return deploy_invocation(remote, depth + 1) if remote and depth < 3 else None
|
|
|
|
if base == "docker" and args and args[0][0] == "compose" and not args[0][1]:
|
|
return _compose(args[1:], state)
|
|
if base in ("docker-compose", "docker-compose-lab.sh"):
|
|
return _compose(args, state)
|
|
|
|
if base == "rsync":
|
|
pos = [w for w in _plain(args) if not w.startswith("-")]
|
|
if pos and _RSYNC_DEST_RE.match(pos[-1]):
|
|
return ("rsync", pos[-1])
|
|
return None
|
|
|
|
if base in ("deploy-html", "deploy-html.sh"):
|
|
pos = [w for w in _plain(args) if not w.startswith("-")]
|
|
return ("deploy-html", pos[0] if pos else "")
|
|
|
|
if base == "zipgo":
|
|
pos = [w for w in _plain(args) if not w.startswith("-")]
|
|
if pos and pos[0] == "deploy":
|
|
return ("zipgo deploy", pos[1] if len(pos) > 1 else "")
|
|
return None
|
|
|
|
if base in ("npm", "pnpm", "yarn", "bun"):
|
|
pos = _pm_args(args)
|
|
if pos and pos[0] in ("run", "run-script"):
|
|
pos = pos[1:]
|
|
if pos and _NPM_SCRIPT_RE.match(pos[0]):
|
|
return ("npm run deploy", "")
|
|
return None
|
|
|
|
if base == "make":
|
|
for t in _pm_args(args):
|
|
if _MAKE_TARGET_RE.match(t):
|
|
return ("make deploy", t)
|
|
return None
|
|
|
|
if _SCRIPT_RE.search(base):
|
|
return ("deploy.sh", prog)
|
|
return None
|
|
|
|
|
|
# Cheap pre-filter: every deploy shape names one of these somewhere.
|
|
_HINT_RE = re.compile(r"deploy|compose|rsync")
|
|
|
|
|
|
def deploy_invocation(command: str, depth: int = 0) -> tuple[str, str] | None:
|
|
"""``(kind, target)`` for the first deploy this command *runs*, else ``None``.
|
|
|
|
kind is one of ``deploy-html``, ``zipgo deploy``, ``npm run deploy``,
|
|
``make deploy``, ``deploy.sh``, ``docker compose``, ``rsync``; target is
|
|
what was deployed, best-effort (the script path, the zipgo/deploy-html
|
|
argument, the make target, the compose services, the rsync destination).
|
|
"""
|
|
if not isinstance(command, str) or not _HINT_RE.search(command):
|
|
return None
|
|
state: dict = {}
|
|
for words in _split(command):
|
|
hit = _match_simple(words, depth, state)
|
|
if hit:
|
|
return hit
|
|
return None
|