wayscloud-cli 0.4.0__tar.gz → 0.5.0__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 (31) hide show
  1. wayscloud_cli-0.5.0/LICENSE +21 -0
  2. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/PKG-INFO +43 -5
  3. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/README.md +38 -2
  4. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/pyproject.toml +3 -3
  5. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/setup.cfg +1 -1
  6. wayscloud_cli-0.5.0/tests/test_k8s_cli.py +101 -0
  7. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/tests/test_smoke.py +54 -1
  8. wayscloud_cli-0.5.0/wayscloud_cli/__init__.py +1 -0
  9. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/__main__.py +4 -0
  10. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/app.py +160 -13
  11. wayscloud_cli-0.5.0/wayscloud_cli/commands/impact.py +392 -0
  12. wayscloud_cli-0.5.0/wayscloud_cli/commands/k8s.py +258 -0
  13. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/PKG-INFO +43 -5
  14. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/SOURCES.txt +4 -0
  15. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/requires.txt +1 -1
  16. wayscloud_cli-0.4.0/wayscloud_cli/__init__.py +0 -1
  17. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/__init__.py +0 -0
  18. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/db.py +0 -0
  19. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/dns.py +0 -0
  20. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/iot.py +0 -0
  21. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/login.py +0 -0
  22. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/redis.py +0 -0
  23. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/shell.py +0 -0
  24. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/storage.py +0 -0
  25. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/vps.py +0 -0
  26. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/config.py +0 -0
  27. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/output.py +0 -0
  28. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/sdk.py +0 -0
  29. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
  30. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
  31. {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WAYSCloud AS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wayscloud-cli
3
- Version: 0.4.0
4
- Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more
3
+ Version: 0.5.0
4
+ Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://wayscloud.services
7
7
  Project-URL: Documentation, https://docs.wayscloud.services/integrations/#wayscloud-cli
@@ -19,10 +19,12 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Operating System :: OS Independent
20
20
  Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
- Requires-Dist: wayscloud>=0.3.0
22
+ License-File: LICENSE
23
+ Requires-Dist: wayscloud>=0.4.0
23
24
  Requires-Dist: typer>=0.9
24
25
  Requires-Dist: rich>=13.0
25
26
  Requires-Dist: websockets>=12.0
27
+ Dynamic: license-file
26
28
 
27
29
  # WAYSCloud CLI
28
30
 
@@ -34,6 +36,8 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
34
36
  pip install wayscloud-cli
35
37
  ```
36
38
 
39
+ Version 0.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.0`.
40
+
37
41
  ## Authentication
38
42
 
39
43
  ```bash
@@ -94,7 +98,8 @@ cloud redis plans
94
98
  cloud storage buckets
95
99
  cloud storage buckets-create my-bucket
96
100
  cloud storage buckets-delete my-bucket --confirm
97
- cloud storage credentials
101
+ cloud storage keys my-bucket
102
+ cloud storage keys-create my-bucket --name ci
98
103
  ```
99
104
 
100
105
  ### Apps
@@ -108,6 +113,30 @@ cloud app stop <id>
108
113
  cloud app delete <id> --confirm
109
114
  ```
110
115
 
116
+ ### Kubernetes
117
+
118
+ ```bash
119
+ cloud k8s plans --kind node
120
+ cloud k8s list
121
+ cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
122
+ cloud k8s info <cluster-id>
123
+ cloud k8s kubeconfig <cluster-id> -o ~/.kube/shop.yaml # written with mode 0600
124
+ cloud k8s scale <cluster-id> default 4
125
+ cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
126
+ --label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
127
+ cloud k8s delete-pool <cluster-id> batch
128
+ cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
129
+ cloud k8s ip-allocate <cluster-id>
130
+ cloud k8s ip-release <cluster-id> <address>
131
+ cloud k8s ptr <cluster-id> <address> mail.example.com
132
+ cloud k8s backups <cluster-id>
133
+ cloud k8s backup <cluster-id>
134
+ cloud k8s restore <cluster-id> <backup-id> -n shop
135
+ cloud k8s upgrade <cluster-id> # list available versions
136
+ cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
137
+ cloud k8s delete <cluster-id> --confirm-name shop --wait
138
+ ```
139
+
111
140
  ### IoT
112
141
 
113
142
  ```bash
@@ -119,6 +148,15 @@ cloud iot groups
119
148
  cloud iot groups-create --name "Floor 2"
120
149
  ```
121
150
 
151
+ ### Impact
152
+
153
+ ```bash
154
+ cloud impact forest create "My forest"
155
+ cloud impact forest status --visualize emoji
156
+ cloud impact tree grow --count 10 --idempotency-key <key>
157
+ cloud impact commitments list
158
+ ```
159
+
122
160
  ### Shell
123
161
 
124
162
  ```bash
@@ -138,7 +176,7 @@ cloud vps list --json
138
176
  ## Requirements
139
177
 
140
178
  - Python 3.9+
141
- - wayscloud (SDK)
179
+ - [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
142
180
 
143
181
  ## Documentation
144
182
 
@@ -8,6 +8,8 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
8
8
  pip install wayscloud-cli
9
9
  ```
10
10
 
11
+ Version 0.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.0`.
12
+
11
13
  ## Authentication
12
14
 
13
15
  ```bash
@@ -68,7 +70,8 @@ cloud redis plans
68
70
  cloud storage buckets
69
71
  cloud storage buckets-create my-bucket
70
72
  cloud storage buckets-delete my-bucket --confirm
71
- cloud storage credentials
73
+ cloud storage keys my-bucket
74
+ cloud storage keys-create my-bucket --name ci
72
75
  ```
73
76
 
74
77
  ### Apps
@@ -82,6 +85,30 @@ cloud app stop <id>
82
85
  cloud app delete <id> --confirm
83
86
  ```
84
87
 
88
+ ### Kubernetes
89
+
90
+ ```bash
91
+ cloud k8s plans --kind node
92
+ cloud k8s list
93
+ cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
94
+ cloud k8s info <cluster-id>
95
+ cloud k8s kubeconfig <cluster-id> -o ~/.kube/shop.yaml # written with mode 0600
96
+ cloud k8s scale <cluster-id> default 4
97
+ cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
98
+ --label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
99
+ cloud k8s delete-pool <cluster-id> batch
100
+ cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
101
+ cloud k8s ip-allocate <cluster-id>
102
+ cloud k8s ip-release <cluster-id> <address>
103
+ cloud k8s ptr <cluster-id> <address> mail.example.com
104
+ cloud k8s backups <cluster-id>
105
+ cloud k8s backup <cluster-id>
106
+ cloud k8s restore <cluster-id> <backup-id> -n shop
107
+ cloud k8s upgrade <cluster-id> # list available versions
108
+ cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
109
+ cloud k8s delete <cluster-id> --confirm-name shop --wait
110
+ ```
111
+
85
112
  ### IoT
86
113
 
87
114
  ```bash
@@ -93,6 +120,15 @@ cloud iot groups
93
120
  cloud iot groups-create --name "Floor 2"
94
121
  ```
95
122
 
123
+ ### Impact
124
+
125
+ ```bash
126
+ cloud impact forest create "My forest"
127
+ cloud impact forest status --visualize emoji
128
+ cloud impact tree grow --count 10 --idempotency-key <key>
129
+ cloud impact commitments list
130
+ ```
131
+
96
132
  ### Shell
97
133
 
98
134
  ```bash
@@ -112,7 +148,7 @@ cloud vps list --json
112
148
  ## Requirements
113
149
 
114
150
  - Python 3.9+
115
- - wayscloud (SDK)
151
+ - [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
116
152
 
117
153
  ## Documentation
118
154
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wayscloud-cli"
7
- version = "0.4.0"
8
- description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more"
7
+ version = "0.5.0"
8
+ description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = {text = "MIT"}
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Operating System :: OS Independent",
23
23
  ]
24
24
  dependencies = [
25
- "wayscloud>=0.3.0",
25
+ "wayscloud>=0.4.0",
26
26
  "typer>=0.9",
27
27
  "rich>=13.0",
28
28
  "websockets>=12.0",
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = wayscloud-cli
3
- version = 0.4.0
3
+ version = 0.5.0
4
4
 
5
5
  [options]
6
6
  packages = find:
@@ -0,0 +1,101 @@
1
+ """k8s command tests -- option parsing, payloads and file handling."""
2
+
3
+ import os
4
+ import stat
5
+
6
+ from typer.testing import CliRunner
7
+
8
+ from wayscloud_cli.__main__ import app
9
+ from wayscloud_cli.commands import k8s as k8s_cmd
10
+
11
+ runner = CliRunner()
12
+
13
+
14
+ class FakeKubernetes:
15
+ def __init__(self):
16
+ self.calls = []
17
+
18
+ def add_node_pool(self, *args, **kwargs):
19
+ self.calls.append((args, kwargs))
20
+ return {"id": "c1"}
21
+
22
+ def kubeconfig(self, cluster_id):
23
+ return "apiVersion: v1\nkind: Config\n"
24
+
25
+
26
+ class FakeClient:
27
+ def __init__(self, kubernetes):
28
+ self.kubernetes = kubernetes
29
+
30
+
31
+ def patch_client(monkeypatch, kubernetes):
32
+ monkeypatch.setattr(k8s_cmd, "get_client", lambda token=None: FakeClient(kubernetes))
33
+
34
+
35
+ def test_parse_labels_and_taints():
36
+ assert k8s_cmd._parse_labels([]) == {}
37
+ assert k8s_cmd._parse_labels(["role=worker", "zone=a=b"]) == {"role": "worker", "zone": "a=b"}
38
+ assert k8s_cmd._parse_taints([]) == []
39
+ assert k8s_cmd._parse_taints(["dedicated=gpu:NoSchedule", "spot=:PreferNoSchedule"]) == [
40
+ {"key": "dedicated", "value": "gpu", "effect": "NoSchedule"},
41
+ {"key": "spot", "value": "", "effect": "PreferNoSchedule"},
42
+ ]
43
+
44
+
45
+ def test_add_pool_passes_labels_taints_and_ssh_keys(monkeypatch):
46
+ kube = FakeKubernetes()
47
+ patch_client(monkeypatch, kube)
48
+
49
+ result = runner.invoke(app, [
50
+ "k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--count", "3",
51
+ "--label", "role=worker", "--label", "tier=compute",
52
+ "--taint", "dedicated=gpu:NoExecute",
53
+ "--ssh-key-id", "11111111-1111-1111-1111-111111111111",
54
+ ])
55
+
56
+ assert result.exit_code == 0, result.output
57
+ (args, kwargs), = kube.calls
58
+ assert args == ("c1", "workers", "k8s-node-2c4g", 3)
59
+ assert kwargs["labels"] == {"role": "worker", "tier": "compute"}
60
+ assert kwargs["taints"] == [{"key": "dedicated", "value": "gpu", "effect": "NoExecute"}]
61
+ assert kwargs["ssh_key_ids"] == ["11111111-1111-1111-1111-111111111111"]
62
+
63
+
64
+ def test_add_pool_defaults_to_no_labels_taints_or_keys(monkeypatch):
65
+ kube = FakeKubernetes()
66
+ patch_client(monkeypatch, kube)
67
+
68
+ result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g"])
69
+
70
+ assert result.exit_code == 0, result.output
71
+ (args, kwargs), = kube.calls
72
+ assert args == ("c1", "workers", "k8s-node-2c4g", 1)
73
+ assert kwargs == {"labels": {}, "taints": [], "ssh_key_ids": []}
74
+
75
+
76
+ def test_add_pool_rejects_malformed_label_and_taint(monkeypatch):
77
+ patch_client(monkeypatch, FakeKubernetes())
78
+
79
+ for value in ("novalue", "=noKey"):
80
+ result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--label", value])
81
+ assert result.exit_code != 0
82
+ assert "key=value" in result.output
83
+
84
+ for value in ("novalue", "key:NoSchedule", "key=value:Sometimes"):
85
+ result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--taint", value])
86
+ assert result.exit_code != 0
87
+ assert "key=value:Effect" in result.output or "taint effect" in result.output
88
+
89
+
90
+ def test_kubeconfig_output_file_is_0600_for_new_and_existing_files(monkeypatch, tmp_path):
91
+ patch_client(monkeypatch, FakeKubernetes())
92
+
93
+ existing = tmp_path / "kubeconfig-existing.yaml"
94
+ existing.write_text("stale-content")
95
+ os.chmod(existing, 0o644)
96
+
97
+ for target in (existing, tmp_path / "kubeconfig-new.yaml"):
98
+ result = runner.invoke(app, ["k8s", "kubeconfig", "c1", "-o", str(target)])
99
+ assert result.exit_code == 0, result.output
100
+ assert stat.S_IMODE(target.stat().st_mode) == 0o600
101
+ assert target.read_text() == "apiVersion: v1\nkind: Config\n"
@@ -30,7 +30,7 @@ def test_version_flag():
30
30
 
31
31
  # ── Command groups registered ───────────────────────────────────
32
32
 
33
- EXPECTED_GROUPS = ["auth", "vps", "dns", "storage", "db", "redis", "app", "iot", "shell"]
33
+ EXPECTED_GROUPS = ["auth", "vps", "dns", "storage", "db", "redis", "app", "iot", "shell", "impact"]
34
34
 
35
35
 
36
36
  def test_all_command_groups_registered():
@@ -144,3 +144,56 @@ def test_iot_devices_create_has_required_flags():
144
144
  assert result.exit_code == 0
145
145
  assert "--device-id" in result.output
146
146
  assert "--name" in result.output
147
+
148
+
149
+ # ── Impact commands ─────────────────────────────────────────────
150
+
151
+ def test_impact_subgroups_registered():
152
+ """cloud impact has forest / tree / commitments subgroups."""
153
+ result = runner.invoke(app, ["impact", "--help"])
154
+ assert result.exit_code == 0
155
+ for sub in ("forest", "tree", "commitments"):
156
+ assert sub in result.output, f"Missing impact subgroup: {sub}"
157
+
158
+
159
+ def test_impact_tree_grow_has_count_flag():
160
+ result = runner.invoke(app, ["impact", "tree", "grow", "--help"])
161
+ assert result.exit_code == 0
162
+ assert "--count" in result.output
163
+ assert "--idempotency-key" in result.output
164
+
165
+
166
+ def test_impact_forest_create_help():
167
+ result = runner.invoke(app, ["impact", "forest", "create", "--help"])
168
+ assert result.exit_code == 0
169
+ # Argument should be the forest name; --token override always accepted.
170
+ assert "--token" in result.output
171
+
172
+
173
+ def test_impact_forest_status_has_visualize():
174
+ result = runner.invoke(app, ["impact", "forest", "status", "--help"])
175
+ assert result.exit_code == 0
176
+ assert "--visualize" in result.output
177
+
178
+
179
+ def test_impact_forbidden_strings_not_in_help():
180
+ """Anti-greenwashing wording lock: these strings must NEVER appear
181
+ anywhere in the impact CLI surface, not even in help text."""
182
+ forbidden = ["carbon neutral", "offset", "net zero", "Carbon Neutral", "Net Zero"]
183
+ for sub_path in (
184
+ ["impact", "--help"],
185
+ ["impact", "forest", "--help"],
186
+ ["impact", "tree", "--help"],
187
+ ["impact", "tree", "grow", "--help"],
188
+ ["impact", "tree", "inspect", "--help"],
189
+ ["impact", "tree", "water", "--help"],
190
+ ["impact", "commitments", "--help"],
191
+ ["impact", "forest", "status", "--help"],
192
+ ["impact", "forest", "feed", "--help"],
193
+ ):
194
+ result = runner.invoke(app, sub_path)
195
+ assert result.exit_code == 0, f"help failed for {sub_path}"
196
+ for s in forbidden:
197
+ assert s not in result.output, (
198
+ f"Forbidden string {s!r} appeared in help for {sub_path}"
199
+ )
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -18,8 +18,10 @@ from .commands.dns import app as dns_app
18
18
  from .commands.storage import app as storage_app
19
19
  from .commands.db import app as db_app
20
20
  from .commands.redis import app as redis_app
21
+ from .commands.k8s import app as k8s_app
21
22
  from .commands.app import app as app_platform_app
22
23
  from .commands.iot import app as iot_app
24
+ from .commands.impact import app as impact_app
23
25
 
24
26
  app = typer.Typer(
25
27
  name="cloud",
@@ -57,9 +59,11 @@ app.add_typer(dns_app, name="dns", help="DNS zones and records")
57
59
  app.add_typer(storage_app, name="storage", help="Object storage (S3)")
58
60
  app.add_typer(db_app, name="db", help="Managed databases")
59
61
  app.add_typer(redis_app, name="redis", help="Managed Redis")
62
+ app.add_typer(k8s_app, name="k8s", help="Managed Kubernetes")
60
63
  app.add_typer(app_platform_app, name="app", help="App platform")
61
64
  app.add_typer(iot_app, name="iot", help="IoT platform")
62
65
  app.add_typer(shell_app, name="shell", help="CloudShell")
66
+ app.add_typer(impact_app, name="impact", help="Impact: forests, trees, contributions")
63
67
 
64
68
  # Top-level shortcuts for login/logout/whoami
65
69
  app.command("login")(login)
@@ -17,7 +17,7 @@ app.add_typer(env_app, name="env")
17
17
 
18
18
  @env_app.command("list")
19
19
  def env_list(
20
- app_id: str = typer.Argument(..., help="App ID or name"),
20
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
21
21
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
22
22
  ):
23
23
  """List environment variable keys for an app."""
@@ -39,7 +39,7 @@ def env_list(
39
39
 
40
40
  @env_app.command("set")
41
41
  def env_set(
42
- app_id: str = typer.Argument(..., help="App ID or name"),
42
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
43
43
  pairs: List[str] = typer.Argument(..., help="KEY=value pairs"),
44
44
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
45
45
  ):
@@ -59,7 +59,7 @@ def env_set(
59
59
 
60
60
  @env_app.command("unset")
61
61
  def env_unset(
62
- app_id: str = typer.Argument(..., help="App ID or name"),
62
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
63
63
  keys: List[str] = typer.Argument(..., help="Variable names to remove"),
64
64
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
65
65
  ):
@@ -92,14 +92,16 @@ def list_apps(
92
92
  print("No apps found.")
93
93
  return
94
94
 
95
- rows = [{"id": str(a.get("id", ""))[:14], "name": a.get("name", ""),
95
+ rows = [{"id": str(a.get("id", "")), "short_id": a.get("short_id", ""),
96
+ "name": a.get("name", ""),
96
97
  "status": a.get("status", ""), "plan": a.get("plan", a.get("plan_code", "")),
97
98
  "url": a.get("default_url", ""), "created_at": a.get("created_at", "")}
98
99
  for a in apps_list]
99
100
 
100
101
  print_table(rows, [
101
- ("id", "ID", 14), ("name", "Name", 20), ("status", "Status", 10),
102
- ("plan", "Plan", 16), ("url", "URL", 30), ("created_at", "Created", 20),
102
+ ("id", "ID", 30), ("short_id", "Short", 10), ("name", "Name", 20),
103
+ ("status", "Status", 10), ("plan", "Plan", 16),
104
+ ("url", "URL", 30), ("created_at", "Created", 20),
103
105
  ])
104
106
 
105
107
 
@@ -124,7 +126,7 @@ def create_app(
124
126
 
125
127
  @app.command("info")
126
128
  def info_app(
127
- app_id: str = typer.Argument(..., help="App ID"),
129
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
128
130
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
129
131
  ):
130
132
  """Show app details."""
@@ -139,7 +141,7 @@ def info_app(
139
141
 
140
142
  @app.command("delete")
141
143
  def delete_app(
142
- app_id: str = typer.Argument(..., help="App ID"),
144
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
143
145
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
144
146
  confirm: bool = typer.Option(False, "--confirm", help="Skip confirmation"),
145
147
  ):
@@ -159,7 +161,7 @@ def delete_app(
159
161
 
160
162
  @app.command("deploy")
161
163
  def deploy_app(
162
- app_id: str = typer.Argument(..., help="App ID"),
164
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
163
165
  image: str = typer.Option(..., "--image", help="Container image URI"),
164
166
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
165
167
  ):
@@ -174,7 +176,7 @@ def deploy_app(
174
176
 
175
177
  @app.command("start")
176
178
  def start_app(
177
- app_id: str = typer.Argument(..., help="App ID"),
179
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
178
180
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
179
181
  ):
180
182
  """Start an app."""
@@ -188,7 +190,7 @@ def start_app(
188
190
 
189
191
  @app.command("stop")
190
192
  def stop_app(
191
- app_id: str = typer.Argument(..., help="App ID"),
193
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
192
194
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
193
195
  ):
194
196
  """Stop an app."""
@@ -202,7 +204,7 @@ def stop_app(
202
204
 
203
205
  @app.command("restart")
204
206
  def restart_app(
205
- app_id: str = typer.Argument(..., help="App ID"),
207
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
206
208
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
207
209
  ):
208
210
  """Restart an app."""
@@ -216,7 +218,7 @@ def restart_app(
216
218
 
217
219
  @app.command("logs")
218
220
  def logs_app(
219
- app_id: str = typer.Argument(..., help="App ID"),
221
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
220
222
  lines: int = typer.Option(100, "--lines", "-n", help="Number of lines"),
221
223
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),
222
224
  ):
@@ -272,6 +274,151 @@ def list_plans(
272
274
  ])
273
275
 
274
276
 
277
+ # ── GitHub auto-deploy sub-group ────────────────────────────────
278
+ auto_deploy_app = typer.Typer(help="GitHub auto-deploy")
279
+ app.add_typer(auto_deploy_app, name="auto-deploy")
280
+
281
+
282
+ def _print_secret_once(secret: str, webhook_url: str) -> None:
283
+ """Format the one-time secret reveal for terminal output."""
284
+ print()
285
+ print("=" * 72)
286
+ print(" This is the only time this secret is shown. Copy it now.")
287
+ print("=" * 72)
288
+ print(f" Webhook URL: {webhook_url}")
289
+ print(f" Secret: {secret}")
290
+ print( " Content type: application/json")
291
+ print( " Events: Just the push event")
292
+ print("=" * 72)
293
+ print()
294
+
295
+
296
+ @auto_deploy_app.command("status")
297
+ def auto_deploy_status(
298
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
299
+ token: Optional[str] = typer.Option(None, "--token", help="Override token"),
300
+ ):
301
+ """Show GitHub auto-deploy configuration for an app."""
302
+ c = get_client(token)
303
+ data = sdk_call(c.apps.auto_deploy_get, app_id)
304
+
305
+ if is_json_mode():
306
+ print_json(data)
307
+ return
308
+
309
+ if not data.get("configured"):
310
+ print("Auto-deploy: not configured.")
311
+ print(f"Webhook URL: {data.get('webhook_url', '')}")
312
+ return
313
+
314
+ print_object(data, [
315
+ ("provider", "Provider"),
316
+ ("repo_url", "Repository"),
317
+ ("branch", "Branch"),
318
+ ("auto_deploy_enabled", "Enabled"),
319
+ ("secret_configured", "Per-app secret"),
320
+ ("credential_linked", "Credential linked"),
321
+ ("webhook_url", "Webhook URL"),
322
+ ("last_push_event_at", "Last push"),
323
+ ("last_auto_deploy_at", "Last deploy"),
324
+ ("last_auto_deploy_commit", "Last commit"),
325
+ ])
326
+ if not data.get("secret_configured"):
327
+ print()
328
+ print(" [!] This app is using the legacy shared webhook secret.")
329
+ print(" Run `cloud app auto-deploy rotate-secret <app>` to")
330
+ print(" get a per-app secret only GitHub and this app know.")
331
+
332
+
333
+ @auto_deploy_app.command("enable")
334
+ def auto_deploy_enable(
335
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
336
+ repo_url: str = typer.Option(..., "--repo", help="GitHub repository URL"),
337
+ branch: str = typer.Option("main", "--branch", help="Branch to auto-deploy"),
338
+ token: Optional[str] = typer.Option(None, "--token", help="Override token"),
339
+ ):
340
+ """Enable GitHub auto-deploy for an app.
341
+
342
+ First invocation generates a per-app webhook secret and prints it
343
+ once. Re-invoking with new repo/branch keeps the existing secret.
344
+ """
345
+ c = get_client(token)
346
+ data = sdk_call(
347
+ c.apps.auto_deploy_configure,
348
+ app_id, repo_url, branch=branch, auto_deploy_enabled=True,
349
+ )
350
+
351
+ if is_json_mode():
352
+ print_json(data)
353
+ return
354
+
355
+ print(f"Auto-deploy enabled: {data.get('repo_url')}:{data.get('branch')}")
356
+ secret = data.get("webhook_secret")
357
+ if secret:
358
+ _print_secret_once(secret, data.get("webhook_url", ""))
359
+ else:
360
+ print("Per-app webhook secret already configured — reusing existing.")
361
+
362
+
363
+ @auto_deploy_app.command("disable")
364
+ def auto_deploy_disable(
365
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
366
+ token: Optional[str] = typer.Option(None, "--token", help="Override token"),
367
+ ):
368
+ """Pause auto-deploy without clearing the repo configuration.
369
+
370
+ Webhook secret is kept so re-enabling does not require updating
371
+ GitHub. To delete the config entirely, use `cloud app auto-deploy
372
+ remove` (not yet implemented — reconfigure with a new repo_url
373
+ instead).
374
+ """
375
+ c = get_client(token)
376
+ # Re-PUT with auto_deploy_enabled=false; backend preserves secret.
377
+ current = sdk_call(c.apps.auto_deploy_get, app_id)
378
+ if not current.get("configured"):
379
+ print("Auto-deploy is not configured for this app.")
380
+ raise typer.Exit(code=1)
381
+ data = sdk_call(
382
+ c.apps.auto_deploy_configure,
383
+ app_id,
384
+ repo_url=current["repo_url"],
385
+ branch=current["branch"],
386
+ auto_deploy_enabled=False,
387
+ )
388
+ if is_json_mode():
389
+ print_json(data)
390
+ else:
391
+ print(f"Auto-deploy paused for {app_id}.")
392
+
393
+
394
+ @auto_deploy_app.command("rotate-secret")
395
+ def auto_deploy_rotate(
396
+ app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
397
+ token: Optional[str] = typer.Option(None, "--token", help="Override token"),
398
+ confirm: bool = typer.Option(False, "--confirm",
399
+ help="Skip interactive confirmation"),
400
+ ):
401
+ """Generate a new webhook secret. The previous one stops working."""
402
+ if not confirm:
403
+ print("This rotates the webhook secret immediately. Pushes from")
404
+ print("GitHub will fail until you update the secret in the repo")
405
+ print("webhook settings. Re-run with --confirm to proceed.")
406
+ raise typer.Exit(code=1)
407
+
408
+ c = get_client(token)
409
+ data = sdk_call(c.apps.auto_deploy_rotate_secret, app_id)
410
+
411
+ if is_json_mode():
412
+ print_json(data)
413
+ return
414
+
415
+ secret = data.get("webhook_secret")
416
+ if not secret:
417
+ print("Error: response did not include a new secret.", err=True)
418
+ raise typer.Exit(code=1)
419
+ _print_secret_once(secret, data.get("webhook_url", ""))
420
+
421
+
275
422
  @app.command("regions")
276
423
  def list_regions(
277
424
  token: Optional[str] = typer.Option(None, "--token", help="Override token"),