Compare commits
7
Commits
127c1b9fdb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0632a510ad | ||
|
|
835283eba6 | ||
|
|
bf5bddda5a | ||
|
|
fe189885d4 | ||
|
|
e94bb3a341 | ||
|
|
558e1d828f | ||
|
|
2bffcdf81f |
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
|
||||
# Connpy (v6.0.3)
|
||||
# Connpy (v6.1.0)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
@@ -44,6 +44,21 @@ Connect to external data sources and tools dynamically via the Model Context Pro
|
||||
conn ai --mcp
|
||||
```
|
||||
|
||||
### 1d. Local Interactive Shell (conn shell)
|
||||
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
|
||||
```bash
|
||||
conn shell # Start local shell (default: $SHELL or /bin/bash)
|
||||
conn shell -c /bin/zsh # Override shell executable
|
||||
conn shell --capture session.log # Log session output to file
|
||||
```
|
||||
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
|
||||
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
|
||||
```bash
|
||||
conn config --shell-command /bin/zsh
|
||||
conn config --shell-prompt "\$\s*$"
|
||||
conn config --shell-os ubuntu
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -179,14 +194,19 @@ conn config --service-mode remote
|
||||
conn config --remote localhost:50051
|
||||
```
|
||||
|
||||
### 8c. User Management
|
||||
Manage server-side user credentials for distributed setups:
|
||||
### 8c. User Management & API Tokens
|
||||
Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
|
||||
```bash
|
||||
conn user --add username
|
||||
conn user --list
|
||||
conn user --regen-password username
|
||||
|
||||
# Personal Access Tokens (PAT) for non-interactive API access
|
||||
conn user --create-token "CI/CD Token" --expires-in 30
|
||||
conn user --list-tokens
|
||||
conn user --revoke-token <token_id>
|
||||
```
|
||||
Use `--path` to specify custom configuration folders in server Mode B.
|
||||
Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
|
||||
|
||||
### 8d. SSO / OIDC
|
||||
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
|
||||
|
||||
+32
-6
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
|
||||
# Connpy (v6.0.3)
|
||||
# Connpy (v6.1.0)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
[](https://pypi.org/pypi/connpy/)
|
||||
@@ -46,6 +46,21 @@ Connect to external data sources and tools dynamically via the Model Context Pro
|
||||
conn ai --mcp
|
||||
```
|
||||
|
||||
### 1d. Local Interactive Shell (conn shell)
|
||||
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
|
||||
```bash
|
||||
conn shell # Start local shell (default: $SHELL or /bin/bash)
|
||||
conn shell -c /bin/zsh # Override shell executable
|
||||
conn shell --capture session.log # Log session output to file
|
||||
```
|
||||
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
|
||||
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
|
||||
```bash
|
||||
conn config --shell-command /bin/zsh
|
||||
conn config --shell-prompt "\$\s*$"
|
||||
conn config --shell-os ubuntu
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -181,14 +196,19 @@ conn config --service-mode remote
|
||||
conn config --remote localhost:50051
|
||||
```
|
||||
|
||||
### 8c. User Management
|
||||
Manage server-side user credentials for distributed setups:
|
||||
### 8c. User Management & API Tokens
|
||||
Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
|
||||
```bash
|
||||
conn user --add username
|
||||
conn user --list
|
||||
conn user --regen-password username
|
||||
|
||||
# Personal Access Tokens (PAT) for non-interactive API access
|
||||
conn user --create-token "CI/CD Token" --expires-in 30
|
||||
conn user --list-tokens
|
||||
conn user --revoke-token <token_id>
|
||||
```
|
||||
Use `--path` to specify custom configuration folders in server Mode B.
|
||||
Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
|
||||
|
||||
### 8d. SSO / OIDC
|
||||
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
|
||||
@@ -282,12 +302,18 @@ from .core import node,nodes
|
||||
from .configfile import configfile
|
||||
from .connapp import connapp
|
||||
from .api import *
|
||||
from .ai import ai
|
||||
from .plugins import Plugins
|
||||
from ._version import __version__
|
||||
from . import printer
|
||||
|
||||
__all__ = ["node", "nodes", "configfile", "connapp", "ai", "Plugins", "printer"]
|
||||
def __getattr__(name: str):
|
||||
if name == "ai":
|
||||
from .ai import ai
|
||||
globals()["ai"] = ai
|
||||
return ai
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
__all__ = ["node", "nodes", "configfile", "connapp", "Plugins", "printer"]
|
||||
__author__ = "Federico Luzzi"
|
||||
__pdoc__ = {
|
||||
'core': False,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from connpy import *
|
||||
from connpy import configfile, connapp
|
||||
|
||||
def main():
|
||||
conf = configfile()
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "6.0.3"
|
||||
__version__ = "6.1.0"
|
||||
|
||||
+3
-2
@@ -1159,8 +1159,9 @@ class ai:
|
||||
for msg in chat_history[-self.max_history:]:
|
||||
if msg.get('role') != 'system':
|
||||
messages.append(msg)
|
||||
# Add current user request
|
||||
messages.append({"role": "user", "content": clean_input})
|
||||
# Add current user request with a system note to prevent infinite escalation loops
|
||||
fallback_msg = clean_input + "\n\n[SYSTEM NOTE: The Architect is currently unavailable/failed to respond. You must handle the user's request directly as the Network Engineer. Do NOT attempt to escalate to the Architect again.]"
|
||||
messages.append({"role": "user", "content": fallback_msg})
|
||||
continue
|
||||
else:
|
||||
return {"response": f"Error: Both engines failed. {str(e)}", "chat_history": messages[1:], "usage": usage}
|
||||
|
||||
@@ -26,7 +26,10 @@ class ConfigHandler:
|
||||
"trusted_commands": self.set_ai_config,
|
||||
"service_mode": self.set_service_mode,
|
||||
"remote_host": self.set_remote_host,
|
||||
"sync_remote": self.set_sync_remote
|
||||
"sync_remote": self.set_sync_remote,
|
||||
"shell_command": self.set_shell_config,
|
||||
"shell_prompt": self.set_shell_config,
|
||||
"shell_os": self.set_shell_config
|
||||
}
|
||||
handler = actions.get(getattr(args, "command", None))
|
||||
if handler:
|
||||
@@ -183,3 +186,19 @@ class ConfigHandler:
|
||||
except Exception:
|
||||
raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")
|
||||
|
||||
def set_shell_config(self, args):
|
||||
key = args.command.replace("shell_", "")
|
||||
val = args.data[0] if isinstance(args.data, list) else args.data
|
||||
try:
|
||||
settings = self.app.services.config_svc.get_settings()
|
||||
shell_cfg = settings.get("shell", {}) if isinstance(settings.get("shell"), dict) else {}
|
||||
if str(val).lower() in ["none", "clear", ""]:
|
||||
if key in shell_cfg:
|
||||
del shell_cfg[key]
|
||||
else:
|
||||
shell_cfg[key] = val
|
||||
self.app.services.config_svc.update_setting("shell", shell_cfg)
|
||||
printer.success("Config saved")
|
||||
except (ConnpyError, InvalidConfigurationError) as e:
|
||||
printer.error(str(e))
|
||||
|
||||
|
||||
+5
-1
@@ -1,5 +1,4 @@
|
||||
import ast
|
||||
import inquirer
|
||||
from .validators import Validators
|
||||
|
||||
class Forms:
|
||||
@@ -8,6 +7,7 @@ class Forms:
|
||||
self.validators = Validators(app)
|
||||
|
||||
def questions_edit(self):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?"))
|
||||
questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?"))
|
||||
@@ -21,6 +21,7 @@ class Forms:
|
||||
return inquirer.prompt(questions)
|
||||
|
||||
def questions_nodes(self, unique, uniques=None, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.nodes.get_node_details(unique)
|
||||
if "tags" not in defaults:
|
||||
@@ -98,6 +99,7 @@ class Forms:
|
||||
return result
|
||||
|
||||
def questions_profiles(self, unique, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
|
||||
if "tags" not in defaults:
|
||||
@@ -163,6 +165,7 @@ class Forms:
|
||||
return result
|
||||
|
||||
def questions_bulk(self, nodes="", hosts=""):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation))
|
||||
questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation))
|
||||
@@ -200,6 +203,7 @@ class Forms:
|
||||
|
||||
def mcp_wizard(self, mcp_servers):
|
||||
"""Interactive wizard to manage MCP servers."""
|
||||
import inquirer
|
||||
from .helpers import theme
|
||||
|
||||
while True:
|
||||
|
||||
+22
-21
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
import inquirer
|
||||
from inquirer.themes import Default, term
|
||||
|
||||
try:
|
||||
from pyfzf.pyfzf import FzfPrompt
|
||||
@@ -9,6 +7,7 @@ except ImportError:
|
||||
|
||||
def hex_to_blessed(hex_str):
|
||||
"""Convert hex color string to blessed/ansi format."""
|
||||
from inquirer.themes import term
|
||||
if not hex_str or not isinstance(hex_str, str):
|
||||
return term.normal
|
||||
|
||||
@@ -42,27 +41,28 @@ def hex_to_blessed(hex_str):
|
||||
except:
|
||||
return prefix + term.normal
|
||||
|
||||
# Custom inquirer theme matching connpy colors
|
||||
class ConnpyTheme(Default):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
from ..printer import _global_active_styles
|
||||
# Use user_prompt as primary accent, fallback to info/cyan
|
||||
accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan"))
|
||||
accent_color = hex_to_blessed(accent)
|
||||
|
||||
self.Question.mark_color = accent_color
|
||||
self.List.selection_color = accent_color
|
||||
self.List.selection_cursor = ">"
|
||||
except:
|
||||
# Absolute fallback to standard cyan
|
||||
self.Question.mark_color = term.cyan
|
||||
self.List.selection_color = term.bold_cyan
|
||||
self.List.selection_cursor = ">"
|
||||
|
||||
def get_theme():
|
||||
"""Returns a fresh instance of the theme with current colors."""
|
||||
from inquirer.themes import Default, term
|
||||
|
||||
class ConnpyTheme(Default):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
from ..printer import _global_active_styles
|
||||
# Use user_prompt as primary accent, fallback to info/cyan
|
||||
accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan"))
|
||||
accent_color = hex_to_blessed(accent)
|
||||
|
||||
self.Question.mark_color = accent_color
|
||||
self.List.selection_color = accent_color
|
||||
self.List.selection_cursor = ">"
|
||||
except:
|
||||
# Absolute fallback to standard cyan
|
||||
self.Question.mark_color = term.cyan
|
||||
self.List.selection_color = term.bold_cyan
|
||||
self.List.selection_cursor = ">"
|
||||
|
||||
return ConnpyTheme()
|
||||
|
||||
class ThemeProxy:
|
||||
@@ -126,6 +126,7 @@ def choose(app, list_, name, action):
|
||||
else:
|
||||
return answer[0]
|
||||
else:
|
||||
import inquirer
|
||||
questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)]
|
||||
answer = inquirer.prompt(questions, theme=theme)
|
||||
if answer == None:
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
import inquirer
|
||||
from .. import printer
|
||||
from ..services.exceptions import ConnpyError
|
||||
from .forms import Forms
|
||||
|
||||
class ImportExportHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def dispatch_import(self, args):
|
||||
file_path = args.data[0]
|
||||
try:
|
||||
printer.warning("This could overwrite your current configuration!")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["import"]:
|
||||
|
||||
@@ -19,6 +19,14 @@ class LoginHandler:
|
||||
sys.exit(1)
|
||||
|
||||
def login(self, args):
|
||||
# Handle token management actions first
|
||||
if getattr(args, "create_token", None):
|
||||
return self.create_token(args)
|
||||
if getattr(args, "list_tokens", False):
|
||||
return self.list_tokens(args)
|
||||
if getattr(args, "revoke_token", None):
|
||||
return self.revoke_token(args)
|
||||
|
||||
if getattr(args, "status", False):
|
||||
return self.show_status()
|
||||
|
||||
@@ -141,3 +149,97 @@ class LoginHandler:
|
||||
printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to check local session status: {e}")
|
||||
|
||||
def _get_auth_service(self):
|
||||
"""Gets an authenticated auth service stub, reusing existing or creating one."""
|
||||
auth_service = getattr(self.app.services, "auth", None)
|
||||
if not auth_service:
|
||||
import grpc
|
||||
from ..grpc_layer.stubs import AuthStub
|
||||
remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
|
||||
if not remote_host:
|
||||
printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
# Load existing session token for authentication
|
||||
token_path = os.path.join(self.app.config.defaultdir, ".token")
|
||||
if not os.path.exists(token_path):
|
||||
printer.error("No active session. Please log in first using 'connpy login'.")
|
||||
sys.exit(1)
|
||||
with open(token_path, "r") as f:
|
||||
session_token = f.read().strip()
|
||||
|
||||
from ..grpc_layer.stubs import AuthClientInterceptor
|
||||
interceptor = AuthClientInterceptor(lambda: session_token)
|
||||
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
|
||||
auth_service = AuthStub(channel, remote_host=remote_host)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to connect to remote server: {e}")
|
||||
sys.exit(1)
|
||||
return auth_service
|
||||
|
||||
def create_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
name = args.create_token
|
||||
expires_days = getattr(args, "expires_days", 0) or 0
|
||||
|
||||
try:
|
||||
result = auth_service.create_api_token(name, expires_in_days=expires_days)
|
||||
printer.success(f"API token '{name}' created successfully.")
|
||||
printer.warning("⚠ Copy this token now. It will NOT be shown again:")
|
||||
printer.data("Token", result["raw_token"])
|
||||
printer.info(f"Token ID: {result['token_id']}")
|
||||
if expires_days > 0:
|
||||
printer.info(f"Expires in: {expires_days} days")
|
||||
else:
|
||||
printer.info("Expires: Never (permanent)")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def list_tokens(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
|
||||
try:
|
||||
tokens = auth_service.list_api_tokens()
|
||||
if not tokens:
|
||||
printer.info("No API tokens found.")
|
||||
return
|
||||
|
||||
import yaml
|
||||
# Clean up empty strings from protobuf defaults
|
||||
cleaned = []
|
||||
for t in tokens:
|
||||
cleaned.append({
|
||||
"token_id": t["token_id"],
|
||||
"name": t["name"],
|
||||
"prefix": t["token_prefix"],
|
||||
"created": t["created_at"] or "N/A",
|
||||
"last_used": t["last_used_at"] or "Never",
|
||||
"expires": t["expires_at"] or "Never",
|
||||
})
|
||||
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
|
||||
printer.data("API Tokens", yaml_str)
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def revoke_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
token_id = args.revoke_token
|
||||
|
||||
try:
|
||||
auth_service.revoke_api_token(token_id)
|
||||
printer.success(f"Token '{token_id}' revoked successfully.")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import sys
|
||||
import yaml
|
||||
import inquirer
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from .. import printer
|
||||
from ..services.exceptions import ConnpyError, InvalidConfigurationError
|
||||
from .helpers import choose
|
||||
from .forms import Forms
|
||||
from .help_text import get_instructions
|
||||
|
||||
class NodeHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def _filter_exact_match(self, matches, query):
|
||||
if not query or len(matches) <= 1:
|
||||
@@ -76,7 +85,7 @@ class NodeHandler:
|
||||
debug=args.debug,
|
||||
logger=self.app._service_logger
|
||||
)
|
||||
except ConnpyError as e:
|
||||
except (ConnpyError, ValueError) as e:
|
||||
printer.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -100,6 +109,7 @@ class NodeHandler:
|
||||
sys.exit(2)
|
||||
|
||||
printer.info(f"Removing: {matches}")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
|
||||
@@ -115,6 +115,8 @@ class PluginHandler:
|
||||
|
||||
# Populate local plugins
|
||||
for name, details in local_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -123,11 +125,14 @@ class PluginHandler:
|
||||
state = "Shadowed (Override by Remote)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Local")
|
||||
origin = details.get("origin", "Local").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
# Populate remote plugins
|
||||
if self.app.services.mode == "remote":
|
||||
for name, details in remote_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -138,7 +143,8 @@ class PluginHandler:
|
||||
state = "Shadowed (Override by Local)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Remote")
|
||||
origin = details.get("origin", "Remote").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
if not local_plugins and not remote_plugins:
|
||||
printer.console.print(" No plugins found.")
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import sys
|
||||
import yaml
|
||||
import inquirer
|
||||
|
||||
from .. import printer
|
||||
from ..services.exceptions import ConnpyError, ProfileNotFoundError
|
||||
from .forms import Forms
|
||||
|
||||
class ProfileHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def dispatch(self, args):
|
||||
if not self.app.case:
|
||||
@@ -29,6 +38,7 @@ class ProfileHandler:
|
||||
printer.error("Can't delete default profile")
|
||||
sys.exit(6)
|
||||
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
|
||||
@@ -379,6 +379,35 @@ class RunHandler:
|
||||
from rich.rule import Rule
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
# Helper to get active theme color
|
||||
def get_theme_color(style_name, fallback="white"):
|
||||
try:
|
||||
style = printer.connpy_theme.styles.get(style_name)
|
||||
if style and style.color:
|
||||
if style.color.is_default: return fallback
|
||||
return style.color.triplet.hex if style.color.triplet else style.color.name
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
user_color = get_theme_color("user_prompt", "#00afd7")
|
||||
|
||||
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('enter')
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@kb.add('c-j')
|
||||
@kb.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
session = PromptSession(key_bindings=kb)
|
||||
|
||||
dest_file = args.data[0]
|
||||
if os.path.exists(dest_file):
|
||||
@@ -390,12 +419,15 @@ class RunHandler:
|
||||
# Consistent layout opening matching global AI (engineer style)
|
||||
from rich.markdown import Markdown
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n"))
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||
user_prompt = session.prompt(
|
||||
HTML(f'<style fg="{user_color}">User (Enter to submit, Ctrl+Enter for newline):</style>\n'),
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Operation cancelled by user.")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import shlex
|
||||
import socket
|
||||
from .. import printer
|
||||
from ..core import node
|
||||
|
||||
class ShellHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def dispatch(self, args):
|
||||
shell_config = self.app.config.config.get("shell", {}) if hasattr(self.app.config, "config") else {}
|
||||
command = getattr(args, 'command_override', None) or shell_config.get("command") or os.environ.get("SHELL", "/bin/bash")
|
||||
|
||||
try:
|
||||
exe = shlex.split(command)[0]
|
||||
except Exception:
|
||||
exe = command
|
||||
|
||||
if not shutil.which(exe):
|
||||
printer.error(f"Shell command executable not found: {exe}")
|
||||
sys.exit(1)
|
||||
|
||||
node_info = self._build_local_identity(shell_config)
|
||||
|
||||
tags = {
|
||||
"os": node_info["os"],
|
||||
"prompt": node_info["prompt"]
|
||||
}
|
||||
|
||||
n = node(
|
||||
unique=node_info["name"],
|
||||
host=command,
|
||||
protocol="local",
|
||||
config=self.app.config,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
capture_file = getattr(args, 'capture_file', None)
|
||||
if capture_file:
|
||||
n.logs = capture_file
|
||||
elif shell_config.get("logging"):
|
||||
n.logs = shell_config.get("log_path", os.path.expanduser("~/.config/conn/shell_logs/session.log"))
|
||||
|
||||
n.interact(debug=getattr(args, 'debug', False))
|
||||
|
||||
def _build_local_identity(self, shell_config):
|
||||
return {
|
||||
"name": "local-shell",
|
||||
"host": socket.gethostname(),
|
||||
"os": shell_config.get("os", "linux"),
|
||||
"prompt": shell_config.get("prompt", r'\$\s*$|#\s*$')
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import sys
|
||||
import yaml
|
||||
import inquirer
|
||||
from .. import printer
|
||||
|
||||
class SSOHandler:
|
||||
@@ -40,6 +39,7 @@ class SSOHandler:
|
||||
sys.exit(1)
|
||||
|
||||
def add_provider(self, args):
|
||||
import inquirer
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.setdefault("providers", {})
|
||||
@@ -113,6 +113,7 @@ class SSOHandler:
|
||||
sys.exit(1)
|
||||
|
||||
# Confirm delete
|
||||
import inquirer
|
||||
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers or not answers["confirm"]:
|
||||
|
||||
+83
-17
@@ -14,34 +14,49 @@ from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.filters import has_completions
|
||||
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
|
||||
from ..printer import connpy_theme
|
||||
from connpy.utils import log_cleaner
|
||||
from ..services.ai_service import AIService
|
||||
|
||||
class CopilotInterface:
|
||||
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
|
||||
from ..services.ai_service import AIService
|
||||
self.config = config
|
||||
self.history = history or InMemoryHistory()
|
||||
self.pt_input = pt_input
|
||||
self.pt_output = pt_output
|
||||
self.rich_file = rich_file
|
||||
self.ai_service = AIService(config)
|
||||
self.session_state = session_state if session_state is not None else {
|
||||
'persona': 'engineer',
|
||||
'trust_mode': False,
|
||||
'memories': [],
|
||||
'os': None,
|
||||
'prompt': None
|
||||
}
|
||||
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
|
||||
|
||||
self.session_state = session_state if session_state is not None else {}
|
||||
self.session_state.setdefault('persona', 'engineer')
|
||||
self.session_state.setdefault('trust_mode', False)
|
||||
self.session_state.setdefault('memories', [])
|
||||
self.session_state.setdefault('os', None)
|
||||
self.session_state.setdefault('prompt', None)
|
||||
self.session_state.setdefault('context_mode', self.mode_range)
|
||||
self.session_state.setdefault('context_cmd', 1)
|
||||
self.session_state.setdefault('context_lines', 50)
|
||||
self.session_state.setdefault('last_total_cmds', None)
|
||||
self.session_state.setdefault('last_total_lines', None)
|
||||
|
||||
if rich_file:
|
||||
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
|
||||
else:
|
||||
self.console = Console(theme=connpy_theme)
|
||||
|
||||
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
|
||||
def _sync_session_context(self, state: dict):
|
||||
"""Persist current context mode, depth, total commands, and total lines into session_state."""
|
||||
self.session_state['context_mode'] = state['context_mode']
|
||||
self.session_state['context_cmd'] = state['context_cmd']
|
||||
self.session_state['context_lines'] = state['context_lines']
|
||||
self.session_state['last_total_cmds'] = state['total_cmds']
|
||||
self.session_state['last_total_lines'] = state['total_lines']
|
||||
|
||||
def _get_theme_color(self, style_name: str, fallback: str = "white") -> str:
|
||||
"""Extract Hex or ANSI color name from the active rich theme."""
|
||||
@@ -75,16 +90,52 @@ class CopilotInterface:
|
||||
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
|
||||
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
|
||||
|
||||
total_cmds = len(blocks)
|
||||
total_lines = len(buffer.split('\n'))
|
||||
|
||||
saved_mode = self.session_state.get('context_mode', self.mode_range)
|
||||
saved_cmd = self.session_state.get('context_cmd', 1)
|
||||
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
|
||||
last_total_cmds = self.session_state.get('last_total_cmds', None)
|
||||
last_total_lines = self.session_state.get('last_total_lines', None)
|
||||
|
||||
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
|
||||
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
|
||||
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
|
||||
|
||||
if is_range or is_single:
|
||||
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
|
||||
new_cmds = total_cmds - last_total_cmds
|
||||
initial_cmd = saved_cmd + new_cmds
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
elif is_lines:
|
||||
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
||||
new_lines = total_lines - last_total_lines
|
||||
initial_lines = saved_lines + new_lines
|
||||
else:
|
||||
initial_lines = saved_lines
|
||||
initial_cmd = saved_cmd
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
|
||||
state = {
|
||||
'context_cmd': 1,
|
||||
'total_cmds': len(blocks),
|
||||
'total_lines': len(buffer.split('\n')),
|
||||
'context_lines': min(50, len(buffer.split('\n'))),
|
||||
'context_mode': self.mode_range,
|
||||
'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
|
||||
'total_cmds': total_cmds,
|
||||
'total_lines': total_lines,
|
||||
'context_lines': min(max(1, initial_lines), max(1, total_lines)),
|
||||
'context_mode': saved_mode,
|
||||
'cancelled': False,
|
||||
'toolbar_msg': '',
|
||||
'msg_expiry': 0
|
||||
}
|
||||
self.session_state['context_mode'] = saved_mode
|
||||
self.session_state['context_cmd'] = max(1, initial_cmd)
|
||||
self.session_state['context_lines'] = max(1, initial_lines)
|
||||
self.session_state['last_total_cmds'] = total_cmds
|
||||
self.session_state['last_total_lines'] = total_lines
|
||||
|
||||
# 1. Visual Separation
|
||||
self.console.print("") # Real line break
|
||||
@@ -103,6 +154,7 @@ class CopilotInterface:
|
||||
state['context_lines'] = min(state['context_lines'] + 50, state['total_lines'])
|
||||
else:
|
||||
state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds'])
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('c-down')
|
||||
def _(event):
|
||||
@@ -110,6 +162,7 @@ class CopilotInterface:
|
||||
state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines']))
|
||||
else:
|
||||
state['context_cmd'] = max(state['context_cmd'] - 1, 1)
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('tab')
|
||||
def _(event):
|
||||
@@ -119,6 +172,7 @@ class CopilotInterface:
|
||||
buf.complete_next()
|
||||
else:
|
||||
state['context_mode'] = (state['context_mode'] + 1) % 3
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('escape', eager=True)
|
||||
@bindings.add('c-c')
|
||||
@@ -126,6 +180,16 @@ class CopilotInterface:
|
||||
state['cancelled'] = True
|
||||
event.app.exit(result='')
|
||||
|
||||
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
|
||||
@bindings.add('enter', filter=~has_completions)
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@bindings.add('c-j')
|
||||
@bindings.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
def get_active_buffer():
|
||||
if state['context_mode'] == self.mode_lines:
|
||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||
@@ -271,7 +335,8 @@ class CopilotInterface:
|
||||
question = await session.prompt_async(
|
||||
get_prompt_text,
|
||||
key_bindings=bindings,
|
||||
bottom_toolbar=get_toolbar
|
||||
bottom_toolbar=get_toolbar,
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
state['cancelled'] = True
|
||||
@@ -284,13 +349,14 @@ class CopilotInterface:
|
||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||
|
||||
if directive["action"] == "state_update":
|
||||
state['toolbar_msg'] = directive['message']
|
||||
msg = directive['message']
|
||||
state['toolbar_msg'] = msg
|
||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||
|
||||
async def delayed_refresh():
|
||||
await asyncio.sleep(3.1)
|
||||
# Only invalidate if the message hasn't been replaced by a newer one
|
||||
if state.get('toolbar_msg') == directive['message']:
|
||||
if state.get('toolbar_msg') == msg:
|
||||
state['toolbar_msg'] = '' # Explicitly clear
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app
|
||||
|
||||
+28
-25
@@ -1,6 +1,9 @@
|
||||
import re
|
||||
import ast
|
||||
import inquirer
|
||||
|
||||
def _raise_val_err(reason):
|
||||
import inquirer
|
||||
raise inquirer.errors.ValidationError("", reason=reason)
|
||||
|
||||
class Validators:
|
||||
def __init__(self, app):
|
||||
@@ -8,61 +11,61 @@ class Validators:
|
||||
|
||||
def host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
return True
|
||||
|
||||
def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535 or leave empty")
|
||||
return True
|
||||
|
||||
def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile or leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
return True
|
||||
|
||||
def pass_validation(self, answers, current, regex = "(^@.+$)"):
|
||||
profiles = current.split(",")
|
||||
for i in profiles:
|
||||
if not re.match(regex, i) or i[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i))
|
||||
_raise_val_err("Profile {} don't exist".format(i))
|
||||
return True
|
||||
|
||||
def tags_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
isdict = False
|
||||
try:
|
||||
@@ -70,7 +73,7 @@ class Validators:
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True
|
||||
|
||||
def profile_tags_validation(self, answers, current):
|
||||
@@ -81,36 +84,36 @@ class Validators:
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True
|
||||
|
||||
def jumphost_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True
|
||||
|
||||
def profile_jumphost_validation(self, answers, current):
|
||||
if current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True
|
||||
|
||||
def default_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_folder_validation(self, answers, current):
|
||||
@@ -123,17 +126,17 @@ class Validators:
|
||||
|
||||
matches = list(filter(lambda k: k == candidate, self.app.folders))
|
||||
if current != "" and len(matches) == 0:
|
||||
raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current))
|
||||
_raise_val_err("Location {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
hosts = current.split(",")
|
||||
nodes = answers["ids"].split(",")
|
||||
if len(hosts) > 1 and len(hosts) != len(nodes):
|
||||
raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list")
|
||||
_raise_val_err("Hosts list should be the same length of nodes list")
|
||||
return True
|
||||
|
||||
+25
-6
@@ -89,9 +89,9 @@ def _get_plugins(which, defaultdir):
|
||||
if name not in final_all_plugins or preferences.get(name) == "remote":
|
||||
final_all_plugins[name] = path
|
||||
|
||||
# Combine enabled/disabled for the helper commands
|
||||
enabled_files = list(set(user_enabled + core_enabled + [k for k,v in remote_all_plugins.items() if preferences.get(k) == "remote"]))
|
||||
disabled_files = list(set(user_disabled + core_disabled))
|
||||
# Combine enabled/disabled for the helper commands (excluding core plugins from management autocomplete)
|
||||
enabled_files = list(set(user_enabled + [k for k,v in remote_all_plugins.items() if preferences.get(k) == "remote"]))
|
||||
disabled_files = list(set(user_disabled))
|
||||
|
||||
# Return based on the command
|
||||
if which == "--disable":
|
||||
@@ -238,12 +238,22 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||
"--sync-remote": ["true", "false"],
|
||||
"--help": None, "-h": None,
|
||||
}
|
||||
for opt in ["--keepalive", "--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key", "--theme", "--remote", "--trusted-commands"]:
|
||||
for opt in ["--keepalive", "--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key", "--theme", "--remote", "--trusted-commands", "--shell-command", "--shell-prompt", "--shell-os"]:
|
||||
config_dict[opt] = {"*": config_dict}
|
||||
config_dict["--configfolder"] = {"__extra__": lambda w: get_cwd(w, "--configfolder", True), "*": config_dict}
|
||||
config_dict["--engineer-auth"] = {"__extra__": lambda w: get_cwd(w, "--engineer-auth"), "*": config_dict}
|
||||
config_dict["--architect-auth"] = {"__extra__": lambda w: get_cwd(w, "--architect-auth"), "*": config_dict}
|
||||
|
||||
shell_dict = {
|
||||
"--command": {"*": None},
|
||||
"-c": {"*": None},
|
||||
"--capture": {"__extra__": lambda w: get_cwd(w, "--capture")},
|
||||
"--debug": None,
|
||||
"-d": None,
|
||||
"--help": None,
|
||||
"-h": None
|
||||
}
|
||||
|
||||
_users = lambda w=None: _get_users(configdir)
|
||||
|
||||
user_dict = {
|
||||
@@ -356,7 +366,10 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||
},
|
||||
"plugin": {
|
||||
"--add": {"*": lambda w: get_cwd(w, "--add")},
|
||||
"--update": {"*": lambda w: get_cwd(w, "--update")},
|
||||
"--update": {
|
||||
"__extra__": lambda w: _get_plugins("--update", configdir),
|
||||
"*": lambda w: get_cwd(w, "--update")
|
||||
},
|
||||
"--del": lambda w: _get_plugins("--del", configdir),
|
||||
"--enable": lambda w: _get_plugins("--enable", configdir),
|
||||
"--disable": lambda w: _get_plugins("--disable", configdir),
|
||||
@@ -365,9 +378,15 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||
},
|
||||
"user": user_dict,
|
||||
"sso": sso_dict,
|
||||
"login": {"--help": None, "-h": None, "*": None},
|
||||
"login": {
|
||||
"--status": None, "-s": None,
|
||||
"--create-token": None, "--list-tokens": None,
|
||||
"--revoke-token": None, "--expires-days": None,
|
||||
"--help": None, "-h": None, "*": None
|
||||
},
|
||||
"logout": {"--help": None, "-h": None},
|
||||
"config": config_dict,
|
||||
"shell": shell_dict,
|
||||
"sync": {
|
||||
"--login": None, "--logout": None,
|
||||
"--status": None, "--list": None,
|
||||
|
||||
+51
-15
@@ -10,15 +10,9 @@ from .core import node,nodes
|
||||
from ._version import __version__
|
||||
from . import printer
|
||||
from .api import start_api,stop_api,debug_api
|
||||
from .ai import ai
|
||||
|
||||
from .plugins import Plugins
|
||||
from .services import (
|
||||
NodeService, ProfileService, ConfigService,
|
||||
PluginService, AIService, SystemService,
|
||||
ExecutionService, ImportExportService, ConnpyError,
|
||||
ProfileNotFoundError, ReservedNameError
|
||||
)
|
||||
from .services.exceptions import ConnpyError, ProfileNotFoundError, ReservedNameError
|
||||
|
||||
from rich_argparse import RichHelpFormatter
|
||||
# Bridge rich-argparse with our design system
|
||||
@@ -46,6 +40,32 @@ console = printer.console
|
||||
|
||||
#functions and classes
|
||||
|
||||
class DeferredAIProxy:
|
||||
"""Proxy for connapp.ai that defers importing connpy.ai until ai is actually invoked or accessed."""
|
||||
def __init__(self):
|
||||
self._deferred_modifications = []
|
||||
self._real_ai = None
|
||||
|
||||
def _load_real_ai(self):
|
||||
if self._real_ai is None:
|
||||
from .ai import ai
|
||||
self._real_ai = ai
|
||||
for mod in self._deferred_modifications:
|
||||
self._real_ai.modify(mod)
|
||||
return self._real_ai
|
||||
|
||||
def modify(self, modification_func):
|
||||
if self._real_ai is not None:
|
||||
self._real_ai.modify(modification_func)
|
||||
else:
|
||||
self._deferred_modifications.append(modification_func)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._load_real_ai()(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._load_real_ai(), name)
|
||||
|
||||
class connapp:
|
||||
''' This class starts the connection manager app. It's normally used by connection manager but you can use it on a script to run the connection manager your way and use a different configfile and key.
|
||||
'''
|
||||
@@ -77,7 +97,7 @@ class connapp:
|
||||
self.start_api = start_api
|
||||
self.stop_api = stop_api # Using SystemService logic eventually
|
||||
self.debug_api = debug_api
|
||||
self.ai = ai
|
||||
self.ai = DeferredAIProxy()
|
||||
|
||||
# Register context filtering hooks (only on Client CLI, bypass on gRPC Server)
|
||||
is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api"
|
||||
@@ -142,6 +162,7 @@ class connapp:
|
||||
from .cli.user_handler import UserHandler
|
||||
from .cli.login_handler import LoginHandler
|
||||
from .cli.sso_handler import SSOHandler
|
||||
from .cli.shell_handler import ShellHandler
|
||||
|
||||
# Instantiate Handlers
|
||||
self._node = NodeHandler(self)
|
||||
@@ -153,6 +174,7 @@ class connapp:
|
||||
self._plugin = PluginHandler(self)
|
||||
self._context = ContextHandler(self)
|
||||
self._import_export = ImportExportHandler(self)
|
||||
self._shell = ShellHandler(self)
|
||||
self._sync = SyncHandler(self)
|
||||
self._user = UserHandler(self)
|
||||
self._login = LoginHandler(self)
|
||||
@@ -364,10 +386,19 @@ class connapp:
|
||||
configcrud.add_argument("--architect-api-key", dest="architect_api_key", nargs=1, action=self._store_type, help="Set architect api_key", metavar="API_KEY")
|
||||
configcrud.add_argument("--architect-auth", dest="architect_auth", nargs=1, action=self._store_type, help="Set architect auth (inline JSON/YAML or file path)", metavar="AUTH")
|
||||
configcrud.add_argument("--sync-remote", dest="sync_remote", nargs=1, action=self._store_type, help="Sync remote nodes to Google Drive", choices=["true","false"])
|
||||
configcrud.add_argument("--shell-command", dest="shell_command", nargs=1, action=self._store_type, help="Set default shell command", metavar="COMMAND")
|
||||
configcrud.add_argument("--shell-prompt", dest="shell_prompt", nargs=1, action=self._store_type, help="Set shell prompt regex for AI", metavar="REGEX")
|
||||
configcrud.add_argument("--shell-os", dest="shell_os", nargs=1, action=self._store_type, help="Set shell OS hint for AI", metavar="OS")
|
||||
configparser.add_argument("--trusted-commands", dest="trusted_commands", nargs=1, action=self._store_type, help="Set custom trusted commands regexes (comma separated)", metavar="REGEX,REGEX")
|
||||
configparser.set_defaults(func=self._config.dispatch)
|
||||
|
||||
#USERPARSER
|
||||
#SHELLPARSER
|
||||
shellparser = subparsers.add_parser("shell", help="Start local interactive shell with copilot", formatter_class=RichHelpFormatter)
|
||||
shellparser.error = self._custom_error
|
||||
shellparser.add_argument("--command", "-c", dest="command_override", help="Override shell command")
|
||||
shellparser.add_argument("--capture", dest="capture_file", help="Capture session to file")
|
||||
shellparser.add_argument("-d", "--debug", action="store_true", help="Debug mode")
|
||||
shellparser.set_defaults(func=self._shell.dispatch)
|
||||
userparser = subparsers.add_parser("user", help="Manage server users", description="Manage server users", formatter_class=RichHelpFormatter)
|
||||
userparser.error = self._custom_error
|
||||
usercrud = userparser.add_mutually_exclusive_group(required=True)
|
||||
@@ -395,6 +426,10 @@ class connapp:
|
||||
loginparser.error = self._custom_error
|
||||
loginparser.add_argument("username", nargs='?', default=None, help="Username to authenticate")
|
||||
loginparser.add_argument("-s", "--status", action="store_true", help="Check current login status")
|
||||
loginparser.add_argument("--create-token", dest="create_token", metavar="NAME", help="Create a permanent API token with the given name")
|
||||
loginparser.add_argument("--list-tokens", dest="list_tokens", action="store_true", help="List all active API tokens")
|
||||
loginparser.add_argument("--revoke-token", dest="revoke_token", metavar="TOKEN_ID", help="Revoke an API token by its ID")
|
||||
loginparser.add_argument("--expires-days", dest="expires_days", type=int, default=0, metavar="DAYS", help="Optional expiration in days for --create-token (default: permanent)")
|
||||
loginparser.set_defaults(func=self._login.dispatch, action="login")
|
||||
|
||||
#LOGOUTPARSER
|
||||
@@ -525,12 +560,13 @@ class connapp:
|
||||
printer.warning("Operation cancelled by user.")
|
||||
sys.exit(130)
|
||||
finally:
|
||||
# Safely cleanup AI sessions (litellm)
|
||||
try:
|
||||
from .ai import cleanup
|
||||
cleanup()
|
||||
except ImportError:
|
||||
pass
|
||||
# Safely cleanup AI sessions (litellm) if AI was loaded
|
||||
if "connpy.ai" in sys.modules:
|
||||
try:
|
||||
from .ai import cleanup
|
||||
cleanup()
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
class _store_type(argparse.Action):
|
||||
#Custom store type for cli app.
|
||||
|
||||
+208
-78
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import re
|
||||
import pexpect
|
||||
import shlex
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
import ast
|
||||
@@ -131,80 +132,12 @@ class node:
|
||||
else:
|
||||
self.password = [password]
|
||||
if self.jumphost != "" and config != '':
|
||||
self.jumphost = config.getitem(self.jumphost)
|
||||
for key in self.jumphost:
|
||||
profile = re.search("^@(.*)", str(self.jumphost[key]))
|
||||
if profile:
|
||||
try:
|
||||
self.jumphost[key] = config.profiles[profile.group(1)][key]
|
||||
except KeyError:
|
||||
self.jumphost[key] = ""
|
||||
elif self.jumphost[key] == '' and key == "protocol":
|
||||
try:
|
||||
self.jumphost[key] = config.profiles["default"][key]
|
||||
except KeyError:
|
||||
self.jumphost[key] = "ssh"
|
||||
if isinstance(self.jumphost["password"],list):
|
||||
jumphost_password = []
|
||||
for i, s in enumerate(self.jumphost["password"]):
|
||||
profile = re.search("^@(.*)", self.jumphost["password"][i])
|
||||
if profile:
|
||||
jumphost_password.append(config.profiles[profile.group(1)]["password"])
|
||||
else:
|
||||
jumphost_password.append(self.jumphost["password"][i])
|
||||
self.jumphost["password"] = jumphost_password
|
||||
else:
|
||||
self.jumphost["password"] = [self.jumphost["password"]]
|
||||
if self.jumphost["password"] != [""]:
|
||||
self.password = self.jumphost["password"] + self.password
|
||||
|
||||
if self.jumphost["protocol"] == "ssh":
|
||||
jumphost_cmd = self.jumphost["protocol"] + " -W %h:%p"
|
||||
if self.jumphost["port"] != '':
|
||||
jumphost_cmd = jumphost_cmd + " -p " + self.jumphost["port"]
|
||||
if self.jumphost["options"] != '':
|
||||
jumphost_cmd = jumphost_cmd + " " + self.jumphost["options"]
|
||||
if self.jumphost["user"] == '':
|
||||
jumphost_cmd = jumphost_cmd + " {}".format(self.jumphost["host"])
|
||||
else:
|
||||
jumphost_cmd = jumphost_cmd + " {}".format("@".join([self.jumphost["user"],self.jumphost["host"]]))
|
||||
self.jumphost = f"-o ProxyCommand=\"{jumphost_cmd}\""
|
||||
elif self.jumphost["protocol"] == "ssm":
|
||||
ssm_target = self.jumphost["host"]
|
||||
ssm_cmd = f"aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters 'portNumber=22'"
|
||||
if isinstance(self.jumphost.get("tags"), dict):
|
||||
if "profile" in self.jumphost["tags"]:
|
||||
ssm_cmd += f" --profile {self.jumphost['tags']['profile']}"
|
||||
if "region" in self.jumphost["tags"]:
|
||||
ssm_cmd += f" --region {self.jumphost['tags']['region']}"
|
||||
if self.jumphost["options"] != '':
|
||||
ssm_cmd += f" {self.jumphost['options']}"
|
||||
|
||||
bastion_user_part = f"{self.jumphost['user']}@{ssm_target}" if self.jumphost['user'] else ssm_target
|
||||
|
||||
ssh_opts = ""
|
||||
if isinstance(self.jumphost.get("tags"), dict) and "ssh_options" in self.jumphost["tags"]:
|
||||
ssh_opts = f" {self.jumphost['tags']['ssh_options']}"
|
||||
|
||||
inner_ssh = f"ssh{ssh_opts} -o ProxyCommand='{ssm_cmd}' -W %h:%p {bastion_user_part}"
|
||||
self.jumphost = f"-o ProxyCommand=\"{inner_ssh}\""
|
||||
elif self.jumphost["protocol"] in ["kubectl", "docker"]:
|
||||
nc_cmd = "nc"
|
||||
if isinstance(self.jumphost.get("tags"), dict) and "nc_command" in self.jumphost["tags"]:
|
||||
nc_cmd = self.jumphost["tags"]["nc_command"]
|
||||
|
||||
if self.jumphost["protocol"] == "kubectl":
|
||||
proxy_cmd = f"kubectl exec "
|
||||
if self.jumphost["options"] != '':
|
||||
proxy_cmd += f"{self.jumphost['options']} "
|
||||
proxy_cmd += f"{self.jumphost['host']} -i -- {nc_cmd} %h %p"
|
||||
else:
|
||||
proxy_cmd = f"docker "
|
||||
if self.jumphost["options"] != '':
|
||||
proxy_cmd += f"{self.jumphost['options']} "
|
||||
proxy_cmd += f"exec -i {self.jumphost['host']} {nc_cmd} %h %p"
|
||||
|
||||
self.jumphost = f"-o ProxyCommand=\"{proxy_cmd}\""
|
||||
raw_cmd, jh_passwords = self._build_jumphost_chain(self.jumphost, config)
|
||||
if jh_passwords:
|
||||
self.password = jh_passwords + self.password
|
||||
if raw_cmd:
|
||||
escaped = raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||||
self.jumphost = f'-o ProxyCommand="{escaped}"'
|
||||
else:
|
||||
self.jumphost = ""
|
||||
|
||||
@@ -213,6 +146,127 @@ class node:
|
||||
self.result = {}
|
||||
self.cmd_byte_positions = [(0, None)]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_jumphost_data(jh_dict, config):
|
||||
'''Resolve @profile references and normalize passwords in a jumphost dict.'''
|
||||
for key in jh_dict:
|
||||
profile = re.search("^@(.*)", str(jh_dict[key]))
|
||||
if profile:
|
||||
try:
|
||||
jh_dict[key] = config.profiles[profile.group(1)][key]
|
||||
except KeyError:
|
||||
jh_dict[key] = ""
|
||||
elif jh_dict[key] == '' and key == "protocol":
|
||||
try:
|
||||
jh_dict[key] = config.profiles["default"][key]
|
||||
except KeyError:
|
||||
jh_dict[key] = "ssh"
|
||||
if isinstance(jh_dict["password"], list):
|
||||
resolved = []
|
||||
for p in jh_dict["password"]:
|
||||
profile = re.search("^@(.*)", p)
|
||||
if profile:
|
||||
resolved.append(config.profiles[profile.group(1)]["password"])
|
||||
else:
|
||||
resolved.append(p)
|
||||
jh_dict["password"] = resolved
|
||||
else:
|
||||
jh_dict["password"] = [jh_dict["password"]]
|
||||
return jh_dict
|
||||
|
||||
def _build_jumphost_chain(self, jumphost_name, config, visited=None, depth=0, target_host="%h", target_port="%p"):
|
||||
'''Recursively build ProxyCommand for chained jumphosts.
|
||||
|
||||
Returns:
|
||||
tuple: (raw_proxy_command, passwords_list)
|
||||
- raw_proxy_command: Command string to embed in ProxyCommand
|
||||
- passwords_list: Ordered passwords (innermost first)
|
||||
|
||||
Raises:
|
||||
ValueError: On circular references or exceeding max depth (5).
|
||||
'''
|
||||
if depth >= 5:
|
||||
raise ValueError("Jumphost chain exceeds maximum depth of 5 hops")
|
||||
if visited is None:
|
||||
visited = []
|
||||
if jumphost_name in visited:
|
||||
cycle = " -> ".join(visited + [jumphost_name])
|
||||
raise ValueError(f"Circular jumphost reference detected: {cycle}")
|
||||
visited = visited + [jumphost_name]
|
||||
|
||||
jh = config.getitem(jumphost_name)
|
||||
jh = self._resolve_jumphost_data(jh, config)
|
||||
|
||||
passwords = []
|
||||
inner_proxy_opt = ""
|
||||
|
||||
# Recursively resolve inner jumphost
|
||||
if jh.get("jumphost", "") != "":
|
||||
if jh["protocol"] not in ["ssh"]:
|
||||
raise ValueError(
|
||||
f"Jumphost '{jumphost_name}' uses protocol '{jh['protocol']}' "
|
||||
f"which does not support chained jumphosts. "
|
||||
f"Only SSH jumphosts can have their own jumphosts."
|
||||
)
|
||||
parent_port = jh["port"] if jh["port"] != "" else "22"
|
||||
inner_raw_cmd, inner_passwords = self._build_jumphost_chain(
|
||||
jh["jumphost"], config, visited, depth + 1, target_host=jh["host"], target_port=parent_port
|
||||
)
|
||||
passwords = inner_passwords
|
||||
escaped = inner_raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||||
inner_proxy_opt = f'-o ProxyCommand="{escaped}"'
|
||||
|
||||
# Collect this hop's passwords
|
||||
if jh["password"] != [""]:
|
||||
passwords = passwords + jh["password"]
|
||||
|
||||
t_port = target_port if target_port != "" else "22"
|
||||
|
||||
# Build raw command based on protocol
|
||||
if jh["protocol"] == "ssh":
|
||||
cmd = f"ssh -W {target_host}:{t_port}"
|
||||
if inner_proxy_opt:
|
||||
cmd += f" {inner_proxy_opt}"
|
||||
if jh["port"] != '':
|
||||
cmd += f" -p {jh['port']}"
|
||||
if jh["options"] != '':
|
||||
cmd += f" {jh['options']}"
|
||||
user_host = f"{jh['user']}@{jh['host']}" if jh['user'] != '' else jh['host']
|
||||
cmd += f" {user_host}"
|
||||
elif jh["protocol"] == "ssm":
|
||||
ssm_target = jh["host"]
|
||||
ssm_cmd = f"aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters 'portNumber=22'"
|
||||
if isinstance(jh.get("tags"), dict):
|
||||
if "profile" in jh["tags"]:
|
||||
ssm_cmd += f" --profile {jh['tags']['profile']}"
|
||||
if "region" in jh["tags"]:
|
||||
ssm_cmd += f" --region {jh['tags']['region']}"
|
||||
if jh["options"] != '':
|
||||
ssm_cmd += f" {jh['options']}"
|
||||
bastion_user_part = f"{jh['user']}@{ssm_target}" if jh['user'] else ssm_target
|
||||
ssh_opts = ""
|
||||
if isinstance(jh.get("tags"), dict) and "ssh_options" in jh["tags"]:
|
||||
ssh_opts = f" {jh['tags']['ssh_options']}"
|
||||
cmd = f"ssh{ssh_opts} -o ProxyCommand='{ssm_cmd}' -W {target_host}:{t_port} {bastion_user_part}"
|
||||
elif jh["protocol"] in ["kubectl", "docker"]:
|
||||
nc_cmd = "nc"
|
||||
if isinstance(jh.get("tags"), dict) and "nc_command" in jh["tags"]:
|
||||
nc_cmd = jh["tags"]["nc_command"]
|
||||
if jh["protocol"] == "kubectl":
|
||||
cmd = "kubectl exec "
|
||||
if jh["options"] != '':
|
||||
cmd += f"{jh['options']} "
|
||||
cmd += f"{jh['host']} -i -- {nc_cmd} {target_host} {t_port}"
|
||||
else:
|
||||
cmd = "docker "
|
||||
if jh["options"] != '':
|
||||
cmd += f"{jh['options']} "
|
||||
cmd += f"exec -i {jh['host']} {nc_cmd} {target_host} {t_port}"
|
||||
else:
|
||||
return "", passwords
|
||||
|
||||
return cmd, passwords
|
||||
|
||||
@MethodHook
|
||||
def _passtx(self, passwords, *, keyfile=None):
|
||||
# decrypts passwords, used by other methdos.
|
||||
@@ -320,7 +374,7 @@ class node:
|
||||
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
|
||||
except OSError:
|
||||
pass
|
||||
if logger:
|
||||
if logger and self.protocol != "local":
|
||||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||||
|
||||
@@ -354,6 +408,34 @@ class node:
|
||||
with open(self.logfile, "w") as f:
|
||||
f.write(self._logclean(self.mylog.getvalue().decode(), True))
|
||||
|
||||
def _is_child_connpy_active(self, child_fd: int) -> bool:
|
||||
if self.protocol != "local":
|
||||
return False
|
||||
try:
|
||||
fg_pgid = os.tcgetpgrp(child_fd)
|
||||
if fg_pgid <= 0:
|
||||
return False
|
||||
cmdline_path = f"/proc/{fg_pgid}/cmdline"
|
||||
if os.path.exists(cmdline_path):
|
||||
with open(cmdline_path, "rb") as f:
|
||||
raw_args = f.read().split(b"\x00")
|
||||
cmdline_str = " ".join([arg.decode(errors="ignore") for arg in raw_args if arg])
|
||||
|
||||
is_active = False
|
||||
for raw_arg in raw_args:
|
||||
arg_str = raw_arg.decode(errors="ignore")
|
||||
if not arg_str:
|
||||
continue
|
||||
base_name = os.path.basename(arg_str)
|
||||
if base_name in ["conn", "connpy", "connapp"] or "connpy" in arg_str or "connapp" in arg_str:
|
||||
is_active = True
|
||||
break
|
||||
|
||||
return is_active
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
|
||||
local_stream.setup(resize_callback=resize_callback)
|
||||
self.current_local_stream = local_stream
|
||||
@@ -418,6 +500,14 @@ class node:
|
||||
|
||||
# Copilot interception
|
||||
if copilot_handler and b'\x00' in data:
|
||||
if self._is_child_connpy_active(child_fd):
|
||||
try:
|
||||
os.write(child_fd, data)
|
||||
except OSError:
|
||||
break
|
||||
self.lastinput = time()
|
||||
continue
|
||||
|
||||
# Build node info from available metadata and ensure values are strings (not bytes)
|
||||
def to_str(val):
|
||||
if isinstance(val, bytes):
|
||||
@@ -525,12 +615,39 @@ class node:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def pwd_tracker_task():
|
||||
import socket
|
||||
hostname = socket.gethostname()
|
||||
last_cwd = None
|
||||
while True:
|
||||
await asyncio.sleep(0.3)
|
||||
try:
|
||||
child_pid = getattr(self.child, 'pid', None)
|
||||
if child_pid:
|
||||
new_cwd = os.readlink(f"/proc/{child_pid}/cwd")
|
||||
if new_cwd != last_cwd:
|
||||
last_cwd = new_cwd
|
||||
try:
|
||||
os.chdir(new_cwd)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await local_stream.write(f"\033]7;file://{hostname}{new_cwd}\007".encode())
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(self.tags, dict):
|
||||
self.tags["cwd"] = new_cwd
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# We wait for either the user (ingress) or the child (egress) to finish
|
||||
tasks = [
|
||||
asyncio.create_task(ingress_task()),
|
||||
asyncio.create_task(egress_task())
|
||||
]
|
||||
if self.protocol == "local":
|
||||
tasks.append(asyncio.create_task(pwd_tracker_task()))
|
||||
if self.idletime > 0:
|
||||
tasks.append(asyncio.create_task(keepalive_task()))
|
||||
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
|
||||
@@ -658,13 +775,14 @@ class node:
|
||||
|
||||
def _copilot_handler(self, config):
|
||||
"""Unified copilot handler for local session."""
|
||||
from .cli.terminal_ui import CopilotInterface
|
||||
from .services.ai_service import AIService
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
async def handler(buffer, node_info, stream, child_fd, cmd_byte_positions=None):
|
||||
try:
|
||||
from .cli.terminal_ui import CopilotInterface
|
||||
from .services.ai_service import AIService
|
||||
|
||||
interface = CopilotInterface(
|
||||
config,
|
||||
history=getattr(stream, 'copilot_history', None),
|
||||
@@ -1051,12 +1169,23 @@ class node:
|
||||
return self._generate_docker_cmd()
|
||||
elif self.protocol == "ssm":
|
||||
return self._generate_ssm_cmd()
|
||||
elif self.protocol == "local":
|
||||
return self.host
|
||||
else:
|
||||
printer.error(f"Invalid protocol: {self.protocol}")
|
||||
sys.exit(1)
|
||||
|
||||
@MethodHook
|
||||
def _connect(self, debug=False, timeout=10, max_attempts=3, logger=None):
|
||||
if self.protocol == "local":
|
||||
cmd = self._get_cmd()
|
||||
args = shlex.split(cmd)
|
||||
self.child = pexpect.spawn(args[0], args[1:], env=os.environ.copy())
|
||||
from pexpect import fdpexpect
|
||||
self.raw_child = fdpexpect.fdspawn(self.child.child_fd)
|
||||
if self.logs != '':
|
||||
self.logfile = self._logfile()
|
||||
return True
|
||||
|
||||
cmd = self._get_cmd()
|
||||
passwords = self._passtx(self.password) if self.password and any(self.password) else []
|
||||
@@ -1104,7 +1233,8 @@ class node:
|
||||
|
||||
attempts = 1
|
||||
while attempts <= max_attempts:
|
||||
child = pexpect.spawn(cmd)
|
||||
args = shlex.split(cmd)
|
||||
child = pexpect.spawn(args[0], args[1:])
|
||||
if isinstance(self.tags, dict) and self.tags.get("console"):
|
||||
child.sendline()
|
||||
if debug:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2652,6 +2652,21 @@ class AuthServiceStub(object):
|
||||
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.create_api_token = channel.unary_unary(
|
||||
'/connpy.AuthService/create_api_token',
|
||||
request_serializer=connpy__pb2.CreateApiTokenRequest.SerializeToString,
|
||||
response_deserializer=connpy__pb2.CreateApiTokenResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.list_api_tokens = channel.unary_unary(
|
||||
'/connpy.AuthService/list_api_tokens',
|
||||
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
response_deserializer=connpy__pb2.ListApiTokensResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.revoke_api_token = channel.unary_unary(
|
||||
'/connpy.AuthService/revoke_api_token',
|
||||
request_serializer=connpy__pb2.RevokeApiTokenRequest.SerializeToString,
|
||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
_registered_method=True)
|
||||
|
||||
|
||||
class AuthServiceServicer(object):
|
||||
@@ -2681,6 +2696,24 @@ class AuthServiceServicer(object):
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def create_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def list_api_tokens(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def revoke_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_AuthServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
@@ -2704,6 +2737,21 @@ def add_AuthServiceServicer_to_server(servicer, server):
|
||||
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
|
||||
),
|
||||
'create_api_token': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.create_api_token,
|
||||
request_deserializer=connpy__pb2.CreateApiTokenRequest.FromString,
|
||||
response_serializer=connpy__pb2.CreateApiTokenResponse.SerializeToString,
|
||||
),
|
||||
'list_api_tokens': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.list_api_tokens,
|
||||
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
response_serializer=connpy__pb2.ListApiTokensResponse.SerializeToString,
|
||||
),
|
||||
'revoke_api_token': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.revoke_api_token,
|
||||
request_deserializer=connpy__pb2.RevokeApiTokenRequest.FromString,
|
||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'connpy.AuthService', rpc_method_handlers)
|
||||
@@ -2822,3 +2870,84 @@ class AuthService(object):
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def create_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/create_api_token',
|
||||
connpy__pb2.CreateApiTokenRequest.SerializeToString,
|
||||
connpy__pb2.CreateApiTokenResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def list_api_tokens(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/list_api_tokens',
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
connpy__pb2.ListApiTokensResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def revoke_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/revoke_api_token',
|
||||
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
+124
-8
@@ -249,10 +249,59 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
raw_bytes = str(raw_bytes).encode()
|
||||
|
||||
from connpy.utils import log_cleaner
|
||||
last_line = log_cleaner(raw_bytes.decode(errors='replace')).split('\n')[-1].strip()
|
||||
cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace'))
|
||||
last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)"
|
||||
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
|
||||
node_info["context_blocks"] = blocks
|
||||
|
||||
total_cmds = len(blocks)
|
||||
total_lines = len(cleaned_buffer.split('\n'))
|
||||
|
||||
if not hasattr(remote_stream, 'copilot_state') or remote_stream.copilot_state is None:
|
||||
remote_stream.copilot_state = {}
|
||||
session_state = remote_stream.copilot_state
|
||||
|
||||
if isinstance(node_info, dict):
|
||||
for k, v in node_info.items():
|
||||
if k in ('context_mode', 'context_cmd', 'context_lines', 'persona', 'trust', 'os', 'prompt'):
|
||||
session_state[k] = v
|
||||
|
||||
saved_mode = session_state.get('context_mode', 0)
|
||||
saved_cmd = session_state.get('context_cmd', 1)
|
||||
saved_lines = session_state.get('context_lines', 50)
|
||||
last_total_cmds = session_state.get('last_total_cmds', None)
|
||||
last_total_lines = session_state.get('last_total_lines', None)
|
||||
|
||||
is_range = saved_mode in (0, 'RANGE', 'range')
|
||||
is_lines = saved_mode in (2, 'LINES', 'lines')
|
||||
is_single = saved_mode in (1, 'SINGLE', 'single')
|
||||
|
||||
if is_range or is_single:
|
||||
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
|
||||
new_cmds = total_cmds - last_total_cmds
|
||||
initial_cmd = saved_cmd + new_cmds
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
elif is_lines:
|
||||
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
||||
new_lines = total_lines - last_total_lines
|
||||
initial_lines = saved_lines + new_lines
|
||||
else:
|
||||
initial_lines = saved_lines
|
||||
initial_cmd = saved_cmd
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
|
||||
session_state['context_cmd'] = max(1, initial_cmd)
|
||||
session_state['context_lines'] = max(1, initial_lines)
|
||||
session_state['last_total_cmds'] = total_cmds
|
||||
session_state['last_total_lines'] = total_lines
|
||||
|
||||
node_info.update(session_state)
|
||||
node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds))
|
||||
node_info['context_lines'] = min(session_state['context_lines'], max(1, total_lines))
|
||||
node_info_json = json.dumps(node_info)
|
||||
|
||||
# Convert buffer to string if it's bytes for the preview
|
||||
@@ -297,6 +346,17 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
if req_session_id and req_session_id != copilot_session_id:
|
||||
continue # Ignore stale request from a previous session
|
||||
|
||||
merged_node_info_str = req_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
node_info.update(merged_node_info)
|
||||
# Sync context state from frontend into session_state for persistence
|
||||
for k in ('context_mode', 'context_cmd', 'context_lines'):
|
||||
if k in merged_node_info:
|
||||
session_state[k] = merged_node_info[k]
|
||||
except: pass
|
||||
|
||||
if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") in ("cancel", "web_cancel"):
|
||||
if req_data.get("action") == "web_cancel":
|
||||
os.write(child_fd, b'\x05')
|
||||
@@ -305,13 +365,6 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
return
|
||||
question = req_data["question"]
|
||||
|
||||
merged_node_info_str = req_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
node_info.update(merged_node_info)
|
||||
except: pass
|
||||
|
||||
context_buffer = req_data.get("context_buffer", "")
|
||||
if context_buffer.startswith('{"context_start_pos"'):
|
||||
try:
|
||||
@@ -373,6 +426,15 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
if not action_data: return
|
||||
action = action_data.get("action", "cancel")
|
||||
|
||||
merged_node_info_str = action_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
for k in ('context_mode', 'context_cmd', 'context_lines'):
|
||||
if k in merged_node_info:
|
||||
session_state[k] = merged_node_info[k]
|
||||
except: pass
|
||||
|
||||
if action == "continue":
|
||||
continue # Loop back for next question
|
||||
|
||||
@@ -1426,6 +1488,58 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
||||
|
||||
return Empty()
|
||||
|
||||
@handle_errors
|
||||
def create_api_token(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
try:
|
||||
expires_in_days = request.expires_in_days if request.expires_in_days > 0 else None
|
||||
result = self.registry.user_service.create_api_token(
|
||||
username, request.name, expires_in_days=expires_in_days
|
||||
)
|
||||
except ValueError as e:
|
||||
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
|
||||
|
||||
return connpy_pb2.CreateApiTokenResponse(
|
||||
token_id=result["token_id"],
|
||||
raw_token=result["raw_token"],
|
||||
name=result["name"],
|
||||
)
|
||||
|
||||
@handle_errors
|
||||
def list_api_tokens(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
tokens = self.registry.user_service.list_api_tokens(username)
|
||||
token_infos = [
|
||||
connpy_pb2.ApiTokenInfo(
|
||||
token_id=t["token_id"],
|
||||
name=t.get("name") or "",
|
||||
token_prefix=t.get("token_prefix") or "",
|
||||
created_at=t.get("created_at") or "",
|
||||
last_used_at=t.get("last_used_at") or "",
|
||||
expires_at=t.get("expires_at") or "",
|
||||
)
|
||||
for t in tokens
|
||||
]
|
||||
return connpy_pb2.ListApiTokensResponse(tokens=token_infos)
|
||||
|
||||
@handle_errors
|
||||
def revoke_api_token(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
removed = self.registry.user_service.revoke_api_token(username, request.token_id)
|
||||
if not removed:
|
||||
context.abort(grpc.StatusCode.NOT_FOUND, f"Token '{request.token_id}' not found")
|
||||
|
||||
return Empty()
|
||||
|
||||
class AuthInterceptor(grpc.ServerInterceptor):
|
||||
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
|
||||
|
||||
@@ -1445,6 +1559,8 @@ class AuthInterceptor(grpc.ServerInterceptor):
|
||||
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
|
||||
|
||||
username = self.registry.user_service.verify_jwt(token)
|
||||
if not username and token.startswith("cnp_pat_"):
|
||||
username = self.registry.user_service.verify_api_token(token)
|
||||
if not username:
|
||||
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
|
||||
|
||||
|
||||
@@ -1148,3 +1148,33 @@ class AuthStub:
|
||||
def change_password(self, old_password, new_password):
|
||||
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
|
||||
self.stub.change_password(req)
|
||||
|
||||
@handle_errors
|
||||
def create_api_token(self, name, expires_in_days=0):
|
||||
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
|
||||
resp = self.stub.create_api_token(req)
|
||||
return {
|
||||
"token_id": resp.token_id,
|
||||
"raw_token": resp.raw_token,
|
||||
"name": resp.name,
|
||||
}
|
||||
|
||||
@handle_errors
|
||||
def list_api_tokens(self):
|
||||
resp = self.stub.list_api_tokens(Empty())
|
||||
return [
|
||||
{
|
||||
"token_id": t.token_id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"created_at": t.created_at,
|
||||
"last_used_at": t.last_used_at,
|
||||
"expires_at": t.expires_at,
|
||||
}
|
||||
for t in resp.tokens
|
||||
]
|
||||
|
||||
@handle_errors
|
||||
def revoke_api_token(self, token_id):
|
||||
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
|
||||
self.stub.revoke_api_token(req)
|
||||
|
||||
@@ -304,6 +304,9 @@ service AuthService {
|
||||
rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
|
||||
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
|
||||
rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {}
|
||||
rpc create_api_token (CreateApiTokenRequest) returns (CreateApiTokenResponse) {}
|
||||
rpc list_api_tokens (google.protobuf.Empty) returns (ListApiTokensResponse) {}
|
||||
rpc revoke_api_token (RevokeApiTokenRequest) returns (google.protobuf.Empty) {}
|
||||
}
|
||||
|
||||
message SSOProvidersResponse {
|
||||
@@ -332,6 +335,34 @@ message ChangePasswordRequest {
|
||||
string new_password = 2;
|
||||
}
|
||||
|
||||
message CreateApiTokenRequest {
|
||||
string name = 1;
|
||||
int32 expires_in_days = 2;
|
||||
}
|
||||
|
||||
message CreateApiTokenResponse {
|
||||
string token_id = 1;
|
||||
string raw_token = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message ApiTokenInfo {
|
||||
string token_id = 1;
|
||||
string name = 2;
|
||||
string token_prefix = 3;
|
||||
string created_at = 4;
|
||||
string last_used_at = 5;
|
||||
string expires_at = 6;
|
||||
}
|
||||
|
||||
message ListApiTokensResponse {
|
||||
repeated ApiTokenInfo tokens = 1;
|
||||
}
|
||||
|
||||
message RevokeApiTokenRequest {
|
||||
string token_id = 1;
|
||||
}
|
||||
|
||||
message AnalyzeRequest {
|
||||
google.protobuf.Struct results = 1;
|
||||
string query = 2;
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
from .exceptions import *
|
||||
from .node_service import NodeService
|
||||
from .profile_service import ProfileService
|
||||
from .execution_service import ExecutionService
|
||||
from .import_export_service import ImportExportService
|
||||
from .ai_service import AIService
|
||||
from .plugin_service import PluginService
|
||||
from .config_service import ConfigService
|
||||
from .system_service import SystemService
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "ExecutionService":
|
||||
from .execution_service import ExecutionService
|
||||
globals()["ExecutionService"] = ExecutionService
|
||||
return ExecutionService
|
||||
elif name == "ImportExportService":
|
||||
from .import_export_service import ImportExportService
|
||||
globals()["ImportExportService"] = ImportExportService
|
||||
return ImportExportService
|
||||
elif name == "SystemService":
|
||||
from .system_service import SystemService
|
||||
globals()["SystemService"] = SystemService
|
||||
return SystemService
|
||||
elif name == "SyncService":
|
||||
from .sync_service import SyncService
|
||||
globals()["SyncService"] = SyncService
|
||||
return SyncService
|
||||
elif name == "UserService":
|
||||
from .user_service import UserService
|
||||
globals()["UserService"] = UserService
|
||||
return UserService
|
||||
elif name == "AIService":
|
||||
from .ai_service import AIService
|
||||
globals()["AIService"] = AIService
|
||||
return AIService
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
__all__ = [
|
||||
'NodeService',
|
||||
@@ -17,6 +40,8 @@ __all__ = [
|
||||
'PluginService',
|
||||
'ConfigService',
|
||||
'SystemService',
|
||||
'SyncService',
|
||||
'UserService',
|
||||
'ConnpyError',
|
||||
'NodeNotFoundError',
|
||||
'NodeAlreadyExistsError',
|
||||
|
||||
@@ -64,7 +64,7 @@ class PluginService(BaseService):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
@@ -74,10 +74,10 @@ class PluginService(BaseService):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
@@ -86,10 +86,10 @@ class PluginService(BaseService):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
@@ -271,13 +271,13 @@ class PluginService(BaseService):
|
||||
is_mock = True
|
||||
def __init__(self, config):
|
||||
from ..core import node, nodes
|
||||
from ..ai import ai
|
||||
from ..connapp import DeferredAIProxy
|
||||
from ..services.provider import ServiceProvider
|
||||
|
||||
self.config = config
|
||||
self.node = node
|
||||
self.nodes = nodes
|
||||
self.ai = ai
|
||||
self.ai = DeferredAIProxy()
|
||||
|
||||
self.services = ServiceProvider(config, mode="local")
|
||||
|
||||
|
||||
+75
-15
@@ -14,6 +14,12 @@ class ServiceProvider:
|
||||
self.mode = mode
|
||||
self.config = config
|
||||
self.remote_host = remote_host
|
||||
self._system = None
|
||||
self._execution = None
|
||||
self._import_export = None
|
||||
self._sync = None
|
||||
self._users = None
|
||||
self._ai = None
|
||||
|
||||
if mode == "local":
|
||||
self._init_local()
|
||||
@@ -27,35 +33,20 @@ class ServiceProvider:
|
||||
from .profile_service import ProfileService
|
||||
from .config_service import ConfigService
|
||||
from .plugin_service import PluginService
|
||||
from .ai_service import AIService
|
||||
from .system_service import SystemService
|
||||
from .execution_service import ExecutionService
|
||||
from .import_export_service import ImportExportService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
from .user_service import UserService
|
||||
|
||||
self.nodes = NodeService(self.config)
|
||||
self.profiles = ProfileService(self.config)
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.plugins = PluginService(self.config)
|
||||
self.ai = AIService(self.config)
|
||||
self.system = SystemService(self.config)
|
||||
self.execution = ExecutionService(self.config)
|
||||
self.import_export = ImportExportService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = UserService(self.config.defaultdir)
|
||||
|
||||
def _init_remote(self):
|
||||
# Allow ConfigService to work locally so the user can revert the mode
|
||||
from .config_service import ConfigService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = None
|
||||
|
||||
if not self.remote_host:
|
||||
raise InvalidConfigurationError("Remote host must be specified in remote mode")
|
||||
@@ -69,6 +60,9 @@ class ServiceProvider:
|
||||
)
|
||||
|
||||
def get_token():
|
||||
env_token = os.environ.get("CONNPY_TOKEN")
|
||||
if env_token:
|
||||
return env_token
|
||||
token_path = os.path.join(self.config.defaultdir, ".token")
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
@@ -95,3 +89,69 @@ class ServiceProvider:
|
||||
self.execution = ExecutionStub(channel, remote_host=self.remote_host)
|
||||
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
|
||||
self.auth = AuthStub(channel, remote_host=self.remote_host)
|
||||
|
||||
@property
|
||||
def system(self):
|
||||
if self._system is None and self.mode == "local":
|
||||
from .system_service import SystemService
|
||||
self._system = SystemService(self.config)
|
||||
return self._system
|
||||
|
||||
@system.setter
|
||||
def system(self, value):
|
||||
self._system = value
|
||||
|
||||
@property
|
||||
def execution(self):
|
||||
if self._execution is None and self.mode == "local":
|
||||
from .execution_service import ExecutionService
|
||||
self._execution = ExecutionService(self.config)
|
||||
return self._execution
|
||||
|
||||
@execution.setter
|
||||
def execution(self, value):
|
||||
self._execution = value
|
||||
|
||||
@property
|
||||
def import_export(self):
|
||||
if self._import_export is None and self.mode == "local":
|
||||
from .import_export_service import ImportExportService
|
||||
self._import_export = ImportExportService(self.config)
|
||||
return self._import_export
|
||||
|
||||
@import_export.setter
|
||||
def import_export(self, value):
|
||||
self._import_export = value
|
||||
|
||||
@property
|
||||
def sync(self):
|
||||
if self._sync is None:
|
||||
from .sync_service import SyncService
|
||||
self._sync = SyncService(self.config)
|
||||
return self._sync
|
||||
|
||||
@sync.setter
|
||||
def sync(self, value):
|
||||
self._sync = value
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
if self._users is None and self.mode == "local":
|
||||
from .user_service import UserService
|
||||
self._users = UserService(self.config.defaultdir)
|
||||
return self._users
|
||||
|
||||
@users.setter
|
||||
def users(self, value):
|
||||
self._users = value
|
||||
|
||||
@property
|
||||
def ai(self):
|
||||
if self._ai is None and self.mode == "local":
|
||||
from .ai_service import AIService
|
||||
self._ai = AIService(self.config)
|
||||
return self._ai
|
||||
|
||||
@ai.setter
|
||||
def ai(self, value):
|
||||
self._ai = value
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import zipfile
|
||||
@@ -6,13 +7,46 @@ import io
|
||||
import yaml
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "Credentials":
|
||||
from google.oauth2.credentials import Credentials
|
||||
return Credentials
|
||||
elif name == "Request":
|
||||
from google.auth.transport.requests import Request
|
||||
return Request
|
||||
elif name == "build":
|
||||
from googleapiclient.discovery import build
|
||||
return build
|
||||
elif name == "RefreshError":
|
||||
from google.auth.exceptions import RefreshError
|
||||
return RefreshError
|
||||
elif name == "InstalledAppFlow":
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
return InstalledAppFlow
|
||||
elif name == "MediaFileUpload":
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
return MediaFileUpload
|
||||
elif name == "MediaIoBaseDownload":
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
return MediaIoBaseDownload
|
||||
elif name == "HttpError":
|
||||
from googleapiclient.errors import HttpError
|
||||
return HttpError
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
def _get_google_libs():
|
||||
mod = sys.modules[__name__]
|
||||
return (
|
||||
getattr(mod, "Credentials"),
|
||||
getattr(mod, "Request"),
|
||||
getattr(mod, "build"),
|
||||
getattr(mod, "RefreshError"),
|
||||
getattr(mod, "InstalledAppFlow"),
|
||||
getattr(mod, "MediaFileUpload"),
|
||||
getattr(mod, "MediaIoBaseDownload"),
|
||||
getattr(mod, "HttpError"),
|
||||
)
|
||||
|
||||
from .base import BaseService
|
||||
from .. import printer
|
||||
@@ -44,6 +78,7 @@ class SyncService(BaseService):
|
||||
|
||||
def login(self):
|
||||
"""Authenticate with Google Drive."""
|
||||
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
|
||||
creds = None
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
@@ -81,6 +116,7 @@ class SyncService(BaseService):
|
||||
|
||||
def get_credentials(self):
|
||||
"""Get valid credentials, refreshing if necessary."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
else:
|
||||
@@ -98,6 +134,7 @@ class SyncService(BaseService):
|
||||
|
||||
def check_login_status(self):
|
||||
"""Check if logged in to Google Drive."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file)
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
@@ -110,6 +147,7 @@ class SyncService(BaseService):
|
||||
|
||||
def list_backups(self):
|
||||
"""List files in Google Drive appDataFolder."""
|
||||
_, _, build, _, _, _, _, HttpError = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds:
|
||||
printer.error("Not logged in to Google Drive.")
|
||||
@@ -168,6 +206,7 @@ class SyncService(BaseService):
|
||||
|
||||
def upload_file(self, file_path, timestamp):
|
||||
"""Internal method to upload to Drive."""
|
||||
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
|
||||
@@ -193,6 +232,7 @@ class SyncService(BaseService):
|
||||
|
||||
def delete_backup(self, file_id):
|
||||
"""Delete a backup from Drive."""
|
||||
_, _, build, _, _, _, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
@@ -226,6 +266,7 @@ class SyncService(BaseService):
|
||||
|
||||
def download_file(self, file_id, dest):
|
||||
"""Internal method to download from Drive."""
|
||||
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import secrets
|
||||
@@ -18,6 +19,9 @@ class UserService:
|
||||
# Ensure users directory exists
|
||||
os.makedirs(self.users_dir, exist_ok=True)
|
||||
|
||||
# Reverse index cache: token_hash -> (username, token_id)
|
||||
self._token_index: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def _load_registry(self) -> dict:
|
||||
"""Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
|
||||
if not os.path.exists(self.registry_file):
|
||||
@@ -61,6 +65,16 @@ class UserService:
|
||||
pass
|
||||
raise e
|
||||
|
||||
def _build_token_index(self, registry: dict) -> dict[str, tuple[str, str]]:
|
||||
"""Builds a reverse index of token_hash -> (username, token_id) for O(1) PAT lookup."""
|
||||
index = {}
|
||||
for username, user_data in registry.get("users", {}).items():
|
||||
for token_id, token_meta in user_data.get("api_tokens", {}).items():
|
||||
token_hash = token_meta.get("token_hash")
|
||||
if token_hash:
|
||||
index[token_hash] = (username, token_id)
|
||||
return index
|
||||
|
||||
def create_user(self, username, password, config_path=None) -> dict:
|
||||
"""Creates a new user with bcrypt-hashed credentials.
|
||||
|
||||
@@ -237,3 +251,125 @@ class UserService:
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||
return None
|
||||
|
||||
# --- Personal Access Token (PAT) Management ---
|
||||
|
||||
def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -> dict:
|
||||
"""Creates a Personal Access Token for the user.
|
||||
|
||||
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Token name cannot be empty")
|
||||
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
user_data = registry["users"][username]
|
||||
if "api_tokens" not in user_data:
|
||||
user_data["api_tokens"] = {}
|
||||
|
||||
# Generate cryptographically secure token with recognizable prefix
|
||||
raw_secret = secrets.token_hex(32)
|
||||
raw_token = f"cnp_pat_{raw_secret}"
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
token_id = f"tok_{secrets.token_hex(4)}"
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
expires_at = None
|
||||
if expires_in_days and expires_in_days > 0:
|
||||
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
|
||||
|
||||
user_data["api_tokens"][token_id] = {
|
||||
"name": name,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": raw_token[:16],
|
||||
"created_at": now.isoformat(),
|
||||
"last_used_at": None,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
return {
|
||||
"token_id": token_id,
|
||||
"raw_token": raw_token,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
def list_api_tokens(self, username: str) -> list[dict]:
|
||||
"""Lists all active API tokens for a user (without sensitive data)."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
return [
|
||||
{
|
||||
"token_id": tid,
|
||||
"name": meta.get("name"),
|
||||
"token_prefix": meta.get("token_prefix"),
|
||||
"created_at": meta.get("created_at"),
|
||||
"last_used_at": meta.get("last_used_at"),
|
||||
"expires_at": meta.get("expires_at"),
|
||||
}
|
||||
for tid, meta in tokens.items()
|
||||
]
|
||||
|
||||
def revoke_api_token(self, username: str, token_id: str) -> bool:
|
||||
"""Revokes (deletes) a specific API token. Returns True if found and removed."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
if token_id not in tokens:
|
||||
return False
|
||||
|
||||
del tokens[token_id]
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return True
|
||||
|
||||
def verify_api_token(self, raw_token: str) -> str | None:
|
||||
"""Validates a PAT by hashing it and looking up the reverse index.
|
||||
|
||||
Returns username if valid and not expired, None otherwise.
|
||||
"""
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
# Rebuild index if empty (cold start or after process restart)
|
||||
if not self._token_index:
|
||||
registry = self._load_registry()
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
match = self._token_index.get(token_hash)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
username, token_id = match
|
||||
|
||||
# Validate token still exists and check expiration
|
||||
registry = self._load_registry()
|
||||
user_data = registry.get("users", {}).get(username, {})
|
||||
token_meta = user_data.get("api_tokens", {}).get(token_id)
|
||||
|
||||
if not token_meta:
|
||||
# Token was revoked between index build and now
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
expires_at = token_meta.get("expires_at")
|
||||
if expires_at:
|
||||
exp_dt = datetime.datetime.fromisoformat(expires_at)
|
||||
if datetime.datetime.now(datetime.timezone.utc) > exp_dt:
|
||||
return None
|
||||
|
||||
# Update last_used_at
|
||||
token_meta["last_used_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
self._save_registry(registry)
|
||||
|
||||
return username
|
||||
|
||||
@@ -400,3 +400,164 @@ def test_build_context_blocks_pager_scrolling_6wind_escapes():
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_copilot_context_state_persistence():
|
||||
from connpy.cli.terminal_ui import CopilotInterface
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.config = {"ai": {}}
|
||||
self.defaultdir = "/tmp"
|
||||
|
||||
session_state = {}
|
||||
interface = CopilotInterface(MockConfig(), session_state=session_state)
|
||||
|
||||
raw_bytes = b"router# show ip\r\nrouter# show run\r\nrouter# "
|
||||
blocks = [
|
||||
(0, 15, "router# show ip"),
|
||||
(15, 30, "router# show run"),
|
||||
(30, 40, "router#")
|
||||
]
|
||||
|
||||
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
|
||||
return {"guide": "Ok", "commands": [], "risk_level": "low"}
|
||||
|
||||
async def mock_prompt_async(self, *args, **kwargs):
|
||||
kb = kwargs.get('key_bindings')
|
||||
if kb:
|
||||
class DummyApp:
|
||||
def invalidate(self): pass
|
||||
class DummyEvent:
|
||||
app = DummyApp()
|
||||
current_buffer = type('Buf', (), {'text': ''})()
|
||||
|
||||
# Trigger TAB key ('c-i' or 'tab') to switch mode from RANGE (0) to SINGLE (1)
|
||||
for b in kb.bindings:
|
||||
if any(k in ('c-i', 'tab') or 'tab' in str(k).lower() or 'c-i' in str(k).lower() for k in b.keys):
|
||||
b.handler(DummyEvent())
|
||||
break
|
||||
return "test question"
|
||||
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface.run_session(
|
||||
raw_bytes=raw_bytes,
|
||||
node_info={"name": "test"},
|
||||
on_ai_call=mock_ai_call,
|
||||
blocks=blocks
|
||||
))
|
||||
|
||||
assert interface.session_state.get('context_mode') == interface.mode_single
|
||||
assert interface.session_state.get('last_total_cmds') == len(blocks)
|
||||
|
||||
|
||||
def test_copilot_range_mode_accumulation():
|
||||
from connpy.cli.terminal_ui import CopilotInterface
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.config = {"ai": {}}
|
||||
self.defaultdir = "/tmp"
|
||||
|
||||
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
|
||||
blocks = [
|
||||
(0, 10, "router# cmd1"),
|
||||
(10, 20, "router# cmd2"),
|
||||
(20, 30, "router# cmd3"),
|
||||
(30, 40, "router#")
|
||||
]
|
||||
|
||||
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
|
||||
return {"guide": "Ok", "commands": [], "risk_level": "low"}
|
||||
|
||||
async def mock_prompt_async(self, *args, **kwargs):
|
||||
return "cancel"
|
||||
|
||||
# Test 1: RANGE mode at default (saved_cmd = 1) -> stays 1 (does not expand)
|
||||
session_state_default = {'context_mode': 0, 'context_cmd': 1, 'last_total_cmds': 2}
|
||||
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_default.session_state.get('context_cmd') == 1
|
||||
|
||||
# Test 2: RANGE mode at expanded (saved_cmd = 2 > 1) -> expands to 2 + 2 = 4
|
||||
session_state_expanded = {'context_mode': 0, 'context_cmd': 2, 'last_total_cmds': 2}
|
||||
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_expanded.session_state.get('context_cmd') == 4
|
||||
|
||||
|
||||
def test_copilot_lines_mode_accumulation():
|
||||
from connpy.cli.terminal_ui import CopilotInterface
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.config = {"ai": {}}
|
||||
self.defaultdir = "/tmp"
|
||||
|
||||
raw_bytes = ("line\n" * 130).encode()
|
||||
blocks = [(0, 10, "router#")]
|
||||
|
||||
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
|
||||
return {"guide": "Ok", "commands": [], "risk_level": "low"}
|
||||
|
||||
async def mock_prompt_async(self, *args, **kwargs):
|
||||
return "cancel"
|
||||
|
||||
# Test 1: LINES mode at default 50 lines -> stays 50
|
||||
session_state_default = {'context_mode': 2, 'context_lines': 50, 'last_total_lines': 100}
|
||||
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_default.session_state.get('context_lines') == 50
|
||||
|
||||
# Test 2: LINES mode at expanded 100 lines -> expands beyond 100
|
||||
session_state_expanded = {'context_mode': 2, 'context_lines': 100, 'last_total_lines': 100}
|
||||
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_expanded.session_state.get('context_lines') > 100
|
||||
|
||||
|
||||
def test_copilot_single_mode_retains_command_block():
|
||||
from connpy.cli.terminal_ui import CopilotInterface
|
||||
|
||||
class MockConfig:
|
||||
def __init__(self):
|
||||
self.config = {"ai": {}}
|
||||
self.defaultdir = "/tmp"
|
||||
|
||||
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
|
||||
blocks = [
|
||||
(0, 10, "router# cmd1"),
|
||||
(10, 20, "router# cmd2"),
|
||||
(20, 30, "router# cmd3"),
|
||||
(30, 40, "router#")
|
||||
]
|
||||
|
||||
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
|
||||
return {"guide": "Ok", "commands": [], "risk_level": "low"}
|
||||
|
||||
async def mock_prompt_async(self, *args, **kwargs):
|
||||
return "cancel"
|
||||
|
||||
# Test 1: In SINGLE mode at default (context_cmd = 1), stays at 1
|
||||
session_state_default = {'context_mode': 1, 'context_cmd': 1, 'last_total_cmds': 2}
|
||||
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_default.session_state.get('context_cmd') == 1
|
||||
|
||||
# Test 2: In SINGLE mode at past command (context_cmd = 2 > 1), becomes 2 + 2 = 4 to stay locked on past command
|
||||
session_state_custom = {'context_mode': 1, 'context_cmd': 2, 'last_total_cmds': 2}
|
||||
interface_custom = CopilotInterface(MockConfig(), session_state=session_state_custom)
|
||||
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
|
||||
asyncio.run(interface_custom.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
|
||||
assert interface_custom.session_state.get('context_cmd') == 4
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -100,15 +100,16 @@ def test_ai_generate_wizard_save(app, tmp_path):
|
||||
})
|
||||
app.services.ai.build_playbook_chat = mock_chat
|
||||
|
||||
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save
|
||||
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]):
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
# Mock prompt_toolkit PromptSession.prompt for user input, and Prompt.ask for save confirmation
|
||||
with patch("prompt_toolkit.PromptSession.prompt", return_value="create a basic task"):
|
||||
with patch("rich.prompt.Prompt.ask", return_value="y"):
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
|
||||
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
|
||||
def test_ai_generate_wizard_run(app, tmp_path):
|
||||
"""Test that ai_generate wizard runs, saves the playbook and executes it when choosing 'run'."""
|
||||
@@ -121,16 +122,17 @@ def test_ai_generate_wizard_run(app, tmp_path):
|
||||
})
|
||||
app.services.ai.build_playbook_chat = mock_chat
|
||||
|
||||
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]):
|
||||
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
with patch("prompt_toolkit.PromptSession.prompt", return_value="create task"):
|
||||
with patch("rich.prompt.Prompt.ask", return_value="run"):
|
||||
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
|
||||
app.start(["run", "--generate-ai", str(dest_yaml)])
|
||||
|
||||
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
mock_chat.assert_called_once_with("create task", chat_history=[], chunk_callback=ANY)
|
||||
assert os.path.exists(dest_yaml)
|
||||
with open(dest_yaml) as f:
|
||||
content = f.read()
|
||||
assert "tasks:" in content
|
||||
|
||||
mock_yaml_run.assert_called_once()
|
||||
args = mock_yaml_run.call_args[0][0]
|
||||
assert args.data == [str(dest_yaml)]
|
||||
mock_yaml_run.assert_called_once()
|
||||
args = mock_yaml_run.call_args[0][0]
|
||||
assert args.data == [str(dest_yaml)]
|
||||
|
||||
@@ -77,6 +77,9 @@ class TestTreeCompletions:
|
||||
config_completions = resolve_completion(["config", ""], tree)
|
||||
assert "--engineer-auth" in config_completions
|
||||
assert "--architect-auth" in config_completions
|
||||
assert "--shell-command" in config_completions
|
||||
assert "--shell-prompt" in config_completions
|
||||
assert "--shell-os" in config_completions
|
||||
|
||||
# Resolve when --engineer-auth is chosen in config
|
||||
auth_comp = resolve_completion(["config", "--engineer-auth", ""], tree)
|
||||
@@ -88,6 +91,20 @@ class TestTreeCompletions:
|
||||
loop_back_comp = resolve_completion(["config", "--engineer-auth", "some_val", ""], tree)
|
||||
assert "--architect-auth" in loop_back_comp
|
||||
assert "--engineer-auth" in loop_back_comp
|
||||
assert "--shell-command" in loop_back_comp
|
||||
|
||||
def test_shell_completions(self):
|
||||
from connpy.completion import _build_tree, resolve_completion
|
||||
tree = _build_tree([], [], [], {}, "/tmp")
|
||||
shell_completions = resolve_completion(["shell", ""], tree)
|
||||
assert "--command" in shell_completions
|
||||
assert "--capture" in shell_completions
|
||||
assert "--debug" in shell_completions
|
||||
assert "--help" in shell_completions
|
||||
# Short flags must NOT be recommended
|
||||
assert "-c" not in shell_completions
|
||||
assert "-d" not in shell_completions
|
||||
assert "-h" not in shell_completions
|
||||
|
||||
def test_ai_auth_completions(self):
|
||||
from connpy.completion import _build_tree, resolve_completion
|
||||
@@ -241,5 +258,28 @@ class TestSsoCompletions:
|
||||
assert "authelia" in completions
|
||||
|
||||
|
||||
class TestPluginUpdateCompletion:
|
||||
def test_plugin_update_first_arg_suggests_plugins(self, tmp_path):
|
||||
from connpy.completion import _build_tree, resolve_completion
|
||||
|
||||
# Create a mock user plugin
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugins_dir / "my_custom_plugin.py").touch()
|
||||
|
||||
# Build tree with plugin mock dict
|
||||
plugins = {"my_custom_plugin": str(plugins_dir / "my_custom_plugin.py")}
|
||||
tree = _build_tree([], [], [], plugins, str(tmp_path))
|
||||
|
||||
# First argument of --update should suggest my_custom_plugin
|
||||
completions = resolve_completion(["plugin", "--update", ""], tree)
|
||||
assert "my_custom_plugin" in completions
|
||||
|
||||
# Second argument should suggest file paths (calling get_cwd)
|
||||
# Type "plugin --update my_custom_plugin "
|
||||
file_completions = resolve_completion(["plugin", "--update", "my_custom_plugin", ""], tree)
|
||||
assert isinstance(file_completions, list)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -487,3 +487,264 @@ class TestNodes:
|
||||
|
||||
mynodes.run(["show version"], on_complete=on_done)
|
||||
assert "r1" in completed
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Jumphost chain tests
|
||||
# =========================================================================
|
||||
|
||||
class TestJumphostChain:
|
||||
"""Tests for chained jumphost (multi-hop ProxyCommand) support."""
|
||||
|
||||
def _make_config_with_nodes(self, tmp_config_dir, nodes, profiles=None):
|
||||
"""Helper to create a config with custom nodes."""
|
||||
import yaml
|
||||
from connpy.configfile import configfile
|
||||
if profiles is None:
|
||||
profiles = {
|
||||
"default": {
|
||||
"host": "", "protocol": "ssh", "port": "", "user": "",
|
||||
"password": "", "options": "", "logs": "", "tags": "", "jumphost": ""
|
||||
}
|
||||
}
|
||||
data = {
|
||||
"config": {"case": False, "idletime": 30, "fzf": False},
|
||||
"connections": nodes,
|
||||
"profiles": profiles
|
||||
}
|
||||
config_file = tmp_config_dir / "config.yaml"
|
||||
config_file.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
||||
import os
|
||||
os.chmod(str(config_file), 0o600)
|
||||
return configfile(conf=str(config_file), key=str(tmp_config_dir / ".osk"))
|
||||
|
||||
def test_single_jumphost(self, tmp_config_dir):
|
||||
"""Regression: single jumphost produces correct ProxyCommand."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"bastion": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "2222",
|
||||
"user": "admin", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastion", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", jumphost="bastion", config=config)
|
||||
assert 'ProxyCommand=' in n.jumphost
|
||||
assert '-W %h:%p' in n.jumphost
|
||||
assert '-p 2222' in n.jumphost
|
||||
assert 'admin@10.0.0.1' in n.jumphost
|
||||
|
||||
def test_two_hop_chain(self, tmp_config_dir):
|
||||
"""Two-hop SSH chain: dest -> bastionA -> bastionB."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"bastionB": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "",
|
||||
"user": "userB", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"bastionA": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "",
|
||||
"user": "userA", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", jumphost="bastionA", config=config)
|
||||
# Should contain nested ProxyCommand
|
||||
assert 'ProxyCommand=' in n.jumphost
|
||||
assert 'userA@10.0.0.2' in n.jumphost
|
||||
assert 'userB@10.0.0.1' in n.jumphost
|
||||
# Inner proxy should be escaped
|
||||
assert 'ProxyCommand=\\"' in n.jumphost or 'ProxyCommand=\\\\' in n.jumphost
|
||||
|
||||
def test_three_hop_chain(self, tmp_config_dir):
|
||||
"""Three-hop SSH chain: dest -> A -> B -> C."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"hopC": {
|
||||
"host": "10.0.0.3", "protocol": "ssh", "port": "",
|
||||
"user": "uc", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"hopB": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "",
|
||||
"user": "ub", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopC", "type": "connection"
|
||||
},
|
||||
"hopA": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "",
|
||||
"user": "ua", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopB", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", jumphost="hopA", config=config)
|
||||
# All three hosts should appear in the command
|
||||
assert 'uc@10.0.0.3' in n.jumphost
|
||||
assert 'ub@10.0.0.2' in n.jumphost
|
||||
assert 'ua@10.0.0.1' in n.jumphost
|
||||
|
||||
def test_circular_detection(self, tmp_config_dir):
|
||||
"""Circular jumphost reference raises ValueError."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"hopA": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopB", "type": "connection"
|
||||
},
|
||||
"hopB": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hopA", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
with pytest.raises(ValueError, match="Circular jumphost reference"):
|
||||
node("dest", "10.0.1.1", jumphost="hopA", config=config)
|
||||
|
||||
def test_max_depth(self, tmp_config_dir):
|
||||
"""Chain exceeding 5 hops raises ValueError."""
|
||||
from connpy.core import node
|
||||
nodes = {}
|
||||
# Build chain of 6 hops: hop0 -> hop1 -> ... -> hop5
|
||||
for i in range(6):
|
||||
jh = f"hop{i+1}" if i < 5 else ""
|
||||
nodes[f"hop{i}"] = {
|
||||
"host": f"10.0.0.{i}", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": jh, "type": "connection"
|
||||
}
|
||||
nodes["dest"] = {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "hop0", "type": "connection"
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
with pytest.raises(ValueError, match="maximum depth of 5"):
|
||||
node("dest", "10.0.1.1", jumphost="hop0", config=config)
|
||||
|
||||
def test_kubectl_with_jumphost_error(self, tmp_config_dir):
|
||||
"""kubectl jumphost with its own jumphost raises ValueError."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"sshhost": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"kubejump": {
|
||||
"host": "my-pod", "protocol": "kubectl", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "sshhost", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "kubejump", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
with pytest.raises(ValueError, match="does not support chained jumphosts"):
|
||||
node("dest", "10.0.1.1", jumphost="kubejump", config=config)
|
||||
|
||||
def test_password_chain_order(self, tmp_config_dir):
|
||||
"""Passwords are collected innermost-first: B, A, dest."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"bastionB": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "",
|
||||
"user": "ub", "password": "passB", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"bastionA": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "",
|
||||
"user": "ua", "password": "passA", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "passDest", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", password="passDest",
|
||||
jumphost="bastionA", config=config)
|
||||
# Order: innermost (B) -> outer (A) -> destination
|
||||
assert n.password == ["passB", "passA", "passDest"]
|
||||
|
||||
def test_chain_with_options_and_port(self, tmp_config_dir):
|
||||
"""Options and ports are preserved for each hop in the chain."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"bastionB": {
|
||||
"host": "10.0.0.1", "protocol": "ssh", "port": "2222",
|
||||
"user": "ub", "password": "", "options": "-o StrictHostKeyChecking=no",
|
||||
"logs": "", "tags": "", "jumphost": "", "type": "connection"
|
||||
},
|
||||
"bastionA": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "3333",
|
||||
"user": "ua", "password": "", "options": "-i /tmp/key.pem",
|
||||
"logs": "", "tags": "", "jumphost": "bastionB", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "bastionA", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", jumphost="bastionA", config=config)
|
||||
assert '-p 2222' in n.jumphost
|
||||
assert '-p 3333' in n.jumphost
|
||||
assert 'StrictHostKeyChecking=no' in n.jumphost
|
||||
assert '/tmp/key.pem' in n.jumphost
|
||||
|
||||
def test_chain_with_ssm_inner(self, tmp_config_dir):
|
||||
"""SSH outer jumphost with SSM inner jumphost works."""
|
||||
from connpy.core import node
|
||||
nodes = {
|
||||
"ssm_bastion": {
|
||||
"host": "i-12345", "protocol": "ssm", "port": "",
|
||||
"user": "ec2-user", "password": "", "options": "",
|
||||
"logs": "", "tags": {"region": "us-east-1"}, "jumphost": "", "type": "connection"
|
||||
},
|
||||
"ssh_jump": {
|
||||
"host": "10.0.0.2", "protocol": "ssh", "port": "",
|
||||
"user": "admin", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "ssm_bastion", "type": "connection"
|
||||
},
|
||||
"dest": {
|
||||
"host": "10.0.1.1", "protocol": "ssh", "port": "",
|
||||
"user": "root", "password": "", "options": "",
|
||||
"logs": "", "tags": "", "jumphost": "ssh_jump", "type": "connection"
|
||||
}
|
||||
}
|
||||
config = self._make_config_with_nodes(tmp_config_dir, nodes)
|
||||
n = node("dest", "10.0.1.1", user="root", jumphost="ssh_jump", config=config)
|
||||
assert 'aws ssm start-session' in n.jumphost
|
||||
assert 'admin@10.0.0.2' in n.jumphost
|
||||
assert '--region us-east-1' in n.jumphost
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from connpy.core import node
|
||||
from connpy.cli.shell_handler import ShellHandler
|
||||
from connpy.cli.validators import Validators
|
||||
|
||||
def test_local_protocol_get_cmd_and_connect():
|
||||
"""Test protocol=local in node returns command string and spawns process."""
|
||||
n = node(unique="test_local", host="echo hello", protocol="local")
|
||||
assert n._get_cmd() == "echo hello"
|
||||
|
||||
with patch("pexpect.spawn") as mock_spawn:
|
||||
mock_child = MagicMock()
|
||||
mock_child.child_fd = 10
|
||||
mock_spawn.return_value = mock_child
|
||||
with patch("pexpect.fdpexpect.fdspawn") as mock_fdspawn:
|
||||
res = n._connect()
|
||||
assert res is True
|
||||
mock_spawn.assert_called_once()
|
||||
|
||||
def test_shell_handler_dispatch(tmp_path):
|
||||
"""Test ShellHandler builds transient node and calls interact."""
|
||||
app_mock = MagicMock()
|
||||
app_mock.config.config = {"shell": {"os": "ubuntu", "prompt": r"\$\s*$"}}
|
||||
|
||||
handler = ShellHandler(app_mock)
|
||||
args = MagicMock()
|
||||
args.command_override = None
|
||||
args.capture_file = str(tmp_path / "session.log")
|
||||
args.debug = False
|
||||
|
||||
with patch("connpy.cli.shell_handler.node") as mock_node_cls:
|
||||
mock_node = MagicMock()
|
||||
mock_node_cls.return_value = mock_node
|
||||
handler.dispatch(args)
|
||||
|
||||
mock_node_cls.assert_called_once()
|
||||
kwargs = mock_node_cls.call_args.kwargs
|
||||
assert kwargs["protocol"] == "local"
|
||||
assert kwargs["unique"] == "local-shell"
|
||||
assert mock_node.interact.called
|
||||
|
||||
def test_validator_excludes_local_protocol():
|
||||
"""Ensure protocol_validation does NOT accept 'local' for inventory forms."""
|
||||
validators = Validators(MagicMock())
|
||||
with pytest.raises(Exception):
|
||||
validators.protocol_validation({}, "local")
|
||||
|
||||
def test_is_child_connpy_active_non_local_protocol():
|
||||
"""Ensure non-local protocols (e.g. ssh) never check for sub-child processes."""
|
||||
n = node(unique="ssh_node", host="10.0.0.1", protocol="ssh")
|
||||
with patch("os.tcgetpgrp") as mock_tcgetpgrp:
|
||||
assert n._is_child_connpy_active(10) is False
|
||||
mock_tcgetpgrp.assert_not_called()
|
||||
|
||||
def test_is_child_connpy_active_local_protocol():
|
||||
"""Test detection of child connpy process when protocol=local."""
|
||||
n = node(unique="local_node", host="/bin/bash", protocol="local")
|
||||
|
||||
# Case 1: child is connpy
|
||||
with patch("os.tcgetpgrp", return_value=1234), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"python3\x00/usr/local/bin/connpy\x00connect\x00r1")))):
|
||||
assert n._is_child_connpy_active(10) is True
|
||||
|
||||
# Case 2: child is regular bash
|
||||
with patch("os.tcgetpgrp", return_value=1234), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/bin/bash\x00")))):
|
||||
assert n._is_child_connpy_active(10) is False
|
||||
|
||||
# Case 3: child is conn entry point (e.g. /home/fluzzi32/.local/bin/conn xr)
|
||||
with patch("os.tcgetpgrp", return_value=1234), \
|
||||
patch("os.path.exists", return_value=True), \
|
||||
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/usr/bin/python3\x00/home/fluzzi32/.local/bin/conn\x00xr")))):
|
||||
assert n._is_child_connpy_active(10) is True
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import os
|
||||
import datetime
|
||||
import hashlib
|
||||
import pytest
|
||||
import yaml
|
||||
from connpy.services.user_service import UserService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_config_dir(tmp_path):
|
||||
"""Creates a temporary config directory for testing."""
|
||||
config_dir = tmp_path / "conn_config"
|
||||
config_dir.mkdir()
|
||||
return config_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_service(test_config_dir):
|
||||
"""Initializes UserService pointing to a temporary directory."""
|
||||
return UserService(str(test_config_dir))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_token(user_service):
|
||||
"""Creates a user and returns (user_service, username, token_result)."""
|
||||
username = "tokenuser"
|
||||
user_service.create_user(username, "password123")
|
||||
result = user_service.create_api_token(username, "Test Token")
|
||||
return user_service, username, result
|
||||
|
||||
|
||||
class TestApiTokenCreation:
|
||||
def test_create_api_token_returns_raw_token(self, user_service):
|
||||
"""Verifies that create_api_token returns a raw token with the correct prefix."""
|
||||
user_service.create_user("alice", "pass")
|
||||
result = user_service.create_api_token("alice", "CI Pipeline")
|
||||
|
||||
assert "raw_token" in result
|
||||
assert result["raw_token"].startswith("cnp_pat_")
|
||||
assert len(result["raw_token"]) > 16
|
||||
assert "token_id" in result
|
||||
assert result["token_id"].startswith("tok_")
|
||||
assert result["name"] == "CI Pipeline"
|
||||
|
||||
def test_create_api_token_stores_hash_not_plaintext(self, user_service):
|
||||
"""Ensures only the SHA-256 hash is persisted, never the raw token."""
|
||||
user_service.create_user("bob", "pass")
|
||||
result = user_service.create_api_token("bob", "My App")
|
||||
|
||||
registry = user_service._load_registry()
|
||||
tokens = registry["users"]["bob"]["api_tokens"]
|
||||
assert len(tokens) == 1
|
||||
|
||||
token_meta = list(tokens.values())[0]
|
||||
expected_hash = hashlib.sha256(result["raw_token"].encode("utf-8")).hexdigest()
|
||||
assert token_meta["token_hash"] == expected_hash
|
||||
# Raw token must NOT be stored
|
||||
assert result["raw_token"] not in str(token_meta)
|
||||
|
||||
def test_create_api_token_with_expiration(self, user_service):
|
||||
"""Verifies that expires_at is set correctly when expires_in_days is provided."""
|
||||
user_service.create_user("charlie", "pass")
|
||||
user_service.create_api_token("charlie", "Temp Token", expires_in_days=30)
|
||||
|
||||
registry = user_service._load_registry()
|
||||
token_meta = list(registry["users"]["charlie"]["api_tokens"].values())[0]
|
||||
assert token_meta["expires_at"] is not None
|
||||
|
||||
exp_dt = datetime.datetime.fromisoformat(token_meta["expires_at"])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
delta = exp_dt - now
|
||||
assert 29 <= delta.days <= 30
|
||||
|
||||
def test_create_api_token_permanent_by_default(self, user_service):
|
||||
"""Verifies that expires_at is None when no expiration is specified."""
|
||||
user_service.create_user("dave", "pass")
|
||||
user_service.create_api_token("dave", "Permanent Token")
|
||||
|
||||
registry = user_service._load_registry()
|
||||
token_meta = list(registry["users"]["dave"]["api_tokens"].values())[0]
|
||||
assert token_meta["expires_at"] is None
|
||||
|
||||
def test_create_api_token_nonexistent_user(self, user_service):
|
||||
"""Ensures creating a token for a non-existent user raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
user_service.create_api_token("ghost", "Token")
|
||||
|
||||
def test_create_api_token_empty_name(self, user_service):
|
||||
"""Ensures empty token names are rejected."""
|
||||
user_service.create_user("eve", "pass")
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
user_service.create_api_token("eve", "")
|
||||
|
||||
def test_create_multiple_tokens(self, user_service):
|
||||
"""Verifies a user can have multiple tokens."""
|
||||
user_service.create_user("frank", "pass")
|
||||
t1 = user_service.create_api_token("frank", "Token 1")
|
||||
t2 = user_service.create_api_token("frank", "Token 2")
|
||||
|
||||
assert t1["token_id"] != t2["token_id"]
|
||||
assert t1["raw_token"] != t2["raw_token"]
|
||||
|
||||
tokens = user_service.list_api_tokens("frank")
|
||||
assert len(tokens) == 2
|
||||
|
||||
|
||||
class TestApiTokenVerification:
|
||||
def test_verify_valid_token(self, user_with_token):
|
||||
"""Verifies that a valid raw token authenticates correctly."""
|
||||
svc, username, result = user_with_token
|
||||
verified = svc.verify_api_token(result["raw_token"])
|
||||
assert verified == username
|
||||
|
||||
def test_verify_invalid_token(self, user_service):
|
||||
"""Verifies that a random/invalid token returns None."""
|
||||
user_service.create_user("alice", "pass")
|
||||
assert user_service.verify_api_token("cnp_pat_invalid_token_here") is None
|
||||
|
||||
def test_verify_expired_token(self, user_service):
|
||||
"""Verifies that an expired token returns None."""
|
||||
user_service.create_user("alice", "pass")
|
||||
result = user_service.create_api_token("alice", "Expiring", expires_in_days=1)
|
||||
|
||||
# Manually set expires_at to the past
|
||||
registry = user_service._load_registry()
|
||||
token_meta = list(registry["users"]["alice"]["api_tokens"].values())[0]
|
||||
token_meta["expires_at"] = (
|
||||
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
|
||||
).isoformat()
|
||||
user_service._save_registry(registry)
|
||||
# Invalidate cache so verify_api_token re-reads
|
||||
user_service._token_index = {}
|
||||
|
||||
assert user_service.verify_api_token(result["raw_token"]) is None
|
||||
|
||||
def test_verify_updates_last_used_at(self, user_with_token):
|
||||
"""Verifies that last_used_at is updated upon successful verification."""
|
||||
svc, username, result = user_with_token
|
||||
|
||||
# Initially last_used_at should be None
|
||||
registry = svc._load_registry()
|
||||
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
|
||||
assert token_meta["last_used_at"] is None
|
||||
|
||||
# Verify the token
|
||||
svc.verify_api_token(result["raw_token"])
|
||||
|
||||
# Now last_used_at should be set
|
||||
registry = svc._load_registry()
|
||||
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
|
||||
assert token_meta["last_used_at"] is not None
|
||||
|
||||
|
||||
class TestApiTokenListing:
|
||||
def test_list_tokens_returns_metadata(self, user_with_token):
|
||||
"""Verifies list returns metadata without sensitive data."""
|
||||
svc, username, result = user_with_token
|
||||
tokens = svc.list_api_tokens(username)
|
||||
|
||||
assert len(tokens) == 1
|
||||
t = tokens[0]
|
||||
assert t["token_id"] == result["token_id"]
|
||||
assert t["name"] == "Test Token"
|
||||
assert t["token_prefix"].startswith("cnp_pat_")
|
||||
assert "created_at" in t
|
||||
# Must NOT expose token_hash or raw_token
|
||||
assert "token_hash" not in t
|
||||
assert "raw_token" not in t
|
||||
|
||||
def test_list_tokens_empty(self, user_service):
|
||||
"""Verifies listing tokens for a user with none returns empty list."""
|
||||
user_service.create_user("alice", "pass")
|
||||
assert user_service.list_api_tokens("alice") == []
|
||||
|
||||
def test_list_tokens_nonexistent_user(self, user_service):
|
||||
"""Ensures listing tokens for a non-existent user raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
user_service.list_api_tokens("ghost")
|
||||
|
||||
|
||||
class TestApiTokenRevocation:
|
||||
def test_revoke_token(self, user_with_token):
|
||||
"""Verifies that a revoked token is immediately invalid."""
|
||||
svc, username, result = user_with_token
|
||||
|
||||
# Token works before revocation
|
||||
assert svc.verify_api_token(result["raw_token"]) == username
|
||||
|
||||
# Revoke
|
||||
removed = svc.revoke_api_token(username, result["token_id"])
|
||||
assert removed is True
|
||||
|
||||
# Token must fail after revocation
|
||||
assert svc.verify_api_token(result["raw_token"]) is None
|
||||
|
||||
# List should be empty
|
||||
assert svc.list_api_tokens(username) == []
|
||||
|
||||
def test_revoke_nonexistent_token(self, user_service):
|
||||
"""Verifies revoking a non-existent token returns False."""
|
||||
user_service.create_user("alice", "pass")
|
||||
assert user_service.revoke_api_token("alice", "tok_nonexistent") is False
|
||||
|
||||
def test_revoke_nonexistent_user(self, user_service):
|
||||
"""Ensures revoking a token for a non-existent user raises ValueError."""
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
user_service.revoke_api_token("ghost", "tok_abc")
|
||||
|
||||
|
||||
class TestJwtUnchanged:
|
||||
def test_jwt_still_works(self, user_service):
|
||||
"""Confirms that existing JWT session tokens still authenticate correctly."""
|
||||
user_service.create_user("jwtuser", "pass")
|
||||
token = user_service.generate_jwt("jwtuser")
|
||||
verified = user_service.verify_jwt(token)
|
||||
assert verified == "jwtuser"
|
||||
@@ -172,6 +172,8 @@ class RemoteStream:
|
||||
})
|
||||
if getattr(req, "copilot_action", ""):
|
||||
copilot_msg["action"] = req.copilot_action
|
||||
if getattr(req, "copilot_node_info_json", ""):
|
||||
copilot_msg["node_info_json"] = req.copilot_node_info_json
|
||||
|
||||
if copilot_msg:
|
||||
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
|
||||
|
||||
+3190
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,10 @@ el.replaceWith(d);
|
||||
"trusted_commands": self.set_ai_config,
|
||||
"service_mode": self.set_service_mode,
|
||||
"remote_host": self.set_remote_host,
|
||||
"sync_remote": self.set_sync_remote
|
||||
"sync_remote": self.set_sync_remote,
|
||||
"shell_command": self.set_shell_config,
|
||||
"shell_prompt": self.set_shell_config,
|
||||
"shell_os": self.set_shell_config
|
||||
}
|
||||
handler = actions.get(getattr(args, "command", None))
|
||||
if handler:
|
||||
@@ -232,7 +235,23 @@ el.replaceWith(d);
|
||||
return parsed
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")</code></pre>
|
||||
raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")
|
||||
|
||||
def set_shell_config(self, args):
|
||||
key = args.command.replace("shell_", "")
|
||||
val = args.data[0] if isinstance(args.data, list) else args.data
|
||||
try:
|
||||
settings = self.app.services.config_svc.get_settings()
|
||||
shell_cfg = settings.get("shell", {}) if isinstance(settings.get("shell"), dict) else {}
|
||||
if str(val).lower() in ["none", "clear", ""]:
|
||||
if key in shell_cfg:
|
||||
del shell_cfg[key]
|
||||
else:
|
||||
shell_cfg[key] = val
|
||||
self.app.services.config_svc.update_setting("shell", shell_cfg)
|
||||
printer.success("Config saved")
|
||||
except (ConnpyError, InvalidConfigurationError) as e:
|
||||
printer.error(str(e))</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
@@ -263,7 +282,10 @@ el.replaceWith(d);
|
||||
"trusted_commands": self.set_ai_config,
|
||||
"service_mode": self.set_service_mode,
|
||||
"remote_host": self.set_remote_host,
|
||||
"sync_remote": self.set_sync_remote
|
||||
"sync_remote": self.set_sync_remote,
|
||||
"shell_command": self.set_shell_config,
|
||||
"shell_prompt": self.set_shell_config,
|
||||
"shell_os": self.set_shell_config
|
||||
}
|
||||
handler = actions.get(getattr(args, "command", None))
|
||||
if handler:
|
||||
@@ -434,6 +456,32 @@ el.replaceWith(d);
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.config_handler.ConfigHandler.set_shell_config"><code class="name flex">
|
||||
<span>def <span class="ident">set_shell_config</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def set_shell_config(self, args):
|
||||
key = args.command.replace("shell_", "")
|
||||
val = args.data[0] if isinstance(args.data, list) else args.data
|
||||
try:
|
||||
settings = self.app.services.config_svc.get_settings()
|
||||
shell_cfg = settings.get("shell", {}) if isinstance(settings.get("shell"), dict) else {}
|
||||
if str(val).lower() in ["none", "clear", ""]:
|
||||
if key in shell_cfg:
|
||||
del shell_cfg[key]
|
||||
else:
|
||||
shell_cfg[key] = val
|
||||
self.app.services.config_svc.update_setting("shell", shell_cfg)
|
||||
printer.success("Config saved")
|
||||
except (ConnpyError, InvalidConfigurationError) as e:
|
||||
printer.error(str(e))</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.config_handler.ConfigHandler.set_sync_remote"><code class="name flex">
|
||||
<span>def <span class="ident">set_sync_remote</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
@@ -538,6 +586,7 @@ el.replaceWith(d);
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_idletime" href="#connpy.cli.config_handler.ConfigHandler.set_idletime">set_idletime</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_remote_host" href="#connpy.cli.config_handler.ConfigHandler.set_remote_host">set_remote_host</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_service_mode" href="#connpy.cli.config_handler.ConfigHandler.set_service_mode">set_service_mode</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_shell_config" href="#connpy.cli.config_handler.ConfigHandler.set_shell_config">set_shell_config</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_sync_remote" href="#connpy.cli.config_handler.ConfigHandler.set_sync_remote">set_sync_remote</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_theme" href="#connpy.cli.config_handler.ConfigHandler.set_theme">set_theme</a></code></li>
|
||||
<li><code><a title="connpy.cli.config_handler.ConfigHandler.show_completion" href="#connpy.cli.config_handler.ConfigHandler.show_completion">show_completion</a></code></li>
|
||||
|
||||
@@ -61,6 +61,7 @@ el.replaceWith(d);
|
||||
self.validators = Validators(app)
|
||||
|
||||
def questions_edit(self):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?"))
|
||||
questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?"))
|
||||
@@ -74,6 +75,7 @@ el.replaceWith(d);
|
||||
return inquirer.prompt(questions)
|
||||
|
||||
def questions_nodes(self, unique, uniques=None, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.nodes.get_node_details(unique)
|
||||
if "tags" not in defaults:
|
||||
@@ -151,6 +153,7 @@ el.replaceWith(d);
|
||||
return result
|
||||
|
||||
def questions_profiles(self, unique, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
|
||||
if "tags" not in defaults:
|
||||
@@ -216,6 +219,7 @@ el.replaceWith(d);
|
||||
return result
|
||||
|
||||
def questions_bulk(self, nodes="", hosts=""):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation))
|
||||
questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation))
|
||||
@@ -253,6 +257,7 @@ el.replaceWith(d);
|
||||
|
||||
def mcp_wizard(self, mcp_servers):
|
||||
"""Interactive wizard to manage MCP servers."""
|
||||
import inquirer
|
||||
from .helpers import theme
|
||||
|
||||
while True:
|
||||
@@ -345,6 +350,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def mcp_wizard(self, mcp_servers):
|
||||
"""Interactive wizard to manage MCP servers."""
|
||||
import inquirer
|
||||
from .helpers import theme
|
||||
|
||||
while True:
|
||||
@@ -435,6 +441,7 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def questions_bulk(self, nodes="", hosts=""):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation))
|
||||
questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation))
|
||||
@@ -481,6 +488,7 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def questions_edit(self):
|
||||
import inquirer
|
||||
questions = []
|
||||
questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?"))
|
||||
questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?"))
|
||||
@@ -504,6 +512,7 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def questions_nodes(self, unique, uniques=None, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.nodes.get_node_details(unique)
|
||||
if "tags" not in defaults:
|
||||
@@ -591,6 +600,7 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def questions_profiles(self, unique, edit=None):
|
||||
import inquirer
|
||||
try:
|
||||
defaults = self.app.services.profiles.get_profile(unique, resolve=False)
|
||||
if "tags" not in defaults:
|
||||
|
||||
@@ -68,6 +68,7 @@ el.replaceWith(d);
|
||||
else:
|
||||
return answer[0]
|
||||
else:
|
||||
import inquirer
|
||||
questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)]
|
||||
answer = inquirer.prompt(questions, theme=theme)
|
||||
if answer == None:
|
||||
@@ -125,6 +126,26 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def get_theme():
|
||||
"""Returns a fresh instance of the theme with current colors."""
|
||||
from inquirer.themes import Default, term
|
||||
|
||||
class ConnpyTheme(Default):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
from ..printer import _global_active_styles
|
||||
# Use user_prompt as primary accent, fallback to info/cyan
|
||||
accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan"))
|
||||
accent_color = hex_to_blessed(accent)
|
||||
|
||||
self.Question.mark_color = accent_color
|
||||
self.List.selection_color = accent_color
|
||||
self.List.selection_cursor = ">"
|
||||
except:
|
||||
# Absolute fallback to standard cyan
|
||||
self.Question.mark_color = term.cyan
|
||||
self.List.selection_color = term.bold_cyan
|
||||
self.List.selection_cursor = ">"
|
||||
|
||||
return ConnpyTheme()</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Returns a fresh instance of the theme with current colors.</p></div>
|
||||
@@ -139,6 +160,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def hex_to_blessed(hex_str):
|
||||
"""Convert hex color string to blessed/ansi format."""
|
||||
from inquirer.themes import term
|
||||
if not hex_str or not isinstance(hex_str, str):
|
||||
return term.normal
|
||||
|
||||
@@ -242,39 +264,6 @@ el.replaceWith(d);
|
||||
<section>
|
||||
<h2 class="section-title" id="header-classes">Classes</h2>
|
||||
<dl>
|
||||
<dt id="connpy.cli.helpers.ConnpyTheme"><code class="flex name class">
|
||||
<span>class <span class="ident">ConnpyTheme</span></span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">class ConnpyTheme(Default):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
try:
|
||||
from ..printer import _global_active_styles
|
||||
# Use user_prompt as primary accent, fallback to info/cyan
|
||||
accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan"))
|
||||
accent_color = hex_to_blessed(accent)
|
||||
|
||||
self.Question.mark_color = accent_color
|
||||
self.List.selection_color = accent_color
|
||||
self.List.selection_cursor = ">"
|
||||
except:
|
||||
# Absolute fallback to standard cyan
|
||||
self.Question.mark_color = term.cyan
|
||||
self.List.selection_color = term.bold_cyan
|
||||
self.List.selection_cursor = ">"</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Ancestors</h3>
|
||||
<ul class="hlist">
|
||||
<li>inquirer.themes.Default</li>
|
||||
<li>inquirer.themes.Theme</li>
|
||||
</ul>
|
||||
</dd>
|
||||
<dt id="connpy.cli.helpers.ThemeProxy"><code class="flex name class">
|
||||
<span>class <span class="ident">ThemeProxy</span></span>
|
||||
</code></dt>
|
||||
@@ -322,9 +311,6 @@ el.replaceWith(d);
|
||||
<li><h3><a href="#header-classes">Classes</a></h3>
|
||||
<ul>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.helpers.ConnpyTheme" href="#connpy.cli.helpers.ConnpyTheme">ConnpyTheme</a></code></h4>
|
||||
</li>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.helpers.ThemeProxy" href="#connpy.cli.helpers.ThemeProxy">ThemeProxy</a></code></h4>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -58,12 +58,24 @@ el.replaceWith(d);
|
||||
<pre><code class="python">class ImportExportHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def dispatch_import(self, args):
|
||||
file_path = args.data[0]
|
||||
try:
|
||||
printer.warning("This could overwrite your current configuration!")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["import"]:
|
||||
@@ -135,6 +147,24 @@ el.replaceWith(d);
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Instance variables</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.import_export_handler.ImportExportHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.import_export_handler.ImportExportHandler.bulk"><code class="name flex">
|
||||
@@ -228,6 +258,7 @@ el.replaceWith(d);
|
||||
file_path = args.data[0]
|
||||
try:
|
||||
printer.warning("This could overwrite your current configuration!")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["import"]:
|
||||
@@ -264,6 +295,7 @@ el.replaceWith(d);
|
||||
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.bulk" href="#connpy.cli.import_export_handler.ImportExportHandler.bulk">bulk</a></code></li>
|
||||
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_export" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_export">dispatch_export</a></code></li>
|
||||
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_import" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_import">dispatch_import</a></code></li>
|
||||
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.forms" href="#connpy.cli.import_export_handler.ImportExportHandler.forms">forms</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -92,6 +92,10 @@ el.replaceWith(d);
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt><code class="name"><a title="connpy.cli.shell_handler" href="shell_handler.html">connpy.cli.shell_handler</a></code></dt>
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt>
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
@@ -146,6 +150,7 @@ el.replaceWith(d);
|
||||
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.shell_handler" href="shell_handler.html">connpy.cli.shell_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
|
||||
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
|
||||
|
||||
@@ -70,6 +70,14 @@ el.replaceWith(d);
|
||||
sys.exit(1)
|
||||
|
||||
def login(self, args):
|
||||
# Handle token management actions first
|
||||
if getattr(args, "create_token", None):
|
||||
return self.create_token(args)
|
||||
if getattr(args, "list_tokens", False):
|
||||
return self.list_tokens(args)
|
||||
if getattr(args, "revoke_token", None):
|
||||
return self.revoke_token(args)
|
||||
|
||||
if getattr(args, "status", False):
|
||||
return self.show_status()
|
||||
|
||||
@@ -191,11 +199,137 @@ el.replaceWith(d);
|
||||
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
|
||||
printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to check local session status: {e}")</code></pre>
|
||||
printer.error(f"Failed to check local session status: {e}")
|
||||
|
||||
def _get_auth_service(self):
|
||||
"""Gets an authenticated auth service stub, reusing existing or creating one."""
|
||||
auth_service = getattr(self.app.services, "auth", None)
|
||||
if not auth_service:
|
||||
import grpc
|
||||
from ..grpc_layer.stubs import AuthStub
|
||||
remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
|
||||
if not remote_host:
|
||||
printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
# Load existing session token for authentication
|
||||
token_path = os.path.join(self.app.config.defaultdir, ".token")
|
||||
if not os.path.exists(token_path):
|
||||
printer.error("No active session. Please log in first using 'connpy login'.")
|
||||
sys.exit(1)
|
||||
with open(token_path, "r") as f:
|
||||
session_token = f.read().strip()
|
||||
|
||||
from ..grpc_layer.stubs import AuthClientInterceptor
|
||||
interceptor = AuthClientInterceptor(lambda: session_token)
|
||||
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
|
||||
auth_service = AuthStub(channel, remote_host=remote_host)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to connect to remote server: {e}")
|
||||
sys.exit(1)
|
||||
return auth_service
|
||||
|
||||
def create_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
name = args.create_token
|
||||
expires_days = getattr(args, "expires_days", 0) or 0
|
||||
|
||||
try:
|
||||
result = auth_service.create_api_token(name, expires_in_days=expires_days)
|
||||
printer.success(f"API token '{name}' created successfully.")
|
||||
printer.warning("⚠ Copy this token now. It will NOT be shown again:")
|
||||
printer.data("Token", result["raw_token"])
|
||||
printer.info(f"Token ID: {result['token_id']}")
|
||||
if expires_days > 0:
|
||||
printer.info(f"Expires in: {expires_days} days")
|
||||
else:
|
||||
printer.info("Expires: Never (permanent)")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def list_tokens(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
|
||||
try:
|
||||
tokens = auth_service.list_api_tokens()
|
||||
if not tokens:
|
||||
printer.info("No API tokens found.")
|
||||
return
|
||||
|
||||
import yaml
|
||||
# Clean up empty strings from protobuf defaults
|
||||
cleaned = []
|
||||
for t in tokens:
|
||||
cleaned.append({
|
||||
"token_id": t["token_id"],
|
||||
"name": t["name"],
|
||||
"prefix": t["token_prefix"],
|
||||
"created": t["created_at"] or "N/A",
|
||||
"last_used": t["last_used_at"] or "Never",
|
||||
"expires": t["expires_at"] or "Never",
|
||||
})
|
||||
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
|
||||
printer.data("API Tokens", yaml_str)
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def revoke_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
token_id = args.revoke_token
|
||||
|
||||
try:
|
||||
auth_service.revoke_api_token(token_id)
|
||||
printer.success(f"Token '{token_id}' revoked successfully.")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.create_token"><code class="name flex">
|
||||
<span>def <span class="ident">create_token</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def create_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
name = args.create_token
|
||||
expires_days = getattr(args, "expires_days", 0) or 0
|
||||
|
||||
try:
|
||||
result = auth_service.create_api_token(name, expires_in_days=expires_days)
|
||||
printer.success(f"API token '{name}' created successfully.")
|
||||
printer.warning("⚠ Copy this token now. It will NOT be shown again:")
|
||||
printer.data("Token", result["raw_token"])
|
||||
printer.info(f"Token ID: {result['token_id']}")
|
||||
if expires_days > 0:
|
||||
printer.info(f"Expires in: {expires_days} days")
|
||||
else:
|
||||
printer.info("Expires: Never (permanent)")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to create token: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.dispatch"><code class="name flex">
|
||||
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
@@ -216,6 +350,46 @@ el.replaceWith(d);
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.list_tokens"><code class="name flex">
|
||||
<span>def <span class="ident">list_tokens</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def list_tokens(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
|
||||
try:
|
||||
tokens = auth_service.list_api_tokens()
|
||||
if not tokens:
|
||||
printer.info("No API tokens found.")
|
||||
return
|
||||
|
||||
import yaml
|
||||
# Clean up empty strings from protobuf defaults
|
||||
cleaned = []
|
||||
for t in tokens:
|
||||
cleaned.append({
|
||||
"token_id": t["token_id"],
|
||||
"name": t["name"],
|
||||
"prefix": t["token_prefix"],
|
||||
"created": t["created_at"] or "N/A",
|
||||
"last_used": t["last_used_at"] or "Never",
|
||||
"expires": t["expires_at"] or "Never",
|
||||
})
|
||||
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
|
||||
printer.data("API Tokens", yaml_str)
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to list tokens: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex">
|
||||
<span>def <span class="ident">login</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
@@ -225,6 +399,14 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def login(self, args):
|
||||
# Handle token management actions first
|
||||
if getattr(args, "create_token", None):
|
||||
return self.create_token(args)
|
||||
if getattr(args, "list_tokens", False):
|
||||
return self.list_tokens(args)
|
||||
if getattr(args, "revoke_token", None):
|
||||
return self.revoke_token(args)
|
||||
|
||||
if getattr(args, "status", False):
|
||||
return self.show_status()
|
||||
|
||||
@@ -312,6 +494,30 @@ el.replaceWith(d);
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.revoke_token"><code class="name flex">
|
||||
<span>def <span class="ident">revoke_token</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def revoke_token(self, args):
|
||||
auth_service = self._get_auth_service()
|
||||
token_id = args.revoke_token
|
||||
|
||||
try:
|
||||
auth_service.revoke_api_token(token_id)
|
||||
printer.success(f"Token '{token_id}' revoked successfully.")
|
||||
except ConnpyError as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
printer.error(f"Failed to revoke token: {e}")
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.cli.login_handler.LoginHandler.show_status"><code class="name flex">
|
||||
<span>def <span class="ident">show_status</span></span>(<span>self)</span>
|
||||
</code></dt>
|
||||
@@ -389,10 +595,13 @@ el.replaceWith(d);
|
||||
<ul>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4>
|
||||
<ul class="">
|
||||
<ul class="two-column">
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.create_token" href="#connpy.cli.login_handler.LoginHandler.create_token">create_token</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.dispatch" href="#connpy.cli.login_handler.LoginHandler.dispatch">dispatch</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.list_tokens" href="#connpy.cli.login_handler.LoginHandler.list_tokens">list_tokens</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.login" href="#connpy.cli.login_handler.LoginHandler.login">login</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.logout" href="#connpy.cli.login_handler.LoginHandler.logout">logout</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.revoke_token" href="#connpy.cli.login_handler.LoginHandler.revoke_token">revoke_token</a></code></li>
|
||||
<li><code><a title="connpy.cli.login_handler.LoginHandler.show_status" href="#connpy.cli.login_handler.LoginHandler.show_status">show_status</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -58,7 +58,18 @@ el.replaceWith(d);
|
||||
<pre><code class="python">class NodeHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def _filter_exact_match(self, matches, query):
|
||||
if not query or len(matches) <= 1:
|
||||
@@ -122,7 +133,7 @@ el.replaceWith(d);
|
||||
debug=args.debug,
|
||||
logger=self.app._service_logger
|
||||
)
|
||||
except ConnpyError as e:
|
||||
except (ConnpyError, ValueError) as e:
|
||||
printer.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -146,6 +157,7 @@ el.replaceWith(d);
|
||||
sys.exit(2)
|
||||
|
||||
printer.info(f"Removing: {matches}")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
@@ -306,6 +318,24 @@ el.replaceWith(d);
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Instance variables</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.node_handler.NodeHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.node_handler.NodeHandler.add"><code class="name flex">
|
||||
@@ -400,7 +430,7 @@ el.replaceWith(d);
|
||||
debug=args.debug,
|
||||
logger=self.app._service_logger
|
||||
)
|
||||
except ConnpyError as e:
|
||||
except (ConnpyError, ValueError) as e:
|
||||
printer.error(str(e))
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
@@ -434,6 +464,7 @@ el.replaceWith(d);
|
||||
sys.exit(2)
|
||||
|
||||
printer.info(f"Removing: {matches}")
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
@@ -630,6 +661,7 @@ el.replaceWith(d);
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.connect" href="#connpy.cli.node_handler.NodeHandler.connect">connect</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.delete" href="#connpy.cli.node_handler.NodeHandler.delete">delete</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.dispatch" href="#connpy.cli.node_handler.NodeHandler.dispatch">dispatch</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.forms" href="#connpy.cli.node_handler.NodeHandler.forms">forms</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.modify" href="#connpy.cli.node_handler.NodeHandler.modify">modify</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.show" href="#connpy.cli.node_handler.NodeHandler.show">show</a></code></li>
|
||||
<li><code><a title="connpy.cli.node_handler.NodeHandler.version" href="#connpy.cli.node_handler.NodeHandler.version">version</a></code></li>
|
||||
|
||||
@@ -167,6 +167,8 @@ el.replaceWith(d);
|
||||
|
||||
# Populate local plugins
|
||||
for name, details in local_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -175,11 +177,14 @@ el.replaceWith(d);
|
||||
state = "Shadowed (Override by Remote)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Local")
|
||||
origin = details.get("origin", "Local").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
# Populate remote plugins
|
||||
if self.app.services.mode == "remote":
|
||||
for name, details in remote_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -190,7 +195,8 @@ el.replaceWith(d);
|
||||
state = "Shadowed (Override by Local)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Remote")
|
||||
origin = details.get("origin", "Remote").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
if not local_plugins and not remote_plugins:
|
||||
printer.console.print(" No plugins found.")
|
||||
@@ -320,6 +326,8 @@ el.replaceWith(d);
|
||||
|
||||
# Populate local plugins
|
||||
for name, details in local_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -328,11 +336,14 @@ el.replaceWith(d);
|
||||
state = "Shadowed (Override by Remote)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Local")
|
||||
origin = details.get("origin", "Local").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
# Populate remote plugins
|
||||
if self.app.services.mode == "remote":
|
||||
for name, details in remote_plugins.items():
|
||||
if details.get("origin") == "core":
|
||||
continue
|
||||
state = "Disabled" if not details.get("enabled", True) else "Active"
|
||||
color = "red" if state == "Disabled" else "green"
|
||||
|
||||
@@ -343,7 +354,8 @@ el.replaceWith(d);
|
||||
state = "Shadowed (Override by Local)"
|
||||
color = "yellow"
|
||||
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", "Remote")
|
||||
origin = details.get("origin", "Remote").capitalize()
|
||||
table.add_row(name, f"[{color}]{state}[/{color}]", origin)
|
||||
|
||||
if not local_plugins and not remote_plugins:
|
||||
printer.console.print(" No plugins found.")
|
||||
|
||||
@@ -58,7 +58,18 @@ el.replaceWith(d);
|
||||
<pre><code class="python">class ProfileHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
self._forms = None
|
||||
|
||||
@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms
|
||||
|
||||
@forms.setter
|
||||
def forms(self, value):
|
||||
self._forms = value
|
||||
|
||||
def dispatch(self, args):
|
||||
if not self.app.case:
|
||||
@@ -78,6 +89,7 @@ el.replaceWith(d);
|
||||
printer.error("Can't delete default profile")
|
||||
sys.exit(6)
|
||||
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
@@ -145,6 +157,24 @@ el.replaceWith(d);
|
||||
sys.exit(1)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Instance variables</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.profile_handler.ProfileHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def forms(self):
|
||||
if self._forms is None:
|
||||
from .forms import Forms
|
||||
self._forms = Forms(self.app)
|
||||
return self._forms</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.profile_handler.ProfileHandler.add"><code class="name flex">
|
||||
@@ -194,6 +224,7 @@ el.replaceWith(d);
|
||||
printer.error("Can't delete default profile")
|
||||
sys.exit(6)
|
||||
|
||||
import inquirer
|
||||
question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")]
|
||||
confirm = inquirer.prompt(question)
|
||||
if confirm == None or not confirm["delete"]:
|
||||
@@ -300,10 +331,11 @@ el.replaceWith(d);
|
||||
<ul>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.profile_handler.ProfileHandler" href="#connpy.cli.profile_handler.ProfileHandler">ProfileHandler</a></code></h4>
|
||||
<ul class="">
|
||||
<ul class="two-column">
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.add" href="#connpy.cli.profile_handler.ProfileHandler.add">add</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.delete" href="#connpy.cli.profile_handler.ProfileHandler.delete">delete</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.dispatch" href="#connpy.cli.profile_handler.ProfileHandler.dispatch">dispatch</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.forms" href="#connpy.cli.profile_handler.ProfileHandler.forms">forms</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.modify" href="#connpy.cli.profile_handler.ProfileHandler.modify">modify</a></code></li>
|
||||
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.show" href="#connpy.cli.profile_handler.ProfileHandler.show">show</a></code></li>
|
||||
</ul>
|
||||
|
||||
@@ -427,6 +427,35 @@ el.replaceWith(d);
|
||||
from rich.rule import Rule
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
# Helper to get active theme color
|
||||
def get_theme_color(style_name, fallback="white"):
|
||||
try:
|
||||
style = printer.connpy_theme.styles.get(style_name)
|
||||
if style and style.color:
|
||||
if style.color.is_default: return fallback
|
||||
return style.color.triplet.hex if style.color.triplet else style.color.name
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
user_color = get_theme_color("user_prompt", "#00afd7")
|
||||
|
||||
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('enter')
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@kb.add('c-j')
|
||||
@kb.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
session = PromptSession(key_bindings=kb)
|
||||
|
||||
dest_file = args.data[0]
|
||||
if os.path.exists(dest_file):
|
||||
@@ -438,12 +467,15 @@ el.replaceWith(d);
|
||||
# Consistent layout opening matching global AI (engineer style)
|
||||
from rich.markdown import Markdown
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n"))
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||
user_prompt = session.prompt(
|
||||
HTML(f'<style fg="{user_color}">User (Enter to submit, Ctrl+Enter for newline):</style>\n'),
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Operation cancelled by user.")
|
||||
@@ -547,6 +579,35 @@ el.replaceWith(d);
|
||||
from rich.rule import Rule
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
# Helper to get active theme color
|
||||
def get_theme_color(style_name, fallback="white"):
|
||||
try:
|
||||
style = printer.connpy_theme.styles.get(style_name)
|
||||
if style and style.color:
|
||||
if style.color.is_default: return fallback
|
||||
return style.color.triplet.hex if style.color.triplet else style.color.name
|
||||
except: pass
|
||||
return fallback
|
||||
|
||||
user_color = get_theme_color("user_prompt", "#00afd7")
|
||||
|
||||
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('enter')
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@kb.add('c-j')
|
||||
@kb.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
session = PromptSession(key_bindings=kb)
|
||||
|
||||
dest_file = args.data[0]
|
||||
if os.path.exists(dest_file):
|
||||
@@ -558,12 +619,15 @@ el.replaceWith(d);
|
||||
# Consistent layout opening matching global AI (engineer style)
|
||||
from rich.markdown import Markdown
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n"))
|
||||
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n"))
|
||||
printer.console.print(Rule(style="engineer"))
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]")
|
||||
user_prompt = session.prompt(
|
||||
HTML(f'<style fg="{user_color}">User (Enter to submit, Ctrl+Enter for newline):</style>\n'),
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
printer.console.print()
|
||||
printer.warning("Operation cancelled by user.")
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.shell_handler API documentation</title>
|
||||
<meta name="description" content="">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
|
||||
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
|
||||
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
|
||||
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
|
||||
<script>window.addEventListener('DOMContentLoaded', () => {
|
||||
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
|
||||
hljs.highlightAll();
|
||||
/* Collapse source docstrings */
|
||||
setTimeout(() => {
|
||||
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
|
||||
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
|
||||
.forEach(el => {
|
||||
let d = document.createElement('details');
|
||||
d.classList.add('hljs-string');
|
||||
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
|
||||
el.replaceWith(d);
|
||||
});
|
||||
}, 100);
|
||||
})</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article id="content">
|
||||
<header>
|
||||
<h1 class="title">Module <code>connpy.cli.shell_handler</code></h1>
|
||||
</header>
|
||||
<section id="section-intro">
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="section-title" id="header-classes">Classes</h2>
|
||||
<dl>
|
||||
<dt id="connpy.cli.shell_handler.ShellHandler"><code class="flex name class">
|
||||
<span>class <span class="ident">ShellHandler</span></span>
|
||||
<span>(</span><span>app)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">class ShellHandler:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def dispatch(self, args):
|
||||
shell_config = self.app.config.config.get("shell", {}) if hasattr(self.app.config, "config") else {}
|
||||
command = getattr(args, 'command_override', None) or shell_config.get("command") or os.environ.get("SHELL", "/bin/bash")
|
||||
|
||||
try:
|
||||
exe = shlex.split(command)[0]
|
||||
except Exception:
|
||||
exe = command
|
||||
|
||||
if not shutil.which(exe):
|
||||
printer.error(f"Shell command executable not found: {exe}")
|
||||
sys.exit(1)
|
||||
|
||||
node_info = self._build_local_identity(shell_config)
|
||||
|
||||
tags = {
|
||||
"os": node_info["os"],
|
||||
"prompt": node_info["prompt"]
|
||||
}
|
||||
|
||||
n = node(
|
||||
unique=node_info["name"],
|
||||
host=command,
|
||||
protocol="local",
|
||||
config=self.app.config,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
capture_file = getattr(args, 'capture_file', None)
|
||||
if capture_file:
|
||||
n.logs = capture_file
|
||||
elif shell_config.get("logging"):
|
||||
n.logs = shell_config.get("log_path", os.path.expanduser("~/.config/conn/shell_logs/session.log"))
|
||||
|
||||
n.interact(debug=getattr(args, 'debug', False))
|
||||
|
||||
def _build_local_identity(self, shell_config):
|
||||
return {
|
||||
"name": "local-shell",
|
||||
"host": socket.gethostname(),
|
||||
"os": shell_config.get("os", "linux"),
|
||||
"prompt": shell_config.get("prompt", r'\$\s*$|#\s*$')
|
||||
}</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
<dt id="connpy.cli.shell_handler.ShellHandler.dispatch"><code class="name flex">
|
||||
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def dispatch(self, args):
|
||||
shell_config = self.app.config.config.get("shell", {}) if hasattr(self.app.config, "config") else {}
|
||||
command = getattr(args, 'command_override', None) or shell_config.get("command") or os.environ.get("SHELL", "/bin/bash")
|
||||
|
||||
try:
|
||||
exe = shlex.split(command)[0]
|
||||
except Exception:
|
||||
exe = command
|
||||
|
||||
if not shutil.which(exe):
|
||||
printer.error(f"Shell command executable not found: {exe}")
|
||||
sys.exit(1)
|
||||
|
||||
node_info = self._build_local_identity(shell_config)
|
||||
|
||||
tags = {
|
||||
"os": node_info["os"],
|
||||
"prompt": node_info["prompt"]
|
||||
}
|
||||
|
||||
n = node(
|
||||
unique=node_info["name"],
|
||||
host=command,
|
||||
protocol="local",
|
||||
config=self.app.config,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
capture_file = getattr(args, 'capture_file', None)
|
||||
if capture_file:
|
||||
n.logs = capture_file
|
||||
elif shell_config.get("logging"):
|
||||
n.logs = shell_config.get("log_path", os.path.expanduser("~/.config/conn/shell_logs/session.log"))
|
||||
|
||||
n.interact(debug=getattr(args, 'debug', False))</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</article>
|
||||
<nav id="sidebar">
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
<ul id="index">
|
||||
<li><h3>Super-module</h3>
|
||||
<ul>
|
||||
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><h3><a href="#header-classes">Classes</a></h3>
|
||||
<ul>
|
||||
<li>
|
||||
<h4><code><a title="connpy.cli.shell_handler.ShellHandler" href="#connpy.cli.shell_handler.ShellHandler">ShellHandler</a></code></h4>
|
||||
<ul class="">
|
||||
<li><code><a title="connpy.cli.shell_handler.ShellHandler.dispatch" href="#connpy.cli.shell_handler.ShellHandler.dispatch">dispatch</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</main>
|
||||
<footer id="footer">
|
||||
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -92,6 +92,7 @@ el.replaceWith(d);
|
||||
sys.exit(1)
|
||||
|
||||
def add_provider(self, args):
|
||||
import inquirer
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.setdefault("providers", {})
|
||||
@@ -165,6 +166,7 @@ el.replaceWith(d);
|
||||
sys.exit(1)
|
||||
|
||||
# Confirm delete
|
||||
import inquirer
|
||||
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers or not answers["confirm"]:
|
||||
@@ -225,6 +227,7 @@ el.replaceWith(d);
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def add_provider(self, args):
|
||||
import inquirer
|
||||
provider = args.provider
|
||||
sso = self.app.config.config.get("sso", {})
|
||||
providers = sso.setdefault("providers", {})
|
||||
@@ -308,6 +311,7 @@ el.replaceWith(d);
|
||||
sys.exit(1)
|
||||
|
||||
# Confirm delete
|
||||
import inquirer
|
||||
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
|
||||
answers = inquirer.prompt(questions)
|
||||
if not answers or not answers["confirm"]:
|
||||
|
||||
@@ -57,25 +57,39 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">class CopilotInterface:
|
||||
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
|
||||
from ..services.ai_service import AIService
|
||||
self.config = config
|
||||
self.history = history or InMemoryHistory()
|
||||
self.pt_input = pt_input
|
||||
self.pt_output = pt_output
|
||||
self.rich_file = rich_file
|
||||
self.ai_service = AIService(config)
|
||||
self.session_state = session_state if session_state is not None else {
|
||||
'persona': 'engineer',
|
||||
'trust_mode': False,
|
||||
'memories': [],
|
||||
'os': None,
|
||||
'prompt': None
|
||||
}
|
||||
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
|
||||
|
||||
self.session_state = session_state if session_state is not None else {}
|
||||
self.session_state.setdefault('persona', 'engineer')
|
||||
self.session_state.setdefault('trust_mode', False)
|
||||
self.session_state.setdefault('memories', [])
|
||||
self.session_state.setdefault('os', None)
|
||||
self.session_state.setdefault('prompt', None)
|
||||
self.session_state.setdefault('context_mode', self.mode_range)
|
||||
self.session_state.setdefault('context_cmd', 1)
|
||||
self.session_state.setdefault('context_lines', 50)
|
||||
self.session_state.setdefault('last_total_cmds', None)
|
||||
self.session_state.setdefault('last_total_lines', None)
|
||||
|
||||
if rich_file:
|
||||
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
|
||||
else:
|
||||
self.console = Console(theme=connpy_theme)
|
||||
|
||||
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
|
||||
def _sync_session_context(self, state: dict):
|
||||
"""Persist current context mode, depth, total commands, and total lines into session_state."""
|
||||
self.session_state['context_mode'] = state['context_mode']
|
||||
self.session_state['context_cmd'] = state['context_cmd']
|
||||
self.session_state['context_lines'] = state['context_lines']
|
||||
self.session_state['last_total_cmds'] = state['total_cmds']
|
||||
self.session_state['last_total_lines'] = state['total_lines']
|
||||
|
||||
def _get_theme_color(self, style_name: str, fallback: str = "white") -> str:
|
||||
"""Extract Hex or ANSI color name from the active rich theme."""
|
||||
@@ -109,16 +123,52 @@ el.replaceWith(d);
|
||||
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
|
||||
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
|
||||
|
||||
total_cmds = len(blocks)
|
||||
total_lines = len(buffer.split('\n'))
|
||||
|
||||
saved_mode = self.session_state.get('context_mode', self.mode_range)
|
||||
saved_cmd = self.session_state.get('context_cmd', 1)
|
||||
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
|
||||
last_total_cmds = self.session_state.get('last_total_cmds', None)
|
||||
last_total_lines = self.session_state.get('last_total_lines', None)
|
||||
|
||||
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
|
||||
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
|
||||
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
|
||||
|
||||
if is_range or is_single:
|
||||
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
|
||||
new_cmds = total_cmds - last_total_cmds
|
||||
initial_cmd = saved_cmd + new_cmds
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
elif is_lines:
|
||||
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
||||
new_lines = total_lines - last_total_lines
|
||||
initial_lines = saved_lines + new_lines
|
||||
else:
|
||||
initial_lines = saved_lines
|
||||
initial_cmd = saved_cmd
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
|
||||
state = {
|
||||
'context_cmd': 1,
|
||||
'total_cmds': len(blocks),
|
||||
'total_lines': len(buffer.split('\n')),
|
||||
'context_lines': min(50, len(buffer.split('\n'))),
|
||||
'context_mode': self.mode_range,
|
||||
'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
|
||||
'total_cmds': total_cmds,
|
||||
'total_lines': total_lines,
|
||||
'context_lines': min(max(1, initial_lines), max(1, total_lines)),
|
||||
'context_mode': saved_mode,
|
||||
'cancelled': False,
|
||||
'toolbar_msg': '',
|
||||
'msg_expiry': 0
|
||||
}
|
||||
self.session_state['context_mode'] = saved_mode
|
||||
self.session_state['context_cmd'] = max(1, initial_cmd)
|
||||
self.session_state['context_lines'] = max(1, initial_lines)
|
||||
self.session_state['last_total_cmds'] = total_cmds
|
||||
self.session_state['last_total_lines'] = total_lines
|
||||
|
||||
# 1. Visual Separation
|
||||
self.console.print("") # Real line break
|
||||
@@ -137,6 +187,7 @@ el.replaceWith(d);
|
||||
state['context_lines'] = min(state['context_lines'] + 50, state['total_lines'])
|
||||
else:
|
||||
state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds'])
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('c-down')
|
||||
def _(event):
|
||||
@@ -144,6 +195,7 @@ el.replaceWith(d);
|
||||
state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines']))
|
||||
else:
|
||||
state['context_cmd'] = max(state['context_cmd'] - 1, 1)
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('tab')
|
||||
def _(event):
|
||||
@@ -153,6 +205,7 @@ el.replaceWith(d);
|
||||
buf.complete_next()
|
||||
else:
|
||||
state['context_mode'] = (state['context_mode'] + 1) % 3
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('escape', eager=True)
|
||||
@bindings.add('c-c')
|
||||
@@ -160,6 +213,16 @@ el.replaceWith(d);
|
||||
state['cancelled'] = True
|
||||
event.app.exit(result='')
|
||||
|
||||
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
|
||||
@bindings.add('enter', filter=~has_completions)
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@bindings.add('c-j')
|
||||
@bindings.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
def get_active_buffer():
|
||||
if state['context_mode'] == self.mode_lines:
|
||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||
@@ -305,7 +368,8 @@ el.replaceWith(d);
|
||||
question = await session.prompt_async(
|
||||
get_prompt_text,
|
||||
key_bindings=bindings,
|
||||
bottom_toolbar=get_toolbar
|
||||
bottom_toolbar=get_toolbar,
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
state['cancelled'] = True
|
||||
@@ -318,13 +382,14 @@ el.replaceWith(d);
|
||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||
|
||||
if directive["action"] == "state_update":
|
||||
state['toolbar_msg'] = directive['message']
|
||||
msg = directive['message']
|
||||
state['toolbar_msg'] = msg
|
||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||
|
||||
async def delayed_refresh():
|
||||
await asyncio.sleep(3.1)
|
||||
# Only invalidate if the message hasn't been replaced by a newer one
|
||||
if state.get('toolbar_msg') == directive['message']:
|
||||
if state.get('toolbar_msg') == msg:
|
||||
state['toolbar_msg'] = '' # Explicitly clear
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app
|
||||
@@ -563,16 +628,52 @@ el.replaceWith(d);
|
||||
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
|
||||
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
|
||||
|
||||
total_cmds = len(blocks)
|
||||
total_lines = len(buffer.split('\n'))
|
||||
|
||||
saved_mode = self.session_state.get('context_mode', self.mode_range)
|
||||
saved_cmd = self.session_state.get('context_cmd', 1)
|
||||
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
|
||||
last_total_cmds = self.session_state.get('last_total_cmds', None)
|
||||
last_total_lines = self.session_state.get('last_total_lines', None)
|
||||
|
||||
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
|
||||
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
|
||||
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
|
||||
|
||||
if is_range or is_single:
|
||||
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
|
||||
new_cmds = total_cmds - last_total_cmds
|
||||
initial_cmd = saved_cmd + new_cmds
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
elif is_lines:
|
||||
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
||||
new_lines = total_lines - last_total_lines
|
||||
initial_lines = saved_lines + new_lines
|
||||
else:
|
||||
initial_lines = saved_lines
|
||||
initial_cmd = saved_cmd
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
|
||||
state = {
|
||||
'context_cmd': 1,
|
||||
'total_cmds': len(blocks),
|
||||
'total_lines': len(buffer.split('\n')),
|
||||
'context_lines': min(50, len(buffer.split('\n'))),
|
||||
'context_mode': self.mode_range,
|
||||
'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
|
||||
'total_cmds': total_cmds,
|
||||
'total_lines': total_lines,
|
||||
'context_lines': min(max(1, initial_lines), max(1, total_lines)),
|
||||
'context_mode': saved_mode,
|
||||
'cancelled': False,
|
||||
'toolbar_msg': '',
|
||||
'msg_expiry': 0
|
||||
}
|
||||
self.session_state['context_mode'] = saved_mode
|
||||
self.session_state['context_cmd'] = max(1, initial_cmd)
|
||||
self.session_state['context_lines'] = max(1, initial_lines)
|
||||
self.session_state['last_total_cmds'] = total_cmds
|
||||
self.session_state['last_total_lines'] = total_lines
|
||||
|
||||
# 1. Visual Separation
|
||||
self.console.print("") # Real line break
|
||||
@@ -591,6 +692,7 @@ el.replaceWith(d);
|
||||
state['context_lines'] = min(state['context_lines'] + 50, state['total_lines'])
|
||||
else:
|
||||
state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds'])
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('c-down')
|
||||
def _(event):
|
||||
@@ -598,6 +700,7 @@ el.replaceWith(d);
|
||||
state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines']))
|
||||
else:
|
||||
state['context_cmd'] = max(state['context_cmd'] - 1, 1)
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('tab')
|
||||
def _(event):
|
||||
@@ -607,6 +710,7 @@ el.replaceWith(d);
|
||||
buf.complete_next()
|
||||
else:
|
||||
state['context_mode'] = (state['context_mode'] + 1) % 3
|
||||
self._sync_session_context(state)
|
||||
event.app.invalidate()
|
||||
@bindings.add('escape', eager=True)
|
||||
@bindings.add('c-c')
|
||||
@@ -614,6 +718,16 @@ el.replaceWith(d);
|
||||
state['cancelled'] = True
|
||||
event.app.exit(result='')
|
||||
|
||||
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
|
||||
@bindings.add('enter', filter=~has_completions)
|
||||
def _(event):
|
||||
event.current_buffer.validate_and_handle()
|
||||
|
||||
@bindings.add('c-j')
|
||||
@bindings.add('escape', 'enter')
|
||||
def _(event):
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
def get_active_buffer():
|
||||
if state['context_mode'] == self.mode_lines:
|
||||
return '\n'.join(buffer.split('\n')[-state['context_lines']:])
|
||||
@@ -759,7 +873,8 @@ el.replaceWith(d);
|
||||
question = await session.prompt_async(
|
||||
get_prompt_text,
|
||||
key_bindings=bindings,
|
||||
bottom_toolbar=get_toolbar
|
||||
bottom_toolbar=get_toolbar,
|
||||
multiline=True
|
||||
)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
state['cancelled'] = True
|
||||
@@ -772,13 +887,14 @@ el.replaceWith(d);
|
||||
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
||||
|
||||
if directive["action"] == "state_update":
|
||||
state['toolbar_msg'] = directive['message']
|
||||
msg = directive['message']
|
||||
state['toolbar_msg'] = msg
|
||||
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
|
||||
|
||||
async def delayed_refresh():
|
||||
await asyncio.sleep(3.1)
|
||||
# Only invalidate if the message hasn't been replaced by a newer one
|
||||
if state.get('toolbar_msg') == directive['message']:
|
||||
if state.get('toolbar_msg') == msg:
|
||||
state['toolbar_msg'] = '' # Explicitly clear
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app
|
||||
|
||||
@@ -61,61 +61,61 @@ el.replaceWith(d);
|
||||
|
||||
def host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
return True
|
||||
|
||||
def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535 or leave empty")
|
||||
return True
|
||||
|
||||
def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile or leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
return True
|
||||
|
||||
def pass_validation(self, answers, current, regex = "(^@.+$)"):
|
||||
profiles = current.split(",")
|
||||
for i in profiles:
|
||||
if not re.match(regex, i) or i[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i))
|
||||
_raise_val_err("Profile {} don't exist".format(i))
|
||||
return True
|
||||
|
||||
def tags_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
isdict = False
|
||||
try:
|
||||
@@ -123,7 +123,7 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True
|
||||
|
||||
def profile_tags_validation(self, answers, current):
|
||||
@@ -134,36 +134,36 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True
|
||||
|
||||
def jumphost_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True
|
||||
|
||||
def profile_jumphost_validation(self, answers, current):
|
||||
if current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True
|
||||
|
||||
def default_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_folder_validation(self, answers, current):
|
||||
@@ -176,19 +176,19 @@ el.replaceWith(d);
|
||||
|
||||
matches = list(filter(lambda k: k == candidate, self.app.folders))
|
||||
if current != "" and len(matches) == 0:
|
||||
raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current))
|
||||
_raise_val_err("Location {} don't exist".format(current))
|
||||
return True
|
||||
|
||||
def bulk_host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
hosts = current.split(",")
|
||||
nodes = answers["ids"].split(",")
|
||||
if len(hosts) > 1 and len(hosts) != len(nodes):
|
||||
raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list")
|
||||
_raise_val_err("Hosts list should be the same length of nodes list")
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -212,7 +212,7 @@ el.replaceWith(d);
|
||||
|
||||
matches = list(filter(lambda k: k == candidate, self.app.folders))
|
||||
if current != "" and len(matches) == 0:
|
||||
raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current))
|
||||
_raise_val_err("Location {} don't exist".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -227,14 +227,14 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def bulk_host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
hosts = current.split(",")
|
||||
nodes = answers["ids"].split(",")
|
||||
if len(hosts) > 1 and len(hosts) != len(nodes):
|
||||
raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list")
|
||||
_raise_val_err("Hosts list should be the same length of nodes list")
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -249,10 +249,10 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -268,7 +268,7 @@ el.replaceWith(d);
|
||||
<pre><code class="python">def default_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -283,10 +283,10 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def host_validation(self, answers, current, regex = "^.+$"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Host cannot be empty")
|
||||
_raise_val_err("Host cannot be empty")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -302,10 +302,10 @@ el.replaceWith(d);
|
||||
<pre><code class="python">def jumphost_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -322,7 +322,7 @@ el.replaceWith(d);
|
||||
profiles = current.split(",")
|
||||
for i in profiles:
|
||||
if not re.match(regex, i) or i[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i))
|
||||
_raise_val_err("Profile {} don't exist".format(i))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -337,16 +337,16 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile or leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -362,7 +362,7 @@ el.replaceWith(d);
|
||||
<pre><code class="python">def profile_jumphost_validation(self, answers, current):
|
||||
if current != "":
|
||||
if current not in self.app.nodes_list:
|
||||
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current))
|
||||
_raise_val_err("Node {} don't exist.".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -377,13 +377,13 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535, @profile o leave empty")
|
||||
try:
|
||||
port = int(current)
|
||||
except ValueError:
|
||||
port = 0
|
||||
if current != "" and not 1 <= int(port) <= 65535:
|
||||
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty")
|
||||
_raise_val_err("Pick a port between 1-65535 or leave empty")
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -398,7 +398,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -419,7 +419,7 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -434,10 +434,10 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"):
|
||||
if not re.match(regex, current):
|
||||
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
_raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -453,7 +453,7 @@ el.replaceWith(d);
|
||||
<pre><code class="python">def tags_validation(self, answers, current):
|
||||
if current.startswith("@"):
|
||||
if current[1:] not in self.app.profiles:
|
||||
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current))
|
||||
_raise_val_err("Profile {} don't exist".format(current))
|
||||
elif current != "":
|
||||
isdict = False
|
||||
try:
|
||||
@@ -461,7 +461,7 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
pass
|
||||
if not isinstance (isdict, dict):
|
||||
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current))
|
||||
_raise_val_err("Tags should be a python dictionary.".format(current))
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
|
||||
@@ -153,6 +153,21 @@ el.replaceWith(d);
|
||||
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
|
||||
),
|
||||
'create_api_token': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.create_api_token,
|
||||
request_deserializer=connpy__pb2.CreateApiTokenRequest.FromString,
|
||||
response_serializer=connpy__pb2.CreateApiTokenResponse.SerializeToString,
|
||||
),
|
||||
'list_api_tokens': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.list_api_tokens,
|
||||
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
response_serializer=connpy__pb2.ListApiTokensResponse.SerializeToString,
|
||||
),
|
||||
'revoke_api_token': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.revoke_api_token,
|
||||
request_deserializer=connpy__pb2.RevokeApiTokenRequest.FromString,
|
||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'connpy.AuthService', rpc_method_handlers)
|
||||
@@ -1779,6 +1794,87 @@ def predict_execution_results(request,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def create_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/create_api_token',
|
||||
connpy__pb2.CreateApiTokenRequest.SerializeToString,
|
||||
connpy__pb2.CreateApiTokenResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def list_api_tokens(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/list_api_tokens',
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
connpy__pb2.ListApiTokensResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)
|
||||
|
||||
@staticmethod
|
||||
def revoke_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/revoke_api_token',
|
||||
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
@@ -1821,6 +1917,43 @@ def change_password(request,
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">create_api_token</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@staticmethod
|
||||
def create_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/create_api_token',
|
||||
connpy__pb2.CreateApiTokenRequest.SerializeToString,
|
||||
connpy__pb2.CreateApiTokenResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex">
|
||||
<span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||
</code></dt>
|
||||
@@ -1858,6 +1991,43 @@ def get_sso_providers(request,
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens"><code class="name flex">
|
||||
<span>def <span class="ident">list_api_tokens</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@staticmethod
|
||||
def list_api_tokens(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/list_api_tokens',
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
connpy__pb2.ListApiTokensResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
|
||||
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||
</code></dt>
|
||||
@@ -1932,6 +2102,43 @@ def login_sso(request,
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">revoke_api_token</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@staticmethod
|
||||
def revoke_api_token(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
'/connpy.AuthService/revoke_api_token',
|
||||
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
|
||||
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
_registered_method=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
|
||||
@@ -1964,6 +2171,24 @@ def login_sso(request,
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def get_sso_providers(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def create_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def list_api_tokens(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def revoke_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
@@ -1992,6 +2217,22 @@ def login_sso(request,
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">create_api_token</span></span>(<span>self, request, context)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def create_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex">
|
||||
<span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span>
|
||||
</code></dt>
|
||||
@@ -2008,6 +2249,22 @@ def login_sso(request,
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens"><code class="name flex">
|
||||
<span>def <span class="ident">list_api_tokens</span></span>(<span>self, request, context)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def list_api_tokens(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
|
||||
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
|
||||
</code></dt>
|
||||
@@ -2040,6 +2297,22 @@ def login_sso(request,
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, request, context)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def revoke_api_token(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
|
||||
@@ -2079,6 +2352,21 @@ def login_sso(request,
|
||||
'/connpy.AuthService/get_sso_providers',
|
||||
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.create_api_token = channel.unary_unary(
|
||||
'/connpy.AuthService/create_api_token',
|
||||
request_serializer=connpy__pb2.CreateApiTokenRequest.SerializeToString,
|
||||
response_deserializer=connpy__pb2.CreateApiTokenResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.list_api_tokens = channel.unary_unary(
|
||||
'/connpy.AuthService/list_api_tokens',
|
||||
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
||||
response_deserializer=connpy__pb2.ListApiTokensResponse.FromString,
|
||||
_registered_method=True)
|
||||
self.revoke_api_token = channel.unary_unary(
|
||||
'/connpy.AuthService/revoke_api_token',
|
||||
request_serializer=connpy__pb2.RevokeApiTokenRequest.SerializeToString,
|
||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||
_registered_method=True)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
||||
@@ -6510,20 +6798,26 @@ def stop_api(request,
|
||||
</li>
|
||||
<li>
|
||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
|
||||
<ul class="">
|
||||
<ul class="two-column">
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token">create_api_token</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens">list_api_tokens</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token">revoke_api_token</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
|
||||
<ul class="">
|
||||
<ul class="two-column">
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token">create_api_token</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens">list_api_tokens</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token">revoke_api_token</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -514,6 +514,8 @@ def service(self):
|
||||
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
|
||||
|
||||
username = self.registry.user_service.verify_jwt(token)
|
||||
if not username and token.startswith("cnp_pat_"):
|
||||
username = self.registry.user_service.verify_api_token(token)
|
||||
if not username:
|
||||
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
|
||||
|
||||
@@ -628,6 +630,8 @@ def service(self):
|
||||
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
|
||||
|
||||
username = self.registry.user_service.verify_jwt(token)
|
||||
if not username and token.startswith("cnp_pat_"):
|
||||
username = self.registry.user_service.verify_api_token(token)
|
||||
if not username:
|
||||
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
|
||||
|
||||
@@ -834,6 +838,58 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
except ValueError as e:
|
||||
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
|
||||
|
||||
return Empty()
|
||||
|
||||
@handle_errors
|
||||
def create_api_token(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
try:
|
||||
expires_in_days = request.expires_in_days if request.expires_in_days > 0 else None
|
||||
result = self.registry.user_service.create_api_token(
|
||||
username, request.name, expires_in_days=expires_in_days
|
||||
)
|
||||
except ValueError as e:
|
||||
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
|
||||
|
||||
return connpy_pb2.CreateApiTokenResponse(
|
||||
token_id=result["token_id"],
|
||||
raw_token=result["raw_token"],
|
||||
name=result["name"],
|
||||
)
|
||||
|
||||
@handle_errors
|
||||
def list_api_tokens(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
tokens = self.registry.user_service.list_api_tokens(username)
|
||||
token_infos = [
|
||||
connpy_pb2.ApiTokenInfo(
|
||||
token_id=t["token_id"],
|
||||
name=t.get("name") or "",
|
||||
token_prefix=t.get("token_prefix") or "",
|
||||
created_at=t.get("created_at") or "",
|
||||
last_used_at=t.get("last_used_at") or "",
|
||||
expires_at=t.get("expires_at") or "",
|
||||
)
|
||||
for t in tokens
|
||||
]
|
||||
return connpy_pb2.ListApiTokensResponse(tokens=token_infos)
|
||||
|
||||
@handle_errors
|
||||
def revoke_api_token(self, request, context):
|
||||
username = _current_user.get()
|
||||
if not username:
|
||||
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
|
||||
|
||||
removed = self.registry.user_service.revoke_api_token(username, request.token_id)
|
||||
if not removed:
|
||||
context.abort(grpc.StatusCode.NOT_FOUND, f"Token '{request.token_id}' not found")
|
||||
|
||||
return Empty()</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||
@@ -846,9 +902,12 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
|
||||
<ul class="hlist">
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token">create_api_token</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens">list_api_tokens</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token">revoke_api_token</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1496,10 +1555,59 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
raw_bytes = str(raw_bytes).encode()
|
||||
|
||||
from connpy.utils import log_cleaner
|
||||
last_line = log_cleaner(raw_bytes.decode(errors='replace')).split('\n')[-1].strip()
|
||||
cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace'))
|
||||
last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)"
|
||||
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
|
||||
node_info["context_blocks"] = blocks
|
||||
|
||||
total_cmds = len(blocks)
|
||||
total_lines = len(cleaned_buffer.split('\n'))
|
||||
|
||||
if not hasattr(remote_stream, 'copilot_state') or remote_stream.copilot_state is None:
|
||||
remote_stream.copilot_state = {}
|
||||
session_state = remote_stream.copilot_state
|
||||
|
||||
if isinstance(node_info, dict):
|
||||
for k, v in node_info.items():
|
||||
if k in ('context_mode', 'context_cmd', 'context_lines', 'persona', 'trust', 'os', 'prompt'):
|
||||
session_state[k] = v
|
||||
|
||||
saved_mode = session_state.get('context_mode', 0)
|
||||
saved_cmd = session_state.get('context_cmd', 1)
|
||||
saved_lines = session_state.get('context_lines', 50)
|
||||
last_total_cmds = session_state.get('last_total_cmds', None)
|
||||
last_total_lines = session_state.get('last_total_lines', None)
|
||||
|
||||
is_range = saved_mode in (0, 'RANGE', 'range')
|
||||
is_lines = saved_mode in (2, 'LINES', 'lines')
|
||||
is_single = saved_mode in (1, 'SINGLE', 'single')
|
||||
|
||||
if is_range or is_single:
|
||||
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
|
||||
new_cmds = total_cmds - last_total_cmds
|
||||
initial_cmd = saved_cmd + new_cmds
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
elif is_lines:
|
||||
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
||||
new_lines = total_lines - last_total_lines
|
||||
initial_lines = saved_lines + new_lines
|
||||
else:
|
||||
initial_lines = saved_lines
|
||||
initial_cmd = saved_cmd
|
||||
else:
|
||||
initial_cmd = saved_cmd
|
||||
initial_lines = saved_lines
|
||||
|
||||
session_state['context_cmd'] = max(1, initial_cmd)
|
||||
session_state['context_lines'] = max(1, initial_lines)
|
||||
session_state['last_total_cmds'] = total_cmds
|
||||
session_state['last_total_lines'] = total_lines
|
||||
|
||||
node_info.update(session_state)
|
||||
node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds))
|
||||
node_info['context_lines'] = min(session_state['context_lines'], max(1, total_lines))
|
||||
node_info_json = json.dumps(node_info)
|
||||
|
||||
# Convert buffer to string if it's bytes for the preview
|
||||
@@ -1544,6 +1652,17 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
if req_session_id and req_session_id != copilot_session_id:
|
||||
continue # Ignore stale request from a previous session
|
||||
|
||||
merged_node_info_str = req_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
node_info.update(merged_node_info)
|
||||
# Sync context state from frontend into session_state for persistence
|
||||
for k in ('context_mode', 'context_cmd', 'context_lines'):
|
||||
if k in merged_node_info:
|
||||
session_state[k] = merged_node_info[k]
|
||||
except: pass
|
||||
|
||||
if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") in ("cancel", "web_cancel"):
|
||||
if req_data.get("action") == "web_cancel":
|
||||
os.write(child_fd, b'\x05')
|
||||
@@ -1552,13 +1671,6 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
return
|
||||
question = req_data["question"]
|
||||
|
||||
merged_node_info_str = req_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
node_info.update(merged_node_info)
|
||||
except: pass
|
||||
|
||||
context_buffer = req_data.get("context_buffer", "")
|
||||
if context_buffer.startswith('{"context_start_pos"'):
|
||||
try:
|
||||
@@ -1620,6 +1732,15 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
||||
if not action_data: return
|
||||
action = action_data.get("action", "cancel")
|
||||
|
||||
merged_node_info_str = action_data.get("node_info_json", "")
|
||||
if merged_node_info_str:
|
||||
try:
|
||||
merged_node_info = json.loads(merged_node_info_str)
|
||||
for k in ('context_mode', 'context_cmd', 'context_lines'):
|
||||
if k in merged_node_info:
|
||||
session_state[k] = merged_node_info[k]
|
||||
except: pass
|
||||
|
||||
if action == "continue":
|
||||
continue # Loop back for next question
|
||||
|
||||
|
||||
@@ -864,7 +864,37 @@ Call-Future's exception value will be an RpcError.</p></div>
|
||||
@handle_errors
|
||||
def change_password(self, old_password, new_password):
|
||||
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
|
||||
self.stub.change_password(req)</code></pre>
|
||||
self.stub.change_password(req)
|
||||
|
||||
@handle_errors
|
||||
def create_api_token(self, name, expires_in_days=0):
|
||||
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
|
||||
resp = self.stub.create_api_token(req)
|
||||
return {
|
||||
"token_id": resp.token_id,
|
||||
"raw_token": resp.raw_token,
|
||||
"name": resp.name,
|
||||
}
|
||||
|
||||
@handle_errors
|
||||
def list_api_tokens(self):
|
||||
resp = self.stub.list_api_tokens(Empty())
|
||||
return [
|
||||
{
|
||||
"token_id": t.token_id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"created_at": t.created_at,
|
||||
"last_used_at": t.last_used_at,
|
||||
"expires_at": t.expires_at,
|
||||
}
|
||||
for t in resp.tokens
|
||||
]
|
||||
|
||||
@handle_errors
|
||||
def revoke_api_token(self, token_id):
|
||||
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
|
||||
self.stub.revoke_api_token(req)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
@@ -884,6 +914,51 @@ def change_password(self, old_password, new_password):
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.stubs.AuthStub.create_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">create_api_token</span></span>(<span>self, name, expires_in_days=0)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@handle_errors
|
||||
def create_api_token(self, name, expires_in_days=0):
|
||||
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
|
||||
resp = self.stub.create_api_token(req)
|
||||
return {
|
||||
"token_id": resp.token_id,
|
||||
"raw_token": resp.raw_token,
|
||||
"name": resp.name,
|
||||
}</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.stubs.AuthStub.list_api_tokens"><code class="name flex">
|
||||
<span>def <span class="ident">list_api_tokens</span></span>(<span>self)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@handle_errors
|
||||
def list_api_tokens(self):
|
||||
resp = self.stub.list_api_tokens(Empty())
|
||||
return [
|
||||
{
|
||||
"token_id": t.token_id,
|
||||
"name": t.name,
|
||||
"token_prefix": t.token_prefix,
|
||||
"created_at": t.created_at,
|
||||
"last_used_at": t.last_used_at,
|
||||
"expires_at": t.expires_at,
|
||||
}
|
||||
for t in resp.tokens
|
||||
]</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.stubs.AuthStub.login"><code class="name flex">
|
||||
<span>def <span class="ident">login</span></span>(<span>self, username, password)</span>
|
||||
</code></dt>
|
||||
@@ -904,6 +979,21 @@ def login(self, username, password):
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.stubs.AuthStub.revoke_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, token_id)</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@handle_errors
|
||||
def revoke_api_token(self, token_id):
|
||||
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
|
||||
self.stub.revoke_api_token(req)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt id="connpy.grpc_layer.stubs.ConfigStub"><code class="flex name class">
|
||||
@@ -2785,7 +2875,10 @@ def stop_api(self):
|
||||
<h4><code><a title="connpy.grpc_layer.stubs.AuthStub" href="#connpy.grpc_layer.stubs.AuthStub">AuthStub</a></code></h4>
|
||||
<ul class="">
|
||||
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.change_password" href="#connpy.grpc_layer.stubs.AuthStub.change_password">change_password</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.create_api_token" href="#connpy.grpc_layer.stubs.AuthStub.create_api_token">create_api_token</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.list_api_tokens" href="#connpy.grpc_layer.stubs.AuthStub.list_api_tokens">list_api_tokens</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.login" href="#connpy.grpc_layer.stubs.AuthStub.login">login</a></code></li>
|
||||
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.revoke_api_token" href="#connpy.grpc_layer.stubs.AuthStub.revoke_api_token">revoke_api_token</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
+435
-2855
File diff suppressed because it is too large
Load Diff
+1752
-14
File diff suppressed because it is too large
Load Diff
@@ -115,7 +115,7 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
@@ -125,10 +125,10 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
@@ -137,10 +137,10 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
@@ -322,13 +322,13 @@ el.replaceWith(d);
|
||||
is_mock = True
|
||||
def __init__(self, config):
|
||||
from ..core import node, nodes
|
||||
from ..ai import ai
|
||||
from ..connapp import DeferredAIProxy
|
||||
from ..services.provider import ServiceProvider
|
||||
|
||||
self.config = config
|
||||
self.node = node
|
||||
self.nodes = nodes
|
||||
self.ai = ai
|
||||
self.ai = DeferredAIProxy()
|
||||
|
||||
self.services = ServiceProvider(config, mode="local")
|
||||
|
||||
@@ -645,13 +645,13 @@ el.replaceWith(d);
|
||||
is_mock = True
|
||||
def __init__(self, config):
|
||||
from ..core import node, nodes
|
||||
from ..ai import ai
|
||||
from ..connapp import DeferredAIProxy
|
||||
from ..services.provider import ServiceProvider
|
||||
|
||||
self.config = config
|
||||
self.node = node
|
||||
self.nodes = nodes
|
||||
self.ai = ai
|
||||
self.ai = DeferredAIProxy()
|
||||
|
||||
self.services = ServiceProvider(config, mode="local")
|
||||
|
||||
@@ -763,7 +763,7 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "core"}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
@@ -773,10 +773,10 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "shared"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "shared"}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
@@ -785,10 +785,10 @@ el.replaceWith(d);
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path), "origin": "user"}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
all_plugin_info[name] = {"enabled": False, "origin": "user"}
|
||||
|
||||
return all_plugin_info</code></pre>
|
||||
</details>
|
||||
|
||||
@@ -79,6 +79,12 @@ el.replaceWith(d);
|
||||
self.mode = mode
|
||||
self.config = config
|
||||
self.remote_host = remote_host
|
||||
self._system = None
|
||||
self._execution = None
|
||||
self._import_export = None
|
||||
self._sync = None
|
||||
self._users = None
|
||||
self._ai = None
|
||||
|
||||
if mode == "local":
|
||||
self._init_local()
|
||||
@@ -92,35 +98,20 @@ el.replaceWith(d);
|
||||
from .profile_service import ProfileService
|
||||
from .config_service import ConfigService
|
||||
from .plugin_service import PluginService
|
||||
from .ai_service import AIService
|
||||
from .system_service import SystemService
|
||||
from .execution_service import ExecutionService
|
||||
from .import_export_service import ImportExportService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
from .user_service import UserService
|
||||
|
||||
self.nodes = NodeService(self.config)
|
||||
self.profiles = ProfileService(self.config)
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.plugins = PluginService(self.config)
|
||||
self.ai = AIService(self.config)
|
||||
self.system = SystemService(self.config)
|
||||
self.execution = ExecutionService(self.config)
|
||||
self.import_export = ImportExportService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = UserService(self.config.defaultdir)
|
||||
|
||||
def _init_remote(self):
|
||||
# Allow ConfigService to work locally so the user can revert the mode
|
||||
from .config_service import ConfigService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = None
|
||||
|
||||
if not self.remote_host:
|
||||
raise InvalidConfigurationError("Remote host must be specified in remote mode")
|
||||
@@ -134,6 +125,9 @@ el.replaceWith(d);
|
||||
)
|
||||
|
||||
def get_token():
|
||||
env_token = os.environ.get("CONNPY_TOKEN")
|
||||
if env_token:
|
||||
return env_token
|
||||
token_path = os.path.join(self.config.defaultdir, ".token")
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
@@ -159,9 +153,168 @@ el.replaceWith(d);
|
||||
self.system = SystemStub(channel, remote_host=self.remote_host)
|
||||
self.execution = ExecutionStub(channel, remote_host=self.remote_host)
|
||||
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
|
||||
self.auth = AuthStub(channel, remote_host=self.remote_host)</code></pre>
|
||||
self.auth = AuthStub(channel, remote_host=self.remote_host)
|
||||
|
||||
@property
|
||||
def system(self):
|
||||
if self._system is None and self.mode == "local":
|
||||
from .system_service import SystemService
|
||||
self._system = SystemService(self.config)
|
||||
return self._system
|
||||
|
||||
@system.setter
|
||||
def system(self, value):
|
||||
self._system = value
|
||||
|
||||
@property
|
||||
def execution(self):
|
||||
if self._execution is None and self.mode == "local":
|
||||
from .execution_service import ExecutionService
|
||||
self._execution = ExecutionService(self.config)
|
||||
return self._execution
|
||||
|
||||
@execution.setter
|
||||
def execution(self, value):
|
||||
self._execution = value
|
||||
|
||||
@property
|
||||
def import_export(self):
|
||||
if self._import_export is None and self.mode == "local":
|
||||
from .import_export_service import ImportExportService
|
||||
self._import_export = ImportExportService(self.config)
|
||||
return self._import_export
|
||||
|
||||
@import_export.setter
|
||||
def import_export(self, value):
|
||||
self._import_export = value
|
||||
|
||||
@property
|
||||
def sync(self):
|
||||
if self._sync is None:
|
||||
from .sync_service import SyncService
|
||||
self._sync = SyncService(self.config)
|
||||
return self._sync
|
||||
|
||||
@sync.setter
|
||||
def sync(self, value):
|
||||
self._sync = value
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
if self._users is None and self.mode == "local":
|
||||
from .user_service import UserService
|
||||
self._users = UserService(self.config.defaultdir)
|
||||
return self._users
|
||||
|
||||
@users.setter
|
||||
def users(self, value):
|
||||
self._users = value
|
||||
|
||||
@property
|
||||
def ai(self):
|
||||
if self._ai is None and self.mode == "local":
|
||||
from .ai_service import AIService
|
||||
self._ai = AIService(self.config)
|
||||
return self._ai
|
||||
|
||||
@ai.setter
|
||||
def ai(self, value):
|
||||
self._ai = value</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div>
|
||||
<h3>Instance variables</h3>
|
||||
<dl>
|
||||
<dt id="connpy.services.provider.ServiceProvider.ai"><code class="name">prop <span class="ident">ai</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def ai(self):
|
||||
if self._ai is None and self.mode == "local":
|
||||
from .ai_service import AIService
|
||||
self._ai = AIService(self.config)
|
||||
return self._ai</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.provider.ServiceProvider.execution"><code class="name">prop <span class="ident">execution</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def execution(self):
|
||||
if self._execution is None and self.mode == "local":
|
||||
from .execution_service import ExecutionService
|
||||
self._execution = ExecutionService(self.config)
|
||||
return self._execution</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.provider.ServiceProvider.import_export"><code class="name">prop <span class="ident">import_export</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def import_export(self):
|
||||
if self._import_export is None and self.mode == "local":
|
||||
from .import_export_service import ImportExportService
|
||||
self._import_export = ImportExportService(self.config)
|
||||
return self._import_export</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.provider.ServiceProvider.sync"><code class="name">prop <span class="ident">sync</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def sync(self):
|
||||
if self._sync is None:
|
||||
from .sync_service import SyncService
|
||||
self._sync = SyncService(self.config)
|
||||
return self._sync</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.provider.ServiceProvider.system"><code class="name">prop <span class="ident">system</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def system(self):
|
||||
if self._system is None and self.mode == "local":
|
||||
from .system_service import SystemService
|
||||
self._system = SystemService(self.config)
|
||||
return self._system</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.provider.ServiceProvider.users"><code class="name">prop <span class="ident">users</span></code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">@property
|
||||
def users(self):
|
||||
if self._users is None and self.mode == "local":
|
||||
from .user_service import UserService
|
||||
self._users = UserService(self.config.defaultdir)
|
||||
return self._users</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -183,6 +336,14 @@ el.replaceWith(d);
|
||||
</li>
|
||||
<li>
|
||||
<h4><code><a title="connpy.services.provider.ServiceProvider" href="#connpy.services.provider.ServiceProvider">ServiceProvider</a></code></h4>
|
||||
<ul class="two-column">
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.ai" href="#connpy.services.provider.ServiceProvider.ai">ai</a></code></li>
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.execution" href="#connpy.services.provider.ServiceProvider.execution">execution</a></code></li>
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.import_export" href="#connpy.services.provider.ServiceProvider.import_export">import_export</a></code></li>
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.sync" href="#connpy.services.provider.ServiceProvider.sync">sync</a></code></li>
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.system" href="#connpy.services.provider.ServiceProvider.system">system</a></code></li>
|
||||
<li><code><a title="connpy.services.provider.ServiceProvider.users" href="#connpy.services.provider.ServiceProvider.users">users</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -82,6 +82,7 @@ el.replaceWith(d);
|
||||
|
||||
def login(self):
|
||||
"""Authenticate with Google Drive."""
|
||||
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
|
||||
creds = None
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
@@ -119,6 +120,7 @@ el.replaceWith(d);
|
||||
|
||||
def get_credentials(self):
|
||||
"""Get valid credentials, refreshing if necessary."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
else:
|
||||
@@ -136,6 +138,7 @@ el.replaceWith(d);
|
||||
|
||||
def check_login_status(self):
|
||||
"""Check if logged in to Google Drive."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file)
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
@@ -148,6 +151,7 @@ el.replaceWith(d);
|
||||
|
||||
def list_backups(self):
|
||||
"""List files in Google Drive appDataFolder."""
|
||||
_, _, build, _, _, _, _, HttpError = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds:
|
||||
printer.error("Not logged in to Google Drive.")
|
||||
@@ -206,6 +210,7 @@ el.replaceWith(d);
|
||||
|
||||
def upload_file(self, file_path, timestamp):
|
||||
"""Internal method to upload to Drive."""
|
||||
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
|
||||
@@ -231,6 +236,7 @@ el.replaceWith(d);
|
||||
|
||||
def delete_backup(self, file_id):
|
||||
"""Delete a backup from Drive."""
|
||||
_, _, build, _, _, _, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
@@ -264,6 +270,7 @@ el.replaceWith(d);
|
||||
|
||||
def download_file(self, file_id, dest):
|
||||
"""Internal method to download from Drive."""
|
||||
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
@@ -508,6 +515,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def check_login_status(self):
|
||||
"""Check if logged in to Google Drive."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file)
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
@@ -570,6 +578,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def delete_backup(self, file_id):
|
||||
"""Delete a backup from Drive."""
|
||||
_, _, build, _, _, _, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
@@ -592,6 +601,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def download_file(self, file_id, dest):
|
||||
"""Internal method to download from Drive."""
|
||||
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
try:
|
||||
@@ -619,6 +629,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def get_credentials(self):
|
||||
"""Get valid credentials, refreshing if necessary."""
|
||||
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
else:
|
||||
@@ -646,6 +657,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def list_backups(self):
|
||||
"""List files in Google Drive appDataFolder."""
|
||||
_, _, build, _, _, _, _, HttpError = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds:
|
||||
printer.error("Not logged in to Google Drive.")
|
||||
@@ -684,6 +696,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def login(self):
|
||||
"""Authenticate with Google Drive."""
|
||||
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
|
||||
creds = None
|
||||
if os.path.exists(self.token_file):
|
||||
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
|
||||
@@ -890,6 +903,7 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def upload_file(self, file_path, timestamp):
|
||||
"""Internal method to upload to Drive."""
|
||||
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
|
||||
creds = self.get_credentials()
|
||||
if not creds: return False
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ el.replaceWith(d);
|
||||
# Ensure users directory exists
|
||||
os.makedirs(self.users_dir, exist_ok=True)
|
||||
|
||||
# Reverse index cache: token_hash -> (username, token_id)
|
||||
self._token_index: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def _load_registry(self) -> dict:
|
||||
"""Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
|
||||
if not os.path.exists(self.registry_file):
|
||||
@@ -107,6 +110,16 @@ el.replaceWith(d);
|
||||
pass
|
||||
raise e
|
||||
|
||||
def _build_token_index(self, registry: dict) -> dict[str, tuple[str, str]]:
|
||||
"""Builds a reverse index of token_hash -> (username, token_id) for O(1) PAT lookup."""
|
||||
index = {}
|
||||
for username, user_data in registry.get("users", {}).items():
|
||||
for token_id, token_meta in user_data.get("api_tokens", {}).items():
|
||||
token_hash = token_meta.get("token_hash")
|
||||
if token_hash:
|
||||
index[token_hash] = (username, token_id)
|
||||
return index
|
||||
|
||||
def create_user(self, username, password, config_path=None) -> dict:
|
||||
"""Creates a new user with bcrypt-hashed credentials.
|
||||
|
||||
@@ -282,7 +295,129 @@ el.replaceWith(d);
|
||||
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||
return None</code></pre>
|
||||
return None
|
||||
|
||||
# --- Personal Access Token (PAT) Management ---
|
||||
|
||||
def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -> dict:
|
||||
"""Creates a Personal Access Token for the user.
|
||||
|
||||
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Token name cannot be empty")
|
||||
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
user_data = registry["users"][username]
|
||||
if "api_tokens" not in user_data:
|
||||
user_data["api_tokens"] = {}
|
||||
|
||||
# Generate cryptographically secure token with recognizable prefix
|
||||
raw_secret = secrets.token_hex(32)
|
||||
raw_token = f"cnp_pat_{raw_secret}"
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
token_id = f"tok_{secrets.token_hex(4)}"
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
expires_at = None
|
||||
if expires_in_days and expires_in_days > 0:
|
||||
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
|
||||
|
||||
user_data["api_tokens"][token_id] = {
|
||||
"name": name,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": raw_token[:16],
|
||||
"created_at": now.isoformat(),
|
||||
"last_used_at": None,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
return {
|
||||
"token_id": token_id,
|
||||
"raw_token": raw_token,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
def list_api_tokens(self, username: str) -> list[dict]:
|
||||
"""Lists all active API tokens for a user (without sensitive data)."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
return [
|
||||
{
|
||||
"token_id": tid,
|
||||
"name": meta.get("name"),
|
||||
"token_prefix": meta.get("token_prefix"),
|
||||
"created_at": meta.get("created_at"),
|
||||
"last_used_at": meta.get("last_used_at"),
|
||||
"expires_at": meta.get("expires_at"),
|
||||
}
|
||||
for tid, meta in tokens.items()
|
||||
]
|
||||
|
||||
def revoke_api_token(self, username: str, token_id: str) -> bool:
|
||||
"""Revokes (deletes) a specific API token. Returns True if found and removed."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
if token_id not in tokens:
|
||||
return False
|
||||
|
||||
del tokens[token_id]
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return True
|
||||
|
||||
def verify_api_token(self, raw_token: str) -> str | None:
|
||||
"""Validates a PAT by hashing it and looking up the reverse index.
|
||||
|
||||
Returns username if valid and not expired, None otherwise.
|
||||
"""
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
# Rebuild index if empty (cold start or after process restart)
|
||||
if not self._token_index:
|
||||
registry = self._load_registry()
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
match = self._token_index.get(token_hash)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
username, token_id = match
|
||||
|
||||
# Validate token still exists and check expiration
|
||||
registry = self._load_registry()
|
||||
user_data = registry.get("users", {}).get(username, {})
|
||||
token_meta = user_data.get("api_tokens", {}).get(token_id)
|
||||
|
||||
if not token_meta:
|
||||
# Token was revoked between index build and now
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
expires_at = token_meta.get("expires_at")
|
||||
if expires_at:
|
||||
exp_dt = datetime.datetime.fromisoformat(expires_at)
|
||||
if datetime.datetime.now(datetime.timezone.utc) > exp_dt:
|
||||
return None
|
||||
|
||||
# Update last_used_at
|
||||
token_meta["last_used_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
self._save_registry(registry)
|
||||
|
||||
return username</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
<h3>Methods</h3>
|
||||
@@ -356,6 +491,62 @@ el.replaceWith(d);
|
||||
</details>
|
||||
<div class="desc"><p>Verifies old password and updates registry with new hashed password.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.create_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">create_api_token</span></span>(<span>self, username: str, name: str, expires_in_days: int | None = None) ‑> dict</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -> dict:
|
||||
"""Creates a Personal Access Token for the user.
|
||||
|
||||
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Token name cannot be empty")
|
||||
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
user_data = registry["users"][username]
|
||||
if "api_tokens" not in user_data:
|
||||
user_data["api_tokens"] = {}
|
||||
|
||||
# Generate cryptographically secure token with recognizable prefix
|
||||
raw_secret = secrets.token_hex(32)
|
||||
raw_token = f"cnp_pat_{raw_secret}"
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
token_id = f"tok_{secrets.token_hex(4)}"
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
expires_at = None
|
||||
if expires_in_days and expires_in_days > 0:
|
||||
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
|
||||
|
||||
user_data["api_tokens"][token_id] = {
|
||||
"name": name,
|
||||
"token_hash": token_hash,
|
||||
"token_prefix": raw_token[:16],
|
||||
"created_at": now.isoformat(),
|
||||
"last_used_at": None,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
return {
|
||||
"token_id": token_id,
|
||||
"raw_token": raw_token,
|
||||
"name": name,
|
||||
}</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Creates a Personal Access Token for the user.</p>
|
||||
<p>Returns the raw token ONCE. Only the SHA-256 hash is persisted.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.create_user"><code class="name flex">
|
||||
<span>def <span class="ident">create_user</span></span>(<span>self, username, password, config_path=None) ‑> dict</span>
|
||||
</code></dt>
|
||||
@@ -514,6 +705,35 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
||||
</details>
|
||||
<div class="desc"><p>Retrieves raw metadata for a specific user.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.list_api_tokens"><code class="name flex">
|
||||
<span>def <span class="ident">list_api_tokens</span></span>(<span>self, username: str) ‑> list[dict]</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def list_api_tokens(self, username: str) -> list[dict]:
|
||||
"""Lists all active API tokens for a user (without sensitive data)."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
return [
|
||||
{
|
||||
"token_id": tid,
|
||||
"name": meta.get("name"),
|
||||
"token_prefix": meta.get("token_prefix"),
|
||||
"created_at": meta.get("created_at"),
|
||||
"last_used_at": meta.get("last_used_at"),
|
||||
"expires_at": meta.get("expires_at"),
|
||||
}
|
||||
for tid, meta in tokens.items()
|
||||
]</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Lists all active API tokens for a user (without sensitive data).</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.list_users"><code class="name flex">
|
||||
<span>def <span class="ident">list_users</span></span>(<span>self) ‑> list[dict]</span>
|
||||
</code></dt>
|
||||
@@ -536,6 +756,83 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
||||
</details>
|
||||
<div class="desc"><p>Lists all registered users with metadata.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.revoke_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, username: str, token_id: str) ‑> bool</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def revoke_api_token(self, username: str, token_id: str) -> bool:
|
||||
"""Revokes (deletes) a specific API token. Returns True if found and removed."""
|
||||
registry = self._load_registry()
|
||||
if username not in registry["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
tokens = registry["users"][username].get("api_tokens", {})
|
||||
if token_id not in tokens:
|
||||
return False
|
||||
|
||||
del tokens[token_id]
|
||||
self._save_registry(registry)
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return True</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Revokes (deletes) a specific API token. Returns True if found and removed.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.verify_api_token"><code class="name flex">
|
||||
<span>def <span class="ident">verify_api_token</span></span>(<span>self, raw_token: str) ‑> str | None</span>
|
||||
</code></dt>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">def verify_api_token(self, raw_token: str) -> str | None:
|
||||
"""Validates a PAT by hashing it and looking up the reverse index.
|
||||
|
||||
Returns username if valid and not expired, None otherwise.
|
||||
"""
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
# Rebuild index if empty (cold start or after process restart)
|
||||
if not self._token_index:
|
||||
registry = self._load_registry()
|
||||
self._token_index = self._build_token_index(registry)
|
||||
|
||||
match = self._token_index.get(token_hash)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
username, token_id = match
|
||||
|
||||
# Validate token still exists and check expiration
|
||||
registry = self._load_registry()
|
||||
user_data = registry.get("users", {}).get(username, {})
|
||||
token_meta = user_data.get("api_tokens", {}).get(token_id)
|
||||
|
||||
if not token_meta:
|
||||
# Token was revoked between index build and now
|
||||
self._token_index = self._build_token_index(registry)
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
expires_at = token_meta.get("expires_at")
|
||||
if expires_at:
|
||||
exp_dt = datetime.datetime.fromisoformat(expires_at)
|
||||
if datetime.datetime.now(datetime.timezone.utc) > exp_dt:
|
||||
return None
|
||||
|
||||
# Update last_used_at
|
||||
token_meta["last_used_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
self._save_registry(registry)
|
||||
|
||||
return username</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Validates a PAT by hashing it and looking up the reverse index.</p>
|
||||
<p>Returns username if valid and not expired, None otherwise.</p></div>
|
||||
</dd>
|
||||
<dt id="connpy.services.user_service.UserService.verify_jwt"><code class="name flex">
|
||||
<span>def <span class="ident">verify_jwt</span></span>(<span>self, token) ‑> str | None</span>
|
||||
</code></dt>
|
||||
@@ -579,11 +876,15 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
||||
<li><code><a title="connpy.services.user_service.UserService.admin_change_password" href="#connpy.services.user_service.UserService.admin_change_password">admin_change_password</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.authenticate" href="#connpy.services.user_service.UserService.authenticate">authenticate</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.change_password" href="#connpy.services.user_service.UserService.change_password">change_password</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.create_api_token" href="#connpy.services.user_service.UserService.create_api_token">create_api_token</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.create_user" href="#connpy.services.user_service.UserService.create_user">create_user</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.delete_user" href="#connpy.services.user_service.UserService.delete_user">delete_user</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.generate_jwt" href="#connpy.services.user_service.UserService.generate_jwt">generate_jwt</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.get_user" href="#connpy.services.user_service.UserService.get_user">get_user</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.list_api_tokens" href="#connpy.services.user_service.UserService.list_api_tokens">list_api_tokens</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.list_users" href="#connpy.services.user_service.UserService.list_users">list_users</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.revoke_api_token" href="#connpy.services.user_service.UserService.revoke_api_token">revoke_api_token</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.verify_api_token" href="#connpy.services.user_service.UserService.verify_api_token">verify_api_token</a></code></li>
|
||||
<li><code><a title="connpy.services.user_service.UserService.verify_jwt" href="#connpy.services.user_service.UserService.verify_jwt">verify_jwt</a></code></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -376,6 +376,8 @@ Handles terminal raw mode, async I/O, and SIGWINCH signals.</p></div>
|
||||
})
|
||||
if getattr(req, "copilot_action", ""):
|
||||
copilot_msg["action"] = req.copilot_action
|
||||
if getattr(req, "copilot_node_info_json", ""):
|
||||
copilot_msg["node_info_json"] = req.copilot_node_info_json
|
||||
|
||||
if copilot_msg:
|
||||
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
|
||||
@@ -454,6 +456,8 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.</p></di
|
||||
})
|
||||
if getattr(req, "copilot_action", ""):
|
||||
copilot_msg["action"] = req.copilot_action
|
||||
if getattr(req, "copilot_node_info_json", ""):
|
||||
copilot_msg["node_info_json"] = req.copilot_node_info_json
|
||||
|
||||
if copilot_msg:
|
||||
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
|
||||
|
||||
Reference in New Issue
Block a user