ChatCoolify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatcoolify/__init__.py +24 -0
- chatcoolify/cli.py +284 -0
- chatcoolify/client.py +310 -0
- chatcoolify/config.py +48 -0
- chatcoolify-0.1.0.dist-info/METADATA +116 -0
- chatcoolify-0.1.0.dist-info/RECORD +10 -0
- chatcoolify-0.1.0.dist-info/WHEEL +5 -0
- chatcoolify-0.1.0.dist-info/entry_points.txt +5 -0
- chatcoolify-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatcoolify-0.1.0.dist-info/top_level.txt +1 -0
chatcoolify/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""ChatCoolify: AI-safe integration with the official Coolify API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
CoolifyAPIError,
|
|
6
|
+
CoolifyClient,
|
|
7
|
+
CoolifyConfigurationError,
|
|
8
|
+
CoolifyError,
|
|
9
|
+
CoolifyPermissionError,
|
|
10
|
+
PublicApplicationSpec,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_BASE_URL",
|
|
17
|
+
"CoolifyAPIError",
|
|
18
|
+
"CoolifyClient",
|
|
19
|
+
"CoolifyConfigurationError",
|
|
20
|
+
"CoolifyError",
|
|
21
|
+
"CoolifyPermissionError",
|
|
22
|
+
"PublicApplicationSpec",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
chatcoolify/cli.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Command-line interface for safe Coolify inspection and automation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .client import DEFAULT_BASE_URL, CoolifyClient, CoolifyError, PublicApplicationSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _emit(value: Any) -> None:
|
|
18
|
+
click.echo(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True, default=str))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _client(ctx: click.Context) -> CoolifyClient:
|
|
22
|
+
return ctx.obj["client"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _handle_error(error: CoolifyError) -> None:
|
|
26
|
+
raise click.ClickException(str(error)) from error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render_tree(command: click.Group, *, brief: bool) -> str:
|
|
30
|
+
"""Render the registered CLI surface instead of maintaining docs by hand."""
|
|
31
|
+
|
|
32
|
+
options = ["--help", "--version", "--tree", "--tree-brief", "--base-url", "--allow-write"]
|
|
33
|
+
lines = [command.name or "chatcoolify"]
|
|
34
|
+
for option in options:
|
|
35
|
+
lines.append(f"|- {option}")
|
|
36
|
+
commands = sorted(command.commands.values(), key=lambda item: item.name)
|
|
37
|
+
for index, subcommand in enumerate(commands):
|
|
38
|
+
connector = "`-" if index == len(commands) - 1 else "|-"
|
|
39
|
+
suffix = "" if brief else f" # {subcommand.help or subcommand.short_help or ''}".rstrip()
|
|
40
|
+
lines.append(f"{connector} {subcommand.name}{suffix}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tree_callback(brief: bool):
|
|
45
|
+
def callback(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
46
|
+
if not value or ctx.resilient_parsing:
|
|
47
|
+
return
|
|
48
|
+
click.echo(_render_tree(ctx.command, brief=brief))
|
|
49
|
+
ctx.exit()
|
|
50
|
+
|
|
51
|
+
return callback
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.group(name="chatcoolify", context_settings=CONTEXT_SETTINGS)
|
|
55
|
+
@click.version_option(__version__, prog_name="chatcoolify")
|
|
56
|
+
@click.option("--tree", is_flag=True, is_eager=True, expose_value=False, callback=_tree_callback(False), help="Print the registered command tree.")
|
|
57
|
+
@click.option("--tree-brief", is_flag=True, is_eager=True, expose_value=False, callback=_tree_callback(True), help="Print the registered command tree without descriptions.")
|
|
58
|
+
@click.option(
|
|
59
|
+
"--base-url",
|
|
60
|
+
envvar="COOLIFY_BASE_URL",
|
|
61
|
+
default=DEFAULT_BASE_URL,
|
|
62
|
+
show_default=True,
|
|
63
|
+
help="Coolify control-plane URL. Keep API tokens out of command arguments.",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--allow-write",
|
|
67
|
+
is_flag=True,
|
|
68
|
+
default=False,
|
|
69
|
+
help="Allow commands that create or deploy resources. Read-only is the default.",
|
|
70
|
+
)
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def main(ctx: click.Context, base_url: str, allow_write: bool) -> None:
|
|
73
|
+
"""Use the official Coolify REST API with a local AI safety gate."""
|
|
74
|
+
|
|
75
|
+
ctx.ensure_object(dict)
|
|
76
|
+
ctx.obj["client"] = CoolifyClient.from_env(allow_write=allow_write)
|
|
77
|
+
if base_url != DEFAULT_BASE_URL:
|
|
78
|
+
ctx.obj["client"] = CoolifyClient(
|
|
79
|
+
base_url,
|
|
80
|
+
token=ctx.obj["client"].token,
|
|
81
|
+
allow_write=allow_write,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.command()
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def health(ctx: click.Context) -> None:
|
|
88
|
+
"""Call the public health endpoint; no API token is needed."""
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
_emit(_client(ctx).health())
|
|
92
|
+
except CoolifyError as error:
|
|
93
|
+
_handle_error(error)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@main.command()
|
|
97
|
+
@click.pass_context
|
|
98
|
+
def team(ctx: click.Context) -> None:
|
|
99
|
+
"""Show the current team bound to the configured API token."""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
_emit(_client(ctx).current_team())
|
|
103
|
+
except CoolifyError as error:
|
|
104
|
+
_handle_error(error)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@main.command("overview")
|
|
108
|
+
@click.pass_context
|
|
109
|
+
def overview(ctx: click.Context) -> None:
|
|
110
|
+
"""Show a compact read-only inventory for the configured team."""
|
|
111
|
+
|
|
112
|
+
client = _client(ctx)
|
|
113
|
+
try:
|
|
114
|
+
_emit(
|
|
115
|
+
{
|
|
116
|
+
"team": client.current_team(),
|
|
117
|
+
"projects": client.list_projects(),
|
|
118
|
+
"applications": client.list_applications(),
|
|
119
|
+
"servers": client.list_servers(),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
except CoolifyError as error:
|
|
123
|
+
_handle_error(error)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@main.command("projects")
|
|
127
|
+
@click.pass_context
|
|
128
|
+
def projects(ctx: click.Context) -> None:
|
|
129
|
+
"""List projects visible to the configured API token."""
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
_emit(_client(ctx).list_projects())
|
|
133
|
+
except CoolifyError as error:
|
|
134
|
+
_handle_error(error)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@main.command("applications")
|
|
138
|
+
@click.pass_context
|
|
139
|
+
def applications(ctx: click.Context) -> None:
|
|
140
|
+
"""List applications visible to the configured API token."""
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
_emit(_client(ctx).list_applications())
|
|
144
|
+
except CoolifyError as error:
|
|
145
|
+
_handle_error(error)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@main.command("servers")
|
|
149
|
+
@click.pass_context
|
|
150
|
+
def servers(ctx: click.Context) -> None:
|
|
151
|
+
"""List servers visible to the configured API token."""
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
_emit(_client(ctx).list_servers())
|
|
155
|
+
except CoolifyError as error:
|
|
156
|
+
_handle_error(error)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@main.command("deployments")
|
|
160
|
+
@click.argument("application_uuid")
|
|
161
|
+
@click.option("--skip", default=0, type=click.IntRange(min=0), show_default=True)
|
|
162
|
+
@click.option("--take", default=20, type=click.IntRange(min=1), show_default=True)
|
|
163
|
+
@click.pass_context
|
|
164
|
+
def deployments(ctx: click.Context, application_uuid: str, skip: int, take: int) -> None:
|
|
165
|
+
"""List deployment history for one application."""
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
_emit(_client(ctx).list_deployments(application_uuid, skip=skip, take=take))
|
|
169
|
+
except CoolifyError as error:
|
|
170
|
+
_handle_error(error)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@main.command("project-create")
|
|
174
|
+
@click.argument("name")
|
|
175
|
+
@click.option("--description", default=None, help="Optional project description.")
|
|
176
|
+
@click.pass_context
|
|
177
|
+
def project_create(ctx: click.Context, name: str, description: str | None) -> None:
|
|
178
|
+
"""Create a project. Requires the global --allow-write flag."""
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
_emit(_client(ctx).create_project(name, description=description))
|
|
182
|
+
except CoolifyError as error:
|
|
183
|
+
_handle_error(error)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _website_spec(
|
|
187
|
+
project_uuid: str,
|
|
188
|
+
server_uuid: str,
|
|
189
|
+
environment_name: str,
|
|
190
|
+
environment_uuid: str,
|
|
191
|
+
repository_url: str,
|
|
192
|
+
branch: str,
|
|
193
|
+
build_pack: str,
|
|
194
|
+
name: str | None,
|
|
195
|
+
domain: str | None,
|
|
196
|
+
publish_directory: str | None,
|
|
197
|
+
port: int | None,
|
|
198
|
+
spa: bool,
|
|
199
|
+
) -> PublicApplicationSpec:
|
|
200
|
+
return PublicApplicationSpec(
|
|
201
|
+
project_uuid=project_uuid,
|
|
202
|
+
server_uuid=server_uuid,
|
|
203
|
+
environment_name=environment_name,
|
|
204
|
+
environment_uuid=environment_uuid,
|
|
205
|
+
git_repository=repository_url,
|
|
206
|
+
git_branch=branch,
|
|
207
|
+
build_pack=build_pack,
|
|
208
|
+
name=name,
|
|
209
|
+
domains=domain,
|
|
210
|
+
publish_directory=publish_directory,
|
|
211
|
+
is_static=build_pack == "static",
|
|
212
|
+
is_spa=spa,
|
|
213
|
+
port=port,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _website_options(function):
|
|
218
|
+
options = [
|
|
219
|
+
click.option("--project-uuid", required=True),
|
|
220
|
+
click.option("--server-uuid", required=True),
|
|
221
|
+
click.option("--environment-name", default="production", show_default=True),
|
|
222
|
+
click.option("--environment-uuid", required=True),
|
|
223
|
+
click.option("--repository-url", required=True),
|
|
224
|
+
click.option("--branch", default="main", show_default=True),
|
|
225
|
+
click.option(
|
|
226
|
+
"--build-pack",
|
|
227
|
+
type=click.Choice(["static", "nixpacks", "railpack", "dockerfile", "dockercompose"]),
|
|
228
|
+
default="static",
|
|
229
|
+
show_default=True,
|
|
230
|
+
),
|
|
231
|
+
click.option("--name", default=None),
|
|
232
|
+
click.option("--domain", default=None, help="Full HTTPS domain, for example https://site.example.com"),
|
|
233
|
+
click.option("--publish-directory", default="dist", show_default=True),
|
|
234
|
+
click.option("--port", type=click.IntRange(min=1, max=65535), default=None),
|
|
235
|
+
click.option("--spa/--no-spa", default=True, show_default=True),
|
|
236
|
+
]
|
|
237
|
+
for option in reversed(options):
|
|
238
|
+
function = option(function)
|
|
239
|
+
return function
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@main.command("website-plan")
|
|
243
|
+
@_website_options
|
|
244
|
+
@click.pass_context
|
|
245
|
+
def website_plan(ctx: click.Context, **kwargs: Any) -> None:
|
|
246
|
+
"""Render a website payload without making a Coolify write request."""
|
|
247
|
+
|
|
248
|
+
del ctx
|
|
249
|
+
try:
|
|
250
|
+
spec = _website_spec(**kwargs)
|
|
251
|
+
_emit({"endpoint": "/api/v1/applications/public", "payload": spec.as_payload(instant_deploy=False)})
|
|
252
|
+
except CoolifyError as error:
|
|
253
|
+
_handle_error(error)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@main.command("website-create")
|
|
257
|
+
@_website_options
|
|
258
|
+
@click.option("--deploy", is_flag=True, default=False, help="Ask Coolify to deploy immediately after creation.")
|
|
259
|
+
@click.pass_context
|
|
260
|
+
def website_create(ctx: click.Context, deploy: bool, **kwargs: Any) -> None:
|
|
261
|
+
"""Create a public Git website. Requires the global --allow-write flag."""
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
spec = _website_spec(**kwargs)
|
|
265
|
+
_emit(_client(ctx).create_public_application(spec, instant_deploy=deploy))
|
|
266
|
+
except CoolifyError as error:
|
|
267
|
+
_handle_error(error)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@main.command("deploy")
|
|
271
|
+
@click.argument("application_uuid")
|
|
272
|
+
@click.option("--force", is_flag=True, default=False, help="Rebuild without the existing cache.")
|
|
273
|
+
@click.pass_context
|
|
274
|
+
def deploy(ctx: click.Context, application_uuid: str, force: bool) -> None:
|
|
275
|
+
"""Trigger a deployment. Requires the global --allow-write flag."""
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
_emit(_client(ctx).deploy(application_uuid, force=force))
|
|
279
|
+
except CoolifyError as error:
|
|
280
|
+
_handle_error(error)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__": # pragma: no cover
|
|
284
|
+
main()
|
chatcoolify/client.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Typed, fail-closed client for the official Coolify REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import quote, urlencode, urljoin, urlparse
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://coolify.example.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CoolifyError(RuntimeError):
|
|
17
|
+
"""Base exception for ChatCoolify."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CoolifyConfigurationError(CoolifyError):
|
|
21
|
+
"""Raised when the client lacks required local configuration."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CoolifyPermissionError(CoolifyError):
|
|
25
|
+
"""Raised before a write request when explicit consent is missing."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CoolifyAPIError(CoolifyError):
|
|
29
|
+
"""A sanitized error response from the Coolify API."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, message: str) -> None:
|
|
32
|
+
self.status = status
|
|
33
|
+
self.message = message
|
|
34
|
+
super().__init__(f"Coolify API returned HTTP {status}: {message}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PublicApplicationSpec:
|
|
39
|
+
"""Minimum safe payload for a public Git-based Coolify application."""
|
|
40
|
+
|
|
41
|
+
project_uuid: str
|
|
42
|
+
server_uuid: str
|
|
43
|
+
environment_name: str
|
|
44
|
+
environment_uuid: str
|
|
45
|
+
git_repository: str
|
|
46
|
+
git_branch: str = "main"
|
|
47
|
+
build_pack: str = "static"
|
|
48
|
+
name: str | None = None
|
|
49
|
+
domains: str | None = None
|
|
50
|
+
publish_directory: str | None = None
|
|
51
|
+
is_static: bool = True
|
|
52
|
+
is_spa: bool = True
|
|
53
|
+
port: int | None = None
|
|
54
|
+
health_check_path: str | None = None
|
|
55
|
+
|
|
56
|
+
def as_payload(self, *, instant_deploy: bool = False) -> dict[str, Any]:
|
|
57
|
+
"""Return the official API payload without adding sensitive fields."""
|
|
58
|
+
|
|
59
|
+
_require_text("project_uuid", self.project_uuid)
|
|
60
|
+
_require_text("server_uuid", self.server_uuid)
|
|
61
|
+
_require_text("environment_name", self.environment_name)
|
|
62
|
+
_require_text("environment_uuid", self.environment_uuid)
|
|
63
|
+
_require_repository(self.git_repository)
|
|
64
|
+
_require_text("git_branch", self.git_branch)
|
|
65
|
+
_require_text("build_pack", self.build_pack)
|
|
66
|
+
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"project_uuid": self.project_uuid,
|
|
69
|
+
"server_uuid": self.server_uuid,
|
|
70
|
+
"environment_name": self.environment_name,
|
|
71
|
+
"environment_uuid": self.environment_uuid,
|
|
72
|
+
"git_repository": self.git_repository,
|
|
73
|
+
"git_branch": self.git_branch,
|
|
74
|
+
"build_pack": self.build_pack,
|
|
75
|
+
"is_static": self.is_static,
|
|
76
|
+
"is_spa": self.is_spa,
|
|
77
|
+
"instant_deploy": instant_deploy,
|
|
78
|
+
}
|
|
79
|
+
optional_text = {
|
|
80
|
+
"name": self.name,
|
|
81
|
+
"domains": self.domains,
|
|
82
|
+
"publish_directory": self.publish_directory,
|
|
83
|
+
"health_check_path": self.health_check_path,
|
|
84
|
+
}
|
|
85
|
+
for key, value in optional_text.items():
|
|
86
|
+
if value is not None and value.strip():
|
|
87
|
+
payload[key] = value.strip()
|
|
88
|
+
if self.port is not None:
|
|
89
|
+
if not 1 <= self.port <= 65535:
|
|
90
|
+
raise CoolifyConfigurationError("port must be between 1 and 65535")
|
|
91
|
+
payload["ports_exposes"] = str(self.port)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CoolifyClient:
|
|
96
|
+
"""Small client around documented Coolify REST endpoints.
|
|
97
|
+
|
|
98
|
+
Instances are read-only by default. Callers must opt in to writes with
|
|
99
|
+
``allow_write=True`` and use a Coolify token that itself has write or deploy
|
|
100
|
+
permission. This creates a local safety gate in addition to Coolify's own
|
|
101
|
+
team scope and API-token permissions.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
107
|
+
*,
|
|
108
|
+
token: str | None = None,
|
|
109
|
+
allow_write: bool = False,
|
|
110
|
+
timeout: float = 30.0,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.base_url = _normalise_base_url(base_url)
|
|
113
|
+
self.token = token.strip() if token else None
|
|
114
|
+
self.allow_write = allow_write
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_env(cls, *, allow_write: bool = False) -> "CoolifyClient":
|
|
119
|
+
"""Create a client from process environment or the active ChatEnv profile."""
|
|
120
|
+
|
|
121
|
+
profile_values = _load_chatenv_values()
|
|
122
|
+
return cls(
|
|
123
|
+
os.getenv("COOLIFY_BASE_URL") or profile_values.get("COOLIFY_BASE_URL") or DEFAULT_BASE_URL,
|
|
124
|
+
token=os.getenv("COOLIFY_API_TOKEN") or profile_values.get("COOLIFY_API_TOKEN"),
|
|
125
|
+
allow_write=allow_write,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def health(self) -> dict[str, Any]:
|
|
129
|
+
"""Read the public health endpoint without an API token."""
|
|
130
|
+
|
|
131
|
+
result = self._request("GET", "/api/health", auth=False)
|
|
132
|
+
if isinstance(result, dict):
|
|
133
|
+
return result
|
|
134
|
+
return {"ok": str(result).strip().upper() == "OK", "response": str(result).strip()}
|
|
135
|
+
|
|
136
|
+
def current_team(self) -> Mapping[str, Any]:
|
|
137
|
+
return _as_mapping(self._request("GET", "/api/v1/team"), "current team")
|
|
138
|
+
|
|
139
|
+
def list_teams(self) -> Sequence[Mapping[str, Any]]:
|
|
140
|
+
return _as_sequence(self._request("GET", "/api/v1/teams"), "teams")
|
|
141
|
+
|
|
142
|
+
def list_projects(self) -> Sequence[Mapping[str, Any]]:
|
|
143
|
+
return _as_sequence(self._request("GET", "/api/v1/projects"), "projects")
|
|
144
|
+
|
|
145
|
+
def list_applications(self) -> Sequence[Mapping[str, Any]]:
|
|
146
|
+
return _as_sequence(self._request("GET", "/api/v1/applications"), "applications")
|
|
147
|
+
|
|
148
|
+
def list_servers(self) -> Sequence[Mapping[str, Any]]:
|
|
149
|
+
return _as_sequence(self._request("GET", "/api/v1/servers"), "servers")
|
|
150
|
+
|
|
151
|
+
def list_deployments(self, application_uuid: str, *, skip: int = 0, take: int = 20) -> Sequence[Mapping[str, Any]]:
|
|
152
|
+
_require_text("application_uuid", application_uuid)
|
|
153
|
+
if skip < 0 or take < 1:
|
|
154
|
+
raise CoolifyConfigurationError("skip must be >= 0 and take must be >= 1")
|
|
155
|
+
query = urlencode({"skip": skip, "take": take})
|
|
156
|
+
path = f"/api/v1/deployments/applications/{quote(application_uuid, safe='')}?{query}"
|
|
157
|
+
return _as_sequence(self._request("GET", path), "deployments")
|
|
158
|
+
|
|
159
|
+
def create_project(self, name: str, *, description: str | None = None) -> Mapping[str, Any]:
|
|
160
|
+
"""Create a project only after the caller explicitly enabled writes."""
|
|
161
|
+
|
|
162
|
+
_require_text("name", name)
|
|
163
|
+
payload: dict[str, Any] = {"name": name.strip()}
|
|
164
|
+
if description and description.strip():
|
|
165
|
+
payload["description"] = description.strip()
|
|
166
|
+
return _as_mapping(self._request("POST", "/api/v1/projects", payload=payload, write=True), "project")
|
|
167
|
+
|
|
168
|
+
def create_public_application(
|
|
169
|
+
self,
|
|
170
|
+
spec: PublicApplicationSpec,
|
|
171
|
+
*,
|
|
172
|
+
instant_deploy: bool = False,
|
|
173
|
+
) -> Mapping[str, Any]:
|
|
174
|
+
"""Create a public Git application using Coolify's documented API."""
|
|
175
|
+
|
|
176
|
+
return _as_mapping(
|
|
177
|
+
self._request(
|
|
178
|
+
"POST",
|
|
179
|
+
"/api/v1/applications/public",
|
|
180
|
+
payload=spec.as_payload(instant_deploy=instant_deploy),
|
|
181
|
+
write=True,
|
|
182
|
+
),
|
|
183
|
+
"application",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def deploy(self, application_uuid: str, *, force: bool = False) -> Mapping[str, Any]:
|
|
187
|
+
"""Trigger an application deployment with an explicit deploy-capable client."""
|
|
188
|
+
|
|
189
|
+
_require_text("application_uuid", application_uuid)
|
|
190
|
+
return _as_mapping(
|
|
191
|
+
self._request(
|
|
192
|
+
"POST",
|
|
193
|
+
"/api/v1/deploy",
|
|
194
|
+
payload={"uuid": application_uuid, "force": force},
|
|
195
|
+
write=True,
|
|
196
|
+
),
|
|
197
|
+
"deployment",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _request(
|
|
201
|
+
self,
|
|
202
|
+
method: str,
|
|
203
|
+
path: str,
|
|
204
|
+
*,
|
|
205
|
+
payload: Mapping[str, Any] | None = None,
|
|
206
|
+
auth: bool = True,
|
|
207
|
+
write: bool = False,
|
|
208
|
+
) -> Any:
|
|
209
|
+
if write and not self.allow_write:
|
|
210
|
+
raise CoolifyPermissionError(
|
|
211
|
+
"write operation blocked locally; recreate the client or CLI with explicit write permission"
|
|
212
|
+
)
|
|
213
|
+
if auth and not self.token:
|
|
214
|
+
raise CoolifyConfigurationError(
|
|
215
|
+
"COOLIFY_API_TOKEN is required for protected Coolify API operations"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
219
|
+
headers = {"Accept": "application/json", "User-Agent": "ChatCoolify/0.1.0"}
|
|
220
|
+
data = None
|
|
221
|
+
if auth:
|
|
222
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
223
|
+
if payload is not None:
|
|
224
|
+
headers["Content-Type"] = "application/json"
|
|
225
|
+
data = json.dumps(payload).encode("utf-8")
|
|
226
|
+
|
|
227
|
+
request = Request(url, data=data, headers=headers, method=method.upper())
|
|
228
|
+
try:
|
|
229
|
+
with urlopen(request, timeout=self.timeout) as response: # nosec B310 - URL is user-configured API origin
|
|
230
|
+
content_type = response.headers.get_content_type()
|
|
231
|
+
raw = response.read().decode("utf-8")
|
|
232
|
+
if not raw:
|
|
233
|
+
return None
|
|
234
|
+
if content_type == "application/json" or raw.lstrip().startswith(("{", "[")):
|
|
235
|
+
return json.loads(raw)
|
|
236
|
+
return raw
|
|
237
|
+
except HTTPError as error:
|
|
238
|
+
message = _error_message(error)
|
|
239
|
+
raise CoolifyAPIError(error.code, message) from error
|
|
240
|
+
except URLError as error:
|
|
241
|
+
raise CoolifyError(f"Coolify API is unreachable: {error.reason}") from error
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _load_chatenv_values() -> Mapping[str, str]:
|
|
245
|
+
"""Read the active package-owned ChatEnv profile without copying secrets."""
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
from chatenv import EnvStore, get_paths
|
|
249
|
+
|
|
250
|
+
from .config import CoolifyConfig
|
|
251
|
+
|
|
252
|
+
return EnvStore(get_paths().envs_dir).load_active(CoolifyConfig)
|
|
253
|
+
except (ImportError, OSError, ValueError):
|
|
254
|
+
return {}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _normalise_base_url(value: str) -> str:
|
|
258
|
+
candidate = value.strip().rstrip("/")
|
|
259
|
+
parsed = urlparse(candidate)
|
|
260
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
261
|
+
raise CoolifyConfigurationError("COOLIFY_BASE_URL must be an absolute http(s) URL")
|
|
262
|
+
return candidate
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _require_text(name: str, value: str) -> None:
|
|
266
|
+
if not value or not value.strip():
|
|
267
|
+
raise CoolifyConfigurationError(f"{name} is required")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _require_repository(value: str) -> None:
|
|
271
|
+
_require_text("git_repository", value)
|
|
272
|
+
parsed = urlparse(value)
|
|
273
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
274
|
+
raise CoolifyConfigurationError("git_repository must be an HTTPS repository URL")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _as_mapping(value: Any, label: str) -> Mapping[str, Any]:
|
|
278
|
+
if not isinstance(value, Mapping):
|
|
279
|
+
raise CoolifyError(f"Coolify returned an unexpected {label} response")
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _as_sequence(value: Any, label: str) -> Sequence[Mapping[str, Any]]:
|
|
284
|
+
if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value):
|
|
285
|
+
raise CoolifyError(f"Coolify returned an unexpected {label} response")
|
|
286
|
+
return value
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _error_message(error: HTTPError) -> str:
|
|
290
|
+
"""Extract only a short server message; never include request headers or token data."""
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
raw = error.read().decode("utf-8", errors="replace")
|
|
294
|
+
parsed = json.loads(raw)
|
|
295
|
+
if isinstance(parsed, Mapping) and isinstance(parsed.get("message"), str):
|
|
296
|
+
return parsed["message"][:500]
|
|
297
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
298
|
+
pass
|
|
299
|
+
return error.reason or "request failed"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
__all__ = [
|
|
303
|
+
"DEFAULT_BASE_URL",
|
|
304
|
+
"CoolifyAPIError",
|
|
305
|
+
"CoolifyClient",
|
|
306
|
+
"CoolifyConfigurationError",
|
|
307
|
+
"CoolifyError",
|
|
308
|
+
"CoolifyPermissionError",
|
|
309
|
+
"PublicApplicationSpec",
|
|
310
|
+
]
|
chatcoolify/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""ChatEnv schema for ChatCoolify credentials and API endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from chatenv import BaseEnvConfig, EnvField, EnvStore, get_paths
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CoolifyConfig(BaseEnvConfig):
|
|
11
|
+
"""Typed configuration for the existing Coolify control plane."""
|
|
12
|
+
|
|
13
|
+
_title = "Coolify Configuration"
|
|
14
|
+
_aliases = ["coolify", "chatcoolify"]
|
|
15
|
+
_storage_dir = "Coolify"
|
|
16
|
+
|
|
17
|
+
COOLIFY_BASE_URL = EnvField(
|
|
18
|
+
"COOLIFY_BASE_URL",
|
|
19
|
+
default="https://coolify.example.com",
|
|
20
|
+
desc="Public HTTPS URL of the Coolify control plane",
|
|
21
|
+
)
|
|
22
|
+
COOLIFY_API_TOKEN = EnvField(
|
|
23
|
+
"COOLIFY_API_TOKEN",
|
|
24
|
+
desc="Team-scoped Coolify API token; use the minimum required permissions and expiration",
|
|
25
|
+
is_sensitive=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def test(cls) -> None:
|
|
30
|
+
"""Validate a configured endpoint, while keeping unconfigured schemas offline."""
|
|
31
|
+
|
|
32
|
+
from .client import DEFAULT_BASE_URL, CoolifyClient, CoolifyError
|
|
33
|
+
|
|
34
|
+
profile_values = EnvStore(get_paths().envs_dir).load_active(cls)
|
|
35
|
+
base_url = os.getenv("COOLIFY_BASE_URL") or profile_values.get("COOLIFY_BASE_URL") or DEFAULT_BASE_URL
|
|
36
|
+
if base_url == DEFAULT_BASE_URL:
|
|
37
|
+
print("ChatCoolify schema loaded; configure COOLIFY_BASE_URL to run a health check.")
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
result = CoolifyClient(base_url).health()
|
|
41
|
+
except CoolifyError as error:
|
|
42
|
+
raise RuntimeError(f"Coolify health check failed: {error}") from error
|
|
43
|
+
if not result.get("ok"):
|
|
44
|
+
raise RuntimeError("Coolify health check did not report OK")
|
|
45
|
+
print(f"Coolify health check passed for {base_url}.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["CoolifyConfig"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ChatCoolify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-safe Python client and CLI for the official Coolify API
|
|
5
|
+
Author-email: ChatArch <1073853456@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://arch.gh.wzhecnu.cn/ChatCoolify/
|
|
8
|
+
Project-URL: Documentation, https://arch.gh.wzhecnu.cn/ChatCoolify/
|
|
9
|
+
Project-URL: Repository, https://github.com/ChatArch/ChatCoolify
|
|
10
|
+
Project-URL: Issues, https://github.com/ChatArch/ChatCoolify/issues
|
|
11
|
+
Keywords: ai,chatarch,coolify,deployment,mcp,paas
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: chatenv<0.3.0,>=0.2.9
|
|
23
|
+
Requires-Dist: click<9.0,>=8.1
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build<2.0,>=1.2; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: twine<7.0,>=5.0; extra == "dev"
|
|
28
|
+
Provides-Extra: docs
|
|
29
|
+
Requires-Dist: mkdocs<2.0,>=1.6; extra == "docs"
|
|
30
|
+
Requires-Dist: mkdocs-material<10.0,>=9.5; extra == "docs"
|
|
31
|
+
Requires-Dist: mkdocs-static-i18n<2.0,>=1.2; extra == "docs"
|
|
32
|
+
Requires-Dist: mkdocs-minify-plugin<1.0,>=0.8; extra == "docs"
|
|
33
|
+
Requires-Dist: mike<3.0,>=2.0; extra == "docs"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
|
|
38
|
+
# ChatCoolify
|
|
39
|
+
|
|
40
|
+
AI-safe Python client and MkDocs guide for the official Coolify API.
|
|
41
|
+
|
|
42
|
+
[文档](https://arch.gh.wzhecnu.cn/ChatCoolify/) · [English](README.en.md) · [PyPI](https://pypi.org/project/ChatCoolify/) · [源码](https://github.com/ChatArch/ChatCoolify)
|
|
43
|
+
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
## 解决什么问题
|
|
47
|
+
|
|
48
|
+
ChatCoolify 不替代 Coolify,而是把官方 REST API 变成可测试的 Python 客户端和 CLI:
|
|
49
|
+
|
|
50
|
+
- 默认只读,适合 AI 清点服务器、项目、应用与部署状态;
|
|
51
|
+
- 写操作必须有 Coolify Token 权限并显式传递 `--allow-write`;
|
|
52
|
+
- 支持 ChatEnv 配置、公开 Git 网站计划与创建、部署触发;
|
|
53
|
+
- 文档覆盖邀请制协作、MCP、网站、API、数据库和运维边界。
|
|
54
|
+
|
|
55
|
+
## 安装
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m pip install --upgrade ChatCoolify
|
|
59
|
+
chatcoolify --version
|
|
60
|
+
chatcoolify --tree
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
支持 Python `>=3.10`。
|
|
64
|
+
|
|
65
|
+
## 最快开始
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
chatcoolify --base-url https://<coolify-url> health
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
需要受保护 API 时,先在 Coolify 创建 Team-scoped 最小权限 Token,再通过 ChatEnv 配置:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
chatenv init -t coolify -I
|
|
75
|
+
chatenv set COOLIFY_BASE_URL=https://<coolify-url>
|
|
76
|
+
chatenv set COOLIFY_API_TOKEN='<team-scoped-token>'
|
|
77
|
+
chatcoolify overview
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 网站计划示例
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
chatcoolify website-plan \
|
|
84
|
+
--project-uuid PROJECT_UUID \
|
|
85
|
+
--server-uuid SERVER_UUID \
|
|
86
|
+
--environment-uuid ENVIRONMENT_UUID \
|
|
87
|
+
--repository-url https://github.com/example/simple-site \
|
|
88
|
+
--domain https://site.example.com
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
该命令只输出官方 API payload。真正创建资源需要同时具备 `write` Token 和显式写门:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
chatcoolify --allow-write website-create ...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 安全边界
|
|
98
|
+
|
|
99
|
+
- 不把 Token 写入参数、仓库、日志或提示词。
|
|
100
|
+
- 不向普通 AI 或 CI 提供 `root` Token。
|
|
101
|
+
- Team 角色不是虚拟机隔离;不可信代码不应共享生产 Docker 守护进程。
|
|
102
|
+
- 完整操作流程见 [文档](https://arch.gh.wzhecnu.cn/ChatCoolify/)。
|
|
103
|
+
|
|
104
|
+
## 开发
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
python -m pip install -e '.[dev,docs]'
|
|
108
|
+
python -m pytest -q
|
|
109
|
+
python -m mkdocs build --strict
|
|
110
|
+
python -m build
|
|
111
|
+
python -m twine check dist/*
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 许可证
|
|
115
|
+
|
|
116
|
+
MIT。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
chatcoolify/__init__.py,sha256=bUbYnP9qZr5jmZUpirt-gpsavsHki04X-0mFqpTjxFc,498
|
|
2
|
+
chatcoolify/cli.py,sha256=CYGLbtTPnqo49TK1V31xOJnsvj-jJyx0eQacEEZQmXM,9235
|
|
3
|
+
chatcoolify/client.py,sha256=Cl14yehC67DT5CwHvFizgkVEiKkhm8X5jkZOqn4uht0,11557
|
|
4
|
+
chatcoolify/config.py,sha256=Qq-sgi9myNf0FawaOFSprq1o5fLKTEr2vxk0v73l8Lc,1708
|
|
5
|
+
chatcoolify-0.1.0.dist-info/licenses/LICENSE,sha256=G-fJLQ4pkXXISE3qeN8J5Cw5VRoNongA0hbsHA9TRe8,1065
|
|
6
|
+
chatcoolify-0.1.0.dist-info/METADATA,sha256=Mm_nMaG1C-qFYFqUUAmQ7gI6HAQHdYL1Sss7Xt8XSUE,3625
|
|
7
|
+
chatcoolify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
chatcoolify-0.1.0.dist-info/entry_points.txt,sha256=9-SlV-yBBEOx9wym6tXyTPDd9ruLXX9ErcJk8eUaNI0,105
|
|
9
|
+
chatcoolify-0.1.0.dist-info/top_level.txt,sha256=FnAqcxT8tBv7rlyLWLwSziJ6oTaQ97p06hq4P5qv8rM,12
|
|
10
|
+
chatcoolify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChatArch
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chatcoolify
|