nodepick-cli 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. nodepick_cli-0.1.4/CHANGELOG.md +27 -0
  2. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/PKG-INFO +11 -2
  3. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/README.md +10 -1
  4. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/pyproject.toml +2 -2
  5. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/__init__.py +1 -1
  6. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ai.py +109 -8
  7. nodepick_cli-0.1.4/src/nodepick_cli/commands/compute.py +117 -0
  8. nodepick_cli-0.1.4/src/nodepick_cli/commands/network.py +126 -0
  9. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/node.py +125 -10
  10. nodepick_cli-0.1.4/src/nodepick_cli/commands/regions.py +83 -0
  11. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/exceptions.py +6 -2
  12. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/main.py +15 -0
  13. nodepick_cli-0.1.4/test_cli.py +786 -0
  14. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/uv.lock +4 -4
  15. nodepick_cli-0.1.2/CHANGELOG.md +0 -17
  16. nodepick_cli-0.1.2/test_cli.py +0 -226
  17. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/.gitignore +0 -0
  18. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/install.sh +0 -0
  19. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/__init__.py +0 -0
  20. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/auth/login.py +0 -0
  21. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/__init__.py +0 -0
  22. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/commands/ssh.py +0 -0
  23. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/__init__.py +0 -0
  24. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/config.py +0 -0
  25. {nodepick_cli-0.1.2 → nodepick_cli-0.1.4}/src/nodepick_cli/core/formatters.py +0 -0
@@ -0,0 +1,27 @@
1
+ # CHANGELOG.md
2
+
3
+ ## [0.1.4] - 2026-10-08
4
+
5
+ ### Added
6
+ - Added short option `-s` alias to `--storage` on `np node create`.
7
+ - Added `--region` (`-r`) option to `np node create` to specify the deployment region.
8
+ - Added `--host` option to `np node create` to deploy directly onto a specified compute Host ID.
9
+ - Added region validation against supported active regions for `np node create` and `np network create`.
10
+ - Added `np regions` command to list available compute regions.
11
+ - Added `np compute` top-level command for compute discovery with real-time pricing and consolidated RAM/Disk/CPU specs.
12
+ - Added `np --version` (`-v`) flag.
13
+ - Added `np ai mcp configure <agent> [nodes...]` command to configure MCP servers on AI agents (Antigravity, OpenCode).
14
+
15
+ ### Changed
16
+ - Changed authentication command from `np auth save` to `np auth configure`.
17
+ - Replaced `np find` and `np node find` with top-level `np compute`.
18
+ - Removed NAT configuration options from `np network` commands (outbound NAT enabled upstream by default).
19
+ - Updated `--region` help message on `np node create` and `np network create` to "Target region or datacenter (e.g. us-west-1, fmt1)".
20
+ - Cleaned option type formatting in `np node create` help output (removed redundant `[x>=...]` range tags).
21
+
22
+ ## [0.1.0] - 2026-07-31
23
+
24
+ ### Added
25
+ - Created `np` CLI tool in `cli/` using Typer and Rich.
26
+ - Added commands: `list`, `create`, `get`, `delete`, `shutdown`, `reboot`.
27
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nodepick-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Official Python CLI for nodepick.ai
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.24.0
@@ -65,8 +65,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
65
65
  ### Basic Usage
66
66
 
67
67
  ```bash
68
- # List all compute nodes (table view with SSH connect command)
68
+ # Check version
69
69
  np --version
70
+
71
+ # List available regions
72
+ np regions
73
+
74
+ # Deploy a compute node in a specific region
75
+ np node create --name dev-node --region us-west-1
76
+
77
+ # List compute nodes
78
+ np node list
70
79
  ```
71
80
 
72
81
  ### Authentication
@@ -53,8 +53,17 @@ Check the official [`Documentation`](https://docs.nodepick.ai/cli-reference/over
53
53
  ### Basic Usage
54
54
 
55
55
  ```bash
56
- # List all compute nodes (table view with SSH connect command)
56
+ # Check version
57
57
  np --version
58
+
59
+ # List available regions
60
+ np regions
61
+
62
+ # Deploy a compute node in a specific region
63
+ np node create --name dev-node --region us-west-1
64
+
65
+ # List compute nodes
66
+ np node list
58
67
  ```
59
68
 
60
69
  ### Authentication
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nodepick-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Official Python CLI for nodepick.ai"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ np = "nodepick_cli.main:app"
25
25
  packages = ["src/nodepick_cli"]
26
26
 
27
27
  [tool.uv.sources]
28
- nodepick = { path = "../sdk/python" }
28
+ nodepick = { path = "../sdk/python", editable = true }
29
29
 
30
30
 
31
31
 
@@ -3,7 +3,7 @@ import importlib.metadata
3
3
  try:
4
4
  __version__ = importlib.metadata.version("nodepick-cli")
5
5
  except Exception:
6
- __version__ = "0.1.0"
6
+ __version__ = "0.1.4"
7
7
 
8
8
  from .main import app
9
9
 
@@ -58,6 +58,57 @@ def configure_antigravity_mcp(servers: Dict[str, Any]) -> List[Path]:
58
58
  return updated_files
59
59
 
60
60
 
61
+ def _delete_json_mcp_servers(file_path: Path, server_names: Optional[List[str]] = None) -> List[str]:
62
+ """Safely remove MCP servers from a JSON configuration file."""
63
+ if not file_path.exists():
64
+ return []
65
+ try:
66
+ with open(file_path, "r", encoding="utf-8") as f:
67
+ content = f.read().strip()
68
+ if not content:
69
+ return []
70
+ data = json.loads(content)
71
+ except Exception:
72
+ return []
73
+
74
+ if not isinstance(data, dict) or "mcpServers" not in data or not isinstance(data["mcpServers"], dict):
75
+ return []
76
+
77
+ removed = []
78
+ if server_names:
79
+ for name in server_names:
80
+ if name in data["mcpServers"]:
81
+ del data["mcpServers"][name]
82
+ removed.append(name)
83
+ else:
84
+ removed = list(data["mcpServers"].keys())
85
+ data["mcpServers"] = {}
86
+
87
+ if removed:
88
+ with open(file_path, "w", encoding="utf-8") as f:
89
+ json.dump(data, f, indent=2)
90
+
91
+ return removed
92
+
93
+
94
+ def delete_antigravity_mcp(server_names: Optional[List[str]] = None) -> List[str]:
95
+ """Remove MCP servers for Google Antigravity across its standard configuration locations."""
96
+ home = Path.home()
97
+ target_files = [
98
+ home / ".gemini" / "config" / "mcp_config.json",
99
+ home / ".gemini" / "settings.json",
100
+ home / ".gemini" / "antigravity-cli" / "settings.json",
101
+ ]
102
+ all_removed = set()
103
+ for path in target_files:
104
+ try:
105
+ removed = _delete_json_mcp_servers(path, server_names)
106
+ all_removed.update(removed)
107
+ except Exception as e:
108
+ console.print(f"[dim yellow]Warning: Failed modifying {path}: {e}[/dim yellow]")
109
+ return list(all_removed)
110
+
111
+
61
112
  @mcp_app.command("configure")
62
113
  def mcp_configure(
63
114
  agent: str = typer.Argument(
@@ -67,8 +118,19 @@ def mcp_configure(
67
118
  nodes: Optional[List[str]] = typer.Argument(
68
119
  None, help="Node IDs or display names to configure MCP for (if omitted, configures all nodes)"
69
120
  ),
121
+ insecure: bool = typer.Option(
122
+ False,
123
+ "--insecure",
124
+ help="Disable HTTPS certificate verification for MCP server connections.",
125
+ ),
126
+ delete: bool = typer.Option(
127
+ False,
128
+ "--delete",
129
+ "-d",
130
+ help="Delete the MCP server configuration for the specified node(s) or agent.",
131
+ ),
70
132
  ):
71
- """Configure MCP servers for an AI agent (e.g. antigravity) to connect to compute nodes."""
133
+ """Configure or delete MCP servers for an AI agent (e.g. antigravity) to connect to compute nodes."""
72
134
  agent_clean = agent.strip().lower()
73
135
  if agent_clean not in ("antigravity", "agy", "gemini"):
74
136
  console.print(
@@ -78,6 +140,42 @@ def mcp_configure(
78
140
  )
79
141
  raise typer.Exit(1)
80
142
 
143
+ if delete:
144
+ try:
145
+ names_to_delete = list(nodes) if nodes else None
146
+ if names_to_delete:
147
+ try:
148
+ client = get_client()
149
+ for n in list(names_to_delete):
150
+ try:
151
+ details = client.node_get_details(n)
152
+ for key in ("display_name", "displayName", "name", "vm_uuid", "id"):
153
+ val = details.get(key)
154
+ if val and val not in names_to_delete:
155
+ names_to_delete.append(val)
156
+ except Exception:
157
+ pass
158
+ except Exception:
159
+ pass
160
+
161
+ if agent_clean in ("antigravity", "agy", "gemini"):
162
+ removed = delete_antigravity_mcp(names_to_delete)
163
+ if removed:
164
+ for r in sorted(removed):
165
+ console.print(f"[bold green]✔[/bold green] Removed MCP server configuration '[bold]{r}[/bold]'")
166
+ console.print(
167
+ f"[bold green]Successfully removed {len(removed)} MCP server configuration(s) for {agent_clean}.[/bold green]"
168
+ )
169
+ else:
170
+ if nodes:
171
+ console.print(f"[yellow]No matching MCP server configurations found to delete for: {', '.join(nodes)}[/yellow]")
172
+ else:
173
+ console.print(f"[yellow]No MCP server configurations found to delete for {agent_clean}.[/yellow]")
174
+ return
175
+ except Exception as e:
176
+ handle_error(e, "Error deleting MCP configuration")
177
+ return
178
+
81
179
  client = get_client()
82
180
 
83
181
  try:
@@ -131,15 +229,18 @@ def mcp_configure(
131
229
  "type": "http",
132
230
  "transport": "http",
133
231
  "protocol": "streamable_http",
134
- "insecure": True,
135
- "insecureSkipVerify": True,
136
- "rejectUnauthorized": False,
137
- "verify": False,
138
- "tls": {
232
+ }
233
+ if insecure:
234
+ server_config.update({
139
235
  "insecure": True,
140
236
  "insecureSkipVerify": True,
141
- },
142
- }
237
+ "rejectUnauthorized": False,
238
+ "verify": False,
239
+ "tls": {
240
+ "insecure": True,
241
+ "insecureSkipVerify": True,
242
+ },
243
+ })
143
244
  if mcp_api_key:
144
245
  server_config["headers"] = {
145
246
  "Authorization": f"Bearer {mcp_api_key}",
@@ -0,0 +1,117 @@
1
+ import typer
2
+ from typing import Optional
3
+ from rich.console import Console
4
+ from rich.table import Table, Column
5
+ from .node import get_client
6
+ from ..core.exceptions import handle_error
7
+ from ..core.formatters import OutputFormat, set_output_format, print_output
8
+
9
+ console = Console()
10
+
11
+
12
+ def _render_compute_hosts_table(hosts):
13
+ if not hosts:
14
+ console.print("[yellow]No compute hosts found matching criteria.[/yellow]")
15
+ return
16
+
17
+ table = Table(
18
+ Column("Host ID", no_wrap=True),
19
+ "Status",
20
+ "Region",
21
+ "Datacenter",
22
+ "RAM/Disk/CPU",
23
+ "Price/hr ($)",
24
+ "Price/mo ($)",
25
+ )
26
+ for host in hosts:
27
+ # Show requested specs (RAM, CPU, and Disk) for which pricing was evaluated,
28
+ # rather than the compute host's total reported capacity.
29
+ req_cpu = host.get("requested_cpu")
30
+ if req_cpu is None:
31
+ cpu_info = host.get("cpu", {})
32
+ req_cpu = cpu_info.get("cores") if isinstance(cpu_info, dict) else cpu_info
33
+
34
+ req_mem = host.get("requested_memory_gb")
35
+ if req_mem is None:
36
+ req_mem = host.get("memory_gb")
37
+ elif isinstance(req_mem, (int, float)) and req_mem == int(req_mem):
38
+ req_mem = int(req_mem)
39
+
40
+ req_disk = host.get("requested_storage_gb")
41
+ if req_disk is None:
42
+ req_disk = host.get("storage_gb")
43
+
44
+ def _fmt(val):
45
+ if val is None:
46
+ return "?"
47
+ if isinstance(val, (int, float)):
48
+ return str(int(val)) if val == int(val) else str(val)
49
+ return str(val)
50
+
51
+ specs_str = f"{_fmt(req_mem)}/{_fmt(req_disk)}/{_fmt(req_cpu)}"
52
+
53
+ pricing = host.get("pricing") if isinstance(host.get("pricing"), dict) else {}
54
+ hourly = pricing.get("hourly") or pricing.get("hour") or host.get("estimated_hourly_cost_usd")
55
+ monthly = pricing.get("monthly") or host.get("estimated_monthly_cost_usd")
56
+
57
+ if hourly is not None:
58
+ try:
59
+ hourly_str = f"${float(hourly):.4f}"
60
+ except (ValueError, TypeError):
61
+ hourly_str = f"${hourly}"
62
+ else:
63
+ hourly_str = "N/A"
64
+
65
+ if monthly is not None:
66
+ try:
67
+ monthly_str = f"${float(monthly):.2f}"
68
+ except (ValueError, TypeError):
69
+ monthly_str = f"${monthly}"
70
+ else:
71
+ monthly_str = "N/A"
72
+
73
+ status_val = str(host.get("status") or "active")
74
+
75
+ table.add_row(
76
+ str(host.get("id") or "N/A"),
77
+ status_val,
78
+ str(host.get("region") or "N/A"),
79
+ str(host.get("datacenter") or "N/A"),
80
+ specs_str,
81
+ hourly_str,
82
+ monthly_str,
83
+ )
84
+ console.print(table)
85
+
86
+
87
+ def compute_command(
88
+ cpu: Optional[int] = typer.Option(None, "--cpu", "-c", help="Minimum CPU cores"),
89
+ memory_gb: Optional[float] = typer.Option(None, "--memory", "-m", help="Minimum RAM in GB"),
90
+ storage_gb: Optional[int] = typer.Option(None, "--storage", "-s", help="Minimum storage in GB"),
91
+ max_price: Optional[float] = typer.Option(None, "--max-price", "-p", help="Maximum monthly price in USD"),
92
+ region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by target region"),
93
+ datacenter: Optional[str] = typer.Option(None, "--datacenter", help="Filter by datacenter facility code"),
94
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by host status (active, reservable, all)"),
95
+ format: OutputFormat = typer.Option(
96
+ OutputFormat.TABLE,
97
+ "--format", "-f",
98
+ help="Output format (table or json).",
99
+ case_sensitive=False,
100
+ ),
101
+ ):
102
+ """Find and discover available compute hosts matching specific hardware and pricing criteria."""
103
+ set_output_format(format)
104
+ client = get_client()
105
+ try:
106
+ hosts = client.find_compute(
107
+ min_cpu=cpu,
108
+ min_memory_gb=memory_gb,
109
+ min_storage_gb=storage_gb,
110
+ max_price=max_price,
111
+ region=region,
112
+ datacenter=datacenter,
113
+ status=status,
114
+ )
115
+ print_output(hosts, table_render_func=_render_compute_hosts_table)
116
+ except Exception as e:
117
+ handle_error(e, "Error finding compute hosts")
@@ -0,0 +1,126 @@
1
+ import typer
2
+ from typing import Optional
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from ..commands.regions import validate_region
6
+ from ..commands.node import get_client
7
+ from ..core.exceptions import handle_error
8
+ from ..core.formatters import OutputFormat, set_output_format, print_output
9
+
10
+ app = typer.Typer(name="network", help="Manage VPC Networks.")
11
+ console = Console()
12
+
13
+
14
+ def _render_networks_table(networks):
15
+ if not networks:
16
+ console.print("[yellow]No VPC networks found.[/yellow]")
17
+ return
18
+
19
+ table = Table("ID", "Name", "Subnet", "Region")
20
+ for net in networks:
21
+ table.add_row(
22
+ str(net.get("id") or "N/A"),
23
+ str(net.get("name") or "N/A"),
24
+ str(net.get("subnet") or "N/A"),
25
+ str(net.get("region") or "-"),
26
+ )
27
+ console.print(table)
28
+
29
+
30
+ def _render_network_details_table(network):
31
+ if not network:
32
+ console.print("[yellow]No VPC network details found.[/yellow]")
33
+ return
34
+
35
+ table = Table("Field", "Value")
36
+ fields = [
37
+ ("ID", network.get("id")),
38
+ ("Name", network.get("name")),
39
+ ("Description", network.get("description")),
40
+ ("Subnet", network.get("subnet")),
41
+ ("Gateway", network.get("gateway")),
42
+ ("Region", network.get("region")),
43
+ ("VNI", network.get("vni")),
44
+ ("Outbound NAT", "Enabled" if network.get("enableOutboundNat", True) else "Disabled"),
45
+ ("Created At", network.get("createdAt") or network.get("created_at")),
46
+ ]
47
+ for field, value in fields:
48
+ if value is not None:
49
+ table.add_row(field, str(value))
50
+ console.print(table)
51
+
52
+
53
+ @app.command("list")
54
+ def network_list(
55
+ format: OutputFormat = typer.Option(
56
+ OutputFormat.TABLE,
57
+ "--format", "-f",
58
+ help="Output format (table or json).",
59
+ case_sensitive=False,
60
+ ),
61
+ ):
62
+ """List organization VPC networks."""
63
+ set_output_format(format)
64
+ client = get_client()
65
+ try:
66
+ networks = client.vpc_list()
67
+ print_output(networks, table_render_func=_render_networks_table)
68
+ except Exception as e:
69
+ handle_error(e, "Error listing VPC networks")
70
+
71
+
72
+ @app.command("create")
73
+ def network_create(
74
+ name: str = typer.Argument(..., help="Name for the VPC network (alphanumeric, dashes, and underscores)"),
75
+ region: str = typer.Option(..., "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
76
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Optional description"),
77
+ ):
78
+ """Create a new VPC network."""
79
+ client = get_client()
80
+ if region:
81
+ validate_region(client, region)
82
+ try:
83
+ console.print(f"[cyan]Creating VPC network '{name}'...[/cyan]")
84
+ network = client.vpc_create(
85
+ name=name,
86
+ region=region,
87
+ description=description,
88
+ )
89
+ vpc_id = network.get("id")
90
+ console.print(f"[bold green]VPC network created successfully![/bold green] ID: {vpc_id}")
91
+ except Exception as e:
92
+ handle_error(e, "Error creating VPC network")
93
+
94
+
95
+ @app.command("get")
96
+ def network_get(
97
+ vpc_id: str = typer.Argument(..., help="VPC Network ID or name"),
98
+ format: OutputFormat = typer.Option(
99
+ OutputFormat.TABLE,
100
+ "--format", "-f",
101
+ help="Output format (table or json).",
102
+ case_sensitive=False,
103
+ ),
104
+ ):
105
+ """Get details of a specific VPC network."""
106
+ set_output_format(format)
107
+ client = get_client()
108
+ try:
109
+ network = client.vpc_get(vpc_id)
110
+ print_output(network, table_render_func=_render_network_details_table)
111
+ except Exception as e:
112
+ handle_error(e, "Error getting VPC network details")
113
+
114
+
115
+ @app.command("delete")
116
+ def network_delete(
117
+ vpc_id: str = typer.Argument(..., help="VPC Network ID or name to delete"),
118
+ ):
119
+ """Delete an organization VPC network."""
120
+ client = get_client()
121
+ try:
122
+ console.print(f"[cyan]Deleting VPC network {vpc_id}...[/cyan]")
123
+ res = client.vpc_delete(vpc_id)
124
+ console.print(f"[bold green]VPC network {vpc_id} deleted.[/bold green]")
125
+ except Exception as e:
126
+ handle_error(e, "Error deleting VPC network")
@@ -1,5 +1,6 @@
1
1
  import typer
2
- from typing import Optional
2
+ import typer.core
3
+ from typing import Optional, List, Dict, Any
3
4
  from rich.console import Console
4
5
  from rich.table import Table, Column
5
6
  import nodepick
@@ -8,8 +9,19 @@ from ..core.exceptions import handle_error
8
9
  from ..core.formatters import OutputFormat, set_output_format, get_output_format, print_output
9
10
 
10
11
 
12
+ class NodeTyperGroup(typer.core.TyperGroup):
13
+ """Custom TyperGroup allowing both 'np node <action> <node_id>' and 'np node <node_id> <action>'."""
14
+ def parse_args(self, ctx, args):
15
+ actions = {
16
+ "attach-ip", "detach-ip",
17
+ "boot", "reboot", "shutdown", "delete", "get"
18
+ }
19
+ if len(args) >= 2 and args[1] in actions and args[0] not in self.commands:
20
+ args = [args[1], args[0]] + list(args[2:])
21
+ return super().parse_args(ctx, args)
11
22
 
12
- app = typer.Typer(name="node", help="Manage Compute Nodes.")
23
+
24
+ app = typer.Typer(cls=NodeTyperGroup, name="node", help="Manage Compute Nodes.")
13
25
  console = Console()
14
26
 
15
27
  from click.core import ParameterSource
@@ -70,6 +82,10 @@ def _render_nodes_table(nodes, client=None):
70
82
  )
71
83
  console.print(table)
72
84
 
85
+
86
+
87
+
88
+
73
89
  @app.command("boot")
74
90
  def node_boot(
75
91
  node_id: str = typer.Argument(..., help="Node ID or display name to boot"),
@@ -84,32 +100,131 @@ def node_boot(
84
100
  handle_error(e, "Error booting node")
85
101
 
86
102
 
103
+ from .regions import validate_region, _render_regions_table
104
+
105
+
106
+ def _validate_cpu(val: int) -> int:
107
+ if val < 1:
108
+ raise typer.BadParameter("Number of vCPUs must be at least 1.")
109
+ return val
110
+
111
+
112
+ def _validate_memory(val: int) -> int:
113
+ if val < 1:
114
+ raise typer.BadParameter("Memory size must be at least 1 GB.")
115
+ return val
116
+
117
+
118
+ def _validate_storage(val: int) -> int:
119
+ if val < 10:
120
+ raise typer.BadParameter("Disk storage must be at least 10 GB.")
121
+ return val
122
+
123
+
87
124
  @app.command("create")
88
125
  def node_create(
89
126
  display_name: Optional[str] = typer.Option(None, "--name", "-n", help="Display name for the node"),
90
- cpu: int = typer.Option(1, "--cpu", "-c", help="Number of vCPUs"),
91
- memory_mb: int = typer.Option(512, "--memory", "-m", help="Memory size in MB"),
92
- network_type: str = typer.Option("private", "--network", help="Network type (private/public)"),
93
- storage_gb: Optional[int] = typer.Option(None, "--storage", help="Disk storage in GB (default: 10)"),
127
+ cpu: int = typer.Option(1, "--cpu", "-c", callback=_validate_cpu, help="Number of vCPUs (min: 1)"),
128
+ memory: int = typer.Option(1, "--memory", "-m", callback=_validate_memory, help="Memory size in GB (min: 1)"),
129
+ storage_gb: int = typer.Option(10, "--storage", "-s", callback=_validate_storage, help="Disk storage in GB (min: 10)"),
130
+ vpc: Optional[str] = typer.Option(None, "--vpc", help="VPC network ID or name to deploy node into"),
131
+ region: Optional[str] = typer.Option(None, "--region", "-r", help="Target region or datacenter (e.g. us-west-1, fmt1)"),
132
+ host_id: Optional[str] = typer.Option(None, "--host", "--host-id", "--system-id", help="Target Host ID to deploy onto"),
94
133
  ):
95
134
  """Deploy a new compute node."""
96
135
  client = get_client()
97
- memory_bytes = memory_mb * 1024 * 1024
136
+ if region:
137
+ validate_region(client, region)
98
138
  try:
99
139
  console.print("[cyan]Creating node...[/cyan]")
100
140
  node = client.node_create(
101
- memory=memory_bytes,
141
+ memory=memory,
102
142
  cpu=cpu,
103
- network_type=network_type,
104
143
  display_name=display_name,
105
144
  storage_gb=storage_gb,
145
+ vpc=vpc,
146
+ region=region,
147
+ host_id=host_id,
106
148
  )
107
- node_id = node.get("vm_uuid")
149
+ node_id = node.get("vm_uuid") or node.get("id")
108
150
  console.print(f"[bold green]Node created successfully![/bold green] ID: {node_id}")
109
151
  except Exception as e:
110
152
  handle_error(e, "Error creating node")
111
153
 
112
154
 
155
+ @app.command("attach-ip")
156
+ def node_attach_ip(
157
+ node_id: str = typer.Argument(..., help="Node ID or display name"),
158
+ ip: str = typer.Option(
159
+ "auto",
160
+ "--ip",
161
+ help="Public IP address to attach (IPv4 or IPv6), or 'auto' (IPv4) / 'auto-ipv6' (IPv6)",
162
+ ),
163
+ ):
164
+ """Attach a public IP address (IPv4 or IPv6) to a compute node."""
165
+ client = get_client()
166
+ target_ip = ip
167
+ if isinstance(target_ip, str):
168
+ cleaned = target_ip.strip().lower()
169
+ if cleaned in ("auto-ipv6", "ipv6", "v6", "6"):
170
+ target_ip = "auto-ipv6"
171
+ elif cleaned in ("auto-ipv4", "ipv4", "v4", "4"):
172
+ target_ip = "auto"
173
+
174
+ is_v6_target = target_ip == "auto-ipv6" or ":" in target_ip
175
+ ver_target = "IPv6" if is_v6_target else "IPv4"
176
+ try:
177
+ console.print(f"[cyan]Attaching public {ver_target} to node {node_id}...[/cyan]")
178
+ res = client.node_attach_ip(node_id, ip=target_ip)
179
+ assigned_ip = res.get("ip") or "assigned"
180
+ is_v6_res = res.get("ipVersion") == 6 or ":" in str(assigned_ip)
181
+ ver_res = "IPv6" if is_v6_res else "IPv4"
182
+ console.print(f"[bold green]Public {ver_res} attached to node {node_id}:[/bold green] [cyan]{assigned_ip}[/cyan]")
183
+ except Exception as e:
184
+ handle_error(e, f"Error attaching public {ver_target}")
185
+
186
+
187
+ @app.command("detach-ip")
188
+ def node_detach_ip(
189
+ node_id: str = typer.Argument(..., help="Node ID or display name"),
190
+ ip: Optional[str] = typer.Option(None, "--ip", help="Specific public IP address to detach (auto-detected if omitted)"),
191
+ ipv6: bool = typer.Option(False, "--ipv6", "-6", help="Detach public IPv6 address"),
192
+ ):
193
+ """Detach a public IP address (IPv4 or IPv6) from a compute node."""
194
+ client = get_client()
195
+ target_ip = ip
196
+ if not target_ip and ipv6:
197
+ try:
198
+ details = client.node_get_details(node_id)
199
+ connect = details.get("connect") or {}
200
+ candidate = connect.get("publicIpv6") or connect.get("ipv6")
201
+ if candidate:
202
+ target_ip = candidate
203
+ except Exception:
204
+ pass
205
+ elif not target_ip:
206
+ try:
207
+ details = client.node_get_details(node_id)
208
+ connect = details.get("connect") or {}
209
+ pub4 = connect.get("publicIp")
210
+ pub6 = connect.get("publicIpv6") or connect.get("ipv6")
211
+ if pub6 and not pub4:
212
+ target_ip = pub6
213
+ elif pub4:
214
+ target_ip = pub4
215
+ except Exception:
216
+ pass
217
+
218
+ is_v6 = (":" in target_ip) if target_ip else ipv6
219
+ ver_label = "IPv6" if is_v6 else "IPv4"
220
+ try:
221
+ console.print(f"[cyan]Detaching public {ver_label} from node {node_id}...[/cyan]")
222
+ res = client.node_detach_ip(node_id, ip=target_ip)
223
+ console.print(f"[bold green]Public {ver_label} detached from node {node_id}.[/bold green]")
224
+ except Exception as e:
225
+ handle_error(e, f"Error detaching public {ver_label}")
226
+
227
+
113
228
  @app.command("delete")
114
229
  def node_delete(
115
230
  node_id: str = typer.Argument(..., help="Node ID or display name to delete"),