Skip to content

Commit ffe0b2b

Browse files
committed
941036
1 parent 06f8d08 commit ffe0b2b

13 files changed

Lines changed: 145 additions & 51 deletions

MANUAL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ Written inline on the step header line, comma-separated:
219219
| `retry Nx backoff Xs..Ys jitter Z%` | `retry 3x backoff 30s..300s jitter 10%` | backoff with ±10% jitter |
220220
| `until "VALUE"` | `until "0"` | disabled |
221221
| `if fails stop\|warn\|ignore` | `if fails warn` | stop |
222+
| `interactive` | (bare keyword) | disabled |
222223

223224
`timeout` is a hard execution deadline by default. If you add `reset on output`, it becomes an inactivity timeout instead: every new stdout or stderr chunk from the running command resets the timer.
224225

@@ -231,6 +232,18 @@ Example:
231232

232233
This step can run longer than 30 seconds overall as long as it keeps producing CLI output at least once every 30 seconds. If output stops for 30 seconds, the step times out.
233234

235+
### The `interactive` keyword — terminal-interactive steps
236+
237+
The engine automatically detects steps that read from the terminal (`read`, `/dev/tty`, `ssh-copy-id`) and suspends live-progress rendering while they own the terminal. For commands the heuristic cannot detect, add the `interactive` keyword to the step body:
238+
239+
```cgr
240+
[set password]:
241+
run $ passwd deploy_user
242+
interactive
243+
```
244+
245+
This forces PTY allocation and stdin forwarding regardless of the command name. Use it for tools like `passwd`, `kinit`, `gpg --gen-key`, or any other utility that opens the terminal directly.
246+
234247
### The `first` keyword — why not `after`?
235248

236249
When reading `[install nginx]`, the dependency keyword should answer "what does this step need?" from the perspective of the step you're currently reading.
@@ -1129,6 +1142,7 @@ resource install_nginx {
11291142
| `on_fail` | No | `stop` | What to do on failure: `stop` (abort the graph), `warn` (continue, mark as warned), `ignore` (treat as success). |
11301143
| `when` | No | `null` | Quoted boolean expression. If false, the resource is skipped. Supports `==` and `!=`. |
11311144
| `env` | No | `{}` | Set environment variables. Syntax: `env KEY = "value"`. Repeatable. |
1145+
| `interactive` | No | `false` | Force terminal-interactive mode (PTY + stdin forwarding). Use for commands like `passwd`, `kinit`, or any tool that reads directly from the terminal. The engine auto-detects common patterns (`read`, `/dev/tty`, `ssh-copy-id`); use this keyword for others. |
11321146

11331147
### Command delimiters
11341148

cgr.py

Lines changed: 21 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cgr_src/ast_nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class ASTResource:
7676
# Backoff/jitter fields (for retry Nx backoff Xs syntax)
7777
retry_backoff_max: int = 0 # cap in seconds (0 = no cap)
7878
retry_jitter_pct: int = 0 # jitter percentage (0 = none)
79+
interactive: bool = False # force terminal-interactive mode (PTY + stdin forwarding)
7980
# On-success/failure variable bindings
8081
on_success_set: list[tuple] = field(default_factory=list) # [(var, val), ...]
8182
on_failure_set: list[tuple] = field(default_factory=list)

cgr_src/executor.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ def _command_reads_tty(cmd) -> bool:
200200
return False
201201
return bool(
202202
"/dev/tty" in cmd
203+
or "ssh-copy-id" in cmd
203204
or re.search(r"(^|[;&|({]\s*)read\s+(-[A-Za-z]*\s+)?", cmd)
204205
)
205206

@@ -242,14 +243,11 @@ def _run_cmd(cmd, node, res, *, timeout, on_output=None, register_proc=None, unr
242243
interactive_tty = (
243244
node.via_method != "ssh"
244245
and stdin_data is None
245-
and _command_reads_tty(full)
246+
and (res.interactive or _command_reads_tty(full))
246247
and sys.stdin and sys.stdin.isatty()
247248
and sys.stdout and sys.stdout.isatty()
248249
)
249250
use_pty = bool(on_output) or interactive_tty
250-
if (not use_pty and node.via_method != "ssh" and stdin_data is None
251-
and isinstance(full, str) and "ssh-copy-id" in full and sys.stdin and sys.stdin.isatty()):
252-
use_pty = True
253251
if use_pty:
254252
master_fd, slave_fd = pty.openpty()
255253
echo_pty = on_output is None
@@ -1704,7 +1702,7 @@ def _run_with_start(rid):
17041702
command_needs_terminal = (
17051703
live_progress.enabled
17061704
and node.via_method != "ssh"
1707-
and _command_reads_tty(_effective_run_cmd(runtime_res, graph.graph_file))
1705+
and (res.interactive or _command_reads_tty(_effective_run_cmd(runtime_res, graph.graph_file)))
17081706
and sys.stdin and sys.stdin.isatty()
17091707
and sys.stdout and sys.stdout.isatty()
17101708
)

cgr_src/parser_cg.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ def _p_resource_body(self, name, group_defaults=None, is_template_root=False) ->
307307
desc=""; needs=[]; check=None; run_cmd=None; script_path=None; run_as=d.get("as")
308308
timeout=d.get("timeout",300); retries=0; retry_delay=5; retry_backoff=False
309309
timeout_reset_on_output=d.get("timeout_reset_on_output", False)
310-
on_fail=d.get("on_fail","stop"); when=None; env={}; env_when={}; children=[]; flags=[]; until=None
310+
on_fail=d.get("on_fail","stop"); when=None; env={}; env_when={}; children=[]; flags=[]; until=None; interactive=False
311311
collect_key=None
312312
collect_format=None
313313
collect_var=None
@@ -441,6 +441,8 @@ def _p_resource_body(self, name, group_defaults=None, is_template_root=False) ->
441441
elif s.value=="from":
442442
self._advance()
443443
subgraph_path=self._expect(TT.STRING).value
444+
elif s.value=="interactive":
445+
self._advance(); interactive=True
444446
# ── Parallel constructs ──────────────────────────────────────
445447
elif s.value=="parallel":
446448
plimit, ppolicy, pchildren = self._p_parallel(group_defaults)
@@ -461,7 +463,7 @@ def _p_resource_body(self, name, group_defaults=None, is_template_root=False) ->
461463
subgraph_vars[arg_name] = arg_value.value
462464
else:
463465
raise self._err(f"Unknown '{s.value}' in resource",
464-
"Valid: description, needs, check, run, script, as, timeout, retry, on_fail, on_success, on_failure, when, env, collect, reduce, flag, until, wait, from, tags, resource, parallel, race, each, stage, get, post, put, patch, delete, auth, header, body, expect")
466+
"Valid: description, needs, check, run, script, as, timeout, retry, on_fail, on_success, on_failure, when, env, collect, reduce, flag, until, wait, from, tags, interactive, resource, parallel, race, each, stage, get, post, put, patch, delete, auth, header, body, expect")
465467
if flags and not run_cmd:
466468
raise self._err("'flag' requires a 'run' command in the same step")
467469
if until and retries <= 0:
@@ -479,7 +481,8 @@ def _p_resource_body(self, name, group_defaults=None, is_template_root=False) ->
479481
wait_kind=wait_kind, wait_target=wait_target,
480482
subgraph_path=subgraph_path, subgraph_vars=dict(subgraph_vars),
481483
on_success_set=list(on_success_set), on_failure_set=list(on_failure_set),
482-
collect_var=collect_var, reduce_key=reduce_key, reduce_var=reduce_var)
484+
collect_var=collect_var, reduce_key=reduce_key, reduce_var=reduce_var,
485+
interactive=interactive)
483486
# Attach optional fields if parsed
484487
if 'tags' in dir(): res.tags = tags
485488
if 'parallel_block' in dir(): res.parallel_block = parallel_block; res.parallel_limit = parallel_limit; res.parallel_fail_policy = parallel_fail_policy

cgr_src/parser_cgr.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ def _parse_cgr_step(name: str, header: str, body: list, ln: int, err,
703703
reduce_var: str|None = None
704704
flags: list[tuple[str, str|None]] = []
705705
until: str|None = None
706+
interactive: bool = False
706707
# Provisioning fields (Phase 2)
707708
prov_block_dest: str|None = None; prov_block_marker: str|None = None
708709
prov_block_inline: str|None = None; prov_block_from: str|None = None
@@ -1331,6 +1332,11 @@ def _parse_cgr_step(name: str, header: str, body: list, ln: int, err,
13311332
tags.extend(t.strip() for t in tags_m.group(1).split(",") if t.strip())
13321333
i += 1; continue
13331334

1335+
# interactive — force PTY + stdin forwarding
1336+
if btext.strip() == "interactive":
1337+
interactive = True
1338+
i += 1; continue
1339+
13341340
# description "text" (for templates)
13351341
desc_m = re.match(r'description\s+"([^"]*)"', btext)
13361342
if desc_m:
@@ -1391,7 +1397,8 @@ def _parse_cgr_step(name: str, header: str, body: list, ln: int, err,
13911397
retry_backoff_max=retry_backoff_max, retry_jitter_pct=retry_jitter_pct,
13921398
on_success_set=list(on_success_set), on_failure_set=list(on_failure_set),
13931399
collect_var=collect_var, reduce_key=reduce_key, reduce_var=reduce_var,
1394-
flags=list(flags), env_when=dict(env_when), until=until)
1400+
flags=list(flags), env_when=dict(env_when), until=until,
1401+
interactive=interactive)
13951402

13961403

13971404
def _parse_cgr_verify(desc: str, body: list, ln: int, err) -> ASTResource:

cgr_src/resolver.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ class Resource:
268268
env_when: dict[str, str] = field(default_factory=dict)
269269
until: str|None = None
270270
cgr_phase_name: str|None = None # name of the phase "..." when "...": block this resource belongs to
271+
interactive: bool = False # force terminal-interactive mode (PTY + stdin forwarding)
271272

272273
@dataclass
273274
class HostNode:
@@ -878,6 +879,7 @@ def _wire_gate(res_id):
878879
reduce_key=ast_res.reduce_key, reduce_var=ast_res.reduce_var,
879880
flags=resolved_flags, env_when=dict(ast_res.env_when), until=ast_res.until,
880881
cgr_phase_name=ast_res.cgr_phase_name,
882+
interactive=ast_res.interactive,
881883
)
882884
collector[rid] = res
883885
dedup_hashes[ihash] = rid

install.sh

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@ set -euo pipefail
77
# ── Terminal capability detection ─────────────────────────────────────────────
88

99
if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ "${NO_COLOR:-}" = "" ]; then
10-
_RED='\033[0;31m'
11-
_GREEN='\033[0;32m'
12-
_YELLOW='\033[1;33m'
13-
_BLUE='\033[0;34m'
14-
_CYAN='\033[0;36m'
15-
_MAGENTA='\033[0;35m'
16-
_WHITE='\033[1;37m'
17-
_BOLD='\033[1m'
18-
_DIM='\033[2m'
19-
_RESET='\033[0m'
10+
_ESC="$(printf '\033')"
11+
_RED="${_ESC}[0;31m"
12+
_GREEN="${_ESC}[0;32m"
13+
_YELLOW="${_ESC}[1;33m"
14+
_BLUE="${_ESC}[0;34m"
15+
_CYAN="${_ESC}[0;36m"
16+
_MAGENTA="${_ESC}[0;35m"
17+
_WHITE="${_ESC}[1;37m"
18+
_BOLD="${_ESC}[1m"
19+
_DIM="${_ESC}[2m"
20+
_RESET="${_ESC}[0m"
2021
else
2122
_RED='' _GREEN='' _YELLOW='' _BLUE='' _CYAN='' _MAGENTA=''
2223
_WHITE='' _BOLD='' _DIM='' _RESET=''

test_commandgraph.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,8 +1704,30 @@ def isatty(self):
17041704
def test_command_reads_tty_detection(self):
17051705
assert cg._command_reads_tty("read -rp 'Username: ' user < /dev/tty")
17061706
assert cg._command_reads_tty("printf prompt; read password")
1707+
assert cg._command_reads_tty("ssh-copy-id -i ~/.ssh/id_ed25519.pub user@example")
17071708
assert not cg._command_reads_tty("printf 'alpha\\n' | wc -l")
17081709

1710+
def test_interactive_attribute_parsed(self):
1711+
src = textwrap.dedent('''\
1712+
target "local" local:
1713+
[prompt user]:
1714+
run $ passwd testuser
1715+
interactive
1716+
''')
1717+
ast = cg.parse_cgr(src)
1718+
r = ast.nodes[0].resources[0]
1719+
assert r.interactive is True
1720+
1721+
def test_interactive_attribute_defaults_false(self):
1722+
src = textwrap.dedent('''\
1723+
target "local" local:
1724+
[do thing]:
1725+
run $ echo hello
1726+
''')
1727+
ast = cg.parse_cgr(src)
1728+
r = ast.nodes[0].resources[0]
1729+
assert r.interactive is False
1730+
17091731
def test_exec_resource_cancel_check_terminates_process(self, tmp_path):
17101732
marker = tmp_path / "cancelled.txt"
17111733
cmd = (

0 commit comments

Comments
 (0)