tapestry-cli 0.1.4__tar.gz → 0.1.8__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.
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/PKG-INFO +5 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/README.md +4 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/pyproject.toml +1 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/cli.py +6 -0
- tapestry_cli-0.1.8/tapestry_cli/deploy.py +443 -0
- tapestry_cli-0.1.8/tapestry_cli/init.py +949 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/make_plugin.py +81 -45
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli.egg-info/PKG-INFO +5 -1
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli.egg-info/SOURCES.txt +5 -1
- tapestry_cli-0.1.8/tests/test_deploy.py +219 -0
- tapestry_cli-0.1.8/tests/test_init_seed.py +130 -0
- tapestry_cli-0.1.8/tests/test_shared_guard.py +134 -0
- tapestry_cli-0.1.4/tapestry_cli/init.py +0 -460
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/setup.cfg +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/__init__.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/__main__.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/observatory.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli/onboard.py +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli.egg-info/dependency_links.txt +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli.egg-info/entry_points.txt +0 -0
- {tapestry_cli-0.1.4 → tapestry_cli-0.1.8}/tapestry_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tapestry-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Tapestry command-line interface — onboard projects, inspect state, open the Observatory.
|
|
5
5
|
Author: Liz Osborn
|
|
6
6
|
License: MIT
|
|
@@ -44,6 +44,10 @@ tapestry observatory Open the Observatory console in a browser.
|
|
|
44
44
|
tapestry init Register the project and write .env / .mcp.json /
|
|
45
45
|
.project-intelligence/ (granular path; does not touch
|
|
46
46
|
.claude/settings.json).
|
|
47
|
+
tapestry make-plugin Scaffold a personalized Claude Code plugin to publish
|
|
48
|
+
to your own marketplace.
|
|
49
|
+
tapestry deploy Create a Render service for the current repo via the
|
|
50
|
+
Render API (--dry-run to preview; needs no API key).
|
|
47
51
|
tapestry version Print version and platform info.
|
|
48
52
|
```
|
|
49
53
|
|
|
@@ -22,6 +22,10 @@ tapestry observatory Open the Observatory console in a browser.
|
|
|
22
22
|
tapestry init Register the project and write .env / .mcp.json /
|
|
23
23
|
.project-intelligence/ (granular path; does not touch
|
|
24
24
|
.claude/settings.json).
|
|
25
|
+
tapestry make-plugin Scaffold a personalized Claude Code plugin to publish
|
|
26
|
+
to your own marketplace.
|
|
27
|
+
tapestry deploy Create a Render service for the current repo via the
|
|
28
|
+
Render API (--dry-run to preview; needs no API key).
|
|
25
29
|
tapestry version Print version and platform info.
|
|
26
30
|
```
|
|
27
31
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tapestry-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
8
8
|
description = "Tapestry command-line interface — onboard projects, inspect state, open the Observatory."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
|
|
20
20
|
from tapestry_cli import (
|
|
21
21
|
__version__,
|
|
22
|
+
deploy as deploy_cmd,
|
|
22
23
|
init as init_cmd,
|
|
23
24
|
make_plugin as make_plugin_cmd,
|
|
24
25
|
observatory as observatory_cmd,
|
|
@@ -47,6 +48,11 @@ SUBCOMMANDS: dict[str, dict] = {
|
|
|
47
48
|
"add_arguments": make_plugin_cmd.add_arguments,
|
|
48
49
|
"run": make_plugin_cmd.run,
|
|
49
50
|
},
|
|
51
|
+
"deploy": {
|
|
52
|
+
"help": "Create a Render service for the current repo via the Render API (--dry-run to preview).",
|
|
53
|
+
"add_arguments": deploy_cmd.add_arguments,
|
|
54
|
+
"run": deploy_cmd.run,
|
|
55
|
+
},
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""tapestry deploy — create a Render service for the current repo, from the terminal.
|
|
2
|
+
|
|
3
|
+
The tedium this removes: every new project means clicking through the Render
|
|
4
|
+
dashboard to create a service, connect the GitHub repo, set build/start/plan,
|
|
5
|
+
and re-attach the shared env group. This does all of that in one command via
|
|
6
|
+
Render's REST API (https://api.render.com/v1), reading a small committed spec.
|
|
7
|
+
|
|
8
|
+
Stdlib-only (urllib), matching the rest of tapestry-cli — no third-party deps.
|
|
9
|
+
|
|
10
|
+
What it does:
|
|
11
|
+
1. Load the deploy spec (deploy/render-service.json by default; flags override).
|
|
12
|
+
2. Resolve the repo URL from `git origin` (unless --repo given).
|
|
13
|
+
3. Resolve the Render ownerId (GET /v1/owners), unless --owner-id given.
|
|
14
|
+
4. Idempotency: GET /v1/services?name=<name> — if it already exists, stop.
|
|
15
|
+
5. POST /v1/services with the repo link + branch + autoDeploy + serviceDetails
|
|
16
|
+
(build/start go under serviceDetails.envSpecificDetails) + inline envVars.
|
|
17
|
+
6. Attach an existing env group by name (POST /v1/env-groups/{id}/services/{id}).
|
|
18
|
+
7. Print the new service URL.
|
|
19
|
+
|
|
20
|
+
Prerequisites (operator, one-time):
|
|
21
|
+
- RENDER_API_KEY in the environment (or --api-key). Generate at:
|
|
22
|
+
Render dashboard -> avatar -> Account Settings -> API Keys.
|
|
23
|
+
- The GitHub<->Render account connection (Render's GitHub App) must already be
|
|
24
|
+
authorized for the repo. That step is dashboard-only; there is no API for it.
|
|
25
|
+
|
|
26
|
+
Use --dry-run to print the exact request without sending it (needs no API key).
|
|
27
|
+
|
|
28
|
+
Verified against Render's API reference 2026-09-25:
|
|
29
|
+
- POST /v1/services: top-level type/name/ownerId (required) + repo/branch/
|
|
30
|
+
autoDeploy/rootDir/envVars/serviceDetails. Native build/start live under
|
|
31
|
+
serviceDetails.envSpecificDetails.{buildCommand,startCommand}.
|
|
32
|
+
- GET /v1/services?name=<>&limit=<> and GET /v1/owners?name=<> return arrays of
|
|
33
|
+
{service|owner, cursor} wrapper objects.
|
|
34
|
+
- POST /v1/env-groups/{envGroupId}/services/{serviceId} — no body.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import copy
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import urllib.error
|
|
45
|
+
import urllib.parse
|
|
46
|
+
import urllib.request
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Optional
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULT_API_URL = os.environ.get("RENDER_API_URL", "https://api.render.com/v1")
|
|
52
|
+
DEFAULT_SPEC_PATH = "deploy/render-service.json"
|
|
53
|
+
|
|
54
|
+
# Friendly aliases -> Render's serviceType enum.
|
|
55
|
+
# Only types the payload builder can produce a valid body for are advertised.
|
|
56
|
+
# web_service / background_worker / private_service / cron_job all take
|
|
57
|
+
# runtime + plan + build/start (healthCheckPath is web-only, gated below).
|
|
58
|
+
# static_site is intentionally omitted: it has no start command / runtime /
|
|
59
|
+
# plan and needs a publishPath the builder does not produce.
|
|
60
|
+
_TYPE_ALIASES = {
|
|
61
|
+
"web": "web_service",
|
|
62
|
+
"cron": "cron_job",
|
|
63
|
+
"worker": "background_worker",
|
|
64
|
+
"private": "private_service",
|
|
65
|
+
}
|
|
66
|
+
_VALID_TYPES = set(_TYPE_ALIASES.values())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
# HTTP (stdlib urllib; same shape as init.py's registry calls)
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
def _request(
|
|
73
|
+
method: str,
|
|
74
|
+
url: str,
|
|
75
|
+
api_key: str,
|
|
76
|
+
body: Optional[dict] = None,
|
|
77
|
+
timeout: float = 60.0,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Call the Render API. Returns parsed JSON (or None on 204). Raises
|
|
80
|
+
RuntimeError with the response body on any 4xx/5xx."""
|
|
81
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
82
|
+
headers = {
|
|
83
|
+
"Authorization": f"Bearer {api_key}",
|
|
84
|
+
"Accept": "application/json",
|
|
85
|
+
}
|
|
86
|
+
if data is not None:
|
|
87
|
+
headers["Content-Type"] = "application/json"
|
|
88
|
+
req = urllib.request.Request(url=url, data=data, headers=headers, method=method)
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
91
|
+
raw = resp.read().decode("utf-8")
|
|
92
|
+
return json.loads(raw) if raw else None
|
|
93
|
+
except urllib.error.HTTPError as e:
|
|
94
|
+
detail = e.read().decode("utf-8", errors="replace")[:500]
|
|
95
|
+
raise RuntimeError(f"{method} {url} -> HTTP {e.code}: {detail}") from None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _git_origin_url(project_dir: Path) -> Optional[str]:
|
|
99
|
+
"""Best-effort `origin` remote URL; None if not a git repo / no origin."""
|
|
100
|
+
try:
|
|
101
|
+
r = subprocess.run(
|
|
102
|
+
["git", "-C", str(project_dir), "remote", "get-url", "origin"],
|
|
103
|
+
capture_output=True, text=True, timeout=10,
|
|
104
|
+
)
|
|
105
|
+
url = (r.stdout or "").strip()
|
|
106
|
+
return url if r.returncode == 0 and url else None
|
|
107
|
+
except Exception: # noqa: BLE001
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_owner_id(api_key: str, owner_name: Optional[str]) -> str:
|
|
112
|
+
"""GET /v1/owners (optionally filtered by name). Returns the single owner's
|
|
113
|
+
id, or raises if zero / ambiguous so the operator picks with --owner-id."""
|
|
114
|
+
url = DEFAULT_API_URL.rstrip("/") + "/owners?limit=100"
|
|
115
|
+
if owner_name:
|
|
116
|
+
url += "&name=" + urllib.parse.quote(owner_name)
|
|
117
|
+
rows = _request("GET", url, api_key) or []
|
|
118
|
+
owners = [row["owner"] for row in rows if "owner" in row]
|
|
119
|
+
if not owners:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"no Render owners returned for this API key"
|
|
122
|
+
+ (f" matching name={owner_name!r}" if owner_name else "")
|
|
123
|
+
)
|
|
124
|
+
if len(owners) > 1:
|
|
125
|
+
listing = ", ".join(f"{o['name']} ({o['id']})" for o in owners)
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
f"multiple Render owners; pass --owner-id to choose one of: {listing}"
|
|
128
|
+
)
|
|
129
|
+
return owners[0]["id"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _find_service_by_name(api_key: str, name: str) -> Optional[dict]:
|
|
133
|
+
"""GET /v1/services?name=<name> — return the exact-name match, else None."""
|
|
134
|
+
url = (
|
|
135
|
+
DEFAULT_API_URL.rstrip("/")
|
|
136
|
+
+ "/services?limit=100&name="
|
|
137
|
+
+ urllib.parse.quote(name)
|
|
138
|
+
)
|
|
139
|
+
rows = _request("GET", url, api_key) or []
|
|
140
|
+
for row in rows:
|
|
141
|
+
svc = row.get("service", {})
|
|
142
|
+
if svc.get("name") == name:
|
|
143
|
+
return svc
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _find_env_group_id(api_key: str, name: str) -> Optional[str]:
|
|
148
|
+
"""GET /v1/env-groups?name=<name> — return the exact-name match id, else None.
|
|
149
|
+
Best-effort: returns None (with a caller warning) if the lookup fails."""
|
|
150
|
+
url = (
|
|
151
|
+
DEFAULT_API_URL.rstrip("/")
|
|
152
|
+
+ "/env-groups?limit=100&name="
|
|
153
|
+
+ urllib.parse.quote(name)
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
rows = _request("GET", url, api_key) or []
|
|
157
|
+
except RuntimeError:
|
|
158
|
+
return None
|
|
159
|
+
for row in rows:
|
|
160
|
+
grp = row.get("envGroup", row)
|
|
161
|
+
if grp.get("name") == name:
|
|
162
|
+
return grp.get("id")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
# Spec + payload
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
def _load_spec(project_dir: Path, args: argparse.Namespace) -> dict:
|
|
170
|
+
"""Merge the on-disk spec (if any) with flag overrides. Flags win."""
|
|
171
|
+
spec: dict[str, Any] = {}
|
|
172
|
+
spec_path = project_dir / args.spec
|
|
173
|
+
if spec_path.is_file():
|
|
174
|
+
try:
|
|
175
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
176
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
177
|
+
raise RuntimeError(f"could not read spec {spec_path}: {e}")
|
|
178
|
+
|
|
179
|
+
# Flag overrides (only when provided).
|
|
180
|
+
for key, val in (
|
|
181
|
+
("name", args.name),
|
|
182
|
+
("type", args.type),
|
|
183
|
+
("plan", args.plan),
|
|
184
|
+
("region", args.region),
|
|
185
|
+
("branch", args.branch),
|
|
186
|
+
("rootDir", args.root),
|
|
187
|
+
("runtime", args.runtime),
|
|
188
|
+
("buildCommand", args.build),
|
|
189
|
+
("startCommand", args.start),
|
|
190
|
+
("healthCheckPath", args.health_check_path),
|
|
191
|
+
("schedule", args.schedule),
|
|
192
|
+
("repo", args.repo),
|
|
193
|
+
("envGroup", args.env_group),
|
|
194
|
+
("autoDeploy", args.auto_deploy),
|
|
195
|
+
):
|
|
196
|
+
if val is not None:
|
|
197
|
+
spec[key] = val
|
|
198
|
+
|
|
199
|
+
# Plain env vars from --set KEY=VALUE (merged over spec["envVars"]).
|
|
200
|
+
env_vars = {ev["key"]: ev["value"] for ev in spec.get("envVars", [])}
|
|
201
|
+
for pair in args.set or []:
|
|
202
|
+
if "=" not in pair:
|
|
203
|
+
raise RuntimeError(f"--set expects KEY=VALUE, got {pair!r}")
|
|
204
|
+
k, v = pair.split("=", 1)
|
|
205
|
+
env_vars[k] = v
|
|
206
|
+
spec["_envVars"] = env_vars
|
|
207
|
+
|
|
208
|
+
# Secret env var NAMES — values pulled from the local environment at run time.
|
|
209
|
+
spec["_secretNames"] = list(dict.fromkeys(spec.get("secretEnv", []) + (args.secret or [])))
|
|
210
|
+
return spec
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_env_vars(spec: dict) -> tuple[list[dict], list[str]]:
|
|
214
|
+
"""Build the envVars array: plain vars + resolved secrets. Returns
|
|
215
|
+
(envVars, missing_secret_names)."""
|
|
216
|
+
env_vars = [{"key": k, "value": v} for k, v in spec["_envVars"].items()]
|
|
217
|
+
missing: list[str] = []
|
|
218
|
+
for name in spec["_secretNames"]:
|
|
219
|
+
val = os.environ.get(name)
|
|
220
|
+
if val is None:
|
|
221
|
+
missing.append(name)
|
|
222
|
+
else:
|
|
223
|
+
env_vars.append({"key": name, "value": val})
|
|
224
|
+
return env_vars, missing
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _build_service_payload(spec: dict, repo_url: str, owner_id: str) -> dict:
|
|
228
|
+
"""Compose the POST /v1/services body from the spec. Native runtimes only
|
|
229
|
+
(build/start under serviceDetails.envSpecificDetails)."""
|
|
230
|
+
raw_type = str(spec.get("type", "web"))
|
|
231
|
+
service_type = _TYPE_ALIASES.get(raw_type, raw_type)
|
|
232
|
+
if service_type not in _VALID_TYPES:
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
f"invalid service type {raw_type!r}; use one of "
|
|
235
|
+
f"{sorted(_TYPE_ALIASES)} or a Render serviceType."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
runtime = spec.get("runtime", "python")
|
|
239
|
+
build_cmd = spec.get("buildCommand")
|
|
240
|
+
start_cmd = spec.get("startCommand")
|
|
241
|
+
if not build_cmd or not start_cmd:
|
|
242
|
+
raise RuntimeError("spec needs buildCommand and startCommand (native runtime)")
|
|
243
|
+
|
|
244
|
+
details: dict[str, Any] = {
|
|
245
|
+
"runtime": runtime,
|
|
246
|
+
"plan": spec.get("plan", "starter"),
|
|
247
|
+
"region": spec.get("region", "oregon"),
|
|
248
|
+
"envSpecificDetails": {
|
|
249
|
+
"buildCommand": build_cmd,
|
|
250
|
+
"startCommand": start_cmd,
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
if service_type == "cron_job":
|
|
254
|
+
schedule = spec.get("schedule")
|
|
255
|
+
if not schedule:
|
|
256
|
+
raise RuntimeError("cron service needs a 'schedule' (cron expression)")
|
|
257
|
+
details["schedule"] = schedule
|
|
258
|
+
elif service_type == "web_service":
|
|
259
|
+
# healthCheckPath is web-only; Render rejects it on worker/private.
|
|
260
|
+
if spec.get("healthCheckPath"):
|
|
261
|
+
details["healthCheckPath"] = spec["healthCheckPath"]
|
|
262
|
+
|
|
263
|
+
env_vars, _missing = _resolve_env_vars(spec)
|
|
264
|
+
|
|
265
|
+
payload: dict[str, Any] = {
|
|
266
|
+
"type": service_type,
|
|
267
|
+
"name": spec["name"],
|
|
268
|
+
"ownerId": owner_id,
|
|
269
|
+
"repo": repo_url,
|
|
270
|
+
"branch": spec.get("branch", "main"),
|
|
271
|
+
"autoDeploy": spec.get("autoDeploy", "yes"),
|
|
272
|
+
"serviceDetails": details,
|
|
273
|
+
}
|
|
274
|
+
if spec.get("rootDir"):
|
|
275
|
+
payload["rootDir"] = spec["rootDir"]
|
|
276
|
+
if env_vars:
|
|
277
|
+
payload["envVars"] = env_vars
|
|
278
|
+
return payload
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _mask_secret_values(payload: dict, secret_names: set[str]) -> dict:
|
|
282
|
+
"""Return a deep copy of the payload with every envVar value whose key is a
|
|
283
|
+
declared secret replaced by '***'. Non-secret env var values are kept. Used
|
|
284
|
+
only for --dry-run output so resolved secret values never reach stdout."""
|
|
285
|
+
masked = copy.deepcopy(payload)
|
|
286
|
+
for ev in masked.get("envVars", []):
|
|
287
|
+
if ev.get("key") in secret_names:
|
|
288
|
+
ev["value"] = "***"
|
|
289
|
+
return masked
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
# CLI
|
|
294
|
+
# --------------------------------------------------------------------------
|
|
295
|
+
def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
296
|
+
p.add_argument("--spec", default=DEFAULT_SPEC_PATH,
|
|
297
|
+
help=f"Deploy spec JSON (default: {DEFAULT_SPEC_PATH}).")
|
|
298
|
+
p.add_argument("--name", default=None, help="Render service name.")
|
|
299
|
+
p.add_argument("--type", default=None,
|
|
300
|
+
help="Service type: web | cron | worker | private.")
|
|
301
|
+
p.add_argument("--plan", default=None, help="Render plan (e.g. starter, free).")
|
|
302
|
+
p.add_argument("--region", default=None, help="Region (default: oregon).")
|
|
303
|
+
p.add_argument("--branch", default=None, help="Git branch (default: main).")
|
|
304
|
+
p.add_argument("--root", default=None, help="rootDir within the repo.")
|
|
305
|
+
p.add_argument("--runtime", default=None, help="Runtime (default: python).")
|
|
306
|
+
p.add_argument("--build", default=None, help="Build command.")
|
|
307
|
+
p.add_argument("--start", default=None, help="Start command.")
|
|
308
|
+
p.add_argument("--health-check-path", default=None, help="Health check path (web).")
|
|
309
|
+
p.add_argument("--schedule", default=None, help="Cron schedule (cron services).")
|
|
310
|
+
p.add_argument("--repo", default=None, help="Repo URL (default: git origin).")
|
|
311
|
+
p.add_argument("--owner-id", default=None, help="Render owner/workspace id.")
|
|
312
|
+
p.add_argument("--owner-name", default=None, help="Resolve owner id by name.")
|
|
313
|
+
p.add_argument("--env-group", default=None, help="Existing env group to attach.")
|
|
314
|
+
p.add_argument("--auto-deploy", default=None, choices=["yes", "no"],
|
|
315
|
+
help="Auto-deploy on push (default: yes).")
|
|
316
|
+
p.add_argument("--set", action="append", metavar="KEY=VALUE",
|
|
317
|
+
help="Plain env var (repeatable).")
|
|
318
|
+
p.add_argument("--secret", action="append", metavar="KEY",
|
|
319
|
+
help="Secret env var name; value read from your environment (repeatable).")
|
|
320
|
+
p.add_argument("--api-key", default=os.environ.get("RENDER_API_KEY"),
|
|
321
|
+
help="Render API key (default: RENDER_API_KEY env var).")
|
|
322
|
+
p.add_argument("--dry-run", action="store_true",
|
|
323
|
+
help="Print the request that WOULD be sent; make no API calls.")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def run(args: argparse.Namespace) -> int:
|
|
327
|
+
project_dir = Path.cwd()
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
spec = _load_spec(project_dir, args)
|
|
331
|
+
except RuntimeError as e:
|
|
332
|
+
print(f"error: {e}", file=sys.stderr)
|
|
333
|
+
return 1
|
|
334
|
+
|
|
335
|
+
if not spec.get("name"):
|
|
336
|
+
print("error: no service name (set 'name' in the spec or pass --name).",
|
|
337
|
+
file=sys.stderr)
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
repo_url = spec.get("repo") or _git_origin_url(project_dir)
|
|
341
|
+
if not repo_url:
|
|
342
|
+
print("error: no repo URL (no git 'origin' remote; pass --repo or set it "
|
|
343
|
+
"in the spec).", file=sys.stderr)
|
|
344
|
+
return 1
|
|
345
|
+
|
|
346
|
+
print(f"==> tapestry deploy")
|
|
347
|
+
print(f" service: {spec['name']} ({_TYPE_ALIASES.get(str(spec.get('type','web')), spec.get('type','web'))})")
|
|
348
|
+
print(f" repo: {repo_url}")
|
|
349
|
+
print(f" branch: {spec.get('branch', 'main')}")
|
|
350
|
+
print(f" plan: {spec.get('plan', 'starter')} / {spec.get('region', 'oregon')}")
|
|
351
|
+
if spec.get("envGroup"):
|
|
352
|
+
print(f" env grp: {spec['envGroup']}")
|
|
353
|
+
|
|
354
|
+
# Secrets present / missing (informational — a missing secret is set later).
|
|
355
|
+
_env_vars, missing = _resolve_env_vars(spec)
|
|
356
|
+
if spec["_secretNames"]:
|
|
357
|
+
print(f" secrets: {len(spec['_secretNames']) - len(missing)}/"
|
|
358
|
+
f"{len(spec['_secretNames'])} resolved from environment")
|
|
359
|
+
if missing:
|
|
360
|
+
print(f" WARN: not in environment (set later in Render): {', '.join(missing)}")
|
|
361
|
+
print()
|
|
362
|
+
|
|
363
|
+
# --- DRY RUN: compose + print, no network, no key needed. ---
|
|
364
|
+
if args.dry_run:
|
|
365
|
+
payload = _build_service_payload(spec, repo_url, args.owner_id or "own-DRYRUN")
|
|
366
|
+
# Never print resolved secret VALUES to stdout: mask any envVar whose key
|
|
367
|
+
# is a declared secret name. The real (non-dry-run) POST is unaffected.
|
|
368
|
+
masked = _mask_secret_values(payload, set(spec["_secretNames"]))
|
|
369
|
+
print("DRY RUN — would POST /v1/services with:")
|
|
370
|
+
print(json.dumps(masked, indent=2))
|
|
371
|
+
if spec.get("envGroup"):
|
|
372
|
+
print(f"\nThen POST /v1/env-groups/<id of {spec['envGroup']}>/services/<new id>")
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
api_key = args.api_key
|
|
376
|
+
if not api_key:
|
|
377
|
+
print("error: no Render API key. Set RENDER_API_KEY or pass --api-key.\n"
|
|
378
|
+
" Generate one: Render dashboard -> avatar -> Account Settings\n"
|
|
379
|
+
" -> API Keys -> Create API Key.", file=sys.stderr)
|
|
380
|
+
return 1
|
|
381
|
+
|
|
382
|
+
# Idempotency: bail if a service with this name already exists.
|
|
383
|
+
try:
|
|
384
|
+
existing = _find_service_by_name(api_key, spec["name"])
|
|
385
|
+
except RuntimeError as e:
|
|
386
|
+
print(f"error: could not query existing services: {e}", file=sys.stderr)
|
|
387
|
+
return 1
|
|
388
|
+
if existing:
|
|
389
|
+
print(f"IDEMPOTENT: a service named '{spec['name']}' already exists "
|
|
390
|
+
f"(id {existing.get('id')}). Nothing created.")
|
|
391
|
+
url = existing.get("serviceDetails", {}).get("url") or existing.get("dashboardUrl")
|
|
392
|
+
if url:
|
|
393
|
+
print(f" {url}")
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
# Owner.
|
|
397
|
+
try:
|
|
398
|
+
owner_id = args.owner_id or _resolve_owner_id(api_key, args.owner_name)
|
|
399
|
+
except RuntimeError as e:
|
|
400
|
+
print(f"error: {e}", file=sys.stderr)
|
|
401
|
+
return 1
|
|
402
|
+
|
|
403
|
+
# Create.
|
|
404
|
+
try:
|
|
405
|
+
payload = _build_service_payload(spec, repo_url, owner_id)
|
|
406
|
+
except RuntimeError as e:
|
|
407
|
+
print(f"error: {e}", file=sys.stderr)
|
|
408
|
+
return 1
|
|
409
|
+
print(f"--> Creating Render service '{spec['name']}'...")
|
|
410
|
+
try:
|
|
411
|
+
created = _request("POST", DEFAULT_API_URL.rstrip("/") + "/services", api_key, body=payload)
|
|
412
|
+
except RuntimeError as e:
|
|
413
|
+
print(f"error: create failed: {e}", file=sys.stderr)
|
|
414
|
+
return 1
|
|
415
|
+
service = created.get("service", created) if isinstance(created, dict) else {}
|
|
416
|
+
service_id = service.get("id")
|
|
417
|
+
print(f" created: {service_id}")
|
|
418
|
+
svc_url = service.get("serviceDetails", {}).get("url") or service.get("dashboardUrl")
|
|
419
|
+
if svc_url:
|
|
420
|
+
print(f" {svc_url}")
|
|
421
|
+
|
|
422
|
+
# Attach env group (best-effort).
|
|
423
|
+
if spec.get("envGroup") and service_id:
|
|
424
|
+
grp_name = spec["envGroup"]
|
|
425
|
+
grp_id = _find_env_group_id(api_key, grp_name)
|
|
426
|
+
if not grp_id:
|
|
427
|
+
print(f" WARN: env group '{grp_name}' not found; skipped. Attach it in "
|
|
428
|
+
f"the dashboard, or create it first.")
|
|
429
|
+
else:
|
|
430
|
+
link_url = (
|
|
431
|
+
DEFAULT_API_URL.rstrip("/")
|
|
432
|
+
+ f"/env-groups/{grp_id}/services/{service_id}"
|
|
433
|
+
)
|
|
434
|
+
try:
|
|
435
|
+
_request("POST", link_url, api_key)
|
|
436
|
+
print(f" attached env group '{grp_name}'")
|
|
437
|
+
except RuntimeError as e:
|
|
438
|
+
print(f" WARN: could not attach env group '{grp_name}': {e}")
|
|
439
|
+
|
|
440
|
+
print()
|
|
441
|
+
print(f"==> Done. Render is building '{spec['name']}' now — watch the deploy in "
|
|
442
|
+
f"the dashboard or with the Render CLI (`render logs`).")
|
|
443
|
+
return 0
|