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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tapestry-cli
3
- Version: 0.1.4
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.4"
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