ntermqt 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl
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.
- nterm/examples/basic_terminal.py +415 -0
- nterm/manager/io.py +360 -47
- nterm/scripting/api.py +199 -35
- nterm/scripting/platform_utils.py +269 -3
- nterm/scripting/repl.py +230 -71
- nterm/scripting/repl_interactive.py +27 -2
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.10.dist-info}/METADATA +2 -2
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.10.dist-info}/RECORD +11 -10
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.10.dist-info}/WHEEL +1 -1
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.10.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.8.dist-info → ntermqt-0.1.10.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Example nterm application.
|
|
4
|
+
|
|
5
|
+
Demonstrates basic usage of the terminal widget with different session types:
|
|
6
|
+
- SSHSession: Paramiko-based (for password/key auth)
|
|
7
|
+
- AskpassSSHSession: Native SSH with GUI prompts (recommended for YubiKey)
|
|
8
|
+
- InteractiveSSHSession: Native SSH with PTY
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import logging
|
|
13
|
+
from PyQt6.QtWidgets import (
|
|
14
|
+
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
15
|
+
QComboBox, QLabel, QPushButton, QStatusBar, QLineEdit, QSpinBox,
|
|
16
|
+
QGroupBox, QFormLayout, QMessageBox, QDialog, QDialogButtonBox,
|
|
17
|
+
QInputDialog
|
|
18
|
+
)
|
|
19
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QObject
|
|
20
|
+
|
|
21
|
+
from nterm import (
|
|
22
|
+
ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig,
|
|
23
|
+
SSHSession, SessionState, TerminalWidget, Theme, ThemeEngine,
|
|
24
|
+
InteractiveSSHSession, is_pty_available
|
|
25
|
+
)
|
|
26
|
+
from nterm.session import AskpassSSHSession
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YubiKeyDialog(QDialog):
|
|
33
|
+
"""Dialog shown when YubiKey touch is required."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, prompt: str, parent=None):
|
|
36
|
+
super().__init__(parent)
|
|
37
|
+
self.setWindowTitle("YubiKey Authentication")
|
|
38
|
+
self.setModal(True)
|
|
39
|
+
self.setMinimumWidth(350)
|
|
40
|
+
|
|
41
|
+
layout = QVBoxLayout(self)
|
|
42
|
+
|
|
43
|
+
# Icon/visual indicator
|
|
44
|
+
icon_label = QLabel("🔑")
|
|
45
|
+
icon_label.setStyleSheet("font-size: 48px;")
|
|
46
|
+
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
47
|
+
layout.addWidget(icon_label)
|
|
48
|
+
|
|
49
|
+
# Prompt
|
|
50
|
+
prompt_label = QLabel(prompt)
|
|
51
|
+
prompt_label.setWordWrap(True)
|
|
52
|
+
prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
53
|
+
prompt_label.setStyleSheet("font-size: 14px; margin: 10px;")
|
|
54
|
+
layout.addWidget(prompt_label)
|
|
55
|
+
|
|
56
|
+
# Instructions
|
|
57
|
+
instructions = QLabel("Touch your YubiKey to authenticate...")
|
|
58
|
+
instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
59
|
+
instructions.setStyleSheet("color: gray;")
|
|
60
|
+
layout.addWidget(instructions)
|
|
61
|
+
|
|
62
|
+
# Cancel button
|
|
63
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
|
|
64
|
+
buttons.rejected.connect(self.reject)
|
|
65
|
+
layout.addWidget(buttons)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NTermWindow(QMainWindow):
|
|
69
|
+
"""Main application window."""
|
|
70
|
+
|
|
71
|
+
def __init__(self):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.setWindowTitle("nterm - SSH Terminal")
|
|
74
|
+
self.resize(1200, 800)
|
|
75
|
+
|
|
76
|
+
self._session = None
|
|
77
|
+
self._theme_engine = ThemeEngine()
|
|
78
|
+
self._yubikey_dialog = None
|
|
79
|
+
|
|
80
|
+
self._setup_ui()
|
|
81
|
+
self._apply_theme("default")
|
|
82
|
+
|
|
83
|
+
def _setup_ui(self):
|
|
84
|
+
"""Set up the user interface."""
|
|
85
|
+
central = QWidget()
|
|
86
|
+
self.setCentralWidget(central)
|
|
87
|
+
layout = QVBoxLayout(central)
|
|
88
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
89
|
+
layout.setSpacing(0)
|
|
90
|
+
|
|
91
|
+
# Toolbar
|
|
92
|
+
toolbar = self._create_toolbar()
|
|
93
|
+
layout.addWidget(toolbar)
|
|
94
|
+
|
|
95
|
+
# Terminal
|
|
96
|
+
self._terminal = TerminalWidget()
|
|
97
|
+
self._terminal.session_state_changed.connect(self._on_state_changed)
|
|
98
|
+
self._terminal.interaction_required.connect(self._on_interaction)
|
|
99
|
+
layout.addWidget(self._terminal, 1)
|
|
100
|
+
|
|
101
|
+
# Status bar
|
|
102
|
+
self._status = QStatusBar()
|
|
103
|
+
self.setStatusBar(self._status)
|
|
104
|
+
self._status.showMessage("Disconnected")
|
|
105
|
+
|
|
106
|
+
def _create_toolbar(self) -> QWidget:
|
|
107
|
+
"""Create connection toolbar."""
|
|
108
|
+
toolbar = QWidget()
|
|
109
|
+
toolbar.setFixedHeight(100)
|
|
110
|
+
layout = QHBoxLayout(toolbar)
|
|
111
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
112
|
+
|
|
113
|
+
# Connection group
|
|
114
|
+
conn_group = QGroupBox("Connection")
|
|
115
|
+
conn_layout = QFormLayout(conn_group)
|
|
116
|
+
conn_layout.setContentsMargins(8, 4, 8, 4)
|
|
117
|
+
|
|
118
|
+
self._host_input = QLineEdit()
|
|
119
|
+
self._host_input.setPlaceholderText("hostname or IP")
|
|
120
|
+
self._host_input.setText("localhost")
|
|
121
|
+
conn_layout.addRow("Host:", self._host_input)
|
|
122
|
+
|
|
123
|
+
port_layout = QHBoxLayout()
|
|
124
|
+
self._port_input = QSpinBox()
|
|
125
|
+
self._port_input.setRange(1, 65535)
|
|
126
|
+
self._port_input.setValue(22)
|
|
127
|
+
port_layout.addWidget(self._port_input)
|
|
128
|
+
|
|
129
|
+
self._user_input = QLineEdit()
|
|
130
|
+
self._user_input.setPlaceholderText("username")
|
|
131
|
+
port_layout.addWidget(QLabel("User:"))
|
|
132
|
+
port_layout.addWidget(self._user_input)
|
|
133
|
+
conn_layout.addRow("Port:", port_layout)
|
|
134
|
+
|
|
135
|
+
layout.addWidget(conn_group)
|
|
136
|
+
|
|
137
|
+
# Session type group
|
|
138
|
+
session_group = QGroupBox("Session Type")
|
|
139
|
+
session_layout = QVBoxLayout(session_group)
|
|
140
|
+
session_layout.setContentsMargins(8, 4, 8, 4)
|
|
141
|
+
|
|
142
|
+
self._session_combo = QComboBox()
|
|
143
|
+
self._session_combo.addItem("Askpass (YubiKey GUI)", "askpass")
|
|
144
|
+
self._session_combo.addItem("Interactive (PTY)", "interactive")
|
|
145
|
+
self._session_combo.addItem("Paramiko", "paramiko")
|
|
146
|
+
self._session_combo.currentIndexChanged.connect(self._on_session_type_changed)
|
|
147
|
+
session_layout.addWidget(self._session_combo)
|
|
148
|
+
|
|
149
|
+
# Status indicator
|
|
150
|
+
self._pty_label = QLabel("✓ GUI auth prompts" if is_pty_available() else "⚠ Limited")
|
|
151
|
+
self._pty_label.setStyleSheet("color: green;" if is_pty_available() else "color: orange;")
|
|
152
|
+
session_layout.addWidget(self._pty_label)
|
|
153
|
+
|
|
154
|
+
layout.addWidget(session_group)
|
|
155
|
+
|
|
156
|
+
# Auth group (for Paramiko mode)
|
|
157
|
+
self._auth_group = QGroupBox("Authentication")
|
|
158
|
+
auth_layout = QFormLayout(self._auth_group)
|
|
159
|
+
auth_layout.setContentsMargins(8, 4, 8, 4)
|
|
160
|
+
|
|
161
|
+
self._auth_combo = QComboBox()
|
|
162
|
+
self._auth_combo.addItems(["Agent", "Password", "Key File"])
|
|
163
|
+
auth_layout.addRow("Method:", self._auth_combo)
|
|
164
|
+
|
|
165
|
+
self._password_input = QLineEdit()
|
|
166
|
+
self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
167
|
+
self._password_input.setPlaceholderText("(for password auth)")
|
|
168
|
+
auth_layout.addRow("Password:", self._password_input)
|
|
169
|
+
|
|
170
|
+
self._auth_group.setVisible(False)
|
|
171
|
+
layout.addWidget(self._auth_group)
|
|
172
|
+
|
|
173
|
+
# Jump host group
|
|
174
|
+
jump_group = QGroupBox("Jump Host (Optional)")
|
|
175
|
+
jump_layout = QFormLayout(jump_group)
|
|
176
|
+
jump_layout.setContentsMargins(8, 4, 8, 4)
|
|
177
|
+
|
|
178
|
+
self._jump_host_input = QLineEdit()
|
|
179
|
+
self._jump_host_input.setPlaceholderText("bastion.example.com")
|
|
180
|
+
jump_layout.addRow("Host:", self._jump_host_input)
|
|
181
|
+
|
|
182
|
+
self._jump_user_input = QLineEdit()
|
|
183
|
+
self._jump_user_input.setPlaceholderText("(same as main if empty)")
|
|
184
|
+
jump_layout.addRow("User:", self._jump_user_input)
|
|
185
|
+
|
|
186
|
+
layout.addWidget(jump_group)
|
|
187
|
+
|
|
188
|
+
# Theme selector
|
|
189
|
+
theme_group = QGroupBox("Theme")
|
|
190
|
+
theme_layout = QVBoxLayout(theme_group)
|
|
191
|
+
theme_layout.setContentsMargins(8, 4, 8, 4)
|
|
192
|
+
|
|
193
|
+
self._theme_combo = QComboBox()
|
|
194
|
+
self._theme_combo.addItems(self._theme_engine.list_themes())
|
|
195
|
+
self._theme_combo.currentTextChanged.connect(self._apply_theme)
|
|
196
|
+
theme_layout.addWidget(self._theme_combo)
|
|
197
|
+
|
|
198
|
+
layout.addWidget(theme_group)
|
|
199
|
+
|
|
200
|
+
# Buttons
|
|
201
|
+
btn_layout = QVBoxLayout()
|
|
202
|
+
|
|
203
|
+
self._connect_btn = QPushButton("Connect")
|
|
204
|
+
self._connect_btn.clicked.connect(self._connect)
|
|
205
|
+
self._connect_btn.setDefault(True)
|
|
206
|
+
btn_layout.addWidget(self._connect_btn)
|
|
207
|
+
|
|
208
|
+
self._disconnect_btn = QPushButton("Disconnect")
|
|
209
|
+
self._disconnect_btn.clicked.connect(self._disconnect)
|
|
210
|
+
self._disconnect_btn.setEnabled(False)
|
|
211
|
+
btn_layout.addWidget(self._disconnect_btn)
|
|
212
|
+
|
|
213
|
+
layout.addLayout(btn_layout)
|
|
214
|
+
layout.addStretch()
|
|
215
|
+
|
|
216
|
+
return toolbar
|
|
217
|
+
|
|
218
|
+
def _on_session_type_changed(self, index: int):
|
|
219
|
+
"""Handle session type change."""
|
|
220
|
+
session_type = self._session_combo.currentData()
|
|
221
|
+
self._auth_group.setVisible(session_type == "paramiko")
|
|
222
|
+
|
|
223
|
+
# Update status label
|
|
224
|
+
if session_type == "askpass":
|
|
225
|
+
self._pty_label.setText("✓ GUI auth prompts")
|
|
226
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
227
|
+
elif session_type == "interactive":
|
|
228
|
+
self._pty_label.setText("⚠ Console prompts")
|
|
229
|
+
self._pty_label.setStyleSheet("color: orange;")
|
|
230
|
+
else:
|
|
231
|
+
self._pty_label.setText("✓ Programmatic auth")
|
|
232
|
+
self._pty_label.setStyleSheet("color: green;")
|
|
233
|
+
|
|
234
|
+
def _apply_theme(self, theme_name: str):
|
|
235
|
+
"""Apply selected theme."""
|
|
236
|
+
theme = self._theme_engine.get_theme(theme_name)
|
|
237
|
+
if theme:
|
|
238
|
+
self._terminal.set_theme(theme)
|
|
239
|
+
|
|
240
|
+
def _connect(self):
|
|
241
|
+
"""Establish connection."""
|
|
242
|
+
hostname = self._host_input.text().strip()
|
|
243
|
+
port = self._port_input.value()
|
|
244
|
+
username = self._user_input.text().strip()
|
|
245
|
+
session_type = self._session_combo.currentData()
|
|
246
|
+
|
|
247
|
+
if not hostname:
|
|
248
|
+
QMessageBox.warning(self, "Error", "Please enter a hostname")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if not username:
|
|
252
|
+
QMessageBox.warning(self, "Error", "Please enter a username")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Build auth config
|
|
256
|
+
if session_type in ("askpass", "interactive"):
|
|
257
|
+
auth = AuthConfig.agent_auth(username)
|
|
258
|
+
else:
|
|
259
|
+
auth_method = self._auth_combo.currentText()
|
|
260
|
+
if auth_method == "Agent":
|
|
261
|
+
auth = AuthConfig.agent_auth(username)
|
|
262
|
+
elif auth_method == "Password":
|
|
263
|
+
password = self._password_input.text()
|
|
264
|
+
if not password:
|
|
265
|
+
QMessageBox.warning(self, "Error", "Please enter a password")
|
|
266
|
+
return
|
|
267
|
+
auth = AuthConfig.password_auth(username, password)
|
|
268
|
+
else:
|
|
269
|
+
auth = AuthConfig.agent_auth(username, allow_fallback=True)
|
|
270
|
+
|
|
271
|
+
# Build jump host config if specified
|
|
272
|
+
jump_hosts = []
|
|
273
|
+
jump_host = self._jump_host_input.text().strip()
|
|
274
|
+
if jump_host:
|
|
275
|
+
jump_user = self._jump_user_input.text().strip() or username
|
|
276
|
+
jump_hosts.append(JumpHostConfig(
|
|
277
|
+
hostname=jump_host,
|
|
278
|
+
auth=AuthConfig.agent_auth(jump_user),
|
|
279
|
+
))
|
|
280
|
+
|
|
281
|
+
# Create profile
|
|
282
|
+
profile = ConnectionProfile(
|
|
283
|
+
name=f"{username}@{hostname}",
|
|
284
|
+
hostname=hostname,
|
|
285
|
+
port=port,
|
|
286
|
+
auth_methods=[auth],
|
|
287
|
+
jump_hosts=jump_hosts,
|
|
288
|
+
auto_reconnect=False, # Disable for testing
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Create appropriate session type
|
|
292
|
+
if session_type == "askpass":
|
|
293
|
+
if not is_pty_available():
|
|
294
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
295
|
+
return
|
|
296
|
+
self._session = AskpassSSHSession(profile)
|
|
297
|
+
elif session_type == "interactive":
|
|
298
|
+
if not is_pty_available():
|
|
299
|
+
QMessageBox.warning(self, "Error", "PTY support required")
|
|
300
|
+
return
|
|
301
|
+
self._session = InteractiveSSHSession(profile)
|
|
302
|
+
else:
|
|
303
|
+
self._session = SSHSession(profile)
|
|
304
|
+
|
|
305
|
+
self._terminal.attach_session(self._session)
|
|
306
|
+
|
|
307
|
+
# Connect
|
|
308
|
+
self._session.connect()
|
|
309
|
+
self._connect_btn.setEnabled(False)
|
|
310
|
+
self._disconnect_btn.setEnabled(True)
|
|
311
|
+
|
|
312
|
+
def _disconnect(self):
|
|
313
|
+
"""Disconnect session."""
|
|
314
|
+
# Close any open dialogs
|
|
315
|
+
if self._yubikey_dialog:
|
|
316
|
+
self._yubikey_dialog.close()
|
|
317
|
+
self._yubikey_dialog = None
|
|
318
|
+
|
|
319
|
+
if self._session:
|
|
320
|
+
self._session.disconnect()
|
|
321
|
+
self._terminal.detach_session()
|
|
322
|
+
self._session = None
|
|
323
|
+
|
|
324
|
+
self._connect_btn.setEnabled(True)
|
|
325
|
+
self._disconnect_btn.setEnabled(False)
|
|
326
|
+
|
|
327
|
+
def _on_state_changed(self, state: SessionState, message: str):
|
|
328
|
+
"""Handle session state changes."""
|
|
329
|
+
status_text = {
|
|
330
|
+
SessionState.DISCONNECTED: "Disconnected",
|
|
331
|
+
SessionState.CONNECTING: "Connecting...",
|
|
332
|
+
SessionState.AUTHENTICATING: "Authenticating...",
|
|
333
|
+
SessionState.CONNECTED: "Connected",
|
|
334
|
+
SessionState.RECONNECTING: f"Reconnecting: {message}",
|
|
335
|
+
SessionState.FAILED: f"Failed: {message}",
|
|
336
|
+
}.get(state, str(state))
|
|
337
|
+
|
|
338
|
+
self._status.showMessage(status_text)
|
|
339
|
+
|
|
340
|
+
# Close YubiKey dialog on connect/disconnect
|
|
341
|
+
if state in (SessionState.CONNECTED, SessionState.DISCONNECTED, SessionState.FAILED):
|
|
342
|
+
if self._yubikey_dialog:
|
|
343
|
+
self._yubikey_dialog.close()
|
|
344
|
+
self._yubikey_dialog = None
|
|
345
|
+
|
|
346
|
+
if state == SessionState.CONNECTED:
|
|
347
|
+
self._connect_btn.setEnabled(False)
|
|
348
|
+
self._disconnect_btn.setEnabled(True)
|
|
349
|
+
self._terminal.focus()
|
|
350
|
+
elif state in (SessionState.DISCONNECTED, SessionState.FAILED):
|
|
351
|
+
self._connect_btn.setEnabled(True)
|
|
352
|
+
self._disconnect_btn.setEnabled(False)
|
|
353
|
+
|
|
354
|
+
def _on_interaction(self, prompt: str, interaction_type: str):
|
|
355
|
+
"""Handle SSH authentication prompts."""
|
|
356
|
+
logger.info(f"Interaction required: {interaction_type} - {prompt}")
|
|
357
|
+
|
|
358
|
+
if not isinstance(self._session, AskpassSSHSession):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
if interaction_type == "yubikey_touch":
|
|
362
|
+
# Show YubiKey dialog
|
|
363
|
+
self._yubikey_dialog = YubiKeyDialog(prompt, self)
|
|
364
|
+
result = self._yubikey_dialog.exec()
|
|
365
|
+
self._yubikey_dialog = None
|
|
366
|
+
|
|
367
|
+
if result == QDialog.DialogCode.Rejected:
|
|
368
|
+
# User cancelled
|
|
369
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
370
|
+
else:
|
|
371
|
+
# YubiKey was touched (dialog closed by external event)
|
|
372
|
+
self._session.provide_askpass_response(True, value="")
|
|
373
|
+
|
|
374
|
+
elif interaction_type == "password":
|
|
375
|
+
# Show password dialog
|
|
376
|
+
password, ok = QInputDialog.getText(
|
|
377
|
+
self, "SSH Authentication", prompt,
|
|
378
|
+
QLineEdit.EchoMode.Password
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if ok and password:
|
|
382
|
+
self._session.provide_askpass_response(True, value=password)
|
|
383
|
+
else:
|
|
384
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
385
|
+
|
|
386
|
+
else:
|
|
387
|
+
# Generic input
|
|
388
|
+
text, ok = QInputDialog.getText(
|
|
389
|
+
self, "SSH Authentication", prompt
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if ok:
|
|
393
|
+
self._session.provide_askpass_response(True, value=text)
|
|
394
|
+
else:
|
|
395
|
+
self._session.provide_askpass_response(False, error="Cancelled by user")
|
|
396
|
+
|
|
397
|
+
def closeEvent(self, event):
|
|
398
|
+
"""Handle window close."""
|
|
399
|
+
if self._session:
|
|
400
|
+
self._session.disconnect()
|
|
401
|
+
event.accept()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main():
|
|
405
|
+
app = QApplication(sys.argv)
|
|
406
|
+
app.setApplicationName("nterm")
|
|
407
|
+
|
|
408
|
+
window = NTermWindow()
|
|
409
|
+
window.show()
|
|
410
|
+
|
|
411
|
+
sys.exit(app.exec())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
main()
|